first commit
Docker-Images bauen und veroeffentlichen / build-and-push (push) Failing after 1m32s

This commit is contained in:
Nick Szaunig committed 2026-09-27 06:06:56 +02:00
commit 59bb5a38a3
132 files changed
+19435

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
/**
* Anlage einer Buchung samt Platzreservierung (Briefing Abschnitte 5–8).
*
* Nebenlaeufigkeit: Jede Transaktion, die Kapazitaet belegt, sperrt zuerst
* die Camp-Zeile (SELECT … FOR UPDATE) und danach ggf. die Gutscheinzeile.
* Parallele Checkouts laufen dadurch fuer den kurzen Moment der Pruefung
* strikt nacheinander — zwei Personen koennen nie denselben letzten Platz
* oder dieselbe letzte Einloesung erhalten. Die feste Sperrreihenfolge
* (bestehende Buchung -> Camp -> Gutschein) schliesst Deadlocks aus: neue
* Buchungen sperren nur Camp und Gutschein, Zahlungs- und Adminaktionen
* sperren zuerst die Buchung und erst danach ggf. Camp bzw. Gutschein.
*/
import "./zod-de";
import { z } from "zod";
import type { Booking, Installment, PlanType as DbPlanType } from "@/generated/prisma/client";
import { audit, CUSTOMER } from "./audit";
import { campStartIso, getCamp, salesStateFor } from "./camp";
import { CAMP_ID, type Db, type Tx } from "./db";
import { buildSchedule, INSTALLMENT_COUNT, PLAN_TYPES, type PlanType, type ScheduledInstallment } from "./payment-plan";
import { calculatePrice, type PriceBreakdown } from "./pricing";
import { sellablePhase, type SalesState, type StripeMode } from "./sales-gate";
import { generateReference, sha256Hex, statusTokenFor } from "./tokens";
import { evaluateVoucher, normalizeEmail, type VoucherRejection } from "./vouchers";
/** Puffer zwischen Ablauf der Stripe-Session und Freigabe des Platzes, damit
* ein kurz vor Ablauf abgeschlossener Checkout seinen Platz sicher behaelt. */
export const RESERVATION_GRACE_MINUTES = 10;
export const participantSchema = z.object({
firstName: z.string().trim().min(1, "Bitte gib deinen Vornamen an.").max(80),
lastName: z.string().trim().min(1, "Bitte gib deinen Nachnamen an.").max(80),
email: z.email("Bitte gib eine gültige E-Mail-Adresse an.").max(200).transform(normalizeEmail),
phone: z
.string()
.trim()
.min(6, "Bitte gib eine Telefonnummer an.")
.max(40)
.regex(/^[+0-9 ()/-]+$/, "Die Telefonnummer darf nur Ziffern, Leerzeichen und + ( ) / - enthalten."),
country: z.string().trim().min(2, "Bitte gib dein Land an.").max(60),
notes: z.string().trim().max(1000).optional().transform((v) => (v ? v : undefined)),
});
export const bookingRequestSchema = z
.object({
idempotencyKey: z.uuid(),
participant: participantSchema,
planType: z.enum(PLAN_TYPES as [PlanType, ...PlanType[]]),
voucherCode: z.string().trim().max(60).optional().transform((v) => (v ? v : undefined)),
consents: z.object({
adult: z.literal(true, "Bitte bestätige, dass du volljährig bist."),
terms: z.literal(true, "Bitte akzeptiere die Buchungsbedingungen."),
accommodation: z.literal(true, "Bitte bestätige die Unterbringung."),
privacy: z.literal(true, "Bitte bestätige die Datenschutzhinweise."),
installmentMandate: z.boolean(),
}),
})
.refine((v) => v.planType === "FULL" || v.consents.installmentMandate, {
message: "Bitte stimme dem Einzug der weiteren Raten zu.",
path: ["consents", "installmentMandate"],
});
export type BookingRequest = z.infer<typeof bookingRequestSchema>;
export type BookingError =
| { code: "SALES_CLOSED"; reason: SalesState["closedReason"] }
| { code: "SOLD_OUT" }
| { code: "VOUCHER_REJECTED"; reason: VoucherRejection }
| { code: "PLAN_UNAVAILABLE" }
| { code: "IDEMPOTENCY_CONFLICT" };
export type CreatedBooking = Booking & { installments: Installment[] };
export type CreateBookingResult =
| { ok: true; reused: boolean; booking: CreatedBooking }
| { ok: false; error: BookingError };
export type BookingContext = { now: Date; mode: StripeMode; secret: string };
/** Belegte Plaetze desselben Modus (Test/Live getrennt). */
export async function countOccupiedSeats(db: Db | Tx, livemode: boolean, now: Date): Promise<number> {
const rows = await db.$queryRaw<{ n: number }[]>`
SELECT count(*)::int AS n FROM "SeatReservation"
WHERE "campId" = ${CAMP_ID} AND "livemode" = ${livemode}
AND ("status" IN ('HELD', 'CONVERTED') OR ("status" = 'ACTIVE' AND "expiresAt" > ${now}))`;
return rows[0]?.n ?? 0;
}
export async function lockCamp(tx: Tx): Promise<void> {
await tx.$queryRaw`SELECT "id" FROM "Camp" WHERE "id" = ${CAMP_ID} FOR UPDATE`;
}
export async function createBooking(db: Db, request: BookingRequest, ctx: BookingContext): Promise<CreateBookingResult> {
const livemode = ctx.mode === "live";
await getCamp(db); // stellt sicher, dass die zu sperrende Zeile existiert
return db.$transaction(
async (tx) => {
// Erst sperren, dann nach dem Idempotenzschluessel suchen: ein parallel
// laufender Doppelklick sieht so die bereits festgeschriebene Buchung.
await lockCamp(tx);
const existing = await tx.booking.findUnique({
where: { idempotencyKey: request.idempotencyKey },
include: { installments: { orderBy: { seq: "asc" } } },
});
if (existing) {
if (existing.email !== request.participant.email) return { ok: false, error: { code: "IDEMPOTENCY_CONFLICT" } };
return { ok: true, reused: true, booking: existing };
}
const camp = await getCamp(tx);
const state = salesStateFor(camp, ctx.mode, ctx.now);
const phase = sellablePhase(state);
if (!state.checkoutOpen || !phase) return { ok: false, error: { code: "SALES_CLOSED", reason: state.closedReason } };
const occupied = await countOccupiedSeats(tx, livemode, ctx.now);
if (occupied >= camp.capacity) return { ok: false, error: { code: "SOLD_OUT" } };
let voucherId: string | null = null;
if (request.voucherCode) {
const evaluation = await evaluateVoucher(tx, {
code: request.voucherCode,
email: request.participant.email,
now: ctx.now,
livemode,
quota: camp.stammmodelQuota,
lock: true,
});
if (!evaluation.ok) return { ok: false, error: { code: "VOUCHER_REJECTED", reason: evaluation.reason } };
voucherId = evaluation.voucher.id;
}
const price = calculatePrice(phase, voucherId !== null);
if (request.planType !== "FULL" && !state.installmentsAllowed) return { ok: false, error: { code: "PLAN_UNAVAILABLE" } };
const schedule = buildSchedule({
planType: request.planType,
totalCents: price.totalCents,
now: ctx.now,
campStartDate: campStartIso(camp),
finalInstallmentLeadDays: camp.finalInstallmentLeadDays,
minInstallmentGapDays: camp.minInstallmentGapDays,
});
if (!schedule.ok) return { ok: false, error: { code: "PLAN_UNAVAILABLE" } };
// Stripe verlangt fuer expires_at mindestens 30 Minuten; eine Minute
// Reserve gleicht Uhrabweichungen aus.
const priceLockedUntil = new Date(ctx.now.getTime() + (camp.reservationMinutes + 1) * 60_000);
const reservationExpiresAt = new Date(priceLockedUntil.getTime() + RESERVATION_GRACE_MINUTES * 60_000);
const p = request.participant;
const bookingId = crypto.randomUUID();
const booking = await tx.booking.create({
data: {
id: bookingId,
reference: generateReference(),
campId: CAMP_ID,
livemode,
idempotencyKey: request.idempotencyKey,
statusTokenHash: sha256Hex(statusTokenFor(bookingId, ctx.secret)),
firstName: p.firstName,
lastName: p.lastName,
email: p.email,
phone: p.phone,
country: p.country,
notes: p.notes ?? null,
adultConfirmedAt: ctx.now,
termsAcceptedAt: ctx.now,
termsVersion: camp.termsVersion ?? "ENTWURF-NICHT-FREIGEGEBEN",
accommodationAcknowledgedAt: ctx.now,
privacyAcknowledgedAt: ctx.now,
installmentMandateAt: request.planType === "FULL" ? null : ctx.now,
pricePhase: price.phase,
basePriceCents: price.basePriceCents,
phaseDiscountCents: price.phaseDiscountCents,
voucherDiscountCents: price.voucherDiscountCents,
totalCents: price.totalCents,
planType: request.planType as DbPlanType,
priceSnapshot: snapshotJson(price, schedule.installments, state, camp.termsVersion),
priceLockedUntil,
voucherId,
installments: {
create: schedule.installments.map((i) => ({ seq: i.seq, amountCents: i.amountCents, dueAt: i.dueAt })),
},
reservation: {
create: { campId: CAMP_ID, livemode, status: "ACTIVE", expiresAt: reservationExpiresAt },
},
...(voucherId ? { redemption: { create: { voucherId, livemode, status: "RESERVED" } } } : {}),
},
include: { installments: { orderBy: { seq: "asc" } } },
});
await audit(tx, CUSTOMER, "booking.created", "Booking", booking.id, {
reference: booking.reference,
livemode,
phase: price.phase,
totalCents: price.totalCents,
planType: request.planType,
voucher: voucherId !== null,
seatsOccupiedBefore: occupied,
reservationExpiresAt: reservationExpiresAt.toISOString(),
});
return { ok: true, reused: false, booking };
},
{ timeout: 15_000 },
);
}
function snapshotJson(price: PriceBreakdown, installments: ScheduledInstallment[], state: SalesState, termsVersion: string | null) {
return {
currency: "EUR",
phase: price.phase,
phaseSimulatedForTest: state.phaseSimulated,
basePriceCents: price.basePriceCents,
phaseDiscountCents: price.phaseDiscountCents,
phasePriceCents: price.phasePriceCents,
voucherDiscountCents: price.voucherDiscountCents,
totalCents: price.totalCents,
installments: installments.map((i) => ({ seq: i.seq, amountCents: i.amountCents, dueDate: i.dueDate })),
installmentCount: installments.length,
termsVersion,
calculatedAt: new Date().toISOString(),
};
}
/**
* Gibt Platz und Gutscheinreservierung einer (noch) nicht bezahlten Buchung
* frei. Muss innerhalb einer Transaktion mit gesperrter Buchungszeile laufen.
*/
export async function releaseBookingHold(
tx: Tx,
bookingId: string,
params: { status: "EXPIRED" | "CANCELED"; reason: string; now: Date },
): Promise<void> {
await tx.seatReservation.updateMany({
where: { bookingId, status: { in: ["ACTIVE", "HELD"] } },
data: { status: "RELEASED", releasedAt: params.now, releaseReason: params.reason },
});
await tx.voucherRedemption.updateMany({
where: { bookingId, status: "RESERVED" },
data: { status: "RELEASED", releasedAt: params.now },
});
await tx.installment.updateMany({
where: { bookingId, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } },
data: { status: "CANCELED" },
});
await tx.booking.update({
where: { id: bookingId },
data:
params.status === "CANCELED"
? { status: "CANCELED", canceledAt: params.now, cancelReason: params.reason }
: { status: "EXPIRED" },
});
}
export async function lockBooking(tx: Tx, bookingId: string): Promise<void> {
await tx.$queryRaw`SELECT "id" FROM "Booking" WHERE "id" = ${bookingId} FOR UPDATE`;
}
export function installmentCount(planType: PlanType): number {
return INSTALLMENT_COUNT[planType];
}