From 7cfb6acb3b3c601bee57e80c7d1662276987e24a Mon Sep 17 00:00:00 2001 From: Lars Lehmann Date: Thu, 29 Oct 2026 01:22:42 +0100 Subject: [PATCH] feat(kurse): one-time "Nachmeldelinks" for registrations after the deadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Course creators, team members and anyone with courses.manage_registrations can create a single-use link on the course dashboard (participants tab). With it, one registration goes through the public form even though the deadline has passed or registration is closed/not yet open. Capacity, waiting list, prices and required fields still apply; the course must not have ended. - New LateRegistrationLink model: only a SHA-256 of the token is stored, so the link is shown once on creation. Optional internal note, validity of 1–30 days (capped at course end), revocable while unused. - registrations.create accepts lateRegistrationToken; the link is claimed with a conditional update inside the serializable registration transaction, so concurrent submissions with one link yield exactly one registration and a failed registration leaves the link unused. - /anmelden?nachmeldung= skips the time gates for a valid link and explains used, revoked, expired or unknown links. - Create, revoke and use are written to the audit log. Closes #354 --- .../migration.sql | 35 +++ prisma/schema.prisma | 109 +++++--- .../dashboard/late-registration-links.tsx | 238 ++++++++++++++++++ .../events/course-registration-form.tsx | 2 + .../events/course-registration-form/types.ts | 4 +- .../events/course-registration-page.tsx | 17 ++ src/app/dashboard/courses/[id]/page.tsx | 8 + src/app/termine/[type]/[id]/anmelden/page.tsx | 97 ++++++- .../__tests__/late-registration-link.test.ts | 70 ++++++ src/lib/late-registration-link.ts | 69 +++++ src/server/api/helpers/course-access.ts | 28 +++ .../api/helpers/late-registration-link.ts | 85 +++++++ src/server/api/root.ts | 2 + .../api/routers/late-registration-links.ts | 217 ++++++++++++++++ src/server/api/routers/registrations.ts | 94 +++++-- src/server/utils/late-registration-token.ts | 17 ++ 16 files changed, 1030 insertions(+), 62 deletions(-) create mode 100644 prisma/migrations/20261029120000_late_registration_link/migration.sql create mode 100644 src/app/_components/dashboard/late-registration-links.tsx create mode 100644 src/lib/__tests__/late-registration-link.test.ts create mode 100644 src/lib/late-registration-link.ts create mode 100644 src/server/api/helpers/late-registration-link.ts create mode 100644 src/server/api/routers/late-registration-links.ts create mode 100644 src/server/utils/late-registration-token.ts diff --git a/prisma/migrations/20261029120000_late_registration_link/migration.sql b/prisma/migrations/20261029120000_late_registration_link/migration.sql new file mode 100644 index 00000000..c57e9926 --- /dev/null +++ b/prisma/migrations/20261029120000_late_registration_link/migration.sql @@ -0,0 +1,35 @@ +-- Einmal nutzbare Nachmeldelinks für Anmeldungen außerhalb des Anmeldezeitraums. +-- CreateTable +CREATE TABLE "late_registration_link" ( + "id" TEXT NOT NULL, + "courseId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "note" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "usedAt" TIMESTAMP(3), + "registrationId" TEXT, + "revokedAt" TIMESTAMP(3), + + CONSTRAINT "late_registration_link_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "late_registration_link_tokenHash_key" ON "late_registration_link"("tokenHash"); + +-- CreateIndex +CREATE UNIQUE INDEX "late_registration_link_registrationId_key" ON "late_registration_link"("registrationId"); + +-- CreateIndex +CREATE INDEX "late_registration_link_courseId_idx" ON "late_registration_link"("courseId"); + +-- AddForeignKey +ALTER TABLE "late_registration_link" ADD CONSTRAINT "late_registration_link_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "late_registration_link" ADD CONSTRAINT "late_registration_link_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "late_registration_link" ADD CONSTRAINT "late_registration_link_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "CourseRegistration"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4209f3ad..17391802 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,25 +33,26 @@ model User { bio String? preferences Json? - accounts Account[] - sessions Session[] - twoFactor TwoFactor? @relation("UserTwoFactor") - bezirk Bezirk? @relation("BezirkUsers", fields: [bezirkId], references: [id], onDelete: SetNull) - uploadedMedia Media[] @relation("MediaUploadedBy") - uploadedDownloads Download[] @relation("DownloadUploadedBy") - posaunenwart Posaunenwart? @relation("PosaunenwartUser") - createdEvents Event[] @relation("EventCreator") - createdCourses Course[] @relation("CourseCreator") - createdPosts Post[] @relation("PostCreator") - authoredPosts Post[] @relation("PostAuthor") - reviewedEvents Event[] @relation("EventReviewer") - reviewedCourses Course[] @relation("CourseReviewer") - reviewedPosts Post[] @relation("PostReviewer") - courseCollaborations CourseCollaborator[] - sentCourseMails CourseMail[] @relation("CourseMailSender") - conductedEnsembles Ensemble[] @relation("EnsembleConductor") - representedEnsembles Ensemble[] @relation("EnsembleRepresentative") - conductedAuswahlChors AuswahlChor[] @relation("AuswahlChorConductor") + accounts Account[] + sessions Session[] + twoFactor TwoFactor? @relation("UserTwoFactor") + bezirk Bezirk? @relation("BezirkUsers", fields: [bezirkId], references: [id], onDelete: SetNull) + uploadedMedia Media[] @relation("MediaUploadedBy") + uploadedDownloads Download[] @relation("DownloadUploadedBy") + posaunenwart Posaunenwart? @relation("PosaunenwartUser") + createdEvents Event[] @relation("EventCreator") + createdCourses Course[] @relation("CourseCreator") + createdLateRegistrationLinks LateRegistrationLink[] @relation("LateRegistrationLinkCreator") + createdPosts Post[] @relation("PostCreator") + authoredPosts Post[] @relation("PostAuthor") + reviewedEvents Event[] @relation("EventReviewer") + reviewedCourses Course[] @relation("CourseReviewer") + reviewedPosts Post[] @relation("PostReviewer") + courseCollaborations CourseCollaborator[] + sentCourseMails CourseMail[] @relation("CourseMailSender") + conductedEnsembles Ensemble[] @relation("EnsembleConductor") + representedEnsembles Ensemble[] @relation("EnsembleRepresentative") + conductedAuswahlChors AuswahlChor[] @relation("AuswahlChorConductor") bezirkPersons BezirkPerson[] @relation("BezirkPersonUser") teamMember TeamMember? @relation("TeamMemberUser") @@ -121,7 +122,6 @@ model GameResult { @@map("game_result") } - /** * Custom Role model for flexible role-based permissions * Separate from the UserRole enum to allow custom roles @@ -470,7 +470,6 @@ model TwoFactor { @@map("twoFactor") } - enum EventEnsembleType { AUSWAHLCHOR ENSEMBLE @@ -570,7 +569,9 @@ enum DownPaymentMode { TICKET } -/** Welcher Hinweis zur Erstattung bei der Anmeldung bestätigt werden muss. */ +/** + * Welcher Hinweis zur Erstattung bei der Anmeldung bestätigt werden muss. + */ enum DownPaymentRefundPolicy { NON_REFUNDABLE REFUNDABLE @@ -649,7 +650,6 @@ enum ContentStatus { ARCHIVED // No longer active } - model Event { id String @id @default(uuid()) /** @@ -811,10 +811,14 @@ model Course { * Bei TICKET trägt jede Preiskategorie ihren eigenen Betrag. */ downPaymentMode DownPaymentMode @default(NONE) - /** Betrag pro Teilnehmer bei DownPaymentMode.COURSE. */ + /** + * Betrag pro Teilnehmer bei DownPaymentMode.COURSE. + */ downPaymentAmount Float? downPaymentRefundPolicy DownPaymentRefundPolicy @default(NON_REFUNDABLE) - /** Eigener Erstattungshinweis bei DownPaymentRefundPolicy.CUSTOM. */ + /** + * Eigener Erstattungshinweis bei DownPaymentRefundPolicy.CUSTOM. + */ downPaymentRefundText String? prerequisites String? @@ -833,6 +837,8 @@ model Course { mails CourseMail[] invoices Invoice[] + lateRegistrationLinks LateRegistrationLink[] + createdById String? createdBy User? @relation("CourseCreator", fields: [createdById], references: [id], onDelete: SetNull) reviewerId String? @@ -993,14 +999,20 @@ model CourseRegistration { * üblichen Regeln zum Bearbeiten und Stornieren. */ downPaymentAmount Float? - /** null genau dann, wenn downPaymentAmount null ist. */ + /** + * null genau dann, wenn downPaymentAmount null ist. + */ downPaymentStatus DownPaymentStatus? downPaymentPaidAt DateTime? - /** Tatsächlich eingegangener Betrag; null heißt "voller Betrag" (wie Invoice.paidAmount). */ + /** + * Tatsächlich eingegangener Betrag; null heißt "voller Betrag" (wie Invoice.paidAmount). + */ downPaymentPaidAmount Float? downPaymentPaidById String? downPaymentPaidBy User? @relation("DownPaymentBooker", fields: [downPaymentPaidById], references: [id], onDelete: SetNull) - /** Interner Vermerk (Kontoauszug, Absprache mit der Kasse zur Erstattung). */ + /** + * Interner Vermerk (Kontoauszug, Absprache mit der Kasse zur Erstattung). + */ downPaymentNote String? invoiceGenerated Boolean @default(false) @@ -1022,7 +1034,9 @@ model CourseRegistration { * Anmeldenden, wer nachrückt — so lange hält die Warteliste an. */ promotionOfferExpiresAt DateTime? - /** Wann das Kursteam an das auslaufende Angebot erinnert wurde. */ + /** + * Wann das Kursteam an das auslaufende Angebot erinnert wurde. + */ promotionOfferReminderSentAt DateTime? /** * Angebot abgelehnt oder verfallen, als so viele Plätze nutzbar waren: bis @@ -1030,9 +1044,13 @@ model CourseRegistration { */ promotionOfferPassedSeats Int? - participants Participant[] - invoices Invoice[] - registrant User? @relation(fields: [registrantId], references: [id], onDelete: SetNull) + participants Participant[] + invoices Invoice[] + registrant User? @relation(fields: [registrantId], references: [id], onDelete: SetNull) + /** + * Nachmeldelink, über den die Anmeldung nach der Frist kam. + */ + lateRegistrationLink LateRegistrationLink? notes String? createdAt DateTime @default(now()) @@ -1046,6 +1064,33 @@ model CourseRegistration { @@index([promotionOfferExpiresAt]) } +/// Einmal nutzbarer Link, mit dem sich jemand außerhalb des Anmeldezeitraums +/// (meist nach der Frist) über das öffentliche Formular anmelden kann. Kapazität, +/// Preise und Pflichtfelder gelten wie sonst; nur die Zeit-Sperren entfallen. +model LateRegistrationLink { + id String @id @default(uuid()) + courseId String + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + + /// SHA-256 des Tokens im Link. Der Token selbst wird nur einmal beim Erstellen gezeigt. + tokenHash String @unique + /// Interne Notiz, für wen der Link gedacht ist. + note String? + expiresAt DateTime + + createdById String? + createdBy User? @relation("LateRegistrationLinkCreator", fields: [createdById], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + + usedAt DateTime? + registrationId String? @unique + registration CourseRegistration? @relation(fields: [registrationId], references: [id], onDelete: SetNull) + revokedAt DateTime? + + @@index([courseId]) + @@map("late_registration_link") +} + /// Archived e-mail from an organizer to a course's registrants. Recipient addresses are /// deliberately not stored, only the count, so registrant data isn't duplicated. model CourseMail { diff --git a/src/app/_components/dashboard/late-registration-links.tsx b/src/app/_components/dashboard/late-registration-links.tsx new file mode 100644 index 00000000..224237ed --- /dev/null +++ b/src/app/_components/dashboard/late-registration-links.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useId, useState } from "react"; +import Link from "next/link"; +import { LinkIcon } from "lucide-react"; +import { api } from "@/trpc/react"; +import { useToast } from "@/app/_components/ui/toast"; +import { Note } from "@/app/_components/programmheft/note"; +import { Tag, type TagTone } from "@/app/_components/programmheft/tag"; +import { CopyField } from "@/app/settings/ki-verbindungen/copy-field"; +import { formatBerlin } from "@/lib/berlin-time"; +import { + LATE_REGISTRATION_LINK_STATE_LABELS, + LATE_REGISTRATION_VALID_DAYS, + type LateRegistrationLinkState, +} from "@/lib/late-registration-link"; + +const BTN_PRIMARY = + "bg-ink text-paper hover:bg-primary hover:text-ink dark:bg-night-text dark:text-night dark:hover:bg-primary dark:hover:text-ink inline-flex min-h-11 items-center justify-center gap-2 px-4 py-2 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50"; +const BTN_OUTLINE = + "border-rule dark:border-night-rule text-ink dark:text-night-text hover:bg-rule/25 dark:hover:bg-night-raised inline-flex min-h-11 items-center justify-center gap-2 border px-4 py-2 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50"; +const FIELD = + "border-ink dark:border-night-text bg-paper dark:bg-night text-ink dark:text-night-text min-h-11 w-full border-2 px-3 py-2 text-sm"; + +const STATE_TONES: Record = { + open: "orange", + used: "inverse", + revoked: "muted", + expired: "muted", +}; + +const formatDateTime = (date: Date) => + `${formatBerlin(date, "datumUhrzeit")} Uhr`; + +const daysLabel = (days: number) => (days === 1 ? "1 Tag" : `${days} Tage`); + +/** + * Einmal nutzbare Links für Anmeldungen nach der Frist (oder bei geschlossener + * Anmeldung). Der Link steht nur direkt nach dem Erstellen hier: gespeichert + * wird allein ein Hash. + */ +export function LateRegistrationLinks({ + courseId, + courseEnded, +}: { + courseId: string; + courseEnded: boolean; +}) { + const toast = useToast(); + const utils = api.useUtils(); + const noteId = useId(); + const validityId = useId(); + + const [note, setNote] = useState(""); + const [validDays, setValidDays] = useState( + LATE_REGISTRATION_VALID_DAYS[0], + ); + const [created, setCreated] = useState<{ + url: string; + expiresAt: Date; + } | null>(null); + + const { data: links, isLoading } = api.lateRegistrationLinks.list.useQuery({ + courseId, + }); + + const createMutation = api.lateRegistrationLinks.create.useMutation({ + onSuccess: (result) => { + setCreated({ url: result.url, expiresAt: result.expiresAt }); + setNote(""); + void utils.lateRegistrationLinks.list.invalidate({ courseId }); + }, + onError: (error) => toast.error(error.message), + }); + + const revokeMutation = api.lateRegistrationLinks.revoke.useMutation({ + onSuccess: () => { + toast.success("Link zurückgezogen"); + void utils.lateRegistrationLinks.list.invalidate({ courseId }); + }, + onError: (error) => toast.error(error.message), + }); + + return ( +
+

