From f2b3f30c2b2dededa03eead75b02d735ce424c76 Mon Sep 17 00:00:00 2001 From: ruby0322 Date: Fri, 7 Aug 2026 12:17:25 +0800 Subject: [PATCH 1/4] feat(admin): consolidate dashboard workbench API and align calendar counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify workbench eligibility (active + non-rejected) across calendar metrics and patient pool, replace 3–4 dashboard REST calls with one workbench endpoint, bound week/month aggregation in SQL, and batch thumbnail image-access to cut request volume. Co-authored-by: Cursor --- apps/backend/app/api/routes/staff.py | 196 +++++++++--- apps/backend/app/schemas/staff_dashboard.py | 31 ++ apps/backend/app/services/attention_triage.py | 12 +- apps/backend/app/services/staff_dashboard.py | 10 +- .../app/services/staff_history_overview.py | 52 ++- apps/backend/app/services/staff_workbench.py | 128 ++++++++ apps/backend/tests/test_attention_triage.py | 10 + .../backend/tests/test_staff_workbench_api.py | 297 ++++++++++++++++++ .../_components/use-upload-image-urls.ts | 89 ++++-- apps/frontend/app/admin/page.tsx | 143 +++------ apps/frontend/lib/api/staff.ts | 58 +++- 11 files changed, 856 insertions(+), 170 deletions(-) create mode 100644 apps/backend/app/services/staff_workbench.py create mode 100644 apps/backend/tests/test_staff_workbench_api.py diff --git a/apps/backend/app/api/routes/staff.py b/apps/backend/app/api/routes/staff.py index f7acd6f..0bbb6d2 100644 --- a/apps/backend/app/api/routes/staff.py +++ b/apps/backend/app/api/routes/staff.py @@ -62,6 +62,11 @@ StaffHistoryOverviewResponse, StaffHistoryOverviewUploadItem, StaffHistoryOverviewUserGroupItem, + StaffUploadImageAccessBatchItem, + StaffUploadImageAccessBatchRequest, + StaffUploadImageAccessBatchResponse, + StaffWorkbenchDashboardResponse, + StaffWorkbenchWeekDayItem, ) from app.db.models import LiffIdentity, Patient, PendingBinding, Upload from app.services.staff_dashboard import ( @@ -91,7 +96,9 @@ get_history_overview_calendar_month, list_history_overview_days, ) +from app.services.staff_workbench import get_workbench_dashboard from app.services.symptoms import derived_symptom_fields +from app.services.attention_triage import HistoryOverviewScope router = APIRouter(tags=["Staff"]) @@ -101,6 +108,56 @@ router.include_router(notifications_router) +def _serialize_today_attention( + *, + today: date, + total_uploads: int, + rows, +) -> StaffTodayAttentionResponse: + suspected_patients = sum(1 for row in rows if row.tier == "suspected") + elevated_patients = sum(1 for row in rows if row.tier == "elevated") + other_patients = sum(1 for row in rows if row.tier == "other") + return StaffTodayAttentionResponse( + date=today.isoformat(), + total_uploads=total_uploads, + suspected_patients=suspected_patients, + elevated_patients=elevated_patients, + other_patients=other_patients, + items=[ + StaffTodayAttentionPatientItem( + patient_id=row.patient.id, + case_number=row.patient.case_number, + full_name=row.patient.full_name, + tier=row.tier, + representative_upload_id=row.representative_upload_id, + sort_upload_at=row.sort_upload_at, + has_annotation=row.has_annotation, + picture_url=row.picture_url, + day_upload_count=row.day_upload_count, + preview_upload_ids=row.preview_upload_ids, + risk_highlight=( + StaffTodayAttentionRiskHighlight( + upload_id=row.risk_highlight.upload_id, + screening_result=row.risk_highlight.screening_result, + probability=row.risk_highlight.probability, + threshold=row.risk_highlight.threshold, + symptom_pain=row.risk_highlight.symptom_pain, + symptom_discharge=row.risk_highlight.symptom_discharge, + symptom_pus=row.risk_highlight.symptom_pus, + symptom_cloudy_dialysate=row.risk_highlight.symptom_cloudy_dialysate, + has_high_risk_symptoms=row.risk_highlight.has_high_risk_symptoms, + symptom_aware_priority=row.risk_highlight.symptom_aware_priority, + created_at=row.risk_highlight.created_at, + ) + if row.risk_highlight is not None + else None + ), + ) + for row in rows + ], + ) + + @router.get("/v1/staff/me") async def get_staff_profile( request: Request, @@ -518,47 +575,51 @@ async def get_staff_today_attention( accessible_patient_ids=accessible_patient_ids, local_date=local_date, ) - suspected_patients = sum(1 for row in rows if row.tier == "suspected") - elevated_patients = sum(1 for row in rows if row.tier == "elevated") - other_patients = sum(1 for row in rows if row.tier == "other") - return StaffTodayAttentionResponse( - date=today.isoformat(), - total_uploads=total_uploads, - suspected_patients=suspected_patients, - elevated_patients=elevated_patients, - other_patients=other_patients, - items=[ - StaffTodayAttentionPatientItem( - patient_id=row.patient.id, - case_number=row.patient.case_number, - full_name=row.patient.full_name, - tier=row.tier, - representative_upload_id=row.representative_upload_id, - sort_upload_at=row.sort_upload_at, - has_annotation=row.has_annotation, - picture_url=row.picture_url, - day_upload_count=row.day_upload_count, - preview_upload_ids=row.preview_upload_ids, - risk_highlight=( - StaffTodayAttentionRiskHighlight( - upload_id=row.risk_highlight.upload_id, - screening_result=row.risk_highlight.screening_result, - probability=row.risk_highlight.probability, - threshold=row.risk_highlight.threshold, - symptom_pain=row.risk_highlight.symptom_pain, - symptom_discharge=row.risk_highlight.symptom_discharge, - symptom_pus=row.risk_highlight.symptom_pus, - symptom_cloudy_dialysate=row.risk_highlight.symptom_cloudy_dialysate, - has_high_risk_symptoms=row.risk_highlight.has_high_risk_symptoms, - symptom_aware_priority=row.risk_highlight.symptom_aware_priority, - created_at=row.risk_highlight.created_at, - ) - if row.risk_highlight is not None - else None - ), + return _serialize_today_attention(today=today, total_uploads=total_uploads, rows=rows) + finally: + session.close() + + +@router.get("/v1/staff/dashboard/workbench", response_model=StaffWorkbenchDashboardResponse) +async def get_staff_dashboard_workbench( + request: Request, + local_date: date = Query(...), + week_start: date = Query(...), + credentials=Depends(bearer_scheme), +) -> StaffWorkbenchDashboardResponse: + principal = require_staff_or_admin(get_current_principal(request, credentials)) + session = get_session(request) + try: + accessible_patient_ids = _get_accessible_patient_ids( + session, + role=principal.role, + identity_id=principal.identity_id, + ) + data = get_workbench_dashboard( + session, + local_date=local_date, + week_start=week_start, + accessible_patient_ids=accessible_patient_ids, + ) + return StaffWorkbenchDashboardResponse( + local_date=data.local_date.isoformat(), + week_start=data.week_start.isoformat(), + available_dates=[day.isoformat() for day in data.available_dates], + week_days=[ + StaffWorkbenchWeekDayItem( + local_date=day.local_date.isoformat(), + upload_count=day.upload_count, + uploaded_users=day.uploaded_users, + risky_patient_count=day.risky_patient_count, + unhandled_patient_count=day.unhandled_patient_count, ) - for row in rows + for day in data.week_days ], + attention=_serialize_today_attention( + today=data.local_date, + total_uploads=data.attention_total_uploads, + rows=data.attention_rows, + ), ) finally: session.close() @@ -567,6 +628,7 @@ async def get_staff_today_attention( @router.get("/v1/staff/uploads/history-overview/days", response_model=StaffHistoryOverviewDaysResponse) async def get_staff_history_overview_days( request: Request, + scope: HistoryOverviewScope = Query(default="all"), credentials=Depends(bearer_scheme), ) -> StaffHistoryOverviewDaysResponse: principal = require_staff_or_admin(get_current_principal(request, credentials)) @@ -580,6 +642,7 @@ async def get_staff_history_overview_days( rows = list_history_overview_days( session, accessible_patient_ids=accessible_patient_ids, + scope=scope, ) return StaffHistoryOverviewDaysResponse( items=[ @@ -1065,6 +1128,61 @@ async def get_staff_upload_image_access( session.close() +@router.post("/v1/staff/uploads/image-access/batch", response_model=StaffUploadImageAccessBatchResponse) +async def post_staff_upload_image_access_batch( + request: Request, + payload: StaffUploadImageAccessBatchRequest, + credentials=Depends(bearer_scheme), +) -> StaffUploadImageAccessBatchResponse: + principal = require_staff_or_admin(get_current_principal(request, credentials)) + session = get_session(request) + try: + storage_service = getattr(request.app.state, "storage_service", None) + if storage_service is None: + raise HTTPException(status_code=503, detail="Storage is not initialized") + ttl_seconds = int(request.app.state.settings.image_access_token_ttl_seconds) + accessible_patient_ids = _get_accessible_patient_ids( + session, + role=principal.role, + identity_id=principal.identity_id, + ) + # Preserve request order; dedupe while resolving. + seen: set[int] = set() + ordered_ids: list[int] = [] + for upload_id in payload.upload_ids: + if upload_id in seen: + continue + seen.add(upload_id) + ordered_ids.append(upload_id) + + uploads = { + upload.id: upload + for upload in session.execute(select(Upload).where(Upload.id.in_(ordered_ids))).scalars().all() + } + items: list[StaffUploadImageAccessBatchItem] = [] + for upload_id in ordered_ids: + upload = uploads.get(upload_id) + if upload is None: + items.append(StaffUploadImageAccessBatchItem(upload_id=upload_id, error="not_found")) + continue + if accessible_patient_ids is not None and upload.patient_id not in accessible_patient_ids: + items.append(StaffUploadImageAccessBatchItem(upload_id=upload_id, error="forbidden")) + continue + token = storage_service.generate_access_token( + upload.object_key, subject="staff", ttl_seconds=ttl_seconds + ) + items.append( + StaffUploadImageAccessBatchItem( + upload_id=upload_id, + image_url=f"/api/v1/staff/uploads/{upload_id}/image-public?token={token}", + expires_in=ttl_seconds, + ) + ) + return StaffUploadImageAccessBatchResponse(items=items) + finally: + session.close() + + @router.get("/v1/staff/uploads/{upload_id}/image-public") async def get_staff_upload_image_public( request: Request, diff --git a/apps/backend/app/schemas/staff_dashboard.py b/apps/backend/app/schemas/staff_dashboard.py index 7b4ed1a..84aa082 100644 --- a/apps/backend/app/schemas/staff_dashboard.py +++ b/apps/backend/app/schemas/staff_dashboard.py @@ -138,6 +138,37 @@ class StaffTodayAttentionResponse(BaseModel): items: list[StaffTodayAttentionPatientItem] +class StaffWorkbenchWeekDayItem(BaseModel): + local_date: str + upload_count: int = 0 + uploaded_users: int = 0 + risky_patient_count: int = 0 + unhandled_patient_count: int = 0 + + +class StaffWorkbenchDashboardResponse(BaseModel): + local_date: str + week_start: str + available_dates: list[str] + week_days: list[StaffWorkbenchWeekDayItem] + attention: StaffTodayAttentionResponse + + +class StaffUploadImageAccessBatchRequest(BaseModel): + upload_ids: list[int] = Field(..., min_length=1, max_length=50) + + +class StaffUploadImageAccessBatchItem(BaseModel): + upload_id: int + image_url: str | None = None + expires_in: int | None = None + error: Literal["not_found", "forbidden"] | None = None + + +class StaffUploadImageAccessBatchResponse(BaseModel): + items: list[StaffUploadImageAccessBatchItem] + + class StaffHistoryOverviewDayItem(BaseModel): local_date: str upload_count: int diff --git a/apps/backend/app/services/attention_triage.py b/apps/backend/app/services/attention_triage.py index 5df299e..2171cf0 100644 --- a/apps/backend/app/services/attention_triage.py +++ b/apps/backend/app/services/attention_triage.py @@ -7,12 +7,22 @@ from __future__ import annotations from datetime import datetime -from typing import Literal, Mapping, NamedTuple, Sequence +from typing import Any, Literal, Mapping, NamedTuple, Sequence +from app.db.models import AIResult, Patient from app.services.symptoms import CalendarRiskTier from app.services.taipei_dates import normalize_datetime AttentionTier = Literal["suspected", "elevated", "other"] +HistoryOverviewScope = Literal["all", "workbench"] + + +def workbench_upload_where_clauses() -> tuple[Any, ...]: + """SQLAlchemy filters for workbench-eligible uploads (active patient, non-rejected).""" + return ( + AIResult.screening_result != "rejected", + Patient.is_active.is_(True), + ) def calendar_tier_to_attention_tier(tier: CalendarRiskTier) -> AttentionTier: diff --git a/apps/backend/app/services/staff_dashboard.py b/apps/backend/app/services/staff_dashboard.py index 3fc2297..3d54dc2 100644 --- a/apps/backend/app/services/staff_dashboard.py +++ b/apps/backend/app/services/staff_dashboard.py @@ -10,7 +10,12 @@ from sqlalchemy.orm import Session, aliased from app.db.models import AIResult, Annotation, LiffIdentity, Notification, Patient, PendingBinding, StaffPatientAssignment, Upload -from app.services.attention_triage import TriageUploadRef, calendar_tier_to_attention_tier, select_risk_representative +from app.services.attention_triage import ( + TriageUploadRef, + calendar_tier_to_attention_tier, + select_risk_representative, + workbench_upload_where_clauses, +) from app.services.symptoms import calendar_risk_tier, has_high_risk_symptoms, symptom_aware_priority from app.services.taipei_dates import TAIPEI_TIMEZONE, resolve_taipei_day_bounds, resolve_taipei_day_bounds_for_date, to_taipei_date from app.services.upload_history import summarize_patient_upload_history @@ -1139,8 +1144,7 @@ def list_today_attention_patients( .where( Upload.created_at >= today_start, Upload.created_at < tomorrow_start, - AIResult.screening_result != "rejected", - Patient.is_active.is_(True), + *workbench_upload_where_clauses(), ) ) if accessible_patient_ids is not None: diff --git a/apps/backend/app/services/staff_history_overview.py b/apps/backend/app/services/staff_history_overview.py index 343a868..241e14c 100644 --- a/apps/backend/app/services/staff_history_overview.py +++ b/apps/backend/app/services/staff_history_overview.py @@ -8,10 +8,16 @@ from sqlalchemy.orm import Session from app.db.models import AIResult, Annotation, LiffIdentity, Patient, Upload -from app.services.attention_triage import TriageUploadRef, calendar_tier_to_attention_tier, count_unhandled_patients +from app.services.attention_triage import ( + HistoryOverviewScope, + TriageUploadRef, + calendar_tier_to_attention_tier, + count_unhandled_patients, + workbench_upload_where_clauses, +) from app.services.staff_dashboard import calculate_age from app.services.symptoms import CalendarRiskTier, calendar_risk_tier, counts_toward_suspected_rate -from app.services.taipei_dates import normalize_datetime, to_taipei_date +from app.services.taipei_dates import normalize_datetime, resolve_taipei_day_bounds_for_date, to_taipei_date @dataclass(frozen=True) @@ -240,12 +246,25 @@ def _count_unhandled_for_day(day_rows: list[_RawUploadRow]) -> int: return count_unhandled_patients(by_patient) -def _raw_rows(session: Session, *, accessible_patient_ids: set[int] | None = None) -> list[_RawUploadRow]: +def _raw_rows( + session: Session, + *, + accessible_patient_ids: set[int] | None = None, + scope: HistoryOverviewScope = "all", + created_from: datetime | None = None, + created_to: datetime | None = None, +) -> list[_RawUploadRow]: base_query: Select = ( select(Upload, AIResult, Patient) .join(AIResult, AIResult.upload_id == Upload.id) .join(Patient, Patient.id == Upload.patient_id) ) + if scope == "workbench": + base_query = base_query.where(*workbench_upload_where_clauses()) + if created_from is not None: + base_query = base_query.where(Upload.created_at >= created_from) + if created_to is not None: + base_query = base_query.where(Upload.created_at < created_to) if accessible_patient_ids is not None: if not accessible_patient_ids: return [] @@ -293,8 +312,17 @@ def list_history_overview_days( session: Session, *, accessible_patient_ids: set[int] | None = None, + scope: HistoryOverviewScope = "all", + created_from: datetime | None = None, + created_to: datetime | None = None, ) -> list[HistoryOverviewDaySummary]: - rows = _raw_rows(session, accessible_patient_ids=accessible_patient_ids) + rows = _raw_rows( + session, + accessible_patient_ids=accessible_patient_ids, + scope=scope, + created_from=created_from, + created_to=created_to, + ) grouped: dict[date, list[_RawUploadRow]] = defaultdict(list) for row in rows: grouped[row.local_date].append(row) @@ -471,8 +499,22 @@ def get_history_overview_calendar_month( year: int, month: int, accessible_patient_ids: set[int] | None = None, + scope: HistoryOverviewScope = "all", ) -> list[HistoryOverviewCalendarItem]: - days = list_history_overview_days(session, accessible_patient_ids=accessible_patient_ids) + month_start = date(year, month, 1) + if month == 12: + next_month_start = date(year + 1, 1, 1) + else: + next_month_start = date(year, month + 1, 1) + _, created_from, _ = resolve_taipei_day_bounds_for_date(month_start) + _, created_to, _ = resolve_taipei_day_bounds_for_date(next_month_start) + days = list_history_overview_days( + session, + accessible_patient_ids=accessible_patient_ids, + scope=scope, + created_from=created_from, + created_to=created_to, + ) return [ HistoryOverviewCalendarItem( local_date=item.local_date, diff --git a/apps/backend/app/services/staff_workbench.py b/apps/backend/app/services/staff_workbench.py new file mode 100644 index 0000000..e30b2ca --- /dev/null +++ b/apps/backend/app/services/staff_workbench.py @@ -0,0 +1,128 @@ +"""Admin dashboard workbench: week metrics + available dates + day attention in one session.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta + +from sqlalchemy import Select, select +from sqlalchemy.orm import Session + +from app.db.models import AIResult, Patient, Upload +from app.services.attention_triage import workbench_upload_where_clauses +from app.services.staff_dashboard import TodayAttentionPatientRow, list_today_attention_patients +from app.services.staff_history_overview import HistoryOverviewDaySummary, list_history_overview_days +from app.services.taipei_dates import resolve_taipei_day_bounds_for_date, to_taipei_date + + +@dataclass(frozen=True) +class WorkbenchWeekDayMetrics: + local_date: date + upload_count: int + uploaded_users: int + risky_patient_count: int + unhandled_patient_count: int + + +@dataclass(frozen=True) +class WorkbenchDashboardData: + local_date: date + week_start: date + available_dates: list[date] + week_days: list[WorkbenchWeekDayMetrics] + attention_total_uploads: int + attention_rows: list[TodayAttentionPatientRow] + + +def list_workbench_dates( + session: Session, + *, + accessible_patient_ids: set[int] | None = None, +) -> list[date]: + """Distinct Taipei local dates with at least one workbench-eligible upload.""" + if accessible_patient_ids is not None and not accessible_patient_ids: + return [] + base_query: Select = ( + select(Upload.created_at) + .join(AIResult, AIResult.upload_id == Upload.id) + .join(Patient, Patient.id == Upload.patient_id) + .where(*workbench_upload_where_clauses()) + ) + if accessible_patient_ids is not None: + base_query = base_query.where(Patient.id.in_(accessible_patient_ids)) + created_ats = session.execute(base_query).scalars().all() + dates = {to_taipei_date(created_at) for created_at in created_ats} + return sorted(dates, reverse=True) + + +def aggregate_workbench_week( + session: Session, + *, + week_start: date, + accessible_patient_ids: set[int] | None = None, +) -> list[WorkbenchWeekDayMetrics]: + """Metrics for the 7 Taipei days starting at week_start (inclusive). Always returns 7 entries.""" + week_end = week_start + timedelta(days=6) + _, created_from, _ = resolve_taipei_day_bounds_for_date(week_start) + _, _, created_to = resolve_taipei_day_bounds_for_date(week_end) + day_summaries = list_history_overview_days( + session, + accessible_patient_ids=accessible_patient_ids, + scope="workbench", + created_from=created_from, + created_to=created_to, + ) + by_date = {item.local_date: item for item in day_summaries} + result: list[WorkbenchWeekDayMetrics] = [] + for offset in range(7): + day = week_start + timedelta(days=offset) + summary: HistoryOverviewDaySummary | None = by_date.get(day) + if summary is None: + result.append( + WorkbenchWeekDayMetrics( + local_date=day, + upload_count=0, + uploaded_users=0, + risky_patient_count=0, + unhandled_patient_count=0, + ) + ) + else: + result.append( + WorkbenchWeekDayMetrics( + local_date=summary.local_date, + upload_count=summary.upload_count, + uploaded_users=summary.uploaded_users, + risky_patient_count=summary.risky_patient_count, + unhandled_patient_count=summary.unhandled_patient_count, + ) + ) + return result + + +def get_workbench_dashboard( + session: Session, + *, + local_date: date, + week_start: date, + accessible_patient_ids: set[int] | None = None, +) -> WorkbenchDashboardData: + available_dates = list_workbench_dates(session, accessible_patient_ids=accessible_patient_ids) + week_days = aggregate_workbench_week( + session, + week_start=week_start, + accessible_patient_ids=accessible_patient_ids, + ) + attention_date, total_uploads, attention_rows = list_today_attention_patients( + session, + accessible_patient_ids=accessible_patient_ids, + local_date=local_date, + ) + return WorkbenchDashboardData( + local_date=attention_date, + week_start=week_start, + available_dates=available_dates, + week_days=week_days, + attention_total_uploads=total_uploads, + attention_rows=attention_rows, + ) diff --git a/apps/backend/tests/test_attention_triage.py b/apps/backend/tests/test_attention_triage.py index 427ef34..0320387 100644 --- a/apps/backend/tests/test_attention_triage.py +++ b/apps/backend/tests/test_attention_triage.py @@ -29,6 +29,16 @@ def test_calendar_tier_to_attention_tier_maps_none_to_other() -> None: assert calendar_tier_to_attention_tier("suspected") == "suspected" +def test_workbench_upload_where_clauses_returns_two_filters() -> None: + from app.services.attention_triage import workbench_upload_where_clauses + + clauses = workbench_upload_where_clauses() + assert len(clauses) == 2 + joined = " ".join(str(clause) for clause in clauses) + assert "screening_result" in joined + assert "is_active" in joined + + def test_select_risk_representative_prefers_suspected_over_elevated() -> None: refs = [ _ref(1, tier="elevated", minute=1), diff --git a/apps/backend/tests/test_staff_workbench_api.py b/apps/backend/tests/test_staff_workbench_api.py new file mode 100644 index 0000000..40a3ed4 --- /dev/null +++ b/apps/backend/tests/test_staff_workbench_api.py @@ -0,0 +1,297 @@ +from __future__ import annotations +# pyright: reportMissingImports=false + +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace + +from fastapi.testclient import TestClient + +from app.config import Settings +from app.db.models import AIResult, LiffIdentity, Patient, StaffPatientAssignment, Upload +from app.main import create_app +from tests.db_test_utils import migrated_sqlite_database_url + + +def make_settings(db_path: Path) -> Settings: + return Settings( + app_name="test-staff-workbench-api", + app_env="test", + model_url="https://example.com/model.pt", + model_path=Path("/tmp/model.pt"), + model_cache_dir=Path("/tmp"), + model_timeout_seconds=5.0, + device="cpu", + model_backbone="mobilenet_v3_large", + model_arch="baseline", + transfer_dropout=0.4, + threshold=0.5, + image_size=384, + infection_class_index=4, + class_names=("class_0", "class_1", "class_2", "class_3", "class_4"), + max_upload_mb=10, + log_level="INFO", + accepted_content_types=("image/jpeg", "image/png"), + cors_allowed_origins=("http://localhost:3000",), + cors_allowed_origin_regex=r"^https?://(?:\d{1,3}\.){3}\d{1,3}:3000$", + workers=1, + eval_hflip_tta=False, + database_url=migrated_sqlite_database_url(db_path), + s3_endpoint_url="http://localhost:8333", + s3_region="us-east-1", + s3_access_key="seaweed-access", + s3_secret_key="seaweed-secret", + s3_bucket_name="pd-care-private", + image_access_token_secret="test-secret", + image_access_token_ttl_seconds=300, + auth_token_secret="test-auth-secret", + auth_token_ttl_seconds=3600, + line_verify_mode="stub", + ) + + +def _taipei_day_start_utc(local_day: datetime) -> datetime: + taipei_tz = timezone(timedelta(hours=8)) + return datetime.combine(local_day.date(), datetime.min.time(), tzinfo=taipei_tz).astimezone(timezone.utc) + + +def _seed_staff(client: TestClient, *, line_user_id: str = "U_STAFF", role: str = "staff") -> int: + session_factory = client.app.state.db_session_factory + with session_factory() as session: + staff_identity = LiffIdentity( + line_user_id=line_user_id, + display_name="Staff", + picture_url=None, + patient_id=None, + role=role, + ) + session.add(staff_identity) + session.commit() + session.refresh(staff_identity) + return staff_identity.id + + +def _login_staff_token(client: TestClient, line_user_id: str = "U_STAFF") -> str: + response = client.post("/v1/auth/login", json={"line_id_token": f"stub:{line_user_id}"}) + assert response.status_code == 200 + return response.json()["access_token"] + + +def _assign_staff_patient(client: TestClient, *, staff_identity_id: int, patient_id: int) -> None: + session_factory = client.app.state.db_session_factory + with session_factory() as session: + session.add(StaffPatientAssignment(staff_identity_id=staff_identity_id, patient_id=patient_id)) + session.commit() + + +def _seed_patient_uploads( + client: TestClient, + *, + case_number: str, + line_user_id: str, + uploads: list[tuple[datetime, str]], + is_active: bool = True, +) -> tuple[int, list[int]]: + session_factory = client.app.state.db_session_factory + with session_factory() as session: + patient = Patient( + case_number=case_number, + birth_date="1985-01-01", + full_name=case_number, + is_active=is_active, + ) + session.add(patient) + session.flush() + session.add( + LiffIdentity( + line_user_id=line_user_id, + display_name=case_number, + picture_url=None, + patient_id=patient.id, + role="patient", + ) + ) + upload_ids: list[int] = [] + for index, (created_at, result) in enumerate(uploads, start=1): + upload = Upload( + patient_id=patient.id, + object_key=f"patients/{patient.id}/uploads/{index}.jpg", + content_type="image/jpeg", + created_at=created_at, + ) + session.add(upload) + session.flush() + session.add(AIResult(upload_id=upload.id, screening_result=result, probability=0.8, threshold=0.5)) + upload_ids.append(upload.id) + session.commit() + return patient.id, upload_ids + + +def test_workbench_excludes_rejected_only_day(tmp_path: Path) -> None: + settings = make_settings(tmp_path / "workbench-rejected.db") + app = create_app(settings=settings, loaded_model=SimpleNamespace(device="cpu")) + with TestClient(app) as client: + staff_identity_id = _seed_staff(client) + day = datetime(2026, 7, 15, 12, 0, tzinfo=timezone(timedelta(hours=8))) + patient_id, _ = _seed_patient_uploads( + client, + case_number="P-REJ", + line_user_id="U_REJ", + uploads=[ + (_taipei_day_start_utc(day) + timedelta(hours=1), "rejected"), + (_taipei_day_start_utc(day) + timedelta(hours=2), "rejected"), + (_taipei_day_start_utc(day) + timedelta(hours=3), "rejected"), + ], + ) + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=patient_id) + token = _login_staff_token(client) + headers = {"Authorization": f"Bearer {token}"} + + workbench = client.get( + "/v1/staff/dashboard/workbench", + headers=headers, + params={"local_date": "2026-07-15", "week_start": "2026-07-12"}, + ) + assert workbench.status_code == 200 + payload = workbench.json() + assert len(payload["week_days"]) == 7 + day_item = next(item for item in payload["week_days"] if item["local_date"] == "2026-07-15") + assert day_item["upload_count"] == 0 + assert day_item["uploaded_users"] == 0 + assert day_item["risky_patient_count"] == 0 + assert "2026-07-15" not in payload["available_dates"] + assert payload["attention"]["items"] == [] + assert payload["attention"]["total_uploads"] == 0 + + days_all = client.get("/v1/staff/uploads/history-overview/days", headers=headers) + assert days_all.status_code == 200 + assert any(item["local_date"] == "2026-07-15" for item in days_all.json()["items"]) + + days_workbench = client.get( + "/v1/staff/uploads/history-overview/days", + headers=headers, + params={"scope": "workbench"}, + ) + assert days_workbench.status_code == 200 + assert not any(item["local_date"] == "2026-07-15" for item in days_workbench.json()["items"]) + + +def test_workbench_excludes_inactive_only_day(tmp_path: Path) -> None: + settings = make_settings(tmp_path / "workbench-inactive.db") + app = create_app(settings=settings, loaded_model=SimpleNamespace(device="cpu")) + with TestClient(app) as client: + staff_identity_id = _seed_staff(client) + day = datetime(2026, 7, 15, 12, 0, tzinfo=timezone(timedelta(hours=8))) + patient_id, _ = _seed_patient_uploads( + client, + case_number="P-INACTIVE", + line_user_id="U_INACTIVE", + is_active=False, + uploads=[(_taipei_day_start_utc(day) + timedelta(hours=1), "normal")], + ) + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=patient_id) + token = _login_staff_token(client) + headers = {"Authorization": f"Bearer {token}"} + + workbench = client.get( + "/v1/staff/dashboard/workbench", + headers=headers, + params={"local_date": "2026-07-15", "week_start": "2026-07-12"}, + ) + assert workbench.status_code == 200 + payload = workbench.json() + day_item = next(item for item in payload["week_days"] if item["local_date"] == "2026-07-15") + assert day_item["upload_count"] == 0 + assert day_item["uploaded_users"] == 0 + assert "2026-07-15" not in payload["available_dates"] + assert payload["attention"]["items"] == [] + + +def test_workbench_aligns_week_metrics_with_attention(tmp_path: Path) -> None: + settings = make_settings(tmp_path / "workbench-align.db") + app = create_app(settings=settings, loaded_model=SimpleNamespace(device="cpu")) + with TestClient(app) as client: + staff_identity_id = _seed_staff(client) + day = datetime(2026, 7, 16, 12, 0, tzinfo=timezone(timedelta(hours=8))) + day_start = _taipei_day_start_utc(day) + patient_a, _ = _seed_patient_uploads( + client, + case_number="P-A", + line_user_id="U_A", + uploads=[ + (day_start + timedelta(hours=1), "normal"), + (day_start + timedelta(hours=2), "suspected"), + ], + ) + patient_b, _ = _seed_patient_uploads( + client, + case_number="P-B", + line_user_id="U_B", + uploads=[(day_start + timedelta(hours=3), "normal")], + ) + # Out-of-week noise should not inflate week_days. + other_month = datetime(2026, 5, 1, 12, 0, tzinfo=timezone(timedelta(hours=8))) + patient_c, _ = _seed_patient_uploads( + client, + case_number="P-C", + line_user_id="U_C", + uploads=[(_taipei_day_start_utc(other_month) + timedelta(hours=1), "suspected")], + ) + for patient_id in (patient_a, patient_b, patient_c): + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=patient_id) + + token = _login_staff_token(client) + headers = {"Authorization": f"Bearer {token}"} + workbench = client.get( + "/v1/staff/dashboard/workbench", + headers=headers, + params={"local_date": "2026-07-16", "week_start": "2026-07-12"}, + ) + assert workbench.status_code == 200 + payload = workbench.json() + assert len(payload["week_days"]) == 7 + day_item = next(item for item in payload["week_days"] if item["local_date"] == "2026-07-16") + attention = payload["attention"] + assert day_item["uploaded_users"] == len(attention["items"]) + assert day_item["upload_count"] == sum(item["day_upload_count"] for item in attention["items"]) + assert day_item["upload_count"] == 3 + assert day_item["uploaded_users"] == 2 + assert day_item["risky_patient_count"] == 1 + assert "2026-07-16" in payload["available_dates"] + assert "2026-05-01" in payload["available_dates"] + + +def test_image_access_batch_partial_errors(tmp_path: Path) -> None: + settings = make_settings(tmp_path / "image-batch.db") + app = create_app(settings=settings, loaded_model=SimpleNamespace(device="cpu")) + with TestClient(app) as client: + staff_identity_id = _seed_staff(client) + day = datetime(2026, 7, 16, 12, 0, tzinfo=timezone(timedelta(hours=8))) + assigned_id, assigned_upload_ids = _seed_patient_uploads( + client, + case_number="P-ASSIGNED", + line_user_id="U_ASSIGNED", + uploads=[(_taipei_day_start_utc(day) + timedelta(hours=1), "normal")], + ) + unassigned_id, unassigned_upload_ids = _seed_patient_uploads( + client, + case_number="P-UNASSIGNED", + line_user_id="U_UNASSIGNED", + uploads=[(_taipei_day_start_utc(day) + timedelta(hours=2), "normal")], + ) + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=assigned_id) + del unassigned_id # intentionally not assigned + + token = _login_staff_token(client) + headers = {"Authorization": f"Bearer {token}"} + response = client.post( + "/v1/staff/uploads/image-access/batch", + headers=headers, + json={"upload_ids": [assigned_upload_ids[0], unassigned_upload_ids[0], 999999]}, + ) + assert response.status_code == 200 + items = {item["upload_id"]: item for item in response.json()["items"]} + assert items[assigned_upload_ids[0]]["image_url"] is not None + assert items[assigned_upload_ids[0]]["error"] is None + assert items[unassigned_upload_ids[0]]["error"] == "forbidden" + assert items[999999]["error"] == "not_found" diff --git a/apps/frontend/app/admin/_components/use-upload-image-urls.ts b/apps/frontend/app/admin/_components/use-upload-image-urls.ts index b4f2f16..613035b 100644 --- a/apps/frontend/app/admin/_components/use-upload-image-urls.ts +++ b/apps/frontend/app/admin/_components/use-upload-image-urls.ts @@ -1,47 +1,88 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; -import { fetchUploadImageAccess } from "@/lib/api/staff"; +import { fetchUploadImageAccessBatch } from "@/lib/api/staff"; + +const BATCH_SIZE = 50; export function useUploadImageUrls(uploadIds: number[]) { const [imageUrlByUploadId, setImageUrlByUploadId] = useState>({}); const [imageErrorByUploadId, setImageErrorByUploadId] = useState>({}); - useEffect(() => { - if (uploadIds.length === 0) { - return; + const stableIds = useMemo(() => { + const seen = new Set(); + const ids: number[] = []; + for (const uploadId of uploadIds) { + if (seen.has(uploadId)) { + continue; + } + seen.add(uploadId); + ids.push(uploadId); } - const missing = uploadIds.filter((uploadId) => !imageUrlByUploadId[uploadId]); + return ids; + }, [uploadIds]); + + const missingKey = useMemo(() => { + return stableIds.filter((uploadId) => !imageUrlByUploadId[uploadId] && !imageErrorByUploadId[uploadId]).join(","); + }, [imageErrorByUploadId, imageUrlByUploadId, stableIds]); + + useEffect(() => { + const missing = missingKey + ? missingKey.split(",").map((value) => Number(value)).filter((value) => Number.isFinite(value)) + : []; if (missing.length === 0) { return; } let cancelled = false; - void Promise.allSettled(missing.map((uploadId) => fetchUploadImageAccess(uploadId))).then((results) => { - if (cancelled) { - return; - } - setImageUrlByUploadId((current) => { - const next = { ...current }; - results.forEach((result, index) => { - if (result.status === "fulfilled") { - next[missing[index]] = result.value.image_url; + const chunks: number[][] = []; + for (let index = 0; index < missing.length; index += BATCH_SIZE) { + chunks.push(missing.slice(index, index + BATCH_SIZE)); + } + void Promise.all(chunks.map((chunk) => fetchUploadImageAccessBatch(chunk))) + .then((responses) => { + if (cancelled) { + return; + } + setImageUrlByUploadId((current) => { + const next = { ...current }; + for (const response of responses) { + for (const item of response.items) { + if (item.image_url) { + next[item.upload_id] = item.image_url; + } + } } + return next; }); - return next; - }); - setImageErrorByUploadId((current) => { - const next = { ...current }; - results.forEach((result, index) => { - next[missing[index]] = result.status === "rejected"; + setImageErrorByUploadId((current) => { + const next = { ...current }; + for (const response of responses) { + for (const item of response.items) { + if (item.error || !item.image_url) { + next[item.upload_id] = true; + } + } + } + return next; + }); + }) + .catch(() => { + if (cancelled) { + return; + } + setImageErrorByUploadId((current) => { + const next = { ...current }; + for (const uploadId of missing) { + next[uploadId] = true; + } + return next; }); - return next; }); - }); return () => { cancelled = true; }; - }, [imageUrlByUploadId, uploadIds]); + }, [missingKey]); return { imageUrlByUploadId, imageErrorByUploadId }; } diff --git a/apps/frontend/app/admin/page.tsx b/apps/frontend/app/admin/page.tsx index f73d62a..b3aecb4 100644 --- a/apps/frontend/app/admin/page.tsx +++ b/apps/frontend/app/admin/page.tsx @@ -10,124 +10,75 @@ import { TodayPatientPool } from "@/app/admin/_components/today-patient-pool"; import { TodayWorkbenchHeader } from "@/app/admin/_components/today-workbench-header"; import { useAdminSelectedDate } from "@/lib/admin/use-admin-selected-date"; import { - fetchHistoryOverviewCalendar, - fetchHistoryOverviewDays, - fetchTodayAttention, + fetchWorkbenchDashboard, type StaffTodayAttentionResponse, } from "@/lib/api/staff"; -import { getMonthKeysForWeek, getWeekStartDateKey } from "@/lib/utils/upload-calendar"; +import { getWeekStartDateKey } from "@/lib/utils/upload-calendar"; function AdminDashboardInner() { const { selectedDate, setSelectedDate, isTodaySelected, dayScopeLabel } = useAdminSelectedDate(); const [attention, setAttention] = useState(null); - const [attentionLoading, setAttentionLoading] = useState(true); - const [attentionError, setAttentionError] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [selectedPatientId, setSelectedPatientId] = useState(null); const [browseWeekStart, setBrowseWeekStart] = useState(null); const weekStartDateKey = browseWeekStart ?? getWeekStartDateKey(selectedDate); - const weekMonthKeys = useMemo(() => getMonthKeysForWeek(weekStartDateKey), [weekStartDateKey]); const [availableDates, setAvailableDates] = useState([]); const [metricsByDate, setMetricsByDate] = useState>({}); - const [calendarLoading, setCalendarLoading] = useState(true); - const loadAttention = useCallback(async (isCancelled?: () => boolean) => { - const cancelled = isCancelled ?? (() => false); - setAttentionLoading(true); - try { - const data = await fetchTodayAttention({ localDate: selectedDate }); - if (cancelled()) { - return; - } - setAttention(data); - setAttentionError(null); - } catch { - if (cancelled()) { - return; - } - setAttentionError(`無法載入${dayScopeLabel}上傳病患`); - setAttention(null); - } finally { - if (!cancelled()) { - setAttentionLoading(false); - } - } - }, [dayScopeLabel, selectedDate]); - - useEffect(() => { - let cancelled = false; - const timer = window.setTimeout(() => { - void loadAttention(() => cancelled); - }, 0); - return () => { - cancelled = true; - window.clearTimeout(timer); - }; - }, [loadAttention]); - - useEffect(() => { - let cancelled = false; - void fetchHistoryOverviewDays() - .then((data) => { - if (!cancelled) { - setAvailableDates(data.items.map((item) => item.local_date)); + const loadWorkbench = useCallback( + async (isCancelled?: () => boolean) => { + const cancelled = isCancelled ?? (() => false); + setLoading(true); + try { + const data = await fetchWorkbenchDashboard({ + localDate: selectedDate, + weekStart: weekStartDateKey, + }); + if (cancelled()) { + return; } - }) - .catch(() => { - if (!cancelled) { - setAvailableDates([]); + setAvailableDates(data.available_dates); + const nextMetrics: Record = {}; + for (const day of data.week_days) { + nextMetrics[day.local_date] = { + uploadCount: day.upload_count ?? 0, + uploadedUsers: day.uploaded_users ?? 0, + riskyPatients: day.risky_patient_count ?? 0, + unhandledPatients: day.unhandled_patient_count ?? 0, + }; } - }); - return () => { - cancelled = true; - }; - }, []); + setMetricsByDate(nextMetrics); + setAttention(data.attention); + setError(null); + } catch { + if (cancelled()) { + return; + } + setError(`無法載入${dayScopeLabel}上傳病患`); + setAttention(null); + } finally { + if (!cancelled()) { + setLoading(false); + } + } + }, + [dayScopeLabel, selectedDate, weekStartDateKey] + ); useEffect(() => { let cancelled = false; const timer = window.setTimeout(() => { - setCalendarLoading(true); - void Promise.all( - weekMonthKeys.map((monthKey) => { - const [year, month] = monthKey.split("-").map(Number); - return fetchHistoryOverviewCalendar({ year, month }); - }) - ) - .then((responses) => { - if (cancelled) { - return; - } - const next: Record = {}; - for (const response of responses) { - for (const item of response.items) { - next[item.local_date] = { - uploadCount: item.upload_count ?? 0, - uploadedUsers: item.uploaded_users ?? 0, - riskyPatients: item.risky_patient_count ?? 0, - unhandledPatients: item.unhandled_patient_count ?? 0, - }; - } - } - setMetricsByDate(next); - }) - .catch(() => { - if (!cancelled) { - setMetricsByDate({}); - } - }) - .finally(() => { - if (!cancelled) { - setCalendarLoading(false); - } - }); + void loadWorkbench(() => cancelled); }, 0); return () => { cancelled = true; window.clearTimeout(timer); }; - }, [weekMonthKeys]); + }, [loadWorkbench]); const availableSet = useMemo(() => new Set(availableDates), [availableDates]); const resolvedPatientId = useMemo(() => { @@ -153,7 +104,7 @@ function AdminDashboardInner() { weekStartDateKey={weekStartDateKey} metricsByDate={metricsByDate} availableDates={availableSet} - loading={calendarLoading} + loading={loading} onSelectDate={(dateKey) => { setBrowseWeekStart(null); setSelectedDate(dateKey); @@ -164,8 +115,8 @@ function AdminDashboardInner() { /> { - void loadAttention(); + void loadWorkbench(); }} /> diff --git a/apps/frontend/lib/api/staff.ts b/apps/frontend/lib/api/staff.ts index 600ea42..6a05829 100644 --- a/apps/frontend/lib/api/staff.ts +++ b/apps/frontend/lib/api/staff.ts @@ -153,6 +153,33 @@ export type StaffTodayAttentionResponse = { items: StaffTodayAttentionPatientItem[]; }; +export type StaffWorkbenchWeekDayItem = { + local_date: string; + upload_count: number; + uploaded_users: number; + risky_patient_count: number; + unhandled_patient_count: number; +}; + +export type StaffWorkbenchDashboardResponse = { + local_date: string; + week_start: string; + available_dates: string[]; + week_days: StaffWorkbenchWeekDayItem[]; + attention: StaffTodayAttentionResponse; +}; + +export type StaffUploadImageAccessBatchItem = { + upload_id: number; + image_url: string | null; + expires_in: number | null; + error: "not_found" | "forbidden" | null; +}; + +export type StaffUploadImageAccessBatchResponse = { + items: StaffUploadImageAccessBatchItem[]; +}; + export type StaffRapidReviewQueueItem = StaffUploadQueueItem & { risk_rank: number; }; @@ -603,8 +630,25 @@ export async function fetchTodayAttention(params?: { return data; } -export async function fetchHistoryOverviewDays(): Promise { - const { data } = await apiClient.get("/v1/staff/uploads/history-overview/days"); +export async function fetchWorkbenchDashboard(params: { + localDate: string; + weekStart: string; +}): Promise { + const { data } = await apiClient.get("/v1/staff/dashboard/workbench", { + params: { + local_date: params.localDate, + week_start: params.weekStart, + }, + }); + return data; +} + +export async function fetchHistoryOverviewDays(params?: { + scope?: "all" | "workbench"; +}): Promise { + const { data } = await apiClient.get("/v1/staff/uploads/history-overview/days", { + params: params?.scope ? { scope: params.scope } : undefined, + }); return data; } @@ -734,6 +778,16 @@ export async function fetchUploadImageAccess(uploadId: number): Promise<{ image_ return data; } +export async function fetchUploadImageAccessBatch( + uploadIds: number[] +): Promise { + const { data } = await apiClient.post( + "/v1/staff/uploads/image-access/batch", + { upload_ids: uploadIds } + ); + return data; +} + export async function fetchStaffNotifications(params?: { limit?: number; offset?: number; From ab10e0a8c5ce9c48c765f257f108445c128e85b1 Mon Sep 17 00:00:00 2001 From: ruby0322 Date: Fri, 7 Aug 2026 12:39:58 +0800 Subject: [PATCH 2/4] test(frontend): mock batch image-access for history overview tests useUploadImageUrls now calls fetchUploadImageAccessBatch; update the staff API jest mock and add a shared helper so history-overview page tests render instead of crashing on mount. Co-authored-by: Cursor --- .../history-overview/__tests__/page.test.tsx | 9 +++++++++ .../lib/testing/staff-image-access-mock.ts | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 apps/frontend/lib/testing/staff-image-access-mock.ts diff --git a/apps/frontend/app/admin/history-overview/__tests__/page.test.tsx b/apps/frontend/app/admin/history-overview/__tests__/page.test.tsx index a9f8396..4643b13 100644 --- a/apps/frontend/app/admin/history-overview/__tests__/page.test.tsx +++ b/apps/frontend/app/admin/history-overview/__tests__/page.test.tsx @@ -5,10 +5,12 @@ import { fetchHistoryOverview, fetchHistoryOverviewDays, fetchUploadImageAccess, + fetchUploadImageAccessBatch, StaffHistoryOverviewResponse, StaffHistoryOverviewUploadItem, StaffHistoryOverviewUserGroupItem, } from "@/lib/api/staff"; +import { makeUploadImageAccessBatchResponse } from "@/lib/testing/staff-image-access-mock"; jest.mock("next/image", () => ({ __esModule: true, @@ -39,6 +41,7 @@ jest.mock("@/lib/api/staff", () => ({ fetchHistoryOverviewDays: jest.fn(), fetchHistoryOverview: jest.fn(), fetchUploadImageAccess: jest.fn(), + fetchUploadImageAccessBatch: jest.fn(), upsertUploadAnnotation: jest.fn(), })); @@ -152,6 +155,9 @@ describe("AdminHistoryOverviewPage grouped patient navigation", () => { }); (fetchHistoryOverview as jest.Mock).mockResolvedValue(makeOverviewResponse()); (fetchUploadImageAccess as jest.Mock).mockResolvedValue({ image_url: "/mock-upload.jpg" }); + (fetchUploadImageAccessBatch as jest.Mock).mockImplementation(async (uploadIds: number[]) => + makeUploadImageAccessBatchResponse(uploadIds) + ); }); test("shows clinical period panel on the clinical-period tab", async () => { @@ -343,6 +349,9 @@ describe("AdminHistoryOverviewPage symptom elevated risk", () => { }) ); (fetchUploadImageAccess as jest.Mock).mockResolvedValue({ image_url: "/mock-upload.jpg" }); + (fetchUploadImageAccessBatch as jest.Mock).mockImplementation(async (uploadIds: number[]) => + makeUploadImageAccessBatchResponse(uploadIds) + ); }); test("shows elevated KPI when only symptom elevated risk is present", async () => { diff --git a/apps/frontend/lib/testing/staff-image-access-mock.ts b/apps/frontend/lib/testing/staff-image-access-mock.ts new file mode 100644 index 0000000..92fd49e --- /dev/null +++ b/apps/frontend/lib/testing/staff-image-access-mock.ts @@ -0,0 +1,16 @@ +/** Shared jest helpers for staff image-access API mocks. */ + +export function makeUploadImageAccessBatchResponse(uploadIds: number[]) { + return { + items: uploadIds.map((upload_id) => ({ + upload_id, + image_url: "/mock-upload.jpg", + expires_in: 300, + error: null as null, + })), + }; +} + +export function mockFetchUploadImageAccessBatch() { + return jest.fn(async (uploadIds: number[]) => makeUploadImageAccessBatchResponse(uploadIds)); +} From bbad423a69bc95f3c710910665db75c41c054194 Mon Sep 17 00:00:00 2001 From: ruby0322 Date: Fri, 7 Aug 2026 12:52:49 +0800 Subject: [PATCH 3/4] refactor(admin): type attention serializer and cache workbench week Type today-attention row serialization and avoid refetching stable calendar fields when only the selected date changes within the same week. Co-authored-by: Cursor --- apps/backend/app/api/routes/staff.py | 5 ++- apps/frontend/app/admin/page.tsx | 65 ++++++++++++++++++---------- 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/apps/backend/app/api/routes/staff.py b/apps/backend/app/api/routes/staff.py index 0bbb6d2..14384f4 100644 --- a/apps/backend/app/api/routes/staff.py +++ b/apps/backend/app/api/routes/staff.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Iterator, Sequence from datetime import date from fastapi import APIRouter, Depends, HTTPException, Query, Request @@ -87,6 +87,7 @@ list_today_attention_patients, list_upload_queue, preview_delete_inactive_patients, + TodayAttentionPatientRow, update_pending_binding_status, update_patient_active_status, upsert_annotation_for_upload, @@ -112,7 +113,7 @@ def _serialize_today_attention( *, today: date, total_uploads: int, - rows, + rows: Sequence[TodayAttentionPatientRow], ) -> StaffTodayAttentionResponse: suspected_patients = sum(1 for row in rows if row.tier == "suspected") elevated_patients = sum(1 for row in rows if row.tier == "elevated") diff --git a/apps/frontend/app/admin/page.tsx b/apps/frontend/app/admin/page.tsx index b3aecb4..02b5232 100644 --- a/apps/frontend/app/admin/page.tsx +++ b/apps/frontend/app/admin/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DashboardDayCalendar, @@ -10,11 +10,26 @@ import { TodayPatientPool } from "@/app/admin/_components/today-patient-pool"; import { TodayWorkbenchHeader } from "@/app/admin/_components/today-workbench-header"; import { useAdminSelectedDate } from "@/lib/admin/use-admin-selected-date"; import { + fetchTodayAttention, fetchWorkbenchDashboard, type StaffTodayAttentionResponse, + type StaffWorkbenchWeekDayItem, } from "@/lib/api/staff"; import { getWeekStartDateKey } from "@/lib/utils/upload-calendar"; +function metricsFromWeekDays(weekDays: StaffWorkbenchWeekDayItem[]): Record { + const next: Record = {}; + for (const day of weekDays) { + next[day.local_date] = { + uploadCount: day.upload_count ?? 0, + uploadedUsers: day.uploaded_users ?? 0, + riskyPatients: day.risky_patient_count ?? 0, + unhandledPatients: day.unhandled_patient_count ?? 0, + }; + } + return next; +} + function AdminDashboardInner() { const { selectedDate, setSelectedDate, isTodaySelected, dayScopeLabel } = useAdminSelectedDate(); @@ -28,31 +43,35 @@ function AdminDashboardInner() { const [availableDates, setAvailableDates] = useState([]); const [metricsByDate, setMetricsByDate] = useState>({}); + const cachedWeekStartRef = useRef(null); const loadWorkbench = useCallback( - async (isCancelled?: () => boolean) => { - const cancelled = isCancelled ?? (() => false); + async (options?: { forceFull?: boolean; isCancelled?: () => boolean }) => { + const cancelled = options?.isCancelled ?? (() => false); + const forceFull = options?.forceFull ?? false; + const weekChanged = forceFull || cachedWeekStartRef.current !== weekStartDateKey; + setLoading(true); try { - const data = await fetchWorkbenchDashboard({ - localDate: selectedDate, - weekStart: weekStartDateKey, - }); - if (cancelled()) { - return; - } - setAvailableDates(data.available_dates); - const nextMetrics: Record = {}; - for (const day of data.week_days) { - nextMetrics[day.local_date] = { - uploadCount: day.upload_count ?? 0, - uploadedUsers: day.uploaded_users ?? 0, - riskyPatients: day.risky_patient_count ?? 0, - unhandledPatients: day.unhandled_patient_count ?? 0, - }; + if (weekChanged) { + const data = await fetchWorkbenchDashboard({ + localDate: selectedDate, + weekStart: weekStartDateKey, + }); + if (cancelled()) { + return; + } + setAvailableDates(data.available_dates); + setMetricsByDate(metricsFromWeekDays(data.week_days)); + setAttention(data.attention); + cachedWeekStartRef.current = weekStartDateKey; + } else { + const data = await fetchTodayAttention({ localDate: selectedDate }); + if (cancelled()) { + return; + } + setAttention(data); } - setMetricsByDate(nextMetrics); - setAttention(data.attention); setError(null); } catch { if (cancelled()) { @@ -72,7 +91,7 @@ function AdminDashboardInner() { useEffect(() => { let cancelled = false; const timer = window.setTimeout(() => { - void loadWorkbench(() => cancelled); + void loadWorkbench({ isCancelled: () => cancelled }); }, 0); return () => { cancelled = true; @@ -127,7 +146,7 @@ function AdminDashboardInner() { selectedPatientId={resolvedPatientId} onSelectPatient={setSelectedPatientId} onReviewSaved={() => { - void loadWorkbench(); + void loadWorkbench({ forceFull: true }); }} /> From 427e909bf947b52d8208dc7f0dc6abc7c07de7e6 Mon Sep 17 00:00:00 2001 From: ruby0322 Date: Fri, 7 Aug 2026 13:06:07 +0800 Subject: [PATCH 4/4] perf(backend): SQL distinct Taipei dates for workbench calendar Push workbench available_dates deduplication into the database with dialect-aware local-date expressions instead of loading every upload timestamp into Python. Co-authored-by: Cursor --- apps/backend/app/services/staff_workbench.py | 22 ++++++++---- apps/backend/app/services/taipei_dates.py | 19 +++++++++++ .../backend/tests/test_staff_workbench_api.py | 34 +++++++++++++++++++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/apps/backend/app/services/staff_workbench.py b/apps/backend/app/services/staff_workbench.py index e30b2ca..fbc2f01 100644 --- a/apps/backend/app/services/staff_workbench.py +++ b/apps/backend/app/services/staff_workbench.py @@ -12,7 +12,11 @@ from app.services.attention_triage import workbench_upload_where_clauses from app.services.staff_dashboard import TodayAttentionPatientRow, list_today_attention_patients from app.services.staff_history_overview import HistoryOverviewDaySummary, list_history_overview_days -from app.services.taipei_dates import resolve_taipei_day_bounds_for_date, to_taipei_date +from app.services.taipei_dates import ( + coerce_sql_local_date, + resolve_taipei_day_bounds_for_date, + upload_taipei_local_date_expr, +) @dataclass(frozen=True) @@ -42,17 +46,21 @@ def list_workbench_dates( """Distinct Taipei local dates with at least one workbench-eligible upload.""" if accessible_patient_ids is not None and not accessible_patient_ids: return [] - base_query: Select = ( - select(Upload.created_at) + bind = session.get_bind() + dialect_name = bind.dialect.name if bind is not None else "postgresql" + local_day = upload_taipei_local_date_expr(Upload.created_at, dialect_name=dialect_name) + stmt: Select = ( + select(local_day) + .select_from(Upload) .join(AIResult, AIResult.upload_id == Upload.id) .join(Patient, Patient.id == Upload.patient_id) .where(*workbench_upload_where_clauses()) ) if accessible_patient_ids is not None: - base_query = base_query.where(Patient.id.in_(accessible_patient_ids)) - created_ats = session.execute(base_query).scalars().all() - dates = {to_taipei_date(created_at) for created_at in created_ats} - return sorted(dates, reverse=True) + stmt = stmt.where(Patient.id.in_(accessible_patient_ids)) + stmt = stmt.distinct().order_by(local_day.desc()) + rows = session.execute(stmt).scalars().all() + return [coerce_sql_local_date(row) for row in rows] def aggregate_workbench_week( diff --git a/apps/backend/app/services/taipei_dates.py b/apps/backend/app/services/taipei_dates.py index b53de50..93a9a47 100644 --- a/apps/backend/app/services/taipei_dates.py +++ b/apps/backend/app/services/taipei_dates.py @@ -1,6 +1,9 @@ from __future__ import annotations from datetime import date, datetime, time, timedelta, timezone +from typing import Any + +from sqlalchemy.sql.elements import ColumnElement TAIPEI_TIMEZONE = timezone(timedelta(hours=8)) @@ -15,6 +18,22 @@ def to_taipei_date(raw_dt: datetime) -> date: return normalize_datetime(raw_dt).astimezone(TAIPEI_TIMEZONE).date() +def upload_taipei_local_date_expr(created_at_column: ColumnElement[Any], *, dialect_name: str) -> ColumnElement[Any]: + """SQL expression for Upload.created_at as a Taipei calendar date.""" + from sqlalchemy import func + + if dialect_name == "postgresql": + return func.date(func.timezone("Asia/Taipei", created_at_column)) + # SQLite tests: fixed +8h offset matches to_taipei_date() for UTC-stored timestamps. + return func.date(func.datetime(created_at_column, "+8 hours")) + + +def coerce_sql_local_date(value: date | str) -> date: + if isinstance(value, date): + return value + return date.fromisoformat(str(value)) + + def resolve_taipei_day_bounds_for_date(local_day: date) -> tuple[date, datetime, datetime]: local_start = datetime.combine(local_day, time.min, tzinfo=TAIPEI_TIMEZONE) local_end = local_start + timedelta(days=1) diff --git a/apps/backend/tests/test_staff_workbench_api.py b/apps/backend/tests/test_staff_workbench_api.py index 40a3ed4..ef3001f 100644 --- a/apps/backend/tests/test_staff_workbench_api.py +++ b/apps/backend/tests/test_staff_workbench_api.py @@ -261,6 +261,40 @@ def test_workbench_aligns_week_metrics_with_attention(tmp_path: Path) -> None: assert "2026-05-01" in payload["available_dates"] +def test_workbench_available_dates_sql_distinct_and_utc_boundary(tmp_path: Path) -> None: + settings = make_settings(tmp_path / "workbench-sql-dates.db") + app = create_app(settings=settings, loaded_model=SimpleNamespace(device="cpu")) + with TestClient(app) as client: + staff_identity_id = _seed_staff(client) + patient_id, _ = _seed_patient_uploads( + client, + case_number="P-DATES", + line_user_id="U_DATES", + uploads=[ + # Same Taipei day (2026-07-16): two uploads should dedupe to one available date. + (datetime(2026, 7, 16, 1, 0, tzinfo=timezone.utc), "normal"), + (datetime(2026, 7, 16, 10, 0, tzinfo=timezone.utc), "normal"), + # 17:00 UTC is 2026-07-17 in Taipei. + (datetime(2026, 7, 16, 17, 0, tzinfo=timezone.utc), "normal"), + ], + ) + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=patient_id) + token = _login_staff_token(client) + headers = {"Authorization": f"Bearer {token}"} + + workbench = client.get( + "/v1/staff/dashboard/workbench", + headers=headers, + params={"local_date": "2026-07-16", "week_start": "2026-07-12"}, + ) + assert workbench.status_code == 200 + available_dates = workbench.json()["available_dates"] + assert available_dates.count("2026-07-16") == 1 + assert "2026-07-17" in available_dates + assert "2026-07-15" not in available_dates + assert available_dates.index("2026-07-17") < available_dates.index("2026-07-16") + + def test_image_access_batch_partial_errors(tmp_path: Path) -> None: settings = make_settings(tmp_path / "image-batch.db") app = create_app(settings=settings, loaded_model=SimpleNamespace(device="cpu"))