Skip to content

Repository files navigation

Registrace logo

Registrace

Bilingual (CZ / EN) event-registration platform for Diamond Way Buddhism (BDC) centres.

Public visitors register themselves and fellow participants for meditation and community events; the app prices the stay server-side and emails a bilingual confirmation. Centre admins manage events, registrations and exports — all scoped by role and centre.

Live   Next.js 16 React 19 TypeScript Prisma 7 Supabase Tailwind v4 Tests Deploy

Public homepage — event list   Admin dashboard


Table of contents


What it is

Registrace is a production web application built for Buddhismus Diamantové cesty (BDC / Diamond Way Buddhism) — a network of Czech meditation centres. It replaces ad-hoc spreadsheets and email threads with a single bilingual flow:

  1. A visitor opens a published event, fills in one form for the whole group (up to 10 participants), picks arrival/departure, meals and diet per person, and submits.
  2. The server recalculates every price from the event's own pricing rules (the browser figure is informational only), writes an idempotent registration, and sends a confirmation email carrying a human-readable registration number (e.g. 260020108).
  3. Centre admins review, filter, search, mark paid, resend confirmations, and export a per-event XLSX for the kitchen and accommodation teams — always scoped to the centres they manage.

It is live in production at registrace.online on Vercel + Supabase, and has been through a full internal build (B1–B8), a production-hardening pass (P1–P8) and a multi-agent security audit. This README is the single source of orientation for anyone joining the project.


Screenshots

All screenshots show a demo dataset — fictional families on the RFC-2606 reserved example.* domains, never real registrants. The admin email addresses and the audit-log IP column are blurred here on purpose for privacy: they are shown normally to admins in the running app — only these public screenshots hide them.

Visitor side

Homepage with the list of published events (Czech) Homepage in English
Homepage — published events (CZ) Same page, one click to English
Event detail with the stay section of the registration form Participant card with age category, both price tiers, diet and priced meals
Event detail + stay (arrival, departure, accommodation) Per-participant age, two independent price tiers, diet & live total

Price overview modal with an age by tier table for the stay and one for the meals
Transparent, data-driven price overview — one age × tier table for the stay, one for the meals, and the arrival / early-departure discounts per tier.

Admin side

Event management list with lifecycle badges Event wizard on its pricing step, showing the age by tier price list
Event management — draft / published / archived, per-event export 7-step bilingual event wizard — here its price list, per age × tier
Registrations list with filters, search and status badges Registration detail with the per-participant tier editor, participants and meals
Registrations — filter, search by number, status badges Registration detail — status, accommodation, both tiers per participant
Admin management with roles and assigned centres Audit log of admin actions
Admins — SUPER_ADMIN vs centre-scoped ADMIN Audit log — who, what, when, where (super-admin only)

Per-event kitchen and accommodation totals
Per-event kitchen & accommodation planning — meat/veg counts per meal and headcount per night, ready for the kitchen and accommodation teams.


Feature highlights

For visitors

  • Bilingual throughout — every page and email in Czech or English; the switch preserves the current place.
  • Group registration — one submission for up to 10 participants, each with their own age category, diet (meat / vegetarian) and two independent price tiers (standard / supported / surplus) — one for the stay, one for the meals, so a surplus room can go with supported food. A tier the event offers only one of is not asked about at all.
  • Per-day meal selection with a per-event meal-ordering deadline (after the cut-off, meal choice is closed and enforced server-side).
  • Live, server-authoritative pricing — the form shows a running total, but the backend always recomputes the authoritative price before saving.
  • Arrival time, early departure and accommodation all feed into the price via the event's own discount and night-rate rules.
  • Human-readable registration number (YYEEENNNN) and a polished, BDC-branded confirmation email whose meal summary is grouped by day and collapsed twice — meals of one day with the same eaters share a line, and a meal nobody missed reads "everyone (N)" rather than ten repeated names, so a ten-person booking of 132 meals fits in seven lines.
  • Privacy-first — in-app GDPR consent, server-validated honeypot, idempotent submit. No cookie banner, because nothing here needs consent: the only cookies are the strictly necessary ones (the admin session and the chosen locale), there is no ad tech or cross-site tracking, and the only analytics is Vercel's cookieless page counter (details).

For admins

  • Role-based accessSUPER_ADMIN sees everything; ADMIN is scoped to their assigned centre(s); an owner tier guards super-admin management.
  • 7-step event wizard — bilingual titles/descriptions, dates, pricing rules per age & tier, meals per day, capacity, and the meal deadline. The pricing and meals steps each carry their own independent row of offered tiers, so an event can price the stay across three tiers while quoting a single meal price — only the ticked tiers are filled in and stored (the standard tier is always offered). Empty drafts stay fully editable.
  • Event lifecycle — draft → published → closed → archived. Public visibility is derived on read (an event leaves the public list at 20:00 Prague on its end day), and a daily Vercel Cron (GET /api/cron/event-lifecycle) writes the stored status to match — closed after that moment, archived three days later — so the admin list tells the truth too. Two consequences an admin sees: a closed or archived event is read-only in the admin (its registrations stay fully editable), and once archived its registrations move behind the registrations list's "show archived" switch — the event's own view still lists them all.
  • Registration workflow — filter by centre / status / archived, search by number, edit status (registered / paid / cancelled), accommodation, and each participant's two pricing tiers — every one of which is re-priced server-side by the real engine, with a meal-tier change also rewriting the stored price of each ordered meal. Plus resend confirmations, and kitchen (meat / veg totals) and accommodation (per-night headcount) tables.
  • Per-event XLSX export — one click per event, with a formula-injection-safe serializer.
  • Centre & admin management — invite/edit/remove admins, assign centres, soft-delete and restore centres. An invited admin lands on a guided password setup: the requirements are listed up front and tick as they are met, with a show/hide toggle and a live match check (details and the important caveat under Security & privacy).
  • Audit log — a forensic trail of admin actions (actor, action, entity, IP, time).

