17 Commits
Author SHA1 Message Date
Michilis 183a2c99ed Merge pull request 'Restrict whole-event door takings to admin and organizer.' (#35) from dev into main
Reviewed-on: #35
2026-08-23 06:38:57 +00:00
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
Michilis 279cd19308 Merge pull request 'Fix the backend build against hono's narrowed route param type.' (#34) from dev into main
Reviewed-on: #34
2026-08-23 06:16:27 +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
Michilis de791fd17d Merge pull request 'Dev' (#33) from dev into main
Reviewed-on: #33
2026-08-23 06:04:26 +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
Michilis d06f80e572 Merge pull request 'Add mobile save sheet with same-origin photo downloads.' (#32) from dev into main
Reviewed-on: #32
2026-08-06 20:20:15 +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
Michilis e0f0b2f1e9 Merge pull request 'Dev' (#31) from dev into main
Reviewed-on: #31
2026-08-05 23:18:50 +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
98 changed files with 8309 additions and 1453 deletions
+16
View File
@@ -86,8 +86,23 @@ npm run dev:photos # go run ./cmd/photo-api
npm run build:photos # go build -o bin/photo-api npm run build:photos # go build -o bin/photo-api
npm run migrate:photos # apply photos_* migrations npm run migrate:photos # apply photos_* migrations
npm run test:photos # go test ./... 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
@@ -117,6 +132,7 @@ Key settings (see `photo-api/.env.example`):
- **DB**: `DB_TYPE` and `DATABASE_URL` — point at the **same** database as the backend - **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). - **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**: `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) - **Uploads/worker**: `MAX_UPLOAD_MB`, `WORKER_CONCURRENCY` (a worker generates thumb/preview JPEG variants with EXIF stripped)
### Frontend (`frontend/.env`) ### Frontend (`frontend/.env`)
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+104 -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`
@@ -836,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`
@@ -1203,6 +1249,8 @@ 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_sessions_user_id_idx ON auth_sessions(user_id)`,
@@ -1296,6 +1344,57 @@ async function migrate() {
`); `);
} }
// ==================== 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 }>(
+39
View File
@@ -138,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(),
@@ -504,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(),
@@ -734,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;
+114
View File
@@ -12,6 +12,7 @@ import authExtRoutes from './routes/authExt.js';
import { getClientIp } from './lib/rateLimit.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';
@@ -767,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'],
@@ -1908,6 +2019,9 @@ app.on(['POST', 'GET'], '/api/auth/*', (c) => {
); );
}); });
app.route('/api/auth-ext', authExtRoutes); 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);
+227 -1
View File
@@ -3,6 +3,8 @@ import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs'; import { mkdtempSync } from 'fs';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; 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 // Environment must be pinned BEFORE the db/betterAuth singletons are imported
// (dotenv never overrides pre-set values). // (dotenv never overrides pre-set values).
@@ -13,7 +15,13 @@ process.env.DATABASE_URL = dbPath;
process.env.FRONTEND_URL = 'http://localhost:3002'; process.env.FRONTEND_URL = 'http://localhost:3002';
process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef'; process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef';
delete process.env.REDIS_URL; // memory lockout/rate-limit backends delete process.env.REDIS_URL; // memory lockout/rate-limit backends
delete process.env.GOOGLE_CLIENT_ID;
// 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) // Capture outgoing auth emails (magic links, password resets)
const sentEmails: Array<{ to: string; subject: string; html: string }> = []; const sentEmails: Array<{ to: string; subject: string; html: string }> = [];
@@ -41,6 +49,85 @@ function extractToken(html: string, param = 'token'): string {
return decodeURIComponent(match![1]); 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 { function cookieHeaders(setCookie: string | null): Headers {
const sessionPart = (setCookie || '') const sessionPart = (setCookie || '')
.split(/,(?=[^ ;]+=)/) .split(/,(?=[^ ;]+=)/)
@@ -60,6 +147,7 @@ beforeAll(() => {
({ db } = await import('../db/index.js')); ({ db } = await import('../db/index.js'));
const Database = (await import('better-sqlite3')).default; const Database = (await import('better-sqlite3')).default;
sqlite = new Database(dbPath); sqlite = new Database(dbPath);
await installGoogleStub();
})(); })();
}, 120_000); }, 120_000);
@@ -291,3 +379,141 @@ describe('Better Auth integration', () => {
expect(days).toBeLessThan(7.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);
});
+58
View File
@@ -209,6 +209,25 @@ export const auth = betterAuth({
// Google verifies email ownership, so linking by email is safe — this // Google verifies email ownership, so linking by email is safe — this
// matches the legacy /api/auth/google auto-link behavior. // matches the legacy /api/auth/google auto-link behavior.
trustedProviders: ['google'], 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,
}, },
}, },
@@ -246,6 +265,45 @@ export const auth = betterAuth({
}, },
}, },
}, },
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: { hooks: {
+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}`;
}
+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 {
+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
+3 -2
View File
@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm';
import { auth } from '../lib/betterAuth.js'; import { auth } from '../lib/betterAuth.js';
import { validatePassword } from '../lib/passwordPolicy.js'; import { validatePassword } from '../lib/passwordPolicy.js';
import { db, dbGet, users } from '../db/index.js'; import { db, dbGet, users } from '../db/index.js';
import { getNow, toDbBool } from '../lib/utils.js'; import { getNow, toDbBool, normalizeEmail } from '../lib/utils.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js'; import { rateLimitMiddleware } from '../lib/rateLimit.js';
// Custom auth flows that Better Auth doesn't provide out of the box. Mounted // Custom auth flows that Better Auth doesn't provide out of the box. Mounted
@@ -83,8 +83,9 @@ authExt.get('/claim-eligibility', authExtRateLimit, async (c) => {
return c.json({ canClaim: false }); return c.json({ canClaim: false });
} }
// Normalized to match how the row is stored (see lib/utils.ts normalizeEmail)
const user = await dbGet<any>( const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email)) (db as any).select().from(users).where(eq((users as any).email, normalizeEmail(email)))
); );
const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended' const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended'
+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;
+26 -5
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();
+64 -36
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, toDbBool, 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,7 +166,7 @@ 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: null, // No password for guest bookings; set on claim (Better Auth credential account) 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,
@@ -548,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);
@@ -625,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
@@ -1164,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);
@@ -1418,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()
@@ -1431,7 +1442,7 @@ 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: null, password: null,
name: adminFullName, name: adminFullName,
phone: data.phone || null, phone: data.phone || null,
@@ -1529,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('')),
@@ -1546,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');
@@ -1565,20 +1583,23 @@ 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: null, password: null,
name: fullName, name: fullName,
phone: data.phone || null, phone: data.phone || null,
@@ -1613,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,
@@ -1636,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'
? { ? {
@@ -1659,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,
@@ -1668,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 => {
@@ -1692,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',
+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;
@@ -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 />;
}
@@ -7,11 +7,18 @@ 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 LoginModal from '@/components/LoginModal';
import { import {
ArrowDownTrayIcon,
CalendarIcon, CalendarIcon,
CameraIcon, CameraIcon,
LinkIcon, LinkIcon,
@@ -42,6 +49,35 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
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 [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
@@ -78,14 +114,10 @@ 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 // Gate pages for restricted galleries. After a successful login in the
@@ -222,10 +254,12 @@ 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, thumbUrl: p.urls.thumb,
})); }));
@@ -250,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" />
@@ -303,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 && (
@@ -351,9 +363,21 @@ 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}
/> />
)} )}
</div>
{/* 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 {/* Call to action: send attendees to their dashboard, everyone else to
the next event. Auth state comes from the same useAuth() the gate the next event. Auth state comes from the same useAuth() the gate
@@ -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 />;
}
+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">
@@ -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); }}
+4
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';
@@ -36,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
@@ -778,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">
@@ -846,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">
+27 -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);
@@ -579,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}
@@ -588,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">
+18 -2
View File
@@ -89,8 +89,24 @@ export default function GoogleSignInButton({
// to avoid open redirects. // to avoid open redirects.
redirectAfterAuth(safeInternalPath(redirectTo, '/dashboard')); 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 {
+56 -10
View File
@@ -7,6 +7,7 @@ import {
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;
@@ -24,17 +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({
items,
index,
onClose,
onNavigate,
renderActions,
onDownload,
downloadingId,
downloadLabels,
}: LightboxProps) {
const touchStart = useRef<{ x: number; y: number } | null>(null); const touchStart = useRef<{ x: number; y: number } | null>(null);
const activeThumbRef = useRef<HTMLButtonElement | null>(null); const activeThumbRef = useRef<HTMLButtonElement | null>(null);
const item = items[index]; const item = items[index];
const hasMultiple = items.length > 1; 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);
@@ -98,14 +117,37 @@ 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>
@@ -137,10 +179,14 @@ export default function Lightbox({ items, index, onClose, onNavigate, renderActi
)} )}
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
{/* Nothing is layered over the image (the navigation buttons sit at
the edges), and neither -webkit-touch-callout nor -webkit-user-
select is suppressed here, so iOS long-press "Save to Photos"
still works as a backup to the save sheet. Don't add select-none. */}
<img <img
src={item.previewUrl} src={item.previewUrl}
alt="" alt=""
className="max-w-[95vw] max-h-full object-contain select-none" className="max-w-[95vw] max-h-full object-contain [-webkit-touch-callout:default]"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
draggable={false} draggable={false}
/> />
@@ -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 };
}
+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'
)}
/>
);
}
+6 -1
View File
@@ -114,7 +114,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
idToken: { token: credential }, idToken: { token: credential },
}); });
if (error) { if (error) {
throw new Error(messageFrom(error, 'Google login failed')); // Carry the code through: GoogleSignInButton turns OAUTH_LINK_ERROR into
// something actionable instead of showing better-auth's bare
// "account not linked".
const err = new Error(messageFrom(error, 'Google login failed'));
(err as Error & { code?: string }).code = error.code;
throw err;
} }
await refreshUser(); await refreshUser();
}; };
+15
View File
@@ -367,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…"
}
} }
} }
+15
View File
@@ -367,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…"
}
} }
} }
+3 -1
View File
@@ -31,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;
} }
+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';
+14
View File
@@ -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 {
+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;
+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: [],
+4
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",
+12 -3
View File
@@ -29,10 +29,19 @@ 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 }` |
+81 -4
View File
@@ -14,9 +14,13 @@ Design/decisions: [PLAN.md](./PLAN.md).
- Originals are stored byte-identical for download; a worker generates JPEG - Originals are stored byte-identical for download; a worker generates JPEG
variants (thumb 512px q78, preview 2048px q85, EXIF stripped/orientation variants (thumb 512px q78, preview 2048px q85, EXIF stripped/orientation
applied) queued in the DB with retries. applied) queued in the DB with retries.
- Uploads are deduplicated per gallery: see
[Duplicate detection](#duplicate-detection).
- Storage: local disk (`STORAGE_PATH`) or S3/Garage/MinIO (set `S3_ENDPOINT` - Storage: local disk (`STORAGE_PATH`) or S3/Garage/MinIO (set `S3_ENDPOINT`
+ `S3_BUCKET`), same selection convention as the backend. S3 downloads use + `S3_BUCKET`), same selection convention as the backend, overridable with
short-lived presigned URLs; nothing in the bucket is public. `STORAGE_BACKEND`. S3 downloads use short-lived presigned URLs; nothing in
the bucket is public. Switching backends later: see
[Move the library between backends](#move-the-library-between-backends).
- Auth: validates the backend's HS256 JWTs with the shared `JWT_SECRET` - Auth: validates the backend's HS256 JWTs with the shared `JWT_SECRET`
(issuer `spanglish`, audience `spanglish-app`) including the DB-backed (issuer `spanglish`, audience `spanglish-app`) including the DB-backed
tokenVersion/account-status revocation check. Admin surface is tokenVersion/account-status revocation check. Admin surface is
@@ -37,7 +41,8 @@ go test ./... # SQLite; add PHOTO_TEST_PG=<url> to also run on Post
``` ```
From the repo root: `npm run dev:photos`, `npm run build:photos`, From the repo root: `npm run dev:photos`, `npm run build:photos`,
`npm run test:photos`. The Next dev server rewrites `/api/photos/*` to `npm run test:photos`, `npm run migrate:photos`, `npm run sync:photos`,
`npm run backfill:photos:checksums`. The Next dev server rewrites `/api/photos/*` to
`PHOTO_API_URL` (default `http://localhost:3003`), so the frontend needs no `PHOTO_API_URL` (default `http://localhost:3003`), so the frontend needs no
extra config in dev. extra config in dev.
@@ -45,6 +50,78 @@ HEIC uploads require a converter CLI on the host: `apt install libvips-tools`
(or `libheif-examples`). Without one, HEIC uploads are rejected with a clear (or `libheif-examples`). Without one, HEIC uploads are rejected with a clear
message and a startup warning is logged. message and a startup warning is logged.
## Move the library between backends
`photo-api sync` copies the whole photo library one way between local disk and
S3, so local↔S3 is a config switch rather than a migration project. **Both
backends must be configured in `photo-api/.env`** (`STORAGE_PATH` *and* the
`S3_*` values); `STORAGE_BACKEND` decides which one actually serves requests,
so filling in S3 does not switch anything by itself.
Local disk → S3:
```bash
npm run sync:photos -- to-s3 --dry-run # see what would be copied
npm run sync:photos:to-s3 # copy it
# then set STORAGE_BACKEND=s3 in photo-api/.env and restart the service
```
S3 → local disk is the same with `to-local` / `STORAGE_BACKEND=local`
(`npm run sync:photos:to-local`). From `photo-api/`: `make sync-to-s3`,
`make sync-to-local`.
Flags (`npm run sync:photos -- to-s3 --overwrite`, or after the direction on
the direct scripts): `--dry-run`, `--overwrite` (re-copy objects already
present with the same size), `--concurrency=N` (default 4), `--gallery=<id>`.
How it behaves:
- The `photos_photos` rows are the inventory — for each photo the original
plus, once processed, the thumb and preview. Objects with no row (worker
scratch files, leftovers of deleted galleries) are not copied.
- Only the destination is written. The source stays as a fallback; delete it
by hand once the switch is verified.
- Reruns are cheap and safe: objects already on the destination with the same
size are skipped, so an interrupted or partly failed sync just needs
rerunning. A failed object is logged and the exit code is non-zero.
- Keys are identical on both backends, so nothing in the database changes and
no re-processing is triggered.
- Photos uploaded *after* the copy but *before* the restart land on the old
backend. For a clean cutover, stop the service, sync, flip
`STORAGE_BACKEND`, start again — or sync a second time after the switch to
pick up stragglers.
## Duplicate detection
Every upload is hashed (sha256 of the original bytes) into
`photos_photos.checksum`, unique per `(gallery_id, checksum)`. If a gallery
already holds those exact bytes, the incoming copy is **discarded**: no object
is stored, no row is inserted, and the upload response echoes the existing
photo with `"duplicate": true`. The rest of the batch continues normally — a
duplicate is not an error and does not consume a position.
Scope is one gallery. The same image can still live in several galleries, each
with its own row and its own stored object, so deleting a gallery never orphans
another one's photos.
The admin uploader panel shows those rows as *"Already in this gallery"*; no
second tile appears in the grid.
Photos uploaded before this existed have no checksum, so they are not matched
until hashed once:
```bash
npm run backfill:photos:checksums -- --dry-run # what would be hashed
npm run backfill:photos:checksums # hash it
```
From `photo-api/`: `make backfill-checksums`. Flags: `--dry-run`,
`--concurrency=N` (default 4), `--gallery=<id>`. It reads from the active
`STORAGE_BACKEND`, only ever writes the checksum column, and is idempotent —
rerun it after a sync or a restore. Photos whose content already matches an
earlier one in the same gallery are **reported and left unhashed**; the command
never deletes anything, so removing the extras is an admin's call.
## API ## API
Everything under `/api/photos`. Errors are `{"error": string}`. Everything under `/api/photos`. Errors are `{"error": string}`.
@@ -58,7 +135,7 @@ Admin (Bearer token, role admin/organizer):
| GET | `/api/photos/galleries/:id` | gallery + photos (all statuses) | | GET | `/api/photos/galleries/:id` | gallery + photos (all statuses) |
| PATCH | `/api/photos/galleries/:id` | update title/visibility/event/cover | | PATCH | `/api/photos/galleries/:id` | update title/visibility/event/cover |
| DELETE | `/api/photos/galleries/:id` | delete gallery + objects | | DELETE | `/api/photos/galleries/:id` | delete gallery + objects |
| POST | `/api/photos/galleries/:id/photos` | multipart upload (`files`) | | POST | `/api/photos/galleries/:id/photos` | multipart upload (`files`), deduplicated |
| PATCH | `/api/photos/galleries/:id/order` | reorder (`{photoIds}`) | | PATCH | `/api/photos/galleries/:id/order` | reorder (`{photoIds}`) |
| POST | `/api/photos/galleries/:id/share-token` | rotate share token | | POST | `/api/photos/galleries/:id/share-token` | rotate share token |
| DELETE | `/api/photos/photos/:photoId` | delete photo | | DELETE | `/api/photos/photos/:photoId` | delete photo |
+135 -15
View File
@@ -1,12 +1,15 @@
// photo-api serves event photo galleries for the Spanglish platform. // photo-api serves event photo galleries for the Spanglish platform.
// //
// photo-api start the HTTP server // photo-api start the HTTP server
// photo-api migrate apply pending photos_* migrations and exit // photo-api migrate apply pending photos_* migrations and exit
// photo-api sync to-s3|to-local copy the photo library between backends
// photo-api backfill-checksums hash photos uploaded before duplicate detection
package main package main
import ( import (
"context" "context"
"errors" "errors"
"flag"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -16,9 +19,11 @@ import (
"time" "time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/checksum"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/httpapi" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/httpapi"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/photosync"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker"
@@ -38,15 +43,21 @@ func main() {
} }
defer db.Close() defer db.Close()
if len(os.Args) > 1 && os.Args[1] == "migrate" {
if err := db.Migrate(context.Background()); err != nil {
log.Fatalf("migrate: %v", err)
}
log.Println("migrations up to date")
return
}
if len(os.Args) > 1 { if len(os.Args) > 1 {
log.Fatalf("unknown subcommand %q (expected: migrate)", os.Args[1]) switch os.Args[1] {
case "migrate":
if err := db.Migrate(context.Background()); err != nil {
log.Fatalf("migrate: %v", err)
}
log.Println("migrations up to date")
case "sync":
runSync(cfg, db, os.Args[2:])
case "backfill-checksums":
runBackfillChecksums(cfg, db, os.Args[2:])
default:
log.Fatalf("unknown subcommand %q (expected: migrate, sync, backfill-checksums)", os.Args[1])
}
return
} }
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
@@ -64,11 +75,7 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("storage: %v", err) log.Fatalf("storage: %v", err)
} }
if cfg.S3Enabled() { log.Printf("storage: %s (STORAGE_BACKEND=%s)", storage.Name(cfg, st), cfg.StorageBackend)
log.Printf("storage: S3 bucket %s at %s", cfg.S3Bucket, cfg.S3Endpoint)
} else {
log.Printf("storage: local disk at %s", cfg.StoragePath)
}
heic := imaging.DetectHeicConverter(cfg.HeicConverter) heic := imaging.DetectHeicConverter(cfg.HeicConverter)
if heic == nil { if heic == nil {
@@ -104,3 +111,116 @@ func main() {
log.Printf("shutdown: %v", err) log.Printf("shutdown: %v", err)
} }
} }
const syncUsage = `usage: photo-api sync to-s3|to-local [flags]
Copies every object of every photo (original, thumb, preview) from one storage
backend to the other. Both must be configured in photo-api/.env; the source is
never modified, and reruns skip objects already present on the destination.
Flags:
`
// runSync handles `photo-api sync <direction> [flags]`, the storage migration
// used when moving the library between local disk and S3.
func runSync(cfg config.Config, db *store.DB, args []string) {
fs := flag.NewFlagSet("sync", flag.ExitOnError)
fs.Usage = func() {
fmt.Fprint(fs.Output(), syncUsage)
fs.PrintDefaults()
}
var (
dryRun = fs.Bool("dry-run", false, "report what would be copied without writing")
overwrite = fs.Bool("overwrite", false, "re-copy objects already present with the same size")
workers = fs.Int("concurrency", 4, "objects copied in parallel")
gallery = fs.String("gallery", "", "limit to one gallery id (default: whole library)")
)
if len(args) == 0 {
fs.Usage()
os.Exit(2)
}
direction, err := photosync.ParseDirection(args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n\n", err)
fs.Usage()
os.Exit(2)
}
if err := fs.Parse(args[1:]); err != nil {
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if _, err := photosync.Run(ctx, cfg, db, photosync.Options{
Direction: direction,
GalleryID: *gallery,
Concurrency: *workers,
DryRun: *dryRun,
Overwrite: *overwrite,
}); err != nil {
log.Fatalf("sync: %v", err)
}
if *dryRun {
return
}
target := "s3"
if direction == photosync.ToLocal {
target = "local"
}
log.Printf("sync: set STORAGE_BACKEND=%s in photo-api/.env and restart the service to serve from it "+
"(the source copy is left in place — delete it once the switch is verified)", target)
}
const backfillUsage = `usage: photo-api backfill-checksums [flags]
Hashes the stored original of every photo that has no checksum yet, so uploads
of a photo already in a gallery are recognised as duplicates. Runs against the
active storage backend (STORAGE_BACKEND) and only ever writes the checksum
column no photo is deleted. Photos whose content already matches an earlier
one in the same gallery are reported and left unhashed.
Flags:
`
// runBackfillChecksums handles `photo-api backfill-checksums [flags]`, the
// one-off pass needed after the checksum column is added to an existing
// library.
func runBackfillChecksums(cfg config.Config, db *store.DB, args []string) {
fs := flag.NewFlagSet("backfill-checksums", flag.ExitOnError)
fs.Usage = func() {
fmt.Fprint(fs.Output(), backfillUsage)
fs.PrintDefaults()
}
var (
dryRun = fs.Bool("dry-run", false, "report what would be hashed without writing")
workers = fs.Int("concurrency", 4, "originals hashed in parallel")
gallery = fs.String("gallery", "", "limit to one gallery id (default: whole library)")
)
if err := fs.Parse(args); err != nil {
os.Exit(2)
}
st, err := storage.New(cfg)
if err != nil {
log.Fatalf("storage: %v", err)
}
log.Printf("backfill-checksums: reading from %s", storage.Name(cfg, st))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
res, err := checksum.Backfill(ctx, db, st, checksum.Options{
GalleryID: *gallery,
Concurrency: *workers,
DryRun: *dryRun,
})
if err != nil {
log.Fatalf("backfill-checksums: %v", err)
}
if res.Duplicates > 0 {
log.Printf("backfill-checksums: %d existing photos duplicate an earlier one in their gallery "+
"(listed above); they still show in the gallery — delete the unwanted ones from the admin page",
res.Duplicates)
}
}
+145
View File
@@ -0,0 +1,145 @@
package checksum
import (
"context"
"errors"
"fmt"
"log"
"sync"
"sync/atomic"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// Options configures a backfill run.
type Options struct {
// GalleryID limits the run to one gallery; empty means the whole library.
GalleryID string
// Concurrency is how many originals are hashed at a time.
Concurrency int
// DryRun reports what would be hashed without writing to the database.
DryRun bool
}
// Result counts what a run did. Duplicates are rows whose bytes match an
// earlier photo in the same gallery; they keep a NULL checksum and are listed
// so an admin can decide what to do with them.
type Result struct {
Total int // rows without a checksum
Hashed int // hashed and recorded (or, with DryRun, would be)
Duplicates int
Missing int // original object absent from storage
Failed int
}
// Backfill hashes the stored original of every photo that has no checksum yet,
// so duplicate detection also catches re-uploads of photos from before the
// checksum column existed. It only ever writes the checksum column; no photo,
// row or object is deleted.
//
// It is idempotent: rerunning it finds only what the previous run left behind.
func Backfill(ctx context.Context, db *store.DB, st storage.Storage, opts Options) (Result, error) {
if opts.Concurrency < 1 {
opts.Concurrency = 4
}
photos, err := db.PhotosMissingChecksum(ctx, opts.GalleryID)
if err != nil {
return Result{}, fmt.Errorf("list photos without a checksum: %w", err)
}
prefix := ""
if opts.DryRun {
prefix = "[dry-run] "
}
log.Printf("backfill-checksums: %s%d photos to hash, concurrency %d", prefix, len(photos), opts.Concurrency)
var (
mu sync.Mutex
res = Result{Total: len(photos)}
done int64
jobs = make(chan store.Photo)
wg sync.WaitGroup
)
// Sequential hashing per worker, but the database update is what can
// collide: two photos of the same gallery with identical bytes race, and
// the unique index decides which one keeps the checksum.
for i := 0; i < opts.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for p := range jobs {
if ctx.Err() != nil {
return
}
sum, err := hashOriginal(ctx, st, p)
if err == nil && !opts.DryRun {
err = db.SetPhotoChecksum(ctx, p.ID, sum)
}
n := atomic.AddInt64(&done, 1)
mu.Lock()
switch {
case errors.Is(err, storage.ErrNotExist):
res.Missing++
log.Printf("backfill-checksums: [%d/%d] MISSING object, skipped: %s (photo %s)",
n, len(photos), p.OriginalKey, p.ID)
case store.IsUniqueViolation(err):
res.Duplicates++
other := "an earlier photo"
if existing, findErr := db.FindPhotoByChecksum(ctx, p.GalleryID, sum); findErr == nil {
other = "photo " + existing.ID
}
log.Printf("backfill-checksums: [%d/%d] DUPLICATE: photo %s (%s) has the same content as %s "+
"in gallery %s — left without a checksum, delete it by hand if unwanted",
n, len(photos), p.ID, p.OriginalFilename, other, p.GalleryID)
case err != nil:
res.Failed++
log.Printf("backfill-checksums: [%d/%d] FAILED %s (photo %s): %v",
n, len(photos), p.OriginalKey, p.ID, err)
default:
res.Hashed++
}
mu.Unlock()
}
}()
}
for _, p := range photos {
select {
case jobs <- p:
case <-ctx.Done():
}
if ctx.Err() != nil {
break
}
}
close(jobs)
wg.Wait()
log.Printf("backfill-checksums: %sdone — %d hashed, %d duplicates left unhashed, %d objects missing, %d failed",
prefix, res.Hashed, res.Duplicates, res.Missing, res.Failed)
if err := ctx.Err(); err != nil {
return res, fmt.Errorf("interrupted after %d/%d photos: %w", res.Hashed, res.Total, err)
}
if res.Failed > 0 {
return res, fmt.Errorf("%d of %d photos failed to hash (rerun to retry; already-hashed photos are skipped)",
res.Failed, res.Total)
}
return res, nil
}
// hashOriginal reads a photo's stored original. Stat runs first because it is
// the only call that reports a missing object as storage.ErrNotExist on both
// backends — Open surfaces the driver's own error.
func hashOriginal(ctx context.Context, st storage.Storage, p store.Photo) (string, error) {
if _, err := st.Stat(ctx, p.OriginalKey); err != nil {
return "", err
}
r, _, err := st.Open(ctx, p.OriginalKey)
if err != nil {
return "", err
}
defer r.Close()
return Sum(r)
}
@@ -0,0 +1,175 @@
package checksum
import (
"context"
"path/filepath"
"strings"
"testing"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
const galleryID = "11111111-1111-1111-1111-111111111111"
// env builds a migrated scratch SQLite database with one gallery, plus a local
// storage backend rooted next to it.
func env(t *testing.T) (*store.DB, storage.Storage) {
t.Helper()
dir := t.TempDir()
db, err := store.Open(config.Config{DBType: "sqlite", DatabaseURL: filepath.Join(dir, "test.db")})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
ctx := context.Background()
if err := db.Migrate(ctx); err != nil {
t.Fatal(err)
}
now := time.Now()
if err := db.CreateGallery(ctx, store.Gallery{
ID: galleryID, Slug: "g", Title: "G", Visibility: "private", ShareToken: "tok",
CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
st, err := storage.NewLocal(filepath.Join(dir, "photos"))
if err != nil {
t.Fatal(err)
}
return db, st
}
// seed inserts an unhashed photo whose original holds body, mimicking a row
// uploaded before the checksum column existed.
func seed(t *testing.T, db *store.DB, st storage.Storage, id, body string) store.Photo {
t.Helper()
ctx := context.Background()
key := "galleries/" + galleryID + "/orig/" + id + ".jpg"
if body != "" {
if err := st.Put(ctx, key, strings.NewReader(body), int64(len(body)), "image/jpeg"); err != nil {
t.Fatal(err)
}
}
now := time.Now()
p := store.Photo{
ID: id, GalleryID: galleryID, OriginalKey: key, OriginalFilename: id + ".jpg",
ContentType: "image/jpeg", SizeBytes: int64(len(body)), Status: "ready",
NextAttemptAt: now, CreatedAt: now, UpdatedAt: now,
}
if err := db.InsertPhoto(ctx, p); err != nil {
t.Fatal(err)
}
return p
}
func checksumOf(t *testing.T, db *store.DB, id string) string {
t.Helper()
p, err := db.GetPhoto(context.Background(), id)
if err != nil {
t.Fatal(err)
}
return p.Checksum
}
func TestBackfill(t *testing.T) {
ctx := context.Background()
t.Run("hashes unhashed photos and is idempotent", func(t *testing.T) {
db, st := env(t)
seed(t, db, st, "p1", "alpha")
seed(t, db, st, "p2", "beta")
res, err := Backfill(ctx, db, st, Options{})
if err != nil {
t.Fatal(err)
}
if res.Total != 2 || res.Hashed != 2 || res.Duplicates != 0 || res.Failed != 0 {
t.Fatalf("first run: %+v", res)
}
want, err := Sum(strings.NewReader("alpha"))
if err != nil {
t.Fatal(err)
}
if got := checksumOf(t, db, "p1"); got != want {
t.Fatalf("p1 checksum = %q, want %q", got, want)
}
res, err = Backfill(ctx, db, st, Options{})
if err != nil {
t.Fatal(err)
}
if res.Total != 0 || res.Hashed != 0 {
t.Fatalf("rerun should find nothing to do: %+v", res)
}
})
t.Run("reports pre-existing duplicates and leaves them unhashed", func(t *testing.T) {
db, st := env(t)
seed(t, db, st, "p1", "same")
seed(t, db, st, "p2", "same")
res, err := Backfill(ctx, db, st, Options{Concurrency: 1})
if err != nil {
t.Fatal(err)
}
if res.Hashed != 1 || res.Duplicates != 1 || res.Failed != 0 {
t.Fatalf("got %+v, want 1 hashed and 1 duplicate", res)
}
// The earlier row keeps the checksum, the later one is left alone —
// nothing is deleted either way.
if checksumOf(t, db, "p1") == "" {
t.Fatal("p1 should have been hashed")
}
if got := checksumOf(t, db, "p2"); got != "" {
t.Fatalf("p2 checksum = %q, want empty", got)
}
if _, err := db.GetPhoto(ctx, "p2"); err != nil {
t.Fatalf("duplicate row must survive: %v", err)
}
})
t.Run("skips photos whose object is gone", func(t *testing.T) {
db, st := env(t)
seed(t, db, st, "p1", "") // row without a stored original
res, err := Backfill(ctx, db, st, Options{})
if err != nil {
t.Fatal(err)
}
if res.Missing != 1 || res.Hashed != 0 || res.Failed != 0 {
t.Fatalf("got %+v, want 1 missing", res)
}
})
t.Run("dry run writes nothing", func(t *testing.T) {
db, st := env(t)
seed(t, db, st, "p1", "alpha")
res, err := Backfill(ctx, db, st, Options{DryRun: true})
if err != nil {
t.Fatal(err)
}
if res.Hashed != 1 {
t.Fatalf("got %+v, want 1 hashed", res)
}
if got := checksumOf(t, db, "p1"); got != "" {
t.Fatalf("dry run recorded %q", got)
}
})
t.Run("limits to one gallery", func(t *testing.T) {
db, st := env(t)
seed(t, db, st, "p1", "alpha")
res, err := Backfill(ctx, db, st, Options{GalleryID: "22222222-2222-2222-2222-222222222222"})
if err != nil {
t.Fatal(err)
}
if res.Total != 0 {
t.Fatalf("other gallery should have nothing to do: %+v", res)
}
})
}
+31
View File
@@ -0,0 +1,31 @@
// Package checksum defines the content hash used to spot duplicate photos and
// the backfill that fills it in for photos uploaded before it existed.
//
// The hash is sha256 over the untouched original bytes, hex encoded, stored in
// photos_photos.checksum. Duplicate scope is one gallery — the unique index is
// on (gallery_id, checksum) — so the same image may still live in several
// galleries, each with its own row and its own stored object.
package checksum
import (
"crypto/sha256"
"encoding/hex"
"hash"
"io"
)
// New returns a fresh hasher. Upload hashes as it spools the body, so it needs
// the writer rather than a finished reader.
func New() hash.Hash { return sha256.New() }
// Format renders a hasher's digest the way it is stored.
func Format(h hash.Hash) string { return hex.EncodeToString(h.Sum(nil)) }
// Sum reads r to EOF and returns its digest.
func Sum(r io.Reader) (string, error) {
h := New()
if _, err := io.Copy(h, r); err != nil {
return "", err
}
return Format(h), nil
}
+31 -3
View File
@@ -20,6 +20,11 @@ type Config struct {
// JWT_SECRET during the Better Auth migration. // JWT_SECRET during the Better Auth migration.
ViewTokenSecret string ViewTokenSecret string
// StorageBackend selects the active backend: "auto" (S3 when it is
// configured, local otherwise), "local" or "s3". Explicit values let both
// backends stay configured — required to run `photo-api sync`, and the
// one-line switch after a sync.
StorageBackend string
StoragePath string StoragePath string
S3Endpoint string S3Endpoint string
S3Region string S3Region string
@@ -34,12 +39,26 @@ type Config struct {
HeicConverter string // optional explicit converter command; autodetected when empty HeicConverter string // optional explicit converter command; autodetected when empty
} }
// S3Enabled mirrors backend/src/lib/storage.ts: S3 is active when both // S3Configured reports whether the S3 credentials are present at all,
// S3_ENDPOINT and S3_BUCKET are set. // mirroring backend/src/lib/storage.ts: both S3_ENDPOINT and S3_BUCKET set.
func (c Config) S3Enabled() bool { // Kept separate from S3Enabled so a configured-but-inactive S3 backend can
// still be reached by `photo-api sync`.
func (c Config) S3Configured() bool {
return c.S3Endpoint != "" && c.S3Bucket != "" return c.S3Endpoint != "" && c.S3Bucket != ""
} }
// S3Enabled reports whether S3 is the backend serving requests.
func (c Config) S3Enabled() bool {
switch c.StorageBackend {
case "s3":
return true
case "local":
return false
default: // auto
return c.S3Configured()
}
}
func Load() (Config, error) { func Load() (Config, error) {
loadDotenv(".env") loadDotenv(".env")
@@ -48,6 +67,7 @@ func Load() (Config, error) {
DBType: strings.ToLower(env("DB_TYPE", "sqlite")), DBType: strings.ToLower(env("DB_TYPE", "sqlite")),
DatabaseURL: env("DATABASE_URL", ""), DatabaseURL: env("DATABASE_URL", ""),
ViewTokenSecret: env("PHOTO_VIEW_SECRET", env("JWT_SECRET", "")), ViewTokenSecret: env("PHOTO_VIEW_SECRET", env("JWT_SECRET", "")),
StorageBackend: strings.ToLower(env("STORAGE_BACKEND", "auto")),
StoragePath: env("STORAGE_PATH", "./data/photos"), StoragePath: env("STORAGE_PATH", "./data/photos"),
S3Endpoint: env("S3_ENDPOINT", ""), S3Endpoint: env("S3_ENDPOINT", ""),
S3Region: env("S3_REGION", "auto"), S3Region: env("S3_REGION", "auto"),
@@ -70,6 +90,14 @@ func Load() (Config, error) {
if cfg.ViewTokenSecret == "" { if cfg.ViewTokenSecret == "" {
return cfg, fmt.Errorf("PHOTO_VIEW_SECRET is required (or legacy JWT_SECRET as fallback)") return cfg, fmt.Errorf("PHOTO_VIEW_SECRET is required (or legacy JWT_SECRET as fallback)")
} }
switch cfg.StorageBackend {
case "auto", "local", "s3":
default:
return cfg, fmt.Errorf("STORAGE_BACKEND must be auto, local or s3, got %q", cfg.StorageBackend)
}
if cfg.StorageBackend == "s3" && !cfg.S3Configured() {
return cfg, fmt.Errorf("STORAGE_BACKEND=s3 requires S3_ENDPOINT and S3_BUCKET")
}
return cfg, nil return cfg, nil
} }
+320 -10
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"image" "image"
"image/color" "image/color"
@@ -38,10 +39,11 @@ const (
) )
type testEnv struct { type testEnv struct {
handler http.Handler handler http.Handler
db *store.DB db *store.DB
worker *worker.Worker worker *worker.Worker
pg bool storagePath string
pg bool
} }
// setup migrates a scratch DB (SQLite by default; Postgres when // setup migrates a scratch DB (SQLite by default; Postgres when
@@ -123,7 +125,13 @@ func setup(t *testing.T) *testEnv {
} }
wrk := worker.New(db, st, nil, cfg.StoragePath, 1) wrk := worker.New(db, st, nil, cfg.StoragePath, 1)
srv := New(cfg, db, st, auth.NewVerifier(db), wrk) srv := New(cfg, db, st, auth.NewVerifier(db), wrk)
return &testEnv{handler: srv.Handler(), db: db, worker: wrk, pg: cfg.DBType == "postgres"} return &testEnv{
handler: srv.Handler(),
db: db,
worker: wrk,
storagePath: cfg.StoragePath,
pg: cfg.DBType == "postgres",
}
} }
// makeToken inserts a Better Auth session row for the user and returns a // makeToken inserts a Better Auth session row for the user and returns a
@@ -175,12 +183,16 @@ func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T {
return v return v
} }
func testJPEG(t *testing.T) []byte { func testJPEG(t *testing.T) []byte { return testJPEGTinted(t, 128) }
// testJPEGTinted varies the blue channel so tests that need two *different*
// images (duplicate detection) can get them without a fixture file.
func testJPEGTinted(t *testing.T, blue uint8) []byte {
t.Helper() t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 800, 600)) img := image.NewRGBA(image.Rect(0, 0, 800, 600))
for x := 0; x < 800; x += 10 { for x := 0; x < 800; x += 10 {
for y := 0; y < 600; y++ { for y := 0; y < 600; y++ {
img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: 128, A: 255}) img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: blue, A: 255})
} }
} }
var buf bytes.Buffer var buf bytes.Buffer
@@ -296,6 +308,130 @@ func uploadPhoto(t *testing.T, e *testEnv, admin, galleryID string, file []byte)
return photos[0] return photos[0]
} }
// uploadFiles posts several parts in one request, the way the API allows even
// though the admin UI sends one file per request.
func uploadFiles(t *testing.T, e *testEnv, admin, galleryID string, files ...[]byte) []photoJSON {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for i, f := range files {
fw, _ := mw.CreateFormFile("files", fmt.Sprintf("photo-%d.jpg", i))
fw.Write(f)
}
mw.Close()
req := httptest.NewRequest("POST", "/api/photos/galleries/"+galleryID+"/photos", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+admin)
w := httptest.NewRecorder()
e.handler.ServeHTTP(w, req)
if w.Code != 201 {
t.Fatalf("upload: %d %s", w.Code, w.Body.String())
}
return decode[struct {
Photos []photoJSON `json:"photos"`
}](t, w).Photos
}
// countOriginals is how many objects actually landed on disk for a gallery —
// a duplicate must not add one.
func countOriginals(t *testing.T, e *testEnv, galleryID string) int {
t.Helper()
entries, err := os.ReadDir(filepath.Join(e.storagePath, "galleries", galleryID, "orig"))
if os.IsNotExist(err) {
return 0
}
if err != nil {
t.Fatal(err)
}
return len(entries)
}
func TestUploadSkipsDuplicates(t *testing.T) {
e := setup(t)
admin := e.makeToken(t, uAdmin)
g := createGallery(t, e, admin, map[string]any{"title": "Dupes"})
img := testJPEG(t)
first := uploadPhoto(t, e, admin, g.ID, img)
if first.Duplicate {
t.Fatalf("first upload flagged as duplicate: %+v", first)
}
// Same bytes again: echoed back as the existing photo, nothing stored.
second := uploadPhoto(t, e, admin, g.ID, img)
if !second.Duplicate {
t.Fatalf("second upload not flagged as duplicate: %+v", second)
}
if second.ID != first.ID {
t.Fatalf("duplicate should echo the existing photo: got %s, want %s", second.ID, first.ID)
}
photos, err := e.db.ListPhotos(context.Background(), g.ID, false)
if err != nil {
t.Fatal(err)
}
if len(photos) != 1 {
t.Fatalf("want 1 row after re-upload, got %d", len(photos))
}
if n := countOriginals(t, e, g.ID); n != 1 {
t.Fatalf("want 1 stored original after re-upload, got %d", n)
}
if photos[0].Checksum == "" {
t.Fatal("checksum was not recorded")
}
// A different image is unaffected and takes the next position.
other := uploadPhoto(t, e, admin, g.ID, testJPEGTinted(t, 32))
if other.Duplicate || other.ID == first.ID {
t.Fatalf("distinct image treated as duplicate: %+v", other)
}
if other.Position != 1 {
t.Fatalf("want position 1 for the second distinct photo, got %d", other.Position)
}
// Duplicate scope is one gallery: the same bytes elsewhere upload normally.
g2 := createGallery(t, e, admin, map[string]any{"title": "Other gallery"})
elsewhere := uploadPhoto(t, e, admin, g2.ID, img)
if elsewhere.Duplicate {
t.Fatalf("same image in another gallery must not be a duplicate: %+v", elsewhere)
}
if elsewhere.ID == first.ID {
t.Fatal("second gallery should get its own photo row")
}
}
func TestUploadBatchContinuesPastDuplicate(t *testing.T) {
e := setup(t)
admin := e.makeToken(t, uAdmin)
g := createGallery(t, e, admin, map[string]any{"title": "Batch"})
a, b, c := testJPEG(t), testJPEGTinted(t, 32), testJPEGTinted(t, 200)
if got := uploadFiles(t, e, admin, g.ID, a); len(got) != 1 {
t.Fatalf("seed upload: %d photos", len(got))
}
// b duplicates nothing, a is already there, c is new: the batch must not
// abort, and the duplicate must not consume a position.
batch := uploadFiles(t, e, admin, g.ID, b, a, c)
if len(batch) != 3 {
t.Fatalf("want 3 results, got %d", len(batch))
}
if batch[0].Duplicate || !batch[1].Duplicate || batch[2].Duplicate {
t.Fatalf("duplicate flags: %v %v %v", batch[0].Duplicate, batch[1].Duplicate, batch[2].Duplicate)
}
if batch[0].Position != 1 || batch[2].Position != 2 {
t.Fatalf("positions should stay dense: %d, %d", batch[0].Position, batch[2].Position)
}
photos, err := e.db.ListPhotos(context.Background(), g.ID, false)
if err != nil {
t.Fatal(err)
}
if len(photos) != 3 {
t.Fatalf("want 3 rows, got %d", len(photos))
}
if n := countOriginals(t, e, g.ID); n != 3 {
t.Fatalf("want 3 stored originals, got %d", n)
}
}
// processQueue runs the worker until the photo is ready or failed. // processQueue runs the worker until the photo is ready or failed.
func processQueue(t *testing.T, e *testEnv, photoID string) store.Photo { func processQueue(t *testing.T, e *testEnv, photoID string) store.Photo {
t.Helper() t.Helper()
@@ -465,9 +601,9 @@ func TestReorderAndVisibilityUpdate(t *testing.T) {
e := setup(t) e := setup(t)
admin := e.makeToken(t, uAdmin) admin := e.makeToken(t, uAdmin)
g := createGallery(t, e, admin, map[string]any{"title": "Orden", "visibility": "public"}) g := createGallery(t, e, admin, map[string]any{"title": "Orden", "visibility": "public"})
jpg := testJPEG(t) // Two distinct images: the same bytes twice would be deduplicated.
p1 := uploadPhoto(t, e, admin, g.ID, jpg) p1 := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
p2 := uploadPhoto(t, e, admin, g.ID, jpg) p2 := uploadPhoto(t, e, admin, g.ID, testJPEGTinted(t, 32))
if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin, if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin,
map[string]any{"photoIds": []string{p2.ID, p1.ID}}); w.Code != 200 { map[string]any{"photoIds": []string{p2.ID, p1.ID}}); w.Code != 200 {
@@ -614,3 +750,177 @@ func TestSlugCollision(t *testing.T) {
t.Fatalf("default visibility: %s", a.Visibility) t.Fatalf("default visibility: %s", a.Visibility)
} }
} }
// TestDownloadEndpoint pins the guarantees the mobile save sheet is built
// on: the bytes arrive same-origin (never a redirect), typed as an image,
// named after the event, sized up front and cacheable forever.
func TestDownloadEndpoint(t *testing.T) {
e := setup(t)
admin := e.makeToken(t, uAdmin)
g := createGallery(t, e, admin, map[string]any{
"title": "Fotos Del Evento", "visibility": "public", "eventId": evPaid,
})
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
if processQueue(t, e, p.ID).Status != "ready" {
t.Fatal("processing failed")
}
base := "/api/photos/files/" + p.ID + "/download"
for _, tc := range []struct {
name, path, wantDisposition, wantFilename string
}{
// No ?size at all must behave exactly like ?size=preview.
{"default", base, "inline", "spanglish-fiesta-1.jpg"},
{"preview", base + "?size=preview", "inline", "spanglish-fiesta-1.jpg"},
{"original", base + "?size=original", "attachment", "spanglish-fiesta-1.jpg"},
} {
t.Run(tc.name, func(t *testing.T) {
w := e.request(t, "GET", tc.path, "", nil)
if w.Code != 200 {
t.Fatalf("status %d %s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Fatalf("content type %q: iOS only offers Save Image for image/*", ct)
}
if n := w.Header().Get("Content-Length"); n != fmt.Sprint(w.Body.Len()) || n == "0" {
t.Fatalf("content length %q, body %d bytes", n, w.Body.Len())
}
cd := w.Header().Get("Content-Disposition")
if !strings.HasPrefix(cd, tc.wantDisposition) || !strings.Contains(cd, tc.wantFilename) {
t.Fatalf("disposition %q, want %s with %s", cd, tc.wantDisposition, tc.wantFilename)
}
if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
t.Fatalf("cache-control %q", cc)
}
})
}
// Each size serves its own variant byte-for-byte. (No size comparison
// between the two here: the fixture is a synthetic 800x600 image, so its
// 2048px preview re-encode is larger than the "original" — which says
// nothing about the multi-megapixel photos the sheet exists for.)
prev := e.request(t, "GET", base, "", nil)
orig := e.request(t, "GET", base+"?size=original", "", nil)
if !bytes.Equal(prev.Body.Bytes(), e.request(t, "GET", "/api/photos/files/"+p.ID+"/preview", "", nil).Body.Bytes()) {
t.Fatal("download?size=preview must serve the same bytes as the preview variant")
}
if !bytes.Equal(orig.Body.Bytes(), e.request(t, "GET", "/api/photos/files/"+p.ID+"/original", "", nil).Body.Bytes()) {
t.Fatal("download?size=original must serve the same bytes as the original variant")
}
// An unknown size is not a silent fallback.
if w := e.request(t, "GET", base+"?size=thumb", "", nil); w.Code != 404 {
t.Fatalf("unknown size: want 404, got %d", w.Code)
}
// The gallery response labels both rows without any extra request.
detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil))
got := detail.Photos[0]
if got.PreviewSizeBytes != int64(prev.Body.Len()) {
t.Fatalf("previewSizeBytes %d, served %d", got.PreviewSizeBytes, prev.Body.Len())
}
if got.SizeBytes != int64(orig.Body.Len()) {
t.Fatalf("sizeBytes %d, served %d", got.SizeBytes, orig.Body.Len())
}
if got.URLs.Download != base {
t.Fatalf("download url %q, want %q", got.URLs.Download, base)
}
if got.URLs.DownloadOriginal != base+"?size=original" {
t.Fatalf("downloadOriginal url %q", got.URLs.DownloadOriginal)
}
}
// A restricted gallery's download must obey the same token check as every
// other byte-serving route, and must not become shared-cacheable.
func TestDownloadRespectsAccess(t *testing.T) {
e := setup(t)
admin := e.makeToken(t, uAdmin)
g := createGallery(t, e, admin, map[string]any{"title": "Privada Descarga", "visibility": "private"})
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
if processQueue(t, e, p.ID).Status != "ready" {
t.Fatal("processing failed")
}
detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil))
url := detail.Photos[0].URLs.Download
if !strings.Contains(url, "?token=v1.") {
t.Fatalf("private download URL should carry a view token: %s", url)
}
// ?size and ?token have to coexist on the original's URL.
if o := detail.Photos[0].URLs.DownloadOriginal; !strings.Contains(o, "?size=original&token=v1.") {
t.Fatalf("private original download URL: %s", o)
}
w := e.request(t, "GET", url, "", nil)
if w.Code != 200 {
t.Fatalf("view token fetch: want 200, got %d", w.Code)
}
// Restricted galleries stay out of shared caches; the browser's own
// cache (which is what the save sheet reuses) still applies.
if cc := w.Header().Get("Cache-Control"); cc != "private, max-age=31536000, immutable" {
t.Fatalf("cache-control %q must not be public for a private gallery", cc)
}
bare := strings.SplitN(url, "?", 2)[0]
if w := e.request(t, "GET", bare, "", nil); w.Code != 403 {
t.Fatalf("anon download without token: want 403, got %d", w.Code)
}
if w := e.request(t, "GET", bare+"?size=original", "", nil); w.Code != 403 {
t.Fatalf("anon original download without token: want 403, got %d", w.Code)
}
}
// Photos processed before preview_size_bytes existed have no stored size.
// The first gallery view measures them and writes the result back, so no
// separate backfill command is needed for an existing library.
func TestPreviewSizeBackfilledOnRead(t *testing.T) {
e := setup(t)
ctx := context.Background()
admin := e.makeToken(t, uAdmin)
g := createGallery(t, e, admin, map[string]any{"title": "Antigua", "visibility": "public"})
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
if processQueue(t, e, p.ID).Status != "ready" {
t.Fatal("processing failed")
}
// Rewind to what an pre-migration row looks like.
if _, err := e.db.ExecContext(ctx, e.db.Rebind(
"UPDATE photos_photos SET preview_size_bytes = NULL WHERE id = ?"), p.ID); err != nil {
t.Fatal(err)
}
if before, err := e.db.GetPhoto(ctx, p.ID); err != nil {
t.Fatal(err)
} else if before.PreviewSizeBytes != 0 {
t.Fatalf("setup: want an unmeasured row, got %d", before.PreviewSizeBytes)
}
served := e.request(t, "GET", "/api/photos/files/"+p.ID+"/download", "", nil).Body.Len()
got := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil))
if got.Photos[0].PreviewSizeBytes != int64(served) {
t.Fatalf("response size %d, served %d", got.Photos[0].PreviewSizeBytes, served)
}
// …and it was persisted, so the next view costs no Stat.
after, err := e.db.GetPhoto(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
if after.PreviewSizeBytes != int64(served) {
t.Fatalf("stored size %d, served %d", after.PreviewSizeBytes, served)
}
}
// Originals uploaded as HEIC or PNG must keep their real type and extension,
// and a row with a missing/generic type must still go out as an image.
func TestDownloadContentTypeNeverOctetStream(t *testing.T) {
for _, tc := range []struct{ ct, name, want string }{
{"image/heic", "IMG_1.HEIC", "image/heic"},
{"image/png", "shot.png", "image/png"},
{"application/octet-stream", "IMG_2.heic", "image/heic"},
{"application/octet-stream", "scan.PNG", "image/png"},
{"", "photo.jpeg", "image/jpeg"},
{"", "", "image/jpeg"},
} {
if got := imageContentType(tc.ct, tc.name); got != tc.want {
t.Errorf("imageContentType(%q, %q) = %q, want %q", tc.ct, tc.name, got, tc.want)
}
}
}
+36 -1
View File
@@ -42,6 +42,13 @@ type photoURLs struct {
Thumb string `json:"thumb,omitempty"` Thumb string `json:"thumb,omitempty"`
Preview string `json:"preview,omitempty"` Preview string `json:"preview,omitempty"`
Original string `json:"original"` Original string `json:"original"`
// Download / DownloadOriginal hit the download endpoint (files.go), which
// always streams the bytes same-origin instead of redirecting to S3, so a
// fetch() can read them into a Blob without CORS. Download serves the
// preview variant and doubles as the lightbox's <img> source, so the save
// fetch is answered from the HTTP cache rather than the network.
Download string `json:"download,omitempty"`
DownloadOriginal string `json:"downloadOriginal"`
} }
type photoJSON struct { type photoJSON struct {
@@ -51,6 +58,9 @@ type photoJSON struct {
OriginalFilename string `json:"originalFilename,omitempty"` OriginalFilename string `json:"originalFilename,omitempty"`
ContentType string `json:"contentType"` ContentType string `json:"contentType"`
SizeBytes int64 `json:"sizeBytes"` SizeBytes int64 `json:"sizeBytes"`
// PreviewSizeBytes lets the mobile save sheet label both of its rows with
// a real file size without fetching anything. Omitted while unknown.
PreviewSizeBytes int64 `json:"previewSizeBytes,omitempty"`
Width int `json:"width,omitempty"` Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"` Height int `json:"height,omitempty"`
TakenAt string `json:"takenAt,omitempty"` TakenAt string `json:"takenAt,omitempty"`
@@ -58,6 +68,10 @@ type photoJSON struct {
LastError string `json:"lastError,omitempty"` // admin only LastError string `json:"lastError,omitempty"` // admin only
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
URLs photoURLs `json:"urls"` URLs photoURLs `json:"urls"`
// Duplicate marks an upload that was skipped because the gallery already
// held these bytes; the rest of the object describes the existing photo.
// Set by the upload handler only, never persisted.
Duplicate bool `json:"duplicate,omitempty"`
} }
// viewTokenFor returns the token to embed in a gallery's file URLs: none // viewTokenFor returns the token to embed in a gallery's file URLs: none
@@ -87,6 +101,22 @@ func fileURL(photoID, variant, token string) string {
return u return u
} }
// downloadURL builds the download endpoint's URL. size is left off for the
// preview, which the endpoint serves by default — a shorter, stabler string
// for the URL the lightbox also renders.
func downloadURL(photoID, size, token string) string {
u := "/api/photos/files/" + photoID + "/download"
sep := "?"
if size != "" && size != downloadSizePreview {
u += "?size=" + size
sep = "&"
}
if token != "" {
u += sep + "token=" + token
}
return u
}
func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON { func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON {
out := photoJSON{ out := photoJSON{
ID: p.ID, ID: p.ID,
@@ -97,16 +127,21 @@ func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON
SizeBytes: p.SizeBytes, SizeBytes: p.SizeBytes,
Width: p.Width, Width: p.Width,
Height: p.Height, Height: p.Height,
PreviewSizeBytes: p.PreviewSizeBytes,
TakenAt: isoTime(p.TakenAt), TakenAt: isoTime(p.TakenAt),
Status: p.Status, Status: p.Status,
CreatedAt: isoTime(p.CreatedAt), CreatedAt: isoTime(p.CreatedAt),
URLs: photoURLs{Original: fileURL(p.ID, "original", token)}, URLs: photoURLs{
Original: fileURL(p.ID, "original", token),
DownloadOriginal: downloadURL(p.ID, downloadSizeOriginal, token),
},
} }
if p.ThumbKey != "" { if p.ThumbKey != "" {
out.URLs.Thumb = fileURL(p.ID, "thumb", token) out.URLs.Thumb = fileURL(p.ID, "thumb", token)
} }
if p.PreviewKey != "" { if p.PreviewKey != "" {
out.URLs.Preview = fileURL(p.ID, "preview", token) out.URLs.Preview = fileURL(p.ID, "preview", token)
out.URLs.Download = downloadURL(p.ID, downloadSizePreview, token)
} }
if admin { if admin {
out.LastError = p.LastError out.LastError = p.LastError
+162 -21
View File
@@ -7,13 +7,24 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"time" "time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
) )
const presignExpiry = 15 * time.Minute const presignExpiry = 15 * time.Minute
// Sizes the download endpoint offers. Preview is the default because it is
// what a phone can actually save to its photo library: iOS and Android only
// reach the gallery through the share sheet, and the share sheet is worth
// handing a 2048px JPEG rather than a multi-megabyte original.
const (
downloadSizePreview = "preview"
downloadSizeOriginal = "original"
)
// serveFile delivers photo bytes after re-running the gallery access check. // serveFile delivers photo bytes after re-running the gallery access check.
// S3: 302 to a short-lived presigned URL; local: streamed directly. // S3: 302 to a short-lived presigned URL; local: streamed directly.
func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) { func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
@@ -22,23 +33,10 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "Not Found") writeError(w, http.StatusNotFound, "Not Found")
return return
} }
p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId")) p, _, ok := s.authorizedPhoto(w, r)
if err != nil { if !ok {
writeStoreError(w, err, "Photo not found")
return return
} }
g, err := s.db.GetGallery(r.Context(), p.GalleryID)
if err != nil {
writeStoreError(w, err, "Photo not found")
return
}
user := s.optionalUser(r)
token := r.URL.Query().Get("token")
if denial := s.authorize(r, g, user, token); denial != nil {
writeError(w, denial.status, denial.msg)
return
}
isAdmin := user != nil && user.IsAdmin()
var key, contentType, downloadName string var key, contentType, downloadName string
switch variant { switch variant {
@@ -58,12 +56,6 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "Not ready") writeError(w, http.StatusNotFound, "Not ready")
return return
} }
// Non-ready originals stay admin-only so uploads that fail processing
// never leak to viewers.
if p.Status != "ready" && !isAdmin {
writeError(w, http.StatusNotFound, "Not ready")
return
}
if url, err := s.storage.PresignGet(r.Context(), key, downloadName, contentType, presignExpiry); err == nil { if url, err := s.storage.PresignGet(r.Context(), key, downloadName, contentType, presignExpiry); err == nil {
http.Redirect(w, r, url, http.StatusFound) http.Redirect(w, r, url, http.StatusFound)
@@ -100,6 +92,155 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
} }
} }
// serveDownload backs the gallery's save/download actions. It differs from
// serveFile in three ways that the mobile save flow depends on:
//
// - It always streams. serveFile 302s to S3 when presigning is available,
// and a cross-origin redirect is opaque to fetch() — the client could
// never read the bytes into a Blob to hand to navigator.share().
// - It names the file (spanglish-<event>-<n>.jpg) and always declares a
// real image/* type, because iOS only offers "Save Image" in the share
// sheet for something it recognises as an image.
// - It caches for a year as immutable. ?size=preview is also the URL the
// lightbox renders, so tapping save re-reads the bytes already on screen
// out of the HTTP cache instead of fetching them again.
func (s *Server) serveDownload(w http.ResponseWriter, r *http.Request) {
size := r.URL.Query().Get("size")
if size == "" {
size = downloadSizePreview
}
if size != downloadSizePreview && size != downloadSizeOriginal {
writeError(w, http.StatusNotFound, "Not Found")
return
}
p, g, ok := s.authorizedPhoto(w, r)
if !ok {
return
}
key, contentType := p.PreviewKey, "image/jpeg"
if size == downloadSizeOriginal {
key, contentType = p.OriginalKey, imageContentType(p.ContentType, p.OriginalFilename)
}
if key == "" {
writeError(w, http.StatusNotFound, "Not ready")
return
}
reader, n, err := s.storage.Open(r.Context(), key)
if err != nil {
if os.IsNotExist(err) || errors.Is(err, storage.ErrNotExist) {
writeError(w, http.StatusNotFound, "Not Found")
return
}
log.Printf("Error: open %s: %v", key, err)
writeError(w, http.StatusInternalServerError, "Internal Server Error")
return
}
defer reader.Close()
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", n))
w.Header().Set("X-Content-Type-Options", "nosniff")
// The preview is rendered as an <img> as well as saved, so it is marked
// inline; only the original is a pure download. Either way the filename
// travels with it, which is what a bare <a download> fallback and the
// Android share sheet display.
disposition := "inline"
if size == downloadSizeOriginal {
disposition = "attachment"
}
w.Header().Set("Content-Disposition", fmt.Sprintf("%s; filename=%q",
disposition, s.downloadFilename(r, g, p, size)))
// Objects are immutable (a new upload writes a new key), so the response
// can be cached for as long as the browser will keep it. Restricted
// galleries stay out of shared caches: access is re-checked per request
// here, and only the browser that passed the check may reuse the bytes.
scope := "private"
if g.Visibility == store.VisibilityPublic {
scope = "public"
}
w.Header().Set("Cache-Control", scope+", max-age=31536000, immutable")
if _, err := io.Copy(w, reader); err != nil {
// Routine on mobile: the client aborts the fetch when the save sheet
// is cancelled, which lands here as a broken pipe.
log.Printf("stream %s: %v", key, err)
}
}
// authorizedPhoto loads the photo and its gallery and re-runs the access
// check every byte-serving handler owes (PLAN.md §7). It writes the error
// response itself; ok=false means the caller is done.
func (s *Server) authorizedPhoto(w http.ResponseWriter, r *http.Request) (store.Photo, store.Gallery, bool) {
p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId"))
if err != nil {
writeStoreError(w, err, "Photo not found")
return store.Photo{}, store.Gallery{}, false
}
g, err := s.db.GetGallery(r.Context(), p.GalleryID)
if err != nil {
writeStoreError(w, err, "Photo not found")
return store.Photo{}, store.Gallery{}, false
}
user := s.optionalUser(r)
if denial := s.authorize(r, g, user, r.URL.Query().Get("token")); denial != nil {
writeError(w, denial.status, denial.msg)
return store.Photo{}, store.Gallery{}, false
}
// Non-ready originals stay admin-only so uploads that fail processing
// never leak to viewers.
if p.Status != "ready" && !(user != nil && user.IsAdmin()) {
writeError(w, http.StatusNotFound, "Not ready")
return store.Photo{}, store.Gallery{}, false
}
return p, g, true
}
// downloadFilename names the saved file after the event it came from rather
// than the photographer's IMG_1234.JPG: spanglish-<event-slug>-<n>.<ext>,
// numbered from 1 in gallery order. Standalone galleries use their own slug.
func (s *Server) downloadFilename(r *http.Request, g store.Gallery, p store.Photo, size string) string {
slug := g.Slug
if g.EventID != "" {
if ev, err := s.db.GetEventSummary(r.Context(), g.EventID); err == nil && ev.Slug != "" {
slug = ev.Slug
}
}
ext := ".jpg"
if size == downloadSizeOriginal {
if e := extForContentType(p.ContentType); e != "" {
ext = e
}
}
return fmt.Sprintf("spanglish-%s-%d%s", slug, p.Position+1, ext)
}
// imageContentType keeps the download endpoint's Content-Type in the image/*
// family. An original stored with a missing or generic type would otherwise
// go out as application/octet-stream, and iOS drops "Save Image" from the
// share sheet for anything it cannot see as an image.
func imageContentType(ct, filename string) string {
if strings.HasPrefix(ct, "image/") {
return ct
}
if i := strings.LastIndex(filename, "."); i >= 0 {
switch strings.ToLower(filename[i:]) {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".heic", ".heif":
return "image/heic"
}
}
return "image/jpeg"
}
func extForContentType(ct string) string { func extForContentType(ct string) string {
switch ct { switch ct {
case "image/jpeg": case "image/jpeg":
+1
View File
@@ -106,6 +106,7 @@ func (s *Server) getGallery(w http.ResponseWriter, r *http.Request, _ auth.User)
writeStoreError(w, err, "") writeStoreError(w, err, "")
return return
} }
s.fillPreviewSizes(r.Context(), photos)
urlToken := s.viewTokenFor(g) urlToken := s.viewTokenFor(g)
out := make([]photoJSON, 0, len(photos)) out := make([]photoJSON, 0, len(photos))
for _, p := range photos { for _, p := range photos {
+50 -15
View File
@@ -12,6 +12,7 @@ import (
"time" "time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/checksum"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
) )
@@ -20,6 +21,10 @@ import (
// Each file is sniffed by magic bytes (client filename/Content-Type are // Each file is sniffed by magic bytes (client filename/Content-Type are
// untrusted, same policy as /api/media/upload), stored as the untouched // untrusted, same policy as /api/media/upload), stored as the untouched
// original, and queued for variant processing. // original, and queued for variant processing.
//
// A file whose bytes are already in this gallery is not stored a second time:
// the incoming copy is discarded, the existing photo is echoed back with
// "duplicate": true, and the rest of the batch carries on.
func (s *Server) uploadPhotos(w http.ResponseWriter, r *http.Request, _ auth.User) { func (s *Server) uploadPhotos(w http.ResponseWriter, r *http.Request, _ auth.User) {
galleryID := r.PathValue("id") galleryID := r.PathValue("id")
g, err := s.db.GetGallery(r.Context(), galleryID) g, err := s.db.GetGallery(r.Context(), galleryID)
@@ -57,7 +62,7 @@ func (s *Server) uploadPhotos(w http.ResponseWriter, r *http.Request, _ auth.Use
part.Close() part.Close()
continue continue
} }
photo, uploadErr := s.saveUpload(r, g, part, position, maxFile) photo, duplicate, uploadErr := s.saveUpload(r, g, part, position, maxFile)
part.Close() part.Close()
if uploadErr != nil { if uploadErr != nil {
// One bad file fails the request explicitly rather than silently // One bad file fails the request explicitly rather than silently
@@ -65,7 +70,12 @@ func (s *Server) uploadPhotos(w http.ResponseWriter, r *http.Request, _ auth.Use
writeError(w, uploadErr.status, uploadErr.msg) writeError(w, uploadErr.status, uploadErr.msg)
return return
} }
created = append(created, s.photoToJSON(photo, s.viewTokenFor(g), true)) out := s.photoToJSON(photo, s.viewTokenFor(g), true)
out.Duplicate = duplicate
created = append(created, out)
if duplicate {
continue // nothing was stored, so the next file keeps this position
}
position++ position++
} }
@@ -82,51 +92,67 @@ type uploadError struct {
msg string msg string
} }
func (s *Server) saveUpload(r *http.Request, g store.Gallery, part *multipart.Part, position int, maxFile int64) (store.Photo, *uploadError) { // saveUpload stores one part. The bool it returns reports a duplicate: the
// gallery already holds these bytes, so nothing was written and the photo
// returned is the existing one.
func (s *Server) saveUpload(r *http.Request, g store.Gallery, part *multipart.Part, position int, maxFile int64) (store.Photo, bool, *uploadError) {
head := make([]byte, 16) head := make([]byte, 16)
n, err := io.ReadFull(part, head) n, err := io.ReadFull(part, head)
if err != nil && err != io.ErrUnexpectedEOF { if err != nil && err != io.ErrUnexpectedEOF {
return store.Photo{}, &uploadError{http.StatusBadRequest, "Could not read file"} return store.Photo{}, false, &uploadError{http.StatusBadRequest, "Could not read file"}
} }
head = head[:n] head = head[:n]
contentType, ext, ok, reason := imaging.Sniff(head) contentType, ext, ok, reason := imaging.Sniff(head)
if !ok { if !ok {
return store.Photo{}, &uploadError{http.StatusUnsupportedMediaType, reason} return store.Photo{}, false, &uploadError{http.StatusUnsupportedMediaType, reason}
} }
if contentType == "image/heic" && imaging.DetectHeicConverter(s.cfg.HeicConverter) == nil { if contentType == "image/heic" && imaging.DetectHeicConverter(s.cfg.HeicConverter) == nil {
return store.Photo{}, &uploadError{http.StatusUnsupportedMediaType, return store.Photo{}, false, &uploadError{http.StatusUnsupportedMediaType,
"HEIC uploads need an image converter on the server (install libvips-tools); please upload JPEG instead"} "HEIC uploads need an image converter on the server (install libvips-tools); please upload JPEG instead"}
} }
// Spool to a temp file to learn the size before handing to storage // Spool to a temp file to learn the size before handing to storage
// (S3 wants a length; local rename wants a file anyway). // (S3 wants a length; local rename wants a file anyway). The same pass
// hashes the bytes for the duplicate check below.
tmp, err := os.CreateTemp(s.cfg.StoragePath, ".incoming-*") tmp, err := os.CreateTemp(s.cfg.StoragePath, ".incoming-*")
if err != nil { if err != nil {
log.Printf("Error: %v", err) log.Printf("Error: %v", err)
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"} return store.Photo{}, false, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
} }
defer os.Remove(tmp.Name()) defer os.Remove(tmp.Name())
defer tmp.Close() defer tmp.Close()
size, err := io.Copy(tmp, io.MultiReader(bytes.NewReader(head), io.LimitReader(part, maxFile+1))) hasher := checksum.New()
size, err := io.Copy(io.MultiWriter(tmp, hasher),
io.MultiReader(bytes.NewReader(head), io.LimitReader(part, maxFile+1)))
if err != nil { if err != nil {
log.Printf("Error: %v", err) log.Printf("Error: %v", err)
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"} return store.Photo{}, false, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
} }
if size > maxFile { if size > maxFile {
return store.Photo{}, &uploadError{http.StatusRequestEntityTooLarge, return store.Photo{}, false, &uploadError{http.StatusRequestEntityTooLarge,
fmt.Sprintf("File exceeds the %d MB limit", s.cfg.MaxUploadMB)} fmt.Sprintf("File exceeds the %d MB limit", s.cfg.MaxUploadMB)}
} }
if _, err := tmp.Seek(0, io.SeekStart); err != nil { if _, err := tmp.Seek(0, io.SeekStart); err != nil {
log.Printf("Error: %v", err) log.Printf("Error: %v", err)
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"} return store.Photo{}, false, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
}
sum := checksum.Format(hasher)
// Duplicate check before anything is written: the temp copy is dropped by
// the deferred Remove, no object is stored and no row is inserted.
if existing, err := s.db.FindPhotoByChecksum(r.Context(), g.ID, sum); err == nil {
return existing, true, nil
} else if err != store.ErrNotFound {
log.Printf("Error: %v", err)
return store.Photo{}, false, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
} }
photoID := newID() photoID := newID()
key := fmt.Sprintf("galleries/%s/orig/%s%s", g.ID, photoID, ext) key := fmt.Sprintf("galleries/%s/orig/%s%s", g.ID, photoID, ext)
if err := s.storage.Put(r.Context(), key, tmp, size, contentType); err != nil { if err := s.storage.Put(r.Context(), key, tmp, size, contentType); err != nil {
log.Printf("Error: %v", err) log.Printf("Error: %v", err)
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"} return store.Photo{}, false, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
} }
now := time.Now() now := time.Now()
@@ -142,13 +168,22 @@ func (s *Server) saveUpload(r *http.Request, g store.Gallery, part *multipart.Pa
NextAttemptAt: now, NextAttemptAt: now,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
Checksum: sum,
} }
if err := s.db.InsertPhoto(r.Context(), photo); err != nil { if err := s.db.InsertPhoto(r.Context(), photo); err != nil {
s.storage.Delete(r.Context(), key) s.storage.Delete(r.Context(), key)
// A concurrent upload of the same bytes won the race between the
// check above and this insert; the unique index caught it, so report
// the winner as the duplicate instead of failing.
if store.IsUniqueViolation(err) {
if existing, findErr := s.db.FindPhotoByChecksum(r.Context(), g.ID, sum); findErr == nil {
return existing, true, nil
}
}
log.Printf("Error: %v", err) log.Printf("Error: %v", err)
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"} return store.Photo{}, false, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
} }
return photo, nil return photo, false, nil
} }
type reorderBody struct { type reorderBody struct {
+1
View File
@@ -66,6 +66,7 @@ func (s *Server) respondGalleryView(w http.ResponseWriter, r *http.Request, g st
writeStoreError(w, err, "") writeStoreError(w, err, "")
return return
} }
s.fillPreviewSizes(r.Context(), photos)
out := make([]photoJSON, 0, len(photos)) out := make([]photoJSON, 0, len(photos))
for _, p := range photos { for _, p := range photos {
out = append(out, s.photoToJSON(p, urlToken, false)) out = append(out, s.photoToJSON(p, urlToken, false))
+3
View File
@@ -52,6 +52,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/photos/public/galleries", s.listPublicGalleries) mux.HandleFunc("GET /api/photos/public/galleries", s.listPublicGalleries)
mux.HandleFunc("GET /api/photos/public/galleries/{slug}", s.getPublicGallery) mux.HandleFunc("GET /api/photos/public/galleries/{slug}", s.getPublicGallery)
mux.HandleFunc("GET /api/photos/public/events/{eventSlug}/gallery", s.getEventGallery) mux.HandleFunc("GET /api/photos/public/events/{eventSlug}/gallery", s.getEventGallery)
// The literal "download" segment takes precedence over {variant} under
// ServeMux's specificity rules, so the two can share the prefix.
mux.HandleFunc("GET /api/photos/files/{photoId}/download", s.serveDownload)
mux.HandleFunc("GET /api/photos/files/{photoId}/{variant}", s.serveFile) mux.HandleFunc("GET /api/photos/files/{photoId}/{variant}", s.serveFile)
return s.withCommon(mux) return s.withCommon(mux)
+64
View File
@@ -0,0 +1,64 @@
package httpapi
import (
"context"
"log"
"sync"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// statConcurrency bounds the one-off Stat burst below; a gallery of ~70
// photos then costs a handful of round-trips against S3, once ever.
const statConcurrency = 8
// fillPreviewSizes measures the preview objects of photos whose
// preview_size_bytes is still unset and writes the result back, updating the
// slice in place.
//
// The worker records the size when it generates the preview, so this only
// ever fires for rows that predate the column (or arrived via `photo-api
// sync`). It is a self-healing cache fill rather than a migration step: the
// first gallery view after deploy pays for one Stat per photo, every view
// after that reads the stored value. Errors are logged and swallowed — a
// missing size costs the save sheet its row label, which is not worth
// failing a gallery response over.
func (s *Server) fillPreviewSizes(ctx context.Context, photos []store.Photo) {
var (
wg sync.WaitGroup
sem = make(chan struct{}, statConcurrency)
mu sync.Mutex
size = make(map[string]int64, len(photos))
)
for _, p := range photos {
if p.PreviewKey == "" || p.PreviewSizeBytes > 0 {
continue
}
wg.Add(1)
go func(p store.Photo) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
n, err := s.storage.Stat(ctx, p.PreviewKey)
if err != nil {
log.Printf("preview size %s: %v", p.PreviewKey, err)
return
}
mu.Lock()
size[p.ID] = n
mu.Unlock()
}(p)
}
wg.Wait()
if len(size) == 0 {
return
}
for i := range photos {
if n, ok := size[photos[i].ID]; ok {
photos[i].PreviewSizeBytes = n
if err := s.db.SetPreviewSize(ctx, photos[i].ID, n); err != nil {
log.Printf("store preview size %s: %v", photos[i].ID, err)
}
}
}
}
+229
View File
@@ -0,0 +1,229 @@
// Package photosync copies the photo library between the local-disk and the
// S3 backend so the active one (STORAGE_BACKEND) can be switched without
// losing photos. Both backends must be configured in photo-api/.env for a
// sync to run — the direction picks which is the source.
//
// The photos_photos rows are the inventory: every row contributes its
// original key and, once the worker has processed it, its thumb and preview
// key. Objects on disk or in the bucket with no row (worker scratch dirs,
// leftovers of deleted galleries) are deliberately not copied.
//
// Sync only ever writes to the destination. The source is left untouched, so
// a sync is repeatable, safe to interrupt, and leaves the old backend as a
// fallback until it is cleaned up by hand.
package photosync
import (
"context"
"errors"
"fmt"
"log"
"sync"
"sync/atomic"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// Direction is which way objects move.
type Direction string
const (
ToS3 Direction = "to-s3"
ToLocal Direction = "to-local"
)
// ParseDirection accepts the short and the spelled-out form.
func ParseDirection(s string) (Direction, error) {
switch s {
case "to-s3", "local-to-s3":
return ToS3, nil
case "to-local", "s3-to-local":
return ToLocal, nil
default:
return "", fmt.Errorf("unknown direction %q (expected to-s3 or to-local)", s)
}
}
type Options struct {
Direction Direction
// GalleryID limits the sync to one gallery; empty means the whole library.
GalleryID string
// Concurrency is how many objects are copied at a time.
Concurrency int
// DryRun reports what would be copied without writing anything.
DryRun bool
// Overwrite re-copies objects that already exist on the destination with
// the same size (default: those are skipped, which makes reruns cheap).
Overwrite bool
}
type Result struct {
Total int // objects in the inventory
Copied int // copied (or, with DryRun, would be copied)
Skipped int // already on the destination
Missing int // absent from the source — nothing to copy
Failed int // copy attempted and errored
Bytes int64 // bytes copied
}
// Run copies the library in the requested direction. It returns a Result even
// on error, and an error if any object failed.
func Run(ctx context.Context, cfg config.Config, db *store.DB, opts Options) (Result, error) {
if !cfg.S3Configured() {
return Result{}, errors.New("sync needs both backends configured: set S3_ENDPOINT, S3_BUCKET, " +
"S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY (next to STORAGE_PATH) in photo-api/.env")
}
if opts.Concurrency < 1 {
opts.Concurrency = 4
}
local, err := storage.NewLocal(cfg.StoragePath)
if err != nil {
return Result{}, fmt.Errorf("local storage: %w", err)
}
s3, err := storage.NewS3(cfg)
if err != nil {
return Result{}, fmt.Errorf("s3 storage: %w", err)
}
src, dst := local, s3
srcName, dstName := "local disk "+cfg.StoragePath, "s3 bucket "+cfg.S3Bucket
if opts.Direction == ToLocal {
src, dst = s3, local
srcName, dstName = dstName, srcName
}
objects, err := db.AllPhotoObjects(ctx, opts.GalleryID)
if err != nil {
return Result{}, fmt.Errorf("list photo objects: %w", err)
}
prefix := ""
if opts.DryRun {
prefix = "[dry-run] "
}
log.Printf("sync: %s%s → %s: %d objects, concurrency %d",
prefix, srcName, dstName, len(objects), opts.Concurrency)
var (
mu sync.Mutex
res = Result{Total: len(objects)}
done int64
jobs = make(chan store.PhotoObject)
wg sync.WaitGroup
)
for i := 0; i < opts.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for o := range jobs {
if ctx.Err() != nil {
return
}
act, size, err := copyObject(ctx, src, dst, o, opts)
n := atomic.AddInt64(&done, 1)
mu.Lock()
switch {
case err != nil:
res.Failed++
log.Printf("sync: [%d/%d] FAILED %s (photo %s %s): %v",
n, len(objects), o.Key, o.PhotoID, o.Variant, err)
case act == actionCopied:
res.Copied++
res.Bytes += size
log.Printf("sync: [%d/%d] %scopied %s (%s)", n, len(objects), prefix, o.Key, humanBytes(size))
case act == actionMissing:
res.Missing++
log.Printf("sync: [%d/%d] MISSING on source, skipped: %s (photo %s %s)",
n, len(objects), o.Key, o.PhotoID, o.Variant)
default:
res.Skipped++
}
mu.Unlock()
}
}()
}
for _, o := range objects {
select {
case jobs <- o:
case <-ctx.Done():
}
if ctx.Err() != nil {
break
}
}
close(jobs)
wg.Wait()
log.Printf("sync: %sdone — %d copied (%s), %d already present, %d missing on source, %d failed",
prefix, res.Copied, humanBytes(res.Bytes), res.Skipped, res.Missing, res.Failed)
if err := ctx.Err(); err != nil {
return res, fmt.Errorf("interrupted after %d/%d objects: %w", res.Copied+res.Skipped, res.Total, err)
}
if res.Failed > 0 {
return res, fmt.Errorf("%d of %d objects failed to copy (rerun to retry; already-copied objects are skipped)",
res.Failed, res.Total)
}
return res, nil
}
type action int
const (
actionCopied action = iota
actionSkipped
actionMissing
)
func copyObject(ctx context.Context, src, dst storage.Storage, o store.PhotoObject, opts Options) (action, int64, error) {
srcSize, err := src.Stat(ctx, o.Key)
if errors.Is(err, storage.ErrNotExist) {
return actionMissing, 0, nil
}
if err != nil {
return actionSkipped, 0, fmt.Errorf("stat source: %w", err)
}
if !opts.Overwrite {
if dstSize, err := dst.Stat(ctx, o.Key); err == nil && dstSize == srcSize {
return actionSkipped, 0, nil
} else if err != nil && !errors.Is(err, storage.ErrNotExist) {
return actionSkipped, 0, fmt.Errorf("stat destination: %w", err)
}
}
if opts.DryRun {
return actionCopied, srcSize, nil
}
r, size, err := src.Open(ctx, o.Key)
if err != nil {
return actionSkipped, 0, fmt.Errorf("read source: %w", err)
}
defer r.Close()
if size <= 0 {
size = srcSize // local Open reports the stat size; be defensive anyway
}
contentType := o.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
if err := dst.Put(ctx, o.Key, r, size, contentType); err != nil {
return actionSkipped, 0, fmt.Errorf("write destination: %w", err)
}
return actionCopied, size, nil
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
v, exp := float64(n), 0
for v >= unit && exp < 4 {
v /= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", v, "KMGT"[exp-1])
}
@@ -0,0 +1,135 @@
package photosync
import (
"context"
"io"
"path/filepath"
"strings"
"testing"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// Two local backends stand in for the real pair: copyObject only talks to the
// storage.Storage interface, so the S3 side needs no fake here.
func backends(t *testing.T) (src, dst storage.Storage, srcRoot string) {
t.Helper()
dir := t.TempDir()
srcRoot = filepath.Join(dir, "src")
src, err := storage.NewLocal(srcRoot)
if err != nil {
t.Fatal(err)
}
dst, err = storage.NewLocal(filepath.Join(dir, "dst"))
if err != nil {
t.Fatal(err)
}
return src, dst, srcRoot
}
func put(t *testing.T, s storage.Storage, key, body string) {
t.Helper()
if err := s.Put(context.Background(), key, strings.NewReader(body), int64(len(body)), "image/jpeg"); err != nil {
t.Fatal(err)
}
}
const testKey = "galleries/g1/original/p1.jpg"
func testObject() store.PhotoObject {
return store.PhotoObject{PhotoID: "p1", GalleryID: "g1", Variant: "original", Key: testKey, ContentType: "image/jpeg"}
}
func TestCopyObject(t *testing.T) {
ctx := context.Background()
t.Run("copies to destination", func(t *testing.T) {
src, dst, _ := backends(t)
put(t, src, testKey, "hello-photo")
act, size, err := copyObject(ctx, src, dst, testObject(), Options{})
if err != nil || act != actionCopied || size != 11 {
t.Fatalf("got (%v, %d, %v), want (copied, 11, nil)", act, size, err)
}
if got, err := dst.Stat(ctx, testKey); err != nil || got != 11 {
t.Fatalf("destination stat: (%d, %v)", got, err)
}
})
t.Run("skips same-size object already present", func(t *testing.T) {
src, dst, _ := backends(t)
put(t, src, testKey, "hello-photo")
put(t, dst, testKey, "hello-photo")
act, _, err := copyObject(ctx, src, dst, testObject(), Options{})
if err != nil || act != actionSkipped {
t.Fatalf("got (%v, %v), want (skipped, nil)", act, err)
}
})
t.Run("overwrite re-copies", func(t *testing.T) {
src, dst, _ := backends(t)
put(t, src, testKey, "new-content")
put(t, dst, testKey, "old-content")
act, _, err := copyObject(ctx, src, dst, testObject(), Options{Overwrite: true})
if err != nil || act != actionCopied {
t.Fatalf("got (%v, %v), want (copied, nil)", act, err)
}
r, _, err := dst.Open(ctx, testKey)
if err != nil {
t.Fatal(err)
}
defer r.Close()
body, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if string(body) != "new-content" {
t.Fatalf("destination body = %q, want new-content", body)
}
})
t.Run("reports objects missing on the source", func(t *testing.T) {
src, dst, _ := backends(t)
act, _, err := copyObject(ctx, src, dst, testObject(), Options{})
if err != nil || act != actionMissing {
t.Fatalf("got (%v, %v), want (missing, nil)", act, err)
}
if _, err := dst.Stat(ctx, testKey); err != storage.ErrNotExist {
t.Fatalf("destination stat err = %v, want ErrNotExist", err)
}
})
t.Run("dry run writes nothing", func(t *testing.T) {
src, dst, _ := backends(t)
put(t, src, testKey, "hello-photo")
act, size, err := copyObject(ctx, src, dst, testObject(), Options{DryRun: true})
if err != nil || act != actionCopied || size != 11 {
t.Fatalf("got (%v, %d, %v), want (copied, 11, nil)", act, size, err)
}
if _, err := dst.Stat(ctx, testKey); err != storage.ErrNotExist {
t.Fatalf("destination stat err = %v, want ErrNotExist", err)
}
})
}
func TestParseDirection(t *testing.T) {
for in, want := range map[string]Direction{
"to-s3": ToS3,
"local-to-s3": ToS3,
"to-local": ToLocal,
"s3-to-local": ToLocal,
} {
got, err := ParseDirection(in)
if err != nil || got != want {
t.Errorf("ParseDirection(%q) = (%v, %v), want %v", in, got, err, want)
}
}
if _, err := ParseDirection("sideways"); err == nil {
t.Error("ParseDirection(\"sideways\") should fail")
}
}
+262
View File
@@ -0,0 +1,262 @@
package photosync
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// stubS3 is a minimal path-style S3 (PUT/HEAD/GET on /bucket/key) so the sync
// can be driven end to end over the real aws-sdk client.
type stubS3 struct {
mu sync.Mutex
objects map[string][]byte
puts int
}
func newStubS3(t *testing.T) (*stubS3, string) {
t.Helper()
s := &stubS3{objects: map[string][]byte{}}
srv := httptest.NewServer(s)
t.Cleanup(srv.Close)
return s, srv.URL
}
func (s *stubS3) get(key string) ([]byte, bool) {
s.mu.Lock()
defer s.mu.Unlock()
b, ok := s.objects[key]
return b, ok
}
func (s *stubS3) ServeHTTP(w http.ResponseWriter, r *http.Request) {
key := strings.TrimPrefix(r.URL.Path, "/test-bucket/")
switch r.Method {
case http.MethodPut:
body, err := readS3Body(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
s.objects[key] = body
s.puts++
s.mu.Unlock()
w.WriteHeader(http.StatusOK)
case http.MethodHead:
body, ok := s.get(key)
if !ok {
w.WriteHeader(http.StatusNotFound) // a HEAD carries no error body
return
}
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.WriteHeader(http.StatusOK)
case http.MethodGet:
body, ok := s.get(key)
if !ok {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `<Error><Code>NoSuchKey</Code></Error>`)
return
}
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.Write(body)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// readS3Body undoes the SDK's aws-chunked framing when it streams with a
// trailing checksum (what it does for the non-seekable S3-to-S3 style reader).
func readS3Body(r *http.Request) ([]byte, error) {
raw, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
if !strings.Contains(r.Header.Get("Content-Encoding"), "aws-chunked") {
return raw, nil
}
var out []byte
rest := raw
for {
nl := strings.Index(string(rest), "\r\n")
if nl < 0 {
return out, nil
}
header := string(rest[:nl])
rest = rest[nl+2:]
size, err := strconv.ParseInt(strings.SplitN(header, ";", 2)[0], 16, 64)
if err != nil || size == 0 {
return out, nil // trailer section or malformed: body is complete
}
if int64(len(rest)) < size {
return nil, fmt.Errorf("truncated aws-chunked body")
}
out = append(out, rest[:size]...)
rest = rest[size:]
if len(rest) >= 2 {
rest = rest[2:] // chunk CRLF
}
}
}
// syncEnv seeds a scratch SQLite DB with one gallery holding one ready photo
// (original + thumb + preview) and returns a config wired to the stub bucket.
func syncEnv(t *testing.T) (config.Config, *store.DB, *stubS3, []string) {
t.Helper()
dir := t.TempDir()
stub, endpoint := newStubS3(t)
cfg := config.Config{
DBType: "sqlite",
DatabaseURL: filepath.Join(dir, "test.db"),
ViewTokenSecret: "test-secret",
StoragePath: filepath.Join(dir, "photos"),
S3Endpoint: endpoint,
S3Region: "auto",
S3Bucket: "test-bucket",
S3AccessKeyID: "key",
S3SecretKey: "secret",
S3ForcePathStyle: true,
}
db, err := store.Open(cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
ctx := context.Background()
if err := db.Migrate(ctx); err != nil {
t.Fatal(err)
}
const (
galleryID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
photoID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
)
now := time.Now()
if err := db.CreateGallery(ctx, store.Gallery{
ID: galleryID, Slug: "trip", Title: "Trip", Visibility: store.VisibilityPublic,
ShareToken: "tok", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
origKey := "galleries/" + galleryID + "/original/" + photoID + ".jpg"
thumbKey := "galleries/" + galleryID + "/thumb/" + photoID + ".jpg"
previewKey := "galleries/" + galleryID + "/preview/" + photoID + ".jpg"
if err := db.InsertPhoto(ctx, store.Photo{
ID: photoID, GalleryID: galleryID, OriginalKey: origKey, ContentType: "image/jpeg",
SizeBytes: 11, NextAttemptAt: now, CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
if err := db.MarkPhotoReady(ctx, photoID, thumbKey, previewKey, 7, 100, 80, now); err != nil {
t.Fatal(err)
}
return cfg, db, stub, []string{origKey, thumbKey, previewKey}
}
func TestRunToS3(t *testing.T) {
ctx := context.Background()
cfg, db, stub, keys := syncEnv(t)
src, err := storage.NewLocal(cfg.StoragePath)
if err != nil {
t.Fatal(err)
}
for i, k := range keys {
put(t, src, k, fmt.Sprintf("photo-bytes-%d", i))
}
res, err := Run(ctx, cfg, db, Options{Direction: ToS3, Concurrency: 2})
if err != nil {
t.Fatal(err)
}
if res.Total != 3 || res.Copied != 3 || res.Skipped != 0 || res.Failed != 0 || res.Missing != 0 {
t.Fatalf("first run = %+v, want 3 total / 3 copied", res)
}
for i, k := range keys {
body, ok := stub.get(k)
if !ok {
t.Fatalf("%s not uploaded", k)
}
if want := fmt.Sprintf("photo-bytes-%d", i); string(body) != want {
t.Errorf("%s = %q, want %q", k, body, want)
}
}
// Rerunning is a no-op: everything is already there at the same size.
res, err = Run(ctx, cfg, db, Options{Direction: ToS3})
if err != nil {
t.Fatal(err)
}
if res.Copied != 0 || res.Skipped != 3 {
t.Fatalf("rerun = %+v, want 0 copied / 3 skipped", res)
}
// --overwrite re-uploads them.
before := stub.puts
res, err = Run(ctx, cfg, db, Options{Direction: ToS3, Overwrite: true})
if err != nil {
t.Fatal(err)
}
if res.Copied != 3 || stub.puts != before+3 {
t.Fatalf("overwrite run = %+v, puts %d → %d", res, before, stub.puts)
}
}
func TestRunToLocal(t *testing.T) {
ctx := context.Background()
cfg, db, stub, keys := syncEnv(t)
for i, k := range keys {
stub.objects[k] = []byte(fmt.Sprintf("s3-bytes-%d", i))
}
res, err := Run(ctx, cfg, db, Options{Direction: ToLocal})
if err != nil {
t.Fatal(err)
}
if res.Total != 3 || res.Copied != 3 || res.Failed != 0 {
t.Fatalf("run = %+v, want 3 total / 3 copied", res)
}
for i, k := range keys {
body, err := os.ReadFile(filepath.Join(cfg.StoragePath, k))
if err != nil {
t.Fatalf("read %s: %v", k, err)
}
if want := fmt.Sprintf("s3-bytes-%d", i); string(body) != want {
t.Errorf("%s = %q, want %q", k, body, want)
}
}
}
func TestRunMissingOnSource(t *testing.T) {
cfg, db, _, _ := syncEnv(t)
// Nothing on local disk: every object is reported missing, none fail.
res, err := Run(context.Background(), cfg, db, Options{Direction: ToS3})
if err != nil {
t.Fatal(err)
}
if res.Missing != 3 || res.Copied != 0 || res.Failed != 0 {
t.Fatalf("run = %+v, want 3 missing", res)
}
}
func TestRunRequiresS3Config(t *testing.T) {
cfg, db, _, _ := syncEnv(t)
cfg.S3Endpoint, cfg.S3Bucket = "", ""
if _, err := Run(context.Background(), cfg, db, Options{Direction: ToS3}); err == nil {
t.Fatal("sync without S3 configured should fail")
}
}
+21
View File
@@ -17,6 +17,12 @@ type local struct {
root string root string
} }
// NewLocal builds the local-disk backend explicitly, whichever backend is
// active — `photo-api sync` needs both sides at once.
func NewLocal(root string) (Storage, error) {
return newLocal(root)
}
func newLocal(root string) (*local, error) { func newLocal(root string) (*local, error) {
if err := os.MkdirAll(root, 0o755); err != nil { if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("create storage dir %s: %w", root, err) return nil, fmt.Errorf("create storage dir %s: %w", root, err)
@@ -74,6 +80,21 @@ func (l *local) Open(_ context.Context, key string) (io.ReadCloser, int64, error
return f, info.Size(), nil return f, info.Size(), nil
} }
func (l *local) Stat(_ context.Context, key string) (int64, error) {
p, err := l.path(key)
if err != nil {
return 0, err
}
info, err := os.Stat(p)
if os.IsNotExist(err) {
return 0, ErrNotExist
}
if err != nil {
return 0, err
}
return info.Size(), nil
}
func (l *local) Delete(_ context.Context, key string) error { func (l *local) Delete(_ context.Context, key string) error {
p, err := l.path(key) p, err := l.path(key)
if err != nil { if err != nil {
+33
View File
@@ -2,14 +2,19 @@ package storage
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"net/http"
"time" "time"
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws"
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
awsconfig "github.com/aws/aws-sdk-go-v2/config" awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
) )
@@ -23,6 +28,12 @@ type s3Store struct {
bucket string bucket string
} }
// NewS3 builds the S3 backend explicitly, whichever backend is active —
// `photo-api sync` needs both sides at once.
func NewS3(cfg config.Config) (Storage, error) {
return newS3(cfg)
}
func newS3(cfg config.Config) (*s3Store, error) { func newS3(cfg config.Config) (*s3Store, error) {
awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(),
awsconfig.WithRegion(cfg.S3Region), awsconfig.WithRegion(cfg.S3Region),
@@ -65,6 +76,28 @@ func (s *s3Store) Open(ctx context.Context, key string) (io.ReadCloser, int64, e
return out.Body, aws.ToInt64(out.ContentLength), nil return out.Body, aws.ToInt64(out.ContentLength), nil
} }
func (s *s3Store) Stat(ctx context.Context, key string) (int64, error) {
out, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
// Compatibles differ: some answer NotFound, some NoSuchKey, and a
// HEAD carries no body to parse, so fall back to the status code.
var notFound *types.NotFound
var noKey *types.NoSuchKey
var apiErr smithy.APIError
var respErr *awshttp.ResponseError
if errors.As(err, &notFound) || errors.As(err, &noKey) ||
(errors.As(err, &apiErr) && (apiErr.ErrorCode() == "NotFound" || apiErr.ErrorCode() == "NoSuchKey")) ||
(errors.As(err, &respErr) && respErr.HTTPStatusCode() == http.StatusNotFound) {
return 0, ErrNotExist
}
return 0, err
}
return aws.ToInt64(out.ContentLength), nil
}
func (s *s3Store) Delete(ctx context.Context, key string) error { func (s *s3Store) Delete(ctx context.Context, key string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket), Bucket: aws.String(s.bucket),
+17 -2
View File
@@ -7,6 +7,7 @@ package storage
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"time" "time"
@@ -17,16 +18,30 @@ import (
// callers then stream the object through the API instead. // callers then stream the object through the API instead.
var ErrNoPresign = errors.New("presigned URLs not supported") var ErrNoPresign = errors.New("presigned URLs not supported")
// ErrNotExist is what Stat reports for a missing object on either backend.
var ErrNotExist = errors.New("object does not exist")
type Storage interface { type Storage interface {
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
Open(ctx context.Context, key string) (io.ReadCloser, int64, error) Open(ctx context.Context, key string) (io.ReadCloser, int64, error)
// Stat returns the object's size without reading it, or ErrNotExist.
Stat(ctx context.Context, key string) (int64, error)
Delete(ctx context.Context, key string) error Delete(ctx context.Context, key string) error
PresignGet(ctx context.Context, key, downloadFilename, contentType string, expiry time.Duration) (string, error) PresignGet(ctx context.Context, key, downloadFilename, contentType string, expiry time.Duration) (string, error)
} }
// New builds the backend that serves requests (see config.S3Enabled).
func New(cfg config.Config) (Storage, error) { func New(cfg config.Config) (Storage, error) {
if cfg.S3Enabled() { if cfg.S3Enabled() {
return newS3(cfg) return NewS3(cfg)
} }
return newLocal(cfg.StoragePath) return NewLocal(cfg.StoragePath)
}
// Name describes a backend for log lines.
func Name(cfg config.Config, s Storage) string {
if _, ok := s.(*s3Store); ok {
return fmt.Sprintf("S3 bucket %s at %s", cfg.S3Bucket, cfg.S3Endpoint)
}
return fmt.Sprintf("local disk at %s", cfg.StoragePath)
} }
+13
View File
@@ -54,6 +54,19 @@ func Open(cfg config.Config) (*DB, error) {
} }
} }
// IsUniqueViolation reports whether err is a duplicate-key error. The two
// drivers wrap it in unrelated types (pgconn.PgError vs sqlite.Error), and
// neither is worth importing here just for one check, so this matches on the
// message: pgx renders "(SQLSTATE 23505)", modernc/sqlite "UNIQUE constraint
// failed: ...".
func IsUniqueViolation(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "SQLSTATE 23505") || strings.Contains(msg, "UNIQUE constraint failed")
}
// Rebind converts ?-style placeholders to $n for Postgres. Queries in this // Rebind converts ?-style placeholders to $n for Postgres. Queries in this
// package never contain literal question marks in strings. // package never contain literal question marks in strings.
func (db *DB) Rebind(query string) string { func (db *DB) Rebind(query string) string {
+153 -17
View File
@@ -22,6 +22,9 @@ type Photo struct {
Height int Height int
ThumbKey string ThumbKey string
PreviewKey string PreviewKey string
// PreviewSizeBytes is the size of the preview object, 0 when it has not
// been measured yet (rows written before the column existed).
PreviewSizeBytes int64
TakenAt time.Time TakenAt time.Time
Status string Status string
Attempts int Attempts int
@@ -29,14 +32,17 @@ type Photo struct {
LastError string LastError string
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
// Checksum is the sha256 of the original bytes, empty when the row has
// not been hashed yet (uploaded before duplicate detection existed).
Checksum string
} }
const photoColumns = `id, gallery_id, position, original_key, original_filename, content_type, const photoColumns = `id, gallery_id, position, original_key, original_filename, content_type,
size_bytes, width, height, thumb_key, preview_key, taken_at, status, attempts, next_attempt_at, size_bytes, width, height, thumb_key, preview_key, preview_size_bytes, taken_at, status,
last_error, created_at, updated_at` attempts, next_attempt_at, last_error, created_at, updated_at, checksum`
func scanPhoto(s scanner) (Photo, error) { func scanPhoto(s scanner) (Photo, error) {
var v [18]any var v [20]any
dest := make([]any, len(v)) dest := make([]any, len(v))
for i := range v { for i := range v {
dest[i] = &v[i] dest[i] = &v[i]
@@ -56,27 +62,90 @@ func scanPhoto(s scanner) (Photo, error) {
Height: int(asInt(v[8])), Height: int(asInt(v[8])),
ThumbKey: asString(v[9]), ThumbKey: asString(v[9]),
PreviewKey: asString(v[10]), PreviewKey: asString(v[10]),
TakenAt: asTime(v[11]), PreviewSizeBytes: asInt(v[11]),
Status: asString(v[12]), TakenAt: asTime(v[12]),
Attempts: int(asInt(v[13])), Status: asString(v[13]),
NextAttemptAt: asTime(v[14]), Attempts: int(asInt(v[14])),
LastError: asString(v[15]), NextAttemptAt: asTime(v[15]),
CreatedAt: asTime(v[16]), LastError: asString(v[16]),
UpdatedAt: asTime(v[17]), CreatedAt: asTime(v[17]),
UpdatedAt: asTime(v[18]),
Checksum: asString(v[19]),
}, nil }, nil
} }
// InsertPhoto returns an error satisfying IsUniqueViolation when the gallery
// already holds a photo with the same checksum; callers treat that as a
// duplicate rather than a failure.
func (db *DB) InsertPhoto(ctx context.Context, p Photo) error { func (db *DB) InsertPhoto(ctx context.Context, p Photo) error {
_, err := db.ExecContext(ctx, db.Rebind(` _, err := db.ExecContext(ctx, db.Rebind(`
INSERT INTO photos_photos INSERT INTO photos_photos
(id, gallery_id, position, original_key, original_filename, content_type, size_bytes, (id, gallery_id, position, original_key, original_filename, content_type, size_bytes,
status, attempts, next_attempt_at, created_at, updated_at) status, attempts, next_attempt_at, created_at, updated_at, checksum)
VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`), VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?, ?)`),
p.ID, p.GalleryID, p.Position, p.OriginalKey, nullable(p.OriginalFilename), p.ContentType, p.ID, p.GalleryID, p.Position, p.OriginalKey, nullable(p.OriginalFilename), p.ContentType,
p.SizeBytes, db.TimeArg(p.NextAttemptAt), db.TimeArg(p.CreatedAt), db.TimeArg(p.UpdatedAt)) p.SizeBytes, db.TimeArg(p.NextAttemptAt), db.TimeArg(p.CreatedAt), db.TimeArg(p.UpdatedAt),
nullable(p.Checksum))
return err return err
} }
// FindPhotoByChecksum looks for an existing photo with the same content in one
// gallery — the duplicate check the upload path runs before storing anything.
// Duplicate scope is per gallery: the same image in another gallery keeps its
// own row and its own stored object.
func (db *DB) FindPhotoByChecksum(ctx context.Context, galleryID, checksum string) (Photo, error) {
if checksum == "" {
return Photo{}, ErrNotFound // never match the not-yet-hashed rows
}
row := db.QueryRowContext(ctx, db.Rebind(
"SELECT "+photoColumns+" FROM photos_photos WHERE gallery_id = ? AND checksum = ?"),
galleryID, checksum)
p, err := scanPhoto(row)
if errors.Is(err, sql.ErrNoRows) {
return Photo{}, ErrNotFound
}
return p, err
}
// SetPhotoChecksum fills in the hash of an already-stored photo. Used by the
// backfill; returns a unique-violation error when the row turns out to
// duplicate one already hashed in the same gallery.
func (db *DB) SetPhotoChecksum(ctx context.Context, id, checksum string) error {
res, err := db.ExecContext(ctx, db.Rebind(
"UPDATE photos_photos SET checksum = ? WHERE id = ?"), checksum, id)
if err != nil {
return err
}
return errIfNoRows(res)
}
// PhotosMissingChecksum lists rows still lacking a hash (optionally of one
// gallery only), oldest first so backfill keeps the earliest upload of a
// duplicate pair as the one that gets the checksum.
func (db *DB) PhotosMissingChecksum(ctx context.Context, galleryID string) ([]Photo, error) {
q := "SELECT " + photoColumns + " FROM photos_photos WHERE checksum IS NULL"
var args []any
if galleryID != "" {
q += " AND gallery_id = ?"
args = append(args, galleryID)
}
q += " ORDER BY gallery_id, position, created_at"
rows, err := db.QueryContext(ctx, db.Rebind(q), args...)
if err != nil {
return nil, err
}
defer rows.Close()
photos := []Photo{}
for rows.Next() {
p, err := scanPhoto(rows)
if err != nil {
return nil, err
}
photos = append(photos, p)
}
return photos, rows.Err()
}
func (db *DB) GetPhoto(ctx context.Context, id string) (Photo, error) { func (db *DB) GetPhoto(ctx context.Context, id string) (Photo, error) {
row := db.QueryRowContext(ctx, row := db.QueryRowContext(ctx,
db.Rebind("SELECT "+photoColumns+" FROM photos_photos WHERE id = ?"), id) db.Rebind("SELECT "+photoColumns+" FROM photos_photos WHERE id = ?"), id)
@@ -169,6 +238,64 @@ func (db *DB) PhotoKeys(ctx context.Context, galleryID string) ([]string, error)
return keys, rows.Err() return keys, rows.Err()
} }
// PhotoObject is one stored object: a photo variant plus the content type it
// should be written with. Used by the storage sync, which treats the rows as
// the inventory of what exists.
type PhotoObject struct {
PhotoID string
GalleryID string
Variant string // original | thumb | preview
Key string
ContentType string
}
// AllPhotoObjects lists every object of every photo (optionally of one
// gallery only), oldest first. Variant keys are empty until the worker has
// processed the photo; those are left out.
func (db *DB) AllPhotoObjects(ctx context.Context, galleryID string) ([]PhotoObject, error) {
q := `SELECT id, gallery_id, content_type, original_key, thumb_key, preview_key
FROM photos_photos`
var args []any
if galleryID != "" {
q += " WHERE gallery_id = ?"
args = append(args, galleryID)
}
q += " ORDER BY gallery_id, position, created_at"
rows, err := db.QueryContext(ctx, db.Rebind(q), args...)
if err != nil {
return nil, err
}
defer rows.Close()
objects := []PhotoObject{}
for rows.Next() {
var id, galID, ctype, orig, thumb, preview any
if err := rows.Scan(&id, &galID, &ctype, &orig, &thumb, &preview); err != nil {
return nil, err
}
variants := []struct {
name, key, contentType string
}{
{"original", asString(orig), asString(ctype)},
{"thumb", asString(thumb), "image/jpeg"},
{"preview", asString(preview), "image/jpeg"},
}
for _, v := range variants {
if v.key == "" {
continue
}
objects = append(objects, PhotoObject{
PhotoID: asString(id),
GalleryID: asString(galID),
Variant: v.name,
Key: v.key,
ContentType: v.contentType,
})
}
}
return objects, rows.Err()
}
// ClaimNextPhoto picks the oldest due queued/failed photo and marks it // ClaimNextPhoto picks the oldest due queued/failed photo and marks it
// processing. Optimistic claim (RowsAffected check) works identically on // processing. Optimistic claim (RowsAffected check) works identically on
// Postgres and SQLite; returns ErrNotFound when the queue is empty. // Postgres and SQLite; returns ErrNotFound when the queue is empty.
@@ -200,17 +327,26 @@ func (db *DB) ClaimNextPhoto(ctx context.Context) (Photo, error) {
return db.GetPhoto(ctx, id) return db.GetPhoto(ctx, id)
} }
func (db *DB) MarkPhotoReady(ctx context.Context, id, thumbKey, previewKey string, width, height int, takenAt time.Time) error { func (db *DB) MarkPhotoReady(ctx context.Context, id, thumbKey, previewKey string, previewSize int64, width, height int, takenAt time.Time) error {
var takenArg any var takenArg any
if !takenAt.IsZero() { if !takenAt.IsZero() {
takenArg = db.TimeArg(takenAt) takenArg = db.TimeArg(takenAt)
} }
_, err := db.ExecContext(ctx, db.Rebind(` _, err := db.ExecContext(ctx, db.Rebind(`
UPDATE photos_photos UPDATE photos_photos
SET status = 'ready', thumb_key = ?, preview_key = ?, width = ?, height = ?, taken_at = ?, SET status = 'ready', thumb_key = ?, preview_key = ?, preview_size_bytes = ?, width = ?,
last_error = NULL, updated_at = ? height = ?, taken_at = ?, last_error = NULL, updated_at = ?
WHERE id = ?`), WHERE id = ?`),
thumbKey, previewKey, width, height, takenArg, db.TimeArg(time.Now()), id) thumbKey, previewKey, previewSize, width, height, takenArg, db.TimeArg(time.Now()), id)
return err
}
// SetPreviewSize records the measured size of an already-generated preview.
// Used to fill in rows processed before the column existed; failure to write
// is not worth failing a read over, so callers may ignore the error.
func (db *DB) SetPreviewSize(ctx context.Context, id string, size int64) error {
_, err := db.ExecContext(ctx, db.Rebind(
"UPDATE photos_photos SET preview_size_bytes = ? WHERE id = ?"), size, id)
return err return err
} }
+14 -7
View File
@@ -146,14 +146,17 @@ func (w *Worker) process(ctx context.Context, p store.Photo) error {
prefix := fmt.Sprintf("galleries/%s", p.GalleryID) prefix := fmt.Sprintf("galleries/%s", p.GalleryID)
thumbKey := fmt.Sprintf("%s/thumb/%s.jpg", prefix, base) thumbKey := fmt.Sprintf("%s/thumb/%s.jpg", prefix, base)
previewKey := fmt.Sprintf("%s/preview/%s.jpg", prefix, base) previewKey := fmt.Sprintf("%s/preview/%s.jpg", prefix, base)
if err := w.upload(ctx, thumbKey, thumbPath); err != nil { if _, err := w.upload(ctx, thumbKey, thumbPath); err != nil {
return fmt.Errorf("store thumb: %w", err) return fmt.Errorf("store thumb: %w", err)
} }
if err := w.upload(ctx, previewKey, previewPath); err != nil { // The preview's size is recorded because the gallery response labels the
// mobile save sheet with it; measuring it here costs nothing.
previewSize, err := w.upload(ctx, previewKey, previewPath)
if err != nil {
return fmt.Errorf("store preview: %w", err) return fmt.Errorf("store preview: %w", err)
} }
return w.db.MarkPhotoReady(ctx, p.ID, thumbKey, previewKey, res.Width, res.Height, res.TakenAt) return w.db.MarkPhotoReady(ctx, p.ID, thumbKey, previewKey, previewSize, res.Width, res.Height, res.TakenAt)
} }
func (w *Worker) download(ctx context.Context, key, dst string) error { func (w *Worker) download(ctx context.Context, key, dst string) error {
@@ -171,15 +174,19 @@ func (w *Worker) download(ctx context.Context, key, dst string) error {
return err return err
} }
func (w *Worker) upload(ctx context.Context, key, src string) error { // upload stores a generated variant and reports how many bytes it holds.
func (w *Worker) upload(ctx context.Context, key, src string) (int64, error) {
f, err := os.Open(src) f, err := os.Open(src)
if err != nil { if err != nil {
return err return 0, err
} }
defer f.Close() defer f.Close()
info, err := f.Stat() info, err := f.Stat()
if err != nil { if err != nil {
return err return 0, err
} }
return w.storage.Put(ctx, key, f, info.Size(), "image/jpeg") if err := w.storage.Put(ctx, key, f, info.Size(), "image/jpeg"); err != nil {
return 0, err
}
return info.Size(), nil
} }
@@ -0,0 +1,8 @@
-- Content hash of the stored original, for per-gallery duplicate detection on
-- upload. NULL means "not hashed yet" — rows predating this migration, until
-- `photo-api backfill-checksums` runs. NULLs are distinct under a unique
-- index, so those rows never collide with each other.
-- (No semicolons in these comments: the migration runner splits on them.)
ALTER TABLE photos_photos ADD COLUMN IF NOT EXISTS checksum varchar(64);
CREATE UNIQUE INDEX IF NOT EXISTS photos_photos_gallery_checksum_idx ON photos_photos (gallery_id, checksum);
@@ -0,0 +1,8 @@
-- Content hash of the stored original, for per-gallery duplicate detection on
-- upload. NULL means "not hashed yet" — rows predating this migration, until
-- `photo-api backfill-checksums` runs. NULLs are distinct under a unique
-- index, so those rows never collide with each other.
-- (No semicolons in these comments: the migration runner splits on them.)
ALTER TABLE photos_photos ADD COLUMN checksum text;
CREATE UNIQUE INDEX IF NOT EXISTS photos_photos_gallery_checksum_idx ON photos_photos (gallery_id, checksum);
@@ -0,0 +1,6 @@
-- Byte size of the generated preview variant, so the gallery response can
-- label the mobile save sheet's rows without the client HEADing every file.
-- NULL means "not measured yet" — rows predating this migration, until the
-- read path stats them once (see fillPreviewSizes in httpapi/sizes.go).
-- (No semicolons in these comments: the migration runner splits on them.)
ALTER TABLE photos_photos ADD COLUMN IF NOT EXISTS preview_size_bytes bigint;
@@ -0,0 +1,6 @@
-- Byte size of the generated preview variant, so the gallery response can
-- label the mobile save sheet's rows without the client HEADing every file.
-- NULL means "not measured yet" — rows predating this migration, until the
-- read path stats them once (see fillPreviewSizes in httpapi/sizes.go).
-- (No semicolons in these comments: the migration runner splits on them.)
ALTER TABLE photos_photos ADD COLUMN preview_size_bytes integer;