feat:simplify dx and improve payment functions

This commit is contained in:
2026-03-03 14:02:29 +01:00
parent b9e5c44588
commit 0a1d1db9ec
10 changed files with 2167 additions and 2224 deletions

View File

@@ -13,6 +13,57 @@ import {
} from "../email";
import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/** Drink card price in euros: €5 base + €2 per extra guest. */
function drinkCardEuros(guestCount: number): number {
return 5 + guestCount * 2;
}
/** 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 [];
}
}
/** 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({
@@ -22,20 +73,24 @@ const guestSchema = z.object({
phone: z.string().optional(),
});
const submitRegistrationSchema = z.object({
const coreRegistrationFields = {
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
registrationType: registrationTypeSchema.default("watcher"),
// Performer-specific
artForm: z.string().optional(),
experience: z.string().optional(),
isOver16: z.boolean().optional(),
// Watcher-specific: drinkCardValue is computed server-side, guests are named
guests: z.array(guestSchema).max(9).optional(),
// Shared
extraQuestions: z.string().optional(),
};
const submitRegistrationSchema = z.object(coreRegistrationFields);
const updateRegistrationSchema = z.object({
token: z.string().uuid(),
...coreRegistrationFields,
});
const getRegistrationsSchema = z.object({
@@ -48,17 +103,17 @@ const getRegistrationsSchema = z.object({
pageSize: z.number().int().min(1).max(100).default(50),
});
export const appRouter = {
healthCheck: publicProcedure.handler(() => {
return "OK";
}),
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
privateData: protectedProcedure.handler(({ context }) => {
return {
message: "This is private",
user: context.session?.user,
};
}),
export const appRouter = {
healthCheck: publicProcedure.handler(() => "OK"),
privateData: protectedProcedure.handler(({ context }) => ({
message: "This is private",
user: context.session?.user,
})),
submitRegistration: publicProcedure
.input(submitRegistrationSchema)
@@ -66,8 +121,7 @@ export const appRouter = {
const managementToken = randomUUID();
const isPerformer = input.registrationType === "performer";
const guests = isPerformer ? [] : (input.guests ?? []);
// €5 for primary registrant + €2 per extra guest
const drinkCardValue = isPerformer ? 0 : 5 + guests.length * 2;
await db.insert(registration).values({
id: randomUUID(),
firstName: input.firstName,
@@ -78,7 +132,7 @@ export const appRouter = {
artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null,
isOver16: isPerformer ? (input.isOver16 ?? false) : false,
drinkCardValue,
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
guests: guests.length > 0 ? JSON.stringify(guests) : null,
extraQuestions: input.extraQuestions || null,
managementToken,
@@ -114,39 +168,15 @@ export const appRouter = {
}),
updateRegistration: publicProcedure
.input(
z.object({
token: z.string().uuid(),
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
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(),
}),
)
.input(updateRegistrationSchema)
.handler(async ({ input }) => {
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 of al geannuleerd");
const row = await getActiveRegistration(input.token);
// If already paid and switching to a watcher with fewer guests, we
// still allow the update — payment adjustments are handled manually.
const isPerformer = input.registrationType === "performer";
const guests = isPerformer ? [] : (input.guests ?? []);
const drinkCardValue = isPerformer ? 0 : 5 + guests.length * 2;
await db
.update(registration)
.set({
@@ -158,7 +188,7 @@ export const appRouter = {
artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null,
isOver16: isPerformer ? (input.isOver16 ?? false) : false,
drinkCardValue,
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
guests: guests.length > 0 ? JSON.stringify(guests) : null,
extraQuestions: input.extraQuestions || null,
})
@@ -172,25 +202,16 @@ export const appRouter = {
artForm: input.artForm,
}).catch((err) => console.error("Failed to send update email:", err));
// Satisfy the linter — row is used to confirm the record existed.
void row;
return { success: true };
}),
cancelRegistration: publicProcedure
.input(z.object({ token: z.string().uuid() }))
.handler(async ({ input }) => {
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 of al geannuleerd");
const row = await getActiveRegistration(input.token);
await db
.update(registration)
@@ -213,46 +234,37 @@ export const appRouter = {
const conditions = [];
if (input.search) {
const searchTerm = `%${input.search}%`;
const term = `%${input.search}%`;
conditions.push(
and(
like(registration.firstName, searchTerm),
like(registration.lastName, searchTerm),
like(registration.email, searchTerm),
like(registration.firstName, term),
like(registration.lastName, term),
like(registration.email, term),
),
);
}
if (input.registrationType) {
if (input.registrationType)
conditions.push(
eq(registration.registrationType, input.registrationType),
);
}
if (input.artForm) {
if (input.artForm)
conditions.push(eq(registration.artForm, input.artForm));
}
if (input.fromDate) {
if (input.fromDate)
conditions.push(gte(registration.createdAt, new Date(input.fromDate)));
}
if (input.toDate) {
if (input.toDate)
conditions.push(lte(registration.createdAt, new Date(input.toDate)));
}
const whereClause =
conditions.length > 0 ? and(...conditions) : undefined;
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [data, countResult] = await Promise.all([
db
.select()
.from(registration)
.where(whereClause)
.where(where)
.orderBy(desc(registration.createdAt))
.limit(input.pageSize)
.offset((input.page - 1) * input.pageSize),
db.select({ count: count() }).from(registration).where(whereClause),
db.select({ count: count() }).from(registration).where(where),
]);
const total = countResult[0]?.count ?? 0;
@@ -280,10 +292,7 @@ export const appRouter = {
.from(registration)
.where(gte(registration.createdAt, today)),
db
.select({
artForm: registration.artForm,
count: count(),
})
.select({ artForm: registration.artForm, count: count() })
.from(registration)
.where(eq(registration.registrationType, "performer"))
.groupBy(registration.artForm),
@@ -329,16 +338,14 @@ export const appRouter = {
"Drink Card Value",
"Guest Count",
"Guests",
"Payment Status",
"Paid At",
"Extra Questions",
"Created At",
];
const rows = data.map((r) => {
const guests: Array<{
firstName: string;
lastName: string;
email?: string;
phone?: string;
}> = r.guests ? JSON.parse(r.guests) : [];
const guests = parseGuestsJson(r.guests);
const guestSummary = guests
.map(
(g) =>
@@ -358,6 +365,8 @@ export const appRouter = {
String(r.drinkCardValue ?? 0),
String(guests.length),
guestSummary,
r.paymentStatus === "paid" ? "Paid" : "Pending",
r.paidAt ? r.paidAt.toISOString() : "",
r.extraQuestions || "",
r.createdAt.toISOString(),
];
@@ -376,49 +385,48 @@ export const appRouter = {
};
}),
// Admin Request Procedures
// ---------------------------------------------------------------------------
// Admin access requests
// ---------------------------------------------------------------------------
requestAdminAccess: protectedProcedure.handler(async ({ context }) => {
const userId = context.session.user.id;
// Check if user is already an admin
if (context.session.user.role === "admin") {
return { success: false, message: "Je bent al een admin" };
}
// Check if request already exists
const existingRequest = await db
const existing = await db
.select()
.from(adminRequest)
.where(eq(adminRequest.userId, userId))
.limit(1);
.limit(1)
.then((rows) => rows[0]);
if (existingRequest.length > 0) {
const existing = existingRequest[0]!;
if (existing.status === "pending") {
return { success: false, message: "Je hebt al een aanvraag openstaan" };
}
if (existing.status === "approved") {
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",
};
}
if (existing.status === "rejected") {
// Allow re-requesting if previously rejected
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" };
}
// 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" };
}
// Create new request
await db.insert(adminRequest).values({
id: randomUUID(),
userId,
@@ -429,7 +437,7 @@ export const appRouter = {
}),
getAdminRequests: adminProcedure.handler(async () => {
const requests = await db
return db
.select({
id: adminRequest.id,
userId: adminRequest.userId,
@@ -443,29 +451,22 @@ export const appRouter = {
.from(adminRequest)
.leftJoin(user, eq(adminRequest.userId, user.id))
.orderBy(desc(adminRequest.requestedAt));
return requests;
}),
approveAdminRequest: adminProcedure
.input(z.object({ requestId: z.string() }))
.handler(async ({ input, context }) => {
const request = await db
const req = await db
.select()
.from(adminRequest)
.where(eq(adminRequest.id, input.requestId))
.limit(1);
.limit(1)
.then((rows) => rows[0]);
if (request.length === 0) {
throw new Error("Aanvraag niet gevonden");
}
const req = request[0]!;
if (req.status !== "pending") {
if (!req) throw new Error("Aanvraag niet gevonden");
if (req.status !== "pending")
throw new Error("Deze aanvraag is al behandeld");
}
// Update request status
await db
.update(adminRequest)
.set({
@@ -475,7 +476,6 @@ export const appRouter = {
})
.where(eq(adminRequest.id, input.requestId));
// Update user role to admin
await db
.update(user)
.set({ role: "admin" })
@@ -487,20 +487,16 @@ export const appRouter = {
rejectAdminRequest: adminProcedure
.input(z.object({ requestId: z.string() }))
.handler(async ({ input, context }) => {
const request = await db
const req = await db
.select()
.from(adminRequest)
.where(eq(adminRequest.id, input.requestId))
.limit(1);
.limit(1)
.then((rows) => rows[0]);
if (request.length === 0) {
throw new Error("Aanvraag niet gevonden");
}
const req = request[0]!;
if (req.status !== "pending") {
if (!req) throw new Error("Aanvraag niet gevonden");
if (req.status !== "pending")
throw new Error("Deze aanvraag is al behandeld");
}
await db
.update(adminRequest)
@@ -514,8 +510,11 @@ export const appRouter = {
return { success: true, message: "Admin toegang geweigerd" };
}),
// Lemon Squeezy Checkout Procedure
createCheckout: publicProcedure
// ---------------------------------------------------------------------------
// Checkout
// ---------------------------------------------------------------------------
getCheckoutUrl: publicProcedure
.input(z.object({ token: z.string().uuid() }))
.handler(async ({ input }) => {
if (
@@ -541,19 +540,12 @@ export const appRouter = {
if (!row) throw new Error("Inschrijving niet gevonden");
if (row.paymentStatus === "paid")
throw new Error("Betaling is al voltooid");
const isPerformer = row.registrationType === "performer";
if (isPerformer) {
if (row.registrationType === "performer")
throw new Error("Artiesten hoeven niet te betalen");
}
// Calculate price: €5 + €2 per guest (in cents)
const guests: Array<{ firstName: string; lastName: string }> = row.guests
? JSON.parse(row.guests)
: [];
const amountInCents = 500 + guests.length * 200;
const guests = parseGuestsJson(row.guests);
const amountInCents = drinkCardCents(guests.length);
// Create checkout via Lemon Squeezy API
const response = await fetch(
"https://api.lemonsqueezy.com/v1/checkouts",
{
@@ -576,9 +568,7 @@ export const appRouter = {
checkout_data: {
email: row.email,
name: `${row.firstName} ${row.lastName}`,
custom: {
registration_token: input.token,
},
custom: { registration_token: input.token },
},
checkout_options: {
embed: false,
@@ -587,16 +577,10 @@ export const appRouter = {
},
relationships: {
store: {
data: {
type: "stores",
id: env.LEMON_SQUEEZY_STORE_ID,
},
data: { type: "stores", id: env.LEMON_SQUEEZY_STORE_ID },
},
variant: {
data: {
type: "variants",
id: env.LEMON_SQUEEZY_VARIANT_ID,
},
data: { type: "variants", id: env.LEMON_SQUEEZY_VARIANT_ID },
},
},
},
@@ -611,17 +595,11 @@ export const appRouter = {
}
const checkoutData = (await response.json()) as {
data?: {
attributes?: {
url?: string;
};
};
data?: { attributes?: { url?: string } };
};
const checkoutUrl = checkoutData.data?.attributes?.url;
if (!checkoutUrl) {
throw new Error("Geen checkout URL ontvangen");
}
if (!checkoutUrl) throw new Error("Geen checkout URL ontvangen");
return { checkoutUrl };
}),