743 lines
20 KiB
TypeScript
743 lines
20 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 { 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.LEMON_SQUEEZY_API_KEY ||
|
|
!serverEnv.LEMON_SQUEEZY_STORE_ID ||
|
|
!serverEnv.LEMON_SQUEEZY_DRINKKAART_VARIANT_ID
|
|
) {
|
|
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
message: "Lemon Squeezy Drinkkaart variant is niet geconfigureerd",
|
|
});
|
|
}
|
|
|
|
const card = await getOrCreateDrinkkaart(session.user.id);
|
|
|
|
const response = await fetch(
|
|
"https://api.lemonsqueezy.com/v1/checkouts",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/vnd.api+json",
|
|
"Content-Type": "application/vnd.api+json",
|
|
Authorization: `Bearer ${serverEnv.LEMON_SQUEEZY_API_KEY}`,
|
|
},
|
|
body: JSON.stringify({
|
|
data: {
|
|
type: "checkouts",
|
|
attributes: {
|
|
custom_price: input.amountCents,
|
|
product_options: {
|
|
name: "Drinkkaart Opladen",
|
|
description: `Drinkkaart top-up — ${formatCents(input.amountCents)}`,
|
|
redirect_url: `${serverEnv.BETTER_AUTH_URL}/account?topup=success`,
|
|
},
|
|
checkout_data: {
|
|
email: session.user.email,
|
|
name: session.user.name,
|
|
custom: {
|
|
type: "drinkkaart_topup",
|
|
drinkkaartId: card.id,
|
|
userId: session.user.id,
|
|
},
|
|
},
|
|
checkout_options: {
|
|
embed: false,
|
|
locale: "nl",
|
|
},
|
|
},
|
|
relationships: {
|
|
store: {
|
|
data: {
|
|
type: "stores",
|
|
id: serverEnv.LEMON_SQUEEZY_STORE_ID,
|
|
},
|
|
},
|
|
variant: {
|
|
data: {
|
|
type: "variants",
|
|
id: serverEnv.LEMON_SQUEEZY_DRINKKAART_VARIANT_ID,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
console.error("Lemon Squeezy Drinkkaart checkout error:", errorData);
|
|
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
message: "Kon checkout niet aanmaken",
|
|
});
|
|
}
|
|
|
|
const data = (await response.json()) as {
|
|
data?: { attributes?: { url?: string } };
|
|
};
|
|
const checkoutUrl = data.data?.attributes?.url;
|
|
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
|
|
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) {
|
|
sendDeductionEmail({
|
|
to: cardUser.email,
|
|
firstName: cardUser.name.split(" ")[0] ?? cardUser.name,
|
|
amountCents: input.amountCents,
|
|
newBalanceCents: balanceAfter,
|
|
}).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;
|
|
}),
|
|
};
|