Files
kunstenkamp/packages/api/src/routers/drinkkaart.ts
zias e5d2b13b21 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.
2026-03-11 17:13:35 +01:00

718 lines
19 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { db } from "@kk/db";
import {
drinkkaart,
drinkkaartTopup,
drinkkaartTransaction,
} from "@kk/db/schema";
import { user } from "@kk/db/schema/auth";
import { ORPCError } from "@orpc/server";
import { and, desc, eq, gte, lte, sql } from "drizzle-orm";
import { z } from "zod";
import type { EmailMessage } from "../email-queue";
import { adminProcedure, protectedProcedure } from "../index";
import { sendDeductionEmail } from "../lib/drinkkaart-email";
import {
formatCents,
generateQrSecret,
signQrToken,
verifyQrToken,
} from "../lib/drinkkaart-utils";
// ---------------------------------------------------------------------------
// Internal helper: get or create the drinkkaart row for a user
// ---------------------------------------------------------------------------
async function getOrCreateDrinkkaart(userId: string) {
const rows = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.userId, userId))
.limit(1);
if (rows[0]) return rows[0];
const now = new Date();
const newCard = {
id: randomUUID(),
userId,
balance: 0,
version: 0,
qrSecret: generateQrSecret(),
createdAt: now,
updatedAt: now,
};
await db.insert(drinkkaart).values(newCard);
return newCard;
}
// ---------------------------------------------------------------------------
// Router procedures
// ---------------------------------------------------------------------------
export const drinkkaartRouter = {
// -------------------------------------------------------------------------
// 4.1 getMyDrinkkaart
// -------------------------------------------------------------------------
getMyDrinkkaart: protectedProcedure.handler(async ({ context }) => {
const userId = context.session.user.id;
const card = await getOrCreateDrinkkaart(userId);
const [topups, transactions] = await Promise.all([
db
.select()
.from(drinkkaartTopup)
.where(eq(drinkkaartTopup.drinkkaartId, card.id))
.orderBy(desc(drinkkaartTopup.paidAt))
.limit(50),
db
.select()
.from(drinkkaartTransaction)
.where(eq(drinkkaartTransaction.drinkkaartId, card.id))
.orderBy(desc(drinkkaartTransaction.createdAt))
.limit(50),
]);
return {
id: card.id,
balance: card.balance,
balanceFormatted: formatCents(card.balance),
createdAt: card.createdAt,
updatedAt: card.updatedAt,
topups: topups.map((t) => ({
id: t.id,
type: t.type,
amountCents: t.amountCents,
balanceBefore: t.balanceBefore,
balanceAfter: t.balanceAfter,
reason: t.reason,
paidAt: t.paidAt,
})),
transactions: transactions.map((t) => ({
id: t.id,
type: t.type,
amountCents: t.amountCents,
balanceBefore: t.balanceBefore,
balanceAfter: t.balanceAfter,
note: t.note,
reversedBy: t.reversedBy,
reverses: t.reverses,
createdAt: t.createdAt,
})),
};
}),
// -------------------------------------------------------------------------
// 4.2 getMyQrToken
// -------------------------------------------------------------------------
getMyQrToken: protectedProcedure.handler(async ({ context }) => {
const userId = context.session.user.id;
const card = await getOrCreateDrinkkaart(userId);
const authSecret = context.env.BETTER_AUTH_SECRET;
const { token, expiresAt } = await signQrToken(
{ drinkkaartId: card.id, userId },
card.qrSecret,
authSecret,
);
return { token, expiresAt };
}),
// -------------------------------------------------------------------------
// 4.3 getTopUpCheckoutUrl
// -------------------------------------------------------------------------
getTopUpCheckoutUrl: protectedProcedure
.input(
z.object({
amountCents: z
.number()
.int()
.min(100, "Minimumbedrag is € 1,00")
.max(50000, "Maximumbedrag is € 500,00"),
}),
)
.handler(async ({ input, context }) => {
const { env: serverEnv, session } = context;
if (!serverEnv.MOLLIE_API_KEY) {
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Mollie is niet geconfigureerd",
});
}
const card = await getOrCreateDrinkkaart(session.user.id);
// Mollie amounts must be formatted as "10.00" (string, 2 decimal places)
const amountValue = (input.amountCents / 100).toFixed(2);
const response = await fetch("https://api.mollie.com/v2/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${serverEnv.MOLLIE_API_KEY}`,
},
body: JSON.stringify({
amount: { value: amountValue, currency: "EUR" },
description: `Drinkkaart Opladen — ${formatCents(input.amountCents)}`,
redirectUrl: `${serverEnv.BETTER_AUTH_URL}/account?topup=success`,
webhookUrl: `${serverEnv.BETTER_AUTH_URL}/api/webhook/mollie`,
locale: "nl_NL",
metadata: {
type: "drinkkaart_topup",
drinkkaartId: card.id,
userId: session.user.id,
},
}),
});
if (!response.ok) {
const errorData = await response.json();
console.error("Mollie Drinkkaart checkout error:", errorData);
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Kon checkout niet aanmaken",
});
}
const data = (await response.json()) as {
_links?: { checkout?: { href?: string } };
};
const checkoutUrl = data._links?.checkout?.href;
if (!checkoutUrl) {
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Geen checkout URL ontvangen",
});
}
return { checkoutUrl };
}),
// -------------------------------------------------------------------------
// 4.4 resolveQrToken (admin)
// -------------------------------------------------------------------------
resolveQrToken: adminProcedure
.input(z.object({ token: z.string().min(1) }))
.handler(async ({ input, context }) => {
let drinkkaartId: string;
let userId: string;
// Decode payload first to get the drinkkaartId for key lookup
const parts = input.token.split(".");
if (parts.length !== 2) {
throw new ORPCError("BAD_REQUEST", {
message: "Ongeldig QR-token formaat",
});
}
let rawPayload: { drinkkaartId: string; userId: string; exp: number };
try {
rawPayload = JSON.parse(
atob((parts[0] as string).replace(/-/g, "+").replace(/_/g, "/")),
);
drinkkaartId = rawPayload.drinkkaartId;
userId = rawPayload.userId;
} catch {
throw new ORPCError("BAD_REQUEST", { message: "Ongeldig QR-token" });
}
// Fetch the card to get the per-user secret
const card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.id, drinkkaartId))
.limit(1)
.then((r) => r[0]);
if (!card) {
throw new ORPCError("NOT_FOUND", {
message: "Drinkkaart niet gevonden",
});
}
// Verify HMAC and expiry
try {
await verifyQrToken(
input.token,
card.qrSecret,
context.env.BETTER_AUTH_SECRET,
);
} catch (err: unknown) {
const msg = (err as Error).message;
if (msg === "EXPIRED") {
throw new ORPCError("GONE", { message: "QR-token is verlopen" });
}
throw new ORPCError("UNAUTHORIZED", { message: "Ongeldig QR-token" });
}
// Fetch user
const cardUser = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.id, userId))
.limit(1)
.then((r) => r[0]);
if (!cardUser) {
throw new ORPCError("NOT_FOUND", {
message: "Gebruiker niet gevonden",
});
}
return {
drinkkaartId: card.id,
userId: cardUser.id,
userName: cardUser.name,
userEmail: cardUser.email,
balance: card.balance,
balanceFormatted: formatCents(card.balance),
};
}),
// -------------------------------------------------------------------------
// 4.5 deductBalance (admin)
// -------------------------------------------------------------------------
deductBalance: adminProcedure
.input(
z.object({
drinkkaartId: z.string().min(1),
amountCents: z.number().int().min(1),
note: z.string().max(200).optional(),
}),
)
.handler(async ({ input, context }) => {
const adminId = context.session.user.id;
const card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.id, input.drinkkaartId))
.limit(1)
.then((r) => r[0]);
if (!card) {
throw new ORPCError("NOT_FOUND", {
message: "Drinkkaart niet gevonden",
});
}
if (card.balance < input.amountCents) {
throw new ORPCError("UNPROCESSABLE_CONTENT", {
message: `Onvoldoende saldo. Huidig saldo: ${formatCents(card.balance)}`,
});
}
const balanceBefore = card.balance;
const balanceAfter = balanceBefore - input.amountCents;
const now = new Date();
// Optimistic lock update
const result = await db
.update(drinkkaart)
.set({
balance: balanceAfter,
version: card.version + 1,
updatedAt: now,
})
.where(
and(
eq(drinkkaart.id, input.drinkkaartId),
eq(drinkkaart.version, card.version),
),
);
if (result.rowsAffected === 0) {
throw new ORPCError("CONFLICT", {
message: "Gelijktijdige wijziging gedetecteerd. Probeer opnieuw.",
});
}
const transactionId = randomUUID();
await db.insert(drinkkaartTransaction).values({
id: transactionId,
drinkkaartId: card.id,
userId: card.userId,
adminId,
amountCents: input.amountCents,
balanceBefore,
balanceAfter,
type: "deduction",
note: input.note ?? null,
createdAt: now,
});
// Fire-and-forget deduction email (via queue when available)
const cardUser = await db
.select({ email: user.email, name: user.name })
.from(user)
.where(eq(user.id, card.userId))
.limit(1)
.then((r) => r[0]);
if (cardUser) {
const deductionMsg: EmailMessage = {
type: "deduction",
to: cardUser.email,
firstName: cardUser.name.split(" ")[0] ?? cardUser.name,
amountCents: input.amountCents,
newBalanceCents: balanceAfter,
};
if (context.emailQueue) {
await context.emailQueue.send(deductionMsg);
} else {
await sendDeductionEmail(deductionMsg).catch((err) =>
console.error("Failed to send deduction email:", err),
);
}
}
return {
transactionId,
balanceBefore,
balanceAfter,
balanceFormatted: formatCents(balanceAfter),
};
}),
// -------------------------------------------------------------------------
// 4.6 adminCreditBalance (admin)
// -------------------------------------------------------------------------
adminCreditBalance: adminProcedure
.input(
z.object({
drinkkaartId: z.string().min(1),
amountCents: z.number().int().min(1),
reason: z.string().min(1).max(200),
}),
)
.handler(async ({ input, context }) => {
const adminId = context.session.user.id;
const card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.id, input.drinkkaartId))
.limit(1)
.then((r) => r[0]);
if (!card) {
throw new ORPCError("NOT_FOUND", {
message: "Drinkkaart niet gevonden",
});
}
const balanceBefore = card.balance;
const balanceAfter = balanceBefore + input.amountCents;
const now = new Date();
const result = await db
.update(drinkkaart)
.set({
balance: balanceAfter,
version: card.version + 1,
updatedAt: now,
})
.where(
and(
eq(drinkkaart.id, input.drinkkaartId),
eq(drinkkaart.version, card.version),
),
);
if (result.rowsAffected === 0) {
throw new ORPCError("CONFLICT", {
message: "Gelijktijdige wijziging gedetecteerd. Probeer opnieuw.",
});
}
const topupId = randomUUID();
await db.insert(drinkkaartTopup).values({
id: topupId,
drinkkaartId: card.id,
userId: card.userId,
amountCents: input.amountCents,
balanceBefore,
balanceAfter,
type: "admin_credit",
adminId,
reason: input.reason,
paidAt: now,
});
return {
topupId,
balanceBefore,
balanceAfter,
balanceFormatted: formatCents(balanceAfter),
};
}),
// -------------------------------------------------------------------------
// 4.7 reverseTransaction (admin)
// -------------------------------------------------------------------------
reverseTransaction: adminProcedure
.input(
z.object({
transactionId: z.string().min(1),
note: z.string().max(200).optional(),
}),
)
.handler(async ({ input, context }) => {
const adminId = context.session.user.id;
const original = await db
.select()
.from(drinkkaartTransaction)
.where(eq(drinkkaartTransaction.id, input.transactionId))
.limit(1)
.then((r) => r[0]);
if (!original) {
throw new ORPCError("NOT_FOUND", {
message: "Transactie niet gevonden",
});
}
if (original.type !== "deduction") {
throw new ORPCError("BAD_REQUEST", {
message: "Alleen afschrijvingen kunnen worden teruggedraaid",
});
}
if (original.reversedBy) {
throw new ORPCError("CONFLICT", {
message: "Deze transactie is al teruggedraaid",
});
}
const card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.id, original.drinkkaartId))
.limit(1)
.then((r) => r[0]);
if (!card) {
throw new ORPCError("NOT_FOUND", {
message: "Drinkkaart niet gevonden",
});
}
const balanceBefore = card.balance;
const balanceAfter = balanceBefore + original.amountCents;
const now = new Date();
const result = await db
.update(drinkkaart)
.set({
balance: balanceAfter,
version: card.version + 1,
updatedAt: now,
})
.where(
and(eq(drinkkaart.id, card.id), eq(drinkkaart.version, card.version)),
);
if (result.rowsAffected === 0) {
throw new ORPCError("CONFLICT", {
message: "Gelijktijdige wijziging gedetecteerd. Probeer opnieuw.",
});
}
const reversalId = randomUUID();
await db.insert(drinkkaartTransaction).values({
id: reversalId,
drinkkaartId: card.id,
userId: card.userId,
adminId,
amountCents: original.amountCents,
balanceBefore,
balanceAfter,
type: "reversal",
reverses: original.id,
note: input.note ?? null,
createdAt: now,
});
// Mark original as reversed
await db
.update(drinkkaartTransaction)
.set({ reversedBy: reversalId })
.where(eq(drinkkaartTransaction.id, original.id));
return {
reversalId,
balanceBefore,
balanceAfter,
balanceFormatted: formatCents(balanceAfter),
};
}),
// -------------------------------------------------------------------------
// 4.8 getTransactionLog (admin) — paginated audit log
// -------------------------------------------------------------------------
getTransactionLog: adminProcedure
.input(
z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(200).default(50),
userId: z.string().optional(),
adminId: z.string().optional(),
type: z.enum(["deduction", "reversal"]).optional(),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
}),
)
.handler(async ({ input }) => {
// Build conditions
const conditions = [];
if (input.userId) {
conditions.push(eq(drinkkaartTransaction.userId, input.userId));
}
if (input.adminId) {
conditions.push(eq(drinkkaartTransaction.adminId, input.adminId));
}
if (input.type) {
conditions.push(eq(drinkkaartTransaction.type, input.type));
}
if (input.dateFrom) {
conditions.push(
gte(drinkkaartTransaction.createdAt, new Date(input.dateFrom)),
);
}
if (input.dateTo) {
conditions.push(
lte(drinkkaartTransaction.createdAt, new Date(input.dateTo)),
);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
// Paginated results with user names
const offset = (input.page - 1) * input.pageSize;
const rows = await db
.select({
id: drinkkaartTransaction.id,
type: drinkkaartTransaction.type,
userId: drinkkaartTransaction.userId,
adminId: drinkkaartTransaction.adminId,
amountCents: drinkkaartTransaction.amountCents,
balanceBefore: drinkkaartTransaction.balanceBefore,
balanceAfter: drinkkaartTransaction.balanceAfter,
note: drinkkaartTransaction.note,
reversedBy: drinkkaartTransaction.reversedBy,
reverses: drinkkaartTransaction.reverses,
createdAt: drinkkaartTransaction.createdAt,
})
.from(drinkkaartTransaction)
.where(where)
.orderBy(desc(drinkkaartTransaction.createdAt))
.limit(input.pageSize)
.offset(offset);
// Collect unique user IDs
const userIds = [
...new Set([
...rows.map((r) => r.userId),
...rows.map((r) => r.adminId),
]),
];
const users =
userIds.length > 0
? await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(
userIds.length === 1
? eq(user.id, userIds[0] as string)
: sql`${user.id} IN (${sql.join(
userIds.map((id) => sql`${id}`),
sql`, `,
)})`,
)
: [];
const userMap = new Map(users.map((u) => [u.id, u]));
// Count total
const countResult = await db
.select({ count: sql<number>`count(*)` })
.from(drinkkaartTransaction)
.where(where);
const total = Number(countResult[0]?.count ?? 0);
return {
transactions: rows.map((r) => ({
id: r.id,
type: r.type,
userId: r.userId,
userName: userMap.get(r.userId)?.name ?? "Onbekend",
userEmail: userMap.get(r.userId)?.email ?? "",
adminId: r.adminId,
adminName: userMap.get(r.adminId)?.name ?? "Onbekend",
amountCents: r.amountCents,
balanceBefore: r.balanceBefore,
balanceAfter: r.balanceAfter,
note: r.note,
reversedBy: r.reversedBy,
reverses: r.reverses,
createdAt: r.createdAt,
})),
total,
page: input.page,
pageSize: input.pageSize,
};
}),
// -------------------------------------------------------------------------
// Extra: getDrinkkaartByUserId (admin) — for manual credit user search
// -------------------------------------------------------------------------
getDrinkkaartByUserId: adminProcedure
.input(z.object({ userId: z.string().min(1) }))
.handler(async ({ input }) => {
const cardUser = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.id, input.userId))
.limit(1)
.then((r) => r[0]);
if (!cardUser) {
throw new ORPCError("NOT_FOUND", {
message: "Gebruiker niet gevonden",
});
}
const card = await getOrCreateDrinkkaart(input.userId);
return {
drinkkaartId: card.id,
userId: cardUser.id,
userName: cardUser.name,
userEmail: cardUser.email,
balance: card.balance,
balanceFormatted: formatCents(card.balance),
};
}),
// -------------------------------------------------------------------------
// Extra: searchUsers (admin) — for manual credit user search UI
// -------------------------------------------------------------------------
searchUsers: adminProcedure
.input(z.object({ query: z.string().min(1) }))
.handler(async ({ input }) => {
const term = `%${input.query}%`;
const results = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(
sql`lower(${user.name}) LIKE lower(${term}) OR lower(${user.email}) LIKE lower(${term})`,
)
.limit(20);
return results;
}),
};