Skip to content

Commit 08ad180

Browse files
committed
feat(root): merge backend backlog phase 1-3
Phase 1: resilience, WebSocket stability, account deletion, FCM deactivation Phase 2: pub/sub, telemetry, notification logs, wellness trends, medication adherence Phase 3: audit logging, LiveKit rooms, GDPR export, idempotency, Redis cache
2 parents 323185e + ca28e55 commit 08ad180

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+3293
-25
lines changed

apps/api/alembic/env.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@
66
from sqlalchemy.ext.asyncio import async_engine_from_config
77

88
from alembic import context
9-
from src.lib.config import settings
10-
from src.lib.database import Base
119

1210
# Import all models for autogenerate detection
11+
from src.admin.model import AuditLog # noqa: F401
12+
from src.lib.config import settings
13+
from src.lib.database import Base
1314
from src.medications.model import Medication # noqa: F401
15+
from src.notification_logs.model import ( # noqa: F401
16+
NotificationLog,
17+
NotificationPreference,
18+
)
1419
from src.relations.model import CareRelation # noqa: F401
1520
from src.users.model import User # noqa: F401
1621
from src.wellness.model import WellnessLog # noqa: F401

apps/api/alembic/versions/0001_initial.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,167 @@ def upgrade() -> None:
232232
unique=False,
233233
)
234234

