feat(admin): add posts CRUD sandbox and shared CRUD foundation

This commit is contained in:
2026-02-10 19:35:41 +01:00
parent de26cb7647
commit 07e5f53793
18 changed files with 887 additions and 38 deletions

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"
import { postSchema, upsertPostSchema } from "./index"
import { createPostInputSchema, postSchema, updatePostInputSchema, upsertPostSchema } from "./index"
describe("content schemas", () => {
it("accepts a valid post", () => {
@ -17,7 +17,24 @@ describe("content schemas", () => {
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({
title: "Hi",
slug: "x",

View File

@ -4,22 +4,32 @@ export * from "./rbac"
export const postStatusSchema = z.enum(["draft", "published"])
export const postSchema = z.object({
id: z.string().uuid(),
const postMutableFieldsSchema = z.object({
title: z.string().min(3).max(180),
slug: z.string().min(3).max(180),
excerpt: z.string().max(320).optional(),
body: z.string().min(1),
status: postStatusSchema,
})
export const postSchema = z.object({
id: z.string().uuid(),
...postMutableFieldsSchema.shape,
createdAt: z.date(),
updatedAt: z.date(),
})
export const upsertPostSchema = postSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
})
export const createPostInputSchema = postMutableFieldsSchema
export const updatePostInputSchema = postMutableFieldsSchema
.partial()
.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 CreatePostInput = z.infer<typeof createPostInputSchema>
export type UpdatePostInput = z.infer<typeof updatePostInputSchema>
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"
},
"dependencies": {
"@cms/crud": "workspace:*",
"@cms/content": "workspace:*",
"@prisma/adapter-pg": "7.3.0",
"@prisma/client": "7.3.0",

View File

@ -1,2 +1,9 @@
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"
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() {
return db.post.findMany({
orderBy: {
updatedAt: "desc",
},
})
return postCrudService.list()
}
export async function createPost(input: unknown) {
const payload = upsertPostSchema.parse(input)
return db.post.create({
data: payload,
})
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)
}