import 'server-only'; import { z } from 'zod'; import type { Prisma } from '@prisma/client'; import { db } from './db'; import { recordAudit } from './audit'; import { canAuthor } from '@/lib/rbac'; import { excerptFromBody, isPublished, readingMinutes } from '@/lib/content'; import { slugify } from '@/lib/utils'; export const POST_PAGE_SIZE = 6; const postInclude = { author: true, tags: { include: { tag: true } } } satisfies Prisma.PostInclude; const postOrder = [{ publishedAt: 'desc' as const }, { createdAt: 'desc' as const }]; export type PostWithRelations = Prisma.PostGetPayload<{ include: typeof postInclude }>; export type DocRow = { id: string; slug: string; title: string; section: string; order: number; body: string; status: string; updatedAt: Date }; export type TagWithCount = { id: string; name: string; slug: string; count: number }; type Actor = { id: string; role: string }; // --------------------------------------------------------------------------- public reads export async function listPublishedPosts({ page = 1, pageSize = POST_PAGE_SIZE }: { page?: number; pageSize?: number } = {}) { const where = { status: 'published' }; const safePage = Math.max(1, Math.floor(page) || 1); const [items, total] = await Promise.all([ db.post.findMany({ where, orderBy: postOrder, skip: (safePage - 1) * pageSize, take: pageSize, include: postInclude, }), db.post.count({ where }), ]); return { items, total, page: safePage, pageSize, pages: Math.max(1, Math.ceil(total / pageSize)) }; } export function getPublishedPost(slug: string) { return db.post.findFirst({ where: { slug, status: 'published' }, include: postInclude }); } /** Related posts: shared tag or same author, ranked by how many tags they share, then recency. */ export async function listRelatedPosts(post: PostWithRelations, take = 3) { const tagIds = post.tags.map((pt) => pt.tagId); const rows = await db.post.findMany({ where: { status: 'published', id: { not: post.id }, OR: [{ authorId: post.authorId }, ...(tagIds.length ? [{ tags: { some: { tagId: { in: tagIds } } } }] : [])], }, include: postInclude, orderBy: postOrder, take: take * 3, }); return rows .map((row) => { const shared = row.tags.filter((pt) => tagIds.includes(pt.tagId)).length; return { row, score: shared * 10 + (row.authorId === post.authorId ? 3 : 0) }; }) .sort((a, b) => b.score - a.score) .slice(0, take) .map((x) => x.row); } export function listPublishedDocs() { return db.doc.findMany({ where: { status: 'published' }, orderBy: [{ section: 'asc' }, { order: 'asc' }, { title: 'asc' }] }); } export function getPublishedDoc(slug: string) { return db.doc.findFirst({ where: { slug, status: 'published' } }); } /** Group docs by section, preserving the ordered sections list. */ export function groupDocsBySection(docs: DocRow[]) { const sections = new Map(); for (const doc of docs) { const list = sections.get(doc.section) ?? []; list.push(doc); sections.set(doc.section, list); } return [...sections.entries()].map(([section, items]) => ({ section, items })); } /** Previous/next doc within the same section (ordered by `order`). */ export function docNeighbors(doc: DocRow, docs: DocRow[]) { const inSection = docs.filter((d) => d.section === doc.section); const index = inSection.findIndex((d) => d.id === doc.id); return { prev: index > 0 ? inSection[index - 1] : null, next: index >= 0 && index < inSection.length - 1 ? inSection[index + 1] : null, }; } /** Server-side, case-insensitive search over title, excerpt and body. */ export async function searchPosts(query: string): Promise { const needle = query.trim().toLowerCase(); if (!needle) return []; const posts = await db.post.findMany({ where: { status: 'published' }, include: postInclude, orderBy: postOrder }); return posts.filter((post) => [post.title, post.excerpt, post.body, post.author.name, ...post.tags.map((pt) => pt.tag.name)] .join('\n') .toLowerCase() .includes(needle), ); } export async function listTags(): Promise { const tags = await db.tag.findMany({ orderBy: { name: 'asc' } }); return Promise.all( tags.map(async (tag) => ({ id: tag.id, name: tag.name, slug: tag.slug, count: await db.postTag.count({ where: { tagId: tag.id, post: { status: 'published' } } }), })), ); } export async function getTagWithPosts(slug: string) { const tag = await db.tag.findUnique({ where: { slug } }); if (!tag) return null; const posts = await db.post.findMany({ where: { status: 'published', tags: { some: { tagId: tag.id } } }, include: postInclude, orderBy: postOrder, }); return { tag, posts }; } export async function getAuthorWithPosts(slug: string) { const author = await db.author.findUnique({ where: { slug } }); if (!author) return null; const posts = await db.post.findMany({ where: { status: 'published', authorId: author.id }, include: postInclude, orderBy: postOrder }); return { author, posts }; } export function listAuthors() { return db.author.findMany({ orderBy: { name: 'asc' } }); } export async function latestAndFeatured(limit = 3) { const [featured, latest] = await Promise.all([ db.post.findMany({ where: { status: 'published', featured: true }, include: postInclude, orderBy: postOrder, take: limit }), db.post.findMany({ where: { status: 'published' }, include: postInclude, orderBy: postOrder, take: limit }), ]); return { featured, latest }; } export async function contentOverview() { const [published, drafts, docs, authors, tags, recent] = await Promise.all([ db.post.count({ where: { status: 'published' } }), db.post.count({ where: { status: 'draft' } }), db.doc.count(), db.author.count(), db.tag.count(), db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 6, include: { actor: true } }), ]); return { published, drafts, docs, authors, tags, recent }; } // --------------------------------------------------------------------------- admin reads export function listPostsAdmin(status?: string) { return db.post.findMany({ where: status ? { status } : {}, include: postInclude, orderBy: { updatedAt: 'desc' }, }); } export function getPostAdmin(id: string) { return db.post.findUnique({ where: { id }, include: postInclude }); } export function listDocsAdmin(status?: string) { return db.doc.findMany({ where: status ? { status } : {}, orderBy: [{ section: 'asc' }, { order: 'asc' }, { updatedAt: 'desc' }], }); } export function getDocAdmin(id: string) { return db.doc.findUnique({ where: { id } }); } // --------------------------------------------------------------------------- validation const slugField = z .string() .trim() .max(200) .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Use lowercase letters, numbers and hyphens.') .optional() .or(z.literal('')); export const postInputSchema = z.object({ title: z.string().trim().min(1).max(200), slug: slugField, excerpt: z.string().trim().max(600).optional().or(z.literal('')), body: z.string().min(1), status: z.enum(['draft', 'published']), featured: z.boolean().optional(), authorId: z.string().min(1), tags: z.array(z.string().trim().min(1).max(40)).max(12).optional(), }); export const docInputSchema = z.object({ title: z.string().trim().min(1).max(200), slug: slugField, section: z.string().trim().min(1).max(80), order: z.number().int().min(0).max(9999).optional(), body: z.string().min(1), status: z.enum(['draft', 'published']), }); export type PostInput = z.infer; export type DocInput = z.infer; // --------------------------------------------------------------------------- helpers async function uniqueSlug(base: string, excludeId?: string): Promise { const root = slugify(base) || 'entry'; let candidate = root; let n = 2; // eslint-disable-next-line no-await-in-loop while (await db.post.findFirst({ where: { slug: candidate, ...(excludeId ? { id: { not: excludeId } } : {}) } })) { candidate = `${root}-${n++}`; } return candidate; } async function uniqueDocSlug(base: string, excludeId?: string): Promise { const root = slugify(base) || 'doc'; let candidate = root; let n = 2; // eslint-disable-next-line no-await-in-loop while (await db.doc.findFirst({ where: { slug: candidate, ...(excludeId ? { id: { not: excludeId } } : {}) } })) { candidate = `${root}-${n++}`; } return candidate; } async function syncTags(postId: string, names: string[]): Promise { const unique = [...new Set(names.map((n) => n.trim()).filter(Boolean))]; await db.postTag.deleteMany({ where: { postId } }); for (const name of unique) { const slug = slugify(name); if (!slug) continue; // eslint-disable-next-line no-await-in-loop const tag = await db.tag.upsert({ where: { slug }, update: {}, create: { name, slug } }); // eslint-disable-next-line no-await-in-loop await db.postTag.create({ data: { postId, tagId: tag.id } }); } } // --------------------------------------------------------------------------- mutations export async function createPost(input: PostInput, actor: Actor) { if (!canAuthor(actor.role)) return { ok: false as const, error: 'Forbidden' }; const author = await db.author.findUnique({ where: { id: input.authorId } }); if (!author) return { ok: false as const, error: 'Unknown author.' }; const published = isPublished(input.status); const slug = input.slug ? await uniqueSlug(input.slug) : await uniqueSlug(input.title); const post = await db.post.create({ data: { slug, title: input.title, excerpt: input.excerpt?.trim() || excerptFromBody(input.body), body: input.body, status: input.status, publishedAt: published ? new Date() : null, readingMinutes: readingMinutes(input.body), featured: input.featured ?? false, authorId: author.id, }, }); await syncTags(post.id, input.tags ?? []); await recordAudit({ actorId: actor.id, action: 'post.create', target: post.slug, meta: { status: post.status } }); return { ok: true as const, post }; } export async function updatePost(id: string, input: PostInput, actor: Actor) { if (!canAuthor(actor.role)) return { ok: false as const, error: 'Forbidden' }; const existing = await db.post.findUnique({ where: { id } }); if (!existing) return { ok: false as const, error: 'Not found' }; const author = await db.author.findUnique({ where: { id: input.authorId } }); if (!author) return { ok: false as const, error: 'Unknown author.' }; const published = isPublished(input.status); const slug = input.slug && input.slug !== existing.slug ? await uniqueSlug(input.slug, id) : existing.slug; const post = await db.post.update({ where: { id }, data: { slug, title: input.title, excerpt: input.excerpt?.trim() || excerptFromBody(input.body), body: input.body, status: input.status, publishedAt: published ? existing.publishedAt ?? new Date() : null, readingMinutes: readingMinutes(input.body), featured: input.featured ?? false, authorId: author.id, }, }); await syncTags(post.id, input.tags ?? []); await recordAudit({ actorId: actor.id, action: 'post.update', target: post.slug, meta: { status: post.status } }); return { ok: true as const, post }; } export async function deletePost(id: string, actor: Actor) { if (!canAuthor(actor.role)) return { ok: false as const, error: 'Forbidden' }; const post = await db.post.delete({ where: { id } }).catch(() => null); if (!post) return { ok: false as const, error: 'Not found' }; await recordAudit({ actorId: actor.id, action: 'post.delete', target: post.slug }); return { ok: true as const }; } export async function createDoc(input: DocInput, actor: Actor) { if (!canAuthor(actor.role)) return { ok: false as const, error: 'Forbidden' }; const slug = input.slug ? await uniqueDocSlug(input.slug) : await uniqueDocSlug(input.title); const doc = await db.doc.create({ data: { slug, title: input.title, section: input.section, order: input.order ?? 1, body: input.body, status: input.status }, }); await recordAudit({ actorId: actor.id, action: 'doc.create', target: doc.slug, meta: { status: doc.status } }); return { ok: true as const, doc }; } export async function updateDoc(id: string, input: DocInput, actor: Actor) { if (!canAuthor(actor.role)) return { ok: false as const, error: 'Forbidden' }; const existing = await db.doc.findUnique({ where: { id } }); if (!existing) return { ok: false as const, error: 'Not found' }; const slug = input.slug && input.slug !== existing.slug ? await uniqueDocSlug(input.slug, id) : existing.slug; const doc = await db.doc.update({ where: { id }, data: { slug, title: input.title, section: input.section, order: input.order ?? existing.order, body: input.body, status: input.status }, }); await recordAudit({ actorId: actor.id, action: 'doc.update', target: doc.slug, meta: { status: doc.status } }); return { ok: true as const, doc }; } export async function deleteDoc(id: string, actor: Actor) { if (!canAuthor(actor.role)) return { ok: false as const, error: 'Forbidden' }; const doc = await db.doc.delete({ where: { id } }).catch(() => null); if (!doc) return { ok: false as const, error: 'Not found' }; await recordAudit({ actorId: actor.id, action: 'doc.delete', target: doc.slug }); return { ok: true as const }; }