Compare commits
17
Commits
backup-prod14
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
183a2c99ed | ||
|
|
745af4184f | ||
|
|
279cd19308 | ||
|
|
02a12ee9e0 | ||
|
|
de791fd17d | ||
|
|
ed1d3a8c12 | ||
|
|
87bf9a6151 | ||
|
|
3f7b2d51db | ||
|
|
5970d707af | ||
|
|
e296e80e48 | ||
|
|
a0161a67d2 | ||
|
|
d06f80e572 | ||
|
|
be4dd5b47f | ||
|
|
e0f0b2f1e9 | ||
|
|
390e1dc0ea | ||
|
|
fa8686276d | ||
|
|
498d7d8a7d |
@@ -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
@@ -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 }>(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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); }}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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}–{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">…</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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}–{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">…</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 };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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…"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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…"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
};
|
||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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, ¬Found) || 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),
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user