1 Commits

Author SHA1 Message Date
07e5f53793 feat(admin): add posts CRUD sandbox and shared CRUD foundation 2026-02-10 19:35:41 +01:00
18 changed files with 887 additions and 38 deletions

11
TODO.md
View File

@@ -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] First-start onboarding route for initial owner creation (`/welcome`)
- [x] [P1] Split auth entry points (`/welcome`, `/login`, `/register`) with cross-links - [x] [P1] Split auth entry points (`/welcome`, `/login`, `/register`) with cross-links
- [~] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access - [~] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access
- [ ] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository) - [~] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository)
- [ ] [P1] Shared CRUD validation strategy (Zod + server-side enforcement) - [~] [P1] Shared CRUD validation strategy (Zod + server-side enforcement)
- [ ] [P1] Shared error and audit hooks for CRUD mutations - [~] [P1] Shared error and audit hooks for CRUD mutations
### Admin App ### Admin App
@@ -44,6 +44,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [~] [P2] Base admin dashboard shell and roadmap page (`/todo`) - [~] [P2] Base admin dashboard shell and roadmap page (`/todo`)
- [x] [P1] Authentication and session model (`admin`, `editor`, `manager`) - [x] [P1] Authentication and session model (`admin`, `editor`, `manager`)
- [x] [P1] Protected admin routes and session handling - [x] [P1] Protected admin routes and session handling
- [~] [P1] Temporary admin posts CRUD sandbox for baseline functional validation
- [ ] [P1] Core admin IA (pages/media/users/commissions/settings) - [ ] [P1] Core admin IA (pages/media/users/commissions/settings)
### Public App ### Public App
@@ -73,7 +74,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [x] [P1] Docs tool baseline added (`docs/` via VitePress) - [x] [P1] Docs tool baseline added (`docs/` via VitePress)
- [x] [P1] RBAC and permission model documentation in docs site - [x] [P1] RBAC and permission model documentation in docs site
- [ ] [P2] i18n conventions docs (keys, namespaces, fallback, translation workflow) - [ ] [P2] i18n conventions docs (keys, namespaces, fallback, translation workflow)
- [ ] [P1] CRUD base patterns documentation and examples - [~] [P1] CRUD base patterns documentation and examples
- [ ] [P1] Environment and deployment runbook docs (dev/staging/production) - [ ] [P1] Environment and deployment runbook docs (dev/staging/production)
- [ ] [P2] API and domain glossary pages - [ ] [P2] API and domain glossary pages
- [ ] [P2] Architecture Decision Records (ADR) structure and first ADRs - [ ] [P2] Architecture Decision Records (ADR) structure and first ADRs
@@ -194,6 +195,8 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-10] Auth delete-account endpoints now block protected users (support + canonical owner); admin user-management delete/demote guards remain to be implemented. - [2026-02-10] Auth delete-account endpoints now block protected users (support + canonical owner); admin user-management delete/demote guards remain to be implemented.
- [2026-02-10] Public app i18n baseline now uses `next-intl` with a Zustand-backed language switcher and path-stable routes; admin i18n runtime is still pending. - [2026-02-10] Public app i18n baseline now uses `next-intl` with a Zustand-backed language switcher and path-stable routes; admin i18n runtime is still pending.
- [2026-02-10] Public baseline locales are now `de`, `en`, `es`, `fr`; locale enable/disable policy will move to admin settings later. - [2026-02-10] Public baseline locales are now `de`, `en`, `es`, `fr`; locale enable/disable policy will move to admin settings later.
- [2026-02-10] Shared CRUD base (`@cms/crud`) is live with validation, not-found errors, and audit hook contracts; only posts are migrated so far.
- [2026-02-10] Admin dashboard includes a temporary posts CRUD sandbox (create/update/delete) to validate the shared CRUD base through the real app UI.
## How We Use This File ## How We Use This File

View File

