{"version":3,"file":"service-BToC262I.mjs","names":[],"sources":["../src/comments/notifications.ts","../src/comments/service.ts"],"sourcesContent":["/**\n * Comment Notification Emails\n *\n * Sends email notifications to content authors when comments are\n * approved on their content. Used by:\n *   - Public comment POST route (comment:afterCreate, if auto-approved)\n *   - Admin moderation route (comment:afterModerate, when approving)\n */\n\nimport type { Kysely } from \"kysely\";\n\nimport { escapeHtml } from \"../api/escape.js\";\nimport type { Database } from \"../database/types.js\";\nimport { validateIdentifier } from \"../database/validate.js\";\nimport type { EmailPipeline } from \"../plugins/email.js\";\nimport type { EmailMessage } from \"../plugins/types.js\";\n\nconst NOTIFICATION_SOURCE = \"emdash-comments\";\nconst MAX_EXCERPT_LENGTH = 500;\nconst CRLF_RE = /[\\r\\n]/g;\n\nexport interface CommentNotificationData {\n\tcommentAuthorName: string;\n\tcommentBody: string;\n\tcontentTitle: string;\n\tcollection: string;\n\tadminBaseUrl: string;\n}\n\n/**\n * Build an email notification for a new comment.\n */\nexport function buildCommentNotificationEmail(\n\tto: string,\n\tdata: CommentNotificationData,\n): EmailMessage {\n\tconst title = data.contentTitle || `${data.collection} item`;\n\tconst subject = `New comment on \"${title}\"`.replace(CRLF_RE, \" \");\n\n\tconst excerpt =\n\t\tdata.commentBody.length > MAX_EXCERPT_LENGTH\n\t\t\t? data.commentBody.slice(0, MAX_EXCERPT_LENGTH) + \"...\"\n\t\t\t: data.commentBody;\n\n\tconst adminUrl = `${data.adminBaseUrl}/admin/comments`;\n\n\tconst text = [\n\t\t`${data.commentAuthorName} commented on \"${title}\":`,\n\t\t\"\",\n\t\texcerpt,\n\t\t\"\",\n\t\t`View in admin: ${adminUrl}`,\n\t].join(\"\\n\");\n\n\tconst html = [\n\t\t`<p><strong>${escapeHtml(data.commentAuthorName)}</strong> commented on &ldquo;${escapeHtml(title)}&rdquo;:</p>`,\n\t\t`<blockquote style=\"border-left:3px solid #ccc;padding-left:12px;margin:12px 0;color:#555\">${escapeHtml(excerpt)}</blockquote>`,\n\t\t`<p><a href=\"${escapeHtml(adminUrl)}\">View in admin</a></p>`,\n\t].join(\"\\n\");\n\n\treturn { to, subject, text, html };\n}\n\n/**\n * Send a comment notification to the content author if all conditions are met:\n * 1. Comment status is \"approved\"\n * 2. Content author exists and has an email\n * 3. Email provider is configured\n * 4. Commenter is not the content author (no self-notifications)\n *\n * Returns true if the email was sent, false if skipped.\n */\nexport async function sendCommentNotification(params: {\n\temail: EmailPipeline;\n\tcomment: {\n\t\tauthorName: string;\n\t\tauthorEmail: string;\n\t\tbody: string;\n\t\tstatus: string;\n\t\tcollection: string;\n\t};\n\tcontentTitle?: string;\n\tcontentAuthor?: { email: string; name: string | null };\n\tadminBaseUrl: string;\n}): Promise<boolean> {\n\tconst { email, comment, contentAuthor, adminBaseUrl } = params;\n\n\tif (comment.status !== \"approved\") return false;\n\tif (!contentAuthor?.email) return false;\n\tif (!email.isAvailable()) return false;\n\tif (comment.authorEmail.toLowerCase() === contentAuthor.email.toLowerCase()) return false;\n\n\tconst message = buildCommentNotificationEmail(contentAuthor.email, {\n\t\tcommentAuthorName: comment.authorName,\n\t\tcommentBody: comment.body,\n\t\tcontentTitle: params.contentTitle || \"\",\n\t\tcollection: comment.collection,\n\t\tadminBaseUrl,\n\t});\n\n\tawait email.send(message, NOTIFICATION_SOURCE);\n\treturn true;\n}\n\n/**\n * Look up a content item's author from the database.\n *\n * Used by the admin moderation route where content info isn't\n * readily available (only the comment record is at hand).\n */\nexport async function lookupContentAuthor(\n\tdb: Kysely<Database>,\n\tcollection: string,\n\tcontentId: string,\n): Promise<{\n\tslug: string;\n\tauthor?: { id: string; email: string; name: string | null };\n} | null> {\n\tvalidateIdentifier(collection, \"collection\");\n\n\tconst contentRow = await db\n\t\t.selectFrom(`ec_${collection}` as never)\n\t\t.select([\"slug\" as never, \"author_id\" as never])\n\t\t.where(\"id\" as never, \"=\", contentId as never)\n\t\t.executeTakeFirst();\n\n\tif (!contentRow) return null;\n\n\tconst typed = contentRow as { slug: string; author_id: string | null };\n\n\tlet author: { id: string; email: string; name: string | null } | undefined;\n\tif (typed.author_id) {\n\t\tconst userRow = await db\n\t\t\t.selectFrom(\"users\")\n\t\t\t.select([\"id\", \"name\", \"email\", \"email_verified\"])\n\t\t\t.where(\"id\", \"=\", typed.author_id)\n\t\t\t.executeTakeFirst();\n\t\tif (userRow && userRow.email_verified) {\n\t\t\tauthor = { id: userRow.id, email: userRow.email, name: userRow.name };\n\t\t}\n\t}\n\n\treturn { slug: typed.slug, author };\n}\n","/**\n * Comment Service\n *\n * Orchestrates comment creation through the hook pipeline:\n *   1. Run comment:beforeCreate pipeline (transform/reject)\n *   2. Query priorApprovedCount for first-time moderation\n *   3. Invoke comment:moderate exclusive hook (or built-in fallback)\n *   4. Save comment with determined status\n *   5. Fire comment:afterCreate (fire-and-forget)\n *\n * Also handles admin moderation (status changes) with afterModerate hooks.\n */\n\nimport type { Kysely } from \"kysely\";\n\nimport { CommentRepository } from \"../database/repositories/comment.js\";\nimport type { Comment, CommentStatus } from \"../database/repositories/comment.js\";\nimport type { Database } from \"../database/types.js\";\nimport type {\n\tCollectionCommentSettings,\n\tCommentAfterCreateEvent,\n\tCommentAfterModerateEvent,\n\tCommentBeforeCreateEvent,\n\tCommentModerateEvent,\n\tModerationDecision,\n\tStoredComment,\n} from \"../plugins/types.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface CommentCreateInput {\n\tcollection: string;\n\tcontentId: string;\n\tparentId?: string | null;\n\tauthorName: string;\n\tauthorEmail: string;\n\tauthorUserId?: string | null;\n\tbody: string;\n\tipHash?: string | null;\n\tuserAgent?: string | null;\n}\n\nexport interface CommentCreateResult {\n\tcomment: Comment;\n\tdecision: ModerationDecision;\n}\n\n/**\n * Hook runner interface — injected from the runtime so the service\n * doesn't need to know about the hook pipeline internals.\n */\nexport interface CommentHookRunner {\n\t/** Run comment:beforeCreate pipeline. Returns modified event or false. */\n\trunBeforeCreate(event: CommentBeforeCreateEvent): Promise<CommentBeforeCreateEvent | false>;\n\n\t/** Run comment:moderate exclusive hook. Returns moderation decision. */\n\trunModerate(event: CommentModerateEvent): Promise<ModerationDecision>;\n\n\t/** Fire comment:afterCreate (fire-and-forget). */\n\tfireAfterCreate(event: CommentAfterCreateEvent): void;\n\n\t/** Fire comment:afterModerate (fire-and-forget). */\n\tfireAfterModerate(event: CommentAfterModerateEvent): void;\n}\n\n// ---------------------------------------------------------------------------\n// Service\n// ---------------------------------------------------------------------------\n\n/**\n * Create a comment through the full hook pipeline.\n *\n * Returns null if the comment was rejected by a beforeCreate handler.\n */\nexport async function createComment(\n\tdb: Kysely<Database>,\n\tinput: CommentCreateInput,\n\tcollectionSettings: CollectionCommentSettings,\n\thooks: CommentHookRunner,\n\tcontentInfo?: {\n\t\tid: string;\n\t\tcollection: string;\n\t\tslug: string;\n\t\ttitle?: string;\n\t\tauthor?: { id: string; name: string | null; email: string };\n\t},\n): Promise<CommentCreateResult | null> {\n\tconst repo = new CommentRepository(db);\n\n\t// 1. Build the beforeCreate event\n\tconst beforeCreateEvent: CommentBeforeCreateEvent = {\n\t\tcomment: {\n\t\t\tcollection: input.collection,\n\t\t\tcontentId: input.contentId,\n\t\t\tparentId: input.parentId ?? null,\n\t\t\tauthorName: input.authorName,\n\t\t\tauthorEmail: input.authorEmail,\n\t\t\tauthorUserId: input.authorUserId ?? null,\n\t\t\tbody: input.body,\n\t\t\tipHash: input.ipHash ?? null,\n\t\t\tuserAgent: input.userAgent ?? null,\n\t\t},\n\t\tmetadata: {},\n\t};\n\n\t// 2. Run comment:beforeCreate pipeline\n\tconst result = await hooks.runBeforeCreate(beforeCreateEvent);\n\tif (result === false) {\n\t\treturn null; // Rejected\n\t}\n\n\tconst event = result;\n\n\t// 3. Query prior approved count for first-time moderation\n\tconst priorApprovedCount = await repo.countApprovedByEmail(event.comment.authorEmail);\n\n\t// 4. Run comment:moderate exclusive hook\n\tconst moderateEvent: CommentModerateEvent = {\n\t\tcomment: event.comment,\n\t\tmetadata: event.metadata,\n\t\tcollectionSettings,\n\t\tpriorApprovedCount,\n\t};\n\n\tconst decision = await hooks.runModerate(moderateEvent);\n\n\t// 5. Save comment with determined status\n\tconst comment = await repo.create({\n\t\tcollection: event.comment.collection,\n\t\tcontentId: event.comment.contentId,\n\t\tparentId: event.comment.parentId,\n\t\tauthorName: event.comment.authorName,\n\t\tauthorEmail: event.comment.authorEmail,\n\t\tauthorUserId: event.comment.authorUserId,\n\t\tbody: event.comment.body,\n\t\tstatus: decision.status,\n\t\tipHash: event.comment.ipHash,\n\t\tuserAgent: event.comment.userAgent,\n\t\tmoderationMetadata: Object.keys(event.metadata).length > 0 ? event.metadata : null,\n\t});\n\n\t// 6. Fire comment:afterCreate (fire-and-forget)\n\tif (contentInfo) {\n\t\tconst afterEvent: CommentAfterCreateEvent = {\n\t\t\tcomment: commentToStored(comment),\n\t\t\tmetadata: event.metadata,\n\t\t\tcontent: {\n\t\t\t\tid: contentInfo.id,\n\t\t\t\tcollection: contentInfo.collection,\n\t\t\t\tslug: contentInfo.slug,\n\t\t\t\ttitle: contentInfo.title,\n\t\t\t},\n\t\t\tcontentAuthor: contentInfo.author,\n\t\t};\n\t\thooks.fireAfterCreate(afterEvent);\n\t}\n\n\treturn { comment, decision };\n}\n\n/**\n * Admin moderation — change a comment's status.\n * Fires comment:afterModerate hook.\n */\nexport async function moderateComment(\n\tdb: Kysely<Database>,\n\tid: string,\n\tnewStatus: CommentStatus,\n\tmoderator: { id: string; name: string | null },\n\thooks: CommentHookRunner,\n): Promise<Comment | null> {\n\tconst repo = new CommentRepository(db);\n\tconst existing = await repo.findById(id);\n\tif (!existing) return null;\n\n\tconst previousStatus = existing.status;\n\tconst updated = await repo.updateStatus(id, newStatus);\n\tif (!updated) return null;\n\n\t// Fire comment:afterModerate (fire-and-forget)\n\tconst afterEvent: CommentAfterModerateEvent = {\n\t\tcomment: commentToStored(updated),\n\t\tpreviousStatus,\n\t\tnewStatus,\n\t\tmoderator,\n\t};\n\thooks.fireAfterModerate(afterEvent);\n\n\treturn updated;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction commentToStored(comment: Comment): StoredComment {\n\treturn {\n\t\tid: comment.id,\n\t\tcollection: comment.collection,\n\t\tcontentId: comment.contentId,\n\t\tparentId: comment.parentId,\n\t\tauthorName: comment.authorName,\n\t\tauthorEmail: comment.authorEmail,\n\t\tauthorUserId: comment.authorUserId,\n\t\tbody: comment.body,\n\t\tstatus: comment.status,\n\t\tmoderationMetadata: comment.moderationMetadata,\n\t\tcreatedAt: comment.createdAt,\n\t\tupdatedAt: comment.updatedAt,\n\t};\n}\n"],"mappings":"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"}