feat(admin): add posts CRUD sandbox and shared CRUD foundation
This commit is contained in:
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cms/crud": "workspace:*",
|
||||
"@cms/content": "workspace:*",
|
||||
"@prisma/adapter-pg": "7.3.0",
|
||||
"@prisma/client": "7.3.0",
|
||||
|
||||
@ -1,2 +1,9 @@
|
||||
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"
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user