+ Nachmeldelinks +

+

+ Mit einem Nachmeldelink kann sich eine Person über das öffentliche + Formular anmelden, obwohl die Anmeldefrist abgelaufen oder die Anmeldung + geschlossen ist. Jeder Link gilt für genau eine Anmeldung. Plätze, + Warteliste und Preise gelten wie sonst auch. +

+ + {courseEnded ? ( +

+ Der Kurs ist vorbei, neue Links lassen sich nicht mehr erstellen. +

+ ) : ( +
{ + e.preventDefault(); + setCreated(null); + createMutation.mutate({ + courseId, + validDays, + ...(note.trim() && { note: note.trim() }), + }); + }} + > +
+ + setNote(e.target.value)} + placeholder="z. B. Familie Muster" + className={FIELD} + /> +
+
+ + +
+ +
+ )} + + {created && ( + +

+ Der Link wird nur jetzt angezeigt. Er gilt bis{" "} + {formatDateTime(created.expiresAt)} für eine Anmeldung. +

+ +
+ )} + + {isLoading ? ( +

Laden…

+ ) : !links?.length ? ( +

+ Noch keine Nachmeldelinks erstellt. +

+ ) : ( +
    + {links.map((link) => ( +
  • +
    +
    + + {LATE_REGISTRATION_LINK_STATE_LABELS[link.state]} + + + {link.note || "Ohne Notiz"} + +
    +

    + Erstellt {formatDateTime(link.createdAt)} + {link.createdBy?.displayName + ? ` von ${link.createdBy.displayName}` + : ""} + {" · "} + {link.state === "used" && link.usedAt + ? `benutzt ${formatDateTime(link.usedAt)}` + : link.state === "revoked" && link.revokedAt + ? `zurückgezogen ${formatDateTime(link.revokedAt)}` + : `${link.state === "expired" ? "abgelaufen" : "gültig bis"} ${formatDateTime(link.expiresAt)}`} +

    + {link.registration && ( +

    + + Anmeldung von {link.registration.registrantFirstName}{" "} + {link.registration.registrantLastName} + +

    + )} +
    + {link.state === "open" && ( + + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/_components/events/course-registration-form.tsx b/src/app/_components/events/course-registration-form.tsx index 5174ee45..0fbf5bb3 100644 --- a/src/app/_components/events/course-registration-form.tsx +++ b/src/app/_components/events/course-registration-form.tsx @@ -87,6 +87,7 @@ export default function CourseRegistrationForm({ staffMode = false, availableSlots, capacityByPriceOption, + lateRegistrationToken, }: CourseRegistrationFormProps) { const toast = useToast(); const botTrap = useBotTrap(); @@ -710,6 +711,7 @@ export default function CourseRegistrationForm({ ...payload, ...splitPayload, downPaymentAcknowledged, + ...(lateRegistrationToken && { lateRegistrationToken }), ...botTrap.fields(), }, handlers, diff --git a/src/app/_components/events/course-registration-form/types.ts b/src/app/_components/events/course-registration-form/types.ts index dce7accf..d5230d29 100644 --- a/src/app/_components/events/course-registration-form/types.ts +++ b/src/app/_components/events/course-registration-form/types.ts @@ -4,7 +4,7 @@ import type { User } from "~/generated/prisma/client"; export type CourseWithRelations = RouterOutputs["courses"]["getById"]; export type RegistrationData = Omit< RouterInputs["registrations"]["create"], - "courseId" | "totalPrice" + "courseId" | "totalPrice" | "lateRegistrationToken" >; /** One entry of the participant list while the form is being filled in. */ @@ -57,6 +57,8 @@ export interface CourseRegistrationFormProps { availableSlots?: number; /** Free seats per price option (by id), as `getAvailableSlots` reports them. */ capacityByPriceOption?: Record | null; + /** Token eines Nachmeldelinks; geht mit der öffentlichen Anmeldung an den Server. */ + lateRegistrationToken?: string; } export type Step = 1 | 2 | 3; diff --git a/src/app/_components/events/course-registration-page.tsx b/src/app/_components/events/course-registration-page.tsx index eb915e6d..73f496dc 100644 --- a/src/app/_components/events/course-registration-page.tsx +++ b/src/app/_components/events/course-registration-page.tsx @@ -13,6 +13,7 @@ import PublicPage from "@/app/_components/general/public-page"; import { BezirkLabel } from "@/app/_components/programmheft/bezirk-label"; import { headMeta } from "@/app/_components/programmheft/page-head"; import { Tag } from "@/app/_components/programmheft/tag"; +import { Note } from "@/app/_components/programmheft/note"; import { courseTypeLabel } from "@/lib/termine-labels"; import CourseRegistrationForm from "@/app/_components/events/course-registration-form"; import { CourseExistingRegistrationOptions } from "@/app/_components/events/course-existing-registration-options"; @@ -35,9 +36,12 @@ function formatCourseSchedule(course: Course): string { export default function CourseRegistrationPage({ course, spots, + lateRegistrationToken, }: { course: Course; spots: Spots; + /** Gültiger Nachmeldelink: die Anmeldung geht trotz Frist bzw. geschlossener Anmeldung durch. */ + lateRegistrationToken?: string; }) { const router = useRouter(); const { data: session } = useSession(); @@ -114,6 +118,7 @@ export default function CourseRegistrationPage({ )} {isWaitlist && Nur Warteliste} + {lateRegistrationToken && Nachmeldung}
@@ -182,6 +187,17 @@ export default function CourseRegistrationPage({

Laden…

)} + {lateRegistrationToken && ( +
+ +

+ Das Kursteam hat Ihnen diesen Link für eine Anmeldung außerhalb + der regulären Anmeldezeit gegeben. Er gilt für genau eine + Anmeldung und ist danach verbraucht. +

+
+
+ )} {showForm && !existingRegistrationLoading && ( )} diff --git a/src/app/dashboard/courses/[id]/page.tsx b/src/app/dashboard/courses/[id]/page.tsx index 3918ebe8..4dda9463 100644 --- a/src/app/dashboard/courses/[id]/page.tsx +++ b/src/app/dashboard/courses/[id]/page.tsx @@ -46,6 +46,7 @@ import { import { renderDescriptionHtml } from "@/lib/sanitize"; import "@/styles/beschreibung.css"; import { RegistrationPaymentBadge } from "@/app/_components/dashboard/invoice-payment-badge"; +import { LateRegistrationLinks } from "@/app/_components/dashboard/late-registration-links"; import { participantPriceOptionLabel } from "@/lib/course-price-options"; import { priceOptionAgeLabel } from "@/lib/course-price-option-age"; import { Tag, type TagTone } from "@/app/_components/programmheft/tag"; @@ -1004,6 +1005,13 @@ export default function CourseDetailPage() { + {canAddRegistrations && ( + + )} +

Anmeldungen diff --git a/src/app/termine/[type]/[id]/anmelden/page.tsx b/src/app/termine/[type]/[id]/anmelden/page.tsx index a9afd556..6bf3594a 100644 --- a/src/app/termine/[type]/[id]/anmelden/page.tsx +++ b/src/app/termine/[type]/[id]/anmelden/page.tsx @@ -1,15 +1,30 @@ +import Link from "next/link"; import { notFound, permanentRedirect, redirect } from "next/navigation"; import { api } from "@/trpc/server"; import CourseRegistrationPage from "@/app/_components/events/course-registration-page"; +import PublicPage from "@/app/_components/general/public-page"; +import { Note } from "@/app/_components/programmheft/note"; import type { RouterOutputs } from "@/trpc/react"; import { isExternalCourse } from "@/lib/course-external"; import { isRegistrationDeadlinePassed } from "@/lib/registration-deadline"; +import { + LATE_REGISTRATION_PARAM, + lateRegistrationLinkUnusableMessage, +} from "@/lib/late-registration-link"; import { coursePath, courseRegistrationPath, isUuid } from "@/lib/slug"; type Course = NonNullable; type Spots = RouterOutputs["courses"]["getAvailableSlots"]; -function canRegisterForCourse(course: Course, spots: Spots): boolean { +/** + * Mit gültigem Nachmeldelink entfallen die Zeit-Sperren (geschlossen, noch nicht + * geöffnet, Frist vorbei); Kursende und volle Plätze ohne Warteliste gelten weiter. + */ +function canRegisterForCourse( + course: Course, + spots: Spots, + hasLateRegistrationLink: boolean, +): boolean { const now = new Date(); const endDate = new Date(course.endDate); const registrationDeadline = course.registrationDeadline @@ -27,20 +42,28 @@ function canRegisterForCourse(course: Course, spots: Spots): boolean { registrationOpensAt !== null && registrationOpensAt > now; return ( - course.registrationOpen && + (hasLateRegistrationLink || + (course.registrationOpen && + !isDeadlinePassed && + !isRegistrationNotOpenYet)) && !isPast && - !isDeadlinePassed && - !isRegistrationNotOpenYet && (!spots.isFull || course.allowWaitingList) ); } interface PageProps { params: Promise<{ type: string; id: string }>; + searchParams: Promise>; } -export default async function CourseAnmeldenPage({ params }: PageProps) { +export default async function CourseAnmeldenPage({ + params, + searchParams, +}: PageProps) { const { type, id } = await params; + const rawToken = (await searchParams)[LATE_REGISTRATION_PARAM]; + const lateRegistrationToken = + typeof rawToken === "string" && rawToken ? rawToken : undefined; if (type !== "course") { notFound(); @@ -54,16 +77,74 @@ export default async function CourseAnmeldenPage({ params }: PageProps) { } if (isUuid(id) && course.slug) { - permanentRedirect(courseRegistrationPath(course)); + permanentRedirect( + lateRegistrationToken + ? `${courseRegistrationPath(course)}?${LATE_REGISTRATION_PARAM}=${encodeURIComponent(lateRegistrationToken)}` + : courseRegistrationPath(course), + ); } if (isExternalCourse(course) && course.externalRegistrationUrl) { redirect(course.externalRegistrationUrl); } - if (!canRegisterForCourse(course, spots)) { + const lateRegistrationState = lateRegistrationToken + ? ( + await api.lateRegistrationLinks.check({ + courseId: course.id, + token: lateRegistrationToken, + }) + ).state + : null; + + if (lateRegistrationState && lateRegistrationState !== "open") { + return ( + +
+
+ +

+ {lateRegistrationLinkUnusableMessage(lateRegistrationState)} +

+

+ Wenn du dich trotzdem noch anmelden möchtest, wende dich bitte + an das Kursteam. +

+
+

+ + ← Zur Kursseite + +

+
+
+
+ ); + } + + const usesLateRegistrationLink = lateRegistrationState === "open"; + + if (!canRegisterForCourse(course, spots, usesLateRegistrationLink)) { redirect(coursePath(course)); } - return ; + return ( + + ); } diff --git a/src/lib/__tests__/late-registration-link.test.ts b/src/lib/__tests__/late-registration-link.test.ts new file mode 100644 index 00000000..e349250c --- /dev/null +++ b/src/lib/__tests__/late-registration-link.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "@jest/globals"; +import { + lateRegistrationLinkExpiry, + lateRegistrationLinkState, +} from "@/lib/late-registration-link"; + +describe("lateRegistrationLinkState", () => { + const now = new Date("2026-10-29T12:00:00Z"); + const future = "2026-11-05T12:00:00Z"; + + it("ist offen, solange er weder benutzt, widerrufen noch abgelaufen ist", () => { + expect( + lateRegistrationLinkState( + { usedAt: null, revokedAt: null, expiresAt: future }, + now, + ), + ).toBe("open"); + }); + + it("gilt ab dem Ablaufzeitpunkt als abgelaufen", () => { + expect( + lateRegistrationLinkState( + { usedAt: null, revokedAt: null, expiresAt: now }, + now, + ), + ).toBe("expired"); + }); + + it("zeigt eine Benutzung auch nach Ablauf oder Widerruf als benutzt", () => { + expect( + lateRegistrationLinkState( + { + usedAt: "2026-10-20T12:00:00Z", + revokedAt: "2026-10-21T12:00:00Z", + expiresAt: "2026-10-22T12:00:00Z", + }, + now, + ), + ).toBe("used"); + }); + + it("geht bei Widerruf dem Ablauf vor", () => { + expect( + lateRegistrationLinkState( + { + usedAt: null, + revokedAt: "2026-10-21T12:00:00Z", + expiresAt: "2026-10-22T12:00:00Z", + }, + now, + ), + ).toBe("revoked"); + }); +}); + +describe("lateRegistrationLinkExpiry", () => { + const now = new Date("2026-10-29T12:00:00Z"); + + it("läuft nach der gewählten Zahl von Tagen ab", () => { + expect( + lateRegistrationLinkExpiry(7, "2026-12-31T12:00:00Z", now).toISOString(), + ).toBe("2026-11-05T12:00:00.000Z"); + }); + + it("gilt höchstens bis Kursende", () => { + expect( + lateRegistrationLinkExpiry(30, "2026-11-01T16:00:00Z", now).toISOString(), + ).toBe("2026-11-01T16:00:00.000Z"); + }); +}); diff --git a/src/lib/late-registration-link.ts b/src/lib/late-registration-link.ts new file mode 100644 index 00000000..fc8ebc5d --- /dev/null +++ b/src/lib/late-registration-link.ts @@ -0,0 +1,69 @@ +/** + * Nachmeldelinks: einmal nutzbare Links, mit denen sich jemand über das öffentliche + * Formular anmelden kann, obwohl die Anmeldefrist abgelaufen (oder die Anmeldung + * geschlossen bzw. noch nicht geöffnet) ist. Kapazität, Preise und Pflichtfelder + * gelten wie bei jeder Anmeldung. + */ + +/** Query-Parameter des Links auf der Anmeldeseite. */ +export const LATE_REGISTRATION_PARAM = "nachmeldung"; + +/** Wählbare Gültigkeiten in Tagen; die erste ist die Vorgabe. */ +export const LATE_REGISTRATION_VALID_DAYS = [7, 3, 1, 14, 30] as const; +export const MAX_LATE_REGISTRATION_VALID_DAYS = 30; + +export type LateRegistrationLinkState = "open" | "used" | "revoked" | "expired"; + +export function lateRegistrationLinkState( + link: { + usedAt: Date | string | null; + revokedAt: Date | string | null; + expiresAt: Date | string; + }, + now: Date = new Date(), +): LateRegistrationLinkState { + if (link.usedAt) return "used"; + if (link.revokedAt) return "revoked"; + if (new Date(link.expiresAt) <= now) return "expired"; + return "open"; +} + +/** + * Ablauf nach `validDays`, aber nie nach Kursende: danach ist eine Anmeldung + * ohnehin sinnlos. + */ +export function lateRegistrationLinkExpiry( + validDays: number, + courseEndDate: Date | string, + now: Date = new Date(), +): Date { + const requested = new Date(now.getTime() + validDays * 24 * 60 * 60 * 1000); + const courseEnd = new Date(courseEndDate); + return requested < courseEnd ? requested : courseEnd; +} + +export const LATE_REGISTRATION_LINK_STATE_LABELS: Record< + LateRegistrationLinkState, + string +> = { + open: "Offen", + used: "Benutzt", + revoked: "Zurückgezogen", + expired: "Abgelaufen", +}; + +/** Meldung für Anmeldende, deren Link nicht (mehr) gilt. */ +export function lateRegistrationLinkUnusableMessage( + state: Exclude | "unknown", +): string { + switch (state) { + case "used": + return "Dieser Nachmeldelink wurde bereits für eine Anmeldung verwendet. Jeder Link gilt nur für eine Anmeldung."; + case "revoked": + return "Dieser Nachmeldelink wurde vom Kursteam zurückgezogen."; + case "expired": + return "Dieser Nachmeldelink ist abgelaufen."; + case "unknown": + return "Dieser Nachmeldelink ist ungültig. Bitte prüfe, ob du ihn vollständig kopiert hast."; + } +} diff --git a/src/server/api/helpers/course-access.ts b/src/server/api/helpers/course-access.ts index 44bd798d..9b69f243 100644 --- a/src/server/api/helpers/course-access.ts +++ b/src/server/api/helpers/course-access.ts @@ -155,3 +155,31 @@ export async function userCanManageSiblingDiscount( return { allowed: canDecide || ownsCourse, canDecide }; } + +/** + * Wie `assertMayManageCourseRegistrations` im Anmelde-Router: Kursersteller, jedes + * Teammitglied oder `courses.manage_registrations`. + */ +export async function userCanManageCourseRegistrations( + db: PrismaClient, + userId: string, + course: { + id: string; + createdById: string | null; + }, + permissionCache?: PermissionCache, +): Promise { + if (course.createdById === userId) return true; + + const [perms, collaborator] = await Promise.all([ + resolveUserPermissionsCached(userId, permissionCache), + db.courseCollaborator.findUnique({ + where: { + courseId_userId: { courseId: course.id, userId }, + }, + select: { role: true }, + }), + ]); + + return !!collaborator || perms.has(PERMISSIONS.COURSES_MANAGE_REGISTRATIONS); +} diff --git a/src/server/api/helpers/late-registration-link.ts b/src/server/api/helpers/late-registration-link.ts new file mode 100644 index 00000000..e3a6519b --- /dev/null +++ b/src/server/api/helpers/late-registration-link.ts @@ -0,0 +1,85 @@ +import { TRPCError } from "@trpc/server"; +import type { Prisma, PrismaClient } from "~/generated/prisma/client"; +import { + lateRegistrationLinkState, + lateRegistrationLinkUnusableMessage, + type LateRegistrationLinkState, +} from "@/lib/late-registration-link"; +import { hashLateRegistrationToken } from "@/server/utils/late-registration-token"; + +type Db = PrismaClient | Prisma.TransactionClient; + +/** Zustand des Links zu `token` für genau diesen Kurs; fremde oder unbekannte Tokens sind "unknown". */ +export async function lateRegistrationLinkStateForToken( + db: Db, + courseId: string, + token: string, + now: Date = new Date(), +): Promise<{ + linkId: string | null; + state: LateRegistrationLinkState | "unknown"; +}> { + const link = await db.lateRegistrationLink.findUnique({ + where: { tokenHash: hashLateRegistrationToken(token) }, + select: { + id: true, + courseId: true, + usedAt: true, + revokedAt: true, + expiresAt: true, + }, + }); + if (!link || link.courseId !== courseId) { + return { linkId: null, state: "unknown" }; + } + return { linkId: link.id, state: lateRegistrationLinkState(link, now) }; +} + +/** Wirft mit einer verständlichen Meldung, wenn der Link nicht (mehr) nutzbar ist. */ +export async function assertLateRegistrationLinkUsable( + db: Db, + courseId: string, + token: string, +): Promise { + const { linkId, state } = await lateRegistrationLinkStateForToken( + db, + courseId, + token, + ); + if (state === "open" && linkId) return linkId; + throw new TRPCError({ + code: "BAD_REQUEST", + message: lateRegistrationLinkUnusableMessage( + state === "open" ? "unknown" : state, + ), + }); +} + +/** + * Löst den Link in der Anmelde-Transaktion ein. Bedingtes Update statt Lesen und + * Schreiben: Zwei gleichzeitige Anmeldungen mit demselben Link bekommen so nur + * eine Anmeldung durch. Läuft in derselben Transaktion wie die Anmeldung: + * scheitert eines von beiden, bleibt der Link unbenutzt. + */ +export async function claimLateRegistrationLink( + tx: Prisma.TransactionClient, + linkId: string, + registrationId: string, + now: Date = new Date(), +): Promise { + const { count } = await tx.lateRegistrationLink.updateMany({ + where: { + id: linkId, + usedAt: null, + revokedAt: null, + expiresAt: { gt: now }, + }, + data: { usedAt: now, registrationId }, + }); + if (count !== 1) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: lateRegistrationLinkUnusableMessage("used"), + }); + } +} diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 88dff0d3..5156d9ba 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -3,6 +3,7 @@ import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc"; import { eventsRouter } from "./routers/events"; import { coursesRouter } from "./routers/courses"; import { registrationsRouter } from "./routers/registrations"; +import { lateRegistrationLinksRouter } from "./routers/late-registration-links"; import { courseMailRouter } from "./routers/course-mail"; import { invoicesRouter } from "./routers/invoices"; import { postsRouter } from "./routers/posts"; @@ -35,6 +36,7 @@ export const appRouter = createTRPCRouter({ events: eventsRouter, courses: coursesRouter, registrations: registrationsRouter, + lateRegistrationLinks: lateRegistrationLinksRouter, courseMail: courseMailRouter, invoices: invoicesRouter, posts: postsRouter, diff --git a/src/server/api/routers/late-registration-links.ts b/src/server/api/routers/late-registration-links.ts new file mode 100644 index 00000000..24d0db9d --- /dev/null +++ b/src/server/api/routers/late-registration-links.ts @@ -0,0 +1,217 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { + createTRPCRouter, + protectedProcedure, + rateLimitedPublicProcedure, +} from "../trpc"; +import { userCanManageCourseRegistrations } from "../helpers/course-access"; +import { lateRegistrationLinkStateForToken } from "../helpers/late-registration-link"; +import { logAudit } from "../helpers/audit"; +import { isExternalCourse } from "@/lib/course-external"; +import { + LATE_REGISTRATION_PARAM, + lateRegistrationLinkExpiry, + lateRegistrationLinkState, + MAX_LATE_REGISTRATION_VALID_DAYS, +} from "@/lib/late-registration-link"; +import { courseRegistrationPath } from "@/lib/slug"; +import { getBaseUrl } from "@/server/utils/get-base-url"; +import { createLateRegistrationToken } from "@/server/utils/late-registration-token"; +import type { PermissionCache } from "../helpers/permissions"; +import type { PrismaClient } from "~/generated/prisma/client"; + +/** Kursersteller, Kursteam oder `courses.manage_registrations` — wie beim Erfassen von Anmeldungen. */ +async function loadManageableCourse( + ctx: { + db: PrismaClient; + session: { user: { id: string } }; + permissionCache: PermissionCache; + }, + courseId: string, +) { + const course = await ctx.db.course.findUnique({ + where: { id: courseId }, + select: { + id: true, + slug: true, + title: true, + endDate: true, + createdById: true, + externalProviderName: true, + externalRegistrationUrl: true, + }, + }); + if (!course) { + throw new TRPCError({ code: "NOT_FOUND", message: "Kurs nicht gefunden" }); + } + const allowed = await userCanManageCourseRegistrations( + ctx.db, + ctx.session.user.id, + course, + ctx.permissionCache, + ); + if (!allowed) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Insufficient permissions", + }); + } + return course; +} + +export const lateRegistrationLinksRouter = createTRPCRouter({ + list: protectedProcedure + .input(z.object({ courseId: z.string() })) + .query(async ({ ctx, input }) => { + await loadManageableCourse(ctx, input.courseId); + const links = await ctx.db.lateRegistrationLink.findMany({ + where: { courseId: input.courseId }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + note: true, + expiresAt: true, + createdAt: true, + usedAt: true, + revokedAt: true, + createdBy: { select: { displayName: true } }, + registration: { + select: { + id: true, + registrantFirstName: true, + registrantLastName: true, + }, + }, + }, + }); + const now = new Date(); + return links.map((link) => ({ + ...link, + state: lateRegistrationLinkState(link, now), + })); + }), + + /** Der Link steht nur in dieser Antwort: gespeichert wird allein der Hash des Tokens. */ + create: protectedProcedure + .input( + z.object({ + courseId: z.string(), + note: z.string().trim().max(200).optional(), + validDays: z + .number() + .int() + .min(1) + .max(MAX_LATE_REGISTRATION_VALID_DAYS), + }), + ) + .mutation(async ({ ctx, input }) => { + const course = await loadManageableCourse(ctx, input.courseId); + + if (isExternalCourse(course)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Dieser Kurs wird über einen externen Anbieter angemeldet.", + }); + } + const now = new Date(); + if (course.endDate <= now) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Der Kurs ist bereits vorbei.", + }); + } + + const { token, tokenHash } = createLateRegistrationToken(); + const link = await ctx.db.lateRegistrationLink.create({ + data: { + courseId: course.id, + tokenHash, + note: input.note || null, + expiresAt: lateRegistrationLinkExpiry( + input.validDays, + course.endDate, + now, + ), + createdById: ctx.session.user.id, + }, + select: { id: true, expiresAt: true }, + }); + + void logAudit(ctx.db, { + actorId: ctx.session.user.id, + actorEmail: ctx.session.user.email, + action: "course.late_registration_link.create", + entityType: "course", + entityId: course.id, + details: { + summary: `Nachmeldelink für „${course.title}“ erstellt`, + linkId: link.id, + expiresAt: link.expiresAt.toISOString(), + }, + }); + + return { + id: link.id, + expiresAt: link.expiresAt, + url: `${getBaseUrl()}${courseRegistrationPath(course)}?${LATE_REGISTRATION_PARAM}=${encodeURIComponent(token)}`, + }; + }), + + revoke: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const link = await ctx.db.lateRegistrationLink.findUnique({ + where: { id: input.id }, + select: { id: true, courseId: true }, + }); + if (!link) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Link nicht gefunden", + }); + } + const course = await loadManageableCourse(ctx, link.courseId); + + // Bereits benutzte Links bleiben benutzt: die Anmeldung gibt es ja schon. + const { count } = await ctx.db.lateRegistrationLink.updateMany({ + where: { id: link.id, usedAt: null, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + if (count !== 1) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Der Link wurde bereits benutzt oder zurückgezogen.", + }); + } + + void logAudit(ctx.db, { + actorId: ctx.session.user.id, + actorEmail: ctx.session.user.email, + action: "course.late_registration_link.revoke", + entityType: "course", + entityId: course.id, + details: { + summary: `Nachmeldelink für „${course.title}“ zurückgezogen`, + linkId: link.id, + }, + }); + + return { success: true }; + }), + + /** Für die Anmeldeseite: ob der Link im Aufruf für diesen Kurs (noch) gilt. */ + check: rateLimitedPublicProcedure("lateRegistrationLinks.check", { + maxRequests: 60, + windowMs: 15 * 60 * 1000, + }) + .input(z.object({ courseId: z.string(), token: z.string().max(200) })) + .query(async ({ ctx, input }) => { + const { state } = await lateRegistrationLinkStateForToken( + ctx.db, + input.courseId, + input.token, + ); + return { state }; + }), +}); diff --git a/src/server/api/routers/registrations.ts b/src/server/api/routers/registrations.ts index 77d44bc5..cc04eee7 100644 --- a/src/server/api/routers/registrations.ts +++ b/src/server/api/routers/registrations.ts @@ -143,6 +143,10 @@ import { siblingDiscountWithinGroup, } from "@/lib/registration-split"; import { createRegistrationAccessToken } from "@/server/utils/registration-access-token"; +import { + assertLateRegistrationLinkUsable, + claimLateRegistrationLink, +} from "../helpers/late-registration-link"; import { logAudit } from "../helpers/audit"; import { createNotification, @@ -521,6 +525,11 @@ export const registrationsRouter = createTRPCRouter({ * verknüpfte Anmeldung. Ohne Angabe wartet die ganze Anmeldung. */ confirmedParticipantIndexes: z.array(z.number().int()).optional(), + /** + * Token eines Nachmeldelinks: hebt die Zeit-Sperren (geschlossen, noch nicht + * geöffnet, Frist vorbei) für genau eine Anmeldung auf. + */ + lateRegistrationToken: z.string().max(200).optional(), [BOT_TRAP_FIELD]: z.string().max(200).optional(), [BOT_TRAP_ELAPSED_FIELD]: z.number().optional(), }), @@ -531,6 +540,7 @@ export const registrationsRouter = createTRPCRouter({ paymentMethod: inputPaymentMethod, downPaymentAcknowledged, confirmedParticipantIndexes, + lateRegistrationToken, // Müssen hier heraus: `registrationData` geht unverändert an Prisma. [BOT_TRAP_FIELD]: botTrap, [BOT_TRAP_ELAPSED_FIELD]: botTrapElapsedMs, @@ -593,28 +603,48 @@ export const registrationsRouter = createTRPCRouter({ }); } - if (!course.registrationOpen) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Registration is closed for this course", - }); - } + // Ein Nachmeldelink ersetzt nur die Zeit-Sperren; Kapazität, Preise und + // Pflichtfelder prüft der Rest wie sonst. Eingelöst wird er erst in der + // Transaktion, damit eine gescheiterte Anmeldung ihn nicht verbraucht. + const lateRegistrationLinkId = lateRegistrationToken + ? await assertLateRegistrationLinkUsable( + ctx.db, + course.id, + lateRegistrationToken, + ) + : null; - if ( - course.registrationOpensAt && - new Date() < course.registrationOpensAt - ) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Registration is not yet open for this course", - }); - } + if (lateRegistrationLinkId) { + if (course.endDate < new Date()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Der Kurs ist bereits vorbei.", + }); + } + } else { + if (!course.registrationOpen) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Registration is closed for this course", + }); + } - if (isRegistrationDeadlinePassed(course.registrationDeadline)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Registration deadline has passed", - }); + if ( + course.registrationOpensAt && + new Date() < course.registrationOpensAt + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Registration is not yet open for this course", + }); + } + + if (isRegistrationDeadlinePassed(course.registrationDeadline)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Registration deadline has passed", + }); + } } if (input.siblingDiscountApplied && !course.allowSiblingDiscount) { @@ -778,10 +808,18 @@ export const registrationsRouter = createTRPCRouter({ return { registration, part }; }; - return { + const created = { primary: await createPart(plan.primary), waitlist: plan.waitlist ? await createPart(plan.waitlist) : null, }; + if (lateRegistrationLinkId) { + await claimLateRegistrationLink( + tx, + lateRegistrationLinkId, + created.primary.registration.id, + ); + } + return created; }); await sendRegistrationPartsEmail(parts, course); @@ -796,6 +834,20 @@ export const registrationsRouter = createTRPCRouter({ waitlistRegistration: parts.waitlist?.registration, }); + if (lateRegistrationLinkId) { + void logAudit(ctx.db, { + actorId: ctx.session?.user.id ?? null, + actorEmail: parts.primary.registration.registrantEmail, + action: "course.late_registration_link.use", + entityType: "registration", + entityId: parts.primary.registration.id, + details: { + summary: `Nachmeldung für „${course.title}“ über Nachmeldelink`, + linkId: lateRegistrationLinkId, + }, + }); + } + return withWaitlistPart(parts); }), diff --git a/src/server/utils/late-registration-token.ts b/src/server/utils/late-registration-token.ts new file mode 100644 index 00000000..b34ac5d9 --- /dev/null +++ b/src/server/utils/late-registration-token.ts @@ -0,0 +1,17 @@ +import { createHash, randomBytes } from "node:crypto"; + +/** + * Zufälliger Token für einen Nachmeldelink. Gespeichert wird nur sein Hash: Wer + * die Datenbank liest, kann damit keine Anmeldung nach der Frist abschicken. + */ +export function createLateRegistrationToken(): { + token: string; + tokenHash: string; +} { + const token = randomBytes(24).toString("base64url"); + return { token, tokenHash: hashLateRegistrationToken(token) }; +} + +export function hashLateRegistrationToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +}