diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 84cfcc443..216f00dfb 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -2398,6 +2398,113 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
+ /api/medications/{id}/schedule-revisions:
+ get:
+ tags:
+ - Medications
+ summary: List a medication's archived schedule eras
+ description: Returns every archived schedule era (newest first) plus `currentSince`, the instant the live plan took
+ over. The dose-history ledger and compliance tallies already mint past days against these eras; this read powers
+ the Zeitplan-tab history timeline.
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: Era list.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ListScheduleRevisionsResponse"
+ "401": *a1
+ "404":
+ description: Medication not found (or owned by another user).
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a3
+ "429": *a4
+ post:
+ tags:
+ - Medications
+ summary: Append a manual schedule era (pre-tracking history)
+ description: Records that the medication dosed at the given daily times during `[validFrom, validUntil)` — history from
+ before the schedule was edited in the app. The era must end at or before the start of the live plan and must not
+ overlap an existing era; violations return 422. The snapshot is shaped exactly like a write-path archive
+ (`FREQ=DAILY`, window pulled to the min/max of the times), so every historical surface reads it transparently.
+ Audits as `medication.schedule_revision.created`.
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CreateScheduleRevisionRequest"
+ responses:
+ "201":
+ description: Manual era created.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CreateScheduleRevisionResponse"
+ "401": *a1
+ "404":
+ description: Medication not found (or owned by another user).
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a3
+ "429": *a4
+ /api/medications/{id}/schedule-revisions/{revisionId}:
+ delete:
+ tags:
+ - Medications
+ summary: Delete a manually added schedule era
+ description: "Removes a `MANUAL` era the owner appended through the sibling POST. Write-path archives (`source:
+ ARCHIVED`) are immutable history and refuse with 409. Audits as `medication.schedule_revision.deleted`."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ - in: path
+ name: revisionId
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: Deletion succeeded.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DeleteScheduleRevisionResponse"
+ "401": *a1
+ "404":
+ description: Medication or revision not found (or owned by another user).
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "409":
+ description: The revision is a write-path archive (`ARCHIVED`) and cannot be deleted.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a3
+ "429": *a4
/api/auth/me/coach-prefs:
get:
tags:
@@ -4791,6 +4898,32 @@ components:
description: Free-text medication description payload. The route runs the text through the Coach provider chain and
returns a partial structured payload the wizard merges. Rate-limited 10 requests / 5 minutes / user;
budget-gated against the daily Coach token ceiling.
+ CreateScheduleRevisionRequest:
+ type: object
+ properties:
+ validFrom:
+ type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ description: Inclusive start instant of the manual era (ISO 8601).
+ validUntil:
+ type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ description: Exclusive end instant of the manual era (ISO 8601). Must precede the current plan.
+ timesOfDay:
+ minItems: 1
+ maxItems: 8
+ type: array
+ items:
+ type: string
+ pattern: ^([01]\d|2[0-3]):([0-5]\d)$
+ description: Daily dose times (HH:mm, user local) the era ran at. 1–8 entries.
+ required:
+ - validFrom
+ - validUntil
+ - timesOfDay
+ description: "Manual schedule-era payload: era bounds plus the daily dose times that were live during the era."
DisableCoachFlag:
type: object
properties:
@@ -9021,6 +9154,141 @@ components:
additionalProperties: false
description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds
step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.
+ ListScheduleRevisionsResponse:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ currentSince:
+ type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
+ description: "Instant the LIVE plan took over: the newest revision's `validUntil`, or the medication's `createdAt` when
+ no era has been archived."
+ revisions:
+ type: array
+ items:
+ $ref: "#/components/schemas/MedicationScheduleRevision"
+ description: Archived eras, newest first.
+ required:
+ - currentSince
+ - revisions
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ MedicationScheduleRevision:
+ type: object
+ properties:
+ id:
+ type: string
+ validFrom:
+ type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
+ description: Inclusive start instant of the era.
+ validUntil:
+ type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
+ description: Exclusive end instant of the era — the moment the next plan took over.
+ source:
+ type: string
+ enum:
+ - ARCHIVED
+ - MANUAL
+ description: Provenance. ARCHIVED = minted by the schedule-replace write path (immutable). MANUAL = user-entered
+ pre-tracking era (deletable).
+ entries:
+ type: array
+ items:
+ $ref: "#/components/schemas/ScheduleRevisionEntry"
+ required:
+ - id
+ - validFrom
+ - validUntil
+ - source
+ - entries
+ additionalProperties: false
+ description: One archived schedule era covering `[validFrom, validUntil)`. The dose-history ledger, compliance tallies,
+ and cadence chips mint past days against the era that was live then.
+ ScheduleRevisionEntry:
+ type: object
+ properties:
+ timesOfDay:
+ type: array
+ items:
+ type: string
+ description: Daily dose times (HH:mm, user local) the era ran at.
+ label:
+ anyOf:
+ - type: string
+ - type: "null"
+ dose:
+ anyOf:
+ - type: string
+ - type: "null"
+ scheduleType:
+ type: string
+ description: Schedule-type discriminator of the archived row (SCHEDULED / PRN / CYCLIC).
+ required:
+ - timesOfDay
+ - label
+ - dose
+ - scheduleType
+ additionalProperties: false
+ description: Display summary of one archived schedule row inside an era. The full snapshot (windows, rrule, doseWindows,
+ …) stays server-side; this projection carries what the timeline renders.
+ CreateScheduleRevisionResponse:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/MedicationScheduleRevision"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ DeleteScheduleRevisionResponse:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ deleted:
+ type: boolean
+ required:
+ - deleted
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
GetCoachPrefsResponse:
type: object
properties:
diff --git a/prisma/migrations/0146_v1165_schedule_revision_source/migration.sql b/prisma/migrations/0146_v1165_schedule_revision_source/migration.sql
new file mode 100644
index 000000000..9d3d04533
--- /dev/null
+++ b/prisma/migrations/0146_v1165_schedule_revision_source/migration.sql
@@ -0,0 +1,19 @@
+-- v1.16.5 — schedule-revision provenance.
+--
+-- The Zeitplan tab now lets a user append a PRE-tracking era by hand
+-- ("the medication dosed at 07:00/19:00 from March to June, before I
+-- edited it here"). Those rows are user-entered, not write-path
+-- archives, so they must stay deletable without touching the archive
+-- the schedule-replace path mints.
+--
+-- `source` records the provenance:
+-- 'ARCHIVED' — minted by the wholesale-replace write path (immutable
+-- history; never deletable through the API).
+-- 'MANUAL' — entered through POST
+-- /api/medications/{id}/schedule-revisions (deletable by
+-- the owner).
+--
+-- Additive + non-destructive: every existing row is a write-path
+-- archive, so the default backfills correctly.
+ALTER TABLE "medication_schedule_revisions"
+ ADD COLUMN "source" TEXT NOT NULL DEFAULT 'ARCHIVED';
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 31f037ec0..75f2921e7 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1470,6 +1470,11 @@ model MedicationScheduleRevision {
/// doseWindows, label, dose, reminderGraceMinutes }` objects — enough
/// to rebuild a `CanonicalSchedule` list for era minting.
payload Json
+ /// v1.16.5 — provenance. 'ARCHIVED' = minted by the schedule-replace
+ /// write path (immutable history). 'MANUAL' = user-entered pre-tracking
+ /// era via POST /api/medications/{id}/schedule-revisions (deletable by
+ /// the owner).
+ source String @default("ARCHIVED")
createdAt DateTime @default(now()) @map("created_at")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
diff --git a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts
new file mode 100644
index 000000000..bd8d1d970
--- /dev/null
+++ b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts
@@ -0,0 +1,74 @@
+/**
+ * v1.16.5 — DELETE /api/medications/[id]/schedule-revisions/[revisionId]
+ *
+ * Removes a MANUAL schedule era (one the owner appended through the
+ * sibling POST). Write-path archives (`source: "ARCHIVED"`) are
+ * immutable history — the wholesale-replace path minted them from rows
+ * that actually existed, so deleting one would falsify the ledger; the
+ * route refuses with 409.
+ *
+ * Auth: requireAuth() + medication ownership; a revision belonging to
+ * another medication (or another user's medication) surfaces as 404,
+ * existence channel sealed like every medication sub-route.
+ */
+import { NextRequest } from "next/server";
+
+import { prisma } from "@/lib/db";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { annotate } from "@/lib/logging/context";
+import { auditLog } from "@/lib/auth/audit";
+import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
+import { assertMedicationOwnership } from "@/lib/medications/route-guards";
+import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
+
+type RouteParams = { params: Promise<{ id: string; revisionId: string }> };
+
+export const DELETE = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+ const { id, revisionId } = await params;
+
+ const guard = await assertMedicationOwnership(id, user.id);
+ if (guard) return guard;
+
+ const revision = await prisma.medicationScheduleRevision.findUnique({
+ where: { id: revisionId },
+ select: { id: true, medicationId: true, source: true },
+ });
+ if (!revision || revision.medicationId !== id) {
+ return apiError("Schedule revision not found", 404);
+ }
+
+ if (revision.source !== "MANUAL") {
+ return apiError(
+ "Only manually added schedule eras can be deleted",
+ 409,
+ );
+ }
+
+ await prisma.medicationScheduleRevision.delete({
+ where: { id: revisionId },
+ });
+
+ await auditLog("medication.schedule_revision.deleted", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { medicationId: id, revisionId },
+ });
+
+ annotate({
+ action: {
+ name: "medication.schedule_revision.manual_deleted",
+ entity_type: "medication",
+ entity_id: id,
+ },
+ meta: { revision_id: revisionId },
+ });
+
+ // History re-segments without the era; refresh the pre-aggregated
+ // compliance rollups asynchronously (best-effort).
+ await enqueueUserMedicationComplianceBackfill(user.id);
+
+ return apiSuccess({ deleted: true });
+ },
+);
diff --git a/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts
new file mode 100644
index 000000000..d9774e10d
--- /dev/null
+++ b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts
@@ -0,0 +1,328 @@
+/**
+ * v1.16.5 — schedule-era endpoints: ownership, validation, provenance.
+ *
+ * GET /api/medications/[id]/schedule-revisions
+ * POST /api/medications/[id]/schedule-revisions
+ * DELETE /api/medications/[id]/schedule-revisions/[revisionId]
+ *
+ * The POST guards are the contract under test: a manual era must end
+ * at or before the start of the live plan, must not overlap an
+ * archived era, and is always minted with `source: "MANUAL"`. The
+ * DELETE refuses write-path archives (409) so the immutable history
+ * the replace path mints can never be falsified through the API.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ medication: {
+ findUnique: vi.fn(),
+ },
+ medicationScheduleRevision: {
+ findMany: vi.fn().mockResolvedValue([]),
+ findUnique: vi.fn(),
+ create: vi.fn(),
+ delete: vi.fn(),
+ },
+ },
+}));
+
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/auth/audit", () => ({
+ auditLog: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
+ enqueueUserMedicationComplianceBackfill: vi
+ .fn()
+ .mockResolvedValue({ enqueued: true, error: null }),
+}));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { GET, POST } from "../route";
+import { DELETE } from "../[revisionId]/route";
+import { prisma } from "@/lib/db";
+import { getSession } from "@/lib/auth/session";
+import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
+
+const SESSION_OK = {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "user-1", username: "tester", role: "USER" as const },
+};
+
+const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) };
+
+function getReq(): NextRequest {
+ return new NextRequest(
+ "http://localhost/api/medications/m1/schedule-revisions",
+ );
+}
+
+function postReq(body: unknown): NextRequest {
+ return new NextRequest(
+ "http://localhost/api/medications/m1/schedule-revisions",
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+}
+
+function deleteReq(revisionId: string): NextRequest {
+ return new NextRequest(
+ `http://localhost/api/medications/m1/schedule-revisions/${revisionId}`,
+ { method: "DELETE" },
+ );
+}
+
+function deleteCtx(revisionId: string) {
+ return { params: Promise.resolve({ id: "m1", revisionId }) };
+}
+
+const MED_CREATED_AT = new Date("2026-01-10T08:00:00Z");
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ // Ownership guard + GET both read `medication.findUnique`; the guard
+ // selects `{ id, userId }`, the GET selects `{ createdAt }` — one
+ // superset row satisfies both.
+ vi.mocked(prisma.medication.findUnique).mockResolvedValue({
+ id: "m1",
+ userId: "user-1",
+ createdAt: MED_CREATED_AT,
+ } as never);
+ vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue(
+ [] as never,
+ );
+});
+
+describe("GET /api/medications/[id]/schedule-revisions", () => {
+ it("404s for another user's medication (existence sealed)", async () => {
+ vi.mocked(prisma.medication.findUnique).mockResolvedValue({
+ id: "m1",
+ userId: "someone-else",
+ createdAt: MED_CREATED_AT,
+ } as never);
+ const res = await GET(getReq(), ROUTE_CTX);
+ expect(res.status).toBe(404);
+ });
+
+ it("returns currentSince = createdAt when no revision exists", async () => {
+ const res = await GET(getReq(), ROUTE_CTX);
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.data.currentSince).toBe(MED_CREATED_AT.toISOString());
+ expect(json.data.revisions).toEqual([]);
+ });
+
+ it("returns currentSince = newest validUntil and summarised entries", async () => {
+ vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([
+ {
+ id: "rev-2",
+ validFrom: new Date("2026-03-01T00:00:00Z"),
+ validUntil: new Date("2026-05-01T00:00:00Z"),
+ source: "ARCHIVED",
+ payload: [
+ {
+ timesOfDay: ["07:00", "19:00"],
+ label: "Morgens",
+ dose: null,
+ scheduleType: "SCHEDULED",
+ },
+ ],
+ },
+ {
+ id: "rev-1",
+ validFrom: new Date("2026-01-10T08:00:00Z"),
+ validUntil: new Date("2026-03-01T00:00:00Z"),
+ source: "MANUAL",
+ // Malformed payload must degrade, never throw on a read path.
+ payload: { not: "an array" },
+ },
+ ] as never);
+
+ const res = await GET(getReq(), ROUTE_CTX);
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.data.currentSince).toBe("2026-05-01T00:00:00.000Z");
+ expect(json.data.revisions).toHaveLength(2);
+ expect(json.data.revisions[0].entries[0].timesOfDay).toEqual([
+ "07:00",
+ "19:00",
+ ]);
+ expect(json.data.revisions[1].entries).toEqual([]);
+ expect(json.data.revisions[1].source).toBe("MANUAL");
+ });
+});
+
+describe("POST /api/medications/[id]/schedule-revisions", () => {
+ const VALID_BODY = {
+ validFrom: "2025-03-12T00:00:00.000Z",
+ validUntil: "2025-06-01T00:00:00.000Z",
+ timesOfDay: ["19:00", "07:00"],
+ };
+
+ it("404s for another user's medication", async () => {
+ vi.mocked(prisma.medication.findUnique).mockResolvedValue({
+ id: "m1",
+ userId: "someone-else",
+ createdAt: MED_CREATED_AT,
+ } as never);
+ const res = await POST(postReq(VALID_BODY), ROUTE_CTX);
+ expect(res.status).toBe(404);
+ expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
+ });
+
+ it("422s when validFrom is not before validUntil", async () => {
+ const res = await POST(
+ postReq({ ...VALID_BODY, validUntil: VALID_BODY.validFrom }),
+ ROUTE_CTX,
+ );
+ expect(res.status).toBe(422);
+ });
+
+ it("422s on a malformed time literal", async () => {
+ const res = await POST(
+ postReq({ ...VALID_BODY, timesOfDay: ["25:99"] }),
+ ROUTE_CTX,
+ );
+ expect(res.status).toBe(422);
+ });
+
+ it("422s when the era overlaps an archived era", async () => {
+ vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([
+ {
+ validFrom: new Date("2025-05-01T00:00:00Z"),
+ validUntil: new Date("2026-01-01T00:00:00Z"),
+ },
+ ] as never);
+ const res = await POST(postReq(VALID_BODY), ROUTE_CTX);
+ expect(res.status).toBe(422);
+ const json = await res.json();
+ expect(json.error).toMatch(/overlap/i);
+ });
+
+ it("422s when the era ends after the live plan began", async () => {
+ vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([
+ {
+ validFrom: new Date("2025-01-01T00:00:00Z"),
+ validUntil: new Date("2025-04-01T00:00:00Z"),
+ },
+ ] as never);
+ // Ends 2025-06-01 — after the live plan start (2025-04-01) — and
+ // does NOT overlap the archived era; the boundary check must fire.
+ const res = await POST(
+ postReq({ ...VALID_BODY, validFrom: "2025-04-01T00:00:00.000Z" }),
+ ROUTE_CTX,
+ );
+ expect(res.status).toBe(422);
+ const json = await res.json();
+ expect(json.error).toMatch(/current plan/i);
+ });
+
+ it("creates a MANUAL revision with a daily snapshot entry", async () => {
+ vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({
+ id: "rev-new",
+ validFrom: new Date(VALID_BODY.validFrom),
+ validUntil: new Date(VALID_BODY.validUntil),
+ source: "MANUAL",
+ payload: [],
+ } as never);
+
+ const res = await POST(postReq(VALID_BODY), ROUTE_CTX);
+ expect(res.status).toBe(201);
+
+ const createArgs = vi.mocked(prisma.medicationScheduleRevision.create)
+ .mock.calls[0][0] as unknown as {
+ data: {
+ medicationId: string;
+ source: string;
+ payload: Array<{
+ timesOfDay: string[];
+ rrule: string | null;
+ windowStart: string;
+ windowEnd: string;
+ scheduleType: string;
+ }>;
+ };
+ };
+ expect(createArgs.data.medicationId).toBe("m1");
+ expect(createArgs.data.source).toBe("MANUAL");
+ expect(createArgs.data.payload).toHaveLength(1);
+ // Times sorted ascending; window pulled to their min/max; daily.
+ expect(createArgs.data.payload[0].timesOfDay).toEqual(["07:00", "19:00"]);
+ expect(createArgs.data.payload[0].windowStart).toBe("07:00");
+ expect(createArgs.data.payload[0].windowEnd).toBe("19:00");
+ expect(createArgs.data.payload[0].rrule).toBe("FREQ=DAILY");
+ expect(createArgs.data.payload[0].scheduleType).toBe("SCHEDULED");
+
+ // History re-segmented — the rollup backfill must be enqueued.
+ expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
+ "user-1",
+ );
+ });
+});
+
+describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => {
+ it("404s when the revision belongs to another medication", async () => {
+ vi.mocked(
+ prisma.medicationScheduleRevision.findUnique,
+ ).mockResolvedValue({
+ id: "rev-x",
+ medicationId: "other-med",
+ source: "MANUAL",
+ } as never);
+ const res = await DELETE(deleteReq("rev-x"), deleteCtx("rev-x"));
+ expect(res.status).toBe(404);
+ expect(prisma.medicationScheduleRevision.delete).not.toHaveBeenCalled();
+ });
+
+ it("409s for a write-path archive", async () => {
+ vi.mocked(
+ prisma.medicationScheduleRevision.findUnique,
+ ).mockResolvedValue({
+ id: "rev-a",
+ medicationId: "m1",
+ source: "ARCHIVED",
+ } as never);
+ const res = await DELETE(deleteReq("rev-a"), deleteCtx("rev-a"));
+ expect(res.status).toBe(409);
+ expect(prisma.medicationScheduleRevision.delete).not.toHaveBeenCalled();
+ });
+
+ it("deletes a MANUAL era and enqueues the rollup backfill", async () => {
+ vi.mocked(
+ prisma.medicationScheduleRevision.findUnique,
+ ).mockResolvedValue({
+ id: "rev-m",
+ medicationId: "m1",
+ source: "MANUAL",
+ } as never);
+ vi.mocked(prisma.medicationScheduleRevision.delete).mockResolvedValue(
+ {} as never,
+ );
+ const res = await DELETE(deleteReq("rev-m"), deleteCtx("rev-m"));
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.data.deleted).toBe(true);
+ expect(prisma.medicationScheduleRevision.delete).toHaveBeenCalledWith({
+ where: { id: "rev-m" },
+ });
+ expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
+ "user-1",
+ );
+ });
+});
diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts
new file mode 100644
index 000000000..0a4ca4259
--- /dev/null
+++ b/src/app/api/medications/[id]/schedule-revisions/route.ts
@@ -0,0 +1,263 @@
+/**
+ * v1.16.5 — schedule-era management for the Zeitplan tab.
+ *
+ * GET /api/medications/[id]/schedule-revisions
+ * - lists the medication's archived schedule eras (newest first)
+ * plus `currentSince`, the instant the LIVE plan took over (the
+ * newest revision's `validUntil`, or `medication.createdAt` when
+ * no era has been archived yet).
+ *
+ * POST /api/medications/[id]/schedule-revisions
+ * - appends a MANUAL era for pre-tracking history ("dosed at
+ * 07:00/19:00 from March to June"). Validation: the era must end
+ * at or before the start of the live era and must not overlap
+ * any existing revision. The snapshot payload mirrors what the
+ * write-path archive mints (`FREQ=DAILY`, window pulled to the
+ * min/max of the times) so the era minter — ledger, compliance,
+ * cadence chips — reads it like any other revision.
+ *
+ * Auth: cookie-session or Bearer via requireAuth(); the medication is
+ * verified to belong to the caller before any read or write. Deleting
+ * a manual era lives in the `[revisionId]` sub-route.
+ */
+import { NextRequest } from "next/server";
+
+import { prisma } from "@/lib/db";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { annotate } from "@/lib/logging/context";
+import { auditLog } from "@/lib/auth/audit";
+import {
+ apiError,
+ apiSuccess,
+ getClientIp,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
+import { assertMedicationOwnership } from "@/lib/medications/route-guards";
+import { scheduleRevisionCreateSchema } from "@/lib/validations/schedule-revision";
+import {
+ toRevisionPayloadEntry,
+ type ScheduleRevisionEntry,
+} from "@/lib/medications/scheduling/schedule-eras";
+import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
+import type { Prisma } from "@/generated/prisma/client";
+
+type RouteParams = { params: Promise<{ id: string }> };
+
+/**
+ * Display projection of one archived schedule row. Defensive parsing —
+ * a malformed payload entry degrades to an empty summary instead of
+ * throwing on a read path (mirrors `canonicalSchedulesFromRevision`).
+ */
+function summariseEntries(payload: unknown): Array<{
+ timesOfDay: string[];
+ label: string | null;
+ dose: string | null;
+ scheduleType: string;
+}> {
+ if (!Array.isArray(payload)) return [];
+ return payload.map((raw) => {
+ const e = (raw ?? {}) as Partial
+ {t("medications.detail.zeitplan.history.loadError")} +
+ ); + } + if (!data) return null; + + const revisions = data.revisions; + + return ( ++ {liveTimes.length > 0 + ? liveTimes.join(" / ") + : t("medications.detail.zeitplan.history.noTimes")} +
++ {t("medications.detail.zeitplan.history.currentSince", { + date: fmt.date(data.currentSince), + })} +
++ {times.length > 0 + ? times.join(" / ") + : t("medications.detail.zeitplan.history.noTimes")} + {revision.source === "MANUAL" && ( + + {t("medications.detail.zeitplan.history.manualChip")} + + )} +
++ {fmt.date(revision.validFrom)} + {" – "} + {fmt.date(revision.validUntil)} +
+