diff --git a/STAFF_PERMISSIONS_REPORT.md b/STAFF_PERMISSIONS_REPORT.md new file mode 100644 index 0000000..6644aa7 --- /dev/null +++ b/STAFF_PERMISSIONS_REPORT.md @@ -0,0 +1,1212 @@ +# Spanglish Staff Permissions Report + +**Audit date:** 22 August 2026 +**Repository:** `/home/dev/Spanglish` +**Scope:** Backend API, frontend admin interface, authentication, database schema and migrations, photo API, tests, deployment configuration, and documentation. + +## Executive summary + +Spanglish currently uses four staff roles: + +1. `admin` +2. `organizer` +3. `staff` +4. `marketing` + +The platform also has the non-staff `user` role. + +These values are flat role strings, not a true rank hierarchy. There is no coded inheritance such as “admin automatically inherits organizer.” Each protected API endpoint explicitly lists every role it permits. Consequently, adding a new role or changing a role’s intended scope requires reviewing every relevant endpoint. + +The effective model is: + +- **Admin:** full platform administration. +- **Organizer:** broad event, attendee, payment, email, media, and gallery operations, without user administration or global settings. +- **Staff:** door, scanner, check-in, walk-in, and selected ticket operations. +- **Marketing:** newsletter subscriber access and draft-event visibility, although its frontend is currently misaligned with its API permissions. +- **User:** self-service customer functions only. + +The backend API is the authoritative permission boundary. Nginx does not enforce roles, and Next.js middleware only checks whether a session cookie exists. Frontend navigation and redirects improve the user experience but do not provide security. + +Several important mismatches currently exist: + +- Marketing is directed to the Contacts page, but its API denies marketing users. +- Staff can navigate to event detail pages whose ticket and email requests deny staff. +- Staff can see event CRUD controls that the API rejects. +- Organizer can access Payments and Payment Options pages that call admin-only APIs. +- `/admin/tickets` is linked from the dashboard but missing from the sidebar and route permission map. + +The most significant authorization weakness found is: + +- `POST /api/tickets/:id/mark-payment-sent` does not require authentication or ticket ownership. Anyone who knows a ticket UUID can move the payment into review. + +--- + +## 1. Canonical role model + +### 1.1 Defined roles + +| Role | Staff role | Default | Intended purpose | +|---|---:|---:|---| +| `admin` | Yes | First registered account only | Full platform administration | +| `organizer` | Yes | No | Events and operational management | +| `staff` | Yes | No | Door, scanner, and check-in operations | +| `marketing` | Yes | No | Subscriber and marketing operations | +| `user` | No | Yes | Customer self-service | + +The SQLite application schema defines: + +```ts +role: text('role', { + enum: ['admin', 'organizer', 'staff', 'marketing', 'user'], +}) + .notNull() + .default('user') +``` + +Source: `backend/src/db/schema.ts:14` + +The same list is validated by the user update API: + +```ts +role: z.enum(['admin', 'organizer', 'staff', 'marketing', 'user']).optional() +``` + +Source: `backend/src/routes/users.ts:23` + +The frontend mirrors it in its API types: + +```ts +role: 'admin' | 'organizer' | 'staff' | 'marketing' | 'user' +``` + +Source: `frontend/src/lib/api/types.ts:187` + +### 1.2 No role inheritance + +The authorization middleware checks exact role membership: + +```ts +if (roles && !roles.includes(user.role)) { + return c.json({ error: 'Forbidden' }, 403); +} +``` + +Source: `backend/src/lib/auth.ts:76-90` + +For example: + +```ts +requireAuth(['admin', 'organizer', 'staff']) +``` + +permits those three roles only. It does not imply a hierarchy, and it does not include marketing unless marketing is explicitly listed. + +The codebase uses several repeated role groups: + +| Group or convention | Roles | +|---|---| +| Better Auth administrators | `admin` | +| Application “admin” flag | `admin`, `organizer` | +| Admin-interface access | `admin`, `organizer`, `staff`, `marketing` | +| Door staff | `admin`, `organizer`, `staff` | +| Privileged draft-event viewers | `admin`, `organizer`, `staff`, `marketing` | +| Photo API administrators | `admin`, `organizer` | + +### 1.3 Account-status restrictions + +A role alone is not sufficient. Backend session resolution rejects: + +- banned users; +- users whose `accountStatus` is not `active`; +- requests without a valid Better Auth session. + +Source: `backend/src/lib/auth.ts:38-73` + +This means suspended accounts immediately lose API access even if they retain a staff role. + +--- + +## 2. Authorization layers + +### 2.1 Nginx + +Production nginx proxies: + +- `/api/photos/` to the Go photo API; +- `/api` to the Node backend; +- `/uploads` to the backend; +- all other paths to the Next.js frontend. + +It does not validate sessions or roles. + +Source: `deploy/prod_nginx/frontend.nginx.conf:46-114` + +### 2.2 Next.js middleware + +Next.js middleware checks only for the presence of a session cookie on `/admin/*` and `/dashboard/*`. + +It does not: + +- validate the cookie; +- load the user; +- verify the account status; +- verify a role; +- stop a regular signed-in user from receiving the admin application shell. + +Source: `frontend/src/middleware.ts:27-44` + +This is an initial navigation convenience, not an authorization boundary. + +### 2.3 Frontend authentication context + +The frontend computes: + +```ts +const isAdmin = + user?.role === 'admin' || + user?.role === 'organizer'; + +const hasAdminAccess = + user?.role === 'admin' || + user?.role === 'organizer' || + user?.role === 'staff' || + user?.role === 'marketing'; +``` + +Source: `frontend/src/context/AuthContext.tsx:181-182` + +`hasAdminAccess` controls whether staff-oriented users receive an Admin link and can remain in the admin layout. `isAdmin` is currently exported but not used by frontend pages. + +### 2.4 Admin layout + +The admin layout: + +- filters navigation items by role; +- redirects known disallowed routes; +- sends staff to `/admin/scanner`; +- sends marketing to `/admin/contacts`; +- sends admin and organizer to `/admin`. + +Source: `frontend/src/app/admin/layout.tsx:63-114` + +These are client-side controls. Direct API calls remain governed by the backend. + +### 2.5 Backend API + +The backend is authoritative. It uses: + +- `requireAuth()` for any active authenticated user; +- `requireAuth([...roles])` for exact role allowlists; +- inline role checks; +- resource ownership checks; +- ticket or booking UUIDs as capability tokens in selected public flows. + +Source: `backend/src/lib/auth.ts` + +### 2.6 Photo API + +The Go photo API independently treats: + +- `admin`; +- `organizer`; + +as photo administrators. + +Staff and marketing do not receive photo administration access. + +Source: `photo-api/internal/auth/auth.go:31-48` + +--- + +## 3. Effective permission matrix + +“Allowed” below means the backend API permits the role. A visible page or button does not change this result. + +| Capability | Admin | Organizer | Staff | Marketing | +|---|---:|---:|---:|---:| +| Enter the staff interface | Allowed | Allowed | Allowed | Allowed | +| Admin dashboard | Allowed | Allowed | Denied | Denied | +| Analytics | Allowed | Denied | Denied | Denied | +| List all users | Allowed | Denied | Denied | Denied | +| Assign staff roles | Allowed | Denied | Denied | Denied | +| Suspend users | Allowed | Denied | Denied | Denied | +| Delete users | Allowed | Denied | Denied | Denied | +| View published events | Allowed | Allowed | Allowed | Allowed | +| View draft events | Allowed | Allowed | Allowed | Allowed | +| Create events | Allowed | Allowed | Denied | Denied | +| Update events | Allowed | Allowed | Denied | Denied | +| Duplicate events | Allowed | Allowed | Denied | Denied | +| Delete events | Allowed | Denied | Denied | Denied | +| View event attendees | Allowed | Allowed | Allowed | Denied | +| Door attendee search | Allowed | Allowed | Allowed | Denied | +| Check in attendees | Allowed | Allowed | Allowed | Denied | +| Undo check-in | Allowed | Allowed | Allowed | Denied | +| Create walk-ins | Allowed | Allowed | Allowed | Denied | +| View door summary | Allowed | Allowed | Allowed | Denied | +| List all tickets | Allowed | Allowed | Denied | Denied | +| Validate/search tickets | Allowed | Allowed | Allowed | Denied | +| Add ticket notes | Allowed | Allowed | Allowed | Denied | +| Mark cash payment paid | Allowed | Allowed | Allowed | Denied | +| Create tickets administratively | Allowed | Allowed | Allowed | Denied | +| Cancel another user’s ticket | Allowed | Denied | Denied | Denied | +| View payment administration | Allowed | Partial | Denied | Denied | +| Approve/reject payments | Allowed | Allowed | Denied | Denied | +| Refund payments | Allowed | Denied | Denied | Denied | +| Global payment options | Allowed | Denied | Denied | Denied | +| Event payment overrides | Allowed | Allowed | Denied | Denied | +| Contacts inbox | Allowed | Allowed | Denied | Denied | +| Newsletter subscriber list | Allowed | Denied | Denied | Allowed | +| Read/send operational email | Allowed | Allowed | Denied | Denied | +| Create/update/delete email templates | Allowed | Denied | Denied | Denied | +| Media library | Allowed | Allowed | Denied | Denied | +| Photo gallery administration | Allowed | Allowed | Denied | Denied | +| Attendee/ticket exports | Allowed | Denied | Denied | Denied | +| Financial exports | Allowed | Denied | Denied | Denied | +| Legal pages | Allowed | Denied | Denied | Denied | +| FAQ administration | Allowed | Denied | Denied | Denied | +| Site settings | Allowed | Denied | Denied | Denied | + +--- + +## 4. Admin permissions + +### 4.1 Summary + +`admin` is the only full-control role and the only role recognized as an administrator by the Better Auth admin plugin. + +Source: `backend/src/lib/betterAuth.ts:354-358` + +### 4.2 User administration + +Admin can: + +- list all users; +- view user statistics; +- view any user; +- update any user; +- assign any defined role; +- change email addresses; +- change account status; +- suspend accounts; +- delete non-admin users; +- access another user’s ticket history. + +Relevant sources: + +- `backend/src/routes/users.ts:29` +- `backend/src/routes/users.ts:93` +- `backend/src/routes/users.ts:120-122` +- `backend/src/routes/users.ts:151-219` +- `backend/src/routes/users.ts:227-229` +- `backend/src/routes/users.ts:261-279` + +Admin cannot delete an account while it still has the `admin` role, but it can first demote that account and then delete it. + +There is no protection preventing: + +- self-demotion; +- demotion of the final admin; +- a state in which no admin accounts remain. + +### 4.3 Event administration + +Admin can: + +- view all events, including drafts; +- create events; +- update events; +- duplicate events; +- delete events; +- set a featured event; +- view event attendees; +- perform all door operations. + +### 4.4 Ticket administration + +Admin can: + +- list all tickets; +- search and validate tickets; +- check in and undo check-in; +- mark cash payments paid; +- add administrative notes; +- create tickets; +- add attendees; +- cancel another user’s ticket; +- access privileged ticket details; +- download ticket and attendee exports. + +### 4.5 Payment administration + +Admin can: + +- list all payments; +- view payment statistics; +- approve or reject payment submissions; +- place payments on hold; +- edit payment records where supported; +- refund payments; +- view and change global payment options; +- configure event-specific payment overrides; +- export financial data. + +### 4.6 Communications and content + +Admin can: + +- manage the contacts inbox; +- list newsletter subscribers; +- read and send operational emails; +- create, update, and delete email templates; +- manage media; +- manage photo galleries; +- configure legal pages; +- manage FAQ content; +- manage site settings. + +### 4.7 Important admin risks + +- Role changes are not written to the existing `audit_logs` table. +- The final admin can be demoted. +- There is no event or tenant scope; all admin access is global. +- Direct database access can assign arbitrary role values. + +--- + +## 5. Organizer permissions + +### 5.1 Summary + +`organizer` is a broad operations role. It is considered an application administrator by some frontend naming, but it is not a Better Auth administrator and cannot manage users or global platform configuration. + +### 5.2 Allowed access + +Organizer can: + +- open the admin dashboard; +- view published and draft events; +- create, update, and duplicate events; +- view event attendees; +- use the full door scanner; +- search and validate tickets; +- check attendees in and undo check-in; +- create walk-ins and administrative tickets; +- mark cash payments paid; +- list tickets; +- approve and reject payments; +- configure event-specific payment overrides; +- manage contacts; +- read and send operational emails; +- manage media; +- manage Node and Go photo galleries. + +### 5.3 Denied access + +Organizer cannot: + +- list or manage users; +- assign roles; +- delete events; +- access analytics; +- export attendees, tickets, or financial data; +- issue refunds; +- change global payment options; +- create, update, or delete email templates; +- manage legal pages; +- manage FAQ content; +- change global site settings. + +### 5.4 Frontend mismatches + +#### Payments page + +The organizer is permitted to navigate to `/admin/payments`, but the page loads multiple resources together, including the full payment list. That list is admin-only. + +Result: + +- the organizer is authorized for payment approval operations; +- the page can still fail because one of its initial requests returns `403`. + +Relevant sources: + +- `frontend/src/app/admin/payments/page.tsx:79-86` +- `backend/src/routes/payments.ts:37` + +#### Payment Options page + +The organizer sees `/admin/payment-options` in navigation, but global payment options are admin-only. + +Result: + +- event-specific overrides are permitted; +- the global Payment Options page is not. + +Relevant sources: + +- `frontend/src/app/admin/payment-options/page.tsx:57` +- `backend/src/routes/payment-options.ts:85` +- `backend/src/routes/payment-options.ts:123` +- `backend/src/routes/payment-options.ts:273-284` + +#### Email templates + +The organizer can read templates and send emails but cannot create, update, or delete templates. The frontend should clearly disable or hide those mutation controls. + +Relevant sources: + +- `backend/src/routes/emails.ts:61` +- `backend/src/routes/emails.ts:103` +- `backend/src/routes/emails.ts:149` +- `backend/src/routes/emails.ts:210` + +--- + +## 6. Staff permissions + +### 6.1 Summary + +`staff` is primarily a door and check-in role. Its strongest supported workflow is `/admin/scanner`. + +The common door group is: + +```ts +const STAFF_ROLES = ['admin', 'organizer', 'staff'] +``` + +Source: `backend/src/routes/door.ts:37` + +### 6.2 Allowed access + +Staff can: + +- enter the admin interface; +- use `/admin/scanner`; +- view the events list; +- view draft events through the events API; +- load door attendees; +- search attendees; +- resolve QR data used by the scanner; +- check attendees in; +- undo check-in; +- create walk-in attendees; +- view door summaries; +- view event attendees through the dedicated event attendee endpoint; +- search and validate tickets; +- add ticket notes; +- mark cash payments paid; +- create tickets administratively; +- add attendees through supported ticket operations. + +### 6.3 Financial power at the door + +Staff can mark payment as received: + +```ts +ticketsRouter.post( + '/:id/mark-paid', + requireAuth(['admin', 'organizer', 'staff']), + ... +) +``` + +Source: `backend/src/routes/tickets.ts:1077-1079` + +The nearby comment says “admin only,” but the code permits organizer and staff. The code is authoritative; the comment is stale. + +This is a meaningful financial permission because it can confirm tickets associated with a cash payment. + +### 6.4 Denied access + +Staff cannot: + +- create, update, duplicate, or delete events through the API; +- list all tickets through the general ticket-list endpoint; +- administer payment approval queues; +- issue refunds; +- manage payment options; +- access exports; +- manage contacts; +- manage email; +- manage media or photo galleries; +- list or manage users; +- manage legal, FAQ, or site settings; +- cancel another user’s ticket. + +### 6.5 Frontend mismatches + +#### Event detail + +The admin layout permits staff under `/admin/events`, including child paths. Event rows link to event detail pages. + +The event detail data hook loads several resources in a single `Promise.all`, including: + +- the full ticket list; +- email templates. + +Those endpoints permit admin and organizer, not staff. + +Result: + +- staff can open the route; +- the event itself is visible; +- required page data returns `403`; +- the page may fail rather than showing a staff-safe subset. + +Relevant sources: + +- `frontend/src/app/admin/layout.tsx:63-78` +- `frontend/src/app/admin/events/[id]/_hooks/useEventDetailData.ts:19-25` +- `backend/src/routes/tickets.ts:1734` +- `backend/src/routes/emails.ts:61` + +#### Event CRUD controls + +The events page shows controls for: + +- creating events; +- editing events; +- deleting events; +- duplicating events; +- setting a featured event. + +The corresponding APIs do not grant those powers to staff. + +Result: + +- staff sees actions it cannot complete; +- requests return `403`; +- deletion and featured-event actions are even narrower than create/update. + +#### Scanner navigation + +The scanner itself is correctly aligned with backend permissions. For a staff user, its back link goes to `/admin/events`. + +Source: `frontend/src/app/admin/scanner/page.tsx:67-68` + +That destination exposes the event-detail mismatch described above. + +--- + +## 7. Marketing permissions + +### 7.1 Summary + +`marketing` is the narrowest staff role. Its implemented backend permissions are: + +- newsletter subscriber list; +- privileged visibility of draft events; +- normal authenticated self-service access. + +### 7.2 Allowed access + +Marketing can: + +- enter the admin layout; +- call the newsletter subscriber list endpoint; +- view published and draft events through the events API; +- view and update its own profile; +- access its own dashboard, tickets, and history. + +Newsletter subscriber access: + +```ts +requireAuth(['admin', 'marketing']) +``` + +Source: `backend/src/routes/contacts.ts:252` + +### 7.3 Denied access + +Marketing cannot: + +- use the contacts inbox API; +- create or update events; +- use the scanner; +- list or administer tickets; +- administer payments; +- manage email templates; +- manage media or photo galleries; +- manage users; +- manage global settings. + +### 7.4 Broken default experience + +The admin layout: + +- includes Contacts in marketing navigation; +- sends marketing users to `/admin/contacts` by default. + +The Contacts page calls `GET /api/contacts`, which allows only: + +- admin; +- organizer. + +Marketing is allowed to call `/api/contacts/subscribers/list`, but no frontend API wrapper or page currently uses that endpoint. + +Result: + +- marketing signs in; +- marketing is redirected to Contacts; +- the page’s primary request returns `403`; +- the one backend resource specifically allowed for marketing has no frontend. + +Relevant sources: + +- `frontend/src/app/admin/layout.tsx:63-88` +- `frontend/src/app/admin/contacts/page.tsx:26` +- `backend/src/routes/contacts.ts:181` +- `backend/src/routes/contacts.ts:252` + +--- + +## 8. Frontend route report + +| Route | Admin | Organizer | Staff | Marketing | Effective condition | +|---|---:|---:|---:|---:|---| +| `/admin` | Works | Works | Redirected | Redirected | Dashboard API is admin/organizer | +| `/admin/events` | Works | Works | List works | Redirected | Staff sees unsupported actions | +| `/admin/events/[id]` | Works | Works | Broken/partial | Redirected | Staff dependencies return 403 | +| `/admin/scanner` | Works | Works | Works | Redirected | Door API aligned | +| `/admin/bookings` | Works | Works | Redirected | Redirected | Ticket list is admin/organizer | +| `/admin/users` | Works | Redirected | Redirected | Redirected | Admin-only | +| `/admin/payments` | Works | Broken/partial | Redirected | Redirected | Full list is admin-only | +| `/admin/payment-options` | Works | Broken | Redirected | Redirected | Global options are admin-only | +| `/admin/contacts` | Works | Works | Redirected | Broken | Marketing API mismatch | +| `/admin/emails` | Works | Partial | Redirected | Redirected | Organizer cannot mutate templates | +| `/admin/gallery` | Works | Works | Redirected | Redirected | Admin/organizer | +| `/admin/photos` | Works | Works | Redirected | Redirected | Admin/organizer | +| `/admin/legal-pages` | Works | Redirected | Redirected | Redirected | Admin-only | +| `/admin/faq` | Works | Redirected | Redirected | Redirected | Admin-only | +| `/admin/settings` | Works | Redirected | Redirected | Redirected | Admin-only | +| `/admin/tickets` | Works | Works | Shell opens, API fails | Shell opens, API fails | Missing from sidebar and route policy | +| `/dashboard` | Works | Works | Works | Works | Self-service for any authenticated user | + +### Orphan `/admin/tickets` route + +`/admin/tickets`: + +- exists; +- is linked from an admin dashboard statistic; +- is absent from the sidebar; +- is absent from the known-route permission list; +- can be opened by staff or marketing because the layout does not identify it as a disallowed known route; +- then fails to load the ticket list because the API permits only admin and organizer. + +This should either be: + +- added to navigation and explicit route permissions for admin/organizer; or +- removed and replaced with the intended bookings/event workflow. + +--- + +## 9. Backend endpoint-level report + +### 9.1 Events + +| Method | Path | Allowed | +|---|---|---| +| `GET` | `/api/events` | Public published events; admin/organizer/staff/marketing can see drafts | +| `GET` | `/api/events/:id` | Public if published; privileged staff roles can view drafts | +| `POST` | `/api/events` | Admin, organizer | +| `PUT` | `/api/events/:id` | Admin, organizer | +| `DELETE` | `/api/events/:id` | Admin | +| `GET` | `/api/events/:id/attendees` | Admin, organizer, staff | + +There is no event assignment or ownership condition. An organizer or staff member with permission sees all applicable events. + +### 9.2 Door operations + +| Method | Path | Allowed | +|---|---|---| +| `GET` | `/api/events/:eventId/door-attendees` | Admin, organizer, staff | +| `POST` | `/api/events/:eventId/door-checkin` | Admin, organizer, staff | +| `POST` | `/api/events/:eventId/door-checkin/undo` | Admin, organizer, staff | +| `GET` | `/api/events/:eventId/door-summary` | Admin, organizer, staff | + +Source: `backend/src/routes/door.ts` + +### 9.3 Tickets + +| Method | Path/action | Allowed | +|---|---|---| +| `GET` | `/api/tickets` | Admin, organizer | +| `GET` | `/api/tickets/:id` | Public reduced response; privileged response for owner or staff trio | +| `GET` | `/api/tickets/:id/pdf` | Public by ticket UUID; owner and staff also supported | +| `GET` | `/api/tickets/booking/:bookingId/pdf` | Public by booking UUID | +| `POST` | Ticket check-in/search/validate/note/admin-create operations | Admin, organizer, staff | +| `POST` | `/api/tickets/:id/mark-paid` | Admin, organizer, staff | +| `POST` | `/api/tickets/:id/cancel` | Ticket owner or admin | +| `POST` | `/api/tickets/:id/mark-payment-sent` | No session role or owner requirement | + +Ticket and booking UUIDs are intentionally used as capability tokens for some emailed and payment flows. They must therefore be treated as secrets. + +### 9.4 Payments + +The payment API is split: + +- full payment listing and selected aggregate views are admin-only; +- payment approval/rejection workflow permits admin and organizer; +- refunds are admin-only; +- public payment provider callbacks use their own validation. + +This split is the reason the organizer Payments page can be visible but still fail during its combined data load. + +### 9.5 Payment options + +| Capability | Admin | Organizer | Staff | Marketing | +|---|---:|---:|---:|---:| +| View/update global payment options | Yes | No | No | No | +| View/update event overrides | Yes | Yes | No | No | +| Public event payment methods | Conditional public subset | Conditional public subset | Conditional public subset | Conditional public subset | + +Detailed bank or TPago data may be returned when: + +- the caller is admin/organizer; or +- a valid ticket ID for the event is supplied. + +### 9.6 Contacts and subscribers + +| Capability | Allowed | +|---|---| +| Contacts inbox CRUD | Admin, organizer | +| Subscriber list | Admin, marketing | + +The frontend currently implements the first surface, not the second. + +### 9.7 Email and media + +| Capability | Allowed | +|---|---| +| Read email templates | Admin, organizer | +| Send operational email | Admin, organizer | +| Create/update/delete email templates | Admin | +| Upload/list/delete media | Admin, organizer | +| Photo API administration | Admin, organizer | + +### 9.8 Platform configuration + +Admin-only areas include: + +- legal pages; +- FAQ administration; +- site settings; +- featured-event setting; +- global payment options; +- user administration; +- role assignment; +- refunds; +- analytics; +- exports. + +--- + +## 10. Ownership and capability rules + +### 10.1 Self-service dashboard + +Any active authenticated role can use `/api/dashboard/*`, but queries are filtered to the current user. + +This includes staff accounts acting as ordinary customers. + +Source: `backend/src/routes/dashboard.ts` + +### 10.2 User profiles + +Non-admin users can view and update only their own profile. Role, email, and account-status fields are stripped or restricted unless the requester is admin. + +Source: `backend/src/routes/users.ts:120-219` + +### 10.3 User ticket history + +Admin and organizer can access other users’ history. Staff, marketing, and ordinary users are limited to their own history. + +Source: `backend/src/routes/users.ts:227-229` + +### 10.4 Public ticket reads + +`GET /api/tickets/:id` is public by ticket UUID, but personal information is reduced unless the caller is: + +- the owner; +- admin; +- organizer; +- staff. + +Source: `backend/src/routes/tickets.ts:779-823` + +### 10.5 PDFs + +Single-ticket and booking PDFs can be downloaded without a session when the caller knows the relevant UUID. + +This supports emailed links, but creates capability-token risk: + +- UUIDs must not appear in logs or analytics unnecessarily; +- links should not be shared; +- revocation and expiry should be considered for sensitive documents. + +### 10.6 No event tenancy + +There is no implemented relationship such as: + +- organizer assigned to event; +- staff assigned to event; +- marketing assigned to campaign. + +Role access is platform-wide. A staff user can operate on every event accepted by the door endpoints. + +--- + +## 11. Security and governance findings + +### 11.1 High: unauthenticated mark-payment-sent + +`POST /api/tickets/:id/mark-payment-sent`: + +- does not require a valid session; +- does not verify ticket ownership; +- relies on knowledge of the ticket UUID; +- can move a payment into `pending_approval`. + +Source: `backend/src/routes/tickets.ts:1187-1317` + +Recommended correction: + +- require the ticket owner’s session; or +- require a short-lived signed booking/payment token tied to the ticket and intended action. + +Rate limiting alone does not establish authorization. + +### 11.2 High on fresh deployments: first user becomes admin + +The first user created in an empty database becomes admin: + +```ts +const existing = await dbAll( + db.select().from(authUsers).limit(1) +); + +if (!existing || existing.length === 0) { + return { data: { ...user, role: 'admin' } }; +} +``` + +Source: `backend/src/lib/betterAuth.ts:241-245` + +Risks: + +- public registration may allow an unintended first registrant to gain full control; +- concurrent initial registrations may create a race; +- deployment order becomes security-sensitive. + +Recommended correction: + +- provision the initial admin explicitly through a deployment-only command or environment-gated bootstrap; +- disable automatic first-user elevation after initial setup. + +### 11.3 Medium: no role-change audit + +The database has an `audit_logs` table, but changing a role does not create an audit record. + +Missing evidence includes: + +- actor; +- target user; +- previous role; +- new role; +- timestamp; +- reason. + +Recommended correction: + +- write a transactional audit event whenever role or account status changes. + +### 11.4 Medium: no final-admin guard + +Admin accounts cannot be directly deleted while they remain admins, but they can be demoted. The system does not prevent the final admin from being demoted. + +Recommended correction: + +- count active admins before a demotion; +- reject changes that would leave no active admin; +- cover the rule with concurrent integration tests. + +### 11.5 Medium: no database role constraint + +The migration-defined role column is plain `TEXT` or `varchar` with no `CHECK` constraint or database enum. + +Application validation protects ordinary API writes, but direct SQL, scripts, or faulty migrations can store arbitrary values. + +Unknown roles generally fail closed at API allowlists, but can: + +- break frontend assumptions; +- produce unusable accounts; +- complicate auditing; +- bypass intended reporting. + +Recommended correction: + +- add a database `CHECK` constraint or native enum; +- validate existing values before applying the migration. + +### 11.6 Review: public capability endpoints + +The following are public or capability-based: + +- ticket detail by UUID, with reduced PII; +- ticket PDF by UUID; +- booking PDF by UUID; +- LNbits ticket/payment status operations; +- payment details when a valid event ticket ID is supplied; +- media metadata by ID. + +Some are deliberate customer flows, not necessarily defects. They should nevertheless be documented as capability-token interfaces and tested for data minimization. + +### 11.7 Review: no event scope + +Organizer and staff access is global. This is acceptable for a single operational team, but unsuitable if: + +- third-party organizers are introduced; +- temporary event workers should see only one event; +- regional or franchise separation is required. + +If event-scoped staff are anticipated, introduce explicit assignments before onboarding them. + +--- + +## 12. Database and role-management observations + +### 12.1 Schema inconsistency + +The SQLite Drizzle declaration has a TypeScript enum annotation, while PostgreSQL uses a plain `varchar`. Neither migration enforces the allowed values at database level. + +Sources: + +- `backend/src/db/schema.ts:14` +- `backend/src/db/schema.ts:402` +- `backend/src/db/migrate.ts:16-22` +- `backend/drizzle/0000_steady_wendell_vaughn.sql:253` + +### 12.2 Role assignment + +Only admin can assign roles through `PUT /api/users/:id`. + +Source: `backend/src/routes/users.ts:161-169` + +The frontend role dropdown applies changes immediately: + +- no confirmation; +- no required reason; +- no audit record; +- no warning for granting admin; +- no final-admin protection. + +Source: `frontend/src/app/admin/users/page.tsx:129-136` + +### 12.3 Suspension behavior + +Suspension: + +- changes application account status; +- mirrors the Better Auth banned flag; +- removes active sessions; +- takes effect during session validation. + +Source: `backend/src/routes/users.ts:179-199` + +This is stronger than a frontend-only suspension. + +--- + +## 13. Deployment and non-application access + +Application roles do not protect: + +- direct database access; +- Drizzle Studio; +- PostgreSQL administration; +- deployment environment files; +- service credentials; +- shell access as the deployment user. + +Anyone with those privileges can bypass application RBAC and modify role data directly. + +Operational access should therefore be managed separately through: + +- OS accounts; +- SSH policy; +- database credentials; +- secret-management policy; +- production access logging. + +The application report must not be interpreted as an infrastructure-access report. + +--- + +## 14. Test coverage + +### Covered + +- First user becomes admin. +- Subsequent users receive the default user role. +- Suspension/ban behavior. +- Staff door operations in backend integration tests. +- Selected photo API administrator behavior. + +Relevant sources: + +- `backend/src/lib/betterAuth.integration.test.ts:67-81` +- `backend/src/lib/betterAuth.integration.test.ts:127-135` +- `backend/src/routes/door.integration.test.ts` +- `photo-api/internal/httpapi/api_test.go` + +### Missing + +There is no comprehensive authorization regression suite covering: + +- every route against every role; +- marketing Contacts behavior; +- organizer Payments behavior; +- organizer Payment Options behavior; +- staff event-detail behavior; +- staff event CRUD rejection; +- final-admin protection; +- role-change auditing; +- malformed database roles; +- event-scoping expectations; +- capability-token data minimization. + +There are no frontend tests verifying role-specific navigation or page behavior. + +--- + +## 15. Recommended remediation plan + +### Priority 1: repair active authorization and workflow defects + +1. Protect `mark-payment-sent` with ownership or a signed action token. +2. Replace marketing’s Contacts landing page with a subscriber-focused page, or intentionally grant marketing the contacts scope. +3. Make staff event detail load staff-safe endpoints only. +4. Hide event create/edit/delete/duplicate/featured controls from staff. +5. Split organizer Payments page data from admin-only payment listing. +6. Remove organizer from global Payment Options navigation, or provide an event-overrides-only page. +7. Add `/admin/tickets` to explicit route permissions and navigation, or remove it. + +### Priority 2: harden privilege administration + +8. Audit every role and account-status change. +9. Prevent demotion of the last active admin. +10. Add confirmation and clear privilege warnings to the role dropdown. +11. Replace public first-user-admin behavior with explicit deployment provisioning. +12. Add database constraints for allowed roles. + +### Priority 3: make the model maintainable + +13. Centralize role-group constants instead of repeating arrays. +14. Document that the model is flat and uses exact allowlists. +15. Add backend role-matrix integration tests. +16. Add frontend navigation and page-load tests for every staff role. +17. Document capability-token routes and their intended exposed fields. +18. Decide whether organizer/staff should eventually be assigned to specific events. + +--- + +## 16. Proposed official role policy + +The following policy most closely matches current code intent after correcting the identified mismatches. + +### Admin + +Full platform owner: + +- users and roles; +- all event and ticket operations; +- all payments, refunds, and exports; +- all contacts, email, media, and galleries; +- legal, FAQ, payment, and site settings. + +### Organizer + +Event operations manager: + +- events except deletion; +- tickets and attendees; +- payment approval and event-level payment options; +- contacts; +- operational email; +- media and galleries; +- no user roles, refunds, exports, or global settings. + +### Staff + +Door operator: + +- scanner; +- attendees; +- check-in and undo; +- walk-ins; +- ticket validation; +- cash mark-paid; +- door summaries; +- read-only event context; +- no event administration or back-office payment administration. + +### Marketing + +Subscriber operator: + +- subscriber list; +- subscriber-related communications if later implemented; +- read-only event campaign context if required; +- no contacts inbox unless explicitly approved; +- no ticket, payment, user, or configuration administration. + +### User + +Customer self-service: + +- own profile; +- own dashboard; +- own tickets and payments; +- public booking and payment flows. + +--- + +## 17. Key source index + +| Purpose | Source | +|---|---| +| Canonical role declaration | `backend/src/db/schema.ts:14` | +| PostgreSQL role declaration | `backend/src/db/schema.ts:402` | +| Authentication middleware | `backend/src/lib/auth.ts:38-90` | +| Better Auth admin configuration | `backend/src/lib/betterAuth.ts:354-358` | +| First-user admin bootstrap | `backend/src/lib/betterAuth.ts:241-245` | +| User role update workflow | `backend/src/routes/users.ts:151-219` | +| Admin dashboard and exports | `backend/src/routes/admin.ts` | +| Event permissions | `backend/src/routes/events.ts` | +| Door permissions | `backend/src/routes/door.ts` | +| Ticket permissions | `backend/src/routes/tickets.ts` | +| Payment permissions | `backend/src/routes/payments.ts` | +| Payment option permissions | `backend/src/routes/payment-options.ts` | +| Contact and subscriber permissions | `backend/src/routes/contacts.ts` | +| Email permissions | `backend/src/routes/emails.ts` | +| Site settings | `backend/src/routes/site-settings.ts` | +| Frontend role flags | `frontend/src/context/AuthContext.tsx:181-182` | +| Frontend admin navigation | `frontend/src/app/admin/layout.tsx:63-114` | +| Next.js cookie middleware | `frontend/src/middleware.ts:27-44` | +| Staff event-detail data load | `frontend/src/app/admin/events/[id]/_hooks/useEventDetailData.ts:19-25` | +| Photo API administrator roles | `photo-api/internal/auth/auth.go:31-48` | +| Production frontend nginx | `deploy/prod_nginx/frontend.nginx.conf` | + +--- + +## Conclusion + +Spanglish has a workable role split, but it is implemented as many exact allowlists rather than one centralized permission system. The backend generally protects sensitive operations correctly, and the door role is reasonably well aligned with scanner APIs. The largest day-to-day problems are frontend/API drift for organizer, staff, and marketing. + +The immediate goals should be: + +1. secure the unauthenticated payment-state endpoint; +2. align each staff role’s pages with its backend permissions; +3. audit and protect role administration; +4. add role-matrix regression tests. + +Until those changes are made, staff permissions should be understood from the backend matrix in this document, not solely from visible navigation or buttons. diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index a08fbfe..78d2622 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -94,6 +94,8 @@ async function migrate() { banner_url TEXT, external_booking_enabled INTEGER NOT NULL DEFAULT 0, external_booking_url TEXT, + presale_closure_enabled INTEGER, + presale_close_minutes_before INTEGER, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) @@ -107,6 +109,14 @@ async function migrate() { await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_url TEXT`); } catch (e) { /* column may already exist */ } + // Pre-sale closure per-event overrides (NULL = inherit site_settings default) + try { + await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_closure_enabled INTEGER`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`); + } catch (e) { /* column may already exist */ } + // Add short description columns to events try { await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description TEXT`); @@ -510,6 +520,8 @@ async function migrate() { maintenance_mode INTEGER NOT NULL DEFAULT 0, maintenance_message TEXT, maintenance_message_es TEXT, + presale_closure_enabled INTEGER NOT NULL DEFAULT 1, + presale_close_minutes_before INTEGER NOT NULL DEFAULT 120, updated_at TEXT NOT NULL, updated_by TEXT REFERENCES users(id) ) @@ -520,6 +532,14 @@ async function migrate() { await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id TEXT REFERENCES events(id)`); } catch (e) { /* column may already exist */ } + // Pre-sale closure site-wide defaults + try { + await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN presale_closure_enabled INTEGER NOT NULL DEFAULT 1`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN presale_close_minutes_before INTEGER NOT NULL DEFAULT 120`); + } catch (e) { /* column may already exist */ } + // Legal pages table for admin-editable legal content await (db as any).run(sql` CREATE TABLE IF NOT EXISTS legal_pages ( @@ -730,6 +750,8 @@ async function migrate() { banner_url VARCHAR(500), external_booking_enabled INTEGER NOT NULL DEFAULT 0, external_booking_url VARCHAR(500), + presale_closure_enabled INTEGER, + presale_close_minutes_before INTEGER, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ) @@ -743,6 +765,14 @@ async function migrate() { await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_url VARCHAR(500)`); } catch (e) { /* column may already exist */ } + // Pre-sale closure per-event overrides (NULL = inherit site_settings default) + try { + await (db as any).execute(sql`ALTER TABLE events ADD COLUMN presale_closure_enabled INTEGER`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`); + } catch (e) { /* column may already exist */ } + // Add short description columns to events try { await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description VARCHAR(300)`); @@ -1104,6 +1134,8 @@ async function migrate() { maintenance_mode INTEGER NOT NULL DEFAULT 0, maintenance_message TEXT, maintenance_message_es TEXT, + presale_closure_enabled INTEGER NOT NULL DEFAULT 1, + presale_close_minutes_before INTEGER NOT NULL DEFAULT 120, updated_at TIMESTAMP NOT NULL, updated_by UUID REFERENCES users(id) ) @@ -1114,6 +1146,14 @@ async function migrate() { await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id UUID REFERENCES events(id)`); } catch (e) { /* column may already exist */ } + // Pre-sale closure site-wide defaults + try { + await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN presale_closure_enabled INTEGER NOT NULL DEFAULT 1`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN presale_close_minutes_before INTEGER NOT NULL DEFAULT 120`); + } catch (e) { /* column may already exist */ } + // Legal pages table for admin-editable legal content await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS legal_pages ( diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index abae95f..05a87d6 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -88,6 +88,9 @@ export const sqliteEvents = sqliteTable('events', { bannerUrl: text('banner_url'), externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false), externalBookingUrl: text('external_booking_url'), + // Pre-sale closure: null = inherit the site_settings default + presaleClosureEnabled: integer('presale_closure_enabled', { mode: 'boolean' }), + presaleCloseMinutesBefore: integer('presale_close_minutes_before'), createdAt: text('created_at').notNull(), updatedAt: text('updated_at').notNull(), }); @@ -387,6 +390,9 @@ export const sqliteSiteSettings = sqliteTable('site_settings', { maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false), maintenanceMessage: text('maintenance_message'), maintenanceMessageEs: text('maintenance_message_es'), + // Pre-sale closure defaults (events inherit these unless they override) + presaleClosureEnabled: integer('presale_closure_enabled', { mode: 'boolean' }).notNull().default(true), + presaleCloseMinutesBefore: integer('presale_close_minutes_before').notNull().default(120), // Metadata updatedAt: text('updated_at').notNull(), updatedBy: text('updated_by').references(() => sqliteUsers.id), @@ -476,6 +482,9 @@ export const pgEvents = pgTable('events', { bannerUrl: varchar('banner_url', { length: 500 }), externalBookingEnabled: pgInteger('external_booking_enabled').notNull().default(0), externalBookingUrl: varchar('external_booking_url', { length: 500 }), + // Pre-sale closure: null = inherit the site_settings default + presaleClosureEnabled: pgInteger('presale_closure_enabled'), + presaleCloseMinutesBefore: pgInteger('presale_close_minutes_before'), createdAt: timestamp('created_at').notNull(), updatedAt: timestamp('updated_at').notNull(), }); @@ -761,6 +770,9 @@ export const pgSiteSettings = pgTable('site_settings', { maintenanceMode: pgInteger('maintenance_mode').notNull().default(0), maintenanceMessage: pgText('maintenance_message'), maintenanceMessageEs: pgText('maintenance_message_es'), + // Pre-sale closure defaults (events inherit these unless they override) + presaleClosureEnabled: pgInteger('presale_closure_enabled').notNull().default(1), + presaleCloseMinutesBefore: pgInteger('presale_close_minutes_before').notNull().default(120), // Metadata updatedAt: timestamp('updated_at').notNull(), updatedBy: uuid('updated_by').references(() => pgUsers.id), diff --git a/backend/src/lib/presale.test.ts b/backend/src/lib/presale.test.ts new file mode 100644 index 0000000..25811b6 --- /dev/null +++ b/backend/src/lib/presale.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { + resolvePresaleClosure, + isPresaleClosed, + DEFAULT_PRESALE_CLOSE_MINUTES, +} from './presale.js'; + +const START = '2030-01-01T20:00:00.000Z'; +const startMs = new Date(START).getTime(); +const minutes = (n: number) => n * 60_000; + +describe('resolvePresaleClosure', () => { + it('falls back to the built-in defaults when neither event nor settings specify anything', () => { + const r = resolvePresaleClosure({ startDatetime: START }, null); + expect(r.enabled).toBe(true); + expect(r.minutesBefore).toBe(DEFAULT_PRESALE_CLOSE_MINUTES); + expect(r.closesAt?.getTime()).toBe(startMs - minutes(DEFAULT_PRESALE_CLOSE_MINUTES)); + }); + + it('inherits from site settings when the event has null overrides', () => { + const r = resolvePresaleClosure( + { startDatetime: START, presaleClosureEnabled: null, presaleCloseMinutesBefore: null }, + { presaleClosureEnabled: true, presaleCloseMinutesBefore: 30 } + ); + expect(r.minutesBefore).toBe(30); + expect(r.closesAt?.getTime()).toBe(startMs - minutes(30)); + }); + + it('lets the event override the site settings', () => { + const r = resolvePresaleClosure( + { startDatetime: START, presaleClosureEnabled: true, presaleCloseMinutesBefore: 1440 }, + { presaleClosureEnabled: false, presaleCloseMinutesBefore: 30 } + ); + expect(r.enabled).toBe(true); + expect(r.closesAt?.getTime()).toBe(startMs - minutes(1440)); + }); + + it('returns closesAt null when closure is disabled (site-wide or per event)', () => { + expect(resolvePresaleClosure({ startDatetime: START }, { presaleClosureEnabled: false }).closesAt).toBeNull(); + expect( + resolvePresaleClosure({ startDatetime: START, presaleClosureEnabled: false }, { presaleClosureEnabled: true }).closesAt + ).toBeNull(); + }); + + it('accepts PostgreSQL 0/1 integers and string minutes', () => { + const r = resolvePresaleClosure( + { startDatetime: new Date(START), presaleClosureEnabled: 1, presaleCloseMinutesBefore: '45' }, + { presaleClosureEnabled: 0, presaleCloseMinutesBefore: 10 } + ); + expect(r.enabled).toBe(true); + expect(r.minutesBefore).toBe(45); + const off = resolvePresaleClosure({ startDatetime: START, presaleClosureEnabled: 0 }, { presaleClosureEnabled: 1 }); + expect(off.enabled).toBe(false); + }); + + it('closes at the event start when minutes is 0', () => { + const r = resolvePresaleClosure({ startDatetime: START, presaleCloseMinutesBefore: 0 }, null); + expect(r.closesAt?.getTime()).toBe(startMs); + }); +}); + +describe('isPresaleClosed', () => { + const event = { startDatetime: START, presaleClosureEnabled: true, presaleCloseMinutesBefore: 60 }; + + it('is open before the cutoff and closed from the cutoff onwards', () => { + const cutoff = startMs - minutes(60); + expect(isPresaleClosed(event, null, cutoff - 1)).toBe(false); + expect(isPresaleClosed(event, null, cutoff)).toBe(true); + expect(isPresaleClosed(event, null, startMs + minutes(5))).toBe(true); + }); + + it('never closes when closure is disabled', () => { + expect(isPresaleClosed({ ...event, presaleClosureEnabled: false }, null, startMs + minutes(60))).toBe(false); + }); +}); diff --git a/backend/src/lib/presale.ts b/backend/src/lib/presale.ts new file mode 100644 index 0000000..8811bd1 --- /dev/null +++ b/backend/src/lib/presale.ts @@ -0,0 +1,70 @@ +/** + * Pre-sale closure: online registration for an event closes a configurable + * number of minutes before the event starts. + * + * Each event may override the site-wide default; a `null`/`undefined` value on + * the event means "inherit from site_settings". Values arrive as booleans on + * SQLite and as 0/1 integers on PostgreSQL, so both are accepted here. + */ + +export const DEFAULT_PRESALE_CLOSURE_ENABLED = true; +export const DEFAULT_PRESALE_CLOSE_MINUTES = 120; + +export interface PresaleEventLike { + startDatetime: string | Date; + presaleClosureEnabled?: boolean | number | null; + presaleCloseMinutesBefore?: number | string | null; +} + +export interface PresaleSettingsLike { + presaleClosureEnabled?: boolean | number | null; + presaleCloseMinutesBefore?: number | string | null; +} + +export interface ResolvedPresaleClosure { + enabled: boolean; + minutesBefore: number; + /** When online registration closes, or null when closure is disabled. */ + closesAt: Date | null; +} + +function toBool(value: boolean | number | null | undefined): boolean | null { + if (value === null || value === undefined) return null; + return typeof value === 'number' ? value !== 0 : Boolean(value); +} + +function toMinutes(value: number | string | null | undefined): number | null { + if (value === null || value === undefined) return null; + const n = typeof value === 'string' ? parseInt(value, 10) : value; + return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null; +} + +export function resolvePresaleClosure( + event: PresaleEventLike, + settings?: PresaleSettingsLike | null +): ResolvedPresaleClosure { + const enabled = + toBool(event.presaleClosureEnabled) ?? + toBool(settings?.presaleClosureEnabled) ?? + DEFAULT_PRESALE_CLOSURE_ENABLED; + const minutesBefore = + toMinutes(event.presaleCloseMinutesBefore) ?? + toMinutes(settings?.presaleCloseMinutesBefore) ?? + DEFAULT_PRESALE_CLOSE_MINUTES; + + if (!enabled) return { enabled, minutesBefore, closesAt: null }; + + const startMs = new Date(event.startDatetime).getTime(); + if (!Number.isFinite(startMs)) return { enabled, minutesBefore, closesAt: null }; + + return { enabled, minutesBefore, closesAt: new Date(startMs - minutesBefore * 60_000) }; +} + +export function isPresaleClosed( + event: PresaleEventLike, + settings?: PresaleSettingsLike | null, + nowMs: number = Date.now() +): boolean { + const { closesAt } = resolvePresaleClosure(event, settings); + return closesAt !== null && closesAt.getTime() <= nowMs; +} diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 589c1c8..1c2ba86 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -8,6 +8,7 @@ import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calcula import { slugify, uniqueSlug } from '../lib/slugify.js'; import { revalidateFrontendCache } from '../lib/revalidate.js'; import { eventSeatBreakdownQuery } from '../lib/capacity.js'; +import { resolvePresaleClosure } from '../lib/presale.js'; interface UserContext { id: string; @@ -19,10 +20,13 @@ interface UserContext { const eventsRouter = new Hono<{ Variables: { user: UserContext } }>(); // Helper to normalize event data for API response -// PostgreSQL decimal returns strings, booleans are stored as integers -function normalizeEvent(event: any) { +// PostgreSQL decimal returns strings, booleans are stored as integers. +// `settings` is the site_settings row; when given, the effective pre-sale +// cutoff (`presaleClosesAt`, ISO or null) is computed so the frontend and the +// booking API agree on when registration closes. +function normalizeEvent(event: any, settings?: any) { if (!event) return event; - return { + const normalized = { ...event, // Convert price from string/decimal to clean number price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price), @@ -30,7 +34,15 @@ function normalizeEvent(event: any) { capacity: typeof event.capacity === 'string' ? parseInt(event.capacity, 10) : Number(event.capacity), // Convert boolean integers to actual booleans for frontend externalBookingEnabled: Boolean(event.externalBookingEnabled), + // Pre-sale overrides: null means "inherit the site default" + presaleClosureEnabled: event.presaleClosureEnabled == null ? null : Boolean(event.presaleClosureEnabled), + presaleCloseMinutesBefore: event.presaleCloseMinutesBefore == null ? null : Number(event.presaleCloseMinutesBefore), }; + if (settings !== undefined) { + const { closesAt } = resolvePresaleClosure(normalized, settings); + return { ...normalized, presaleClosesAt: closesAt ? closesAt.toISOString() : null }; + } + return normalized; } // Load every slug currently in use (canonical event slugs + historical aliases), @@ -138,8 +150,30 @@ const baseEventSchema = z.object({ // External booking support - accept boolean or number (0/1 from DB) externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false), externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')), + // Pre-sale closure overrides - null/omitted means "inherit the site default" + presaleClosureEnabled: z.union([z.boolean(), z.number(), z.null()]) + .transform((v) => (v === null ? null : normalizeBoolean(v))) + .optional(), + presaleCloseMinutesBefore: z.union([z.number(), z.string(), z.null()]) + .transform((v) => { + if (v === null) return null; + const n = typeof v === 'string' ? parseInt(v, 10) : v; + return Number.isFinite(n) ? Math.floor(n) : NaN; + }) + .pipe(z.number().int().min(0, 'Pre-sale closure time cannot be negative').nullable()) + .optional(), }); +// When pre-sale closure is explicitly enabled on an event, the cutoff must be set too. +const presaleRefine = { + check: (data: { presaleClosureEnabled?: boolean | null; presaleCloseMinutesBefore?: number | null }) => + data.presaleClosureEnabled !== true || typeof data.presaleCloseMinutesBefore === 'number', + options: { + message: 'Pre-sale closure time is required when pre-sale closure is enabled', + path: ['presaleCloseMinutesBefore'], + }, +}; + const createEventSchema = baseEventSchema.refine( (data) => { // If external booking is enabled, URL must be provided and must start with https:// @@ -152,7 +186,7 @@ const createEventSchema = baseEventSchema.refine( message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled', path: ['externalBookingUrl'], } -); +).refine(presaleRefine.check, presaleRefine.options); const updateEventSchema = baseEventSchema.partial().refine( (data) => { @@ -166,7 +200,7 @@ const updateEventSchema = baseEventSchema.partial().refine( message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled', path: ['externalBookingUrl'], } -); +).refine(presaleRefine.check, presaleRefine.options); // Get all events (public) eventsRouter.get('/', async (c) => { @@ -235,8 +269,9 @@ eventsRouter.get('/', async (c) => { }); } + const siteSettingsRow = await getSiteSettingsRow(); const eventsWithCounts = result.map((event: any) => { - const normalized = normalizeEvent(event); + const normalized = normalizeEvent(event, siteSettingsRow); const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 }; return { ...normalized, @@ -269,7 +304,7 @@ eventsRouter.get('/:id', async (c) => { } } - const normalized = normalizeEvent(event); + const normalized = normalizeEvent(event, await getSiteSettingsRow()); const counts = await getEventSeatCounts(event.id); return c.json({ event: { @@ -281,10 +316,15 @@ eventsRouter.get('/:id', async (c) => { }); }); -async function getSiteTimezone(): Promise { +// Single site_settings row (or null when none has been created yet) +async function getSiteSettingsRow(): Promise { const settings = await dbGet( (db as any).select().from(siteSettings).limit(1) ); + return settings || null; +} + +function siteTimezoneOf(settings: any | null): string { return settings?.timezone || 'America/Asuncion'; } @@ -320,7 +360,7 @@ async function getNextChronologicalUpcoming(): Promise { } const counts = await getEventSeatCounts(event.id); - const normalized = normalizeEvent(event); + const normalized = normalizeEvent(event, await getSiteSettingsRow()); return { ...normalized, bookedCount: counts.paid, @@ -389,7 +429,7 @@ eventsRouter.get('/next/upcoming', async (c) => { // If we have a valid featured event, return it if (featuredEvent) { const counts = await getEventSeatCounts(featuredEvent.id); - const normalized = normalizeEvent(featuredEvent); + const normalized = normalizeEvent(featuredEvent, settings); return c.json({ event: { ...normalized, @@ -417,7 +457,8 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c const user = c.get('user'); const now = getNow(); const id = generateId(); - const tz = await getSiteTimezone(); + const siteSettingsRow = await getSiteSettingsRow(); + const tz = siteTimezoneOf(siteSettingsRow); // Convert data for database compatibility const dbData = convertBooleansForDb(data); @@ -442,7 +483,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c revalidateFrontendCache(); // Return normalized event data - return c.json({ event: normalizeEvent(newEvent) }, 201); + return c.json({ event: normalizeEvent(newEvent, siteSettingsRow) }, 201); }); // Update event (admin/organizer only) @@ -458,7 +499,8 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', } const now = getNow(); - const tz = await getSiteTimezone(); + const siteSettingsRow = await getSiteSettingsRow(); + const tz = siteTimezoneOf(siteSettingsRow); // Convert data for database compatibility const updateData: Record = { ...convertBooleansForDb(data), updatedAt: now }; // Slug changes are handled explicitly below to manage aliases @@ -517,7 +559,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', // Revalidate sitemap when an event is updated (status/dates may have changed) revalidateFrontendCache(); - return c.json({ event: normalizeEvent(updated) }); + return c.json({ event: normalizeEvent(updated, siteSettingsRow) }); }); // Delete event (admin only) @@ -634,6 +676,8 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async ( bannerUrl: existing.bannerUrl, externalBookingEnabled: existing.externalBookingEnabled ?? 0, // Already in DB format (0/1) externalBookingUrl: existing.externalBookingUrl, + presaleClosureEnabled: existing.presaleClosureEnabled ?? null, // Already in DB format (0/1/null) + presaleCloseMinutesBefore: existing.presaleCloseMinutesBefore ?? null, createdAt: now, updatedAt: now, }; diff --git a/backend/src/routes/site-settings.ts b/backend/src/routes/site-settings.ts index e8a1389..e373cc2 100644 --- a/backend/src/routes/site-settings.ts +++ b/backend/src/routes/site-settings.ts @@ -6,6 +6,7 @@ import { eq, and, gte } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; import { generateId, getNow, toDbBool } from '../lib/utils.js'; import { revalidateFrontendCache } from '../lib/revalidate.js'; +import { DEFAULT_PRESALE_CLOSURE_ENABLED, DEFAULT_PRESALE_CLOSE_MINUTES } from '../lib/presale.js'; interface UserContext { id: string; @@ -43,8 +44,26 @@ const updateSiteSettingsSchema = z.object({ maintenanceMode: z.boolean().optional(), maintenanceMessage: z.string().optional().nullable(), maintenanceMessageEs: z.string().optional().nullable(), + // Pre-sale closure defaults inherited by events that don't override them + presaleClosureEnabled: z.boolean().optional(), + presaleCloseMinutesBefore: z.number().int().min(0).optional(), }); +// Booleans are stored as 0/1 integers on PostgreSQL; hand the frontend real booleans. +function normalizeSettings(row: any) { + if (!row) return row; + return { + ...row, + maintenanceMode: Boolean(row.maintenanceMode), + presaleClosureEnabled: row.presaleClosureEnabled == null + ? DEFAULT_PRESALE_CLOSURE_ENABLED + : Boolean(row.presaleClosureEnabled), + presaleCloseMinutesBefore: row.presaleCloseMinutesBefore == null + ? DEFAULT_PRESALE_CLOSE_MINUTES + : Number(row.presaleCloseMinutesBefore), + }; +} + // Get site settings (public - needed for frontend timezone) siteSettingsRouter.get('/', async (c) => { const settings = await dbGet( @@ -69,11 +88,13 @@ siteSettingsRouter.get('/', async (c) => { maintenanceMode: false, maintenanceMessage: null, maintenanceMessageEs: null, + presaleClosureEnabled: DEFAULT_PRESALE_CLOSURE_ENABLED, + presaleCloseMinutesBefore: DEFAULT_PRESALE_CLOSE_MINUTES, }, }); } - return c.json({ settings }); + return c.json({ settings: normalizeSettings(settings) }); }); // Get available timezones @@ -145,13 +166,15 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit maintenanceMode: toDbBool(data.maintenanceMode || false), maintenanceMessage: data.maintenanceMessage || null, maintenanceMessageEs: data.maintenanceMessageEs || null, + presaleClosureEnabled: toDbBool(data.presaleClosureEnabled ?? DEFAULT_PRESALE_CLOSURE_ENABLED), + presaleCloseMinutesBefore: data.presaleCloseMinutesBefore ?? DEFAULT_PRESALE_CLOSE_MINUTES, updatedAt: now, updatedBy: user.id, }; await (db as any).insert(siteSettings).values(newSettings); - return c.json({ settings: newSettings, message: 'Settings created successfully' }, 201); + return c.json({ settings: normalizeSettings(newSettings), message: 'Settings created successfully' }, 201); } // Validate featured event if provided @@ -174,6 +197,9 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit if (typeof data.maintenanceMode === 'boolean') { updateData.maintenanceMode = toDbBool(data.maintenanceMode); } + if (typeof data.presaleClosureEnabled === 'boolean') { + updateData.presaleClosureEnabled = toDbBool(data.presaleClosureEnabled); + } await (db as any) .update(siteSettings) @@ -184,12 +210,17 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit (db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id)) ); - // Revalidate frontend cache if featured event changed - if (data.featuredEventId !== undefined) { + // Revalidate frontend cache if featured event changed or the pre-sale + // defaults changed (public event pages embed the computed cutoff). + if ( + data.featuredEventId !== undefined || + data.presaleClosureEnabled !== undefined || + data.presaleCloseMinutesBefore !== undefined + ) { revalidateFrontendCache(); } - return c.json({ settings: updated, message: 'Settings updated successfully' }); + return c.json({ settings: normalizeSettings(updated), message: 'Settings updated successfully' }); }); // Set featured event (admin only) - convenience endpoint for event editor diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 49f266c..9590cfe 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -11,6 +11,7 @@ import emailService from '../lib/email.js'; import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js'; import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js'; import { seatHolderCountQuery } from '../lib/capacity.js'; +import { isPresaleClosed } from '../lib/presale.js'; const ticketsRouter = new Hono(); @@ -111,6 +112,16 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { return c.json({ error: 'Event is not available for booking' }, 400); } + // Pre-sale closure: online registration stops N minutes before the event + // starts (per-event override, else the site-wide default). Staff/door and + // admin ticket creation use separate endpoints and are not gated. + const siteSettingsRow = await dbGet( + (db as any).select().from(siteSettings).limit(1) + ); + if (isPresaleClosed(event, siteSettingsRow)) { + return c.json({ error: 'Registration for this event is closed' }, 400); + } + // Validate the requested payment method is actually enabled for this event // (merge global options with any event-level overrides; override wins when not null) const globalPaymentOptions = await dbGet( diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index a159ed5..4fa02bd 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api'; -import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut } from '@/lib/utils'; +import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut, isPresaleClosed } from '@/lib/utils'; import { isSafeExternalUrl } from '@/lib/safeRedirect'; import toast from 'react-hot-toast'; import type { @@ -108,6 +108,14 @@ export default function BookingPage() { return; } + // Pre-sale closure: the booking API rejects after the cutoff, so + // bounce back to the event page instead of showing a dead form. + if (isPresaleClosed(eventRes.event)) { + toast.error(t('events.details.registrationClosed')); + router.push(`/events/${eventRes.event.slug}`); + return; + } + // Server-authoritative availability — same formula the booking API // enforces, so a sold-out event is caught here, not at submit time. if (isEventSoldOut(eventRes.event)) { @@ -364,11 +372,18 @@ export default function BookingPage() { toast.success(t('booking.success.message')); } } catch (error: any) { + const message = String(error?.message || ''); + // Pre-sale closed while the form was open: send the user back to the + // event page, which now shows the "Registration Closed" state. + if (/registration .*closed/i.test(message)) { + toast.error(t('events.details.registrationClosed')); + if (event?.slug) router.push(`/events/${event.slug}`); + return; + } toast.error(error.message || t('booking.form.errors.bookingFailed')); // Capacity race on the last seats: refresh availability so the page // reflects reality (sold-out block / lower quantity cap) instead of the // stale counts loaded when the form was opened. - const message = String(error?.message || ''); if (/sold out|seats available/i.test(message)) { try { const { event: freshEvent } = await eventsApi.getById(params.eventId as string); diff --git a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx index ba0eb77..050aeae 100644 --- a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx +++ b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx @@ -5,7 +5,7 @@ import Link from 'next/link'; import Image from 'next/image'; import { useLanguage } from '@/context/LanguageContext'; import { eventsApi, Event } from '@/lib/api'; -import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut } from '@/lib/utils'; +import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut, isPresaleClosed, parseDate, formatDurationWords } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import ShareButtons from '@/components/ShareButtons'; @@ -69,7 +69,13 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail const isCancelled = event.status === 'cancelled'; // Only calculate isPastEvent after mount to avoid hydration mismatch const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false; - const canBook = !isSoldOut && !isCancelled && !isPastEvent && (event.status === 'published' || event.status === 'unlisted'); + // Pre-sale closure (server-computed cutoff); same mount guard as isPastEvent + const presaleClosed = mounted ? isPresaleClosed(event) : false; + const canBook = !isSoldOut && !isCancelled && !isPastEvent && !presaleClosed && (event.status === 'published' || event.status === 'unlisted'); + // Effective lead time (event override or site default), derived from the server cutoff + const presaleLeadMinutes = event.presaleClosesAt + ? Math.max(0, Math.round((parseDate(event.startDatetime).getTime() - parseDate(event.presaleClosesAt).getTime()) / 60_000)) + : null; // Booking card content - reused for mobile and desktop positions const BookingCardContent = () => ( @@ -143,11 +149,23 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail )} + + {canBook && !event.externalBookingEnabled && presaleLeadMinutes !== null && ( +