235+
# --- audit_logs ---
236+
op.create_table(
237+
"audit_logs",
238+
sa.Column(
239+
"id",
240+
sa.UUID(),
241+
server_default=sa.text("gen_random_uuid()"),
242+
nullable=False,
243+
),
244+
sa.Column(
245+
"actor_id",
246+
sa.UUID(),
247+
nullable=True,
248+
comment="User or service that performed the action",
249+
),
250+
sa.Column(
251+
"action",
252+
sa.String(length=100),
253+
nullable=False,
254+
comment="e.g. cleanup, deactivate_tokens",
255+
),
256+
sa.Column(
257+
"resource_type",
258+
sa.String(length=100),
259+
nullable=False,
260+
comment="e.g. wellness_logs, device_tokens",
261+
),
262+
sa.Column(
263+
"detail",
264+
postgresql.JSONB(astext_type=sa.Text()),
265+
server_default=sa.text("'{}'::jsonb"),
266+
nullable=False,
267+
),
268+
sa.Column("description", sa.Text(), nullable=True),
269+
sa.Column(
270+
"timestamp",
271+
sa.DateTime(timezone=True),
272+
server_default=sa.text("now()"),
273+
nullable=False,
274+
),
275+
sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_logs")),
276+
)
277+
op.create_index(
278+
"ix_audit_logs_actor_id",
279+
"audit_logs",
280+
["actor_id"],
281+
unique=False,
282+
)
283+
op.create_index(
284+
"ix_audit_logs_timestamp",
285+
"audit_logs",
286+
["timestamp"],
287+
unique=False,
288+
)
289+
290+
# --- notification_logs ---
291+
op.create_table(
292+
"notification_logs",
293+
sa.Column(
294+
"id",
295+
sa.UUID(),
296+
server_default=sa.text("gen_random_uuid()"),
297+
nullable=False,
298+
),
299+
sa.Column("recipient_id", sa.UUID(), nullable=False),
300+
sa.Column("title", sa.String(length=255), nullable=False),
301+
sa.Column("body", sa.Text(), nullable=False),
302+
sa.Column(
303+
"status",
304+
sa.String(length=20),
305+
server_default="pending",
306+
nullable=False,
307+
comment="pending | sent | failed",
308+
),
309+
sa.Column(
310+
"channel",
311+
sa.String(length=20),
312+
server_default="push",
313+
nullable=False,
314+
comment="push",
315+
),
316+
sa.Column(
317+
"metadata",
318+
postgresql.JSONB(astext_type=sa.Text()),
319+
server_default=sa.text("'{}'::jsonb"),
320+
nullable=False,
321+
),
322+
sa.Column(
323+
"created_at",
324+
sa.DateTime(timezone=True),
325+
server_default=sa.text("now()"),
326+
nullable=False,
327+
),
328+
sa.ForeignKeyConstraint(
329+
["recipient_id"],
330+
["users.id"],
331+
name=op.f("fk_notification_logs_recipient_id_users"),
332+
ondelete="CASCADE",
333+
),
334+
sa.PrimaryKeyConstraint("id", name=op.f("pk_notification_logs")),
335+
)
336+
op.create_index(
337+
"ix_notification_logs_recipient_id",
338+
"notification_logs",
339+
["recipient_id"],
340+
unique=False,
341+
)
342+
op.create_index(
343+
"ix_notification_logs_created_at",
344+
"notification_logs",
345+
["created_at"],
346+
unique=False,
347+
)
348+
349+
# --- notification_preferences ---
350+
op.create_table(
351+
"notification_preferences",
352+
sa.Column(
353+
"id",
354+
sa.UUID(),
355+
server_default=sa.text("gen_random_uuid()"),
356+
nullable=False,
357+
),
358+
sa.Column("user_id", sa.UUID(), nullable=False),
359+
sa.Column(
360+
"wellness_alerts",
361+
sa.Boolean(),
362+
server_default=sa.text("true"),
363+
nullable=False,
364+
),
365+
sa.Column(
366+
"medication_reminders",
367+
sa.Boolean(),
368+
server_default=sa.text("true"),
369+
nullable=False,
370+
),
371+
sa.Column(
372+
"system_updates",
373+
sa.Boolean(),
374+
server_default=sa.text("true"),
375+
nullable=False,
376+
),
377+
sa.Column(
378+
"created_at",
379+
sa.DateTime(timezone=True),
380+
server_default=sa.text("now()"),
381+
nullable=False,
382+
),
383+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
384+
sa.ForeignKeyConstraint(
385+
["user_id"],
386+
["users.id"],
387+
name=op.f("fk_notification_preferences_user_id_users"),
388+
ondelete="CASCADE",
389+
),
390+
sa.PrimaryKeyConstraint("id", name=op.f("pk_notification_preferences")),
391+
sa.UniqueConstraint(
392+
"user_id", name=op.f("uq_notification_preferences_user_id")
393+
),
394+
)
395+
235396
# --- device_tokens ---
236397
op.create_table(
237398
"device_tokens",
@@ -293,6 +454,13 @@ def downgrade() -> None:
293454
op.drop_index(op.f("ix_device_tokens_token"), table_name="device_tokens")
294455
op.drop_index("ix_device_tokens_user_id", table_name="device_tokens")
295456
op.drop_table("device_tokens")
457+
op.drop_table("notification_preferences")
458+
op.drop_index("ix_notification_logs_created_at", table_name="notification_logs")
459+
op.drop_index("ix_notification_logs_recipient_id", table_name="notification_logs")
460+
op.drop_table("notification_logs")
461+
op.drop_index("ix_audit_logs_timestamp", table_name="audit_logs")
462+
op.drop_index("ix_audit_logs_actor_id", table_name="audit_logs")
463+
op.drop_table("audit_logs")
296464
op.drop_index("ix_medications_schedule_time", table_name="medications")
297465
op.drop_index("ix_medications_host_id", table_name="medications")
298466
op.drop_table("medications")

apps/api/src/admin/model.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Admin domain models."""
2+
3+
import uuid
4+
from datetime import datetime
5+
from typing import Any
6+
7+
from sqlalchemy import DateTime, Index, String, Text, func, text
8+
from sqlalchemy.dialects.postgresql import JSONB, UUID
9+
from sqlalchemy.orm import Mapped, mapped_column
10+
11+
from src.common.models.base import UUIDMixin
12+
from src.lib.database import Base
13+
14+
15+
class AuditLog(Base, UUIDMixin):
16+
"""Immutable record of administrative actions."""
17+
18+
__tablename__ = "audit_logs"
19+
__table_args__ = (
20+
Index("ix_audit_logs_actor_id", "actor_id"),
21+
Index("ix_audit_logs_timestamp", "timestamp"),
22+
)
23+
24+
actor_id: Mapped[uuid.UUID | None] = mapped_column(
25+
UUID(as_uuid=True),
26+
nullable=True,
27+
comment="User or service that performed the action",
28+
)
29+
action: Mapped[str] = mapped_column(
30+
String(100),
31+
nullable=False,
32+
comment="e.g. cleanup, deactivate_tokens",
33+
)
34+
resource_type: Mapped[str] = mapped_column(
35+
String(100),
36+
nullable=False,
37+
comment="e.g. wellness_logs, device_tokens",
38+
)
39+
detail: Mapped[dict[str, Any]] = mapped_column(
40+
JSONB,
41+
nullable=False,
42+
default=dict,
43+
server_default=text("'{}'::jsonb"),
44+
)
45+
description: Mapped[str | None] = mapped_column(
46+
Text,
47+
nullable=True,
48+
)
49+
timestamp: Mapped[datetime] = mapped_column(
50+
DateTime(timezone=True),
51+
server_default=func.now(),
52+
nullable=False,
53+
)

apps/api/src/admin/repository.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from sqlalchemy import Row, delete, func, select, update
99
from sqlalchemy.ext.asyncio import AsyncSession
1010

11+
from src.admin.model import AuditLog
1112
from src.notifications.model import DeviceToken
1213
from src.relations.model import CareRelation
1314
from src.wellness.model import WellnessLog
@@ -92,3 +93,28 @@ async def aggregate_wellness(
9293
)
9394
result = await db.execute(stmt)
9495
return dict(result.all()) # type: ignore[arg-type]
96+
97+
98+
async def create_audit_log(db: AsyncSession, audit_log: AuditLog) -> AuditLog:
99+
"""Persist an audit log entry."""
100+
db.add(audit_log)
101+
await db.flush()
102+
await db.refresh(audit_log)
103+
return audit_log
104+
105+
106+
async def find_audit_logs(
107+
db: AsyncSession,
108+
*,
109+
limit: int = 50,
110+
offset: int = 0,
111+
) -> tuple[list[AuditLog], int]:
112+
"""List audit logs (newest first) with total count."""
113+
base = select(AuditLog)
114+
count_result = await db.execute(select(func.count()).select_from(base.subquery()))
115+
total = count_result.scalar_one()
116+
117+
rows = await db.execute(
118+
base.order_by(AuditLog.timestamp.desc()).limit(limit).offset(offset)
119+
)
120+
return list(rows.scalars().all()), total

apps/api/src/admin/router.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
"""Admin endpoints for internal service-to-service calls."""
22

3+
from typing import Any
34
from uuid import UUID
45

56
from fastapi import APIRouter, Query
67

78
from src.admin import service
89
from src.admin.schemas import (
10+
AuditLogResponse,
911
CleanupRequest,
1012
CleanupResponse,
1113
InactiveRelationResponse,
14+
TokenDeactivateRequest,
15+
TokenDeactivateResponse,
1216
WellnessAggregateResponse,
1317
)
18+
from src.common.models import PaginatedResponse, PaginationParams
1419
from src.lib.dependencies import DBSession
1520
from src.lib.internal_auth import InternalAuth
1621

@@ -55,3 +60,53 @@ async def wellness_aggregate(
5560
) -> WellnessAggregateResponse:
5661
"""Get daily wellness statistics for a host."""
5762
return await service.get_wellness_aggregate(db, host_id, date)
63+
64+
65+
@router.post(
66+
"/tokens/deactivate",
67+
response_model=TokenDeactivateResponse,
68+
dependencies=[InternalAuth],
69+
)
70+
async def deactivate_tokens(
71+
payload: TokenDeactivateRequest,
72+
db: DBSession,
73+
) -> TokenDeactivateResponse:
74+
"""Deactivate failed FCM tokens reported by the worker."""
75+
count = await service.deactivate_failed_tokens(db, payload.tokens)
76+
return TokenDeactivateResponse(deactivated_count=count)
77+
78+
79+
@router.get(
80+
"/audit-logs",
81+
response_model=PaginatedResponse[AuditLogResponse],
82+
dependencies=[InternalAuth],
83+
)
84+
async def list_audit_logs(
85+
db: DBSession,
86+
page: int = Query(default=1, ge=1),
87+
limit: int = Query(default=50, ge=1, le=100),
88+
) -> PaginatedResponse[AuditLogResponse]:
89+
"""List audit log entries (paginated, newest first)."""
90+
params = PaginationParams(page=page, limit=limit)
91+
logs, total = await service.list_audit_logs(
92+
db, limit=params.limit, offset=params.offset
93+
)
94+
data = [AuditLogResponse.model_validate(lg) for lg in logs]
95+
return PaginatedResponse[AuditLogResponse].create(
96+
data=data,
97+
total=total,
98+
page=params.page,
99+
limit=params.limit,
100+
)
101+
102+
103+
@router.get(
104+
"/export/{user_id}",
105+
dependencies=[InternalAuth],
106+
)
107+
async def export_user_data(
108+
user_id: UUID,
109+
db: DBSession,
110+
) -> dict[str, Any]:
111+
"""Export all data for a user (GDPR compliance)."""
112+
return await service.export_user_data(db, user_id)

apps/api/src/admin/schemas.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,23 @@ class WellnessAggregateResponse(BaseModel):
3030
date: str
3131
total_logs: int
3232
by_status: dict[str, int]
33+
34+
35+
class TokenDeactivateRequest(BaseModel):
36+
tokens: list[str]
37+
38+
39+
class TokenDeactivateResponse(BaseModel):
40+
deactivated_count: int
41+
42+
43+
class AuditLogResponse(BaseModel):
44+
id: uuid.UUID
45+
actor_id: uuid.UUID | None
46+
action: str
47+
resource_type: str
48+
detail: dict[str, object]
49+
description: str | None
50+
timestamp: datetime
51+
52+
model_config = {"from_attributes": True}

0 commit comments

Comments
 (0)