Skip to content

Commit 4158cb5

Browse files
authored
Merge pull request #78 from MicroClub-USTHB/feat/onboarding-gate-and-audit-fixes
feat: gate mobile endpoints behind face enrollment, fix jsonb round-trip
2 parents 1fdf1fb + f244a3b commit 4158cb5

12 files changed

Lines changed: 87 additions & 29 deletions

File tree

app/core/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ class Settings(BaseSettings):
5454
# Rate Limit Settings
5555
RATE_LIMIT_LOGIN_MAX_ATTEMPTS: int = 5
5656
RATE_LIMIT_LOGIN_WINDOW_SECONDS: int = 60
57+
# In dev env, registration OTPs are fixed to this value and the email/NATS
58+
# send is skipped, so mobile devs can verify without a real inbox.
59+
DEV_OTP_BYPASS_CODE: str = "000000"
5760
TRUST_PROXY_HEADERS: bool = True
5861
# Admin list defaults
5962
ADMIN_USERS_DEFAULT_LIMIT: int = 20

app/deps/token_auth.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,24 @@ async def get_current_mobile_user(
114114
email=user.email or "",
115115
session_id=session.id,
116116
)
117+
118+
119+
async def require_onboarded_mobile_user(
120+
current_user: Annotated[MobileUserSchema, Depends(get_current_mobile_user)],
121+
container: Annotated[Container, Depends(get_container)],
122+
) -> MobileUserSchema:
123+
"""Gate for endpoints that require a completed face enrollment.
124+
Auth (login/me/devices/etc.), /enroll, and /event/join stay reachable
125+
via plain get_current_mobile_user so a new user can always finish
126+
onboarding; everything else (photos, notifications, audits, /event/me)
127+
depends on this instead.
128+
"""
129+
user = await container.auth_service.user_querier.get_user_by_id(id=current_user.user_id)
130+
if user is None:
131+
raise HTTPException(status_code=401, detail="User not found")
132+
if user.face_embedding is None:
133+
raise HTTPException(
134+
status_code=403,
135+
detail="Complete face enrollment before accessing this resource",
136+
)
137+
return current_user

app/infra/database.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
from typing import AsyncGenerator
1+
import json
2+
from typing import Any, AsyncGenerator
3+
24
import sqlalchemy.ext.asyncio
5+
from sqlalchemy import event
36
from app.core.config import settings
47

58

@@ -13,6 +16,27 @@
1316
)
1417

1518

19+
@event.listens_for(engine.sync_engine, "connect")
20+
def _register_jsonb_codec(dbapi_connection: Any, connection_record: Any) -> None:
21+
# SQLAlchemy's asyncpg dialect doesn't forward connect_args={"init": ...}
22+
# to asyncpg (it raises TypeError: unexpected keyword argument 'init'),
23+
# so codec registration has to go through the wrapped connection's
24+
# run_async bridge instead. Without this, asyncpg neither accepts a
25+
# Python dict as a jsonb bind param (raises DataError) nor decodes a
26+
# jsonb column back into one (returns raw JSON text) — every jsonb
27+
# column in the schema (audit metadata, notification payloads) needs
28+
# both directions to work.
29+
dbapi_connection.run_async(
30+
lambda conn: conn.set_type_codec(
31+
"jsonb",
32+
encoder=json.dumps,
33+
decoder=json.loads,
34+
schema="pg_catalog",
35+
format="text",
36+
)
37+
)
38+
39+
1640
async def get_db() -> AsyncGenerator[sqlalchemy.ext.asyncio.AsyncConnection, None]:
1741
async with engine.begin() as conn:
1842
yield conn

app/router/mobile/audit.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from app.container import Container, get_container
99
from app.core.constant import AuditEventType
10-
from app.deps.token_auth import MobileUserSchema, get_current_mobile_user
10+
from app.deps.token_auth import MobileUserSchema, require_onboarded_mobile_user
1111
from app.schema.response.mobile.audit import AuditEventListResponse, AuditEventSchema
1212

