1403 lines
39 KiB
TypeScript
1403 lines
39 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { db } from "@kk/db";
|
|
import { adminRequest, registration, reminder } from "@kk/db/schema";
|
|
import { user } from "@kk/db/schema/auth";
|
|
import { drinkkaart, drinkkaartTopup } from "@kk/db/schema/drinkkaart";
|
|
import { env } from "@kk/env/server";
|
|
import type { RouterClient } from "@orpc/server";
|
|
import {
|
|
and,
|
|
count,
|
|
desc,
|
|
eq,
|
|
gte,
|
|
isNull,
|
|
like,
|
|
lte,
|
|
or,
|
|
sum,
|
|
} from "drizzle-orm";
|
|
import { z } from "zod";
|
|
import { REGISTRATION_OPENS_AT } from "../constants";
|
|
import {
|
|
emailLog,
|
|
sendCancellationEmail,
|
|
sendConfirmationEmail,
|
|
sendPaymentReminderEmail,
|
|
sendReminder24hEmail,
|
|
sendReminderEmail,
|
|
sendSubscriptionConfirmationEmail,
|
|
sendUpdateEmail,
|
|
} 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 { generateQrSecret } from "../lib/drinkkaart-utils";
|
|
import { drinkkaartRouter } from "./drinkkaart";
|
|
|
|
// Reminder windows derived from the canonical registration open date
|
|
const REMINDER_1H_WINDOW_START = new Date(
|
|
REGISTRATION_OPENS_AT.getTime() - 60 * 60 * 1000,
|
|
);
|
|
const REMINDER_24H_WINDOW_START = new Date(
|
|
REGISTRATION_OPENS_AT.getTime() - 25 * 60 * 60 * 1000,
|
|
);
|
|
const REMINDER_24H_WINDOW_END = new Date(
|
|
REGISTRATION_OPENS_AT.getTime() - 23 * 60 * 60 * 1000,
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MAX_WATCHERS = 70; // max total watcher spots (people, not registrations)
|
|
|
|
/** Drink card price in euros: €5 per person (primary + guests). */
|
|
function drinkCardEuros(guestCount: number): number {
|
|
return 5 + guestCount * 5;
|
|
}
|
|
|
|
/** Drink card price in cents for payment processing. */
|
|
function drinkCardCents(guestCount: number): number {
|
|
return drinkCardEuros(guestCount) * 100;
|
|
}
|
|
|
|
/** Parses the stored guest JSON blob, returning an empty array on failure. */
|
|
function parseGuestsJson(raw: string | null): Array<{
|
|
firstName: string;
|
|
lastName: string;
|
|
email?: string;
|
|
phone?: string;
|
|
}> {
|
|
if (!raw) return [];
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Credits a watcher's drinkCardValue to their drinkkaart account.
|
|
*
|
|
* Safe to call multiple times — the `drinkkaartCreditedAt IS NULL` guard and the
|
|
* `mollie_payment_id` UNIQUE constraint together prevent double-crediting even
|
|
* if called concurrently (webhook + signup racing each other).
|
|
*
|
|
* Returns a status string describing the outcome.
|
|
*/
|
|
export async function creditRegistrationToAccount(
|
|
email: string,
|
|
userId: string,
|
|
): Promise<{
|
|
credited: boolean;
|
|
amountCents: number;
|
|
status:
|
|
| "credited"
|
|
| "already_credited"
|
|
| "no_paid_registration"
|
|
| "zero_value";
|
|
}> {
|
|
// Find the most recent paid watcher registration for this email that hasn't
|
|
// been credited yet and has a non-zero drink card value.
|
|
const rows = await db
|
|
.select()
|
|
.from(registration)
|
|
.where(
|
|
and(
|
|
eq(registration.email, email),
|
|
eq(registration.registrationType, "watcher"),
|
|
eq(registration.paymentStatus, "paid"),
|
|
isNull(registration.cancelledAt),
|
|
isNull(registration.drinkkaartCreditedAt),
|
|
),
|
|
)
|
|
.orderBy(desc(registration.createdAt))
|
|
.limit(1);
|
|
|
|
const reg = rows[0];
|
|
|
|
if (!reg) {
|
|
// Either no paid registration exists, or it was already credited.
|
|
// Distinguish between the two for better logging.
|
|
const credited = await db
|
|
.select({ id: registration.id })
|
|
.from(registration)
|
|
.where(
|
|
and(
|
|
eq(registration.email, email),
|
|
eq(registration.registrationType, "watcher"),
|
|
eq(registration.paymentStatus, "paid"),
|
|
isNull(registration.cancelledAt),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((r) => r[0]);
|
|
|
|
return {
|
|
credited: false,
|
|
amountCents: 0,
|
|
status: credited ? "already_credited" : "no_paid_registration",
|
|
};
|
|
}
|
|
|
|
const drinkCardValueEuros = reg.drinkCardValue ?? 0;
|
|
if (drinkCardValueEuros === 0) {
|
|
return { credited: false, amountCents: 0, status: "zero_value" };
|
|
}
|
|
|
|
const amountCents = drinkCardValueEuros * 100;
|
|
|
|
// Get or create the drinkkaart for this user.
|
|
let card = await db
|
|
.select()
|
|
.from(drinkkaart)
|
|
.where(eq(drinkkaart.userId, userId))
|
|
.limit(1)
|
|
.then((r) => r[0]);
|
|
|
|
if (!card) {
|
|
const now = new Date();
|
|
card = {
|
|
id: randomUUID(),
|
|
userId,
|
|
balance: 0,
|
|
version: 0,
|
|
qrSecret: generateQrSecret(),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
await db.insert(drinkkaart).values(card);
|
|
}
|
|
|
|
const balanceBefore = card.balance;
|
|
const balanceAfter = balanceBefore + amountCents;
|
|
|
|
// Optimistic-lock update — if concurrent, the second caller will see
|
|
// drinkkaartCreditedAt IS NOT NULL and return "already_credited" above.
|
|
const updateResult = await db
|
|
.update(drinkkaart)
|
|
.set({
|
|
balance: balanceAfter,
|
|
version: card.version + 1,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(eq(drinkkaart.id, card.id), eq(drinkkaart.version, card.version)),
|
|
);
|
|
|
|
if (updateResult.rowsAffected === 0) {
|
|
// Lost the optimistic lock race — the other caller will handle it.
|
|
return { credited: false, amountCents: 0, status: "already_credited" };
|
|
}
|
|
|
|
// Record the topup. Re-use the registration's molliePaymentId so the
|
|
// topup row is clearly linked to the original payment. We prefix with
|
|
// "reg_" to distinguish from direct drinkkaart top-up orders if needed.
|
|
const topupPaymentId = reg.molliePaymentId
|
|
? `reg_${reg.molliePaymentId}`
|
|
: null;
|
|
|
|
await db.insert(drinkkaartTopup).values({
|
|
id: randomUUID(),
|
|
drinkkaartId: card.id,
|
|
userId,
|
|
amountCents,
|
|
balanceBefore,
|
|
balanceAfter,
|
|
type: "payment",
|
|
molliePaymentId: topupPaymentId,
|
|
adminId: null,
|
|
reason: "Drinkkaart bij registratie",
|
|
paidAt: reg.paidAt ?? new Date(),
|
|
});
|
|
|
|
// Mark the registration as credited — prevents any future double-credit.
|
|
await db
|
|
.update(registration)
|
|
.set({ drinkkaartCreditedAt: new Date() })
|
|
.where(eq(registration.id, reg.id));
|
|
|
|
return { credited: true, amountCents, status: "credited" };
|
|
}
|
|
|
|
/** Fetches a single registration by management token, throws if not found or cancelled. */
|
|
async function getActiveRegistration(token: string) {
|
|
const rows = await db
|
|
.select()
|
|
.from(registration)
|
|
.where(
|
|
and(
|
|
eq(registration.managementToken, token),
|
|
isNull(registration.cancelledAt),
|
|
),
|
|
)
|
|
.limit(1);
|
|
const row = rows[0];
|
|
if (!row) throw new Error("Inschrijving niet gevonden of al geannuleerd");
|
|
return row;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Schemas
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const registrationTypeSchema = z.enum(["performer", "watcher"]);
|
|
|
|
const guestSchema = z.object({
|
|
firstName: z.string().min(1),
|
|
lastName: z.string().min(1),
|
|
email: z.string().email().optional().or(z.literal("")),
|
|
phone: z.string().optional(),
|
|
birthdate: z.string().min(1),
|
|
postcode: z.string().min(1),
|
|
});
|
|
|
|
const coreRegistrationFields = {
|
|
firstName: z.string().min(1),
|
|
lastName: z.string().min(1),
|
|
email: z.string().email(),
|
|
phone: z.string().optional(),
|
|
birthdate: z.string().min(1),
|
|
postcode: z.string().min(1),
|
|
registrationType: registrationTypeSchema.default("watcher"),
|
|
artForm: z.string().optional(),
|
|
experience: z.string().optional(),
|
|
isOver16: z.boolean().optional(),
|
|
guests: z.array(guestSchema).max(9).optional(),
|
|
extraQuestions: z.string().optional(),
|
|
giftAmount: z.number().int().min(0).default(0),
|
|
};
|
|
|
|
const submitRegistrationSchema = z.object(coreRegistrationFields);
|
|
|
|
const updateRegistrationSchema = z.object({
|
|
token: z.string().uuid(),
|
|
...coreRegistrationFields,
|
|
});
|
|
|
|
const getRegistrationsSchema = z.object({
|
|
search: z.string().optional(),
|
|
registrationType: registrationTypeSchema.optional(),
|
|
artForm: z.string().optional(),
|
|
fromDate: z.string().datetime().optional(),
|
|
toDate: z.string().datetime().optional(),
|
|
page: z.number().int().min(1).default(1),
|
|
pageSize: z.number().int().min(1).max(100).default(50),
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Router
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const appRouter = {
|
|
healthCheck: publicProcedure.handler(() => "OK"),
|
|
drinkkaart: drinkkaartRouter,
|
|
|
|
getWatcherCapacity: publicProcedure.handler(async () => {
|
|
const rows = await db
|
|
.select({ guests: registration.guests })
|
|
.from(registration)
|
|
.where(
|
|
and(
|
|
eq(registration.registrationType, "watcher"),
|
|
isNull(registration.cancelledAt),
|
|
),
|
|
);
|
|
const takenSpots = rows.reduce((sum, r) => {
|
|
const g = r.guests ? (JSON.parse(r.guests) as unknown[]) : [];
|
|
return sum + 1 + g.length;
|
|
}, 0);
|
|
return {
|
|
total: MAX_WATCHERS,
|
|
taken: takenSpots,
|
|
available: Math.max(0, MAX_WATCHERS - takenSpots),
|
|
isFull: takenSpots >= MAX_WATCHERS,
|
|
};
|
|
}),
|
|
|
|
privateData: protectedProcedure.handler(({ context }) => ({
|
|
message: "This is private",
|
|
user: context.session?.user,
|
|
})),
|
|
|
|
submitRegistration: publicProcedure
|
|
.input(submitRegistrationSchema)
|
|
.handler(async ({ input, context }) => {
|
|
const managementToken = randomUUID();
|
|
const isPerformer = input.registrationType === "performer";
|
|
const guests = isPerformer ? [] : (input.guests ?? []);
|
|
|
|
// Enforce max watcher capacity (70 people total, counting guests)
|
|
if (!isPerformer) {
|
|
const rows = await db
|
|
.select({ guests: registration.guests })
|
|
.from(registration)
|
|
.where(
|
|
and(
|
|
eq(registration.registrationType, "watcher"),
|
|
isNull(registration.cancelledAt),
|
|
),
|
|
);
|
|
const takenSpots = rows.reduce((sum, r) => {
|
|
const g = r.guests ? (JSON.parse(r.guests) as unknown[]) : [];
|
|
return sum + 1 + g.length;
|
|
}, 0);
|
|
const newSpots = 1 + guests.length;
|
|
if (takenSpots + newSpots > MAX_WATCHERS) {
|
|
throw new Error(
|
|
`Er zijn helaas niet genoeg plaatsen meer beschikbaar. Nog ${MAX_WATCHERS - takenSpots} ${MAX_WATCHERS - takenSpots === 1 ? "plaats" : "plaatsen"} vrij.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
await db.insert(registration).values({
|
|
id: randomUUID(),
|
|
firstName: input.firstName,
|
|
lastName: input.lastName,
|
|
email: input.email,
|
|
phone: input.phone || null,
|
|
birthdate: input.birthdate,
|
|
postcode: input.postcode,
|
|
registrationType: input.registrationType,
|
|
artForm: isPerformer ? input.artForm || null : null,
|
|
experience: isPerformer ? input.experience || null : null,
|
|
isOver16: isPerformer ? (input.isOver16 ?? false) : false,
|
|
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
|
|
guests: guests.length > 0 ? JSON.stringify(guests) : null,
|
|
extraQuestions: input.extraQuestions || null,
|
|
giftAmount: input.giftAmount,
|
|
managementToken,
|
|
});
|
|
|
|
const confirmationMsg: EmailMessage = {
|
|
type: "registrationConfirmation",
|
|
to: input.email,
|
|
firstName: input.firstName,
|
|
managementToken,
|
|
wantsToPerform: isPerformer,
|
|
artForm: input.artForm,
|
|
giftAmount: input.giftAmount,
|
|
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
|
|
};
|
|
|
|
if (context.emailQueue) {
|
|
await context.emailQueue.send(confirmationMsg);
|
|
} else {
|
|
await sendConfirmationEmail(confirmationMsg).catch((err) =>
|
|
emailLog("error", "email.catch", {
|
|
type: "confirmation",
|
|
error: String(err),
|
|
}),
|
|
);
|
|
}
|
|
|
|
return { success: true, managementToken };
|
|
}),
|
|
|
|
getRegistrationByToken: publicProcedure
|
|
.input(z.object({ token: z.string().uuid() }))
|
|
.handler(async ({ input }) => {
|
|
const rows = await db
|
|
.select()
|
|
.from(registration)
|
|
.where(eq(registration.managementToken, input.token))
|
|
.limit(1);
|
|
|
|
const row = rows[0];
|
|
if (!row) throw new Error("Inschrijving niet gevonden");
|
|
if (row.cancelledAt) throw new Error("Deze inschrijving is geannuleerd");
|
|
|
|
return row;
|
|
}),
|
|
|
|
updateRegistration: publicProcedure
|
|
.input(updateRegistrationSchema)
|
|
.handler(async ({ input, context }) => {
|
|
const row = await getActiveRegistration(input.token);
|
|
|
|
const isPerformer = input.registrationType === "performer";
|
|
const guests = isPerformer ? [] : (input.guests ?? []);
|
|
|
|
// Validate gift amount cannot be changed after payment
|
|
const hasPaidGift =
|
|
(row.paymentStatus === "paid" ||
|
|
row.paymentStatus === "extra_payment_pending") &&
|
|
(row.giftAmount ?? 0) > 0;
|
|
if (hasPaidGift && input.giftAmount !== row.giftAmount) {
|
|
throw new Error(
|
|
"Gift kan niet worden aangepast na betaling. Neem contact op met de organisatie.",
|
|
);
|
|
}
|
|
|
|
// Detect whether an already-paid watcher is adding extra guests so we
|
|
// can charge them the delta instead of the full amount.
|
|
const isPaid = row.paymentStatus === "paid";
|
|
const oldGuestCount = isPerformer
|
|
? 0
|
|
: parseGuestsJson(row.guests).length;
|
|
const newGuestCount = guests.length;
|
|
const extraGuests =
|
|
!isPerformer && isPaid ? newGuestCount - oldGuestCount : 0;
|
|
|
|
// Determine the new paymentStatus and paymentAmount (delta in cents).
|
|
// Only flag extra_payment_pending when the watcher genuinely owes more.
|
|
const newPaymentStatus =
|
|
isPaid && extraGuests > 0 ? "extra_payment_pending" : row.paymentStatus;
|
|
const newPaymentAmount =
|
|
newPaymentStatus === "extra_payment_pending"
|
|
? extraGuests * 2 * 100 // €2 per extra guest, in cents
|
|
: row.paymentAmount;
|
|
|
|
await db
|
|
.update(registration)
|
|
.set({
|
|
firstName: input.firstName,
|
|
lastName: input.lastName,
|
|
email: input.email,
|
|
phone: input.phone || null,
|
|
birthdate: input.birthdate,
|
|
postcode: input.postcode,
|
|
registrationType: input.registrationType,
|
|
artForm: isPerformer ? input.artForm || null : null,
|
|
experience: isPerformer ? input.experience || null : null,
|
|
isOver16: isPerformer ? (input.isOver16 ?? false) : false,
|
|
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
|
|
guests: guests.length > 0 ? JSON.stringify(guests) : null,
|
|
extraQuestions: input.extraQuestions || null,
|
|
giftAmount: input.giftAmount,
|
|
paymentStatus: newPaymentStatus,
|
|
paymentAmount: newPaymentAmount,
|
|
})
|
|
.where(eq(registration.managementToken, input.token));
|
|
|
|
const updateMsg: EmailMessage = {
|
|
type: "updateConfirmation",
|
|
to: input.email,
|
|
firstName: input.firstName,
|
|
managementToken: input.token,
|
|
wantsToPerform: isPerformer,
|
|
artForm: input.artForm,
|
|
giftAmount: input.giftAmount,
|
|
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
|
|
};
|
|
|
|
if (context.emailQueue) {
|
|
await context.emailQueue.send(updateMsg);
|
|
} else {
|
|
await sendUpdateEmail(updateMsg).catch((err) =>
|
|
emailLog("error", "email.catch", {
|
|
type: "update",
|
|
error: String(err),
|
|
}),
|
|
);
|
|
}
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
cancelRegistration: publicProcedure
|
|
.input(z.object({ token: z.string().uuid() }))
|
|
.handler(async ({ input, context }) => {
|
|
const row = await getActiveRegistration(input.token);
|
|
|
|
await db
|
|
.update(registration)
|
|
.set({ cancelledAt: new Date() })
|
|
.where(eq(registration.managementToken, input.token));
|
|
|
|
const cancellationMsg: EmailMessage = {
|
|
type: "cancellation",
|
|
to: row.email,
|
|
firstName: row.firstName,
|
|
};
|
|
|
|
if (context.emailQueue) {
|
|
await context.emailQueue.send(cancellationMsg);
|
|
} else {
|
|
await sendCancellationEmail(cancellationMsg).catch((err) =>
|
|
emailLog("error", "email.catch", {
|
|
type: "cancellation",
|
|
error: String(err),
|
|
}),
|
|
);
|
|
}
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
getRegistrations: adminProcedure
|
|
.input(getRegistrationsSchema)
|
|
.handler(async ({ input }) => {
|
|
const conditions = [];
|
|
|
|
if (input.search) {
|
|
const term = `%${input.search}%`;
|
|
conditions.push(
|
|
or(
|
|
like(registration.firstName, term),
|
|
like(registration.lastName, term),
|
|
like(registration.email, term),
|
|
),
|
|
);
|
|
}
|
|
if (input.registrationType)
|
|
conditions.push(
|
|
eq(registration.registrationType, input.registrationType),
|
|
);
|
|
if (input.artForm)
|
|
conditions.push(eq(registration.artForm, input.artForm));
|
|
if (input.fromDate)
|
|
conditions.push(gte(registration.createdAt, new Date(input.fromDate)));
|
|
if (input.toDate)
|
|
conditions.push(lte(registration.createdAt, new Date(input.toDate)));
|
|
|
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
|
|
const [data, countResult] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(registration)
|
|
.where(where)
|
|
.orderBy(desc(registration.createdAt))
|
|
.limit(input.pageSize)
|
|
.offset((input.page - 1) * input.pageSize),
|
|
db.select({ count: count() }).from(registration).where(where),
|
|
]);
|
|
|
|
const total = countResult[0]?.count ?? 0;
|
|
|
|
return {
|
|
data,
|
|
pagination: {
|
|
page: input.page,
|
|
pageSize: input.pageSize,
|
|
total,
|
|
totalPages: Math.ceil(total / input.pageSize),
|
|
},
|
|
};
|
|
}),
|
|
|
|
getRegistrationStats: adminProcedure.handler(async () => {
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
const [
|
|
totalResult,
|
|
todayResult,
|
|
artFormResult,
|
|
typeResult,
|
|
giftResult,
|
|
watcherGuestRows,
|
|
] = await Promise.all([
|
|
db.select({ count: count() }).from(registration),
|
|
db
|
|
.select({ count: count() })
|
|
.from(registration)
|
|
.where(gte(registration.createdAt, today)),
|
|
db
|
|
.select({ artForm: registration.artForm, count: count() })
|
|
.from(registration)
|
|
.where(eq(registration.registrationType, "performer"))
|
|
.groupBy(registration.artForm),
|
|
db
|
|
.select({
|
|
registrationType: registration.registrationType,
|
|
count: count(),
|
|
})
|
|
.from(registration)
|
|
.groupBy(registration.registrationType),
|
|
db.select({ total: sum(registration.giftAmount) }).from(registration),
|
|
// Fetch guests column for all watcher registrations to count total guests
|
|
db
|
|
.select({ guests: registration.guests })
|
|
.from(registration)
|
|
.where(
|
|
and(
|
|
eq(registration.registrationType, "watcher"),
|
|
isNull(registration.cancelledAt),
|
|
),
|
|
),
|
|
]);
|
|
|
|
// Sum up all guest counts across all watcher registrations
|
|
const totalGuestCount = watcherGuestRows.reduce((sum, r) => {
|
|
const guests = parseGuestsJson(r.guests);
|
|
return sum + guests.length;
|
|
}, 0);
|
|
|
|
return {
|
|
total: totalResult[0]?.count ?? 0,
|
|
today: todayResult[0]?.count ?? 0,
|
|
byArtForm: artFormResult.map((r) => ({
|
|
artForm: r.artForm,
|
|
count: r.count,
|
|
})),
|
|
byType: typeResult.map((r) => ({
|
|
registrationType: r.registrationType,
|
|
count: r.count,
|
|
})),
|
|
totalGiftRevenue: giftResult[0]?.total ?? 0,
|
|
totalGuestCount,
|
|
};
|
|
}),
|
|
|
|
exportRegistrations: adminProcedure.handler(async () => {
|
|
const data = await db
|
|
.select()
|
|
.from(registration)
|
|
.orderBy(desc(registration.createdAt));
|
|
|
|
const escapeCell = (val: string) => `"${val.replace(/"/g, '""')}"`;
|
|
|
|
// Main registration headers
|
|
const headers = [
|
|
"ID",
|
|
"First Name",
|
|
"Last Name",
|
|
"Email",
|
|
"Phone",
|
|
"Birthdate",
|
|
"Postcode",
|
|
"Type",
|
|
"Art Form",
|
|
"Experience",
|
|
"Is Over 16",
|
|
"Drink Card Value (EUR)",
|
|
"Gift Amount (cents)",
|
|
"Guest Count",
|
|
"Guest 1 First Name",
|
|
"Guest 1 Last Name",
|
|
"Guest 1 Email",
|
|
"Guest 1 Phone",
|
|
"Guest 1 Birthdate",
|
|
"Guest 1 Postcode",
|
|
"Guest 2 First Name",
|
|
"Guest 2 Last Name",
|
|
"Guest 2 Email",
|
|
"Guest 2 Phone",
|
|
"Guest 2 Birthdate",
|
|
"Guest 2 Postcode",
|
|
"Guest 3 First Name",
|
|
"Guest 3 Last Name",
|
|
"Guest 3 Email",
|
|
"Guest 3 Phone",
|
|
"Guest 3 Birthdate",
|
|
"Guest 3 Postcode",
|
|
"Guest 4 First Name",
|
|
"Guest 4 Last Name",
|
|
"Guest 4 Email",
|
|
"Guest 4 Phone",
|
|
"Guest 4 Birthdate",
|
|
"Guest 4 Postcode",
|
|
"Guest 5 First Name",
|
|
"Guest 5 Last Name",
|
|
"Guest 5 Email",
|
|
"Guest 5 Phone",
|
|
"Guest 5 Birthdate",
|
|
"Guest 5 Postcode",
|
|
"Guest 6 First Name",
|
|
"Guest 6 Last Name",
|
|
"Guest 6 Email",
|
|
"Guest 6 Phone",
|
|
"Guest 6 Birthdate",
|
|
"Guest 6 Postcode",
|
|
"Guest 7 First Name",
|
|
"Guest 7 Last Name",
|
|
"Guest 7 Email",
|
|
"Guest 7 Phone",
|
|
"Guest 7 Birthdate",
|
|
"Guest 7 Postcode",
|
|
"Guest 8 First Name",
|
|
"Guest 8 Last Name",
|
|
"Guest 8 Email",
|
|
"Guest 8 Phone",
|
|
"Guest 8 Birthdate",
|
|
"Guest 8 Postcode",
|
|
"Guest 9 First Name",
|
|
"Guest 9 Last Name",
|
|
"Guest 9 Email",
|
|
"Guest 9 Phone",
|
|
"Guest 9 Birthdate",
|
|
"Guest 9 Postcode",
|
|
"Payment Status",
|
|
"Paid At",
|
|
"Extra Questions",
|
|
"Cancelled At",
|
|
"Created At",
|
|
];
|
|
|
|
const MAX_GUESTS = 9;
|
|
|
|
const rows = data.map((r) => {
|
|
const guests = parseGuestsJson(r.guests);
|
|
|
|
// Build guest columns (up to 9 guests, 6 fields each)
|
|
const guestCols: string[] = [];
|
|
for (let i = 0; i < MAX_GUESTS; i++) {
|
|
const g = guests[i];
|
|
guestCols.push(g?.firstName ?? "");
|
|
guestCols.push(g?.lastName ?? "");
|
|
guestCols.push(g?.email ?? "");
|
|
guestCols.push(g?.phone ?? "");
|
|
guestCols.push((g as { birthdate?: string })?.birthdate ?? "");
|
|
guestCols.push((g as { postcode?: string })?.postcode ?? "");
|
|
}
|
|
|
|
return [
|
|
r.id,
|
|
r.firstName,
|
|
r.lastName,
|
|
r.email,
|
|
r.phone || "",
|
|
r.birthdate || "",
|
|
r.postcode || "",
|
|
r.registrationType ?? "",
|
|
r.artForm || "",
|
|
r.experience || "",
|
|
r.isOver16 ? "Yes" : "No",
|
|
String(r.drinkCardValue ?? 0),
|
|
String(r.giftAmount ?? 0),
|
|
String(guests.length),
|
|
...guestCols,
|
|
r.paymentStatus === "paid"
|
|
? "Paid"
|
|
: r.paymentStatus === "extra_payment_pending"
|
|
? "Extra Payment Pending"
|
|
: "Pending",
|
|
r.paidAt ? r.paidAt.toISOString() : "",
|
|
r.extraQuestions || "",
|
|
r.cancelledAt ? r.cancelledAt.toISOString() : "",
|
|
r.createdAt.toISOString(),
|
|
];
|
|
});
|
|
|
|
const csvContent = [
|
|
headers.map(escapeCell).join(","),
|
|
...rows.map((row) =>
|
|
row.map((cell) => escapeCell(String(cell))).join(","),
|
|
),
|
|
].join("\n");
|
|
|
|
return {
|
|
csv: csvContent,
|
|
filename: `registrations-${new Date().toISOString().split("T")[0]}.csv`,
|
|
};
|
|
}),
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// User account
|
|
// ---------------------------------------------------------------------------
|
|
|
|
claimRegistrationCredit: protectedProcedure.handler(async ({ context }) => {
|
|
const result = await creditRegistrationToAccount(
|
|
context.session.user.email,
|
|
context.session.user.id,
|
|
);
|
|
return result;
|
|
}),
|
|
|
|
getMyRegistration: protectedProcedure.handler(async ({ context }) => {
|
|
const email = context.session.user.email;
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(registration)
|
|
.where(
|
|
and(eq(registration.email, email), isNull(registration.cancelledAt)),
|
|
)
|
|
.orderBy(desc(registration.createdAt))
|
|
.limit(1);
|
|
|
|
const row = rows[0];
|
|
if (!row) return null;
|
|
|
|
return {
|
|
...row,
|
|
guests: parseGuestsJson(row.guests),
|
|
};
|
|
}),
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Admin access requests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
requestAdminAccess: protectedProcedure.handler(async ({ context }) => {
|
|
const userId = context.session.user.id;
|
|
|
|
if (context.session.user.role === "admin") {
|
|
return { success: false, message: "Je bent al een admin" };
|
|
}
|
|
|
|
const existing = await db
|
|
.select()
|
|
.from(adminRequest)
|
|
.where(eq(adminRequest.userId, userId))
|
|
.limit(1)
|
|
.then((rows) => rows[0]);
|
|
|
|
if (existing) {
|
|
if (existing.status === "pending")
|
|
return {
|
|
success: false,
|
|
message: "Je hebt al een aanvraag openstaan",
|
|
};
|
|
if (existing.status === "approved")
|
|
return {
|
|
success: false,
|
|
message: "Je aanvraag is al goedgekeurd, log opnieuw in",
|
|
};
|
|
// Rejected — allow re-request
|
|
await db
|
|
.update(adminRequest)
|
|
.set({
|
|
status: "pending",
|
|
requestedAt: new Date(),
|
|
reviewedAt: null,
|
|
reviewedBy: null,
|
|
})
|
|
.where(eq(adminRequest.userId, userId));
|
|
return { success: true, message: "Nieuwe aanvraag ingediend" };
|
|
}
|
|
|
|
await db.insert(adminRequest).values({
|
|
id: randomUUID(),
|
|
userId,
|
|
status: "pending",
|
|
});
|
|
|
|
return { success: true, message: "Admin toegang aangevraagd" };
|
|
}),
|
|
|
|
getAdminRequests: adminProcedure.handler(async () => {
|
|
return db
|
|
.select({
|
|
id: adminRequest.id,
|
|
userId: adminRequest.userId,
|
|
status: adminRequest.status,
|
|
requestedAt: adminRequest.requestedAt,
|
|
reviewedAt: adminRequest.reviewedAt,
|
|
reviewedBy: adminRequest.reviewedBy,
|
|
userName: user.name,
|
|
userEmail: user.email,
|
|
})
|
|
.from(adminRequest)
|
|
.leftJoin(user, eq(adminRequest.userId, user.id))
|
|
.orderBy(desc(adminRequest.requestedAt));
|
|
}),
|
|
|
|
approveAdminRequest: adminProcedure
|
|
.input(z.object({ requestId: z.string() }))
|
|
.handler(async ({ input, context }) => {
|
|
const req = await db
|
|
.select()
|
|
.from(adminRequest)
|
|
.where(eq(adminRequest.id, input.requestId))
|
|
.limit(1)
|
|
.then((rows) => rows[0]);
|
|
|
|
if (!req) throw new Error("Aanvraag niet gevonden");
|
|
if (req.status !== "pending")
|
|
throw new Error("Deze aanvraag is al behandeld");
|
|
|
|
await db
|
|
.update(adminRequest)
|
|
.set({
|
|
status: "approved",
|
|
reviewedAt: new Date(),
|
|
reviewedBy: context.session.user.id,
|
|
})
|
|
.where(eq(adminRequest.id, input.requestId));
|
|
|
|
await db
|
|
.update(user)
|
|
.set({ role: "admin" })
|
|
.where(eq(user.id, req.userId));
|
|
|
|
return { success: true, message: "Admin toegang goedgekeurd" };
|
|
}),
|
|
|
|
rejectAdminRequest: adminProcedure
|
|
.input(z.object({ requestId: z.string() }))
|
|
.handler(async ({ input, context }) => {
|
|
const req = await db
|
|
.select()
|
|
.from(adminRequest)
|
|
.where(eq(adminRequest.id, input.requestId))
|
|
.limit(1)
|
|
.then((rows) => rows[0]);
|
|
|
|
if (!req) throw new Error("Aanvraag niet gevonden");
|
|
if (req.status !== "pending")
|
|
throw new Error("Deze aanvraag is al behandeld");
|
|
|
|
await db
|
|
.update(adminRequest)
|
|
.set({
|
|
status: "rejected",
|
|
reviewedAt: new Date(),
|
|
reviewedBy: context.session.user.id,
|
|
})
|
|
.where(eq(adminRequest.id, input.requestId));
|
|
|
|
return { success: true, message: "Admin toegang geweigerd" };
|
|
}),
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reminder opt-in
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Subscribe an email address to receive reminders before registration opens:
|
|
* one 24 hours before and one 1 hour before. Silently deduplicates — if the
|
|
* email is already subscribed, returns ok:true without error. Returns
|
|
* ok:false if registration is already open.
|
|
*/
|
|
subscribeReminder: publicProcedure
|
|
.input(z.object({ email: z.string().email() }))
|
|
.handler(async ({ input, context }) => {
|
|
const now = Date.now();
|
|
|
|
// Registration is already open — no point subscribing
|
|
if (now >= REGISTRATION_OPENS_AT.getTime()) {
|
|
return { ok: false, reason: "already_open" as const };
|
|
}
|
|
|
|
// Check for an existing subscription (silent dedup)
|
|
const existing = await db
|
|
.select({ id: reminder.id })
|
|
.from(reminder)
|
|
.where(eq(reminder.email, input.email))
|
|
.limit(1)
|
|
.then((rows) => rows[0]);
|
|
|
|
if (existing) {
|
|
return { ok: true };
|
|
}
|
|
|
|
await db.insert(reminder).values({
|
|
id: randomUUID(),
|
|
email: input.email,
|
|
});
|
|
|
|
const subMsg: EmailMessage = {
|
|
type: "subscriptionConfirmation",
|
|
to: input.email,
|
|
};
|
|
|
|
if (context.emailQueue) {
|
|
await context.emailQueue.send(subMsg);
|
|
} 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 };
|
|
}),
|
|
|
|
/**
|
|
* Send pending reminder emails to all opted-in addresses.
|
|
* Protected by a shared secret (`CRON_SECRET`) passed as an Authorization
|
|
* header. Should be called by a Cloudflare Cron Trigger at the top of
|
|
* every hour, or directly via `POST /api/cron/reminders`.
|
|
*
|
|
* Fires two waves:
|
|
* - 24 hours before: window [REGISTRATION_OPENS_AT - 25h, REGISTRATION_OPENS_AT - 23h)
|
|
* - 1 hour before: window [REGISTRATION_OPENS_AT - 1h, REGISTRATION_OPENS_AT)
|
|
*/
|
|
sendReminders: publicProcedure
|
|
.input(z.object({ secret: z.string() }))
|
|
.handler(async ({ input }) => {
|
|
// Validate the shared secret
|
|
if (!env.CRON_SECRET || input.secret !== env.CRON_SECRET) {
|
|
throw new Error("Unauthorized");
|
|
}
|
|
|
|
const now = Date.now();
|
|
const in24hWindow =
|
|
now >= REMINDER_24H_WINDOW_START.getTime() &&
|
|
now < REMINDER_24H_WINDOW_END.getTime();
|
|
const in1hWindow =
|
|
now >= REMINDER_1H_WINDOW_START.getTime() &&
|
|
now < REGISTRATION_OPENS_AT.getTime();
|
|
|
|
const activeWindow = in24hWindow ? "24h" : in1hWindow ? "1h" : null;
|
|
emailLog("info", "reminders.cron.tick", { activeWindow });
|
|
|
|
if (!in24hWindow && !in1hWindow) {
|
|
return { sent: 0, skipped: true, reason: "outside_window" as const };
|
|
}
|
|
|
|
let sent = 0;
|
|
const errors: string[] = [];
|
|
|
|
if (in24hWindow) {
|
|
// Fetch reminders where the 24h email has not been sent yet
|
|
const pending = await db
|
|
.select()
|
|
.from(reminder)
|
|
.where(isNull(reminder.sent24hAt));
|
|
|
|
emailLog("info", "reminders.24h.pending", { count: pending.length });
|
|
|
|
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),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (in1hWindow) {
|
|
// Fetch reminders where the 1h email has not been sent yet
|
|
const pending = await db
|
|
.select()
|
|
.from(reminder)
|
|
.where(isNull(reminder.sentAt));
|
|
|
|
emailLog("info", "reminders.1h.pending", { count: pending.length });
|
|
|
|
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),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
emailLog("info", "reminders.cron.done", {
|
|
sent,
|
|
errorCount: errors.length,
|
|
});
|
|
|
|
return { sent, skipped: false, errors };
|
|
}),
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Checkout
|
|
// ---------------------------------------------------------------------------
|
|
|
|
getCheckoutUrl: publicProcedure
|
|
.input(
|
|
z.object({
|
|
token: z.string().uuid(),
|
|
redirectUrl: z.string().optional(),
|
|
}),
|
|
)
|
|
.handler(async ({ input }) => {
|
|
if (!env.MOLLIE_API_KEY) {
|
|
throw new Error("Mollie is niet geconfigureerd");
|
|
}
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(registration)
|
|
.where(
|
|
and(
|
|
eq(registration.managementToken, input.token),
|
|
isNull(registration.cancelledAt),
|
|
),
|
|
)
|
|
.limit(1);
|
|
|
|
const row = rows[0];
|
|
if (!row) throw new Error("Inschrijving niet gevonden");
|
|
if (row.paymentStatus === "paid")
|
|
throw new Error("Betaling is al voltooid");
|
|
if (
|
|
row.paymentStatus !== "pending" &&
|
|
row.paymentStatus !== "extra_payment_pending"
|
|
)
|
|
throw new Error("Onverwachte betalingsstatus");
|
|
|
|
const isPerformer = row.registrationType === "performer";
|
|
const guests = parseGuestsJson(row.guests);
|
|
const giftTotal = row.giftAmount ?? 0;
|
|
|
|
// Performers only pay if they have a gift; watchers pay drink card + gift
|
|
if (isPerformer && giftTotal === 0) {
|
|
throw new Error("Artiesten hoeven niet te betalen");
|
|
}
|
|
|
|
// For an extra_payment_pending registration, charge only the delta
|
|
// (stored in paymentAmount in cents). For a fresh pending registration
|
|
// charge the full drink card price. Always add any gift amount.
|
|
const isExtraPayment = row.paymentStatus === "extra_payment_pending";
|
|
const drinkCardTotal = isExtraPayment
|
|
? (row.paymentAmount ?? 0)
|
|
: isPerformer
|
|
? 0
|
|
: drinkCardCents(guests.length);
|
|
const amountInCents = drinkCardTotal + giftTotal;
|
|
|
|
const productDescription = isExtraPayment
|
|
? `Extra bijdrage${giftTotal > 0 ? " + gift" : ""}`
|
|
: isPerformer
|
|
? `Vrijwillige gift${drinkCardTotal > 0 ? " + drinkkaart" : ""}`
|
|
: `Toegangskaart${giftTotal > 0 ? " + gift" : ""}`;
|
|
|
|
const redirectUrl =
|
|
input.redirectUrl ?? `${env.CORS_ORIGIN}/manage/${input.token}`;
|
|
|
|
// Mollie amounts must be formatted as "10.00" (string, 2 decimal places)
|
|
const amountValue = (amountInCents / 100).toFixed(2);
|
|
|
|
const webhookUrl = `${env.CORS_ORIGIN}/api/webhook/mollie`;
|
|
|
|
const response = await fetch("https://api.mollie.com/v2/payments", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${env.MOLLIE_API_KEY}`,
|
|
},
|
|
body: JSON.stringify({
|
|
amount: { value: amountValue, currency: "EUR" },
|
|
description: `Kunstenkamp Evenement — ${productDescription}`,
|
|
redirectUrl,
|
|
webhookUrl,
|
|
locale: "nl_NL",
|
|
metadata: { registration_token: input.token },
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
console.error("Mollie checkout error:", errorData);
|
|
throw new Error("Kon checkout niet aanmaken");
|
|
}
|
|
|
|
const checkoutData = (await response.json()) as {
|
|
_links?: { checkout?: { href?: string } };
|
|
};
|
|
const checkoutUrl = checkoutData._links?.checkout?.href;
|
|
|
|
if (!checkoutUrl) throw new Error("Geen checkout URL ontvangen");
|
|
|
|
return { checkoutUrl };
|
|
}),
|
|
};
|
|
|
|
export type AppRouter = typeof appRouter;
|
|
export type AppRouterClient = RouterClient<typeof appRouter>;
|
|
|
|
/**
|
|
* Standalone function that sends pending reminder emails.
|
|
* Exported so that the Cloudflare Cron HTTP handler can call it directly
|
|
* 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(emailQueue?: EmailQueue): Promise<{
|
|
sent: number;
|
|
skipped: boolean;
|
|
reason?: string;
|
|
errors: string[];
|
|
}> {
|
|
const now = Date.now();
|
|
const in24hWindow =
|
|
now >= REMINDER_24H_WINDOW_START.getTime() &&
|
|
now < REMINDER_24H_WINDOW_END.getTime();
|
|
const in1hWindow =
|
|
now >= REMINDER_1H_WINDOW_START.getTime() &&
|
|
now < REGISTRATION_OPENS_AT.getTime();
|
|
|
|
const activeWindow = in24hWindow ? "24h" : in1hWindow ? "1h" : null;
|
|
emailLog("info", "reminders.cron.tick", { activeWindow });
|
|
|
|
if (!in24hWindow && !in1hWindow) {
|
|
return { sent: 0, skipped: true, reason: "outside_window", errors: [] };
|
|
}
|
|
|
|
let sent = 0;
|
|
const errors: string[] = [];
|
|
|
|
if (in24hWindow) {
|
|
const pending = await db
|
|
.select()
|
|
.from(reminder)
|
|
.where(isNull(reminder.sent24hAt));
|
|
|
|
emailLog("info", "reminders.24h.pending", { count: pending.length });
|
|
|
|
if (emailQueue && pending.length > 0) {
|
|
// Mark rows optimistically before enqueuing — prevents re-enqueue on the
|
|
// next cron tick if the queue send succeeds but the consumer hasn't run yet.
|
|
for (const row of pending) {
|
|
await db
|
|
.update(reminder)
|
|
.set({ sent24hAt: new Date() })
|
|
.where(eq(reminder.id, row.id));
|
|
}
|
|
await emailQueue.sendBatch(
|
|
pending.map((row) => ({
|
|
body: {
|
|
type: "reminder24h" as const,
|
|
to: row.email,
|
|
} 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),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (in1hWindow) {
|
|
const pending = await db
|
|
.select()
|
|
.from(reminder)
|
|
.where(isNull(reminder.sentAt));
|
|
|
|
emailLog("info", "reminders.1h.pending", { count: pending.length });
|
|
|
|
if (emailQueue && pending.length > 0) {
|
|
// Mark rows optimistically before enqueuing — prevents re-enqueue on the
|
|
// next cron tick if the queue send succeeds but the consumer hasn't run yet.
|
|
for (const row of pending) {
|
|
await db
|
|
.update(reminder)
|
|
.set({ sentAt: new Date() })
|
|
.where(eq(reminder.id, row.id));
|
|
}
|
|
await emailQueue.sendBatch(
|
|
pending.map((row) => ({
|
|
body: {
|
|
type: "reminder1h" as const,
|
|
to: row.email,
|
|
} 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.
|
|
// Always run directly (not via queue) — each row needs a DB update after send.
|
|
const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000;
|
|
const unpaidWatchers = await db
|
|
.select()
|
|
.from(registration)
|
|
.where(
|
|
and(
|
|
eq(registration.registrationType, "watcher"),
|
|
eq(registration.paymentStatus, "pending"),
|
|
isNull(registration.paymentReminderSentAt),
|
|
lte(registration.createdAt, new Date(threeDaysAgo)),
|
|
),
|
|
);
|
|
|
|
emailLog("info", "reminders.payment.pending", {
|
|
count: unpaidWatchers.length,
|
|
});
|
|
|
|
for (const reg of unpaidWatchers) {
|
|
if (!reg.managementToken) continue;
|
|
try {
|
|
await sendPaymentReminderEmail({
|
|
to: reg.email,
|
|
firstName: reg.firstName,
|
|
managementToken: reg.managementToken,
|
|
drinkCardValue: reg.drinkCardValue ?? undefined,
|
|
giftAmount: reg.giftAmount ?? undefined,
|
|
});
|
|
await db
|
|
.update(registration)
|
|
.set({ paymentReminderSentAt: new Date() })
|
|
.where(eq(registration.id, reg.id));
|
|
sent++;
|
|
} catch (err) {
|
|
errors.push(`payment-reminder ${reg.email}: ${String(err)}`);
|
|
emailLog("error", "email.catch", {
|
|
type: "payment_reminder",
|
|
to: reg.email,
|
|
error: String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
emailLog("info", "reminders.cron.done", { sent, errorCount: errors.length });
|
|
|
|
return { sent, skipped: false, errors };
|
|
}
|