Repository navigation
Add routes for birthday mail service #81
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
a8296b7
Add routes for birthday mail service
Sherin-2711 8cca9d0
fix: address review feedback on birthday email API
i-am-that-guy 0f5bfdf
fix: accept UNCERTAIN failures and tighten birthday API input
i-am-that-guy f1055fd
refactor: simplify birthday email service
i-am-that-guy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
33 changes: 33 additions & 0 deletions
33
prisma/migrations/20261021120000_add_birthday_email_log/migration.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ); | ||
|
|
||
| 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, | ||
| }); | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.