feat(email): route all email sends through Cloudflare Queue
Introduces a CF Queue binding (kk-email-queue) to decouple email delivery from request handlers, preventing slow responses and providing automatic retries. All send*Email calls now go through the queue when the binding is available, with direct-send fallbacks for local dev. Reminder fan-outs mark DB rows optimistically before enqueueing to prevent re-enqueue on subsequent cron ticks.
This commit is contained in:
@@ -52,6 +52,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.17.1",
|
"@cloudflare/vite-plugin": "^1.17.1",
|
||||||
|
"@tanstack/router-core": "^1.141.1",
|
||||||
"@kk/config": "workspace:*",
|
"@kk/config": "workspace:*",
|
||||||
"@tanstack/react-query-devtools": "^5.91.1",
|
"@tanstack/react-query-devtools": "^5.91.1",
|
||||||
"@tanstack/react-router-devtools": "^1.141.1",
|
"@tanstack/react-router-devtools": "^1.141.1",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { EmailMessage } from "@kk/api/email-queue";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||||
|
|
||||||
@@ -6,6 +7,18 @@ import Loader from "./components/loader";
|
|||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
import { orpc, queryClient } from "./utils/orpc";
|
import { orpc, queryClient } from "./utils/orpc";
|
||||||
|
|
||||||
|
// Minimal CF Queue binding shape needed for type inference
|
||||||
|
type Queue = {
|
||||||
|
send(
|
||||||
|
message: EmailMessage,
|
||||||
|
options?: { contentType?: string },
|
||||||
|
): Promise<void>;
|
||||||
|
sendBatch(
|
||||||
|
messages: Array<{ body: EmailMessage }>,
|
||||||
|
options?: { contentType?: string },
|
||||||
|
): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
export const getRouter = () => {
|
export const getRouter = () => {
|
||||||
const router = createTanStackRouter({
|
const router = createTanStackRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
@@ -26,3 +39,9 @@ declare module "@tanstack/react-router" {
|
|||||||
router: ReturnType<typeof getRouter>;
|
router: ReturnType<typeof getRouter>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "@tanstack/router-core" {
|
||||||
|
interface Register {
|
||||||
|
server: { requestContext: { emailQueue?: Queue } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createContext } from "@kk/api/context";
|
import { createContext } from "@kk/api/context";
|
||||||
|
import type { EmailMessage } from "@kk/api/email-queue";
|
||||||
import { appRouter } from "@kk/api/routers/index";
|
import { appRouter } from "@kk/api/routers/index";
|
||||||
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
||||||
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
|
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
|
||||||
@@ -7,6 +8,18 @@ import { RPCHandler } from "@orpc/server/fetch";
|
|||||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
// Minimal CF Queue binding shape — mirrors the declaration in router.tsx / context.ts
|
||||||
|
type Queue = {
|
||||||
|
send(
|
||||||
|
message: EmailMessage,
|
||||||
|
options?: { contentType?: string },
|
||||||
|
): Promise<void>;
|
||||||
|
sendBatch(
|
||||||
|
messages: Array<{ body: EmailMessage }>,
|
||||||
|
options?: { contentType?: string },
|
||||||
|
): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
const rpcHandler = new RPCHandler(appRouter, {
|
const rpcHandler = new RPCHandler(appRouter, {
|
||||||
interceptors: [
|
interceptors: [
|
||||||
onError((error) => {
|
onError((error) => {
|
||||||
@@ -28,16 +41,21 @@ const apiHandler = new OpenAPIHandler(appRouter, {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handle({ request }: { request: Request }) {
|
async function handle(ctx: { request: Request; context?: unknown }) {
|
||||||
const rpcResult = await rpcHandler.handle(request, {
|
// emailQueue is threaded in via the fetch wrapper in server.ts
|
||||||
|
const emailQueue = (ctx.context as { emailQueue?: Queue } | undefined)
|
||||||
|
?.emailQueue;
|
||||||
|
const context = await createContext({ req: ctx.request, emailQueue });
|
||||||
|
|
||||||
|
const rpcResult = await rpcHandler.handle(ctx.request, {
|
||||||
prefix: "/api/rpc",
|
prefix: "/api/rpc",
|
||||||
context: await createContext({ req: request }),
|
context,
|
||||||
});
|
});
|
||||||
if (rpcResult.response) return rpcResult.response;
|
if (rpcResult.response) return rpcResult.response;
|
||||||
|
|
||||||
const apiResult = await apiHandler.handle(request, {
|
const apiResult = await apiHandler.handle(ctx.request, {
|
||||||
prefix: "/api/rpc/api-reference",
|
prefix: "/api/rpc/api-reference",
|
||||||
context: await createContext({ req: request }),
|
context,
|
||||||
});
|
});
|
||||||
if (apiResult.response) return apiResult.response;
|
if (apiResult.response) return apiResult.response;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { sendPaymentConfirmationEmail } from "@kk/api/email";
|
import { sendPaymentConfirmationEmail } from "@kk/api/email";
|
||||||
|
import type { EmailMessage } from "@kk/api/email-queue";
|
||||||
import { creditRegistrationToAccount } from "@kk/api/routers/index";
|
import { creditRegistrationToAccount } from "@kk/api/routers/index";
|
||||||
import { db } from "@kk/db";
|
import { db } from "@kk/db";
|
||||||
import { drinkkaart, drinkkaartTopup, registration } from "@kk/db/schema";
|
import { drinkkaart, drinkkaartTopup, registration } from "@kk/db/schema";
|
||||||
@@ -8,6 +9,14 @@ import { env } from "@kk/env/server";
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
// Minimal CF Queue binding shape used to enqueue emails
|
||||||
|
type EmailQueue = {
|
||||||
|
send(
|
||||||
|
message: EmailMessage,
|
||||||
|
options?: { contentType?: string },
|
||||||
|
): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
// Mollie payment object (relevant fields only)
|
// Mollie payment object (relevant fields only)
|
||||||
interface MolliePayment {
|
interface MolliePayment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,7 +50,15 @@ async function fetchMolliePayment(paymentId: string): Promise<MolliePayment> {
|
|||||||
return response.json() as Promise<MolliePayment>;
|
return response.json() as Promise<MolliePayment>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleWebhook({ request }: { request: Request }) {
|
async function handleWebhook({
|
||||||
|
request,
|
||||||
|
context,
|
||||||
|
}: {
|
||||||
|
request: Request;
|
||||||
|
context?: unknown;
|
||||||
|
}) {
|
||||||
|
const emailQueue = (context as { emailQueue?: EmailQueue } | undefined)
|
||||||
|
?.emailQueue;
|
||||||
if (!env.MOLLIE_API_KEY) {
|
if (!env.MOLLIE_API_KEY) {
|
||||||
console.error("MOLLIE_API_KEY not configured");
|
console.error("MOLLIE_API_KEY not configured");
|
||||||
return new Response("Payment provider not configured", { status: 500 });
|
return new Response("Payment provider not configured", { status: 500 });
|
||||||
@@ -207,19 +224,25 @@ async function handleWebhook({ request }: { request: Request }) {
|
|||||||
`Payment successful for registration ${registrationToken}, payment ${payment.id}`,
|
`Payment successful for registration ${registrationToken}, payment ${payment.id}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send payment confirmation email (fire-and-forget; don't block webhook)
|
// Send payment confirmation email via queue when available, otherwise direct.
|
||||||
sendPaymentConfirmationEmail({
|
const confirmMsg: EmailMessage = {
|
||||||
|
type: "paymentConfirmation",
|
||||||
to: regRow.email,
|
to: regRow.email,
|
||||||
firstName: regRow.firstName,
|
firstName: regRow.firstName,
|
||||||
managementToken: regRow.managementToken ?? registrationToken,
|
managementToken: regRow.managementToken ?? registrationToken,
|
||||||
drinkCardValue: regRow.drinkCardValue ?? undefined,
|
drinkCardValue: regRow.drinkCardValue ?? undefined,
|
||||||
giftAmount: regRow.giftAmount ?? undefined,
|
giftAmount: regRow.giftAmount ?? undefined,
|
||||||
}).catch((err) =>
|
};
|
||||||
console.error(
|
if (emailQueue) {
|
||||||
`Failed to send payment confirmation email for ${regRow.email}:`,
|
await emailQueue.send(confirmMsg);
|
||||||
err,
|
} else {
|
||||||
),
|
sendPaymentConfirmationEmail(confirmMsg).catch((err) =>
|
||||||
);
|
console.error(
|
||||||
|
`Failed to send payment confirmation email for ${regRow.email}:`,
|
||||||
|
err,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// If this is a watcher with a drink card value, try to credit their
|
// If this is a watcher with a drink card value, try to credit their
|
||||||
// drinkkaart immediately — but only if they already have an account.
|
// drinkkaart immediately — but only if they already have an account.
|
||||||
|
|||||||
@@ -1,18 +1,72 @@
|
|||||||
|
import type { EmailMessage } from "@kk/api/email-queue";
|
||||||
|
import { dispatchEmailMessage } from "@kk/api/email-queue";
|
||||||
import { runSendReminders } from "@kk/api/routers/index";
|
import { runSendReminders } from "@kk/api/routers/index";
|
||||||
import {
|
import {
|
||||||
createStartHandler,
|
createStartHandler,
|
||||||
defaultStreamHandler,
|
defaultStreamHandler,
|
||||||
} from "@tanstack/react-start/server";
|
} from "@tanstack/react-start/server";
|
||||||
|
|
||||||
const fetch = createStartHandler(defaultStreamHandler);
|
// ---------------------------------------------------------------------------
|
||||||
|
// CF Workers globals — not in DOM/ES2022 lib
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
type MessageItem<Body> = {
|
||||||
|
body: Body;
|
||||||
|
ack(): void;
|
||||||
|
retry(): void;
|
||||||
|
};
|
||||||
|
type MessageBatch<Body> = { messages: Array<MessageItem<Body>> };
|
||||||
|
type ExecutionContext = { waitUntil(promise: Promise<unknown>): void };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Minimal CF Queue binding shape
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
type EmailQueue = {
|
||||||
|
send(
|
||||||
|
message: EmailMessage,
|
||||||
|
options?: { contentType?: string },
|
||||||
|
): Promise<void>;
|
||||||
|
sendBatch(
|
||||||
|
messages: Array<{ body: EmailMessage }>,
|
||||||
|
options?: { contentType?: string },
|
||||||
|
): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Env = {
|
||||||
|
EMAIL_QUEUE?: EmailQueue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startHandler = createStartHandler(defaultStreamHandler);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
fetch,
|
fetch(request: Request, env: Env) {
|
||||||
|
// Cast required: TanStack Start's BaseContext doesn't know about emailQueue,
|
||||||
|
// but it threads the value through to route handlers via requestContext.
|
||||||
|
return startHandler(request, {
|
||||||
|
context: { emailQueue: env.EMAIL_QUEUE } as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async queue(
|
||||||
|
batch: MessageBatch<EmailMessage>,
|
||||||
|
_env: Env,
|
||||||
|
_ctx: ExecutionContext,
|
||||||
|
) {
|
||||||
|
for (const msg of batch.messages) {
|
||||||
|
try {
|
||||||
|
await dispatchEmailMessage(msg.body);
|
||||||
|
msg.ack();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Queue dispatch failed:", err);
|
||||||
|
msg.retry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async scheduled(
|
async scheduled(
|
||||||
_event: { cron: string; scheduledTime: number },
|
_event: { cron: string; scheduledTime: number },
|
||||||
_env: Record<string, unknown>,
|
env: Env,
|
||||||
ctx: { waitUntil: (promise: Promise<unknown>) => void },
|
ctx: ExecutionContext,
|
||||||
) {
|
) {
|
||||||
ctx.waitUntil(runSendReminders());
|
ctx.waitUntil(runSendReminders(env.EMAIL_QUEUE));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
1
bun.lock
1
bun.lock
@@ -67,6 +67,7 @@
|
|||||||
"@kk/config": "workspace:*",
|
"@kk/config": "workspace:*",
|
||||||
"@tanstack/react-query-devtools": "^5.91.1",
|
"@tanstack/react-query-devtools": "^5.91.1",
|
||||||
"@tanstack/react-router-devtools": "^1.141.1",
|
"@tanstack/react-router-devtools": "^1.141.1",
|
||||||
|
"@tanstack/router-core": "^1.141.1",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.2.0",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
|
|||||||
@@ -1,13 +1,33 @@
|
|||||||
import { auth } from "@kk/auth";
|
import { auth } from "@kk/auth";
|
||||||
import { env } from "@kk/env/server";
|
import { env } from "@kk/env/server";
|
||||||
|
import type { EmailMessage } from "./email-queue";
|
||||||
|
|
||||||
export async function createContext({ req }: { req: Request }) {
|
// CF Workers runtime Queue type (not the alchemy resource type)
|
||||||
|
type Queue = {
|
||||||
|
send(
|
||||||
|
message: EmailMessage,
|
||||||
|
options?: { contentType?: string },
|
||||||
|
): Promise<void>;
|
||||||
|
sendBatch(
|
||||||
|
messages: Array<{ body: EmailMessage }>,
|
||||||
|
options?: { contentType?: string },
|
||||||
|
): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createContext({
|
||||||
|
req,
|
||||||
|
emailQueue,
|
||||||
|
}: {
|
||||||
|
req: Request;
|
||||||
|
emailQueue?: Queue;
|
||||||
|
}) {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: req.headers,
|
headers: req.headers,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
env,
|
env,
|
||||||
|
emailQueue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
147
packages/api/src/email-queue.ts
Normal file
147
packages/api/src/email-queue.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Email queue types and dispatcher.
|
||||||
|
*
|
||||||
|
* All email sends are modelled as a discriminated union so the Cloudflare Queue
|
||||||
|
* consumer can pattern-match on `msg.type` and call the right send*Email().
|
||||||
|
*
|
||||||
|
* The `Queue<EmailMessage>` type used in context.ts / server.ts refers to the
|
||||||
|
* CF runtime binding type (`import type { Queue } from "@cloudflare/workers-types"`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
sendCancellationEmail,
|
||||||
|
sendConfirmationEmail,
|
||||||
|
sendPaymentConfirmationEmail,
|
||||||
|
sendPaymentReminderEmail,
|
||||||
|
sendReminder24hEmail,
|
||||||
|
sendReminderEmail,
|
||||||
|
sendSubscriptionConfirmationEmail,
|
||||||
|
sendUpdateEmail,
|
||||||
|
} from "./email";
|
||||||
|
import { sendDeductionEmail } from "./lib/drinkkaart-email";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Message types — one variant per send*Email function
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ConfirmationMessage = {
|
||||||
|
type: "registrationConfirmation";
|
||||||
|
to: string;
|
||||||
|
firstName: string;
|
||||||
|
managementToken: string;
|
||||||
|
wantsToPerform: boolean;
|
||||||
|
artForm?: string | null;
|
||||||
|
giftAmount?: number;
|
||||||
|
drinkCardValue?: number;
|
||||||
|
signupUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateMessage = {
|
||||||
|
type: "updateConfirmation";
|
||||||
|
to: string;
|
||||||
|
firstName: string;
|
||||||
|
managementToken: string;
|
||||||
|
wantsToPerform: boolean;
|
||||||
|
artForm?: string | null;
|
||||||
|
giftAmount?: number;
|
||||||
|
drinkCardValue?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CancellationMessage = {
|
||||||
|
type: "cancellation";
|
||||||
|
to: string;
|
||||||
|
firstName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubscriptionConfirmationMessage = {
|
||||||
|
type: "subscriptionConfirmation";
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Reminder24hMessage = {
|
||||||
|
type: "reminder24h";
|
||||||
|
to: string;
|
||||||
|
firstName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Reminder1hMessage = {
|
||||||
|
type: "reminder1h";
|
||||||
|
to: string;
|
||||||
|
firstName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaymentReminderMessage = {
|
||||||
|
type: "paymentReminder";
|
||||||
|
to: string;
|
||||||
|
firstName: string;
|
||||||
|
managementToken: string;
|
||||||
|
drinkCardValue?: number;
|
||||||
|
giftAmount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaymentConfirmationMessage = {
|
||||||
|
type: "paymentConfirmation";
|
||||||
|
to: string;
|
||||||
|
firstName: string;
|
||||||
|
managementToken: string;
|
||||||
|
drinkCardValue?: number;
|
||||||
|
giftAmount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeductionMessage = {
|
||||||
|
type: "deduction";
|
||||||
|
to: string;
|
||||||
|
firstName: string;
|
||||||
|
amountCents: number;
|
||||||
|
newBalanceCents: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EmailMessage =
|
||||||
|
| ConfirmationMessage
|
||||||
|
| UpdateMessage
|
||||||
|
| CancellationMessage
|
||||||
|
| SubscriptionConfirmationMessage
|
||||||
|
| Reminder24hMessage
|
||||||
|
| Reminder1hMessage
|
||||||
|
| PaymentReminderMessage
|
||||||
|
| PaymentConfirmationMessage
|
||||||
|
| DeductionMessage;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Consumer-side dispatcher — called once per queue message
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function dispatchEmailMessage(msg: EmailMessage): Promise<void> {
|
||||||
|
switch (msg.type) {
|
||||||
|
case "registrationConfirmation":
|
||||||
|
await sendConfirmationEmail(msg);
|
||||||
|
break;
|
||||||
|
case "updateConfirmation":
|
||||||
|
await sendUpdateEmail(msg);
|
||||||
|
break;
|
||||||
|
case "cancellation":
|
||||||
|
await sendCancellationEmail(msg);
|
||||||
|
break;
|
||||||
|
case "subscriptionConfirmation":
|
||||||
|
await sendSubscriptionConfirmationEmail({ to: msg.to });
|
||||||
|
break;
|
||||||
|
case "reminder24h":
|
||||||
|
await sendReminder24hEmail(msg);
|
||||||
|
break;
|
||||||
|
case "reminder1h":
|
||||||
|
await sendReminderEmail(msg);
|
||||||
|
break;
|
||||||
|
case "paymentReminder":
|
||||||
|
await sendPaymentReminderEmail(msg);
|
||||||
|
break;
|
||||||
|
case "paymentConfirmation":
|
||||||
|
await sendPaymentConfirmationEmail(msg);
|
||||||
|
break;
|
||||||
|
case "deduction":
|
||||||
|
await sendDeductionEmail(msg);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Exhaustiveness check — TypeScript will catch unhandled variants at compile time
|
||||||
|
msg satisfies never;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { env } from "@kk/env/server";
|
import { env } from "@kk/env/server";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
import { emailLog } from "../email";
|
||||||
|
|
||||||
// Re-use the same SMTP transport strategy as email.ts.
|
// Re-use the same SMTP transport strategy as email.ts:
|
||||||
let _transport: nodemailer.Transporter | null | undefined;
|
// only cache a successfully-created transporter; never cache null so that a
|
||||||
|
// cold isolate that receives env vars after module init can still pick them up.
|
||||||
|
let _transport: nodemailer.Transporter | undefined;
|
||||||
|
|
||||||
function getTransport(): nodemailer.Transporter | null {
|
function getTransport(): nodemailer.Transporter | null {
|
||||||
if (_transport !== undefined) return _transport;
|
if (_transport) return _transport;
|
||||||
if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) {
|
if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) {
|
||||||
_transport = null;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
_transport = nodemailer.createTransport({
|
_transport = nodemailer.createTransport({
|
||||||
@@ -22,9 +24,6 @@ function getTransport(): nodemailer.Transporter | null {
|
|||||||
return _transport;
|
return _transport;
|
||||||
}
|
}
|
||||||
|
|
||||||
const from = env.SMTP_FROM ?? "Kunstenkamp <info@kunstenkamp.be>";
|
|
||||||
const baseUrl = env.BETTER_AUTH_URL ?? "https://kunstenkamp.be";
|
|
||||||
|
|
||||||
function formatEuro(cents: number): string {
|
function formatEuro(cents: number): string {
|
||||||
return new Intl.NumberFormat("nl-BE", {
|
return new Intl.NumberFormat("nl-BE", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
@@ -116,7 +115,7 @@ function deductionHtml(params: {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a deduction notification email to the cardholder.
|
* Send a deduction notification email to the cardholder.
|
||||||
* Fire-and-forget: errors are logged but not re-thrown.
|
* Throws on SMTP error so the queue consumer can retry.
|
||||||
*/
|
*/
|
||||||
export async function sendDeductionEmail(params: {
|
export async function sendDeductionEmail(params: {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -124,9 +123,18 @@ export async function sendDeductionEmail(params: {
|
|||||||
amountCents: number;
|
amountCents: number;
|
||||||
newBalanceCents: number;
|
newBalanceCents: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
emailLog("info", "email.attempt", { type: "deduction", to: params.to });
|
||||||
|
|
||||||
const transport = getTransport();
|
const transport = getTransport();
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
console.warn("SMTP not configured — skipping deduction email");
|
emailLog("warn", "email.skipped", {
|
||||||
|
type: "deduction",
|
||||||
|
to: params.to,
|
||||||
|
reason: "smtp_not_configured",
|
||||||
|
hasHost: !!env.SMTP_HOST,
|
||||||
|
hasUser: !!env.SMTP_USER,
|
||||||
|
hasPass: !!env.SMTP_PASS,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,16 +152,26 @@ export async function sendDeductionEmail(params: {
|
|||||||
currency: "EUR",
|
currency: "EUR",
|
||||||
}).format(params.amountCents / 100);
|
}).format(params.amountCents / 100);
|
||||||
|
|
||||||
await transport.sendMail({
|
try {
|
||||||
from,
|
await transport.sendMail({
|
||||||
to: params.to,
|
from: env.SMTP_FROM,
|
||||||
subject: `Drinkkaart — ${amountFormatted} afgeschreven`,
|
to: params.to,
|
||||||
html: deductionHtml({
|
subject: `Drinkkaart — ${amountFormatted} afgeschreven`,
|
||||||
firstName: params.firstName,
|
html: deductionHtml({
|
||||||
amountCents: params.amountCents,
|
firstName: params.firstName,
|
||||||
newBalanceCents: params.newBalanceCents,
|
amountCents: params.amountCents,
|
||||||
dateTime,
|
newBalanceCents: params.newBalanceCents,
|
||||||
drinkkaartUrl: `${baseUrl}/drinkkaart`,
|
dateTime,
|
||||||
}),
|
drinkkaartUrl: `${env.BETTER_AUTH_URL}/drinkkaart`,
|
||||||
});
|
}),
|
||||||
|
});
|
||||||
|
emailLog("info", "email.sent", { type: "deduction", to: params.to });
|
||||||
|
} catch (err) {
|
||||||
|
emailLog("error", "email.error", {
|
||||||
|
type: "deduction",
|
||||||
|
to: params.to,
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { user } from "@kk/db/schema/auth";
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { and, desc, eq, gte, lte, sql } from "drizzle-orm";
|
import { and, desc, eq, gte, lte, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { EmailMessage } from "../email-queue";
|
||||||
import { adminProcedure, protectedProcedure } from "../index";
|
import { adminProcedure, protectedProcedure } from "../index";
|
||||||
import { sendDeductionEmail } from "../lib/drinkkaart-email";
|
import { sendDeductionEmail } from "../lib/drinkkaart-email";
|
||||||
import {
|
import {
|
||||||
@@ -339,7 +340,7 @@ export const drinkkaartRouter = {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fire-and-forget deduction email
|
// Fire-and-forget deduction email (via queue when available)
|
||||||
const cardUser = await db
|
const cardUser = await db
|
||||||
.select({ email: user.email, name: user.name })
|
.select({ email: user.email, name: user.name })
|
||||||
.from(user)
|
.from(user)
|
||||||
@@ -348,14 +349,21 @@ export const drinkkaartRouter = {
|
|||||||
.then((r) => r[0]);
|
.then((r) => r[0]);
|
||||||
|
|
||||||
if (cardUser) {
|
if (cardUser) {
|
||||||
await sendDeductionEmail({
|
const deductionMsg: EmailMessage = {
|
||||||
|
type: "deduction",
|
||||||
to: cardUser.email,
|
to: cardUser.email,
|
||||||
firstName: cardUser.name.split(" ")[0] ?? cardUser.name,
|
firstName: cardUser.name.split(" ")[0] ?? cardUser.name,
|
||||||
amountCents: input.amountCents,
|
amountCents: input.amountCents,
|
||||||
newBalanceCents: balanceAfter,
|
newBalanceCents: balanceAfter,
|
||||||
}).catch((err) =>
|
};
|
||||||
console.error("Failed to send deduction email:", err),
|
|
||||||
);
|
if (context.emailQueue) {
|
||||||
|
await context.emailQueue.send(deductionMsg);
|
||||||
|
} else {
|
||||||
|
await sendDeductionEmail(deductionMsg).catch((err) =>
|
||||||
|
console.error("Failed to send deduction email:", err),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ import {
|
|||||||
sendSubscriptionConfirmationEmail,
|
sendSubscriptionConfirmationEmail,
|
||||||
sendUpdateEmail,
|
sendUpdateEmail,
|
||||||
} from "../email";
|
} from "../email";
|
||||||
|
import type { EmailMessage } from "../email-queue";
|
||||||
|
|
||||||
|
// Minimal CF Queue binding shape (mirrors context.ts)
|
||||||
|
type EmailQueue = {
|
||||||
|
send(
|
||||||
|
message: EmailMessage,
|
||||||
|
options?: { contentType?: string },
|
||||||
|
): Promise<void>;
|
||||||
|
sendBatch(
|
||||||
|
messages: Array<{ body: EmailMessage }>,
|
||||||
|
options?: { contentType?: string },
|
||||||
|
): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
|
import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
|
||||||
import { generateQrSecret } from "../lib/drinkkaart-utils";
|
import { generateQrSecret } from "../lib/drinkkaart-utils";
|
||||||
import { drinkkaartRouter } from "./drinkkaart";
|
import { drinkkaartRouter } from "./drinkkaart";
|
||||||
@@ -294,7 +308,7 @@ export const appRouter = {
|
|||||||
|
|
||||||
submitRegistration: publicProcedure
|
submitRegistration: publicProcedure
|
||||||
.input(submitRegistrationSchema)
|
.input(submitRegistrationSchema)
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
const managementToken = randomUUID();
|
const managementToken = randomUUID();
|
||||||
const isPerformer = input.registrationType === "performer";
|
const isPerformer = input.registrationType === "performer";
|
||||||
const guests = isPerformer ? [] : (input.guests ?? []);
|
const guests = isPerformer ? [] : (input.guests ?? []);
|
||||||
@@ -316,7 +330,8 @@ export const appRouter = {
|
|||||||
managementToken,
|
managementToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendConfirmationEmail({
|
const confirmationMsg: EmailMessage = {
|
||||||
|
type: "registrationConfirmation",
|
||||||
to: input.email,
|
to: input.email,
|
||||||
firstName: input.firstName,
|
firstName: input.firstName,
|
||||||
managementToken,
|
managementToken,
|
||||||
@@ -324,12 +339,18 @@ export const appRouter = {
|
|||||||
artForm: input.artForm,
|
artForm: input.artForm,
|
||||||
giftAmount: input.giftAmount,
|
giftAmount: input.giftAmount,
|
||||||
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
|
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
|
||||||
}).catch((err) =>
|
};
|
||||||
emailLog("error", "email.catch", {
|
|
||||||
type: "confirmation",
|
if (context.emailQueue) {
|
||||||
error: String(err),
|
await context.emailQueue.send(confirmationMsg);
|
||||||
}),
|
} else {
|
||||||
);
|
await sendConfirmationEmail(confirmationMsg).catch((err) =>
|
||||||
|
emailLog("error", "email.catch", {
|
||||||
|
type: "confirmation",
|
||||||
|
error: String(err),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, managementToken };
|
return { success: true, managementToken };
|
||||||
}),
|
}),
|
||||||
@@ -352,7 +373,7 @@ export const appRouter = {
|
|||||||
|
|
||||||
updateRegistration: publicProcedure
|
updateRegistration: publicProcedure
|
||||||
.input(updateRegistrationSchema)
|
.input(updateRegistrationSchema)
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
const row = await getActiveRegistration(input.token);
|
const row = await getActiveRegistration(input.token);
|
||||||
|
|
||||||
const isPerformer = input.registrationType === "performer";
|
const isPerformer = input.registrationType === "performer";
|
||||||
@@ -408,7 +429,8 @@ export const appRouter = {
|
|||||||
})
|
})
|
||||||
.where(eq(registration.managementToken, input.token));
|
.where(eq(registration.managementToken, input.token));
|
||||||
|
|
||||||
await sendUpdateEmail({
|
const updateMsg: EmailMessage = {
|
||||||
|
type: "updateConfirmation",
|
||||||
to: input.email,
|
to: input.email,
|
||||||
firstName: input.firstName,
|
firstName: input.firstName,
|
||||||
managementToken: input.token,
|
managementToken: input.token,
|
||||||
@@ -416,19 +438,25 @@ export const appRouter = {
|
|||||||
artForm: input.artForm,
|
artForm: input.artForm,
|
||||||
giftAmount: input.giftAmount,
|
giftAmount: input.giftAmount,
|
||||||
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
|
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
|
||||||
}).catch((err) =>
|
};
|
||||||
emailLog("error", "email.catch", {
|
|
||||||
type: "update",
|
if (context.emailQueue) {
|
||||||
error: String(err),
|
await context.emailQueue.send(updateMsg);
|
||||||
}),
|
} else {
|
||||||
);
|
await sendUpdateEmail(updateMsg).catch((err) =>
|
||||||
|
emailLog("error", "email.catch", {
|
||||||
|
type: "update",
|
||||||
|
error: String(err),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
cancelRegistration: publicProcedure
|
cancelRegistration: publicProcedure
|
||||||
.input(z.object({ token: z.string().uuid() }))
|
.input(z.object({ token: z.string().uuid() }))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
const row = await getActiveRegistration(input.token);
|
const row = await getActiveRegistration(input.token);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
@@ -436,15 +464,22 @@ export const appRouter = {
|
|||||||
.set({ cancelledAt: new Date() })
|
.set({ cancelledAt: new Date() })
|
||||||
.where(eq(registration.managementToken, input.token));
|
.where(eq(registration.managementToken, input.token));
|
||||||
|
|
||||||
await sendCancellationEmail({
|
const cancellationMsg: EmailMessage = {
|
||||||
|
type: "cancellation",
|
||||||
to: row.email,
|
to: row.email,
|
||||||
firstName: row.firstName,
|
firstName: row.firstName,
|
||||||
}).catch((err) =>
|
};
|
||||||
emailLog("error", "email.catch", {
|
|
||||||
type: "cancellation",
|
if (context.emailQueue) {
|
||||||
error: String(err),
|
await context.emailQueue.send(cancellationMsg);
|
||||||
}),
|
} else {
|
||||||
);
|
await sendCancellationEmail(cancellationMsg).catch((err) =>
|
||||||
|
emailLog("error", "email.catch", {
|
||||||
|
type: "cancellation",
|
||||||
|
error: String(err),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
@@ -854,7 +889,7 @@ export const appRouter = {
|
|||||||
*/
|
*/
|
||||||
subscribeReminder: publicProcedure
|
subscribeReminder: publicProcedure
|
||||||
.input(z.object({ email: z.string().email() }))
|
.input(z.object({ email: z.string().email() }))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Registration is already open — no point subscribing
|
// Registration is already open — no point subscribing
|
||||||
@@ -879,16 +914,25 @@ export const appRouter = {
|
|||||||
email: input.email,
|
email: input.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Awaited — CF Workers abandon unawaited promises when the response
|
const subMsg: EmailMessage = {
|
||||||
// returns. Mail errors are caught so they don't fail the request.
|
type: "subscriptionConfirmation",
|
||||||
await sendSubscriptionConfirmationEmail({ to: input.email }).catch(
|
to: input.email,
|
||||||
(err) =>
|
};
|
||||||
emailLog("error", "email.catch", {
|
|
||||||
type: "subscription_confirmation",
|
if (context.emailQueue) {
|
||||||
to: input.email,
|
await context.emailQueue.send(subMsg);
|
||||||
error: String(err),
|
} else {
|
||||||
}),
|
// Awaited — CF Workers abandon unawaited promises when the response
|
||||||
);
|
// returns. Mail errors are caught so they don't fail the request.
|
||||||
|
await sendSubscriptionConfirmationEmail({ to: input.email }).catch(
|
||||||
|
(err) =>
|
||||||
|
emailLog("error", "email.catch", {
|
||||||
|
type: "subscription_confirmation",
|
||||||
|
to: input.email,
|
||||||
|
error: String(err),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
@@ -1104,8 +1148,14 @@ export type AppRouterClient = RouterClient<typeof appRouter>;
|
|||||||
* Standalone function that sends pending reminder emails.
|
* Standalone function that sends pending reminder emails.
|
||||||
* Exported so that the Cloudflare Cron HTTP handler can call it directly
|
* Exported so that the Cloudflare Cron HTTP handler can call it directly
|
||||||
* without needing drizzle-orm as a direct dependency of apps/web.
|
* without needing drizzle-orm as a direct dependency of apps/web.
|
||||||
|
*
|
||||||
|
* When `emailQueue` is provided, reminder fan-outs mark the DB rows
|
||||||
|
* optimistically before calling `sendBatch()` — preventing re-enqueue on
|
||||||
|
* the next cron tick while still letting the queue handle email delivery
|
||||||
|
* and retries. Payment reminders always run directly since they need
|
||||||
|
* per-row DB updates tightly coupled to the send.
|
||||||
*/
|
*/
|
||||||
export async function runSendReminders(): Promise<{
|
export async function runSendReminders(emailQueue?: EmailQueue): Promise<{
|
||||||
sent: number;
|
sent: number;
|
||||||
skipped: boolean;
|
skipped: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
@@ -1137,21 +1187,41 @@ export async function runSendReminders(): Promise<{
|
|||||||
|
|
||||||
emailLog("info", "reminders.24h.pending", { count: pending.length });
|
emailLog("info", "reminders.24h.pending", { count: pending.length });
|
||||||
|
|
||||||
for (const row of pending) {
|
if (emailQueue && pending.length > 0) {
|
||||||
try {
|
// Mark rows optimistically before enqueuing — prevents re-enqueue on the
|
||||||
await sendReminder24hEmail({ to: row.email });
|
// next cron tick if the queue send succeeds but the consumer hasn't run yet.
|
||||||
|
for (const row of pending) {
|
||||||
await db
|
await db
|
||||||
.update(reminder)
|
.update(reminder)
|
||||||
.set({ sent24hAt: new Date() })
|
.set({ sent24hAt: new Date() })
|
||||||
.where(eq(reminder.id, row.id));
|
.where(eq(reminder.id, row.id));
|
||||||
sent++;
|
}
|
||||||
} catch (err) {
|
await emailQueue.sendBatch(
|
||||||
errors.push(`24h ${row.email}: ${String(err)}`);
|
pending.map((row) => ({
|
||||||
emailLog("error", "email.catch", {
|
body: {
|
||||||
type: "reminder_24h",
|
type: "reminder24h" as const,
|
||||||
to: row.email,
|
to: row.email,
|
||||||
error: String(err),
|
} satisfies EmailMessage,
|
||||||
});
|
})),
|
||||||
|
);
|
||||||
|
sent += pending.length;
|
||||||
|
} else {
|
||||||
|
for (const row of pending) {
|
||||||
|
try {
|
||||||
|
await sendReminder24hEmail({ to: row.email });
|
||||||
|
await db
|
||||||
|
.update(reminder)
|
||||||
|
.set({ sent24hAt: new Date() })
|
||||||
|
.where(eq(reminder.id, row.id));
|
||||||
|
sent++;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`24h ${row.email}: ${String(err)}`);
|
||||||
|
emailLog("error", "email.catch", {
|
||||||
|
type: "reminder_24h",
|
||||||
|
to: row.email,
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1164,26 +1234,47 @@ export async function runSendReminders(): Promise<{
|
|||||||
|
|
||||||
emailLog("info", "reminders.1h.pending", { count: pending.length });
|
emailLog("info", "reminders.1h.pending", { count: pending.length });
|
||||||
|
|
||||||
for (const row of pending) {
|
if (emailQueue && pending.length > 0) {
|
||||||
try {
|
// Mark rows optimistically before enqueuing — prevents re-enqueue on the
|
||||||
await sendReminderEmail({ to: row.email });
|
// next cron tick if the queue send succeeds but the consumer hasn't run yet.
|
||||||
|
for (const row of pending) {
|
||||||
await db
|
await db
|
||||||
.update(reminder)
|
.update(reminder)
|
||||||
.set({ sentAt: new Date() })
|
.set({ sentAt: new Date() })
|
||||||
.where(eq(reminder.id, row.id));
|
.where(eq(reminder.id, row.id));
|
||||||
sent++;
|
}
|
||||||
} catch (err) {
|
await emailQueue.sendBatch(
|
||||||
errors.push(`1h ${row.email}: ${String(err)}`);
|
pending.map((row) => ({
|
||||||
emailLog("error", "email.catch", {
|
body: {
|
||||||
type: "reminder_1h",
|
type: "reminder1h" as const,
|
||||||
to: row.email,
|
to: row.email,
|
||||||
error: String(err),
|
} satisfies EmailMessage,
|
||||||
});
|
})),
|
||||||
|
);
|
||||||
|
sent += pending.length;
|
||||||
|
} else {
|
||||||
|
for (const row of pending) {
|
||||||
|
try {
|
||||||
|
await sendReminderEmail({ to: row.email });
|
||||||
|
await db
|
||||||
|
.update(reminder)
|
||||||
|
.set({ sentAt: new Date() })
|
||||||
|
.where(eq(reminder.id, row.id));
|
||||||
|
sent++;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`1h ${row.email}: ${String(err)}`);
|
||||||
|
emailLog("error", "email.catch", {
|
||||||
|
type: "reminder_1h",
|
||||||
|
to: row.email,
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payment reminders: watchers with pending payment older than 3 days
|
// Payment reminders: watchers with pending payment older than 3 days.
|
||||||
|
// Always run directly (not via queue) — each row needs a DB update after send.
|
||||||
const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000;
|
const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000;
|
||||||
const unpaidWatchers = await db
|
const unpaidWatchers = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import alchemy from "alchemy";
|
import alchemy from "alchemy";
|
||||||
import { TanStackStart } from "alchemy/cloudflare";
|
import { Queue, TanStackStart } from "alchemy/cloudflare";
|
||||||
import { config } from "dotenv";
|
import { config } from "dotenv";
|
||||||
|
|
||||||
config({ path: "./.env" });
|
config({ path: "./.env" });
|
||||||
@@ -16,6 +16,10 @@ function getEnvVar(name: string): string {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emailQueue = await Queue("email-queue", {
|
||||||
|
name: "kk-email-queue",
|
||||||
|
});
|
||||||
|
|
||||||
export const web = await TanStackStart("web", {
|
export const web = await TanStackStart("web", {
|
||||||
cwd: "../../apps/web",
|
cwd: "../../apps/web",
|
||||||
bindings: {
|
bindings: {
|
||||||
@@ -34,7 +38,21 @@ export const web = await TanStackStart("web", {
|
|||||||
MOLLIE_API_KEY: getEnvVar("MOLLIE_API_KEY"),
|
MOLLIE_API_KEY: getEnvVar("MOLLIE_API_KEY"),
|
||||||
// Cron secret for protected scheduled endpoints
|
// Cron secret for protected scheduled endpoints
|
||||||
CRON_SECRET: getEnvVar("CRON_SECRET"),
|
CRON_SECRET: getEnvVar("CRON_SECRET"),
|
||||||
|
// Queue binding for async email sends
|
||||||
|
EMAIL_QUEUE: emailQueue,
|
||||||
},
|
},
|
||||||
|
// Queue consumer: the worker's queue() handler processes EmailMessage batches
|
||||||
|
eventSources: [
|
||||||
|
{
|
||||||
|
queue: emailQueue,
|
||||||
|
settings: {
|
||||||
|
batchSize: 10,
|
||||||
|
maxRetries: 3,
|
||||||
|
retryDelay: 60, // seconds before retrying a failed message
|
||||||
|
maxWaitTimeMs: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
// Fire every hour so reminder checks can run at 19:00 on 2026-03-15 (24h) and 18:00 on 2026-03-16 (1h)
|
// Fire every hour so reminder checks can run at 19:00 on 2026-03-15 (24h) and 18:00 on 2026-03-16 (1h)
|
||||||
crons: ["0 * * * *"],
|
crons: ["0 * * * *"],
|
||||||
domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
|
domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
|
||||||
|
|||||||
Reference in New Issue
Block a user