feat(documents): choose how the vault is laid out, and confirm every delete

The wrench beside Upload opens Settings, Appearance, Documents, the way
the illness journal offers its display options: preview cards or a
compact list, and months stacked as blocks or flowing across the full
width with the month name inline above the document that opens it. The
choice is stored per account in `users.documents_layout_json` behind
GET/PUT /api/documents/inbound/layout (preserve-when-absent, module
gated, the read delegable like the rest of the vault), carried in the
account-settings backup and cleared by the data wipe.

Deleting from the vault now always asks first. The bulk bar's Delete and
a focused card's Delete key both go through one confirmation on the page,
and the detail sheet's Delete opens its own; before, all three deleted on
the spot with only an undo toast behind them.

The selection bar is two tight lines: the count with an X at its right
edge, then one action row with Delete alone at the right in the
destructive style. Below 1024 px the verbs are icon-only at the 44 px tap
floor, so the row stays one line at 360 px. The bar carries the
selection-bar slot the Coach launcher already steps aside for.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 16:45:06 +02:00
1 parent ce6d2fe72a
commit 4273af231e
39 files changed
+1661 -209

No files matched your search

@@ -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<Response>;
const callPut = PUT as unknown as (req: NextRequest) => Promise<Response>;
function put(body: unknown): Promise<Response> {
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();
});
});
@@ -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<DocumentsLayout> {
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);
});