diff --git a/.env.example b/.env.example index 6d8cbae..52efaef 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,6 @@ SUPABASE_SERVICE_ROLE_KEY= # Environment variables for the application NODE_ENV=development +# Service-to-service internal authentication secret (used by codenest backend) +INTERNAL_SERVICE_SECRET= + diff --git a/prisma/migrations/20261021120000_add_birthday_email_log/migration.sql b/prisma/migrations/20261021120000_add_birthday_email_log/migration.sql new file mode 100644 index 0000000..640541c --- /dev/null +++ b/prisma/migrations/20261021120000_add_birthday_email_log/migration.sql @@ -0,0 +1,33 @@ +-- CreateEnum +CREATE TYPE "BirthdayEmailStatus" AS ENUM ('PENDING', 'PROCESSING', 'SENT', 'FAILED', 'DEAD'); + +-- CreateTable +CREATE TABLE "BirthdayEmailLog" ( + "id" TEXT NOT NULL, + "memberId" TEXT NOT NULL, + "birthdayYear" INTEGER NOT NULL, + "status" "BirthdayEmailStatus" NOT NULL DEFAULT 'PENDING', + "attemptCount" INTEGER NOT NULL DEFAULT 0, + "nextRetryAt" TIMESTAMP(3), + "lockedUntil" TIMESTAMP(3), + "lockToken" TEXT, + "providerMessageId" TEXT, + "sentAt" TIMESTAMP(3), + "lastError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BirthdayEmailLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "BirthdayEmailLog_memberId_birthdayYear_key" ON "BirthdayEmailLog"("memberId", "birthdayYear"); + +-- CreateIndex +CREATE INDEX "BirthdayEmailLog_status_nextRetryAt_idx" ON "BirthdayEmailLog"("status", "nextRetryAt"); + +-- CreateIndex +CREATE INDEX "BirthdayEmailLog_status_lockedUntil_idx" ON "BirthdayEmailLog"("status", "lockedUntil"); + +-- AddForeignKey +ALTER TABLE "BirthdayEmailLog" ADD CONSTRAINT "BirthdayEmailLog_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "Member"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0d277c4..87bc4de 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -62,6 +62,7 @@ model Member { updatedSiteActions SiteAction[] @relation("SiteActionUpdatedBy") createdEmailTemplates EmailTemplate[] @relation("EmailTemplateCreatedBy") updatedEmailTemplates EmailTemplate[] @relation("EmailTemplateUpdatedBy") + birthdayEmailLogs BirthdayEmailLog[] } model Account { @@ -249,3 +250,33 @@ model EmailTemplate { updatedById String? updatedAt DateTime @updatedAt } + +enum BirthdayEmailStatus { + PENDING + PROCESSING + SENT + FAILED + DEAD +} + +model BirthdayEmailLog { + id String @id @default(uuid()) + memberId String + birthdayYear Int + status BirthdayEmailStatus @default(PENDING) + attemptCount Int @default(0) + nextRetryAt DateTime? + lockedUntil DateTime? + lockToken String? + providerMessageId String? + sentAt DateTime? + lastError String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + member Member @relation(fields: [memberId], references: [id], onDelete: Cascade) + + @@unique([memberId, birthdayYear]) + @@index([status, nextRetryAt]) + @@index([status, lockedUntil]) +} diff --git a/src/config/index.ts b/src/config/index.ts index cc31432..03d7399 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -5,4 +5,5 @@ export default { SUPABASE_URL: process.env.SUPABASE_URL!, SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY!, ALLOWED_ORIGINS: process.env.ALLOWED_ORIGINS, + INTERNAL_SERVICE_SECRET: process.env.INTERNAL_SERVICE_SECRET, }; diff --git a/src/controllers/birthdayEmail.controller.ts b/src/controllers/birthdayEmail.controller.ts new file mode 100644 index 0000000..bd5df91 --- /dev/null +++ b/src/controllers/birthdayEmail.controller.ts @@ -0,0 +1,135 @@ +import { Request, Response } from "express"; +import * as birthdayEmailService from "../services/birthdayEmail.service"; +import { ApiError } from "../utils/apiError"; + +const { ERROR_CLASSIFICATIONS } = birthdayEmailService; +type ErrorClassification = birthdayEmailService.ErrorClassification; + +function requireLockToken(body: Record): string { + const { lockToken } = body; + if (!lockToken || typeof lockToken !== "string") { + throw new ApiError("lockToken is required in request body", 400); + } + return lockToken; +} + +function parseSentAt(value: unknown): Date | undefined { + if (value === undefined || value === null) return undefined; + const date = new Date(value as string); + if (typeof value !== "string" || Number.isNaN(date.getTime())) { + throw new ApiError("sentAt must be a valid ISO date string", 400); + } + return date; +} + +function parseProviderMessageId(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") { + throw new ApiError("providerMessageId must be a string", 400); + } + return value; +} + +function parseErrorClassification(value: unknown): ErrorClassification { + if (value === undefined || value === null) return "TRANSIENT"; + if (!ERROR_CLASSIFICATIONS.includes(value as ErrorClassification)) { + throw new ApiError( + `errorClassification must be one of: ${ERROR_CLASSIFICATIONS.join(", ")}`, + 400 + ); + } + return value as ErrorClassification; +} + +/** + * POST /api/v1/birthday-emails/initialize + */ +export const initializeJobs = async (req: Request, res: Response) => { + // birthdayYear and memberIds are validated by the service + const { birthdayYear, memberIds } = req.body || {}; + + const result = await birthdayEmailService.initializeBirthdayJobs( + birthdayYear, + memberIds + ); + + res.status(200).json({ + success: true, + message: "Birthday jobs initialized", + data: result, + }); +}; + +/** + * POST /api/v1/birthday-emails/claim + */ +export const claimJobs = async (req: Request, res: Response) => { + const { batchSize, leaseDurationMs } = req.body || {}; + + const jobs = await birthdayEmailService.claimBirthdayJobs( + typeof batchSize === "number" ? batchSize : undefined, + typeof leaseDurationMs === "number" ? leaseDurationMs : undefined + ); + + res.status(200).json({ + success: true, + count: jobs.length, + jobs, + }); +}; + +/** + * POST /api/v1/birthday-emails/:id/sent + */ +export const markJobSent = async (req: Request, res: Response) => { + const body = req.body || {}; + const lockToken = requireLockToken(body); + const providerMessageId = parseProviderMessageId(body.providerMessageId); + const sentAt = parseSentAt(body.sentAt); + + const { alreadySent } = await birthdayEmailService.recordBirthdayJobSent( + req.params.id, + lockToken, + providerMessageId, + sentAt + ); + + res.status(200).json({ + success: true, + message: alreadySent ? "Record was already marked as sent" : "Marked as sent", + }); +}; + +/** + * POST /api/v1/birthday-emails/:id/failed + */ +export const markJobFailed = async (req: Request, res: Response) => { + const body = req.body || {}; + const lockToken = requireLockToken(body); + const errorClassification = parseErrorClassification(body.errorClassification); + + const result = await birthdayEmailService.recordBirthdayJobFailed( + req.params.id, + lockToken, + errorClassification, + typeof body.errorMessage === "string" ? body.errorMessage : "Unknown failure", + typeof body.isRetryable === "boolean" ? body.isRetryable : true + ); + + // Flat shape is the contract the COC-Members birthday worker is typed against. + res.status(200).json({ + success: true, + ...result, + }); +}; + +/** + * GET /api/v1/birthday-emails/stats + */ +export const getStats = async (_req: Request, res: Response) => { + const stats = await birthdayEmailService.getBirthdayEmailStats(); + res.status(200).json({ + success: true, + stats, + }); +}; diff --git a/src/middleware/internalAuth.ts b/src/middleware/internalAuth.ts new file mode 100644 index 0000000..c9a3b67 --- /dev/null +++ b/src/middleware/internalAuth.ts @@ -0,0 +1,47 @@ +import { Request, Response, NextFunction } from "express"; +import crypto from "crypto"; +import config from "../config"; +import { ApiError } from "../utils/apiError"; + +function sha256(value: string): Buffer { + return crypto.createHash("sha256").update(value, "utf-8").digest(); +} + +/** + * Service-to-service authentication middleware for internal APIs. + * Compares the `x-internal-secret` header against INTERNAL_SERVICE_SECRET in constant time. + */ +export function requireInternalAuth( + req: Request, + _res: Response, + next: NextFunction +): void { + const configuredSecret = config.INTERNAL_SERVICE_SECRET; + + if (!configuredSecret) { + throw new ApiError( + "Service authentication is not configured on server", + 500 + ); + } + + const providedSecret = req.headers["x-internal-secret"]; + + if (!providedSecret || typeof providedSecret !== "string") { + throw new ApiError( + "Unauthorized: Missing internal service secret header", + 401 + ); + } + + // Hash both sides so the buffers are always equal length and the + // comparison doesn't reveal the secret's length. + if (!crypto.timingSafeEqual(sha256(providedSecret), sha256(configuredSecret))) { + throw new ApiError( + "Unauthorized: Invalid internal service secret", + 401 + ); + } + + next(); +} diff --git a/src/routes/birthdayEmails.ts b/src/routes/birthdayEmails.ts new file mode 100644 index 0000000..d5887c1 --- /dev/null +++ b/src/routes/birthdayEmails.ts @@ -0,0 +1,122 @@ +import { Router } from "express"; +import * as birthdayCtrl from "../controllers/birthdayEmail.controller"; +import { requireInternalAuth } from "../middleware/internalAuth"; + +/** + * Internal, service-to-service API used by the birthday email worker. + * Every route requires the INTERNAL_SERVICE_SECRET in the `x-internal-secret` header. + */ +export default function birthdayEmailsRouter(): Router { + const router = Router(); + + router.use(requireInternalAuth); + + /** + * @api {post} /birthday-emails/initialize Create birthday email jobs + * @apiName InitializeBirthdayJobs + * @apiGroup BirthdayEmail + * @apiDescription Idempotently creates one PENDING job per eligible member + * (approved, not ghosted, has a birth date and email) for the given year. + * Existing jobs are left untouched. + * + * @apiHeader {String} x-internal-secret Internal service secret. + * + * @apiBody {Number} birthdayYear Year the birthday email is for. (Required) + * @apiBody {String[]} memberIds Member IDs to create jobs for. (Required) + * + * @apiSuccess {Boolean} success Always true. + * @apiSuccess {Object} data + * @apiSuccess {Number} data.created Jobs newly created. + * @apiSuccess {Number} data.existing Jobs that already existed. + * @apiSuccess {Number} data.totalEligible Eligible members found. + * @apiError (400) BadRequest Invalid birthdayYear or memberIds. + * @apiError (401) Unauthorized Missing or invalid service secret. + */ + router.post("/initialize", birthdayCtrl.initializeJobs); + + /** + * @api {post} /birthday-emails/claim Claim a batch of jobs + * @apiName ClaimBirthdayJobs + * @apiGroup BirthdayEmail + * @apiDescription Atomically leases PENDING jobs, FAILED jobs due for retry, + * and PROCESSING jobs whose lease expired. Each claimed job gets a fresh + * lockToken that must be sent back when reporting the result. + * + * @apiHeader {String} x-internal-secret Internal service secret. + * + * @apiBody {Number} [batchSize=10] Jobs to claim (1-50). + * @apiBody {Number} [leaseDurationMs=300000] Lease length in ms (10s-1h). + * + * @apiSuccess {Boolean} success Always true. + * @apiSuccess {Number} count Number of jobs claimed. + * @apiSuccess {Object[]} jobs Claimed jobs (id, memberId, birthdayYear, attemptCount, lockToken). + * @apiError (401) Unauthorized Missing or invalid service secret. + */ + router.post("/claim", birthdayCtrl.claimJobs); + + /** + * @api {get} /birthday-emails/stats Get job statistics + * @apiName GetBirthdayEmailStats + * @apiGroup BirthdayEmail + * + * @apiHeader {String} x-internal-secret Internal service secret. + * + * @apiSuccess {Boolean} success Always true. + * @apiSuccess {Object} stats Counts: pending, processing, sent, failed, dead, dueForRetry, total. + * @apiError (401) Unauthorized Missing or invalid service secret. + */ + router.get("/stats", birthdayCtrl.getStats); + + /** + * @api {post} /birthday-emails/:id/sent Report a sent email + * @apiName MarkBirthdayJobSent + * @apiGroup BirthdayEmail + * @apiDescription Marks a claimed job as SENT. Calling it again for a job + * that is already SENT succeeds without changing anything. + * + * @apiHeader {String} x-internal-secret Internal service secret. + * + * @apiParam (URL Params) {String} id Job ID. + * + * @apiBody {String} lockToken Token returned by /claim. (Required) + * @apiBody {String} [providerMessageId] Email provider's message ID (null allowed). + * @apiBody {String} [sentAt] ISO timestamp of the send. Defaults to now. + * + * @apiSuccess {Boolean} success Always true. + * @apiSuccess {String} message Result description. + * @apiError (400) BadRequest Missing lockToken or invalid sentAt. + * @apiError (404) NotFound Job not found. + * @apiError (409) Conflict Lease expired or held by another worker. + */ + router.post("/:id/sent", birthdayCtrl.markJobSent); + + /** + * @api {post} /birthday-emails/:id/failed Report a failed send + * @apiName MarkBirthdayJobFailed + * @apiGroup BirthdayEmail + * @apiDescription Records a failed attempt. Retryable failures are + * rescheduled with backoff; permanent failures, or a job's 5th attempt, + * move it to DEAD. + * + * @apiHeader {String} x-internal-secret Internal service secret. + * + * @apiParam (URL Params) {String} id Job ID. + * + * @apiBody {String} lockToken Token returned by /claim. (Required) + * @apiBody {String="TRANSIENT","UNCERTAIN","PERMANENT","CONFIG","QUOTA"} [errorClassification=TRANSIENT] Failure type. + * UNCERTAIN (provider timed out after the request was sent) is retried like TRANSIENT. + * @apiBody {String} [errorMessage] Error details. Secrets are redacted before storing. + * @apiBody {Boolean} [isRetryable=true] Whether the failure can be retried. + * + * @apiSuccess {Boolean} success Always true. + * @apiSuccess {String} status FAILED or DEAD. + * @apiSuccess {Number} attemptCount Attempts made so far. + * @apiSuccess {String} nextRetryAt Next retry time, or null when DEAD. + * @apiError (400) BadRequest Missing lockToken or invalid errorClassification. + * @apiError (404) NotFound Job not found. + * @apiError (409) Conflict Lease expired or held by another worker. + */ + router.post("/:id/failed", birthdayCtrl.markJobFailed); + + return router; +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 74ddb5b..673ff79 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,6 +1,5 @@ import { Router } from 'express' import { Multer } from 'multer' -import { SupabaseClient } from '@supabase/supabase-js' import projectsRouter from './projects' import acheivementsRouter from './achievements' import interviewRouter from './interviews' @@ -10,6 +9,7 @@ import progressRouter from './progress' import membersRouter from './members' import siteContentRouter from './site-content' import emailRouter from './email' +import birthdayEmailsRouter from './birthdayEmails' export default function routes(upload: Multer) { const router = Router(); @@ -29,6 +29,7 @@ export default function routes(upload: Multer) { router.use("/site-content", siteContentRouter(upload)); router.use("/email", emailRouter()); + router.use("/birthday-emails", birthdayEmailsRouter()); return router; } diff --git a/src/services/birthdayEmail.service.ts b/src/services/birthdayEmail.service.ts new file mode 100644 index 0000000..e46bbb9 --- /dev/null +++ b/src/services/birthdayEmail.service.ts @@ -0,0 +1,377 @@ +import prisma from "../db/client"; +import type { BirthdayEmailLog, Prisma } from "../generated/prisma/client"; +import { ApiError } from "../utils/apiError"; +import crypto from "crypto"; + +export type ClaimedJob = Pick< + BirthdayEmailLog, + "id" | "memberId" | "birthdayYear" | "attemptCount" +> & { lockToken: string }; + +export interface JobStats { + pending: number; + processing: number; + sent: number; + failed: number; + dead: number; + dueForRetry: number; + total: number; +} + +/** + * UNCERTAIN means the request to the email provider timed out after it was + * sent, so the email may or may not have gone out. It is retried like + * TRANSIENT (the provider's idempotency key stops a duplicate send). + */ +export const ERROR_CLASSIFICATIONS = [ + "TRANSIENT", + "UNCERTAIN", + "PERMANENT", + "CONFIG", + "QUOTA", +] as const; +export type ErrorClassification = (typeof ERROR_CLASSIFICATIONS)[number]; + +export interface FailedJobResult { + status: "FAILED" | "DEAD"; + attemptCount: number; + nextRetryAt: Date | null; +} + +const MINUTE_MS = 60 * 1000; + +/** Retry delay after the Nth failed attempt (index 0 = after attempt 1). */ +const RETRY_DELAYS_MS = [5, 15, 30, 120].map((m) => m * MINUTE_MS); +const QUOTA_RETRY_DELAY_MS = 4 * 60 * MINUTE_MS; +const MAX_ATTEMPTS = 5; + +export const DEFAULT_BATCH_SIZE = 10; +export const DEFAULT_LEASE_MS = 5 * MINUTE_MS; + +/** + * Sanitize error message to ensure no secrets, keys, or bearer tokens are stored. + */ +function sanitizeErrorMessage(msg: string): string { + if (!msg || typeof msg !== "string") return "Unknown error"; + return msg + .replace(/(bearer\s+)[^\s]+/gi, "$1[REDACTED]") + .replace(/(api[-_]?key\s*[:=]\s*)[^\s]+/gi, "$1[REDACTED]") + .replace(/(secret\s*[:=]\s*)[^\s]+/gi, "$1[REDACTED]") + .replace(/(re_[a-zA-Z0-9_-]{20,})/g, "[REDACTED_RESEND_KEY]") + .substring(0, 500); +} + +function retryDelayFor(attemptCount: number): number { + const index = Math.min(Math.max(attemptCount, 1), RETRY_DELAYS_MS.length) - 1; + return RETRY_DELAYS_MS[index]; +} + +/** + * Initialize birthday email jobs for a given year and list of member IDs idempotently. + */ +export async function initializeBirthdayJobs( + birthdayYear: number, + memberIds: string[] +): Promise<{ + created: number; + existing: number; + totalEligible: number; +}> { + if (!Number.isInteger(birthdayYear) || birthdayYear < 1900 || birthdayYear > 2200) { + throw new ApiError("Invalid birthdayYear: must be a 4-digit integer", 400); + } + + if (!Array.isArray(memberIds) || memberIds.length === 0) { + throw new ApiError("memberIds must be a non-empty array of strings", 400); + } + + const cleanIds = Array.from( + new Set(memberIds.filter((id) => typeof id === "string" && id.trim().length > 0)) + ); + + if (cleanIds.length === 0) { + throw new ApiError("No valid member IDs provided", 400); + } + + // Eligible: approved, not ghosted, has a birth date and an email address + const eligibleMembers = await prisma.member.findMany({ + where: { + id: { in: cleanIds }, + isApproved: true, + isGhosted: false, + birth_date: { not: null }, + email: { contains: "@" }, + }, + select: { id: true }, + }); + + if (eligibleMembers.length === 0) { + return { created: 0, existing: 0, totalEligible: 0 }; + } + + // The unique (memberId, birthdayYear) index makes skipDuplicates leave + // existing jobs untouched, so SENT jobs and attempt counts are never reset. + const { count: created } = await prisma.birthdayEmailLog.createMany({ + data: eligibleMembers.map((m) => ({ memberId: m.id, birthdayYear })), + skipDuplicates: true, + }); + + return { + created, + existing: eligibleMembers.length - created, + totalEligible: eligibleMembers.length, + }; +} + +/** + * Atomically claim a batch of eligible jobs for processing. + * A job is claimable when: + * - PENDING + * - FAILED and nextRetryAt <= now + * - PROCESSING and lease expired (lockedUntil < now) + * Prevents race conditions with FOR UPDATE SKIP LOCKED. + * + * An expired PROCESSING lease means the previous worker never reported a + * result (for example, it crashed), so that lost attempt is counted. A job + * that runs out of attempts this way is moved to DEAD instead of reclaimed. + */ +export async function claimBirthdayJobs( + batchSize = DEFAULT_BATCH_SIZE, + leaseDurationMs = DEFAULT_LEASE_MS +): Promise { + const safeBatchSize = Math.max(1, Math.min(batchSize, 50)); + const safeLeaseMs = Math.max(10000, Math.min(leaseDurationMs, 3600000)); + const now = new Date(); + + return await prisma.$transaction(async (tx) => { + // Select eligible jobs with row-level locks, skipping already-locked rows + const eligible = await tx.$queryRaw< + Array<{ id: string; status: string; attemptCount: number }> + >` + SELECT id, "status", "attemptCount" FROM "BirthdayEmailLog" + WHERE ( + "status" = 'PENDING'::"BirthdayEmailStatus" OR + ("status" = 'FAILED'::"BirthdayEmailStatus" AND ("nextRetryAt" IS NULL OR "nextRetryAt" <= ${now})) OR + ("status" = 'PROCESSING'::"BirthdayEmailStatus" AND "lockedUntil" < ${now}) + ) + ORDER BY "createdAt" ASC + LIMIT ${safeBatchSize} + FOR UPDATE SKIP LOCKED + `; + + if (!eligible || eligible.length === 0) { + return []; + } + + const lockedUntil = new Date(now.getTime() + safeLeaseMs); + const claimed: ClaimedJob[] = []; + + for (const row of eligible) { + let attemptCount = row.attemptCount; + + if (row.status === "PROCESSING") { + attemptCount += 1; + if (attemptCount >= MAX_ATTEMPTS) { + await tx.birthdayEmailLog.update({ + where: { id: row.id }, + data: { + status: "DEAD", + attemptCount, + lastError: "Lease expired: worker never reported a result", + lockToken: null, + lockedUntil: null, + nextRetryAt: null, + }, + }); + continue; + } + } + + const lockToken = crypto.randomUUID(); + const updated = await tx.birthdayEmailLog.update({ + where: { id: row.id }, + data: { + status: "PROCESSING", + attemptCount, + lockToken, + lockedUntil, + }, + select: { + id: true, + memberId: true, + birthdayYear: true, + attemptCount: true, + }, + }); + + claimed.push({ ...updated, lockToken }); + } + + return claimed; + }); +} + +async function findJobOrThrow(id: string) { + const record = await prisma.birthdayEmailLog.findUnique({ where: { id } }); + if (!record) { + throw new ApiError("Birthday email record not found", 404); + } + return record; +} + +function leaseConflict(): ApiError { + return new ApiError( + "Invalid or expired lock token: job is no longer owned by caller", + 409 + ); +} + +/** + * Write a job result only if the caller still holds the lease. + * The lease check and the write are one statement, so a worker whose lease + * expired cannot overwrite a job that another worker has since claimed. + * Returns false when the caller no longer holds the lease. + */ +async function updateIfLeaseOwned( + id: string, + lockToken: string, + data: Prisma.BirthdayEmailLogUpdateManyMutationInput +): Promise { + const { count } = await prisma.birthdayEmailLog.updateMany({ + where: { id, lockToken, status: "PROCESSING" }, + data, + }); + return count > 0; +} + +/** + * Mark a claimed birthday email job as SENT. + * Returns `alreadySent: true` when the job had already been marked as sent. + */ +export async function recordBirthdayJobSent( + id: string, + lockToken: string, + providerMessageId?: string, + sentAt: Date = new Date() +): Promise<{ alreadySent: boolean }> { + const updated = await updateIfLeaseOwned(id, lockToken, { + status: "SENT", + providerMessageId, // undefined leaves any stored ID unchanged + sentAt, + lockToken: null, + lockedUntil: null, + nextRetryAt: null, + attemptCount: { increment: 1 }, + lastError: null, + }); + + if (updated) { + return { alreadySent: false }; + } + + // Nothing written: work out why. + const record = await findJobOrThrow(id); + if (record.status === "SENT") { + return { alreadySent: true }; + } + throw leaseConflict(); +} + +/** + * Record a failed attempt for a claimed job. + * Calculates next retry time or transitions to DEAD if max attempts exceeded or permanent failure. + */ +export async function recordBirthdayJobFailed( + id: string, + lockToken: string, + errorClassification: ErrorClassification, + errorMessage: string, + isRetryable: boolean +): Promise { + const record = await findJobOrThrow(id); + + if (record.status !== "PROCESSING" || record.lockToken !== lockToken) { + throw leaseConflict(); + } + + const attemptCount = record.attemptCount + 1; + const isPermanent = + !isRetryable || + errorClassification === "PERMANENT" || + errorClassification === "CONFIG"; + + let status: "FAILED" | "DEAD"; + let nextRetryAt: Date | null = null; + + if (isPermanent || attemptCount >= MAX_ATTEMPTS) { + status = "DEAD"; + } else { + status = "FAILED"; + const delayMs = + errorClassification === "QUOTA" + ? QUOTA_RETRY_DELAY_MS + : retryDelayFor(attemptCount); + nextRetryAt = new Date(Date.now() + delayMs); + } + + const updated = await updateIfLeaseOwned(id, lockToken, { + status, + attemptCount, + nextRetryAt, + lastError: sanitizeErrorMessage(errorMessage), + lockToken: null, + lockedUntil: null, + }); + if (!updated) { + throw leaseConflict(); + } + + return { status, attemptCount, nextRetryAt }; +} + +const STATS_KEY_BY_STATUS: Record< + BirthdayEmailLog["status"], + "pending" | "processing" | "sent" | "failed" | "dead" +> = { + PENDING: "pending", + PROCESSING: "processing", + SENT: "sent", + FAILED: "failed", + DEAD: "dead", +}; + +/** + * Fetch tracking statistics without exposing member emails. + */ +export async function getBirthdayEmailStats(): Promise { + const countsByStatus = await prisma.birthdayEmailLog.groupBy({ + by: ["status"], + _count: { id: true }, + }); + + const stats: JobStats = { + pending: 0, + processing: 0, + sent: 0, + failed: 0, + dead: 0, + dueForRetry: 0, + total: 0, + }; + + for (const group of countsByStatus) { + const count = group._count.id; + stats[STATS_KEY_BY_STATUS[group.status]] = count; + stats.total += count; + } + + const dueForRetry = await prisma.birthdayEmailLog.count({ + where: { + status: "FAILED", + nextRetryAt: { lte: new Date() }, + }, + }); + stats.dueForRetry = dueForRetry; + + return stats; +} diff --git a/tests/BirthdayEmail.test.ts b/tests/BirthdayEmail.test.ts new file mode 100644 index 0000000..8b0aa13 --- /dev/null +++ b/tests/BirthdayEmail.test.ts @@ -0,0 +1,619 @@ +import { Request, Response, NextFunction } from "express"; +import * as birthdayService from "../src/services/birthdayEmail.service"; +import * as birthdayCtrl from "../src/controllers/birthdayEmail.controller"; +import { requireInternalAuth } from "../src/middleware/internalAuth"; +import prisma from "../src/db/client"; +import config from "../src/config"; +import { ApiError } from "../src/utils/apiError"; + +jest.mock("../src/db/client", () => { + const mockPrisma = { + member: { + findMany: jest.fn(), + findUnique: jest.fn(), + }, + birthdayEmailLog: { + findMany: jest.fn(), + findUnique: jest.fn(), + createMany: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + groupBy: jest.fn(), + count: jest.fn(), + }, + $transaction: jest.fn(), + $queryRaw: jest.fn(), + }; + return { + __esModule: true, + default: mockPrisma, + prisma: mockPrisma, + }; +}); + +const mockResponse = (): Response => { + const res = {} as Response; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +}; + +/** Run claimBirthdayJobs against a fake transaction returning the given rows. */ +function mockClaimTransaction( + rows: Array<{ id: string; status: string; attemptCount: number }> +) { + const update = jest.fn().mockImplementation(({ where, data }) => + Promise.resolve({ + id: where.id, + memberId: `member-of-${where.id}`, + birthdayYear: 2026, + attemptCount: data.attemptCount, + }) + ); + (prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => + callback({ + $queryRaw: jest.fn().mockResolvedValue(rows), + birthdayEmailLog: { update }, + }) + ); + return update; +} + +describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { + beforeEach(() => { + jest.clearAllMocks(); + config.INTERNAL_SERVICE_SECRET = "super-secret-service-token"; + }); + + // ────────────────────────────────────────────── + // 1. Initializing the same member/year job twice creates one record + // ────────────────────────────────────────────── + it("1. Initializing the same member/year job twice creates one record", async () => { + const memberId = "member-123"; + (prisma.member.findMany as jest.Mock).mockResolvedValue([{ id: memberId }]); + + // First run inserts; second run hits the unique index and is skipped + (prisma.birthdayEmailLog.createMany as jest.Mock) + .mockResolvedValueOnce({ count: 1 }) + .mockResolvedValueOnce({ count: 0 }); + + const firstRun = await birthdayService.initializeBirthdayJobs(2026, [memberId]); + expect(firstRun).toEqual({ created: 1, existing: 0, totalEligible: 1 }); + + const secondRun = await birthdayService.initializeBirthdayJobs(2026, [memberId]); + expect(secondRun).toEqual({ created: 0, existing: 1, totalEligible: 1 }); + + expect(prisma.birthdayEmailLog.createMany).toHaveBeenCalledWith({ + data: [{ memberId, birthdayYear: 2026 }], + skipDuplicates: true, + }); + }); + + // ────────────────────────────────────────────── + // 2. Existing records are never reset, and only eligible members get jobs + // ────────────────────────────────────────────── + it("2. Existing records are not reset during initialization", async () => { + (prisma.member.findMany as jest.Mock).mockResolvedValue([{ id: "member-sent" }]); + (prisma.birthdayEmailLog.createMany as jest.Mock).mockResolvedValue({ count: 0 }); + + const res = await birthdayService.initializeBirthdayJobs(2026, ["member-sent"]); + expect(res.created).toBe(0); + expect(res.existing).toBe(1); + // Only inserts with skipDuplicates; never updates existing rows + expect(prisma.birthdayEmailLog.update).not.toHaveBeenCalled(); + expect(prisma.birthdayEmailLog.updateMany).not.toHaveBeenCalled(); + }); + + it("2b. Eligibility filter is applied in the member query", async () => { + (prisma.member.findMany as jest.Mock).mockResolvedValue([]); + + const res = await birthdayService.initializeBirthdayJobs(2026, ["m1", "m1", " "]); + expect(res).toEqual({ created: 0, existing: 0, totalEligible: 0 }); + expect(prisma.member.findMany).toHaveBeenCalledWith({ + where: { + id: { in: ["m1"] }, + isApproved: true, + isGhosted: false, + birth_date: { not: null }, + email: { contains: "@" }, + }, + select: { id: true }, + }); + expect(prisma.birthdayEmailLog.createMany).not.toHaveBeenCalled(); + }); + + it("2c. Invalid initialize input is rejected with 400", async () => { + await expect( + birthdayService.initializeBirthdayJobs("2026" as unknown as number, ["m1"]) + ).rejects.toMatchObject({ statusCode: 400 }); + await expect( + birthdayService.initializeBirthdayJobs(2026, undefined as unknown as string[]) + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + // ────────────────────────────────────────────── + // 3. Two concurrent workers cannot claim the same active job + // ────────────────────────────────────────────── + it("3. Atomic job claim locks jobs and prevents concurrent workers from claiming the same active job", async () => { + mockClaimTransaction([{ id: "job-1", status: "PENDING", attemptCount: 0 }]); + + const claimed = await birthdayService.claimBirthdayJobs(10, 300000); + expect(claimed).toHaveLength(1); + expect(claimed[0].id).toBe("job-1"); + expect(claimed[0].attemptCount).toBe(0); + expect(claimed[0].lockToken).toEqual(expect.any(String)); + + // Second worker claims concurrently, queryRaw returns empty (already locked/skipped) + mockClaimTransaction([]); + + const secondClaim = await birthdayService.claimBirthdayJobs(10, 300000); + expect(secondClaim).toHaveLength(0); + }); + + // ────────────────────────────────────────────── + // 4. Expired PROCESSING jobs can be recovered, and the lost attempt counts + // ────────────────────────────────────────────── + it("4. Expired PROCESSING jobs are reclaimed and the lost attempt is counted", async () => { + let queryText = ""; + const update = jest.fn().mockImplementation(({ where, data }) => + Promise.resolve({ + id: where.id, + memberId: "m-expired", + birthdayYear: 2026, + attemptCount: data.attemptCount, + }) + ); + (prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => + callback({ + $queryRaw: jest.fn().mockImplementation((strings: string[]) => { + queryText = strings.join(""); + return Promise.resolve([ + { id: "expired-job-1", status: "PROCESSING", attemptCount: 1 }, + ]); + }), + birthdayEmailLog: { update }, + }) + ); + + const claimed = await birthdayService.claimBirthdayJobs(5, 300000); + expect(queryText).toContain('"lockedUntil" <'); + expect(claimed).toHaveLength(1); + expect(claimed[0].id).toBe("expired-job-1"); + expect(claimed[0].attemptCount).toBe(2); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: "PROCESSING", attemptCount: 2 }), + }) + ); + }); + + it("4b. An expired PROCESSING job on its last attempt goes DEAD instead of being reclaimed", async () => { + const update = mockClaimTransaction([ + { id: "crashy-job", status: "PROCESSING", attemptCount: 4 }, + { id: "fresh-job", status: "PENDING", attemptCount: 0 }, + ]); + + const claimed = await birthdayService.claimBirthdayJobs(10, 300000); + expect(claimed.map((j) => j.id)).toEqual(["fresh-job"]); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "crashy-job" }, + data: expect.objectContaining({ + status: "DEAD", + attemptCount: 5, + lockToken: null, + }), + }) + ); + }); + + // ────────────────────────────────────────────── + // 5. A stale lock token cannot overwrite a newer worker's result + // ────────────────────────────────────────────── + it("5. A stale lock token cannot overwrite a newer worker's result (409 conflict)", async () => { + // Record in DB currently has 'newer-lock-token', so the guarded write matches nothing + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-conflict", + status: "PROCESSING", + lockToken: "newer-lock-token", + attemptCount: 2, + }); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 0 }); + + await expect( + birthdayService.recordBirthdayJobSent("job-conflict", "stale-old-token", "msg-123") + ).rejects.toMatchObject({ statusCode: 409 }); + expect(prisma.birthdayEmailLog.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "job-conflict", lockToken: "stale-old-token", status: "PROCESSING" }, + }) + ); + + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockClear(); + await expect( + birthdayService.recordBirthdayJobFailed( + "job-conflict", + "stale-old-token", + "TRANSIENT", + "network timeout", + true + ) + ).rejects.toMatchObject({ statusCode: 409 }); + // /failed rejects on its pre-read, before writing + expect(prisma.birthdayEmailLog.updateMany).not.toHaveBeenCalled(); + }); + + it("5b. /failed losing the lease between the read and the write is rejected (409 conflict)", async () => { + // The read still shows our lease... + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-race", + status: "PROCESSING", + lockToken: "my-token", + attemptCount: 1, + }); + // ...but another worker reclaimed it before our write landed. + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 0 }); + + await expect( + birthdayService.recordBirthdayJobFailed("job-race", "my-token", "TRANSIENT", "boom", true) + ).rejects.toMatchObject({ statusCode: 409 }); + }); + + it("5c. /sent for an unknown job is a 404", async () => { + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 0 }); + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue(null); + + await expect( + birthdayService.recordBirthdayJobSent("missing", "tok") + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + // ────────────────────────────────────────────── + // 6. Successful sends persist provider IDs and timestamps + // ────────────────────────────────────────────── + it("6. Successful sends persist provider IDs and timestamps in one write", async () => { + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); + + const sentAt = new Date("2026-10-21T09:05:00.000Z"); + const res = await birthdayService.recordBirthdayJobSent( + "job-success", + "valid-token-abc", + "resend-msg-999", + sentAt + ); + + expect(res.alreadySent).toBe(false); + expect(prisma.birthdayEmailLog.updateMany).toHaveBeenCalledWith({ + where: { id: "job-success", lockToken: "valid-token-abc", status: "PROCESSING" }, + data: expect.objectContaining({ + status: "SENT", + providerMessageId: "resend-msg-999", + sentAt, + lockToken: null, + lockedUntil: null, + attemptCount: { increment: 1 }, + }), + }); + expect(prisma.birthdayEmailLog.findUnique).not.toHaveBeenCalled(); + }); + + it("6b. Reporting an already SENT job (including overlapping calls) returns alreadySent", async () => { + // The guarded write matches nothing because the job is no longer PROCESSING + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 0 }); + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-done", + status: "SENT", + lockToken: null, + attemptCount: 1, + }); + + const res = await birthdayService.recordBirthdayJobSent("job-done", "any-token"); + expect(res.alreadySent).toBe(true); + }); + + // ────────────────────────────────────────────── + // 7. Retryable failures schedule a future retry + // ────────────────────────────────────────────── + it("7. Retryable failures schedule a future retry with backoff", async () => { + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-retryable", + status: "PROCESSING", + lockToken: "tok-retry", + attemptCount: 1, // attempt 1 failed, next will be attempt 2 + }); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); + + const res = await birthdayService.recordBirthdayJobFailed( + "job-retryable", + "tok-retry", + "TRANSIENT", + "Temporary 500 error", + true + ); + + expect(res.status).toBe("FAILED"); + expect(res.attemptCount).toBe(2); + expect(res.nextRetryAt).toBeInstanceOf(Date); + // Attempt 2 backoff is 15 minutes (~900000ms from now) + const diffMs = res.nextRetryAt!.getTime() - Date.now(); + expect(diffMs).toBeGreaterThan(14 * 60 * 1000); + expect(diffMs).toBeLessThanOrEqual(16 * 60 * 1000); + }); + + // ────────────────────────────────────────────── + // 8. Permanent failures become terminal (DEAD) + // ────────────────────────────────────────────── + it("8. Permanent failures become terminal (DEAD) without scheduling retry", async () => { + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-perm", + status: "PROCESSING", + lockToken: "tok-perm", + attemptCount: 0, + }); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); + + const res = await birthdayService.recordBirthdayJobFailed( + "job-perm", + "tok-perm", + "PERMANENT", + "Invalid recipient mailbox not found", + false + ); + + expect(res.status).toBe("DEAD"); + expect(res.nextRetryAt).toBeNull(); + expect(res.attemptCount).toBe(1); + expect(prisma.birthdayEmailLog.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: "DEAD", + nextRetryAt: null, + }), + }) + ); + }); + + // ────────────────────────────────────────────── + // 9. Maximum attempts are enforced + // ────────────────────────────────────────────── + it("9. Maximum attempts (5) are enforced, moving record to DEAD", async () => { + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-max-attempt", + status: "PROCESSING", + lockToken: "tok-max", + attemptCount: 4, // 5th attempt failing + }); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); + + const res = await birthdayService.recordBirthdayJobFailed( + "job-max-attempt", + "tok-max", + "TRANSIENT", + "Server error 503", + true + ); + + expect(res.status).toBe("DEAD"); + expect(res.attemptCount).toBe(5); + expect(res.nextRetryAt).toBeNull(); + }); + + // ────────────────────────────────────────────── + // 10. Unauthorized internal API requests are rejected + // ────────────────────────────────────────────── + it("10. Unauthorized internal API requests are rejected by middleware", () => { + const next: NextFunction = jest.fn(); + const res = {} as Response; + + // 10a: Missing secret + const reqMissing = { headers: {} } as unknown as Request; + expect(() => requireInternalAuth(reqMissing, res, next)).toThrow( + expect.objectContaining({ statusCode: 401 }) + ); + + // 10b: Invalid secret (different length from the real one) + const reqInvalid = { + headers: { "x-internal-secret": "wrong" }, + } as unknown as Request; + expect(() => requireInternalAuth(reqInvalid, res, next)).toThrow( + expect.objectContaining({ statusCode: 401 }) + ); + expect(next).not.toHaveBeenCalled(); + + // 10c: Valid secret + const reqValid = { + headers: { "x-internal-secret": "super-secret-service-token" }, + } as unknown as Request; + expect(() => requireInternalAuth(reqValid, res, next)).not.toThrow(); + expect(next).toHaveBeenCalled(); + }); + + it("10e. Only the x-internal-secret header is accepted", () => { + const next: NextFunction = jest.fn(); + const secret = "super-secret-service-token"; + for (const headers of [ + { "x-service-secret": secret }, + { authorization: `Bearer ${secret}` }, + ]) { + const req = { headers } as unknown as Request; + expect(() => requireInternalAuth(req, {} as Response, next)).toThrow( + expect.objectContaining({ statusCode: 401 }) + ); + } + expect(next).not.toHaveBeenCalled(); + }); + + it("10d. Missing server secret is a 500", () => { + config.INTERNAL_SERVICE_SECRET = undefined; + const req = { headers: { "x-internal-secret": "anything" } } as unknown as Request; + expect(() => requireInternalAuth(req, {} as Response, jest.fn())).toThrow( + expect.objectContaining({ statusCode: 500 }) + ); + }); + + // ────────────────────────────────────────────── + // 11. Controller input validation and response shape + // ────────────────────────────────────────────── + describe("controller", () => { + it("rejects an invalid sentAt with 400", async () => { + const req = { + params: { id: "job-1" }, + body: { lockToken: "tok", sentAt: "not-a-date" }, + } as unknown as Request; + + await expect(birthdayCtrl.markJobSent(req, mockResponse())).rejects.toMatchObject({ + statusCode: 400, + }); + expect(prisma.birthdayEmailLog.findUnique).not.toHaveBeenCalled(); + }); + + it("rejects an unknown errorClassification with 400", async () => { + const req = { + params: { id: "job-1" }, + body: { lockToken: "tok", errorClassification: "permanent" }, + } as unknown as Request; + + await expect(birthdayCtrl.markJobFailed(req, mockResponse())).rejects.toBeInstanceOf( + ApiError + ); + expect(prisma.birthdayEmailLog.findUnique).not.toHaveBeenCalled(); + }); + + it("accepts UNCERTAIN and retries it like TRANSIENT", async () => { + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-1", + status: "PROCESSING", + lockToken: "tok", + attemptCount: 0, + }); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); + + const req = { + params: { id: "job-1" }, + body: { + lockToken: "tok", + errorClassification: "UNCERTAIN", + errorMessage: "Network timeout: ETIMEDOUT", + isRetryable: true, + }, + } as unknown as Request; + const res = mockResponse(); + + await birthdayCtrl.markJobFailed(req, res); + + const body = (res.json as jest.Mock).mock.calls[0][0]; + expect(body.status).toBe("FAILED"); + // Attempt 1 backoff is 5 minutes + const diffMs = body.nextRetryAt.getTime() - Date.now(); + expect(diffMs).toBeGreaterThan(4 * 60 * 1000); + expect(diffMs).toBeLessThanOrEqual(5 * 60 * 1000); + }); + + it("accepts a null providerMessageId and leaves the stored one unchanged", async () => { + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); + + const req = { + params: { id: "job-1" }, + body: { lockToken: "tok", providerMessageId: null }, + } as unknown as Request; + + await birthdayCtrl.markJobSent(req, mockResponse()); + + const { data } = (prisma.birthdayEmailLog.updateMany as jest.Mock).mock.calls[0][0]; + expect(data.status).toBe("SENT"); + // undefined tells Prisma to leave the column as is + expect(data.providerMessageId).toBeUndefined(); + }); + + it("rejects a non-string providerMessageId with 400", async () => { + const req = { + params: { id: "job-1" }, + body: { lockToken: "tok", providerMessageId: 12345 }, + } as unknown as Request; + + await expect(birthdayCtrl.markJobSent(req, mockResponse())).rejects.toMatchObject({ + statusCode: 400, + }); + expect(prisma.birthdayEmailLog.findUnique).not.toHaveBeenCalled(); + }); + + it("rejects a missing lockToken with 400", async () => { + const req = { params: { id: "job-1" }, body: {} } as unknown as Request; + + await expect(birthdayCtrl.markJobSent(req, mockResponse())).rejects.toMatchObject({ + statusCode: 400, + }); + }); + + it("wraps a failure result as { success: true, ... }", async () => { + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-1", + status: "PROCESSING", + lockToken: "tok", + attemptCount: 0, + }); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); + + const req = { + params: { id: "job-1" }, + body: { lockToken: "tok", errorClassification: "QUOTA", errorMessage: "429" }, + } as unknown as Request; + const res = mockResponse(); + + await birthdayCtrl.markJobFailed(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + status: "FAILED", + attemptCount: 1, + nextRetryAt: expect.any(Date), + }); + }); + + it("passes a valid sentAt through as a Date", async () => { + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-1", + status: "PROCESSING", + lockToken: "tok", + attemptCount: 0, + providerMessageId: null, + }); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); + + const req = { + params: { id: "job-1" }, + body: { lockToken: "tok", sentAt: "2026-10-21T09:05:00.000Z" }, + } as unknown as Request; + const res = mockResponse(); + + await birthdayCtrl.markJobSent(req, res); + + expect(prisma.birthdayEmailLog.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ sentAt: new Date("2026-10-21T09:05:00.000Z") }), + }) + ); + expect(res.json).toHaveBeenCalledWith({ success: true, message: "Marked as sent" }); + }); + }); + + // ────────────────────────────────────────────── + // Stats endpoint + // ────────────────────────────────────────────── + it("Fetches internal stats safely", async () => { + (prisma.birthdayEmailLog.groupBy as jest.Mock).mockResolvedValue([ + { status: "SENT", _count: { id: 10 } }, + { status: "PENDING", _count: { id: 2 } }, + { status: "FAILED", _count: { id: 1 } }, + ]); + (prisma.birthdayEmailLog.count as jest.Mock).mockResolvedValue(1); + + const stats = await birthdayService.getBirthdayEmailStats(); + expect(stats.sent).toBe(10); + expect(stats.pending).toBe(2); + expect(stats.failed).toBe(1); + expect(stats.dueForRetry).toBe(1); + expect(stats.total).toBe(13); + }); +});