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
4 changes: 2 additions & 2 deletions src/app/api/teaching/assessments/bookings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ const bookingActionSchema = z.discriminatedUnion("action", [
supervisorId: z.string().uuid().optional(),
traineeId: z.string().uuid().optional(),
title: z.string().min(2).max(200).optional(),
startsAt: z.string().min(1),
endsAt: z.string().min(1),
startsAt: z.string().datetime({ offset: true }),
endsAt: z.string().datetime({ offset: true }),
locationOrLink: z.string().min(1).max(500).optional(),
assessmentType: assessmentTypeSchema.optional(),
notes: z.string().max(2000).optional(),
Expand Down
3 changes: 2 additions & 1 deletion src/lib/cme/training-assessments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,8 @@ export function buildOwnerTrainingAssessments(
// The deadline is the current rotation's own end date; with none recorded the page shows no date
// rather than borrowing an unrelated milestone's due date.
const rotationEndsOn = currentRotation?.endsOn ?? null;
const minimumEpas = currentRotation ? 2 * currentRotation.fte : 2;
// Pro rata for part-time, rounded up so the page never asks for a fraction of an EPA.
const minimumEpas = currentRotation ? Math.max(1, Math.ceil(2 * currentRotation.fte)) : 2;

const epas: TrainingEpa[] = epaRecords.map((record) => ({
id: record.id,
Expand Down
46 changes: 36 additions & 10 deletions src/lib/teaching/assessments/dual-sign-off.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ export type SupervisorSignOffInput = {
};

export type RegistrarAssessmentRatingsPayload = {
/** Marks rows written by the dual sign-off flow; reads accept nothing else. */
kind: typeof DUAL_SIGN_OFF_KIND;
serviceId: string;
clinicalSetting: string;
traineeReflection: string;
Expand All @@ -115,7 +117,6 @@ export type RegistrarAssessmentRatingsPayload = {
supervisorSignedAt: string | null;
revisionReason: string | null;
cancelReason: string | null;
kind?: string;
};

export type RegistrarAssessmentRow = {
Expand Down Expand Up @@ -164,6 +165,8 @@ export type RegistrarAssessmentsTableClient = {
from(table: "registrar_assessments"): RegistrarAssessmentsQueryBuilder;
};

export const DUAL_SIGN_OFF_KIND = "dual_sign_off" as const;

const ASSESSMENT_SELECT_COLUMNS =
"id, registrar_id, assessor_id, category, title, ratings, feedback, status, created_at, updated_at";

Expand Down Expand Up @@ -231,6 +234,7 @@ export function assessmentRecordToRow(
category: RegistrarAssessmentCategory = "wba",
): RegistrarAssessmentRow {
const ratingsPayload: RegistrarAssessmentRatingsPayload = {
kind: DUAL_SIGN_OFF_KIND,
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Resolved
serviceId: record.serviceId,
clinicalSetting: record.clinicalSetting,
traineeReflection: record.traineeReflection,
Expand Down Expand Up @@ -297,15 +301,37 @@ export function rowToAssessmentRecord(row: RegistrarAssessmentRow): AssessmentRe
};
}

function isSupervisorBookingRow(row: RegistrarAssessmentRow): boolean {
return Boolean(
row.category === "supervisor_review" &&
row.ratings &&
typeof row.ratings === "object" &&
(row.ratings as Record<string, unknown>).kind === "supervisor_booking",
/**
* Rows written by the current dual sign-off flow carry an explicit discriminator.
*
* Before that discriminator was added, dual sign-off rows had the same payload
* below without `kind`. Keep accepting only that recognisable legacy shape so
* those assessments can still be read and updated; in particular, do not treat
* every row with a missing kind as a dual sign-off assessment.
*/
function isLegacyDualSignOffRow(row: RegistrarAssessmentRow): boolean {
if (!row.ratings || typeof row.ratings !== "object") return false;
const ratings = row.ratings as Record<string, unknown>;
if ("kind" in ratings || typeof ratings.serviceId !== "string") return false;

return (
typeof ratings.clinicalSetting === "string" &&
typeof ratings.traineeReflection === "string" &&
ratings.traineeSelfRatings !== null &&
typeof ratings.traineeSelfRatings === "object" &&
typeof ratings.state === "string" &&
VALID_LIFECYCLE_STATES.has(ratings.state as AssessmentLifecycleState) &&
!("startsAt" in ratings) &&
!("bookingState" in ratings)
);
}

function isDualSignOffRow(row: RegistrarAssessmentRow): boolean {
if (!row.ratings || typeof row.ratings !== "object") return false;
const ratings = row.ratings as Record<string, unknown>;
return ratings.kind === DUAL_SIGN_OFF_KIND || isLegacyDualSignOffRow(row);
}

function assessmentDatabaseError(error: DbErrorLike | null): PublicApiError {
if (error?.code === "22P02") return new PublicApiError("Invalid assessment identifier.", 400, { code: "invalid_id" });
if (error?.code === "23503")
Expand Down Expand Up @@ -753,7 +779,7 @@ export class DurableClinicalAssessmentRepository {
throw assessmentDatabaseError(error);
}

if (!data || isSupervisorBookingRow(data)) {
if (!data || !isDualSignOffRow(data)) {
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
throw new AssessmentNotFoundError(id);
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
}

Expand Down Expand Up @@ -862,7 +888,7 @@ export class DurableClinicalAssessmentRepository {

const results: SafeAssessmentView[] = [];
for (const row of data ?? []) {
if (isSupervisorBookingRow(row)) continue;
if (!isDualSignOffRow(row)) continue;
const record = rowToAssessmentRecord(row);
if (!filterState || record.state === filterState) {
results.push(projectAssessmentForActor(record, supervisorId));
Expand All @@ -884,7 +910,7 @@ export class DurableClinicalAssessmentRepository {

const results: SafeAssessmentView[] = [];
for (const row of data ?? []) {
if (isSupervisorBookingRow(row)) continue;
if (!isDualSignOffRow(row)) continue;
const record = rowToAssessmentRecord(row);
results.push(projectAssessmentForActor(record, traineeId));
}
Expand Down
11 changes: 11 additions & 0 deletions src/lib/teaching/assessments/supervisor-bookings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,17 @@ export class DurableSupervisorBookingRepository {
}

async listBookingsForActor(filter: ListBookingsFilter): Promise<SupervisorBookingRecord[]> {
if (!filter.role) {
// No role: the caller's own slots either side, plus open slots when a service is named. Each
// part is filtered in the database, so the listing never scans other people's bookings.
const parts = await Promise.all([
this.listBookingsForActor({ ...filter, role: "supervisor" }),
this.listBookingsForActor({ ...filter, role: "trainee" }),
filter.serviceId ? this.listBookingsForActor({ ...filter, role: "open" }) : Promise.resolve([]),
]);
const byId = new Map(parts.flat().map((slot) => [slot.id, slot]));
return [...byId.values()].sort((a, b) => Date.parse(a.startsAt) - Date.parse(b.startsAt));
}
const buildQuery = () => {
let query = this.db
.from("registrar_assessments")
Expand Down
64 changes: 64 additions & 0 deletions tests/teaching-dual-sign-off-durable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,70 @@ describe("Durable Teaching Assessments & Supervisor Bookings (registrar_assessme
expect(mockDb.rows.size).toBe(0);
});

it("never reads a registrar_assessments row the dual sign-off flow did not write", async () => {
vi.stubEnv("NODE_ENV", "production");
const foreignId = "77777777-7777-4777-8777-777777777777";
mockDb.rows.set(foreignId, {
id: foreignId,
registrar_id: TRAINEE_ID,
assessor_id: SUPERVISOR_ID,
category: "epa",
title: "Imported EPA record",
ratings: { state: "approved", somethingElse: true },
feedback: null,
status: "draft",
created_at: "2026-10-01T00:00:00.000Z",
updated_at: "2026-10-01T00:00:00.000Z",
});

mocks.user = { id: TRAINEE_ID };
expect((await getDualSignOff(makeDualSignOffGet(`id=${foreignId}`))).status).toBe(404);
const update = await postDualSignOff(
makeDualSignOffPost({ action: "update_draft", assessmentId: foreignId, title: "Overwrite" }),
);
expect(update.status).toBe(404);
expect(mockDb.rows.get(foreignId)?.ratings).toEqual({ state: "approved", somethingElse: true });
});

it("keeps a pre-marker dual sign-off row (no ratings.kind) readable, listable and updatable", async () => {
vi.stubEnv("NODE_ENV", "production");
const legacyId = "88888888-8888-4888-8888-888888888888";
mockDb.rows.set(legacyId, {
id: legacyId,
registrar_id: TRAINEE_ID,
assessor_id: SUPERVISOR_ID,
category: "wba",
title: "Legacy assessment",
ratings: {
serviceId: SERVICE_ID,
clinicalSetting: "Adult Acute Inpatient Unit",
traineeReflection: "Reflection.",
traineeSelfRatings: { 1: 3 },
traineeSignedAt: null,
state: "draft",
supervisorRatings: null,
globalRecommendation: null,
supervisorSignedAt: null,
revisionReason: null,
cancelReason: null,
},
feedback: null,
status: "draft",
created_at: "2026-10-17T14:30:00.000Z",
updated_at: "2026-10-17T14:30:00.000Z",
});

mocks.user = { id: TRAINEE_ID };
expect((await getDualSignOff(makeDualSignOffGet(`id=${legacyId}`))).status).toBe(200);
const list = await getDualSignOff(makeDualSignOffGet("role=trainee"));
expect(JSON.stringify(await list.json())).toContain(legacyId);
const update = await postDualSignOff(
makeDualSignOffPost({ action: "update_draft", assessmentId: legacyId, title: "Legacy assessment edited" }),
);
expect(update.status).toBe(200);
expect((mockDb.rows.get(legacyId)?.ratings as Record<string, unknown>).kind).toBe("dual_sign_off");
});

it("fails closed with 503 assessment_store_unavailable in production when admin client lacks durable table access", async () => {
vi.stubEnv("NODE_ENV", "production");
mocks.adminClient = {};
Expand Down
Loading