+ The shift goes back on the list for everyone, and your roster manager no longer needs to decide. You can ask
+ again while it's still open.
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ );
+}
+
export function OpenShiftsAdvertPage({
serviceId,
openShiftId,
@@ -265,7 +363,23 @@ export function OpenShiftsAdvertPage({
const [sheetOpen, setSheetOpen] = useState(false);
// Kept mounted after the first open so closing hands focus back to "Request this shift".
const [sheetLoaded, setSheetLoaded] = useState(false);
- const [sent, setSent] = useState(null);
+ const [sentState, setSent] = useState(null);
+ // When the request was sent, so a later read that no longer shows it as this doctor's
+ // (declined or cancelled since) lets this visit's record of sending it lapse.
+ const [sentReadAt, setSentReadAt] = useState(null);
+ const [withdrawOpen, setWithdrawOpen] = useState(false);
+ const [withdrawLoaded, setWithdrawLoaded] = useState(false);
+ // The shift whose waiting request was taken back, so the page drops it before the reload lands.
+ const [withdrawnKey, setWithdrawnKey] = useState(null);
+ // The read the take-back was made against. Only that read is overridden: a newer read is the truth,
+ // so a request made again from another tab or device shows as waiting.
+ const [withdrawnReadAt, setWithdrawnReadAt] = useState(null);
+ const withdrawnNoticeRef = useRef(null);
+ // The button that opened the take-back is gone, so keyboard focus lands on the result instead,
+ // once per take-back rather than on every re-render.
+ useEffect(() => {
+ if (withdrawnKey) withdrawnNoticeRef.current?.focus();
+ }, [withdrawnKey]);
const [autoOpened, setAutoOpened] = useState(false);
const listing = state.listings.find((row) => row.serviceId === serviceId && row.id === openShiftId) ?? null;
@@ -315,14 +429,21 @@ export function OpenShiftsAdvertPage({
const timeText = endsNextDay(listing.startsAt, listing.endsAt)
? `${perthTimeOf(listing.startsAt)}–${perthTimeOf(listing.endsAt)} next day`
: formatShiftTimes(listing.startsAt, listing.endsAt);
- const alreadyMine = listing.claimedByMe || sent !== null;
+ const withdrawnHere = withdrawnKey === `${listing.serviceId}:${listing.id}`;
+ const withdrawn = withdrawnHere && (state.readAt?.getTime() ?? null) === withdrawnReadAt;
+ const sent = listing.claimedByMe || (state.readAt?.getTime() ?? null) === sentReadAt ? sentState : null;
+ const alreadyMine = (listing.claimedByMe && !withdrawn) || sent !== null;
+ const approved = sent === "approved" || listing.status === "approved";
+ // A started shift can no longer be claimed, so the server refuses to reopen it.
+ const started = Date.parse(listing.startsAt) <= now.getTime();
+ const canWithdraw = alreadyMine && !approved && !started && state.sample === null && !state.offline;
const sample = state.sample !== null;
// A deep link or a list read earlier can still name a shift nobody can ask for now.
const closed: string | null = alreadyMine
? listing.status === "cancelled" && sent === null
? "cancelled"
: null
- : isBrowsable(listing, now)
+ : isBrowsable(listing, now) || (withdrawn && listing.claimedByMe && Date.parse(listing.startsAt) > now.getTime())
? null
: listing.mine
? "own"
@@ -423,6 +544,18 @@ export function OpenShiftsAdvertPage({
+ {withdrawnHere && !alreadyMine ? (
+
+ {listing.claimedByMe || listing.status === "open"
+ ? "Request taken back. The shift is open again."
+ : "Request taken back."}
+
- {sent === "approved" || listing.status === "approved" ? (
+ {approved ? (
<>
ApprovedIt's on your
roster now.
@@ -509,6 +642,18 @@ export function OpenShiftsAdvertPage({
>
Go to My shifts
+ {canWithdraw ? (
+
+ ) : null}
) : state.offline ? (
@@ -561,12 +706,33 @@ export function OpenShiftsAdvertPage({
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSent={(status) => {
+ setWithdrawnKey(null);
setSent(status);
+ setSentReadAt(state.readAt?.getTime() ?? null);
setSheetOpen(false);
state.reload();
}}
/>
) : null}
+ {withdrawLoaded ? (
+ setWithdrawOpen(false)}
+ onStale={() => {
+ // The request was decided elsewhere, so this visit's own record of sending it no longer holds.
+ setSent(null);
+ state.reload();
+ }}
+ onWithdrawn={() => {
+ setSent(null);
+ setWithdrawnKey(`${listing.serviceId}:${listing.id}`);
+ setWithdrawnReadAt(state.readAt?.getTime() ?? null);
+ setWithdrawOpen(false);
+ state.reload();
+ }}
+ />
+ ) : null}
);
}
diff --git a/src/lib/roster/team/demo-team-core.ts b/src/lib/roster/team/demo-team-core.ts
index 8ca73f0720..b63afdf72b 100644
--- a/src/lib/roster/team/demo-team-core.ts
+++ b/src/lib/roster/team/demo-team-core.ts
@@ -427,6 +427,7 @@ export function demoRosterCommand(action: RosterAction): RosterCommandResult {
return { ok: true, openShiftId: action.openShiftId, status: "filled" };
case "open.decline":
case "open.release":
+ case "open.withdraw":
return { ok: true, openShiftId: action.openShiftId, status: "open" };
case "open.cancel":
return { ok: true, openShiftId: action.openShiftId, status: "cancelled" };
diff --git a/src/lib/roster/team/model.ts b/src/lib/roster/team/model.ts
index 9f3f7dedb7..4557614e5a 100644
--- a/src/lib/roster/team/model.ts
+++ b/src/lib/roster/team/model.ts
@@ -487,6 +487,8 @@ export const rosterActionSchema = z.union([
openIdAction("open.approve"),
openIdAction("open.decline"),
openIdAction("open.cancel"),
+ // A doctor takes back their own request while it still waits for approval (roster_open_withdraw).
+ openIdAction("open.withdraw"),
z.object({ action: z.literal("open.release"), openShiftId: uuid, urgent: z.boolean().optional() }).strict(),
z.object({ action: z.literal("seen.mark"), publicationId: uuid }).strict(),
// The team's safe number: replaces its whole list of cover needs. Manager only (the SQL checks).
diff --git a/src/lib/roster/team/repository.ts b/src/lib/roster/team/repository.ts
index 313240d010..6b024f5b43 100644
--- a/src/lib/roster/team/repository.ts
+++ b/src/lib/roster/team/repository.ts
@@ -79,20 +79,27 @@ export async function rosterCommand(
requireActor(actorId);
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.
const { data, error } =
- action.action === "needs.set"
- ? await client.rpc("roster_needs_replace", {
+ action.action === "open.withdraw"
+ ? await client.rpc("roster_open_withdraw", {
p_actor_id: actorId,
p_service_id: serviceId,
- p_expected_ids: action.expectedIds as never,
- p_needs: action.needs as never,
+ p_open_shift_id: action.openShiftId,
})
- : await client.rpc("roster_command", {
- p_actor_id: actorId,
- p_service_id: serviceId,
- p_action: name,
- p_payload: payload as never,
- });
+ : action.action === "needs.set"
+ ? await client.rpc("roster_needs_replace", {
+ p_actor_id: actorId,
+ p_service_id: serviceId,
+ 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,
+ });
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 a490c10fa2..d6171f7116 100644
--- a/src/lib/supabase/database.types.ts
+++ b/src/lib/supabase/database.types.ts
@@ -4941,6 +4941,10 @@ export type Database = {
Args: { p_actor_id: string; p_service_id: string; p_expected_ids: Json; p_needs: Json };
Returns: Json;
};
+ roster_open_withdraw: {
+ Args: { p_actor_id: string; p_service_id: string; p_open_shift_id: string };
+ Returns: Json;
+ };
on_call_service_set_verified: {
Args: { p_service_id: string; p_actor_id: string; p_verified: boolean; p_is_demo: boolean };
Returns: Json;
diff --git a/supabase/applied-migration-hashes.json b/supabase/applied-migration-hashes.json
index 495792169b..909ce59a61 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-11T06:04:22.664Z",
+ "sealed_at": "2026-10-12T12:49:50.382Z",
"versions": {
"20260527000000_bulk_ingestion.sql": "813e8e314064a6dcbaaa55e2765a8ce901d5783b304b4a7af7bb7417a07daf7c",
"20260527001000_documents_policy_cleanup.sql": "559c26f3172bf75e545a41adef75538b2bf648ea7d53c8eb3d3ea7c244c65a48",
@@ -302,6 +302,7 @@
"20261009225900_work_bookings_retention.sql": "67800d7b4136b2955b8f34360e9b79564d2bd9c89aa66397548b1e1b3e4fc5cb",
"20261010011100_work_bookings_organiser_role.sql": "e8c284f391bccc4d6327968e076ef15dd8f17d9a4ff40254a01f92ea687cad76",
"20261010081500_roster_needs_stale_check.sql": "b9db1f643397ab1919d087d8cede955272c22474353b060acdf19a617db1b7d8",
- "20261010120000_evaluate_site_content_release_digest_once_per_health_read.sql": "4d9cf9487e74d3773c0263928fa5546b2d4ed60877c8cfdd179cbf1e16b866c2"
+ "20261010120000_evaluate_site_content_release_digest_once_per_health_read.sql": "4d9cf9487e74d3773c0263928fa5546b2d4ed60877c8cfdd179cbf1e16b866c2",
+ "20261012090000_roster_open_withdraw.sql": "35a6591442186ba238d96695d7a407d08aa6a019fa5eb01318c039039fedc8a5"
}
}
diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json
index 54f3cf20cd..9d561b13ab 100644
--- a/supabase/drift-manifest.json
+++ b/supabase/drift-manifest.json
@@ -2,7 +2,7 @@
"generated_at": "2026-10-11T06:07:15.735Z",
"generator": "scripts/generate-drift-manifest.ts",
"postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83",
- "schema_sha256": "81cf0bcf6adba44b01ade3e0961a0335766e57c9596160cf37d7ce9c6d04dde4",
+ "schema_sha256": "58ae519e96479e7fd131dc50a7781bc4c394c30d7dc0cfeffd68b1e41a5ba6de",
"replay_seconds": 13,
"snapshot": {
"views": [
@@ -19096,6 +19096,14 @@
"def_hash": "2c12e7f103e1b697134442322ee25fd3",
"signature": "public.roster_open_shifts_mark_reported()"
},
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "abed0339949b065bf0ce884e689f4205",
+ "signature": "public.roster_open_withdraw(uuid,uuid,uuid)"
+ },
{
"acl": [
"postgres=X/postgres",
diff --git a/supabase/migrations/20261012090000_roster_open_withdraw.sql b/supabase/migrations/20261012090000_roster_open_withdraw.sql
new file mode 100644
index 0000000000..e04c3173e2
--- /dev/null
+++ b/supabase/migrations/20261012090000_roster_open_withdraw.sql
@@ -0,0 +1,44 @@
+-- Never edit an applied migration. Merging this applies it to the live database.
+-- A doctor can take back their own open-shift request while it is still waiting for approval
+-- (owner decision 12 Oct 2026, Open shifts expansion item 10, "Go ahead with your recommendations").
+-- Until now only a roster manager could clear a waiting request (open.decline), so a doctor who
+-- asked by mistake, or whose plans changed, had to message the manager. Taking it back puts the
+-- shift straight back on the list for everyone else. An approved shift is already on the roster,
+-- so it is not taken back here: the doctor gives it away through Roster as for any other shift.
+--
+-- One new function. roster_command is unchanged. No table or data changes.
+set local lock_timeout = '5s';
+set local statement_timeout = '30s';
+
+create function public.roster_open_withdraw(p_actor_id uuid, p_service_id uuid, p_open_shift_id uuid)
+returns jsonb language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare
+ v_service public.on_call_services;
+ v_open public.roster_open_shifts;
+begin
+ if p_actor_id is null then raise exception 'roster_auth_required'; end if;
+ if p_service_id is null or p_open_shift_id 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, then the row itself.
+ 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_open from public.roster_open_shifts
+ where id = p_open_shift_id and service_id = p_service_id for update;
+ -- Someone else's request reads as missing, so nobody learns who asked for what.
+ if not found or v_open.claimed_by is distinct from p_actor_id then raise exception 'roster_not_found'; end if;
+ -- Approved, cancelled or expired since the doctor last looked, or the shift has already
+ -- started (a started shift can no longer be claimed, so reopening it would strand it): nothing
+ -- is changed. The clock is read after the locks, so a call that waited on them can't slip past the start.
+ if v_open.status <> 'claimed' or v_open.starts_at <= clock_timestamp() then raise exception 'roster_conflict'; end if;
+
+ update public.roster_open_shifts set status = 'open', claimed_by = null, claimed_at = null where id = v_open.id;
+ return jsonb_build_object('openShiftId', v_open.id, 'status', 'open');
+end $$;
+
+revoke all on function public.roster_open_withdraw(uuid, uuid, uuid) from public, anon, authenticated;
+grant execute on function public.roster_open_withdraw(uuid, uuid, uuid) to service_role;
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 018f1a9704..2fe838aa3f 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -27284,3 +27284,38 @@ end $$;
revoke all on function public.roster_needs_replace(uuid, uuid, jsonb, jsonb) from public, anon, authenticated;
grant execute on function public.roster_needs_replace(uuid, uuid, jsonb, jsonb) to service_role;
+
+-- A doctor takes back their own open-shift request while it still waits for approval
+-- (20261012090000_roster_open_withdraw.sql). roster_command is unchanged.
+create function public.roster_open_withdraw(p_actor_id uuid, p_service_id uuid, p_open_shift_id uuid)
+returns jsonb language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare
+ v_service public.on_call_services;
+ v_open public.roster_open_shifts;
+begin
+ if p_actor_id is null then raise exception 'roster_auth_required'; end if;
+ if p_service_id is null or p_open_shift_id 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, then the row itself.
+ 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_open from public.roster_open_shifts
+ where id = p_open_shift_id and service_id = p_service_id for update;
+ -- Someone else's request reads as missing, so nobody learns who asked for what.
+ if not found or v_open.claimed_by is distinct from p_actor_id then raise exception 'roster_not_found'; end if;
+ -- Approved, cancelled or expired since the doctor last looked, or the shift has already
+ -- started (a started shift can no longer be claimed, so reopening it would strand it): nothing
+ -- is changed. The clock is read after the locks, so a call that waited on them can't slip past the start.
+ if v_open.status <> 'claimed' or v_open.starts_at <= clock_timestamp() then raise exception 'roster_conflict'; end if;
+
+ update public.roster_open_shifts set status = 'open', claimed_by = null, claimed_at = null where id = v_open.id;
+ return jsonb_build_object('openShiftId', v_open.id, 'status', 'open');
+end $$;
+
+revoke all on function public.roster_open_withdraw(uuid, uuid, uuid) from public, anon, authenticated;
+grant execute on function public.roster_open_withdraw(uuid, uuid, uuid) to service_role;
diff --git a/tests/open-shifts-withdraw.dom.test.tsx b/tests/open-shifts-withdraw.dom.test.tsx
new file mode 100644
index 0000000000..a35e861166
--- /dev/null
+++ b/tests/open-shifts-withdraw.dom.test.tsx
@@ -0,0 +1,191 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { OpenShiftsState } from "@/components/open-shifts/use-open-shifts";
+import type { OpenShiftListing } from "@/lib/open-shifts/model";
+import { sampleListings, sampleRoster } from "@/lib/open-shifts/sample";
+
+// Mon 5 Oct 2026, 08:00 Perth.
+const NOW = new Date("2026-10-05T00:00:00.000Z");
+
+const mocks = vi.hoisted(() => ({
+ state: null as unknown as OpenShiftsState,
+ post: vi.fn(),
+}));
+vi.mock("@/components/open-shifts/use-open-shifts", () => ({ useOpenShifts: () => mocks.state }));
+vi.mock("@/components/mode-kit/use-signed-out-sample", () => ({ useSignedOut: () => false }));
+vi.mock("@/components/roster/roster-format", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useRosterNow: () => NOW,
+}));
+vi.mock("@/components/roster/use-roster-team", async (importOriginal) => ({
+ ...(await importOriginal()),
+ postRosterAction: mocks.post,
+}));
+
+import { OpenShiftsAdvertPage } from "@/components/open-shifts/open-shifts-advert-page";
+
+function waiting(overrides: Partial = {}): OpenShiftListing {
+ const future = sampleListings(NOW).find((row) => Date.parse(row.startsAt) > NOW.getTime() + 86_400_000)!;
+ return { ...future, status: "claimed", claimedByMe: true, mine: false, ...overrides };
+}
+
+function stateWith(listing: OpenShiftListing, overrides: Partial = {}): OpenShiftsState {
+ return {
+ status: "ready",
+ listings: [listing],
+ teams: [],
+ roster: sampleRoster(NOW),
+ rosterStatus: "ready",
+ readAt: NOW,
+ sample: null,
+ offline: false,
+ failedTeams: [],
+ refreshFailed: false,
+ actorId: null,
+ message: null,
+ reload: vi.fn(),
+ ...overrides,
+ };
+}
+
+function renderFor(listing: OpenShiftListing) {
+ return render();
+}
+
+beforeEach(() => mocks.post.mockReset());
+afterEach(cleanup);
+
+describe("Open shifts, taking back a waiting request", () => {
+ it("takes back a waiting request after the doctor confirms, and says the shift is open again", async () => {
+ const listing = waiting();
+ mocks.state = stateWith(listing);
+ mocks.post.mockResolvedValue({ ok: true, result: { ok: true, openShiftId: listing.id, status: "open" } });
+ renderFor(listing);
+ expect(screen.getByText("Waiting for approval")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Take back my request" }));
+ // Nothing is sent until the doctor confirms.
+ expect(mocks.post).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole("button", { name: "Yes, take it back" }));
+ await waitFor(() => expect(screen.getByText("Request taken back. The shift is open again.")).toBeTruthy());
+ expect(mocks.post).toHaveBeenCalledWith(listing.serviceId, { action: "open.withdraw", openShiftId: listing.id });
+ expect(mocks.state.reload).toHaveBeenCalledOnce();
+ expect(screen.queryByText("Waiting for approval")).toBeNull();
+ expect(screen.queryByText(/Someone else has it now/)).toBeNull();
+ // The button that opened the take-back is gone, so focus moves to the result.
+ await waitFor(() =>
+ expect(document.activeElement?.textContent).toBe("Request taken back. The shift is open again."),
+ );
+ });
+
+ it("shows the request as waiting again when a newer read finds it asked for again elsewhere", async () => {
+ const listing = waiting();
+ mocks.state = stateWith(listing);
+ mocks.post.mockResolvedValue({ ok: true, result: { ok: true, openShiftId: listing.id, status: "open" } });
+ const view = renderFor(listing);
+ fireEvent.click(screen.getByRole("button", { name: "Take back my request" }));
+ fireEvent.click(screen.getByRole("button", { name: "Yes, take it back" }));
+ await waitFor(() => expect(mocks.state.reload).toHaveBeenCalledOnce());
+ // The same doctor asks again from another device before this page's re-read lands.
+ mocks.state = stateWith(listing, { readAt: new Date(NOW.getTime() + 60_000) });
+ view.rerender();
+ expect(screen.getByText("Waiting for approval")).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "Request this shift" })).toBeNull();
+ expect(screen.queryByText("Request taken back. The shift is open again.")).toBeNull();
+ });
+
+ it("does not call the shift open when someone else asked for it before the page re-read it", async () => {
+ const listing = waiting();
+ mocks.state = stateWith(listing);
+ mocks.post.mockResolvedValue({ ok: true, result: { ok: true, openShiftId: listing.id, status: "open" } });
+ const view = renderFor(listing);
+ fireEvent.click(screen.getByRole("button", { name: "Take back my request" }));
+ fireEvent.click(screen.getByRole("button", { name: "Yes, take it back" }));
+ await waitFor(() => expect(mocks.state.reload).toHaveBeenCalledOnce());
+ const taken = { ...listing, status: "claimed" as const, claimedByMe: false };
+ mocks.state = stateWith(taken);
+ view.rerender();
+ expect(screen.getByText("Request taken back.")).toBeTruthy();
+ expect(screen.queryByText(/The shift is open again/)).toBeNull();
+ });
+
+ it("explains a request decided before it could be taken back, and keeps it", async () => {
+ const listing = waiting();
+ mocks.state = stateWith(listing);
+ mocks.post.mockResolvedValue({ ok: false, code: "roster_conflict", message: "Already decided." });
+ renderFor(listing);
+ fireEvent.click(screen.getByRole("button", { name: "Take back my request" }));
+ fireEvent.click(screen.getByRole("button", { name: "Yes, take it back" }));
+ await waitFor(() =>
+ expect(screen.getByRole("alert").textContent).toMatch(
+ /can't be taken back now\. Your roster manager has decided, or the shift has started/,
+ ),
+ );
+ // The page reads the request again, so it shows what the manager decided.
+ expect(mocks.state.reload).toHaveBeenCalledOnce();
+ // The same take-back can't be sent again against what the page last read.
+ expect((screen.getByRole("button", { name: "Yes, take it back" }) as HTMLButtonElement).disabled).toBe(true);
+ });
+
+ it("drops a request sent on this visit once a take-back finds it already declined", async () => {
+ const open = waiting({ status: "open", claimedByMe: false });
+ mocks.state = stateWith(open);
+ const view = renderFor(open);
+ mocks.post.mockResolvedValueOnce({ ok: true, result: { ok: true, openShiftId: open.id, status: "claimed" } });
+ fireEvent.click(screen.getAllByRole("button", { name: "Request this shift" })[0]!);
+ fireEvent.click(screen.getByRole("checkbox"));
+ fireEvent.click(screen.getByRole("button", { name: "Yes, send request" }));
+ await waitFor(() => expect(screen.getByText("Waiting for approval")).toBeTruthy());
+
+ // The manager declines before the take-back lands, which clears the request.
+ mocks.post.mockResolvedValueOnce({ ok: false, code: "roster_not_found", message: "Not found." });
+ fireEvent.click(screen.getByRole("button", { name: "Take back my request" }));
+ fireEvent.click(screen.getByRole("button", { name: "Yes, take it back" }));
+ await waitFor(() => expect(mocks.state.reload).toHaveBeenCalledTimes(2));
+ mocks.state = stateWith(open);
+ view.rerender();
+ expect(screen.queryByText("Waiting for approval")).toBeNull();
+ });
+
+ it("lets a request sent on this visit lapse once a later read shows it declined", async () => {
+ const open = waiting({ status: "open", claimedByMe: false });
+ mocks.state = stateWith(open);
+ const view = renderFor(open);
+ mocks.post.mockResolvedValueOnce({ ok: true, result: { ok: true, openShiftId: open.id, status: "claimed" } });
+ fireEvent.click(screen.getAllByRole("button", { name: "Request this shift" })[0]!);
+ fireEvent.click(screen.getByRole("checkbox"));
+ fireEvent.click(screen.getByRole("button", { name: "Yes, send request" }));
+ await waitFor(() => expect(screen.getByText("Waiting for approval")).toBeTruthy());
+
+ // The manager declines, and the next read shows the shift open again and not this doctor's.
+ mocks.state = stateWith(open, { readAt: new Date(NOW.getTime() + 60_000) });
+ view.rerender();
+ expect(screen.queryByText("Waiting for approval")).toBeNull();
+ });
+
+ it("never offers to take back an approved shift, or while offline", () => {
+ const approved = waiting({ status: "approved" });
+ mocks.state = stateWith(approved);
+ renderFor(approved);
+ expect(screen.queryByRole("button", { name: "Take back my request" })).toBeNull();
+ cleanup();
+
+ const listing = waiting();
+ mocks.state = stateWith(listing, { offline: true });
+ renderFor(listing);
+ expect(screen.queryByRole("button", { name: "Take back my request" })).toBeNull();
+ });
+
+ it("stops offering a take-back once the shift has started", () => {
+ const started = waiting({
+ startsAt: new Date(NOW.getTime() - 3_600_000).toISOString(),
+ endsAt: new Date(NOW.getTime() + 7 * 3_600_000).toISOString(),
+ });
+ mocks.state = stateWith(started);
+ renderFor(started);
+ expect(screen.queryByRole("button", { name: "Take back my request" })).toBeNull();
+ });
+});
diff --git a/tests/roster-team-api.test.ts b/tests/roster-team-api.test.ts
index f96c7a398c..6a8c0eba8f 100644
--- a/tests/roster-team-api.test.ts
+++ b/tests/roster-team-api.test.ts
@@ -247,6 +247,22 @@ describe("writing to a team", () => {
});
});
+ it("sends a doctor's take-back of their open-shift request (open.withdraw) to its own function", async () => {
+ mocks.rpc.mockResolvedValue({ data: { openShiftId: SWAP, status: "open" }, error: null });
+ const response = await POST(jsonRequest({ action: "open.withdraw", openShiftId: SWAP }), ctx());
+ expect(response.status).toBe(200);
+ expect(mocks.rpc).toHaveBeenCalledTimes(1);
+ expect(mocks.rpc).toHaveBeenCalledWith("roster_open_withdraw", {
+ p_actor_id: ALEX,
+ p_service_id: SERVICE,
+ p_open_shift_id: SWAP,
+ });
+ expect((await POST(jsonRequest({ action: "open.withdraw", openShiftId: "not-an-id" }), ctx())).status).toBe(400);
+ expect((await POST(jsonRequest({ action: "open.withdraw", openShiftId: SWAP, userId: ALEX }), ctx())).status).toBe(
+ 400,
+ );
+ });
+
it("answers a safe number saved over a stale read with 409 roster_conflict", async () => {
mocks.rpc.mockResolvedValue({ data: null, error: { message: "roster_conflict" } });
const response = await POST(jsonRequest({ action: "needs.set", expectedIds: [], needs: [] }), ctx());
diff --git a/tests/sql/roster-behaviour.sql b/tests/sql/roster-behaviour.sql
index 98797db7f4..4e8e018f7e 100644
--- a/tests/sql/roster-behaviour.sql
+++ b/tests/sql/roster-behaviour.sql
@@ -154,7 +154,7 @@ $clash$;
-- 8. Open shifts: the first eligible claim reserves it; a different grade needs manager approval.
do $open$
-declare day uuid; r jsonb; oid uuid;
+declare day uuid; r jsonb; oid uuid; was_start timestamptz; was_end timestamptz;
begin
select id into day from public.roster_assignments where user_id = pg_temp.id('ivy') and shift_code = 'D';
r := pg_temp.cmd('ivy', 'open.post', jsonb_build_object('assignmentId', day));
@@ -163,11 +163,35 @@ begin
if r ->> 'status' is distinct from 'claimed' then raise exception 'open: expected different-grade claim pending approval, got %', r; end if;
perform pg_temp.expect_error(format($f$select pg_temp.cmd('mei', 'open.claim', %L)$f$, jsonb_build_object('openShiftId', oid)),
'roster_open_shift_taken');
+ -- Taking back a waiting request: only the doctor who asked can, and the shift is open again.
+ perform pg_temp.expect_error(format('select public.roster_open_withdraw(%L,%L,%L)',
+ pg_temp.id('mei'), pg_temp.id('svc'), oid), 'roster_not_found');
+ perform pg_temp.expect_error(format('select public.roster_open_withdraw(%L,%L,%L)',
+ pg_temp.id('mgr'), pg_temp.id('svc'), oid), 'roster_not_found');
+ r := public.roster_open_withdraw(pg_temp.id('lee'), pg_temp.id('svc'), oid);
+ if r ->> 'status' is distinct from 'open'
+ or exists (select 1 from public.roster_open_shifts where id = oid and (status <> 'open' or claimed_by is not null or claimed_at is not null)) then
+ raise exception 'open: withdraw did not reopen the shift, got %', r;
+ end if;
+ r := pg_temp.cmd('lee', 'open.claim', jsonb_build_object('openShiftId', oid));
+ if r ->> 'status' is distinct from 'claimed' then raise exception 'open: claim after withdraw failed %', r; end if;
+ -- Once the shift has started it can no longer be claimed, so a waiting request is not taken back.
+ select starts_at, ends_at into was_start, was_end from public.roster_open_shifts where id = oid;
+ update public.roster_open_shifts set starts_at = now() - interval '1 hour', ends_at = now() + interval '7 hours' where id = oid;
+ perform pg_temp.expect_error(format('select public.roster_open_withdraw(%L,%L,%L)',
+ pg_temp.id('lee'), pg_temp.id('svc'), oid), 'roster_conflict');
+ if (select status from public.roster_open_shifts where id = oid) is distinct from 'claimed' then
+ raise exception 'open: refused withdraw of a started shift changed the row';
+ end if;
+ update public.roster_open_shifts set starts_at = was_start, ends_at = was_end where id = oid;
r := pg_temp.cmd('mgr', 'open.approve', jsonb_build_object('openShiftId', oid));
if r ->> 'status' is distinct from 'approved' then raise exception 'open: manager approval failed %', r; end if;
if (select user_id from public.roster_assignments where id = day) is distinct from pg_temp.id('lee') then
raise exception 'open: approved assignment did not move to claimant';
end if;
+ -- An approved shift is on the roster, so it is never taken back here.
+ perform pg_temp.expect_error(format('select public.roster_open_withdraw(%L,%L,%L)',
+ pg_temp.id('lee'), pg_temp.id('svc'), oid), 'roster_conflict');
end
$open$;
diff --git a/tests/ui-open-shifts-withdraw.spec.ts b/tests/ui-open-shifts-withdraw.spec.ts
new file mode 100644
index 0000000000..e915de59ca
--- /dev/null
+++ b/tests/ui-open-shifts-withdraw.spec.ts
@@ -0,0 +1,98 @@
+import { expect, test, type Page } from "playwright/test";
+import { clickWhenHydrated } from "./playwright-settlement";
+
+const serviceId = "5e000000-0000-4000-8000-000000000001";
+const openShiftId = "5e000000-0000-4000-8000-000000000002";
+const actorId = "5e000000-0000-4000-8000-000000000003";
+
+async function stubOpenShift(page: Page) {
+ await page.clock.setFixedTime(new Date("2026-10-05T00:00:00.000Z"));
+ const startsAt = "2026-10-12T09:00:00.000Z";
+ const endsAt = "2026-10-12T17:00:00.000Z";
+ const openShift = {
+ id: openShiftId,
+ status: "open",
+ urgent: false,
+ startsAt,
+ endsAt,
+ shiftCode: "D",
+ kind: "day",
+ minGrade: "registrar",
+ siteId: null,
+ mine: false,
+ claimedByMe: false,
+ };
+ // The server's view moves on: the request is waiting after the claim, and on the reads after the
+ // stale take-back it has already been decided elsewhere, so it is no longer this doctor's.
+ const phase = { current: "before" as "before" | "claimed" | "withdrawn", readsAfterWithdraw: 0 };
+
+ await page.route("**/api/roster/team", async (route) =>
+ route.fulfill({
+ json: {
+ actorId,
+ teams: [{ serviceId, name: "Example team", enabled: true, role: "member", grade: "registrar" }],
+ },
+ }),
+ );
+ await page.route(`**/api/roster/team/${serviceId}**`, async (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+ if (request.method() === "POST") {
+ const action = request.postDataJSON() as { action?: string };
+ if (action.action === "open.claim") {
+ phase.current = "claimed";
+ return route.fulfill({ json: { result: { ok: true, openShiftId, status: "claimed" } } });
+ }
+ if (action.action === "open.withdraw") {
+ phase.current = "withdrawn";
+ return route.fulfill({
+ status: 404,
+ json: { code: "roster_not_found", message: "Not found." },
+ });
+ }
+ }
+ if (url.searchParams.get("what") === "overview") {
+ return route.fulfill({
+ json: {
+ service: { id: serviceId, name: "Example team" },
+ me: { role: "member", grade: "registrar", rotationEndsOn: null },
+ latestPublication: null,
+ seenLatest: true,
+ settings: { swapApproval: "manager", rules: {}, rulesSource: null, payFortnightAnchor: null },
+ sites: [],
+ managers: [],
+ },
+ });
+ }
+ if (url.searchParams.get("what") === "requests") {
+ if (phase.current === "withdrawn") phase.readsAfterWithdraw += 1;
+ const listing = phase.current === "claimed" ? { ...openShift, status: "claimed", claimedByMe: true } : openShift;
+ return route.fulfill({ json: { swaps: [], openShifts: [listing] } });
+ }
+ return route.fulfill({ json: { assignments: [] } });
+ });
+ await page.route("**/api/roster/shifts**", (route) => route.fulfill({ json: { shifts: [], sample: false } }));
+ return phase;
+}
+
+test("a stale take-back reports the decision and refreshes the real advert", async ({ page }) => {
+ const phase = await stubOpenShift(page);
+ await page.goto(`/open-shifts/shift/${serviceId}/${openShiftId}`);
+
+ await clickWhenHydrated(page.getByRole("button", { name: "Request this shift", exact: true }));
+ await clickWhenHydrated(page.getByRole("checkbox"));
+ await clickWhenHydrated(page.getByRole("button", { name: "Yes, send request", exact: true }));
+ await expect(page.getByText("Waiting for approval")).toBeVisible();
+
+ await clickWhenHydrated(page.getByRole("button", { name: "Take back my request", exact: true }));
+ await clickWhenHydrated(page.getByRole("button", { name: "Yes, take it back", exact: true }));
+
+ // Next.js renders its own empty route announcer with role="alert", so match the notice by its text.
+ await expect(page.getByRole("alert").filter({ hasText: "This request can't be found any more." })).toHaveText(
+ "This request can't be found any more. Check My shifts.",
+ );
+ // The page re-read the request after the take-back failed, and shows that read, not this visit's copy.
+ await expect.poll(() => phase.readsAfterWithdraw).toBeGreaterThan(0);
+ await expect(page.getByText("Waiting for approval")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Take back my request", exact: true })).toHaveCount(0);
+});