@@ -1,6 +1,7 @@
import { hasPermission } from "@cms/content/rbac" import { hasPermission } from "@cms/content/rbac"
import { listPosts } from "@cms/db" import { createPost, deletePost, listPosts, updatePost } from "@cms/db"
import { Button } from "@cms/ui/button" import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import Link from "next/link" import Link from "next/link"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
@@ -9,7 +10,131 @@ import { LogoutButton } from "./logout-button"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default async function AdminHomePage() { type SearchParamsInput = Record<string, string | string[] | undefined>
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
}
function readRequiredField(formData: FormData, field: string): string {
const value = formData.get(field)
if (typeof value !== "string") {
return ""
}
return value.trim()
}
function readOptionalField(formData: FormData, field: string): string | undefined {
const value = readRequiredField(formData, field)
return value.length > 0 ? value : undefined
}
async function requireNewsWritePermission() {
const role = await resolveRoleFromServerContext()
if (!role || !hasPermission(role, "news:write", "team")) {
redirect("/unauthorized?required=news:write&scope=team")
}
}
function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams()
if (params.notice) {
query.set("notice", params.notice)
}
if (params.error) {
query.set("error", params.error)
}
const value = query.toString()
redirect(value ? `/?${value}` : "/")
}
async function createPostAction(formData: FormData) {
"use server"
await requireNewsWritePermission()
const status = readRequiredField(formData, "status")
try {
await createPost({
title: readRequiredField(formData, "title"),
slug: readRequiredField(formData, "slug"),
excerpt: readOptionalField(formData, "excerpt"),
body: readRequiredField(formData, "body"),
status: status === "published" ? "published" : "draft",
})
} catch {
redirectWithState({ error: "Create failed. Please check your input." })
}
revalidatePath("/")
redirectWithState({ notice: "Post created." })
}
async function updatePostAction(formData: FormData) {
"use server"
await requireNewsWritePermission()
const id = readRequiredField(formData, "id")
const status = readRequiredField(formData, "status")
if (!id) {
redirectWithState({ error: "Update failed. Missing post id." })
}
try {
await updatePost(id, {
title: readRequiredField(formData, "title"),
slug: readRequiredField(formData, "slug"),
excerpt: readOptionalField(formData, "excerpt"),
body: readRequiredField(formData, "body"),
status: status === "published" ? "published" : "draft",
})
} catch {
redirectWithState({ error: "Update failed. Please check your input." })
}
revalidatePath("/")
redirectWithState({ notice: "Post updated." })
}
async function deletePostAction(formData: FormData) {
"use server"
await requireNewsWritePermission()
const id = readRequiredField(formData, "id")
if (!id) {
redirectWithState({ error: "Delete failed. Missing post id." })
}
try {
await deletePost(id)
} catch {
redirectWithState({ error: "Delete failed." })
}
revalidatePath("/")
redirectWithState({ notice: "Post deleted." })
}
export default async function AdminHomePage({
searchParams,
}: {
searchParams: Promise<SearchParamsInput>
}) {
const role = await resolveRoleFromServerContext() const role = await resolveRoleFromServerContext()
if (!role) { if (!role) {
@@ -20,6 +145,9 @@ export default async function AdminHomePage() {
redirect("/unauthorized?required=news:read&scope=team") redirect("/unauthorized?required=news:read&scope=team")
} }
const resolvedSearchParams = await searchParams
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
const canCreatePost = hasPermission(role, "news:write", "team") const canCreatePost = hasPermission(role, "news:write", "team")
const posts = await listPosts() const posts = await listPosts()
@@ -40,22 +168,168 @@ export default async function AdminHomePage() {
</div> </div>
</header> </header>
{notice ? (
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{notice}
</section>
) : null}
{error ? (
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</section>
) : null}
<section className="rounded-xl border border-neutral-200 p-6"> <section className="rounded-xl border border-neutral-200 p-6">
<div className="mb-4 flex items-center justify-between"> <div className="space-y-4">
<h2 className="text-xl font-medium">Posts</h2> <div className="flex items-center justify-between">
<Button disabled={!canCreatePost}>Create post</Button> <h2 className="text-xl font-medium">Posts CRUD Sandbox</h2>
<p className="text-xs uppercase tracking-wide text-neutral-500">MVP0 functional test</p>
</div>
{canCreatePost ? (
<form
action={createPostAction}
className="space-y-3 rounded-lg border border-neutral-200 p-4"
>
<h3 className="text-sm font-semibold">Create post</h3>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
required
minLength={3}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
required
minLength={3}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<input
name="excerpt"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<textarea
name="body"
required
minLength={1}
rows={4}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Status</span>
<select
name="status"
defaultValue="draft"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</label>
<Button type="submit">Create post</Button>
</form>
) : (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
You can read posts, but your role cannot create/update/delete posts.
</div>
)}
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{posts.map((post) => ( {posts.map((post) => (
<article key={post.id} className="rounded-lg border border-neutral-200 p-4"> <article key={post.id} className="rounded-lg border border-neutral-200 p-4">
<div className="flex items-center justify-between gap-3"> {canCreatePost ? (
<h3 className="text-lg font-medium">{post.title}</h3> <>
<span className="rounded-full bg-neutral-100 px-3 py-1 text-xs uppercase tracking-wide"> <form action={updatePostAction} className="space-y-3">
{post.status} <input type="hidden" name="id" value={post.id} />
</span> <div className="grid gap-3 md:grid-cols-2">
</div> <label className="space-y-1">
<p className="mt-2 text-sm text-neutral-600">{post.slug}</p> <span className="text-xs text-neutral-600">Title</span>
<input
name="title"
required
minLength={3}
defaultValue={post.title}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
required
minLength={3}
defaultValue={post.slug}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<input
name="excerpt"
defaultValue={post.excerpt ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<textarea
name="body"
required
minLength={1}
rows={4}
defaultValue={post.body}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Status</span>
<select
name="status"
defaultValue={post.status}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</label>
<Button type="submit">Save changes</Button>
</form>
<form action={deletePostAction} className="mt-3">
<input type="hidden" name="id" value={post.id} />
<Button type="submit" variant="secondary">
Delete
</Button>
</form>
</>
) : (
<>
<div className="flex items-center justify-between gap-3">
<h3 className="text-lg font-medium">{post.title}</h3>
<span className="rounded-full bg-neutral-100 px-3 py-1 text-xs uppercase tracking-wide">
{post.status}
</span>
</div>
<p className="mt-2 text-sm text-neutral-600">{post.slug}</p>
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? "No excerpt"}</p>
</>
)}
</article> </article>
))} ))}
</div> </div>

View File

@@ -95,11 +95,24 @@
"typescript": "5.9.3", "typescript": "5.9.3",
}, },
}, },
"packages/crud": {
"name": "@cms/crud",
"version": "0.0.1",
"dependencies": {
"zod": "4.3.6",
},
"devDependencies": {
"@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*",
"typescript": "5.9.3",
},
},
"packages/db": { "packages/db": {
"name": "@cms/db", "name": "@cms/db",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@cms/content": "workspace:*", "@cms/content": "workspace:*",
"@cms/crud": "workspace:*",
"@prisma/adapter-pg": "7.3.0", "@prisma/adapter-pg": "7.3.0",
"@prisma/client": "7.3.0", "@prisma/client": "7.3.0",
"pg": "8.18.0", "pg": "8.18.0",
@@ -273,6 +286,8 @@
"@cms/content": ["@cms/content@workspace:packages/content"], "@cms/content": ["@cms/content@workspace:packages/content"],
"@cms/crud": ["@cms/crud@workspace:packages/crud"],
"@cms/db": ["@cms/db@workspace:packages/db"], "@cms/db": ["@cms/db@workspace:packages/db"],
"@cms/i18n": ["@cms/i18n@workspace:packages/i18n"], "@cms/i18n": ["@cms/i18n@workspace:packages/i18n"],

View File

@@ -20,6 +20,7 @@ export default defineConfig({
{ text: "Getting Started", link: "/getting-started" }, { text: "Getting Started", link: "/getting-started" },
{ text: "Architecture", link: "/architecture" }, { text: "Architecture", link: "/architecture" },
{ text: "Better Auth Baseline", link: "/product-engineering/auth-baseline" }, { text: "Better Auth Baseline", link: "/product-engineering/auth-baseline" },
{ text: "CRUD Baseline", link: "/product-engineering/crud-baseline" },
{ text: "i18n Baseline", link: "/product-engineering/i18n-baseline" }, { text: "i18n Baseline", link: "/product-engineering/i18n-baseline" },
{ text: "RBAC And Permissions", link: "/product-engineering/rbac-permission-model" }, { text: "RBAC And Permissions", link: "/product-engineering/rbac-permission-model" },
{ text: "Workflow", link: "/workflow" }, { text: "Workflow", link: "/workflow" },

View File

@@ -6,6 +6,7 @@
- `apps/admin`: admin app - `apps/admin`: admin app
- `packages/db`: prisma + data access - `packages/db`: prisma + data access
- `packages/content`: shared schemas and domain contracts - `packages/content`: shared schemas and domain contracts
- `packages/crud`: shared CRUD service patterns (validation, errors, audit hooks)
- `packages/ui`: shared UI layer - `packages/ui`: shared UI layer
- `packages/i18n`: shared locale definitions and i18n helpers - `packages/i18n`: shared locale definitions and i18n helpers
- `packages/config`: shared TS config - `packages/config`: shared TS config

View File

@@ -0,0 +1,33 @@
# CRUD Baseline
## Scope
MVP0 now includes a shared CRUD foundation package: `@cms/crud`.
Current baseline:
- Shared service factory: `createCrudService`
- Shared validation error type: `CrudValidationError`
- Shared not-found error type: `CrudNotFoundError`
- Shared mutation audit hook contract: `CrudAuditHook`
- Shared mutation context contract (`actor`, `metadata`)
## First Integration
`@cms/db` `posts` now uses the shared CRUD foundation:
- `listPosts`
- `getPostById`
- `createPost`
- `updatePost`
- `deletePost`
- `registerPostCrudAuditHook`
Validation for create/update is enforced by `@cms/content` schemas.
The admin dashboard currently includes a temporary posts CRUD sandbox to validate this flow through a real app UI.
## Notes
- This is the base layer for future entities (pages, navigation, media, users, commissions).
- Audit hook persistence/transport is intentionally left for later implementation work.

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { postSchema, upsertPostSchema } from "./index" import { createPostInputSchema, postSchema, updatePostInputSchema, upsertPostSchema } from "./index"
describe("content schemas", () => { describe("content schemas", () => {
it("accepts a valid post", () => { it("accepts a valid post", () => {
@@ -17,7 +17,24 @@ describe("content schemas", () => {
expect(post.slug).toBe("hello-world") expect(post.slug).toBe("hello-world")
}) })
it("rejects invalid upsert payload", () => { it("rejects invalid create payload", () => {
const result = createPostInputSchema.safeParse({
title: "Hi",
slug: "x",
body: "",
status: "unknown",
})
expect(result.success).toBe(false)
})
it("rejects empty update payload", () => {
const result = updatePostInputSchema.safeParse({})
expect(result.success).toBe(false)
})
it("keeps upsert alias for backward compatibility", () => {
const result = upsertPostSchema.safeParse({ const result = upsertPostSchema.safeParse({
title: "Hi", title: "Hi",
slug: "x", slug: "x",

View File

@@ -4,22 +4,32 @@ export * from "./rbac"
export const postStatusSchema = z.enum(["draft", "published"]) export const postStatusSchema = z.enum(["draft", "published"])
export const postSchema = z.object({ const postMutableFieldsSchema = z.object({
id: z.string().uuid(),
title: z.string().min(3).max(180), title: z.string().min(3).max(180),
slug: z.string().min(3).max(180), slug: z.string().min(3).max(180),
excerpt: z.string().max(320).optional(), excerpt: z.string().max(320).optional(),
body: z.string().min(1), body: z.string().min(1),
status: postStatusSchema, status: postStatusSchema,
})
export const postSchema = z.object({
id: z.string().uuid(),
...postMutableFieldsSchema.shape,
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
}) })
export const upsertPostSchema = postSchema.omit({ export const createPostInputSchema = postMutableFieldsSchema
id: true, export const updatePostInputSchema = postMutableFieldsSchema
createdAt: true, .partial()
updatedAt: true, .refine((value) => Object.keys(value).length > 0, {
}) message: "At least one field is required for an update.",
})
// Backward-compatible alias while migrating callers to create/update-specific schemas.
export const upsertPostSchema = createPostInputSchema
export type Post = z.infer<typeof postSchema> export type Post = z.infer<typeof postSchema>
export type CreatePostInput = z.infer<typeof createPostInputSchema>
export type UpdatePostInput = z.infer<typeof updatePostInputSchema>
export type UpsertPostInput = z.infer<typeof upsertPostSchema> export type UpsertPostInput = z.infer<typeof upsertPostSchema>

View File

@@ -0,0 +1,22 @@
{
"name": "@cms/crud",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "biome check src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"zod": "4.3.6"
},
"devDependencies": {
"@cms/config": "workspace:*",
"@biomejs/biome": "2.3.14",
"typescript": "5.9.3"
}
}

View File

@@ -0,0 +1,41 @@
import type { ZodIssue } from "zod"
export class CrudError extends Error {
public readonly code: string
constructor(message: string, code: string) {
super(message)
this.name = "CrudError"
this.code = code
}
}
export class CrudValidationError extends CrudError {
public readonly resource: string
public readonly operation: "create" | "update"
public readonly issues: ZodIssue[]
constructor(params: {
resource: string
operation: "create" | "update"
issues: ZodIssue[]
}) {
super(`Validation failed for ${params.resource} ${params.operation}`, "CRUD_VALIDATION")
this.name = "CrudValidationError"
this.resource = params.resource
this.operation = params.operation
this.issues = params.issues
}
}
export class CrudNotFoundError extends CrudError {
public readonly resource: string
public readonly id: string
constructor(params: { resource: string; id: string }) {
super(`${params.resource} ${params.id} was not found`, "CRUD_NOT_FOUND")
this.name = "CrudNotFoundError"
this.resource = params.resource
this.id = params.id
}
}

View File

@@ -0,0 +1,3 @@
export * from "./errors"
export * from "./service"
export * from "./types"

View File

@@ -0,0 +1,161 @@
import { describe, expect, it } from "vitest"
import { z } from "zod"
import { CrudNotFoundError, CrudValidationError } from "./errors"
import { createCrudService } from "./service"
type FakeEntity = {
id: string
title: string
}
type CreateFakeEntityInput = {
title: string
}
type UpdateFakeEntityInput = {
title?: string
}
function createMemoryRepository() {
const state = new Map<string, FakeEntity>()
let sequence = 0
return {
list: async () => Array.from(state.values()),
findById: async (id: string) => state.get(id) ?? null,
create: async (input: CreateFakeEntityInput) => {
sequence += 1
const created = {
id: `${sequence}`,
title: input.title,
}
state.set(created.id, created)
return created
},
update: async (id: string, input: UpdateFakeEntityInput) => {
const current = state.get(id)
if (!current) {
throw new Error("unexpected missing entity in test repository")
}
const updated = {
...current,
...input,
}
state.set(id, updated)
return updated
},
delete: async (id: string) => {
const current = state.get(id)
if (!current) {
throw new Error("unexpected missing entity in test repository")
}
state.delete(id)
return current
},
}
}
describe("createCrudService", () => {
it("validates create and update payloads", 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(),
})
.refine((value) => Object.keys(value).length > 0, {
message: "at least one field must be updated",
}),
},
})
await expect(service.create({ title: "ok" })).rejects.toBeInstanceOf(CrudValidationError)
await expect(service.update("1", {})).rejects.toBeInstanceOf(CrudValidationError)
})
it("throws not found for unknown update and delete", 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(),
}),
},
})
await expect(service.update("missing", { title: "Updated" })).rejects.toBeInstanceOf(
CrudNotFoundError,
)
await expect(service.delete("missing")).rejects.toBeInstanceOf(CrudNotFoundError)
})
it("emits audit events for create, update and delete", async () => {
const events: Array<{ action: string; beforeTitle: string | null; afterTitle: string | null }> =
[]
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(),
}),
},
auditHooks: [
(event) => {
events.push({
action: event.action,
beforeTitle: event.before?.title ?? null,
afterTitle: event.after?.title ?? null,
})
},
],
})
const created = await service.create(
{ title: "Created" },
{
actor: { id: "u-1", role: "owner" },
},
)
await service.update(created.id, { title: "Updated" })
await service.delete(created.id)
expect(events).toEqual([
{
action: "create",
beforeTitle: null,
afterTitle: "Created",
},
{
action: "update",
beforeTitle: "Created",
afterTitle: "Updated",
},
{
action: "delete",
beforeTitle: "Updated",
afterTitle: null,
},
])
})
})