Tech stack

Layer Choice Notes
Framework Next.js 16 (App Router, Turbopack) · React 19 Server Components + route handlers; no middleware.ts — edge logic lives in proxy.ts
Language TypeScript (strict, noUncheckedIndexedAccess)
ORM Prisma 7 + @prisma/adapter-pg (pg) Driver-adapter pattern; client generated to generated/prisma (gitignored)
Database & Auth Supabase (PostgreSQL + Auth) RLS deny-all; all data access goes through Prisma
Validation Zod 4 Client-safe schemas (no Prisma imports)
Forms React Hook Form + @hookform/resolvers
i18n next-intl 4 Locales cs (default) / en
Email Resend Bilingual, inline-CSS, non-blocking
Export exceljs XLSX (chosen over the vulnerable xlsx package)
Styling Tailwind CSS v4 Design tokens via @theme in globals.css, no JS config
Tests Vitest (+ v8 coverage) 350 unit / integration tests
Analytics Vercel Web Analytics Cookieless page analytics; the only third party in the page
Hosting Vercel + own domain (Wedos DNS) Auto-deploy on push to main

Exact versions live in package.json. No Docker.


Architecture

Non-negotiable rules

These invariants are enforced across the codebase (full list in CLAUDE.md):

  1. Auth = Supabase Auth. Data = Prisma. Never mix.
  2. The pricing engine (modules/pricing) is pure, server-only, no DB access.
  3. Frontend prices are informational; backend prices are authoritative and always recomputed server-side before any DB write.
  4. UI text lives in next-intl JSON; event content lives in bilingual DB columns (*_cs / *_en).
  5. Email failure never rolls back the registration transaction.
  6. Soft delete (deletedAt) everywhere — no permanent deletion of audit-relevant data.
  7. Money = whole-CZK integers; datetimes = UTC in the DB, Europe/Prague in the UI.
  8. Registration submit is idempotent (client-supplied UUID v4 key), honeypot-guarded, and capped at 10 participants.
  9. SUPER_ADMIN sees all; ADMIN is scoped to their centre(s).

Request & data flow

flowchart LR
    subgraph Client["Browser (CZ / EN)"]
        Pub["Public pages<br/>+ registration form"]
        Adm["Admin panel"]
    end

    Edge["proxy.ts — edge middleware<br/>matcher: pages + /api/admin/** only<br/>i18n routing · CSP nonce · session refresh<br/>admin API: rate-limit · CSRF · 401 gate"]

    subgraph Next["Next.js server"]
        Pages["Server Components<br/>app/[locale]/**"]
        PubAPI["Public handlers<br/>/api/events · /api/registration/** · /api/cron/**<br/>rate-limit in handler"]
        AdmAPI["Admin handlers<br/>/api/admin/**<br/>role + ownership guard"]
        SVC["Services<br/>modules/**"]
        Price["Pricing engine<br/>modules/pricing (pure)"]
    end

    DB[("Supabase<br/>PostgreSQL")]
    AuthSvc["Supabase Auth"]
    Mail["Resend<br/>(email)"]

    Pub -->|"page requests"| Edge
    Adm -->|"page requests"| Edge
    Edge --> Pages
    Adm -->|"manage"| Edge
    Edge --> AdmAPI
    Pub -->|"submit / calculate-price<br/>(edge NOT in path)"| PubAPI
    Pages --> SVC
    PubAPI --> SVC
    AdmAPI --> SVC
    SVC --> Price
    SVC -->|Prisma| DB
    Adm -.->|"signInWithPassword"| AuthSvc
    Edge -.->|"session"| AuthSvc
    SVC -.->|"non-blocking"| Mail
