From ac1f6af8daa404474ae86c8b3def676ac70a1d11 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 18:28:56 +0000 Subject: [PATCH 01/11] Open shifts follow-up: keep post keys across a reload, clear them on account deletion - Post keys are kept in the tab's session storage, so a retry after a reload sends the same key and the server answers with the shift it already saved. - roster_open_post_keys.posted_by now references auth.users on delete set null, like roster_open_shifts.posted_by (migration 20261015181500, not yet sealed). - Account-deletion SQL test covers the post keys. Roster alerts test checks the new post takes the offer path. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LuYbdEGXeMntVA9KMgtAEx --- .../open-shifts/open-shifts-post-keys.ts | 63 +++++++++++++++++++ .../open-shifts/open-shifts-post-page.tsx | 13 ++-- src/lib/account-scoped-browser-state.ts | 8 +++ supabase/drift-manifest.json | 7 ++- ...500_roster_open_post_keys_posted_by_fk.sql | 17 +++++ supabase/schema.sql | 4 ++ tests/open-shifts-post-retry.dom.test.tsx | 29 +++++++++ tests/roster-alerts.test.ts | 5 ++ tests/sql/roster-behaviour.sql | 7 +++ 9 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 src/components/open-shifts/open-shifts-post-keys.ts create mode 100644 supabase/migrations/20261015181500_roster_open_post_keys_posted_by_fk.sql diff --git a/src/components/open-shifts/open-shifts-post-keys.ts b/src/components/open-shifts/open-shifts-post-keys.ts new file mode 100644 index 0000000000..f092f00d25 --- /dev/null +++ b/src/components/open-shifts/open-shifts-post-keys.ts @@ -0,0 +1,63 @@ +import { OPEN_SHIFTS_POST_KEYS_STORAGE_KEY } from "@/lib/account-scoped-browser-state"; + +/** + * The retry key for each shift being posted, kept for this tab so it survives a reload. If a post + * went live but its answer was lost, reloading the page and posting the same shift again sends the + * same key, and the server answers with the shift it already saved instead of saving a second one. + * A key is dropped once its post is confirmed, so a deliberate later post of the same shift gets a + * new key. Keys older than a day are dropped as stale. Storage can be missing or blocked, so every + * read and write is guarded and the page still works for this visit without it. + */ + +const KEY = OPEN_SHIFTS_POST_KEYS_STORAGE_KEY; +const MAX_AGE_MS = 24 * 60 * 60 * 1000; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +type Stored = Record; + +function read(now: number): Stored { + try { + const raw = window.sessionStorage.getItem(KEY); + const value = raw ? (JSON.parse(raw) as unknown) : null; + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const kept: Stored = {}; + for (const [signature, entry] of Object.entries(value as Record)) { + if (!entry || typeof entry !== "object") continue; + const { key, at } = entry as { key?: unknown; at?: unknown }; + if (typeof key === "string" && UUID.test(key) && typeof at === "number" && now - at < MAX_AGE_MS) { + kept[signature] = { key, at }; + } + } + return kept; + } catch { + return {}; + } +} + +function write(stored: Stored): void { + try { + if (Object.keys(stored).length === 0) window.sessionStorage.removeItem(KEY); + else window.sessionStorage.setItem(KEY, JSON.stringify(stored)); + } catch { + // Storage is blocked or full: the key still holds for this visit. + } +} + +/** The key for this shift: the one kept from an earlier unanswered attempt, or a new one, kept now. */ +export function postKeyFor(signature: string, memory: Record, now = Date.now()): string { + const stored = read(now); + const key = memory[signature] ?? stored[signature]?.key ?? crypto.randomUUID(); + memory[signature] = key; + if (stored[signature]?.key !== key) write({ ...stored, [signature]: { key, at: now } }); + return key; +} + +/** The post is confirmed, so a later post of the same shift is a new one and gets a new key. */ +export function forgetPostKey(signature: string, memory: Record, now = Date.now()): void { + delete memory[signature]; + const stored = read(now); + if (signature in stored) { + delete stored[signature]; + write(stored); + } +} diff --git a/src/components/open-shifts/open-shifts-post-page.tsx b/src/components/open-shifts/open-shifts-post-page.tsx index 77dbc5a62c..9d49e658f5 100644 --- a/src/components/open-shifts/open-shifts-post-page.tsx +++ b/src/components/open-shifts/open-shifts-post-page.tsx @@ -25,6 +25,7 @@ import { addDaysToDate, perthDateOf } from "@/lib/roster/shifts/perth-time"; import { ROSTER_OPEN_SHIFT_KINDS, type RosterAction } from "@/lib/roster/team/model"; import { ConfirmSheet } from "./open-shifts-confirm"; +import { forgetPostKey, postKeyFor } from "./open-shifts-post-keys"; import { SignInAction } from "./open-shifts-sign-in"; import { LoadFailed, NoTeam } from "./open-shifts-states"; import { @@ -109,9 +110,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. + // One post key per date being posted, by everything that was sent, also kept for this tab so it survives a + // reload. 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. @@ -259,8 +261,7 @@ 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 postKey = postKeyFor(signature(on), postKeys.current); const result = await postRosterAction(team.serviceId, { action: "open.post", startsAt: onTimes.startsAt, @@ -275,7 +276,7 @@ export function OpenShiftsPostPage() { 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)]; + forgetPostKey(signature(on), postKeys.current); } 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/account-scoped-browser-state.ts b/src/lib/account-scoped-browser-state.ts index 4def75f271..b683ba1792 100644 --- a/src/lib/account-scoped-browser-state.ts +++ b/src/lib/account-scoped-browser-state.ts @@ -202,6 +202,13 @@ export const WORK_ACCOUNT_SYNC_MARKER_KEY = "psychsift:work:account-sync-v1"; * `psychsift:` prefix, so the sweep would miss it: it is named here. */ export const OPEN_SHIFTS_SAVED_FILTERS_STORAGE_KEY = "psychsift.open-shifts.filters.v1"; +/** + * sessionStorage — Open shifts Post: the retry key for each date whose post got no answer, by the + * shift that was sent, so a retry after a reload sends the same key and the server never saves the + * shift twice. Random keys and the shift's team, times and code, no name. Cleared on account + * transitions so the next doctor in the tab starts with no keys. + */ +export const OPEN_SHIFTS_POST_KEYS_STORAGE_KEY = "psychsift:open-shifts:post-keys-v1"; /** * The app preferences (`use-app-preferences.ts`). Display settings in it are the @@ -305,6 +312,7 @@ export function clearAccountScopedBrowserStorage(): void { removeQuietly(() => window.localStorage, WORK_TAB_PICKS_STORAGE_KEY); removeQuietly(() => window.localStorage, WORK_ACCOUNT_SYNC_MARKER_KEY); removeQuietly(() => window.localStorage, OPEN_SHIFTS_SAVED_FILTERS_STORAGE_KEY); + removeQuietly(() => window.sessionStorage, OPEN_SHIFTS_POST_KEYS_STORAGE_KEY); stripAccountScopedPreferences(); // The catch-all: every other `psychsift:` key, the clinical drafts, IndexedDB, // page caches and PsychSift's notifications (see account-device-sweep.ts). diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 2ef9197500..6187b52c64 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -2,7 +2,7 @@ "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": "e561ea277c34ddec08d6b676ebcb8603c0e98b04e0ca311c9fbf4a4f49246895", + "schema_sha256": "dd71af292b7d21af14c035e945508ade13c7cf23869c22a5ffffba169e9d62f1", "replay_seconds": 38, "snapshot": { "views": [ @@ -23729,6 +23729,11 @@ "name": "roster_open_post_keys_pkey", "table": "roster_open_post_keys" }, + { + "def": "FOREIGN KEY (posted_by) REFERENCES auth.users(id) ON DELETE SET NULL", + "name": "roster_open_post_keys_posted_by_fkey", + "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", diff --git a/supabase/migrations/20261015181500_roster_open_post_keys_posted_by_fk.sql b/supabase/migrations/20261015181500_roster_open_post_keys_posted_by_fk.sql new file mode 100644 index 0000000000..53ecee89ed --- /dev/null +++ b/supabase/migrations/20261015181500_roster_open_post_keys_posted_by_fk.sql @@ -0,0 +1,17 @@ +-- Never edit an applied migration. Merging this applies it to the live database. +-- Deleting a doctor's account also clears their id from the open-shift post keys. +-- roster_open_shifts.posted_by already clears itself when the account goes (on delete set null); +-- the post keys added in 20261015174500 kept the same id with no link to the account, so it stayed +-- until the open shift itself was purged. This gives roster_open_post_keys.posted_by the same +-- link. Any key whose poster's account is already gone is cleared first so the link can be added. +set local lock_timeout = '5s'; +set local statement_timeout = '30s'; + +update public.roster_open_post_keys k + set posted_by = null + where k.posted_by is not null + and not exists (select 1 from auth.users u where u.id = k.posted_by); + +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; diff --git a/supabase/schema.sql b/supabase/schema.sql index 5c41424857..b59ef67d12 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -34203,6 +34203,10 @@ create table public.roster_open_post_keys ( primary key (service_id, post_key) ); create index roster_open_post_keys_open_shift_idx on public.roster_open_post_keys (open_shift_id); +-- Deleting an account clears its id from the post keys too (20261015181500_roster_open_post_keys_posted_by_fk.sql). +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; alter table public.roster_open_post_keys enable row level security; revoke all on table public.roster_open_post_keys from public, anon, authenticated; diff --git a/tests/open-shifts-post-retry.dom.test.tsx b/tests/open-shifts-post-retry.dom.test.tsx index d90fd734e9..240ef17f60 100644 --- a/tests/open-shifts-post-retry.dom.test.tsx +++ b/tests/open-shifts-post-retry.dom.test.tsx @@ -65,6 +65,7 @@ function liveShift(): PostedShift { } beforeEach(() => { + window.sessionStorage.clear(); mocks.post.mockReset(); mocks.push.mockReset(); mocks.state = { @@ -192,6 +193,34 @@ describe("Posting a shift", () => { expect(mocks.push.mock.calls[0]![0]).toContain(SAVED); }); + it("after a post gets no answer and the page is reloaded, the retry still sends the same post key", async () => { + const SAVED = "5e000000-0000-4000-8000-0000000000b6"; + 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 }, + }); + 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/)); + + // Reload: the page starts again from nothing but the tab's storage, and the same shift is posted. + cleanup(); + 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()); + + const [first, retry] = mocks.post.mock.calls.map((call) => (call[1] as { postKey?: string }).postKey); + expect(first).toBeTruthy(); + expect(retry).toBe(first); + expect(mocks.push.mock.calls[0]![0]).toContain(SAVED); + // Confirmed, so the key is gone and a later post of the same shift would be a new one. + expect(window.sessionStorage.getItem("psychsift:open-shifts:post-keys-v1")).toBeNull(); + }); + it("gives a deliberate new post its own key once the last one went live", async () => { mocks.post.mockResolvedValue({ ok: true, diff --git a/tests/roster-alerts.test.ts b/tests/roster-alerts.test.ts index 71e8688591..26cc5cc13c 100644 --- a/tests/roster-alerts.test.ts +++ b/tests/roster-alerts.test.ts @@ -139,6 +139,11 @@ describe("generic phone alerts", () => { result: { openShiftId: OPEN, status: "open" }, }); expect(mocks.open).toHaveBeenCalledWith(client, SERVICE, OPEN); + // The new post took the offer path: it read the week either side of the gap to find who to offer it to. + expect(mocks.read).toHaveBeenCalledWith(client, ALEX, SERVICE, "assignments", { + from: "2026-11-13", + to: "2026-11-27", + }); vi.clearAllMocks(); await dispatchRosterAlerts(client, { diff --git a/tests/sql/roster-behaviour.sql b/tests/sql/roster-behaviour.sql index 10bfb826da..aca560da57 100644 --- a/tests/sql/roster-behaviour.sql +++ b/tests/sql/roster-behaviour.sql @@ -579,7 +579,14 @@ reset role; do $delete_account$ declare lee uuid := pg_temp.id('lee'); begin + -- An open-shift post key that names them, so deleting the account has to clear it. + insert into public.roster_open_post_keys (service_id, post_key, open_shift_id, posted_by) + select service_id, gen_random_uuid(), open_shift_id, lee from public.roster_open_post_keys limit 1; + if not exists (select 1 from public.roster_open_post_keys where posted_by = lee) then + raise exception 'delete: no post key fixture to check'; + end if; delete from auth.users where id = lee; + if exists (select 1 from public.roster_open_post_keys where posted_by = lee) then raise exception 'delete: post key still names them'; end if; if exists (select 1 from public.on_call_shifts where owner_id = lee) then raise exception 'delete: own shifts kept'; end if; if exists (select 1 from public.roster_publication_seen where user_id = lee) then raise exception 'delete: seen kept'; end if; if exists (select 1 from public.roster_assignments where user_id = lee) then raise exception 'delete: assignment still names them'; end if; From e270353c3f8a154971a11b9eabd2b0acef4476ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 23:28:41 +0000 Subject: [PATCH 02/11] Seal the Open shifts follow-up migration Adds the new migration's hash only. No existing entry changes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LuYbdEGXeMntVA9KMgtAEx --- supabase/applied-migration-hashes.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/supabase/applied-migration-hashes.json b/supabase/applied-migration-hashes.json index 0a76a4211b..9a1c69712d 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-15T15:57:11.538Z", + "sealed_at": "2026-10-15T23:25:36.210Z", "versions": { "20260527000000_bulk_ingestion.sql": "813e8e314064a6dcbaaa55e2765a8ce901d5783b304b4a7af7bb7417a07daf7c", "20260527001000_documents_policy_cleanup.sql": "559c26f3172bf75e545a41adef75538b2bf648ea7d53c8eb3d3ea7c244c65a48", @@ -313,6 +313,7 @@ "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", - "20261015174500_roster_open_post_once.sql": "a4edfe07497e3b833c4bedecf1b41d0d7b9fb0b73a798a71b3c17bccb9f851ff" + "20261015174500_roster_open_post_once.sql": "a4edfe07497e3b833c4bedecf1b41d0d7b9fb0b73a798a71b3c17bccb9f851ff", + "20261015181500_roster_open_post_keys_posted_by_fk.sql": "57963eee880db9542b6e9a3383dcef01625a0d05bcbbc3465cd43ec613860639" } } From 5cb9dadbad45969fdd0cde73af168310416b27d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 23:33:03 +0000 Subject: [PATCH 03/11] Gitleaks: ignore the migration seal hash for the post keys migration The filename contains "keys" and its sha256 value matches the generic-api-key rule. It is a file hash, not a secret. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LuYbdEGXeMntVA9KMgtAEx --- .gitleaksignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitleaksignore b/.gitleaksignore index 85dc5e4db1..2a3ff8fff2 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -170,3 +170,6 @@ a958fdce92f53571c02fa3ea2a03c61300122763:supabase/applied-migration-hashes.json: # migrations rewrote the snapshot (CRLF to LF), so the unchanged line rescans as new. dd709e6fcc6bf8940272537e8afada52396af1a4:supabase/schema.sql:generic-api-key:13492 2323e43ae1484f18f6e245034867095af84c5360:supabase/schema.sql:generic-api-key:13492 +# Migration seal entry: the filename contains "keys" and the value is a sha256, +# which the generic-api-key rule reads as a key. Not a secret. +e270353c3f8a154971a11b9eabd2b0acef4476ec:supabase/applied-migration-hashes.json:generic-api-key:317 From 2444c3b1f57e486275243a981d086304600a1fd9 Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:18:03 +0000 Subject: [PATCH 04/11] fix: Index post keys by account --- .../20261015181500_roster_open_post_keys_posted_by_fk.sql | 2 ++ supabase/schema.sql | 1 + 2 files changed, 3 insertions(+) diff --git a/supabase/migrations/20261015181500_roster_open_post_keys_posted_by_fk.sql b/supabase/migrations/20261015181500_roster_open_post_keys_posted_by_fk.sql index 53ecee89ed..5a9344d0f4 100644 --- a/supabase/migrations/20261015181500_roster_open_post_keys_posted_by_fk.sql +++ b/supabase/migrations/20261015181500_roster_open_post_keys_posted_by_fk.sql @@ -12,6 +12,8 @@ update public.roster_open_post_keys k where k.posted_by is not null and not exists (select 1 from auth.users u where u.id = k.posted_by); +create index roster_open_post_keys_posted_by_idx on public.roster_open_post_keys (posted_by); + 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; diff --git a/supabase/schema.sql b/supabase/schema.sql index b59ef67d12..1611975cc2 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -34203,6 +34203,7 @@ create table public.roster_open_post_keys ( 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 index roster_open_post_keys_posted_by_idx on public.roster_open_post_keys (posted_by); -- Deleting an account clears its id from the post keys too (20261015181500_roster_open_post_keys_posted_by_fk.sql). alter table public.roster_open_post_keys add constraint roster_open_post_keys_posted_by_fkey From 81da70bbe0b0b6fc8046c345f5b7d7a6574195bc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 05:17:06 +0000 Subject: [PATCH 05/11] Open shifts: forget the post key when the list confirms a lost post When a post got no answer but the refreshed list shows it went live, the saved key stayed for 24 hours. A later deliberate post of the same slot would reuse it and get the old shift back instead of a new opening. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LuYbdEGXeMntVA9KMgtAEx --- src/components/open-shifts/open-shifts-post-page.tsx | 2 ++ tests/open-shifts-post-retry.dom.test.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/components/open-shifts/open-shifts-post-page.tsx b/src/components/open-shifts/open-shifts-post-page.tsx index 9d49e658f5..521920fdd9 100644 --- a/src/components/open-shifts/open-shifts-post-page.tsx +++ b/src/components/open-shifts/open-shifts-post-page.tsx @@ -255,6 +255,8 @@ export function OpenShiftsPostPage() { if (live) { attempts.push({ date: on, ok: true, openShiftId: live.id }); nowDone[signature(on)] = live.id; + // Confirmed from the list, so a later deliberate post of the same slot gets a new key. + forgetPostKey(signature(on), postKeys.current); continue; } if (!onTimes) { diff --git a/tests/open-shifts-post-retry.dom.test.tsx b/tests/open-shifts-post-retry.dom.test.tsx index 240ef17f60..216bb97e06 100644 --- a/tests/open-shifts-post-retry.dom.test.tsx +++ b/tests/open-shifts-post-retry.dom.test.tsx @@ -116,6 +116,8 @@ describe("Posting a shift", () => { await waitFor(() => expect(mocks.push).toHaveBeenCalledOnce()); expect(mocks.post).toHaveBeenCalledOnce(); expect(mocks.push.mock.calls[0]![0]).toContain(liveShift().id); + // Confirmed from the list, so the saved key is gone and a later post of the same slot is a new one. + expect(window.sessionStorage.getItem("psychsift:open-shifts:post-keys-v1")).toBeNull(); }); it("after a post gets no answer, still sends it when the matching new shift was posted by someone else", async () => { From d67b83d703ac8ae777aeedf2a54594e7a4d03689 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 05:26:09 +0000 Subject: [PATCH 06/11] Open shifts: tie post keys to the signed-in person If someone else signed in on the same tab while the post page stayed open, the next post of the same shift reused the earlier person's key. The server refuses a key another poster used, so the post failed. 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 | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/components/open-shifts/open-shifts-post-page.tsx b/src/components/open-shifts/open-shifts-post-page.tsx index 521920fdd9..840116fa56 100644 --- a/src/components/open-shifts/open-shifts-post-page.tsx +++ b/src/components/open-shifts/open-shifts-post-page.tsx @@ -115,6 +115,8 @@ export function OpenShiftsPostPage() { // 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>({}); + // Keys belong to the signed-in person, so someone else signing in on this tab never reuses one. + const keyFor = (on: string) => `${state.actorId ?? ""}|${signature(on)}`; 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); @@ -256,14 +258,14 @@ export function OpenShiftsPostPage() { attempts.push({ date: on, ok: true, openShiftId: live.id }); nowDone[signature(on)] = live.id; // Confirmed from the list, so a later deliberate post of the same slot gets a new key. - forgetPostKey(signature(on), postKeys.current); + forgetPostKey(keyFor(on), postKeys.current); continue; } if (!onTimes) { attempts.push({ date: on, ok: false, message: "The times couldn't be worked out for this date." }); continue; } - const postKey = postKeyFor(signature(on), postKeys.current); + const postKey = postKeyFor(keyFor(on), postKeys.current); const result = await postRosterAction(team.serviceId, { action: "open.post", startsAt: onTimes.startsAt, @@ -278,7 +280,7 @@ export function OpenShiftsPostPage() { if (result.ok) { attempts.push({ date: on, ok: true, openShiftId: result.result.openShiftId ?? null }); nowDone[signature(on)] = result.result.openShiftId ?? ""; - forgetPostKey(signature(on), postKeys.current); + forgetPostKey(keyFor(on), postKeys.current); } 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 216bb97e06..671e492bab 100644 --- a/tests/open-shifts-post-retry.dom.test.tsx +++ b/tests/open-shifts-post-retry.dom.test.tsx @@ -223,6 +223,30 @@ describe("Posting a shift", () => { expect(window.sessionStorage.getItem("psychsift:open-shifts:post-keys-v1")).toBeNull(); }); + it("never reuses another person's post key after someone else signs in on the same tab", async () => { + mocks.state = { ...mocks.state, shifts: [] }; + mocks.post.mockResolvedValue({ ok: false, code: "roster_unavailable", message: "Couldn't reach the server." }); + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "Review and post" })); + fireEvent.click(screen.getByRole("button", { name: "Yes, post it" })); + await waitFor(() => expect(mocks.post).toHaveBeenCalledOnce()); + + // A different manager signs in without the page closing, and posts the same shift. + mocks.state = { + ...mocks.state, + actorId: "5e000000-0000-4000-8000-000000000002", + readAt: new Date(Date.now() + 1000), + }; + view.rerender(); + 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("gives a deliberate new post its own key once the last one went live", async () => { mocks.post.mockResolvedValue({ ok: true, From f6ef7901640443ee9d27e364181c8725d6eb5dc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:54:21 +0000 Subject: [PATCH 07/11] Revert "fix: Index post keys by account" This reverts commit 2444c3b1f57e486275243a981d086304600a1fd9. --- .../20261015181500_roster_open_post_keys_posted_by_fk.sql | 2 -- supabase/schema.sql | 1 - 2 files changed, 3 deletions(-) diff --git a/supabase/migrations/20261015181500_roster_open_post_keys_posted_by_fk.sql b/supabase/migrations/20261015181500_roster_open_post_keys_posted_by_fk.sql index 5a9344d0f4..53ecee89ed 100644 --- a/supabase/migrations/20261015181500_roster_open_post_keys_posted_by_fk.sql +++ b/supabase/migrations/20261015181500_roster_open_post_keys_posted_by_fk.sql @@ -12,8 +12,6 @@ update public.roster_open_post_keys k where k.posted_by is not null and not exists (select 1 from auth.users u where u.id = k.posted_by); -create index roster_open_post_keys_posted_by_idx on public.roster_open_post_keys (posted_by); - 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; diff --git a/supabase/schema.sql b/supabase/schema.sql index 1611975cc2..b59ef67d12 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -34203,7 +34203,6 @@ create table public.roster_open_post_keys ( 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 index roster_open_post_keys_posted_by_idx on public.roster_open_post_keys (posted_by); -- Deleting an account clears its id from the post keys too (20261015181500_roster_open_post_keys_posted_by_fk.sql). alter table public.roster_open_post_keys add constraint roster_open_post_keys_posted_by_fkey From 96a88e39c5f3df3bb4725af52f1719702de7f1f0 Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:09:06 +0000 Subject: [PATCH 08/11] fix: Stop posting when the account changes --- .../open-shifts/open-shifts-post-page.tsx | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/components/open-shifts/open-shifts-post-page.tsx b/src/components/open-shifts/open-shifts-post-page.tsx index 840116fa56..9221ffbc5c 100644 --- a/src/components/open-shifts/open-shifts-post-page.tsx +++ b/src/components/open-shifts/open-shifts-post-page.tsx @@ -115,8 +115,12 @@ export function OpenShiftsPostPage() { // 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>({}); + // Keep the current actor available to an in-progress post, whose render state can otherwise be stale after + // authentication changes. + const actorIdRef = useRef(state.actorId); + actorIdRef.current = state.actorId; // Keys belong to the signed-in person, so someone else signing in on this tab never reuses one. - const keyFor = (on: string) => `${state.actorId ?? ""}|${signature(on)}`; + const keyFor = (on: string, actorId = state.actorId) => `${actorId ?? ""}|${signature(on)}`; 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); @@ -223,6 +227,7 @@ export function OpenShiftsPostPage() { * rest; the answer names every date that went live and every one that didn't. */ async function post(): Promise { + const postingActorId = actorIdRef.current; if (!team || !times) return "Choose a team, date and times."; // A post that got no answer for a team that couldn't be read just now: wait, rather than let the form // fall back to another team and post there instead. A team the manager picked themselves still posts. @@ -239,7 +244,13 @@ export function OpenShiftsPostPage() { const attempts: PostAttempt[] = []; const nowDone = { ...done }; const nowUnanswered = { ...unanswered }; + let stopped = false; for (const [index, on] of toPost.entries()) { + // Do not issue another request after the account changes while a previous date is pending. + if (actorIdRef.current !== postingActorId) { + stopped = true; + break; + } setProgress({ current: index + 1, total: toPost.length }); const onTimes = gapTimes(on, start, end); // A date whose earlier post got no answer waits for a fresh read of the team's shifts, so it is never sent @@ -258,14 +269,14 @@ export function OpenShiftsPostPage() { attempts.push({ date: on, ok: true, openShiftId: live.id }); nowDone[signature(on)] = live.id; // Confirmed from the list, so a later deliberate post of the same slot gets a new key. - forgetPostKey(keyFor(on), postKeys.current); + forgetPostKey(keyFor(on, postingActorId), postKeys.current); continue; } if (!onTimes) { attempts.push({ date: on, ok: false, message: "The times couldn't be worked out for this date." }); continue; } - const postKey = postKeyFor(keyFor(on), postKeys.current); + const postKey = postKeyFor(keyFor(on, postingActorId), postKeys.current); const result = await postRosterAction(team.serviceId, { action: "open.post", startsAt: onTimes.startsAt, @@ -277,10 +288,14 @@ export function OpenShiftsPostPage() { urgent, postKey, } as RosterAction); + if (actorIdRef.current !== postingActorId) { + stopped = true; + break; + } if (result.ok) { attempts.push({ date: on, ok: true, openShiftId: result.result.openShiftId ?? null }); nowDone[signature(on)] = result.result.openShiftId ?? ""; - forgetPostKey(keyFor(on), postKeys.current); + forgetPostKey(keyFor(on, postingActorId), postKeys.current); } 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 @@ -296,6 +311,7 @@ export function OpenShiftsPostPage() { } } setProgress(null); + if (stopped) return "Posting stopped because the signed-in account changed. Please review and try again."; setDone(nowDone); setUnanswered(nowUnanswered); // Pin the team a post with no answer went to, so a re-read that misses it can't quietly switch the form. From 433c359fe76dedf69d743fa3480fe36a1796de8a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:08:48 +0000 Subject: [PATCH 09/11] Drop in-memory open-shift post keys on sign-out Signing out and back in as the same person on one tab kept the page's in-memory post keys, so a deliberate repost could replay the earlier post. The page now clears them on the account transition, with a test. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LuYbdEGXeMntVA9KMgtAEx --- .../open-shifts/open-shifts-post-page.tsx | 13 ++++++++++- tests/open-shifts-post-retry.dom.test.tsx | 22 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/components/open-shifts/open-shifts-post-page.tsx b/src/components/open-shifts/open-shifts-post-page.tsx index 9221ffbc5c..f404f542cf 100644 --- a/src/components/open-shifts/open-shifts-post-page.tsx +++ b/src/components/open-shifts/open-shifts-post-page.tsx @@ -7,6 +7,7 @@ import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useRosterNow } from "@/components/roster/roster-format"; import { postRosterAction } from "@/components/roster/use-roster-team"; import { Button } from "@/components/ui/button"; +import { subscribeAccountTransition } from "@/lib/account-scoped-browser-state"; import { formatHours, gapTimes, gradeLabel, hoursBetween, kindLabel } from "@/lib/open-shifts/model"; import { MAX_POST_DATES, @@ -118,9 +119,19 @@ export function OpenShiftsPostPage() { // Keep the current actor available to an in-progress post, whose render state can otherwise be stale after // authentication changes. const actorIdRef = useRef(state.actorId); - actorIdRef.current = state.actorId; + useEffect(() => { + actorIdRef.current = state.actorId; + }, [state.actorId]); // Keys belong to the signed-in person, so someone else signing in on this tab never reuses one. const keyFor = (on: string, actorId = state.actorId) => `${actorId ?? ""}|${signature(on)}`; + // Signing out drops this tab's keys, so the same person signing back in never replays an older post. + useEffect( + () => + subscribeAccountTransition(() => { + postKeys.current = {}; + }), + [], + ); 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); diff --git a/tests/open-shifts-post-retry.dom.test.tsx b/tests/open-shifts-post-retry.dom.test.tsx index 671e492bab..58ad664047 100644 --- a/tests/open-shifts-post-retry.dom.test.tsx +++ b/tests/open-shifts-post-retry.dom.test.tsx @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PostedShift, PostedShiftsState } from "@/components/open-shifts/use-posted-shifts"; +import { clearAccountScopedBrowserStorage } from "@/lib/account-scoped-browser-state"; import { gapTimes } from "@/lib/open-shifts/model"; import { sampleListings } from "@/lib/open-shifts/sample"; @@ -247,6 +248,27 @@ describe("Posting a shift", () => { expect(second).not.toBe(first); }); + it("never replays an older post key after the same person signs out and back in on this tab", async () => { + mocks.state = { ...mocks.state, shifts: [] }; + mocks.post.mockResolvedValue({ ok: false, code: "roster_unavailable", message: "Couldn't reach the server." }); + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "Review and post" })); + fireEvent.click(screen.getByRole("button", { name: "Yes, post it" })); + await waitFor(() => expect(mocks.post).toHaveBeenCalledOnce()); + + // Sign out and back in as the same manager without the page closing, then post the same shift. + clearAccountScopedBrowserStorage(); + mocks.state = { ...mocks.state, readAt: new Date(Date.now() + 1000) }; + view.rerender(); + 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("gives a deliberate new post its own key once the last one went live", async () => { mocks.post.mockResolvedValue({ ok: true, From ef8884acc22ebfdd4906599a0504ae125ee10a80 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:15:25 +0000 Subject: [PATCH 10/11] Keep posted dates when an account switch stops a post When the signed-in account changed mid-run, the stop path returned before saving which dates had already gone live, so the same person retrying could post a duplicate. The page now records the run's results before stopping, records a request that was already answered, and names the dates that went live in the stop message. Adds a test for the mid-post switch. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LuYbdEGXeMntVA9KMgtAEx --- .../open-shifts/open-shifts-post-page.tsx | 11 ++++---- tests/open-shifts-post-retry.dom.test.tsx | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/components/open-shifts/open-shifts-post-page.tsx b/src/components/open-shifts/open-shifts-post-page.tsx index f404f542cf..a27fc5a85f 100644 --- a/src/components/open-shifts/open-shifts-post-page.tsx +++ b/src/components/open-shifts/open-shifts-post-page.tsx @@ -299,10 +299,6 @@ export function OpenShiftsPostPage() { urgent, postKey, } as RosterAction); - if (actorIdRef.current !== postingActorId) { - stopped = true; - break; - } if (result.ok) { attempts.push({ date: on, ok: true, openShiftId: result.result.openShiftId ?? null }); nowDone[signature(on)] = result.result.openShiftId ?? ""; @@ -322,9 +318,14 @@ export function OpenShiftsPostPage() { } } setProgress(null); - if (stopped) return "Posting stopped because the signed-in account changed. Please review and try again."; + // Keep what this run learned even when it stopped, so a date that already went live is never sent again. setDone(nowDone); setUnanswered(nowUnanswered); + if (stopped) { + const posted = attempts.filter((row) => row.ok).map((row) => formatDayShort(row.date)); + const already = posted.length > 0 ? ` Already posted: ${posted.join(", ")}.` : ""; + return `Posting stopped because the signed-in account changed.${already} Please review and try again.`; + } // Pin the team a post with no answer went to, so a re-read that misses it can't quietly switch the form. if (Object.keys(nowUnanswered).length > Object.keys(unanswered).length) setServiceId(team.serviceId); const { failed } = splitAttempts(attempts); diff --git a/tests/open-shifts-post-retry.dom.test.tsx b/tests/open-shifts-post-retry.dom.test.tsx index 58ad664047..f82090d39f 100644 --- a/tests/open-shifts-post-retry.dom.test.tsx +++ b/tests/open-shifts-post-retry.dom.test.tsx @@ -269,6 +269,34 @@ describe("Posting a shift", () => { expect(second).not.toBe(first); }); + it("when the account changes mid-post, stops, names what went live, and never sends that date again", async () => { + mocks.state = { ...mocks.state, shifts: [] }; + let answerFirst: (value: unknown) => void = () => undefined; + mocks.post.mockImplementationOnce(() => new Promise((resolve) => (answerFirst = resolve))); + mocks.post.mockResolvedValue({ ok: false, code: "roster_unavailable", message: "Couldn't reach the server." }); + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "Add another date" })); + fireEvent.click(screen.getByRole("button", { name: "Review and post 2 shifts" })); + fireEvent.click(screen.getByRole("button", { name: "Yes, post all 2" })); + await waitFor(() => expect(mocks.post).toHaveBeenCalledOnce()); + + // Someone else signs in while the first date is still being sent, and then it goes live. + const first = mocks.state.actorId; + mocks.state = { ...mocks.state, actorId: "5e000000-0000-4000-8000-000000000002" }; + view.rerender(); + answerFirst({ ok: true, result: { openShiftId: "5e000000-0000-4000-8000-0000000000b7", status: "open" } }); + await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/Posting stopped.*Already posted/)); + expect(mocks.post).toHaveBeenCalledOnce(); + + // The first manager signs back in and tries again: only the date that never went out is sent. + mocks.state = { ...mocks.state, actorId: first, readAt: new Date(Date.now() + 1000) }; + view.rerender(); + fireEvent.click(screen.getByRole("button", { name: "Yes, post it" })); + await waitFor(() => expect(mocks.post).toHaveBeenCalledTimes(2)); + const sent = mocks.post.mock.calls.map((call) => (call[1] as { startsAt: string }).startsAt); + expect(sent[1]).not.toBe(sent[0]); + }); + it("gives a deliberate new post its own key once the last one went live", async () => { mocks.post.mockResolvedValue({ ok: true, From 97559753e9516f3fb53c93954619dd7ec8a48dde Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:55:28 +0000 Subject: [PATCH 11/11] Stop an open-shift post as soon as the account signs out The mid-post guard compared the roster's actor ID, which only updates after the team list re-reads, so a sign-out could still let the next date send. The page now also counts sign-outs on the tab and stops on any change. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LuYbdEGXeMntVA9KMgtAEx --- .../open-shifts/open-shifts-post-page.tsx | 6 +++++- tests/open-shifts-post-retry.dom.test.tsx | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/open-shifts/open-shifts-post-page.tsx b/src/components/open-shifts/open-shifts-post-page.tsx index a27fc5a85f..3f39370ae3 100644 --- a/src/components/open-shifts/open-shifts-post-page.tsx +++ b/src/components/open-shifts/open-shifts-post-page.tsx @@ -116,6 +116,8 @@ export function OpenShiftsPostPage() { // 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>({}); + // Counts sign-outs on this tab, so a post in progress stops at once, before the team list catches up. + const signIns = useRef(0); // Keep the current actor available to an in-progress post, whose render state can otherwise be stale after // authentication changes. const actorIdRef = useRef(state.actorId); @@ -129,6 +131,7 @@ export function OpenShiftsPostPage() { () => subscribeAccountTransition(() => { postKeys.current = {}; + signIns.current += 1; }), [], ); @@ -239,6 +242,7 @@ export function OpenShiftsPostPage() { */ async function post(): Promise { const postingActorId = actorIdRef.current; + const postingSignIn = signIns.current; if (!team || !times) return "Choose a team, date and times."; // A post that got no answer for a team that couldn't be read just now: wait, rather than let the form // fall back to another team and post there instead. A team the manager picked themselves still posts. @@ -258,7 +262,7 @@ export function OpenShiftsPostPage() { let stopped = false; for (const [index, on] of toPost.entries()) { // Do not issue another request after the account changes while a previous date is pending. - if (actorIdRef.current !== postingActorId) { + if (actorIdRef.current !== postingActorId || signIns.current !== postingSignIn) { stopped = true; break; } diff --git a/tests/open-shifts-post-retry.dom.test.tsx b/tests/open-shifts-post-retry.dom.test.tsx index f82090d39f..c529d3486c 100644 --- a/tests/open-shifts-post-retry.dom.test.tsx +++ b/tests/open-shifts-post-retry.dom.test.tsx @@ -297,6 +297,23 @@ describe("Posting a shift", () => { expect(sent[1]).not.toBe(sent[0]); }); + it("stops before the next date as soon as the account signs out, even before the team list catches up", async () => { + mocks.state = { ...mocks.state, shifts: [] }; + let answerFirst: (value: unknown) => void = () => undefined; + mocks.post.mockImplementationOnce(() => new Promise((resolve) => (answerFirst = resolve))); + render(); + fireEvent.click(screen.getByRole("button", { name: "Add another date" })); + fireEvent.click(screen.getByRole("button", { name: "Review and post 2 shifts" })); + fireEvent.click(screen.getByRole("button", { name: "Yes, post all 2" })); + await waitFor(() => expect(mocks.post).toHaveBeenCalledOnce()); + + // The account signs out while the first date is still being sent; the team list has not re-read yet. + clearAccountScopedBrowserStorage(); + answerFirst({ ok: true, result: { openShiftId: "5e000000-0000-4000-8000-0000000000b8", status: "open" } }); + await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/Posting stopped/)); + expect(mocks.post).toHaveBeenCalledOnce(); + }); + it("gives a deliberate new post its own key once the last one went live", async () => { mocks.post.mockResolvedValue({ ok: true,