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.
- What it is
- Screenshots
- Feature highlights
- Tech stack
- Architecture
- Security & privacy
- Internationalization
- Getting started
- Environment variables
- npm scripts
- Project structure
- Testing
- Deployment
- Documentation
- Status & roadmap
- License & credits
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:
- 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.
- 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). - 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.
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.
![]() |
![]() |
| Homepage — published events (CZ) | Same page, one click to English |
![]() |
![]() |
| Event detail + stay (arrival, departure, accommodation) | Per-participant age, two independent price tiers, diet & live total |

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

Per-event kitchen & accommodation planning — meat/veg counts per meal and headcount per night, ready for the kitchen and accommodation teams.
- 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).
- Role-based access —
SUPER_ADMINsees everything;ADMINis 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).
| 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 |
| 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.
These invariants are enforced across the codebase (full list in
CLAUDE.md):
- Auth = Supabase Auth. Data = Prisma. Never mix.
- The pricing engine (
modules/pricing) is pure, server-only, no DB access. - Frontend prices are informational; backend prices are authoritative and always recomputed server-side before any DB write.
- UI text lives in next-intl JSON; event content lives in bilingual DB columns
(
*_cs/*_en). - Email failure never rolls back the registration transaction.
- Soft delete (
deletedAt) everywhere — no permanent deletion of audit-relevant data. - Money = whole-CZK integers; datetimes = UTC in the DB, Europe/Prague in the UI.
- Registration submit is idempotent (client-supplied UUID v4 key), honeypot-guarded, and capped at 10 participants.
- SUPER_ADMIN sees all; ADMIN is scoped to their centre(s).
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
- Edge (
proxy.ts) is deliberately not a global gate: itsconfig.matchercovers 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/meand 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.
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.
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).
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 IPPOST /api/registration/calculate-price— 60/min per IPPOST /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](DELETEsoft-deletes,PATCHrestores) - 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
Auth — GET /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.
- Content-Security-Policy with a per-request nonce +
strict-dynamic(set inproxy.ts), droppingunsafe-inlinefromscript-srcin 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=()andCross-Origin-Resource-Policy: same-origin. (Thepreloaddirective 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 unsetNEXT_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) andprisma/rls.test.tsfails 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 — soMealPricingRule, created by a migration, went live with RLS off andanongranted SELECT/INSERT/UPDATE/DELETE on it through PostgREST. Verify the live state withselect tablename, rowsecurity from pg_tables where schemaname = 'public'(expect alltrue) andselect * 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-emailOWNER_EMAILSfallback) 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 withstrings.ContainsAnyagainst 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 storedipAddressis retained solely for abuse prevention and never appears in the UI or exports. - Analytics — Vercel Web Analytics
(
<Analytics />inapp/layout.tsx) runs on every page. It is cookieless and does not fingerprint or track visitors across sites; its beacon posts to/_vercel/insightson this origin, which is whyconnect-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.
- Routing and UI copy use next-intl 4; locales are
cs(default) anden, prefixed in the URL (/cs/...,/en/...) and handled inproxy.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.
- Node.js 20+ (the tooling uses the built-in
fetch/WebSocket) - A Supabase project (PostgreSQL + Auth)
- A Resend account + API key (for confirmation emails)
git clone https://github.com/Martin8O/Registrace.git registrace
cd registrace
npm install # runs `prisma generate` via postinstallcp .env.example .env.local
# then fill in the values — see “Environment variables” below# 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 seedCreate 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.
npm run dev # http://localhost:3000Admins 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.
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_FROMis a runtime value, so changing it needs a redeploy.
| 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).
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).
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
0rule, 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, sototalPriceis 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
isPubliclyVisibleat 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_SECRETfails closed (503) rather than open, that an authorised call is never throttled while failed attempts are, and that?dryRun=1reaches the service as a dry run. Two more pinvercel.jsonto 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.apporigin 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
eventIdthis 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 === 0as “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 SECURITYin 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, andMealPricingRulearrived later through a migration with RLS off andanonholding 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.eachexpands and regex literals aren't mistaken for code), as are the badge, the tech-stack row andAGENTS.md. It also checks that every counted file is one Vitest is configured to run, so a test outside theincludeglobs 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.
- Hosting: Vercel (serverless), auto-deploying every push to
main(~1–2 min). - Database/Auth: Supabase (
eu-west-1). Migrations are applied withprisma 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.jsonregisters a daily cron (15 2 * * *, UTC) that callsGET /api/cron/event-lifecycle. It needsCRON_SECRETset 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 withcurl -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
postinstallhook; allNEXT_PUBLIC_*vars must be set at build time.
| 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.
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_URLis the single origin the whole card is built from — change it on Vercel and everyog:url/og:imagefollows — but the card also printsregistrace.onlineas its own watermark, and that string is hard-coded in the twoopengraph-imageroutes rather than derived from the origin. Deliberate: the watermark is a wordmark, not a link, and deriving it would have printedlocalhost:3000on 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.
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 Svoboda — svobodamartin.dev.










