feat(admin): add posts CRUD sandbox and shared CRUD foundation
This commit is contained in:
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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user