Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Original file line number Diff line number Diff line change
@@ -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;
31 changes: 31 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ model Member {
updatedSiteActions SiteAction[] @relation("SiteActionUpdatedBy")
createdEmailTemplates EmailTemplate[] @relation("EmailTemplateCreatedBy")
updatedEmailTemplates EmailTemplate[] @relation("EmailTemplateUpdatedBy")
birthdayEmailLogs BirthdayEmailLog[]
}

model Account {
Expand Down Expand Up @@ -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])
}
1 change: 1 addition & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
135 changes: 135 additions & 0 deletions src/controllers/birthdayEmail.controller.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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,
});
};
47 changes: 47 additions & 0 deletions src/middleware/internalAuth.ts
Original file line number Diff line number Diff line change
@@ -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();
}
122 changes: 122 additions & 0 deletions src/routes/birthdayEmails.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading