Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
63 changes: 63 additions & 0 deletions src/components/open-shifts/open-shifts-post-keys.ts
Original file line number Diff line number Diff line change
@@ -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<string, { key: string; at: number }>;

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<string, unknown>)) {
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<string, string>, now = Date.now()): string {
const stored = read(now);
const key = memory[signature] ?? stored[signature]?.key ?? crypto.randomUUID();
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
memory[signature] = key;
if (stored[signature]?.key !== key) write({ ...stored, [signature]: { key, at: now } });
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
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<string, string>, now = Date.now()): void {
delete memory[signature];
const stored = read(now);
if (signature in stored) {
delete stored[signature];
write(stored);
}
}
49 changes: 43 additions & 6 deletions src/components/open-shifts/open-shifts-post-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +26,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 {
Expand Down Expand Up @@ -109,10 +111,30 @@ export function OpenShiftsPostPage() {
const [unanswered, setUnanswered] = useState<
Record<string, { readonly ids: readonly string[]; readonly at: number; readonly serviceId: string }>
>({});
// 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<Record<string, string>>({});
// 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);
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 = {};
signIns.current += 1;
}),
[],
);
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<string | null>(null);
Expand Down Expand Up @@ -219,6 +241,8 @@ export function OpenShiftsPostPage() {
* rest; the answer names every date that went live and every one that didn't.
*/
async function post(): Promise<string | null> {
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.
Expand All @@ -235,7 +259,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 || signIns.current !== postingSignIn) {
stopped = true;
break;
}
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
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
Expand All @@ -253,14 +283,15 @@ 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(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 = postKeys.current[signature(on)] ?? crypto.randomUUID();
postKeys.current[signature(on)] = postKey;
const postKey = postKeyFor(keyFor(on, postingActorId), postKeys.current);
const result = await postRosterAction(team.serviceId, {
action: "open.post",
startsAt: onTimes.startsAt,
Expand All @@ -275,7 +306,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(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
Expand All @@ -291,8 +322,14 @@ export function OpenShiftsPostPage() {
}
}
setProgress(null);
// Keep what this run learned even when it stopped, so a date that already went live is never sent again.
setDone(nowDone);
setUnanswered(nowUnanswered);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
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);
Expand Down
8 changes: 8 additions & 0 deletions src/lib/account-scoped-browser-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
stripAccountScopedPreferences();
// The catch-all: every other `psychsift:` key, the clinical drafts, IndexedDB,
// page caches and PsychSift's notifications (see account-device-sweep.ts).
Expand Down
5 changes: 3 additions & 2 deletions supabase/applied-migration-hashes.json
Original file line number Diff line number Diff line change
@@ -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-15T17:36:28.021Z",
"sealed_at": "2026-10-15T23:25:36.210Z",
"versions": {
"20260527000000_bulk_ingestion.sql": "813e8e314064a6dcbaaa55e2765a8ce901d5783b304b4a7af7bb7417a07daf7c",
"20260527001000_documents_policy_cleanup.sql": "559c26f3172bf75e545a41adef75538b2bf648ea7d53c8eb3d3ea7c244c65a48",
Expand Down Expand Up @@ -314,6 +314,7 @@
"20261014164220_validate_managed_ingestion_historical_reconciliation.sql": "8ffd75292620e82065300eb1e1d008c229545dde37826862820bfd6ea1314587",
"20261015160000_document_child_policies_check_parent_owner.sql": "71210bbc5aecdca74bee2ae32181ba2861e65017ca1a5e87995aa8e557946094",
"20261015174500_roster_open_post_once.sql": "a4edfe07497e3b833c4bedecf1b41d0d7b9fb0b73a798a71b3c17bccb9f851ff",
"20261015180000_prefilter_text_only_governed_retrieval.sql": "8ff1f445269ad6413d5f00b304fc169dd2521f0d01e215415bbc4f573d2a926a"
"20261015180000_prefilter_text_only_governed_retrieval.sql": "8ff1f445269ad6413d5f00b304fc169dd2521f0d01e215415bbc4f573d2a926a",
"20261015181500_roster_open_post_keys_posted_by_fk.sql": "57963eee880db9542b6e9a3383dcef01625a0d05bcbbc3465cd43ec613860639"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
}
7 changes: 6 additions & 1 deletion supabase/drift-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"generated_at": "2026-10-15T23:31:52.942Z",
"generator": "scripts/generate-drift-manifest.ts",
"postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83",
"schema_sha256": "067f617ef440b12532d63d7a4e89054919da9258e403c1beaace99a7578033f2",
"schema_sha256": "6c24504e8ec1a03728867dada28a4668c56d33e819f65940db7fa45f45a88fd0",
"replay_seconds": 34,
"snapshot": {
"views": [
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
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;
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
4 changes: 4 additions & 0 deletions supabase/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -34213,6 +34213,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;
Expand Down
Loading
Loading