/** * Comment Service * * Orchestrates comment creation through the hook pipeline: * 1. Run comment:beforeCreate pipeline (transform/reject) * 2. Query priorApprovedCount for first-time moderation * 3. Invoke comment:moderate exclusive hook (or built-in fallback) * 4. Save comment with determined status * 5. Fire comment:afterCreate (fire-and-forget) * * Also handles admin moderation (status changes) with afterModerate hooks. */ import type { Kysely } from "kysely"; import { CommentRepository } from "../database/repositories/comment.js"; import type { Comment, CommentStatus } from "../database/repositories/comment.js"; import type { Database } from "../database/types.js"; import type { CollectionCommentSettings, CommentAfterCreateEvent, CommentAfterModerateEvent, CommentBeforeCreateEvent, CommentModerateEvent, ModerationDecision, StoredComment, } from "../plugins/types.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface CommentCreateInput { collection: string; contentId: string; parentId?: string | null; authorName: string; authorEmail: string; authorUserId?: string | null; body: string; ipHash?: string | null; userAgent?: string | null; } export interface CommentCreateResult { comment: Comment; decision: ModerationDecision; } /** * Hook runner interface — injected from the runtime so the service * doesn't need to know about the hook pipeline internals. */ export interface CommentHookRunner { /** Run comment:beforeCreate pipeline. Returns modified event or false. */ runBeforeCreate(event: CommentBeforeCreateEvent): Promise; /** Run comment:moderate exclusive hook. Returns moderation decision. */ runModerate(event: CommentModerateEvent): Promise; /** Fire comment:afterCreate (fire-and-forget). */ fireAfterCreate(event: CommentAfterCreateEvent): void; /** Fire comment:afterModerate (fire-and-forget). */ fireAfterModerate(event: CommentAfterModerateEvent): void; } // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- /** * Create a comment through the full hook pipeline. * * Returns null if the comment was rejected by a beforeCreate handler. */ export async function createComment( db: Kysely, input: CommentCreateInput, collectionSettings: CollectionCommentSettings, hooks: CommentHookRunner, contentInfo?: { id: string; collection: string; slug: string; title?: string; author?: { id: string; name: string | null; email: string }; }, ): Promise { const repo = new CommentRepository(db); // 1. Build the beforeCreate event const beforeCreateEvent: CommentBeforeCreateEvent = { comment: { collection: input.collection, contentId: input.contentId, parentId: input.parentId ?? null, authorName: input.authorName, authorEmail: input.authorEmail, authorUserId: input.authorUserId ?? null, body: input.body, ipHash: input.ipHash ?? null, userAgent: input.userAgent ?? null, }, metadata: {}, }; // 2. Run comment:beforeCreate pipeline const result = await hooks.runBeforeCreate(beforeCreateEvent); if (result === false) { return null; // Rejected } const event = result; // 3. Query prior approved count for first-time moderation const priorApprovedCount = await repo.countApprovedByEmail(event.comment.authorEmail); // 4. Run comment:moderate exclusive hook const moderateEvent: CommentModerateEvent = { comment: event.comment, metadata: event.metadata, collectionSettings, priorApprovedCount, }; const decision = await hooks.runModerate(moderateEvent); // 5. Save comment with determined status const comment = await repo.create({ collection: event.comment.collection, contentId: event.comment.contentId, parentId: event.comment.parentId, authorName: event.comment.authorName, authorEmail: event.comment.authorEmail, authorUserId: event.comment.authorUserId, body: event.comment.body, status: decision.status as CommentStatus, ipHash: event.comment.ipHash, userAgent: event.comment.userAgent, moderationMetadata: Object.keys(event.metadata).length > 0 ? event.metadata : null, }); // 6. Fire comment:afterCreate (fire-and-forget) if (contentInfo) { const afterEvent: CommentAfterCreateEvent = { comment: commentToStored(comment), metadata: event.metadata, content: { id: contentInfo.id, collection: contentInfo.collection, slug: contentInfo.slug, title: contentInfo.title, }, contentAuthor: contentInfo.author, }; hooks.fireAfterCreate(afterEvent); } return { comment, decision }; } /** * Admin moderation — change a comment's status. * Fires comment:afterModerate hook. */ export async function moderateComment( db: Kysely, id: string, newStatus: CommentStatus, moderator: { id: string; name: string | null }, hooks: CommentHookRunner, ): Promise { const repo = new CommentRepository(db); const existing = await repo.findById(id); if (!existing) return null; const previousStatus = existing.status; const updated = await repo.updateStatus(id, newStatus); if (!updated) return null; // Fire comment:afterModerate (fire-and-forget) const afterEvent: CommentAfterModerateEvent = { comment: commentToStored(updated), previousStatus, newStatus, moderator, }; hooks.fireAfterModerate(afterEvent); return updated; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function commentToStored(comment: Comment): StoredComment { return { id: comment.id, collection: comment.collection, contentId: comment.contentId, parentId: comment.parentId, authorName: comment.authorName, authorEmail: comment.authorEmail, authorUserId: comment.authorUserId, body: comment.body, status: comment.status, moderationMetadata: comment.moderationMetadata, createdAt: comment.createdAt, updatedAt: comment.updatedAt, }; }