+ {presaleLeadMinutes > 0 + ? t('events.details.presaleClosesBefore', { + duration: formatDurationWords(presaleLeadMinutes, locale as 'en' | 'es'), + }) + : t('events.details.presaleClosesAtStart')} +

+ )} {!event.externalBookingEnabled && (

diff --git a/frontend/src/app/(public)/events/[id]/page.tsx b/frontend/src/app/(public)/events/[id]/page.tsx index cec0e03..638a674 100644 --- a/frontend/src/app/(public)/events/[id]/page.tsx +++ b/frontend/src/app/(public)/events/[id]/page.tsx @@ -25,6 +25,7 @@ interface Event { bannerUrl?: string; availableSeats?: number; bookedCount?: number; + presaleClosesAt?: string | null; createdAt: string; updatedAt: string; } @@ -125,6 +126,7 @@ function generateEventJsonLd(event: Event) { : 'https://schema.org/SoldOut', url: `${siteUrl}/events/${event.slug}`, validFrom: new Date().toISOString(), + ...(event.presaleClosesAt ? { validThrough: event.presaleClosesAt } : {}), }, image: event.bannerUrl ? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`) diff --git a/frontend/src/app/admin/events/_components/EventFormModal.tsx b/frontend/src/app/admin/events/_components/EventFormModal.tsx index 7af3652..c8c49c5 100644 --- a/frontend/src/app/admin/events/_components/EventFormModal.tsx +++ b/frontend/src/app/admin/events/_components/EventFormModal.tsx @@ -6,10 +6,11 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import MediaPicker from '@/components/MediaPicker'; +import DurationInput from '@/components/admin/DurationInput'; import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import { useLanguage } from '@/context/LanguageContext'; -import { parseDate, EVENT_TIMEZONE } from '@/lib/utils'; +import { parseDate, EVENT_TIMEZONE, formatDurationWords } from '@/lib/utils'; interface EventFormData { title: string; @@ -30,14 +31,26 @@ interface EventFormData { bannerUrl: string; externalBookingEnabled: boolean; externalBookingUrl: string; + presaleClosureEnabled: boolean; + presaleCloseMinutesBefore: number; } +// Site-wide pre-sale defaults, used to pre-fill events that haven't overridden them +interface PresaleDefaults { + enabled: boolean; + minutesBefore: number; +} + +const FALLBACK_PRESALE_DEFAULTS: PresaleDefaults = { enabled: true, minutesBefore: 120 }; + const EMPTY_FORM: EventFormData = { title: '', titleEs: '', slug: '', description: '', descriptionEs: '', shortDescription: '', shortDescriptionEs: '', startDatetime: '', endDatetime: '', location: '', locationUrl: '', price: 0, currency: 'PYG', capacity: 50, status: 'draft', bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '', + presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled, + presaleCloseMinutesBefore: FALLBACK_PRESALE_DEFAULTS.minutesBefore, }; function isoToLocalDatetime(isoString: string): string { @@ -89,6 +102,30 @@ export default function EventFormModal({ const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]); const [saving, setSaving] = useState(false); const [settingFeatured, setSettingFeatured] = useState(false); + const [presaleDefaults, setPresaleDefaults] = useState(FALLBACK_PRESALE_DEFAULTS); + + useEffect(() => { + if (!open) return; + let cancelled = false; + // Site defaults fill in whatever the event hasn't overridden (null = inherit) + siteSettingsApi.get() + .then(({ settings }) => { + if (cancelled) return; + const defaults: PresaleDefaults = { + enabled: settings.presaleClosureEnabled ?? FALLBACK_PRESALE_DEFAULTS.enabled, + minutesBefore: settings.presaleCloseMinutesBefore ?? FALLBACK_PRESALE_DEFAULTS.minutesBefore, + }; + setPresaleDefaults(defaults); + setFormData((prev) => ({ + ...prev, + presaleClosureEnabled: event?.presaleClosureEnabled ?? defaults.enabled, + presaleCloseMinutesBefore: event?.presaleCloseMinutesBefore ?? defaults.minutesBefore, + })); + }) + .catch(() => { /* keep fallback defaults */ }); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, event]); useEffect(() => { if (!open) return; @@ -104,6 +141,8 @@ export default function EventFormModal({ status: event.status, bannerUrl: event.bannerUrl || '', externalBookingEnabled: event.externalBookingEnabled || false, externalBookingUrl: event.externalBookingUrl || '', + presaleClosureEnabled: event.presaleClosureEnabled ?? presaleDefaults.enabled, + presaleCloseMinutesBefore: event.presaleCloseMinutesBefore ?? presaleDefaults.minutesBefore, }); loadSlugAliases(event.id); } else { @@ -161,6 +200,14 @@ export default function EventFormModal({ setSaving(false); return; } + if ( + formData.presaleClosureEnabled && + (!Number.isFinite(formData.presaleCloseMinutesBefore) || formData.presaleCloseMinutesBefore < 0) + ) { + toast.error('Pre-sale closure time must be zero or more'); + setSaving(false); + return; + } const eventData: Partial = { title: formData.title, titleEs: formData.titleEs || undefined, description: formData.description, descriptionEs: formData.descriptionEs || undefined, @@ -172,6 +219,9 @@ export default function EventFormModal({ status: formData.status, bannerUrl: formData.bannerUrl || undefined, externalBookingEnabled: formData.externalBookingEnabled, externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined, + // Saving from the modal always writes explicit values (overrides the site default) + presaleClosureEnabled: formData.presaleClosureEnabled, + presaleCloseMinutesBefore: formData.presaleCloseMinutesBefore, }; if (event) { // Only send slug when editing so creates still auto-generate from title @@ -346,6 +396,35 @@ export default function EventFormModal({ )} +

+
+
+ +

Stop online registration before the event starts

+
+ +
+ {formData.presaleClosureEnabled && ( + setFormData({ ...formData, presaleCloseMinutesBefore: minutes })} /> + )} +

+ Site default: {presaleDefaults.enabled + ? `closes ${formatDurationWords(presaleDefaults.minutesBefore)} before the start` + : 'off'} + {' '}(Admin › Settings › General). +

+
+ setFormData({ ...formData, bannerUrl: url })} relatedId={event?.id} relatedType="event" /> diff --git a/frontend/src/app/admin/scanner/_components/ResultScreen.tsx b/frontend/src/app/admin/scanner/_components/ResultScreen.tsx new file mode 100644 index 0000000..f0c5f8b --- /dev/null +++ b/frontend/src/app/admin/scanner/_components/ResultScreen.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { useEffect, useState, type SyntheticEvent } from 'react'; +import clsx from 'clsx'; +import { CheckCircleIcon, XCircleIcon, BanknotesIcon } from '@heroicons/react/24/outline'; +import type { SessionEntry } from './SessionSheet'; + +// Full-screen outcome after a door action. At a loud, dark door a row flash and +// a small toast are too easy to miss; a screen that is entirely green or +// entirely red answers "is this person in or not?" from across the table. +// +// It never traps the queue: tapping anywhere closes it, and it closes itself +// after a few seconds. The undo affordance survives the close — the page shows +// the bottom Undo toast for whatever is left of the window. + +export type DoorResultKind = 'success' | 'already_in' | 'not_found' | 'unpaid' | 'failed'; + +export interface DoorResult { + kind: DoorResultKind; + /** Headline: the attendee's name, or a short verdict when there is no one. */ + name: string; + /** Second line: "Checked in 19:42 · paid cash", "Already in at 19:42", "Collect ₲60.000". */ + detail?: string; + /** Success only — drives Undo and the post-close toast. */ + entry?: SessionEntry; +} + +// Success needs no reading; the red ones carry a time or an amount the staff +// member has to relay to the person, so they get a little longer. +export const RESULT_AUTO_CLOSE_MS: Record = { + success: 4000, + already_in: 6000, + not_found: 6000, + unpaid: 6000, + failed: 6000, +}; + +const SURFACE: Record = { + success: { bg: 'bg-emerald-600', text: 'text-emerald-700', icon: CheckCircleIcon, title: 'Checked in' }, + already_in: { bg: 'bg-red-600', text: 'text-red-700', icon: XCircleIcon, title: 'Already checked in' }, + not_found: { bg: 'bg-red-600', text: 'text-red-700', icon: XCircleIcon, title: 'Not found' }, + unpaid: { bg: 'bg-amber-600', text: 'text-amber-700', icon: BanknotesIcon, title: 'Payment due' }, + failed: { bg: 'bg-red-700', text: 'text-red-800', icon: XCircleIcon, title: 'NOT checked in' }, +}; + +export function ResultScreen({ + result, + onClose, + onUndo, +}: { + result: DoorResult; + onClose: () => void; + onUndo: () => void; +}) { + const surface = SURFACE[result.kind]; + const duration = RESULT_AUTO_CLOSE_MS[result.kind]; + // Toggled after mount so the fade-in and the countdown bar both animate from + // their starting state instead of appearing already finished. + const [shown, setShown] = useState(false); + + // Keyed on the result identity: a success that flips to failed restarts both + // the fade and the countdown for the new state. + const identity = `${result.kind}:${result.entry?.idempotencyKey ?? result.name}`; + + useEffect(() => { + setShown(false); + const raf = requestAnimationFrame(() => setShown(true)); + const timer = setTimeout(onClose, duration); + return () => { + cancelAnimationFrame(raf); + clearTimeout(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [identity]); + + const stop = (e: SyntheticEvent) => e.stopPropagation(); + const Icon = surface.icon; + + return ( +
+
+
+ +
+

{surface.title}

+

{result.name}

+ {result.detail &&

{result.detail}

} +

Tap anywhere to continue

+
+ +
+ {result.kind === 'success' && ( + + )} + +
+ + {/* Countdown to auto-close, so the screen vanishing never surprises anyone. */} +
+
+
+
+ ); +} diff --git a/frontend/src/app/admin/scanner/_components/SessionSheet.tsx b/frontend/src/app/admin/scanner/_components/SessionSheet.tsx index 8ccb2a0..0916074 100644 --- a/frontend/src/app/admin/scanner/_components/SessionSheet.tsx +++ b/frontend/src/app/admin/scanner/_components/SessionSheet.tsx @@ -21,6 +21,8 @@ export interface SessionEntry { entry: 'scan' | 'search' | 'walkin'; method: DoorPaymentMethod | null; amount: number; + /** Epoch ms the action was fired; the undo window is measured from here. */ + startedAt: number; undone: boolean; failed: boolean; } diff --git a/frontend/src/app/admin/scanner/page.tsx b/frontend/src/app/admin/scanner/page.tsx index 30e90c1..f4e8c37 100644 --- a/frontend/src/app/admin/scanner/page.tsx +++ b/frontend/src/app/admin/scanner/page.tsx @@ -31,6 +31,7 @@ import { QRScannerOverlay } from './_components/QRScannerOverlay'; import { AttendeeRow } from './_components/AttendeeRow'; import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow'; import { SessionSheet, type SessionEntry } from './_components/SessionSheet'; +import { ResultScreen, type DoorResult } from './_components/ResultScreen'; // ═══════════════════════════════════════════════════════════════ // Door check-in screen @@ -90,6 +91,14 @@ export default function AdminDoorPage() { const [walkInDraft, setWalkInDraft] = useState(emptyWalkIn()); const [scannerOpen, setScannerOpen] = useState(false); const [sessionOpen, setSessionOpen] = useState(false); + // Full-screen outcome of the last action. Mirrored in a ref because the + // background write in runAction must read the *current* screen when it fails, + // not the one captured when it started. + const [result, setResult] = useState(null); + const resultRef = useRef(null); + // Undo toast per action, so a write that fails after the toast is up can + // pull it down instead of offering to undo something that never happened. + const undoToastIdsRef = useRef(new Map()); // ── Session bookkeeping ── const [sessionEntries, setSessionEntries] = useState([]); @@ -256,8 +265,8 @@ export default function AdminDoorPage() { ); const showUndoToast = useCallback( - (message: string, entry: SessionEntry) => { - toast.custom( + (message: string, entry: SessionEntry, durationMs: number = UNDO_WINDOW_MS) => { + const id = toast.custom( (t) => (
), - { duration: UNDO_WINDOW_MS, position: 'bottom-center' }, + { duration: durationMs, position: 'bottom-center' }, ); + undoToastIdsRef.current.set(entry.idempotencyKey, id); }, [handleUndo], ); + // ─── Full-screen result ────────────────────────────────────── + const showResult = useCallback((next: DoorResult) => { + resultRef.current = next; + setResult(next); + }, []); + + // Closing a success hands the undo affordance to the bottom toast for + // whatever remains of the window — never both at once (the toast layer sits + // above the overlay and would show a second Undo on top of the first). + const closeResult = useCallback( + (opts?: { skipUndoToast?: boolean }) => { + const closing = resultRef.current; + resultRef.current = null; + setResult(null); + focusInput(); + + if (opts?.skipUndoToast || closing?.kind !== 'success' || !closing.entry) return; + const remaining = UNDO_WINDOW_MS - (Date.now() - closing.entry.startedAt); + if (remaining < 500) return; + const suffix = closing.entry.method ? `, ${METHOD_PAST_TENSE[closing.entry.method]}` : ''; + showUndoToast(`${closing.entry.name} checked in${suffix}`, closing.entry, remaining); + }, + [focusInput, showUndoToast], + ); + + const undoFromResult = useCallback(() => { + const entry = resultRef.current?.entry; + // handleUndo announces itself with its own toast; don't also queue the undo one. + closeResult({ skipUndoToast: true }); + if (entry) handleUndo(entry); + }, [closeResult, handleUndo]); + // ─── The one write path ────────────────────────────────────── // Every completed action on this screen — scan, tap, collect, walk-in — flows // through here so the flash, the counter, the session feed and the undo all @@ -321,6 +363,7 @@ export default function AdminDoorPage() { entry: opts.entry, method: opts.payment?.method ?? null, amount: opts.payment?.amount ?? 0, + startedAt: now.getTime(), undone: false, failed: false, }; @@ -355,7 +398,12 @@ export default function AdminDoorPage() { playSuccessSound(); const paidSuffix = opts.payment ? `, ${METHOD_PAST_TENSE[opts.payment.method]}` : ''; - showUndoToast(`${opts.displayName} checked in${paidSuffix}`, sessionEntry); + showResult({ + kind: 'success', + name: opts.displayName, + detail: `Checked in ${clockTime(now)}${paidSuffix}`, + entry: sessionEntry, + }); setQuery(''); setExpandedId(null); @@ -395,6 +443,20 @@ export default function AdminDoorPage() { if (previousRow) upsertAttendee(previousRow); playErrorSound(); vibrate([100, 50, 100]); + // If the green screen for this very action is still up, turn it red in + // place; the toast below covers the case where it has already closed. + const staleUndo = undoToastIdsRef.current.get(idempotencyKey); + if (staleUndo) { + toast.dismiss(staleUndo); + undoToastIdsRef.current.delete(idempotencyKey); + } + if (resultRef.current?.entry?.idempotencyKey === idempotencyKey) { + showResult({ + kind: 'failed', + name: opts.displayName, + detail: error?.message || 'The check-in did not reach the server. Try again.', + }); + } toast.error(`FAILED — ${opts.displayName} is NOT checked in. ${error?.message || ''}`.trim(), { duration: 12000, position: 'bottom-center', @@ -404,7 +466,7 @@ export default function AdminDoorPage() { setBusyId((current) => (current === opts.busyKey ? null : current)); } }, - [data?.attendees, patchAttendee, upsertAttendee, showUndoToast, focusInput, loadAttendees], + [data?.attendees, patchAttendee, upsertAttendee, showResult, focusInput, loadAttendees], ); // ─── Row interactions ──────────────────────────────────────── @@ -488,19 +550,17 @@ export default function AdminDoorPage() { if (!attendee) { playErrorSound(); vibrate([100, 50, 100]); - toast.error('Ticket not found for this event', { position: 'bottom-center', duration: 6000 }); - focusInput(); + showResult({ kind: 'not_found', name: 'Ticket not found', detail: 'No ticket for this event' }); return; } if (attendee.checkedIn) { playErrorSound(); vibrate([100, 50, 100]); - toast(`${attendee.fullName} already checked in${attendee.checkinAt ? ` at ${clockTime(attendee.checkinAt)}` : ''}`, { - icon: 'ℹ️', - position: 'bottom-center', - duration: 6000, - }); + const at = attendee.checkinAt ? ` at ${clockTime(attendee.checkinAt)}` : ''; + const by = attendee.checkedInBy ? ` by ${attendee.checkedInBy}` : ''; + showResult({ kind: 'already_in', name: attendee.fullName, detail: `Already checked in${at}${by}` }); + // Their row sits open underneath, so closing lands on the same person. setQuery(attendee.fullName); setExpandedId(attendee.ticketId); return; @@ -508,13 +568,14 @@ export default function AdminDoorPage() { if (attendee.paymentStatus === 'unpaid') { // Checking an unpaid ticket in silently would walk the money out the - // door. Surface them with the tenders open instead. + // door. Surface them with the tenders open instead: "Collect" closes + // the screen straight onto the payment buttons. playErrorSound(); vibrate(200); - toast(`Collect ${formatCurrency(attendee.amountDue || price, currency)} from ${attendee.fullName}`, { - icon: '💰', - position: 'bottom-center', - duration: 8000, + showResult({ + kind: 'unpaid', + name: attendee.fullName, + detail: `Collect ${formatCurrency(attendee.amountDue || price, currency)}`, }); setQuery(attendee.fullName); setExpandedId(attendee.ticketId); @@ -529,7 +590,7 @@ export default function AdminDoorPage() { busyKey: attendee.ticketId, }); }, - [data?.attendees, runAction, focusInput, price, currency], + [data?.attendees, runAction, showResult, price, currency], ); // ─── Session summary ───────────────────────────────────────── @@ -732,6 +793,8 @@ export default function AdminDoorPage() { /> )} + {result && } + {sessionOpen && ( ({ @@ -508,6 +512,69 @@ export default function AdminSettingsPage() {
+ {/* Pre-sale Closure defaults */} + +
+
+
+ +
+
+

+ {locale === 'es' ? 'Cierre de Preventa' : 'Pre-sale Closure'} +

+

+ {locale === 'es' + ? 'Valor por defecto para los eventos. Cada evento puede cambiarlo en su ventana de edición.' + : 'Default for events. Each event can override this in its edit dialog.'} +

+
+
+ +
+
+

+ {locale === 'es' ? 'Cerrar la preventa antes del evento' : 'Close pre-sale before the event starts'} +

+

+ {settings.presaleClosureEnabled + ? (locale === 'es' ? 'Las inscripciones en línea se cierran antes del inicio' : 'Online registration stops before the start time') + : (locale === 'es' ? 'Las inscripciones siguen abiertas hasta el inicio' : 'Registration stays open until the event starts')} +

+
+ +
+ + {settings.presaleClosureEnabled && ( +
+ updateSetting('presaleCloseMinutesBefore', minutes)} + unitLabels={locale === 'es' + ? { minutes: 'minutos', hours: 'horas', days: 'días' } + : { minutes: 'minutes', hours: 'hours', days: 'days' }} + helper={locale === 'es' + ? 'Se aplica a los eventos que no tienen su propia configuración.' + : 'Applies to events that have not set their own value.'} + /> +
+ )} +
+
+ {/* Maintenance Mode */}
diff --git a/frontend/src/components/admin/DurationInput.tsx b/frontend/src/components/admin/DurationInput.tsx new file mode 100644 index 0000000..5cdcff6 --- /dev/null +++ b/frontend/src/components/admin/DurationInput.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Input from '@/components/ui/Input'; +import { DurationUnit, durationToMinutes, minutesToDuration } from '@/lib/utils'; + +interface DurationInputProps { + label?: string; + /** Duration in minutes (the stored unit). */ + valueMinutes: number; + onChange: (minutes: number) => void; + disabled?: boolean; + helper?: string; + /** Unit labels, overridable for Spanish admin pages. */ + unitLabels?: Record; +} + +const DEFAULT_UNIT_LABELS: Record = { + minutes: 'minutes', + hours: 'hours', + days: 'days', +}; + +/** + * Number + unit (minutes / hours / days) picker that always reports minutes. + * The unit is local UI state so switching hours -> minutes keeps the typed + * number rather than the stored value. + */ +export default function DurationInput({ + label, + valueMinutes, + onChange, + disabled, + helper, + unitLabels = DEFAULT_UNIT_LABELS, +}: DurationInputProps) { + const initial = minutesToDuration(valueMinutes); + const [unit, setUnit] = useState(initial.unit); + const [value, setValue] = useState(String(initial.value)); + + // Re-sync when the parent swaps in a new stored value (e.g. modal reopened + // for another event) that doesn't match what this input last reported. + useEffect(() => { + if (durationToMinutes(Number(value), unit) === valueMinutes) return; + const next = minutesToDuration(valueMinutes); + setUnit(next.unit); + setValue(String(next.value)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [valueMinutes]); + + const emit = (nextValue: string, nextUnit: DurationUnit) => { + const n = Number(nextValue); + onChange(durationToMinutes(Number.isFinite(n) ? n : 0, nextUnit)); + }; + + return ( +
+
+
+ { + setValue(e.target.value); + emit(e.target.value, unit); + }} + /> +
+ +
+ {helper &&

{helper}

} +
+ ); +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 916d6f2..b982f97 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -88,7 +88,10 @@ "spotsLeft": "spots left", "soldOut": "Sold Out", "cancelled": "Cancelled", - "eventEnded": "Event Ended" + "eventEnded": "Event Ended", + "registrationClosed": "Registration Closed", + "presaleClosesBefore": "Pre-sale registration closes {duration} before the event begins.", + "presaleClosesAtStart": "Pre-sale registration closes when the event begins." }, "booking": { "join": "Join Event", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index fed3414..5771049 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -88,7 +88,10 @@ "spotsLeft": "lugares disponibles", "soldOut": "Agotado", "cancelled": "Cancelado", - "eventEnded": "Evento Finalizado" + "eventEnded": "Evento Finalizado", + "registrationClosed": "Inscripciones Cerradas", + "presaleClosesBefore": "La preventa cierra {duration} antes de que comience el evento.", + "presaleClosesAtStart": "La preventa cierra cuando comienza el evento." }, "booking": { "join": "Unirse al Evento", diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index aa2346c..af0f304 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -18,6 +18,9 @@ export interface Event { bannerUrl?: string; externalBookingEnabled?: boolean; externalBookingUrl?: string; + presaleClosureEnabled?: boolean | null; // null = inherit the site default + presaleCloseMinutesBefore?: number | null; // null = inherit the site default + presaleClosesAt?: string | null; // server-computed cutoff (ISO); null = never closes bookedCount?: number; // paid seats (confirmed + checked_in) claimedCount?: number; // "I've paid" claims awaiting admin verification (hold seats) availableSeats?: number; // capacity - booked - claimed; the server-authoritative number @@ -484,6 +487,8 @@ export interface SiteSettings { maintenanceMode: boolean; maintenanceMessage?: string | null; maintenanceMessageEs?: string | null; + presaleClosureEnabled: boolean; + presaleCloseMinutesBefore: number; updatedAt?: string; updatedBy?: string; } diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index b91961e..fe6147a 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -235,3 +235,49 @@ export function isEventSoldOut(event: { }): boolean { return eventSpotsLeft(event) <= 0; } + +/** + * True once online registration has closed for an event. Relies on the + * server-computed `presaleClosesAt` (event override or site default), so the + * page and the booking API agree on the cutoff. + */ +export function isPresaleClosed( + event: { presaleClosesAt?: string | null }, + now: Date = new Date() +): boolean { + if (!event.presaleClosesAt) return false; + return parseDate(event.presaleClosesAt).getTime() <= now.getTime(); +} + +export type DurationUnit = 'minutes' | 'hours' | 'days'; + +const MINUTES_PER_UNIT: Record = { + minutes: 1, + hours: 60, + days: 1440, +}; + +/** Split a minute count into the largest unit that divides it evenly. */ +export function minutesToDuration(minutes: number): { value: number; unit: DurationUnit } { + const safe = Number.isFinite(minutes) && minutes >= 0 ? Math.floor(minutes) : 0; + if (safe > 0 && safe % MINUTES_PER_UNIT.days === 0) return { value: safe / MINUTES_PER_UNIT.days, unit: 'days' }; + if (safe > 0 && safe % MINUTES_PER_UNIT.hours === 0) return { value: safe / MINUTES_PER_UNIT.hours, unit: 'hours' }; + return { value: safe, unit: 'minutes' }; +} + +const DURATION_WORDS: Record<'en' | 'es', Record> = { + en: { minutes: ['minute', 'minutes'], hours: ['hour', 'hours'], days: ['day', 'days'] }, + es: { minutes: ['minuto', 'minutos'], hours: ['hora', 'horas'], days: ['día', 'días'] }, +}; + +/** "30 minutes", "1 hour", "2 días" — largest unit that divides the minutes evenly. */ +export function formatDurationWords(minutes: number, locale: 'en' | 'es' = 'en'): string { + const { value, unit } = minutesToDuration(minutes); + const [one, many] = DURATION_WORDS[locale][unit]; + return `${value} ${value === 1 ? one : many}`; +} + +export function durationToMinutes(value: number, unit: DurationUnit): number { + const safe = Number.isFinite(value) && value >= 0 ? value : 0; + return Math.round(safe * MINUTES_PER_UNIT[unit]); +}