1313
router = APIRouter(prefix="/audits", tags=["audits"])
@@ -22,7 +22,7 @@ async def list_audits(
2222
limit: int = Query(50, ge=1, le=200),
2323
offset: int = Query(0, ge=0),
2424
container: Container = Depends(get_container),
25-
_: MobileUserSchema = Depends(get_current_mobile_user),
25+
_: MobileUserSchema = Depends(require_onboarded_mobile_user),
2626
) -> AuditEventListResponse:
2727
events = await container.audit_service.list_audit_events(
2828
event_type=event_type,

app/router/mobile/auth.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ async def get_me(
206206
email=user.email,
207207
name=user.display_name,
208208
avatar_url="/user/auth/me/avatar/image" if user.avatar_key else None,
209+
is_onboarded=user.face_embedding is not None,
209210
),
210211
devices=device_list,
211212
sessions=session_schema,
@@ -239,6 +240,7 @@ async def upload_avatar(
239240
email=user.email,
240241
name=user.display_name,
241242
avatar_url="/user/auth/me/avatar/image",
243+
is_onboarded=user.face_embedding is not None,
242244
)
243245

244246

app/router/mobile/event.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from fastapi import APIRouter, Depends
44

55
from app.container import Container, get_container
6-
from app.deps.token_auth import MobileUserSchema, get_current_mobile_user
6+
from app.deps.token_auth import MobileUserSchema, require_onboarded_mobile_user
77
from app.schema.request.web.event import JoinEventRequest
88
from app.schema.response.web.event import JoinEventResponse, UserEventResponse
99

@@ -13,7 +13,7 @@
1313
async def join_event(
1414
req: JoinEventRequest,
1515
container: Container = Depends(get_container),
16-
current_user: MobileUserSchema = Depends(get_current_mobile_user),
16+
current_user: MobileUserSchema = Depends(require_onboarded_mobile_user),
1717
)-> JoinEventResponse:
1818
return await container.event_service.join_event_by_code(
1919
user_id=current_user.user_id,
@@ -24,6 +24,6 @@ async def join_event(
2424
@router.get("/me", response_model=List[UserEventResponse])
2525
async def get_my_joined_events(
2626
container: Container = Depends(get_container),
27-
current_user: MobileUserSchema = Depends(get_current_mobile_user),
27+
current_user: MobileUserSchema = Depends(require_onboarded_mobile_user),
2828
)-> List[UserEventResponse]:
2929
return await container.event_service.get_my_events(current_user.user_id)

app/router/mobile/notifications.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from fastapi import APIRouter, Depends
22

33
from app.container import Container, get_container
4-
from app.deps.token_auth import MobileUserSchema, get_current_mobile_user
4+
from app.deps.token_auth import MobileUserSchema, require_onboarded_mobile_user
55
from app.schema.request.mobile.notifications import MarkUserNotificationsReadRequest
66
from app.schema.response.mobile.notifications import UserNotificationListResponse
77

@@ -12,7 +12,7 @@
1212
@router.get("", response_model=UserNotificationListResponse)
1313
async def get_all_notifications(
1414
container: Container = Depends(get_container),
15-
current_user: MobileUserSchema = Depends(get_current_mobile_user),
15+
current_user: MobileUserSchema = Depends(require_onboarded_mobile_user),
1616
) -> UserNotificationListResponse:
1717
notifications = await container.user_notifications_service.get_all_notifications(
1818
user_id=current_user.user_id,
@@ -24,7 +24,7 @@ async def get_all_notifications(
2424
async def mark_as_read(
2525
req: MarkUserNotificationsReadRequest,
2626
container: Container = Depends(get_container),
27-
current_user: MobileUserSchema = Depends(get_current_mobile_user),
27+
current_user: MobileUserSchema = Depends(require_onboarded_mobile_user),
2828
) -> UserNotificationListResponse:
2929
notifications = await container.user_notifications_service.mark_notifications_as_read(
3030
notification_ids=req.notification_ids,

app/router/mobile/photo_approval.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from fastapi import APIRouter, Depends, Query
55

66
from app.container import Container, get_container
7-
from app.deps.token_auth import MobileUserSchema, get_current_mobile_user
7+
from app.deps.token_auth import MobileUserSchema, require_onboarded_mobile_user
88
from app.schema.request.mobile.photo_approval import PhotoApprovalRequest
99

1010
router = APIRouter(prefix="/photos")
@@ -15,7 +15,7 @@ async def list_my_approvals(
1515
status: Literal["pending", "approved", "rejected"] | None = Query(default=None),
1616
limit: int = Query(default=50, ge=1, le=100),
1717
offset: int = Query(default=0, ge=0),
18-
current_user: MobileUserSchema = Depends(get_current_mobile_user),
18+
current_user: MobileUserSchema = Depends(require_onboarded_mobile_user),
1919
container: Container = Depends(get_container),
2020
) -> list[dict[str, object]]:
2121
approvals: list[dict[str, object]] = []
@@ -39,7 +39,7 @@ async def list_my_approvals(
3939
async def decide_photo_approval(
4040
photo_id: UUID,
4141
req: PhotoApprovalRequest,
42-
current_user: MobileUserSchema = Depends(get_current_mobile_user),
42+
current_user: MobileUserSchema = Depends(require_onboarded_mobile_user),
4343
container: Container = Depends(get_container),
4444
) -> dict[str, str]:
4545
photo_status = await container.photo_approval_service.decide(

app/router/mobile/photos.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from fastapi.responses import Response
66

77
from app.container import Container, get_container
8-
from app.deps.token_auth import MobileUserSchema, get_current_mobile_user
8+
from app.deps.token_auth import MobileUserSchema, require_onboarded_mobile_user
99
from app.deps.rate_limit import RateLimiter
1010

1111
router = APIRouter(prefix="/photos")
@@ -17,7 +17,7 @@ async def list_my_photos(
1717
sort: Literal["asc", "desc"] = Query(default="desc"),
1818
limit: int = Query(default=50, ge=1, le=100),
1919
offset: int = Query(default=0, ge=0),
20-
current_user: MobileUserSchema = Depends(get_current_mobile_user),
20+
current_user: MobileUserSchema = Depends(require_onboarded_mobile_user),
2121
container: Container = Depends(get_container),
2222
) -> list[dict[str, object]]:
2323
photos = await container.user_photo_service.list_photos(
@@ -47,7 +47,7 @@ async def list_event_photos(
4747
sort: Literal["asc", "desc"] = Query(default="desc"),
4848
limit: int = Query(default=50, ge=1, le=100),
4949
offset: int = Query(default=0, ge=0),
50-
current_user: MobileUserSchema = Depends(get_current_mobile_user),
50+
current_user: MobileUserSchema = Depends(require_onboarded_mobile_user),
5151
container: Container = Depends(get_container),
5252
) -> dict[str, object]:
5353
photos = await container.user_photo_service.list_event_photos(
@@ -81,7 +81,7 @@ async def list_event_photos(
8181
@router.get("/{photo_id}/image")
8282
async def get_photo_image(
8383
photo_id: UUID,
84-
current_user: MobileUserSchema = Depends(get_current_mobile_user),
84+
current_user: MobileUserSchema = Depends(require_onboarded_mobile_user),
8585
container: Container = Depends(get_container),
8686
) -> Response:
8787
data, filename, content_type = await container.user_photo_service.get_photo_bytes(

app/schema/response/mobile/auth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class UserSchema(BaseModel):
2626
email: str
2727
name: str | None
2828
avatar_url: str | None
29+
is_onboarded: bool
2930

3031
class MeResponse(BaseModel):
3132
user: UserSchema

0 commit comments

Comments
 (0)