View File

@@ -0,0 +1,159 @@
import type { ZodIssue } from "zod"
import { CrudNotFoundError, CrudValidationError } from "./errors"
import type { CrudAction, CrudAuditHook, CrudMutationContext, CrudRepository } from "./types"
type SchemaSafeParseResult<TInput> =
| {
success: true
data: TInput
}
| {
success: false
error: {
issues: ZodIssue[]
}
}
type CrudSchema<TInput> = {
safeParse: (input: unknown) => SchemaSafeParseResult<TInput>
}
type CrudSchemas<TCreateInput, TUpdateInput> = {
create: CrudSchema<TCreateInput>
update: CrudSchema<TUpdateInput>
}
type CreateCrudServiceOptions<TRecord, TCreateInput, TUpdateInput, TId extends string = string> = {
resource: string
repository: CrudRepository<TRecord, TCreateInput, TUpdateInput, TId>
schemas: CrudSchemas<TCreateInput, TUpdateInput>
auditHooks?: Array<CrudAuditHook<TRecord>>
}
async function emitAuditHooks<TRecord>(
hooks: Array<CrudAuditHook<TRecord>>,
event: {
resource: string
action: CrudAction
actor: CrudMutationContext["actor"]
metadata: CrudMutationContext["metadata"]
before: TRecord | null
after: TRecord | null
},
): Promise<void> {
if (hooks.length === 0) {
return
}
const payload = {
...event,
actor: event.actor ?? null,
at: new Date(),
}
for (const hook of hooks) {
await hook(payload)
}
}
function parseOrThrow<TInput>(params: {
schema: CrudSchema<TInput>
input: unknown
resource: string
operation: "create" | "update"
}): TInput {
const parsed = params.schema.safeParse(params.input)
if (parsed.success) {
return parsed.data
}
throw new CrudValidationError({
resource: params.resource,
operation: params.operation,
issues: parsed.error.issues,
})
}
export function createCrudService<TRecord, TCreateInput, TUpdateInput, TId extends string = string>(
options: CreateCrudServiceOptions<TRecord, TCreateInput, TUpdateInput, TId>,
) {
const auditHooks = options.auditHooks ?? []
return {
list: () => options.repository.list(),
getById: (id: TId) => options.repository.findById(id),
create: async (input: unknown, context: CrudMutationContext = {}) => {
const payload = parseOrThrow({
schema: options.schemas.create,
input,
resource: options.resource,
operation: "create",
})
const created = await options.repository.create(payload)
await emitAuditHooks(auditHooks, {
resource: options.resource,
action: "create",
actor: context.actor,
metadata: context.metadata,
before: null,
after: created,
})
return created
},
update: async (id: TId, input: unknown, context: CrudMutationContext = {}) => {
const payload = parseOrThrow({
schema: options.schemas.update,
input,
resource: options.resource,
operation: "update",
})
const existing = await options.repository.findById(id)
if (!existing) {
throw new CrudNotFoundError({
resource: options.resource,
id,
})
}
const updated = await options.repository.update(id, payload)
await emitAuditHooks(auditHooks, {
resource: options.resource,
action: "update",
actor: context.actor,
metadata: context.metadata,
before: existing,
after: updated,
})
return updated
},
delete: async (id: TId, context: CrudMutationContext = {}) => {
const existing = await options.repository.findById(id)
if (!existing) {
throw new CrudNotFoundError({
resource: options.resource,
id,
})
}
const deleted = await options.repository.delete(id)
await emitAuditHooks(auditHooks, {
resource: options.resource,
action: "delete",
actor: context.actor,
metadata: context.metadata,
before: existing,
after: null,
})
return deleted
},
}
}

