diff --git a/docs/codebase-index.md b/docs/codebase-index.md index b27c6c99ad..4aa385c00f 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -961,7 +961,7 @@ Administrator source intake also records admission events in `document_admission `documents`, `document_pages`, `document_images`, `document_chunks`, `document_embedding_fields`, `document_index_units`, `document_table_facts`, `document_labels`, `document_summaries`, `document_sections`, `document_memory_cards`, `document_index_quality`, `document_title_words`, `document_publication_approvals`, `document_corpus_access_state`, `document_corpus_access_snapshots`, `ingestion_jobs`, `ingestion_job_stages`, `indexing_v3_agent_jobs`, `import_batches`, `image_caption_cache`, `rag_queries`, `rag_query_misses`, `rag_aliases`, `rag_response_cache`, `rag_retrieval_logs`, `rag_visual_eval_cases`, `rag_visual_eval_runs`, `rag_answer_feedback`, `clinical_registry_records`, `clinical_registry_record_sources`, `clinical_quality_feedback_triage`, `clinical_quality_feedback_triage_events`, `medication_records`, `differential_records`, `source_review_events`, `user_favourites`, `user_favourite_sets`, `user_preferences`, `work_admin_paperwork`, `work_backups`, `work_booking_courses`, `work_course_bookings`, `work_hospitals`, `work_hospital_teams`, `work_role_grants`, `alert_reminder_times`, `alert_brief_sent`, `api_rate_limits`, `api_rate_limit_subjects`, `audit_logs`, `storage_cleanup_jobs`, `on_call_entries`, `cme_years`, `cme_requirements`, `cme_routines`, `cme_entries`, `cme_allocations`, `cme_evidence`, `cme_year_snapshots`, `cme_year_amendments`, `cme_plan_goals`, `cme_plan_goal_carries`, `cme_entry_goals`, `cme_training_periods`, `cme_training_milestones`, `cme_missed_sessions`, `cme_entry_drafts`, `calendar_feed_tokens`, `on_call_shift_imports`, `on_call_shifts`, `on_call_services`, `on_call_service_sites`, `on_call_service_members`, `on_call_service_invitations`, `on_call_service_entries`, `on_call_service_reports`, `on_call_service_orientation`, `site_content_publications`, `site_content_reconciliation_plans`, `site_content_public_records`, `site_content_sync_state`, `site_content_sync_events`, `site_content_sync_event_plans`, `site_content_sync_worker_invocations`, `site_content_releases`, `site_content_release_records`, `site_content_release_receipts` -Team modes (Roster, Admin, Teaching): `admin_leave_balances`, `admin_settings`, `extra_time_records`, `on_call_service_member_events`, `registrar_assessments`, `registrar_training_milestones`, `roster_assignments`, `roster_calendar_links`, `roster_change_agreements`, `roster_changes`, `roster_draft_assignments`, `roster_drafts`, `roster_leave`, `roster_member_roles`, `roster_open_shifts`, `roster_publication_protections`, `roster_publication_seen`, `roster_publications`, `roster_rotation_preferences`, `roster_rotation_rounds`, `roster_shift_codes`, `roster_staffing_needs`, `roster_swaps`, `roster_team_settings`, `roster_unavailability`, `teaching_attendance`, `teaching_audit_events`, `teaching_calendar_optins`, `teaching_checkin_claims`, `teaching_collection_sections`, `teaching_collections`, `teaching_display_links`, `teaching_feedback_answers`, `teaching_feedback_replied`, `teaching_group_members`, `teaching_groups`, `teaching_member_roles`, `teaching_notice_reads`, `teaching_notices`, `teaching_occurrences`, `teaching_readiness`, `teaching_resource_saves`, `teaching_resources`, `teaching_series`, `teaching_supervision_entries`, `teaching_supervision_notes`, `teaching_supervision_pairings`, `teaching_team_settings`, `teaching_week_adds`, `web_push_subscriptions`. +Team modes (Roster, Admin, Teaching): `admin_leave_balances`, `admin_settings`, `extra_time_records`, `on_call_service_member_events`, `registrar_assessments`, `registrar_training_milestones`, `roster_assignments`, `roster_calendar_links`, `roster_change_agreements`, `roster_changes`, `roster_draft_assignments`, `roster_drafts`, `roster_leave`, `roster_member_roles`, `roster_open_post_keys`, `roster_open_shifts`, `roster_publication_protections`, `roster_publication_seen`, `roster_publications`, `roster_rotation_preferences`, `roster_rotation_rounds`, `roster_shift_codes`, `roster_staffing_needs`, `roster_swaps`, `roster_team_settings`, `roster_unavailability`, `teaching_attendance`, `teaching_audit_events`, `teaching_calendar_optins`, `teaching_checkin_claims`, `teaching_collection_sections`, `teaching_collections`, `teaching_display_links`, `teaching_feedback_answers`, `teaching_feedback_replied`, `teaching_group_members`, `teaching_groups`, `teaching_member_roles`, `teaching_notice_reads`, `teaching_notices`, `teaching_occurrences`, `teaching_readiness`, `teaching_resource_saves`, `teaching_resources`, `teaching_series`, `teaching_supervision_entries`, `teaching_supervision_notes`, `teaching_supervision_pairings`, `teaching_team_settings`, `teaching_week_adds`, `web_push_subscriptions`. Public-source control-plane tables: `public_source_policy_entries`, `public_source_activation_events`, `public_source_versions`, `public_source_upload_attempts`, `public_source_activation_guards`, `public_source_cleanup_mutation_guards`. diff --git a/scripts/lib/cross-tenant-roster-probe.ts b/scripts/lib/cross-tenant-roster-probe.ts index 4abea3f067..120fc2c65e 100644 --- a/scripts/lib/cross-tenant-roster-probe.ts +++ b/scripts/lib/cross-tenant-roster-probe.ts @@ -34,6 +34,7 @@ function actionSamples(id: string, userId: string, date: string): RosterAction[] swapId: id, })), { action: "open.post", assignmentId: id }, + { action: "open.post", assignmentId: id, postKey: id }, { action: "open.report", assignmentId: id }, ...(["open.claim", "open.approve", "open.decline", "open.cancel", "open.withdraw"] as const).map((action) => ({ action, diff --git a/src/components/open-shifts/open-shifts-post-page.tsx b/src/components/open-shifts/open-shifts-post-page.tsx index 9352d4394c..77dbc5a62c 100644 --- a/src/components/open-shifts/open-shifts-post-page.tsx +++ b/src/components/open-shifts/open-shifts-post-page.tsx @@ -109,6 +109,10 @@ export function OpenShiftsPostPage() { const [unanswered, setUnanswered] = useState< Record >({}); + // One post key per date being posted, by everything that was sent. A retry of a date whose answer was + // lost sends the same key, so the server answers with the shift that post already saved instead of saving + // a second one. A key is dropped once its date is posted; changing anything in the shift makes a new key. + const postKeys = useRef>({}); const [copied, setCopied] = useState(false); // The last partial-failure report, kept on the page so closing the sheet never hides what went live. const [report, setReport] = useState(null); @@ -255,6 +259,8 @@ export function OpenShiftsPostPage() { attempts.push({ date: on, ok: false, message: "The times couldn't be worked out for this date." }); continue; } + const postKey = postKeys.current[signature(on)] ?? crypto.randomUUID(); + postKeys.current[signature(on)] = postKey; const result = await postRosterAction(team.serviceId, { action: "open.post", startsAt: onTimes.startsAt, @@ -264,10 +270,12 @@ export function OpenShiftsPostPage() { siteId: site || null, minGrade: level || null, urgent, + postKey, } as RosterAction); if (result.ok) { attempts.push({ date: on, ok: true, openShiftId: result.result.openShiftId ?? null }); nowDone[signature(on)] = result.result.openShiftId ?? ""; + delete postKeys.current[signature(on)]; } else { attempts.push({ date: on, ok: false, message: result.message }); // No answer at all: it may still have gone live, so a retry checks the team's list first. A repeat diff --git a/src/lib/roster/alerts/dispatch.ts b/src/lib/roster/alerts/dispatch.ts index df324d7f23..5351fcb682 100644 --- a/src/lib/roster/alerts/dispatch.ts +++ b/src/lib/roster/alerts/dispatch.ts @@ -23,6 +23,7 @@ function resultOf(value: unknown): { status?: string; swapId?: string; openShiftId?: string; + replayed?: boolean; recipientIds?: string[]; changedUserIds?: string[]; swapsCancelled?: { requesterId?: string; counterpartyId?: string }[]; @@ -35,6 +36,9 @@ export async function dispatchRosterAlerts(client: RosterAdminClient, event: Ros try { const result = resultOf(event.result); if (result.status === "cancelled" || result.status === "expired") return; + // A repeat of a post the server had already saved (the phone never heard back the first time): + // its alerts went out with the first post, so they are never sent twice. + if (result.replayed === true) return; // The overview checks the actor's team access. G3's manager list is the // only authority for manager alerts; active membership is checked below. const overview = await rosterRead(client, event.actorId, event.serviceId, "overview"); diff --git a/src/lib/roster/team/demo-team-core.ts b/src/lib/roster/team/demo-team-core.ts index b63afdf72b..ab3ecb9550 100644 --- a/src/lib/roster/team/demo-team-core.ts +++ b/src/lib/roster/team/demo-team-core.ts @@ -420,6 +420,7 @@ export function demoRosterCommand(action: RosterAction): RosterCommandResult { case "swap.undo": return { ok: true, swapId: action.swapId, status: "cancelled" }; case "open.post": + // A post key is accepted and ignored: nothing is saved here, so there is nothing to post twice. return { ok: true, openShiftId: id, status: "open" }; case "open.claim": return { ok: true, openShiftId: action.openShiftId, status: "claimed" }; diff --git a/src/lib/roster/team/model.ts b/src/lib/roster/team/model.ts index 4557614e5a..4b75fa46b1 100644 --- a/src/lib/roster/team/model.ts +++ b/src/lib/roster/team/model.ts @@ -391,6 +391,9 @@ const openPostSchema = z siteId: nullableOptional(uuid), minGrade: nullableOptional(z.enum(["intern", "resident", "registrar", "fellow", "consultant"])), urgent: z.boolean().optional(), + // One random key per date the phone posts, sent again when it retries that date after a lost + // answer, so the retry answers with the shift already saved (roster_open_post_once). + postKey: uuid.optional(), }) .strict() .superRefine((value, context) => { diff --git a/src/lib/roster/team/repository.ts b/src/lib/roster/team/repository.ts index 6b024f5b43..f27a38bb27 100644 --- a/src/lib/roster/team/repository.ts +++ b/src/lib/roster/team/repository.ts @@ -69,6 +69,12 @@ export async function rosterReadTeams(client: AdminClient, actorId: string): Pro return parsed.data.teams; } +function withoutPostKey(payload: Record): Record { + const rest = { ...payload }; + delete rest.postKey; + return rest; +} + /** A team write. `action` has already passed `rosterActionSchema` (or is a publish-route action). */ export async function rosterCommand( client: AdminClient, @@ -80,6 +86,8 @@ export async function rosterCommand( const { action: name, ...payload } = action; // The safe number goes through the stale check, which refuses it once anyone has saved since the editor's read. // Taking back an open-shift request has its own function, which only the doctor who asked can call. + // A post that carries a key goes through roster_open_post_once, which answers a repeat of the same + // key with the shift already saved instead of saving another; the key itself is not part of the post. const { data, error } = action.action === "open.withdraw" ? await client.rpc("roster_open_withdraw", { @@ -94,12 +102,19 @@ export async function rosterCommand( p_expected_ids: action.expectedIds as never, p_needs: action.needs as never, }) - : await client.rpc("roster_command", { - p_actor_id: actorId, - p_service_id: serviceId, - p_action: name, - p_payload: payload as never, - }); + : action.action === "open.post" && action.postKey + ? await client.rpc("roster_open_post_once", { + p_actor_id: actorId, + p_service_id: serviceId, + p_post_key: action.postKey, + p_payload: withoutPostKey(payload) as never, + }) + : await client.rpc("roster_command", { + p_actor_id: actorId, + p_service_id: serviceId, + p_action: name, + p_payload: payload as never, + }); if (error) throw rosterApiError(error); const parsed = rosterCommandResultSchema.safeParse(data); if (!parsed.success) throw rosterUnavailable(); diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index d6171f7116..0f9c5222c2 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -4363,6 +4363,30 @@ export type Database = { }; Relationships: []; }; + roster_open_post_keys: { + Row: { + created_at: string; + open_shift_id: string; + post_key: string; + posted_by: string | null; + service_id: string; + }; + Insert: { + created_at?: string; + open_shift_id: string; + post_key: string; + posted_by?: string | null; + service_id: string; + }; + Update: { + created_at?: string; + open_shift_id?: string; + post_key?: string; + posted_by?: string | null; + service_id?: string; + }; + Relationships: []; + }; roster_open_shifts: { Row: { assignment_id: string | null; @@ -4945,6 +4969,10 @@ export type Database = { Args: { p_actor_id: string; p_service_id: string; p_open_shift_id: string }; Returns: Json; }; + roster_open_post_once: { + Args: { p_actor_id: string; p_service_id: string; p_post_key: string; p_payload: Json }; + Returns: Json; + }; on_call_service_set_verified: { Args: { p_service_id: string; p_actor_id: string; p_verified: boolean; p_is_demo: boolean }; Returns: Json; diff --git a/supabase/applied-migration-hashes.json b/supabase/applied-migration-hashes.json index 6c647841c6..0a76a4211b 100644 --- a/supabase/applied-migration-hashes.json +++ b/supabase/applied-migration-hashes.json @@ -312,6 +312,7 @@ "20261013170000_force_rls_control_plane.sql": "46d746dd78ed0c62489d72244ffc70b38dc08e3adb7d62ef360482dcb7214a85", "20261013200007_managed_native_pdf_terminal_append.sql": "94ac297bad4a1f9d28fcb13d018237df081aa5b3da328073be8c9e17774bd292", "20261014164220_validate_managed_ingestion_historical_reconciliation.sql": "8ffd75292620e82065300eb1e1d008c229545dde37826862820bfd6ea1314587", - "20261015160000_document_child_policies_check_parent_owner.sql": "71210bbc5aecdca74bee2ae32181ba2861e65017ca1a5e87995aa8e557946094" + "20261015160000_document_child_policies_check_parent_owner.sql": "71210bbc5aecdca74bee2ae32181ba2861e65017ca1a5e87995aa8e557946094", + "20261015174500_roster_open_post_once.sql": "a4edfe07497e3b833c4bedecf1b41d0d7b9fb0b73a798a71b3c17bccb9f851ff" } } diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 9636938caf..2ef9197500 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -2,8 +2,8 @@ "generated_at": "2026-10-15T15:56:45.785Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83", - "schema_sha256": "99c313f9b394551fe2bfc44361802c9523fb5cb092721b3739b7059c7d472606", - "replay_seconds": 18, + "schema_sha256": "e561ea277c34ddec08d6b676ebcb8603c0e98b04e0ca311c9fbf4a4f49246895", + "replay_seconds": 38, "snapshot": { "views": [ { @@ -9197,6 +9197,58 @@ "rls_forced": false, "rls_enabled": true }, + { + "acl": [ + "postgres=arwdDxtm/postgres", + "service_role=arwd/postgres" + ], + "name": "roster_open_post_keys", + "columns": [ + { + "name": "created_at", + "type": "timestamp with time zone", + "default": "now()", + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "open_shift_id", + "type": "uuid", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "post_key", + "type": "uuid", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "posted_by", + "type": "uuid", + "default": null, + "identity": "", + "not_null": false, + "generated": "" + }, + { + "name": "service_id", + "type": "uuid", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + } + ], + "reloptions": null, + "rls_forced": false, + "rls_enabled": true + }, { "acl": [ "postgres=arwdDxtm/postgres", @@ -16009,6 +16061,18 @@ "table": "roster_member_roles", "def_hash": "590bcff136e7af842298b48ee261e3f2" }, + { + "def": "CREATE INDEX roster_open_post_keys_open_shift_idx ON public.roster_open_post_keys USING btree (open_shift_id)", + "name": "roster_open_post_keys_open_shift_idx", + "table": "roster_open_post_keys", + "def_hash": "b6bf535bf8cb73f1cd9c8a0c01519b13" + }, + { + "def": "CREATE UNIQUE INDEX roster_open_post_keys_pkey ON public.roster_open_post_keys USING btree (service_id, post_key)", + "name": "roster_open_post_keys_pkey", + "table": "roster_open_post_keys", + "def_hash": "5747b4e8b8ecd35fa92d19e91d40b72a" + }, { "def": "CREATE UNIQUE INDEX roster_open_shifts_live_assignment_idx ON public.roster_open_shifts USING btree (assignment_id) WHERE ((status = ANY (ARRAY['reported'::text, 'open'::text, 'claimed'::text])) AND (assignment_id IS NOT NULL))", "name": "roster_open_shifts_live_assignment_idx", @@ -19931,6 +19995,14 @@ "def_hash": "3970f82d1ac917820e8f1d2d8cc9b88e", "signature": "public.roster_on_membership_revoked()" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "ca06394dd5c4556e65af5a771fc47841", + "signature": "public.roster_open_post_once(uuid,uuid,uuid,jsonb)" + }, { "acl": [ "postgres=X/postgres" @@ -23647,6 +23719,21 @@ "name": "roster_member_roles_service_id_user_id_fkey", "table": "roster_member_roles" }, + { + "def": "FOREIGN KEY (open_shift_id) REFERENCES public.roster_open_shifts(id) ON DELETE CASCADE", + "name": "roster_open_post_keys_open_shift_id_fkey", + "table": "roster_open_post_keys" + }, + { + "def": "PRIMARY KEY (service_id, post_key)", + "name": "roster_open_post_keys_pkey", + "table": "roster_open_post_keys" + }, + { + "def": "FOREIGN KEY (service_id) REFERENCES public.on_call_services(id) ON DELETE CASCADE", + "name": "roster_open_post_keys_service_id_fkey", + "table": "roster_open_post_keys" + }, { "def": "FOREIGN KEY (assignment_id) REFERENCES public.roster_assignments(id) ON DELETE CASCADE", "name": "roster_open_shifts_assignment_id_fkey", diff --git a/supabase/migrations/20261015174500_roster_open_post_once.sql b/supabase/migrations/20261015174500_roster_open_post_once.sql new file mode 100644 index 0000000000..5c0715ae23 --- /dev/null +++ b/supabase/migrations/20261015174500_roster_open_post_once.sql @@ -0,0 +1,66 @@ +-- Never edit an applied migration. Merging this applies it to the live database. +-- A roster manager's open-shift post goes live once, even when the phone never hears back. +-- If the connection drops after the server saved a posted gap but before the answer reached the +-- phone, sending the post again used to save a second identical open shift. Posts that give away +-- a rostered shift were already protected by roster_open_shifts_live_assignment_idx; a posted gap +-- was not, and two identical gaps can be genuine (two doctors needed), so the shift itself can't +-- be made unique. Instead the phone sends one random key per date it posts and sends the same key +-- again when it retries that date. The first post with a key saves the shift and remembers the +-- key; the same key again answers with the shift already saved instead of saving another. +-- +-- One new table, one new function. roster_command is unchanged. No data changes. +set local lock_timeout = '5s'; +set local statement_timeout = '30s'; + +-- Which open shift each post key made. Tenancy is the same as every Roster table: RLS on, no +-- policies, no grant to anon or authenticated, service_role only. The rows go when the open shift +-- or the team goes. +create table public.roster_open_post_keys ( + service_id uuid not null references public.on_call_services (id) on delete cascade, + post_key uuid not null, + open_shift_id uuid not null references public.roster_open_shifts (id) on delete cascade, + posted_by uuid, + created_at timestamptz not null default now(), + primary key (service_id, post_key) +); +create index roster_open_post_keys_open_shift_idx on public.roster_open_post_keys (open_shift_id); + +alter table public.roster_open_post_keys enable row level security; +revoke all on table public.roster_open_post_keys from public, anon, authenticated; +grant select, insert, update, delete on table public.roster_open_post_keys to service_role; + +create function public.roster_open_post_once(p_actor_id uuid, p_service_id uuid, p_post_key uuid, p_payload jsonb) +returns jsonb language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$ +declare + v_service public.on_call_services; + v_key public.roster_open_post_keys; + v_status text; + v_result jsonb; +begin + if p_actor_id is null then raise exception 'roster_auth_required'; end if; + if p_service_id is null or p_post_key is null then raise exception 'roster_invalid_request'; end if; + -- The same lock order as every team write: the service row FOR SHARE, then the per-service + -- advisory lock. roster_command takes both again, which the same transaction already holds. + select * into v_service from public.on_call_services where id = p_service_id for share; + if not found or not public.service_member_active(p_service_id, p_actor_id) then + raise exception 'roster_access_denied'; + end if; + if v_service.verified_at is null and not v_service.is_demo then raise exception 'roster_team_not_verified'; end if; + perform pg_advisory_xact_lock(hashtextextended(p_service_id::text, 74817)); + + select * into v_key from public.roster_open_post_keys where service_id = p_service_id and post_key = p_post_key; + if found then + -- A key is only ever replayed for the person who first sent it. + if v_key.posted_by is distinct from p_actor_id then raise exception 'roster_conflict'; end if; + select status into v_status from public.roster_open_shifts where id = v_key.open_shift_id; + return jsonb_build_object('openShiftId', v_key.open_shift_id, 'status', v_status, 'replayed', true); + end if; + + v_result := public.roster_command(p_actor_id, p_service_id, 'open.post', coalesce(p_payload, '{}'::jsonb)); + insert into public.roster_open_post_keys (service_id, post_key, open_shift_id, posted_by) + values (p_service_id, p_post_key, (v_result ->> 'openShiftId')::uuid, p_actor_id); + return v_result; +end $$; + +revoke all on function public.roster_open_post_once(uuid, uuid, uuid, jsonb) from public, anon, authenticated; +grant execute on function public.roster_open_post_once(uuid, uuid, uuid, jsonb) to service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index 0c5461026f..5c41424857 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -34190,3 +34190,56 @@ begin return v_result; end; $function$; + +-- A roster manager's open-shift post goes live once, even when the phone never hears back +-- (20261015174500_roster_open_post_once.sql). The post keys are the same tenancy as every Roster +-- table. roster_command is unchanged. +create table public.roster_open_post_keys ( + service_id uuid not null references public.on_call_services (id) on delete cascade, + post_key uuid not null, + open_shift_id uuid not null references public.roster_open_shifts (id) on delete cascade, + posted_by uuid, + created_at timestamptz not null default now(), + primary key (service_id, post_key) +); +create index roster_open_post_keys_open_shift_idx on public.roster_open_post_keys (open_shift_id); + +alter table public.roster_open_post_keys enable row level security; +revoke all on table public.roster_open_post_keys from public, anon, authenticated; +grant select, insert, update, delete on table public.roster_open_post_keys to service_role; + +create function public.roster_open_post_once(p_actor_id uuid, p_service_id uuid, p_post_key uuid, p_payload jsonb) +returns jsonb language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$ +declare + v_service public.on_call_services; + v_key public.roster_open_post_keys; + v_status text; + v_result jsonb; +begin + if p_actor_id is null then raise exception 'roster_auth_required'; end if; + if p_service_id is null or p_post_key is null then raise exception 'roster_invalid_request'; end if; + -- The same lock order as every team write: the service row FOR SHARE, then the per-service + -- advisory lock. roster_command takes both again, which the same transaction already holds. + select * into v_service from public.on_call_services where id = p_service_id for share; + if not found or not public.service_member_active(p_service_id, p_actor_id) then + raise exception 'roster_access_denied'; + end if; + if v_service.verified_at is null and not v_service.is_demo then raise exception 'roster_team_not_verified'; end if; + perform pg_advisory_xact_lock(hashtextextended(p_service_id::text, 74817)); + + select * into v_key from public.roster_open_post_keys where service_id = p_service_id and post_key = p_post_key; + if found then + -- A key is only ever replayed for the person who first sent it. + if v_key.posted_by is distinct from p_actor_id then raise exception 'roster_conflict'; end if; + select status into v_status from public.roster_open_shifts where id = v_key.open_shift_id; + return jsonb_build_object('openShiftId', v_key.open_shift_id, 'status', v_status, 'replayed', true); + end if; + + v_result := public.roster_command(p_actor_id, p_service_id, 'open.post', coalesce(p_payload, '{}'::jsonb)); + insert into public.roster_open_post_keys (service_id, post_key, open_shift_id, posted_by) + values (p_service_id, p_post_key, (v_result ->> 'openShiftId')::uuid, p_actor_id); + return v_result; +end $$; + +revoke all on function public.roster_open_post_once(uuid, uuid, uuid, jsonb) from public, anon, authenticated; +grant execute on function public.roster_open_post_once(uuid, uuid, uuid, jsonb) to service_role; diff --git a/tests/open-shifts-post-retry.dom.test.tsx b/tests/open-shifts-post-retry.dom.test.tsx index 0d5dfdc997..d90fd734e9 100644 --- a/tests/open-shifts-post-retry.dom.test.tsx +++ b/tests/open-shifts-post-retry.dom.test.tsx @@ -164,6 +164,55 @@ describe("Posting a shift", () => { expect(mocks.post).toHaveBeenCalledTimes(2); }); + it("after a post gets no answer, a retry sends the same post key, so the server answers with the saved shift", async () => { + const SAVED = "5e000000-0000-4000-8000-0000000000b4"; + mocks.state = { ...mocks.state, shifts: [] }; + mocks.post.mockResolvedValueOnce({ ok: false, code: "roster_unavailable", message: "Couldn't reach the server." }); + mocks.post.mockResolvedValueOnce({ + ok: true, + result: { openShiftId: SAVED, status: "open", replayed: true }, + }); + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "Review and post" })); + fireEvent.click(screen.getByRole("button", { name: "Yes, post it" })); + await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/Not posted/)); + + // The re-read doesn't show the shift yet (it went live just after the read), so the date is sent again. + await new Promise((resolve) => setTimeout(resolve, 5)); + mocks.state = { ...mocks.state, readAt: new Date() }; + view.rerender(); + fireEvent.click(screen.getByRole("button", { name: "Yes, post it" })); + await waitFor(() => expect(mocks.push).toHaveBeenCalledOnce()); + + expect(mocks.post).toHaveBeenCalledTimes(2); + const first = mocks.post.mock.calls[0]![1] as { postKey?: string }; + const retry = mocks.post.mock.calls[1]![1] as { postKey?: string }; + expect(first.postKey).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(retry.postKey).toBe(first.postKey); + expect(mocks.push.mock.calls[0]![0]).toContain(SAVED); + }); + + it("gives a deliberate new post its own key once the last one went live", async () => { + mocks.post.mockResolvedValue({ + ok: true, + result: { ok: true, openShiftId: "5e000000-0000-4000-8000-0000000000b5" }, + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Review and post" })); + fireEvent.click(screen.getByRole("button", { name: "Yes, post it" })); + await waitFor(() => expect(mocks.push).toHaveBeenCalledOnce()); + cleanup(); + // A fresh visit to the post page is a new post: it never reuses an earlier visit's key. + render(); + fireEvent.click(screen.getByRole("button", { name: "Review and post" })); + fireEvent.click(screen.getByRole("button", { name: "Yes, post it" })); + await waitFor(() => expect(mocks.post).toHaveBeenCalledTimes(2)); + const [first, second] = mocks.post.mock.calls.map((call) => (call[1] as { postKey?: string }).postKey); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(second).not.toBe(first); + }); + it("after a post gets no answer, doesn't post to another team while the first team can't be read", async () => { const OTHER = "5e000000-0000-4000-8000-000000000020"; mocks.state = { ...mocks.state, shifts: [] }; diff --git a/tests/roster-alerts.test.ts b/tests/roster-alerts.test.ts index d91108dba2..71e8688591 100644 --- a/tests/roster-alerts.test.ts +++ b/tests/roster-alerts.test.ts @@ -113,4 +113,42 @@ describe("generic phone alerts", () => { await dispatchRosterAlerts(client, event("swap.approve", MEI)); expect(mocks.subscriptions).toHaveBeenCalledWith(client, [SAM]); }); + + it("offers a newly posted gap, but never re-sends the offer for a replayed post", async () => { + const OPEN = "5e000000-0000-4000-8000-000000000007"; + const gap = { + action: "open.post", + startsAt: "2026-11-20T00:00:00.000Z", + endsAt: "2026-11-20T08:30:00.000Z", + shiftCode: "D", + kind: "day", + postKey: "5e000000-0000-4000-8000-000000000008", + } as const; + mocks.open.mockResolvedValue({ + starts_at: gap.startsAt, + ends_at: gap.endsAt, + min_grade: null, + assignment_id: null, + posted_by: ALEX, + claimed_by: null, + }); + await dispatchRosterAlerts(client, { + serviceId: SERVICE, + actorId: ALEX, + action: gap, + result: { openShiftId: OPEN, status: "open" }, + }); + expect(mocks.open).toHaveBeenCalledWith(client, SERVICE, OPEN); + + vi.clearAllMocks(); + await dispatchRosterAlerts(client, { + serviceId: SERVICE, + actorId: ALEX, + action: gap, + result: { openShiftId: OPEN, status: "open", replayed: true }, + }); + expect(mocks.read).not.toHaveBeenCalled(); + expect(mocks.open).not.toHaveBeenCalled(); + expect(mocks.send).not.toHaveBeenCalled(); + }); }); diff --git a/tests/roster-team-api.test.ts b/tests/roster-team-api.test.ts index 6a8c0eba8f..fd4f94aa71 100644 --- a/tests/roster-team-api.test.ts +++ b/tests/roster-team-api.test.ts @@ -327,6 +327,78 @@ describe("writing to a team", () => { expect(mocks.rpc).toHaveBeenCalledTimes(1); }); + it("sends an open.post with a post key through roster_open_post_once, without the key in the post", async () => { + const KEY = "5e000000-0000-4000-8000-0000000000b1"; + const gap = { + action: "open.post", + startsAt: "2026-10-18T06:00:00.000Z", + endsAt: "2026-10-18T14:30:00.000Z", + shiftCode: "E", + kind: "evening", + siteId: null, + urgent: false, + }; + mocks.rpc.mockResolvedValue({ data: { openShiftId: OPEN, status: "open", replayed: true }, error: null }); + const response = await POST(jsonRequest({ ...gap, postKey: KEY }), ctx()); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ result: { openShiftId: OPEN, status: "open", replayed: true } }); + expect(mocks.rpc).toHaveBeenCalledTimes(1); + const payload = { ...gap } as Partial; + delete payload.action; + expect(mocks.rpc).toHaveBeenCalledWith("roster_open_post_once", { + p_actor_id: ALEX, + p_service_id: SERVICE, + p_post_key: KEY, + p_payload: payload, + }); + // The replayed answer still reaches the alerts step, which itself sends nothing for a replay. + await vi.waitFor(() => expect(mocks.dispatch).toHaveBeenCalledTimes(1)); + expect(mocks.dispatch.mock.calls[0][1]).toMatchObject({ result: { replayed: true } }); + + // A give-away with a key goes the same way; a post without one still goes through roster_command. + mocks.rpc.mockClear(); + mocks.rpc.mockResolvedValue({ data: { openShiftId: OPEN, status: "open" }, error: null }); + expect((await POST(jsonRequest({ action: "open.post", assignmentId: SWAP, postKey: KEY }), ctx())).status).toBe( + 200, + ); + expect(mocks.rpc).toHaveBeenLastCalledWith("roster_open_post_once", { + p_actor_id: ALEX, + p_service_id: SERVICE, + p_post_key: KEY, + p_payload: { assignmentId: SWAP }, + }); + expect((await POST(jsonRequest(gap), ctx())).status).toBe(200); + expect(mocks.rpc).toHaveBeenLastCalledWith("roster_command", { + p_actor_id: ALEX, + p_service_id: SERVICE, + p_action: "open.post", + p_payload: payload, + }); + }); + + it("refuses a post key that isn't a uuid before the database is asked", async () => { + const gap = { + action: "open.post", + startsAt: "2026-10-18T06:00:00.000Z", + endsAt: "2026-10-18T14:30:00.000Z", + shiftCode: "E", + kind: "evening", + }; + for (const postKey of ["not-a-key", "", 42, null]) { + expect((await POST(jsonRequest({ ...gap, postKey }), ctx())).status).toBe(400); + } + // A key belongs to a post only: another action carrying one is refused too. + expect( + ( + await POST( + jsonRequest({ action: "open.claim", openShiftId: OPEN, postKey: "5e000000-0000-4000-8000-0000000000b1" }), + ctx(), + ) + ).status, + ).toBe(400); + expect(mocks.rpc).not.toHaveBeenCalled(); + }); + it("needs all four team settings, because the SQL overwrites all four", async () => { const response = await POST(jsonRequest({ action: "settings.set", swapApproval: "manager" }), ctx()); expect(response.status).toBe(400); diff --git a/tests/sql/roster-behaviour.sql b/tests/sql/roster-behaviour.sql index 5d198c7c15..10bfb826da 100644 --- a/tests/sql/roster-behaviour.sql +++ b/tests/sql/roster-behaviour.sql @@ -530,6 +530,50 @@ begin end if; end $needs_stale$; +-- Open shifts: a posted gap goes live once per key. Sending the same key again (the phone never +-- heard back) answers with the shift already saved; another member can't use that key; a new key +-- is a deliberate second opening for the same slot. +do $open_post_once$ +declare + svc uuid := pg_temp.id('svc'); + k1 uuid := gen_random_uuid(); + k2 uuid := gen_random_uuid(); + gap jsonb := jsonb_build_object('startsAt', date_trunc('day', now()) + interval '40 days', + 'endsAt', date_trunc('day', now()) + interval '40 days 8 hours 30 minutes', 'shiftCode', 'DX', 'kind', 'day'); + first jsonb; again jsonb; second jsonb; +begin + perform pg_temp.expect_error(format('select public.roster_open_post_once(%L,%L,null,%L)', + pg_temp.id('mgr'), svc, gap), 'roster_invalid_request'); + first := public.roster_open_post_once(pg_temp.id('mgr'), svc, k1, gap); + if first ->> 'status' is distinct from 'open' or first ? 'replayed' then raise exception 'post once: first post got %', first; end if; + if (select count(*) from public.roster_open_shifts where service_id = svc and shift_code = 'DX') <> 1 then + raise exception 'post once: the first post did not save exactly one shift'; + end if; + again := public.roster_open_post_once(pg_temp.id('mgr'), svc, k1, gap); + if again ->> 'openShiftId' is distinct from first ->> 'openShiftId' or again ->> 'status' is distinct from 'open' + or (again ->> 'replayed')::boolean is not true then + raise exception 'post once: the same key did not replay the first post, got %', again; + end if; + if (select count(*) from public.roster_open_shifts where service_id = svc and shift_code = 'DX') <> 1 then + raise exception 'post once: the same key saved a second shift'; + end if; + perform pg_temp.expect_error(format('select public.roster_open_post_once(%L,%L,%L,%L)', + pg_temp.id('lee'), svc, k1, gap), 'roster_conflict'); + second := public.roster_open_post_once(pg_temp.id('mgr'), svc, k2, gap); + if second ->> 'openShiftId' is not distinct from first ->> 'openShiftId' or second ? 'replayed' then + raise exception 'post once: a new key did not post a second opening, got %', second; + end if; + if (select count(*) from public.roster_open_shifts where service_id = svc and shift_code = 'DX') <> 2 then + raise exception 'post once: expected two openings for two keys'; + end if; + -- A member who isn't a manager is still refused a gap, and the refused key is not kept. + perform pg_temp.expect_error(format('select public.roster_open_post_once(%L,%L,%L,%L)', + pg_temp.id('lee'), svc, gen_random_uuid(), gap), 'roster_role_denied'); + if (select count(*) from public.roster_open_post_keys where service_id = svc) <> 2 then + raise exception 'post once: expected one kept key per saved post'; + end if; +end $open_post_once$; + -- 14. Deleting a doctor's account is never blocked by a Roster table. reset role; do $delete_account$