Docker-Images bauen und veroeffentlichen / build-and-push (push) Failing after 1m32s
122 lines
4.5 KiB
TypeScript
122 lines
4.5 KiB
TypeScript
/**
|
|
* Stammmodel-Codes (Briefing Abschnitt 6).
|
|
*
|
|
* Oeffentlich gibt es nur eine einzige, neutrale Ablehnung — ob ein Code
|
|
* unbekannt, abgelaufen oder ausgeschoepft ist, erfaehrt nur der
|
|
* Adminbereich. So laesst sich die Existenz gueltiger Codes nicht
|
|
* durchprobieren.
|
|
*/
|
|
import type { Voucher } from "@/generated/prisma/client";
|
|
import type { Db, Tx } from "./db";
|
|
|
|
export const PUBLIC_VOUCHER_REJECTION = "Dieser Gutscheincode kann leider nicht angewendet werden.";
|
|
|
|
export type VoucherRejection =
|
|
| "MALFORMED"
|
|
| "UNKNOWN"
|
|
| "INACTIVE"
|
|
| "NOT_YET_VALID"
|
|
| "EXPIRED"
|
|
| "EMAIL_MISMATCH"
|
|
| "EXHAUSTED"
|
|
| "QUOTA_EXHAUSTED";
|
|
|
|
/** Grossbuchstaben, ohne Leerzeichen; null bei offensichtlich ungueltiger Form. */
|
|
export function normalizeVoucherCode(input: string): string | null {
|
|
const code = input.replace(/\s+/g, "").toUpperCase();
|
|
return /^[A-Z0-9-]{4,40}$/.test(code) ? code : null;
|
|
}
|
|
|
|
export function normalizeEmail(email: string): string {
|
|
return email.trim().toLowerCase();
|
|
}
|
|
|
|
type StaticVoucherFields = Pick<Voucher, "active" | "validFrom" | "validUntil" | "boundEmail">;
|
|
|
|
export function checkVoucherStatic(
|
|
voucher: StaticVoucherFields,
|
|
ctx: { now: Date; email: string | null },
|
|
): VoucherRejection | null {
|
|
if (!voucher.active) return "INACTIVE";
|
|
if (voucher.validFrom && ctx.now < voucher.validFrom) return "NOT_YET_VALID";
|
|
if (voucher.validUntil && ctx.now >= voucher.validUntil) return "EXPIRED";
|
|
if (voucher.boundEmail) {
|
|
// Ohne E-Mail (reine Preisvorschau) laesst sich die Bindung nicht
|
|
// pruefen — die verbindliche Pruefung passiert beim Checkout.
|
|
if (ctx.email !== null && normalizeEmail(ctx.email) !== normalizeEmail(voucher.boundEmail)) {
|
|
return "EMAIL_MISMATCH";
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Zaehlt belegte Einloesungen: abgeschlossene plus reservierte, deren
|
|
* Buchung ihren Platz noch haelt. Ohne voucherId: Gesamtkontingent ueber
|
|
* alle Stammmodel-Codes.
|
|
*/
|
|
export async function countUsedRedemptions(
|
|
db: Db | Tx,
|
|
params: { livemode: boolean; now: Date; voucherId?: string },
|
|
): Promise<number> {
|
|
const rows = await db.$queryRaw<{ n: number }[]>`
|
|
SELECT count(*)::int AS n
|
|
FROM "VoucherRedemption" r
|
|
JOIN "SeatReservation" s ON s."bookingId" = r."bookingId"
|
|
WHERE r."livemode" = ${params.livemode}
|
|
AND (${params.voucherId ?? null}::text IS NULL OR r."voucherId" = ${params.voucherId ?? null})
|
|
AND (
|
|
r."status" = 'COMPLETED'
|
|
OR (r."status" = 'RESERVED' AND (
|
|
s."status" IN ('HELD', 'CONVERTED')
|
|
OR (s."status" = 'ACTIVE' AND s."expiresAt" > ${params.now})
|
|
))
|
|
)`;
|
|
return rows[0]?.n ?? 0;
|
|
}
|
|
|
|
export type VoucherEvaluation =
|
|
| { ok: true; voucher: Voucher }
|
|
| { ok: false; reason: VoucherRejection };
|
|
|
|
/**
|
|
* Vollstaendige Pruefung eines Codes inklusive Einloeselimit und
|
|
* Gesamtkontingent. Mit lock=true wird die Gutscheinzeile gesperrt (nur
|
|
* innerhalb einer Transaktion, die bereits die Camp-Zeile haelt).
|
|
*/
|
|
export async function evaluateVoucher(
|
|
db: Db | Tx,
|
|
params: { code: string; email: string | null; now: Date; livemode: boolean; quota: number; lock: boolean },
|
|
): Promise<VoucherEvaluation> {
|
|
const code = normalizeVoucherCode(params.code);
|
|
if (!code) return { ok: false, reason: "MALFORMED" };
|
|
|
|
if (params.lock) {
|
|
await db.$queryRaw`SELECT "id" FROM "Voucher" WHERE "code" = ${code} FOR UPDATE`;
|
|
}
|
|
const voucher = await db.voucher.findUnique({ where: { code } });
|
|
if (!voucher) return { ok: false, reason: "UNKNOWN" };
|
|
|
|
const rejection = checkVoucherStatic(voucher, { now: params.now, email: params.email });
|
|
if (rejection) return { ok: false, reason: rejection };
|
|
|
|
const used = await countUsedRedemptions(db, { livemode: params.livemode, now: params.now, voucherId: voucher.id });
|
|
if (used >= voucher.maxRedemptions) return { ok: false, reason: "EXHAUSTED" };
|
|
|
|
const quotaUsed = await countUsedRedemptions(db, { livemode: params.livemode, now: params.now });
|
|
if (quotaUsed >= params.quota) return { ok: false, reason: "QUOTA_EXHAUSTED" };
|
|
|
|
return { ok: true, voucher };
|
|
}
|
|
|
|
export const VOUCHER_REJECTION_ADMIN_TEXT: Record<VoucherRejection, string> = {
|
|
MALFORMED: "Format ungültig",
|
|
UNKNOWN: "Unbekannter Code",
|
|
INACTIVE: "Code ist deaktiviert",
|
|
NOT_YET_VALID: "Gültigkeit hat noch nicht begonnen",
|
|
EXPIRED: "Gültigkeit abgelaufen",
|
|
EMAIL_MISMATCH: "An andere E-Mail-Adresse gebunden",
|
|
EXHAUSTED: "Maximale Einlösungen erreicht",
|
|
QUOTA_EXHAUSTED: "Gesamtkontingent für Stammmodel-Buchungen erreicht",
|
|
};
|