View File

@@ -0,0 +1,31 @@
export type CrudAction = "create" | "update" | "delete"
export type CrudActor = {
id?: string | null
role?: string | null
}
export type CrudMutationContext = {
actor?: CrudActor | null
metadata?: Record<string, unknown>
}
export type CrudAuditEvent<TRecord> = {
resource: string
action: CrudAction
at: Date
actor: CrudActor | null
metadata?: Record<string, unknown>
before: TRecord | null
after: TRecord | null
}
export type CrudAuditHook<TRecord> = (event: CrudAuditEvent<TRecord>) => Promise<void> | void
export type CrudRepository<TRecord, TCreateInput, TUpdateInput, TId extends string = string> = {
list: () => Promise<TRecord[]>
findById: (id: TId) => Promise<TRecord | null>
create: (input: TCreateInput) => Promise<TRecord>
update: (id: TId, input: TUpdateInput) => Promise<TRecord>
delete: (id: TId) => Promise<TRecord>
}

View File

@@ -0,0 +1,9 @@
{
"extends": "@cms/config/tsconfig/base",
"compilerOptions": {
"noEmit": false,
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}

View File

@@ -20,6 +20,7 @@
"db:seed": "bun --env-file=../../.env prisma/seed.ts" "db:seed": "bun --env-file=../../.env prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@cms/crud": "workspace:*",
"@cms/content": "workspace:*", "@cms/content": "workspace:*",
"@prisma/adapter-pg": "7.3.0", "@prisma/adapter-pg": "7.3.0",
"@prisma/client": "7.3.0", "@prisma/client": "7.3.0",

View File

@@ -1,2 +1,9 @@
export { db } from "./client" export { db } from "./client"
export { createPost, listPosts } from "./posts" export {
createPost,
deletePost,
getPostById,
listPosts,
registerPostCrudAuditHook,
updatePost,
} from "./posts"

View File

@@ -1,19 +1,80 @@
import { upsertPostSchema } from "@cms/content" import {
type CreatePostInput,
createPostInputSchema,
type UpdatePostInput,
updatePostInputSchema,
} from "@cms/content"
import { type CrudAuditHook, type CrudMutationContext, createCrudService } from "@cms/crud"
import type { Post } from "../prisma/generated/client/client"
import { db } from "./client" import { db } from "./client"
const postRepository = {
list: () =>
db.post.findMany({
orderBy: {
updatedAt: "desc",
},
}),
findById: (id: string) =>
db.post.findUnique({
where: { id },
}),
create: (input: CreatePostInput) =>
db.post.create({
data: input,
}),
update: (id: string, input: UpdatePostInput) =>
db.post.update({
where: { id },
data: input,
}),
delete: (id: string) =>
db.post.delete({
where: { id },
}),
}
const postAuditHooks: Array<CrudAuditHook<Post>> = []
const postCrudService = createCrudService({
resource: "post",
repository: postRepository,
schemas: {
create: createPostInputSchema,
update: updatePostInputSchema,
},
auditHooks: postAuditHooks,
})
export function registerPostCrudAuditHook(hook: CrudAuditHook<Post>): () => void {
postAuditHooks.push(hook)
return () => {
const index = postAuditHooks.indexOf(hook)
if (index >= 0) {
postAuditHooks.splice(index, 1)
}
}
}
export async function listPosts() { export async function listPosts() {
return db.post.findMany({ return postCrudService.list()
orderBy: {
updatedAt: "desc",
},
})
} }
export async function createPost(input: unknown) { export async function getPostById(id: string) {
const payload = upsertPostSchema.parse(input) return postCrudService.getById(id)
}
return db.post.create({
data: payload, export async function createPost(input: unknown, context?: CrudMutationContext) {
}) return postCrudService.create(input, context)
}
export async function updatePost(id: string, input: unknown, context?: CrudMutationContext) {
return postCrudService.update(id, input, context)
}
export async function deletePost(id: string, context?: CrudMutationContext) {
return postCrudService.delete(id, context)
} }