diff --git a/TODO.md b/TODO.md index 7eff558..de8dd22 100644 --- a/TODO.md +++ b/TODO.md @@ -32,9 +32,9 @@ This file is the single source of truth for roadmap and delivery progress. - [x] [P1] First-start onboarding route for initial owner creation (`/welcome`) - [x] [P1] Split auth entry points (`/welcome`, `/login`, `/register`) with cross-links - [x] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access -- [~] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository) -- [~] [P1] Shared CRUD validation strategy (Zod + server-side enforcement) -- [~] [P1] Shared error and audit hooks for CRUD mutations +- [x] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository) +- [x] [P1] Shared CRUD validation strategy (Zod + server-side enforcement) +- [x] [P1] Shared error and audit hooks for CRUD mutations ### Admin App diff --git a/docs/product-engineering/crud-baseline.md b/docs/product-engineering/crud-baseline.md index 3c5c41d..aff07ab 100644 --- a/docs/product-engineering/crud-baseline.md +++ b/docs/product-engineering/crud-baseline.md @@ -7,6 +7,8 @@ MVP0 now includes a shared CRUD foundation package: `@cms/crud`. Current baseline: - Shared service factory: `createCrudService` +- Repository contract: `list`, `findById`, `create`, `update`, `delete` +- Service surface for list/detail/editor flows: `list`, `getById`, `create`, `update`, `delete` - Shared validation error type: `CrudValidationError` - Shared not-found error type: `CrudNotFoundError` - Shared mutation audit hook contract: `CrudAuditHook` @@ -24,6 +26,11 @@ Current baseline: - `registerPostCrudAuditHook` Validation for create/update is enforced by `@cms/content` schemas. +Contract tests validate: + +- repository list/detail behavior via CRUD service +- validation and not-found errors +- audit payload propagation (`actor`, `metadata`) The admin dashboard currently includes a temporary posts CRUD sandbox to validate this flow through a real app UI. diff --git a/packages/crud/src/service.test.ts b/packages/crud/src/service.test.ts index b636774..a094052 100644 --- a/packages/crud/src/service.test.ts +++ b/packages/crud/src/service.test.ts @@ -63,6 +63,32 @@ function createMemoryRepository() { } describe("createCrudService", () => { + it("supports list and detail lookups through the repository contract", async () => { + const service = createCrudService({ + resource: "fake-entity", + repository: createMemoryRepository(), + schemas: { + create: z.object({ + title: z.string().min(3), + }), + update: z.object({ + title: z.string().min(3).optional(), + }), + }, + }) + + const createdA = await service.create({ title: "First" }) + const createdB = await service.create({ title: "Second" }) + + expect(await service.getById(createdA.id)).toEqual(createdA) + expect(await service.getById("missing")).toBeNull() + + const listed = await service.list() + expect(listed).toHaveLength(2) + expect(listed).toContainEqual(createdA) + expect(listed).toContainEqual(createdB) + }) + it("validates create and update payloads", async () => { const service = createCrudService({ resource: "fake-entity", @@ -106,8 +132,13 @@ describe("createCrudService", () => { }) it("emits audit events for create, update and delete", async () => { - const events: Array<{ action: string; beforeTitle: string | null; afterTitle: string | null }> = - [] + const events: Array<{ + action: string + beforeTitle: string | null + afterTitle: string | null + actorRole: string | null + requestId: string | null + }> = [] const service = createCrudService({ resource: "fake-entity", repository: createMemoryRepository(), @@ -125,6 +156,9 @@ describe("createCrudService", () => { action: event.action, beforeTitle: event.before?.title ?? null, afterTitle: event.after?.title ?? null, + actorRole: event.actor?.role ?? null, + requestId: + typeof event.metadata?.requestId === "string" ? event.metadata.requestId : null, }) }, ], @@ -134,6 +168,9 @@ describe("createCrudService", () => { { title: "Created" }, { actor: { id: "u-1", role: "owner" }, + metadata: { + requestId: "req-1", + }, }, ) @@ -145,16 +182,22 @@ describe("createCrudService", () => { action: "create", beforeTitle: null, afterTitle: "Created", + actorRole: "owner", + requestId: "req-1", }, { action: "update", beforeTitle: "Created", afterTitle: "Updated", + actorRole: null, + requestId: null, }, { action: "delete", beforeTitle: "Updated", afterTitle: null, + actorRole: null, + requestId: null, }, ]) })