feat:simplify dx and improve payment functions
This commit is contained in:
@@ -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 };
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user