160 lines
3.9 KiB
TypeScript
160 lines
3.9 KiB
TypeScript
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
|
|
},
|
|
}
|
|
}
|