feat:drinkkaart
This commit is contained in:
159
packages/api/src/lib/drinkkaart-email.ts
Normal file
159
packages/api/src/lib/drinkkaart-email.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { env } from "@kk/env/server";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
// Re-use the same SMTP transport strategy as email.ts.
|
||||
let _transport: nodemailer.Transporter | null | undefined;
|
||||
|
||||
function getTransport(): nodemailer.Transporter | null {
|
||||
if (_transport !== undefined) return _transport;
|
||||
if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) {
|
||||
_transport = null;
|
||||
return null;
|
||||
}
|
||||
_transport = nodemailer.createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: env.SMTP_PORT,
|
||||
secure: env.SMTP_PORT === 465,
|
||||
auth: {
|
||||
user: env.SMTP_USER,
|
||||
pass: env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
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 {
|
||||
return new Intl.NumberFormat("nl-BE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
function deductionHtml(params: {
|
||||
firstName: string;
|
||||
amountCents: number;
|
||||
newBalanceCents: number;
|
||||
dateTime: string;
|
||||
drinkkaartUrl: string;
|
||||
}) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Drinkkaart afschrijving</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="padding:40px 48px 32px;">
|
||||
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
|
||||
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Drinkkaart afschrijving</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding:0 48px 32px;">
|
||||
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
|
||||
Hoi ${params.firstName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
|
||||
Er is zojuist een bedrag afgeschreven van je Drinkkaart.
|
||||
</p>
|
||||
<!-- Summary -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:0 0 24px;">
|
||||
<tr>
|
||||
<td style="padding:20px 24px;">
|
||||
<p style="margin:0 0 12px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Afschrijving</p>
|
||||
<p style="margin:0 0 8px;font-size:22px;color:#ffffff;font-weight:700;">− ${formatEuro(params.amountCents)}</p>
|
||||
<p style="margin:8px 0 0;font-size:14px;color:rgba(255,255,255,0.55);">${params.dateTime}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 24px 20px;border-top:1px solid rgba(255,255,255,0.08);">
|
||||
<p style="margin:12px 0 4px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Nieuw saldo</p>
|
||||
<p style="margin:0;font-size:20px;color:#ffffff;font-weight:600;">${formatEuro(params.newBalanceCents)}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- CTA -->
|
||||
<table cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="border-radius:2px;background:#ffffff;">
|
||||
<a href="${params.drinkkaartUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
|
||||
Bekijk mijn Drinkkaart
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:16px 0 0;font-size:13px;color:rgba(255,255,255,0.4);">
|
||||
Klopt er iets niet? Neem contact op via <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
|
||||
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.6;">
|
||||
Kunstenkamp vzw — Een initiatief voor en door kunstenaars.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a deduction notification email to the cardholder.
|
||||
* Fire-and-forget: errors are logged but not re-thrown.
|
||||
*/
|
||||
export async function sendDeductionEmail(params: {
|
||||
to: string;
|
||||
firstName: string;
|
||||
amountCents: number;
|
||||
newBalanceCents: number;
|
||||
}): Promise<void> {
|
||||
const transport = getTransport();
|
||||
if (!transport) {
|
||||
console.warn("SMTP not configured — skipping deduction email");
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const dateTime = now.toLocaleString("nl-BE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const amountFormatted = new Intl.NumberFormat("nl-BE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(params.amountCents / 100);
|
||||
|
||||
await transport.sendMail({
|
||||
from,
|
||||
to: params.to,
|
||||
subject: `Drinkkaart — ${amountFormatted} afgeschreven`,
|
||||
html: deductionHtml({
|
||||
firstName: params.firstName,
|
||||
amountCents: params.amountCents,
|
||||
newBalanceCents: params.newBalanceCents,
|
||||
dateTime,
|
||||
drinkkaartUrl: `${baseUrl}/drinkkaart`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
120
packages/api/src/lib/drinkkaart-utils.ts
Normal file
120
packages/api/src/lib/drinkkaart-utils.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// QR token utilities for Drinkkaart using Web Crypto API
|
||||
// Compatible with Cloudflare Workers (no node:crypto dependency).
|
||||
|
||||
const QR_TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
interface QrPayload {
|
||||
drinkkaartId: string;
|
||||
userId: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
function toBase64Url(buffer: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
function base64UrlToBuffer(str: string): Uint8Array<ArrayBuffer> {
|
||||
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
||||
// Ensure the underlying buffer is a plain ArrayBuffer (required by crypto.subtle)
|
||||
return new Uint8Array(bytes.buffer.slice(0));
|
||||
}
|
||||
|
||||
/** Generate a cryptographically secure 32-byte hex secret for a new Drinkkaart. */
|
||||
export function generateQrSecret(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a QR token for the given drinkkaart / user.
|
||||
* Returns the compact token string and the expiry date.
|
||||
*/
|
||||
export async function signQrToken(
|
||||
payload: Omit<QrPayload, "iat" | "exp">,
|
||||
qrSecret: string,
|
||||
authSecret: string,
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
const now = Date.now();
|
||||
const exp = now + QR_TOKEN_TTL_MS;
|
||||
const full: QrPayload = { ...payload, iat: now, exp };
|
||||
|
||||
const encodedPayload = btoa(JSON.stringify(full))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
|
||||
const keyData = new TextEncoder().encode(qrSecret + authSecret);
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyData,
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
|
||||
const sigBuffer = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
key,
|
||||
new TextEncoder().encode(encodedPayload),
|
||||
);
|
||||
const sig = toBase64Url(sigBuffer);
|
||||
|
||||
return { token: `${encodedPayload}.${sig}`, expiresAt: new Date(exp) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a QR token. Throws if the signature is invalid or the token is expired.
|
||||
* Returns the decoded payload.
|
||||
*/
|
||||
export async function verifyQrToken(
|
||||
token: string,
|
||||
qrSecret: string,
|
||||
authSecret: string,
|
||||
): Promise<QrPayload> {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 2) throw new Error("MALFORMED");
|
||||
|
||||
const [encodedPayload, providedSig] = parts as [string, string];
|
||||
|
||||
const keyData = new TextEncoder().encode(qrSecret + authSecret);
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyData,
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
|
||||
const sigBytes = base64UrlToBuffer(providedSig);
|
||||
const valid = await crypto.subtle.verify(
|
||||
"HMAC",
|
||||
key,
|
||||
sigBytes,
|
||||
new TextEncoder().encode(encodedPayload),
|
||||
);
|
||||
if (!valid) throw new Error("INVALID_SIGNATURE");
|
||||
|
||||
const payload: QrPayload = JSON.parse(
|
||||
atob(encodedPayload.replace(/-/g, "+").replace(/_/g, "/")),
|
||||
);
|
||||
|
||||
if (Date.now() > payload.exp) throw new Error("EXPIRED");
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Format a cents integer as a Belgian euro string, e.g. 1250 → "€ 12,50". */
|
||||
export function formatCents(cents: number): string {
|
||||
return new Intl.NumberFormat("nl-BE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(cents / 100);
|
||||
}
|
||||
Reference in New Issue
Block a user