Loading
  • Edge (proxy.ts) is deliberately not a global gate: its config.matcher covers pages and /api/admin/** only. On pages it does locale routing, the CSP nonce and Supabase session refresh; on the admin API it adds rate-limiting (120/min/IP), a CSRF same-origin check on mutations, and a 401 for anonymous callers. It checks session presence only.
  • The public API bypasses the edge entirely/api/events, /api/registration/**, /api/auth/me and the cron endpoint /api/cron/** are excluded by the matcher and reach their handlers directly, so each one enforces its own rate limit (submit 10/h, price 60/min, public reads 60/min per IP; the cron endpoint demands the bearer Vercel sends and throttles only failed attempts, see Routes).
  • Handlers/services are the authoritative role/ownership gate (Prisma can't run at the edge). Business logic never lives in a route handler — it lives in modules/*, which Server Components call directly rather than fetching their own API.
  • Prices are computed by the pure engine and re-verified before every write.

Pricing engine

modules/pricing/index.ts is pure and defensive (a missing rule or degenerate stay yields 0, never throws — the price endpoint calls it mid-edit on incomplete input). Per participant:

participation = dailyRate × days
              − arrival discount      (by arrival time: morning / afternoon / evening)
              − early-departure discount
              + nightRate × (days − 1)   (only when accommodation is chosen)
              floored at 0

meals         = Σ price of each unique, open, known selected meal
subtotal      = participation + meals

Pricing is data-driven: every age is charged by its matching PricingRule.dailyRate — no age is hard-coded to 0. Young children carry a 0-rate rule, but an event may charge, say, ages 8–14 (the real BDC "MLK" course does, at 100 CZK/day). Discounts apply to 15+ only because child rules carry 0 discounts — not via any age branch. The discounts are per tier, not per event: most events give a supported arrival a smaller discount than a standard one, so the price overview lists them by tier whenever they differ.

Each person carries two independent price tiers, and both apply at every age: pricingType prices the stay, mealPricingType prices the meals. Surplus accommodation with supported meals is a normal, intended combination — neither tier is ever derived from the other. A meal costs MealPricingRule(mealType, ageCategory, pricingType), a 36-cell price list per event keyed by the eater's meal tier; EventMeal.price is a legacy mirror of the 15+/standard cell and is used only by an event that has no price list at all. An event declares which tiers it offers for each half separately, and every surface honours that: the price overview filters each of its two tables by its own set, and the registration form renders a selector only for a half that offers more than one tier — so the common single-tier event shows none at all. Because the tier is real at every age, the admin registration detail, the confirmation email and the export all print both tiers for every participant, children included.

Data model

11 Prisma models, 9 enums, 6 applied migrations. The source of truth is prisma/schema.prisma.

Models (click to expand)
Model Purpose
User id @db.Uuid (= Supabase Auth id), email, role.
UserCenter Explicit User ↔ Center join (which centres an admin manages).
Center 25 seeded rows — 23 BDC centres plus the Jiné / Mimo ČR (Other / outside CZ) catch-alls a visitor can pick as their home centre. Bilingual names, sortOrder, soft-active.
Event Bilingual title/subtitle/description, contact fields, status, centerId (host centre), dates, createdBy, mealRegistrationDeadline, numberPrefix + registrationSeq (reg-number support), and the two independent sets of offered tiers — participationPricingTypes / mealPricingTypes.
EventDate A day of the event; used as arrival/departure reference.
PricingRule Per event × ageCategory × pricingType: dailyRate, nightRate and the four *Discount fields (subtracted).
MealPricingRule The event's meal price list: per mealType × ageCategory × pricingType (36 cells), what one meal costs one person. Keyed by the eater's meal tier.
EventMeal A meal slot on a given day: mealType, price, isClosed. price is a legacy mirror of the 15+/standard cell, used only by an event with no price list.
Registration The submission: home centerId, arrival/departure, hasAccommodation, email, gdprConsent, totalPrice, status, idempotencyKey, registrationNumber, locale, ipAddress.
Participant One person: ageCategory, two independent tiers — pricingType (stay/accommodation) and mealPricingType (meals) — mealType (diet), computed prices.
ParticipantMeal Participant ↔ EventMeal join with the charged price.
AuditLog Append-only trail: userId, action, entityType, entityId, oldData/newData, ip.

Enums: AgeCategory · PricingType (standard / supported / surplus) · ArrivalTime · EarlyDeparture · EventStatus (draft / published / closed / archived) · MealType (breakfast / lunch / dinner) · MealCategory (meat / vegetarian) · RegistrationStatus (registered / cancelled / paid) · UserRole (super_admin / admin).

API map

Routes (click to expand)

Public (not matched by proxy.ts — each handler rate-limits itself)

  • GET /api/events · GET /api/events/[id] — 60/min per IP
  • POST /api/registration/calculate-price — 60/min per IP
  • POST /api/registration/submit — 10/hour per IP

Admin (edge: session + rate-limit + CSRF; handler: role/ownership)

  • Events — GET/POST /api/admin/events, GET/PUT /api/admin/events/[id], PATCH /api/admin/events/[id]/status
  • Registrations — GET /api/admin/registrations, GET/PUT /api/admin/registrations/[id], POST /api/admin/registrations/export, POST /api/admin/registrations/[id]/resend-confirmation
  • Centres — GET/POST /api/admin/centers, PUT/DELETE/PATCH /api/admin/centers/[id] (DELETE soft-deletes, PATCH restores)
  • Admins — GET/POST /api/admin/users, PUT/DELETE /api/admin/users/[id], POST /api/admin/users/[id]/reset-password
  • Audit — GET /api/admin/audit-log

AuthGET /api/auth/me (60/min per IP; login/logout go through the Supabase browser client, not a route handler).

Scheduled (Vercel Cron, not matched by proxy.ts) — GET /api/cron/event-lifecycle, daily (vercel.json). Requires Authorization: Bearer $CRON_SECRET — the header Vercel adds itself — and fails closed with 503 when the secret is unset. The bearer is checked first and only failed attempts are rate-limited (10/min per IP), so the real run can never be throttled; ?dryRun=1 reports what would change without writing.

Validation errors return a canonical 400 { error, details } (Zod issues) via the shared validationError() helper.


Security & privacy

  • Content-Security-Policy with a per-request nonce + strict-dynamic (set in proxy.ts), dropping unsafe-inline from script-src in production.
  • Static security headers in next.config.ts, applied to every response — Strict-Transport-Security: max-age=31536000; includeSubDomains; preload, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy: camera=(), microphone=(), geolocation=() and Cross-Origin-Resource-Policy: same-origin. (The preload directive is a one-way commitment and only takes effect once the domain is submitted at hstspreload.org.)
  • CSRF — mutating admin requests must be same-origin, checked against NEXT_PUBLIC_APP_URL. Fail-closed: a missing Origin and Referer, or an unset NEXT_PUBLIC_APP_URL, is rejected; the any-localhost relaxation is gated to non-production.
  • Rate limiting — best-effort in-memory limits: admin API 120/min/IP at the edge; submit 10/hour, price calc 60/min and public reads 60/min enforced inside each public handler.
  • Audit log — best-effort, non-blocking; never rolls back the business write.
  • No browser-direct data access — the Supabase anon key is used for Auth only; the JS client never reads or writes tables. Every data access goes through Prisma on the server, which connects directly and bypasses RLS by design.
  • RLS — enabled deny-all on all 13 public tables (the 12 models + _prisma_migrations): row security is on and zero policies are defined, so nothing is reachable through the anon key. This is a backstop, not the access control — the real authorization is the role/ownership gate in the handlers and services. It lives in the migrations (20260721104500_enable_rls_on_all_tables) and prisma/rls.test.ts fails the build if a model is added without it. That is a correction, not a preference: RLS was originally set by hand in the Supabase dashboard, which covers the tables that exist at that moment — so MealPricingRule, created by a migration, went live with RLS off and anon granted SELECT/INSERT/UPDATE/DELETE on it through PostgREST. Verify the live state with select tablename, rowsecurity from pg_tables where schemaname = 'public' (expect all true) and select * from pg_policies where schemaname = 'public' (expect no rows). Supabase's Security Advisor reports this as informational "RLS Enabled No Policy", which is the intended state here.
  • Owner tier — only an owner (OWNER_USER_IDS, immutable Supabase ids; verified-email OWNER_EMAILS fallback) may create/modify super-admins. Both lists empty → nobody can (fail-closed).
  • Admin password policy — at least 12 characters with a lowercase letter, an uppercase letter, a digit and a symbol, shown as a live checklist while the password is typed, with a per-field show/hide toggle and a live match indicator on the confirm field (lib/validation/password). Note the split, which mirrors the pricing rule: admin passwords are set by the browser calling Supabase Auth directly, with no route of ours in between, so the checklist is informational and the authoritative gate is the policy configured in the Supabase project (Authentication → Providers → Email), currently minimum length 12 + lowercase, uppercase, digits and symbols. The two must be kept in sync, and the client must never be the laxer of the pair — a checklist that goes all-ticks on a password Supabase then refuses is worse than no checklist.
    The subtle part: GoTrue validates with strings.ContainsAny against literal ASCII sets, not Unicode categories. Ž is not an uppercase letter to it and § is not a symbol, so the rules here mirror those exact sets (and the labels say "a–z" / "A–Z" out loud, because otherwise a Czech admin types Ž, reads "uppercase ○" and assumes the form is broken). Length is the one deliberate asymmetry: we count characters where GoTrue counts bytes, which makes us stricter on accented input — the safe direction.
  • GDPR — explicit z.literal(true) consent; the stored ipAddress is retained solely for abuse prevention and never appears in the UI or exports.
  • AnalyticsVercel Web Analytics (<Analytics /> in app/layout.tsx) runs on every page. It is cookieless and does not fingerprint or track visitors across sites; its beacon posts to /_vercel/insights on this origin, which is why connect-src 'self' covers it. It is the only third party in the page, and it never sees registration data — that all moves over our own API.
  • Export hardening — XLSX cells are neutralized against spreadsheet formula injection (=, +, -, @, tab and CR are prefixed) across title, headers and every data row.
  • Idempotency & honeypot on the public submit path; max 10 participants. The honeypot is re-checked in the service layer, and an idempotency-key race is recovered on the unique constraint rather than surfacing an error.

Internationalization

  • Routing and UI copy use next-intl 4; locales are cs (default) and en, prefixed in the URL (/cs/..., /en/...) and handled in proxy.ts.
  • UI strings live in locales/cs.json / locales/en.json; keys are namespaced (form, home, event, badge, admin, …).
  • Event content is bilingual in the database (title_cs / title_en, etc.) — not in the locale files — so admins author both languages per event.
  • The confirmation email renders in the visitor's original locale (persisted on the registration), so a later admin resend stays in the right language.

Getting started

Prerequisites

  • Node.js 20+ (the tooling uses the built-in fetch/WebSocket)
  • A Supabase project (PostgreSQL + Auth)
  • A Resend account + API key (for confirmation emails)

1. Install

git clone https://github.com/Martin8O/Registrace.git registrace
cd registrace
npm install          # runs `prisma generate` via postinstall

2. Configure environment

cp .env.example .env.local
# then fill in the values — see “Environment variables” below

3. Set up the database

# Apply all migrations to your database (uses DIRECT_URL)
npx prisma migrate deploy

# Seed the centre rows (23 BDC centres + 2 catch-alls) — and nothing else.
# Idempotent, deletes nothing, safe to re-run.
npx prisma db seed

Create events and registrations from the admin panel. There is deliberately no demo data anywhere in the setup path: the seeder creates only the centre rows every instance needs. Two demo-data paths existed during the build and both are gone — one truncated Event and Registration before reseeding, the other put a fictional published event on the public homepage of whatever instance ran the documented setup command. Both are in the git history if a throwaway environment ever wants them.

4. Run

npm run dev          # http://localhost:3000

5. Admin access

Admins sign in with Supabase Auth at /<locale>/admin/login. The first SUPER_ADMIN is provisioned manually: create the user in Supabase Auth, then set their role in the database (e.g. npx tsx --env-file .env.local prisma/promote-super-admin.ts <email> once their User row exists). From then on, further admins are invited from the panel's Admins (Správci) screen, which assigns roles and centres.

Note: opening an invite/reset link signs that browser in as the link's user, replacing any session already present in every window (cookie auth is one session per browser). This is deliberate — the identity must come from the token, never from whoever happens to be logged in. If you're testing an invite while signed in as a super-admin, open it in a private window to keep your own session; the set-password page also states whose account it is.


Environment variables

Copy .env.example to .env.local. All are required in production unless noted.

Variable Purpose
DATABASE_URL Pooled Supabase connection (port 6543) — used by the app at runtime.
DIRECT_URL Direct Supabase connection (port 5432) — used by Prisma migrate/seed.
NEXT_PUBLIC_SUPABASE_URL Supabase project URL (also feeds the CSP connect-src; must be set at build).
NEXT_PUBLIC_SUPABASE_ANON_KEY Supabase anon key (browser auth client).
SUPABASE_SERVICE_ROLE_KEY Service-role key for admin user management (server only).
RESEND_API_KEY Resend API key for confirmation emails.
NEXT_PUBLIC_APP_URL The app's own origin — used for invite/reset links, the admin CSRF check and metadataBase (every absolute URL in a link preview). A wrong value silently 403s every admin write.
EMAIL_FROM Verified sender, e.g. BDC Registrace <noreply@send.registrace.online>.
CRON_SECRET Bearer token Vercel sends with the daily /api/cron/event-lifecycle call (Vercel adds the header itself once the variable exists on the project). Any random string of 32+ chars. Unset → the endpoint refuses to run (503) and event statuses stay manual.
OWNER_USER_IDS Comma-separated Supabase Auth user UUIDs allowed to manage super-admins (preferred, immutable). Find them in Supabase → Authentication → Users.
OWNER_EMAILS Legacy fallback — verified emails allowed to manage super-admins. Both owner lists empty → nobody can manage super-admins.
SUPER_ADMIN_EMAIL Optional, tooling only. Fallback address for prisma/promote-super-admin.ts when no argument is passed. Not read by the app.

NEXT_PUBLIC_* values are inlined at build time; on Vercel they must be present when the build runs. EMAIL_FROM is a runtime value, so changing it needs a redeploy.


npm scripts

Script What it does
npm run dev Start the dev server (Turbopack) on :3000.
npm run build Production build.
npm start Serve the production build.
npm run lint ESLint.
npm test Run the Vitest suite once (CI-friendly).
npm run test:watch Vitest in watch mode.
npm run test:coverage Vitest with v8 coverage.
postinstall prisma generate (regenerates the gitignored client).

Database utilities: npx prisma migrate deploy (apply migrations), npx prisma db seed (centre rows), prisma/promote-super-admin.ts (bootstrap a super-admin).


Project structure

app/
  [locale]/(public)/           public pages (home, event detail + registration form)
  [locale]/admin/(panel)/      admin panel — dashboard, events, registrations,
                               centres (/admin/centers), admins (/admin/users), logs, profile
  [locale]/admin/login|set-password|auth/confirm   auth entry points
  api/                         route handlers (public + admin) + _lib (guard, http helpers)
components/{public,admin,shared}   UI components
modules/{events,registrations,pricing,auth,centers,users}   business services (no fat handlers)
lib/                           infrastructure
  {db,security,email,export,supabase,utils,mock,admin}/   modules
  validation/                  client-safe Zod schemas + the admin password policy
  audit.ts · types.ts          audit-log writer · shared types
  auth-errors.ts               Supabase auth-error code → next-intl key
locales/{cs,en}.json           UI translations
prisma/
  schema.prisma · migrations/  data layer (6 applied migrations)
  seed.ts                      the 25 centre rows (no demo data — see Getting started)
  promote-super-admin.ts       one-off super-admin bootstrap
public/images/                 static assets (BDC logo)
proxy.ts                       edge middleware (i18n + session + admin hardening + CSP)
i18n/request.ts                next-intl request config
next.config.ts                 static security headers
prisma.config.ts               Prisma CLI config (reads DIRECT_URL)
vitest.config.ts               test runner config
generated/prisma/              generated Prisma client (gitignored)
docs/screenshots/              README images

Note the naming: the “centres” screen lives at /admin/centers and the “admins” screen at /admin/users (the route names use the model names).


Testing

npm test runs 350 Vitest tests across 26 files, with no database required:

  • Pricing engine (48) — the arithmetic against the hand-derived BDC formula, grouped by concern: children on a 0 rule, ages 8–14 on a configured rate, 15+ per tier, discounts subtracted, accommodation nights, meal pricing per age × tier (a child's lunch priced differently from an adult's, and the tier moving both), the two independent tiers pricing the stay and the meals without either leaking into the other, the flat-price fallback that keeps pre-matrix events billing exactly what they always did, defensive behaviour (missing rule, degenerate stay, over-large discount → 0, never a throw) and the full aggregated result.
  • Meal price lookup (7) — the one shared definition of what a meal costs one person, and its two fallback contracts: no price list at all → the event's flat price; a gap in a list that exists → 0, never the flat price. Plus the rule that a payload arriving without a meal tier falls back to that person's own tier rather than to the standard one.
  • Validation (16) — the Zod submit/price schemas (honeypot, participant caps, the tier accepted at every age but still bounded by its enum, the independent meal tier, diet).
  • Event configuration (8) — that an event's two tier sets must each be non-empty and contain the standard tier, and that neither price list may quote a tier the event does not offer — each list checked against its own set, never the other's.
  • Submit service (21) — control-flow with a mocked Prisma (vi.mock('@/lib/db')) while keeping the real engine, so totalPrice is asserted end-to-end; plus the two tiers pricing the two halves independently, both being persisted, each meal snapshotted at the meal tier's price, a tier the event does not offer being refused before anything is written, and both tiers reaching the confirmation email — for a child as well as an adult. Three more pin that each ordered meal reaches the mail with its day and that day's sort order, in the caller's language: the confirmation groups meals by day, which it cannot do from the pre-composed slot label it used to be handed.
  • Admin re-pricing (30) — that toggling a registration's accommodation re-prices it through the real engine (both directions, children included — no age is special-cased), that a centre or status edit writes no price and issues no extra query, that the registration and its participants move in one transaction, that a participant eating on a non-standard tier keeps their meal price, and that the meals already ordered survive a re-price after the meal deadline. That last one is the trap: the submit path strips meals once the cut-off passes, and copying that gate into an edit would delete what people had ordered. Plus the tier edit: each of the two tiers re-prices its own half and only its own half, a meal-tier change also re-snapshots every stored ParticipantMeal.price, an unchanged tier list re-prices nothing, and a tier the event does not offer — or a participant from another registration — is refused before anything is written. Two of them guard the shape of that refusal: only a tier the admin is actually changing is checked, so one participant stranded on a tier the event no longer offers cannot block every save on the registration; and the meal re-snapshot is written one statement per distinct price, because row-at-a-time made a large booking dozens of sequential round trips inside a single transaction. Three more check the audit entry: a tier edit records the before/after tiers of exactly the people who moved, beside the old and new total, and adds nothing at all when no tier moved.
  • Event lifecycle (15) — the one definition of when a published event closes (20:00 Prague on its end day, in summer and winter time alike) and archives (20:00 three days later, pinned across a DST switch and across a year end), that DRAFT and ARCHIVED are never touched, that a missed close goes straight to ARCHIVED, and that the derivation agrees with isPubliclyVisible at every instant. Plus the scheduled job's write: guarded by the status it read (a concurrent admin edit wins and is skipped, audit entry included), audited as a system write, and a dry run that reports the same plan and writes nothing.
  • Cron endpoint (9) — that nothing runs without the bearer Vercel sends, that a deployment with no CRON_SECRET fails closed (503) rather than open, that an authorised call is never throttled while failed attempts are, and that ?dryRun=1 reaches the service as a dry run. Two more pin vercel.json to the route: it schedules exactly this path, with a once-a-day expression (the Hobby plan rejects anything more frequent at deploy) — move the route and this fails instead of the job silently stopping.
  • Resend gate (2) — that re-sending the confirmation for a cancelled registration is refused before anything leaves, and that a registered or paid one still sends. The template is headed "Potvrzení registrace" and prints an amount to pay, so for a cancelled booking it states the opposite of the truth — on the one surface the guest keeps. The test asserts the absence of a send rather than a thrown error, because reporting the send as failed afterwards would look the same from the outside and would still have delivered the mail.
  • Link previews (8 + 2) — what a shared event link shows in a chat. The card carries the centre, the event's name and its dates, and the meal cut-off as its description; a draft or finished event gets the neutral site card instead, with its name absent from the whole response. Both halves are checked, because the metadata and the card image are two separate reads and the gate has to hold in each: the image is where a leak would be hardest to notice, since the name would be baked into a PNG that chat clients cache for days. Four more pin what must not reach a meta tag — the description (operational instructions whose first 160 characters are a price table) and the subtitle (a field the wizard cannot fill) — and that the card falls back to the other language rather than previewing a nameless event.
  • Public date formatting (5) — the two strings the public side renders dates with. The meal cut-off is stored in UTC and read in Europe/Prague: the live event's deadline is 21:59Z, which is 23:59 there, so a UTC render would state the one number whose purpose is a cut-off two hours early — and at 22:00Z it would name the wrong day entirely. One formatter, because the same string is rendered on the event page and inside the link preview.
  • CSRF origin gate (13) — that the admin origin check accepts the canonical origin and a Vercel preview's own url, and rejects everything else: foreign origins, a missing Origin + Referer, localhost in production, and — the regression that matters — a vercel.app origin while running in production.
  • Export & auth (12 + 4) — the registration-export scoping (including the cross-centre IDOR regression), the export's two independent tier columns shown at every age, and the owner-tier auth helpers. Plus two properties of the workbook itself: the “selection” sheet picks its columns out of the full sheet by name, so its last column is provably participant 1's name rather than whatever now sits at a fixed index; and an eventId this admin may not see is a 404 rather than a 200 carrying an empty file — refused before the row query runs.
  • Email wording and its meal summary (14) — the confirmation email's own labels, held against the locale files, and the by-day summary that only exists inside the template. It is the least observable surface in the app: a wrong label in the admin panel is seen the next time someone opens the page, but a wrong label in a confirmation is seen only by whoever receives one, and by then it has been sent. The rest of the mail is covered by the submit suite, which asserts the data reaching it and never its words. These check that the stay tier is named in the mail exactly as the form names it — one amount must not arrive under two names — that the keys its table cell needs all exist, and that both languages define the same keys, since a key missing from one renders as raw text for half the recipients. Nine more cover the meal summary, whose collapsing is observable only in the rendered HTML: meals of one day sharing the same eaters go on one line, a meal nobody missed is named "everyone" rather than spelled out (the exception is the information), a partial set IS spelled out, two namesakes who ordered differently are not merged into one line (the grouping keys on the participant's index, never their name), a one-person registration names nobody at all, days sort by the event's own order rather than by their human labels, the slot count is right, and an empty order says so in words instead of rendering an empty grid.
  • Auth error wording (8) — the Supabase-code → message mapping, plus a check that every key it can return is translated in both locales (an unmapped key would render as raw text).
  • Password policy (24) — that the rules mirror GoTrue's literal ASCII sets (Czech accented letters and non-ASCII symbols must not tick a rule, or the checklist would green-light a password Supabase rejects), that the checklist and the submit gate can never disagree, and that every rule is labelled in both locales.
  • Component rendering (21 + 18 + 12) — the two islands that move money, rendered for real in jsdom with the actual locale file as messages (so a missing key fails here rather than showing a raw key to a registrant). The public form: which tier selector each of the four offer-variants renders, meal labels priced from the meal tier and repainted by it and by age but never by the stay tier, and — the trap the whole matrix was built to find — both tiers sent explicitly even when only one selector exists, because an omitted meal tier would fall back to the stay tier, miss the price list and bill the meal at 0. Plus the success panel: the registration number, the confirmation actually going to the address shown, the honest "it did not send" when it did not, and nothing extra for the honeypot's numberless fake success. The admin tier editor: the same four variants, each half listing only its own set, an empty set reading as all three, a save that sends choices and never amounts, and a refusal that states its reason instead of "try again". Three more guard the resend button: it is disabled for a cancelled registration and says why, it stays available for a live one, and it follows the status the admin has SELECTED rather than the stored one — confirming a booking somebody is in the middle of cancelling is the same contradiction one save later. Three cover the stranded case the click-through could not reach at all: the stored tier stays in the options so the select cannot show a different one, a single-tier half still renders when somebody is stranded on it (otherwise the block opened with a name and no control, hiding the tier it exists to reveal), and nobody else's controls are dragged into view. The event wizard: the description survives the review step — the last screen before publishing — with every line break and the blank line intact, under the rule that makes those breaks visible rather than collapsed, and its box is tall enough to look like somewhere paragraphs belong. That suite exists because opening the wizard means signing in, which means typing a password into a form, so it is the one screen no click-through covers. The same suite pins that publishing is a transition, not a state: an event that is already public is saved without being asked for permission to publish it, is reported as saved rather than as newly published, and offers one button instead of two — while a draft, a closed event going public again, and a brand-new event all still confirm before they become visible.
  • Price popups (12 + 21) — the two informational panels a registrant opens to see how a number was reached. The price overview: an all-zero column and an all-zero category are dropped whole (a Těnovice weekend charges no daily rate, nothing under 15 and nothing to feed a toddler, which used to print as eight cells of “0 CZK” around the two numbers that matter), while a category priced on one tier and free on another keeps every row — dropping just the free one would read as “standard is missing” rather than “standard is free”. A fully priced event is provably untouched, tier collapsing still works, and an event that charges nothing at all says so in words instead of rendering an empty grid. Two more hold the discounts table's orientation — kinds down, tiers across, and only the tiers the stay is offered on: with the four kinds as columns the last two sat outside a horizontal scrollbar, amounts the event really deducts hidden behind a gesture nobody makes while reading. The participation breakdown: the fix for a popup that answered “this category is not charged” beside a participation price of 400 CZK, because it read dailyRate === 0 as “free” while the nights were priced by the other half of the same rule. Nine of its cases re-price the same scenario through the real engine and compare totals, so the informational panel and the authoritative arithmetic cannot drift apart in silence. Two more pin that the label for that number says the same thing in all three places it appears — the overview's first table, the form row and the breakdown's total — because the stay half is a daily rate AND a rate per night, and naming only one of them is how 400 CZK ended up under a heading that never mentioned nights.
  • Event edit lock (7) — that an event stops accepting relation edits the moment anything references it (a DRAFT with a registration is as locked as a published one — the lock is not about publishing), and that neither tier set is ever written by the scalar path. This is what makes a stranded tier unreachable through the product, and it used to be argued only in a comment; adding either set to that whitelist "for completeness" now fails a test.
  • RLS guard (3) — that every model's table has ENABLE ROW LEVEL SECURITY in a migration, and that no migration defines a policy or forces RLS on the owner. It reads the schema and the migration SQL, not the database. It exists because RLS used to be a dashboard setting: it covered the tables that existed when it was clicked, and MealPricingRule arrived later through a migration with RLS off and anon holding read/write on it.
  • Docs guard (10) — the counts on this page. Every number above is parsed back out of the README and checked against the test files (via the TypeScript AST, so it.each expands and regex literals aren't mistaken for code), as are the badge, the tech-stack row and AGENTS.md. It also checks that every counted file is one Vitest is configured to run, so a test outside the include globs can't inflate the total with cases nobody executes. It exists because "a 22-scenario matrix" outlived the matrix by two audits: both checked the total, which was right, and trusted the prose beside it.

Deployment

  • Hosting: Vercel (serverless), auto-deploying every push to main (~1–2 min).
  • Database/Auth: Supabase (eu-west-1). Migrations are applied with prisma migrate deploy (a no-op when already in sync).
  • Domain: registrace.online — apex canonical, www → 308 → apex, DNS kept at Wedos.
  • Email: Resend sends from the verified subdomain send.registrace.online (DKIM/SPF/DMARC), isolating sending reputation.
  • Scheduled job: vercel.json registers a daily cron (15 2 * * *, UTC) that calls GET /api/cron/event-lifecycle. It needs CRON_SECRET set on the Vercel project — Vercel then sends it as the bearer itself; without it the endpoint answers 503 every night and event statuses stay manual, with the only symptom in the cron log. On the Hobby plan the run lands anywhere inside the scheduled hour and delivery is best-effort; the job reconciles rather than steps, so a missed or doubled run is harmless. Check a fresh deploy with curl -H "Authorization: Bearer $CRON_SECRET" "https://registrace.online/api/cron/event-lifecycle?dryRun=1".
  • Build note: the Prisma client is gitignored and regenerated on Vercel via the postinstall hook; all NEXT_PUBLIC_* vars must be set at build time.

Documentation

Document What's in it
CLAUDE.md Project constitution — the 20 architectural invariants, roles, folder map and translation-key conventions the code is held to. Written for Claude Code, which built the app; readable as plain architecture notes. It also references a local/ workspace that is gitignored and not published — see the note at the top of the file.
AGENTS.md Briefing for AI coding agents (the agents.md convention, read by Claude Code, Cursor, Copilot and others) — commands, the non-negotiable rules, and the things about this codebase that surprise people. Its top block is Next.js-managed and points agents at the version-matched docs bundled in node_modules/next/dist/docs/.
.env.example Annotated environment-variable template — every variable with its purpose, where to find its value, and the build-time vs runtime distinction.
LICENSE MIT.

This README is the only document a reader needs; the rest are supporting detail.


Status & roadmap

The full build (B1–B8) and production-hardening (P1–P8) phases are complete, and the app is deployed and verified in production. A multi-agent security audit has been run and its findings fixed.

Known parking-lot items (non-blocking):

  • Persist a form draft so switching language mid-registration doesn't reset the form.
  • Move the in-memory rate-limiter to a shared store (Upstash/Postgres) if serverless scale demands it.
  • Optional granular Supabase RLS policies, should any browser-direct data reads ever be added (none are currently planned).
  • If the app ever moves to a domain under bdc.cz, the link previews need one pass. NEXT_PUBLIC_APP_URL is the single origin the whole card is built from — change it on Vercel and every og:url / og:image follows — but the card also prints registrace.online as its own watermark, and that string is hard-coded in the two opengraph-image routes rather than derived from the origin. Deliberate: the watermark is a wordmark, not a link, and deriving it would have printed localhost:3000 on every card in development. It is written down here so the move does not leave a beautifully rendered card advertising an address the site no longer answers on. Chat clients cache scraped cards for days, so plan on re-sharing links after the cutover rather than expecting the old previews to refresh themselves.

License & credits

Licensed under the MIT License — see LICENSE. The code is open to read, learn from and reuse.

The Buddhismus Diamantové cesty (BDC) name, logo and visual identity belong to BDC and are not covered by the MIT grant, which applies to the source code only.

Built with Next.js, Prisma, Supabase, next-intl, Zod, React Hook Form, Resend, exceljs, Tailwind CSS and Vitest.

Designed and built by Martin Svobodasvobodamartin.dev.

About

A bilingual (CZ/EN) event-registration platform for 25 Diamond Way Buddhism centres — self + group registration, server-side pricing, emailed confirmations, role-scoped admin. Next.js + Prisma + Supabase.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages