18 Commits
Author SHA1 Message Date
MichilisandClaude Opus 5 745af4184f Restrict whole-event door takings to admin and organizer.
The door session sheet showed every staff member what the event had
taken overall, by tender and against pre-sale. That is management
information, not door information, and it matches the convention
already applied to the other revenue aggregates (admin/analytics,
admin/export/financial are both admin-only).

Door staff keep their own shift cash-up: the "This session" totals are
computed on the device from its own action log, so nothing they need to
reconcile at the end of the night is lost.

The gate is on GET /api/events/:eventId/door-summary, not only on the
section that renders it -- hiding the panel while the endpoint still
returned the figures would leave them one network response away. The
client skips the request entirely for staff rather than provoking a 403.

The test auth mock previously waved every role through, so it could not
have caught a wrong gate; it now honours the role list, which also puts
several already-written assertions onto real code paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:35:57 +00:00
MichilisandClaude Opus 5 02a12ee9e0 Fix the backend build against hono's narrowed route param type.
npm audit fix floated hono from 4.4 to 4.11 within the ^4.4.7 range and
tsc stopped compiling. c.req.param(key) has two overloads: it returns
string only when the route path's literal type survives inference, and
string | undefined otherwise. requireAuth() returns a handler annotated
with a bare Context, which erases the path generic, so every param read
behind it is now string | undefined.

Only four sites failed. Everywhere else the value lands in
eq((table as any).id, ...), where the any swallows it. Routes that also
run zValidator kept their typing, since that middleware is generic and
restores the inference -- door.ts:251 compiles while the two plain
requireAuth routes beside it do not.

loadEvent now accepts string | undefined and returns null for a missing
id, which both door callers already handle with their 404 branch. The
payments and tickets handlers pass the id off the row they just fetched
and null-checked rather than the raw param.

This leaves the underlying erasure in place. Typing requireAuth to
preserve the path generic would restore string on every authed route,
but it touches all of them and belongs in its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:15:28 +00:00
MichilisandClaude Opus 5 ed1d3a8c12 Paginate the bookings page and the event attendee/ticket tabs.
Extract the pagination controls shared by the events and users lists into
components/admin/Pagination, along with a usePaginatedList helper that slices a
list and keeps the page in range when the list shrinks under it.

Bookings, the Attendees tab and the Tickets tab paginate client-side rather than
on the server: each already loads its full set for figures a slice would break —
the bookings stat cards, the group-booking totals and the sibling payment-method
lookup, and the per-status counts the two tabs share with the event's other tabs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:01:27 +00:00
MichilisandClaude Opus 5 87bf9a6151 Fix Google sign-in for existing email and ticket-buyer accounts.
Google sign-in only worked for people who already had a linked google
row in auth_accounts. Anyone who first appeared another way -- a guest
ticket purchase, or an email/password signup made after the Better Auth
migration -- got a 401 "account not linked".

trustedProviders: ['google'] defeats only one of better-auth's two
linking gates. The second, requireLocalEmailVerified, defaults to true
and refuses the link whenever the LOCAL users.email_verified is false,
independently of whether the provider is trusted. That flag is false for
every guest-booking row and for every post-migration signup, since
requireEmailVerification is off and no verification mail is sent.

Turn that gate off: the Google id_token is signature-verified against
Google's JWKS with issuer/audience/max-age checks and carries its own
email_verified, so the local column proves nothing extra here.

Linking alone was not enough. getAuthUser() rejects any session whose
user is not 'active', so a ticket buyer would link Google, receive a
cookie, and still look logged out. A databaseHooks.account.create.after
hook now promotes unclaimed rows to claimed/active when a google account
is attached, scoped in the WHERE clause so a suspended account is never
reactivated this way.

Also normalize users.email. The unique index is case-sensitive while
better-auth lowercases every lookup, so someone who booked as
John@Gmail.com was invisible to sign-in and Google minted a SECOND user
row, stranding their tickets on the first. normalizeEmail() covers the
find-or-create sites in tickets.ts and door.ts plus the claim-eligibility
lookup, and an idempotent migration lowercases existing rows -- skipping
any that would collide and reporting those for manual merge, since
merging two people's tickets and payments is not a migration's call.
tickets.attendeeEmail still stores the address exactly as typed.

Tests drive the real signInSocial id-token path with Google stubbed by
signing tokens with a throwaway RS256 key and serving our own JWKS, so
the actual verification runs without network or credentials. That also
makes the deprecation risk loud: requireLocalEmailVerified is marked for
removal upstream, and an upgrade that drops it now fails CI instead of
silently locking ticket buyers out again.

Frontend carries error.code through so OAUTH_LINK_ERROR renders an
actionable message in both locales rather than a bare "account not
linked".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:31:02 +00:00
MichilisandClaude Opus 5 3f7b2d51db Redesign the PDF ticket around the branded card layout.
The old ticket was a centred stack of Helvetica on white that read as a
receipt: the QR sat in open space, the event, attendee and code lines
were indistinguishable at a glance, and nothing on the page identified
Spanglish beyond a text heading. The page is now a full-bleed card --
orange rule, cream field, navy footer -- with the logo and event title
in the header, the QR raised into a white rounded panel above its code,
and labelled venue and ticket holder blocks below it, so door staff can
find the code and the name without reading the page.

The ticket is bilingual, driven by the ticket's preferredLanguage: the
labels, the terms line, the Spanish event title and the date format all
follow it, with 24h time and day-first ordering in Spanish. Events
store the venue as one string, so the text before the first comma is
treated as the venue name and the remainder as its address.

Layout adapts rather than overflowing: long titles wrap to two lines at
a smaller size, and the QR panel flexes so the detail block always
lands just above the footer. The single and combined generators were
copies of each other and now share one page renderer, with multi-ticket
bookings marked by a small counter in the panel.

The logo ships as backend/assets/logo-spanglish.png, resolved from both
src/lib and dist/lib, falling back to the frontend copy and then to a
text wordmark, so a deployment that misses the asset still produces a
ticket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:16:41 +00:00
MichilisandClaude Opus 5 5970d707af Paginate the admin events list.
The admin events page fetched every event on each load and rendered
them all, which grows without bound as the archive fills up. GET
/api/events now takes optional page and pageSize parameters and returns
total alongside the rows; pagination is opt-in, so the public pages and
the admin filter dropdowns that pass neither still get the full list
and the untouched response shape.

Page size is selectable (10/25/50/100) and deleting the last event on a
page falls back to the new last page instead of showing an empty table.
The ?edit=<id> deep link no longer depends on the target being in the
current page: when it is missing from the loaded rows the event is
fetched directly, guarded by a ref so the modal opens once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:16:28 +00:00
MichilisandClaude Opus 5 e296e80e48 Rebuild the Scanner page into a unified door check-in screen.
Most attendees arrive without their QR open, and taking money at the
door meant leaving the scanner for the event dashboard, where the Add
Ticket modal demanded an email and recorded no payment method. The
screen now leads with manual name search, keeps the camera one tap away
behind a fullscreen overlay, and creates and charges walk-ins inline.

Check-in and payment are one action: anything done here is born
confirmed, paid (or comp) and checked in through a single endpoint,
POST /api/events/:eventId/door-checkin. There are no confirm dialogs
anywhere, because they stall the queue; a ten-second Undo replaces
them, reversing exactly what the action changed via the undo state
recorded alongside its idempotency key. Writes fire in the background
with retries, so venue wifi never blocks the person at the door, and a
capacity limit only warns, since staff at the door are the authority.

Every write carries a client-generated idempotency key, inserted in the
same transaction as the writes it guards, so a double tap or a retry
after a timeout cannot produce a second ticket, payment or check-in.
Search runs entirely in memory over one preloaded list: names are
matched accent- and case-insensitively in both directions, per word,
prefix before substring, with a mostly-numeric query searching phone
digits so two people with the same name can be told apart.

Door money is recorded as payments.source 'door' plus payments.method
(cash, bitcoin, transfer or guest) while provider keeps its existing
value, so capacity counting, the stale-booking sweeps and the admin
payment lists are unaffected and revenue can still be split pre-sale
versus door. Bitcoin records the payment as made, on the same trust
model as cash, with no invoice generated; lib/doorPayments.ts is where
a real Lightning flow slots in later.

Also fixes the SQLite tickets DDL, which still created the pre-split
attendee_name column with NOT NULL email and phone. Only fresh
databases were affected -- existing ones were relaxed by later ALTERs
-- but on those, door walk-ins (and any other ticket) could not be
inserted at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 06:09:55 +00:00
MichilisandClaude Opus 5 a0161a67d2 Add "paid at the door" ticket type to the Add Ticket modal.
Door walk-ins were only expressible as an unpaid ticket, which left the
cash out of revenue. The new type records the cash payment as paid and
makes every field optional, since a walk-in often gives no details:
a blank name is logged as "Walk-in", and a confirmation email only goes
out when an email is entered.

Door tickets reuse paymentStatus 'paid' (the column enum is capped at
paid/unpaid/comp) so badges and revenue totals pick them up with no
migration; the cash payment row is referenced "Paid at door" to keep
them distinguishable from emailed manual tickets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:39:54 +00:00
MichilisandCursor be4dd5b47f Add mobile save sheet with same-origin photo downloads.
Phones cannot land downloads in the photo library, so the gallery opens a share sheet for a cacheable preview while streaming via a dedicated download endpoint and recording preview sizes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 20:14:31 +00:00
MichilisandCursor 390e1dc0ea Hide admin payment summary cards when privacy mode is enabled.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:15:24 +00:00
MichilisandCursor fa8686276d Share gallery layout with loading skeletons and add download progress.
Keep the public gallery hero/masonry geometry stable across route and client loading, and show busy state while photos download.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:05:33 +00:00
Michilis 498d7d8a7d Add photo content-hash dedup and local↔S3 library sync.
Uploads skip per-gallery duplicates, checksums can be backfilled, and STORAGE_BACKEND plus sync tooling make switching storage backends safe.
2026-08-05 05:41:12 +00:00
MichilisandClaude Opus 4.6 dafa3711f8 Fix post-login redirect when the session cookie is off-origin
Signing in showed the "Welcome back!" toast but never left /login. The
session cookie was host-only on the API subdomain, so the Next middleware
guard on the site origin saw no cookie and bounced /dashboard straight
back to /login?redirect=/dashboard.

- Add AUTH_COOKIE_DOMAIN, wiring Better Auth's crossSubDomainCookies so
  the cookie also reaches the site origin. Unset in dev, where localhost
  is single-host and must stay host-only.
- Navigate after authentication with a full page load, via a shared
  authRedirect helper: only a top-level request carries the httpOnly
  cookie. Used by the login, register, magic-link and Google flows.
- Show "Redirecting..." on the login and register pages and keep the
  submit button disabled until the browser replaces the page, instead of
  re-enabling it mid-navigation.
- Guard against a redirect loop with a sessionStorage marker. A React ref
  cannot do this: the full page load resets component state. If the
  destination bounces back, explain it rather than navigating again.
- Middleware: accept any *.session_token cookie so a cookiePrefix change
  cannot lock everyone out, and preserve the destination's query string.
- Trust any loopback port in dev, so reaching the dev server through a
  forwarded port does not fail Better Auth's CSRF origin check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-29 20:45:05 +00:00
MichilisandClaude Opus 5 733d2459df Migrate authentication to Better Auth
Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie
sessions, validated against the database on every request so revocation,
bans and role changes take effect immediately.

Backend:
- betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and
  the admin plugin; auth-schema.ts maps Better Auth's models onto the
  existing `users` table so user IDs and their foreign keys survive intact.
- routes/auth.ts is gone; Better Auth serves the standard endpoints and
  authExt.ts carries the flows it doesn't cover.
- auth.ts shrinks to session resolution and helpers; sessions/revocation in
  dashboard.ts now read and delete `auth_sessions` rows directly.
- Schema adds the Better Auth core + admin columns (email_verified, image,
  banned, ban_reason, ban_expires), with migrations and tests.
- rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only
  honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES.
- passwordPolicy.ts centralises password validation.
- Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible
  with Better Auth.

Frontend:
- auth-client.ts plus a reworked AuthContext and api/client.ts move to
  cookie-based sessions; no more bearer tokens in requests or middleware.

photo-api:
- Validate Better Auth session cookies against the shared auth_sessions
  table instead of verifying JWTs; JWT_SECRET is no longer needed for user
  auth, and PHOTO_VIEW_SECRET now signs gallery view tokens.

BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the
deprecated JWT_SECRET stays only as the photo-api view-token fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:07:04 +00:00
MichilisandClaude Opus 5 4afa5d6fa0 Show when the booking was made in the verify-payment modal
The modal only had "User marked as paid", which is absent for manual
payments the customer never confirmed — those opened with no timestamp
at all. Show the payment's createdAt unconditionally as "Booking made",
and give both lines a relative age suffix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:06:39 +00:00
MichilisandClaude Opus 4.6 617c884012 Add view tokens for non-public gallery images and per-mode gate pages.
<img> tags cannot send Authorization headers, so non-public gallery photos
were invisible even to authorized viewers. The server now mints short-lived
HMAC view tokens (gallery-scoped, hour-bucketed) and embeds them in every
file URL for non-public galleries. Access denials return distinct 403
messages per visibility mode, and the frontend renders a matching gate page
(private, link-only, ticket-holders, login prompt) with an inline login
modal so visitors never leave the gallery page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-26 06:48:05 +00:00
MichilisandClaude Opus 4.8 93476ac72a Document photo galleries, payment providers, and updated deploy topology in README
Cover features added since the last README update: the standalone photo-api
Go service, the central payment provider registry (Lightning + manual
TPago/bank/card/cash), stable /next and /featured URLs, dashboard overview,
and the three-service dev/deploy setup (photo-api on 3003 dev / 3020 prod).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:57:30 +00:00
MichilisandClaude Fable 5 c4094630d9 Add a thumbnail filmstrip to the photo lightbox.
- Render a horizontally scrollable strip of thumbnails below the image
  that highlights and auto-centres the active photo and lets you jump
  straight to any shot.
- Pass thumbUrl through from the public and admin galleries, falling
  back to previewUrl when no thumbnail exists.
- Restructure the overlay into a flex column (image area + strip) and
  require a clearly horizontal gesture before treating a touch as a
  prev/next swipe, so vertical scrolls no longer trigger navigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 05:53:37 +00:00
133 changed files with 11588 additions and 3173 deletions
+70 -18
View File
@@ -4,17 +4,24 @@ A full-stack web app for organizing and managing language exchange events (Asunc
## Features ## Features
- **Public site**: events, booking, contact, community, legal pages, bilingual (EN/ES) - **Public site**: events, booking, contact, community, **photo galleries**, legal pages, bilingual (EN/ES)
- **User dashboard**: profile, tickets, payments, sessions/security - Stable `/next` and `/featured` URLs that redirect to the current event
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads - Human-readable event URL slugs (with legacy-ID redirect support)
- **User dashboard**: overview tab, profile, tickets, payments, sessions/security
- Lightning invoice reuse and re-payment straight from the dashboard
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads, **photo galleries**
- One unified ticket-creation modal with first-class payment status
- **Payments**: one automatic provider (**Lightning** via LNbits) plus manual providers (**TPago link**, bank transfer, card, cash), all defined in a central provider registry. Manual payments stay pending until an admin reconciles them (they are not auto-failed after the pending TTL).
- **Photo galleries** (standalone `photo-api` Go service): admins upload event photos, group them into galleries, and share them by visibility mode (public / private / share-link / ticket-holders). See [`photo-api/`](photo-api/README.md).
- **API**: Swagger UI at `/api-docs`, OpenAPI JSON at `/openapi.json`, health check at `/health` - **API**: Swagger UI at `/api-docs`, OpenAPI JSON at `/openapi.json`, health check at `/health`
## Tech stack ## Tech stack
- **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL - **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL
- **Auth**: JWT (via `jose`), **Argon2id** password hashing (with legacy bcrypt verification for older hashes) - **Photo service**: standalone Go module (`photo-api/`), its own binary/deploy unit, sharing the backend database and `JWT_SECRET`
- **Auth**: [Better Auth](https://better-auth.com) — httpOnly cookie sessions (DB-validated on every request for instant revocation), **Argon2id** password hashing (with legacy bcrypt verification for older hashes), magic links, Google sign-in, admin ban/suspend
- **Email**: `nodemailer` (SMTP) with optional provider config - **Email**: `nodemailer` (SMTP) with optional provider config
- **Frontend**: Next.js 14 (App Router), Tailwind CSS, Heroicons - **Frontend**: Next.js (App Router), Tailwind CSS, Heroicons, skeleton loading states, custom error / global-error pages
## Local development ## Local development
@@ -22,6 +29,8 @@ A full-stack web app for organizing and managing language exchange events (Asunc
- Node.js 18+ - Node.js 18+
- npm - npm
- Go 1.26+ (for the `photo-api` service)
- Optional: `libvips-tools` (or `libheif-examples`) on the host to accept HEIC photo uploads
### Setup ### Setup
@@ -29,12 +38,14 @@ A full-stack web app for organizing and managing language exchange events (Asunc
npm install npm install
cp backend/.env.example backend/.env cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.env cp frontend/.env.example frontend/.env
cp photo-api/.env.example photo-api/.env # set JWT_SECRET + DATABASE_URL to match backend/.env
``` ```
### Initialize database (SQLite by default) ### Initialize database (SQLite by default)
```bash ```bash
npm run db:migrate npm run db:migrate # backend (Drizzle) tables
npm run migrate:photos # photo-api owns only the photos_* tables via its own migrations
``` ```
### Run ### Run
@@ -43,10 +54,13 @@ npm run db:migrate
npm run dev npm run dev
``` ```
`npm run dev` starts the backend, frontend, and photo-api together (via `concurrently`).
Default URLs: Default URLs:
- Frontend: `http://localhost:3002` - Frontend: `http://localhost:3002`
- Backend API: `http://localhost:3001` - Backend API: `http://localhost:3001`
- Photo API: `http://localhost:3003` (the Next dev server rewrites `/api/photos/*` to it)
- API docs: `http://localhost:3001/api-docs` - API docs: `http://localhost:3001/api-docs`
### First user becomes admin ### First user becomes admin
@@ -58,16 +72,37 @@ The first user to register becomes the **admin**. Register at `/register`.
Run these from the repo root: Run these from the repo root:
```bash ```bash
npm run dev npm run dev # backend + frontend + photo-api
npm run build npm run build # build all workspaces
npm run start npm run start
npm run db:generate npm run db:generate
npm run db:migrate npm run db:migrate
npm run db:studio npm run db:studio
npm run db:export # Backup database npm run db:export # Backup database
npm run db:import # Restore from backup npm run db:import # Restore from backup
# Photo service (Go)
npm run dev:photos # go run ./cmd/photo-api
npm run build:photos # go build -o bin/photo-api
npm run migrate:photos # apply photos_* migrations
npm run test:photos # go test ./...
# Move the photo library between storage backends (both must be configured
# in photo-api/.env; the source is left untouched, reruns skip what is there)
npm run sync:photos:to-s3 # local disk -> S3
npm run sync:photos:to-local # S3 -> local disk
npm run sync:photos -- to-s3 --dry-run # flags: --dry-run --overwrite
# --concurrency=N --gallery=<id>
# Uploads are deduplicated per gallery by content hash. Photos uploaded before
# that existed need hashing once (idempotent, deletes nothing):
npm run backfill:photos:checksums
``` ```
After a sync, set `STORAGE_BACKEND=s3` (or `local`) in `photo-api/.env` and
restart the service to serve from the new backend. See
[`photo-api/README.md`](photo-api/README.md#move-the-library-between-backends).
You can also run per workspace: You can also run per workspace:
```bash ```bash
@@ -82,20 +117,34 @@ npm run dev --workspace=frontend
Key settings (see `backend/.env.example` for the full list): Key settings (see `backend/.env.example` for the full list):
- **DB**: `DB_TYPE=sqlite|postgres`, `DATABASE_URL=./data/spanglish.db` (or Postgres URL) - **DB**: `DB_TYPE=sqlite|postgres`, `DATABASE_URL=./data/spanglish.db` (or Postgres URL)
- **Auth**: `JWT_SECRET` (change in production) - **Auth**: `BETTER_AUTH_SECRET` (required in production, 32+ chars), `BETTER_AUTH_URL` (public site origin; falls back to `FRONTEND_URL`), optional `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`
- **Auth cookie scope**: `AUTH_COOKIE_DOMAIN` (optional, e.g. `.spanglishcommunity.com`) — set it only when the API is served from a different host than the site. The session cookie is host-only by default, so a cookie issued by `api.example.com` is invisible to the frontend's Next middleware on `example.com` and every `/dashboard`/`/admin` visit bounces back to `/login`. Leave empty in development.
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL` - **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials - **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
- **Payments (optional)**: Stripe/MercadoPago/LNbits configuration - **Payments (optional)**: LNbits (Lightning) configuration for the automatic provider. Manual providers (TPago link, bank transfer, card, cash) need no API keys — the TPago pay link is configured and sent via an email template.
- **Scaling (optional)**: `REDIS_URL`, `DB_POOL_MAX`, and `S3_*` (see "Horizontal scaling" below) - **Scaling (optional)**: `REDIS_URL`, `DB_POOL_MAX`, and `S3_*` (see "Horizontal scaling" below)
### Photo service (`photo-api/.env`)
Key settings (see `photo-api/.env.example`):
- **Port**: `PORT=3003` (dev)
- **DB**: `DB_TYPE` and `DATABASE_URL` — point at the **same** database as the backend
- **Auth**: none needed for user auth — the service validates Better Auth session cookies against the shared database. `PHOTO_VIEW_SECRET` signs gallery image view tokens (falls back to `JWT_SECRET` during migration).
- **Storage**: `STORAGE_PATH` (local disk) or `S3_ENDPOINT` + `S3_BUCKET` (S3/Garage/MinIO); S3 downloads use short-lived presigned URLs
- **Storage switch**: `STORAGE_BACKEND=auto|local|s3` (`auto` = S3 when it is configured). Keep both sides configured and flip this one line to move between them; `npm run sync:photos:to-s3` / `:to-local` copies the existing library first
- **Uploads/worker**: `MAX_UPLOAD_MB`, `WORKER_CONCURRENCY` (a worker generates thumb/preview JPEG variants with EXIF stripped)
### Frontend (`frontend/.env`) ### Frontend (`frontend/.env`)
Key settings (see `frontend/.env.example`): Key settings (see `frontend/.env.example`):
- **Server port**: `PORT=3002` - **Server port**: `PORT=3002`
- **API base URL**: `NEXT_PUBLIC_API_URL` (optional) - **API base URL**: `NEXT_PUBLIC_API_URL` (optional)
- Leave empty to use same-origin `/api` (recommended when running behind nginx) - Leave empty to use same-origin `/api` (recommended when running behind nginx). Requests become relative paths, so nginx maps them to the backend port in production and the Next.js rewrites do it in dev — the browser never needs to know the port.
- In local dev, Next.js rewrites `/api/*` and `/uploads/*` to the backend - Inlined at build time: changing it requires a rebuild, not just a restart.
- Pointing it at a separate API host (e.g. `https://api.example.com`) puts the session cookie on that host, where the Next middleware guarding `/admin` and `/dashboard` cannot read it. If you do that, set `AUTH_COOKIE_DOMAIN` on the backend as well.
- **Server-side API hosts**: `PHOTO_API_URL` (server-rendered `/photos` pages and the sitemap) and `BACKEND_URL` — server components cannot use relative URLs, so these are needed even when `NEXT_PUBLIC_API_URL` is empty. In production point them at loopback (`http://127.0.0.1:3020` / `http://127.0.0.1:3018`).
- **Social links (optional)**: `NEXT_PUBLIC_WHATSAPP`, `NEXT_PUBLIC_INSTAGRAM`, etc. - **Social links (optional)**: `NEXT_PUBLIC_WHATSAPP`, `NEXT_PUBLIC_INSTAGRAM`, etc.
## Database ## Database
@@ -143,12 +192,13 @@ npm run db:import -- --yes ./data/backups/spanglish-2025-03-07.sql # Skip conf
This repo includes example configs in `deploy/`: This repo includes example configs in `deploy/`:
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service` - **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`, `deploy/spanglish-photos.service`
- Backend runs on **3018**, frontend on **3019** by default (see the unit files) - Backend runs on **3018**, frontend on **3019**, photo-api on **3020** by default (see the unit files)
- Backend needs write access to `backend/data` and `backend/uploads` - Backend needs write access to `backend/data` and `backend/uploads`
- The photo service runs its own migrations on start (`photo-api migrate`) and needs write access to its `STORAGE_PATH` (or S3 config)
- **nginx**: - **nginx**:
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019 - `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019/3020
- `deploy/front-end_nginx.conf` proxies `/api` and `/uploads` to the backend and everything else to the frontend - `deploy/front-end_nginx.conf` proxies `/api/photos` to the photo service, `/api` and `/uploads` to the backend, and everything else to the frontend
- `deploy/back-end_nginx.conf` is a dedicated API vhost example with CORS handling - `deploy/back-end_nginx.conf` is a dedicated API vhost example with CORS handling
Typical production flow: Typical production flow:
@@ -156,7 +206,9 @@ Typical production flow:
```bash ```bash
npm ci npm ci
npm run build npm run build
npm run build:photos
npm run db:migrate npm run db:migrate
npm run migrate:photos
``` ```
Then install/enable the systemd services and nginx configs for your server. Then install/enable the systemd services and nginx configs for your server.
+29 -1
View File
@@ -50,13 +50,41 @@ DATABASE_URL=./data/spanglish.db
# Use path-style addressing (true for Garage/MinIO). Defaults to true. # Use path-style addressing (true for Garage/MinIO). Defaults to true.
# S3_FORCE_PATH_STYLE=true # S3_FORCE_PATH_STYLE=true
# JWT Secret (change in production!) # Better Auth session secret. REQUIRED in production, 32+ characters.
# Generate one with: openssl rand -base64 48
# Rotating it signs everyone out (cookie signatures invalidate).
BETTER_AUTH_SECRET=
# Public site origin Better Auth builds its URLs against (falls back to
# FRONTEND_URL when unset). E.g. https://spanglishcommunity.com
BETTER_AUTH_URL=
# Session cookie domain, shared across subdomains. Set this when the API is served
# from a different host than the site (e.g. api.spanglishcommunity.com vs
# spanglishcommunity.com): without it the cookie is host-only and the frontend's
# Next middleware cannot see it, so /dashboard and /admin bounce back to /login.
# Must start with a dot. Leave EMPTY in development (localhost is single-host).
# E.g. .spanglishcommunity.com
AUTH_COOKIE_DOMAIN=
# Extra reverse-proxy IP prefixes allowed to set X-Real-IP / X-Forwarded-For
# (comma-separated, e.g. "172.20."). Loopback and RFC1918 ranges are always
# trusted; anything else is treated as a client and rate-limited by its
# actual socket address.
# TRUSTED_PROXIES=
# DEPRECATED: no longer used for API auth (Better Auth replaced the JWTs).
# Still read by photo-api as the fallback secret for gallery view tokens
# until PHOTO_VIEW_SECRET is set there; safe to remove after that.
JWT_SECRET=your-super-secret-key-change-in-production JWT_SECRET=your-super-secret-key-change-in-production
# Google OAuth (optional - for Google Sign-In) # Google OAuth (optional - for Google Sign-In)
# Get your Client ID from: https://console.cloud.google.com/apis/credentials # Get your Client ID from: https://console.cloud.google.com/apis/credentials
# Note: The same Client ID should be used in frontend/.env # Note: The same Client ID should be used in frontend/.env
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
# Only needed for the redirect OAuth flow; the Google Identity Services
# button (ID token sign-in) works with the Client ID alone.
GOOGLE_CLIENT_SECRET=
# Server Configuration # Server Configuration
PORT=3001 PORT=3001
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+4 -3
View File
@@ -20,9 +20,10 @@
"@hono/zod-openapi": "^0.14.4", "@hono/zod-openapi": "^0.14.4",
"argon2": "^0.44.0", "argon2": "^0.44.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^11.0.0", "better-auth": "1.6.25",
"better-sqlite3": "^12.11.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.31.2", "drizzle-orm": "^0.45.2",
"hono": "^4.4.7", "hono": "^4.4.7",
"ioredis": "^5.11.1", "ioredis": "^5.11.1",
"jose": "^5.4.0", "jose": "^5.4.0",
@@ -42,7 +43,7 @@
"@types/pdfkit": "^0.17.4", "@types/pdfkit": "^0.17.4",
"@types/pg": "^8.11.6", "@types/pg": "^8.11.6",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"drizzle-kit": "^0.22.8", "drizzle-kit": "^0.31.10",
"ioredis-mock": "^8.13.1", "ioredis-mock": "^8.13.1",
"tsx": "^4.15.7", "tsx": "^4.15.7",
"typescript": "^5.5.2", "typescript": "^5.5.2",
+200
View File
@@ -0,0 +1,200 @@
import { sqliteTable, text, integer, customType as sqliteCustomType } from 'drizzle-orm/sqlite-core';
import {
pgTable,
uuid,
varchar,
text as pgText,
timestamp,
boolean as pgBoolean,
bigint,
customType as pgCustomType,
} from 'drizzle-orm/pg-core';
// Better Auth table definitions for both dialects.
//
// The `user` model maps onto the EXISTING `users` table so user IDs (and every
// foreign key that references them) survive the auth migration untouched. Only
// the columns Better Auth reads/writes are declared here; legacy columns
// (password, google_id, token_version) stay physically present but invisible
// to Better Auth. The full application-facing definition lives in schema.ts —
// two Drizzle table objects can safely describe the same SQL table.
const dbType = process.env.DB_TYPE || 'sqlite';
// Better Auth hands the adapter JS Date objects, but the legacy sqlite `users`
// timestamps are ISO-8601 TEXT columns. Bridge the two representations.
const isoText = sqliteCustomType<{ data: Date; driverData: string }>({
dataType() {
return 'text';
},
toDriver(value: Date): string {
return (value instanceof Date ? value : new Date(value)).toISOString();
},
fromDriver(value: string): Date {
return new Date(value);
},
});
// Legacy pg `users.is_claimed` is an INTEGER 0/1 column; expose it as boolean.
const pgIntBool = pgCustomType<{ data: boolean; driverData: number }>({
dataType() {
return 'integer';
},
toDriver(value: boolean): number {
return value ? 1 : 0;
},
fromDriver(value: number | boolean): boolean {
return Boolean(value);
},
});
// ==================== SQLite ====================
export const sqliteAuthUsers = sqliteTable('users', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
image: text('image'),
createdAt: isoText('created_at').notNull(),
updatedAt: isoText('updated_at').notNull(),
// admin plugin fields
role: text('role').notNull().default('user'),
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
banReason: text('ban_reason'),
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
// application additionalFields
phone: text('phone'),
languagePreference: text('language_preference'),
rucNumber: text('ruc_number'),
isClaimed: integer('is_claimed', { mode: 'boolean' }).notNull().default(true),
accountStatus: text('account_status').notNull().default('active'),
});
export const sqliteAuthSessions = sqliteTable('auth_sessions', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
token: text('token').notNull().unique(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
// admin plugin (impersonation)
impersonatedBy: text('impersonated_by'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sqliteAuthAccounts = sqliteTable('auth_accounts', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }),
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
scope: text('scope'),
password: text('password'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sqliteAuthVerifications = sqliteTable('auth_verifications', {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sqliteAuthRateLimits = sqliteTable('auth_rate_limits', {
id: text('id').primaryKey(),
key: text('key'),
count: integer('count'),
lastRequest: integer('last_request'),
});
// ==================== PostgreSQL ====================
export const pgAuthUsers = pgTable('users', {
id: uuid('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }).notNull().unique(),
emailVerified: pgBoolean('email_verified').notNull().default(false),
image: pgText('image'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
// admin plugin fields
role: varchar('role', { length: 20 }).notNull().default('user'),
banned: pgBoolean('banned').notNull().default(false),
banReason: pgText('ban_reason'),
banExpires: timestamp('ban_expires'),
// application additionalFields
phone: varchar('phone', { length: 50 }),
languagePreference: varchar('language_preference', { length: 10 }),
rucNumber: varchar('ruc_number', { length: 15 }),
isClaimed: pgIntBool('is_claimed').notNull(),
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
});
export const pgAuthSessions = pgTable('auth_sessions', {
id: uuid('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
token: varchar('token', { length: 255 }).notNull().unique(),
expiresAt: timestamp('expires_at').notNull(),
ipAddress: varchar('ip_address', { length: 45 }),
userAgent: pgText('user_agent'),
// admin plugin (impersonation)
impersonatedBy: uuid('impersonated_by'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgAuthAccounts = pgTable('auth_accounts', {
id: uuid('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
accountId: varchar('account_id', { length: 255 }).notNull(),
providerId: varchar('provider_id', { length: 100 }).notNull(),
accessToken: pgText('access_token'),
refreshToken: pgText('refresh_token'),
idToken: pgText('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: pgText('scope'),
password: pgText('password'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgAuthVerifications = pgTable('auth_verifications', {
id: uuid('id').primaryKey(),
identifier: varchar('identifier', { length: 255 }).notNull(),
value: pgText('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgAuthRateLimits = pgTable('auth_rate_limits', {
id: varchar('id', { length: 64 }).primaryKey(),
key: varchar('key', { length: 255 }),
count: bigint('count', { mode: 'number' }),
lastRequest: bigint('last_request', { mode: 'number' }),
});
// ==================== Runtime-switched exports ====================
export const authUsers = dbType === 'postgres' ? pgAuthUsers : sqliteAuthUsers;
export const authSessions = dbType === 'postgres' ? pgAuthSessions : sqliteAuthSessions;
export const authAccounts = dbType === 'postgres' ? pgAuthAccounts : sqliteAuthAccounts;
export const authVerifications = dbType === 'postgres' ? pgAuthVerifications : sqliteAuthVerifications;
export const authRateLimits = dbType === 'postgres' ? pgAuthRateLimits : sqliteAuthRateLimits;
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import Database from 'better-sqlite3';
// Migration idempotency for the Better Auth backfill: seed legacy-shaped user
// rows, run migrate repeatedly, and assert the backfill is correct and never
// duplicates.
const dir = mkdtempSync(join(tmpdir(), 'ba-migrate-test-'));
const dbPath = join(dir, 'migrate.db');
function runMigrate() {
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], {
env: {
...process.env,
DB_TYPE: 'sqlite',
DATABASE_URL: dbPath,
REDIS_URL: '',
},
stdio: 'pipe',
});
}
let db: Database.Database;
beforeAll(() => {
// First run creates the schema
runMigrate();
db = new Database(dbPath);
// Seed legacy-shaped users (pre-Better-Auth): password lives on users,
// google_id links Google, '' marks guest accounts, suspended via status.
const now = new Date().toISOString();
const insert = db.prepare(
`INSERT INTO users (id, email, password, name, role, is_claimed, google_id, account_status, token_version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
);
insert.run('legacy-argon', 'argon@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake', 'Argon', 'user', 1, null, 'active', now, now);
insert.run('legacy-bcrypt', 'bcrypt@old.py', '$2a$10$fakebcryptfakebcryptfakebc', 'Bcrypt', 'admin', 1, null, 'active', now, now);
insert.run('legacy-guest', 'guest@old.py', '', 'Guest', 'user', 1, null, 'active', now, now);
insert.run('legacy-google', 'google@old.py', null, 'Google', 'user', 1, 'google-sub-123', 'active', now, now);
insert.run('legacy-both', 'both@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake2', 'Both', 'user', 1, 'google-sub-456', 'active', now, now);
insert.run('legacy-suspended', 'suspended@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake3', 'Bad', 'user', 1, null, 'suspended', now, now);
// Second run performs the backfill against the seeded rows
runMigrate();
}, 240_000);
describe('Better Auth migration backfill', () => {
it('creates credential accounts for users with real passwords only', () => {
const rows = db
.prepare("SELECT user_id, password FROM auth_accounts WHERE provider_id = 'credential' ORDER BY user_id")
.all() as any[];
const byUser = new Map(rows.map((r) => [r.user_id, r.password]));
expect(byUser.get('legacy-argon')).toContain('$argon2id$');
expect(byUser.get('legacy-bcrypt')).toContain('$2a$');
expect(byUser.get('legacy-both')).toContain('$argon2id$');
expect(byUser.get('legacy-suspended')).toBeTruthy();
// Guests ('' password) and Google-only users get no credential account
expect(byUser.has('legacy-guest')).toBe(false);
expect(byUser.has('legacy-google')).toBe(false);
});
it('creates google accounts from google_id', () => {
const rows = db
.prepare("SELECT user_id, account_id FROM auth_accounts WHERE provider_id = 'google' ORDER BY user_id")
.all() as any[];
expect(rows).toEqual([
{ user_id: 'legacy-both', account_id: 'google-sub-456' },
{ user_id: 'legacy-google', account_id: 'google-sub-123' },
]);
});
it('marks claimed legacy users email-verified, guests not', () => {
const verified = (email: string) =>
(db.prepare('SELECT email_verified FROM users WHERE email = ?').get(email) as any).email_verified;
expect(verified('argon@old.py')).toBe(1);
expect(verified('google@old.py')).toBe(1);
expect(verified('guest@old.py')).toBe(0);
});
it('mirrors suspended accounts to banned', () => {
const row = db.prepare('SELECT banned, ban_reason FROM users WHERE email = ?').get('suspended@old.py') as any;
expect(row.banned).toBe(1);
expect(row.ban_reason).toContain('suspended');
const active = db.prepare('SELECT banned FROM users WHERE email = ?').get('argon@old.py') as any;
expect(active.banned).toBe(0);
});
it('is idempotent: a third run adds nothing', () => {
const count = () => (db.prepare('SELECT COUNT(*) AS n FROM auth_accounts').get() as any).n;
const before = count();
runMigrate();
expect(count()).toBe(before);
}, 60_000);
});
+333 -5
View File
@@ -1,6 +1,6 @@
import 'dotenv/config'; import 'dotenv/config';
import { db, dbAll, events } from './index.js'; import { db, dbAll, dbGet, events, users } from './index.js';
import { sql, eq } from 'drizzle-orm'; import { sql, eq, ne } from 'drizzle-orm';
import { uniqueSlug } from '../lib/slugify.js'; import { uniqueSlug } from '../lib/slugify.js';
const dbType = process.env.DB_TYPE || 'sqlite'; const dbType = process.env.DB_TYPE || 'sqlite';
@@ -133,17 +133,25 @@ async function migrate() {
`); `);
await (db as any).run(sql` await (db as any).run(sql`
-- Matches db/schema.ts. The legacy attendee_name / NOT NULL email+phone
-- shape only survives in databases created before the split into
-- first/last name, where the ALTERs below relaxed it; a fresh database
-- must not recreate constraints the app no longer satisfies (door
-- walk-ins have neither an email nor a phone).
CREATE TABLE IF NOT EXISTS tickets ( CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id), user_id TEXT NOT NULL REFERENCES users(id),
event_id TEXT NOT NULL REFERENCES events(id), event_id TEXT NOT NULL REFERENCES events(id),
attendee_name TEXT NOT NULL, attendee_first_name TEXT NOT NULL,
attendee_email TEXT NOT NULL, attendee_last_name TEXT,
attendee_phone TEXT NOT NULL, attendee_email TEXT,
attendee_phone TEXT,
attendee_ruc TEXT,
preferred_language TEXT, preferred_language TEXT,
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
checkin_at TEXT, checkin_at TEXT,
qr_code TEXT, qr_code TEXT,
admin_note TEXT,
created_at TEXT NOT NULL created_at TEXT NOT NULL
) )
`); `);
@@ -259,6 +267,25 @@ async function migrate() {
try { try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`); await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
// Door check-in screen: split pre-sale vs door revenue and record the tender
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN source TEXT NOT NULL DEFAULT 'presale'`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN method TEXT`);
} catch (e) { /* column may already exist */ }
// Idempotency records for door check-in actions (retries / double taps)
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS idempotency_keys (
key TEXT PRIMARY KEY,
scope TEXT NOT NULL,
result TEXT NOT NULL,
undo_state TEXT,
undone_at TEXT,
created_at TEXT NOT NULL
)
`);
// Invoices table // Invoices table
await (db as any).run(sql` await (db as any).run(sql`
@@ -544,6 +571,81 @@ async function migrate() {
updated_by TEXT REFERENCES users(id) updated_by TEXT REFERENCES users(id)
) )
`); `);
// ==================== Better Auth ====================
// Better Auth core + admin plugin columns on the existing users table
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN image TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_reason TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_expires INTEGER`);
} catch (e) { /* column may already exist */ }
// Better Auth sessions (replaces the legacy user_sessions table).
// Timestamps are integer epoch-milliseconds (Drizzle timestamp_ms mode).
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
ip_address TEXT,
user_agent TEXT,
impersonated_by TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Better Auth accounts: credential (password hash) and OAuth provider links
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at INTEGER,
refresh_token_expires_at INTEGER,
scope TEXT,
password TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Better Auth verification values (magic links, password reset tokens)
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_verifications (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Better Auth rate limiting (used when Redis is not configured)
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_rate_limits (
id TEXT PRIMARY KEY,
key TEXT,
count INTEGER,
last_request INTEGER
)
`);
} else { } else {
// PostgreSQL migrations // PostgreSQL migrations
await (db as any).execute(sql` await (db as any).execute(sql`
@@ -761,6 +863,25 @@ async function migrate() {
try { try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`); await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
// Door check-in screen: split pre-sale vs door revenue and record the tender
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'presale'`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN method VARCHAR(20)`);
} catch (e) { /* column may already exist */ }
// Idempotency records for door check-in actions (retries / double taps)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS idempotency_keys (
key VARCHAR(128) PRIMARY KEY,
scope VARCHAR(64) NOT NULL,
result TEXT NOT NULL,
undo_state TEXT,
undone_at TIMESTAMP,
created_at TIMESTAMP NOT NULL
)
`);
// Invoices table // Invoices table
await (db as any).execute(sql` await (db as any).execute(sql`
@@ -1044,6 +1165,80 @@ async function migrate() {
updated_by UUID REFERENCES users(id) updated_by UUID REFERENCES users(id)
) )
`); `);
// ==================== Better Auth ====================
// Better Auth core + admin plugin columns on the existing users table
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS image TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS banned BOOLEAN NOT NULL DEFAULT FALSE`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_reason TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_expires TIMESTAMP`);
} catch (e) { /* column may already exist */ }
// Better Auth sessions (replaces the legacy user_sessions table)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
ip_address VARCHAR(45),
user_agent TEXT,
impersonated_by UUID,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Better Auth accounts: credential (password hash) and OAuth provider links
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_accounts (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id VARCHAR(255) NOT NULL,
provider_id VARCHAR(100) NOT NULL,
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at TIMESTAMP,
refresh_token_expires_at TIMESTAMP,
scope TEXT,
password TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Better Auth verification values (magic links, password reset tokens)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_verifications (
id UUID PRIMARY KEY,
identifier VARCHAR(255) NOT NULL,
value TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Better Auth rate limiting (used when Redis is not configured)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_rate_limits (
id VARCHAR(64) PRIMARY KEY,
key VARCHAR(255),
count BIGINT,
last_request BIGINT
)
`);
} }
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines) // Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
@@ -1054,8 +1249,15 @@ async function migrate() {
`CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets(status)`, `CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets(status)`,
`CREATE INDEX IF NOT EXISTS payments_ticket_id_idx ON payments(ticket_id)`, `CREATE INDEX IF NOT EXISTS payments_ticket_id_idx ON payments(ticket_id)`,
`CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`, `CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`,
`CREATE INDEX IF NOT EXISTS payments_source_idx ON payments(source)`,
`CREATE INDEX IF NOT EXISTS idempotency_keys_created_at_idx ON idempotency_keys(created_at)`,
`CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`, `CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`,
`CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`, `CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`,
`CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth_sessions(user_id)`,
`CREATE INDEX IF NOT EXISTS auth_accounts_user_id_idx ON auth_accounts(user_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS auth_accounts_provider_account_idx ON auth_accounts(provider_id, account_id)`,
`CREATE INDEX IF NOT EXISTS auth_verifications_identifier_idx ON auth_verifications(identifier)`,
`CREATE INDEX IF NOT EXISTS auth_rate_limits_key_idx ON auth_rate_limits(key)`,
]; ];
for (const stmt of indexStatements) { for (const stmt of indexStatements) {
try { try {
@@ -1067,6 +1269,132 @@ async function migrate() {
} catch (e) { /* index may already exist */ } } catch (e) { /* index may already exist */ }
} }
// ==================== Better Auth data backfill ====================
// Idempotent: every statement is guarded so re-running migrate is safe, and
// legacy users are distinguished from Better-Auth-created users by having
// users.password / users.google_id set (Better Auth never writes either).
if (dbType === 'sqlite') {
// Legacy password hashes -> credential accounts (argon2 and bcrypt hashes
// both stay valid via the custom password verifier in lib/betterAuth.ts)
await (db as any).run(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, password, created_at, updated_at)
SELECT lower(hex(randomblob(16))), u.id, u.id, 'credential', u.password,
CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000
FROM users u
WHERE u.password IS NOT NULL AND u.password != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'credential'
)
`);
// Legacy Google links -> google provider accounts
await (db as any).run(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, created_at, updated_at)
SELECT lower(hex(randomblob(16))), u.id, u.google_id, 'google',
CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000
FROM users u
WHERE u.google_id IS NOT NULL AND u.google_id != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'google'
)
`);
// Claimed legacy accounts proved their email (register/claim link/Google)
await (db as any).run(sql`
UPDATE users SET email_verified = 1
WHERE email_verified = 0 AND is_claimed = 1
AND ((password IS NOT NULL AND password != '') OR google_id IS NOT NULL)
`);
// Suspended -> banned (admin plugin field); users.ts keeps them in sync
await (db as any).run(sql`
UPDATE users SET banned = 1, ban_reason = 'migrated: account suspended'
WHERE account_status = 'suspended' AND banned = 0
`);
} else {
await (db as any).execute(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, password, created_at, updated_at)
SELECT gen_random_uuid(), u.id, u.id::text, 'credential', u.password, NOW(), NOW()
FROM users u
WHERE u.password IS NOT NULL AND u.password != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'credential'
)
`);
await (db as any).execute(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, created_at, updated_at)
SELECT gen_random_uuid(), u.id, u.google_id, 'google', NOW(), NOW()
FROM users u
WHERE u.google_id IS NOT NULL AND u.google_id != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'google'
)
`);
await (db as any).execute(sql`
UPDATE users SET email_verified = TRUE
WHERE email_verified = FALSE AND is_claimed = 1
AND ((password IS NOT NULL AND password != '') OR google_id IS NOT NULL)
`);
await (db as any).execute(sql`
UPDATE users SET banned = TRUE, ban_reason = 'migrated: account suspended'
WHERE account_status = 'suspended' AND banned = FALSE
`);
}
// ==================== users.email normalization ====================
// Better Auth lowercases the address on every lookup and write it performs,
// but the users.email unique index is case-sensitive on both dialects. Rows
// written outside Better Auth (guest bookings, door sales, admin-added
// tickets) used to keep the address exactly as typed, so a buyer who entered
// "John@Gmail.com" was invisible to sign-in and to Google account linking:
// signing in with Google minted a SECOND user row and left their tickets
// stranded on the first. lib/utils.ts normalizeEmail() fixes new writes; this
// fixes the rows already in the table.
//
// Idempotent, and deliberately conservative: a row is only lowercased when
// nothing already occupies the lowercase address. A genuine collision means
// two user rows for the same person, each with its own tickets, invoices and
// payments — merging those is a judgement call, not a migration, so they are
// reported for manual review instead.
const lowercaseEmailsSql = `
UPDATE users SET email = LOWER(email)
WHERE email <> LOWER(email)
AND NOT EXISTS (
SELECT 1 FROM users u2 WHERE u2.id <> users.id AND u2.email = LOWER(users.email)
)
`;
if (dbType === 'sqlite') {
await (db as any).run(sql.raw(lowercaseEmailsSql));
} else {
await (db as any).execute(sql.raw(lowercaseEmailsSql));
}
// Whatever still differs from its own lowercase form is exactly the set the
// UPDATE refused to touch, i.e. the collisions.
const collisions = await dbAll<{ id: string; email: string }>(
(db as any)
.select({ id: (users as any).id, email: (users as any).email })
.from(users)
.where(ne((users as any).email, sql`LOWER(${(users as any).email})`))
);
if (collisions.length > 0) {
console.warn(
`WARNING: ${collisions.length} users row(s) keep a mixed-case email because the ` +
`lowercase address is already taken. Sign-in and Google linking only ever reach ` +
`the lowercase row, so these need a manual merge:`
);
for (const row of collisions) {
const canonical = row.email.toLowerCase();
const existing = await dbGet<{ id: string }>(
(db as any).select({ id: (users as any).id }).from(users).where(eq((users as any).email, canonical))
);
console.warn(` ${row.id} (${row.email}) -> keeps losing to ${existing?.id} (${canonical})`);
}
}
// Backfill slugs for any events that don't have one yet (shared across DB types). // Backfill slugs for any events that don't have one yet (shared across DB types).
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes. // Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>( const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
+52 -1
View File
@@ -1,5 +1,5 @@
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'; import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger } from 'drizzle-orm/pg-core'; import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger, boolean as pgBoolean } from 'drizzle-orm/pg-core';
// Type to determine which schema to use // Type to determine which schema to use
const dbType = process.env.DB_TYPE || 'sqlite'; const dbType = process.env.DB_TYPE || 'sqlite';
@@ -20,6 +20,12 @@ export const sqliteUsers = sqliteTable('users', {
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'), accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset) // Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
tokenVersion: integer('token_version').notNull().default(0), tokenVersion: integer('token_version').notNull().default(0),
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
image: text('image'),
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
banReason: text('ban_reason'),
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(), updatedAt: text('updated_at').notNull(),
}); });
@@ -132,10 +138,32 @@ export const sqlitePayments = sqliteTable('payments', {
paidByAdminId: text('paid_by_admin_id'), paidByAdminId: text('paid_by_admin_id'),
adminNote: text('admin_note'), // Internal admin notes adminNote: text('admin_note'), // Internal admin notes
reminderSentAt: text('reminder_sent_at'), // When payment reminder email was sent reminderSentAt: text('reminder_sent_at'), // When payment reminder email was sent
// Where the money was taken: 'presale' (online/admin, the default) or 'door'
// (recorded by staff on the door check-in screen). Splits pre-sale vs door revenue.
source: text('source', { enum: ['presale', 'door'] }).notNull().default('presale'),
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
// 'guest' is a zero-amount comp entry and carries no revenue.
method: text('method', { enum: ['cash', 'bitcoin', 'transfer', 'guest'] }),
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(), updatedAt: text('updated_at').notNull(),
}); });
// Idempotency records for door check-in actions.
//
// The door screen fires check-ins / walk-in creations optimistically and retries
// on flaky venue wifi, so every action carries a client-generated key. The first
// request stores its response here; replays return that stored response instead
// of creating a second ticket, payment or check-in. `undoState` holds exactly
// what the action changed so the 10-second Undo can reverse it precisely.
export const sqliteIdempotencyKeys = sqliteTable('idempotency_keys', {
key: text('key').primaryKey(),
scope: text('scope').notNull(),
result: text('result').notNull(), // JSON response body of the original request
undoState: text('undo_state'), // JSON describing how to reverse the action
undoneAt: text('undone_at'),
createdAt: text('created_at').notNull(),
});
// Payment Options Configuration Table (global settings) // Payment Options Configuration Table (global settings)
export const sqlitePaymentOptions = sqliteTable('payment_options', { export const sqlitePaymentOptions = sqliteTable('payment_options', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
@@ -380,6 +408,12 @@ export const pgUsers = pgTable('users', {
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'), accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset) // Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
tokenVersion: pgInteger('token_version').notNull().default(0), tokenVersion: pgInteger('token_version').notNull().default(0),
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
emailVerified: pgBoolean('email_verified').notNull().default(false),
image: pgText('image'),
banned: pgBoolean('banned').notNull().default(false),
banReason: pgText('ban_reason'),
banExpires: timestamp('ban_expires'),
createdAt: timestamp('created_at').notNull(), createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
}); });
@@ -492,10 +526,26 @@ export const pgPayments = pgTable('payments', {
paidByAdminId: uuid('paid_by_admin_id'), paidByAdminId: uuid('paid_by_admin_id'),
adminNote: pgText('admin_note'), adminNote: pgText('admin_note'),
reminderSentAt: timestamp('reminder_sent_at'), // When payment reminder email was sent reminderSentAt: timestamp('reminder_sent_at'), // When payment reminder email was sent
// Where the money was taken: 'presale' (online/admin, the default) or 'door'
// (recorded by staff on the door check-in screen). Splits pre-sale vs door revenue.
source: varchar('source', { length: 20 }).notNull().default('presale'),
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
// 'guest' is a zero-amount comp entry and carries no revenue.
method: varchar('method', { length: 20 }),
createdAt: timestamp('created_at').notNull(), createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
}); });
// Idempotency records for door check-in actions (see sqliteIdempotencyKeys).
export const pgIdempotencyKeys = pgTable('idempotency_keys', {
key: varchar('key', { length: 128 }).primaryKey(),
scope: varchar('scope', { length: 64 }).notNull(),
result: pgText('result').notNull(),
undoState: pgText('undo_state'),
undoneAt: timestamp('undone_at'),
createdAt: timestamp('created_at').notNull(),
});
// Payment Options Configuration Table (global settings) // Payment Options Configuration Table (global settings)
export const pgPaymentOptions = pgTable('payment_options', { export const pgPaymentOptions = pgTable('payment_options', {
id: uuid('id').primaryKey(), id: uuid('id').primaryKey(),
@@ -722,6 +772,7 @@ export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases; export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases;
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets; export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments; export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
export const idempotencyKeys = dbType === 'postgres' ? pgIdempotencyKeys : sqliteIdempotencyKeys;
export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts; export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts;
export const emailSubscribers = dbType === 'postgres' ? pgEmailSubscribers : sqliteEmailSubscribers; export const emailSubscribers = dbType === 'postgres' ? pgEmailSubscribers : sqliteEmailSubscribers;
export const media = dbType === 'postgres' ? pgMedia : sqliteMedia; export const media = dbType === 'postgres' ? pgMedia : sqliteMedia;
+246 -124
View File
@@ -7,9 +7,12 @@ import { logger } from 'hono/logger';
import { swaggerUI } from '@hono/swagger-ui'; import { swaggerUI } from '@hono/swagger-ui';
import { serveStatic } from '@hono/node-server/serve-static'; import { serveStatic } from '@hono/node-server/serve-static';
import authRoutes from './routes/auth.js'; import { auth } from './lib/betterAuth.js';
import authExtRoutes from './routes/authExt.js';
import { getClientIp } from './lib/rateLimit.js';
import eventsRoutes from './routes/events.js'; import eventsRoutes from './routes/events.js';
import ticketsRoutes from './routes/tickets.js'; import ticketsRoutes from './routes/tickets.js';
import doorRoutes from './routes/door.js';
import usersRoutes from './routes/users.js'; import usersRoutes from './routes/users.js';
import contactsRoutes from './routes/contacts.js'; import contactsRoutes from './routes/contacts.js';
import paymentsRoutes from './routes/payments.js'; import paymentsRoutes from './routes/payments.js';
@@ -57,7 +60,7 @@ app.use(
if (!origin) return frontendUrl; if (!origin) return frontendUrl;
return allowedOrigins.has(origin) ? origin : null; return allowedOrigins.has(origin) ? origin : null;
}, },
// We use bearer tokens, but keeping credentials=true matches nginx config. // Session cookies must be allowed on cross-origin API calls (api.* vhost).
credentials: true, credentials: true,
}) })
); );
@@ -110,11 +113,15 @@ const openApiSpec = {
], ],
paths: { paths: {
// ==================== Auth Endpoints ==================== // ==================== Auth Endpoints ====================
'/api/auth/register': { // Authentication is handled by Better Auth, mounted at /api/auth/*.
// Sessions are httpOnly cookies (spanglish.session_token); the endpoints
// below are the subset the frontend uses. See https://better-auth.com/docs
// for the full endpoint reference.
'/api/auth/sign-up/email': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Register a new user', summary: 'Register a new user (Better Auth)',
description: 'Create a new user account. First registered user becomes admin. Password must be at least 10 characters.', description: 'Create a user account and start a cookie session. First registered user becomes admin. Password policy: 10-128 chars, upper+lower+digit-or-symbol, common passwords rejected.',
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
@@ -124,8 +131,8 @@ const openApiSpec = {
required: ['email', 'password', 'name'], required: ['email', 'password', 'name'],
properties: { properties: {
email: { type: 'string', format: 'email' }, email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 10, description: 'Minimum 10 characters' }, password: { type: 'string', minLength: 10 },
name: { type: 'string', minLength: 2 }, name: { type: 'string' },
phone: { type: 'string' }, phone: { type: 'string' },
languagePreference: { type: 'string', enum: ['en', 'es'] }, languagePreference: { type: 'string', enum: ['en', 'es'] },
}, },
@@ -134,16 +141,17 @@ const openApiSpec = {
}, },
}, },
responses: { responses: {
201: { description: 'User created successfully' }, 200: { description: 'User created; session cookie set' },
400: { description: 'Email already registered or validation error' }, 422: { description: 'Email already registered' },
400: { description: 'Validation error' },
}, },
}, },
}, },
'/api/auth/login': { '/api/auth/sign-in/email': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Login with email and password', summary: 'Login with email and password (Better Auth)',
description: 'Authenticate user with email and password. Rate limited to 5 attempts per 15 minutes.', description: 'Starts a cookie session. Per-email lockout: 5 failures / 15 min. Per-IP rate limited.',
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
@@ -160,42 +168,46 @@ const openApiSpec = {
}, },
}, },
responses: { responses: {
200: { description: 'Login successful, returns JWT token' }, 200: { description: 'Login successful; session cookie set' },
401: { description: 'Invalid credentials' }, 401: { description: 'Invalid credentials' },
429: { description: 'Too many login attempts' }, 429: { description: 'Too many attempts' },
}, },
}, },
}, },
'/api/auth/google': { '/api/auth/sign-in/social': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Login or register with Google', summary: 'Login or register with Google (Better Auth)',
description: 'Authenticate using Google OAuth. Creates account if user does not exist.', description: 'Sign in with a Google ID token (Google Identity Services credential). Links to an existing account by verified email.',
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
'application/json': { 'application/json': {
schema: { schema: {
type: 'object', type: 'object',
required: ['credential'], required: ['provider'],
properties: { properties: {
credential: { type: 'string', description: 'Google ID token' }, provider: { type: 'string', enum: ['google'] },
idToken: {
type: 'object',
properties: { token: { type: 'string', description: 'Google ID token' } },
},
}, },
}, },
}, },
}, },
}, },
responses: { responses: {
200: { description: 'Login successful' }, 200: { description: 'Login successful; session cookie set' },
400: { description: 'Invalid Google token' }, 401: { description: 'Invalid Google token' },
}, },
}, },
}, },
'/api/auth/magic-link/request': { '/api/auth/sign-in/magic-link': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Request magic link login', summary: 'Request magic link login (Better Auth)',
description: 'Send a one-time login link to email. Link expires in 10 minutes.', description: 'Emails a one-time login link (10 min TTL, single use, hashed at rest). Does not create accounts.',
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
@@ -205,46 +217,33 @@ const openApiSpec = {
required: ['email'], required: ['email'],
properties: { properties: {
email: { type: 'string', format: 'email' }, email: { type: 'string', format: 'email' },
callbackURL: { type: 'string' },
}, },
}, },
}, },
}, },
}, },
responses: { responses: { 200: { description: 'Magic link sent (if account exists)' } },
200: { description: 'Magic link sent (if account exists)' },
},
}, },
}, },
'/api/auth/magic-link/verify': { '/api/auth/magic-link/verify': {
post: { get: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Verify magic link token', summary: 'Verify magic link token (Better Auth)',
description: 'Verify the magic link token and login user.', parameters: [
requestBody: { { name: 'token', in: 'query', required: true, schema: { type: 'string' } },
required: true, ],
content: {
'application/json': {
schema: {
type: 'object',
required: ['token'],
properties: {
token: { type: 'string' },
},
},
},
},
},
responses: { responses: {
200: { description: 'Login successful' }, 200: { description: 'Login successful; session cookie set' },
400: { description: 'Invalid or expired token' }, 400: { description: 'Invalid or expired token' },
}, },
}, },
}, },
'/api/auth/password-reset/request': { '/api/auth/request-password-reset': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Request password reset', summary: 'Request password reset (Better Auth)',
description: 'Send a password reset link to email. Link expires in 30 minutes.', description: 'Emails a reset link. Token expires in 30 minutes.',
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
@@ -254,31 +253,30 @@ const openApiSpec = {
required: ['email'], required: ['email'],
properties: { properties: {
email: { type: 'string', format: 'email' }, email: { type: 'string', format: 'email' },
redirectTo: { type: 'string' },
}, },
}, },
}, },
}, },
}, },
responses: { responses: { 200: { description: 'Reset link sent (if account exists)' } },
200: { description: 'Reset link sent (if account exists)' },
},
}, },
}, },
'/api/auth/password-reset/confirm': { '/api/auth/reset-password': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Confirm password reset', summary: 'Reset password with token (Better Auth)',
description: 'Reset password using the token from email.', description: 'Sets a new password and revokes all existing sessions.',
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
'application/json': { 'application/json': {
schema: { schema: {
type: 'object', type: 'object',
required: ['token', 'password'], required: ['newPassword', 'token'],
properties: { properties: {
newPassword: { type: 'string', minLength: 10 },
token: { type: 'string' }, token: { type: 'string' },
password: { type: 'string', minLength: 10 },
}, },
}, },
}, },
@@ -290,62 +288,10 @@ const openApiSpec = {
}, },
}, },
}, },
'/api/auth/claim-account/request': {
post: {
tags: ['Auth'],
summary: 'Request account claim link',
description: 'For unclaimed accounts created during booking. Link expires in 24 hours.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
},
},
},
},
},
responses: {
200: { description: 'Claim link sent (if unclaimed account exists)' },
},
},
},
'/api/auth/claim-account/confirm': {
post: {
tags: ['Auth'],
summary: 'Confirm account claim',
description: 'Claim an unclaimed account by setting password or linking Google.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['token'],
properties: {
token: { type: 'string' },
password: { type: 'string', minLength: 10, description: 'Required if not linking Google' },
googleId: { type: 'string', description: 'Google ID for OAuth linking' },
},
},
},
},
},
responses: {
200: { description: 'Account claimed successfully' },
400: { description: 'Invalid token or missing credentials' },
},
},
},
'/api/auth/change-password': { '/api/auth/change-password': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Change password', summary: 'Change password (Better Auth)',
description: 'Change password for authenticated user.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
requestBody: { requestBody: {
required: true, required: true,
@@ -357,6 +303,7 @@ const openApiSpec = {
properties: { properties: {
currentPassword: { type: 'string' }, currentPassword: { type: 'string' },
newPassword: { type: 'string', minLength: 10 }, newPassword: { type: 'string', minLength: 10 },
revokeOtherSessions: { type: 'boolean' },
}, },
}, },
}, },
@@ -369,26 +316,66 @@ const openApiSpec = {
}, },
}, },
}, },
'/api/auth/me': { '/api/auth/get-session': {
get: { get: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Get current user', summary: 'Get current session and user (Better Auth)',
description: 'Get the currently authenticated user profile.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
responses: { responses: {
200: { description: 'Current user data' }, 200: { description: '{ session, user } or null when not authenticated' },
401: { description: 'Unauthorized' },
}, },
}, },
}, },
'/api/auth/logout': { '/api/auth/sign-out': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Logout', summary: 'Logout (Better Auth)',
description: 'Logout current user (client-side token removal).', description: 'Revokes the current session and clears the session cookie.',
responses: { security: [{ bearerAuth: [] }],
200: { description: 'Logged out' }, responses: { 200: { description: 'Logged out' } },
},
},
'/api/auth/list-sessions': {
get: {
tags: ['Auth'],
summary: 'List active sessions (Better Auth)',
security: [{ bearerAuth: [] }],
responses: { 200: { description: 'Active sessions for the current user' } },
},
},
'/api/auth-ext/claim-account': {
post: {
tags: ['Auth'],
summary: 'Claim a guest-created account',
description: 'Completes the progressive-account claim: requires a session established via the claim magic link, sets the password, and activates the account.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['password'],
properties: { password: { type: 'string', minLength: 10 } },
},
},
},
}, },
responses: {
200: { description: 'Account claimed successfully' },
400: { description: 'Already claimed or validation error' },
401: { description: 'No session (claim link required)' },
},
},
},
'/api/auth-ext/claim-eligibility': {
get: {
tags: ['Auth'],
summary: 'Check whether an email has an unclaimed account',
parameters: [
{ name: 'email', in: 'query', required: true, schema: { type: 'string', format: 'email' } },
],
responses: { 200: { description: '{ canClaim: boolean }' } },
}, },
}, },
@@ -781,6 +768,116 @@ const openApiSpec = {
}, },
}, },
}, },
// ==================== Door Check-in Screen ====================
'/api/events/{eventId}/door-attendees': {
get: {
tags: ['Tickets'],
summary: 'Full attendee list for the door check-in screen',
description: 'One payload the door screen searches entirely client-side. Includes cancelled tickets so staff can see and reactivate them.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Event, attendees and check-in stats' },
404: { description: 'Event not found' },
},
},
},
'/api/events/{eventId}/door-checkin': {
post: {
tags: ['Tickets'],
summary: 'Check in, settle payment, or create a walk-in (atomic)',
description: 'Pass ticketId to check in an existing attendee, or attendee to create a walk-in born confirmed, paid and checked in. Idempotent on idempotencyKey: replays return the original response instead of writing again.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['idempotencyKey'],
properties: {
ticketId: { type: 'string' },
attendee: {
type: 'object',
required: ['firstName'],
properties: {
firstName: { type: 'string' },
lastName: { type: 'string' },
phone: { type: 'string' },
email: { type: 'string', format: 'email' },
ruc: { type: 'string' },
},
},
payment: {
type: 'object',
required: ['method'],
properties: {
method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'guest'] },
amount: { type: 'number', description: 'Defaults to the event price; a multiple covers a group paid in one go.' },
},
},
entryMethod: { type: 'string', enum: ['scan', 'search', 'walkin'] },
idempotencyKey: { type: 'string' },
},
},
},
},
},
responses: {
201: { description: 'Attendee checked in; warnings may contain at_capacity' },
200: { description: 'Replay of an already-processed idempotencyKey' },
400: { description: 'Ticket belongs to a different event' },
404: { description: 'Event or ticket not found' },
},
},
},
'/api/events/{eventId}/door-checkin/undo': {
post: {
tags: ['Tickets'],
summary: 'Reverse one door check-in action',
description: 'Reverts exactly what the keyed action did: restores the previous check-in and payment state, or cancels a ticket that was created at the door.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['idempotencyKey'],
properties: { idempotencyKey: { type: 'string' } },
},
},
},
},
responses: {
200: { description: 'Action reversed (or already undone)' },
404: { description: 'No action recorded for this key' },
},
},
},
'/api/events/{eventId}/door-summary': {
get: {
tags: ['Payments'],
summary: 'Door cash-up and pre-sale/door revenue split',
description: 'Totals per door tender (cash, bitcoin, transfer, guest) for end-of-night reconciliation, plus the pre-sale versus door revenue split shown on the event dashboard.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Door totals by method, door lines, and pre-sale totals' },
404: { description: 'Event not found' },
},
},
},
'/api/tickets/{id}/checkin': { '/api/tickets/{id}/checkin': {
post: { post: {
tags: ['Tickets'], tags: ['Tickets'],
@@ -1762,10 +1859,10 @@ const openApiSpec = {
components: { components: {
securitySchemes: { securitySchemes: {
bearerAuth: { bearerAuth: {
type: 'http', type: 'apiKey',
scheme: 'bearer', in: 'cookie',
bearerFormat: 'JWT', name: 'spanglish.session_token',
description: 'JWT token obtained from login endpoint', description: 'Better Auth httpOnly session cookie (set by sign-in; __Secure- prefixed in production)',
}, },
}, },
schemas: { schemas: {
@@ -1899,7 +1996,32 @@ app.get('/health', (c) => {
}); });
// API Routes // API Routes
app.route('/api/auth', authRoutes); // Better Auth handles all /api/auth/* endpoints (sign-in/up/out, magic link,
// password reset, Google, session management). CORS above runs first.
//
// Better Auth only sees the Request (no TCP peer address), so its per-IP rate
// limiting is fed the socket-anchored client IP resolved by getClientIp via a
// private header. The inbound value is always discarded — a client cannot
// choose its own rate-limit bucket.
app.on(['POST', 'GET'], '/api/auth/*', (c) => {
const headers = new Headers(c.req.raw.headers);
headers.delete('x-client-ip');
const clientIp = getClientIp(c);
if (clientIp && clientIp !== 'unknown') {
headers.set('x-client-ip', clientIp);
}
return auth.handler(
new Request(c.req.raw, {
headers,
// Node's fetch requires duplex for requests carrying a body stream
...(c.req.raw.body ? { duplex: 'half' as const } : {}),
} as RequestInit)
);
});
app.route('/api/auth-ext', authExtRoutes);
// Door check-in screen endpoints live under /api/events/:eventId/door-*.
// Mounted first so the generic /:id routes below can never shadow them.
app.route('/api/events', doorRoutes);
app.route('/api/events', eventsRoutes); app.route('/api/events', eventsRoutes);
app.route('/api/tickets', ticketsRoutes); app.route('/api/tickets', ticketsRoutes);
app.route('/api/users', usersRoutes); app.route('/api/users', usersRoutes);
+89 -328
View File
@@ -1,334 +1,78 @@
import * as jose from 'jose';
import * as argon2 from 'argon2';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { Context } from 'hono'; import { Context } from 'hono';
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js'; import { and, eq } from 'drizzle-orm';
import { eq, and, gt, sql, isNull } from 'drizzle-orm'; import { auth } from './betterAuth.js';
import { generateId, getNow, toDbDate } from './utils.js'; import { db, dbGet } from '../db/index.js';
import { authAccounts } from '../db/auth-schema.js';
const DEFAULT_DEV_JWT_SECRET = 'your-super-secret-key-change-in-production'; // Auth is provided by Better Auth (lib/betterAuth.ts): httpOnly cookie
const rawJwtSecret = process.env.JWT_SECRET; // sessions validated against the auth_sessions table on every request, so
// revocation (ban/suspend/password reset) applies instantly. This module keeps
// the request-side helpers that the route files use.
// Never allow the insecure default in production: forgeable tokens = full account takeover. // Re-exported for routes that hash/validate passwords outside Better Auth
if (process.env.NODE_ENV === 'production' && (!rawJwtSecret || rawJwtSecret === DEFAULT_DEV_JWT_SECRET)) { export { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
throw new Error('JWT_SECRET must be set to a strong, unique value in production. Refusing to start with the default secret.');
}
if (!rawJwtSecret) {
console.warn('[auth] JWT_SECRET is not set; using an insecure development default. Set JWT_SECRET in production.');
}
const JWT_SECRET = new TextEncoder().encode(rawJwtSecret || DEFAULT_DEV_JWT_SECRET); export interface AuthUser {
const JWT_ISSUER = 'spanglish'; id: string;
const JWT_AUDIENCE = 'spanglish-app';
export interface JWTPayload {
sub: string;
email: string; email: string;
name: string;
phone: string | null;
role: string; role: string;
tokenVersion?: number; languagePreference: string | null;
iat: number; isClaimed: boolean;
exp: number; rucNumber: string | null;
accountStatus: string;
emailVerified: boolean;
image: string | null;
createdAt: Date | string;
updatedAt: Date | string;
/** ID of the Better Auth session backing this request. */
sessionId: string;
} }
// Password hashing with Argon2 (spec requirement) /**
export async function hashPassword(password: string): Promise<string> { * Resolve the authenticated user for a request from its Better Auth session
return argon2.hash(password, { * cookie, or null when there is no valid session. Suspended/unclaimed/banned
type: argon2.argon2id, * accounts never get API access even with a live session cookie.
memoryCost: 65536, // 64 MB */
timeCost: 3, export async function getAuthUser(c: Context): Promise<AuthUser | null> {
parallelism: 4,
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
// Support both bcrypt (legacy) and argon2 hashes for migration
if (hash.startsWith('$argon2')) {
return argon2.verify(hash, password);
}
// Legacy bcrypt support
return bcrypt.compare(password, hash);
}
// Generate secure random token for magic links
export function generateSecureToken(): string {
return crypto.randomBytes(32).toString('hex');
}
// Create magic link token
export async function createMagicLinkToken(
userId: string,
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification',
expiresInMinutes: number = 10
): Promise<string> {
const token = generateSecureToken();
const now = getNow();
const expiresAt = toDbDate(new Date(Date.now() + expiresInMinutes * 60 * 1000));
await (db as any).insert(magicLinkTokens).values({
id: generateId(),
userId,
token,
type,
expiresAt,
createdAt: now,
});
return token;
}
// Verify and consume magic link token
export async function verifyMagicLinkToken(
token: string,
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification'
): Promise<{ valid: boolean; userId?: string; error?: string }> {
const now = getNow();
const tokenRecord = await dbGet<any>(
(db as any)
.select()
.from(magicLinkTokens)
.where(
and(
eq((magicLinkTokens as any).token, token),
eq((magicLinkTokens as any).type, type)
)
)
);
// Use a single generic error for all invalid states to avoid leaking token state
const genericError = 'Invalid or expired token';
if (!tokenRecord) {
return { valid: false, error: genericError };
}
if (tokenRecord.usedAt) {
return { valid: false, error: genericError };
}
if (new Date(tokenRecord.expiresAt) < new Date()) {
return { valid: false, error: genericError };
}
// Atomically consume the token: only the request that flips used_at from NULL wins.
// This prevents a double-spend race where two concurrent requests both pass the
// read-time "not used" check above.
const result: any = await (db as any)
.update(magicLinkTokens)
.set({ usedAt: now })
.where(and(
eq((magicLinkTokens as any).id, tokenRecord.id),
isNull((magicLinkTokens as any).usedAt)
));
const affected = result?.changes ?? result?.rowCount ?? 0;
if (affected === 0) {
return { valid: false, error: genericError };
}
return { valid: true, userId: tokenRecord.userId };
}
// Create user session
export async function createUserSession(
userId: string,
userAgent?: string,
ipAddress?: string
): Promise<string> {
const sessionToken = generateSecureToken();
const now = getNow();
const expiresAt = toDbDate(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)); // 30 days
await (db as any).insert(userSessions).values({
id: generateId(),
userId,
token: sessionToken,
userAgent: userAgent || null,
ipAddress: ipAddress || null,
lastActiveAt: now,
expiresAt,
createdAt: now,
});
return sessionToken;
}
// Get user's active sessions
export async function getUserSessions(userId: string) {
const now = getNow();
return dbAll(
(db as any)
.select()
.from(userSessions)
.where(
and(
eq((userSessions as any).userId, userId),
gt((userSessions as any).expiresAt, now)
)
)
);
}
// Invalidate a specific session
export async function invalidateSession(sessionId: string, userId: string): Promise<boolean> {
const result = await (db as any)
.delete(userSessions)
.where(
and(
eq((userSessions as any).id, sessionId),
eq((userSessions as any).userId, userId)
)
);
return true;
}
// Invalidate all user sessions (logout everywhere)
export async function invalidateAllUserSessions(userId: string): Promise<void> {
await (db as any)
.delete(userSessions)
.where(eq((userSessions as any).userId, userId));
}
// Small blocklist of common/weak passwords (and obvious app-specific ones).
// Compared case-insensitively after stripping non-alphanumerics so that e.g.
// "P@ssw0rd!" still matches "password".
const COMMON_PASSWORDS = new Set([
'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890',
'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou',
'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123',
'spanglish', 'changeme', 'secret', 'master', 'login', 'access',
]);
// Password policy: 10-128 chars, requires a mix of character types, and rejects
// common/weak passwords. Centralized so register/reset/change all share it.
export function validatePassword(password: string): { valid: boolean; error?: string } {
if (password.length < 10) {
return { valid: false, error: 'Password must be at least 10 characters long' };
}
if (password.length > 128) {
return { valid: false, error: 'Password must be at most 128 characters long' };
}
const hasLower = /[a-z]/.test(password);
const hasUpper = /[A-Z]/.test(password);
const hasDigit = /\d/.test(password);
const hasSymbol = /[^A-Za-z0-9]/.test(password);
// Require lowercase, uppercase, and at least one digit or symbol.
if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) {
return {
valid: false,
error: 'Password must include uppercase and lowercase letters and at least one number or symbol',
};
}
const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, '');
if (COMMON_PASSWORDS.has(normalized)) {
return { valid: false, error: 'Password is too common. Please choose a less guessable password.' };
}
return { valid: true };
}
export async function createToken(userId: string, email: string, role: string, tokenVersion: number = 0): Promise<string> {
const token = await new jose.SignJWT({ sub: userId, email, role, tokenVersion })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setExpirationTime('1d')
.sign(JWT_SECRET);
return token;
}
// Invalidate all previously issued JWTs for a user (logout-everywhere, password change/reset).
export async function bumpTokenVersion(userId: string): Promise<void> {
await (db as any)
.update(users)
.set({ tokenVersion: sql`${(users as any).tokenVersion} + 1` })
.where(eq((users as any).id, userId));
}
export async function createRefreshToken(userId: string): Promise<string> {
const token = await new jose.SignJWT({ sub: userId, type: 'refresh' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer(JWT_ISSUER)
.setExpirationTime('30d')
.sign(JWT_SECRET);
return token;
}
export async function verifyToken(token: string): Promise<JWTPayload | null> {
try { try {
const { payload } = await jose.jwtVerify(token, JWT_SECRET, { const session = await auth.api.getSession({ headers: c.req.raw.headers });
issuer: JWT_ISSUER, if (!session?.user) {
audience: JWT_AUDIENCE, return null;
}); }
return payload as unknown as JWTPayload;
const user = session.user as any;
// Suspended (banned) or unclaimed accounts must not retain API access
if (user.banned) {
return null;
}
if (user.accountStatus && user.accountStatus !== 'active') {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
phone: user.phone ?? null,
role: user.role ?? 'user',
languagePreference: user.languagePreference ?? null,
isClaimed: Boolean(user.isClaimed),
rucNumber: user.rucNumber ?? null,
accountStatus: user.accountStatus ?? 'active',
emailVerified: Boolean(user.emailVerified),
image: user.image ?? null,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
sessionId: session.session.id,
};
} catch { } catch {
return null; return null;
} }
} }
export async function getAuthUser(c: Context): Promise<any | null> {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return null;
}
const token = authHeader.slice(7);
const payload = await verifyToken(token);
if (!payload) {
return null;
}
// Never load the password hash into request context — it is only needed for
// explicit password-verification routes that query it separately.
const user = await dbGet<any>(
(db as any)
.select({
id: (users as any).id,
email: (users as any).email,
name: (users as any).name,
phone: (users as any).phone,
role: (users as any).role,
languagePreference: (users as any).languagePreference,
isClaimed: (users as any).isClaimed,
googleId: (users as any).googleId,
rucNumber: (users as any).rucNumber,
accountStatus: (users as any).accountStatus,
tokenVersion: (users as any).tokenVersion,
createdAt: (users as any).createdAt,
updatedAt: (users as any).updatedAt,
})
.from(users)
.where(eq((users as any).id, payload.sub))
);
if (!user) {
return null;
}
// Reject tokens issued before a logout-everywhere / password change
if ((payload.tokenVersion ?? 0) !== (user.tokenVersion ?? 0)) {
return null;
}
// Suspended/unclaimed accounts must not retain API access via an old JWT
if (user.accountStatus && user.accountStatus !== 'active') {
return null;
}
return user;
}
export function requireAuth(roles?: string[]) { export function requireAuth(roles?: string[]) {
return async (c: Context, next: () => Promise<void>) => { return async (c: Context, next: () => Promise<void>) => {
const user = await getAuthUser(c); const user = await getAuthUser(c);
@@ -346,21 +90,38 @@ export function requireAuth(roles?: string[]) {
}; };
} }
export async function isFirstUser(): Promise<boolean> { /**
const result = await dbAll( * Fetch only the credential password hash (never exposed via getAuthUser).
(db as any).select().from(users).limit(1) * Returns null when the user has no password set (Google-only or unclaimed).
); */
return !result || result.length === 0;
}
/** Fetch only the password hash column (never expose via getAuthUser). */
export async function getUserPasswordHash(userId: string): Promise<string | null> { export async function getUserPasswordHash(userId: string): Promise<string | null> {
const row = await dbGet<any>( const row = await dbGet<any>(
(db as any) (db as any)
.select({ password: (users as any).password }) .select({ password: (authAccounts as any).password })
.from(users) .from(authAccounts)
.where(eq((users as any).id, userId)) .where(
and(
eq((authAccounts as any).userId, userId),
eq((authAccounts as any).providerId, 'credential')
)
)
); );
const hash = row?.password; const hash = row?.password;
return hash && String(hash).length > 0 ? String(hash) : null; return hash && String(hash).length > 0 ? String(hash) : null;
} }
/** Whether the user has a linked Google account. */
export async function hasGoogleAccount(userId: string): Promise<boolean> {
const row = await dbGet<any>(
(db as any)
.select({ id: (authAccounts as any).id })
.from(authAccounts)
.where(
and(
eq((authAccounts as any).userId, userId),
eq((authAccounts as any).providerId, 'google')
)
)
);
return !!row;
}
@@ -0,0 +1,519 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { exportJWK, generateKeyPair, SignJWT } from 'jose';
import { normalizeEmail } from './utils.js';
// Environment must be pinned BEFORE the db/betterAuth singletons are imported
// (dotenv never overrides pre-set values).
const dir = mkdtempSync(join(tmpdir(), 'ba-test-'));
const dbPath = join(dir, 'test.db');
process.env.DB_TYPE = 'sqlite';
process.env.DATABASE_URL = dbPath;
process.env.FRONTEND_URL = 'http://localhost:3002';
process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef';
delete process.env.REDIS_URL; // memory lockout/rate-limit backends
// Google IS configured here: account linking is the whole point of the tests at
// the bottom of this file, and betterAuth.ts omits `socialProviders` entirely
// when this is unset. No real credentials are involved — the id tokens are
// signed with a throwaway keypair and Google's JWKS endpoint is stubbed below.
const GOOGLE_CLIENT_ID = 'spanglish-test.apps.googleusercontent.com';
process.env.GOOGLE_CLIENT_ID = GOOGLE_CLIENT_ID;
// Capture outgoing auth emails (magic links, password resets)
const sentEmails: Array<{ to: string; subject: string; html: string }> = [];
vi.mock('./email.js', () => ({
sendEmail: vi.fn(async (opts: any) => {
sentEmails.push(opts);
}),
emailService: {},
default: {},
}));
let auth: (typeof import('./betterAuth.js'))['auth'];
let db: any;
let sqlite: any;
function lastEmailTo(email: string) {
const found = [...sentEmails].reverse().find((e) => e.to === email);
expect(found, `expected an email sent to ${email}`).toBeTruthy();
return found!;
}
function extractToken(html: string, param = 'token'): string {
const match = html.match(new RegExp(`[?&]${param}=([^"&\\s]+)`));
expect(match, `expected a ${param} in the email link`).toBeTruthy();
return decodeURIComponent(match![1]);
}
// ---- Google Identity Services stub -------------------------------------
// verifyGoogleIdToken() checks signature, issuer, audience and max age against
// Google's published JWKS; its only network call is that JWKS fetch. Signing
// with our own key and serving our own JWKS exercises the real verification
// path without touching the network or needing OAuth credentials.
const GOOGLE_KID = 'spanglish-test-key';
let googlePrivateKey: CryptoKey;
async function installGoogleStub() {
const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true });
googlePrivateKey = privateKey as CryptoKey;
const jwk = { ...(await exportJWK(publicKey)), kid: GOOGLE_KID, alg: 'RS256', use: 'sig' };
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: any, init?: any) => {
const url = typeof input === 'string' ? input : (input?.url ?? String(input));
if (url.startsWith('https://www.googleapis.com/oauth2/v3/certs')) {
return new Response(JSON.stringify({ keys: [jwk] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return realFetch(input, init);
}) as typeof fetch;
}
function googleIdToken(opts: { email: string; sub: string; name?: string; emailVerified?: boolean }) {
return new SignJWT({
email: opts.email,
email_verified: opts.emailVerified ?? true,
name: opts.name ?? 'Google User',
picture: 'https://example.test/avatar.png',
})
.setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID })
.setIssuer('https://accounts.google.com')
.setAudience(GOOGLE_CLIENT_ID)
.setSubject(opts.sub)
.setIssuedAt()
.setExpirationTime('10m')
.sign(googlePrivateKey);
}
async function signInWithGoogle(
opts: Parameters<typeof googleIdToken>[0],
returnHeaders = false
): Promise<any> {
const token = await googleIdToken(opts);
return auth.api.signInSocial({
body: { provider: 'google', idToken: { token } },
headers: new Headers(),
...(returnHeaders ? { returnHeaders: true } : {}),
} as any);
}
/** Insert a user the way a guest booking does (routes/tickets.ts, routes/door.ts):
* unclaimed, unverified, and with no auth_accounts row at all. */
function insertBookingUser(id: string, email: string, name = 'Ticket Buyer') {
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, created_at, updated_at)
VALUES (?, ?, NULL, ?, 'user', 0, 'unclaimed', 0, ?, ?)`
)
.run(id, email, name, now, now);
return id;
}
function userRow(email: string) {
return sqlite
.prepare('SELECT id, email, is_claimed, account_status, email_verified FROM users WHERE email = ?')
.get(email);
}
function googleAccountsFor(userId: string) {
return sqlite
.prepare("SELECT id, account_id FROM auth_accounts WHERE user_id = ? AND provider_id = 'google'")
.all(userId);
}
function cookieHeaders(setCookie: string | null): Headers {
const sessionPart = (setCookie || '')
.split(/,(?=[^ ;]+=)/)
.map((c) => c.split(';')[0].trim())
.filter((c) => c.includes('session_token'))
.join('; ');
return new Headers({ cookie: sessionPart });
}
beforeAll(() => {
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], {
env: { ...process.env },
stdio: 'pipe',
});
return (async () => {
({ auth } = await import('./betterAuth.js'));
({ db } = await import('../db/index.js'));
const Database = (await import('better-sqlite3')).default;
sqlite = new Database(dbPath);
await installGoogleStub();
})();
}, 120_000);
describe('Better Auth integration', () => {
it('makes the first registered user an admin, later users regular', async () => {
const first = await auth.api.signUpEmail({
body: { email: 'admin@test.py', password: 'FirstAdmin1!x', name: 'Admin' },
});
expect((first.user as any).id).toBeTruthy();
const row = sqlite.prepare('SELECT role, is_claimed, account_status FROM users WHERE email = ?').get('admin@test.py');
expect(row.role).toBe('admin');
expect(row.account_status).toBe('active');
await auth.api.signUpEmail({
body: { email: 'user@test.py', password: 'SecondUser1!x', name: 'User' },
});
const row2 = sqlite.prepare('SELECT role FROM users WHERE email = ?').get('user@test.py');
expect(row2.role).toBe('user');
});
it('stores credential passwords as argon2id in auth_accounts, not users', async () => {
const acct = sqlite
.prepare("SELECT a.password FROM auth_accounts a JOIN users u ON u.id = a.user_id WHERE u.email = ? AND a.provider_id = 'credential'")
.get('admin@test.py');
expect(acct.password.startsWith('$argon2id$')).toBe(true);
const user = sqlite.prepare('SELECT password FROM users WHERE email = ?').get('admin@test.py');
expect(user.password).toBeNull();
});
it('rejects passwords that violate the policy', async () => {
// Too short
await expect(
auth.api.signUpEmail({ body: { email: 'weak1@test.py', password: 'Short1!', name: 'W' } })
).rejects.toThrow(/at least 10 characters/);
// Long enough but no character mix (app policy hook)
await expect(
auth.api.signUpEmail({ body: { email: 'weak2@test.py', password: 'alllowercasepw', name: 'W' } })
).rejects.toThrow(/uppercase and lowercase/);
// Common password normalized (policy blocklist)
await expect(
auth.api.signUpEmail({ body: { email: 'weak3@test.py', password: 'Spanglish!', name: 'W' } })
).rejects.toThrow(/too common/);
expect(sqlite.prepare("SELECT COUNT(*) AS n FROM users WHERE email LIKE 'weak%'").get().n).toBe(0);
});
it('locks an email after 5 failed sign-ins', async () => {
await auth.api.signUpEmail({
body: { email: 'lockout@test.py', password: 'LockoutPass1!', name: 'L' },
});
for (let i = 0; i < 5; i++) {
await expect(
auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'WrongPass1!x' } })
).rejects.toThrow();
}
// Correct password now also refused: locked
await expect(
auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'LockoutPass1!' } })
).rejects.toThrow(/Too many login attempts/);
});
it('refuses sign-in for banned (suspended) users and kills nothing else', async () => {
await auth.api.signUpEmail({
body: { email: 'banned@test.py', password: 'BannedPass1!x', name: 'B' },
});
sqlite.prepare("UPDATE users SET banned = 1, account_status = 'suspended' WHERE email = ?").run('banned@test.py');
await expect(
auth.api.signInEmail({ body: { email: 'banned@test.py', password: 'BannedPass1!x' } })
).rejects.toThrow(/suspended|banned/i);
});
it('verifies legacy bcrypt hashes and upgrades them to argon2 on sign-in', async () => {
const bcrypt = (await import('bcryptjs')).default;
const legacyHash = bcrypt.hashSync('LegacyBcrypt1!', 10);
const su = await auth.api.signUpEmail({
body: { email: 'legacy@test.py', password: 'TempPass123!x', name: 'Legacy' },
});
sqlite
.prepare("UPDATE auth_accounts SET password = ? WHERE user_id = ? AND provider_id = 'credential'")
.run(legacyHash, (su.user as any).id);
const si = await auth.api.signInEmail({
body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' },
});
expect(si.user.email).toBe('legacy@test.py');
// Upgrade happens in the after-hook; poll briefly for it
let upgraded = '';
for (let i = 0; i < 20 && !upgraded.startsWith('$argon2'); i++) {
await new Promise((r) => setTimeout(r, 100));
upgraded = sqlite
.prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'")
.get((su.user as any).id).password;
}
expect(upgraded.startsWith('$argon2id$')).toBe(true);
// And the upgraded hash still verifies
const again = await auth.api.signInEmail({
body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' },
});
expect(again.user.email).toBe('legacy@test.py');
});
it('magic link signs in existing users but never creates accounts', async () => {
await auth.api.signUpEmail({
body: { email: 'magic@test.py', password: 'MagicPass12!x', name: 'M' },
});
await auth.api.signInMagicLink({
body: { email: 'magic@test.py', callbackURL: '/dashboard' },
headers: new Headers(),
});
const email = lastEmailTo('magic@test.py');
// Emails link to the frontend page, not the raw API endpoint
expect(email.html).toContain('http://localhost:3002/auth/magic-link?token=');
const token = extractToken(email.html);
const verified = await auth.api.magicLinkVerify({
query: { token },
headers: new Headers(),
});
expect((verified as any).user?.email ?? (verified as any).session?.userId).toBeTruthy();
// Unknown email: enumeration-safe success (an email may still go out),
// but verification can never create an account (disableSignUp)
const ghost = await auth.api.signInMagicLink({
body: { email: 'ghost@test.py' },
headers: new Headers(),
});
expect((ghost as any).status).toBe(true);
const ghostEmail = sentEmails.filter((e) => e.to === 'ghost@test.py').pop();
if (ghostEmail) {
const ghostToken = extractToken(ghostEmail.html);
await expect(
auth.api.magicLinkVerify({ query: { token: ghostToken }, headers: new Headers() })
).rejects.toThrow();
}
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('ghost@test.py').n).toBe(0);
});
it('completes the guest claim path: magic link session + setPassword', async () => {
// Simulate tickets.ts guest creation: user row, no credential account
const guestId = 'guest-claim-user-000001';
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, banned, token_version, created_at, updated_at)
VALUES (?, ?, NULL, 'Guest', 'user', 0, 'unclaimed', 0, 0, 0, ?, ?)`
)
.run(guestId, 'guest@test.py', now, now);
await auth.api.signInMagicLink({
body: { email: 'guest@test.py', callbackURL: '/auth/claim-account' },
headers: new Headers(),
});
const token = extractToken(lastEmailTo('guest@test.py').html);
const verified = await auth.api.magicLinkVerify({
query: { token },
returnHeaders: true,
headers: new Headers(),
});
const headers = cookieHeaders(verified.headers.get('set-cookie'));
// The session works even while unclaimed (the claim endpoint depends on this)
const session = await auth.api.getSession({ headers });
expect((session?.user as any)?.accountStatus).toBe('unclaimed');
// Set the password (what /api/auth-ext/claim-account does)
await auth.api.setPassword({ body: { newPassword: 'ClaimedPass1!x' }, headers });
const acct = sqlite
.prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'")
.get(guestId);
expect(acct.password.startsWith('$argon2id$')).toBe(true);
// The claim email uses the claim template with the frontend link
const claimEmail = lastEmailTo('guest@test.py');
expect(claimEmail.subject).toContain('Claim');
expect(claimEmail.html).toContain('callbackURL=%2Fauth%2Fclaim-account');
});
it('password reset revokes existing sessions and applies the new password', async () => {
const su = await auth.api.signUpEmail({
body: { email: 'reset@test.py', password: 'BeforeReset1!x', name: 'R' },
});
const userId = (su.user as any).id;
// A live session from sign-in
await auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } });
expect(
sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n
).toBeGreaterThan(0);
await auth.api.requestPasswordReset({
body: { email: 'reset@test.py', redirectTo: '/auth/reset-password' },
});
const email = lastEmailTo('reset@test.py');
// Reset URLs are either .../reset-password/{token}?... or ...?token={token}
const html = email.html;
const pathMatch = html.match(/reset-password\/([^?"&\s]+)/);
const token = pathMatch ? decodeURIComponent(pathMatch[1]) : extractToken(html);
await auth.api.resetPassword({ body: { newPassword: 'AfterReset1!x', token } });
// revokeSessionsOnPasswordReset: true
expect(
sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n
).toBe(0);
await expect(
auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } })
).rejects.toThrow();
const after = await auth.api.signInEmail({
body: { email: 'reset@test.py', password: 'AfterReset1!x' },
});
expect(after.user.email).toBe('reset@test.py');
});
it('sessions are stored in auth_sessions with 7-day expiry', async () => {
const si = await auth.api.signInEmail({
body: { email: 'admin@test.py', password: 'FirstAdmin1!x' },
});
const row = sqlite
.prepare('SELECT expires_at FROM auth_sessions WHERE token = ?')
.get((si as any).token);
expect(row).toBeTruthy();
const days = (row.expires_at - Date.now()) / (1000 * 60 * 60 * 24);
expect(days).toBeGreaterThan(6.5);
expect(days).toBeLessThan(7.5);
});
});
describe('Google sign-in and account linking', () => {
it('links Google onto a guest-booking user instead of failing with "account not linked"', async () => {
const id = insertBookingUser('booking-user-1', 'buyer@test.py');
expect(userRow('buyer@test.py').email_verified).toBe(0);
const res = await signInWithGoogle({ email: 'buyer@test.py', sub: 'google-sub-buyer' });
expect(res.user.id).toBe(id);
expect(googleAccountsFor(id)).toHaveLength(1);
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('buyer@test.py').n).toBe(1);
});
it('claims the booking account so the resulting session is actually accepted', async () => {
// getAuthUser() (lib/auth.ts) rejects any session whose user is not
// 'active', so linking alone would leave the user looking logged out.
const id = insertBookingUser('booking-user-2', 'buyer2@test.py');
const { headers, response } = await signInWithGoogle(
{ email: 'buyer2@test.py', sub: 'google-sub-buyer2' },
true
);
expect(response.user.id).toBe(id);
const row = userRow('buyer2@test.py');
expect(row.account_status).toBe('active');
expect(row.is_claimed).toBe(1);
expect(row.email_verified).toBe(1);
const session = await auth.api.getSession({
headers: cookieHeaders(headers.get('set-cookie')),
});
expect(session?.user.id).toBe(id);
expect((session?.user as any).accountStatus).toBe('active');
});
it('never reactivates a suspended account through a Google link', async () => {
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, banned, created_at, updated_at)
VALUES (?, ?, 'Suspended', 'user', 1, 'suspended', 1, 1, ?, ?)`
)
.run('suspended-google', 'suspended-google@test.py', now, now);
await expect(
signInWithGoogle({ email: 'suspended-google@test.py', sub: 'google-sub-suspended' })
).rejects.toThrow();
expect(userRow('suspended-google@test.py').account_status).toBe('suspended');
});
it('links Google onto an email/password account created after the Better Auth migration', async () => {
// Better Auth writes email_verified = 0 on sign-up (requireEmailVerification
// is off), which used to be enough to block linking on its own.
await auth.api.signUpEmail({
body: { email: 'pwuser@test.py', password: 'PwUserPass1!x', name: 'Pw User' },
});
expect(userRow('pwuser@test.py').email_verified).toBe(0);
const id = userRow('pwuser@test.py').id;
const res = await signInWithGoogle({ email: 'pwuser@test.py', sub: 'google-sub-pwuser' });
expect(res.user.id).toBe(id);
expect(googleAccountsFor(id)).toHaveLength(1);
// The credential account survives: they can still sign in with a password.
const after = await auth.api.signInEmail({
body: { email: 'pwuser@test.py', password: 'PwUserPass1!x' },
});
expect(after.user.id).toBe(id);
});
it('creates exactly one user for a brand-new Google address and reuses it on the next sign-in', async () => {
const first = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' });
const row = userRow('fresh@test.py');
expect(row.id).toBe(first.user.id);
expect(row.email_verified).toBe(1);
expect(row.account_status).toBe('active');
const second = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' });
expect(second.user.id).toBe(first.user.id);
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('fresh@test.py').n).toBe(1);
expect(googleAccountsFor(first.user.id)).toHaveLength(1);
});
it('rejects an id token minted for a different client id', async () => {
const token = await new SignJWT({ email: 'forged@test.py', email_verified: true, name: 'F' })
.setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID })
.setIssuer('https://accounts.google.com')
.setAudience('some-other-app.apps.googleusercontent.com')
.setSubject('google-sub-forged')
.setIssuedAt()
.setExpirationTime('10m')
.sign(googlePrivateKey);
await expect(
auth.api.signInSocial({
body: { provider: 'google', idToken: { token } },
headers: new Headers(),
} as any)
).rejects.toThrow();
expect(userRow('forged@test.py')).toBeUndefined();
});
});
describe('users.email normalization', () => {
it('normalizes an address to its canonical stored form', () => {
expect(normalizeEmail(' John@Example.COM ')).toBe('john@example.com');
});
it('lowercases legacy mixed-case rows on migrate, and reports collisions instead of merging', async () => {
const now = new Date().toISOString();
const insert = (id: string, email: string, status = 'unclaimed') =>
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, created_at, updated_at)
VALUES (?, ?, 'Legacy', 'user', 0, ?, 0, ?, ?)`
)
.run(id, email, status, now, now);
insert('legacy-mixed', 'John@Example.com');
// A pair that genuinely collides: the migration must leave both alone.
insert('legacy-dup-lower', 'dup@example.com');
insert('legacy-dup-mixed', 'Dup@Example.com');
// The backfill lives in migrate.ts and is idempotent, so just re-run it.
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
expect(userRow('john@example.com').id).toBe('legacy-mixed');
expect(userRow('John@Example.com')).toBeUndefined();
expect(userRow('Dup@Example.com').id).toBe('legacy-dup-mixed');
expect(userRow('dup@example.com').id).toBe('legacy-dup-lower');
// ...and the lowercased row is now reachable by Google sign-in.
const res = await signInWithGoogle({ email: 'john@example.com', sub: 'google-sub-legacy' });
expect(res.user.id).toBe('legacy-mixed');
}, 120_000);
});
+420
View File
@@ -0,0 +1,420 @@
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { magicLink, admin } from 'better-auth/plugins';
import { APIError, createAuthMiddleware } from 'better-auth/api';
import { eq, and } from 'drizzle-orm';
import { db, dbGet, dbAll, isPostgres } from '../db/index.js';
import {
authUsers,
authSessions,
authAccounts,
authVerifications,
authRateLimits,
} from '../db/auth-schema.js';
import { generateId } from './utils.js';
import { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
import { sendEmail } from './email.js';
import { getLoginLockout } from './stores/loginLockout.js';
const isProduction = process.env.NODE_ENV === 'production';
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
// Cookie domain shared across subdomains (e.g. ".spanglishcommunity.com") so a session
// issued by api.* is also sent to the site origin, where the frontend's Next middleware
// reads it to gate /admin and /dashboard. Leave unset in dev: localhost is single-host
// and needs a host-only cookie.
const cookieDomain = process.env.AUTH_COOKIE_DOMAIN?.trim();
const DEFAULT_DEV_SECRET = 'spanglish-dev-only-better-auth-secret';
const rawSecret = process.env.BETTER_AUTH_SECRET;
// Never allow a weak/default secret in production: the secret signs session
// cookies, so a guessable value means forgeable sessions = account takeover.
if (isProduction && (!rawSecret || rawSecret.length < 32 || rawSecret === DEFAULT_DEV_SECRET)) {
throw new Error(
'BETTER_AUTH_SECRET must be set to a strong value (32+ characters) in production. Refusing to start.'
);
}
if (!rawSecret) {
console.warn('[auth] BETTER_AUTH_SECRET is not set; using an insecure development default.');
}
// The site origin plus its www/non-www alias, mirroring the CORS allowlist in
// index.ts. Requests whose Origin is not listed here are rejected by Better
// Auth's CSRF origin check.
function computeTrustedOrigins(): string[] {
const origins = new Set<string>([frontendUrl]);
try {
const url = new URL(frontendUrl);
const alias = url.hostname.startsWith('www.')
? url.hostname.slice(4)
: `www.${url.hostname}`;
origins.add(`${url.protocol}//${alias}${url.port ? `:${url.port}` : ''}`);
} catch {
/* keep frontendUrl as-is */
}
if (process.env.API_URL) origins.add(process.env.API_URL);
if (!isProduction) {
// Dev is frequently reached through a forwarded or proxied port (SSH tunnel, editor
// port forwarding), so the browser's Origin is http://localhost:<random> and every
// POST would fail the CSRF origin check. Trust any loopback port rather than pinning
// FRONTEND_URL to a port that changes between sessions. Wildcard patterns are matched
// per better-auth's trusted-origins helper; production stays on the exact allowlist.
origins.add('http://localhost:*');
origins.add('http://127.0.0.1:*');
}
return [...origins];
}
// Paths whose request body carries a new password that must satisfy the policy
// (Better Auth's minPasswordLength alone is weaker than the app's policy).
const PASSWORD_SETTING_PATHS = new Set([
'/sign-up/email',
'/reset-password',
'/change-password',
'/set-password',
]);
async function getCredentialAccount(userId: string): Promise<any | null> {
return dbGet<any>(
(db as any)
.select()
.from(authAccounts)
.where(
and(
eq((authAccounts as any).userId, userId),
eq((authAccounts as any).providerId, 'credential')
)
)
);
}
export const auth = betterAuth({
appName: 'Spanglish',
baseURL: process.env.BETTER_AUTH_URL || frontendUrl,
basePath: '/api/auth',
secret: rawSecret || DEFAULT_DEV_SECRET,
trustedOrigins: computeTrustedOrigins(),
telemetry: { enabled: false },
database: drizzleAdapter(db as any, {
provider: isPostgres() ? 'pg' : 'sqlite',
schema: {
user: authUsers,
session: authSessions,
account: authAccounts,
verification: authVerifications,
rateLimit: authRateLimits,
},
// better-sqlite3 cannot run Drizzle's async transactions; operations run
// sequentially instead (also the adapter default).
transaction: false,
}),
advanced: {
cookiePrefix: 'spanglish',
useSecureCookies: isProduction,
// Only adds a `Domain=` attribute — name, sameSite, secure and path are unchanged,
// so the cookie names hardcoded in the frontend middleware and the Go photo-api
// stay valid.
...(cookieDomain
? { crossSubDomainCookies: { enabled: true, domain: cookieDomain } }
: {}),
ipAddress: {
// Set by the /api/auth/* mount in index.ts from getClientIp(), which
// anchors trust in the TCP peer address (only our own proxies may speak
// for the client via X-Real-IP / X-Forwarded-For). Never read the raw
// forwarded headers here: without the socket they are spoofable, and
// Better Auth would fall back to one shared rate-limit bucket.
ipAddressHeaders: ['x-client-ip'],
},
database: {
// Match the app's existing ID convention (uuid on pg, nanoid on sqlite)
// so Better Auth rows are indistinguishable from legacy rows.
generateId: () => generateId(),
},
},
user: {
additionalFields: {
// `role`, `banned`, `banReason`, `banExpires` come from the admin plugin.
phone: { type: 'string', required: false, input: true },
languagePreference: { type: 'string', required: false, input: true },
rucNumber: { type: 'string', required: false, input: false },
isClaimed: { type: 'boolean', required: false, input: false, defaultValue: true },
accountStatus: { type: 'string', required: false, input: false, defaultValue: 'active' },
},
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days, rolling
updateAge: 60 * 60 * 24, // refresh expiry at most once a day
freshAge: 60 * 60 * 24, // sensitive operations require a session younger than this
// Disabled deliberately: every request validates against the session table,
// so ban/suspend/password-reset revocations apply instantly — and the Go
// photo-api (which reads the same table) can never disagree with us.
cookieCache: { enabled: false },
storeSessionInDatabase: true,
},
emailAndPassword: {
enabled: true,
minPasswordLength: 10,
maxPasswordLength: 128,
autoSignIn: true,
requireEmailVerification: false,
revokeSessionsOnPasswordReset: true,
resetPasswordTokenExpiresIn: 60 * 30, // 30 minutes, matches the legacy flow
sendResetPassword: async ({ user, url }) => {
try {
await sendEmail({
to: user.email,
subject: 'Reset Your Spanglish Password',
html: `
<h2>Reset Your Password</h2>
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
<p><a href="${url}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
<p>Or copy this link: ${url}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send password reset email:', error);
}
},
password: {
// Keep the existing argon2id parameters; legacy bcrypt hashes (migrated
// into auth_accounts) still verify and are upgraded on login below.
hash: (password) => hashPassword(password),
verify: ({ hash, password }) => verifyPassword(password, hash),
},
},
...(process.env.GOOGLE_CLIENT_ID
? {
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
// Not required for ID-token (Google Identity Services) sign-in,
// only for the redirect OAuth flow.
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
},
},
}
: {}),
account: {
accountLinking: {
enabled: true,
// Google verifies email ownership, so linking by email is safe — this
// matches the legacy /api/auth/google auto-link behavior.
trustedProviders: ['google'],
// `trustedProviders` alone is NOT enough: better-auth ORs a second,
// independent gate — `requireLocalEmailVerified` (default true) — which
// refuses the link whenever the LOCAL users.email_verified is false.
// That is the state of every guest-booking user (routes/tickets.ts,
// routes/door.ts insert email_verified = false) and of every
// email/password signup made after the Better Auth migration, so Google
// sign-in failed for them with "account not linked".
//
// The local flag adds nothing here: the Google ID token is signature-
// verified against Google's JWKS with issuer/audience/max-age checks and
// carries its own `email_verified`, so Google — not our column — is what
// proves ownership of the address.
//
// NOTE: upstream marks this option deprecated ("the gate will become
// unconditional"). better-auth is pinned exactly at 1.6.25 in both
// workspaces, and betterAuth.integration.test.ts covers this path, so an
// upgrade that drops the option fails CI rather than silently locking
// ticket buyers out again.
requireLocalEmailVerified: false,
},
},
rateLimit: {
// Explicitly enabled so dev behaves like production (off in dev by default).
enabled: true,
window: 60,
max: 100,
// DB-backed rather than Redis: our Redis layer is fail-open by design,
// which is the wrong default for auth rate limiting. The per-email login
// lockout below is already Redis-shared across replicas.
storage: 'database',
modelName: 'rateLimit',
customRules: {
'/sign-in/email': { window: 900, max: 10 },
'/sign-up/email': { window: 900, max: 10 },
'/sign-in/magic-link': { window: 900, max: 5 },
'/magic-link/verify': { window: 900, max: 30 },
'/request-password-reset': { window: 900, max: 5 },
'/reset-password': { window: 900, max: 10 },
'/sign-in/social': { window: 900, max: 20 },
'/change-password': { window: 900, max: 10 },
},
},
databaseHooks: {
user: {
create: {
before: async (user) => {
// First user to register becomes admin (replaces isFirstUser())
const existing = await dbAll<any>((db as any).select().from(authUsers).limit(1));
if (!existing || existing.length === 0) {
return { data: { ...user, role: 'admin' } };
}
},
},
},
account: {
create: {
// Fires for both branches of the OAuth path: createOAuthUser (new user)
// and linkAccount (existing user), since both go through the adapter's
// createWithHooks(..., 'account').
after: async (account) => {
if (account.providerId !== 'google') return;
// Attaching a Google account proves ownership of the address, so a
// row created during guest booking is now a real, claimed account.
// Without this, getAuthUser() (lib/auth.ts) rejects the brand-new
// session because accountStatus is still 'unclaimed' — the user gets
// a cookie and still looks logged out. Mirrors the tail of the
// magic-link claim flow in routes/authExt.ts.
//
// Scoped to 'unclaimed' in the WHERE clause so a suspended account is
// never silently reactivated by linking Google to it.
try {
await (db as any)
.update(authUsers)
// `emailVerified` is deliberately left alone: better-auth's link
// branch sets it right after this hook, but only when Google's
// id_token actually asserted email_verified.
.set({
isClaimed: true,
accountStatus: 'active',
updatedAt: new Date(),
})
.where(
and(
eq((authUsers as any).id, account.userId),
eq((authUsers as any).accountStatus, 'unclaimed')
)
);
} catch (err: any) {
console.error('[auth] Failed to claim account on Google link:', err?.message || err);
}
},
},
},
},
hooks: {
before: createAuthMiddleware(async (ctx) => {
// Enforce the full password policy (character classes + blocklist) on
// every password-setting path, for both client and server-side calls.
if (PASSWORD_SETTING_PATHS.has(ctx.path)) {
const password = ctx.body?.password ?? ctx.body?.newPassword;
if (typeof password === 'string') {
const result = validatePassword(password);
if (!result.valid) {
throw new APIError('BAD_REQUEST', { message: result.error });
}
}
}
// Per-email lockout: 5 failures / 15 min, Redis-shared when configured.
// Kept as defense-in-depth on top of Better Auth's per-IP rate limits.
if (ctx.path === '/sign-in/email' && typeof ctx.body?.email === 'string') {
const lockout = await getLoginLockout().isLocked(ctx.body.email);
if (lockout.locked) {
throw new APIError('TOO_MANY_REQUESTS', {
message: 'Too many login attempts. Please try again later.',
});
}
}
}),
after: createAuthMiddleware(async (ctx) => {
if (ctx.path !== '/sign-in/email' || typeof ctx.body?.email !== 'string') return;
const email = ctx.body.email as string;
if (ctx.context.returned instanceof APIError) {
// Failed sign-in attempt counts toward the per-email lockout
await getLoginLockout().recordFailure(email);
return;
}
await getLoginLockout().clear(email);
// Transparently upgrade legacy bcrypt hashes to argon2 now that we have
// the verified plaintext. Best-effort: never block the login.
try {
const password = ctx.body?.password;
const userId = (ctx.context.newSession?.user as any)?.id;
if (typeof password === 'string' && userId) {
const account = await getCredentialAccount(userId);
if (account?.password && !String(account.password).startsWith('$argon2')) {
const upgraded = await hashPassword(password);
await (db as any)
.update(authAccounts)
.set({ password: upgraded, updatedAt: new Date() })
.where(eq((authAccounts as any).id, account.id));
}
}
} catch (err: any) {
console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err);
}
}),
},
plugins: [
magicLink({
expiresIn: 60 * 10, // 10 minutes, matches the legacy flow
// Magic links never create accounts (parity with the legacy behavior;
// account creation is register / Google / guest booking only).
disableSignUp: true,
// Hashed at rest: a leaked verification table cannot be replayed.
storeToken: 'hashed',
sendMagicLink: async ({ email, url, token }) => {
// Email a frontend URL (not the raw API verify URL) so the login
// completes on the site, preserving the legacy UX. The page calls
// authClient.magicLink.verify with the token.
let callbackURL = '/';
try {
callbackURL = new URL(url).searchParams.get('callbackURL') || '/';
} catch {
/* default */
}
const link = `${frontendUrl}/auth/magic-link?token=${encodeURIComponent(token)}&callbackURL=${encodeURIComponent(callbackURL)}`;
const isClaim = callbackURL.startsWith('/auth/claim-account');
try {
await sendEmail({
to: email,
subject: isClaim ? 'Claim Your Spanglish Account' : 'Your Spanglish Login Link',
html: isClaim
? `
<h2>Claim Your Account</h2>
<p>An account was created for you during booking. Click below to set up your login credentials. This link expires in 10 minutes.</p>
<p><a href="${link}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
<p>Or copy this link: ${link}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`
: `
<h2>Login to Spanglish</h2>
<p>Click the link below to log in. This link expires in 10 minutes.</p>
<p><a href="${link}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
<p>Or copy this link: ${link}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send magic link email:', error);
}
},
}),
admin({
defaultRole: 'user',
adminRoles: ['admin'],
bannedUserMessage: 'Account is suspended. Please contact support.',
}),
],
});
export type Auth = typeof auth;
+51
View File
@@ -0,0 +1,51 @@
// Door payment tenders.
//
// The door check-in screen offers four one-tap tenders. Each maps onto an
// existing payments.provider so the rest of the app (capacity, sweeps, admin
// payment lists, receipts) keeps working unchanged, while payments.method
// records which tender was actually used for the end-of-night cash-up.
//
// Bitcoin currently maps to the 'lightning' provider but records the payment as
// already made — the same trust model as cash, no invoice generated. When a real
// Lightning flow lands it slots in here: the tender keeps its name and provider,
// only the settlement path in routes/door.ts changes.
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'guest'] as const;
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
interface DoorTender {
/** Existing payments.provider this tender is stored as. */
provider: 'cash' | 'lightning' | 'bank_transfer';
/** Human label used in payment references and toasts. */
label: string;
/** Comp tenders carry no revenue and always record a zero amount. */
isComp: boolean;
}
export const DOOR_TENDERS: Record<DoorPaymentMethod, DoorTender> = {
cash: { provider: 'cash', label: 'cash', isComp: false },
bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false },
transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false },
guest: { provider: 'cash', label: 'guest', isComp: true },
};
export function isDoorPaymentMethod(value: unknown): value is DoorPaymentMethod {
return typeof value === 'string' && (DOOR_PAYMENT_METHODS as readonly string[]).includes(value);
}
/** Ticket paymentStatus a tender settles to: comps are 'comp', everything else 'paid'. */
export function paymentStatusForMethod(method: DoorPaymentMethod): 'paid' | 'comp' {
return DOOR_TENDERS[method].isComp ? 'comp' : 'paid';
}
/** Amount actually recorded: comps are always zero regardless of what was requested. */
export function amountForMethod(method: DoorPaymentMethod, requested: number): number {
return DOOR_TENDERS[method].isComp ? 0 : Math.max(0, requested);
}
export function doorReference(method: DoorPaymentMethod): string {
return DOOR_TENDERS[method].isComp
? 'Door — guest (comp)'
: `Door — paid by ${DOOR_TENDERS[method].label}`;
}
+62
View File
@@ -0,0 +1,62 @@
import * as argon2 from 'argon2';
import bcrypt from 'bcryptjs';
// Password hashing with Argon2 (spec requirement)
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
// Support both bcrypt (legacy) and argon2 hashes for migration
if (hash.startsWith('$argon2')) {
return argon2.verify(hash, password);
}
// Legacy bcrypt support
return bcrypt.compare(password, hash);
}
// Small blocklist of common/weak passwords (and obvious app-specific ones).
// Compared case-insensitively after stripping non-alphanumerics so that e.g.
// "P@ssw0rd!" still matches "password".
const COMMON_PASSWORDS = new Set([
'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890',
'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou',
'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123',
'spanglish', 'changeme', 'secret', 'master', 'login', 'access',
]);
// Password policy: 10-128 chars, requires a mix of character types, and rejects
// common/weak passwords. Centralized so register/reset/change all share it.
export function validatePassword(password: string): { valid: boolean; error?: string } {
if (password.length < 10) {
return { valid: false, error: 'Password must be at least 10 characters long' };
}
if (password.length > 128) {
return { valid: false, error: 'Password must be at most 128 characters long' };
}
const hasLower = /[a-z]/.test(password);
const hasUpper = /[A-Z]/.test(password);
const hasDigit = /\d/.test(password);
const hasSymbol = /[^A-Za-z0-9]/.test(password);
// Require lowercase, uppercase, and at least one digit or symbol.
if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) {
return {
valid: false,
error: 'Password must include uppercase and lowercase letters and at least one number or symbol',
};
}
const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, '');
if (COMMON_PASSWORDS.has(normalized)) {
return { valid: false, error: 'Password is too common. Please choose a less guessable password.' };
}
return { valid: true };
}
+354 -197
View File
@@ -1,6 +1,8 @@
// PDF Ticket Generation Service // PDF Ticket Generation Service
import PDFDocument from 'pdfkit'; import PDFDocument from 'pdfkit';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { existsSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
interface TicketData { interface TicketData {
id: string; id: string;
@@ -15,235 +17,390 @@ interface TicketData {
locationUrl?: string; locationUrl?: string;
}; };
timezone?: string; timezone?: string;
/** 'en' | 'es' - drives the labels and the date/time format on the ticket */
locale?: string;
/** Optional perk line shown under the ticket holder (falls back to the terms line) */
note?: string;
}
// ==================== Brand ====================
const COLORS = {
navy: '#002F44',
orange: '#F5821F',
cream: '#FDF8F0',
card: '#FFFFFF',
cardBorder: '#EFE6D8',
divider: '#E7DFD1',
label: '#9AA3AC',
muted: '#6B7580',
footerMuted: '#7FA3B5',
};
const PAGE_W = 595.28;
const PAGE_H = 841.89;
const MARGIN = 48;
const CONTENT_W = PAGE_W - MARGIN * 2;
const ACCENT_H = 10;
const FOOTER_H = 48;
const LOGO_RATIO = 1158 / 324;
const STRINGS = {
en: {
scan: 'SCAN AT THE ENTRANCE',
venue: 'VENUE',
holder: 'TICKET HOLDER',
terms: 'This ticket is non-transferable. One scan per entry.',
},
es: {
scan: 'ESCANEÁ AL INGRESAR',
venue: 'LUGAR',
holder: 'TITULAR',
terms: 'Esta entrada es personal e intransferible. Un escaneo por ingreso.',
},
} as const;
function strings(locale?: string) {
return locale === 'es' ? STRINGS.es : STRINGS.en;
} }
/** /**
* Generate a QR code as a data URL * Locate the logo. `../../assets` resolves to backend/assets from both
* src/lib (tsx) and dist/lib (compiled), with the frontend copy as a fallback.
*/
function loadLogo(): Buffer | null {
const candidates = [
new URL('../../assets/logo-spanglish.png', import.meta.url),
new URL('../../../frontend/public/images/logo-spanglish.png', import.meta.url),
].map((u) => fileURLToPath(u));
for (const path of candidates) {
if (existsSync(path)) return readFileSync(path);
}
return null;
}
let logoCache: Buffer | null | undefined;
function getLogo(): Buffer | null {
if (logoCache === undefined) logoCache = loadLogo();
return logoCache;
}
/**
* Generate a QR code as a PNG buffer
*/ */
async function generateQRCode(data: string): Promise<Buffer> { async function generateQRCode(data: string): Promise<Buffer> {
return QRCode.toBuffer(data, { return QRCode.toBuffer(data, {
type: 'png', type: 'png',
width: 200, width: 600,
margin: 2, margin: 1,
errorCorrectionLevel: 'M', errorCorrectionLevel: 'M',
color: { dark: '#000000', light: '#FFFFFF' },
}); });
} }
/** /**
* Format date for display using site timezone * Short date + time as shown in the ticket header:
* en -> "JUL 25 · 4:30 PM" es -> "25 JUL · 16:30"
*/ */
function formatDate(dateStr: string, timezone: string = 'America/Asuncion'): string { function formatWhen(
const date = new Date(dateStr); startStr: string,
return date.toLocaleDateString('en-US', { endStr: string | undefined,
weekday: 'long', timezone: string,
year: 'numeric', locale: string
month: 'long', ): string {
day: 'numeric', const isEs = locale === 'es';
timeZone: timezone, const start = new Date(startStr);
}); const tag = isEs ? 'es-ES' : 'en-US';
const day = start.toLocaleDateString(tag, { day: 'numeric', timeZone: timezone });
const month = start
.toLocaleDateString(tag, { month: 'short', timeZone: timezone })
.replace(/\.$/, '')
.toUpperCase();
const time = (d: Date) =>
d
.toLocaleTimeString(tag, {
hour: isEs ? '2-digit' : 'numeric',
minute: '2-digit',
hour12: !isEs,
timeZone: timezone,
})
.toUpperCase();
const date = isEs ? `${day} ${month}` : `${month} ${day}`;
const end = endStr ? new Date(endStr) : null;
const when = end ? `${time(start)} ${time(end)}` : time(start);
return `${date} · ${when}`;
} }
/** /**
* Format time for display using site timezone * Events store the venue as a single string; the part before the first comma
* reads as the venue name and the remainder as its address.
*/ */
function formatTime(dateStr: string, timezone: string = 'America/Asuncion'): string { function splitLocation(location: string): { name: string; address?: string } {
const date = new Date(dateStr); const idx = location.indexOf(',');
return date.toLocaleTimeString('en-US', { if (idx === -1) return { name: location.trim() };
hour: '2-digit', return {
minute: '2-digit', name: location.slice(0, idx).trim(),
hour12: true, address: location.slice(idx + 1).trim() || undefined,
timeZone: timezone, };
}
// ==================== Drawing helpers ====================
function drawLabel(doc: PDFKit.PDFDocument, text: string, y: number, width = CONTENT_W, x = MARGIN) {
doc
.font('Helvetica-Bold')
.fontSize(8)
.fillColor(COLORS.label)
.text(text.toUpperCase(), x, y, { width, characterSpacing: 1.6 });
}
function drawDivider(doc: PDFKit.PDFDocument, y: number) {
doc
.moveTo(MARGIN, y)
.lineTo(PAGE_W - MARGIN, y)
.lineWidth(1)
.strokeColor(COLORS.divider)
.stroke();
}
/** Centered text with letter spacing: pdfkit also spaces the last glyph, so nudge it back. */
function drawSpacedCentered(
doc: PDFKit.PDFDocument,
text: string,
x: number,
y: number,
width: number,
spacing: number
) {
doc.text(text, x - spacing / 2, y, { width, align: 'center', characterSpacing: spacing });
}
interface DetailBlock {
label: string;
value: string;
sub?: string;
}
/**
* Draw (or, with `measureOnly`, just measure) the venue / ticket holder / note
* block. Returns its total height so the caller can anchor it above the footer.
*/
function renderDetails(
doc: PDFKit.PDFDocument,
blocks: DetailBlock[],
note: string,
yStart: number,
measureOnly: boolean
): number {
let y = yStart;
blocks.forEach((block, i) => {
if (i > 0) {
y += 14;
if (!measureOnly) drawDivider(doc, y);
y += 18;
}
if (!measureOnly) drawLabel(doc, block.label, y);
y += 15;
doc.font('Helvetica-Bold').fontSize(13);
if (!measureOnly) doc.fillColor(COLORS.navy).text(block.value, MARGIN, y, { width: CONTENT_W });
y += doc.heightOfString(block.value, { width: CONTENT_W }) + 3;
if (block.sub) {
doc.font('Helvetica').fontSize(10.5);
if (!measureOnly) doc.fillColor(COLORS.muted).text(block.sub, MARGIN, y, { width: CONTENT_W });
y += doc.heightOfString(block.sub, { width: CONTENT_W }) + 3;
}
}); });
y += 16;
doc.font('Helvetica').fontSize(10.5);
if (!measureOnly) doc.fillColor(COLORS.muted).text(note, MARGIN, y, { width: CONTENT_W });
y += doc.heightOfString(note, { width: CONTENT_W });
return y - yStart;
}
/**
* Render one full-page ticket. Assumes the page is already added.
*/
function renderTicketPage(
doc: PDFKit.PDFDocument,
ticket: TicketData,
qrBuffer: Buffer,
siteDomain: string,
index = 0,
total = 1
) {
const locale = ticket.locale === 'es' ? 'es' : 'en';
const t = strings(locale);
const tz = ticket.timezone || 'America/Asuncion';
const footerY = PAGE_H - FOOTER_H;
// ==================== Background ====================
doc.rect(0, 0, PAGE_W, PAGE_H).fill(COLORS.cream);
doc.rect(0, 0, PAGE_W, ACCENT_H).fill(COLORS.orange);
// ==================== Logo ====================
const logo = getLogo();
let headerY = MARGIN + 6;
if (logo) {
const logoW = 158;
doc.image(logo, MARGIN, headerY, { width: logoW });
headerY += logoW / LOGO_RATIO;
} else {
doc.font('Helvetica-Bold').fontSize(21).fillColor(COLORS.navy).text('spanglish social', MARGIN, headerY);
headerY += 26;
}
// ==================== Title + date ====================
const titleY = headerY + 30;
const when = formatWhen(ticket.event.startDatetime, ticket.event.endDatetime, tz, locale);
doc.font('Helvetica-Bold').fontSize(11.5);
const whenW = Math.min(doc.widthOfString(when) + 2, CONTENT_W * 0.5);
const titleW = CONTENT_W - whenW - 20;
doc.font('Helvetica-Bold').fontSize(26);
if (doc.widthOfString(ticket.event.title) > titleW) doc.fontSize(20);
doc.fillColor(COLORS.navy).text(ticket.event.title, MARGIN, titleY, { width: titleW });
const titleBottom = doc.y;
doc
.font('Helvetica-Bold')
.fontSize(11.5)
.fillColor(COLORS.orange)
.text(when, PAGE_W - MARGIN - whenW, titleY + 9, { width: whenW, align: 'right' });
// ==================== Layout: card fills what the detail block leaves ====================
const venue = splitLocation(ticket.event.location);
const note = ticket.note || t.terms;
const blocks: DetailBlock[] = [
{ label: t.venue, value: venue.name, sub: venue.address },
{ label: t.holder, value: ticket.attendeeName, sub: ticket.attendeeEmail },
];
const detailsH = renderDetails(doc, blocks, note, 0, true);
const detailsY = footerY - 46 - detailsH;
const cardY = Math.max(titleBottom, titleY + 36) + 24;
const cardX = MARGIN;
const cardW = CONTENT_W;
const cardH = Math.max(300, Math.min(detailsY - 32 - cardY, 430));
doc
.roundedRect(cardX, cardY, cardW, cardH, 14)
.lineWidth(1)
.fillAndStroke(COLORS.card, COLORS.cardBorder);
// ==================== QR card contents ====================
const labelH = 12;
const codeH = 24;
const qrSize = Math.min(236, cardH - (labelH + 20 + 22 + codeH + 44));
const stackH = labelH + 20 + qrSize + 22 + codeH;
let inner = cardY + (cardH - stackH) / 2;
doc.font('Helvetica-Bold').fontSize(8.5).fillColor(COLORS.label);
drawSpacedCentered(doc, t.scan, cardX, inner, cardW, 2);
if (total > 1) {
doc
.font('Helvetica-Bold')
.fontSize(8.5)
.fillColor(COLORS.label)
.text(`${index + 1} / ${total}`, cardX, inner, { width: cardW - 22, align: 'right', characterSpacing: 1 });
}
inner += labelH + 20;
doc.image(qrBuffer, (PAGE_W - qrSize) / 2, inner, { width: qrSize, height: qrSize });
inner += qrSize + 22;
const code = ticket.qrCode || ticket.id.slice(0, 8).toUpperCase();
doc.font('Courier-Bold').fontSize(19).fillColor(COLORS.navy);
drawSpacedCentered(doc, code, cardX, inner, cardW, 3);
// ==================== Venue / ticket holder / note ====================
renderDetails(doc, blocks, note, detailsY, false);
// ==================== Footer ====================
doc.rect(0, footerY, PAGE_W, FOOTER_H).fill(COLORS.navy);
doc
.font('Courier')
.fontSize(7.5)
.fillColor(COLORS.footerMuted)
.text(ticket.id, MARGIN, footerY + FOOTER_H / 2 - 4, { width: CONTENT_W * 0.6, lineBreak: false });
doc
.font('Helvetica')
.fontSize(10)
.fillColor('#FFFFFF')
.text(siteDomain, MARGIN, footerY + FOOTER_H / 2 - 5.5, { width: CONTENT_W, align: 'right' });
}
function createDoc(): PDFKit.PDFDocument {
return new PDFDocument({ size: 'A4', margin: 0 });
}
function collect(doc: PDFKit.PDFDocument): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
});
}
function siteUrl(): { base: string; domain: string } {
const base = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
let domain = base;
try {
domain = new URL(base).host.replace(/^www\./, '');
} catch {
domain = base.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '');
}
return { base, domain };
} }
/** /**
* Generate a PDF ticket for a single ticket * Generate a PDF ticket for a single ticket
*/ */
export async function generateTicketPDF(ticket: TicketData): Promise<Buffer> { export async function generateTicketPDF(ticket: TicketData): Promise<Buffer> {
return new Promise(async (resolve, reject) => { return generateCombinedTicketsPDF([ticket]);
try {
const doc = new PDFDocument({
size: 'A4',
margin: 50,
});
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
// Generate QR code with ticket URL
const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
const qrBuffer = await generateQRCode(qrUrl);
// ==================== Header ====================
doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
// Divider line
doc.moveDown(1);
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
doc.moveDown(1);
// ==================== Event Info ====================
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
doc.moveDown(0.5);
// Date and time (using site timezone)
const tz = ticket.timezone || 'America/Asuncion';
doc.fontSize(14).fillColor('#333');
doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' });
const startTime = formatTime(ticket.event.startDatetime, tz);
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
doc.text(timeRange, { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
// ==================== QR Code ====================
doc.moveDown(2);
// Center the QR code
const qrSize = 180;
const pageWidth = 595; // A4 width in points
const qrX = (pageWidth - qrSize) / 2;
doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
doc.y += qrSize + 10;
// ==================== Attendee Info ====================
doc.moveDown(1);
doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
if (ticket.attendeeEmail) {
doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
}
// ==================== Ticket ID ====================
doc.moveDown(1);
doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
// ==================== Footer ====================
doc.moveDown(2);
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
doc.moveDown(0.5);
doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
doc.moveDown(0.3);
doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
doc.end();
} catch (error) {
reject(error);
}
});
} }
/** /**
* Generate a combined PDF with multiple tickets * Generate a combined PDF with multiple tickets (one page each)
*/ */
export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise<Buffer> { export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise<Buffer> {
return new Promise(async (resolve, reject) => { const doc = createDoc();
try { const done = collect(doc);
const doc = new PDFDocument({ const { base, domain } = siteUrl();
size: 'A4',
margin: 50,
});
const chunks: Buffer[] = []; try {
doc.on('data', (chunk: Buffer) => chunks.push(chunk)); for (let i = 0; i < tickets.length; i++) {
doc.on('end', () => resolve(Buffer.concat(chunks))); const ticket = tickets[i];
doc.on('error', reject); if (i > 0) doc.addPage();
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com'; const qrBuffer = await generateQRCode(`${base}/ticket/${ticket.id}`);
renderTicketPage(doc, ticket, qrBuffer, domain, i, tickets.length);
for (let i = 0; i < tickets.length; i++) {
const ticket = tickets[i];
if (i > 0) {
doc.addPage();
}
// Generate QR code
const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
const qrBuffer = await generateQRCode(qrUrl);
// ==================== Header ====================
doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
// Divider line
doc.moveDown(1);
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
doc.moveDown(1);
// ==================== Event Info ====================
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
doc.moveDown(0.5);
// Date and time (using site timezone)
const tz = ticket.timezone || 'America/Asuncion';
doc.fontSize(14).fillColor('#333');
doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' });
const startTime = formatTime(ticket.event.startDatetime, tz);
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
doc.text(timeRange, { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
// ==================== QR Code ====================
doc.moveDown(2);
const qrSize = 180;
const pageWidth = 595;
const qrX = (pageWidth - qrSize) / 2;
doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
doc.y += qrSize + 10;
// ==================== Attendee Info ====================
doc.moveDown(1);
doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
if (ticket.attendeeEmail) {
doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
}
// ==================== Ticket ID ====================
doc.moveDown(1);
doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
// Ticket number for multi-ticket bookings
if (tickets.length > 1) {
doc.text(`Ticket ${i + 1} of ${tickets.length}`, { align: 'center' });
}
// ==================== Footer ====================
doc.moveDown(2);
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
doc.moveDown(0.5);
doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
doc.moveDown(0.3);
doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
}
doc.end();
} catch (error) {
reject(error);
} }
}); doc.end();
} catch (error) {
doc.end();
throw error;
}
return done;
} }
export default { export default {
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { getClientIp, isTrustedProxyIp } from './rateLimit.js';
// Minimal Hono-Context stand-in: headers + the node-server env with the
// socket peer address.
function fakeContext(opts: { peer?: string; headers?: Record<string, string> }) {
const headers = new Map(
Object.entries(opts.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
);
return {
req: { header: (name: string) => headers.get(name.toLowerCase()) },
env: opts.peer ? { incoming: { socket: { remoteAddress: opts.peer } } } : {},
} as any;
}
describe('isTrustedProxyIp', () => {
it('trusts loopback and private ranges, including IPv4-mapped IPv6', () => {
expect(isTrustedProxyIp('127.0.0.1')).toBe(true);
expect(isTrustedProxyIp('::1')).toBe(true);
expect(isTrustedProxyIp('::ffff:127.0.0.1')).toBe(true);
expect(isTrustedProxyIp('10.1.2.3')).toBe(true);
expect(isTrustedProxyIp('172.18.0.5')).toBe(true);
expect(isTrustedProxyIp('192.168.1.1')).toBe(true);
});
it('does not trust public addresses or near-miss ranges', () => {
expect(isTrustedProxyIp('203.0.113.7')).toBe(false);
expect(isTrustedProxyIp('172.15.0.1')).toBe(false); // outside 172.16/12
expect(isTrustedProxyIp('172.32.0.1')).toBe(false);
expect(isTrustedProxyIp('1270.0.0.1')).toBe(false);
expect(isTrustedProxyIp('')).toBe(false);
});
});
describe('getClientIp', () => {
it('prefers X-Real-IP when the peer is a trusted proxy', () => {
const c = fakeContext({
peer: '127.0.0.1',
headers: { 'x-real-ip': '203.0.113.7', 'x-forwarded-for': '9.9.9.9' },
});
expect(getClientIp(c)).toBe('203.0.113.7');
});
it('walks X-Forwarded-For from the right past our own proxy hops', () => {
// spoofed prefix, then the real client appended by nginx, then the Next
// proxy hop — the rightmost untrusted entry wins
const c = fakeContext({
peer: '127.0.0.1',
headers: { 'x-forwarded-for': '9.9.9.9, 203.0.113.7, 127.0.0.1' },
});
expect(getClientIp(c)).toBe('203.0.113.7');
});
it('ignores forwarded headers entirely when the peer is untrusted', () => {
// A client hitting the API directly cannot pick its own bucket
const c = fakeContext({
peer: '198.51.100.4',
headers: { 'x-forwarded-for': '9.9.9.9', 'x-real-ip': '8.8.8.8' },
});
expect(getClientIp(c)).toBe('198.51.100.4');
});
it('falls back to the socket address for local traffic with no headers', () => {
expect(getClientIp(fakeContext({ peer: '127.0.0.1' }))).toBe('127.0.0.1');
expect(getClientIp(fakeContext({ peer: '::ffff:127.0.0.1' }))).toBe('127.0.0.1');
});
it('falls back to the socket address when every forwarded hop is internal', () => {
const c = fakeContext({
peer: '127.0.0.1',
headers: { 'x-forwarded-for': '127.0.0.1' },
});
expect(getClientIp(c)).toBe('127.0.0.1');
});
it('rejects junk header values instead of using them as bucket keys', () => {
const c = fakeContext({
peer: '127.0.0.1',
headers: { 'x-forwarded-for': 'not-an-ip; DROP TABLE users' },
});
expect(getClientIp(c)).toBe('127.0.0.1');
});
it('returns "unknown" without a socket address or trusted headers', () => {
expect(getClientIp(fakeContext({}))).toBe('unknown');
});
});
+61 -4
View File
@@ -10,11 +10,68 @@ import { getRateLimiter } from './stores/rateLimiter.js';
* (horizontal scaling). See lib/stores/rateLimiter.ts. * (horizontal scaling). See lib/stores/rateLimiter.ts.
*/ */
/** Best-effort client IP extraction (honours common reverse-proxy headers). */ // Peers allowed to speak for the client via X-Real-IP / X-Forwarded-For:
// loopback (nginx on the same host, the Next.js proxy) and RFC1918 ranges
// (the docker-compose scale deployment, where nginx is another container).
// Extend with TRUSTED_PROXIES (comma-separated IP prefixes, e.g. "172.20.").
const DEFAULT_TRUSTED_PROXY_PREFIXES = ['127.', '10.', '192.168.', '::1'];
function trustedProxyPrefixes(): string[] {
const extra = (process.env.TRUSTED_PROXIES || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return [...DEFAULT_TRUSTED_PROXY_PREFIXES, ...extra];
}
/** Strip the IPv4-mapped IPv6 prefix so "::ffff:127.0.0.1" matches "127.". */
function normalizeIp(ip: string | undefined | null): string {
const trimmed = (ip || '').trim();
return trimmed.toLowerCase().startsWith('::ffff:') ? trimmed.slice(7) : trimmed;
}
export function isTrustedProxyIp(ip: string): boolean {
const normalized = normalizeIp(ip);
if (!normalized) return false;
if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(normalized)) return true; // 172.16.0.0/12
return trustedProxyPrefixes().some((prefix) => normalized === prefix || normalized.startsWith(prefix));
}
// Rough shape check so a junk header value can't become a rate-limit key.
function looksLikeIp(value: string): boolean {
return value.length > 0 && value.length <= 45 && /^[0-9a-fA-F.:]+$/.test(value);
}
/**
* Spoof-resistant client IP resolution.
*
* The TCP peer address (via @hono/node-server's env.incoming) anchors the
* trust decision: forwarded headers are only honoured when the direct peer is
* one of our own proxies. X-Real-IP is preferred because nginx overwrites it
* at the edge (deploy/*.conf); X-Forwarded-For is append-only, so it is
* walked from the right past our proxy hops — the leftmost entries are
* client-controlled and never trusted on their own.
*/
export function getClientIp(c: Context): string { export function getClientIp(c: Context): string {
const forwarded = c.req.header('x-forwarded-for'); const socketAddr = normalizeIp((c.env as any)?.incoming?.socket?.remoteAddress);
if (forwarded) return forwarded.split(',')[0].trim();
return c.req.header('x-real-ip') || 'unknown'; if (socketAddr && isTrustedProxyIp(socketAddr)) {
const realIp = normalizeIp(c.req.header('x-real-ip'));
if (realIp && looksLikeIp(realIp)) return realIp;
const forwarded = c.req.header('x-forwarded-for');
if (forwarded) {
const chain = forwarded.split(',').map((s) => normalizeIp(s)).filter(Boolean);
for (let i = chain.length - 1; i >= 0; i--) {
if (!isTrustedProxyIp(chain[i])) {
return looksLikeIp(chain[i]) ? chain[i] : socketAddr;
}
}
// Every hop is one of ours: a genuinely local/internal client.
}
}
return socketAddr || 'unknown';
} }
/** /**
+42
View File
@@ -0,0 +1,42 @@
// Engine-neutral transactional writes.
//
// better-sqlite3 transactions take a *synchronous* callback (awaiting inside one
// silently breaks atomicity), while node-postgres takes an async one. Rather than
// fork every multi-write route into two near-identical branches, callers build a
// plain list of operations and hand it here: the business logic stays in one
// place and only the six lines below know which driver is underneath.
import { db, isSqlite } from '../db/index.js';
export type TxOp =
| { kind: 'insert'; table: any; values: any }
| { kind: 'update'; table: any; values: any; where: any }
| { kind: 'delete'; table: any; where: any };
export const insertOp = (table: any, values: any): TxOp => ({ kind: 'insert', table, values });
export const updateOp = (table: any, values: any, where: any): TxOp => ({ kind: 'update', table, values, where });
export const deleteOp = (table: any, where: any): TxOp => ({ kind: 'delete', table, where });
/** Apply every op inside a single transaction; any throw rolls back all of them. */
export async function runOps(ops: TxOp[]): Promise<void> {
if (ops.length === 0) return;
if (isSqlite()) {
(db as any).transaction((tx: any) => {
for (const op of ops) {
if (op.kind === 'insert') tx.insert(op.table).values(op.values).run();
else if (op.kind === 'update') tx.update(op.table).set(op.values).where(op.where).run();
else tx.delete(op.table).where(op.where).run();
}
});
return;
}
await (db as any).transaction(async (tx: any) => {
for (const op of ops) {
if (op.kind === 'insert') await tx.insert(op.table).values(op.values);
else if (op.kind === 'update') await tx.update(op.table).set(op.values).where(op.where);
else await tx.delete(op.table).where(op.where);
}
});
}
+13
View File
@@ -22,6 +22,19 @@ export function generateTicketCode(): string {
return `TKT-${nanoid(8).toUpperCase()}`; return `TKT-${nanoid(8).toUpperCase()}`;
} }
/**
* Canonical form for `users.email`.
*
* Better Auth lowercases the address on every lookup and write it performs,
* but the `users.email` unique index is case-sensitive on both dialects. Any
* row written outside Better Auth (guest bookings, door sales, admin-added
* tickets) must therefore be normalized the same way, or the row becomes
* invisible to sign-in / Google linking and a duplicate person gets created.
*/
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
/** /**
* Get current timestamp in the format appropriate for the database type. * Get current timestamp in the format appropriate for the database type.
* - SQLite: returns ISO string * - SQLite: returns ISO string
-684
View File
@@ -1,684 +0,0 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, users, magicLinkTokens, User } from '../db/index.js';
import { eq } from 'drizzle-orm';
import {
hashPassword,
verifyPassword,
createToken,
createRefreshToken,
isFirstUser,
getAuthUser,
validatePassword,
createMagicLinkToken,
verifyMagicLinkToken,
invalidateAllUserSessions,
bumpTokenVersion,
requireAuth,
getUserPasswordHash,
} from '../lib/auth.js';
import { generateId, getNow, toDbBool } from '../lib/utils.js';
import { sendEmail } from '../lib/email.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
import { getLoginLockout } from '../lib/stores/loginLockout.js';
// Per-IP rate limit for sensitive auth endpoints (registration, login, and all
// email-dispatching flows) to curb credential stuffing and email flooding.
const authRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth' });
// User type that includes all fields (some added in schema updates)
type AuthUser = User & {
isClaimed: boolean;
googleId: string | null;
rucNumber: string | null;
accountStatus: string;
};
const auth = new Hono();
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(10, 'Password must be at least 10 characters'),
name: z.string().min(2),
phone: z.string().optional(),
languagePreference: z.enum(['en', 'es']).optional(),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
const magicLinkRequestSchema = z.object({
email: z.string().email(),
});
const magicLinkVerifySchema = z.object({
token: z.string(),
});
const passwordResetRequestSchema = z.object({
email: z.string().email(),
});
const passwordResetSchema = z.object({
token: z.string(),
password: z.string().min(10, 'Password must be at least 10 characters'),
});
const claimAccountSchema = z.object({
token: z.string(),
password: z.string().min(10, 'Password must be at least 10 characters'),
});
const changePasswordSchema = z.object({
currentPassword: z.string(),
newPassword: z.string().min(10, 'Password must be at least 10 characters'),
});
const googleAuthSchema = z.object({
credential: z.string(), // Google ID token
});
// Register
auth.post('/register', authRateLimit, zValidator('json', registerSchema), async (c) => {
const data = c.req.valid('json');
// Validate password strength
const passwordValidation = validatePassword(data.password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
// Check if email exists
const existing = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
if (existing) {
// If user exists but is unclaimed, allow claiming
if (!existing.isClaimed || existing.accountStatus === 'unclaimed') {
return c.json({
error: 'Email already registered',
canClaim: true,
message: 'This email has an unclaimed account. Please check your email for the claim link or request a new one.'
}, 400);
}
return c.json({ error: 'Email already registered' }, 400);
}
// Check if first user (becomes admin)
const firstUser = await isFirstUser();
const hashedPassword = await hashPassword(data.password);
const now = getNow();
const id = generateId();
const newUser = {
id,
email: data.email,
password: hashedPassword,
name: data.name,
phone: data.phone || null,
role: firstUser ? 'admin' : 'user',
languagePreference: data.languagePreference || null,
isClaimed: toDbBool(true),
googleId: null,
rucNumber: null,
accountStatus: 'active',
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(newUser);
const token = await createToken(id, data.email, newUser.role, 0);
const refreshToken = await createRefreshToken(id);
return c.json({
user: {
id,
email: data.email,
name: data.name,
role: newUser.role,
isClaimed: true,
},
token,
refreshToken,
message: firstUser ? 'Admin account created successfully' : 'Account created successfully',
}, 201);
});
// Login with email/password
auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => {
const data = c.req.valid('json');
// Per-email lockout (shared across instances when Redis is configured).
const lockout = await getLoginLockout().isLocked(data.email);
if (lockout.locked) {
return c.json({
error: 'Too many login attempts. Please try again later.',
retryAfter: lockout.retryAfter
}, 429);
}
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
if (!user) {
await getLoginLockout().recordFailure(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
// Check if account is suspended
if (user.accountStatus === 'suspended') {
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
}
// Check if user has a password set
if (!user.password) {
return c.json({
error: 'No password set for this account',
needsClaim: !user.isClaimed,
message: user.isClaimed
? 'Please use Google login or request a password reset.'
: 'Please claim your account first.'
}, 400);
}
const validPassword = await verifyPassword(data.password, user.password);
if (!validPassword) {
await getLoginLockout().recordFailure(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
// Clear failed attempts on successful login
await getLoginLockout().clear(data.email);
// Transparently upgrade legacy bcrypt hashes to argon2 now that we have the
// plaintext and have verified it. Best-effort: a failure here must not block
// the login.
if (!String(user.password).startsWith('$argon2')) {
try {
const upgradedHash = await hashPassword(data.password);
await (db as any)
.update(users)
.set({ password: upgradedHash })
.where(eq((users as any).id, user.id));
} catch (err: any) {
console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err);
}
}
const token = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token,
refreshToken,
});
});
// Request magic link login
auth.post('/magic-link/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) {
// Don't reveal if email exists
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
}
if (user.accountStatus === 'suspended') {
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
}
// Create magic link token (expires in 10 minutes)
const token = await createMagicLinkToken(user.id, 'login', 10);
const magicLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/magic-link?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Your Spanglish Login Link',
html: `
<h2>Login to Spanglish</h2>
<p>Click the link below to log in. This link expires in 10 minutes.</p>
<p><a href="${magicLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
<p>Or copy this link: ${magicLink}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send magic link email:', error);
}
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
});
// Verify magic link and login
auth.post('/magic-link/verify', authRateLimit, zValidator('json', magicLinkVerifySchema), async (c) => {
const { token } = c.req.valid('json');
const verification = await verifyMagicLinkToken(token, 'login');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
);
if (!user || user.accountStatus === 'suspended') {
return c.json({ error: 'Invalid token' }, 400);
}
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
});
});
// Request password reset
auth.post('/password-reset/request', authRateLimit, zValidator('json', passwordResetRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) {
// Don't reveal if email exists
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
}
if (user.accountStatus === 'suspended') {
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
}
// Create reset token (expires in 30 minutes)
const token = await createMagicLinkToken(user.id, 'reset_password', 30);
const resetLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/reset-password?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Reset Your Spanglish Password',
html: `
<h2>Reset Your Password</h2>
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
<p><a href="${resetLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
<p>Or copy this link: ${resetLink}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send password reset email:', error);
}
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
});
// Reset password
auth.post('/password-reset/confirm', authRateLimit, zValidator('json', passwordResetSchema), async (c) => {
const { token, password } = c.req.valid('json');
// Validate password strength
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
const verification = await verifyMagicLinkToken(token, 'reset_password');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const hashedPassword = await hashPassword(password);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, verification.userId));
// Invalidate all existing sessions/JWTs for security
await invalidateAllUserSessions(verification.userId!);
await bumpTokenVersion(verification.userId!);
return c.json({ message: 'Password reset successfully. Please log in with your new password.' });
});
// Claim unclaimed account
auth.post('/claim-account/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) {
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
}
if (user.isClaimed && user.accountStatus !== 'unclaimed') {
return c.json({ error: 'Account is already claimed' }, 400);
}
// Create claim token (expires in 1 hour)
const token = await createMagicLinkToken(user.id, 'claim_account', 60);
const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Claim Your Spanglish Account',
html: `
<h2>Claim Your Account</h2>
<p>An account was created for you during booking. Click below to set up your login credentials.</p>
<p><a href="${claimLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
<p>Or copy this link: ${claimLink}</p>
<p>This link expires in 1 hour.</p>
`,
});
} catch (error) {
console.error('Failed to send claim account email:', error);
}
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
});
// Complete account claim
auth.post('/claim-account/confirm', authRateLimit, zValidator('json', claimAccountSchema), async (c) => {
const { token, password } = c.req.valid('json');
const verification = await verifyMagicLinkToken(token, 'claim_account');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
const now = getNow();
// Only set a password here. Linking a Google account requires a verified Google
// ID token via /google; we never trust a client-supplied googleId.
const updates: Record<string, any> = {
isClaimed: toDbBool(true),
accountStatus: 'active',
password: await hashPassword(password),
updatedAt: now,
};
await (db as any)
.update(users)
.set(updates)
.where(eq((users as any).id, verification.userId));
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
);
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
message: 'Account claimed successfully!',
});
});
// Google OAuth login/register
auth.post('/google', authRateLimit, zValidator('json', googleAuthSchema), async (c) => {
const { credential } = c.req.valid('json');
try {
// Verify the Google ID token. Google's tokeninfo endpoint validates the
// signature and expiry server-side; we additionally enforce the audience so a
// token minted for a different OAuth client cannot be replayed against us.
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`);
if (!response.ok) {
return c.json({ error: 'Invalid Google token' }, 400);
}
const googleData = await response.json() as {
sub: string;
email: string;
name: string;
email_verified: string;
aud?: string;
exp?: string;
};
// email_verified can be returned as boolean true or string "true"
if (String(googleData.email_verified) !== 'true') {
return c.json({ error: 'Google email not verified' }, 400);
}
// Enforce audience when a client ID is configured (closes token-confusion attacks)
const expectedAud = process.env.GOOGLE_CLIENT_ID;
if (expectedAud) {
if (googleData.aud !== expectedAud) {
return c.json({ error: 'Invalid Google token audience' }, 400);
}
} else {
console.warn('[auth] GOOGLE_CLIENT_ID is not set; skipping audience verification for Google login.');
}
// Reject expired tokens (defense-in-depth; tokeninfo also rejects them)
if (googleData.exp && Number(googleData.exp) * 1000 < Date.now()) {
return c.json({ error: 'Google token expired' }, 400);
}
const { sub: googleId, email, name } = googleData;
// Check if user exists by email or google_id
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) {
// Check by google_id
user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).googleId, googleId))
);
}
const now = getNow();
if (user) {
// User exists - link Google account if not already linked
if (user.accountStatus === 'suspended') {
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
}
if (!user.googleId) {
await (db as any)
.update(users)
.set({
googleId,
isClaimed: toDbBool(true),
accountStatus: 'active',
updatedAt: now,
})
.where(eq((users as any).id, user.id));
}
// Refresh user data
user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, user.id))
);
} else {
// Create new user
const firstUser = await isFirstUser();
const id = generateId();
const newUser = {
id,
email,
password: null,
name,
phone: null,
role: firstUser ? 'admin' : 'user',
languagePreference: null,
isClaimed: toDbBool(true),
googleId,
rucNumber: null,
accountStatus: 'active',
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(newUser);
user = newUser;
}
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
});
} catch (error) {
console.error('Google auth error:', error);
return c.json({ error: 'Failed to authenticate with Google' }, 500);
}
});
// Get current user
auth.get('/me', async (c) => {
const user = await getAuthUser(c);
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
phone: user.phone,
isClaimed: user.isClaimed,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
accountStatus: user.accountStatus,
createdAt: user.createdAt,
},
});
});
// Change password (authenticated users)
auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSchema), async (c) => {
const user = (c as any).get('user') as AuthUser;
const { currentPassword, newPassword } = c.req.valid('json');
// Validate new password
const passwordValidation = validatePassword(newPassword);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
// Verify current password if user has one
const existingHash = await getUserPasswordHash(user.id);
if (existingHash) {
const validPassword = await verifyPassword(currentPassword, existingHash);
if (!validPassword) {
return c.json({ error: 'Current password is incorrect' }, 400);
}
}
const hashedPassword = await hashPassword(newPassword);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, user.id));
// Invalidate all previously issued JWTs so a stolen old token can't outlive the change,
// then hand the current client a fresh token so it stays logged in on this device.
await bumpTokenVersion(user.id);
const refreshedUser = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, user.id))
);
const newToken = await createToken(user.id, user.email, user.role, refreshedUser?.tokenVersion ?? 0);
return c.json({ message: 'Password changed successfully', token: newToken });
});
// Logout - invalidate all previously issued JWTs for this user (logout everywhere)
auth.post('/logout', async (c) => {
const user = await getAuthUser(c);
if (user) {
await invalidateAllUserSessions(user.id);
await bumpTokenVersion(user.id);
}
return c.json({ message: 'Logged out successfully' });
});
export default auth;
+97
View File
@@ -0,0 +1,97 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { auth } from '../lib/betterAuth.js';
import { validatePassword } from '../lib/passwordPolicy.js';
import { db, dbGet, users } from '../db/index.js';
import { getNow, toDbBool, normalizeEmail } from '../lib/utils.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
// Custom auth flows that Better Auth doesn't provide out of the box. Mounted
// at /api/auth-ext to avoid colliding with Better Auth's /api/auth/* handler.
const authExtRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth-ext' });
const authExt = new Hono();
const claimAccountSchema = z.object({
password: z.string().min(10, 'Password must be at least 10 characters'),
});
// Complete a progressive-account claim. The user arrives here already holding
// a session established by the claim magic link; this endpoint deliberately
// accepts accountStatus 'unclaimed' sessions (requireAuth would reject them)
// and is the ONLY endpoint that does.
authExt.post('/claim-account', authExtRateLimit, zValidator('json', claimAccountSchema), async (c) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session?.user) {
return c.json({ error: 'Unauthorized. Please use the claim link from your email.' }, 401);
}
const user = session.user as any;
if (user.banned || user.accountStatus === 'suspended') {
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
}
if (user.isClaimed && user.accountStatus === 'active') {
return c.json({ error: 'Account is already claimed' }, 400);
}
const { password } = c.req.valid('json');
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
// Creates the credential account with the argon2id hash from lib/betterAuth.ts
await auth.api.setPassword({
body: { newPassword: password },
headers: c.req.raw.headers,
});
// The magic link click proved email ownership
await (db as any)
.update(users)
.set({
isClaimed: toDbBool(true),
accountStatus: 'active',
emailVerified: true,
updatedAt: getNow(),
})
.where(eq((users as any).id, user.id));
return c.json({
message: 'Account claimed successfully!',
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: true,
phone: user.phone ?? null,
rucNumber: user.rucNumber ?? null,
languagePreference: user.languagePreference ?? null,
},
});
});
// Whether an email belongs to an unclaimed account. Deliberate, rate-limited
// exception to enumeration-safety, matching the legacy register/login UX that
// surfaced "this account can be claimed".
authExt.get('/claim-eligibility', authExtRateLimit, async (c) => {
const email = c.req.query('email');
if (!email || !z.string().email().safeParse(email).success) {
return c.json({ canClaim: false });
}
// Normalized to match how the row is stored (see lib/utils.ts normalizeEmail)
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, normalizeEmail(email)))
);
const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended'
&& (!user.isClaimed || user.accountStatus === 'unclaimed');
return c.json({ canClaim });
});
export default authExt;
+71 -45
View File
@@ -1,18 +1,12 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js'; import { db, dbGet, dbAll, users, tickets, payments, events, invoices } from '../db/index.js';
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm'; import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, bumpTokenVersion, createToken, hashPassword, validatePassword, getUserPasswordHash } from '../lib/auth.js'; import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, type AuthUser } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js'; import { auth } from '../lib/betterAuth.js';
import { authSessions, authAccounts } from '../db/auth-schema.js';
// User type that includes all fields (some added in schema updates) import { getNow } from '../lib/utils.js';
type AuthUser = User & {
isClaimed: boolean;
googleId: string | null;
rucNumber: string | null;
accountStatus: string;
};
const dashboard = new Hono(); const dashboard = new Hono();
@@ -50,7 +44,7 @@ dashboard.get('/profile', async (c) => {
isClaimed: user.isClaimed, isClaimed: user.isClaimed,
accountStatus: user.accountStatus, accountStatus: user.accountStatus,
hasPassword, hasPassword,
hasGoogleLinked: !!user.googleId, hasGoogleLinked: await hasGoogleAccount(user.id),
memberSince: user.createdAt, memberSince: user.createdAt,
membershipDays, membershipDays,
createdAt: user.createdAt, createdAt: user.createdAt,
@@ -423,49 +417,77 @@ dashboard.get('/invoices', async (c) => {
// ==================== Security Routes ==================== // ==================== Security Routes ====================
// Get active sessions // Get active sessions (Better Auth session table; validated per-request so
// this list is always live). Session tokens are never exposed to the client.
dashboard.get('/sessions', async (c) => { dashboard.get('/sessions', async (c) => {
const user = (c as any).get('user') as AuthUser; const user = (c as any).get('user') as AuthUser;
const sessions = await getUserSessions(user.id); const sessions = await dbAll<any>(
(db as any)
.select({
id: (authSessions as any).id,
userAgent: (authSessions as any).userAgent,
ipAddress: (authSessions as any).ipAddress,
createdAt: (authSessions as any).createdAt,
updatedAt: (authSessions as any).updatedAt,
expiresAt: (authSessions as any).expiresAt,
})
.from(authSessions)
.where(
and(
eq((authSessions as any).userId, user.id),
gt((authSessions as any).expiresAt, new Date())
)
)
.orderBy(desc((authSessions as any).updatedAt))
);
return c.json({ return c.json({
sessions: sessions.map((s: any) => ({ sessions: sessions.map((s: any) => ({
id: s.id, id: s.id,
userAgent: s.userAgent, userAgent: s.userAgent,
ipAddress: s.ipAddress, ipAddress: s.ipAddress,
lastActiveAt: s.lastActiveAt, lastActiveAt: s.updatedAt,
createdAt: s.createdAt, createdAt: s.createdAt,
expiresAt: s.expiresAt,
current: s.id === user.sessionId,
})), })),
}); });
}); });
// Revoke a specific session // Revoke a specific session. Deleting the row is immediately effective:
// sessions are validated against the table on every request (no cookie cache).
dashboard.delete('/sessions/:id', async (c) => { dashboard.delete('/sessions/:id', async (c) => {
const user = (c as any).get('user') as AuthUser; const user = (c as any).get('user') as AuthUser;
const sessionId = c.req.param('id'); const sessionId = c.req.param('id');
await invalidateSession(sessionId, user.id); await (db as any)
.delete(authSessions)
.where(
and(
eq((authSessions as any).id, sessionId),
eq((authSessions as any).userId, user.id)
)
);
return c.json({ message: 'Session revoked' }); return c.json({ message: 'Session revoked' });
}); });
// Revoke all sessions (logout everywhere). Bumping the token version invalidates // Revoke all other sessions (logout everywhere else); the current session
// every previously issued JWT for this user, which is the actual enforcement // stays valid so this device remains signed in.
// mechanism (auth is stateless JWT, not DB-session based).
dashboard.post('/sessions/revoke-all', async (c) => { dashboard.post('/sessions/revoke-all', async (c) => {
const user = (c as any).get('user') as AuthUser; const user = (c as any).get('user') as AuthUser;
await invalidateAllUserSessions(user.id); await (db as any)
await bumpTokenVersion(user.id); .delete(authSessions)
.where(
and(
eq((authSessions as any).userId, user.id),
sql`${(authSessions as any).id} != ${user.sessionId}`
)
);
// Issue a fresh token so the current device stays signed in return c.json({ message: 'All other sessions revoked.' });
const refreshed = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, user.id))
);
const token = await createToken(user.id, user.email, user.role, refreshed?.tokenVersion ?? 0);
return c.json({ message: 'All other sessions revoked.', token });
}); });
// Set password (for users without one) // Set password (for users without one)
@@ -482,21 +504,21 @@ dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c)
return c.json({ error: 'Password already set. Use change password instead.' }, 400); return c.json({ error: 'Password already set. Use change password instead.' }, 400);
} }
// setPassword is a server-only Better Auth endpoint, so the HTTP-layer
// policy hook does not cover it — validate explicitly.
const passwordValidation = validatePassword(password); const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) { if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400); return c.json({ error: passwordValidation.error }, 400);
} }
const hashedPassword = await hashPassword(password); try {
const now = getNow(); await auth.api.setPassword({
body: { newPassword: password },
await (db as any) headers: c.req.raw.headers,
.update(users) });
.set({ } catch (err: any) {
password: hashedPassword, return c.json({ error: err?.body?.message || 'Failed to set password' }, 400);
updatedAt: now, }
})
.where(eq((users as any).id, user.id));
return c.json({ message: 'Password set successfully' }); return c.json({ message: 'Password set successfully' });
}); });
@@ -505,7 +527,7 @@ dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c)
dashboard.post('/unlink-google', async (c) => { dashboard.post('/unlink-google', async (c) => {
const user = (c as any).get('user') as AuthUser; const user = (c as any).get('user') as AuthUser;
if (!user.googleId) { if (!(await hasGoogleAccount(user.id))) {
return c.json({ error: 'Google account not linked' }, 400); return c.json({ error: 'Google account not linked' }, 400);
} }
@@ -513,14 +535,18 @@ dashboard.post('/unlink-google', async (c) => {
return c.json({ error: 'Cannot unlink Google without a password set' }, 400); return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
} }
const now = getNow(); await (db as any)
.delete(authAccounts)
.where(
and(
eq((authAccounts as any).userId, user.id),
eq((authAccounts as any).providerId, 'google')
)
);
await (db as any) await (db as any)
.update(users) .update(users)
.set({ .set({ updatedAt: getNow() })
googleId: null,
updatedAt: now,
})
.where(eq((users as any).id, user.id)); .where(eq((users as any).id, user.id));
return c.json({ message: 'Google account unlinked' }); return c.json({ message: 'Google account unlinked' });
+443
View File
@@ -0,0 +1,443 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
// Env must be pinned before the db singleton is imported (dotenv never overrides).
const dir = mkdtempSync(join(tmpdir(), 'door-test-'));
const dbPath = join(dir, 'test.db');
process.env.DB_TYPE = 'sqlite';
process.env.DATABASE_URL = dbPath;
process.env.FRONTEND_URL = 'http://localhost:3002';
process.env.BETTER_AUTH_SECRET = 'door-test-secret-0123456789abcdef';
delete process.env.REDIS_URL;
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
const ADMIN = { id: 'admin-user-id', name: 'The Admin', role: 'admin' };
const ORGANIZER = { id: 'organizer-user-id', name: 'The Organizer', role: 'organizer' };
// Who the next request is from. Session auth itself is Better Auth's concern and
// has its own integration suite; this mock keeps the *role* check real so the
// tests can prove which endpoints door staff may reach.
let currentUser: { id: string; name: string; role: string } = STAFF;
vi.mock('../lib/auth.js', () => ({
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
if (roles && !roles.includes(currentUser.role)) {
return c.json({ error: 'Forbidden' }, 403);
}
c.set('user', currentUser);
await next();
},
getAuthUser: async () => currentUser,
}));
/** Run one request as a given role, always restoring the default afterwards. */
async function as<T>(user: typeof STAFF, fn: () => Promise<T>): Promise<T> {
const previous = currentUser;
currentUser = user;
try {
return await fn();
} finally {
currentUser = previous;
}
}
// Walk-ins with an email trigger a confirmation send; keep it out of the test.
vi.mock('../lib/email.js', () => ({
default: { sendBookingConfirmation: vi.fn(async () => ({ success: true })) },
}));
let app: any;
let sqlite: any;
const EVENT_ID = 'evt-door-1';
const PRICE = 60000;
/** POST helper that mirrors how the door screen calls the API. */
async function post(path: string, body: unknown) {
const res = await app.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
async function get(path: string) {
const res = await app.request(path);
return { status: res.status, body: await res.json() };
}
function seedTicket(row: {
id: string;
first: string;
last?: string | null;
status: string;
paymentStatus: string;
phone?: string | null;
bookingId?: string | null;
qr?: string;
}) {
sqlite
.prepare(
`INSERT INTO tickets (id, booking_id, user_id, event_id, attendee_first_name, attendee_last_name,
attendee_email, attendee_phone, status, payment_status, is_guest, qr_code, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
)
.run(
row.id,
row.bookingId ?? null,
'seed-user',
EVENT_ID,
row.first,
row.last ?? null,
`${row.id}@test.py`,
row.phone ?? null,
row.status,
row.paymentStatus,
row.qr ?? `QR-${row.id}`,
new Date().toISOString()
);
}
beforeAll(() => {
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
return (async () => {
const { Hono } = await import('hono');
const doorRoutes = (await import('./door.js')).default;
app = new Hono();
app.route('/api/events', doorRoutes);
const Database = (await import('better-sqlite3')).default;
sqlite = new Database(dbPath);
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
VALUES (?, ?, ?, 'user', 0, 'unclaimed', ?, ?)`
)
.run('seed-user', 'seed@test.py', 'Seed User', now, now);
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
VALUES (?, ?, ?, 'staff', 1, 'active', ?, ?)`
)
.run(STAFF.id, 'staff@test.py', STAFF.name, now, now);
sqlite
.prepare(
`INSERT INTO events (id, title, description, start_datetime, location, price, currency, capacity, status, created_at, updated_at)
VALUES (?, 'Door Night', 'desc', ?, 'Asuncion', ?, 'PYG', 2, 'published', ?, ?)`
)
.run(EVENT_ID, now, PRICE, now, now);
seedTicket({ id: 'tkt-paid', first: 'José', last: 'Núñez', status: 'confirmed', paymentStatus: 'paid', phone: '+595 981 234 567' });
seedTicket({ id: 'tkt-unpaid', first: 'Ana', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' });
seedTicket({ id: 'tkt-unpaid-2', first: 'Beto', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' });
seedTicket({ id: 'tkt-cancelled', first: 'Carla', last: 'Gone', status: 'cancelled', paymentStatus: 'unpaid' });
})();
}, 120_000);
describe('door-attendees', () => {
it('returns everyone including cancelled, with group bookings flagged', async () => {
const { status, body } = await get(`/api/events/${EVENT_ID}/door-attendees`);
expect(status).toBe(200);
expect(body.event.price).toBe(PRICE);
expect(body.attendees).toHaveLength(4);
const cancelled = body.attendees.find((a: any) => a.ticketId === 'tkt-cancelled');
expect(cancelled.status).toBe('cancelled');
const grouped = body.attendees.find((a: any) => a.ticketId === 'tkt-unpaid');
expect(grouped.isGroupBooking).toBe(true);
expect(grouped.amountDue).toBe(PRICE);
const solo = body.attendees.find((a: any) => a.ticketId === 'tkt-paid');
expect(solo.isGroupBooking).toBe(false);
expect(solo.amountDue).toBe(0);
});
it('is sorted alphabetically so an empty search is scrollable', async () => {
const { body } = await get(`/api/events/${EVENT_ID}/door-attendees`);
const names = body.attendees.map((a: any) => a.fullName);
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })));
});
});
describe('door-checkin: existing ticket', () => {
it('checks in a paid attendee with no payment record touched', async () => {
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-paid',
entryMethod: 'search',
idempotencyKey: 'key-paid-checkin',
});
expect(status).toBe(201);
expect(body.attendee.checkedIn).toBe(true);
expect(body.attendee.checkinAt).toBeTruthy();
expect(body.attendee.checkedInBy).toBe(STAFF.name);
expect(body.payment).toBeNull();
const row = sqlite.prepare('SELECT status, checked_in_by_admin_id FROM tickets WHERE id = ?').get('tkt-paid');
expect(row.status).toBe('checked_in');
expect(row.checked_in_by_admin_id).toBe(STAFF.id);
});
it('replays an already-processed key instead of checking in twice', async () => {
const before = sqlite.prepare('SELECT checkin_at FROM tickets WHERE id = ?').get('tkt-paid').checkin_at;
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-paid',
idempotencyKey: 'key-paid-checkin',
});
expect(status).toBe(200);
expect(body.replayed).toBe(true);
const after = sqlite.prepare('SELECT checkin_at FROM tickets WHERE id = ?').get('tkt-paid').checkin_at;
expect(after).toBe(before);
expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-paid').n).toBe(0);
});
it('settles an unpaid group-booking ticket in cash and checks in, in one call', async () => {
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-unpaid',
payment: { method: 'cash', amount: PRICE },
entryMethod: 'search',
idempotencyKey: 'key-unpaid-cash',
});
expect(status).toBe(201);
expect(body.attendee.paymentStatus).toBe('paid');
expect(body.attendee.checkedIn).toBe(true);
expect(body.payment).toMatchObject({ method: 'cash', amount: PRICE });
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-unpaid');
expect(payment.source).toBe('door');
expect(payment.method).toBe('cash');
expect(payment.provider).toBe('cash');
expect(payment.status).toBe('paid');
expect(payment.paid_by_admin_id).toBe(STAFF.id);
});
it('takes a group payment at a multiple of the ticket price', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-unpaid-2',
payment: { method: 'transfer', amount: PRICE * 2 },
idempotencyKey: 'key-unpaid-2-transfer',
});
expect(body.payment.amount).toBe(PRICE * 2);
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-unpaid-2');
expect(payment.provider).toBe('bank_transfer');
expect(payment.method).toBe('transfer');
expect(payment.amount).toBe(PRICE * 2);
});
it('reactivates a cancelled ticket through the same payment flow', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-cancelled',
payment: { method: 'bitcoin' },
idempotencyKey: 'key-cancelled-reactivate',
});
expect(body.attendee.status).toBe('checked_in');
expect(body.attendee.paymentStatus).toBe('paid');
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-cancelled');
// Bitcoin is recorded as already-paid Lightning: same trust model as cash,
// no invoice generated (see lib/doorPayments.ts).
expect(payment.provider).toBe('lightning');
expect(payment.method).toBe('bitcoin');
expect(payment.amount).toBe(PRICE);
});
it('rejects a ticket from another event', async () => {
const { status, body } = await post('/api/events/other-event/door-checkin', {
ticketId: 'tkt-paid',
idempotencyKey: 'key-wrong-event',
});
expect(status).toBe(404);
expect(body.error).toMatch(/Event not found/);
});
});
describe('door-checkin: walk-ins', () => {
it('creates a cash walk-in confirmed, paid and checked in with no email', async () => {
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Walk' },
payment: { method: 'cash' },
entryMethod: 'walkin',
idempotencyKey: 'key-walkin-cash',
});
expect(status).toBe(201);
expect(body.action).toBe('walkin');
expect(body.attendee.fullName).toBe('Walk');
expect(body.attendee.checkedIn).toBe(true);
expect(body.attendee.paymentStatus).toBe('paid');
expect(body.attendee.email).toBeNull();
const ticket = sqlite.prepare('SELECT * FROM tickets WHERE id = ?').get(body.attendee.ticketId);
expect(ticket.status).toBe('checked_in');
expect(ticket.qr_code).toBeTruthy();
// A placeholder account keeps users.email unique without mailing anyone.
const account = sqlite.prepare('SELECT email FROM users WHERE id = ?').get(ticket.user_id);
expect(account.email).toMatch(/@doorentry\.local$/);
});
it('records a guest walk-in as a zero-amount comp', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Free', lastName: 'Guest' },
payment: { method: 'guest', amount: PRICE },
entryMethod: 'walkin',
idempotencyKey: 'key-walkin-guest',
});
expect(body.attendee.paymentStatus).toBe('comp');
expect(body.attendee.isGuest).toBe(true);
expect(body.payment.amount).toBe(0);
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment.amount).toBe(0);
expect(payment.method).toBe('guest');
});
it('does not create a second ticket when the same walk-in key is retried', async () => {
const before = sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n;
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Walk' },
payment: { method: 'cash' },
idempotencyKey: 'key-walkin-cash',
});
expect(status).toBe(200);
expect(body.replayed).toBe(true);
expect(sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n).toBe(before);
});
it('warns rather than blocks once the event is over capacity', async () => {
// Capacity is 2 and several tickets already hold seats.
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Overflow' },
payment: { method: 'cash' },
idempotencyKey: 'key-walkin-overflow',
});
expect(body.ok).toBe(true);
expect(body.warnings).toContain('at_capacity');
});
});
describe('undo', () => {
it('reverts a plain check-in to its previous state', async () => {
seedTicket({ id: 'tkt-undo', first: 'Undo', last: 'Me', status: 'confirmed', paymentStatus: 'paid' });
await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-undo',
idempotencyKey: 'key-undo-checkin',
});
expect(sqlite.prepare('SELECT status FROM tickets WHERE id = ?').get('tkt-undo').status).toBe('checked_in');
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, {
idempotencyKey: 'key-undo-checkin',
});
expect(status).toBe(200);
expect(body.reverted).toBe('existing');
const row = sqlite.prepare('SELECT status, checkin_at FROM tickets WHERE id = ?').get('tkt-undo');
expect(row.status).toBe('confirmed');
expect(row.checkin_at).toBeNull();
});
it('removes the payment it created and restores the unpaid balance', async () => {
seedTicket({ id: 'tkt-undo-pay', first: 'Undo', last: 'Pay', status: 'confirmed', paymentStatus: 'unpaid' });
await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-undo-pay',
payment: { method: 'cash' },
idempotencyKey: 'key-undo-pay',
});
expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-undo-pay').n).toBe(1);
await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-pay' });
const row = sqlite.prepare('SELECT status, payment_status FROM tickets WHERE id = ?').get('tkt-undo-pay');
expect(row.status).toBe('confirmed');
expect(row.payment_status).toBe('unpaid');
expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-undo-pay').n).toBe(0);
});
it('cancels a walk-in it created', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Mistake' },
payment: { method: 'cash' },
idempotencyKey: 'key-undo-walkin',
});
await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-walkin' });
const ticket = sqlite.prepare('SELECT status FROM tickets WHERE id = ?').get(body.attendee.ticketId);
expect(ticket.status).toBe('cancelled');
const payment = sqlite.prepare('SELECT status FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment.status).toBe('cancelled');
});
it('is safe to call twice and rejects an unknown key', async () => {
const repeat = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-walkin' });
expect(repeat.body.alreadyUndone).toBe(true);
const unknown = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'never-happened' });
expect(unknown.status).toBe(404);
});
});
describe('door-summary access', () => {
it('is hidden from door staff — whole-event takings are not door information', async () => {
const { status, body } = await get(`/api/events/${EVENT_ID}/door-summary`);
expect(status).toBe(403);
// The numbers must not leak in the body either: hiding the section in the UI
// alone would still expose them to anyone reading the network response.
expect(body).not.toHaveProperty('door');
expect(body).not.toHaveProperty('presale');
});
it('is available to admin and organizer', async () => {
for (const role of [ADMIN, ORGANIZER]) {
const { status } = await as(role, () => get(`/api/events/${EVENT_ID}/door-summary`));
expect(status, `${role.role} should see door takings`).toBe(200);
}
});
it('still lets door staff do their job — list, check in and undo', async () => {
expect((await get(`/api/events/${EVENT_ID}/door-attendees`)).status).toBe(200);
// Comp, so this ticket stays out of the revenue totals asserted below and
// the two tests cannot drift into each other through the shared database.
seedTicket({ id: 'tkt-role', first: 'Role', last: 'Check', status: 'confirmed', paymentStatus: 'comp' });
const checkin = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-role',
idempotencyKey: 'key-role-check',
});
expect(checkin.status).toBe(201);
const undo = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, {
idempotencyKey: 'key-role-check',
});
expect(undo.status).toBe(200);
});
});
describe('door-summary', () => {
it('totals door takings by tender and splits them from pre-sale', async () => {
const { status, body } = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/door-summary`));
expect(status).toBe(200);
// Cash: tkt-unpaid + the 'Walk' and 'Overflow' walk-ins (the undone ones are
// cancelled and no longer count).
expect(body.door.byMethod.cash.count).toBe(3);
expect(body.door.byMethod.cash.total).toBe(PRICE * 3);
expect(body.door.byMethod.transfer).toEqual({ count: 1, total: PRICE * 2 });
expect(body.door.byMethod.bitcoin).toEqual({ count: 1, total: PRICE });
expect(body.door.byMethod.guest).toEqual({ count: 1, total: 0 });
expect(body.door.total).toBe(PRICE * 6);
// Settled tickets with no door payment against them: tkt-paid, plus tkt-undo,
// whose door check-in was undone and which is a pre-paid ticket again.
expect(body.presale.count).toBe(2);
expect(body.presale.total).toBe(PRICE * 2);
expect(body.total).toBe(PRICE * 8);
expect(body.door.lines.length).toBe(body.door.count);
expect(body.door.lines[0]).toHaveProperty('name');
});
});
+663
View File
@@ -0,0 +1,663 @@
// Door check-in screen (admin/scanner) API.
//
// At the door, check-in and ticket creation are the same action, so everything
// here is written for one-tap speed on a phone with unreliable venue wifi:
//
// GET /:eventId/door-attendees full attendee list, fetched once and searched
// client-side so typing never hits the network
// POST /:eventId/door-checkin the single write endpoint — checks in, settles
// payment, or creates a walk-in, atomically
// POST /:eventId/door-checkin/undo reverses exactly what one keyed action did
// GET /:eventId/door-summary end-of-night cash-up + pre-sale/door revenue
// split (admin/organizer only)
//
// Every write carries a client-generated idempotencyKey. The key is inserted in
// the same transaction as the writes, so a double tap or a retry after a timeout
// can never produce a second ticket, a second payment or a double check-in — the
// replay returns the original response instead.
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { eq, and, inArray, sql } from 'drizzle-orm';
import {
db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys,
} from '../db/index.js';
import { requireAuth } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, toDbBool, toDbDate, normalizeEmail } from '../lib/utils.js';
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
import { seatHolderCountQuery } from '../lib/capacity.js';
import {
DOOR_PAYMENT_METHODS, DOOR_TENDERS, amountForMethod, doorReference,
paymentStatusForMethod, type DoorPaymentMethod,
} from '../lib/doorPayments.js';
import emailService from '../lib/email.js';
const doorRouter = new Hono();
const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const;
// Whole-event money is management information, not door information: door staff
// reconcile their own shift from the session feed the client keeps locally, and
// never see what the event took overall. Matches the existing convention for
// revenue aggregates (admin/export/financial, admin/analytics).
const REVENUE_ROLES = ['admin', 'organizer'] as const;
const IDEMPOTENCY_SCOPE = 'door-checkin';
// ==================== Shared helpers ====================
const num = (v: any): number => {
const n = typeof v === 'string' ? parseFloat(v) : Number(v);
return Number.isFinite(n) ? n : 0;
};
const iso = (v: any): string | null => {
if (!v) return null;
return v instanceof Date ? v.toISOString() : String(v);
};
function fullName(ticket: any): string {
return `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
}
/**
* The row shape the door screen renders. Returned both by the preload list and
* by every write, so the client can splice an updated attendee straight back
* into its in-memory list without a refetch.
*/
function toDoorAttendee(
ticket: any,
opts: { price: number; groupBookingIds: Set<string>; adminNames: Map<string, string>; doorMethod?: string | null } ,
) {
return {
ticketId: ticket.id,
firstName: ticket.attendeeFirstName,
lastName: ticket.attendeeLastName || null,
fullName: fullName(ticket),
email: ticket.attendeeEmail || null,
phone: ticket.attendeePhone || null,
status: ticket.status,
paymentStatus: ticket.paymentStatus,
isGuest: !!ticket.isGuest,
checkedIn: ticket.status === 'checked_in',
checkinAt: iso(ticket.checkinAt),
checkedInBy: ticket.checkedInByAdminId ? opts.adminNames.get(ticket.checkedInByAdminId) || null : null,
bookingId: ticket.bookingId || null,
isGroupBooking: !!(ticket.bookingId && opts.groupBookingIds.has(ticket.bookingId)),
amountDue: ticket.paymentStatus === 'unpaid' ? opts.price : 0,
doorMethod: opts.doorMethod ?? null,
qrCode: ticket.qrCode || null,
createdAt: iso(ticket.createdAt),
};
}
async function loadEvent(eventId: string | undefined) {
if (!eventId) return null;
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, eventId))
);
if (!event) return null;
return {
...event,
price: num(event.price),
capacity: Number(event.capacity),
};
}
/** Names of the admins/staff referenced by the given check-in rows, in one query. */
async function loadAdminNames(adminIds: string[]): Promise<Map<string, string>> {
const unique = [...new Set(adminIds.filter(Boolean))];
if (unique.length === 0) return new Map();
const rows = await dbAll<any>(
(db as any)
.select({ id: (users as any).id, name: (users as any).name })
.from(users)
.where(inArray((users as any).id, unique))
);
return new Map(rows.map((r: any) => [r.id, r.name]));
}
/** Seats currently held for an event, used only to warn (never to block) at the door. */
async function seatsHeld(eventId: string): Promise<number> {
const row = await dbGet<any>(seatHolderCountQuery(db, eventId));
return Number(row?.count || 0);
}
// ==================== GET /:eventId/door-attendees ====================
// One payload, fetched on load and refreshed every ~30s by the client. Cancelled
// tickets are included on purpose: staff must be able to see and reactivate them.
doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async (c) => {
const eventId = c.req.param('eventId');
const event = await loadEvent(eventId);
if (!event) return c.json({ error: 'Event not found' }, 404);
const rows = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).eventId, eventId))
);
// A booking id shared by more than one ticket marks a group booking, which is
// the usual reason an otherwise-confirmed attendee still shows as unpaid.
const bookingCounts = new Map<string, number>();
for (const t of rows) {
if (t.bookingId) bookingCounts.set(t.bookingId, (bookingCounts.get(t.bookingId) || 0) + 1);
}
const groupBookingIds = new Set(
[...bookingCounts.entries()].filter(([, n]) => n > 1).map(([id]) => id)
);
const adminNames = await loadAdminNames(rows.map((t: any) => t.checkedInByAdminId));
// Door tender per ticket, so a row already settled at the door shows how.
// Joined on the event rather than on a list of ticket ids: the id list would
// grow with the guest list and eventually blow the statement parameter limit.
const doorMethods = new Map<string, string>();
const doorPayments = await dbAll<any>(
(db as any)
.select({ ticketId: (payments as any).ticketId, method: (payments as any).method })
.from(payments)
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
.where(and(
eq((tickets as any).eventId, eventId),
eq((payments as any).source, 'door')
))
);
for (const p of doorPayments) if (p.method) doorMethods.set(p.ticketId, p.method);
const attendees = rows
.map((t: any) => toDoorAttendee(t, {
price: event.price,
groupBookingIds,
adminNames,
doorMethod: doorMethods.get(t.id) || null,
}))
.sort((a, b) => a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' }));
const checkedIn = attendees.filter((a) => a.checkedIn).length;
const totalActive = attendees.filter((a) => a.status === 'confirmed' || a.status === 'checked_in').length;
return c.json({
event: {
id: event.id,
title: event.title,
price: event.price,
currency: event.currency,
capacity: event.capacity,
},
attendees,
stats: { checkedIn, totalActive, capacity: event.capacity },
});
});
// ==================== POST /:eventId/door-checkin ====================
const doorCheckinSchema = z.object({
// Existing ticket to check in (and optionally settle), or…
ticketId: z.string().optional(),
// …a walk-in to create. Only a first name is ever required.
attendee: z.object({
firstName: z.string().trim().min(1).max(255),
lastName: z.string().trim().max(255).optional().or(z.literal('')),
phone: z.string().trim().max(50).optional().or(z.literal('')),
email: z.string().trim().email().optional().or(z.literal('')),
ruc: z.string().trim().max(15).optional().or(z.literal('')),
}).optional(),
payment: z.object({
method: z.enum(DOOR_PAYMENT_METHODS),
// Omitted means "one ticket at event price"; a multiple covers someone
// paying for their whole group in one go.
amount: z.number().min(0).optional(),
}).optional(),
// How the attendee reached this action, for the session feed.
entryMethod: z.enum(['scan', 'search', 'walkin']).optional(),
idempotencyKey: z.string().min(8).max(128),
}).refine((d) => !!d.ticketId || !!d.attendee, {
message: 'Either ticketId or attendee is required',
path: ['ticketId'],
});
/** Undo instructions recorded alongside each processed idempotency key. */
type UndoState =
| {
kind: 'created';
ticketId: string;
paymentId: string;
}
| {
kind: 'existing';
ticketId: string;
prevTicket: { status: string; checkinAt: string | null; checkedInByAdminId: string | null; paymentStatus: string; isGuest: boolean };
createdPaymentId?: string;
prevPayment?: {
id: string; provider: string; amount: number; status: string; reference: string | null;
paidAt: string | null; paidByAdminId: string | null; source: string; method: string | null;
};
};
/** A replay of a key we already processed returns the original response verbatim. */
async function findProcessedKey(key: string) {
return dbGet<any>(
(db as any).select().from(idempotencyKeys).where(eq((idempotencyKeys as any).key, key))
);
}
doorRouter.post(
'/:eventId/door-checkin',
requireAuth([...STAFF_ROLES]),
zValidator('json', doorCheckinSchema),
async (c) => {
const eventId = c.req.param('eventId');
const data = c.req.valid('json');
const adminUser = (c as any).get('user');
const existingKey = await findProcessedKey(data.idempotencyKey);
if (existingKey) {
return c.json({ ...JSON.parse(existingKey.result), replayed: true, undone: !!existingKey.undoneAt });
}
const event = await loadEvent(eventId);
if (!event) return c.json({ error: 'Event not found' }, 404);
const now = getNow();
const nowIso = new Date().toISOString();
const method = data.payment?.method as DoorPaymentMethod | undefined;
const requestedAmount = data.payment?.amount ?? event.price;
const ops: TxOp[] = [];
let undoState: UndoState;
let action: 'checkin' | 'walkin';
let ticketRow: any;
let paymentSummary: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | null = null;
let emailTicketId: string | null = null;
if (data.ticketId) {
// ---- Existing ticket: settle (optionally) and check in ----
const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, data.ticketId))
);
if (!ticket) return c.json({ error: 'Ticket not found' }, 404);
if (ticket.eventId !== eventId) {
return c.json({ error: 'Ticket belongs to a different event', code: 'WRONG_EVENT' }, 400);
}
action = 'checkin';
const prevTicket = {
status: ticket.status,
checkinAt: iso(ticket.checkinAt),
checkedInByAdminId: ticket.checkedInByAdminId || null,
paymentStatus: ticket.paymentStatus,
isGuest: !!ticket.isGuest,
};
const undo: UndoState = { kind: 'existing', ticketId: ticket.id, prevTicket };
const ticketUpdate: Record<string, any> = {};
if (method) {
const amount = amountForMethod(method, requestedAmount);
const tender = DOOR_TENDERS[method];
ticketUpdate.paymentStatus = paymentStatusForMethod(method);
if (method === 'guest') ticketUpdate.isGuest = toDbBool(true);
const existingPayment = await dbGet<any>(
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticket.id))
);
if (existingPayment) {
undo.prevPayment = {
id: existingPayment.id,
provider: existingPayment.provider,
amount: num(existingPayment.amount),
status: existingPayment.status,
reference: existingPayment.reference || null,
paidAt: iso(existingPayment.paidAt),
paidByAdminId: existingPayment.paidByAdminId || null,
source: existingPayment.source || 'presale',
method: existingPayment.method || null,
};
ops.push(updateOp(payments, {
provider: tender.provider,
amount,
currency: event.currency,
status: 'paid',
reference: doorReference(method),
paidAt: now,
paidByAdminId: adminUser?.id || null,
source: 'door',
method,
updatedAt: now,
}, eq((payments as any).id, existingPayment.id)));
paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency };
} else {
const paymentId = generateId();
undo.createdPaymentId = paymentId;
ops.push(insertOp(payments, {
id: paymentId,
ticketId: ticket.id,
provider: tender.provider,
amount,
currency: event.currency,
status: 'paid',
reference: doorReference(method),
paidAt: now,
paidByAdminId: adminUser?.id || null,
source: 'door',
method,
createdAt: now,
updatedAt: now,
}));
paymentSummary = { id: paymentId, method, amount, currency: event.currency };
}
}
// Check in. An already-checked-in ticket keeps its original timestamp so
// staff can still tell the person when they actually entered.
if (ticket.status !== 'checked_in') {
ticketUpdate.status = 'checked_in';
ticketUpdate.checkinAt = now;
ticketUpdate.checkedInByAdminId = adminUser?.id || null;
}
if (Object.keys(ticketUpdate).length > 0) {
ops.push(updateOp(tickets, ticketUpdate, eq((tickets as any).id, ticket.id)));
}
undoState = undo;
ticketRow = { ...ticket, ...ticketUpdate, checkinAt: ticketUpdate.checkinAt ?? ticket.checkinAt };
} else {
// ---- Walk-in: born confirmed, settled and checked in, in one write ----
const attendee = data.attendee!;
action = 'walkin';
const tenderMethod: DoorPaymentMethod = method || 'cash';
const tender = DOOR_TENDERS[tenderMethod];
const amount = amountForMethod(tenderMethod, requestedAmount);
const hasEmail = !!(attendee.email && attendee.email.trim());
const firstNameValue = attendee.firstName.trim();
const lastNameValue = attendee.lastName?.trim() || null;
const displayName = lastNameValue ? `${firstNameValue} ${lastNameValue}` : firstNameValue;
// No email is the fast path; a placeholder keeps the users.email unique
// constraint satisfied without ever mailing anyone.
// Normalized: Better Auth lowercases every lookup it makes, and the
// users.email unique index is case-sensitive, so a mixed-case address
// written here would be invisible to sign-in and Google linking.
const accountEmail = normalizeEmail(
hasEmail
? attendee.email!
: `${tenderMethod === 'guest' ? 'guest' : 'door'}-${generateId()}@doorentry.local`
);
let user = hasEmail
? await dbGet<any>((db as any).select().from(users).where(eq((users as any).email, accountEmail)))
: null;
if (!user) {
const userId = generateId();
user = { id: userId, email: accountEmail };
ops.push(insertOp(users, {
id: userId,
email: accountEmail,
password: null,
name: displayName,
phone: attendee.phone?.trim() || null,
role: 'user',
languagePreference: null,
isClaimed: toDbBool(false),
accountStatus: 'unclaimed',
emailVerified: false,
createdAt: now,
updatedAt: now,
}));
}
const ticketId = generateId();
const paymentId = generateId();
const newTicket = {
id: ticketId,
bookingId: null,
userId: user.id,
eventId,
attendeeFirstName: firstNameValue,
attendeeLastName: lastNameValue,
attendeeEmail: hasEmail ? attendee.email!.trim() : null,
attendeePhone: attendee.phone?.trim() || null,
attendeeRuc: attendee.ruc?.trim() || null,
preferredLanguage: null,
status: 'checked_in',
paymentStatus: paymentStatusForMethod(tenderMethod),
isGuest: toDbBool(tenderMethod === 'guest'),
qrCode: generateTicketCode(),
checkinAt: now,
checkedInByAdminId: adminUser?.id || null,
adminNote: null,
createdAt: now,
};
ops.push(insertOp(tickets, newTicket));
ops.push(insertOp(payments, {
id: paymentId,
ticketId,
provider: tender.provider,
amount,
currency: event.currency,
status: 'paid',
reference: doorReference(tenderMethod),
paidAt: now,
paidByAdminId: adminUser?.id || null,
source: 'door',
method: tenderMethod,
createdAt: now,
updatedAt: now,
}));
paymentSummary = { id: paymentId, method: tenderMethod, amount, currency: event.currency };
undoState = { kind: 'created', ticketId, paymentId };
ticketRow = newTicket;
// Only mail people who actually gave an address; no QR for the rest.
if (hasEmail) emailTicketId = ticketId;
}
// Staff at the door is the authority: a full event is a warning, never a block.
const held = await seatsHeld(eventId);
const atCapacity = event.capacity > 0 && held >= event.capacity;
const adminNames = await loadAdminNames([ticketRow.checkedInByAdminId]);
const responseBody = {
ok: true,
action,
attendee: toDoorAttendee(ticketRow, {
price: event.price,
groupBookingIds: new Set(ticketRow.bookingId ? [ticketRow.bookingId] : []),
adminNames,
doorMethod: paymentSummary?.method || null,
}),
payment: paymentSummary,
warnings: atCapacity ? ['at_capacity'] : [],
idempotencyKey: data.idempotencyKey,
processedAt: nowIso,
};
// The key row goes in with the writes, so two concurrent replays of the same
// key cannot both commit — the loser hits the primary-key conflict below.
ops.unshift(insertOp(idempotencyKeys, {
key: data.idempotencyKey,
scope: IDEMPOTENCY_SCOPE,
result: JSON.stringify(responseBody),
undoState: JSON.stringify(undoState),
undoneAt: null,
createdAt: now,
}));
try {
await runOps(ops);
} catch (err: any) {
const replay = await findProcessedKey(data.idempotencyKey);
if (replay) {
return c.json({ ...JSON.parse(replay.result), replayed: true, undone: !!replay.undoneAt });
}
throw err;
}
if (emailTicketId) {
emailService.sendBookingConfirmation(emailTicketId).catch((err) => {
console.error('[Email] Failed to send door walk-in confirmation:', err);
});
}
return c.json(responseBody, 201);
}
);
// ==================== POST /:eventId/door-checkin/undo ====================
// Reverses exactly what the keyed action did — nothing more. This is what makes
// the door screen safe to run without a single confirm dialog.
doorRouter.post(
'/:eventId/door-checkin/undo',
requireAuth([...STAFF_ROLES]),
zValidator('json', z.object({ idempotencyKey: z.string().min(8).max(128) })),
async (c) => {
const { idempotencyKey } = c.req.valid('json');
const record = await findProcessedKey(idempotencyKey);
if (!record) return c.json({ error: 'Nothing to undo for this action' }, 404);
if (record.undoneAt) return c.json({ ok: true, alreadyUndone: true });
const undo = JSON.parse(record.undoState || 'null') as UndoState | null;
if (!undo) return c.json({ error: 'This action cannot be undone' }, 400);
const now = getNow();
const ops: TxOp[] = [];
if (undo.kind === 'created') {
// Walk-ins created here are cancelled, not deleted: the row stays as an
// audit trail and can be reactivated from the same screen.
ops.push(updateOp(tickets, {
status: 'cancelled',
checkinAt: null,
checkedInByAdminId: null,
}, eq((tickets as any).id, undo.ticketId)));
ops.push(updateOp(payments, {
status: 'cancelled',
paidAt: null,
updatedAt: now,
}, eq((payments as any).id, undo.paymentId)));
} else {
ops.push(updateOp(tickets, {
status: undo.prevTicket.status,
checkinAt: undo.prevTicket.checkinAt ? toDbDate(undo.prevTicket.checkinAt) : null,
checkedInByAdminId: undo.prevTicket.checkedInByAdminId,
paymentStatus: undo.prevTicket.paymentStatus,
isGuest: toDbBool(undo.prevTicket.isGuest),
}, eq((tickets as any).id, undo.ticketId)));
if (undo.createdPaymentId) {
ops.push(deleteOp(payments, eq((payments as any).id, undo.createdPaymentId)));
} else if (undo.prevPayment) {
const prev = undo.prevPayment;
ops.push(updateOp(payments, {
provider: prev.provider,
amount: prev.amount,
status: prev.status,
reference: prev.reference,
paidAt: prev.paidAt ? toDbDate(prev.paidAt) : null,
paidByAdminId: prev.paidByAdminId,
source: prev.source,
method: prev.method,
updatedAt: now,
}, eq((payments as any).id, prev.id)));
}
}
ops.push(updateOp(idempotencyKeys, { undoneAt: now }, eq((idempotencyKeys as any).key, idempotencyKey)));
await runOps(ops);
return c.json({ ok: true, ticketId: undo.ticketId, reverted: undo.kind });
}
);
// ==================== GET /:eventId/door-summary ====================
// End-of-night reconciliation: what was taken at the door, by tender, plus the
// pre-sale/door split the event dashboard shows.
doorRouter.get('/:eventId/door-summary', requireAuth([...REVENUE_ROLES]), async (c) => {
const eventId = c.req.param('eventId');
const event = await loadEvent(eventId);
if (!event) return c.json({ error: 'Event not found' }, 404);
// Door payments settled for this event, with the attendee attached so the
// session feed can show who each line belongs to.
const rows = await dbAll<any>(
(db as any)
.select({
paymentId: (payments as any).id,
ticketId: (tickets as any).id,
method: (payments as any).method,
amount: (payments as any).amount,
paidAt: (payments as any).paidAt,
firstName: (tickets as any).attendeeFirstName,
lastName: (tickets as any).attendeeLastName,
ticketStatus: (tickets as any).status,
})
.from(payments)
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
.where(and(
eq((tickets as any).eventId, eventId),
eq((payments as any).source, 'door'),
eq((payments as any).status, 'paid')
))
);
const byMethod: Record<string, { count: number; total: number }> = {};
for (const m of DOOR_PAYMENT_METHODS) byMethod[m] = { count: 0, total: 0 };
let doorTotal = 0;
for (const r of rows) {
const key = (r.method && byMethod[r.method]) ? r.method : 'cash';
const amount = num(r.amount);
byMethod[key].count += 1;
byMethod[key].total += amount;
doorTotal += amount;
}
// Pre-sale revenue keeps the dashboard's existing definition — settled tickets
// at event price — minus anything that was actually taken at the door.
const doorTicketIds = new Set(rows.map((r: any) => r.ticketId));
const settled = await dbAll<any>(
(db as any)
.select({ id: (tickets as any).id })
.from(tickets)
.where(and(
eq((tickets as any).eventId, eventId),
eq((tickets as any).paymentStatus, 'paid'),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
))
);
const presaleCount = settled.filter((t: any) => !doorTicketIds.has(t.id)).length;
const presaleTotal = presaleCount * event.price;
return c.json({
eventId,
currency: event.currency,
price: event.price,
door: {
count: rows.length,
total: doorTotal,
byMethod,
lines: rows
.map((r: any) => ({
paymentId: r.paymentId,
ticketId: r.ticketId,
name: `${r.firstName} ${r.lastName || ''}`.trim(),
method: r.method || 'cash',
amount: num(r.amount),
paidAt: iso(r.paidAt),
}))
.sort((a: any, b: any) => (b.paidAt || '').localeCompare(a.paidAt || '')),
},
presale: { count: presaleCount, total: presaleTotal },
total: presaleTotal + doorTotal,
});
});
export default doorRouter;
+25 -4
View File
@@ -172,6 +172,13 @@ const updateEventSchema = baseEventSchema.partial().refine(
eventsRouter.get('/', async (c) => { eventsRouter.get('/', async (c) => {
const status = c.req.query('status'); const status = c.req.query('status');
const upcoming = c.req.query('upcoming'); const upcoming = c.req.query('upcoming');
// Pagination is opt-in: callers that pass neither page nor pageSize (public
// pages, admin filter dropdowns) still get the full list.
const pageParam = c.req.query('page');
const pageSizeParam = c.req.query('pageSize');
const paginated = pageParam !== undefined || pageSizeParam !== undefined;
const page = Math.max(parseInt(pageParam || '1', 10) || 1, 1);
const pageSize = Math.min(Math.max(parseInt(pageSizeParam || '25', 10) || 25, 1), 200);
// Only privileged users may see non-public events (drafts, archived, etc.). // Only privileged users may see non-public events (drafts, archived, etc.).
// Anonymous/regular callers are restricted to published events regardless of // Anonymous/regular callers are restricted to published events regardless of
@@ -195,12 +202,24 @@ eventsRouter.get('/', async (c) => {
conditions.push(eq((events as any).status, 'published')); conditions.push(eq((events as any).status, 'published'));
} }
const whereClause = conditions.length === 0
? undefined
: conditions.length === 1 ? conditions[0] : and(...conditions);
let query = (db as any).select().from(events); let query = (db as any).select().from(events);
if (conditions.length > 0) { if (whereClause) query = query.where(whereClause);
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions)); query = query.orderBy(desc((events as any).startDatetime));
let total: number | undefined;
if (paginated) {
let countQuery = (db as any).select({ count: sql`count(*)` }).from(events);
if (whereClause) countQuery = countQuery.where(whereClause);
const totalRow = await dbGet<any>(countQuery);
total = Number(totalRow?.count || 0);
query = query.limit(pageSize).offset((page - 1) * pageSize);
} }
const result = await dbAll<any>(query.orderBy(desc((events as any).startDatetime))); const result = await dbAll<any>(query);
// Single grouped query for seat counts across all events (avoids N+1: previously // Single grouped query for seat counts across all events (avoids N+1: previously
// this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in); // this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in);
@@ -227,7 +246,9 @@ eventsRouter.get('/', async (c) => {
}; };
}); });
return c.json({ events: eventsWithCounts }); return paginated
? c.json({ events: eventsWithCounts, total, page, pageSize })
: c.json({ events: eventsWithCounts });
}); });
// Get single event (public) - resolves by id, canonical slug, or historical alias // Get single event (public) - resolves by id, canonical slug, or historical alias
+1 -1
View File
@@ -667,7 +667,7 @@ paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), a
} }
try { try {
const result = await emailService.sendPaymentReminder(id); const result = await emailService.sendPaymentReminder(payment.id);
if (result.success) { if (result.success) {
const now = getNow(); const now = getNow();
+76 -39
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js'; import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
import { eq, and, or, sql, inArray } from 'drizzle-orm'; import { eq, and, or, sql, inArray } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js'; import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, toDbDate, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, normalizeEmail, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js'; import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js'; import { rateLimitMiddleware } from '../lib/rateLimit.js';
import emailService from '../lib/email.js'; import emailService from '../lib/email.js';
@@ -148,9 +148,12 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
}, 400); }, 400);
} }
// Find or create user // Find or create user. The account row is keyed on the normalized address so
// it stays reachable from Better Auth (which lowercases every lookup) —
// tickets.attendeeEmail below keeps the address exactly as the buyer typed it.
const accountEmail = normalizeEmail(data.email);
let user = await dbGet<any>( let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email)) (db as any).select().from(users).where(eq((users as any).email, accountEmail))
); );
const now = getNow(); const now = getNow();
@@ -163,13 +166,16 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
const userId = generateId(); const userId = generateId();
user = { user = {
id: userId, id: userId,
email: data.email, email: accountEmail,
password: '', // No password for guest bookings password: null, // No password for guest bookings; set on claim (Better Auth credential account)
name: fullName, name: fullName,
phone: data.phone || null, phone: data.phone || null,
role: 'user', role: 'user',
languagePreference: null, languagePreference: null,
rucNumber, rucNumber,
isClaimed: toDbBool(false),
accountStatus: 'unclaimed',
emailVerified: false,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -545,20 +551,24 @@ ticketsRouter.get('/booking/:bookingId/pdf', async (c) => {
); );
const timezone = settings?.timezone || 'America/Asuncion'; const timezone = settings?.timezone || 'America/Asuncion';
const ticketsData = confirmedTickets.map((ticket: any) => ({ const ticketsData = confirmedTickets.map((ticket: any) => {
id: ticket.id, const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en';
qrCode: ticket.qrCode, return {
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(), id: ticket.id,
attendeeEmail: ticket.attendeeEmail, qrCode: ticket.qrCode,
event: { attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
title: event.title, attendeeEmail: ticket.attendeeEmail,
startDatetime: event.startDatetime, event: {
endDatetime: event.endDatetime, title: locale === 'es' && event.titleEs ? event.titleEs : event.title,
location: event.location, startDatetime: event.startDatetime,
locationUrl: event.locationUrl, endDatetime: event.endDatetime,
}, location: event.location,
timezone, locationUrl: event.locationUrl,
})); },
timezone,
locale,
};
});
const pdfBuffer = await generateCombinedTicketsPDF(ticketsData); const pdfBuffer = await generateCombinedTicketsPDF(ticketsData);
@@ -622,19 +632,22 @@ ticketsRouter.get('/:id/pdf', async (c) => {
); );
const timezone = settings?.timezone || 'America/Asuncion'; const timezone = settings?.timezone || 'America/Asuncion';
const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en';
const pdfBuffer = await generateTicketPDF({ const pdfBuffer = await generateTicketPDF({
id: ticket.id, id: ticket.id,
qrCode: ticket.qrCode, qrCode: ticket.qrCode,
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(), attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
event: { event: {
title: event.title, title: locale === 'es' && event.titleEs ? event.titleEs : event.title,
startDatetime: event.startDatetime, startDatetime: event.startDatetime,
endDatetime: event.endDatetime, endDatetime: event.endDatetime,
location: event.location, location: event.location,
locationUrl: event.locationUrl, locationUrl: event.locationUrl,
}, },
timezone, timezone,
locale,
}); });
// Set response headers for PDF download // Set response headers for PDF download
@@ -1161,7 +1174,7 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
// Send confirmation emails asynchronously (don't block the response) // Send confirmation emails asynchronously (don't block the response)
Promise.all([ Promise.all([
emailService.sendBookingConfirmation(id), emailService.sendBookingConfirmation(ticket.id),
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(), payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
]).catch(err => { ]).catch(err => {
console.error('[Email] Failed to send confirmation emails:', err); console.error('[Email] Failed to send confirmation emails:', err);
@@ -1415,9 +1428,10 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
? data.email.trim() ? data.email.trim()
: `door-${generateId()}@doorentry.local`; : `door-${generateId()}@doorentry.local`;
// Find or create user // Find or create user (see the note on `accountEmail` in the booking route)
const accountEmail = normalizeEmail(attendeeEmail);
let user = await dbGet<any>( let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) (db as any).select().from(users).where(eq((users as any).email, accountEmail))
); );
const adminFullName = data.lastName && data.lastName.trim() const adminFullName = data.lastName && data.lastName.trim()
@@ -1428,12 +1442,15 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
const userId = generateId(); const userId = generateId();
user = { user = {
id: userId, id: userId,
email: attendeeEmail, email: accountEmail,
password: '', password: null,
name: adminFullName, name: adminFullName,
phone: data.phone || null, phone: data.phone || null,
role: 'user', role: 'user',
languagePreference: null, languagePreference: null,
isClaimed: toDbBool(false),
accountStatus: 'unclaimed',
emailVerified: false,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -1523,14 +1540,18 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
// Unified admin add-attendee endpoint backing the single Add Ticket modal. // Unified admin add-attendee endpoint backing the single Add Ticket modal.
// type drives payment handling: // type drives payment handling:
// paid — email required; paid cash payment; confirmation email + QR sent // paid — email required; paid cash payment; confirmation email + QR sent
// door — paid in cash at the door; all fields optional; counts toward revenue;
// confirmation email only when an email is provided
// unpaid — QR issued with balance due (collect at door); pending tpago payment; // unpaid — QR issued with balance due (collect at door); pending tpago payment;
// pay-link (Bancard/TPago) email sent when an email is provided // pay-link (Bancard/TPago) email sent when an email is provided
// guest — free comp ticket, not counted in revenue; confirmation email only // guest — free comp ticket, not counted in revenue; confirmation email only
// when an email is provided // when an email is provided
ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
eventId: z.string(), eventId: z.string(),
type: z.enum(['paid', 'unpaid', 'guest']), type: z.enum(['paid', 'door', 'unpaid', 'guest']),
firstName: z.string().min(1), // Door walk-ins can be logged with nothing filled in, so firstName is only
// required for the other types
firstName: z.string().optional().or(z.literal('')),
lastName: z.string().optional().or(z.literal('')), lastName: z.string().optional().or(z.literal('')),
email: z.string().email().optional().or(z.literal('')), email: z.string().email().optional().or(z.literal('')),
phone: z.string().optional().or(z.literal('')), phone: z.string().optional().or(z.literal('')),
@@ -1540,6 +1561,9 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
}).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), { }).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), {
message: 'Email is required for paid tickets', message: 'Email is required for paid tickets',
path: ['email'], path: ['email'],
}).refine((d) => d.type === 'door' || !!(d.firstName && d.firstName.trim()), {
message: 'First name is required',
path: ['firstName'],
})), async (c) => { })), async (c) => {
const data = c.req.valid('json'); const data = c.req.valid('json');
@@ -1559,25 +1583,31 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
? data.email!.trim() ? data.email!.trim()
: `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`; : `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`;
// Nameless door walk-ins still need a display name on the ticket
const firstName = (data.firstName && data.firstName.trim()) || 'Walk-in';
const fullName = data.lastName && data.lastName.trim() const fullName = data.lastName && data.lastName.trim()
? `${data.firstName} ${data.lastName}`.trim() ? `${firstName} ${data.lastName.trim()}`
: data.firstName; : firstName;
// Find or create user // Find or create user (see the note on `accountEmail` in the booking route)
const accountEmail = normalizeEmail(attendeeEmail);
let user = await dbGet<any>( let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) (db as any).select().from(users).where(eq((users as any).email, accountEmail))
); );
if (!user) { if (!user) {
const userId = generateId(); const userId = generateId();
user = { user = {
id: userId, id: userId,
email: attendeeEmail, email: accountEmail,
password: '', password: null,
name: fullName, name: fullName,
phone: data.phone || null, phone: data.phone || null,
role: 'user', role: 'user',
languagePreference: null, languagePreference: null,
isClaimed: toDbBool(false),
accountStatus: 'unclaimed',
emailVerified: false,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -1604,13 +1634,13 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
const ticketId = generateId(); const ticketId = generateId();
const qrCode = generateTicketCode(); const qrCode = generateTicketCode();
const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'paid' ? 'paid' : 'unpaid'; const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'unpaid' ? 'unpaid' : 'paid';
const newTicket = { const newTicket = {
id: ticketId, id: ticketId,
userId: user.id, userId: user.id,
eventId: data.eventId, eventId: data.eventId,
attendeeFirstName: data.firstName, attendeeFirstName: firstName,
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
attendeeEmail: hasEmail ? data.email!.trim() : null, attendeeEmail: hasEmail ? data.email!.trim() : null,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
@@ -1627,7 +1657,7 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
await (db as any).insert(tickets).values(newTicket); await (db as any).insert(tickets).values(newTicket);
// Payment record: paid cash for paid/guest ($0 for guest), pending tpago for unpaid // Payment record: paid cash for paid/door/guest ($0 for guest), pending tpago for unpaid
const paymentId = generateId(); const paymentId = generateId();
const newPayment = data.type === 'unpaid' const newPayment = data.type === 'unpaid'
? { ? {
@@ -1650,7 +1680,11 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
amount: data.type === 'guest' ? 0 : event.price, amount: data.type === 'guest' ? 0 : event.price,
currency: event.currency, currency: event.currency,
status: 'paid', status: 'paid',
reference: data.type === 'guest' ? 'Guest invite' : 'Manual ticket', reference: data.type === 'guest'
? 'Guest invite'
: data.type === 'door'
? 'Paid at door'
: 'Manual ticket',
paidAt: now, paidAt: now,
paidByAdminId: adminUser?.id || null, paidByAdminId: adminUser?.id || null,
createdAt: now, createdAt: now,
@@ -1659,8 +1693,8 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
await (db as any).insert(payments).values(newPayment); await (db as any).insert(payments).values(newPayment);
// Emails (asynchronous): paid always confirms; guest confirms when an email // Emails (asynchronous): paid always confirms; door/guest confirm only when an
// exists; unpaid sends the TPago (Bancard) pay-link instructions instead // email exists; unpaid sends the TPago (Bancard) pay-link instructions instead
if (data.type === 'unpaid') { if (data.type === 'unpaid') {
if (hasEmail) { if (hasEmail) {
emailService.sendPaymentInstructions(ticketId).then(result => { emailService.sendPaymentInstructions(ticketId).then(result => {
@@ -1683,6 +1717,9 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
const messages: Record<string, string> = { const messages: Record<string, string> = {
paid: 'Ticket created — confirmation email sent', paid: 'Ticket created — confirmation email sent',
door: hasEmail
? 'Ticket created — paid at the door, confirmation email sent'
: 'Ticket created — paid at the door',
unpaid: hasEmail unpaid: hasEmail
? 'Unpaid ticket created — payment link sent' ? 'Unpaid ticket created — payment link sent'
: 'Unpaid ticket created — collect payment at the door', : 'Unpaid ticket created — collect payment at the door',
+19 -1
View File
@@ -4,6 +4,7 @@ import { z } from 'zod';
import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js'; import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js';
import { eq, desc, sql, and, gte, lte } from 'drizzle-orm'; import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { authSessions } from '../db/auth-schema.js';
import { getNow, toDbDate } from '../lib/utils.js'; import { getNow, toDbDate } from '../lib/utils.js';
interface UserContext { interface UserContext {
@@ -175,11 +176,28 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
return c.json({ error: 'User not found' }, 404); return c.json({ error: 'User not found' }, 404);
} }
// Keep the Better Auth admin `banned` flag in sync with accountStatus so
// sign-in is blocked at the auth layer too, and kill live sessions on
// suspension so it takes effect immediately (sessions are DB-validated on
// every request by both the backend and the photo API).
const statusMirror: Record<string, any> = {};
if (data.accountStatus === 'suspended') {
statusMirror.banned = true;
statusMirror.banReason = 'Suspended by admin';
} else if (data.accountStatus) {
statusMirror.banned = false;
statusMirror.banReason = null;
}
await (db as any) await (db as any)
.update(users) .update(users)
.set({ ...data, updatedAt: getNow() }) .set({ ...data, ...statusMirror, updatedAt: getNow() })
.where(eq((users as any).id, id)); .where(eq((users as any).id, id));
if (data.accountStatus === 'suspended') {
await (db as any).delete(authSessions).where(eq((authSessions as any).userId, id));
}
const updated = await dbGet( const updated = await dbGet(
(db as any) (db as any)
.select({ .select({
+1 -1
View File
@@ -8,7 +8,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"declaration": true, "declaration": false,
"resolveJsonModule": true "resolveJsonModule": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
+109
View File
@@ -0,0 +1,109 @@
# ============================================================
# Spanglish Community - Photo Gallery API
# photos.spanglishcommunity.com
# ============================================================
server {
listen 80;
server_name photos.spanglishcommunity.com;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://photos.spanglishcommunity.com$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name photos.spanglishcommunity.com;
# Photos can be larger than typical JSON payloads
client_max_body_size 25m;
# SSL
ssl_certificate /etc/letsencrypt/live/photos.spanglishcommunity.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/photos.spanglishcommunity.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Logs
access_log /var/log/nginx/spanglish_photo_access.log;
error_log /var/log/nginx/spanglish_photo_error.log;
# CORS Configuration
set $cors_origin "";
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
set $cors_origin $http_origin;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
# Ensure 413 returns JSON + CORS
error_page 413 = @payload_too_large;
location @payload_too_large {
default_type application/json;
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
}
# Ensure 429 (rate limited) returns JSON + CORS
error_page 429 = @rate_limited;
location @rate_limited {
default_type application/json;
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
return 429 '{"error":"Too many requests. Please slow down."}';
}
location / {
limit_req zone=spanglish_photo_limit burst=40 nodelay;
# Preflight
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://spanglish_photo_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
proxy_hide_header 'Access-Control-Expose-Headers';
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
# Buffer large image uploads to disk rather than memory
proxy_request_buffering on;
proxy_max_temp_file_size 1024m;
}
}
+146
View File
@@ -0,0 +1,146 @@
# ============================================================
# Spanglish Community - Backend API
# api.spanglishcommunity.com
# ============================================================
server {
listen 80;
server_name api.spanglishcommunity.com;
# ACME
location /.well-known/acme-challenge/ {
root /var/www/html;
}
# Force HTTPS
location / {
return 301 https://api.spanglishcommunity.com$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name api.spanglishcommunity.com;
# Upload size limit (avoid nginx 413 on media uploads)
# Keep this >= backend MEDIA_MAX_UPLOAD_MB (default 10MB).
client_max_body_size 20m;
# SSL
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security (API)
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
# Logs
access_log /var/log/nginx/spanglish_api_access.log;
error_log /var/log/nginx/spanglish_api_error.log;
# CORS Configuration (set once, used everywhere)
set $cors_origin "";
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
set $cors_origin $http_origin;
}
# Add CORS headers to all responses (including nginx-generated errors)
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
# Ensure 413 returns JSON + CORS (browser otherwise reports "CORS blocked")
error_page 413 = @payload_too_large;
location @payload_too_large {
default_type application/json;
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
}
# Photo gallery service (photo-api, port 3003). ^~ wins over the "/"
# prefix below, so /api/photos/* reaches the Go photo-api instead of the
# Node backend (which has no photo routes and would 404). The admin UI
# calls this cross-origin via NEXT_PUBLIC_API_URL, so preflight + CORS
# must be handled here just like location / below.
location ^~ /api/photos/ {
limit_req zone=spanglish_api_limit burst=50 nodelay;
# Handle preflight OPTIONS requests (add_header inside if{} does NOT
# inherit server-level headers, so repeat all CORS headers here).
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://spanglish_photo_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Strip CORS headers from the service (nginx handles CORS here)
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
proxy_hide_header 'Access-Control-Expose-Headers';
# Photo batches can be large; allow bigger bodies + unbuffered upload.
client_max_body_size 100m;
proxy_request_buffering off;
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
}
location / {
limit_req zone=spanglish_api_limit burst=50 nodelay;
# Handle preflight OPTIONS requests
# NOTE: add_header inside if{} does NOT inherit server-level headers,
# so we must repeat all CORS headers here.
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://spanglish_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Strip CORS headers from backend (nginx handles CORS at server level)
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
proxy_hide_header 'Access-Control-Expose-Headers';
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
}
}
+115
View File
@@ -0,0 +1,115 @@
# ============================================================
# Spanglish Community - Frontend
# spanglishcommunity.com / www
# ============================================================
server {
listen 80;
server_name spanglishcommunity.com www.spanglishcommunity.com;
# ACME
location /.well-known/acme-challenge/ {
root /var/www/html;
}
# Force HTTPS
location / {
return 301 https://spanglishcommunity.com$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name spanglishcommunity.com www.spanglishcommunity.com;
# Upload size limit (covers same-origin /api uploads via this vhost)
client_max_body_size 20m;
# SSL
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Logs
access_log /var/log/nginx/spanglish_frontend_access.log;
error_log /var/log/nginx/spanglish_frontend_error.log;
# Proxy /api/photos to the photo-api (Go service, port 3003). ^~ wins over
# the /api prefix below so same-origin image/gallery requests reach the
# photo-api instead of the Node backend (which has no photo routes -> 404).
location ^~ /api/photos/ {
proxy_pass http://spanglish_photo_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Photo uploads/batches can be large; allow bigger bodies.
client_max_body_size 100m;
proxy_request_buffering off;
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
# Let the photo-api set Cache-Control per image visibility (public vs.
# token-gated) rather than forcing a cache policy here.
}
# Proxy /api to backend
location /api {
proxy_pass http://spanglish_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
}
# Proxy /uploads to backend
location /uploads {
proxy_pass http://spanglish_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache static files
proxy_cache_valid 200 1d;
expires 1d;
add_header Cache-Control "public, immutable";
}
# Frontend App
location / {
proxy_pass http://spanglish_frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket / HMR
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 60s;
proxy_connect_timeout 60s;
}
}
+108
View File
@@ -0,0 +1,108 @@
# ============================================================
# Spanglish Community - Photo Gallery API
# photos.spanglishcommunity.com
# ============================================================
server {
listen 80;
server_name photos.spanglishcommunity.com;
location /.well-known/acme-challenge/ {
}
location / {
return 301 https://photos.spanglishcommunity.com$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name photos.spanglishcommunity.com;
# Photos can be larger than typical JSON payloads
client_max_body_size 25m;
# SSL
ssl_certificate /etc/letsencrypt/live/photos.spanglishcommunity.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/photos.spanglishcommunity.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Logs
access_log /var/log/nginx/spanglish_photo_access.log;
error_log /var/log/nginx/spanglish_photo_error.log;
# CORS Configuration
set $cors_origin "";
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
set $cors_origin $http_origin;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
# Ensure 413 returns JSON + CORS
error_page 413 = @payload_too_large;
location @payload_too_large {
default_type application/json;
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
}
# Ensure 429 (rate limited) returns JSON + CORS
error_page 429 = @rate_limited;
location @rate_limited {
default_type application/json;
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
return 429 '{"error":"Too many requests. Please slow down."}';
}
location / {
limit_req zone=spanglish_photo_limit burst=40 nodelay;
# Preflight
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://spanglish_photo_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
proxy_hide_header 'Access-Control-Expose-Headers';
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
# Buffer large image uploads to disk rather than memory
proxy_request_buffering on;
proxy_max_temp_file_size 1024m;
}
}
@@ -0,0 +1,12 @@
upstream spanglish_frontend {
server 127.0.0.1:3019;
}
upstream spanglish_backend {
server 127.0.0.1:3018;
}
upstream spanglish_photo_api {
server 127.0.0.1:3003;
}
limit_req_zone $binary_remote_addr zone=spanglish_photo_limit:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=spanglish_api_limit:10m rate=30r/s;
+1
View File
@@ -14,6 +14,7 @@
"@tiptap/pm": "^3.18.0", "@tiptap/pm": "^3.18.0",
"@tiptap/react": "^3.18.0", "@tiptap/react": "^3.18.0",
"@tiptap/starter-kit": "^3.18.0", "@tiptap/starter-kit": "^3.18.0",
"better-auth": "1.6.25",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"next": "^14.2.4", "next": "^14.2.4",
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, Suspense } from 'react'; import { useEffect, useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
@@ -9,29 +9,48 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import { authApi } from '@/lib/api'; import { authApi } from '@/lib/api';
import { authClient } from '@/lib/auth-client';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
/**
* Progressive-account claim. The claim email contains a magic link that signs
* the user in (via /auth/magic-link) and redirects here; this page then asks
* for a password and completes the claim against /api/auth-ext/claim-account.
*/
function ClaimAccountContent() { function ClaimAccountContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const { locale: language } = useLanguage(); const { locale: language } = useLanguage();
const { setAuthData } = useAuth(); const { refreshUser } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true);
const [hasSession, setHasSession] = useState(false);
const [alreadyClaimed, setAlreadyClaimed] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
password: '', password: '',
confirmPassword: '', confirmPassword: '',
}); });
const token = searchParams.get('token'); useEffect(() => {
let cancelled = false;
authClient
.getSession()
.then(({ data }) => {
if (cancelled) return;
const user: any = data?.user;
setHasSession(!!user);
setAlreadyClaimed(!!user && user.isClaimed && user.accountStatus === 'active');
})
.finally(() => {
if (!cancelled) setChecking(false);
});
return () => {
cancelled = true;
};
}, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!token) {
toast.error(language === 'es' ? 'Token no válido' : 'Invalid token');
return;
}
if (formData.password !== formData.confirmPassword) { if (formData.password !== formData.confirmPassword) {
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match'); toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return; return;
@@ -49,8 +68,8 @@ function ClaimAccountContent() {
setLoading(true); setLoading(true);
try { try {
const result = await authApi.confirmClaimAccount(token, { password: formData.password }); await authApi.confirmClaimAccount(formData.password);
setAuthData({ user: result.user, token: result.token }); await refreshUser();
toast.success(language === 'es' ? '¡Cuenta activada!' : 'Account activated!'); toast.success(language === 'es' ? '¡Cuenta activada!' : 'Account activated!');
router.push('/dashboard'); router.push('/dashboard');
} catch (error: any) { } catch (error: any) {
@@ -60,7 +79,21 @@ function ClaimAccountContent() {
} }
}; };
if (!token) { if (checking) {
return (
<div className="section-padding min-h-[70vh] flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
</div>
);
}
if (alreadyClaimed) {
// Signed-in and already active: nothing to claim
router.push('/dashboard');
return null;
}
if (!hasSession) {
return ( return (
<div className="section-padding min-h-[70vh] flex items-center"> <div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page"> <div className="container-page">
@@ -76,8 +109,8 @@ function ClaimAccountContent() {
</h2> </h2>
<p className="text-gray-600 mb-6"> <p className="text-gray-600 mb-6">
{language === 'es' {language === 'es'
? 'Este enlace de activación no es válido o ha expirado.' ? 'Este enlace de activación no es válido o ha expirado. Solicita uno nuevo con "Enlace por Email" en la página de inicio de sesión.'
: 'This activation link is invalid or has expired.'} : 'This activation link is invalid or has expired. Request a new one using "Email Link" on the login page.'}
</p> </p>
<Link href="/login"> <Link href="/login">
<Button> <Button>
@@ -6,6 +6,8 @@ import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { safeInternalPath } from '@/lib/safeRedirect';
import { redirectAfterAuth } from '@/lib/authRedirect';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
function MagicLinkContent() { function MagicLinkContent() {
@@ -18,6 +20,7 @@ function MagicLinkContent() {
const verificationAttempted = useRef(false); const verificationAttempted = useRef(false);
const token = searchParams.get('token'); const token = searchParams.get('token');
const callbackURL = safeInternalPath(searchParams.get('callbackURL'), '/dashboard');
useEffect(() => { useEffect(() => {
// Prevent duplicate verification attempts (React StrictMode double-invokes effects) // Prevent duplicate verification attempts (React StrictMode double-invokes effects)
@@ -34,11 +37,17 @@ function MagicLinkContent() {
const verifyToken = async () => { const verifyToken = async () => {
try { try {
await loginWithMagicLink(token!); const user = await loginWithMagicLink(token!);
setStatus('success'); setStatus('success');
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome!'); toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome!');
// Unclaimed accounts must finish the claim (set a password) before the
// rest of the API will accept their session.
const destination =
user && (user.isClaimed === false || user.accountStatus === 'unclaimed')
? '/auth/claim-account'
: callbackURL;
setTimeout(() => { setTimeout(() => {
router.push('/dashboard'); redirectAfterAuth(destination);
}, 1500); }, 1500);
} catch (err: any) { } catch (err: any) {
setStatus('error'); setStatus('error');
@@ -25,7 +25,7 @@ interface AccountTabProps {
*/ */
export default function AccountTab({ onUpdate }: AccountTabProps) { export default function AccountTab({ onUpdate }: AccountTabProps) {
const { locale } = useLanguage(); const { locale } = useLanguage();
const { user, updateUser, logout } = useAuth(); const { user, updateUser } = useAuth();
const [profile, setProfile] = useState<UserProfile | null>(null); const [profile, setProfile] = useState<UserProfile | null>(null);
const [sessions, setSessions] = useState<UserSession[]>([]); const [sessions, setSessions] = useState<UserSession[]>([]);
@@ -187,15 +187,17 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
if ( if (
!confirm( !confirm(
locale === 'es' locale === 'es'
? '¿Cerrar todas las sesiones? Serás desconectado.' ? '¿Cerrar todas las otras sesiones? Esta sesión permanecerá activa.'
: 'Log out of all sessions? You will be logged out.' : 'Log out of all other sessions? This session stays signed in.'
) )
) )
return; return;
try { try {
await dashboardApi.revokeAllSessions(); await dashboardApi.revokeAllSessions();
toast.success(locale === 'es' ? 'Todas las sesiones cerradas' : 'All sessions revoked'); toast.success(
logout(); locale === 'es' ? 'Todas las otras sesiones cerradas' : 'All other sessions revoked'
);
loadData();
} catch (error) { } catch (error) {
toast.error(locale === 'es' ? 'Error' : 'Failed'); toast.error(locale === 'es' ? 'Error' : 'Failed');
} }
@@ -477,7 +479,7 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
</p> </p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{sessions.map((session, index) => ( {sessions.map((session) => (
<div <div
key={session.id} key={session.id}
className="flex items-center justify-between rounded-card bg-secondary-gray p-3" className="flex items-center justify-between rounded-card bg-secondary-gray p-3"
@@ -494,7 +496,7 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
{session.ipAddress && `${session.ipAddress}`} {session.ipAddress && `${session.ipAddress}`}
</p> </p>
</div> </div>
{index === 0 ? ( {session.current ? (
<span className="ml-3 whitespace-nowrap text-xs font-medium text-green-600"> <span className="ml-3 whitespace-nowrap text-xs font-medium text-green-600">
{locale === 'es' ? 'Esta sesión' : 'This session'} {locale === 'es' ? 'Esta sesión' : 'This session'}
</span> </span>
+5 -3
View File
@@ -23,7 +23,7 @@ type Tab = 'overview' | 'tickets' | 'payments' | 'account';
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
const { locale } = useLanguage(); const { locale } = useLanguage();
const { user, isLoading: authLoading, token } = useAuth(); const { user, isLoading: authLoading } = useAuth();
const [activeTab, setActiveTab] = useState<Tab>('overview'); const [activeTab, setActiveTab] = useState<Tab>('overview');
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null); const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
@@ -36,11 +36,13 @@ export default function DashboardPage() {
router.push('/login'); router.push('/login');
return; return;
} }
if (user && token) { // Auth rides on the httpOnly session cookie; once the user has resolved
// the API calls are authenticated automatically.
if (user) {
loadDashboardData(); loadDashboardData();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, authLoading, token]); }, [user, authLoading]);
const loadDashboardData = async () => { const loadDashboardData = async () => {
setLoading(true); setLoading(true);
@@ -0,0 +1,6 @@
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
// Shown while the server component fetches the event gallery.
export default function Loading() {
return <GallerySkeleton />;
}
+84 -7
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, Suspense } from 'react'; import { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
@@ -11,14 +11,20 @@ import Input from '@/components/ui/Input';
import GoogleSignInButton from '@/components/GoogleSignInButton'; import GoogleSignInButton from '@/components/GoogleSignInButton';
import { authApi } from '@/lib/api'; import { authApi } from '@/lib/api';
import { safeInternalPath } from '@/lib/safeRedirect'; import { safeInternalPath } from '@/lib/safeRedirect';
import {
clearRedirectAttempt,
didRedirectBounce,
redirectAfterAuth,
} from '@/lib/authRedirect';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
function LoginContent() { function LoginContent() {
const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { t, locale: language } = useLanguage(); const { t, locale: language } = useLanguage();
const { login } = useAuth(); const { login, user, isLoading: authLoading } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [redirecting, setRedirecting] = useState(false);
const [bounced, setBounced] = useState(false);
const [loginMode, setLoginMode] = useState<'password' | 'magic-link'>('password'); const [loginMode, setLoginMode] = useState<'password' | 'magic-link'>('password');
const [magicLinkSent, setMagicLinkSent] = useState(false); const [magicLinkSent, setMagicLinkSent] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -29,6 +35,27 @@ function LoginContent() {
// Check for redirect after login (only same-origin relative paths are honoured) // Check for redirect after login (only same-origin relative paths are honoured)
const redirectTo = safeInternalPath(searchParams.get('redirect'), '/dashboard'); const redirectTo = safeInternalPath(searchParams.get('redirect'), '/dashboard');
// Send an already-signed-in visitor on to their destination — and detect the case
// where that destination bounced them back here, which otherwise looks like the
// login page silently ignoring a successful sign-in.
useEffect(() => {
if (authLoading || redirecting) return;
if (!user) {
// Signed out on the login page is a clean slate.
clearRedirectAttempt();
return;
}
if (didRedirectBounce(redirectTo)) {
setBounced(true);
return;
}
setRedirecting(true);
redirectAfterAuth(redirectTo);
}, [authLoading, redirecting, user, redirectTo]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
@@ -36,10 +63,12 @@ function LoginContent() {
try { try {
await login(formData.email, formData.password); await login(formData.email, formData.password);
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome back!'); toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome back!');
router.push(redirectTo); // Deliberately leaves `loading` set: the button must stay disabled until the
// browser replaces this page.
setRedirecting(true);
redirectAfterAuth(redirectTo);
} catch (error: any) { } catch (error: any) {
toast.error(error.message || t('auth.errors.invalidCredentials')); toast.error(error.message || t('auth.errors.invalidCredentials'));
} finally {
setLoading(false); setLoading(false);
} }
}; };
@@ -67,6 +96,38 @@ function LoginContent() {
} }
}; };
// The destination sent us back here even though the session is valid. Say so, rather
// than re-showing a form that appears to do nothing. The retry link is a plain <a> so
// it is a full page load, like every other navigation out of this page.
if (bounced) {
return (
<div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page">
<div className="max-w-md mx-auto">
<Card className="p-8 text-center">
<h1 className="text-2xl font-bold">{t('auth.login.redirectBlocked')}</h1>
{user && <p className="mt-2 text-sm text-gray-600">{user.email}</p>}
<p className="mt-4 text-sm text-gray-600">
<code className="px-1.5 py-0.5 bg-gray-100 rounded">{redirectTo}</code>
</p>
<a href={redirectTo} className="mt-6 block">
<Button className="w-full" size="lg">
{t('auth.login.redirectRetry')}
</Button>
</a>
<Link
href="/"
className="mt-3 inline-block text-sm text-secondary-blue hover:underline"
>
{t('nav.home')}
</Link>
</Card>
</div>
</div>
</div>
);
}
return ( return (
<div className="section-padding min-h-[70vh] flex items-center"> <div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page"> <div className="container-page">
@@ -151,9 +212,25 @@ function LoginContent() {
</Link> </Link>
</div> </div>
<Button type="submit" className="w-full" size="lg" isLoading={loading}> <Button
type="submit"
className="w-full"
size="lg"
isLoading={loading || redirecting}
loadingText={redirecting ? t('auth.login.redirecting') : t('common.loading')}
>
{t('auth.login.submit')} {t('auth.login.submit')}
</Button> </Button>
{redirecting && (
<p
className="text-center text-sm text-gray-600"
role="status"
aria-live="polite"
>
{t('auth.login.redirecting')}
</p>
)}
</form> </form>
) : magicLinkSent ? ( ) : magicLinkSent ? (
<div className="text-center py-8"> <div className="text-center py-8">
@@ -2,17 +2,26 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams, useRouter, usePathname } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { photosApi, PhotoGallery, Photo } from '@/lib/api'; import { photosApi, PhotoGallery, Photo } from '@/lib/api';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { ImageGridSkeleton } from '@/components/ui/Skeleton'; import GallerySkeleton from '@/components/gallery/GallerySkeleton';
import PhotoTile from '@/components/gallery/PhotoTile';
import {
GalleryContainer,
GalleryHeroFrame,
MasonryGrid,
} from '@/components/gallery/GalleryLayout';
import { useDownloads } from '@/components/gallery/useDownloads';
import SaveSheet, { SavePhoto, useMobileSave } from '@/components/gallery/SaveSheet';
import Lightbox from '@/components/Lightbox'; import Lightbox from '@/components/Lightbox';
import LoginModal from '@/components/LoginModal';
import { import {
ArrowDownTrayIcon,
CalendarIcon, CalendarIcon,
CameraIcon, CameraIcon,
LinkIcon,
LockClosedIcon, LockClosedIcon,
TicketIcon, TicketIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
@@ -25,15 +34,13 @@ interface GalleryClientProps {
initial: { gallery: PhotoGallery; photos: Photo[] } | null; initial: { gallery: PhotoGallery; photos: Photo[] } | null;
} }
type DeniedState = 'login' | 'ticket' | 'notfound' | null; type DeniedState = 'login' | 'ticket' | 'private' | 'link' | 'notfound' | null;
export default function GalleryClient({ slug, eventSlug, initial }: GalleryClientProps) { export default function GalleryClient({ slug, eventSlug, initial }: GalleryClientProps) {
const { locale } = useLanguage(); const { locale } = useLanguage();
const es = locale === 'es'; const es = locale === 'es';
const { user, isLoading: authLoading } = useAuth(); const { user, isLoading: authLoading } = useAuth();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const shareToken = searchParams.get('token') || undefined; const shareToken = searchParams.get('token') || undefined;
const [gallery, setGallery] = useState<PhotoGallery | null>(initial?.gallery ?? null); const [gallery, setGallery] = useState<PhotoGallery | null>(initial?.gallery ?? null);
@@ -41,13 +48,43 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
const [loading, setLoading] = useState(!initial); const [loading, setLoading] = useState(!initial);
const [denied, setDenied] = useState<DeniedState>(null); const [denied, setDenied] = useState<DeniedState>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [loginOpen, setLoginOpen] = useState(false);
const downloads = useDownloads(es);
const downloadLabels = {
download: es ? 'Descargar' : 'Download',
downloading: es ? 'Descargando…' : 'Downloading…',
};
// On a phone a download cannot reach the photo library, so the button
// opens a sheet offering the shareable preview instead (SaveSheet).
// Desktop keeps downloading the original straight away.
const mobileSave = useMobileSave();
const [savePhoto, setSavePhoto] = useState<SavePhoto | null>(null);
const downloadPhoto = (photo: Photo) => {
if (mobileSave) {
setSavePhoto({
id: photo.id,
previewUrl: photo.urls.download || photo.urls.preview || photo.urls.original,
originalUrl: photo.urls.downloadOriginal || photo.urls.original,
previewSize: photo.previewSizeBytes,
originalSize: photo.sizeBytes,
});
return;
}
downloads.start({
id: photo.id,
url: photo.urls.original,
filename: photo.originalFilename,
});
};
// Server-rendered public galleries need no client fetch. Everything else // Server-rendered public galleries need no client fetch. Everything else
// (link/ticket/private) is fetched here with the share token and/or the // (link/ticket/private) is fetched here with the share token and/or the
// viewer's own auth token attached. // viewer's own auth token attached.
useEffect(() => { useEffect(() => {
if (initial) return; if (initial) return;
if (authLoading) return; // wait so the Bearer token is available if (authLoading) return; // wait until the session state has resolved
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
const fetcher = eventSlug const fetcher = eventSlug
@@ -62,9 +99,13 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
}) })
.catch((err: Error) => { .catch((err: Error) => {
if (cancelled) return; if (cancelled) return;
// The photo-api returns a distinct message per visibility mode so
// each gets its own gate page (see accessDenial in access.go).
const msg = err.message || ''; const msg = err.message || '';
if (msg.includes('Authentication required')) setDenied('login'); if (msg.includes('Authentication required')) setDenied('login');
else if (msg.includes('attendees')) setDenied('ticket'); else if (msg.includes('attendees')) setDenied('ticket');
else if (msg.includes('private')) setDenied('private');
else if (msg.includes('share link')) setDenied('link');
else setDenied('notfound'); else setDenied('notfound');
}) })
.finally(() => !cancelled && setLoading(false)); .finally(() => !cancelled && setLoading(false));
@@ -73,54 +114,123 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
}; };
}, [slug, eventSlug, shareToken, initial, authLoading, user?.id]); }, [slug, eventSlug, shareToken, initial, authLoading, user?.id]);
// Mirrors the hero + masonry layout below, so the real page drops straight
// into the placeholder's geometry instead of replacing it.
if (loading || (authLoading && !initial)) { if (loading || (authLoading && !initial)) {
return ( return <GallerySkeleton count={gallery?.photoCount || undefined} />;
<div className="section-padding">
<div className="container-page">
<ImageGridSkeleton />
</div>
</div>
);
} }
// Gate pages for restricted galleries. After a successful login in the
// pop-up, AuthContext's user changes, which re-runs the fetch effect —
// the gate resolves by itself when access is granted.
const loginModal = (
<LoginModal
open={loginOpen}
onClose={() => setLoginOpen(false)}
message={
es
? 'Inicia sesión para ver esta galería.'
: 'Log in to view this gallery.'
}
/>
);
if (denied === 'login') { if (denied === 'login') {
const redirect = encodeURIComponent(`${pathname}${shareToken ? `?token=${shareToken}` : ''}`);
return ( return (
<GateMessage <>
icon={LockClosedIcon} <GateMessage
title={es ? 'Inicia sesión para ver esta galería' : 'Log in to view this gallery'} icon={TicketIcon}
body={ title={es ? 'Solo para asistentes' : 'Attendees only'}
es body={
? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.' es
: 'This gallery is for event attendees. Log in with the account you used to book.' ? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.'
} : 'This gallery is for event attendees. Log in with the account you used to book.'
action={ }
<Button onClick={() => router.push(`/login?redirect=${redirect}`)}> action={
{es ? 'Iniciar sesión' : 'Log in'} <Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
</Button> }
} />
/> {loginModal}
</>
); );
} }
if (denied === 'ticket') { if (denied === 'ticket') {
return ( return (
<GateMessage <>
icon={TicketIcon} <GateMessage
title={es ? 'Solo para asistentes' : 'Attendees only'} icon={TicketIcon}
body={ title={es ? 'Solo para asistentes' : 'Attendees only'}
es body={
? 'Esta galería es para quienes asistieron al evento con una entrada confirmada.' es
: 'This gallery is only available to people who attended the event with a confirmed ticket.' ? 'Esta galería es para quienes asistieron al evento con una entrada confirmada. ¿Reservaste con otra cuenta?'
} : 'This gallery is only available to people who attended the event with a confirmed ticket. Booked with a different account?'
action={ }
eventSlug || gallery?.event ? ( action={
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}> <div className="flex flex-wrap justify-center gap-3">
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button> {(eventSlug || gallery?.event) && (
</Link> <Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
) : undefined <Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
} </Link>
/> )}
<Button onClick={() => setLoginOpen(true)}>
{es ? 'Cambiar de cuenta' : 'Switch account'}
</Button>
</div>
}
/>
{loginModal}
</>
);
}
if (denied === 'private') {
return (
<>
<GateMessage
icon={LockClosedIcon}
title={es ? 'Esta galería es privada' : 'This gallery is private'}
body={
es
? 'Solo los organizadores pueden verla. Si eres parte del equipo, inicia sesión.'
: 'Only the organizers can see it. If thats you, log in.'
}
action={
<div className="flex flex-wrap justify-center gap-3">
<Link href="/photos">
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
</Link>
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
</div>
}
/>
{loginModal}
</>
);
}
if (denied === 'link') {
return (
<>
<GateMessage
icon={LinkIcon}
title={es ? 'Esta galería necesita su enlace' : 'This gallery needs its share link'}
body={
es
? 'Solo se puede abrir con el enlace que compartieron los organizadores. Pídeles el enlace completo, o inicia sesión si eres parte del equipo.'
: 'It can only be opened with the link the organizers shared. Ask them for the full link, or log in if youre part of the team.'
}
action={
<div className="flex flex-wrap justify-center gap-3">
<Link href="/photos">
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
</Link>
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
</div>
}
/>
{loginModal}
</>
); );
} }
@@ -144,11 +254,14 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
} }
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb); const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
// previewUrl is the download endpoint's preview: rendering and saving from
// the same URL is what lets "Save photo" be answered by the HTTP cache.
const lightboxItems = readyPhotos.map((p) => ({ const lightboxItems = readyPhotos.map((p) => ({
id: p.id, id: p.id,
previewUrl: p.urls.preview || p.urls.original, previewUrl: p.urls.download || p.urls.preview || p.urls.original,
downloadUrl: p.urls.original, downloadUrl: p.urls.downloadOriginal || p.urls.original,
filename: p.originalFilename, filename: p.originalFilename,
thumbUrl: p.urls.thumb,
})); }));
const title = es && gallery.titleEs ? gallery.titleEs : gallery.title; const title = es && gallery.titleEs ? gallery.titleEs : gallery.title;
@@ -171,51 +284,56 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
// first photo. Falls back to a navy gradient with no image. // first photo. Falls back to a navy gradient with no image.
const coverPhoto = const coverPhoto =
readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null; readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null;
const heroUrl = coverPhoto ? coverPhoto.urls.preview || coverPhoto.urls.thumb : null; // Same preview URL the lightbox uses, so the cover photo's bytes are
// fetched once for both.
const heroUrl = coverPhoto
? coverPhoto.urls.download || coverPhoto.urls.preview || coverPhoto.urls.thumb
: null;
return ( return (
<div> <div>
{/* Hero */} {/* Hero — same frame the skeleton renders (GalleryLayout). */}
<div className="relative bg-brand-navy overflow-hidden"> <GalleryHeroFrame
{heroUrl && ( backdrop={
<> heroUrl ? (
{/* eslint-disable-next-line @next/next/no-img-element */} <>
<img {/* eslint-disable-next-line @next/next/no-img-element */}
src={heroUrl} <img
alt="" src={heroUrl}
className="absolute inset-0 w-full h-full object-cover opacity-60" alt=""
/> className="absolute inset-0 w-full h-full object-cover opacity-60"
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" /> />
</> <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" />
</>
) : null
}
>
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm">
{title}
</h1>
{description && (
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p>
)} )}
<div className="relative container-page px-4 pt-20 pb-8 md:pt-32 md:pb-12"> <div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm"> <span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
{title} <CameraIcon className="w-4 h-4" />
</h1> {readyPhotos.length} {es ? 'fotos' : 'photos'}
{description && ( </span>
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p> {gallery.event && (
<Link
href={`/events/${gallery.event.slug}`}
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
>
<CalendarIcon className="w-4 h-4" />
{eventTitle}
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
</Link>
)} )}
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
<CameraIcon className="w-4 h-4" />
{readyPhotos.length} {es ? 'fotos' : 'photos'}
</span>
{gallery.event && (
<Link
href={`/events/${gallery.event.slug}`}
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
>
<CalendarIcon className="w-4 h-4" />
{eventTitle}
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
</Link>
)}
</div>
</div> </div>
</div> </GalleryHeroFrame>
{/* Masonry grid */} {/* Masonry grid */}
<div className="container-page px-2 sm:px-4 py-4 md:py-8"> <GalleryContainer>
{readyPhotos.length === 0 ? ( {readyPhotos.length === 0 ? (
<div className="text-center py-16"> <div className="text-center py-16">
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" /> <CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
@@ -224,46 +342,19 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
</p> </p>
</div> </div>
) : ( ) : (
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]"> <MasonryGrid>
{readyPhotos.map((photo, i) => ( {readyPhotos.map((photo, i) => (
<div <PhotoTile
key={photo.id} key={photo.id}
role="button" photo={photo}
tabIndex={0} eager={i < 8}
onClick={() => setLightboxIndex(i)} onOpen={() => setLightboxIndex(i)}
onKeyDown={(e) => { onDownload={() => downloadPhoto(photo)}
if (e.key === 'Enter' || e.key === ' ') { downloading={downloads.isPending(photo.id)}
e.preventDefault(); labels={downloadLabels}
setLightboxIndex(i); />
}
}}
className="group relative mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow"
style={
photo.width && photo.height
? { aspectRatio: `${photo.width} / ${photo.height}` }
: undefined
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo.urls.thumb}
alt=""
loading={i < 8 ? 'eager' : 'lazy'}
className="w-full h-auto group-hover:scale-[1.03] transition-transform duration-300"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<a
href={photo.urls.original}
download={photo.originalFilename || true}
onClick={(e) => e.stopPropagation()}
className="absolute bottom-2 right-2 hidden md:flex p-2 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80"
aria-label={es ? 'Descargar' : 'Download'}
>
<ArrowDownTrayIcon className="w-4 h-4" />
</a>
</div>
))} ))}
</div> </MasonryGrid>
)} )}
{lightboxIndex !== null && ( {lightboxIndex !== null && (
@@ -272,8 +363,59 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
index={lightboxIndex} index={lightboxIndex}
onClose={() => setLightboxIndex(null)} onClose={() => setLightboxIndex(null)}
onNavigate={setLightboxIndex} onNavigate={setLightboxIndex}
onDownload={(it) => {
const photo = readyPhotos.find((p) => p.id === it.id);
if (photo) downloadPhoto(photo);
}}
downloadingId={
lightboxItems.find((it) => downloads.isPending(it.id))?.id ?? null
}
downloadLabels={downloadLabels}
/> />
)} )}
{/* Shared by both entry points: the tile button and the lightbox's.
Fetches nothing until it is open. */}
<SaveSheet photo={savePhoto} onClose={() => setSavePhoto(null)} />
</GalleryContainer>
{/* Call to action: send attendees to their dashboard, everyone else to
the next event. Auth state comes from the same useAuth() the gate
pages use. */}
<div className="bg-brand-navy">
<div className="container-page px-4 py-12 md:py-16 text-center">
<h2 className="font-heading font-bold text-2xl md:text-3xl text-white">
{user
? es
? '¿Listo para lo que sigue?'
: 'Ready for whats next?'
: es
? '¿Te gustó lo que viste?'
: 'Liked what you saw?'}
</h2>
<p className="mt-2 max-w-xl mx-auto text-white/80 text-sm md:text-base">
{user
? es
? 'Revisa tus entradas y próximos eventos en tu panel.'
: 'Check your tickets and upcoming events from your dashboard.'
: es
? 'Únete a nuestro próximo evento y sé parte de las próximas fotos.'
: 'Join our next event and be part of the next set of photos.'}
</p>
<div className="mt-6">
<Link href={user ? '/dashboard' : '/next'}>
<Button size="lg">
{user
? es
? 'Ir a mi panel'
: 'Go to dashboard'
: es
? 'Únete al próximo evento'
: 'Join the next event'}
</Button>
</Link>
</div>
</div>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,7 @@
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
// Shown while the server component fetches the gallery, so the first paint is
// already the gallery's layout rather than an empty page.
export default function Loading() {
return <GallerySkeleton />;
}
+25 -5
View File
@@ -1,7 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
@@ -9,13 +8,16 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import GoogleSignInButton from '@/components/GoogleSignInButton'; import GoogleSignInButton from '@/components/GoogleSignInButton';
import { redirectAfterAuth } from '@/lib/authRedirect';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
const REDIRECT_TO = '/dashboard';
export default function RegisterPage() { export default function RegisterPage() {
const router = useRouter();
const { t, locale: language } = useLanguage(); const { t, locale: language } = useLanguage();
const { register } = useAuth(); const { register } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [redirecting, setRedirecting] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
email: '', email: '',
@@ -30,10 +32,12 @@ export default function RegisterPage() {
try { try {
await register(formData); await register(formData);
toast.success(language === 'es' ? 'Cuenta creada exitosamente!' : 'Account created successfully!'); toast.success(language === 'es' ? 'Cuenta creada exitosamente!' : 'Account created successfully!');
router.push('/dashboard'); // Deliberately leaves `loading` set: the button must stay disabled until the
// browser replaces this page.
setRedirecting(true);
redirectAfterAuth(REDIRECT_TO);
} catch (error: any) { } catch (error: any) {
toast.error(error.message || t('auth.errors.emailExists')); toast.error(error.message || t('auth.errors.emailExists'));
} finally {
setLoading(false); setLoading(false);
} }
}; };
@@ -104,9 +108,25 @@ export default function RegisterPage() {
onChange={(e) => setFormData({ ...formData, phone: e.target.value })} onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/> />
<Button type="submit" className="w-full" size="lg" isLoading={loading}> <Button
type="submit"
className="w-full"
size="lg"
isLoading={loading || redirecting}
loadingText={redirecting ? t('auth.login.redirecting') : t('common.loading')}
>
{t('auth.register.submit')} {t('auth.register.submit')}
</Button> </Button>
{redirecting && (
<p
className="text-center text-sm text-gray-600"
role="status"
aria-live="polite"
>
{t('auth.login.redirecting')}
</p>
)}
</form> </form>
<p className="mt-6 text-center text-sm text-gray-600"> <p className="mt-6 text-center text-sm text-gray-600">
+28 -3
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api'; import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
import { parseDate, formatRucDisplay } from '@/lib/utils'; import { parseDate, formatRucDisplay } from '@/lib/utils';
@@ -8,6 +8,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { AdminPageSkeleton } from '@/components/ui/Skeleton'; import { AdminPageSkeleton } from '@/components/ui/Skeleton';
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents'; import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import Pagination, { usePaginatedList } from '@/components/admin/Pagination';
import { import {
TicketIcon, TicketIcon,
CheckCircleIcon, CheckCircleIcon,
@@ -51,6 +52,8 @@ export default function AdminBookingsPage() {
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>(''); const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [mobileFilterOpen, setMobileFilterOpen] = useState(false); const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@@ -203,6 +206,19 @@ export default function AdminBookingsPage() {
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
); );
// Bookings are paginated client-side: the page already loads every ticket so
// that the stat cards, the group-booking totals and the sibling payment-method
// lookup can see the whole set, and those would break on a server-side slice.
const filterKey = JSON.stringify([selectedEvent, selectedStatus, selectedPaymentStatus, searchQuery]);
const prevFilterKey = useRef(filterKey);
useEffect(() => {
if (prevFilterKey.current !== filterKey) {
prevFilterKey.current = filterKey;
setPage(1);
}
}, [filterKey]);
const pagedTickets = usePaginatedList(sortedTickets, page, pageSize, setPage);
const stats = { const stats = {
total: tickets.length, total: tickets.length,
pending: tickets.filter(t => t.status === 'pending').length, pending: tickets.filter(t => t.status === 'pending').length,
@@ -408,7 +424,7 @@ export default function AdminBookingsPage() {
</td> </td>
</tr> </tr>
) : ( ) : (
sortedTickets.map((ticket) => { pagedTickets.map((ticket) => {
const bookingInfo = getBookingInfo(ticket); const bookingInfo = getBookingInfo(ticket);
return ( return (
<tr key={ticket.id} className="hover:bg-gray-50"> <tr key={ticket.id} className="hover:bg-gray-50">
@@ -502,7 +518,7 @@ export default function AdminBookingsPage() {
No bookings found. No bookings found.
</div> </div>
) : ( ) : (
sortedTickets.map((ticket) => { pagedTickets.map((ticket) => {
const bookingInfo = getBookingInfo(ticket); const bookingInfo = getBookingInfo(ticket);
const primary = getPrimaryAction(ticket); const primary = getPrimaryAction(ticket);
const eventTitle = ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'; const eventTitle = ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown';
@@ -580,6 +596,15 @@ export default function AdminBookingsPage() {
)} )}
</div> </div>
<Pagination
id="bookings"
page={page}
pageSize={pageSize}
total={sortedTickets.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
{/* Mobile Filter BottomSheet */} {/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters"> <BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
<div className="space-y-4"> <div className="space-y-4">
+2 -8
View File
@@ -107,11 +107,7 @@ export default function AdminEmailsPage() {
const loadEvents = async () => { const loadEvents = async () => {
try { try {
const res = await fetch('/api/events', { const res = await fetch('/api/events', { credentials: 'same-origin' });
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setEvents(data.events || []); setEvents(data.events || []);
@@ -169,9 +165,7 @@ export default function AdminEmailsPage() {
try { try {
const res = await fetch(`/api/events/${composeForm.eventId}/attendees`, { const res = await fetch(`/api/events/${composeForm.eventId}/attendees`, {
headers: { credentials: 'same-origin',
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
}); });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
@@ -1,27 +1,33 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { eventsApi, ticketsApi, emailsApi, Event, Ticket, EmailTemplate } from '@/lib/api'; import { eventsApi, ticketsApi, emailsApi, doorApi, Event, Ticket, EmailTemplate, DoorSummary } from '@/lib/api';
/** /**
* Loads the core data for the admin event detail page (event, tickets, active * Loads the core data for the admin event detail page (event, tickets, active
* email templates) and exposes a reload function used after mutations. * email templates, door takings) and exposes a reload function used after
* mutations.
*/ */
export function useEventDetailData(eventId: string) { export function useEventDetailData(eventId: string) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [event, setEvent] = useState<Event | null>(null); const [event, setEvent] = useState<Event | null>(null);
const [tickets, setTickets] = useState<Ticket[]>([]); const [tickets, setTickets] = useState<Ticket[]>([]);
const [templates, setTemplates] = useState<EmailTemplate[]>([]); const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [doorSummary, setDoorSummary] = useState<DoorSummary | null>(null);
const loadEventData = async () => { const loadEventData = async () => {
try { try {
const [eventRes, ticketsRes, templatesRes] = await Promise.all([ const [eventRes, ticketsRes, templatesRes, doorRes] = await Promise.all([
eventsApi.getById(eventId), eventsApi.getById(eventId),
ticketsApi.getAll({ eventId }), ticketsApi.getAll({ eventId }),
emailsApi.getTemplates(), emailsApi.getTemplates(),
// Door takings split pre-sale from cash/bitcoin/transfer taken on the
// night. It is supporting detail, so a failure here must not blank the page.
doorApi.summary(eventId).catch(() => null),
]); ]);
setEvent(eventRes.event); setEvent(eventRes.event);
setTickets(ticketsRes.tickets); setTickets(ticketsRes.tickets);
setTemplates(templatesRes.templates.filter(t => t.isActive)); setTemplates(templatesRes.templates.filter(t => t.isActive));
setDoorSummary(doorRes);
} catch (error) { } catch (error) {
toast.error('Failed to load event data'); toast.error('Failed to load event data');
} finally { } finally {
@@ -33,5 +39,5 @@ export function useEventDetailData(eventId: string) {
loadEventData(); loadEventData();
}, [eventId]); }, [eventId]);
return { loading, event, tickets, templates, loadEventData }; return { loading, event, tickets, templates, doorSummary, loadEventData };
} }
@@ -24,18 +24,21 @@ interface AddTicketModalProps {
const TYPE_OPTIONS: { value: AddTicketType; label: string }[] = [ const TYPE_OPTIONS: { value: AddTicketType; label: string }[] = [
{ value: 'paid', label: 'Paid' }, { value: 'paid', label: 'Paid' },
{ value: 'door', label: 'At Door' },
{ value: 'unpaid', label: 'Unpaid' }, { value: 'unpaid', label: 'Unpaid' },
{ value: 'guest', label: 'Guest' }, { value: 'guest', label: 'Guest' },
]; ];
const SUBMIT_LABELS: Record<AddTicketType, string> = { const SUBMIT_LABELS: Record<AddTicketType, string> = {
paid: 'Create & send ticket', paid: 'Create & send ticket',
door: 'Record door payment',
unpaid: 'Create & send pay link', unpaid: 'Create & send pay link',
guest: 'Invite guest', guest: 'Invite guest',
}; };
const SUBMIT_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = { const SUBMIT_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
paid: EnvelopeIcon, paid: EnvelopeIcon,
door: BanknotesIcon,
unpaid: LinkIcon, unpaid: LinkIcon,
guest: StarIcon, guest: StarIcon,
}; };
@@ -47,6 +50,15 @@ function previewLines(form: AddTicketFormState, eventPriceLabel: string): string
if (form.type === 'paid') { if (form.type === 'paid') {
lines.push(`Payment of ${eventPriceLabel} recorded as paid — counts toward revenue`); lines.push(`Payment of ${eventPriceLabel} recorded as paid — counts toward revenue`);
lines.push('Confirmation email with QR ticket sent'); lines.push('Confirmation email with QR ticket sent');
} else if (form.type === 'door') {
lines.push(`Cash payment of ${eventPriceLabel} recorded as paid at the door — counts toward revenue`);
lines.push('QR code issued');
if (!form.firstName.trim()) {
lines.push('No name — the ticket is logged as a "Walk-in"');
}
lines.push(hasEmail
? 'Confirmation email with QR ticket sent'
: 'No email — nothing is sent, walk-in kept on the list only');
} else if (form.type === 'unpaid') { } else if (form.type === 'unpaid') {
lines.push(`Ticket marked unpaid — balance of ${eventPriceLabel} to collect at the door`); lines.push(`Ticket marked unpaid — balance of ${eventPriceLabel} to collect at the door`);
lines.push('QR code issued, flagged "unpaid" for door staff'); lines.push('QR code issued, flagged "unpaid" for door staff');
@@ -66,12 +78,14 @@ function previewLines(form: AddTicketFormState, eventPriceLabel: string): string
const PREVIEW_STYLES: Record<AddTicketType, { box: string; icon: string; text: string }> = { const PREVIEW_STYLES: Record<AddTicketType, { box: string; icon: string; text: string }> = {
paid: { box: 'bg-blue-50 border-blue-200', icon: 'text-blue-500', text: 'text-blue-800' }, paid: { box: 'bg-blue-50 border-blue-200', icon: 'text-blue-500', text: 'text-blue-800' },
door: { box: 'bg-emerald-50 border-emerald-200', icon: 'text-emerald-500', text: 'text-emerald-800' },
unpaid: { box: 'bg-orange-50 border-orange-200', icon: 'text-orange-500', text: 'text-orange-800' }, unpaid: { box: 'bg-orange-50 border-orange-200', icon: 'text-orange-500', text: 'text-orange-800' },
guest: { box: 'bg-amber-50 border-amber-200', icon: 'text-amber-500', text: 'text-amber-800' }, guest: { box: 'bg-amber-50 border-amber-200', icon: 'text-amber-500', text: 'text-amber-800' },
}; };
const PREVIEW_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = { const PREVIEW_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
paid: CheckCircleIcon, paid: CheckCircleIcon,
door: BanknotesIcon,
unpaid: BanknotesIcon, unpaid: BanknotesIcon,
guest: StarIcon, guest: StarIcon,
}; };
@@ -88,6 +102,8 @@ export function AddTicketModal({
if (!open) return null; if (!open) return null;
const emailRequired = form.type === 'paid'; const emailRequired = form.type === 'paid';
// Door walk-ins can be logged with nothing filled in
const nameRequired = form.type !== 'door';
const style = PREVIEW_STYLES[form.type]; const style = PREVIEW_STYLES[form.type];
const PreviewIcon = PREVIEW_ICONS[form.type]; const PreviewIcon = PREVIEW_ICONS[form.type];
const SubmitIcon = SUBMIT_ICONS[form.type]; const SubmitIcon = SUBMIT_ICONS[form.type];
@@ -120,7 +136,7 @@ export function AddTicketModal({
type="button" type="button"
onClick={() => setForm((f) => ({ ...f, type: option.value }))} onClick={() => setForm((f) => ({ ...f, type: option.value }))}
className={clsx( className={clsx(
'flex-1 px-3 py-2 text-sm font-medium rounded-btn min-h-[36px] transition-colors', 'flex-1 px-2 py-2 text-xs sm:text-sm font-medium rounded-btn min-h-[36px] whitespace-nowrap transition-colors',
form.type === option.value form.type === option.value
? 'bg-white shadow-sm text-primary-dark' ? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-500 hover:text-gray-700' : 'text-gray-500 hover:text-gray-700'
@@ -133,11 +149,11 @@ export function AddTicketModal({
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium mb-1">First Name *</label> <label className="block text-xs font-medium mb-1">First Name {nameRequired && '*'}</label>
<input type="text" required value={form.firstName} <input type="text" required={nameRequired} value={form.firstName}
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="First name" /> placeholder={nameRequired ? 'First name' : 'First name (optional)'} />
</div> </div>
<div> <div>
<label className="block text-xs font-medium mb-1">Last Name</label> <label className="block text-xs font-medium mb-1">Last Name</label>
@@ -155,6 +171,7 @@ export function AddTicketModal({
placeholder={emailRequired ? 'email@example.com' : 'email@example.com (optional)'} /> placeholder={emailRequired ? 'email@example.com' : 'email@example.com (optional)'} />
<p className="text-[10px] text-gray-500 mt-1"> <p className="text-[10px] text-gray-500 mt-1">
{form.type === 'paid' && 'Ticket will be sent to this email'} {form.type === 'paid' && 'Ticket will be sent to this email'}
{form.type === 'door' && 'Optional — if provided, the ticket confirmation is sent here'}
{form.type === 'unpaid' && 'If provided, the payment link is sent here'} {form.type === 'unpaid' && 'If provided, the payment link is sent here'}
{form.type === 'guest' && 'If provided, a confirmation email will be sent'} {form.type === 'guest' && 'If provided, a confirmation email will be sent'}
</p> </p>
@@ -133,6 +133,16 @@ export function EventModals(props: EventModalsProps) {
<p className="text-xs text-gray-500">Send confirmation email with QR ticket</p> <p className="text-xs text-gray-500">Send confirmation email with QR ticket</p>
</div> </div>
</button> </button>
<button
onClick={() => { openAddTicket('door'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<BanknotesIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Paid at Door</p>
<p className="text-xs text-gray-500">Cash taken at the door, all fields optional</p>
</div>
</button>
<button <button
onClick={() => { openAddTicket('unpaid'); setShowAddTicketSheet(false); }} onClick={() => { openAddTicket('unpaid'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3" className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import { Ticket } from '@/lib/api'; import { Ticket } from '@/lib/api';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils'; import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents'; import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
import Pagination, { usePaginatedList } from '@/components/admin/Pagination';
import clsx from 'clsx'; import clsx from 'clsx';
import { import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
@@ -79,6 +81,20 @@ export function AttendeesTab({
handleMarkPaid, handleMarkPaid,
handleCheckin, handleCheckin,
}: AttendeesTabProps) { }: AttendeesTabProps) {
// Paginated client-side: the parent already holds every ticket for the event
// so the status counts and the other tabs keep seeing the full set.
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const filterKey = `${searchQuery}|${statusFilter}`;
const prevFilterKey = useRef(filterKey);
useEffect(() => {
if (prevFilterKey.current !== filterKey) {
prevFilterKey.current = filterKey;
setPage(1);
}
}, [filterKey]);
const pagedTickets = usePaginatedList(filteredTickets, page, pageSize, setPage);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Desktop toolbar */} {/* Desktop toolbar */}
@@ -148,6 +164,9 @@ export function AttendeesTab({
<DropdownItem onClick={() => { openAddTicket('paid'); setShowAddTicketDropdown(false); }}> <DropdownItem onClick={() => { openAddTicket('paid'); setShowAddTicketDropdown(false); }}>
<EnvelopeIcon className="w-4 h-4 mr-2" /> Paid Ticket <EnvelopeIcon className="w-4 h-4 mr-2" /> Paid Ticket
</DropdownItem> </DropdownItem>
<DropdownItem onClick={() => { openAddTicket('door'); setShowAddTicketDropdown(false); }}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Paid at Door
</DropdownItem>
<DropdownItem onClick={() => { openAddTicket('unpaid'); setShowAddTicketDropdown(false); }}> <DropdownItem onClick={() => { openAddTicket('unpaid'); setShowAddTicketDropdown(false); }}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Unpaid Ticket <BanknotesIcon className="w-4 h-4 mr-2" /> Unpaid Ticket
</DropdownItem> </DropdownItem>
@@ -234,7 +253,7 @@ export function AttendeesTab({
</td> </td>
</tr> </tr>
) : ( ) : (
filteredTickets.map((ticket) => { pagedTickets.map((ticket) => {
const primary = getPrimaryAction(ticket); const primary = getPrimaryAction(ticket);
return ( return (
<tr key={ticket.id} className="hover:bg-gray-50/50"> <tr key={ticket.id} className="hover:bg-gray-50/50">
@@ -320,7 +339,7 @@ export function AttendeesTab({
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'} {tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
</div> </div>
) : ( ) : (
filteredTickets.map((ticket) => { pagedTickets.map((ticket) => {
const primary = getPrimaryAction(ticket); const primary = getPrimaryAction(ticket);
return ( return (
<Card key={ticket.id} className="p-3"> <Card key={ticket.id} className="p-3">
@@ -377,6 +396,16 @@ export function AttendeesTab({
)} )}
</div> </div>
<Pagination
id="attendees"
page={page}
pageSize={pageSize}
total={filteredTickets.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
className="mb-20 md:mb-0"
/>
{/* Mobile FAB */} {/* Mobile FAB */}
<div className="md:hidden fixed bottom-6 right-6 z-40"> <div className="md:hidden fixed bottom-6 right-6 z-40">
<button <button
@@ -1,4 +1,4 @@
import { PaymentOptionsConfig } from '@/lib/api'; import { PaymentOptionsConfig, DOOR_PAYMENT_METHODS, type DoorPaymentMethod, type DoorSummary } from '@/lib/api';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -12,13 +12,85 @@ import {
XCircleIcon, XCircleIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import type { PaymentOverridesController } from '../_hooks/usePaymentOverrides'; import type { PaymentOverridesController } from '../_hooks/usePaymentOverrides';
import { formatCurrency } from '../_utils/format';
interface PaymentsTabProps { interface PaymentsTabProps {
locale: string; locale: string;
payments: PaymentOverridesController; payments: PaymentOverridesController;
/** Takings recorded on the door check-in screen; null while loading or unavailable. */
doorSummary: DoorSummary | null;
} }
export function PaymentsTab({ locale, payments }: PaymentsTabProps) { const DOOR_METHOD_LABELS: Record<DoorPaymentMethod, { en: string; es: string }> = {
cash: { en: 'Cash', es: 'Efectivo' },
bitcoin: { en: 'Bitcoin', es: 'Bitcoin' },
transfer: { en: 'Transfer', es: 'Transferencia' },
guest: { en: 'Guests', es: 'Invitados' },
};
/**
* End-of-night reconciliation for this event: what staff took on the door, split
* by tender, next to the pre-sale total. Guests are counted, not totalled they
* are free and carry no revenue.
*/
function DoorTakings({ locale, summary }: { locale: string; summary: DoorSummary }) {
const es = locale === 'es';
return (
<Card>
<div className="p-4 md:p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-emerald-100 rounded-full flex items-center justify-center flex-shrink-0">
<BanknotesIcon className="w-4 h-4 text-emerald-600" />
</div>
<div>
<h4 className="font-semibold text-sm">{es ? 'Ventas en Puerta' : 'Door Sales'}</h4>
<p className="text-[10px] text-gray-500">
{es ? 'Cobrado por el staff en la entrada' : 'Taken by staff at the door'}
</p>
</div>
</div>
<p className="font-bold text-lg">{formatCurrency(summary.door.total, summary.currency)}</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 pt-3 border-t">
{DOOR_PAYMENT_METHODS.map((method) => {
const entry = summary.door.byMethod[method];
return (
<div key={method} className="bg-gray-50 rounded-lg px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-gray-500">
{es ? DOOR_METHOD_LABELS[method].es : DOOR_METHOD_LABELS[method].en}
</p>
<p className="font-bold text-sm leading-tight">
{method === 'guest'
? `${entry.count}`
: formatCurrency(entry.total, summary.currency)}
</p>
{method !== 'guest' && (
<p className="text-[10px] text-gray-500">
{entry.count} {es ? (entry.count === 1 ? 'pago' : 'pagos') : (entry.count === 1 ? 'payment' : 'payments')}
</p>
)}
</div>
);
})}
</div>
<div className="flex items-center justify-between text-xs text-gray-600 pt-3 mt-3 border-t">
<span>
{es ? 'Preventa' : 'Pre-sale'}: <strong>{formatCurrency(summary.presale.total, summary.currency)}</strong>
{' '}({summary.presale.count})
</span>
<span>
{es ? 'Total' : 'Total'}: <strong>{formatCurrency(summary.total, summary.currency)}</strong>
</span>
</div>
</div>
</Card>
);
}
export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps) {
const { const {
loadingPayments, loadingPayments,
hasPaymentOverrides, hasPaymentOverrides,
@@ -39,6 +111,11 @@ export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
</div> </div>
) : ( ) : (
<> <>
{/* Door takings — reconciliation first, configuration below */}
{doorSummary && (doorSummary.door.count > 0 || doorSummary.presale.count > 0) && (
<DoorTakings locale={locale} summary={doorSummary} />
)}
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div> <div>
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import { Ticket } from '@/lib/api'; import { Ticket } from '@/lib/api';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils'; import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents'; import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
import Pagination, { usePaginatedList } from '@/components/admin/Pagination';
import { import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
ChevronDownIcon, ChevronDownIcon,
@@ -48,6 +50,20 @@ export function TicketsTab({
handleRemoveCheckin, handleRemoveCheckin,
setShowTicketExportSheet, setShowTicketExportSheet,
}: TicketsTabProps) { }: TicketsTabProps) {
// Paginated client-side, same as the Attendees tab: the parent keeps the full
// ticket list for the header counts and the export actions.
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const filterKey = `${ticketSearchQuery}|${ticketStatusFilter}`;
const prevFilterKey = useRef(filterKey);
useEffect(() => {
if (prevFilterKey.current !== filterKey) {
prevFilterKey.current = filterKey;
setPage(1);
}
}, [filterKey]);
const pagedTickets = usePaginatedList(filteredConfirmedTickets, page, pageSize, setPage);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Desktop toolbar */} {/* Desktop toolbar */}
@@ -152,7 +168,7 @@ export function TicketsTab({
</td> </td>
</tr> </tr>
) : ( ) : (
filteredConfirmedTickets.map((ticket) => ( pagedTickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50/50"> <tr key={ticket.id} className="hover:bg-gray-50/50">
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p> <p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
@@ -216,7 +232,7 @@ export function TicketsTab({
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'} {confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
</div> </div>
) : ( ) : (
filteredConfirmedTickets.map((ticket) => ( pagedTickets.map((ticket) => (
<Card key={ticket.id} className="p-3"> <Card key={ticket.id} className="p-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -253,6 +269,15 @@ export function TicketsTab({
)) ))
)} )}
</div> </div>
<Pagination
id="tickets"
page={page}
pageSize={pageSize}
total={filteredConfirmedTickets.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</div> </div>
); );
} }
+2 -1
View File
@@ -15,9 +15,10 @@ export interface PrimaryAction {
// Ticket type in the unified Add Ticket modal: // Ticket type in the unified Add Ticket modal:
// paid = confirmation + QR emailed, counts toward revenue // paid = confirmation + QR emailed, counts toward revenue
// door = already paid in cash at the door, counts toward revenue, every field optional
// unpaid = QR flagged unpaid, balance collected at door, pay link emailed if possible // unpaid = QR flagged unpaid, balance collected at door, pay link emailed if possible
// guest = free comp ticket, auto-confirmed, no revenue // guest = free comp ticket, auto-confirmed, no revenue
export type AddTicketType = 'paid' | 'unpaid' | 'guest'; export type AddTicketType = 'paid' | 'door' | 'unpaid' | 'guest';
export interface AddTicketFormState { export interface AddTicketFormState {
type: AddTicketType; type: AddTicketType;
+32 -9
View File
@@ -66,7 +66,7 @@ export default function AdminEventDetailPage() {
const eventId = params.id as string; const eventId = params.id as string;
const { locale } = useLanguage(); const { locale } = useLanguage();
const { loading, event, tickets, templates, loadEventData } = useEventDetailData(eventId); const { loading, event, tickets, templates, doorSummary, loadEventData } = useEventDetailData(eventId);
const [activeTab, setActiveTab] = useState<TabType>('overview'); const [activeTab, setActiveTab] = useState<TabType>('overview');
// Email state // Email state
@@ -84,7 +84,7 @@ export default function AdminEventDetailPage() {
const [showNoteModal, setShowNoteModal] = useState(false); const [showNoteModal, setShowNoteModal] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null); const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [noteText, setNoteText] = useState(''); const [noteText, setNoteText] = useState('');
// Unified Add Ticket modal (paid / unpaid / guest via segmented control) // Unified Add Ticket modal (paid / door / unpaid / guest via segmented control)
const [showAddTicketModal, setShowAddTicketModal] = useState(false); const [showAddTicketModal, setShowAddTicketModal] = useState(false);
const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM); const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -222,7 +222,7 @@ export default function AdminEventDetailPage() {
const res = await ticketsApi.adminAdd({ const res = await ticketsApi.adminAdd({
eventId: event.id, eventId: event.id,
type: addTicketForm.type, type: addTicketForm.type,
firstName: addTicketForm.firstName, firstName: addTicketForm.firstName || undefined,
lastName: addTicketForm.lastName || undefined, lastName: addTicketForm.lastName || undefined,
email: addTicketForm.email || undefined, email: addTicketForm.email || undefined,
phone: addTicketForm.phone || undefined, phone: addTicketForm.phone || undefined,
@@ -401,7 +401,14 @@ export default function AdminEventDetailPage() {
const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest); const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest);
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length; const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length;
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length; const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length;
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price; // Door sales can be taken at a custom amount (someone paying for their whole
// group), so once the door summary is loaded it is the authority on the total:
// pre-sale tickets at face value plus whatever was actually taken on the night.
const presaleRevenue = doorSummary
? doorSummary.presale.total
: (paidConfirmedCount + paidCheckedInCount) * event.price;
const doorRevenue = doorSummary?.door.total ?? 0;
const revenue = presaleRevenue + doorRevenue;
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [ const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
{ key: 'overview', label: 'Overview', icon: CalendarIcon }, { key: 'overview', label: 'Overview', icon: CalendarIcon },
@@ -507,7 +514,15 @@ export default function AdminEventDetailPage() {
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' }, { label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' },
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' }, { label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' },
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' }, { label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' },
{ label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'bg-gray-50 text-gray-600' }, {
label: 'Revenue',
value: formatCurrency(revenue, event.currency),
icon: CurrencyDollarIcon,
color: 'bg-gray-50 text-gray-600',
detail: doorSummary
? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}`
: undefined,
},
].map((stat) => ( ].map((stat) => (
<div key={stat.label} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5"> <div key={stat.label} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5">
<div className={clsx('w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}> <div className={clsx('w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
@@ -515,7 +530,7 @@ export default function AdminEventDetailPage() {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-lg font-bold leading-tight truncate">{stat.value}</p> <p className="text-lg font-bold leading-tight truncate">{stat.value}</p>
<p className="text-xs text-gray-500">{stat.label}</p> <p className="text-xs text-gray-500 truncate">{('detail' in stat && stat.detail) || stat.label}</p>
</div> </div>
</div> </div>
))} ))}
@@ -547,7 +562,15 @@ export default function AdminEventDetailPage() {
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' }, { label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' },
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' }, { label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' },
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' }, { label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' },
{ label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'text-gray-600 bg-gray-50' }, {
label: 'Revenue',
value: formatCurrency(revenue, event.currency),
icon: CurrencyDollarIcon,
color: 'text-gray-600 bg-gray-50',
detail: doorSummary
? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}`
: undefined,
},
].map((stat) => ( ].map((stat) => (
<div key={stat.label} className="flex items-center gap-2 bg-white rounded-card shadow-card px-3 py-2"> <div key={stat.label} className="flex items-center gap-2 bg-white rounded-card shadow-card px-3 py-2">
<div className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}> <div className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
@@ -555,7 +578,7 @@ export default function AdminEventDetailPage() {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-base font-bold leading-tight truncate">{stat.value}</p> <p className="text-base font-bold leading-tight truncate">{stat.value}</p>
<p className="text-[10px] text-gray-500">{stat.label}</p> <p className="text-[10px] text-gray-500 truncate">{('detail' in stat && stat.detail) || stat.label}</p>
</div> </div>
</div> </div>
))} ))}
@@ -705,7 +728,7 @@ export default function AdminEventDetailPage() {
)} )}
{activeTab === 'payments' && ( {activeTab === 'payments' && (
<PaymentsTab locale={locale} payments={payments} /> <PaymentsTab locale={locale} payments={payments} doorSummary={doorSummary} />
)} )}
</div> </div>
+41 -7
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
@@ -15,12 +15,16 @@ import toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
import { parseDate } from '@/lib/utils'; import { parseDate } from '@/lib/utils';
import EventFormModal from './_components/EventFormModal'; import EventFormModal from './_components/EventFormModal';
import Pagination from '@/components/admin/Pagination';
export default function AdminEventsPage() { export default function AdminEventsPage() {
const router = useRouter(); const router = useRouter();
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<Event | null>(null); const [editingEvent, setEditingEvent] = useState<Event | null>(null);
@@ -28,22 +32,42 @@ export default function AdminEventsPage() {
const [settingFeatured, setSettingFeatured] = useState<string | null>(null); const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
loadEvents();
loadFeaturedEvent(); loadFeaturedEvent();
}, []); }, []);
useEffect(() => {
loadEvents();
}, [page, pageSize]);
// The ?edit=<id> deep link may point at an event that is not on the current
// page, so fall back to fetching it directly instead of only scanning the page.
const handledEditId = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
const editId = searchParams.get('edit'); const editId = searchParams.get('edit');
if (editId && events.length > 0) { if (!editId || handledEditId.current === editId) return;
const event = events.find(e => e.id === editId); const event = events.find(e => e.id === editId);
if (event) handleEdit(event); if (event) {
handledEditId.current = editId;
handleEdit(event);
return;
} }
}, [searchParams, events]); if (loading) return;
handledEditId.current = editId;
eventsApi.getById(editId)
.then(({ event }) => handleEdit(event))
.catch(() => toast.error('Event not found'));
}, [searchParams, events, loading]);
const loadEvents = async () => { const loadEvents = async () => {
try { try {
const { events } = await eventsApi.getAll(); const { events, total } = await eventsApi.getAll({ page, pageSize });
setEvents(events); setEvents(events);
setTotal(total ?? events.length);
// If the current page emptied out (e.g. after deleting its last event),
// fall back to the new last page.
if (events.length === 0 && (total ?? 0) > 0 && page > 1) {
setPage(Math.max(1, Math.ceil((total ?? 0) / pageSize)));
}
} catch (error) { } catch (error) {
toast.error('Failed to load events'); toast.error('Failed to load events');
} finally { } finally {
@@ -401,6 +425,16 @@ export default function AdminEventsPage() {
)} )}
</div> </div>
<Pagination
id="events"
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={setPageSize}
className="mb-20 md:mb-0"
/>
{/* Mobile FAB */} {/* Mobile FAB */}
<div className="md:hidden fixed bottom-6 right-6 z-40"> <div className="md:hidden fixed bottom-6 right-6 z-40">
<button onClick={() => { setEditingEvent(null); setShowForm(true); }} <button onClick={() => { setEditingEvent(null); setShowForm(true); }}
+1 -5
View File
@@ -35,11 +35,7 @@ export default function AdminGalleryPage() {
const loadMedia = async () => { const loadMedia = async () => {
try { try {
// We need to call the media API - let's add it if it doesn't exist // We need to call the media API - let's add it if it doesn't exist
const res = await fetch('/api/media', { const res = await fetch('/api/media', { credentials: 'same-origin' });
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setMedia(data.media || []); setMedia(data.media || []);
+28 -6
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { usePrivacy } from '@/context/PrivacyContext';
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api'; import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
import { isManualProvider } from '@/lib/api/payments'; import { isManualProvider } from '@/lib/api/payments';
import { parseDate, formatRucDisplay } from '@/lib/utils'; import { parseDate, formatRucDisplay } from '@/lib/utils';
@@ -22,6 +23,7 @@ import {
BoltIcon, BoltIcon,
BanknotesIcon, BanknotesIcon,
BuildingLibraryIcon, BuildingLibraryIcon,
CalendarDaysIcon,
CreditCardIcon, CreditCardIcon,
EnvelopeIcon, EnvelopeIcon,
FunnelIcon, FunnelIcon,
@@ -35,6 +37,7 @@ type Tab = 'pending_approval' | 'all';
export default function AdminPaymentsPage() { export default function AdminPaymentsPage() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const { privacyMode } = usePrivacy();
const [payments, setPayments] = useState<PaymentWithDetails[]>([]); const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
const [pendingApprovalPayments, setPendingApprovalPayments] = useState<PaymentWithDetails[]>([]); const [pendingApprovalPayments, setPendingApprovalPayments] = useState<PaymentWithDetails[]>([]);
// Manual-gateway payments still in bare 'pending': the customer may have paid // Manual-gateway payments still in bare 'pending': the customer may have paid
@@ -534,12 +537,29 @@ export default function AdminPaymentsPage() {
</div> </div>
)} )}
{selectedPayment.userMarkedPaidAt && ( {/* Always shown for payments the customer never confirmed, this
<div className="flex items-center gap-2 text-sm text-gray-600"> is the only timestamp there is. */}
<ClockIcon className="w-4 h-4" /> {(() => {
{locale === 'es' ? 'Usuario marcó como pagado:' : 'User marked as paid:'} {formatDate(selectedPayment.userMarkedPaidAt)} const age = getAgeInfo(selectedPayment.createdAt);
</div> return (
)} <div className="flex items-center gap-2 text-sm text-gray-600">
<CalendarDaysIcon className="w-4 h-4" />
{locale === 'es' ? 'Reserva realizada:' : 'Booking made:'} {formatDate(selectedPayment.createdAt)}
{age && <span className="text-gray-400">({age.label})</span>}
</div>
);
})()}
{selectedPayment.userMarkedPaidAt && (() => {
const age = getAgeInfo(selectedPayment.userMarkedPaidAt);
return (
<div className="flex items-center gap-2 text-sm text-gray-600">
<ClockIcon className="w-4 h-4" />
{locale === 'es' ? 'Usuario marcó como pagado:' : 'User marked as paid:'} {formatDate(selectedPayment.userMarkedPaidAt)}
{age && <span className="text-gray-400">({age.label})</span>}
</div>
);
})()}
{selectedPayment.reminderSentAt && ( {selectedPayment.reminderSentAt && (
<div className="flex items-center gap-2 text-sm text-amber-600"> <div className="flex items-center gap-2 text-sm text-amber-600">
@@ -760,6 +780,7 @@ export default function AdminPaymentsPage() {
)} )}
{/* Summary Cards */} {/* Summary Cards */}
{!privacyMode && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<Card className="p-4"> <Card className="p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -828,6 +849,7 @@ export default function AdminPaymentsPage() {
</div> </div>
</Card> </Card>
</div> </div>
)}
{/* Tabs */} {/* Tabs */}
<div className="border-b mb-6 overflow-x-auto scrollbar-hide"> <div className="border-b mb-6 overflow-x-auto scrollbar-hide">
+28 -4
View File
@@ -18,6 +18,7 @@ import {
CheckCircleIcon, CheckCircleIcon,
ChevronDownIcon, ChevronDownIcon,
ChevronUpIcon, ChevronUpIcon,
DocumentDuplicateIcon,
ExclamationCircleIcon, ExclamationCircleIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
LinkIcon, LinkIcon,
@@ -34,7 +35,9 @@ interface UploadItem {
name: string; name: string;
sizeBytes: number; sizeBytes: number;
progress: number; // 0..1 while uploading progress: number; // 0..1 while uploading
status: 'queued' | 'uploading' | 'processing' | 'error'; // 'duplicate' is terminal: the gallery already held these bytes, so nothing
// was stored and no new tile appears in the grid.
status: 'queued' | 'uploading' | 'processing' | 'error' | 'duplicate';
error?: string; error?: string;
photoId?: string; photoId?: string;
} }
@@ -117,8 +120,19 @@ export default function AdminGalleryDetailPage() {
const { photos: added } = await photosApi.uploadPhotoWithProgress(id, file, (fraction) => const { photos: added } = await photosApi.uploadPhotoWithProgress(id, file, (fraction) =>
patchUpload(key, { progress: fraction }) patchUpload(key, { progress: fraction })
); );
setPhotos((prev) => [...prev, ...added]); // A duplicate echoes back a photo already in the grid, so merge by id
patchUpload(key, { status: 'processing', progress: 1, photoId: added[0]?.id }); // instead of appending (`duplicate` is an upload outcome, not photo
// state, so it is dropped here).
setPhotos((prev) => {
const byId = new Map(prev.map((p) => [p.id, p]));
added.forEach(({ duplicate: _duplicate, ...photo }) => byId.set(photo.id, photo));
return Array.from(byId.values());
});
patchUpload(key, {
status: added[0]?.duplicate ? 'duplicate' : 'processing',
progress: 1,
photoId: added[0]?.id,
});
} catch (err) { } catch (err) {
patchUpload(key, { patchUpload(key, {
status: 'error', status: 'error',
@@ -148,7 +162,8 @@ export default function AdminGalleryDetailPage() {
}; };
// A row is "done" once its photo finished processing; the panel derives // A row is "done" once its photo finished processing; the panel derives
// this from the photos list instead of tracking it separately. // this from the photos list instead of tracking it separately. 'duplicate'
// is terminal and never reconciled — its photo was already there.
const displayStatus = (u: UploadItem): { state: string; error?: string } => { const displayStatus = (u: UploadItem): { state: string; error?: string } => {
if (u.status === 'processing' && u.photoId) { if (u.status === 'processing' && u.photoId) {
const photo = photos.find((p) => p.id === u.photoId); const photo = photos.find((p) => p.id === u.photoId);
@@ -266,6 +281,7 @@ export default function AdminGalleryDetailPage() {
previewUrl: p.urls.preview || p.urls.original, previewUrl: p.urls.preview || p.urls.original,
downloadUrl: p.urls.original, downloadUrl: p.urls.original,
filename: p.originalFilename, filename: p.originalFilename,
thumbUrl: p.urls.thumb,
})); }));
const openLightboxFor = (photoId: string) => { const openLightboxFor = (photoId: string) => {
const idx = readyPhotos.findIndex((p) => p.id === photoId); const idx = readyPhotos.findIndex((p) => p.id === photoId);
@@ -578,6 +594,11 @@ export default function AdminGalleryDetailPage() {
{ds.state === 'queued' && (es ? 'En cola' : 'Queued')} {ds.state === 'queued' && (es ? 'En cola' : 'Queued')}
{ds.state === 'processing' && (es ? 'Procesando…' : 'Processing…')} {ds.state === 'processing' && (es ? 'Procesando…' : 'Processing…')}
{ds.state === 'done' && formatBytes(u.sizeBytes)} {ds.state === 'done' && formatBytes(u.sizeBytes)}
{ds.state === 'duplicate' && (
<span className="text-amber-600">
{es ? 'Ya está en esta galería' : 'Already in this gallery'}
</span>
)}
{ds.state === 'error' && ( {ds.state === 'error' && (
<span className="text-red-600" title={ds.error}> <span className="text-red-600" title={ds.error}>
{ds.error} {ds.error}
@@ -587,6 +608,9 @@ export default function AdminGalleryDetailPage() {
</div> </div>
<span className="shrink-0"> <span className="shrink-0">
{ds.state === 'done' && <CheckCircleIcon className="w-6 h-6 text-green-600" />} {ds.state === 'done' && <CheckCircleIcon className="w-6 h-6 text-green-600" />}
{ds.state === 'duplicate' && (
<DocumentDuplicateIcon className="w-6 h-6 text-amber-600" />
)}
{ds.state === 'error' && <ExclamationCircleIcon className="w-6 h-6 text-red-600" />} {ds.state === 'error' && <ExclamationCircleIcon className="w-6 h-6 text-red-600" />}
{ds.state === 'processing' && ( {ds.state === 'processing' && (
<div className="animate-spin w-5 h-5 border-2 border-primary-yellow border-t-transparent rounded-full" /> <div className="animate-spin w-5 h-5 border-2 border-primary-yellow border-t-transparent rounded-full" />
@@ -0,0 +1,159 @@
'use client';
import clsx from 'clsx';
import {
CheckCircleIcon,
ArrowUturnLeftIcon,
UserGroupIcon,
} from '@heroicons/react/24/outline';
import type { DoorAttendee, DoorPaymentMethod } from '@/lib/api';
import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import { PaymentButtons } from './PaymentButtons';
function checkinTime(checkinAt: string | null): string {
if (!checkinAt) return '';
return parseDate(checkinAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
timeZone: EVENT_TIMEZONE,
});
}
const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
cash: 'cash',
bitcoin: 'bitcoin',
transfer: 'transfer',
guest: 'guest',
};
/** The second line of a row: everything staff needs to decide in one glance. */
function statusLine(attendee: DoorAttendee, currency: string): string {
if (attendee.status === 'cancelled') return 'Cancelled';
if (attendee.checkedIn) {
const time = checkinTime(attendee.checkinAt);
const how = attendee.doorMethod ? ` · paid ${METHOD_LABELS[attendee.doorMethod]}` : '';
return time ? `Checked in ${time}${how}` : `Checked in${how}`;
}
const parts: string[] = [];
if (attendee.paymentStatus === 'comp') parts.push('Guest');
else if (attendee.paymentStatus === 'paid') parts.push('Paid');
else parts.push(`Unpaid · ${formatCurrency(attendee.amountDue, currency)} due`);
if (attendee.isGroupBooking) parts.push('group booking');
if (attendee.status === 'pending') parts.push('pending');
return parts.join(' · ');
}
export function AttendeeRow({
attendee,
currency,
price,
expanded,
flashing,
busy,
onTap,
onPay,
}: {
attendee: DoorAttendee;
currency: string;
price: number;
expanded: boolean;
flashing: boolean;
busy: boolean;
onTap: () => void;
onPay: (method: DoorPaymentMethod, amount: number) => void;
}) {
const isCancelled = attendee.status === 'cancelled';
const settled = attendee.paymentStatus === 'paid' || attendee.paymentStatus === 'comp';
// A settled, not-yet-arrived attendee is the one-tap case: the whole row checks
// them in. Everyone else opens the tenders inline instead.
const isOneTap = !isCancelled && !attendee.checkedIn && settled;
return (
<div
className={clsx(
'rounded-2xl border transition-colors',
flashing
? 'bg-emerald-600 border-emerald-400'
: attendee.checkedIn || isCancelled
? 'bg-gray-900 border-gray-800'
: 'bg-gray-800 border-gray-700',
)}
>
<button
onClick={onTap}
disabled={busy}
className="w-full text-left px-4 py-3 min-h-[64px] flex items-center gap-3 active:scale-[0.99] transition-transform disabled:opacity-60"
>
<div className="flex-1 min-w-0">
<p
className={clsx(
'font-bold text-lg truncate',
flashing ? 'text-white' : attendee.checkedIn || isCancelled ? 'text-gray-400' : 'text-white',
)}
>
{attendee.fullName}
</p>
<p
className={clsx(
'text-sm truncate flex items-center gap-1.5',
flashing
? 'text-emerald-50'
: isCancelled
? 'text-red-400'
: attendee.checkedIn
? 'text-gray-500'
: attendee.paymentStatus === 'unpaid'
? 'text-amber-400'
: 'text-gray-400',
)}
>
{attendee.isGroupBooking && !attendee.checkedIn && <UserGroupIcon className="w-4 h-4 flex-shrink-0" />}
{statusLine(attendee, currency)}
</p>
</div>
{flashing ? (
<CheckCircleIcon className="w-8 h-8 text-white flex-shrink-0" />
) : attendee.checkedIn ? (
<CheckCircleIcon className="w-7 h-7 text-emerald-500/60 flex-shrink-0" />
) : isCancelled ? (
<span className="flex-shrink-0 text-[10px] font-bold uppercase tracking-wide px-2 py-1 rounded-full bg-red-950 text-red-400">
Cancelled
</span>
) : isOneTap ? (
<span className="flex-shrink-0 text-xs font-bold uppercase tracking-wide text-primary-yellow">
Check in
</span>
) : (
<span className="flex-shrink-0 text-xs font-bold uppercase tracking-wide text-amber-400">
Collect
</span>
)}
</button>
{expanded && !attendee.checkedIn && (
<div className="px-3 pb-3 pt-1 space-y-2">
{isCancelled && (
<p className="text-xs text-gray-400 px-1 flex items-center gap-1.5">
<ArrowUturnLeftIcon className="w-4 h-4" />
Reactivate as a walk-in pick how they are paying.
</p>
)}
<PaymentButtons price={price} currency={currency} onPay={onPay} disabled={busy} />
</div>
)}
{expanded && attendee.checkedIn && (
<div className="px-4 pb-3 -mt-1">
<p className="text-sm text-gray-400">
Already checked in
{attendee.checkinAt ? ` at ${checkinTime(attendee.checkinAt)}` : ''}
{attendee.checkedInBy ? ` by ${attendee.checkedInBy}` : ''}.
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,185 @@
'use client';
import { useState } from 'react';
import clsx from 'clsx';
import {
BanknotesIcon,
BoltIcon,
BuildingLibraryIcon,
GiftIcon,
ChevronDownIcon,
} from '@heroicons/react/24/outline';
import type { DoorPaymentMethod } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
// The four tenders staff can take at the door. One tap settles and checks in;
// long-press (or the chevron) opens multiples for someone paying for their group.
const TENDERS: {
method: DoorPaymentMethod;
label: string;
icon: typeof BanknotesIcon;
className: string;
}[] = [
{ method: 'cash', label: 'Cash', icon: BanknotesIcon, className: 'bg-emerald-600 active:bg-emerald-700' },
{ method: 'bitcoin', label: 'Bitcoin', icon: BoltIcon, className: 'bg-orange-500 active:bg-orange-600' },
{ method: 'transfer', label: 'Transfer', icon: BuildingLibraryIcon, className: 'bg-blue-600 active:bg-blue-700' },
{ method: 'guest', label: 'Guest', icon: GiftIcon, className: 'bg-gray-600 active:bg-gray-700' },
];
const LONG_PRESS_MS = 450;
export function PaymentButtons({
price,
currency,
onPay,
disabled,
}: {
price: number;
currency: string;
onPay: (method: DoorPaymentMethod, amount: number) => void;
disabled?: boolean;
}) {
// Which tender has its quick-amounts open. Guest is always free, so it never opens one.
const [amountsFor, setAmountsFor] = useState<DoorPaymentMethod | null>(null);
const [customOpen, setCustomOpen] = useState(false);
const [customValue, setCustomValue] = useState('');
const [pressTimer, setPressTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const [longPressed, setLongPressed] = useState(false);
const openAmounts = (method: DoorPaymentMethod) => {
if (method === 'guest') return;
setAmountsFor(method);
setCustomOpen(false);
setCustomValue('');
};
const startPress = (method: DoorPaymentMethod) => {
setLongPressed(false);
const timer = setTimeout(() => {
setLongPressed(true);
openAmounts(method);
}, LONG_PRESS_MS);
setPressTimer(timer);
};
const endPress = (method: DoorPaymentMethod) => {
if (pressTimer) clearTimeout(pressTimer);
setPressTimer(null);
// A long press already opened the multiples; don't also charge 1x on release.
if (longPressed) {
setLongPressed(false);
return;
}
if (disabled) return;
onPay(method, method === 'guest' ? 0 : price);
};
const cancelPress = () => {
if (pressTimer) clearTimeout(pressTimer);
setPressTimer(null);
setLongPressed(false);
};
if (amountsFor) {
const tender = TENDERS.find((t) => t.method === amountsFor)!;
return (
<div className="space-y-2">
<div className="flex items-center justify-between px-1">
<p className="text-sm font-semibold text-white">{tender.label} how many?</p>
<button
onClick={() => { setAmountsFor(null); setCustomOpen(false); }}
className="text-sm text-gray-400 min-h-[48px] px-2 active:text-white"
>
Back
</button>
</div>
<div className="grid grid-cols-4 gap-2">
{[1, 2, 3].map((qty) => (
<button
key={qty}
disabled={disabled}
onClick={() => onPay(tender.method, price * qty)}
className={clsx(
'min-h-[56px] rounded-2xl font-bold text-white text-lg flex flex-col items-center justify-center leading-tight disabled:opacity-50 active:scale-[0.97] transition-transform',
tender.className,
)}
>
{qty}x
<span className="text-[10px] font-medium opacity-80">
{formatCurrency(price * qty, currency)}
</span>
</button>
))}
<button
disabled={disabled}
onClick={() => setCustomOpen((open) => !open)}
className="min-h-[56px] rounded-2xl font-bold text-white text-sm bg-gray-700 active:bg-gray-600 disabled:opacity-50 active:scale-[0.97] transition-transform"
>
Custom
</button>
</div>
{customOpen && (
<div className="flex gap-2">
<input
type="number"
inputMode="numeric"
autoFocus
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
placeholder={`Amount in ${currency}`}
className="flex-1 min-h-[48px] px-4 bg-gray-800 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
<button
disabled={disabled || !customValue || Number(customValue) < 0}
onClick={() => onPay(tender.method, Number(customValue))}
className={clsx(
'min-h-[48px] px-5 rounded-xl font-bold text-white disabled:opacity-50 active:scale-[0.97] transition-transform',
tender.className,
)}
>
Take
</button>
</div>
)}
</div>
);
}
return (
<div className="grid grid-cols-4 gap-2">
{TENDERS.map((tender) => (
<button
key={tender.method}
disabled={disabled}
onPointerDown={() => startPress(tender.method)}
onPointerUp={() => endPress(tender.method)}
onPointerLeave={cancelPress}
onPointerCancel={cancelPress}
onContextMenu={(e) => e.preventDefault()}
className={clsx(
'relative min-h-[64px] rounded-2xl text-white font-bold flex flex-col items-center justify-center gap-1 select-none disabled:opacity-50 active:scale-[0.97] transition-transform',
tender.className,
)}
>
<tender.icon className="w-6 h-6" />
<span className="text-xs">{tender.label}</span>
{tender.method !== 'guest' && (
// Visible affordance for the same thing long-press does: staff who
// never discover the hold still find the multiples.
<span
role="button"
aria-label={`${tender.label} quick amounts`}
onPointerDown={(e) => { e.stopPropagation(); cancelPress(); }}
onPointerUp={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); openAmounts(tender.method); }}
className="absolute top-0.5 right-0.5 w-7 h-7 flex items-center justify-center rounded-full text-white/70"
>
<ChevronDownIcon className="w-4 h-4" />
</span>
)}
</button>
))}
</div>
);
}
@@ -0,0 +1,179 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { QrCodeIcon, XMarkIcon, VideoCameraIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
// The camera is a fullscreen overlay opened from the search row, not a tab. It
// only exists while a scan is happening, so it never holds the camera (or the
// screen) while staff are typing a name.
/** Release any camera stream html5-qrcode left attached to a <video> element. */
function stopAllTracks() {
try {
document.querySelectorAll('video').forEach((video) => {
const stream = video.srcObject as MediaStream | null;
if (stream) {
stream.getTracks().forEach((track) => track.stop());
video.srcObject = null;
}
});
} catch {}
}
export function QRScannerOverlay({
onScan,
onClose,
}: {
onScan: (code: string) => void;
onClose: () => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const scannerRef = useRef<any>(null);
const mountedRef = useRef(true);
const elementId = useRef(`qr-scanner-${Date.now()}`);
const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment');
const [ready, setReady] = useState(false);
const destroyScanner = useCallback(async () => {
if (scannerRef.current) {
try { await scannerRef.current.stop(); } catch {}
try { scannerRef.current.clear(); } catch {}
scannerRef.current = null;
}
stopAllTracks();
}, []);
useEffect(() => {
mountedRef.current = true;
let cancelled = false;
const init = async () => {
const container = containerRef.current;
if (!container) return;
const id = elementId.current;
container.innerHTML = '';
const div = document.createElement('div');
div.id = id;
div.style.width = '100%';
div.style.height = '100%';
container.appendChild(div);
try {
const { Html5Qrcode } = await import('html5-qrcode');
if (cancelled) return;
const scanner = new Html5Qrcode(id);
scannerRef.current = scanner;
await scanner.start(
{ facingMode },
{ fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1 },
(decodedText: string) => {
if (mountedRef.current) onScan(decodedText);
},
() => {}
);
if (cancelled) {
await destroyScanner();
return;
}
// Force a layout pass: some browsers leave the video mis-sized until reflow.
requestAnimationFrame(() => {
if (container) {
container.style.display = 'none';
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
container.offsetHeight;
container.style.display = '';
}
if (mountedRef.current) setReady(true);
});
} catch (error) {
console.error('Scanner error:', error);
if (!cancelled && mountedRef.current) {
toast.error('Failed to start camera. Check permissions.');
onClose();
}
}
};
init();
return () => {
cancelled = true;
mountedRef.current = false;
destroyScanner();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [facingMode]);
// Backgrounding the browser suspends the camera; drop it and rebuild on return.
useEffect(() => {
const handleVisibility = () => {
if (document.visibilityState === 'hidden') {
destroyScanner();
} else if (document.visibilityState === 'visible' && mountedRef.current) {
setFacingMode((prev) => {
const temp = prev === 'environment' ? 'user' : 'environment';
setTimeout(() => {
if (mountedRef.current) setFacingMode(prev);
}, 100);
return temp;
});
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, [destroyScanner]);
return (
<div className="fixed inset-0 z-50 bg-black flex flex-col">
<div className="flex-shrink-0 flex items-center justify-between px-4 py-3 safe-area-top">
<p className="text-white font-semibold">Scan ticket</p>
<div className="flex items-center gap-2">
{ready && (
<button
onClick={() => setFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment'))}
className="min-w-[48px] min-h-[48px] flex items-center justify-center bg-white/10 text-white rounded-full active:scale-95 transition-transform"
aria-label="Switch camera"
>
<VideoCameraIcon className="w-6 h-6" />
</button>
)}
<button
onClick={onClose}
className="min-w-[48px] min-h-[48px] flex items-center justify-center bg-white/10 text-white rounded-full active:scale-95 transition-transform"
aria-label="Close scanner"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
</div>
<div className="relative flex-1 min-h-0 overflow-hidden">
<div
ref={containerRef}
className="w-full h-full [&_video]:!object-cover [&_video]:!h-full [&_video]:!w-full"
/>
{!ready && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<div className="text-center">
<QrCodeIcon className="w-16 h-16 mx-auto mb-2 opacity-30" />
<p className="text-sm opacity-60">Starting camera...</p>
</div>
</div>
)}
</div>
<div className="flex-shrink-0 px-6 py-5 pb-safe">
<p className="text-center text-gray-400 text-sm">
Point at the ticket QR it checks in and closes automatically.
</p>
</div>
</div>
);
}
@@ -0,0 +1,240 @@
'use client';
import clsx from 'clsx';
import {
XMarkIcon,
ClockIcon,
QrCodeIcon,
MagnifyingGlassIcon,
UserPlusIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline';
import type { DoorPaymentMethod, DoorSummary } from '@/lib/api';
import { DOOR_PAYMENT_METHODS } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
export interface SessionEntry {
idempotencyKey: string;
ticketId: string;
name: string;
at: string;
entry: 'scan' | 'search' | 'walkin';
method: DoorPaymentMethod | null;
amount: number;
undone: boolean;
failed: boolean;
}
const ENTRY_ICONS = {
scan: QrCodeIcon,
search: MagnifyingGlassIcon,
walkin: UserPlusIcon,
};
const ENTRY_LABELS = {
scan: 'Scanned',
search: 'Search',
walkin: 'Walk-in',
};
const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
cash: 'Cash',
bitcoin: 'Bitcoin',
transfer: 'Transfer',
guest: 'Guest',
};
/** Totals for the current shift, computed from this session's own entries. */
function sessionTotals(entries: SessionEntry[]) {
const totals: Record<DoorPaymentMethod, { count: number; total: number }> = {
cash: { count: 0, total: 0 },
bitcoin: { count: 0, total: 0 },
transfer: { count: 0, total: 0 },
guest: { count: 0, total: 0 },
};
let grand = 0;
for (const entry of entries) {
if (entry.undone || entry.failed || !entry.method) continue;
totals[entry.method].count += 1;
totals[entry.method].total += entry.amount;
grand += entry.amount;
}
return { totals, grand };
}
function CashUpGrid({
totals,
currency,
}: {
totals: Record<DoorPaymentMethod, { count: number; total: number }>;
currency: string;
}) {
return (
<div className="grid grid-cols-2 gap-2">
{DOOR_PAYMENT_METHODS.map((method) => (
<div key={method} className="bg-gray-800 border border-gray-700 rounded-xl px-3 py-2.5">
<p className="text-[11px] uppercase tracking-wide text-gray-500">{METHOD_LABELS[method]}</p>
<p className="font-bold text-white text-base leading-tight">
{method === 'guest' ? `${totals[method].count} free` : formatCurrency(totals[method].total, currency)}
</p>
{method !== 'guest' && (
<p className="text-[11px] text-gray-500">
{totals[method].count} {totals[method].count === 1 ? 'payment' : 'payments'}
</p>
)}
</div>
))}
</div>
);
}
/**
* The end-of-night view: what this shift took, what the whole event day took,
* and the feed of who came in and how.
*/
export function SessionSheet({
entries,
summary,
summaryLoading,
showEventTotals,
currency,
onRefresh,
onClose,
}: {
entries: SessionEntry[];
summary: DoorSummary | null;
summaryLoading: boolean;
/** Whole-event takings are admin/organizer only; door staff see their own shift. */
showEventTotals: boolean;
currency: string;
onRefresh: () => void;
onClose: () => void;
}) {
const { totals, grand } = sessionTotals(entries);
const liveEntries = entries.filter((e) => !e.undone);
return (
<div className="fixed inset-0 z-50 bg-gray-950 flex flex-col" style={{ height: '100dvh' }}>
<header className="flex-shrink-0 bg-gray-900 border-b border-gray-800 px-4 py-3 safe-area-top flex items-center justify-between">
<div>
<p className="font-bold text-white text-lg">Session</p>
<p className="text-xs text-gray-500">{liveEntries.length} checked in from this device</p>
</div>
<div className="flex items-center gap-2">
{showEventTotals && (
<button
onClick={onRefresh}
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
aria-label="Refresh totals"
>
<ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} />
</button>
)}
<button
onClick={onClose}
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
aria-label="Close session view"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
</header>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-5 pb-safe">
{/* This shift */}
<section className="space-y-2">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-bold text-white uppercase tracking-wide">This session</h2>
<p className="text-primary-yellow font-bold">{formatCurrency(grand, currency)}</p>
</div>
<CashUpGrid totals={totals} currency={currency} />
</section>
{/* Whole event, from the server the number to reconcile the cash box
against. Admin/organizer only; the API enforces the same split. */}
{showEventTotals && (
<section className="space-y-2">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Door total, whole event</h2>
<p className="text-primary-yellow font-bold">
{summary ? formatCurrency(summary.door.total, summary.currency) : '—'}
</p>
</div>
{summary ? (
<>
<CashUpGrid totals={summary.door.byMethod} currency={summary.currency} />
<div className="flex items-center justify-between bg-gray-800 border border-gray-700 rounded-xl px-3 py-2.5">
<div>
<p className="text-[11px] uppercase tracking-wide text-gray-500">Pre-sale</p>
<p className="font-bold text-white">
{formatCurrency(summary.presale.total, summary.currency)}
</p>
</div>
<div className="text-right">
<p className="text-[11px] uppercase tracking-wide text-gray-500">Event total</p>
<p className="font-bold text-white">{formatCurrency(summary.total, summary.currency)}</p>
</div>
</div>
</>
) : (
<p className="text-sm text-gray-500">
{summaryLoading ? 'Loading totals…' : 'Totals unavailable — pull to refresh.'}
</p>
)}
</section>
)}
{/* Feed */}
<section className="space-y-2">
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Recent check-ins</h2>
{entries.length === 0 ? (
<div className="text-center text-gray-500 py-10">
<ClockIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">No check-ins yet</p>
</div>
) : (
<div className="space-y-2">
{entries.map((entry) => {
const Icon = ENTRY_ICONS[entry.entry];
return (
<div
key={entry.idempotencyKey}
className={clsx(
'rounded-xl border px-3 py-2.5 flex items-center gap-3',
entry.failed
? 'bg-red-950/40 border-red-900'
: entry.undone
? 'bg-gray-900 border-gray-800 opacity-50'
: 'bg-gray-800 border-gray-700',
)}
>
<Icon className="w-5 h-5 text-gray-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p
className={clsx(
'font-medium truncate',
entry.undone ? 'text-gray-500 line-through' : 'text-white',
)}
>
{entry.name}
</p>
<p className="text-xs text-gray-500 truncate">
{ENTRY_LABELS[entry.entry]}
{entry.method ? ` · ${METHOD_LABELS[entry.method]}` : ''}
{entry.method && entry.method !== 'guest'
? ` ${formatCurrency(entry.amount, currency)}`
: ''}
{entry.failed ? ' · failed' : entry.undone ? ' · undone' : ''}
</p>
</div>
<p className="text-sm text-gray-400 flex-shrink-0">{entry.at}</p>
</div>
);
})}
</div>
)}
</section>
</div>
</div>
);
}
@@ -0,0 +1,157 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { UserPlusIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import type { DoorPaymentMethod } from '@/lib/api';
import { PaymentButtons } from './PaymentButtons';
export interface WalkInDraft {
firstName: string;
lastName: string;
phone: string;
email: string;
ruc: string;
}
export const emptyWalkIn = (firstName = ''): WalkInDraft => ({
firstName,
lastName: '',
phone: '',
email: '',
ruc: '',
});
/**
* The pinned bottom row. Collapsed it is a single tap; expanded it is a first
* name and four tenders. Email, phone and RUC live behind "Add details" so the
* rare person who wants a receipt never slows down the queue behind them.
*/
export function WalkInRow({
typedText,
expanded,
draft,
price,
currency,
busy,
onExpand,
onChange,
onPay,
onCancel,
}: {
typedText: string;
expanded: boolean;
draft: WalkInDraft;
price: number;
currency: string;
busy: boolean;
onExpand: () => void;
onChange: (draft: WalkInDraft) => void;
onPay: (method: DoorPaymentMethod, amount: number) => void;
onCancel: () => void;
}) {
const [detailsOpen, setDetailsOpen] = useState(false);
const firstNameRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (expanded) firstNameRef.current?.focus();
}, [expanded]);
useEffect(() => {
if (!expanded) setDetailsOpen(false);
}, [expanded]);
if (!expanded) {
return (
<button
onClick={onExpand}
className="w-full min-h-[64px] px-4 py-3 rounded-2xl border-2 border-dashed border-primary-yellow/50 bg-primary-yellow/5 text-left flex items-center gap-3 active:scale-[0.99] transition-transform"
>
<UserPlusIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<span className="font-bold text-primary-yellow truncate">
{typedText ? `Add "${typedText}" as walk-in` : 'Add a walk-in'}
</span>
</button>
);
}
const field = (key: keyof WalkInDraft, value: string) => onChange({ ...draft, [key]: value });
return (
<div className="rounded-2xl border-2 border-primary-yellow/50 bg-gray-800 p-3 space-y-2">
<div className="flex items-center justify-between px-1">
<p className="font-bold text-primary-yellow">New walk-in</p>
<button onClick={onCancel} className="text-sm text-gray-400 min-h-[48px] px-2 active:text-white">
Cancel
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<input
ref={firstNameRef}
value={draft.firstName}
onChange={(e) => field('firstName', e.target.value)}
placeholder="First name"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
className="min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
<input
value={draft.lastName}
onChange={(e) => field('lastName', e.target.value)}
placeholder="Last name (optional)"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
className="min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<input
value={draft.phone}
onChange={(e) => field('phone', e.target.value)}
placeholder="Phone (optional)"
inputMode="tel"
autoComplete="off"
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
<button
onClick={() => setDetailsOpen((open) => !open)}
className="w-full min-h-[48px] flex items-center justify-between px-2 text-sm text-gray-400 active:text-white"
>
Add details (email, RUC)
<ChevronDownIcon className={clsx('w-4 h-4 transition-transform', detailsOpen && 'rotate-180')} />
</button>
{detailsOpen && (
<div className="space-y-2">
<input
value={draft.email}
onChange={(e) => field('email', e.target.value)}
placeholder="Email — sends the usual confirmation"
inputMode="email"
autoComplete="off"
autoCapitalize="none"
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
<input
value={draft.ruc}
onChange={(e) => field('ruc', e.target.value)}
placeholder="RUC (for factura)"
autoComplete="off"
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
)}
<PaymentButtons
price={price}
currency={currency}
onPay={onPay}
disabled={busy || !draft.firstName.trim()}
/>
</div>
);
}
@@ -0,0 +1,56 @@
// Firing door actions without ever blocking the queue of people at the door.
//
// The UI flashes green and clears the input the moment staff taps; the write
// happens here, in the background, with retries. Venue wifi drops constantly, so
// every action carries an idempotency key: a retry that actually succeeded the
// first time returns the original result instead of double-charging anyone.
import { doorApi, type DoorCheckinRequest, type DoorCheckinResponse } from '@/lib/api';
/** UUID per action. crypto.randomUUID needs a secure context; fall back when absent. */
export function newIdempotencyKey(): string {
const cryptoRef = typeof crypto !== 'undefined' ? crypto : undefined;
if (cryptoRef?.randomUUID) return cryptoRef.randomUUID();
return `door-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
}
// Roughly 5 seconds of retrying in total — long enough to ride out a wifi blip,
// short enough that staff learn about a real failure while the person is still
// in front of them.
const RETRY_DELAYS_MS = [400, 1200, 3000];
/**
* Errors worth retrying are the ones a retry can fix: network failures, gateway
* errors, rate limits. A 400 "ticket belongs to a different event" will fail
* identically forever, so it surfaces immediately.
*/
function isRetryable(error: any): boolean {
const status = error?.status;
if (typeof status === 'number') return status >= 500 || status === 408 || status === 429;
// No status at all means the request never reached the server (fetch rejects
// with a TypeError when the connection drops) — exactly the case to retry.
return true;
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function submitDoorAction(
eventId: string,
body: DoorCheckinRequest,
): Promise<DoorCheckinResponse> {
let lastError: any;
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
try {
return await doorApi.checkin(eventId, body);
} catch (error: any) {
lastError = error;
if (attempt === RETRY_DELAYS_MS.length || !isRetryable(error)) break;
await sleep(RETRY_DELAYS_MS[attempt]);
}
}
throw lastError;
}
export async function undoDoorAction(eventId: string, idempotencyKey: string): Promise<void> {
await doorApi.undo(eventId, idempotencyKey);
}
@@ -0,0 +1,41 @@
// Haptic + audio confirmation. At a loud, dark door the sound and the buzz are
// what staff actually register — the green flash is confirmation for the person
// standing in front of them.
export function playSuccessSound() {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
osc.type = 'sine';
gain.gain.value = 0.3;
osc.start();
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15);
osc.stop(ctx.currentTime + 0.15);
} catch {}
}
export function playErrorSound() {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 300;
osc.type = 'square';
gain.gain.value = 0.2;
osc.start();
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
osc.stop(ctx.currentTime + 0.3);
} catch {}
}
export function vibrate(pattern: number | number[]) {
try {
if (navigator.vibrate) navigator.vibrate(pattern);
} catch {}
}
@@ -0,0 +1,170 @@
// Door search: runs entirely in memory over the preloaded attendee list, so
// typing never touches the network.
//
// The rules exist because of who is standing at the door. People say "Jose" for
// José and "Nunez" for Núñez, so both sides are stripped of diacritics. They give
// a surname first as often as a first name, so every word is matched
// independently. Two people called María are told apart by the last digits of a
// phone number, so a mostly-numeric query searches phone digits instead of names.
import type { DoorAttendee } from '@/lib/api';
/** Lowercase and strip combining marks, so "José" and "jose" are the same string. */
export function normalize(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim();
}
export function digitsOnly(value: string): string {
return value.replace(/\D/g, '');
}
/** Precomputed per attendee once per list load; recomputing per keystroke is what makes search feel slow. */
export interface DoorSearchIndex {
words: string[];
full: string;
phoneDigits: string;
email: string;
}
export function buildIndex(attendee: DoorAttendee): DoorSearchIndex {
const first = normalize(attendee.firstName || '');
const last = normalize(attendee.lastName || '');
const full = `${first} ${last}`.trim();
return {
words: full.split(/\s+/).filter(Boolean),
full,
phoneDigits: digitsOnly(attendee.phone || ''),
email: normalize(attendee.email || ''),
};
}
/**
* A query is treated as a phone lookup when it is mostly digits staff asking
* "what are the last four of your number?" type exactly that and nothing else.
*/
export function isPhoneQuery(query: string): boolean {
const compact = query.replace(/\s/g, '');
if (compact.length < 3) return false;
const digits = digitsOnly(compact);
return digits.length >= 3 && digits.length / compact.length >= 0.6;
}
/**
* Bounded Damerau-Levenshtein: true when one insert, delete, substitution or
* swap of adjacent letters apart. The swap matters "Jhon" for John is the
* single commonest way a name gets mistyped, and plain Levenshtein scores it 2.
*/
function withinOneEdit(a: string, b: string): boolean {
const la = a.length;
const lb = b.length;
if (Math.abs(la - lb) > 1) return false;
let i = 0;
let j = 0;
let edits = 0;
while (i < la && j < lb) {
if (a[i] === b[j]) {
i++;
j++;
continue;
}
if (++edits > 1) return false;
if (la > lb) i++;
else if (lb > la) j++;
else if (a[i + 1] === b[j] && a[i] === b[j + 1]) {
// Adjacent letters swapped: consume both and count it as the one edit.
i += 2;
j += 2;
} else {
i++;
j++;
}
}
return edits + (la - i) + (lb - j) <= 1;
}
// Lower tier ranks first.
const TIER_PREFIX = 0;
const TIER_SUBSTRING = 1;
const TIER_FUZZY = 2;
/** Best match tier for one attendee, or null when the query does not match at all. */
export function matchTier(index: DoorSearchIndex, query: string, phoneMode: boolean): number | null {
if (phoneMode) {
const digits = digitsOnly(query);
if (!index.phoneDigits || !digits) return null;
// Substring, so a query of the last four digits matches +595 981 234 567.
return index.phoneDigits.includes(digits) ? TIER_PREFIX : null;
}
if (index.full.startsWith(query)) return TIER_PREFIX;
if (index.words.some((word) => word.startsWith(query))) return TIER_PREFIX;
if (index.full.includes(query)) return TIER_SUBSTRING;
// Email is a fallback, not a way staff normally searches, and a one- or
// two-letter query would match almost every address — so it needs 3 characters.
if (query.length >= 3 && index.email && index.email.includes(query)) return TIER_SUBSTRING;
// Typo tolerance is the last resort: "jhon" still finds John, but only after
// every real prefix and substring match has been listed.
if (query.length >= 4 && index.words.some((word) => withinOneEdit(word, query))) return TIER_FUZZY;
return null;
}
/** Attendees who are still waiting to come in, shown when the input is empty. */
const OPEN_STATUSES = new Set(['confirmed', 'pending', 'on_hold']);
export interface IndexedAttendee {
attendee: DoorAttendee;
index: DoorSearchIndex;
}
/**
* Sort weight for equal match quality: people still to come in first, then those
* already inside, then cancelled tickets last a cancelled row must never sit
* above a valid one that matches just as well.
*/
function stateWeight(attendee: DoorAttendee): number {
if (attendee.status === 'cancelled') return 2;
return attendee.checkedIn ? 1 : 0;
}
function byRank(
a: { attendee: DoorAttendee; tier: number },
b: { attendee: DoorAttendee; tier: number },
): number {
if (a.tier !== b.tier) return a.tier - b.tier;
const stateDiff = stateWeight(a.attendee) - stateWeight(b.attendee);
if (stateDiff !== 0) return stateDiff;
return a.attendee.fullName.localeCompare(b.attendee.fullName, undefined, { sensitivity: 'base' });
}
export function searchAttendees(indexed: IndexedAttendee[], rawQuery: string): DoorAttendee[] {
const query = normalize(rawQuery);
// Empty input is the small-event case: everyone still to come in, alphabetical,
// so staff can scroll and tap without typing anything at all.
if (!query) {
return indexed
.filter(({ attendee }) => OPEN_STATUSES.has(attendee.status) && !attendee.checkedIn)
.map(({ attendee }) => attendee)
.sort((a, b) => a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' }));
}
const phoneMode = isPhoneQuery(rawQuery);
const scored: { attendee: DoorAttendee; tier: number }[] = [];
for (const entry of indexed) {
const tier = matchTier(entry.index, query, phoneMode);
if (tier !== null) scored.push({ attendee: entry.attendee, tier });
}
return scored.sort(byRank).map((s) => s.attendee);
}
/** True when the typed text already names somebody exactly — no walk-in row needed. */
export function hasExactMatch(results: DoorAttendee[], rawQuery: string): boolean {
const query = normalize(rawQuery);
if (!query) return true;
return results.some((a) => normalize(a.fullName) === query || normalize(a.firstName) === query);
}
File diff suppressed because it is too large Load Diff
+10 -73
View File
@@ -9,26 +9,13 @@ import Button from '@/components/ui/Button';
import { AdminPageSkeleton } from '@/components/ui/Skeleton'; import { AdminPageSkeleton } from '@/components/ui/Skeleton';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents'; import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
import Pagination from '@/components/admin/Pagination';
type RegisteredRange = '' | '7d' | '30d' | '90d'; type RegisteredRange = '' | '7d' | '30d' | '90d';
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
function getPageNumbers(current: number, totalPages: number): (number | '...')[] {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
const pages: (number | '...')[] = [1];
const start = Math.max(2, current - 1);
const end = Math.min(totalPages - 1, current + 1);
if (start > 2) pages.push('...');
for (let i = start; i <= end; i++) pages.push(i);
if (end < totalPages - 1) pages.push('...');
pages.push(totalPages);
return pages;
}
function registeredAfterFromRange(range: RegisteredRange): string | undefined { function registeredAfterFromRange(range: RegisteredRange): string | undefined {
if (!range) return undefined; if (!range) return undefined;
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90; const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
@@ -418,64 +405,14 @@ export default function AdminUsersPage() {
)} )}
</div> </div>
{/* Pagination */} <Pagination
{total > 0 && ( id="users"
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-3"> page={page}
<div className="flex items-center gap-2 text-sm text-gray-600"> pageSize={pageSize}
<label htmlFor="users-page-size" className="whitespace-nowrap">Per page</label> total={total}
<select onPageChange={setPage}
id="users-page-size" onPageSizeChange={setPageSize}
value={pageSize} />
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
className="px-2 py-1.5 rounded-btn border border-secondary-light-gray text-sm"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span className="text-xs text-gray-500 whitespace-nowrap">
{(page - 1) * pageSize + 1}&ndash;{Math.min(page * pageSize, total)} of {total}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(page - 1)}
disabled={page <= 1}
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
aria-label="Previous page"
>
<ChevronLeftIcon className="w-4 h-4" />
</button>
{getPageNumbers(page, Math.max(1, Math.ceil(total / pageSize))).map((p, i) =>
p === '...' ? (
<span key={`ellipsis-${i}`} className="px-1.5 text-sm text-gray-400">&hellip;</span>
) : (
<button
key={p}
onClick={() => setPage(p)}
className={clsx(
'min-h-[36px] min-w-[36px] px-2 rounded-btn text-sm',
p === page
? 'bg-primary-yellow text-primary-dark font-semibold'
: 'border border-secondary-light-gray text-gray-600 hover:bg-gray-50'
)}
aria-current={p === page ? 'page' : undefined}
>
{p}
</button>
)
)}
<button
onClick={() => setPage(page + 1)}
disabled={page >= Math.ceil(total / pageSize)}
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
aria-label="Next page"
>
<ChevronRightIcon className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Mobile Filter BottomSheet */} {/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters"> <BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
+23 -5
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { safeInternalPath } from '@/lib/safeRedirect'; import { safeInternalPath } from '@/lib/safeRedirect';
import { redirectAfterAuth } from '@/lib/authRedirect';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
declare global { declare global {
@@ -83,12 +84,29 @@ export default function GoogleSignInButton({
toast.success(locale === 'es' ? 'Bienvenido!' : 'Welcome!'); toast.success(locale === 'es' ? 'Bienvenido!' : 'Welcome!');
onSuccess?.(); onSuccess?.();
// Use window.location for navigation to ensure clean state. // Full page load for a clean state, recording the attempt so the login page can
// Constrain to a same-origin path to avoid open redirects. // tell a bounced redirect from a fresh visit. Constrained to a same-origin path
window.location.href = safeInternalPath(redirectTo, '/dashboard'); // to avoid open redirects.
redirectAfterAuth(safeInternalPath(redirectTo, '/dashboard'));
} catch (error: unknown) { } catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Google login failed'; // better-auth returns OAUTH_LINK_ERROR (message: "account not linked")
const displayError = locale === 'es' ? 'Error al iniciar sesion con Google' : errorMessage; // when it refuses to attach the Google identity to the existing user
// row for that address. The backend now links unverified local rows
// (see lib/betterAuth.ts accountLinking), so this should be
// unreachable — but a bare "account not linked" toast is a dead end,
// so keep an actionable fallback rather than a generic one.
const isLinkError = (error as { code?: string } | null)?.code === 'OAUTH_LINK_ERROR';
const errorMessage = isLinkError
? 'This email is already registered. Sign in with your password, or use the "Email Link" option on the login page.'
: error instanceof Error
? error.message
: 'Google login failed';
const displayError =
locale === 'es'
? isLinkError
? 'Este correo ya esta registrado. Inicia sesion con tu contrasena o usa la opcion "Enlace por correo".'
: 'Error al iniciar sesion con Google'
: errorMessage;
onError?.(displayError); onError?.(displayError);
toast.error(displayError); toast.error(displayError);
} finally { } finally {
+154 -53
View File
@@ -1,18 +1,21 @@
'use client'; 'use client';
import { useEffect, useCallback, useState } from 'react'; import { useEffect, useCallback, useRef, useState } from 'react';
import { import {
XMarkIcon, XMarkIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
ArrowDownTrayIcon, ArrowDownTrayIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import Spinner from '@/components/ui/Spinner';
export interface LightboxItem { export interface LightboxItem {
id: string; id: string;
previewUrl: string; previewUrl: string;
downloadUrl: string; downloadUrl: string;
filename?: string; filename?: string;
/** Small thumbnail for the filmstrip; falls back to previewUrl. */
thumbUrl?: string;
} }
interface LightboxProps { interface LightboxProps {
@@ -22,15 +25,35 @@ interface LightboxProps {
onNavigate: (index: number) => void; onNavigate: (index: number) => void;
/** Extra per-item action buttons rendered in the top bar (admin use). */ /** Extra per-item action buttons rendered in the top bar (admin use). */
renderActions?: (item: LightboxItem, index: number) => React.ReactNode; renderActions?: (item: LightboxItem, index: number) => React.ReactNode;
/**
* Handles the download in JS instead of navigating, so the button can show
* progress. Without it the button stays a plain <a download>.
*/
onDownload?: (item: LightboxItem) => void;
/** Id of the item currently downloading (pairs with onDownload). */
downloadingId?: string | null;
downloadLabels?: { download: string; downloading: string };
} }
/** /**
* Full-screen photo lightbox with keyboard and swipe navigation, in the * Full-screen photo lightbox with keyboard and swipe navigation, in the
* style of the admin gallery preview modal (fixed inset-0 bg-black/90). * style of the admin gallery preview modal (fixed inset-0 bg-black/90).
*/ */
export default function Lightbox({ items, index, onClose, onNavigate, renderActions }: LightboxProps) { export default function Lightbox({
const [touchStartX, setTouchStartX] = useState<number | null>(null); items,
index,
onClose,
onNavigate,
renderActions,
onDownload,
downloadingId,
downloadLabels,
}: LightboxProps) {
const touchStart = useRef<{ x: number; y: number } | null>(null);
const activeThumbRef = useRef<HTMLButtonElement | null>(null);
const item = items[index]; const item = items[index];
const hasMultiple = items.length > 1;
const downloading = !!downloadingId && item?.id === downloadingId;
const prev = useCallback(() => { const prev = useCallback(() => {
onNavigate(index > 0 ? index - 1 : items.length - 1); onNavigate(index > 0 ? index - 1 : items.length - 1);
@@ -53,19 +76,33 @@ export default function Lightbox({ items, index, onClose, onNavigate, renderActi
}; };
}, [onClose, prev, next]); }, [onClose, prev, next]);
// Keep the active thumbnail centred in the filmstrip as you navigate.
useEffect(() => {
activeThumbRef.current?.scrollIntoView({
behavior: 'smooth',
inline: 'center',
block: 'nearest',
});
}, [index]);
if (!item) return null; if (!item) return null;
return ( return (
<div <div
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center" className="fixed inset-0 bg-black/90 z-50 flex flex-col"
onClick={onClose} onClick={onClose}
onTouchStart={(e) => setTouchStartX(e.touches[0].clientX)} onTouchStart={(e) => {
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}}
onTouchEnd={(e) => { onTouchEnd={(e) => {
if (touchStartX === null) return; if (touchStart.current === null) return;
const dx = e.changedTouches[0].clientX - touchStartX; const dx = e.changedTouches[0].clientX - touchStart.current.x;
if (dx > 60) prev(); const dy = e.changedTouches[0].clientY - touchStart.current.y;
if (dx < -60) next(); touchStart.current = null;
setTouchStartX(null); // Only treat as a swipe when it's clearly horizontal.
if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy)) return;
if (dx > 0) prev();
else next();
}} }}
> >
<button <button
@@ -80,58 +117,122 @@ export default function Lightbox({ items, index, onClose, onNavigate, renderActi
className="absolute top-4 left-4 z-10 flex items-center gap-4" className="absolute top-4 left-4 z-10 flex items-center gap-4"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<a {onDownload ? (
href={item.downloadUrl} <button
download={item.filename || true} type="button"
className="text-white hover:text-gray-300" onClick={() => onDownload(item)}
aria-label="Download" disabled={downloading}
> aria-busy={downloading}
<ArrowDownTrayIcon className="w-7 h-7" /> aria-label={
</a> downloading
? downloadLabels?.downloading || 'Downloading…'
: downloadLabels?.download || 'Download'
}
className={`text-white hover:text-gray-300 flex items-center ${
downloading ? 'cursor-wait' : ''
}`}
>
{downloading ? (
<Spinner className="w-6 h-6" />
) : (
<ArrowDownTrayIcon className="w-7 h-7" />
)}
</button>
) : (
<a
href={item.downloadUrl}
download={item.filename || true}
className="text-white hover:text-gray-300"
aria-label="Download"
>
<ArrowDownTrayIcon className="w-7 h-7" />
</a>
)}
{renderActions?.(item, index)} {renderActions?.(item, index)}
</div> </div>
{items.length > 1 && ( {/* Main image area */}
<> <div className="relative flex-1 min-h-0 flex items-center justify-center">
<button {hasMultiple && (
className="absolute left-2 md:left-4 text-white/80 hover:text-white z-10 p-2" <>
onClick={(e) => { <button
e.stopPropagation(); className="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white z-10 p-2"
prev(); onClick={(e) => {
}} e.stopPropagation();
aria-label="Previous" prev();
> }}
<ChevronLeftIcon className="w-9 h-9" /> aria-label="Previous"
</button> >
<button <ChevronLeftIcon className="w-9 h-9" />
className="absolute right-2 md:right-4 text-white/80 hover:text-white z-10 p-2" </button>
onClick={(e) => { <button
e.stopPropagation(); className="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white z-10 p-2"
next(); onClick={(e) => {
}} e.stopPropagation();
aria-label="Next" next();
> }}
<ChevronRightIcon className="w-9 h-9" /> aria-label="Next"
</button> >
</> <ChevronRightIcon className="w-9 h-9" />
)} </button>
</>
)}
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img {/* Nothing is layered over the image (the navigation buttons sit at
src={item.previewUrl} the edges), and neither -webkit-touch-callout nor -webkit-user-
alt="" select is suppressed here, so iOS long-press "Save to Photos"
className="max-w-[95vw] max-h-[90vh] object-contain select-none" still works as a backup to the save sheet. Don't add select-none. */}
<img
src={item.previewUrl}
alt=""
className="max-w-[95vw] max-h-full object-contain [-webkit-touch-callout:default]"
onClick={(e) => e.stopPropagation()}
draggable={false}
/>
</div>
{/* Counter + thumbnail filmstrip you can skip through. */}
<div
className="shrink-0 pb-3 pt-2"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
draggable={false} onTouchStart={(e) => e.stopPropagation()}
/> onTouchEnd={(e) => e.stopPropagation()}
>
<div className="absolute bottom-3 inset-x-0 text-center text-white/70 text-sm"> <div className="text-center text-white/70 text-sm mb-2">
{index + 1} / {items.length} {index + 1} / {items.length}
</div>
{hasMultiple && (
<div className="flex gap-2 overflow-x-auto px-4 pb-1 justify-start sm:justify-center [scrollbar-width:thin]">
{items.map((it, i) => (
<button
key={it.id}
ref={i === index ? activeThumbRef : null}
onClick={() => onNavigate(i)}
aria-label={`Photo ${i + 1}`}
aria-current={i === index}
className={`relative shrink-0 overflow-hidden rounded transition ${
i === index
? 'ring-2 ring-white opacity-100'
: 'opacity-50 hover:opacity-90'
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={it.thumbUrl || it.previewUrl}
alt=""
className="h-14 w-14 sm:h-16 sm:w-16 object-cover"
draggable={false}
/>
</button>
))}
</div>
)}
</div> </div>
{/* Preload neighbours so prev/next feels instant. */} {/* Preload neighbours so prev/next feels instant. */}
<div className="hidden" aria-hidden> <div className="hidden" aria-hidden>
{items.length > 1 && ( {hasMultiple && (
<> <>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src={items[(index + 1) % items.length].previewUrl} alt="" /> <img src={items[(index + 1) % items.length].previewUrl} alt="" />
+190
View File
@@ -0,0 +1,190 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import GoogleSignInButton from '@/components/GoogleSignInButton';
import { authApi } from '@/lib/api';
import { XMarkIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
interface LoginModalProps {
open: boolean;
onClose: () => void;
/** Called after a successful password login (Google logins reload the page). */
onSuccess?: () => void;
/** Optional context line shown under the title (e.g. why login is needed). */
message?: string;
}
/**
* Reusable login pop-up: same capabilities as /login (password,
* magic link, Google) but inline, so the visitor stays on the page
* used by gated photo galleries.
*/
export default function LoginModal({ open, onClose, onSuccess, message }: LoginModalProps) {
const { t, locale } = useLanguage();
const es = locale === 'es';
const { login } = useAuth();
const pathname = usePathname();
const searchParams = useSearchParams();
const [mode, setMode] = useState<'password' | 'magic-link'>('password');
const [magicLinkSent, setMagicLinkSent] = useState(false);
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// Google logins hard-reload; send them back to exactly where they are
// (including a ?token= share link).
const query = searchParams.toString();
const currentUrl = query ? `${pathname}?${query}` : pathname;
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [open, onClose]);
if (!open) return null;
const handlePasswordLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await login(email, password);
toast.success(es ? '¡Bienvenido!' : 'Welcome back!');
onClose();
onSuccess?.();
} catch (error) {
toast.error(error instanceof Error ? error.message : t('auth.errors.invalidCredentials'));
} finally {
setLoading(false);
}
};
const handleMagicLink = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
toast.error(es ? 'Ingresa tu email' : 'Please enter your email');
return;
}
setLoading(true);
try {
await authApi.requestMagicLink(email);
setMagicLinkSent(true);
toast.success(es ? 'Revisa tu correo para el enlace de acceso' : 'Check your email for the login link');
} catch (error) {
toast.error(error instanceof Error ? error.message : es ? 'Error' : 'Failed');
} finally {
setLoading(false);
}
};
return (
<div
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
>
<Card className="w-full max-w-md p-6 md:p-8" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between mb-1">
<h2 className="text-xl font-bold text-primary-dark">{t('auth.login.title')}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 p-1 -m-1" aria-label="Close">
<XMarkIcon className="w-6 h-6" />
</button>
</div>
{message && <p className="text-sm text-gray-600 mb-4">{message}</p>}
<div className="mt-4">
<GoogleSignInButton redirectTo={currentUrl} />
</div>
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-secondary-light-gray" />
<span className="text-xs text-gray-500">{es ? 'o' : 'or'}</span>
<div className="flex-1 h-px bg-secondary-light-gray" />
</div>
{mode === 'password' ? (
<form onSubmit={handlePasswordLogin} className="space-y-4">
<Input
id="login-modal-email"
label={t('auth.login.email')}
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input
id="login-modal-password"
label={t('auth.login.password')}
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button type="submit" className="w-full" isLoading={loading}>
{t('auth.login.submit')}
</Button>
</form>
) : magicLinkSent ? (
<p className="text-sm text-gray-600 text-center py-4">
{es
? 'Te enviamos un enlace de acceso. Abre tu correo y vuelve a esta página.'
: 'We sent you a login link. Open your email and come back to this page.'}
</p>
) : (
<form onSubmit={handleMagicLink} className="space-y-4">
<Input
id="login-modal-magic-email"
label={t('auth.login.email')}
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Button type="submit" className="w-full" isLoading={loading}>
{es ? 'Enviarme un enlace de acceso' : 'Email me a login link'}
</Button>
</form>
)}
<div className="mt-4 flex items-center justify-between text-sm">
<button
type="button"
onClick={() => {
setMode(mode === 'password' ? 'magic-link' : 'password');
setMagicLinkSent(false);
}}
className="text-secondary-blue hover:underline"
>
{mode === 'password'
? es
? 'Entrar con enlace por correo'
: 'Log in with an email link'
: es
? 'Entrar con contraseña'
: 'Log in with a password'}
</button>
<Link href="/register" className="text-secondary-blue hover:underline" onClick={onClose}>
{es ? 'Crear cuenta' : 'Create account'}
</Link>
</div>
</Card>
</div>
);
}
@@ -0,0 +1,119 @@
'use client';
import { useEffect } from 'react';
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
/**
* Page buttons to show: first, last, the current page and its neighbours, with
* ellipses standing in for the gaps once there are more than 7 pages.
*/
export function getPageNumbers(current: number, totalPages: number): (number | '...')[] {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
const pages: (number | '...')[] = [1];
const start = Math.max(2, current - 1);
const end = Math.min(totalPages - 1, current + 1);
if (start > 2) pages.push('...');
for (let i = start; i <= end; i++) pages.push(i);
if (end < totalPages - 1) pages.push('...');
pages.push(totalPages);
return pages;
}
/**
* Slices a list for client-side pagination and keeps the page in range when the
* list shrinks underneath it (filter change, deletion, refresh).
*/
export function usePaginatedList<T>(items: T[], page: number, pageSize: number, setPage: (page: number) => void) {
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages, setPage]);
const safePage = Math.min(page, totalPages);
return items.slice((safePage - 1) * pageSize, safePage * pageSize);
}
interface PaginationProps {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
/** Unique per page — the per-page <select> needs its own id for the label. */
id: string;
className?: string;
}
export default function Pagination({
page,
pageSize,
total,
onPageChange,
onPageSizeChange,
id,
className,
}: PaginationProps) {
if (total === 0) return null;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div className={clsx('mt-4 flex flex-col sm:flex-row items-center justify-between gap-3', className)}>
<div className="flex items-center gap-2 text-sm text-gray-600">
<label htmlFor={`${id}-page-size`} className="whitespace-nowrap">Per page</label>
<select
id={`${id}-page-size`}
value={pageSize}
onChange={(e) => { onPageSizeChange(Number(e.target.value)); onPageChange(1); }}
className="px-2 py-1.5 rounded-btn border border-secondary-light-gray text-sm"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span className="text-xs text-gray-500 whitespace-nowrap">
{(page - 1) * pageSize + 1}&ndash;{Math.min(page * pageSize, total)} of {total}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
aria-label="Previous page"
>
<ChevronLeftIcon className="w-4 h-4" />
</button>
{getPageNumbers(page, totalPages).map((p, i) =>
p === '...' ? (
<span key={`ellipsis-${i}`} className="px-1.5 text-sm text-gray-400">&hellip;</span>
) : (
<button
key={p}
onClick={() => onPageChange(p)}
className={clsx(
'min-h-[36px] min-w-[36px] px-2 rounded-btn text-sm',
p === page
? 'bg-primary-yellow text-primary-dark font-semibold'
: 'border border-secondary-light-gray text-gray-600 hover:bg-gray-50'
)}
aria-current={p === page ? 'page' : undefined}
>
{p}
</button>
)
)}
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
aria-label="Next page"
>
<ChevronRightIcon className="w-4 h-4" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,46 @@
// Layout shell shared by the public gallery page (GalleryClient) and its
// loading placeholder (GallerySkeleton). Column count, gaps, radii and
// container padding are defined once here, so the skeleton's geometry can
// never drift from the grid it stands in for.
export function GalleryHeroFrame({
children,
backdrop,
}: {
children: React.ReactNode;
/** Cover image + scrim, absolutely positioned behind the text. */
backdrop?: React.ReactNode;
}) {
return (
<div className="relative bg-brand-navy overflow-hidden">
{backdrop}
<div className="relative container-page px-4 pt-20 pb-8 md:pt-32 md:pb-12">{children}</div>
</div>
);
}
export function GalleryContainer({ children }: { children: React.ReactNode }) {
return <div className="container-page px-2 sm:px-4 py-4 md:py-8">{children}</div>;
}
export function MasonryGrid({ children }: { children: React.ReactNode }) {
return (
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]">
{children}
</div>
);
}
// Per-tile geometry. Deliberately carries no background so callers can pick
// one (photo tiles: bg-gray-100, skeleton tiles: the skeleton surface) without
// two `bg-*` utilities fighting over CSS order.
export const masonryTileClass = 'mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl';
/**
* Reserves the tile's aspect ratio up front so the image can load into a box
* that is already the right size late arrivals never reflow the columns.
* Returns undefined when the photo has no stored dimensions.
*/
export function aspectStyle(width?: number, height?: number): React.CSSProperties | undefined {
return width && height ? { aspectRatio: `${width} / ${height}` } : undefined;
}
@@ -0,0 +1,59 @@
import clsx from 'clsx';
import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton';
import { GalleryContainer, GalleryHeroFrame, MasonryGrid, masonryTileClass } from './GalleryLayout';
// Loading placeholder for the public gallery page. It renders through the same
// hero frame, container and masonry grid as the real page (see GalleryLayout),
// so replacing it with photos changes only the pixels inside the tiles.
// Portrait/landscape/square mix standing in for a real event set. Fixed order
// on purpose — a random shuffle would differ between the server and client
// render and blow up hydration.
const FALLBACK_RATIOS = [3 / 4, 4 / 3, 1, 2 / 3, 3 / 2, 4 / 5, 1, 3 / 4, 16 / 9, 4 / 5, 3 / 4, 4 / 3];
interface GallerySkeletonProps {
/** Number of tiles to draw; ignored when `ratios` is given. */
count?: number;
/**
* Real width/height ratios when the caller already knows them (e.g. a
* cached gallery payload). Produces a grid with exactly the right geometry
* instead of the guessed mix above.
*/
ratios?: number[];
}
export default function GallerySkeleton({ count = 12, ratios }: GallerySkeletonProps) {
const tiles =
ratios && ratios.length > 0
? ratios
: Array.from({ length: count }, (_, i) => FALLBACK_RATIOS[i % FALLBACK_RATIOS.length]);
return (
<SkeletonGroup>
<GalleryHeroFrame>
{/* Matches the h1 (text-3xl / md:text-5xl) and the pill row below it. */}
<Skeleton tone="on-dark" className="h-9 md:h-12 w-2/3 max-w-md" />
<div className="mt-4 flex flex-wrap items-center gap-2">
<Skeleton tone="on-dark" className="h-7 w-28 rounded-full" />
<Skeleton tone="on-dark" className="h-7 w-56 max-w-[60%] rounded-full" />
</div>
</GalleryHeroFrame>
<GalleryContainer>
<MasonryGrid>
{tiles.map((ratio, i) => (
<div
key={i}
aria-hidden="true"
className={clsx(
masonryTileClass,
'bg-secondary-light-gray/70 animate-pulse motion-reduce:animate-none'
)}
style={{ aspectRatio: `${ratio}` }}
/>
))}
</MasonryGrid>
</GalleryContainer>
</SkeletonGroup>
);
}
@@ -0,0 +1,102 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import type { Photo } from '@/lib/api';
import Spinner from '@/components/ui/Spinner';
import { aspectStyle, masonryTileClass } from './GalleryLayout';
interface PhotoTileProps {
photo: Photo;
/** Above-the-fold tiles load eagerly, the rest lazily. */
eager?: boolean;
onOpen: () => void;
onDownload: () => void;
downloading: boolean;
labels: { download: string; downloading: string };
}
export default function PhotoTile({
photo,
eager,
onOpen,
onDownload,
downloading,
labels,
}: PhotoTileProps) {
const imgRef = useRef<HTMLImageElement>(null);
// 'initial' is what the server renders: fully visible, so a public gallery
// still paints its photos if hydration is slow or JS never arrives. The
// fade only takes over once we're mounted and know the image is still in
// flight — a cached image reports `complete` here and skips it entirely
// (its onLoad already fired before React attached the handler).
const [state, setState] = useState<'initial' | 'pending' | 'loaded'>('initial');
const pending = state === 'pending';
useEffect(() => {
setState(imgRef.current?.complete ? 'loaded' : 'pending');
}, []);
return (
<div
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen();
}
}}
className={clsx(
masonryTileClass,
'group relative bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow'
)}
/* Sized from the stored dimensions before the bytes arrive, so a slow
image never pushes its column around. */
style={aspectStyle(photo.width, photo.height)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imgRef}
src={photo.urls.thumb}
alt=""
loading={eager ? 'eager' : 'lazy'}
onLoad={() => setState('loaded')}
onError={() => setState('loaded')}
className={clsx(
'w-full h-auto transition-[opacity,transform] duration-300 motion-reduce:transition-none group-hover:scale-[1.03]',
pending ? 'opacity-0' : 'opacity-100'
)}
/>
{pending && (
<span
aria-hidden="true"
className="absolute inset-0 bg-secondary-light-gray/70 animate-pulse motion-reduce:animate-none"
/>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDownload();
}}
disabled={downloading}
aria-busy={downloading}
aria-label={downloading ? labels.downloading : labels.download}
className={clsx(
'absolute bottom-2 right-2 flex p-2.5 md:p-2 rounded-full bg-black/50 text-white transition-opacity hover:bg-black/80 focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary-yellow',
// Touch devices have no hover to reveal it, and this is where the
// button matters most — it is the entry point to the save sheet.
downloading
? 'opacity-100 cursor-wait hover:bg-black/50'
: 'opacity-100 md:opacity-0 md:group-hover:opacity-100'
)}
>
{downloading ? <Spinner /> : <ArrowDownTrayIcon className="w-5 h-5 md:w-4 md:h-4" />}
</button>
</div>
);
}
@@ -0,0 +1,370 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowDownTrayIcon, PhotoIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import Spinner from '@/components/ui/Spinner';
// The mobile save flow. On a phone `<a download>` is a dead end for someone
// who wants the photo in their camera roll: iOS is WebKit everywhere, so it
// files the download under Files > Downloads and nothing else, and Android
// drops it in Downloads where Google Photos may or may not pick it up. The
// only route into the device gallery on either platform is the Web Share
// API, which hands the file to the native share sheet ("Save Image" on iOS,
// "Photos" on Android).
//
// So on mobile the download button opens this sheet instead of downloading,
// and the default row shares the 2048px preview — a file a phone screen
// cannot tell from the original, at a fraction of the bytes. The original
// stays one tap away for anyone who actually wants it.
export interface SavePhoto {
id: string;
/** Preview download URL the same URL the lightbox renders, so this is
* usually answered from the HTTP cache rather than the network. */
previewUrl: string;
originalUrl: string;
/** Byte sizes from the gallery response, used to label the rows. */
previewSize?: number;
originalSize?: number;
}
type RowKey = 'preview' | 'original';
type RowStatus = 'idle' | 'loading' | 'ready' | 'error';
interface RowState {
status: RowStatus;
/** 0..1, only tracked for the original's determinate progress bar. */
progress: number;
}
const IDLE: RowState = { status: 'idle', progress: 0 };
/**
* True on finger-first devices, i.e. where a plain download lands in
* Downloads instead of the photo library and this sheet is worth showing.
* Feature detection only user agent strings lie, and iPad has been
* claiming to be a Mac for years.
*
* Deliberately NOT gated on `navigator.share`. Web Share only exists in a
* secure context, so requiring it here made the sheet disappear entirely on
* any plain-HTTP origin (a phone hitting the dev server over the LAN sees
* `navigator.share === undefined`) and on Firefox for Android, which has no
* file sharing. Both of those should still get the sheet their rows just
* fall back to a download. Whether a given row shares or downloads is
* decided per file, at tap time, by canShareFile() below.
*/
export function useMobileSave(): boolean {
const [mobile, setMobile] = useState(false);
useEffect(() => {
// Comma = OR. `hover: none` catches touch devices that report a fine
// pointer because a stylus is paired.
const mq = window.matchMedia('(pointer: coarse), (hover: none)');
const sync = () => setMobile(mq.matches);
sync();
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
}, []);
return mobile;
}
function canShareFile(file: File): boolean {
return typeof navigator.canShare === 'function' && navigator.canShare({ files: [file] });
}
/** Reads the server's Content-Disposition name (spanglish-<event>-<n>.jpg). */
function filenameFrom(header: string | null, fallback: string): string {
const match = header?.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i);
return match ? decodeURIComponent(match[1]) : fallback;
}
function formatBytes(bytes: number | undefined, locale: string): string | null {
if (!bytes || bytes <= 0) return null;
const mb = bytes / 1_000_000;
if (mb < 1) {
return `${Math.round(bytes / 1000).toLocaleString(locale)} kB`;
}
return `${mb.toLocaleString(locale, { maximumFractionDigits: 1 })} MB`;
}
/**
* Fetches a URL into a File. onProgress is only wired up when the caller
* wants a determinate bar reading the body in chunks costs an extra copy,
* which is not worth it for the preview that is normally already cached.
*/
async function fetchAsFile(
url: string,
signal: AbortSignal,
onProgress?: (fraction: number) => void
): Promise<File> {
// No `cache` option: the default is what lets the lightbox's already
// rendered preview be reused instead of refetched.
const res = await fetch(url, { credentials: 'same-origin', signal });
if (!res.ok) throw new Error(`Download failed (${res.status})`);
const type = res.headers.get('Content-Type') || 'image/jpeg';
const name = filenameFrom(res.headers.get('Content-Disposition'), 'spanglish-photo.jpg');
const total = Number(res.headers.get('Content-Length') || 0);
let blob: Blob;
if (onProgress && total > 0 && res.body) {
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
onProgress(Math.min(1, received / total));
}
blob = new Blob(chunks as BlobPart[], { type });
} else {
blob = await res.blob();
}
return new File([blob], name, { type: blob.type || type });
}
/** Fallback for browsers without file sharing: a plain download. */
function saveViaAnchor(file: File) {
const objectUrl = URL.createObjectURL(file);
const a = document.createElement('a');
a.href = objectUrl;
a.download = file.name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
}
interface SaveSheetProps {
/** The photo to save; null keeps the sheet closed. */
photo: SavePhoto | null;
onClose: () => void;
}
export default function SaveSheet({ photo, onClose }: SaveSheetProps) {
if (!photo) return null;
// Keyed by photo so a sheet opened for a different photo starts clean
// instead of inheriting the previous one's fetched file.
return <SaveSheetPanel key={photo.id} photo={photo} onClose={onClose} />;
}
function SaveSheetPanel({ photo, onClose }: { photo: SavePhoto; onClose: () => void }) {
const { t, locale } = useLanguage();
const [rows, setRows] = useState<Record<RowKey, RowState>>({
preview: IDLE,
original: IDLE,
});
// Resolved files live in a ref as well as state: the tap handler has to
// read them *synchronously*. iOS rejects navigator.share() with
// NotAllowedError if anything is awaited between the tap and the call,
// so there is no chance to read them out of a promise first.
const files = useRef<Partial<Record<RowKey, File>>>({});
const aborter = useRef<AbortController>(new AbortController());
const closed = useRef(false);
const setRow = useCallback((key: RowKey, patch: Partial<RowState>) => {
if (closed.current) return;
setRows((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }));
}, []);
const prepare = useCallback(
async (key: RowKey): Promise<File | null> => {
setRow(key, { status: 'loading', progress: 0 });
try {
const file = await fetchAsFile(
key === 'preview' ? photo.previewUrl : photo.originalUrl,
aborter.current.signal,
// Only the original gets a determinate bar; the preview is
// normally cached and resolves before a spinner would even paint.
key === 'original' ? (progress) => setRow(key, { progress }) : undefined
);
files.current[key] = file;
setRow(key, { status: 'ready', progress: 1 });
return file;
} catch (err) {
// Aborting is how the sheet closes and how the user dismisses the
// native share sheet — neither is an error worth reporting.
if ((err as Error)?.name === 'AbortError') return null;
setRow(key, { status: 'error', progress: 0 });
return null;
}
},
[photo.previewUrl, photo.originalUrl, setRow]
);
// Nothing is fetched until the sheet is open — not on lightbox open, not
// on swipe, not on scroll. Browsing the whole gallery without tapping
// save costs exactly the thumbs and previews already on screen.
useEffect(() => {
// The controller is created here, not at render: closing the sheet
// aborts it, so a re-run of this effect (React StrictMode double-invokes
// it in development) has to start from a fresh, unaborted one.
closed.current = false;
const controller = new AbortController();
aborter.current = controller;
void prepare('preview');
return () => {
closed.current = true;
controller.abort();
};
}, [prepare]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const deliver = (file: File) => {
if (canShareFile(file)) {
// Called with nothing awaited in front of it — see the ref above.
navigator
.share({ files: [file] })
.then(() => onClose())
.catch((err: Error) => {
// AbortError just means the share sheet was dismissed.
if (err?.name !== 'AbortError') saveViaAnchor(file);
});
return;
}
saveViaAnchor(file);
onClose();
};
const onRowTap = (key: RowKey) => {
const ready = files.current[key];
if (ready) {
deliver(ready);
return;
}
if (rows[key].status === 'loading') return;
// Not fetched yet (or it failed): fetch now and stop. The row goes back
// to tappable when it resolves, so the share happens on a fresh
// gesture rather than a stale one iOS would refuse.
void prepare(key);
};
const sizes = {
preview: formatBytes(photo.previewSize, locale),
original: formatBytes(photo.originalSize, locale),
};
return (
<div
className="fixed inset-0 z-[60] flex flex-col justify-end"
role="dialog"
aria-modal="true"
aria-label={t('gallery.save.sheetTitle')}
>
<button
type="button"
aria-label={t('gallery.save.cancel')}
className="absolute inset-0 bg-black/60 motion-safe:animate-sheet-fade"
onClick={onClose}
/>
<div className="relative w-full sm:max-w-md sm:mx-auto bg-white rounded-t-2xl sm:rounded-2xl sm:mb-4 shadow-xl p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] motion-safe:animate-sheet-rise">
<div className="mx-auto mb-2 mt-1 h-1 w-10 rounded-full bg-gray-300" aria-hidden />
<SaveRow
icon={<PhotoIcon className="w-6 h-6 text-primary-dark" />}
label={t('gallery.save.photo')}
hint={t('gallery.save.photoHint')}
size={sizes.preview}
state={rows.preview}
statusLabels={t}
onTap={() => onRowTap('preview')}
/>
<SaveRow
icon={<ArrowDownTrayIcon className="w-6 h-6 text-primary-dark" />}
label={t('gallery.save.original')}
hint={t('gallery.save.originalHint')}
size={sizes.original}
state={rows.original}
statusLabels={t}
showProgress
onTap={() => onRowTap('original')}
/>
<button
type="button"
onClick={onClose}
className="mt-1 w-full rounded-xl py-3 text-center font-medium text-gray-600 hover:bg-gray-50 active:bg-gray-100"
>
{t('gallery.save.cancel')}
</button>
</div>
</div>
);
}
function SaveRow({
icon,
label,
hint,
size,
state,
statusLabels,
showProgress,
onTap,
}: {
icon: React.ReactNode;
label: string;
hint: string;
size: string | null;
state: RowState;
statusLabels: (key: string) => string;
showProgress?: boolean;
onTap: () => void;
}) {
const loading = state.status === 'loading';
// The subtitle carries the row's state, so a slow fetch explains itself
// without the row ever becoming untappable for good.
const subtitle =
state.status === 'loading'
? statusLabels('gallery.save.preparing')
: state.status === 'error'
? statusLabels('gallery.save.failed')
: [hint, size].filter(Boolean).join(' · ');
return (
<button
type="button"
onClick={onTap}
aria-busy={loading}
className="relative w-full overflow-hidden rounded-xl px-4 py-3 text-left flex items-center gap-3 hover:bg-gray-50 active:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow"
>
<span className="shrink-0">
{loading ? (
// Spinner defaults to the white ring used over photos; this one
// sits on the sheet's white surface.
<Spinner className="w-6 h-6 border-gray-300 border-t-primary-dark" />
) : (
icon
)}
</span>
<span className="min-w-0">
<span className="block font-medium text-primary-dark">{label}</span>
<span
className={`block text-sm ${state.status === 'error' ? 'text-red-600' : 'text-gray-500'}`}
>
{subtitle}
</span>
</span>
{showProgress && loading && (
<span className="absolute inset-x-0 bottom-0 h-1 bg-gray-200" aria-hidden>
<span
className="block h-full bg-primary-yellow transition-[width] duration-150"
style={{ width: `${Math.round(state.progress * 100)}%` }}
/>
</span>
)}
</button>
);
}
@@ -0,0 +1,148 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import toast from 'react-hot-toast';
// Per-photo download state for the public gallery. The photo-api serves full
// quality originals, which takes a few seconds, so downloads run through
// fetch() instead of a bare <a download> — that gives us a spinner, a real
// success/failure signal, a timeout and abort-on-unmount.
// Originals run to ~20 MB, so a single flat timeout would either be too short
// for a phone on mobile data or useless as a stall detector. Time out on the
// response headers instead, then give the body a generous ceiling.
const HEADERS_TIMEOUT_MS = 30_000;
const BODY_TIMEOUT_MS = 10 * 60_000;
export interface DownloadRequest {
/** Photo id; keys the in-flight state so tiles stay independent. */
id: string;
url: string;
filename?: string;
}
/** Saves fetched bytes without navigating away from the gallery. */
function saveBlob(blob: Blob, filename: string) {
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// Give the browser time to start the save before the URL is invalidated.
setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
}
/**
* Plain-anchor download, i.e. what the page did before this hook existed.
* Used as a fallback when fetch() itself fails: with S3 storage the file
* endpoint 302s to a presigned URL on another origin, which a cross-origin
* fetch cannot read but a navigation downloads fine (the presigned URL
* carries its own Content-Disposition).
*/
function navigateToDownload({ url, filename }: DownloadRequest) {
const a = document.createElement('a');
a.href = url;
if (filename) a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
export function useDownloads(es: boolean) {
const [pending, setPending] = useState<ReadonlySet<string>>(() => new Set());
const controllers = useRef(new Map<string, AbortController>());
const unmounted = useRef(false);
// Lets the retry button in the error toast call the latest `start`.
const startRef = useRef<(req: DownloadRequest) => void>(() => {});
useEffect(() => {
unmounted.current = false;
return () => {
unmounted.current = true;
controllers.current.forEach((c) => c.abort());
controllers.current.clear();
};
}, []);
const start = useCallback(
async (req: DownloadRequest) => {
// Repeat clicks while the same photo is in flight are no-ops; other
// photos are unaffected because state is keyed by id.
if (controllers.current.has(req.id)) return;
const controller = new AbortController();
controllers.current.set(req.id, controller);
setPending((prev) => new Set(prev).add(req.id));
let timer = setTimeout(() => controller.abort(), HEADERS_TIMEOUT_MS);
const timedOut = () => controller.signal.aborted && !unmounted.current;
try {
const res = await fetch(req.url, {
credentials: 'same-origin',
signal: controller.signal,
});
clearTimeout(timer);
timer = setTimeout(() => controller.abort(), BODY_TIMEOUT_MS);
if (!res.ok) throw new Error(`Download failed (${res.status})`);
const blob = await res.blob();
saveBlob(blob, req.filename || `${req.id}.jpg`);
} catch (err) {
if (unmounted.current) return; // page gone, nothing to report
if (err instanceof TypeError) {
// Network-level failure — most likely a cross-origin presigned
// redirect. Hand it to the browser, which can follow it.
navigateToDownload(req);
} else {
const message = timedOut()
? es
? 'La descarga tardó demasiado.'
: 'The download timed out.'
: es
? 'No se pudo descargar la foto.'
: 'Could not download the photo.';
toast.error(
(t) => (
<span className="flex items-center gap-3">
{message}
<button
type="button"
onClick={() => {
toast.dismiss(t.id);
startRef.current(req);
}}
className="font-medium underline underline-offset-2 whitespace-nowrap"
>
{es ? 'Reintentar' : 'Retry'}
</button>
</span>
),
{ duration: 6000 }
);
}
} finally {
clearTimeout(timer);
controllers.current.delete(req.id);
if (!unmounted.current) {
setPending((prev) => {
const next = new Set(prev);
next.delete(req.id);
return next;
});
}
}
},
[es]
);
useEffect(() => {
startRef.current = start;
}, [start]);
const isPending = useCallback((id: string) => pending.has(id), [pending]);
return { start, isPending };
}
+16 -2
View File
@@ -7,10 +7,24 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'; variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
isLoading?: boolean; isLoading?: boolean;
/** Label shown in place of the children while loading. */
loadingText?: string;
} }
const Button = forwardRef<HTMLButtonElement, ButtonProps>( const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => { (
{
className,
variant = 'primary',
size = 'md',
isLoading,
loadingText = 'Loading...',
children,
disabled,
...props
},
ref
) => {
return ( return (
<button <button
ref={ref} ref={ref}
@@ -62,7 +76,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/> />
</svg> </svg>
Loading... {loadingText}
</> </>
) : ( ) : (
children children
+12 -2
View File
@@ -9,11 +9,21 @@ interface SkeletonProps {
className?: string; className?: string;
} }
export function Skeleton({ className }: SkeletonProps) { export function Skeleton({
className,
tone = 'default',
}: SkeletonProps & {
/** `on-dark` lightens the surface for placeholders over a dark hero. */
tone?: 'default' | 'on-dark';
}) {
return ( return (
<div <div
aria-hidden="true" aria-hidden="true"
className={clsx('animate-pulse rounded-lg bg-secondary-light-gray/70', className)} className={clsx(
'animate-pulse motion-reduce:animate-none rounded-lg',
tone === 'on-dark' ? 'bg-white/20' : 'bg-secondary-light-gray/70',
className
)}
/> />
); );
} }
+18
View File
@@ -0,0 +1,18 @@
import clsx from 'clsx';
// Inline busy indicator in the same style as the full-page loaders
// (animate-spin ring with one contrasting edge). Purely decorative: callers
// own the accessible state via aria-busy / aria-label. Defaults to the
// white-on-dark ring used by the photo overlay controls; pass `border-*`
// classes to restyle it elsewhere.
export default function Spinner({ className }: { className?: string }) {
return (
<span
aria-hidden="true"
className={clsx(
'inline-block rounded-full border-2 animate-spin border-white/40 border-t-white',
className || 'w-4 h-4'
)}
/>
);
}
+103 -136
View File
@@ -1,8 +1,8 @@
'use client'; 'use client';
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { authClient } from '@/lib/auth-client';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || ''; import { fetchApi } from '@/lib/api/client';
interface User { interface User {
id: string; id: string;
@@ -18,18 +18,19 @@ interface User {
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
/** Always null: auth moved to httpOnly session cookies (Better Auth). Kept for compat. */
token: string | null; token: string | null;
isLoading: boolean; isLoading: boolean;
isAdmin: boolean; isAdmin: boolean;
hasAdminAccess: boolean; hasAdminAccess: boolean;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
loginWithGoogle: (credential: string) => Promise<void>; loginWithGoogle: (credential: string) => Promise<void>;
loginWithMagicLink: (token: string) => Promise<void>; loginWithMagicLink: (token: string) => Promise<User | null>;
register: (data: RegisterData) => Promise<void>; register: (data: RegisterData) => Promise<void>;
logout: () => void; logout: () => void;
updateUser: (user: User) => void; updateUser: (user: User) => void;
setAuthData: (data: { user: User; token: string }) => void; setAuthData: (data: { user: User; token?: string }) => void;
refreshUser: () => Promise<void>; refreshUser: () => Promise<User | null>;
} }
interface RegisterData { interface RegisterData {
@@ -42,178 +43,144 @@ interface RegisterData {
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
const TOKEN_KEY = 'spanglish-token'; // Legacy storage from the pre-Better-Auth JWT era; cleared once on mount.
const USER_KEY = 'spanglish-user'; const LEGACY_TOKEN_KEY = 'spanglish-token';
const AUTH_COOKIE = 'spanglish-auth'; const LEGACY_USER_KEY = 'spanglish-user';
const LEGACY_AUTH_COOKIE = 'spanglish-auth';
function setAuthCookie() { function mapSessionUser(sessionUser: any): User {
if (typeof document === 'undefined') return; return {
document.cookie = `${AUTH_COOKIE}=1; path=/; max-age=${60 * 60 * 24}; SameSite=Lax`; id: sessionUser.id,
email: sessionUser.email,
name: sessionUser.name,
role: sessionUser.role ?? 'user',
phone: sessionUser.phone ?? undefined,
languagePreference: sessionUser.languagePreference ?? undefined,
isClaimed: Boolean(sessionUser.isClaimed ?? true),
rucNumber: sessionUser.rucNumber ?? undefined,
accountStatus: sessionUser.accountStatus ?? 'active',
};
} }
function clearAuthCookie() { function messageFrom(error: { message?: string; code?: string; status?: number } | null, fallback: string): string {
if (typeof document === 'undefined') return; return error?.message || fallback;
document.cookie = `${AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
} }
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const refreshUser = useCallback(async () => { const refreshUser = useCallback(async (): Promise<User | null> => {
const currentToken = localStorage.getItem(TOKEN_KEY);
if (!currentToken) return;
try { try {
const res = await fetch(`${API_BASE}/api/auth/me`, { const { data } = await authClient.getSession();
headers: { if (data?.user) {
'Authorization': `Bearer ${currentToken}`, const mapped = mapSessionUser(data.user);
'Content-Type': 'application/json', setUser(mapped);
}, return mapped;
});
if (res.ok) {
const data = await res.json();
setUser(data.user);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
setAuthCookie();
} else if (res.status === 401) {
// Token is invalid, clear auth state
setToken(null);
setUser(null);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
clearAuthCookie();
} }
setUser(null);
return null;
} catch (error) { } catch (error) {
// Network error, keep using cached data // Network error: keep current state
console.error('Failed to refresh user data:', error); console.error('Failed to refresh user data:', error);
return null;
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
// Load auth state from localStorage // One-time cleanup of legacy JWT-era storage
const savedToken = localStorage.getItem(TOKEN_KEY); try {
const savedUser = localStorage.getItem(USER_KEY); localStorage.removeItem(LEGACY_TOKEN_KEY);
localStorage.removeItem(LEGACY_USER_KEY);
if (savedToken && savedUser) { document.cookie = `${LEGACY_AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
// Guard against corrupt/tampered localStorage so the whole app doesn't crash. } catch {
let parsedUser: User | null = null; /* SSR / storage unavailable */
try {
parsedUser = JSON.parse(savedUser);
} catch {
parsedUser = null;
}
if (parsedUser) {
setToken(savedToken);
setUser(parsedUser);
// Refresh user data from server to get latest role/permissions (source of truth)
refreshUser().finally(() => setIsLoading(false));
} else {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
setIsLoading(false);
}
} else {
setIsLoading(false);
} }
refreshUser().finally(() => setIsLoading(false));
}, [refreshUser]); }, [refreshUser]);
const setAuthData = useCallback((data: { user: User; token: string }) => {
setToken(data.token);
setUser(data.user);
localStorage.setItem(TOKEN_KEY, data.token);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
setAuthCookie();
}, []);
const login = async (email: string, password: string) => { const login = async (email: string, password: string) => {
const res = await fetch(`${API_BASE}/api/auth/login`, { const { error } = await authClient.signIn.email({ email, password });
method: 'POST', if (error) {
headers: { 'Content-Type': 'application/json' }, throw new Error(messageFrom(error, 'Login failed'));
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Login failed');
} }
await refreshUser();
const data = await res.json();
setAuthData(data);
}; };
const loginWithGoogle = async (credential: string) => { const loginWithGoogle = async (credential: string) => {
const res = await fetch(`${API_BASE}/api/auth/google`, { const { error } = await authClient.signIn.social({
method: 'POST', provider: 'google',
headers: { 'Content-Type': 'application/json' }, idToken: { token: credential },
body: JSON.stringify({ credential }),
}); });
if (error) {
if (!res.ok) { // Carry the code through: GoogleSignInButton turns OAUTH_LINK_ERROR into
const error = await res.json(); // something actionable instead of showing better-auth's bare
throw new Error(error.error || 'Google login failed'); // "account not linked".
const err = new Error(messageFrom(error, 'Google login failed'));
(err as Error & { code?: string }).code = error.code;
throw err;
} }
await refreshUser();
const data = await res.json();
setAuthData(data);
}; };
const loginWithMagicLink = async (magicToken: string) => { const loginWithMagicLink = async (magicToken: string): Promise<User | null> => {
const res = await fetch(`${API_BASE}/api/auth/magic-link/verify`, { const { error } = await authClient.magicLink.verify({ query: { token: magicToken } });
method: 'POST', if (error) {
headers: { 'Content-Type': 'application/json' }, throw new Error(messageFrom(error, 'Invalid or expired link'));
body: JSON.stringify({ token: magicToken }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Magic link login failed');
} }
return refreshUser();
const data = await res.json();
setAuthData(data);
}; };
const register = async (registerData: RegisterData) => { const register = async (registerData: RegisterData) => {
const res = await fetch(`${API_BASE}/api/auth/register`, { const { error } = await authClient.signUp.email({
method: 'POST', email: registerData.email,
headers: { 'Content-Type': 'application/json' }, password: registerData.password,
body: JSON.stringify(registerData), name: registerData.name,
}); phone: registerData.phone || undefined,
languagePreference: registerData.languagePreference || undefined,
if (!res.ok) { } as any);
const error = await res.json(); if (error) {
throw new Error(error.error || 'Registration failed'); // Existing-but-unclaimed accounts (created during guest booking) should
// point the user to the claim flow instead of a bare "already exists".
if (error.code === 'USER_ALREADY_EXISTS') {
try {
const { canClaim } = await fetchApi<{ canClaim: boolean }>(
`/api/auth-ext/claim-eligibility?email=${encodeURIComponent(registerData.email)}`
);
if (canClaim) {
const err = new Error(
'This email has an unclaimed account from a previous booking. Use "Email Link" on the login page to claim it.'
);
(err as any).canClaim = true;
throw err;
}
} catch (e: any) {
if (e?.canClaim) throw e;
/* eligibility check failed: fall through to generic message */
}
}
throw new Error(messageFrom(error, 'Registration failed'));
} }
await refreshUser();
const data = await res.json();
setAuthData(data);
}; };
const logout = useCallback(() => { const logout = useCallback(() => {
// Best-effort server-side invalidation (bumps token version so the JWT can't be reused). // Best-effort server-side revocation; local state clears regardless.
const currentToken = localStorage.getItem(TOKEN_KEY); authClient.signOut().catch(() => {
if (currentToken) { /* ignore network errors */
fetch(`${API_BASE}/api/auth/logout`, { });
method: 'POST',
headers: { 'Authorization': `Bearer ${currentToken}` },
}).catch(() => {
// Ignore network errors; local state is cleared regardless.
});
}
setToken(null);
setUser(null); setUser(null);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
clearAuthCookie();
}, []); }, []);
const updateUser = useCallback((updatedUser: User) => { const updateUser = useCallback((updatedUser: User) => {
setUser(updatedUser); setUser(updatedUser);
localStorage.setItem(USER_KEY, JSON.stringify(updatedUser)); }, []);
// Compat shim for callers that used to push {user, token} after custom auth
// flows; the session cookie is already set by then, so only state updates.
const setAuthData = useCallback((data: { user: User; token?: string }) => {
setUser(data.user);
}, []); }, []);
const isAdmin = user?.role === 'admin' || user?.role === 'organizer'; const isAdmin = user?.role === 'admin' || user?.role === 'organizer';
@@ -223,7 +190,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
user, user,
token, token: null,
isLoading, isLoading,
isAdmin, isAdmin,
hasAdminAccess, hasAdminAccess,
+18
View File
@@ -235,6 +235,9 @@
"email": "Email", "email": "Email",
"password": "Password", "password": "Password",
"submit": "Sign In", "submit": "Sign In",
"redirecting": "Redirecting...",
"redirectBlocked": "You're signed in, but we couldn't open that page.",
"redirectRetry": "Try again",
"noAccount": "Don't have an account?", "noAccount": "Don't have an account?",
"register": "Sign Up" "register": "Sign Up"
}, },
@@ -364,5 +367,20 @@
"title": "TikTok", "title": "TikTok",
"subtitle": "Videos & fun content" "subtitle": "Videos & fun content"
} }
},
"gallery": {
"save": {
"sheetTitle": "Save photo",
"photo": "Save photo",
"photoHint": "Best for phone",
"original": "Download original",
"originalHint": "Full quality",
"cancel": "Cancel",
"preparing": "Preparing…",
"ready": "Ready — tap to save",
"failed": "Could not prepare it. Tap to retry.",
"download": "Download",
"downloading": "Downloading…"
}
} }
} }
+18
View File
@@ -235,6 +235,9 @@
"email": "Email", "email": "Email",
"password": "Contraseña", "password": "Contraseña",
"submit": "Iniciar Sesión", "submit": "Iniciar Sesión",
"redirecting": "Redirigiendo...",
"redirectBlocked": "Has iniciado sesión, pero no pudimos abrir esa página.",
"redirectRetry": "Intentar de nuevo",
"noAccount": "¿No tienes cuenta?", "noAccount": "¿No tienes cuenta?",
"register": "Registrarse" "register": "Registrarse"
}, },
@@ -364,5 +367,20 @@
"title": "TikTok", "title": "TikTok",
"subtitle": "Videos y contenido divertido" "subtitle": "Videos y contenido divertido"
} }
},
"gallery": {
"save": {
"sheetTitle": "Guardar foto",
"photo": "Guardar foto",
"photoHint": "Ideal para el teléfono",
"original": "Descargar original",
"originalHint": "Calidad completa",
"cancel": "Cancelar",
"preparing": "Preparando…",
"ready": "Lista: toca para guardar",
"failed": "No se pudo preparar. Toca para reintentar.",
"download": "Descargar",
"downloading": "Descargando…"
}
} }
} }
+79 -45
View File
@@ -1,63 +1,97 @@
import { authClient } from '../auth-client';
import { fetchApi } from './client'; import { fetchApi } from './client';
import type { User } from './types'; import type { User } from './types';
// Thin wrappers over the Better Auth client, preserving the legacy authApi
// call surface used by the auth pages.
type ClientError = { message?: string; code?: string; status: number } | null;
function throwIfError(error: ClientError, fallback: string): void {
if (error) {
const err = new Error(error.message || fallback);
(err as any).code = error.code;
(err as any).status = error.status;
throw err;
}
}
export const authApi = { export const authApi = {
// Magic link // Magic link login. Enumeration-safe UX parity: unknown emails resolve with
requestMagicLink: (email: string) => // the same generic message (magic links never create accounts server-side);
fetchApi<{ message: string }>('/api/auth/magic-link/request', { // only rate limiting surfaces as an error.
method: 'POST', requestMagicLink: async (email: string, callbackURL: string = '/dashboard') => {
body: JSON.stringify({ email }), const { error } = await authClient.signIn.magicLink({ email, callbackURL });
}), if (error && error.status === 429) {
throwIfError(error, 'Too many requests. Please try again later.');
}
return { message: 'If an account exists with this email, a login link has been sent.' };
},
verifyMagicLink: (token: string) => verifyMagicLink: async (token: string) => {
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/magic-link/verify', { const { data, error } = await authClient.magicLink.verify({ query: { token } });
method: 'POST', throwIfError(error, 'Invalid or expired token');
body: JSON.stringify({ token }), return data;
}), },
// Password reset // Password reset (Better Auth is enumeration-safe here by default)
requestPasswordReset: (email: string) => requestPasswordReset: async (email: string) => {
fetchApi<{ message: string }>('/api/auth/password-reset/request', { const { error } = await authClient.requestPasswordReset({
method: 'POST', email,
body: JSON.stringify({ email }), redirectTo: '/auth/reset-password',
}), });
throwIfError(error, 'Failed to request password reset');
return { message: 'If an account exists with this email, a password reset link has been sent.' };
},
confirmPasswordReset: (token: string, password: string) => confirmPasswordReset: async (token: string, password: string) => {
fetchApi<{ message: string }>('/api/auth/password-reset/confirm', { const { error } = await authClient.resetPassword({ newPassword: password, token });
method: 'POST', throwIfError(error, 'Invalid or expired token');
body: JSON.stringify({ token, password }), return { message: 'Password reset successfully. Please log in with your new password.' };
}), },
// Account claiming // Account claiming: a magic link that lands on the claim page, where the
// session-holding user sets a password via /api/auth-ext/claim-account.
requestClaimAccount: (email: string) => requestClaimAccount: (email: string) =>
fetchApi<{ message: string }>('/api/auth/claim-account/request', { authApi.requestMagicLink(email, '/auth/claim-account'),
confirmClaimAccount: (password: string) =>
fetchApi<{ user: User; message: string }>('/api/auth-ext/claim-account', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email }), body: JSON.stringify({ password }),
}), }),
confirmClaimAccount: (token: string, data: { password?: string; googleId?: string }) => claimEligibility: (email: string) =>
fetchApi<{ user: User; token: string; refreshToken: string; message: string }>( fetchApi<{ canClaim: boolean }>(
'/api/auth/claim-account/confirm', `/api/auth-ext/claim-eligibility?email=${encodeURIComponent(email)}`
{
method: 'POST',
body: JSON.stringify({ token, ...data }),
}
), ),
// Google OAuth // Google Identity Services credential (ID token) sign-in
googleAuth: (credential: string) => googleAuth: async (credential: string) => {
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/google', { const { data, error } = await authClient.signIn.social({
method: 'POST', provider: 'google',
body: JSON.stringify({ credential }), idToken: { token: credential },
}), });
throwIfError(error, 'Google login failed');
return data;
},
// Change password // Change password; other sessions are revoked so a stolen session can't
changePassword: (currentPassword: string, newPassword: string) => // outlive the change (this device stays signed in).
fetchApi<{ message: string }>('/api/auth/change-password', { changePassword: async (currentPassword: string, newPassword: string) => {
method: 'POST', const { error } = await authClient.changePassword({
body: JSON.stringify({ currentPassword, newPassword }), currentPassword,
}), newPassword,
revokeOtherSessions: true,
});
throwIfError(error, 'Failed to change password');
return { message: 'Password changed successfully' };
},
// Get current user // Get current user
me: () => fetchApi<{ user: User }>('/api/auth/me'), me: async (): Promise<{ user: User | null }> => {
const { data, error } = await authClient.getSession();
throwIfError(error, 'Failed to load session');
return { user: (data?.user as unknown as User) ?? null };
},
}; };
+10 -17
View File
@@ -1,30 +1,25 @@
export const API_BASE = process.env.NEXT_PUBLIC_API_URL || ''; export const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
// Auth rides on the Better Auth httpOnly session cookie. With API_BASE unset
// (same-origin via Next rewrites) 'same-origin' sends it; a cross-origin
// API_BASE needs 'include' plus CORS credentials on the backend.
const CREDENTIALS: RequestCredentials = API_BASE ? 'include' : 'same-origin';
export interface ApiError { export interface ApiError {
error: string; error: string;
} }
/** Read the stored auth token (browser only). */
export function getToken(): string | null {
return typeof window !== 'undefined' ? localStorage.getItem('spanglish-token') : null;
}
export async function fetchApi<T>( export async function fetchApi<T>(
endpoint: string, endpoint: string,
options: RequestInit = {} options: RequestInit = {}
): Promise<T> { ): Promise<T> {
const token = getToken();
const headers: HeadersInit = { const headers: HeadersInit = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers, ...options.headers,
}; };
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${endpoint}`, { const res = await fetch(`${API_BASE}${endpoint}`, {
credentials: CREDENTIALS,
...options, ...options,
headers, headers,
}); });
@@ -36,8 +31,10 @@ export async function fetchApi<T>(
: (errorData.message || JSON.stringify(errorData) || 'Request failed'); : (errorData.message || JSON.stringify(errorData) || 'Request failed');
const error = new Error(errorMessage); const error = new Error(errorMessage);
// Preserve structured error info (e.g. code: 'EVENT_OVER_CAPACITY') so // Preserve structured error info (e.g. code: 'EVENT_OVER_CAPACITY') so
// callers can react beyond the message text. // callers can react beyond the message text. The status lets callers tell a
// retryable server/network fault from a request that will always fail.
(error as any).code = errorData.code; (error as any).code = errorData.code;
(error as any).status = res.status;
(error as any).data = errorData; (error as any).data = errorData;
throw error; throw error;
} }
@@ -53,11 +50,7 @@ export async function fetchBlob(
endpoint: string, endpoint: string,
fallbackFilename: string fallbackFilename: string
): Promise<{ blob: Blob; filename: string }> { ): Promise<{ blob: Blob; filename: string }> {
const token = getToken(); const res = await fetch(`${API_BASE}${endpoint}`, { credentials: CREDENTIALS });
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${endpoint}`, { headers });
if (!res.ok) { if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Export failed' })); const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
throw new Error(errorData.error || 'Export failed'); throw new Error(errorData.error || 'Export failed');
+112
View File
@@ -0,0 +1,112 @@
import { fetchApi } from './client';
// ─── Door check-in screen API ────────────────────────────────
// Every write is idempotent on a client-generated key so the door screen can
// fire actions optimistically and retry on flaky venue wifi without ever
// creating a duplicate ticket, payment or check-in.
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'guest'] as const;
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
export type DoorEntryMethod = 'scan' | 'search' | 'walkin';
export interface DoorAttendee {
ticketId: string;
firstName: string;
lastName: string | null;
fullName: string;
email: string | null;
phone: string | null;
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in' | 'on_hold';
paymentStatus: 'paid' | 'unpaid' | 'comp';
isGuest: boolean;
checkedIn: boolean;
checkinAt: string | null;
checkedInBy: string | null;
bookingId: string | null;
isGroupBooking: boolean;
amountDue: number;
doorMethod: DoorPaymentMethod | null;
qrCode: string | null;
createdAt: string | null;
}
export interface DoorAttendeesResponse {
event: { id: string; title: string; price: number; currency: string; capacity: number };
attendees: DoorAttendee[];
stats: { checkedIn: number; totalActive: number; capacity: number };
}
export interface DoorCheckinRequest {
ticketId?: string;
attendee?: {
firstName: string;
lastName?: string;
phone?: string;
email?: string;
ruc?: string;
};
payment?: { method: DoorPaymentMethod; amount?: number };
entryMethod?: DoorEntryMethod;
idempotencyKey: string;
}
export interface DoorCheckinResponse {
ok: true;
action: 'checkin' | 'walkin';
attendee: DoorAttendee;
payment: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | null;
/** 'at_capacity' — the event is full; the attendee was added anyway. */
warnings: string[];
idempotencyKey: string;
processedAt: string;
/** True when this response was replayed from an already-processed key. */
replayed?: boolean;
undone?: boolean;
}
export interface DoorMethodTotal {
count: number;
total: number;
}
export interface DoorSummary {
eventId: string;
currency: string;
price: number;
door: {
count: number;
total: number;
byMethod: Record<DoorPaymentMethod, DoorMethodTotal>;
lines: {
paymentId: string;
ticketId: string;
name: string;
method: DoorPaymentMethod;
amount: number;
paidAt: string | null;
}[];
};
presale: { count: number; total: number };
total: number;
}
export const doorApi = {
// Preloaded once per event, then searched entirely in memory.
attendees: (eventId: string) =>
fetchApi<DoorAttendeesResponse>(`/api/events/${eventId}/door-attendees`),
checkin: (eventId: string, body: DoorCheckinRequest) =>
fetchApi<DoorCheckinResponse>(`/api/events/${eventId}/door-checkin`, {
method: 'POST',
body: JSON.stringify(body),
}),
undo: (eventId: string, idempotencyKey: string) =>
fetchApi<{ ok: true; ticketId?: string; reverted?: string; alreadyUndone?: boolean }>(
`/api/events/${eventId}/door-checkin/undo`,
{ method: 'POST', body: JSON.stringify({ idempotencyKey }) }
),
summary: (eventId: string) => fetchApi<DoorSummary>(`/api/events/${eventId}/door-summary`),
};
+6 -2
View File
@@ -2,11 +2,15 @@ import { fetchApi } from './client';
import type { Event } from './types'; import type { Event } from './types';
export const eventsApi = { export const eventsApi = {
getAll: (params?: { status?: string; upcoming?: boolean }) => { getAll: (params?: { status?: string; upcoming?: boolean; page?: number; pageSize?: number }) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.status) query.set('status', params.status); if (params?.status) query.set('status', params.status);
if (params?.upcoming) query.set('upcoming', 'true'); if (params?.upcoming) query.set('upcoming', 'true');
return fetchApi<{ events: Event[] }>(`/api/events?${query}`); // Passing page/pageSize switches the endpoint into paginated mode, which also
// returns `total`; without them the full list comes back as before.
if (params?.page) query.set('page', String(params.page));
if (params?.pageSize) query.set('pageSize', String(params.pageSize));
return fetchApi<{ events: Event[]; total?: number }>(`/api/events?${query}`);
}, },
getById: (id: string) => fetchApi<{ event: Event }>(`/api/events/${id}`), getById: (id: string) => fetchApi<{ event: Event }>(`/api/events/${id}`),
+11
View File
@@ -4,6 +4,17 @@ export * from './types';
export { eventsApi } from './events'; export { eventsApi } from './events';
export { ticketsApi } from './tickets'; export { ticketsApi } from './tickets';
export { doorApi, DOOR_PAYMENT_METHODS } from './door';
export type {
DoorAttendee,
DoorAttendeesResponse,
DoorCheckinRequest,
DoorCheckinResponse,
DoorEntryMethod,
DoorMethodTotal,
DoorPaymentMethod,
DoorSummary,
} from './door';
export { contactsApi } from './contacts'; export { contactsApi } from './contacts';
export { usersApi } from './users'; export { usersApi } from './users';
export { paymentsApi } from './payments'; export { paymentsApi } from './payments';
+3 -4
View File
@@ -1,4 +1,4 @@
import { fetchApi, API_BASE, getToken } from './client'; import { fetchApi, API_BASE } from './client';
import type { Media } from './types'; import type { Media } from './types';
export const mediaApi = { export const mediaApi = {
@@ -11,16 +11,15 @@ export const mediaApi = {
}, },
upload: async (file: File, relatedId?: string, relatedType?: string) => { upload: async (file: File, relatedId?: string, relatedType?: string) => {
const token = getToken();
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
if (relatedId) formData.append('relatedId', relatedId); if (relatedId) formData.append('relatedId', relatedId);
if (relatedType) formData.append('relatedType', relatedType); if (relatedType) formData.append('relatedType', relatedType);
// Auth rides on the session cookie
const res = await fetch(`${API_BASE}/api/media/upload`, { const res = await fetch(`${API_BASE}/api/media/upload`, {
method: 'POST', method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {}, credentials: API_BASE ? 'include' : 'same-origin',
body: formData, body: formData,
}); });
+20 -5
View File
@@ -1,4 +1,4 @@
import { fetchApi, API_BASE, getToken } from './client'; import { fetchApi, API_BASE } from './client';
// Client for the standalone photo-api Go service (photo-api/), reachable // Client for the standalone photo-api Go service (photo-api/), reachable
// under /api/photos via the Next rewrite (dev) or nginx (prod). // under /api/photos via the Next rewrite (dev) or nginx (prod).
@@ -41,6 +41,8 @@ export interface Photo {
originalFilename?: string; originalFilename?: string;
contentType: string; contentType: string;
sizeBytes: number; sizeBytes: number;
/** Size of the preview variant; absent until the server has measured it. */
previewSizeBytes?: number;
width?: number; width?: number;
height?: number; height?: number;
takenAt?: string; takenAt?: string;
@@ -51,7 +53,19 @@ export interface Photo {
thumb?: string; thumb?: string;
preview?: string; preview?: string;
original: string; original: string;
/**
* Download endpoints. Unlike `preview`/`original` these always stream
* same-origin instead of redirecting to S3, so fetch() can read them
* into a Blob (needed by the mobile share sheet) without CORS.
* `download` serves the preview and is what the lightbox renders, so
* saving it hits the HTTP cache instead of the network.
*/
download?: string;
downloadOriginal: string;
}; };
// Upload responses only: these bytes were already in the gallery, so nothing
// was stored and the rest of this object describes the existing photo.
duplicate?: boolean;
} }
export interface CreateGalleryInput { export interface CreateGalleryInput {
@@ -94,13 +108,13 @@ export const photosApi = {
}), }),
uploadPhoto: async (galleryId: string, file: File) => { uploadPhoto: async (galleryId: string, file: File) => {
const token = getToken();
const formData = new FormData(); const formData = new FormData();
formData.append('files', file); formData.append('files', file);
// Auth rides on the session cookie (sent same-origin automatically)
const res = await fetch(`${API_BASE}/api/photos/galleries/${galleryId}/photos`, { const res = await fetch(`${API_BASE}/api/photos/galleries/${galleryId}/photos`, {
method: 'POST', method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {}, credentials: API_BASE ? 'include' : 'same-origin',
body: formData, body: formData,
}); });
if (!res.ok) { if (!res.ok) {
@@ -122,8 +136,9 @@ export const photosApi = {
new Promise<{ photos: Photo[] }>((resolve, reject) => { new Promise<{ photos: Photo[] }>((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE}/api/photos/galleries/${galleryId}/photos`); xhr.open('POST', `${API_BASE}/api/photos/galleries/${galleryId}/photos`);
const token = getToken(); // Auth rides on the session cookie; XHR sends same-origin cookies by
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); // default, withCredentials is only needed for a cross-origin API_BASE.
if (API_BASE) xhr.withCredentials = true;
xhr.upload.onprogress = (e) => { xhr.upload.onprogress = (e) => {
if (e.lengthComputable && e.total > 0) onProgress(e.loaded / e.total); if (e.lengthComputable && e.total > 0) onProgress(e.loaded / e.total);
}; };
+4 -3
View File
@@ -106,11 +106,12 @@ export const ticketsApi = {
}), }),
// Unified add-attendee endpoint behind the single Add Ticket modal // Unified add-attendee endpoint behind the single Add Ticket modal
// (paid = confirmation + QR, unpaid = pay link + door collection, guest = free comp) // (paid = confirmation + QR, door = cash taken at the door, unpaid = pay link +
// door collection, guest = free comp)
adminAdd: (data: { adminAdd: (data: {
eventId: string; eventId: string;
type: 'paid' | 'unpaid' | 'guest'; type: 'paid' | 'door' | 'unpaid' | 'guest';
firstName: string; firstName?: string;
lastName?: string; lastName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
+3
View File
@@ -439,6 +439,9 @@ export interface UserSession {
ipAddress?: string; ipAddress?: string;
lastActiveAt: string; lastActiveAt: string;
createdAt: string; createdAt: string;
expiresAt?: string;
/** True for the session backing the current request. */
current?: boolean;
} }
export interface DashboardSummary { export interface DashboardSummary {
+22
View File
@@ -0,0 +1,22 @@
import { createAuthClient } from 'better-auth/react';
import { magicLinkClient, adminClient, inferAdditionalFields } from 'better-auth/client/plugins';
// Better Auth client. Sessions are httpOnly cookies set by the backend; with
// NEXT_PUBLIC_API_URL unset everything is same-origin through the Next.js
// rewrites (see next.config.js), so cookies flow automatically.
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_API_URL || '',
plugins: [
magicLinkClient(),
adminClient(),
inferAdditionalFields({
user: {
phone: { type: 'string', required: false },
languagePreference: { type: 'string', required: false },
rucNumber: { type: 'string', required: false },
isClaimed: { type: 'boolean', required: false },
accountStatus: { type: 'string', required: false },
},
}),
],
});
+56
View File
@@ -0,0 +1,56 @@
/**
* Post-authentication navigation.
*
* Navigation uses a full page load rather than `router.push`. The session is an httpOnly
* cookie, and only a top-level request lets the middleware guard in `middleware.ts` see it
* and re-render the header and the destination together.
*
* A marker in sessionStorage records where we last sent someone, and when. If the
* middleware cannot see the session cookie it redirects straight back to `/login`, and
* without the marker the login page would immediately navigate again an endless reload
* loop. A React ref cannot do this job: a full page load resets component state.
*/
const ATTEMPT_KEY = 'spanglish.auth-redirect-attempt';
// A real bounce comes back within a few hundred milliseconds (it is a server redirect).
// An older marker means an unrelated later visit to /login, not a failed redirect.
const ATTEMPT_TTL_MS = 30_000;
export function markRedirectAttempt(target: string): void {
try {
sessionStorage.setItem(ATTEMPT_KEY, JSON.stringify({ target, at: Date.now() }));
} catch {
/* storage disabled (private mode): the loop guard degrades, navigation still works */
}
}
export function clearRedirectAttempt(): void {
try {
sessionStorage.removeItem(ATTEMPT_KEY);
} catch {
/* ignore */
}
}
/** True when we just sent the user to `target` and are back on the login page instead. */
export function didRedirectBounce(target: string): boolean {
try {
const raw = sessionStorage.getItem(ATTEMPT_KEY);
if (!raw) return false;
const { target: attempted, at } = JSON.parse(raw) as { target?: string; at?: number };
return (
attempted === target && typeof at === 'number' && Date.now() - at < ATTEMPT_TTL_MS
);
} catch {
return false;
}
}
/**
* Records the attempt, then navigates. Callers should keep their pending/loading UI on
* screen: this never resolves, the page is replaced.
*/
export function redirectAfterAuth(target: string): void {
markRedirectAttempt(target);
window.location.assign(target);
}
+24 -7
View File
@@ -3,18 +3,35 @@ import { NextRequest, NextResponse } from 'next/server';
/** /**
* Defense-in-depth guard for authenticated areas. * Defense-in-depth guard for authenticated areas.
* *
* Auth tokens live in localStorage (not readable here), so we rely on a lightweight * Auth is a Better Auth httpOnly session cookie, which IS visible to this
* `spanglish-auth` cookie set alongside login. The API remains the authoritative gate; * server-side middleware (unlike client JS). The API remains the authoritative
* this only keeps unauthenticated visitors from loading the admin/dashboard JS shell. * gate cookie presence is not validated here; this only keeps clearly
* unauthenticated visitors from loading the admin/dashboard JS shell.
*/ */
const SESSION_COOKIES = [
'__Secure-spanglish.session_token', // production (useSecureCookies)
'spanglish.session_token', // development
];
// Matched as a fallback so a change to Better Auth's `advanced.cookiePrefix` cannot
// silently lock every user out of these routes.
const SESSION_COOKIE_SUFFIX = '.session_token';
function hasSessionCookie(request: NextRequest): boolean {
if (SESSION_COOKIES.some((name) => !!request.cookies.get(name)?.value)) return true;
return request.cookies
.getAll()
.some((cookie) => cookie.name.endsWith(SESSION_COOKIE_SUFFIX) && !!cookie.value);
}
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname, search } = request.nextUrl;
if (pathname.startsWith('/admin') || pathname.startsWith('/dashboard')) { if (pathname.startsWith('/admin') || pathname.startsWith('/dashboard')) {
const hasAuthCookie = request.cookies.get('spanglish-auth')?.value === '1'; if (!hasSessionCookie(request)) {
if (!hasAuthCookie) {
const loginUrl = new URL('/login', request.url); const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname); // Keep the query string, so a bounced /admin/photos?page=3 resumes where it was.
loginUrl.searchParams.set('redirect', `${pathname}${search}`);
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);
} }
} }
+12
View File
@@ -37,6 +37,18 @@ module.exports = {
'card': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', 'card': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
'card-hover': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', 'card-hover': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
}, },
// Entrance for the mobile save sheet (components/gallery/SaveSheet).
keyframes: {
'sheet-fade': { from: { opacity: '0' }, to: { opacity: '1' } },
'sheet-rise': {
from: { transform: 'translateY(100%)' },
to: { transform: 'translateY(0)' },
},
},
animation: {
'sheet-fade': 'sheet-fade 150ms ease-out',
'sheet-rise': 'sheet-rise 200ms ease-out',
},
}, },
}, },
plugins: [], plugins: [],
+12
View File
@@ -14,6 +14,10 @@
"build:photos": "cd photo-api && go build -o bin/photo-api ./cmd/photo-api", "build:photos": "cd photo-api && go build -o bin/photo-api ./cmd/photo-api",
"test:photos": "cd photo-api && go test ./...", "test:photos": "cd photo-api && go test ./...",
"migrate:photos": "cd photo-api && go run ./cmd/photo-api migrate", "migrate:photos": "cd photo-api && go run ./cmd/photo-api migrate",
"sync:photos": "cd photo-api && go run ./cmd/photo-api sync",
"sync:photos:to-s3": "cd photo-api && go run ./cmd/photo-api sync to-s3",
"sync:photos:to-local": "cd photo-api && go run ./cmd/photo-api sync to-local",
"backfill:photos:checksums": "cd photo-api && go run ./cmd/photo-api backfill-checksums",
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"", "start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
"start:backend": "npm run start --workspace=backend", "start:backend": "npm run start --workspace=backend",
"start:frontend": "npm run start --workspace=frontend", "start:frontend": "npm run start --workspace=frontend",
@@ -32,5 +36,13 @@
}, },
"dependencies": { "dependencies": {
"next": "^16.2.10" "next": "^16.2.10"
},
"allowScripts": {
"esbuild@0.18.20": true,
"esbuild@0.25.12": true,
"esbuild@0.27.2": true,
"better-sqlite3@12.11.1": true,
"sharp@0.34.5": true,
"argon2@0.44.0": true
} }
} }
+20 -5
View File
@@ -16,17 +16,32 @@ DATABASE_URL=postgresql://spanglish:password@localhost:5432/spanglish_db
# DB_TYPE=sqlite # DB_TYPE=sqlite
# DATABASE_URL=../backend/data/spanglish.db # DATABASE_URL=../backend/data/spanglish.db
# MUST be identical to JWT_SECRET in backend/.env — photo-api validates the # Secret for gallery image view tokens (HMAC). Any strong random value; does
# same HS256 tokens the backend issues. # not need to match any backend secret. Rotating it invalidates outstanding
# image URLs for at most ~1 hour (they are re-minted per page load).
PHOTO_VIEW_SECRET=8b69468724b730dddecba3d04717cf44e6dc5b808773e7e60156d38875f0f6cd
# DEPRECATED: fallback for PHOTO_VIEW_SECRET during the Better Auth migration
# (user auth now validates Better Auth sessions from the shared database, not
# JWTs). Set PHOTO_VIEW_SECRET and remove this.
JWT_SECRET= JWT_SECRET=
# Public site origin, used to build share links and allow dev CORS # Public site origin, used to build share links and allow dev CORS
FRONTEND_URL=https://spanglishcommunity.com FRONTEND_URL=https://spanglishcommunity.com
# Photo storage. Local disk by default; setting BOTH S3_ENDPOINT and # Photo storage. Which backend serves requests:
# S3_BUCKET switches to S3 (same convention as the backend's media storage). # auto (default) S3 when S3_ENDPOINT + S3_BUCKET are set, local otherwise
# Use a photos-specific bucket — do not reuse the backend's media bucket. # — the backend's convention (backend/src/lib/storage.ts)
# local always local disk, even with the S3 settings filled in
# s3 always S3
# Keep both sides filled in and flip this one line to switch backends. Both
# are also required by the library sync (`npm run sync:photos:to-s3` /
# `:to-local`), which copies every photo from one backend to the other.
STORAGE_BACKEND=auto
STORAGE_PATH=./data/photos STORAGE_PATH=./data/photos
# Use a photos-specific bucket — do not reuse the backend's media bucket.
#S3_ENDPOINT= #S3_ENDPOINT=
#S3_REGION=auto #S3_REGION=auto
#S3_BUCKET=spanglish-photos #S3_BUCKET=spanglish-photos
+11 -2
View File
@@ -1,11 +1,11 @@
.PHONY: start build test migrate clean help .PHONY: start build test migrate sync-to-s3 sync-to-local backfill-checksums clean help
BINARY := bin/photo-api BINARY := bin/photo-api
CMD := ./cmd/photo-api CMD := ./cmd/photo-api
help: ## Show available targets help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \ @grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}' awk 'BEGIN {FS = ":.*?## "}; {printf " %-19s %s\n", $$1, $$2}'
start: ## Run the photo-api server (go run) start: ## Run the photo-api server (go run)
go run $(CMD) go run $(CMD)
@@ -19,5 +19,14 @@ test: ## Run all Go tests
migrate: ## Apply pending photos_* migrations migrate: ## Apply pending photos_* migrations
go run $(CMD) migrate go run $(CMD) migrate
sync-to-s3: ## Copy the photo library from local disk to S3 (needs both in .env)
go run $(CMD) sync to-s3
sync-to-local: ## Copy the photo library from S3 back to local disk
go run $(CMD) sync to-local
backfill-checksums: ## Hash photos uploaded before duplicate detection existed
go run $(CMD) backfill-checksums
clean: ## Remove built binary clean: ## Remove built binary
rm -rf bin rm -rf bin
+1 -1
View File
@@ -131,7 +131,7 @@ All routes under `/api/photos`. Auth = `Authorization: Bearer <existing JWT>`. "
| `GET /api/photos/galleries/:id` | Full gallery detail incl. photos in position order with per-variant URLs and processing status. | `{ gallery, photos: [...] }` | | `GET /api/photos/galleries/:id` | Full gallery detail incl. photos in position order with per-variant URLs and processing status. | `{ gallery, photos: [...] }` |
| `PATCH /api/photos/galleries/:id` | Update title/description/eventId/visibility/coverPhotoId. | `{ gallery }` | | `PATCH /api/photos/galleries/:id` | Update title/description/eventId/visibility/coverPhotoId. | `{ gallery }` |
| `DELETE /api/photos/galleries/:id` | Delete gallery + photos + stored objects. | `{ message }` | | `DELETE /api/photos/galleries/:id` | Delete gallery + photos + stored objects. | `{ message }` |
| `POST /api/photos/galleries/:id/photos` | Multipart upload, field `files` (repeatable). Sniffs magic bytes (JPEG/PNG/WebP/GIF/AVIF; explicit `415 { error: "HEIC is not supported, please upload JPEG" }` for HEIC). Stores original, inserts row `status='queued'`, appends at end position. | `201 { photos: [...] }` | | `POST /api/photos/galleries/:id/photos` | Multipart upload, field `files` (repeatable). Sniffs magic bytes (JPEG/PNG/WebP/GIF/AVIF; explicit `415 { error: "HEIC is not supported, please upload JPEG" }` for HEIC). Stores original, inserts row `status='queued'`, appends at end position. Hashes the bytes into `checksum`: if the gallery already holds them, nothing is stored and the existing photo is echoed with `duplicate: true` (the batch continues, no position consumed). | `201 { photos: [...] }` |
| `PATCH /api/photos/galleries/:id/order` | Body `{ photoIds: [uuid,...] }` — full ordering; positions rewritten in one transaction. | `{ message }` | | `PATCH /api/photos/galleries/:id/order` | Body `{ photoIds: [uuid,...] }` — full ordering; positions rewritten in one transaction. | `{ message }` |
| `DELETE /api/photos/photos/:photoId` | Delete one photo + its objects; compacts positions. | `{ message }` | | `DELETE /api/photos/photos/:photoId` | Delete one photo + its objects; compacts positions. | `{ message }` |
| `POST /api/photos/galleries/:id/share-token` | Rotate share token (invalidate old links). | `{ gallery }` | | `POST /api/photos/galleries/:id/share-token` | Rotate share token (invalidate old links). | `{ gallery }` |

Some files were not shown because too many files have changed in this diff Show More