diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d9447ab1d..b66a81c6c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -17386,6 +17386,46 @@ paths: "403": *a11 "422": *a4 "429": *a5 + /api/documents/inbound/layout: + get: + tags: + - Documents + summary: Get the vault presentation + description: The resolved card/list view and month arrangement for the vault, with defaults filled in when nothing has + been chosen. Inside a shared record this answers the owner's choice. + responses: + "200": + description: The resolved presentation. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentsLayoutEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 + put: + tags: + - Documents + summary: Change the vault presentation + description: "Preserve-when-absent: a body carrying only `view` keeps the stored arrangement and vice versa. Returns the + full resolved presentation. Refused while acting inside somebody else's record." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentsLayoutPutBody" + responses: + "200": + description: The presentation as stored. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentsLayoutSavedEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 /api/documents/inbound/bulk: post: tags: @@ -31275,6 +31315,26 @@ components: additionalProperties: false description: Partial edit of a family-history entry; an omitted key leaves the column untouched. A `null` `ageAtOnset`/`note` clears that field. Rejects unknown keys. + DocumentsLayoutPutBody: + type: object + properties: + version: + type: number + const: 1 + view: + type: string + enum: + - cards + - list + arrangement: + type: string + enum: + - stacked + - flow + required: + - version + additionalProperties: false + description: Either field may be omitted; the stored value is kept for the one left out. DocumentBulkRequest: type: object properties: @@ -36661,6 +36721,7 @@ components: insightsLayoutJson: {} medicationListLayoutJson: {} moodTagLayoutJson: {} + documentsLayoutJson: {} reportSelectionJson: {} globalExcludedInjectionSites: type: array @@ -72136,6 +72197,65 @@ components: - assistAvailable - contentIndex additionalProperties: false + DocumentsLayoutEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentsLayout" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentsLayout: + type: object + properties: + version: + type: number + const: 1 + view: + type: string + enum: + - cards + - list + description: '`cards` renders the preview-tile grid, `list` one compact row per document. Default "cards".' + arrangement: + type: string + enum: + - stacked + - flow + description: '`stacked` gives every month its own block; `flow` runs the documents across the full width and wraps, with + month names inline. Default "stacked".' + required: + - version + - view + - arrangement + additionalProperties: false + description: Per-user document vault presentation. Display-only; a client that does not render a vault ignores it. + DocumentsLayoutSavedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentsLayout" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false DocumentBulkEnvelope: type: object properties: diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 4dc51327d..4cd77f256 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -464,6 +464,8 @@ test.describe("document vault", () => { const sheet = page.getByRole("dialog", { name: `${title}.pdf` }); await expect(sheet).toBeVisible(); await sheet.getByRole("button", { name: "Delete" }).click(); + // v1.42 — the sheet's Delete asks first, like every vault delete. + await page.locator('[data-slot="document-detail-delete-confirm"]').click(); // Undo toast → restore → the card returns. // diff --git a/messages/de.json b/messages/de.json index 625afb001..7ed78a685 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5352,6 +5352,10 @@ "mood": { "title": "Stimmungs-Tags", "description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl." + }, + "documents": { + "title": "Dokumente", + "description": "Ansicht und Anordnung der Monate." } }, "dashboard": { @@ -11061,6 +11065,21 @@ "finishedHint": "Suche erneut oder ändere einen Filter, um weitere Dokumente auszuwählen.", "maxReached": "Du kannst höchstens {max} Dokumente auf einmal importieren.", "noMatchHint": "Versuche einen Teil des Namens oder setze Tag und Datum zurück." + }, + "customize": "Darstellung anpassen", + "layout": { + "viewDescription": "Vorschaukarten mit Miniaturansicht oder eine kompakte Liste.", + "arrangementHeading": "Anordnung", + "arrangementDescription": "Jeder Monat als eigener Block oder alles fortlaufend über die ganze Breite.", + "stacked": "Monate untereinander", + "flow": "Fortlaufend", + "saveFailed": "Die Darstellung konnte nicht gespeichert werden." + }, + "deleteConfirm": { + "titleOne": "Dieses Dokument löschen?", + "titleMany": "{count} Dokumente löschen?", + "body": "Direkt danach lässt sich das noch rückgängig machen. Nach 30 Tagen wird es endgültig entfernt.", + "action": "Löschen" } }, "selfDescription": { diff --git a/messages/en.json b/messages/en.json index c8a52c09a..62f080bed 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5352,6 +5352,10 @@ "mood": { "title": "Mood tags", "description": "Groups, tags, and archived tags for the mood picker." + }, + "documents": { + "title": "Documents", + "description": "View and how the months are arranged." } }, "dashboard": { @@ -11061,6 +11065,21 @@ "finishedHint": "Search again or change a filter to pick more documents.", "maxReached": "You can import up to {max} documents at a time.", "noMatchHint": "Try part of the name, or clear the tag and dates." + }, + "customize": "Customize view", + "layout": { + "viewDescription": "Preview cards with thumbnails, or a compact list.", + "arrangementHeading": "Arrangement", + "arrangementDescription": "Each month as its own block, or everything flowing across the full width.", + "stacked": "Months stacked", + "flow": "Continuous", + "saveFailed": "Couldn't save the view." + }, + "deleteConfirm": { + "titleOne": "Delete this document?", + "titleMany": "Delete {count} documents?", + "body": "You can undo this right afterwards. After 30 days it is removed for good.", + "action": "Delete" } }, "selfDescription": { diff --git a/messages/es.json b/messages/es.json index 238bb72f4..b0ba7065f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5352,6 +5352,10 @@ "mood": { "title": "Etiquetas de ánimo", "description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo." + }, + "documents": { + "title": "Documentos", + "description": "Vista y disposición de los meses." } }, "dashboard": { @@ -11061,6 +11065,21 @@ "finishedHint": "Busca de nuevo o cambia un filtro para elegir más documentos.", "maxReached": "Puedes importar como máximo {max} documentos a la vez.", "noMatchHint": "Prueba con parte del nombre o quita la etiqueta y las fechas." + }, + "customize": "Personalizar vista", + "layout": { + "viewDescription": "Tarjetas con miniatura o una lista compacta.", + "arrangementHeading": "Disposición", + "arrangementDescription": "Cada mes en su propio bloque o todo seguido a lo ancho.", + "stacked": "Meses apilados", + "flow": "Continuo", + "saveFailed": "No se pudo guardar la vista." + }, + "deleteConfirm": { + "titleOne": "¿Eliminar este documento?", + "titleMany": "¿Eliminar {count} documentos?", + "body": "Puedes deshacerlo justo después. A los 30 días se elimina definitivamente.", + "action": "Eliminar" } }, "selfDescription": { diff --git a/messages/fr.json b/messages/fr.json index 0fd018599..6f8adf4a5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5352,6 +5352,10 @@ "mood": { "title": "Étiquettes d’humeur", "description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur." + }, + "documents": { + "title": "Documents", + "description": "Affichage et disposition des mois." } }, "dashboard": { @@ -11061,6 +11065,21 @@ "finishedHint": "Cherchez à nouveau ou changez un filtre pour choisir d'autres documents.", "maxReached": "Vous pouvez importer au plus {max} documents à la fois.", "noMatchHint": "Essayez une partie du nom, ou effacez l'étiquette et les dates." + }, + "customize": "Personnaliser l'affichage", + "layout": { + "viewDescription": "Des cartes avec aperçu ou une liste compacte.", + "arrangementHeading": "Disposition", + "arrangementDescription": "Chaque mois dans son propre bloc, ou tout à la suite sur toute la largeur.", + "stacked": "Mois empilés", + "flow": "En continu", + "saveFailed": "Impossible d'enregistrer l'affichage." + }, + "deleteConfirm": { + "titleOne": "Supprimer ce document ?", + "titleMany": "Supprimer {count} documents ?", + "body": "Vous pouvez annuler juste après. Au bout de 30 jours, la suppression est définitive.", + "action": "Supprimer" } }, "selfDescription": { diff --git a/messages/it.json b/messages/it.json index 582cd2e10..a3fa178ae 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5352,6 +5352,10 @@ "mood": { "title": "Tag dell’umore", "description": "Gruppi, tag e tag archiviati del selettore dell’umore." + }, + "documents": { + "title": "Documenti", + "description": "Visualizzazione e disposizione dei mesi." } }, "dashboard": { @@ -11061,6 +11065,21 @@ "finishedHint": "Cerca di nuovo o cambia un filtro per scegliere altri documenti.", "maxReached": "Puoi importare al massimo {max} documenti alla volta.", "noMatchHint": "Prova con parte del nome, oppure azzera etichetta e date." + }, + "customize": "Personalizza visualizzazione", + "layout": { + "viewDescription": "Schede con anteprima oppure un elenco compatto.", + "arrangementHeading": "Disposizione", + "arrangementDescription": "Ogni mese in un blocco a sé oppure tutto di seguito su tutta la larghezza.", + "stacked": "Mesi impilati", + "flow": "Continuo", + "saveFailed": "Impossibile salvare la visualizzazione." + }, + "deleteConfirm": { + "titleOne": "Eliminare questo documento?", + "titleMany": "Eliminare {count} documenti?", + "body": "Puoi annullare subito dopo. Dopo 30 giorni viene rimosso definitivamente.", + "action": "Elimina" } }, "selfDescription": { diff --git a/messages/ko.json b/messages/ko.json index 3fc16b737..fa7c8f328 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5352,6 +5352,10 @@ "mood": { "title": "기분 태그", "description": "기분 선택기의 그룹, 태그, 보관된 태그." + }, + "documents": { + "title": "문서", + "description": "보기 방식과 월 배치." } }, "dashboard": { @@ -11061,6 +11065,21 @@ "finishedHint": "다시 검색하거나 필터를 바꿔 문서를 더 고르세요.", "maxReached": "한 번에 최대 {max}개 문서를 가져올 수 있습니다.", "noMatchHint": "이름의 일부로 검색하거나 태그와 날짜를 지워 보세요." + }, + "customize": "보기 사용자 지정", + "layout": { + "viewDescription": "미리보기가 있는 카드 또는 간단한 목록.", + "arrangementHeading": "배치", + "arrangementDescription": "월별로 따로 묶거나 전체 너비에 이어서 표시합니다.", + "stacked": "월별로 나누기", + "flow": "이어서 보기", + "saveFailed": "보기 설정을 저장하지 못했습니다." + }, + "deleteConfirm": { + "titleOne": "이 문서를 삭제할까요?", + "titleMany": "문서 {count}개를 삭제할까요?", + "body": "삭제 직후에는 되돌릴 수 있습니다. 30일이 지나면 완전히 삭제됩니다.", + "action": "삭제" } }, "selfDescription": { diff --git a/messages/pl.json b/messages/pl.json index f9487fb8f..8fc97f260 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5352,6 +5352,10 @@ "mood": { "title": "Tagi nastroju", "description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju." + }, + "documents": { + "title": "Dokumenty", + "description": "Widok i układ miesięcy." } }, "dashboard": { @@ -11061,6 +11065,21 @@ "finishedHint": "Wyszukaj ponownie lub zmień filtr, aby wybrać więcej dokumentów.", "maxReached": "Możesz zaimportować naraz najwyżej {max} dokumentów.", "noMatchHint": "Spróbuj części nazwy albo wyczyść tag i daty." + }, + "customize": "Dostosuj widok", + "layout": { + "viewDescription": "Karty z miniaturą albo zwarta lista.", + "arrangementHeading": "Układ", + "arrangementDescription": "Każdy miesiąc osobno albo wszystko po kolei na całą szerokość.", + "stacked": "Miesiące jeden pod drugim", + "flow": "Ciągły", + "saveFailed": "Nie udało się zapisać widoku." + }, + "deleteConfirm": { + "titleOne": "Usunąć ten dokument?", + "titleMany": "Usunąć dokumenty ({count})?", + "body": "Zaraz potem możesz to cofnąć. Po 30 dniach zostanie usunięte na stałe.", + "action": "Usuń" } }, "selfDescription": { diff --git a/prisma/migrations/0388_documents_layout/migration.sql b/prisma/migrations/0388_documents_layout/migration.sql new file mode 100644 index 000000000..b7b5122c7 --- /dev/null +++ b/prisma/migrations/0388_documents_layout/migration.sql @@ -0,0 +1,3 @@ +-- Per-user document vault presentation (view + month arrangement). +-- Nullable, display-only; null reads as the defaults. +ALTER TABLE "users" ADD COLUMN "documents_layout_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f24308c7b..72acf6c71 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -310,6 +310,12 @@ model User { // once the user has explicitly toggled the view or saved an order. // Shape: { "version": 1, "view": "table", "order": ["", ...] } medicationListLayoutJson Json? @map("medication_list_layout_json") + // v1.42 — per-user document vault presentation: the card/list view and + // the stacked/flowing month arrangement. Display-only, preserve-when-absent + // PUT on `/api/documents/inbound/layout`, resolved field by field on read + // (`src/lib/documents/documents-layout.ts`). Null = defaults (cards, stacked). + // Shape: { "version": 1, "view": "list", "arrangement": "flow" } + documentsLayoutJson Json? @map("documents_layout_json") // v1.17.0 — per-user mood-tag presentation: group order + per-group tag // placement (catalogue tags "moved" into a group live here only — their // `categoryId` never changes). Display-only, preserve-when-absent PUT, diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 1324fca12..46ec7e4db 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1190,6 +1190,10 @@ const DELEGABLE_ROUTES: Record = { domain: "documents", why: "The record's vault state: quota, the filter bar's condition chips, and index coverage. Without it the admitted list loses its filters. The `assistAvailable` boolean is the same integration-adjacent availability flag `nutrients` already returns — no credential, endpoint or token — and every AI action it would gate stays refused. No write arm exists.", }, + "app/api/documents/inbound/layout/route.ts": { + domain: "documents", + why: "The record's vault presentation, read only: two closed enums (card or list view, stacked or flowing months) and nothing else. The admitted list renders through it, so a refused read would leave a delegate's vault waiting on a shape it can never learn. The PUT stays bare — how the owner's vault is laid out is the owner's choice, the same split `/api/medications/layout` keeps.", + }, /* ------------------------------------------------------------------ */ /* v1.37.0 — the fifty-one modules that became delegable at MANAGE. */ @@ -2086,8 +2090,11 @@ const ACTOR_ROUTES: Record = { * the life-event writes join it with their audit rows: the edit/delete module * on the record list, the create and the edit/delete on the manage literal. * 251 -> 255. + * + * v1.42 -- the document vault's presentation read joins the record list + * beside the rest of the vault; its PUT stays bare. 255 -> 256. */ -const FROZEN_ENTRY_COUNT = 255; +const FROZEN_ENTRY_COUNT = 256; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/documents/inbound/layout/__tests__/route.test.ts b/src/app/api/documents/inbound/layout/__tests__/route.test.ts new file mode 100644 index 000000000..8c267af61 --- /dev/null +++ b/src/app/api/documents/inbound/layout/__tests__/route.test.ts @@ -0,0 +1,167 @@ +/** + * `/api/documents/inbound/layout` — the vault presentation. + * + * GET answers the defaults for an account that never chose, without writing + * a row; PUT is preserve-when-absent per field, refuses unknown values and + * unknown keys with the multi-issue 422, and both halves stop at the + * documents module gate. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, + toJson: (v: unknown) => v, +})); + +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), +})); + +vi.mock("@/lib/logging/transports", () => ({ + emitIfSampled: vi.fn(), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PUT } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { DEFAULT_DOCUMENTS_LAYOUT } from "@/lib/documents/documents-layout"; + +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, + displayName: null, + }, +}; + +const callGet = GET as unknown as () => Promise; +const callPut = PUT as unknown as (req: NextRequest) => Promise; + +function put(body: unknown): Promise { + return callPut( + new NextRequest("http://localhost/api/documents/inbound/layout", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); +}); + +describe("GET /api/documents/inbound/layout", () => { + it("answers the defaults and writes nothing for an account that never chose", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsLayoutJson: null, + } as never); + + const res = await callGet(); + expect(res.status).toBe(200); + expect((await res.json()).data).toEqual(DEFAULT_DOCUMENTS_LAYOUT); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("serves the stored choice, repairing an unknown value field by field", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsLayoutJson: { version: 1, view: "list", arrangement: "spiral" }, + } as never); + + const res = await callGet(); + expect((await res.json()).data).toEqual({ + version: 1, + view: "list", + arrangement: "stacked", + }); + }); + + it("stops at the module gate", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), + } as never); + + const res = await callGet(); + expect(res.status).toBe(403); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); +}); + +describe("PUT /api/documents/inbound/layout", () => { + it("keeps the stored arrangement when only the view changes", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsLayoutJson: { version: 1, view: "cards", arrangement: "flow" }, + } as never); + + const res = await put({ version: 1, view: "list" }); + expect(res.status).toBe(200); + const expected = { version: 1, view: "list", arrangement: "flow" }; + expect((await res.json()).data).toEqual(expected); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { documentsLayoutJson: expected }, + }); + }); + + it("keeps the stored view when only the arrangement changes", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsLayoutJson: { version: 1, view: "list", arrangement: "stacked" }, + } as never); + + const res = await put({ version: 1, arrangement: "flow" }); + expect((await res.json()).data).toEqual({ + version: 1, + view: "list", + arrangement: "flow", + }); + }); + + it("refuses an unknown value and an unknown key with 422 and writes nothing", async () => { + const bad = await put({ version: 1, view: "carousel" }); + expect(bad.status).toBe(422); + const extra = await put({ version: 1, view: "list", userId: "user-2" }); + expect(extra.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("stops at the module gate before reading the body", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), + } as never); + + const res = await put({ version: 1, view: "list" }); + expect(res.status).toBe(403); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/documents/inbound/layout/route.ts b/src/app/api/documents/inbound/layout/route.ts new file mode 100644 index 000000000..45d3e2b94 --- /dev/null +++ b/src/app/api/documents/inbound/layout/route.ts @@ -0,0 +1,99 @@ +/** + * GET / PUT the document vault presentation (view + month arrangement). + * + * GET returns the resolved presentation, defaults merged in when the user has + * not chosen yet. PUT changes it with preserve-when-absent semantics: a body + * carrying only `view` keeps the stored arrangement and vice versa, the same + * contract `/api/medications/layout` keeps for its view and order. The blob + * lives on its own `User` column (`documents_layout_json`) per the + * per-surface-column convention. + * + * Both halves sit behind the documents module gate like every other route + * under `/api/documents/inbound`. + */ +import { z } from "zod/v4"; + +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { prisma, toJson } from "@/lib/db"; +import { + DOCUMENTS_LAYOUT_ARRANGEMENTS, + DOCUMENTS_LAYOUT_VIEWS, + resolveDocumentsLayout, + type DocumentsLayout, +} from "@/lib/documents/documents-layout"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +export const dynamic = "force-dynamic"; + +const layoutPutSchema = z + .object({ + version: z.literal(1), + view: z.enum(DOCUMENTS_LAYOUT_VIEWS).optional(), + arrangement: z.enum(DOCUMENTS_LAYOUT_ARRANGEMENTS).optional(), + }) + .strict(); + +async function readLayout(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { documentsLayoutJson: true }, + }); + return resolveDocumentsLayout(row?.documentsLayoutJson); +} + +export const GET = apiHandler(async () => { + // Read: the RECORD's presentation, so a delegate browsing somebody's vault + // sees it the way its owner arranged it. The write below stays the + // caller's own and refuses under a switch. + const { user } = await requireRecordAuth("read", "documents"); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const layout = await readLayout(user.id); + annotate({ + action: { name: "documents.layout.read" }, + meta: { view: layout.view, arrangement: layout.arrangement }, + }); + return apiSuccess(layout); +}); + +export const PUT = apiHandler(async (request: Request) => { + // Bare on purpose: how the owner's vault is laid out is the owner's choice, + // not something a helper rearranges on their behalf. + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = layoutPutSchema.safeParse(body); + if (!parsed.success) { + annotate({ action: { name: "documents.layout.validation-failed" } }); + return returnAllZodIssues(parsed.error, 422); + } + + // Preserve-when-absent: one stored read fills whichever field the body + // left out. + const existing = await readLayout(user.id); + const next: DocumentsLayout = { + version: 1, + view: parsed.data.view ?? existing.view, + arrangement: parsed.data.arrangement ?? existing.arrangement, + }; + + await prisma.user.update({ + where: { id: user.id }, + data: { documentsLayoutJson: toJson(next) }, + }); + + annotate({ + action: { name: "documents.layout.update" }, + meta: { view: next.view, arrangement: next.arrangement }, + }); + return apiSuccess(next); +}); diff --git a/src/components/documents/__tests__/document-bulk-bar.test.tsx b/src/components/documents/__tests__/document-bulk-bar.test.tsx index 2770facfe..3ddea1b6c 100644 --- a/src/components/documents/__tests__/document-bulk-bar.test.tsx +++ b/src/components/documents/__tests__/document-bulk-bar.test.tsx @@ -29,7 +29,7 @@ function render( onLinkEpisode={() => {}} onLinkEncounter={() => {}} onShare={() => {}} - onDelete={() => {}} + onRequestDelete={() => {}} onClear={() => {}} /> , @@ -51,6 +51,51 @@ describe("", () => { expect(html).toContain("Clear selection"); }); + it("clears with an X icon at the right of the count line, not a text button", () => { + const html = render([{ id: "ep1", label: "Knee" }]); + const countLine = html.slice( + html.indexOf('data-slot="document-bulk-count"'), + html.indexOf('data-slot="document-bulk-actions"'), + ); + // The clear control lives on the count line, before the action row… + expect(countLine).toContain('data-slot="document-bulk-clear"'); + expect(countLine).toContain('aria-label="Clear selection"'); + // …and carries no visible label: the name rides on aria-label and title. + expect(countLine).not.toContain(">Clear selection<"); + }); + + it("keeps every verb in ONE action row with Delete alone at its right edge", () => { + const html = render([{ id: "ep1", label: "Knee" }]); + const actions = html.slice( + html.indexOf('data-slot="document-bulk-actions"'), + ); + const order = [ + "document-bulk-set-kind", + "document-bulk-link-condition", + "document-bulk-link-visit", + "document-bulk-share", + "document-bulk-delete", + ].map((slot) => actions.indexOf(`data-slot="${slot}"`)); + expect(order.every((index) => index >= 0)).toBe(true); + expect([...order].sort((a, b) => a - b)).toEqual(order); + // The row never wraps into a second line, and Delete is pushed right. + const rowTag = /]*data-slot="document-bulk-actions"[^>]*>/.exec( + html, + ); + expect(rowTag?.[0]).not.toContain("flex-wrap"); + const deleteTag = /]*data-slot="document-bulk-delete"[^>]*>/.exec( + html, + ); + expect(deleteTag?.[0]).toContain("ml-auto"); + }); + + it("carries the selection-bar slot the Coach launcher steps aside for", () => { + const html = render([{ id: "ep1", label: "Knee" }]); + expect(html).toMatch( + /^
/, + ); + }); + it("omits the link-condition verb when the account has no episodes", () => { const html = render([]); expect(html).not.toContain("Link condition"); diff --git a/src/components/documents/__tests__/document-month-grid.test.tsx b/src/components/documents/__tests__/document-month-grid.test.tsx index c0b2a8a1b..360c2bb48 100644 --- a/src/components/documents/__tests__/document-month-grid.test.tsx +++ b/src/components/documents/__tests__/document-month-grid.test.tsx @@ -7,12 +7,50 @@ import { describe, expect, it } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { columnsForWidth, DocumentMonthHeading, + DocumentMonthRow, estimatedRowHeight, + listColumnsForWidth, } from "../document-month-grid"; +function doc(id: string): InboundDocumentDto { + return { + id, + kind: "DOCTOR_REPORT", + title: `Letter ${id}`, + filename: null, + mimeType: "application/pdf", + byteSize: 1024, + status: "STORED", + documentDate: "2026-03-10", + createdAt: "2026-03-10T10:00:00Z", + conditionLinks: [], + servingClass: "inline", + hasThumbnail: false, + hasContentIndex: true, + } as unknown as InboundDocumentDto; +} + +function renderRow(props: Partial[0]>) { + return renderToStaticMarkup( + + {}} + highlightId={null} + rovingId="a" + onCardFocus={() => {}} + {...props} + /> + , + ); +} + describe("columnsForWidth", () => { it("lays a 390 px phone out in two columns, the narrowest screens in one", () => { // 390 viewport minus the shell's 16 px gutters. @@ -50,3 +88,40 @@ describe("", () => { expect(html).toMatch(/^

]*uppercase[^>]*>March 2026<\/h2>$/); }); }); + +describe("listColumnsForWidth", () => { + it("keeps compact rows single-file on a phone and side by side on a desktop", () => { + expect(listColumnsForWidth(358)).toBe(1); + expect(listColumnsForWidth(1024 - 48)).toBe(2); + expect(listColumnsForWidth(1280 - 48)).toBe(3); + }); +}); + +describe("", () => { + it("renders preview tiles by default and compact rows in the list view", () => { + expect(renderRow({})).toContain('data-variant="tile"'); + const list = renderRow({ view: "list" }); + expect(list).toContain('data-variant="row"'); + expect(list).not.toContain('data-variant="tile"'); + }); + + it("flowing: the month name rides inline above the document that opens it", () => { + const html = renderRow({ + monthStarts: { b: "2026-02" }, + formatMonth: () => "February 2026", + }); + expect(html.match(/data-slot="document-flow-month"/g)).toHaveLength(1); + expect(html).toContain("February 2026"); + // The marker belongs to b's cell, not a's. + expect(html.indexOf("February 2026")).toBeGreaterThan( + html.indexOf('data-document-id="a"'), + ); + expect(html.indexOf("February 2026")).toBeLessThan( + html.indexOf('data-document-id="b"'), + ); + }); + + it("stacked rows carry no inline month marker", () => { + expect(renderRow({})).not.toContain('data-slot="document-flow-month"'); + }); +}); diff --git a/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts b/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts new file mode 100644 index 000000000..abfde0ac0 --- /dev/null +++ b/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts @@ -0,0 +1,55 @@ +/** + * Every way of deleting from the vault asks first. + * + * Until v1.42 the bulk bar's Delete and a focused card's Delete key both + * called the bulk delete straight away; only the transient undo toast stood + * between a stray press and a medical document leaving the vault. The page now + * routes both through one confirmation (`requestDelete` → `ConfirmDialog` → + * `confirmDelete`), and the detail sheet's Delete opens its own. + * + * Structural rather than behavioural because the unit layer renders statically; + * the e2e journey (`documents-qol.spec.ts`) clicks through the real dialog. The + * matchers assert a non-zero count, so a rename that leaves them matching + * nothing fails instead of passing quietly. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const dir = join(process.cwd(), "src/components/documents"); +const view = readFileSync(join(dir, "documents-view.tsx"), "utf8"); +const sheet = readFileSync(join(dir, "document-detail-sheet.tsx"), "utf8"); +const bar = readFileSync(join(dir, "document-bulk-bar.tsx"), "utf8"); + +describe("vault delete paths confirm before deleting", () => { + it("the page calls the bulk delete only from the confirmation", () => { + const calls = [...view.matchAll(/deleteBulk\(\s*([^)]*)\)/g)].map((m) => + m[1].trim(), + ); + // One call site, and it is the confirmed id list. + expect(calls).toEqual(["pendingDeleteIds"]); + expect(view).toMatch( + /const confirmDelete = useCallback\(\(\) => \{\s*if \(pendingDeleteIds\) deleteBulk\(pendingDeleteIds\);/, + ); + expect(view).toContain(" { + expect(view).toMatch( + /onDelete=\{\s*canManageDocuments \? \(id\) => requestDelete\(\[id\]\)/, + ); + expect(view).toMatch( + /onRequestDelete=\{\(\) => requestDelete\(\[\.\.\.selectedIds\]\)\}/, + ); + // The bar has no delete of its own to call. + expect(bar).not.toMatch(/\bonDelete\b/); + expect(bar).toMatch(/onClick=\{onRequestDelete\}/); + }); + + it("the detail sheet's Delete opens a confirmation instead of deleting", () => { + const mutateCalls = [...sheet.matchAll(/remove\.mutate\(/g)]; + expect(mutateCalls.length).toBe(1); + expect(sheet).toMatch(/onConfirm=\{\(\) => remove\.mutate\(doc\.id\)\}/); + expect(sheet).toMatch(/onClick=\{\(\) => setConfirmDeleteOpen\(true\)\}/); + }); +}); diff --git a/src/components/documents/__tests__/documents-view-states.test.tsx b/src/components/documents/__tests__/documents-view-states.test.tsx index 56c270cf1..324b287a6 100644 --- a/src/components/documents/__tests__/documents-view-states.test.tsx +++ b/src/components/documents/__tests__/documents-view-states.test.tsx @@ -49,11 +49,18 @@ import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { DocumentsView } from "../documents-view"; -function render(search: string, seedList?: { kinds?: never } | object) { +function render( + search: string, + seedList?: { kinds?: never } | object, + layout: object | null = { version: 1, view: "cards", arrangement: "stacked" }, +) { mockSearch = search; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); + if (layout !== null) { + queryClient.setQueryData(queryKeys.documentsLayout(), layout); + } if (seedList !== undefined) { queryClient.setQueryData(queryKeys.inboundDocumentList(seedList), { pages: [{ documents: [], nextCursor: null }], @@ -80,6 +87,29 @@ describe(" states", () => { expect(html).not.toContain("Your document vault is empty"); }); + it("header: a wrench opens the vault's display settings, left of Upload", () => { + const html = render("", {}); + const wrench = html.indexOf('data-slot="documents-customize"'); + expect(wrench).toBeGreaterThan(-1); + expect(html).toContain('href="/settings/layout/documents"'); + expect(html.indexOf("Upload", wrench)).toBeGreaterThan(wrench); + }); + + it("first load waits for the presentation, then paints the list skeleton for a list reader", () => { + // The presentation read is still out: the vault does not guess a shape. + const pending = render("", {}, null); + expect(pending).toContain('data-slot="documents-loading"'); + expect(pending).not.toContain("Your document vault is empty"); + + const listReader = render("", undefined, { + version: 1, + view: "list", + arrangement: "flow", + }); + expect(listReader).toContain('data-slot="documents-loading"'); + expect(listReader).not.toContain("aspect-[4/3]"); + }); + it("filter bar: type facet is a compact dropdown, not an inline chip row", () => { const html = render(""); // The type facet renders as a single dropdown trigger labelled by the diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index ad9ddb2e9..cc515e682 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { buildProcedureChoices, + buildFlowTimelineItems, buildTimelineItems, buildVaultListApiSearch, classifyUploadFailure, @@ -127,6 +128,28 @@ describe("API list search", () => { }); describe("timeline items", () => { + it("flowing: rows run across month boundaries and mark where each month opens", () => { + const docs = [ + doc({ id: "a", documentDate: "2026-03-20" }), + doc({ id: "b", documentDate: "2026-03-15" }), + doc({ id: "c", documentDate: "2026-02-28" }), + doc({ id: "d", documentDate: "2026-02-10" }), + doc({ id: "e", documentDate: "2026-01-05" }), + ]; + const items = buildFlowTimelineItems(docs, 3, "UTC"); + // No row is spent on a month: five documents in three columns are two rows. + expect(items.map((i) => i.type)).toEqual(["row", "row"]); + expect(items[0]).toMatchObject({ + documents: [docs[0], docs[1], docs[2]], + monthStarts: { a: "2026-03", c: "2026-02" }, + }); + // February continues into the second row without a second marker. + expect(items[1]).toMatchObject({ + documents: [docs[3], docs[4]], + monthStarts: { e: "2026-01" }, + }); + }); + it("buckets by month and chunks rows to the column count", () => { const docs = [ doc({ id: "a", documentDate: "2026-03-20" }), diff --git a/src/components/documents/document-bulk-bar.tsx b/src/components/documents/document-bulk-bar.tsx index 45d3a2069..ece68fb42 100644 --- a/src/components/documents/document-bulk-bar.tsx +++ b/src/components/documents/document-bulk-bar.tsx @@ -3,20 +3,24 @@ /** * Floating bulk-action bar for the vault's multi-select mode. Appears once * at least one document is selected, pinned above the bottom nav on phones - * and near the bottom edge on desktop. Carries the selected count and the - * bulk verbs — set type, link condition, file against a visit, share (one - * link for the whole selection), delete (undo-able), clear — all driving the - * page's handlers. + * and near the bottom edge on desktop. + * + * Two tight lines. The first carries the selected count and, at its right + * edge, the X that clears the selection. The second is the one action row: + * set type, link condition, file against a visit and share lead from the + * left, and Delete sits alone at the right edge in the destructive style. + * Delete never deletes from here — it asks the page, which confirms first + * (`onRequestDelete`), the same path the card's Delete key takes. * * The two link verbs are the same shape and stay that way: each renders only * when the account HAS something to link to, so an empty menu is never * offered. * - * A `role="toolbar"` with proper labels; the destructive verb sits last - * before Clear so a stray tap sequence never ends on Delete. Share sits just - * before Delete (the safe actions lead). The bar is a deliberate hand-rolled - * shell (a floating toolbar is not a Card surface): dense-tile padding `p-3` - * per the standards. + * Below `lg` every verb is icon-only with its name in `aria-label` and + * `title`, at the 44 px tap floor, so the row stays one line at 360 px; the + * labels return where the bar is wide enough to hold them on one line. A + * `role="toolbar"`; the bar is a deliberate hand-rolled shell (a floating + * toolbar is not a Card surface): dense-tile padding `p-3` per the standards. */ import { CalendarClock, @@ -50,6 +54,13 @@ export interface BulkEncounterOption { label: string; } +/** Shared shape of the leading verbs: icon-only below `lg`, labelled from it. */ +const VERB_CLASS = "min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"; + +function VerbLabel({ children }: { children: string }) { + return {children}; +} + export function DocumentBulkBar({ selectedCount, episodes, @@ -59,7 +70,7 @@ export function DocumentBulkBar({ onLinkEpisode, onLinkEncounter, onShare, - onDelete, + onRequestDelete, onClear, }: { selectedCount: number; @@ -78,153 +89,170 @@ export function DocumentBulkBar({ * this handler just opens the share sheet seeded with the selection. */ onShare: () => void; - onDelete: () => void; + /** Ask to delete the selection. The page confirms before anything goes. */ + onRequestDelete: () => void; onClear: () => void; }) { const { t } = useTranslations(); return ( -
-

- {t("documents.selection.count", { count: selectedCount })} -

+ // The outer slot is the data-list selection bar's: the Coach launcher + // watches for it and steps aside while a selection is open, so it never + // sits over Delete at the bar's right edge. `contents` keeps the wrapper + // out of the layout. +
+
+
+

+ {t("documents.selection.count", { count: selectedCount })} +

+ +
-
- {/* v1.30 mobile audit (DO-1) — five labelled buttons wrapped into a - 2–3-row floating slab at ~360 px. Below `sm` every verb collapses - to icon-only (`aria-label` carries the accessible name; the - visible label returns at `sm+`) — the `document-detail-sheet` - footer's established icon-collapse pattern. */} - - - - - - {DOCUMENT_KIND_ORDER.map((kind) => { - const Icon = DOCUMENT_KIND_ICONS[kind]; - return ( - onSetKind(kind)}> - - {t(`documents.kind.${kind}`)} - - ); - })} - - - - {episodes.length > 0 ? ( - - - - - - {episodes.map((episode) => ( - onLinkEpisode(episode.id)} - > - {episode.label} - - ))} - - - ) : null} - - {encounters.length > 0 ? ( - - - - - - {encounters.map((encounter) => ( - onLinkEncounter(encounter.id)} - > - {encounter.label} - - ))} - - - ) : null} - - + + + + + + {DOCUMENT_KIND_ORDER.map((kind) => { + const Icon = DOCUMENT_KIND_ICONS[kind]; + return ( + onSetKind(kind)}> + + {t(`documents.kind.${kind}`)} + + ); + })} + + - {/* Solid destructive (matching the detail sheet's Delete) — the + {episodes.length > 0 ? ( + + + + + + {episodes.map((episode) => ( + onLinkEpisode(episode.id)} + > + {episode.label} + + ))} + + + ) : null} + + {encounters.length > 0 ? ( + + + + + + {encounters.map((encounter) => ( + onLinkEncounter(encounter.id)} + > + {encounter.label} + + ))} + + + ) : null} + + + + {/* Solid destructive (matching the detail sheet's Delete) — the outline variant's destructive text on the card surface fails the - WCAG contrast gate. */} - - - + WCAG contrast gate. Alone at the right edge, away from the safe + verbs. */} + +
); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 396e1db28..740670268 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -38,6 +38,7 @@ import { toast } from "sonner"; import { useIllnessEpisodes } from "@/components/illness/use-illness"; import { useAuth } from "@/hooks/use-auth"; import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-button"; import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -442,6 +443,8 @@ export function DocumentDetailSheet({ /** Bumped on a failed write; remounts the link block onto server state. */ const [linkResets, setLinkResets] = useState(0); const [shareOpen, setShareOpen] = useState(false); + // Delete asks first, like every other way of deleting from the vault. + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [editingTitle, setEditingTitle] = useState(false); /** * The visit this SESSION filed the document against, if any. @@ -769,8 +772,9 @@ export function DocumentDetailSheet({ + {/* The wrench left of the primary action, as on the illness + journal: it opens this vault's display settings. `/settings` + is the caller's own, so it never shows inside somebody + else's record. */} + {!inSharedRecord ? ( + + ) : null} + {canManageDocuments ? ( + <> + {/* Renders only with a connected Paperless-ngx or Papra. */} + + + + ) : null}
) : null } @@ -990,22 +1038,30 @@ export function DocumentsView() { onIndexAll={handleIndexAll} /> - {list.isPending ? ( -
- {Array.from({ length: 8 }, (_, i) => ( -
- - - -
- ))} -
+ {list.isPending || isLayoutLoading ? ( + layout.view === "list" ? ( +
+ {Array.from({ length: 6 }, (_, i) => ( + + ))} +
+ ) : ( +
+ {Array.from({ length: 8 }, (_, i) => ( +
+ + + +
+ ))} +
+ ) ) : list.isError ? ( ) : ( deleteBulk([id]) : undefined} + onDelete={ + canManageDocuments ? (id) => requestDelete([id]) : undefined + } highlightId={upload.highlightId} onPrefetch={prefetchDetail} /> @@ -1081,11 +1141,30 @@ export function DocumentsView() { runBulk("linkEncounter", { encounterId }) } onShare={openBulkShare} - onDelete={() => deleteBulk([...selectedIds])} + onRequestDelete={() => requestDelete([...selectedIds])} onClear={clearSelection} /> ) : null} + { + if (!open) setPendingDeleteIds(null); + }} + title={ + pendingDeleteIds !== null && pendingDeleteIds.length > 1 + ? t("documents.deleteConfirm.titleMany", { + count: pendingDeleteIds.length, + }) + : t("documents.deleteConfirm.titleOne") + } + body={t("documents.deleteConfirm.body")} + confirmLabel={t("documents.deleteConfirm.action")} + onConfirm={confirmDelete} + slot="documents-delete" + destructive + /> + ; + }; /** * Bucket an already-sorted document list into consecutive YYYY-MM sections @@ -224,6 +234,40 @@ export function buildTimelineItems( return items; } +/** + * The flowing arrangement: documents run left to right across every row and + * wrap, with no row reserved for a month. The first document of each month + * carries that month in `monthStarts`, so its name can sit inline above it. + * Order is preserved exactly as received, like `buildTimelineItems`. + */ +export function buildFlowTimelineItems( + documents: InboundDocumentDto[], + columns: number, + timezone: string, +): TimelineItem[] { + const cols = Math.max(1, columns); + const items: TimelineItem[] = []; + let month: string | null = null; + for (let i = 0; i < documents.length; i += cols) { + const row = documents.slice(i, i + cols); + const monthStarts: Record = {}; + for (const doc of row) { + const key = documentMonthKey(doc, timezone); + if (key !== month) { + monthStarts[doc.id] = key; + month = key; + } + } + items.push({ + type: "row", + key: `flow:${row[0].id}`, + documents: row, + monthStarts, + }); + } + return items; +} + /** * Format a YYYY-MM month key as a locale month label ("März 2026"). Handed * over as a noon-UTC instant so the month never shifts under the display diff --git a/src/components/settings/__tests__/layout-groups.test.ts b/src/components/settings/__tests__/layout-groups.test.ts index a4a666ca4..b028df7dc 100644 --- a/src/components/settings/__tests__/layout-groups.test.ts +++ b/src/components/settings/__tests__/layout-groups.test.ts @@ -29,7 +29,7 @@ import { } from "../layout-groups"; describe("layout-groups registry", () => { - it("exposes the seven Appearance modules as statically-generated subpage params", () => { + it("exposes the eight Appearance modules as statically-generated subpage params", () => { expect(LAYOUT_GROUP_IDS).toEqual([ "dashboard", "insights", @@ -37,6 +37,7 @@ describe("layout-groups registry", () => { "mood", "labs", "illness", + "documents", "vorsorge", ]); // generateStaticParams() shape. @@ -47,6 +48,7 @@ describe("layout-groups registry", () => { { module: "mood" }, { module: "labs" }, { module: "illness" }, + { module: "documents" }, { module: "vorsorge" }, ]); }); diff --git a/src/components/settings/documents-section.tsx b/src/components/settings/documents-section.tsx new file mode 100644 index 000000000..f1e9b4f12 --- /dev/null +++ b/src/components/settings/documents-section.tsx @@ -0,0 +1,65 @@ +"use client"; + +/** + * Settings → Appearance → Documents. + * + * Reached from the wrench beside the vault's Upload button, the way the + * illness journal's wrench reaches its own page. Two choices, each in its own + * card with the toggle in the header's status slot (the illness page's + * shape): the card/list view and the month arrangement. Both persist per user + * through `/api/documents/inbound/layout`. + */ +import { Eye, Rows3, WrapText } from "lucide-react"; + +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { ModuleViewToggle } from "@/components/module-list/module-view-toggle"; +import { ViewToggle } from "@/components/ui/view-toggle"; +import { useTranslations } from "@/lib/i18n/context"; +import { useDocumentsLayout } from "@/lib/queries/use-documents-layout"; + +export function DocumentsSection() { + const { t } = useTranslations(); + const { layout, setView, setArrangement } = useDocumentsLayout(); + + return ( +
+ + } + /> + + + + + } + /> + +
+ ); +} diff --git a/src/components/settings/layout-groups.ts b/src/components/settings/layout-groups.ts index 42f2edb77..5186a54bb 100644 --- a/src/components/settings/layout-groups.ts +++ b/src/components/settings/layout-groups.ts @@ -8,6 +8,7 @@ import { MedicationsSection } from "./medications-section"; import { MoodSection } from "./mood-section"; import { LabsSection } from "./labs-section"; import { IllnessSection } from "./illness-section"; +import { DocumentsSection } from "./documents-section"; import { VorsorgeSection } from "./vorsorge-section"; /** @@ -93,6 +94,12 @@ export const LAYOUT_GROUPS: ReadonlyArray = [ descriptionKey: "settings.sections.layout.illness.description", Body: IllnessSection, }, + { + id: "documents", + titleKey: "settings.sections.layout.documents.title", + descriptionKey: "settings.sections.layout.documents.description", + Body: DocumentsSection, + }, { id: "vorsorge", titleKey: "settings.sections.layout.vorsorge.title", diff --git a/src/components/ui/confirm-button.tsx b/src/components/ui/confirm-button.tsx index 108f36f49..3f7022c9c 100644 --- a/src/components/ui/confirm-button.tsx +++ b/src/components/ui/confirm-button.tsx @@ -54,6 +54,11 @@ interface ConfirmCopy { slot?: string; /** A control that belongs with the question, such as an opt-out checkbox. */ extra?: ReactNode; + /** + * The action deletes something: it wears the destructive style, as the + * data-list selection bar's confirm already does. + */ + destructive?: boolean; } function ConfirmBody({ @@ -64,6 +69,7 @@ function ConfirmBody({ pending = false, slot, extra, + destructive = false, }: ConfirmCopy) { const { t } = useTranslations(); @@ -77,6 +83,7 @@ function ConfirmBody({ {t("common.cancel")} `) are mood's too; see diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 1c585fa2d..ce08c12f3 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -30,6 +30,10 @@ import { INBOUND_DOCUMENT_KINDS, INBOUND_DOCUMENT_STATUSES, } from "@/lib/validations/inbound-documents"; +import { + DOCUMENTS_LAYOUT_ARRANGEMENTS, + DOCUMENTS_LAYOUT_VIEWS, +} from "@/lib/documents/documents-layout"; import { aiExtractionRefusals } from "./ai-extraction-refusals"; import { aiRunAccepted, preferRespondAsyncParameter } from "./ai-run-accepted"; @@ -44,6 +48,41 @@ import { } from "./shared"; const kindEnum = z.enum(INBOUND_DOCUMENT_KINDS); + +// ─── Vault presentation (v1.42) ──────────────────────────────────────────── + +const documentsLayoutSchema = z + .object({ + version: z.literal(1), + view: z + .enum(DOCUMENTS_LAYOUT_VIEWS) + .describe( + '`cards` renders the preview-tile grid, `list` one compact row per document. Default "cards".', + ), + arrangement: z + .enum(DOCUMENTS_LAYOUT_ARRANGEMENTS) + .describe( + '`stacked` gives every month its own block; `flow` runs the documents across the full width and wraps, with month names inline. Default "stacked".', + ), + }) + .meta({ + id: "DocumentsLayout", + description: + "Per-user document vault presentation. Display-only; a client that does not render a vault ignores it.", + }); + +const documentsLayoutPutBody = z + .object({ + version: z.literal(1), + view: z.enum(DOCUMENTS_LAYOUT_VIEWS).optional(), + arrangement: z.enum(DOCUMENTS_LAYOUT_ARRANGEMENTS).optional(), + }) + .strict() + .meta({ + id: "DocumentsLayoutPutBody", + description: + "Either field may be omitted; the stored value is kept for the one left out.", + }); const statusEnum = z.enum(INBOUND_DOCUMENT_STATUSES); // ─── Chat about a document (P4) — response shapes ────────────────────────── @@ -719,6 +758,53 @@ export const inboundDocumentPaths: NonNullable = { }, }, }, + "/api/documents/inbound/layout": { + get: { + tags: ["Documents"], + summary: "Get the vault presentation", + description: + "The resolved card/list view and month arrangement for the vault, with defaults filled in when nothing has been chosen. Inside a shared record this answers the owner's choice.", + responses: { + ...recordRefusal(), + "200": { + description: "The resolved presentation.", + content: { + "application/json": { + schema: dataEnvelope( + documentsLayoutSchema, + "DocumentsLayoutEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Documents"], + summary: "Change the vault presentation", + description: + "Preserve-when-absent: a body carrying only `view` keeps the stored arrangement and vice versa. Returns the full resolved presentation. Refused while acting inside somebody else's record.", + requestBody: { + required: true, + content: { "application/json": { schema: documentsLayoutPutBody } }, + }, + responses: { + "200": { + description: "The presentation as stored.", + content: { + "application/json": { + schema: dataEnvelope( + documentsLayoutSchema, + "DocumentsLayoutSavedEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/documents/inbound/bulk": { post: { tags: ["Documents"], diff --git a/src/lib/queries/__tests__/use-documents-layout.test.ts b/src/lib/queries/__tests__/use-documents-layout.test.ts new file mode 100644 index 000000000..b7c3d01e5 --- /dev/null +++ b/src/lib/queries/__tests__/use-documents-layout.test.ts @@ -0,0 +1,85 @@ +/** + * The vault presentation switch: field-scoped PUT, optimistic cache flip, + * rollback and a toast when the save fails. Exercised through the + * dependency-injected `runSetDocumentsLayout`, without a React render. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiPut: vi.fn(), +})); +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn(), message: vi.fn() }, +})); + +import { apiPut } from "@/lib/api/api-fetch"; +import { toast } from "sonner"; +import { runSetDocumentsLayout } from "@/lib/queries/use-documents-layout"; +import { queryKeys } from "@/lib/query-keys"; +import type { DocumentsLayout } from "@/lib/documents/documents-layout"; + +const t = (key: string) => key; +const key = queryKeys.documentsLayout(); +const STORED: DocumentsLayout = { + version: 1, + view: "cards", + arrangement: "stacked", +}; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("runSetDocumentsLayout", () => { + it("sends only the field that changed and keeps the server's answer", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(key, STORED); + const saved: DocumentsLayout = { ...STORED, arrangement: "flow" }; + vi.mocked(apiPut).mockResolvedValue(saved); + + await runSetDocumentsLayout({ + patch: { arrangement: "flow" }, + queryClient, + t, + }); + + expect(apiPut).toHaveBeenCalledWith("/api/documents/inbound/layout", { + version: 1, + arrangement: "flow", + }); + expect(queryClient.getQueryData(key)).toEqual(saved); + }); + + it("flips the cache before the save lands", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(key, STORED); + let settle: (value: DocumentsLayout) => void = () => {}; + vi.mocked(apiPut).mockReturnValue( + new Promise((resolve) => { + settle = resolve; + }), + ); + + const run = runSetDocumentsLayout({ + patch: { view: "list" }, + queryClient, + t, + }); + expect(queryClient.getQueryData(key)?.view).toBe("list"); + settle({ ...STORED, view: "list" }); + await run; + }); + + it("rolls back and says so when the save fails", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(key, STORED); + vi.mocked(apiPut).mockRejectedValue(new Error("offline")); + + await runSetDocumentsLayout({ patch: { view: "list" }, queryClient, t }); + + expect(queryClient.getQueryData(key)).toEqual(STORED); + expect(toast.error).toHaveBeenCalledWith("documents.layout.saveFailed"); + }); +}); diff --git a/src/lib/queries/use-documents-layout.ts b/src/lib/queries/use-documents-layout.ts new file mode 100644 index 000000000..e9602a5d7 --- /dev/null +++ b/src/lib/queries/use-documents-layout.ts @@ -0,0 +1,85 @@ +"use client"; + +import { + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { + DEFAULT_DOCUMENTS_LAYOUT, + type DocumentsLayout, + type DocumentsLayoutArrangement, + type DocumentsLayoutView, +} from "@/lib/documents/documents-layout"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +type Translator = ( + key: string, + params?: Record, +) => string; + +type LayoutPatch = { + view?: DocumentsLayoutView; + arrangement?: DocumentsLayoutArrangement; +}; + +/** + * Apply one field optimistically (the switch has to feel instant), then + * persist it. The PUT is field-scoped, so a view change never resets the + * arrangement and vice versa. On failure the cache rolls back and a calm + * toast says so. + */ +export async function runSetDocumentsLayout(deps: { + patch: LayoutPatch; + queryClient: QueryClient; + t: Translator; +}): Promise { + const { patch, queryClient, t } = deps; + const key = queryKeys.documentsLayout(); + const previous = queryClient.getQueryData(key); + queryClient.setQueryData(key, { + ...(previous ?? DEFAULT_DOCUMENTS_LAYOUT), + ...patch, + }); + try { + const saved = await apiPut( + "/api/documents/inbound/layout", + { version: 1, ...patch }, + ); + if (saved) queryClient.setQueryData(key, saved); + } catch { + queryClient.setQueryData(key, previous); + toast.error(t("documents.layout.saveFailed")); + } +} + +/** The vault presentation, persisted per user. Defaults until the read lands. */ +export function useDocumentsLayout(enabled: boolean = true): { + layout: DocumentsLayout; + isLayoutLoading: boolean; + setView: (view: DocumentsLayoutView) => Promise; + setArrangement: (arrangement: DocumentsLayoutArrangement) => Promise; +} { + const queryClient = useQueryClient(); + const { t } = useTranslations(); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.documentsLayout(), + queryFn: () => apiGet("/api/documents/inbound/layout"), + staleTime: 5 * 60 * 1000, + enabled, + }); + + return { + layout: data ?? DEFAULT_DOCUMENTS_LAYOUT, + isLayoutLoading: enabled && isLoading, + setView: (view) => + runSetDocumentsLayout({ patch: { view }, queryClient, t }), + setArrangement: (arrangement) => + runSetDocumentsLayout({ patch: { arrangement }, queryClient, t }), + }; +} diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts index c5c8e8dc9..a562672b7 100644 --- a/src/lib/query-keys/documents.ts +++ b/src/lib/query-keys/documents.ts @@ -30,6 +30,12 @@ export interface DocumentVaultFilters { export const documentKeys = { documents: () => ["documents"] as const, + /** + * The vault presentation (view + month arrangement). Deliberately outside + * the `["documents"]` prefix: a document write changes nothing about how + * the vault is laid out, so it must not refetch this. + */ + documentsLayout: () => ["documents-layout"] as const, inboundDocuments: () => ["documents", "inbound"] as const, /** * The parameterised vault list (keyset-paginated via `useInfiniteQuery`). diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 50c666df3..23083ea68 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -2008,6 +2008,7 @@ const accountSettingsBackupSchema = z insightsLayoutJson: z.unknown().optional(), medicationListLayoutJson: z.unknown().optional(), moodTagLayoutJson: z.unknown().optional(), + documentsLayoutJson: z.unknown().optional(), reportSelectionJson: z.unknown().optional(), globalExcludedInjectionSites: z.array(z.string()).optional(), healthKitConfigJson: z.unknown().optional(),