From a6ceed573ae2f8448b2e84554cae8e2fcc6f8bc1 Mon Sep 17 00:00:00 2001 From: Anthony Wright Date: Tue, 2 Jun 2026 11:52:16 -0500 Subject: [PATCH 1/2] Fix customer appointment lifecycle visibility --- apps/api/app/routers/appointments.py | 23 +- apps/api/app/routers/company_ops.py | 4 +- apps/api/app/services/live_events.py | 2 + apps/api/tests/test_assignment_claiming.py | 237 ++++++++++++++++++ .../customerNotificationsGrouping.test.ts | 8 + .../__tests__/liveAppointmentEvents.test.ts | 100 ++++++++ .../src/__tests__/servicesCache.test.ts | 1 + .../src/hooks/liveAppointmentEventHandler.ts | 95 +++++++ .../src/hooks/useCustomerNotifications.ts | 3 +- .../src/hooks/useLiveAppointmentEvents.ts | 45 +--- apps/mobile/src/hooks/usePushNotifications.ts | 8 +- apps/mobile/src/navigation/RootTabs.tsx | 5 +- apps/mobile/src/navigation/rootTabsOptions.ts | 3 + apps/mobile/src/query/keys.ts | 1 + .../.openspec.yaml | 2 + .../design.md | 87 +++++++ .../proposal.md | 31 +++ .../appointment-lifecycle-visibility/spec.md | 141 +++++++++++ .../tasks.md | 39 +++ 19 files changed, 779 insertions(+), 56 deletions(-) create mode 100644 apps/mobile/src/__tests__/liveAppointmentEvents.test.ts create mode 100644 apps/mobile/src/hooks/liveAppointmentEventHandler.ts create mode 100644 apps/mobile/src/navigation/rootTabsOptions.ts create mode 100644 openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/.openspec.yaml create mode 100644 openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/design.md create mode 100644 openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/proposal.md create mode 100644 openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/specs/appointment-lifecycle-visibility/spec.md create mode 100644 openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/tasks.md diff --git a/apps/api/app/routers/appointments.py b/apps/api/app/routers/appointments.py index 2719b9a..babb4da 100644 --- a/apps/api/app/routers/appointments.py +++ b/apps/api/app/routers/appointments.py @@ -10,6 +10,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, UploadFile, status +from sqlalchemy import func, or_ from sqlalchemy.orm import Session from app.core.config import settings @@ -256,11 +257,17 @@ def list_my_appointments( current_customer=Depends(get_current_customer), db: Session = Depends(get_db) ) -> list[AppointmentListItem]: items: list[AppointmentListItem] = [] + owner_filters = [] + if hasattr(Appointment, "customer_id"): + owner_filters.append(Appointment.customer_id == current_customer.id) + if current_customer.email: + owner_filters.append(func.lower(Appointment.customer_email) == current_customer.email.lower()) + if not owner_filters: + return [] + q = ( db.query(Appointment) - .filter(Appointment.customer_email == current_customer.email) - .filter(Appointment.status != AppointmentStatus.cancelled) - .filter(Appointment.status != AppointmentStatus.completed) + .filter(or_(*owner_filters)) .order_by(Appointment.start_time.desc()) ) for appt in q.all(): @@ -297,10 +304,12 @@ def _ensure_company_access(appointment: Appointment, company_id) -> None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") def _ensure_customer_access(appointment: Appointment, current_customer) -> None: - if not appointment.customer_email: - raise HTTPException(status_code=403, detail="Forbidden") - if appointment.customer_email.lower() != current_customer.email.lower(): - raise HTTPException(status_code=403, detail="Forbidden") + if hasattr(appointment, "customer_id") and appointment.customer_id == current_customer.id: + return + if appointment.customer_email and current_customer.email: + if appointment.customer_email.lower() == current_customer.email.lower(): + return + raise HTTPException(status_code=403, detail="Forbidden") def _provider_display_name(user: User | None) -> str | None: if not user or not user.full_name: diff --git a/apps/api/app/routers/company_ops.py b/apps/api/app/routers/company_ops.py index 5722e52..29fdb63 100644 --- a/apps/api/app/routers/company_ops.py +++ b/apps/api/app/routers/company_ops.py @@ -911,6 +911,7 @@ async def set_ready_with_photo( db, appointment=appt, previous_status=previous_status.value if previous_status else None, + actor_role=current_user.role, ) ready_photo_url = appt.ready_photo_url @@ -931,7 +932,7 @@ def update_status( current=Depends(get_current_company_user), db: Session = Depends(get_db), ): - _, company_id = current + current_user, company_id = current appt = db.get(Appointment, appointment_id) if not appt: raise HTTPException(status_code=404, detail="Not found") @@ -979,6 +980,7 @@ def update_status( db, appointment=appt, previous_status=previous_status.value if previous_status else None, + actor_role=current_user.role, ) return {"id": appt.id, "status": appt.status} diff --git a/apps/api/app/services/live_events.py b/apps/api/app/services/live_events.py index 4047148..4afa720 100644 --- a/apps/api/app/services/live_events.py +++ b/apps/api/app/services/live_events.py @@ -138,6 +138,7 @@ def publish_status_changed( *, appointment: Appointment, previous_status: str | None, + actor_role: str | None = None, ) -> None: user_ids = { str(company_user.user_id) @@ -156,6 +157,7 @@ def publish_status_changed( { "status": appointment.status.value, "previous_status": previous_status, + "actor_role": actor_role, } ) live_event_manager.publish(event, user_ids=user_ids, company_id=str(appointment.company_id)) diff --git a/apps/api/tests/test_assignment_claiming.py b/apps/api/tests/test_assignment_claiming.py index 5b3d70e..7038229 100644 --- a/apps/api/tests/test_assignment_claiming.py +++ b/apps/api/tests/test_assignment_claiming.py @@ -510,3 +510,240 @@ def test_status_update_publishes_live_status_event_to_customer_clients(db_sessio assert payload["appointment_id"] == str(appointment.id) assert payload["previous_status"] == "confirmed" assert payload["status"] == "en_route_pickup" + assert payload["actor_role"] == "provider" + + +def test_customer_appointment_list_includes_appointment_after_provider_claim( + db_session: Session, + client: TestClient, +) -> None: + company = _make_company(db_session, name="Customer Claim Visibility Co") + service = _make_service(db_session, company) + provider = _make_user( + db_session, + email="claim-visible-provider@example.com", + role="provider", + full_name="Claim Visible Provider", + ) + customer = _make_user( + db_session, + email="claim-visible-customer@example.com", + role="customer", + full_name="Claim Visible Customer", + ) + db_session.add(CompanyUser(user_id=provider.id, company_id=company.id)) + appointment = _make_appointment(db_session, company=company, service=service, email=customer.email) + db_session.commit() + + claim_response = client.post( + f"/company/appointments/{appointment.id}/claim", + headers=_auth_header(provider, company_id=company.id), + ) + assert claim_response.status_code == 201, claim_response.text + + mine_response = client.get("/appointments/mine", headers=_auth_header(customer)) + + assert mine_response.status_code == 200, mine_response.text + ids = {item["id"] for item in mine_response.json()} + assert str(appointment.id) in ids + + +def test_customer_appointment_list_includes_appointment_after_provider_status_update( + db_session: Session, + client: TestClient, +) -> None: + company = _make_company(db_session, name="Customer Status Visibility Co") + service = _make_service(db_session, company) + provider = _make_user( + db_session, + email="status-visible-provider@example.com", + role="provider", + full_name="Status Visible Provider", + ) + customer = _make_user( + db_session, + email="status-visible-customer@example.com", + role="customer", + full_name="Status Visible Customer", + ) + db_session.add(CompanyUser(user_id=provider.id, company_id=company.id)) + appointment = _make_appointment(db_session, company=company, service=service, email=customer.email) + db_session.add( + AppointmentAssignment( + appointment_id=appointment.id, + user_id=provider.id, + is_active=True, + ) + ) + db_session.commit() + + status_response = client.post( + f"/company/appointments/{appointment.id}/status", + json={"status": "en_route_pickup"}, + headers=_auth_header(provider, company_id=company.id), + ) + assert status_response.status_code == 200, status_response.text + + mine_response = client.get("/appointments/mine", headers=_auth_header(customer)) + + assert mine_response.status_code == 200, mine_response.text + item = next(item for item in mine_response.json() if item["id"] == str(appointment.id)) + assert item["status"] == "en_route_pickup" + + +def test_customer_appointment_list_includes_completed_appointment_by_default( + db_session: Session, + client: TestClient, +) -> None: + company = _make_company(db_session, name="Customer Completed Visibility Co") + service = _make_service(db_session, company) + customer = _make_user( + db_session, + email="completed-visible-customer@example.com", + role="customer", + full_name="Completed Visible Customer", + ) + appointment = _make_appointment( + db_session, + company=company, + service=service, + email=customer.email, + status=AppointmentStatus.completed, + ) + db_session.commit() + + mine_response = client.get("/appointments/mine", headers=_auth_header(customer)) + + assert mine_response.status_code == 200, mine_response.text + assert str(appointment.id) in {item["id"] for item in mine_response.json()} + + +def test_provider_status_update_creates_customer_status_notification( + db_session: Session, + client: TestClient, +) -> None: + company = _make_company(db_session, name="Status Notification Co") + service = _make_service(db_session, company) + provider = _make_user( + db_session, + email="notify-status-provider@example.com", + role="provider", + full_name="Notify Status Provider", + ) + customer = _make_user( + db_session, + email="notify-status-customer@example.com", + role="customer", + full_name="Notify Status Customer", + ) + db_session.add(CompanyUser(user_id=provider.id, company_id=company.id)) + appointment = _make_appointment(db_session, company=company, service=service, email=customer.email) + db_session.add( + AppointmentAssignment( + appointment_id=appointment.id, + user_id=provider.id, + is_active=True, + ) + ) + db_session.commit() + + response = client.post( + f"/company/appointments/{appointment.id}/status", + json={"status": "en_route_pickup"}, + headers=_auth_header(provider, company_id=company.id), + ) + assert response.status_code == 200, response.text + + notification = ( + db_session.query(Notification) + .filter( + Notification.appointment_id == appointment.id, + Notification.kind == "APPOINTMENT_STATUS_CHANGED", + Notification.channel == "in_app", + Notification.target == str(customer.id), + ) + .one() + ) + assert notification.payload_json["appointment_id"] == str(appointment.id) + assert notification.payload_json["old_status"] == "confirmed" + assert notification.payload_json["new_status"] == "en_route_pickup" + + event = ( + db_session.query(AppointmentEvent) + .filter( + AppointmentEvent.appointment_id == appointment.id, + AppointmentEvent.kind == "status_change", + ) + .one() + ) + assert event.payload["status"] == "en_route_pickup" + + +def test_provider_and_company_admin_views_remain_correct_after_status_update( + db_session: Session, + client: TestClient, +) -> None: + company = _make_company(db_session, name="Provider Admin Visibility Co") + service = _make_service(db_session, company) + provider = _make_user( + db_session, + email="provider-admin-visible@example.com", + role="provider", + full_name="Provider Admin Visible", + ) + other_provider = _make_user( + db_session, + email="other-admin-visible@example.com", + role="provider", + full_name="Other Admin Visible", + ) + admin = _make_user( + db_session, + email="admin-visible@example.com", + role="company_admin", + full_name="Admin Visible", + ) + db_session.add_all( + [ + CompanyUser(user_id=provider.id, company_id=company.id), + CompanyUser(user_id=other_provider.id, company_id=company.id), + CompanyUser(user_id=admin.id, company_id=company.id), + ] + ) + appointment = _make_appointment(db_session, company=company, service=service) + claim_response = client.post( + f"/company/appointments/{appointment.id}/claim", + headers=_auth_header(provider, company_id=company.id), + ) + assert claim_response.status_code == 201, claim_response.text + + status_response = client.post( + f"/company/appointments/{appointment.id}/status", + json={"status": "en_route_pickup"}, + headers=_auth_header(provider, company_id=company.id), + ) + assert status_response.status_code == 200, status_response.text + + open_response = client.get( + "/company/appointments/open", + headers=_auth_header(other_provider, company_id=company.id), + ) + assert open_response.status_code == 200, open_response.text + assert str(appointment.id) not in {item["id"] for item in open_response.json()} + + my_response = client.get( + "/company/appointments/my", + headers=_auth_header(provider, company_id=company.id), + ) + assert my_response.status_code == 200, my_response.text + my_item = next(item for item in my_response.json() if item["id"] == str(appointment.id)) + assert my_item["status"] == "en_route_pickup" + + admin_response = client.get( + "/company/appointments/open", + headers=_auth_header(admin, company_id=company.id), + ) + assert admin_response.status_code == 200, admin_response.text + admin_item = next(item for item in admin_response.json() if item["id"] == str(appointment.id)) + assert admin_item["status"] == "en_route_pickup" + assert admin_item["provider_name"] == "Provider Admin Visible" diff --git a/apps/mobile/src/__tests__/customerNotificationsGrouping.test.ts b/apps/mobile/src/__tests__/customerNotificationsGrouping.test.ts index 32f1f6a..789aac7 100644 --- a/apps/mobile/src/__tests__/customerNotificationsGrouping.test.ts +++ b/apps/mobile/src/__tests__/customerNotificationsGrouping.test.ts @@ -1,3 +1,11 @@ +jest.mock("../api/http", () => ({ + ackAllMyNotifications: jest.fn(), + ackMyNotification: jest.fn(), + fetchMyNotifications: jest.fn(), + getMyNotificationPreferences: jest.fn(), + updateMyNotificationPreferences: jest.fn(), +})); + import { getLatestNotificationForAppointment, groupCustomerNotifications, diff --git a/apps/mobile/src/__tests__/liveAppointmentEvents.test.ts b/apps/mobile/src/__tests__/liveAppointmentEvents.test.ts new file mode 100644 index 0000000..bc4b3a1 --- /dev/null +++ b/apps/mobile/src/__tests__/liveAppointmentEvents.test.ts @@ -0,0 +1,100 @@ +import { QueryClient } from "@tanstack/react-query"; + +import { handleLiveAppointmentEvent } from "../hooks/liveAppointmentEventHandler"; +import { appointmentsTabOptions } from "../navigation/rootTabsOptions"; +import { + appointmentAssignmentQueryKey, + appointmentEventsQueryKey, + appointmentQueryKey, + customerAppointmentsQueryKey, + customerNotificationsQueryKey, +} from "../query/keys"; +import type { Appointment, AppointmentSummary } from "../types/booking"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); +} + +function makeSummary(overrides: Partial): AppointmentSummary { + return { + id: overrides.id ?? "appt-1", + company_id: overrides.company_id ?? "company-1", + service_name: overrides.service_name ?? "Cleaning", + customer_name: overrides.customer_name ?? "Customer", + customer_phone: overrides.customer_phone ?? "555-0100", + start_time: overrides.start_time ?? "2026-06-02T12:00:00Z", + status: overrides.status ?? "confirmed", + payment_status: overrides.payment_status ?? "succeeded", + }; +} + +function makeAppointment(overrides: Partial): Appointment { + return { + ...makeSummary(overrides), + service_id: overrides.service_id ?? "service-1", + end_time: overrides.end_time ?? "2026-06-02T13:00:00Z", + status: overrides.status ?? "confirmed", + created_at: overrides.created_at ?? "2026-06-02T11:00:00Z", + }; +} + +describe("live appointment events", () => { + it("patches customer appointment list and detail status without dropping the list item", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(customerAppointmentsQueryKey, [ + makeSummary({ id: "appt-1", status: "confirmed" }), + makeSummary({ id: "appt-2", status: "ready" }), + ]); + queryClient.setQueryData(appointmentQueryKey("appt-1"), makeAppointment({ id: "appt-1", status: "confirmed" })); + + handleLiveAppointmentEvent(queryClient, "customer", { + type: "appointment_status_changed", + appointment_id: "appt-1", + event_kind: "status_change", + status: "en_route_pickup", + previous_status: "confirmed", + actor_role: "provider", + }); + + const list = queryClient.getQueryData(customerAppointmentsQueryKey); + const detail = queryClient.getQueryData(appointmentQueryKey("appt-1")); + + expect(list).toHaveLength(2); + expect(list?.find((appointment) => appointment.id === "appt-1")?.status).toBe("en_route_pickup"); + expect(list?.find((appointment) => appointment.id === "appt-2")?.status).toBe("ready"); + expect(detail?.status).toBe("en_route_pickup"); + queryClient.clear(); + }); + + it("invalidates customer appointment, detail, assignment, events, and notification queries", () => { + const queryClient = createQueryClient(); + const invalidateSpy = jest.spyOn(queryClient, "invalidateQueries"); + + handleLiveAppointmentEvent(queryClient, "customer", { + type: "appointment_status_changed", + appointment_id: "appt-1", + event_kind: "status_change", + status: "out_for_delivery", + previous_status: "ready", + actor_role: "provider", + }); + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customerAppointmentsQueryKey }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customerNotificationsQueryKey }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: appointmentQueryKey("appt-1") }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: appointmentAssignmentQueryKey("appt-1") }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: appointmentEventsQueryKey("appt-1") }); + queryClient.clear(); + }); + + it("keeps notification count off the Appointments tab options", () => { + expect(appointmentsTabOptions.title).toBe("Appointments"); + expect("tabBarBadge" in appointmentsTabOptions).toBe(false); + }); +}); diff --git a/apps/mobile/src/__tests__/servicesCache.test.ts b/apps/mobile/src/__tests__/servicesCache.test.ts index 154400e..79518b4 100644 --- a/apps/mobile/src/__tests__/servicesCache.test.ts +++ b/apps/mobile/src/__tests__/servicesCache.test.ts @@ -40,5 +40,6 @@ describe('services cache hydration', () => { const hydrated = queryClient.getQueryData(servicesQueryKey); expect(hydrated).toEqual(sampleServices); + queryClient.clear(); }); }); diff --git a/apps/mobile/src/hooks/liveAppointmentEventHandler.ts b/apps/mobile/src/hooks/liveAppointmentEventHandler.ts new file mode 100644 index 0000000..972f034 --- /dev/null +++ b/apps/mobile/src/hooks/liveAppointmentEventHandler.ts @@ -0,0 +1,95 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { + appointmentAssignmentQueryKey, + appointmentEventsQueryKey, + appointmentQueryKey, + customerAppointmentsQueryKey, + customerNotificationsQueryKey, +} from "../query/keys"; +import type { Appointment, AppointmentSummary, AppointmentStatus } from "../types/booking"; + +export type LiveEvent = + | { + type: "assignment_changed"; + appointment_id: string; + event_kind: string; + company_id?: string | null; + assignment_action?: string; + } + | { + type: "appointment_status_changed"; + appointment_id: string; + event_kind: string; + company_id?: string | null; + status?: string; + previous_status?: string | null; + actor_role?: string | null; + }; + +const APPOINTMENT_STATUSES = new Set([ + "requested", + "pending_payment", + "payment_failed", + "confirmed", + "en_route_pickup", + "picked_up", + "cleaning", + "ready", + "out_for_delivery", + "delivered", + "completed", + "cancelled", +]); + +function isAppointmentStatus(value: string | undefined): value is AppointmentStatus { + return Boolean(value && APPOINTMENT_STATUSES.has(value)); +} + +function patchCustomerAppointmentStatus( + queryClient: QueryClient, + appointmentId: string, + status: AppointmentStatus | undefined, +) { + if (!status) { + return; + } + + queryClient.setQueryData( + customerAppointmentsQueryKey, + (current) => + current?.map((appointment) => + appointment.id === appointmentId ? { ...appointment, status } : appointment, + ), + ); + queryClient.setQueryData( + appointmentQueryKey(appointmentId), + (current) => (current ? { ...current, status } : current), + ); +} + +export function handleLiveAppointmentEvent( + queryClient: QueryClient, + role: string | null | undefined, + event: LiveEvent, +) { + const appointmentId = event.appointment_id; + if (!appointmentId) { + return; + } + + if (role === "customer") { + if (event.type === "appointment_status_changed" && isAppointmentStatus(event.status)) { + patchCustomerAppointmentStatus(queryClient, appointmentId, event.status); + } + void queryClient.invalidateQueries({ queryKey: customerAppointmentsQueryKey }); + void queryClient.invalidateQueries({ queryKey: customerNotificationsQueryKey }); + } else { + void queryClient.invalidateQueries({ queryKey: ["provider", "open"] }); + void queryClient.invalidateQueries({ queryKey: ["provider", "my"] }); + } + + void queryClient.invalidateQueries({ queryKey: appointmentQueryKey(appointmentId) }); + void queryClient.invalidateQueries({ queryKey: appointmentAssignmentQueryKey(appointmentId) }); + void queryClient.invalidateQueries({ queryKey: appointmentEventsQueryKey(appointmentId) }); +} diff --git a/apps/mobile/src/hooks/useCustomerNotifications.ts b/apps/mobile/src/hooks/useCustomerNotifications.ts index fae5c8a..ac3fd1f 100644 --- a/apps/mobile/src/hooks/useCustomerNotifications.ts +++ b/apps/mobile/src/hooks/useCustomerNotifications.ts @@ -11,8 +11,9 @@ import { } from "../api/http"; import type { Notification } from "../types/notification"; import type { NotificationPreferences } from "../types/user"; +import { customerNotificationsQueryKey } from "../query/keys"; -export const customerNotificationsQueryKey = ["me", "notifications"] as const; +export { customerNotificationsQueryKey }; export const customerNotificationPreferencesQueryKey = ["me", "notification-preferences"] as const; const statusLabels: Record = { diff --git a/apps/mobile/src/hooks/useLiveAppointmentEvents.ts b/apps/mobile/src/hooks/useLiveAppointmentEvents.ts index ccf7e46..31901b9 100644 --- a/apps/mobile/src/hooks/useLiveAppointmentEvents.ts +++ b/apps/mobile/src/hooks/useLiveAppointmentEvents.ts @@ -2,31 +2,9 @@ import { useEffect, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { getLiveEventsWebSocketUrl } from "../api/http"; -import { - appointmentAssignmentQueryKey, - appointmentEventsQueryKey, - appointmentQueryKey, - customerAppointmentsQueryKey, -} from "../query/keys"; +import { handleLiveAppointmentEvent, type LiveEvent } from "./liveAppointmentEventHandler"; import { useAuthStore } from "../state/authStore"; -type LiveEvent = - | { - type: "assignment_changed"; - appointment_id: string; - event_kind: string; - company_id?: string | null; - assignment_action?: string; - } - | { - type: "appointment_status_changed"; - appointment_id: string; - event_kind: string; - company_id?: string | null; - status?: string; - previous_status?: string | null; - }; - const LIVE_ENABLED_ROLES = new Set(["customer", "company", "provider", "company_admin"]); const RECONNECT_DELAY_MS = 2000; const HEARTBEAT_INTERVAL_MS = 25000; @@ -64,25 +42,6 @@ export function useLiveAppointmentEvents() { } }; - const invalidateForEvent = (event: LiveEvent) => { - const appointmentId = event.appointment_id; - if (!appointmentId) { - return; - } - - if (role === "customer") { - void queryClient.invalidateQueries({ queryKey: customerAppointmentsQueryKey }); - void queryClient.invalidateQueries({ queryKey: ["me", "notifications"] }); - } else { - void queryClient.invalidateQueries({ queryKey: ["provider", "open"] }); - void queryClient.invalidateQueries({ queryKey: ["provider", "my"] }); - } - - void queryClient.invalidateQueries({ queryKey: appointmentQueryKey(appointmentId) }); - void queryClient.invalidateQueries({ queryKey: appointmentAssignmentQueryKey(appointmentId) }); - void queryClient.invalidateQueries({ queryKey: appointmentEventsQueryKey(appointmentId) }); - }; - const connect = () => { if (!isEnabled || socketRef.current) { return; @@ -107,7 +66,7 @@ export function useLiveAppointmentEvents() { socket.onmessage = (message) => { try { const event = JSON.parse(message.data) as LiveEvent; - invalidateForEvent(event); + handleLiveAppointmentEvent(queryClient, role, event); } catch (error) { console.warn("[LiveEvents] Failed to parse event", error); } diff --git a/apps/mobile/src/hooks/usePushNotifications.ts b/apps/mobile/src/hooks/usePushNotifications.ts index 092c087..1ac13ae 100644 --- a/apps/mobile/src/hooks/usePushNotifications.ts +++ b/apps/mobile/src/hooks/usePushNotifications.ts @@ -67,7 +67,13 @@ export function usePushNotifications() { } Notifications.setNotificationHandler({ - handleNotification: async () => ({ shouldShowAlert: true, shouldPlaySound: false, shouldSetBadge: false }), + handleNotification: async () => ({ + shouldShowAlert: true, + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: false, + shouldSetBadge: false, + }), }); const responseListener = Notifications.addNotificationResponseReceivedListener((response) => { diff --git a/apps/mobile/src/navigation/RootTabs.tsx b/apps/mobile/src/navigation/RootTabs.tsx index 0568baf..7759330 100644 --- a/apps/mobile/src/navigation/RootTabs.tsx +++ b/apps/mobile/src/navigation/RootTabs.tsx @@ -29,6 +29,7 @@ import type { } from "./types"; import { useAuthStore } from "../state/authStore"; import { useTheme } from "../theme/theme"; +import { appointmentsTabOptions } from "./rootTabsOptions"; const HomeStack = createNativeStackNavigator(); const AppointmentStack = createNativeStackNavigator(); @@ -136,9 +137,7 @@ export default function RootTabs() { ({ tabPress: (event) => { event.preventDefault(); diff --git a/apps/mobile/src/navigation/rootTabsOptions.ts b/apps/mobile/src/navigation/rootTabsOptions.ts new file mode 100644 index 0000000..d8948c8 --- /dev/null +++ b/apps/mobile/src/navigation/rootTabsOptions.ts @@ -0,0 +1,3 @@ +export const appointmentsTabOptions = { + title: "Appointments", +} as const; diff --git a/apps/mobile/src/query/keys.ts b/apps/mobile/src/query/keys.ts index 879ad62..cfb2eb1 100644 --- a/apps/mobile/src/query/keys.ts +++ b/apps/mobile/src/query/keys.ts @@ -1,5 +1,6 @@ export const servicesQueryKey = ["services"] as const; export const customerAppointmentsQueryKey = ["appointments", "mine"] as const; +export const customerNotificationsQueryKey = ["me", "notifications"] as const; export const appointmentQueryKey = (appointmentId: string) => ["appointment", appointmentId] as const; export const appointmentEventsQueryKey = (appointmentId: string) => ["appointment", appointmentId, "events"] as const; diff --git a/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/.openspec.yaml b/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/.openspec.yaml new file mode 100644 index 0000000..db47328 --- /dev/null +++ b/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-02 diff --git a/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/design.md b/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/design.md new file mode 100644 index 0000000..c91fa18 --- /dev/null +++ b/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/design.md @@ -0,0 +1,87 @@ +## Context + +Customer appointment visibility is split across backend customer reads in `apps/api/app/routers/appointments.py`, provider/company commands in `apps/api/app/routers/company_ops.py`, notification creation in `apps/api/app/services/notifications.py`, live websocket fanout in `apps/api/app/services/live_events.py`, and active mobile appointment screens under `apps/mobile/src/screens/appointments` and `apps/mobile/src/screens/customer`. + +The current customer list route uses customer email matching and excludes `cancelled` and `completed`. Provider claim and reassignment flows already preserve assignment history and notify customers. Provider status update flows already record appointment events, enqueue customer status notifications, and publish live status events, but the event contract needs to include actor role and the mobile customer list/detail state must stay coherent when those events arrive. + +## Goals / Non-Goals + +**Goals:** + +- Return customer-owned appointments throughout the active lifecycle after provider claim and status changes. +- Keep provider assignment fields independent from customer ownership and customer read authorization. +- Ensure provider status updates create a customer-visible status notification and websocket event. +- Ensure customer live events invalidate and/or immediately update appointment list, appointment detail, assignment, timeline, and notification queries. +- Keep notification counts on notification surfaces and keep the Appointments tab badge free of notification count. +- Preserve provider open/my job lists and company admin appointment visibility. + +**Non-Goals:** + +- Redesigning the appointment lifecycle state machine. +- Adding new appointment tables or a new notification storage model. +- Changing provider claim eligibility or company admin reassignment policy except where regressions are found. +- Reworking inactive legacy mobile stacks that are outside the active `RootTabs` flow. +- Hiding historical appointments unless a caller explicitly requests a historical/archive filter. + +## Decisions + +- Anchor customer appointment reads to customer ownership, not assignment state. + + The customer list and detail authorization should use `appointment.customer_id == current_user.id` when the appointment has a customer user id. A compatible fallback may keep email matching for older rows that do not yet have `customer_id`, but provider assignment, active assignment existence, and provider status must not be part of the customer ownership predicate. This directly prevents a claimed appointment from disappearing because ownership and fulfillment are separate concepts. + + Alternative considered: broaden the current email predicate only. That is less robust because staging and seeded data can drift on email casing or missing email fields, and it does not express the actual ownership contract. + +- Treat active lifecycle statuses as customer-visible by default. + + Customer list filtering should not exclude `requested`, `pending_payment`, `payment_failed`, `confirmed`, `en_route_pickup`, `picked_up`, `cleaning`, `ready`, `out_for_delivery`, `delivered`, or `completed` unless the request explicitly asks for historical filtering. The only unconditional exclusions should be records that are deleted/archived by an explicit data model, and cancellation should remain visible unless the product intentionally asks for cancelled appointments to be hidden. + + Alternative considered: keep excluding `completed` to make the default list "active only". That conflicts with the requested full lifecycle visibility and makes status changes look like data loss. + +- Keep provider claim as an assignment-only side effect. + + Provider claim, reassignment, and active assignment writes in `company_ops.py` should create or update `AppointmentAssignment` rows, assignment events, assignment notifications, and assignment live events without clearing or changing appointment customer ownership. The implementation should add regression tests around claim and status transitions rather than introduce a new claim path. + + Alternative considered: duplicate customer notification or list repair logic in the claim endpoint. That would hide the root issue and risk inconsistent behavior across claim, reassignment, and future assignment commands. + +- Centralize provider status update side effects in the existing status command path. + + When a provider changes status, the command should capture `previous_status`, update `Appointment.status`, append an appointment status event, enqueue an `APPOINTMENT_STATUS_CHANGED` customer notification, and call live event publication after the database state is flushed. Notification payloads should include both `old_status` and `new_status`; live payloads should include `appointment_id`, `status`, `previous_status`, and `actor_role`. + + Alternative considered: rely only on websocket invalidation with no notification row. That would leave the notification center and unread counts stale when the customer is offline or misses the socket event. + +- Extend the live status event contract additively. + + `publish_status_changed` should accept actor context or an actor role string and add `actor_role` to the payload. Existing fields and event type names should remain stable so current clients continue to parse `appointment_status_changed` events. Customer delivery should continue targeting the resolved customer user id plus company users where appropriate. + + Alternative considered: create a new event type for provider status updates. That would split the mobile invalidation logic without adding useful semantics for this fix. + +- Use React Query cache updates plus invalidation for customer live events. + + `useLiveAppointmentEvents` should keep invalidating `customerAppointmentsQueryKey`, `appointmentQueryKey(appointmentId)`, `appointmentAssignmentQueryKey(appointmentId)`, `appointmentEventsQueryKey(appointmentId)`, and the customer notifications query for customer status and assignment events. For `appointment_status_changed`, it should also opportunistically update cached customer list/detail items for the matching `appointment_id` with the received `status` so visible screens change immediately while refetch confirms the full server state. + + Alternative considered: refetch only. Refetch is correct but can look stale until the network returns, which misses the expected immediate visible update. + +- Keep notification count scoped to notification UI. + + The active `RootTabs` Appointments tab should not use notification count as a tab badge. Notification count may remain on the bell/header notification entry and notification center surfaces. This preserves prior tab-badge correction while still making customer status notifications visible. + + Alternative considered: show unread notification count on both the tab and bell. That repeats notification state on an appointment navigation target and can be mistaken for appointment count. + +## Risks / Trade-offs + +- [Risk] Older appointments may not have `customer_id`. -> Mitigation: support a limited email fallback while preferring `customer_id`, and add tests for the owned-row path. +- [Risk] Returning completed and cancelled appointments may increase list length. -> Mitigation: order consistently by appointment time and leave explicit historical filtering as a future product option if needed. +- [Risk] Immediate cache patching can diverge from server data if payloads are incomplete. -> Mitigation: patch only the status field for the matching appointment id, then invalidate/refetch the canonical queries. +- [Risk] Customer and provider/company live event audiences overlap. -> Mitigation: keep role-specific mobile invalidation branches and test provider/company lists after status updates. +- [Risk] Status notification fanout could duplicate existing payment-driven status notifications. -> Mitigation: only assert provider status update commands create the provider-originated status notification and avoid adding duplicate notifications in read/list code. + +## Migration Plan + +No database migration is expected unless implementation discovers missing `customer_id` data on appointment rows. Deploy backend and mobile changes together when possible because the backend event payload gains fields and the mobile event handler consumes them additively. + +Rollback is straightforward: mobile cache patching can be removed without server changes, and the backend list filter can be reverted independently. If reverting backend status side effects, keep existing notification/live event contracts intact for other active changes. + +## Open Questions + +- Should cancelled appointments remain visible in the default customer Appointments list, or should they require an explicit historical filter? The stated lifecycle list names do not include cancelled, but the ownership rule says only archived/deleted should be excluded. +- Does staging data consistently populate `appointment.customer_id`, or should this change include a one-time data repair for customer-owned appointments created before that field was populated? diff --git a/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/proposal.md b/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/proposal.md new file mode 100644 index 0000000..a9f8a59 --- /dev/null +++ b/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/proposal.md @@ -0,0 +1,31 @@ +## Why + +Customers can currently lose visibility into an appointment after a provider claims it and moves it through active statuses such as `en_route`. This breaks the customer booking lifecycle in staging because the Appointments tab no longer reflects customer-owned work and the customer does not receive live status or notification updates. + +## What Changes + +- Keep customer appointment list endpoints anchored to `appointment.customer_id == current_user.id` across the active lifecycle unless an appointment is explicitly archived, deleted, or intentionally requested through a historical filter. +- Preserve customer ownership when a provider claims an appointment or assignment/status fields change. +- Emit customer-targeted notification and live appointment status events when a provider updates appointment status. +- Include `appointment_id`, `status`, `previous_status` when available, and actor role in appointment status live event payloads. +- Update mobile appointment state handling so customer appointment lists/details refetch or update immediately after live status events. +- Keep notification counts scoped to notification surfaces and prevent the Appointments tab badge from using notification count. +- Preserve existing provider and company admin appointment views. + +## Capabilities + +### New Capabilities + +- `appointment-lifecycle-visibility`: Defines customer appointment visibility across claimed and active lifecycle states, status update event semantics, customer notifications, and mobile appointment list/detail update behavior. + +### Modified Capabilities + +- None. + +## Impact + +- Backend appointment list endpoints and customer role filters. +- Backend provider claim and appointment status update endpoints. +- Backend notification, outbox, and live event publishing for appointment status changes. +- Mobile appointment list/detail fetching, local filtering, cache invalidation, WebSocket status event handling, notification count handling, and tab badge behavior. +- Regression coverage for customer, provider, and company admin appointment views. diff --git a/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/specs/appointment-lifecycle-visibility/spec.md b/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/specs/appointment-lifecycle-visibility/spec.md new file mode 100644 index 0000000..79db593 --- /dev/null +++ b/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/specs/appointment-lifecycle-visibility/spec.md @@ -0,0 +1,141 @@ +# Appointment Lifecycle Visibility + +## ADDED Requirements + +### Requirement: Customer appointment reads preserve ownership across active lifecycle changes + +The system SHALL return customer-owned appointments from customer appointment read flows based on appointment ownership, not provider assignment or fulfillment status. + +#### Scenario: Customer list includes appointment after provider claim + +- **GIVEN** a customer owns an appointment +- **AND** a provider claims the appointment +- **WHEN** the customer requests their appointment list +- **THEN** the appointment is returned in the list +- **AND** provider assignment state does not remove the appointment from the customer response + +#### Scenario: Customer list includes appointment after provider status update + +- **GIVEN** a customer owns an appointment +- **AND** a provider changes the appointment status to an active lifecycle status such as `en_route_pickup`, `picked_up`, `cleaning`, `ready`, `out_for_delivery`, `delivered`, or `completed` +- **WHEN** the customer requests their appointment list +- **THEN** the appointment is returned in the list with the latest status + +#### Scenario: Customer ownership authorizes detail access + +- **GIVEN** a customer owns an appointment +- **WHEN** the customer requests appointment detail for that appointment +- **THEN** access is allowed regardless of active provider assignment +- **AND** access is not granted to other customers who do not own the appointment + +### Requirement: Provider claim preserves customer ownership + +The system SHALL keep customer ownership fields intact when provider claim or reassignment commands update assignment state. + +#### Scenario: Claim does not change customer ownership + +- **GIVEN** a customer owns a confirmed unassigned appointment +- **WHEN** an eligible provider claims the appointment +- **THEN** an active assignment is created for the provider +- **AND** the appointment remains owned by the original customer +- **AND** the customer can still read the appointment through customer list and detail flows + +#### Scenario: Assignment changes do not affect customer visibility + +- **GIVEN** a customer owns an appointment with an active provider assignment +- **WHEN** the appointment is reassigned to another provider +- **THEN** the appointment remains owned by the original customer +- **AND** the customer can still read the appointment through customer list and detail flows + +### Requirement: Provider status updates notify the customer and publish live status events + +The system SHALL create customer-facing notification and live event side effects when a provider updates appointment status. + +#### Scenario: Provider status update creates customer notification + +- **GIVEN** a provider is authorized to update an appointment status +- **WHEN** the provider changes the appointment status +- **THEN** the system creates a customer-facing `APPOINTMENT_STATUS_CHANGED` notification +- **AND** the notification payload includes the appointment id, previous status when known, and new status +- **AND** the notification is available through the customer notification surface + +#### Scenario: Provider status update publishes live status event + +- **GIVEN** a provider is authorized to update an appointment status +- **WHEN** the provider changes the appointment status +- **THEN** the system publishes an `appointment_status_changed` live event to the customer +- **AND** the event payload includes `appointment_id`, `status`, `previous_status` when known, and `actor_role` + +#### Scenario: Status history remains queryable + +- **GIVEN** a provider changes an appointment status +- **WHEN** appointment events are requested for that appointment +- **THEN** the status change is represented in the appointment event history +- **AND** the event history reflects the updated status + +### Requirement: Customer mobile state refreshes on live appointment status events + +The mobile app SHALL update customer appointment list, detail, timeline, assignment, and notification state when it receives a live appointment event for a customer appointment. + +#### Scenario: Live status event updates visible customer list item + +- **GIVEN** a customer is viewing the Appointments list +- **AND** the list contains an appointment +- **WHEN** the app receives an `appointment_status_changed` event for that appointment +- **THEN** the visible list item updates to the event status promptly +- **AND** the customer appointment list query is invalidated or refetched + +#### Scenario: Live status event refreshes customer appointment detail + +- **GIVEN** a customer is viewing appointment detail +- **WHEN** the app receives an `appointment_status_changed` event for that appointment +- **THEN** the appointment detail query is invalidated or refetched +- **AND** the appointment events query is invalidated or refetched +- **AND** the latest status can be displayed without a manual refresh + +#### Scenario: Live assignment event refreshes customer appointment state + +- **GIVEN** a customer is logged in +- **WHEN** the app receives an assignment live event for the customer's appointment +- **THEN** the customer appointment list query is invalidated or refetched +- **AND** the appointment assignment query for that appointment is invalidated or refetched + +### Requirement: Customer notification updates stay on notification surfaces + +The mobile app SHALL surface customer status-update notifications through notification UI and SHALL NOT use notification unread count as the Appointments tab badge. + +#### Scenario: Status update refreshes notification count on notification surface + +- **GIVEN** a customer is logged in +- **WHEN** the app receives a live status event for the customer's appointment +- **THEN** the customer notification query is invalidated or refetched +- **AND** notification unread count can update on notification entry points such as the bell or notification center + +#### Scenario: Appointments tab badge does not use notification count + +- **GIVEN** a customer has unread notifications +- **WHEN** the customer views the bottom tab navigation +- **THEN** the Appointments tab does not display the unread notification count as a tab badge +- **AND** the unread count remains available only on notification surfaces + +### Requirement: Provider and company admin appointment views remain correct + +The system SHALL preserve existing provider and company admin appointment list semantics while fixing customer appointment visibility. + +#### Scenario: Provider open list excludes claimed appointments + +- **GIVEN** an appointment has been claimed by a provider +- **WHEN** another provider requests the open appointments list +- **THEN** the claimed appointment is not returned as an unassigned open appointment + +#### Scenario: Provider my list includes claimed appointment after status update + +- **GIVEN** a provider has claimed an appointment +- **WHEN** the provider updates the appointment status +- **THEN** the appointment remains available in the provider's claimed or my appointments flow with the latest status + +#### Scenario: Company admin can still see company appointments + +- **GIVEN** a company admin belongs to the appointment's company +- **WHEN** provider assignment or status changes occur +- **THEN** the company admin appointment view still includes the appointment with current assignment and status information diff --git a/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/tasks.md b/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/tasks.md new file mode 100644 index 0000000..7a7add6 --- /dev/null +++ b/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/tasks.md @@ -0,0 +1,39 @@ +## 1. Backend Investigation And Tests + +- [x] 1.1 Inspect customer appointment list/detail filters in `apps/api/app/routers/appointments.py` and confirm whether current ownership checks use `customer_id`, email fallback, or status exclusions. +- [x] 1.2 Inspect provider claim, reassignment, and status update flows in `apps/api/app/routers/company_ops.py` for customer ownership preservation and status side effects. +- [x] 1.3 Add backend regression coverage proving customer appointment list includes a customer-owned appointment after provider claim. +- [x] 1.4 Add backend regression coverage proving customer appointment list includes a customer-owned appointment after provider status update to an active lifecycle status. +- [x] 1.5 Add backend regression coverage proving provider status update creates a customer status notification and publishes or records the expected live status event payload fields. +- [x] 1.6 Add backend regression coverage proving provider open/my lists and company admin appointment view remain correct after claim and status update. + +## 2. Backend Implementation + +- [x] 2.1 Update customer appointment list filtering to prefer `Appointment.customer_id == current_user.id`, keep only necessary legacy email fallback, and avoid excluding claimed or active lifecycle statuses by default. +- [x] 2.2 Update customer appointment detail/read authorization to use the same customer ownership semantics and preserve access after assignment/status changes. +- [x] 2.3 Verify provider claim and reassignment commands preserve appointment customer ownership and do not modify ownership fields. +- [x] 2.4 Update provider status update side effects to consistently capture `previous_status`, append appointment status history, enqueue `APPOINTMENT_STATUS_CHANGED` for the customer, and publish a live status event. +- [x] 2.5 Extend live status event publication to include `actor_role` while preserving existing `appointment_status_changed`, `appointment_id`, `status`, and `previous_status` fields. +- [x] 2.6 Run targeted backend tests for appointment listing, assignment claiming, status updates, customer notifications, and provider/company admin views. + +## 3. Mobile Investigation And Tests + +- [x] 3.1 Inspect active mobile appointment list/detail query usage, local filtering, focus refresh, websocket handling, notification query invalidation, and Appointments tab badge behavior. +- [x] 3.2 Add mobile test coverage proving a customer appointment list item is not dropped when an appointment status live event is processed. +- [x] 3.3 Add mobile test coverage proving a websocket status update invalidates or refetches customer appointment list/detail and notification queries. +- [x] 3.4 Add mobile test coverage proving notification unread count remains on notification surfaces and is not used as the Appointments tab badge. + +## 4. Mobile Implementation + +- [x] 4.1 Update `useLiveAppointmentEvents` to handle status events by invalidating customer appointment list, appointment detail, appointment events, assignment, and customer notification queries. +- [x] 4.2 Update `useLiveAppointmentEvents` to optimistically patch cached customer appointment list/detail status for matching `appointment_id` when `appointment_status_changed` includes a status. +- [x] 4.3 Ensure active customer appointment list/detail screens do not locally filter out claimed or active lifecycle appointments returned by the backend. +- [x] 4.4 Confirm the active `RootTabs` Appointments tab has no notification-count badge while notification buttons/surfaces still show unread notification count. +- [x] 4.5 Run mobile typecheck and targeted tests for appointment list/detail live event handling and notification badge behavior. + +## 5. Validation + +- [x] 5.1 Run backend test suite or targeted backend tests covering this change. +- [x] 5.2 Run mobile typecheck/tests covering this change. +- [x] 5.3 Run `openspec validate fix-customer-appointment-lifecycle-visibility-and-status-updates --strict`. +- [x] 5.4 Review changed files for unintended edits to legacy mobile stacks or unrelated provider/company admin behavior. From f677ea36c8ea6b33e47092a128c7b05ab61bb09d Mon Sep 17 00:00:00 2001 From: Anthony Wright Date: Tue, 2 Jun 2026 15:27:02 -0500 Subject: [PATCH 2/2] Hide cancelled appointments from default customer list --- apps/api/app/routers/appointments.py | 1 + .../design.md | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/api/app/routers/appointments.py b/apps/api/app/routers/appointments.py index babb4da..5b0b839 100644 --- a/apps/api/app/routers/appointments.py +++ b/apps/api/app/routers/appointments.py @@ -268,6 +268,7 @@ def list_my_appointments( q = ( db.query(Appointment) .filter(or_(*owner_filters)) + .filter(Appointment.status != AppointmentStatus.cancelled) .order_by(Appointment.start_time.desc()) ) for appt in q.all(): diff --git a/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/design.md b/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/design.md index c91fa18..c6c63f6 100644 --- a/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/design.md +++ b/openspec/changes/fix-customer-appointment-lifecycle-visibility-and-status-updates/design.md @@ -33,7 +33,7 @@ The current customer list route uses customer email matching and excludes `cance - Treat active lifecycle statuses as customer-visible by default. - Customer list filtering should not exclude `requested`, `pending_payment`, `payment_failed`, `confirmed`, `en_route_pickup`, `picked_up`, `cleaning`, `ready`, `out_for_delivery`, `delivered`, or `completed` unless the request explicitly asks for historical filtering. The only unconditional exclusions should be records that are deleted/archived by an explicit data model, and cancellation should remain visible unless the product intentionally asks for cancelled appointments to be hidden. + Customer list filtering should not exclude `requested`, `pending_payment`, `payment_failed`, `confirmed`, `en_route_pickup`, `picked_up`, `cleaning`, `ready`, `out_for_delivery`, `delivered`, or `completed` unless the request explicitly asks for historical filtering. Cancelled appointments remain excluded from the default list to preserve the existing unpaid-cancel behavior, and deleted/archived records should remain excluded when such a data model exists. Alternative considered: keep excluding `completed` to make the default list "active only". That conflicts with the requested full lifecycle visibility and makes status changes look like data loss. @@ -70,7 +70,7 @@ The current customer list route uses customer email matching and excludes `cance ## Risks / Trade-offs - [Risk] Older appointments may not have `customer_id`. -> Mitigation: support a limited email fallback while preferring `customer_id`, and add tests for the owned-row path. -- [Risk] Returning completed and cancelled appointments may increase list length. -> Mitigation: order consistently by appointment time and leave explicit historical filtering as a future product option if needed. +- [Risk] Returning completed appointments may increase list length. -> Mitigation: order consistently by appointment time and leave explicit historical filtering as a future product option if needed. - [Risk] Immediate cache patching can diverge from server data if payloads are incomplete. -> Mitigation: patch only the status field for the matching appointment id, then invalidate/refetch the canonical queries. - [Risk] Customer and provider/company live event audiences overlap. -> Mitigation: keep role-specific mobile invalidation branches and test provider/company lists after status updates. - [Risk] Status notification fanout could duplicate existing payment-driven status notifications. -> Mitigation: only assert provider status update commands create the provider-originated status notification and avoid adding duplicate notifications in read/list code. @@ -83,5 +83,5 @@ Rollback is straightforward: mobile cache patching can be removed without server ## Open Questions -- Should cancelled appointments remain visible in the default customer Appointments list, or should they require an explicit historical filter? The stated lifecycle list names do not include cancelled, but the ownership rule says only archived/deleted should be excluded. +- Cancelled appointments remain hidden in the default customer Appointments list to preserve existing unpaid-cancel behavior; future historical/archive filtering can expose them intentionally if the product needs that view. - Does staging data consistently populate `appointment.customer_id`, or should this change include a one-time data repair for customer-owned appointments created before that field was populated?