merge: todo/mvp0-crud-foundation into dev
This commit is contained in:
11
TODO.md
11
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] 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
|
||||||
|
|
||||||
|
|||||||
@@ -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,15 +168,158 @@ 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">
|
||||||
|
{canCreatePost ? (
|
||||||
|
<>
|
||||||
|
<form action={updatePostAction} className="space-y-3">
|
||||||
|
<input type="hidden" name="id" value={post.id} />
|
||||||
|
<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}
|
||||||
|
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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h3 className="text-lg font-medium">{post.title}</h3>
|
<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">
|
<span className="rounded-full bg-neutral-100 px-3 py-1 text-xs uppercase tracking-wide">
|
||||||
@@ -56,6 +327,9 @@ export default async function AdminHomePage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-neutral-600">{post.slug}</p>
|
<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>
|
||||||
|
|||||||
15
bun.lock
15
bun.lock
@@ -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"],
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
33
docs/product-engineering/crud-baseline.md
Normal file
33
docs/product-engineering/crud-baseline.md
Normal 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.
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
22
packages/crud/package.json
Normal file
22
packages/crud/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
packages/crud/src/errors.ts
Normal file
41
packages/crud/src/errors.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/crud/src/index.ts
Normal file
3
packages/crud/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./errors"
|
||||||
|
export * from "./service"
|
||||||
|
export * from "./types"
|
||||||
161
packages/crud/src/service.test.ts
Normal file
161
packages/crud/src/service.test.ts
Normal 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,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
159
packages/crud/src/service.ts
Normal file
159
packages/crud/src/service.ts
Normal 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
31
packages/crud/src/types.ts
Normal file
31
packages/crud/src/types.ts
Normal 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>
|
||||||
|
}
|
||||||
9
packages/crud/tsconfig.json
Normal file
9
packages/crud/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@cms/config/tsconfig/base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
export async function listPosts() {
|
const postRepository = {
|
||||||
return db.post.findMany({
|
list: () =>
|
||||||
|
db.post.findMany({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
updatedAt: "desc",
|
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 },
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPost(input: unknown) {
|
const postAuditHooks: Array<CrudAuditHook<Post>> = []
|
||||||
const payload = upsertPostSchema.parse(input)
|
|
||||||
|
|
||||||
return db.post.create({
|
const postCrudService = createCrudService({
|
||||||
data: payload,
|
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() {
|
||||||
|
return postCrudService.list()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPostById(id: string) {
|
||||||
|
return postCrudService.getById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user