From e89cdcf61cf09ab1092e978df6964c723064fc9b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:50:26 +0000 Subject: [PATCH 1/6] Open shifts: post a gap once per key, so a lost answer can't double-post it A new roster_open_post_once function remembers which open shift each post key made (new table roster_open_post_keys, service_role only, RLS on with no policies). The same key again from the same manager answers with the shift already saved and replayed: true instead of saving a second one; another member's use of that key is refused with roster_conflict. Two identical gaps under different keys still post, because two doctors can be needed. roster_command is unchanged. schema.sql mirrors the migration; the migration is sealed; the drift manifest gains the table, its two indexes, three constraints and the function. The SQL behaviour test covers first post, replay, another member's key, a new key, a null key and a refused non-manager post. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LuYbdEGXeMntVA9KMgtAEx --- docs/codebase-index.md | 2 +- supabase/applied-migration-hashes.json | 5 +- supabase/drift-manifest.json | 89 ++++++++++++++++++- .../20261015133000_roster_open_post_once.sql | 66 ++++++++++++++ supabase/schema.sql | 53 +++++++++++ tests/sql/roster-behaviour.sql | 44 +++++++++ 6 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 supabase/migrations/20261015133000_roster_open_post_once.sql 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/supabase/applied-migration-hashes.json b/supabase/applied-migration-hashes.json index 766b8de59f..2c199bc9d1 100644 --- a/supabase/applied-migration-hashes.json +++ b/supabase/applied-migration-hashes.json @@ -1,7 +1,7 @@ { "note": "sha256 of every migration this repository has shipped, with newlines normalised. An entry whose hash changes means an applied migration was edited, which cannot reach the live database. See scripts/check-migration-immutability.mjs.", "generator": "scripts/check-migration-immutability.mjs", - "sealed_at": "2026-10-15T11:24:23.374Z", + "sealed_at": "2026-10-15T13:43:06.103Z", "versions": { "20260527000000_bulk_ingestion.sql": "813e8e314064a6dcbaaa55e2765a8ce901d5783b304b4a7af7bb7417a07daf7c", "20260527001000_documents_policy_cleanup.sql": "559c26f3172bf75e545a41adef75538b2bf648ea7d53c8eb3d3ea7c244c65a48", @@ -311,6 +311,7 @@ "20261013160000_teaching_assessments_and_milestones.sql": "adb491df9ace7fa9cbf20091d14e8411df216a53935e7b95f7dcd23d1fd5776b", "20261013170000_force_rls_control_plane.sql": "46d746dd78ed0c62489d72244ffc70b38dc08e3adb7d62ef360482dcb7214a85", "20261013200007_managed_native_pdf_terminal_append.sql": "94ac297bad4a1f9d28fcb13d018237df081aa5b3da328073be8c9e17774bd292", - "20261014164220_validate_managed_ingestion_historical_reconciliation.sql": "8ffd75292620e82065300eb1e1d008c229545dde37826862820bfd6ea1314587" + "20261014164220_validate_managed_ingestion_historical_reconciliation.sql": "8ffd75292620e82065300eb1e1d008c229545dde37826862820bfd6ea1314587", + "20261015133000_roster_open_post_once.sql": "a4edfe07497e3b833c4bedecf1b41d0d7b9fb0b73a798a71b3c17bccb9f851ff" } } diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 90122fd320..9a69adde6f 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -2,7 +2,7 @@ "generated_at": "2026-10-14T16:34:21.177Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83", - "schema_sha256": "8b544451a3604bc191db8a76c0176ef1bcfc1c236d84a8f2a5da701c340b5db2", + "schema_sha256": "325bfcfa402062f2c4b2c023fa4de623061820172dcfbb3b346c73d21a263286", "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/20261015133000_roster_open_post_once.sql b/supabase/migrations/20261015133000_roster_open_post_once.sql new file mode 100644 index 0000000000..5c0715ae23 --- /dev/null +++ b/supabase/migrations/20261015133000_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 41c7cdd4cf..174e7783d5 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -34178,3 +34178,56 @@ begin return v_result; end; $function$; + +-- A roster manager's open-shift post goes live once, even when the phone never hears back +-- (20261015133000_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/sql/roster-behaviour.sql b/tests/sql/roster-behaviour.sql index 4e8e018f7e..b7d851a73a 100644 --- a/tests/sql/roster-behaviour.sql +++ b/tests/sql/roster-behaviour.sql @@ -529,6 +529,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$ From bb36109c18d4a59b4c9b228e7618c8d2c2e8b010 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:52:12 +0000 Subject: [PATCH 2/6] Open shifts: send a keyed post through roster_open_post_once, and never re-alert a replay open.post takes an optional postKey (a uuid). With one, the repository calls roster_open_post_once with the post minus the key; without one it still calls roster_command. A replayed answer (replayed: true) sends no phone alerts, because the first post already sent them. The sample team accepts and ignores the key. The cross-tenant probe also tries a keyed post against another team. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LuYbdEGXeMntVA9KMgtAEx --- scripts/lib/cross-tenant-roster-probe.ts | 1 + src/lib/roster/alerts/dispatch.ts | 4 ++ src/lib/roster/team/demo-team-core.ts | 1 + src/lib/roster/team/model.ts | 3 + src/lib/roster/team/repository.ts | 26 +++++++-- src/lib/supabase/database.types.ts | 28 ++++++++++ tests/roster-alerts.test.ts | 38 +++++++++++++ tests/roster-team-api.test.ts | 71 ++++++++++++++++++++++++ 8 files changed, 166 insertions(+), 6 deletions(-) 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/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..1838967a86 100644 --- a/src/lib/roster/team/repository.ts +++ b/src/lib/roster/team/repository.ts @@ -69,6 +69,11 @@ export async function rosterReadTeams(client: AdminClient, actorId: string): Pro return parsed.data.teams; } +function withoutPostKey(payload: Record): Record { + const { postKey: _postKey, ...rest } = payload; + return rest; +} + /** A team write. `action` has already passed `rosterActionSchema` (or is a publish-route action). */ export async function rosterCommand( client: AdminClient, @@ -80,6 +85,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 +101,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/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..440ebbe678 100644 --- a/tests/roster-team-api.test.ts +++ b/tests/roster-team-api.test.ts @@ -327,6 +327,77 @@ 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 { action: _action, ...payload } = gap; + 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); From 84e3492bd923a03d48c34596217b6a46a68ebd72 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:53:49 +0000 Subject: [PATCH 3/6] Open shifts: reuse a date's post key when its post is retried after a lost answer The post page sends one random post key per date it posts, keyed by everything that was sent, and sends the same key again when that date is retried. If the first post did go live before its answer was lost, the server answers with that shift instead of saving a second one. The key is dropped once the date is posted, and changing the shift makes a new one. The existing safeguards stay: a retry still waits for a fresh read of the team's shifts, and a shift already showing is never sent again. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LuYbdEGXeMntVA9KMgtAEx --- .../open-shifts/open-shifts-post-page.tsx | 8 +++ tests/open-shifts-post-retry.dom.test.tsx | 49 +++++++++++++++++++ 2 files changed, 57 insertions(+) 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/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: [] }; From 174cd505a453b9fb27b121ff222cabc643a3572e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:58:39 +0000 Subject: [PATCH 4/6] Open shifts: drop the post key without an unused binding Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LuYbdEGXeMntVA9KMgtAEx --- src/lib/roster/team/repository.ts | 3 ++- tests/roster-team-api.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/roster/team/repository.ts b/src/lib/roster/team/repository.ts index 1838967a86..f27a38bb27 100644 --- a/src/lib/roster/team/repository.ts +++ b/src/lib/roster/team/repository.ts @@ -70,7 +70,8 @@ export async function rosterReadTeams(client: AdminClient, actorId: string): Pro } function withoutPostKey(payload: Record): Record { - const { postKey: _postKey, ...rest } = payload; + const rest = { ...payload }; + delete rest.postKey; return rest; } diff --git a/tests/roster-team-api.test.ts b/tests/roster-team-api.test.ts index 440ebbe678..fd4f94aa71 100644 --- a/tests/roster-team-api.test.ts +++ b/tests/roster-team-api.test.ts @@ -343,7 +343,8 @@ describe("writing to a team", () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ result: { openShiftId: OPEN, status: "open", replayed: true } }); expect(mocks.rpc).toHaveBeenCalledTimes(1); - const { action: _action, ...payload } = gap; + const payload = { ...gap } as Partial; + delete payload.action; expect(mocks.rpc).toHaveBeenCalledWith("roster_open_post_once", { p_actor_id: ALEX, p_service_id: SERVICE, From 6094429d7e2ef1fb73d91856266a3e7611258602 Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:51:52 +0000 Subject: [PATCH 5/6] =?UTF-8?q?fix:=203=20findings=20=E2=80=94=20Retry=20u?= =?UTF-8?q?ndelivered=20open-shift=20alerts;=20Clear=20poster=20IDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Retry undelivered open-shift alerts - Clear poster IDs when accounts are deleted - Repost shifts after cancelled-key replays --- src/lib/roster/alerts/dispatch.ts | 33 +++++++++-- src/lib/supabase/database.types.ts | 18 ++++++ ...40000_roster_open_post_replay_receipts.sql | 57 +++++++++++++++++++ supabase/schema.sql | 20 ++++++- 4 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 supabase/migrations/20261015140000_roster_open_post_replay_receipts.sql diff --git a/src/lib/roster/alerts/dispatch.ts b/src/lib/roster/alerts/dispatch.ts index 5351fcb682..4e64bbc96d 100644 --- a/src/lib/roster/alerts/dispatch.ts +++ b/src/lib/roster/alerts/dispatch.ts @@ -36,9 +36,18 @@ 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; + // A replay is suppressed only after the deferred alert work recorded a receipt. If the + // request succeeded but the after-response work was killed, the retry is the recovery path. + if (result.replayed === true && result.openShiftId) { + const { data: receipt, error } = await client + .from("roster_open_post_alerts") + .select("delivered_at") + .eq("service_id", event.serviceId) + .eq("open_shift_id", result.openShiftId) + .maybeSingle(); + if (error) throw error; + if (receipt?.delivered_at) 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"); @@ -120,12 +129,26 @@ export async function dispatchRosterAlerts(client: RosterAdminClient, event: Ros ].slice(0, 500); const active = await activeRecipientIds(client, event.serviceId, candidateIds); const eligible = candidateIds.filter((id) => active.has(id)); - if (!eligible.length) return; + if (!eligible.length) { + if (action === "open.post" && result.status === "open" && result.openShiftId) { + await client.from("roster_open_post_alerts").upsert( + { service_id: event.serviceId, open_shift_id: result.openShiftId, delivered_at: new Date().toISOString() }, + { onConflict: "service_id,open_shift_id" }, + ); + } + return; + } const quiet = type === "changed" ? new Set() : await recipientsOnNightNow(client, event.actorId, event.serviceId, eligible); - await sendRosterAlerts(client, eligible, type, quiet); + const delivery = await sendRosterAlerts(client, eligible, type, quiet); + if (action === "open.post" && result.status === "open" && result.openShiftId && delivery.failed === 0) { + await client.from("roster_open_post_alerts").upsert( + { service_id: event.serviceId, open_shift_id: result.openShiftId, delivered_at: new Date().toISOString() }, + { onConflict: "service_id,open_shift_id" }, + ); + } } catch { // A phone alert must never turn a successful roster change into an error. // No identity, endpoint or payload is logged here. diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index 0f9c5222c2..6f0cfec637 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -4387,6 +4387,24 @@ export type Database = { }; Relationships: []; }; + roster_open_post_alerts: { + Row: { + delivered_at: string; + open_shift_id: string; + service_id: string; + }; + Insert: { + delivered_at?: string; + open_shift_id: string; + service_id: string; + }; + Update: { + delivered_at?: string; + open_shift_id?: string; + service_id?: string; + }; + Relationships: []; + }; roster_open_shifts: { Row: { assignment_id: string | null; diff --git a/supabase/migrations/20261015140000_roster_open_post_replay_receipts.sql b/supabase/migrations/20261015140000_roster_open_post_replay_receipts.sql new file mode 100644 index 0000000000..b8bc520989 --- /dev/null +++ b/supabase/migrations/20261015140000_roster_open_post_replay_receipts.sql @@ -0,0 +1,57 @@ +-- Recover deferred open-post alerts on a retried request, and do not replay a key +-- after its shift has ceased to be live. +set local lock_timeout = '5s'; +set local statement_timeout = '30s'; + +alter table public.roster_open_post_keys + add constraint roster_open_post_keys_posted_by_fkey + foreign key (posted_by) references auth.users (id) on delete set null; + +create table public.roster_open_post_alerts ( + service_id uuid not null references public.on_call_services (id) on delete cascade, + open_shift_id uuid not null references public.roster_open_shifts (id) on delete cascade, + delivered_at timestamptz not null default now(), + primary key (service_id, open_shift_id) +); + +alter table public.roster_open_post_alerts enable row level security; +revoke all on table public.roster_open_post_alerts from public, anon, authenticated; +grant select, insert, update, delete on table public.roster_open_post_alerts to service_role; + +create or replace 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; + 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 + 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; + -- A cancelled/expired shift must not make the client believe this date was posted. + if v_status is null or v_status not in ('reported', 'open', 'claimed') then + delete from public.roster_open_post_keys where service_id = p_service_id and post_key = p_post_key; + else + return jsonb_build_object('openShiftId', v_key.open_shift_id, 'status', v_status, 'replayed', true); + end if; + 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 174e7783d5..83622af854 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -34186,12 +34186,23 @@ 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, + posted_by uuid references auth.users (id) on delete set null, 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); +create table public.roster_open_post_alerts ( + service_id uuid not null references public.on_call_services (id) on delete cascade, + open_shift_id uuid not null references public.roster_open_shifts (id) on delete cascade, + delivered_at timestamptz not null default now(), + primary key (service_id, open_shift_id) +); + +alter table public.roster_open_post_alerts enable row level security; +revoke all on table public.roster_open_post_alerts from public, anon, authenticated; +grant select, insert, update, delete on table public.roster_open_post_alerts to service_role; + 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; @@ -34220,7 +34231,12 @@ begin -- 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); + -- A cancelled/expired shift must not make the client believe this date was posted. + if v_status is null or v_status not in ('reported', 'open', 'claimed') then + delete from public.roster_open_post_keys where service_id = p_service_id and post_key = p_post_key; + else + return jsonb_build_object('openShiftId', v_key.open_shift_id, 'status', v_status, 'replayed', true); + end if; end if; v_result := public.roster_command(p_actor_id, p_service_id, 'open.post', coalesce(p_payload, '{}'::jsonb)); From 34e76e6d4064bd9bc2627dcb31d5229f5497b0fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:54:11 +0000 Subject: [PATCH 6/6] Revert an unreviewed bot commit that added a second migration Qodo pushed 6094429d7 with a new migration (20261015140000) that the owner has not approved. Under the database permission rule it cannot merge. Reverted so the PR matches the change the owner said yes to; the findings are handled separately. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LuYbdEGXeMntVA9KMgtAEx --- src/lib/roster/alerts/dispatch.ts | 33 ++--------- src/lib/supabase/database.types.ts | 18 ------ ...40000_roster_open_post_replay_receipts.sql | 57 ------------------- supabase/schema.sql | 20 +------ 4 files changed, 7 insertions(+), 121 deletions(-) delete mode 100644 supabase/migrations/20261015140000_roster_open_post_replay_receipts.sql diff --git a/src/lib/roster/alerts/dispatch.ts b/src/lib/roster/alerts/dispatch.ts index 4e64bbc96d..5351fcb682 100644 --- a/src/lib/roster/alerts/dispatch.ts +++ b/src/lib/roster/alerts/dispatch.ts @@ -36,18 +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 replay is suppressed only after the deferred alert work recorded a receipt. If the - // request succeeded but the after-response work was killed, the retry is the recovery path. - if (result.replayed === true && result.openShiftId) { - const { data: receipt, error } = await client - .from("roster_open_post_alerts") - .select("delivered_at") - .eq("service_id", event.serviceId) - .eq("open_shift_id", result.openShiftId) - .maybeSingle(); - if (error) throw error; - if (receipt?.delivered_at) 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"); @@ -129,26 +120,12 @@ export async function dispatchRosterAlerts(client: RosterAdminClient, event: Ros ].slice(0, 500); const active = await activeRecipientIds(client, event.serviceId, candidateIds); const eligible = candidateIds.filter((id) => active.has(id)); - if (!eligible.length) { - if (action === "open.post" && result.status === "open" && result.openShiftId) { - await client.from("roster_open_post_alerts").upsert( - { service_id: event.serviceId, open_shift_id: result.openShiftId, delivered_at: new Date().toISOString() }, - { onConflict: "service_id,open_shift_id" }, - ); - } - return; - } + if (!eligible.length) return; const quiet = type === "changed" ? new Set() : await recipientsOnNightNow(client, event.actorId, event.serviceId, eligible); - const delivery = await sendRosterAlerts(client, eligible, type, quiet); - if (action === "open.post" && result.status === "open" && result.openShiftId && delivery.failed === 0) { - await client.from("roster_open_post_alerts").upsert( - { service_id: event.serviceId, open_shift_id: result.openShiftId, delivered_at: new Date().toISOString() }, - { onConflict: "service_id,open_shift_id" }, - ); - } + await sendRosterAlerts(client, eligible, type, quiet); } catch { // A phone alert must never turn a successful roster change into an error. // No identity, endpoint or payload is logged here. diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index 6f0cfec637..0f9c5222c2 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -4387,24 +4387,6 @@ export type Database = { }; Relationships: []; }; - roster_open_post_alerts: { - Row: { - delivered_at: string; - open_shift_id: string; - service_id: string; - }; - Insert: { - delivered_at?: string; - open_shift_id: string; - service_id: string; - }; - Update: { - delivered_at?: string; - open_shift_id?: string; - service_id?: string; - }; - Relationships: []; - }; roster_open_shifts: { Row: { assignment_id: string | null; diff --git a/supabase/migrations/20261015140000_roster_open_post_replay_receipts.sql b/supabase/migrations/20261015140000_roster_open_post_replay_receipts.sql deleted file mode 100644 index b8bc520989..0000000000 --- a/supabase/migrations/20261015140000_roster_open_post_replay_receipts.sql +++ /dev/null @@ -1,57 +0,0 @@ --- Recover deferred open-post alerts on a retried request, and do not replay a key --- after its shift has ceased to be live. -set local lock_timeout = '5s'; -set local statement_timeout = '30s'; - -alter table public.roster_open_post_keys - add constraint roster_open_post_keys_posted_by_fkey - foreign key (posted_by) references auth.users (id) on delete set null; - -create table public.roster_open_post_alerts ( - service_id uuid not null references public.on_call_services (id) on delete cascade, - open_shift_id uuid not null references public.roster_open_shifts (id) on delete cascade, - delivered_at timestamptz not null default now(), - primary key (service_id, open_shift_id) -); - -alter table public.roster_open_post_alerts enable row level security; -revoke all on table public.roster_open_post_alerts from public, anon, authenticated; -grant select, insert, update, delete on table public.roster_open_post_alerts to service_role; - -create or replace 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; - 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 - 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; - -- A cancelled/expired shift must not make the client believe this date was posted. - if v_status is null or v_status not in ('reported', 'open', 'claimed') then - delete from public.roster_open_post_keys where service_id = p_service_id and post_key = p_post_key; - else - return jsonb_build_object('openShiftId', v_key.open_shift_id, 'status', v_status, 'replayed', true); - end if; - 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 83622af854..174e7783d5 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -34186,23 +34186,12 @@ 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 references auth.users (id) on delete set null, + 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); -create table public.roster_open_post_alerts ( - service_id uuid not null references public.on_call_services (id) on delete cascade, - open_shift_id uuid not null references public.roster_open_shifts (id) on delete cascade, - delivered_at timestamptz not null default now(), - primary key (service_id, open_shift_id) -); - -alter table public.roster_open_post_alerts enable row level security; -revoke all on table public.roster_open_post_alerts from public, anon, authenticated; -grant select, insert, update, delete on table public.roster_open_post_alerts to service_role; - 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; @@ -34231,12 +34220,7 @@ begin -- 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; - -- A cancelled/expired shift must not make the client believe this date was posted. - if v_status is null or v_status not in ('reported', 'open', 'claimed') then - delete from public.roster_open_post_keys where service_id = p_service_id and post_key = p_post_key; - else - return jsonb_build_object('openShiftId', v_key.open_shift_id, 'status', v_status, 'replayed', true); - end if; + 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));