6 Commits
Author SHA1 Message Date
MichilisandClaude Fable 5 9b2668f498 Unify admin ticket creation into one modal with first-class payment status.
- Replace the three Attendees-tab modals (Manual Ticket / Add at Door /
  Invite Guest) with a single Add Ticket modal: Paid/Unpaid/Guest segmented
  control, shared fields, "Check in now" for all types, and a live
  "what happens" preview, backed by one POST /api/tickets/admin/add.
- Add tickets.payment_status (paid | unpaid | comp) with a backfill
  migration; keep it in sync on every payment-settlement path (mark-paid,
  admin approval, Lightning, free bookings, hold recovery).
- Show Paid/Unpaid/Comp badges in the attendee list, count only paid
  tickets toward revenue, let unpaid tickets be resolved via Mark Paid,
  and flag unpaid tickets with their balance due in the door scanner.
- Replace the per-page useStatsPrivacy hook with an admin-wide
  PrivacyContext + SensitiveValue mask, toggled from the admin layout.
- Add server-side pagination with page-size options to the users page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 05:25:00 +00:00
MichilisandClaude Fable 5 e38d14970d Harden the Redis layer for production.
- Locks no longer fail open: an unreachable backend throws
  LockUnavailableError and withLock skips the run (or opts into running
  unlocked, as template seeding does). The lnbits payment poller keeps
  polling through an outage, made safe by only sending confirmation
  emails when a row actually transitioned.
- Rate limiter INCR+PEXPIRE now runs as one Lua script, self-healing
  counters stranded without a TTL.
- Per-email login lockout moves to a Redis-backed store shared across
  replicas (in-memory fallback preserved), case-insensitive on email.
- Graceful shutdown on SIGTERM/SIGINT: stop periodic jobs, close the
  server, force-close lingering SSE sockets, close Redis.
- Active PING probe backs the health flag; /health reports last ping.
  SSE payment streams also poll the DB as a pub/sub-gap fallback.
- Scale compose gains requirepass, maxmemory 256mb with noeviction, and
  an authenticated healthcheck; .env.example documents passwords, TLS
  (rediss://), and DB-index selection.
- First tests in the repo: vitest + ioredis-mock covering the lock,
  rate limiter, and login lockout stores (27 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 04:58:52 +00:00
MichilisandClaude Fable 5 71c277045b Centralize seat capacity accounting and the payment provider registry.
Replace manualProviders.ts with a paymentProviders.ts registry
(automatic vs manual settlement) and move all seat counting into
capacity.ts as the single source of truth: only paid/checked-in tickets
and pending_approval payments hold a seat, so abandoned checkouts never
block sales. Admins can now knowingly approve a payment over capacity
(allowOverCapacity), with the booking and admin UIs updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 04:58:40 +00:00
MichilisandCursor c9a600b6d6 Add photo galleries API and frontend for public and admin viewing.
Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 17:32:23 +00:00
MichilisandCursor 4772b85f3d Stop auto-failing manual payments (TPago/bank/cash) after the pending TTL.
Exclude manual providers from booking cleanup, hold them via the 72h sweep instead, and let admins reopen failed payments to pending.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 03:53:51 +00:00
MichilisandCursor 0d47156071 Normalize RUC formatting to base-checkdigit across booking and admin views.
Accept dashed or digits-only RUC input on the backend, store a canonical dashed form, and display it consistently in booking and admin UIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 03:47:35 +00:00
115 changed files with 9254 additions and 1099 deletions
+5
View File
@@ -7,6 +7,7 @@ package-lock.json
# Build outputs
dist/
.next/
.next-build/
out/
build/
@@ -56,6 +57,10 @@ yarn-error.log*
# Testing
coverage/
# Go photo-api service
photo-api/bin/
photo-api/data/
# Misc
*.pem
+11 -3
View File
@@ -22,10 +22,18 @@ DATABASE_URL=./data/spanglish.db
# Note: running more than one instance requires DB_TYPE=postgres. SQLite is a
# single local file and cannot be shared safely across instances.
# Redis connection URL. When set, the cache, rate limiter, pub/sub (real-time
# payment events), distributed locks, and the email hourly cap are shared across
# all instances. When unset, each instance uses in-memory equivalents.
# Redis connection URL. When set, the cache, rate limiter, login lockout,
# pub/sub (real-time payment events), distributed locks, and the email hourly
# cap are shared across all instances. When unset, each instance uses in-memory
# equivalents.
#
# In production always set a password (requirepass on the server) and put it in
# the URL. Use the rediss:// scheme for TLS (handled natively by the client),
# and an optional /N path to select a DB index when sharing a Redis instance
# with other applications.
# REDIS_URL=redis://localhost:6379
# REDIS_URL=redis://:your-redis-password@redis.internal:6379
# REDIS_URL=rediss://:your-redis-password@redis.example.com:6380/1
# Optional S3-compatible object storage for media uploads (e.g. Garage, MinIO,
# AWS S3). When S3_ENDPOINT and S3_BUCKET are set, uploads go to the bucket and
+4 -1
View File
@@ -5,6 +5,7 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"test": "vitest run",
"start": "NODE_ENV=production node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
@@ -42,7 +43,9 @@
"@types/pg": "^8.11.6",
"@types/qrcode": "^1.5.6",
"drizzle-kit": "^0.22.8",
"ioredis-mock": "^8.13.1",
"tsx": "^4.15.7",
"typescript": "^5.5.2"
"typescript": "^5.5.2",
"vitest": "^4.1.10"
}
}
+25 -1
View File
@@ -199,7 +199,19 @@ async function migrate() {
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// Migration: Add payment_status column to tickets (paid | unpaid | comp),
// backfilled from is_guest and the payments table on first run
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN payment_status TEXT NOT NULL DEFAULT 'unpaid'`);
await (db as any).run(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`);
await (db as any).run(sql`
UPDATE tickets SET payment_status = 'paid'
WHERE is_guest = 0
AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid')
`);
} catch (e) { /* column may already exist */ }
// Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries)
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
@@ -702,6 +714,18 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// Migration: Add payment_status column to tickets (paid | unpaid | comp),
// backfilled from is_guest and the payments table on first run
try {
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN payment_status VARCHAR(10) NOT NULL DEFAULT 'unpaid'`);
await (db as any).execute(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`);
await (db as any).execute(sql`
UPDATE tickets SET payment_status = 'paid'
WHERE is_guest = 0
AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid')
`);
} catch (e) { /* column may already exist */ }
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS payments (
id UUID PRIMARY KEY,
+4
View File
@@ -110,6 +110,8 @@ export const sqliteTickets = sqliteTable('tickets', {
qrCode: text('qr_code'),
adminNote: text('admin_note'),
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'),
createdAt: text('created_at').notNull(),
});
@@ -468,6 +470,8 @@ export const pgTickets = pgTable('tickets', {
qrCode: varchar('qr_code', { length: 255 }),
adminNote: pgText('admin_note'),
isGuest: pgInteger('is_guest').notNull().default(0),
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'),
createdAt: timestamp('created_at').notNull(),
});
+46 -6
View File
@@ -24,10 +24,11 @@ import legalPagesRoutes from './routes/legal-pages.js';
import legalSettingsRoutes from './routes/legal-settings.js';
import faqRoutes from './routes/faq.js';
import emailService from './lib/email.js';
import { initEmailQueue } from './lib/emailQueue.js';
import { startBookingCleanup } from './lib/bookingCleanup.js';
import { startHoldSweep } from './lib/holdSweep.js';
import { startEventEndSweep } from './lib/eventEndSweep.js';
import { initEmailQueue, stopQueue } from './lib/emailQueue.js';
import { startBookingCleanup, stopBookingCleanup } from './lib/bookingCleanup.js';
import { startHoldSweep, stopHoldSweep } from './lib/holdSweep.js';
import { startEventEndSweep, stopEventEndSweep } from './lib/eventEndSweep.js';
import { closeRedis } from './lib/redis.js';
import { getLock } from './lib/stores/lock.js';
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
@@ -1943,8 +1944,11 @@ startEventEndSweep();
// Initialize email templates on startup.
// Guarded by a distributed lock so that, when running multiple replicas, only
// one instance seeds/updates templates per boot instead of all of them racing.
// onUnavailable 'run': at boot the Redis connection may not be ready yet, and
// seeding is upsert-idempotent, so racing replicas are safe — never skipping
// beats never seeding on a first boot during a Redis blip.
getLock()
.withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates())
.withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates(), { onUnavailable: 'run' })
.then((result) => {
if (result === null) {
console.log('[Email] Template seeding skipped (another instance holds the lock)');
@@ -1961,7 +1965,43 @@ console.log(`📋 OpenAPI spec at http://localhost:${port}/openapi.json`);
// Log which backend (memory/redis, local/s3) each subsystem selected.
logSelectedBackends();
serve({
const server = serve({
fetch: app.fetch,
port,
});
// Graceful shutdown: stop the periodic jobs, stop accepting connections, then
// close Redis and exit. Open SSE payment streams hold sockets forever, so
// server.close() alone never completes — force-close remaining connections
// after a short grace period, with a hard exit as the final backstop.
let shuttingDown = false;
function shutdown(signal: string): void {
if (shuttingDown) return;
shuttingDown = true;
console.log(`[shutdown] ${signal} received, draining...`);
stopBookingCleanup();
stopHoldSweep();
stopEventEndSweep();
stopQueue();
server.close(() => {
console.log('[shutdown] server closed, closing redis');
closeRedis().finally(() => process.exit(0));
});
const forceClose = setTimeout(() => {
console.warn('[shutdown] force-closing remaining connections (SSE streams)');
(server as any).closeAllConnections?.();
}, 5_000);
forceClose.unref();
const forceExit = setTimeout(() => {
console.warn('[shutdown] drain timed out, forcing exit');
process.exit(1);
}, 10_000);
forceExit.unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+5 -2
View File
@@ -1,11 +1,12 @@
// Reports which backend each scalable subsystem is using, for the health
// endpoint and startup logging.
import { isRedisEnabled, isRedisHealthy } from './redis.js';
import { isRedisEnabled, isRedisHealthy, getRedisHealthDetail } from './redis.js';
import { getRateLimiter } from './stores/rateLimiter.js';
import { getPubSub } from './stores/pubsub.js';
import { getCache } from './stores/cache.js';
import { getLock } from './stores/lock.js';
import { getLoginLockout } from './stores/loginLockout.js';
import { getStorage } from './storage.js';
export function describeBackends() {
@@ -14,12 +15,13 @@ export function describeBackends() {
rateLimiter: getRateLimiter().backend,
pubsub: getPubSub().backend,
lock: getLock().backend,
loginLockout: getLoginLockout().backend,
storage: getStorage().backend,
};
}
export function describeRedis() {
return { enabled: isRedisEnabled(), healthy: isRedisHealthy() };
return { enabled: isRedisEnabled(), healthy: isRedisHealthy(), ...getRedisHealthDetail() };
}
/** Log one line per subsystem at startup so the active backend is obvious. */
@@ -32,5 +34,6 @@ export function logSelectedBackends(): void {
console.log(` rate limiter: ${b.rateLimiter}`);
console.log(` pub/sub: ${b.pubsub}`);
console.log(` lock: ${b.lock}`);
console.log(` login lockout:${b.loginLockout}`);
console.log(` storage: ${b.storage}`);
}
+15 -4
View File
@@ -5,11 +5,20 @@
// abandoned checkout would otherwise hold those seats forever. This job cancels
// pending tickets whose payment is still 'pending' (i.e. never paid and not
// awaiting admin approval) after a configurable TTL, freeing the seats.
//
// Two exclusions:
// - Manual-verification providers (bank transfer / TPago / cash) are never
// auto-failed; they are settled by an admin and instead follow the 72h on-hold
// sweep. See holdSweep.ts and MANUAL_PAYMENT_PROVIDERS.
// - Staleness is measured from `updatedAt`, not `createdAt`, so an admin action
// (e.g. reopening a payment to 'pending' via /reopen) restarts the TTL rather
// than being immediately re-failed on the next run.
import { and, eq, lt, inArray } from 'drizzle-orm';
import { and, eq, lt, inArray, notInArray } from 'drizzle-orm';
import { db, dbAll, tickets, payments } from '../db/index.js';
import { getNow, toDbDate } from './utils.js';
import { getLock } from './stores/lock.js';
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
function getTtlMs(): number {
const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10);
@@ -20,8 +29,9 @@ function getTtlMs(): number {
* Cancel stale pending bookings. Returns the number of tickets cancelled.
*
* A booking is considered stale when its payment is still 'pending' (not
* 'pending_approval', which means an admin is reviewing a manual transfer) and
* older than PENDING_BOOKING_TTL_MINUTES.
* 'pending_approval', which means an admin is reviewing a manual transfer),
* uses a non-manual provider, and has not been touched (updatedAt) for
* PENDING_BOOKING_TTL_MINUTES.
*/
export async function cleanupStalePendingBookings(): Promise<number> {
const cutoff = toDbDate(new Date(Date.now() - getTtlMs()));
@@ -35,7 +45,8 @@ export async function cleanupStalePendingBookings(): Promise<number> {
.from(payments)
.where(and(
eq((payments as any).status, 'pending'),
lt((payments as any).createdAt, cutoff)
notInArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
lt((payments as any).updatedAt, cutoff)
))
);
+70
View File
@@ -0,0 +1,70 @@
// Single source of truth for event seat accounting.
//
// A seat is held by a booking the moment the money is real or claimed to be:
// - ticket 'confirmed' or 'checked_in' (paid), or
// - ticket 'pending' whose payment is 'pending_approval' (customer clicked
// "I've paid" and is waiting for admin verification).
//
// A bare 'pending' payment — an opened checkout that was never paid nor claimed,
// on any provider — holds NO seat, so abandoned bookings can never block sales.
// The accepted trade-off is a small oversell window when several people book the
// last seats and all later pay/claim; admin approval is the backstop and may
// knowingly approve over capacity (see routes/payments.ts).
//
// 'pending_approval' is a payment status (the ticket row stays 'pending'), so
// every capacity count joins tickets to payments. There is exactly one payment
// row per ticket (created together in routes/tickets.ts).
//
// Every place that counts seats — booking creation, public availability,
// hold recovery, admin dashboards — must go through these builders so the
// formula cannot diverge between surfaces.
import { sql, and, eq, inArray } from 'drizzle-orm';
import { tickets, payments } from '../db/index.js';
// COALESCE keeps the predicate two-valued under the LEFT JOIN (a ticket with no
// payment row must count as "not holding" — NULL would poison NOT ...).
export const seatHoldingSql = sql`(${(tickets as any).status} IN ('confirmed', 'checked_in') OR (${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval'))`;
/**
* Query: number of seats currently held for an event.
* `executor` is the db, or a transaction (sync sqlite tx: finish with `.get()`;
* async pg tx / plain db: await via dbGet).
*/
export function seatHolderCountQuery(executor: any, eventId: string) {
return executor
.select({ count: sql<number>`count(distinct ${(tickets as any).id})` })
.from(tickets)
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id))
.where(and(eq((tickets as any).eventId, eventId), seatHoldingSql));
}
/**
* Query: how many of the given tickets do NOT currently hold a seat (and so
* would need fresh capacity if promoted to a seat-holding state).
*/
export function unseatedTicketCountQuery(executor: any, ticketIds: string[]) {
return executor
.select({ count: sql<number>`count(distinct ${(tickets as any).id})` })
.from(tickets)
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id))
.where(and(inArray((tickets as any).id, ticketIds), sql`NOT ${seatHoldingSql}`));
}
/**
* Query: per-event breakdown of paid vs claimed seats, grouped by event.
* paidCount = confirmed + checked_in; claimedCount = pending_approval-held.
* Pass `eventId` to restrict to one event (still returns a grouped row).
*/
export function eventSeatBreakdownQuery(executor: any, eventId?: string) {
const query = executor
.select({
eventId: (tickets as any).eventId,
paidCount: sql<number>`sum(case when ${(tickets as any).status} IN ('confirmed', 'checked_in') then 1 else 0 end)`,
claimedCount: sql<number>`sum(case when ${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval' then 1 else 0 end)`,
})
.from(tickets)
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id));
return (eventId ? query.where(eq((tickets as any).eventId, eventId)) : query)
.groupBy((tickets as any).eventId);
}
+57 -35
View File
@@ -1,14 +1,22 @@
// Shared capacity-checked recovery for on-hold bookings.
// Shared capacity-checked recovery for bookings that don't currently hold a seat.
//
// When a booking is put on hold, its ticket(s) drop out of the capacity-counting
// statuses ('pending', 'confirmed', 'checked_in'), releasing the seat. Recovering
// an on-hold booking (user "I've paid" again, or an admin reactivating / marking it
// paid) must atomically re-check that the event still has room before re-reserving
// the seat, exactly like the original booking-creation flow in routes/tickets.ts.
// Under the seat-holding rule (lib/capacity.ts) a seat is held by paid/checked-in
// tickets and by 'pending_approval' payments. Promoting a booking INTO one of those
// states — user clicking "I've paid", an admin approving/reactivating a payment —
// must atomically re-check that the event still has room, exactly like the original
// booking-creation flow in routes/tickets.ts. Demoting back to bare 'pending'
// (e.g. reopening a failed payment) claims no seat and skips the check.
//
// Callers choose which ticket statuses are eligible to be flipped via
// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list are
// left untouched. Tickets that already hold a seat cost no new capacity.
// `options.skipCapacityCheck` lets an admin knowingly approve over capacity —
// the UI warns first (routes/payments.ts /approve with allowOverCapacity).
import { eq, and, inArray, sql } from 'drizzle-orm';
import { eq, and, inArray } from 'drizzle-orm';
import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js';
import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js';
import { seatHolderCountQuery, unseatedTicketCountQuery } from './capacity.js';
export class HoldCapacityError extends Error {
constructor(public available: number) {
@@ -19,22 +27,36 @@ export class HoldCapacityError extends Error {
interface ReserveOptions {
paidByAdminId?: string;
extraPaymentFields?: Record<string, any>;
/** Ticket statuses eligible to be flipped to targetTicketStatus. Default: ['on_hold']. */
fromTicketStatuses?: Array<'on_hold' | 'cancelled' | 'pending'>;
/** Admin override: reserve even when it puts the event over capacity. */
skipCapacityCheck?: boolean;
}
/**
* Re-reserve seats for a group of on-hold tickets (e.g. all tickets sharing a
* Re-reserve seats for a group of released tickets (e.g. all tickets sharing a
* bookingId), atomically re-checking capacity before flipping their status.
* Throws HoldCapacityError if the event no longer has room for ticketIds.length seats.
* Only tickets whose current status is in `fromTicketStatuses` are flipped.
* Capacity is asserted against the number of those tickets that don't already
* hold a seat, so re-reserving tickets that are already seated is a no-op.
* Throws HoldCapacityError if the event no longer has room (unless the target
* state holds no seat, or skipCapacityCheck is set).
*/
export async function reserveOnHoldBooking(
eventId: string,
ticketIds: string[],
targetTicketStatus: 'pending' | 'confirmed',
targetPaymentStatus: 'pending_approval' | 'paid',
targetPaymentStatus: 'pending_approval' | 'paid' | 'pending',
options: ReserveOptions = {}
): Promise<void> {
if (ticketIds.length === 0) return;
const fromTicketStatuses = options.fromTicketStatuses ?? ['on_hold'];
// Bare 'pending' payments hold no seat, so moving a booking back to 'pending'
// consumes no capacity and needs no check.
const targetHoldsSeat = targetPaymentStatus !== 'pending' || targetTicketStatus === 'confirmed';
const checkCapacity = targetHoldsSeat && !options.skipCapacityCheck;
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, eventId))
);
@@ -53,33 +75,38 @@ export async function reserveOnHoldBooking(
if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId;
}
const assertCapacity = (reserved: number) => {
// Keep the ticket-level payment flag in sync when the payment settles
const ticketUpdate: Record<string, any> = { status: targetTicketStatus };
if (targetPaymentStatus === 'paid') {
ticketUpdate.paymentStatus = 'paid';
}
// `needed` is how many of these tickets don't currently hold a seat and so must
// be found new capacity; tickets already in a seat-holding state cost nothing.
const assertCapacity = (reserved: number, needed: number) => {
if (needed <= 0) return;
if (isEventSoldOut(event.capacity, reserved)) {
throw new HoldCapacityError(0);
}
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
if (ticketIds.length > seatsLeft) {
if (needed > seatsLeft) {
throw new HoldCapacityError(seatsLeft);
}
};
if (isSqlite()) {
(db as any).transaction((tx: any) => {
const countRow = tx
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(and(
eq((tickets as any).eventId, eventId),
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
))
.get();
assertCapacity(Number(countRow?.count || 0));
if (checkCapacity) {
const countRow = seatHolderCountQuery(tx, eventId).get();
const neededRow = unseatedTicketCountQuery(tx, ticketIds).get();
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
}
tx.update(tickets)
.set({ status: targetTicketStatus })
.set(ticketUpdate)
.where(and(
inArray((tickets as any).id, ticketIds),
eq((tickets as any).status, 'on_hold')
inArray((tickets as any).status, fromTicketStatuses)
))
.run();
@@ -90,22 +117,17 @@ export async function reserveOnHoldBooking(
});
} else {
await (db as any).transaction(async (tx: any) => {
const countRow = await dbGet<any>(
tx
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(and(
eq((tickets as any).eventId, eventId),
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
))
);
assertCapacity(Number(countRow?.count || 0));
if (checkCapacity) {
const countRow = await dbGet<any>(seatHolderCountQuery(tx, eventId));
const neededRow = await dbGet<any>(unseatedTicketCountQuery(tx, ticketIds));
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
}
await tx.update(tickets)
.set({ status: targetTicketStatus })
.set(ticketUpdate)
.where(and(
inArray((tickets as any).id, ticketIds),
eq((tickets as any).status, 'on_hold')
inArray((tickets as any).status, fromTicketStatuses)
));
await tx.update(payments)
+21 -12
View File
@@ -1,17 +1,24 @@
// Auto-hold stale pending-approval bookings.
// Auto-hold stale unsettled manual-payment bookings.
//
// A payment enters 'pending_approval' when a user clicks "I've paid" on a manual
// payment method (bank transfer / TPago) and is waiting for an admin to review it.
// If no admin acts within HOLD_THRESHOLD_HOURS, this job silently moves the payment
// (and its ticket) to 'on_hold', which drops it out of the capacity-counting statuses
// ('pending', 'confirmed', 'checked_in') and so releases the seat back to the event.
// The user receives no notification — they can recover via "I've paid" again, and an
// admin can reactivate or mark it paid directly, both re-checking capacity.
// This job moves abandoned manual-payment bookings (bank transfer / TPago / cash)
// to 'on_hold' after HOLD_THRESHOLD_HOURS — bookings still in bare 'pending', i.e.
// the customer never clicked "I've paid" and no admin settled them. These are exempt
// from the 30-min auto-fail in bookingCleanup.ts, and under the capacity rule in
// lib/capacity.ts they hold no seat, so this sweep is pure list hygiene: it keeps
// dead checkouts out of the admin's pending queues.
//
// 'pending_approval' (customer claims they paid) is deliberately NOT swept: a
// claimed payment keeps its seat until an admin approves or rejects it — the admin
// UI surfaces aging claims instead of silently releasing them.
//
// The user receives no notification — they can recover via "I've paid", and an
// admin can approve/reactivate directly; every recovery path re-checks capacity.
import { and, eq, lt, inArray } from 'drizzle-orm';
import { db, dbAll, tickets, payments } from '../db/index.js';
import { getNow, toDbDate } from './utils.js';
import { getLock } from './stores/lock.js';
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
function getThresholdMs(): number {
const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10);
@@ -19,8 +26,9 @@ function getThresholdMs(): number {
}
/**
* Move stale pending-approval payments (and their tickets) to 'on_hold'.
* Returns the number of payments put on hold.
* Move stale unsettled manual payments (and their tickets) to 'on_hold'.
* Covers only bare 'pending' payments on manual providers; 'pending_approval'
* is never swept. Returns the number of payments put on hold.
*/
export async function sweepStaleApprovals(): Promise<number> {
const cutoff = toDbDate(new Date(Date.now() - getThresholdMs()));
@@ -33,7 +41,8 @@ export async function sweepStaleApprovals(): Promise<number> {
})
.from(payments)
.where(and(
eq((payments as any).status, 'pending_approval'),
eq((payments as any).status, 'pending'),
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
lt((payments as any).createdAt, cutoff)
))
);
@@ -59,7 +68,7 @@ export async function sweepStaleApprovals(): Promise<number> {
));
}
console.log(`[HoldSweep] Put ${stale.length} stale pending-approval payment(s) on hold.`);
console.log(`[HoldSweep] Put ${stale.length} stale unsettled manual payment(s) on hold.`);
return stale.length;
}
+37
View File
@@ -0,0 +1,37 @@
// Payment provider registry.
//
// Every provider is either:
// - 'automatic': the gateway itself confirms the payment (webhook/invoice
// settlement) and the booking is auto-approved on success. No admin involved.
// Currently Lightning; future online gateways (e.g. Stripe) go here.
// - 'manual': a human must verify the money arrived (TPago, bank transfer,
// card handled offline, cash at the door). These are never auto-confirmed
// and never auto-failed; an admin settles them by hand. Bank transfer and
// TPago additionally expose an online "I've paid" step that moves the
// payment to 'pending_approval'.
//
// Capacity note (see lib/capacity.ts): only paid/checked-in tickets and
// 'pending_approval' payments hold a seat. A bare 'pending' payment — of either
// kind — holds no seat, so an abandoned checkout can never block sales.
export type PaymentProviderKind = 'automatic' | 'manual';
export const PAYMENT_PROVIDERS: Record<string, { kind: PaymentProviderKind }> = {
lightning: { kind: 'automatic' },
tpago: { kind: 'manual' },
bank_transfer: { kind: 'manual' },
card: { kind: 'manual' },
cash: { kind: 'manual' },
};
export const MANUAL_PAYMENT_PROVIDERS = Object.keys(PAYMENT_PROVIDERS).filter(
(p) => PAYMENT_PROVIDERS[p].kind === 'manual'
);
export function isManualProvider(provider: string): boolean {
return PAYMENT_PROVIDERS[provider]?.kind === 'manual';
}
export function isAutomaticProvider(provider: string): boolean {
return PAYMENT_PROVIDERS[provider]?.kind === 'automatic';
}
+80 -10
View File
@@ -4,7 +4,15 @@
// before with in-memory backends. When set, this module owns a single shared
// command connection plus a dedicated subscriber connection (a connection in
// subscribe mode cannot run normal commands), with auto-reconnect, capped
// backoff, and a health flag that callers and the health endpoint can read.
// backoff, an active PING probe, and a health flag that callers and the health
// endpoint can read.
//
// TLS: use a rediss:// URL — ioredis enables TLS from the scheme. A /N path
// selects a DB index (e.g. redis://host:6379/1) when sharing an instance.
// We deliberately do not use ioredis's keyPrefix option: all keys are already
// namespaced per subsystem (cache:, rl:, lock:, lockout:), and keyPrefix has a
// pub/sub asymmetry (SUBSCRIBE channels get prefixed, PUBLISH channels do not)
// that would silently break the payment SSE channel.
import Redis from 'ioredis';
@@ -12,6 +20,14 @@ let client: Redis | null = null;
let subscriber: Redis | null = null;
let healthy = false;
let initialized = false;
let pingTimer: ReturnType<typeof setInterval> | null = null;
let lastPingOkAt: string | null = null;
let lastPingMs: number | null = null;
// Debounce repeated error logs during a sustained outage: transitions are
// always logged, repeated per-retry errors at most once per LOG_EVERY_MS.
const ERROR_LOG_EVERY_MS = 30_000;
let lastErrorLogAt = 0;
/** Whether Redis is configured via REDIS_URL. */
export function isRedisEnabled(): boolean {
@@ -23,14 +39,41 @@ export function isRedisHealthy(): boolean {
return isRedisEnabled() && healthy;
}
/** Detail for the health endpoint: when the last successful PING happened. */
export function getRedisHealthDetail(): { lastPingOkAt: string | null; lastPingMs: number | null } {
return { lastPingOkAt, lastPingMs };
}
function setHealthy(next: boolean, context: string): void {
if (next !== healthy) {
if (next) {
console.log(`[redis] healthy (${context})`);
} else {
console.warn(`[redis] unhealthy (${context})`);
}
}
healthy = next;
}
function logErrorDebounced(label: string, err: unknown): void {
const now = Date.now();
if (now - lastErrorLogAt >= ERROR_LOG_EVERY_MS) {
lastErrorLogAt = now;
console.error(`[redis] (${label}) error:`, (err as any)?.message || err);
}
}
function buildClient(label: string): Redis {
const url = process.env.REDIS_URL as string;
const instance = new Redis(url, {
// Keep the process responsive: fail fast on a per-command basis and let the
// callers degrade to their in-memory fallback rather than hanging.
// callers degrade to their in-memory fallback rather than hanging. Do not
// queue commands while disconnected — with fail-open callers everywhere a
// growing offline queue would only add latency and memory pressure.
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
lazyConnect: false,
connectTimeout: 5000,
retryStrategy(times) {
// Capped exponential backoff for reconnects: 200ms, 400ms ... max 5s.
const delay = Math.min(times * 200, 5000);
@@ -42,30 +85,53 @@ function buildClient(label: string): Redis {
console.log(`[redis] (${label}) connecting`);
});
instance.on('ready', () => {
healthy = true;
console.log(`[redis] (${label}) ready`);
setHealthy(true, `${label} ready`);
});
instance.on('error', (err) => {
healthy = false;
console.error(`[redis] (${label}) error:`, err?.message || err);
setHealthy(false, `${label} error`);
logErrorDebounced(label, err);
});
instance.on('reconnecting', () => {
healthy = false;
console.warn(`[redis] (${label}) reconnecting`);
setHealthy(false, `${label} reconnecting`);
});
instance.on('end', () => {
healthy = false;
console.warn(`[redis] (${label}) connection closed`);
setHealthy(false, `${label} connection closed`);
});
return instance;
}
// Actively probe the command connection. The event-driven flag alone misses a
// silently hung connection; a periodic PING with a hard timeout catches it.
async function pingOnce(): Promise<void> {
if (!client) return;
const started = Date.now();
try {
await Promise.race([
client.ping(),
new Promise((_, reject) => {
const t = setTimeout(() => reject(new Error('ping timeout')), 2000);
(t as any).unref?.();
}),
]);
lastPingMs = Date.now() - started;
lastPingOkAt = new Date().toISOString();
setHealthy(true, 'ping ok');
} catch (err) {
setHealthy(false, 'ping failed');
logErrorDebounced('ping', err);
}
}
function ensureInit(): void {
if (initialized || !isRedisEnabled()) return;
initialized = true;
client = buildClient('commands');
subscriber = buildClient('subscriber');
pingTimer = setInterval(() => {
void pingOnce();
}, 10_000);
(pingTimer as any).unref?.();
}
/** Shared command connection, or null when Redis is not configured. */
@@ -82,6 +148,10 @@ export function getSubscriber(): Redis | null {
/** Close connections (used for graceful shutdown). */
export async function closeRedis(): Promise<void> {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
const tasks: Promise<unknown>[] = [];
if (client) tasks.push(client.quit().catch(() => undefined));
if (subscriber) tasks.push(subscriber.quit().catch(() => undefined));
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import RedisMock from 'ioredis-mock';
const mocks = vi.hoisted(() => ({ redis: null as any }));
vi.mock('../redis.js', () => ({
isRedisEnabled: () => true,
getRedis: () => mocks.redis,
}));
import { MemoryLock, RedisLock, LockUnavailableError } from './lock.js';
describe('MemoryLock', () => {
it('grants the lock, blocks contenders, and frees on release', async () => {
const lock = new MemoryLock();
const token = await lock.acquire('a', 60_000);
expect(token).toBeTruthy();
expect(await lock.acquire('a', 60_000)).toBeNull();
await lock.release('a', token!);
expect(await lock.acquire('a', 60_000)).toBeTruthy();
});
it('ignores release with the wrong token', async () => {
const lock = new MemoryLock();
const token = await lock.acquire('a', 60_000);
await lock.release('a', 'not-the-token');
expect(await lock.acquire('a', 60_000)).toBeNull();
await lock.release('a', token!);
});
it('withLock runs fn while held and returns null under contention', async () => {
const lock = new MemoryLock();
const held = await lock.acquire('a', 60_000);
expect(await lock.withLock('a', 60_000, async () => 'ran')).toBeNull();
await lock.release('a', held!);
expect(await lock.withLock('a', 60_000, async () => 'ran')).toBe('ran');
// Released after withLock completes.
expect(await lock.acquire('a', 60_000)).toBeTruthy();
});
});
describe('RedisLock', () => {
beforeEach(async () => {
mocks.redis = new RedisMock();
// ioredis-mock shares data between instances by connection string.
await mocks.redis.flushall();
});
it('grants the lock and blocks contenders', async () => {
const lock = new RedisLock();
const token = await lock.acquire('a', 60_000);
expect(token).toBeTruthy();
expect(await lock.acquire('a', 60_000)).toBeNull();
});
it('release is compare-and-delete: wrong token does not free the lock', async () => {
const lock = new RedisLock();
const token = await lock.acquire('a', 60_000);
await lock.release('a', 'not-the-token');
expect(await lock.acquire('a', 60_000)).toBeNull();
await lock.release('a', token!);
expect(await lock.acquire('a', 60_000)).toBeTruthy();
});
it('throws LockUnavailableError when the client is not initialized', async () => {
mocks.redis = null;
const lock = new RedisLock();
await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError);
});
it('throws LockUnavailableError when the backend errors', async () => {
mocks.redis = { set: () => Promise.reject(new Error('connection refused')) };
const lock = new RedisLock();
await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError);
});
it('withLock skips (returns null, fn not called) when unavailable by default', async () => {
mocks.redis = null;
const lock = new RedisLock();
const fn = vi.fn(async () => 'ran');
expect(await lock.withLock('a', 60_000, fn)).toBeNull();
expect(fn).not.toHaveBeenCalled();
});
it('withLock runs fn without the lock when onUnavailable is "run"', async () => {
mocks.redis = null;
const lock = new RedisLock();
const fn = vi.fn(async () => 'ran');
expect(await lock.withLock('a', 60_000, fn, { onUnavailable: 'run' })).toBe('ran');
expect(fn).toHaveBeenCalledOnce();
});
it('withLock returns fn result and releases the lock afterwards', async () => {
const lock = new RedisLock();
expect(await lock.withLock('a', 60_000, async () => 42)).toBe(42);
expect(await lock.acquire('a', 60_000)).toBeTruthy();
});
});
+44 -10
View File
@@ -5,22 +5,45 @@
//
// Use acquire/release for long-lived ownership (e.g. a background poller) and
// withLock for a one-shot critical section. Selection is based on REDIS_URL.
//
// There is deliberately no auto-renewal/watchdog: every guarded section (sweep
// jobs, template seeding) is a handful of status-conditional bulk UPDATEs that
// finish far below the lock TTL, and a rare TTL overrun only risks one
// idempotent overlapping run. Keep TTLs generous instead of adding renewal.
import { randomUUID } from 'crypto';
import { getRedis, isRedisEnabled } from '../redis.js';
// Thrown when Redis is configured but the lock backend cannot be reached.
// This is distinct from contention (acquire resolves null): the caller must
// decide whether its critical section is safe to run without mutual exclusion.
export class LockUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = 'LockUnavailableError';
}
}
export interface WithLockOptions {
// What withLock should do when the lock backend is unavailable (not mere
// contention): 'skip' (default) returns null as if the lock were held
// elsewhere; 'run' executes fn without mutual exclusion.
onUnavailable?: 'skip' | 'run';
}
export interface Lock {
readonly backend: 'memory' | 'redis';
// Returns a token when the lock was acquired, or null when already held.
// Throws LockUnavailableError when the backend is configured but erroring.
acquire(key: string, ttlMs: number): Promise<string | null>;
release(key: string, token: string): Promise<void>;
// Runs fn while holding the lock; returns fn's result, or null if not acquired.
withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null>;
withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>, opts?: WithLockOptions): Promise<T | null>;
}
// ==================== Memory implementation ====================
class MemoryLock implements Lock {
export class MemoryLock implements Lock {
readonly backend = 'memory' as const;
private held = new Map<string, { token: string; expiresAt: number }>();
@@ -58,23 +81,23 @@ class MemoryLock implements Lock {
const RELEASE_SCRIPT =
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
class RedisLock implements Lock {
export class RedisLock implements Lock {
readonly backend = 'redis' as const;
async acquire(key: string, ttlMs: number): Promise<string | null> {
const redis = getRedis();
if (!redis) {
// Redis configured but unavailable: do not block critical sections.
return randomUUID();
throw new LockUnavailableError('redis client not initialized');
}
const token = randomUUID();
try {
const result = await redis.set(`lock:${key}`, token, 'PX', ttlMs, 'NX');
return result === 'OK' ? token : null;
} catch (err: any) {
console.error('[lock] redis acquire error, proceeding without lock:', err?.message || err);
// Fail open so a Redis outage does not deadlock startup or jobs.
return randomUUID();
// Do NOT fabricate a token here: during an outage every replica would
// "acquire" every lock and run the guarded sections concurrently. Let the
// caller choose between skipping the run and running unlocked.
throw new LockUnavailableError(err?.message || String(err));
}
}
@@ -88,8 +111,19 @@ class RedisLock implements Lock {
}
}
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null> {
const token = await this.acquire(key, ttlMs);
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>, opts?: WithLockOptions): Promise<T | null> {
let token: string | null;
try {
token = await this.acquire(key, ttlMs);
} catch (err) {
if (!(err instanceof LockUnavailableError)) throw err;
if (opts?.onUnavailable === 'run') {
console.warn(`[lock] backend unavailable, running "${key}" WITHOUT mutual exclusion:`, err.message);
return fn();
}
console.warn(`[lock] backend unavailable, skipping "${key}" this run:`, err.message);
return null;
}
if (!token) return null;
try {
return await fn();
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import RedisMock from 'ioredis-mock';
const mocks = vi.hoisted(() => ({ redis: null as any }));
vi.mock('../redis.js', () => ({
isRedisEnabled: () => true,
getRedis: () => mocks.redis,
}));
import {
MemoryLoginLockout,
RedisLoginLockout,
MAX_LOGIN_ATTEMPTS,
} from './loginLockout.js';
describe('MemoryLoginLockout', () => {
it('locks after MAX_LOGIN_ATTEMPTS failures with retryAfter', async () => {
const lockout = new MemoryLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) {
await lockout.recordFailure('user@example.com');
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
}
await lockout.recordFailure('user@example.com');
const status = await lockout.isLocked('user@example.com');
expect(status.locked).toBe(true);
expect(status.retryAfter).toBeGreaterThan(0);
});
it('clear removes the lockout', async () => {
const lockout = new MemoryLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
await lockout.recordFailure('user@example.com');
}
await lockout.clear('user@example.com');
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
});
it('treats emails case-insensitively', async () => {
const lockout = new MemoryLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
await lockout.recordFailure('User@Example.com');
}
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
});
it('expires after the lockout window', async () => {
vi.useFakeTimers();
try {
const lockout = new MemoryLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
await lockout.recordFailure('user@example.com');
}
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
vi.advanceTimersByTime(16 * 60 * 1000);
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
} finally {
vi.useRealTimers();
}
});
});
describe('RedisLoginLockout', () => {
beforeEach(async () => {
mocks.redis = new RedisMock();
// ioredis-mock shares data between instances by connection string.
await mocks.redis.flushall();
});
it('locks after MAX_LOGIN_ATTEMPTS failures shared via redis', async () => {
const lockout = new RedisLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) {
await lockout.recordFailure('user@example.com');
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
}
await lockout.recordFailure('user@example.com');
const status = await lockout.isLocked('user@example.com');
expect(status.locked).toBe(true);
expect(status.retryAfter).toBeGreaterThan(0);
});
it('sets the lockout window TTL on the first failure', async () => {
const lockout = new RedisLoginLockout();
await lockout.recordFailure('user@example.com');
const ttl = await mocks.redis.pttl('lockout:user@example.com');
expect(ttl).toBeGreaterThan(0);
});
it('clear removes the lockout', async () => {
const lockout = new RedisLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
await lockout.recordFailure('user@example.com');
}
await lockout.clear('user@example.com');
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
});
it('treats emails case-insensitively', async () => {
const lockout = new RedisLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
await lockout.recordFailure('User@Example.com');
}
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
});
it('fails open when the client is not initialized', async () => {
mocks.redis = null;
const lockout = new RedisLoginLockout();
await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined();
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
});
it('fails open when the backend errors', async () => {
mocks.redis = {
get: () => Promise.reject(new Error('connection refused')),
pttl: () => Promise.reject(new Error('connection refused')),
del: () => Promise.reject(new Error('connection refused')),
lockoutRecordFailure: () => Promise.reject(new Error('connection refused')),
};
const lockout = new RedisLoginLockout();
await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined();
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
await expect(lockout.clear('user@example.com')).resolves.toBeUndefined();
});
});
+164
View File
@@ -0,0 +1,164 @@
// Per-email login lockout abstraction with two implementations:
// - memory: per-process Map (the original routes/auth.ts behavior)
// - redis: shared counter across all instances so the lockout cannot be
// bypassed by round-robining replicas
//
// Semantics: recordFailure starts a window on the first failure; once the
// failure count reaches the max the email is locked until the window expires;
// clear removes the counter on successful login. Selection is based on
// REDIS_URL. The redis implementation fails open (never locked, failures not
// recorded) — the per-IP auth rate limit remains as a backstop during a
// Redis outage.
import type Redis from 'ioredis';
import { getRedis, isRedisEnabled } from '../redis.js';
export const MAX_LOGIN_ATTEMPTS = 5;
export const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes
export interface LockoutStatus {
locked: boolean;
// Seconds until the lockout lifts; only set when locked.
retryAfter?: number;
}
export interface LoginLockout {
readonly backend: 'memory' | 'redis';
isLocked(email: string): Promise<LockoutStatus>;
recordFailure(email: string): Promise<void>;
clear(email: string): Promise<void>;
}
// Emails are compared case-insensitively so "User@x.com" and "user@x.com"
// share one counter.
function normalize(email: string): string {
return email.trim().toLowerCase();
}
// ==================== Memory implementation ====================
export class MemoryLoginLockout implements LoginLockout {
readonly backend = 'memory' as const;
private attempts = new Map<string, { count: number; resetAt: number }>();
constructor() {
// Periodically drop expired entries so the Map does not grow unbounded.
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of this.attempts) {
if (now > entry.resetAt) this.attempts.delete(key);
}
}, 60_000);
(cleanup as any).unref?.();
}
async isLocked(email: string): Promise<LockoutStatus> {
const entry = this.attempts.get(normalize(email));
const now = Date.now();
if (!entry || now > entry.resetAt) return { locked: false };
if (entry.count >= MAX_LOGIN_ATTEMPTS) {
return { locked: true, retryAfter: Math.ceil((entry.resetAt - now) / 1000) };
}
return { locked: false };
}
async recordFailure(email: string): Promise<void> {
const key = normalize(email);
const now = Date.now();
const entry = this.attempts.get(key);
if (!entry || now > entry.resetAt) {
this.attempts.set(key, { count: 1, resetAt: now + LOCKOUT_DURATION_MS });
return;
}
entry.count++;
}
async clear(email: string): Promise<void> {
this.attempts.delete(normalize(email));
}
}
// ==================== Redis implementation ====================
// Same atomic INCR+PEXPIRE shape as the rate limiter's consume script: the
// window starts at the first failure and any TTL-less counter is repaired.
const RECORD_FAILURE_SCRIPT = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
if redis.call('PTTL', KEYS[1]) < 0 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
return count
`;
type RedisWithLockout = Redis & {
lockoutRecordFailure(key: string, windowMs: number): Promise<number>;
};
function withLockoutCommand(redis: Redis): RedisWithLockout {
if (typeof (redis as any).lockoutRecordFailure !== 'function') {
redis.defineCommand('lockoutRecordFailure', { numberOfKeys: 1, lua: RECORD_FAILURE_SCRIPT });
}
return redis as RedisWithLockout;
}
export class RedisLoginLockout implements LoginLockout {
readonly backend = 'redis' as const;
private key(email: string): string {
return `lockout:${normalize(email)}`;
}
async isLocked(email: string): Promise<LockoutStatus> {
const redis = getRedis();
if (!redis) return { locked: false };
try {
const [count, ttl] = await Promise.all([
redis.get(this.key(email)),
redis.pttl(this.key(email)),
]);
if (count !== null && parseInt(count, 10) >= MAX_LOGIN_ATTEMPTS) {
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(LOCKOUT_DURATION_MS / 1000);
return { locked: true, retryAfter };
}
return { locked: false };
} catch (err: any) {
// Fail open: the per-IP auth rate limit still applies.
console.error('[loginLockout] redis error, treating as unlocked:', err?.message || err);
return { locked: false };
}
}
async recordFailure(email: string): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await withLockoutCommand(redis).lockoutRecordFailure(this.key(email), LOCKOUT_DURATION_MS);
} catch (err: any) {
console.error('[loginLockout] redis error recording failure:', err?.message || err);
}
}
async clear(email: string): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await redis.del(this.key(email));
} catch (err: any) {
console.error('[loginLockout] redis error clearing failures:', err?.message || err);
}
}
}
// ==================== Selection ====================
let instance: LoginLockout | null = null;
export function getLoginLockout(): LoginLockout {
if (!instance) {
instance = isRedisEnabled() ? new RedisLoginLockout() : new MemoryLoginLockout();
}
return instance;
}
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import RedisMock from 'ioredis-mock';
const mocks = vi.hoisted(() => ({ redis: null as any }));
vi.mock('../redis.js', () => ({
isRedisEnabled: () => true,
getRedis: () => mocks.redis,
}));
import { MemoryRateLimiter, RedisRateLimiter } from './rateLimiter.js';
describe('MemoryRateLimiter', () => {
it('allows up to max within the window, then blocks with retryAfter', async () => {
const limiter = new MemoryRateLimiter();
for (let i = 0; i < 3; i++) {
expect((await limiter.consume('k', 3, 60_000)).allowed).toBe(true);
}
const blocked = await limiter.consume('k', 3, 60_000);
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfter).toBeGreaterThan(0);
expect(blocked.retryAfter).toBeLessThanOrEqual(60);
});
it('resets after the window elapses', async () => {
vi.useFakeTimers();
try {
const limiter = new MemoryRateLimiter();
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true);
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(false);
vi.advanceTimersByTime(1_500);
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true);
} finally {
vi.useRealTimers();
}
});
});
describe('RedisRateLimiter', () => {
beforeEach(async () => {
mocks.redis = new RedisMock();
// ioredis-mock shares data between instances by connection string.
await mocks.redis.flushall();
});
it('sets the window TTL atomically on the first hit', async () => {
const limiter = new RedisRateLimiter();
expect((await limiter.consume('k', 5, 60_000)).allowed).toBe(true);
const ttl = await mocks.redis.pttl('rl:k');
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(60_000);
});
it('blocks over the limit with a sane retryAfter', async () => {
const limiter = new RedisRateLimiter();
for (let i = 0; i < 2; i++) {
expect((await limiter.consume('k', 2, 60_000)).allowed).toBe(true);
}
const blocked = await limiter.consume('k', 2, 60_000);
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfter).toBeGreaterThan(0);
expect(blocked.retryAfter).toBeLessThanOrEqual(60);
});
it('self-heals a counter stranded without a TTL', async () => {
await mocks.redis.set('rl:k', '3');
expect(await mocks.redis.pttl('rl:k')).toBeLessThan(0);
const limiter = new RedisRateLimiter();
await limiter.consume('k', 10, 60_000);
expect(await mocks.redis.pttl('rl:k')).toBeGreaterThan(0);
});
it('fails open when the backend errors', async () => {
mocks.redis = { rlConsume: () => Promise.reject(new Error('connection refused')) };
const limiter = new RedisRateLimiter();
expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true);
});
it('fails open when the client is not initialized', async () => {
mocks.redis = null;
const limiter = new RedisRateLimiter();
expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true);
});
});
+35 -10
View File
@@ -1,10 +1,13 @@
// Rate limiter abstraction with two implementations:
// - memory: per-process fixed window (the original behavior)
// - redis: shared fixed window across all instances (INCR + PEXPIRE)
// - redis: shared fixed window across all instances, implemented as a single
// Lua script so INCR and PEXPIRE are atomic (a crash between separate calls
// would otherwise strand a counter with no expiry)
//
// Selection happens once based on REDIS_URL. On any Redis error the limiter
// fails open (allows the request) so a Redis blip never takes the API down.
import type Redis from 'ioredis';
import { getRedis, isRedisEnabled } from '../redis.js';
export interface RateLimitResult {
@@ -24,7 +27,7 @@ interface Bucket {
resetAt: number;
}
class MemoryRateLimiter implements RateLimiter {
export class MemoryRateLimiter implements RateLimiter {
readonly backend = 'memory' as const;
private buckets = new Map<string, Bucket>();
@@ -58,22 +61,44 @@ class MemoryRateLimiter implements RateLimiter {
// ==================== Redis implementation ====================
class RedisRateLimiter implements RateLimiter {
// Atomically increments the window counter, sets the expiry on the first hit,
// and repairs any counter left without a TTL (self-heals keys stranded by the
// pre-Lua implementation or a lost PEXPIRE). Returns {count, ttlMs}.
export const CONSUME_SCRIPT = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('PTTL', KEYS[1])
if ttl < 0 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
ttl = tonumber(ARGV[1])
end
return {count, ttl}
`;
type RedisWithConsume = Redis & {
rlConsume(key: string, windowMs: number): Promise<[number, number]>;
};
function withConsumeCommand(redis: Redis): RedisWithConsume {
if (typeof (redis as any).rlConsume !== 'function') {
// ioredis caches the script SHA and transparently handles NOSCRIPT.
redis.defineCommand('rlConsume', { numberOfKeys: 1, lua: CONSUME_SCRIPT });
}
return redis as RedisWithConsume;
}
export class RedisRateLimiter implements RateLimiter {
readonly backend = 'redis' as const;
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
const redis = getRedis();
if (!redis) return { allowed: true };
const redisKey = `rl:${key}`;
try {
const count = await redis.incr(redisKey);
if (count === 1) {
// First hit in this window: set the expiry that defines the window.
await redis.pexpire(redisKey, windowMs);
}
const [count, ttl] = await withConsumeCommand(redis).rlConsume(`rl:${key}`, windowMs);
if (count > max) {
const ttl = await redis.pttl(redisKey);
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000);
return { allowed: false, retryAfter };
}
+32 -2
View File
@@ -3,6 +3,7 @@ import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubs
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js';
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
const adminRouter = new Hono();
@@ -20,8 +21,9 @@ const csvEscape = (value: string) => {
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
const now = getNow();
// Get upcoming events
const upcomingEvents = await dbAll(
// Get upcoming events with seat counts (paid + claimed, per lib/capacity.ts)
// so the dashboard's capacity alerts reflect real availability.
const upcomingEventsRaw = await dbAll<any>(
(db as any)
.select()
.from(events)
@@ -34,6 +36,24 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
.orderBy((events as any).startDatetime)
.limit(5)
);
const seatRows = await dbAll<any>(eventSeatBreakdownQuery(db));
const seatsByEvent = new Map<string, { paid: number; claimed: number }>();
for (const row of seatRows) {
seatsByEvent.set(row.eventId, {
paid: Number(row.paidCount) || 0,
claimed: Number(row.claimedCount) || 0,
});
}
const upcomingEvents = upcomingEventsRaw.map((event: any) => {
const counts = seatsByEvent.get(event.id) || { paid: 0, claimed: 0 };
return {
...event,
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: Math.max(0, (event.capacity || 0) - counts.paid - counts.claimed),
};
});
// Get recent tickets
const recentTickets = await dbAll(
@@ -70,6 +90,8 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
.where(eq((tickets as any).status, 'confirmed'))
);
// 'pending' = checkout opened, nothing paid or claimed (informational);
// 'pending_approval' = customer says they paid, needs admin verification (actionable).
const pendingPayments = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
@@ -77,6 +99,13 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
.where(eq((payments as any).status, 'pending'))
);
const awaitingApprovalPayments = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(payments)
.where(eq((payments as any).status, 'pending_approval'))
);
const onHoldPayments = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
@@ -115,6 +144,7 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
totalTickets: totalTickets?.count || 0,
confirmedTickets: confirmedTickets?.count || 0,
pendingPayments: pendingPayments?.count || 0,
awaitingApprovalPayments: awaitingApprovalPayments?.count || 0,
onHoldPayments: onHoldPayments?.count || 0,
totalRevenue,
newContacts: newContacts?.count || 0,
+9 -44
View File
@@ -21,6 +21,7 @@ import {
import { generateId, getNow, toDbBool } from '../lib/utils.js';
import { sendEmail } from '../lib/email.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
import { getLoginLockout } from '../lib/stores/loginLockout.js';
// Per-IP rate limit for sensitive auth endpoints (registration, login, and all
// email-dispatching flows) to curb credential stuffing and email flooding.
@@ -36,42 +37,6 @@ type AuthUser = User & {
const auth = new Hono();
// Rate limiting store (in production, use Redis)
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
function checkRateLimit(email: string): { allowed: boolean; retryAfter?: number } {
const now = Date.now();
const attempts = loginAttempts.get(email);
if (!attempts) {
return { allowed: true };
}
if (now > attempts.resetAt) {
loginAttempts.delete(email);
return { allowed: true };
}
if (attempts.count >= MAX_LOGIN_ATTEMPTS) {
return { allowed: false, retryAfter: Math.ceil((attempts.resetAt - now) / 1000) };
}
return { allowed: true };
}
function recordFailedAttempt(email: string): void {
const now = Date.now();
const attempts = loginAttempts.get(email) || { count: 0, resetAt: now + LOCKOUT_DURATION };
attempts.count++;
loginAttempts.set(email, attempts);
}
function clearFailedAttempts(email: string): void {
loginAttempts.delete(email);
}
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(10, 'Password must be at least 10 characters'),
@@ -188,12 +153,12 @@ auth.post('/register', authRateLimit, zValidator('json', registerSchema), async
auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => {
const data = c.req.valid('json');
// Check rate limit
const rateLimit = checkRateLimit(data.email);
if (!rateLimit.allowed) {
return c.json({
// Per-email lockout (shared across instances when Redis is configured).
const lockout = await getLoginLockout().isLocked(data.email);
if (lockout.locked) {
return c.json({
error: 'Too many login attempts. Please try again later.',
retryAfter: rateLimit.retryAfter
retryAfter: lockout.retryAfter
}, 429);
}
@@ -201,7 +166,7 @@ auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) =>
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
if (!user) {
recordFailedAttempt(data.email);
await getLoginLockout().recordFailure(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
@@ -223,12 +188,12 @@ auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) =>
const validPassword = await verifyPassword(data.password, user.password);
if (!validPassword) {
recordFailedAttempt(data.email);
await getLoginLockout().recordFailure(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
// Clear failed attempts on successful login
clearFailedAttempts(data.email);
await getLoginLockout().clear(data.email);
// Transparently upgrade legacy bcrypt hashes to argon2 now that we have the
// plaintext and have verified it. Best-effort: a failure here must not block
+36 -51
View File
@@ -7,6 +7,7 @@ import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
import { slugify, uniqueSlug } from '../lib/slugify.js';
import { revalidateFrontendCache } from '../lib/revalidate.js';
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
interface UserContext {
id: string;
@@ -201,27 +202,28 @@ eventsRouter.get('/', async (c) => {
const result = await dbAll<any>(query.orderBy(desc((events as any).startDatetime)));
// Single grouped query for booked counts across all events (avoids N+1: previously
// this ran one COUNT query per event).
const countRows = await dbAll<any>(
(db as any)
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
.from(tickets)
.where(sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`)
.groupBy((tickets as any).eventId)
);
const countByEvent = new Map<string, number>();
// 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);
// claimedCount = "I've paid" claims awaiting admin verification. Both hold seats,
// so availableSeats subtracts them together — the same formula the booking-creation
// capacity check enforces (lib/capacity.ts).
const countRows = await dbAll<any>(eventSeatBreakdownQuery(db));
const countByEvent = new Map<string, { paid: number; claimed: number }>();
for (const row of countRows) {
countByEvent.set(row.eventId, Number(row.count) || 0);
countByEvent.set(row.eventId, {
paid: Number(row.paidCount) || 0,
claimed: Number(row.claimedCount) || 0,
});
}
const eventsWithCounts = result.map((event: any) => {
const normalized = normalizeEvent(event);
const bookedCount = countByEvent.get(event.id) || 0;
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
return {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
};
});
@@ -246,27 +248,14 @@ eventsRouter.get('/:id', async (c) => {
}
}
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
// This ensures check-in doesn't affect capacity/spots_left
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
)
);
const normalized = normalizeEvent(event);
const bookedCount = ticketCount?.count || 0;
const counts = await getEventSeatCounts(event.id);
return c.json({
event: {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
},
});
});
@@ -278,20 +267,14 @@ async function getSiteTimezone(): Promise<string> {
return settings?.timezone || 'America/Asuncion';
}
// Helper function to get ticket count for an event
async function getEventTicketCount(eventId: string): Promise<number> {
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, eventId),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
)
);
return ticketCount?.count || 0;
// Helper: paid (confirmed/checked_in) and claimed (pending_approval-held) seat
// counts for one event — see lib/capacity.ts for the seat-holding rule.
async function getEventSeatCounts(eventId: string): Promise<{ paid: number; claimed: number }> {
const row = await dbGet<any>(eventSeatBreakdownQuery(db, eventId));
return {
paid: Number(row?.paidCount) || 0,
claimed: Number(row?.claimedCount) || 0,
};
}
// Get the earliest upcoming published event with ticket counts (ignores featured promotion)
@@ -315,12 +298,13 @@ async function getNextChronologicalUpcoming(): Promise<any | null> {
return null;
}
const bookedCount = await getEventTicketCount(event.id);
const counts = await getEventSeatCounts(event.id);
const normalized = normalizeEvent(event);
return {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
};
}
@@ -383,13 +367,14 @@ eventsRouter.get('/next/upcoming', async (c) => {
// If we have a valid featured event, return it
if (featuredEvent) {
const bookedCount = await getEventTicketCount(featuredEvent.id);
const counts = await getEventSeatCounts(featuredEvent.id);
const normalized = normalizeEvent(featuredEvent);
return c.json({
event: {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
isFeatured: true,
},
});
+58 -18
View File
@@ -12,7 +12,7 @@ import {
} from '../lib/lnbits.js';
import emailService from '../lib/email.js';
import { getPubSub } from '../lib/stores/pubsub.js';
import { getLock } from '../lib/stores/lock.js';
import { getLock, LockUnavailableError } from '../lib/stores/lock.js';
const lnbitsRouter = new Hono();
@@ -106,12 +106,26 @@ async function startBackgroundChecker(ticketId: string, paymentHash: string, exp
const expiryMs = expirySeconds * 1000;
const lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
if (!lockToken) {
let lockToken: string | null = null;
let lockUnavailable = false;
try {
lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
} catch (err) {
if (!(err instanceof LockUnavailableError)) throw err;
// Fail open: in webhook-less deployments this poller is the only way a
// Lightning payment gets confirmed, so a Redis outage must not stop it.
// Duplicate pollers across replicas are harmless because
// handlePaymentComplete only acts on rows it actually transitions.
console.warn(`[lnbits] lock backend unavailable, polling ticket ${ticketId} without lock:`, err.message);
lockUnavailable = true;
}
if (!lockToken && !lockUnavailable) {
// Another instance is already polling this ticket.
return;
}
checkerLockTokens.set(ticketId, lockToken);
if (lockToken) {
checkerLockTokens.set(ticketId, lockToken);
}
const startTime = Date.now();
let checkCount = 0;
@@ -270,25 +284,35 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
}
// Confirm all tickets in the booking (idempotent: only flip pending -> confirmed)
let transitioned = 0;
for (const ticket of ticketsToConfirm) {
await (db as any)
const result: any = await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.set({ status: 'confirmed', paymentStatus: 'paid' })
.where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending')));
transitioned += result?.changes ?? result?.rowCount ?? 0;
await (db as any)
.update(payments)
.set({
.set({
status: 'paid',
reference: paymentHash,
paidAt: now,
updatedAt: now,
})
.where(and(eq((payments as any).ticketId, ticket.id), eq((payments as any).status, 'pending')));
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
}
// Only the caller that actually flipped rows sends the emails. The webhook
// and the background poller (and pollers on multiple replicas during a Redis
// outage) can all land here; without this guard they would each email.
if (transitioned === 0) {
console.log(`Ticket ${ticketId} was already confirmed by a concurrent caller, skipping emails`);
return;
}
// Get primary payment for sending receipt
const payment = await dbGet<any>(
(db as any)
@@ -296,7 +320,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
.from(payments)
.where(eq((payments as any).ticketId, ticketId))
);
// Send confirmation emails asynchronously
// For multi-ticket bookings, send email with all ticket info
Promise.all([
@@ -372,8 +396,7 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
}
}, 15000);
// Clean up on disconnect
stream.onAbort(() => {
const cleanup = () => {
clearInterval(heartbeat);
const connections = activeConnections.get(ticketId);
if (connections) {
@@ -388,11 +411,28 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
}
}
}
});
// Keep the stream open
while (true) {
await stream.sleep(30000);
};
// Clean up on disconnect
stream.onAbort(cleanup);
// Keep the stream open, and every 15s fall back to reading the ticket
// status from the DB. Pub/sub is best-effort: a message published while the
// subscriber connection was reconnecting is lost, and without this check
// the client would wait forever on a payment that already confirmed.
try {
while (true) {
await stream.sleep(15000);
const current = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
);
if (current?.status === 'confirmed') {
await sendEvent({ type: 'paid', ticketId });
return;
}
}
} finally {
cleanup();
}
});
});
+151 -53
View File
@@ -19,6 +19,9 @@ const updatePaymentSchema = z.object({
const approvePaymentSchema = z.object({
adminNote: z.string().optional(),
sendEmail: z.boolean().optional().default(true),
// Admin override: confirm the booking even when it puts the event over
// capacity. The UI asks for explicit confirmation before sending this.
allowOverCapacity: z.boolean().optional().default(false),
});
const rejectPaymentSchema = z.object({
@@ -26,6 +29,10 @@ const rejectPaymentSchema = z.object({
sendEmail: z.boolean().optional().default(true),
});
const reopenPaymentSchema = z.object({
adminNote: z.string().optional(),
});
// Get all payments (admin) - with ticket and event details
paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
const status = c.req.query('status');
@@ -232,10 +239,16 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
return c.json({ error: 'Payment not found' }, 404);
}
// Confirming a failed payment must go through /approve, which re-checks event
// capacity before re-reserving the (previously released) seat. Block the raw path.
if (data.status === 'paid' && existing.status === 'failed') {
return c.json({ error: 'Use the approve action to confirm a failed payment' }, 400);
}
const now = getNow();
const updateData: any = { ...data, updatedAt: now };
// If marking as paid, record who approved it and when
if (data.status === 'paid' && existing.status !== 'paid') {
updateData.paidAt = now;
@@ -275,7 +288,7 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.set({ status: 'confirmed', paymentStatus: 'paid' })
.where(eq((tickets as any).id, (t as any).id));
}
@@ -307,27 +320,28 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
// Approve payment (admin) - specifically for pending_approval payments
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
const id = c.req.param('id');
const { adminNote, sendEmail } = c.req.valid('json');
const { adminNote, sendEmail, allowOverCapacity } = c.req.valid('json');
const user = (c as any).get('user');
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
);
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
// Can approve pending, pending_approval, or on_hold payments
if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) {
// Can approve pending, pending_approval, on_hold, or failed payments.
// 'failed' covers an admin confirming a payment that was auto-failed or rejected
// in error; its tickets are cancelled, so recovery re-checks capacity below.
// Bare 'pending' covers customers who paid but never clicked "I've paid".
if (!['pending', 'pending_approval', 'on_hold', 'failed'].includes(payment.status)) {
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
}
const now = getNow();
// Get the ticket associated with this payment
const ticket = await dbGet<any>(
(db as any)
@@ -350,51 +364,54 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
}
if (payment.status === 'on_hold') {
// The seat was released when this booking went on hold - re-check capacity
// before confirming it directly.
try {
await reserveOnHoldBooking(
ticket.eventId,
ticketsToConfirm.map((t: any) => t.id),
'confirmed',
'paid',
{ paidByAdminId: user.id }
);
} catch (err) {
if (err instanceof HoldCapacityError) {
return c.json({
error: 'This event is now full. Your spot was released after the payment deadline passed.',
}, 400);
}
throw err;
// For a failed payment, only recover tickets whose own payment is also failed.
// This protects mixed bookings (e.g. a sibling ticket was refunded) from being
// resurrected or having its payment flipped to paid.
if (payment.status === 'failed') {
const bookingPayments = await dbAll<any>(
(db as any)
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
.from(payments)
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)))
);
const failedTicketIds = new Set(
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
);
ticketsToConfirm = ticketsToConfirm.filter((t: any) => failedTicketIds.has(t.id));
if (ticketsToConfirm.length === 0) {
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
}
if (adminNote) {
await (db as any)
.update(payments)
.set({ adminNote })
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)));
}
} else {
// Update all payments in the booking to paid
for (const t of ticketsToConfirm) {
await (db as any)
.update(payments)
.set({
status: 'paid',
paidAt: now,
paidByAdminId: user.id,
adminNote: adminNote || payment.adminNote,
updatedAt: now,
})
.where(eq((payments as any).ticketId, (t as any).id));
}
// Update ticket status to confirmed
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.where(eq((tickets as any).id, (t as any).id));
// Confirm the booking through the shared capacity-checked reservation.
// Tickets that already hold a seat ('pending_approval' claims) cost no new
// capacity; unseated ones (bare 'pending', on_hold, failed/cancelled) do.
// When the event is full, the admin gets a structured over-capacity error and
// may retry with allowOverCapacity to knowingly overbook.
try {
await reserveOnHoldBooking(
ticket.eventId,
ticketsToConfirm.map((t: any) => t.id),
'confirmed',
'paid',
{
paidByAdminId: user.id,
fromTicketStatuses:
payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold', 'pending'],
skipCapacityCheck: allowOverCapacity,
...(adminNote ? { extraPaymentFields: { adminNote } } : {}),
}
);
} catch (err) {
if (err instanceof HoldCapacityError) {
return c.json({
error: 'Approving this payment puts the event over capacity.',
code: 'EVENT_OVER_CAPACITY',
availableSeats: err.available,
requestedSeats: ticketsToConfirm.length,
}, 409);
}
throw err;
}
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
@@ -548,6 +565,87 @@ paymentsRouter.post('/:id/reactivate', requireAuth(['admin', 'organizer']), asyn
return c.json({ payment: updated, message: 'Booking reactivated and pending admin review' });
});
// Reopen a failed payment back to pending (admin) - re-reserves the seat.
// For when a payment was failed in error (auto-fail or rejection) and should
// return to the normal pending flow (reminders, "I've paid", approve/reject).
paymentsRouter.post('/:id/reopen', requireAuth(['admin', 'organizer']), zValidator('json', reopenPaymentSchema), async (c) => {
const id = c.req.param('id');
const { adminNote } = c.req.valid('json');
const payment = await dbGet<any>(
(db as any).select().from(payments).where(eq((payments as any).id, id))
);
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
if (payment.status !== 'failed') {
return c.json({ error: 'Only failed payments can be reopened' }, 400);
}
const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
let ticketsToReopen: any[] = [ticket];
if (ticket.bookingId) {
ticketsToReopen = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
);
}
// Only reopen tickets whose own payment is also failed (protects mixed bookings).
const bookingPayments = await dbAll<any>(
(db as any)
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
.from(payments)
.where(inArray((payments as any).ticketId, ticketsToReopen.map((t: any) => t.id)))
);
const failedTicketIds = new Set(
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
);
ticketsToReopen = ticketsToReopen.filter((t: any) => failedTicketIds.has(t.id));
if (ticketsToReopen.length === 0) {
return c.json({ error: 'Only failed payments can be reopened' }, 400);
}
const reopenIds = ticketsToReopen.map((t: any) => t.id);
try {
await reserveOnHoldBooking(
ticket.eventId,
reopenIds,
'pending',
'pending',
{ fromTicketStatuses: ['cancelled', 'on_hold'] }
);
} catch (err) {
if (err instanceof HoldCapacityError) {
return c.json({
error: 'This event is now full. Your spot was released after the payment deadline passed.',
}, 400);
}
throw err;
}
if (adminNote) {
await (db as any)
.update(payments)
.set({ adminNote })
.where(inArray((payments as any).ticketId, reopenIds));
}
const updated = await dbGet(
(db as any).select().from(payments).where(eq((payments as any).id, id))
);
return c.json({ payment: updated, message: 'Payment reopened and set to pending' });
});
// Send payment reminder email
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
+119 -215
View File
@@ -10,6 +10,7 @@ import { rateLimitMiddleware } from '../lib/rateLimit.js';
import emailService from '../lib/email.js';
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
import { seatHolderCountQuery } from '../lib/capacity.js';
const ticketsRouter = new Hono();
@@ -31,11 +32,19 @@ const createTicketSchema = z.object({
preferredLanguage: z.enum(['en', 'es']).optional(),
// 'bancard' intentionally excluded: no checkout integration exists for it
paymentMethod: z.enum(['lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
ruc: z.string().regex(/^\d{6,10}$/, 'Invalid RUC format').optional().or(z.literal('')),
// Base + optional "-" + check digit; digits-only kept for older clients, normalized to dashed form on save
ruc: z.string().regex(/^(\d{6,10}|\d{5,8}-\d)$/, 'Invalid RUC format').optional().or(z.literal('')),
// Optional: array of attendees for multi-ticket booking (capped at MAX_TICKETS_PER_BOOKING)
attendees: z.array(attendeeSchema).min(1).max(MAX_TICKETS_PER_BOOKING).optional(),
});
// Canonical stored RUC form is "base-checkdigit" (e.g. 1234567-9); older clients send digits only
function normalizeRuc(ruc: string | undefined): string | null {
if (!ruc) return null;
if (ruc.includes('-')) return ruc;
return `${ruc.slice(0, -1)}-${ruc.slice(-1)}`;
}
// Maps a payment provider to the merged payment-option flag that enables it
function isPaymentMethodEnabled(method: string, merged: Record<string, any>): boolean {
const truthy = (v: any) => v === true || v === 1;
@@ -76,7 +85,8 @@ const adminCreateTicketSchema = z.object({
// Book a ticket (public) - supports single or multi-ticket bookings
ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
const data = c.req.valid('json');
const rucNumber = normalizeRuc(data.ruc);
// Determine attendees list (use attendees array if provided, otherwise single attendee from main fields)
const attendeesList = data.attendees && data.attendees.length > 0
? data.attendees
@@ -119,21 +129,12 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
return c.json({ error: 'Selected payment method is not available for this event' }, 400);
}
// Check capacity - count pending, confirmed AND checked_in tickets.
// Pending reservations must hold seats to prevent overselling via unpaid bookings
// (cancelled/failed tickets are excluded so abandoned/rejected bookings free their seats).
// Check capacity against held seats (paid/checked-in tickets plus claimed
// manual payments) — see lib/capacity.ts. Bare pending bookings hold no seat.
const existingTicketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, data.eventId),
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
)
)
seatHolderCountQuery(db, data.eventId)
);
const confirmedCount = existingTicketCount?.count || 0;
const availableSeats = calculateAvailableSeats(event.capacity, confirmedCount);
@@ -168,19 +169,19 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
phone: data.phone || null,
role: 'user',
languagePreference: null,
rucNumber: data.ruc || null,
rucNumber,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(user);
} else if (data.ruc) {
} else if (rucNumber) {
// Keep the user's saved RUC up to date for future bookings, but never blank
// out an existing value if this booking didn't include one.
await (db as any)
.update(users)
.set({ rucNumber: data.ruc, updatedAt: now })
.set({ rucNumber, updatedAt: now })
.where(eq((users as any).id, user.id));
user.rucNumber = data.ruc;
user.rucNumber = rucNumber;
}
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
@@ -221,16 +222,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
try {
if (isSqlite()) {
(db as any).transaction((tx: any) => {
const countRow = tx
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, data.eventId),
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
)
)
.get();
const countRow = seatHolderCountQuery(tx, data.eventId).get();
const reserved = Number(countRow?.count || 0);
if (isEventSoldOut(event.capacity, reserved)) {
throw new BookingCapacityError('SOLD_OUT');
@@ -253,7 +245,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
attendeeEmail: data.email,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
attendeeRuc: data.ruc || null,
attendeeRuc: rucNumber,
preferredLanguage: data.preferredLanguage || null,
status: 'pending',
qrCode,
@@ -281,17 +273,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
});
} else {
await (db as any).transaction(async (tx: any) => {
const countRow = await dbGet<any>(
tx
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, data.eventId),
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
)
)
);
const countRow = await dbGet<any>(seatHolderCountQuery(tx, data.eventId));
const reserved = Number(countRow?.count || 0);
if (isEventSoldOut(event.capacity, reserved)) {
throw new BookingCapacityError('SOLD_OUT');
@@ -314,7 +296,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
attendeeEmail: data.email,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
attendeeRuc: data.ruc || null,
attendeeRuc: rucNumber,
preferredLanguage: data.preferredLanguage || null,
status: 'pending',
qrCode,
@@ -379,7 +361,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
for (const t of createdTickets) {
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.set({ status: 'confirmed', paymentStatus: 'paid' })
.where(and(eq((tickets as any).id, t.id), eq((tickets as any).status, 'pending')));
await (db as any)
.update(payments)
@@ -1020,6 +1002,9 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as
attendeeEmail: ticket.attendeeEmail,
attendeePhone: ticket.attendeePhone,
status: ticket.status,
paymentStatus: ticket.paymentStatus,
// Balance to collect at the door for unpaid tickets
amountDue: ticket.paymentStatus === 'unpaid' && event ? event.price : 0,
checkinAt: ticket.checkinAt,
checkedInBy,
},
@@ -1100,10 +1085,12 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
return c.json({ error: 'Ticket not found' }, 404);
}
if (ticket.status === 'confirmed') {
// Confirmed/checked-in tickets can still be marked paid when they carry an
// unpaid balance (admin-added unpaid tickets collected at the door)
if (['confirmed', 'checked_in'].includes(ticket.status) && ticket.paymentStatus !== 'unpaid') {
return c.json({ error: 'Ticket already confirmed' }, 400);
}
if (ticket.status === 'cancelled') {
return c.json({ error: 'Cannot confirm cancelled ticket' }, 400);
}
@@ -1143,12 +1130,12 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
throw err;
}
} else {
// Confirm all tickets in the booking
// Confirm all tickets in the booking (checked-in tickets keep their status)
for (const t of ticketsToConfirm) {
// Update ticket status
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.set({ status: t.status === 'checked_in' ? 'checked_in' : 'confirmed', paymentStatus: 'paid' })
.where(eq((tickets as any).id, t.id));
// Update payment status
@@ -1489,6 +1476,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
preferredLanguage: data.preferredLanguage || null,
status: ticketStatus,
paymentStatus: 'paid',
qrCode,
checkinAt: data.autoCheckin ? now : null,
adminNote: data.adminNote || null,
@@ -1532,148 +1520,26 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
}, 201);
});
// Admin create manual ticket (sends confirmation email + ticket to attendee)
ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
eventId: z.string(),
firstName: z.string().min(2),
lastName: z.string().optional().or(z.literal('')),
email: z.string().email('Valid email is required for manual tickets'),
phone: z.string().optional().or(z.literal('')),
preferredLanguage: z.enum(['en', 'es']).optional(),
adminNote: z.string().max(1000).optional(),
})), async (c) => {
const data = c.req.valid('json');
// Get event
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, data.eventId))
);
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Admin manual ticket: bypass capacity check (allow over-capacity for admin-created tickets)
const now = getNow();
const attendeeEmail = data.email.trim();
// Find or create user
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
);
const fullName = data.lastName && data.lastName.trim()
? `${data.firstName} ${data.lastName}`.trim()
: data.firstName;
if (!user) {
const userId = generateId();
user = {
id: userId,
email: attendeeEmail,
password: '',
name: fullName,
phone: data.phone || null,
role: 'user',
languagePreference: null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(user);
}
// Check for existing active ticket for this user and event
const existingTicket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(
and(
eq((tickets as any).userId, user.id),
eq((tickets as any).eventId, data.eventId)
)
)
);
if (existingTicket && existingTicket.status !== 'cancelled') {
return c.json({ error: 'This person already has a ticket for this event' }, 400);
}
// Create ticket as confirmed
const ticketId = generateId();
const qrCode = generateTicketCode();
const newTicket = {
id: ticketId,
userId: user.id,
eventId: data.eventId,
attendeeFirstName: data.firstName,
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
attendeeEmail: attendeeEmail,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
preferredLanguage: data.preferredLanguage || null,
status: 'confirmed',
qrCode,
checkinAt: null,
adminNote: data.adminNote || null,
createdAt: now,
};
await (db as any).insert(tickets).values(newTicket);
// Create payment record (marked as paid - manual entry)
const paymentId = generateId();
const adminUser = (c as any).get('user');
const newPayment = {
id: paymentId,
ticketId,
provider: 'cash',
amount: event.price,
currency: event.currency,
status: 'paid',
reference: 'Manual ticket',
paidAt: now,
paidByAdminId: adminUser?.id || null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(payments).values(newPayment);
// Send booking confirmation email + ticket (asynchronously)
emailService.sendBookingConfirmation(ticketId).then(result => {
if (result.success) {
console.log(`[Email] Booking confirmation sent for manual ticket ${ticketId}`);
} else {
console.error(`[Email] Failed to send booking confirmation for manual ticket ${ticketId}:`, result.error);
}
}).catch(err => {
console.error('[Email] Exception sending booking confirmation for manual ticket:', err);
});
return c.json({
ticket: {
...newTicket,
event: {
title: event.title,
startDatetime: event.startDatetime,
location: event.location,
},
},
payment: newPayment,
message: 'Manual ticket created and confirmation email sent',
}, 201);
});
// Admin invite guest ticket (free, confirmed, not counted in revenue)
ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
// Unified admin add-attendee endpoint backing the single Add Ticket modal.
// type drives payment handling:
// paid — email required; paid cash payment; confirmation email + QR sent
// unpaid — QR issued with balance due (collect at door); pending tpago payment;
// pay-link (Bancard/TPago) email sent when an email is provided
// guest — free comp ticket, not counted in revenue; confirmation email only
// when an email is provided
ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
eventId: z.string(),
type: z.enum(['paid', 'unpaid', 'guest']),
firstName: z.string().min(1),
lastName: z.string().optional().or(z.literal('')),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().optional().or(z.literal('')),
preferredLanguage: z.enum(['en', 'es']).optional(),
checkinNow: z.boolean().optional().default(false),
adminNote: z.string().max(1000).optional(),
}).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), {
message: 'Email is required for paid tickets',
path: ['email'],
})), async (c) => {
const data = c.req.valid('json');
@@ -1684,18 +1550,20 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
return c.json({ error: 'Event not found' }, 404);
}
// Admin-added tickets bypass the capacity check (intentional over-capacity)
const now = getNow();
const adminUser = (c as any).get('user');
// Find or create user (use placeholder email if none provided)
const attendeeEmail = data.email && data.email.trim()
? data.email.trim()
: `guest-${generateId()}@guestinvite.local`;
const hasEmail = !!(data.email && data.email.trim());
const attendeeEmail = hasEmail
? data.email!.trim()
: `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`;
const fullName = data.lastName && data.lastName.trim()
? `${data.firstName} ${data.lastName}`.trim()
: data.firstName;
// Find or create user
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
);
@@ -1716,8 +1584,8 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
await (db as any).insert(users).values(user);
}
// Check for existing active ticket (only for real emails, not placeholder)
if (data.email && data.email.trim()) {
// Check for existing active ticket (only when a real email was provided)
if (hasEmail) {
const existingTicket = await dbGet<any>(
(db as any)
.select()
@@ -1736,6 +1604,7 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
const ticketId = generateId();
const qrCode = generateTicketCode();
const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'paid' ? 'paid' : 'unpaid';
const newTicket = {
id: ticketId,
@@ -1743,50 +1612,85 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
eventId: data.eventId,
attendeeFirstName: data.firstName,
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null,
attendeeEmail: hasEmail ? data.email!.trim() : null,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
preferredLanguage: data.preferredLanguage || null,
status: 'confirmed',
isGuest: 1,
status: data.checkinNow ? 'checked_in' : 'confirmed',
isGuest: data.type === 'guest' ? 1 : 0,
paymentStatus,
qrCode,
checkinAt: null,
checkinAt: data.checkinNow ? now : null,
checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null,
adminNote: data.adminNote || null,
createdAt: now,
};
await (db as any).insert(tickets).values(newTicket);
// Create a $0 payment record to track the invite
// Payment record: paid cash for paid/guest ($0 for guest), pending tpago for unpaid
const paymentId = generateId();
const newPayment = {
id: paymentId,
ticketId,
provider: 'cash',
amount: 0,
currency: event.currency,
status: 'paid',
reference: 'Guest invite',
paidAt: now,
paidByAdminId: adminUser?.id || null,
createdAt: now,
updatedAt: now,
};
const newPayment = data.type === 'unpaid'
? {
id: paymentId,
ticketId,
provider: 'tpago',
amount: event.price,
currency: event.currency,
status: 'pending',
reference: 'Unpaid ticket — collect at door',
paidAt: null,
paidByAdminId: null,
createdAt: now,
updatedAt: now,
}
: {
id: paymentId,
ticketId,
provider: 'cash',
amount: data.type === 'guest' ? 0 : event.price,
currency: event.currency,
status: 'paid',
reference: data.type === 'guest' ? 'Guest invite' : 'Manual ticket',
paidAt: now,
paidByAdminId: adminUser?.id || null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(payments).values(newPayment);
// Send booking confirmation email if a real email was provided
if (data.email && data.email.trim()) {
// Emails (asynchronous): paid always confirms; guest confirms when an email
// exists; unpaid sends the TPago (Bancard) pay-link instructions instead
if (data.type === 'unpaid') {
if (hasEmail) {
emailService.sendPaymentInstructions(ticketId).then(result => {
if (!result.success) {
console.error(`[Email] Failed to send pay link for unpaid ticket ${ticketId}:`, result.error);
}
}).catch(err => {
console.error('[Email] Exception sending pay link for unpaid ticket:', err);
});
}
} else if (data.type === 'paid' || hasEmail) {
emailService.sendBookingConfirmation(ticketId).then(result => {
if (result.success) {
console.log(`[Email] Booking confirmation sent for guest ticket ${ticketId}`);
} else {
console.error(`[Email] Failed to send booking confirmation for guest ticket ${ticketId}:`, result.error);
if (!result.success) {
console.error(`[Email] Failed to send booking confirmation for ${data.type} ticket ${ticketId}:`, result.error);
}
}).catch(err => {
console.error('[Email] Exception sending booking confirmation for guest ticket:', err);
console.error(`[Email] Exception sending booking confirmation for ${data.type} ticket:`, err);
});
}
const messages: Record<string, string> = {
paid: 'Ticket created — confirmation email sent',
unpaid: hasEmail
? 'Unpaid ticket created — payment link sent'
: 'Unpaid ticket created — collect payment at the door',
guest: hasEmail
? 'Guest invited — confirmation email sent'
: 'Guest invited',
};
return c.json({
ticket: {
...newTicket,
@@ -1797,7 +1701,7 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
},
},
payment: newPayment,
message: 'Guest ticket created successfully',
message: data.checkinNow ? `${messages[data.type]} · checked in` : messages[data.type],
}, 201);
});
+1 -1
View File
@@ -12,5 +12,5 @@
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
});
+23
View File
@@ -63,6 +63,29 @@ server {
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
}
# Photo gallery service (photo-api, port 3020). ^~ wins over the /
# prefix below; bigger body cap and unbuffered uploads for photo batches.
location ^~ /api/photos/ {
limit_req zone=spanglish_api_limit burst=50 nodelay;
proxy_pass http://spanglish_photos;
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';
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;
+15 -2
View File
@@ -28,7 +28,20 @@ services:
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
# noeviction is deliberate: rate-limit, lock, and lockout keys must never
# be evicted for correctness. The cache workload is a single short-TTL key,
# so memory pressure is negligible; if caching ever grows, revisit this or
# move the cache to its own DB index.
command:
[
"redis-server",
"--appendonly", "yes",
"--requirepass", "${REDIS_PASSWORD:-change-me-redis-password}",
"--maxmemory", "256mb",
"--maxmemory-policy", "noeviction",
]
environment:
REDISCLI_AUTH: "${REDIS_PASSWORD:-change-me-redis-password}"
volumes:
- redisdata:/data
healthcheck:
@@ -46,7 +59,7 @@ services:
DB_TYPE: postgres
DATABASE_URL: postgresql://spanglish:spanglish@postgres:5432/spanglish
DB_POOL_MAX: "15"
REDIS_URL: redis://redis:6379
REDIS_URL: "redis://:${REDIS_PASSWORD:-change-me-redis-password}@redis:6379"
JWT_SECRET: change-me-to-a-strong-secret
FRONTEND_URL: http://localhost:8080
# Optional S3-compatible storage so uploads are shared across replicas.
+18
View File
@@ -79,6 +79,24 @@ server {
proxy_connect_timeout 300s;
}
# Photo gallery service (photo-api, port 3020). ^~ wins over the /api
# prefix below. Batch photo uploads are large and slow, so the body cap
# is raised and request buffering is off for this location only.
location ^~ /api/photos/ {
proxy_pass http://spanglish_photos;
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;
client_max_body_size 100m;
proxy_request_buffering off;
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
}
# Proxy /api to backend
location /api {
proxy_pass http://spanglish_backend;
+30
View File
@@ -0,0 +1,30 @@
[Unit]
Description=Spanglish Photo Gallery API
Documentation=https://git.azzamo.net/Michilis/Spanglish
After=network.target spanglish-backend.service
[Service]
Type=simple
User=spanglish
Group=spanglish
WorkingDirectory=/home/spanglish/Spanglish/photo-api
EnvironmentFile=/home/spanglish/Spanglish/photo-api/.env
Environment=PORT=3020
# Apply pending photos_* migrations before serving (idempotent, advisory-locked)
ExecStartPre=/home/spanglish/Spanglish/photo-api/bin/photo-api migrate
ExecStart=/home/spanglish/Spanglish/photo-api/bin/photo-api
Restart=on-failure
RestartSec=10
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=spanglish-photos
# Security hardening (mirrors spanglish-backend.service)
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/spanglish/Spanglish/photo-api/data
[Install]
WantedBy=multi-user.target
+4
View File
@@ -4,4 +4,8 @@ upstream spanglish_frontend {
upstream spanglish_backend {
server 127.0.0.1:3018;
}
upstream spanglish_photos {
server 127.0.0.1:3020;
}
+5
View File
@@ -7,6 +7,11 @@ NEXT_PUBLIC_SITE_URL=https://spanglishcommunity.com
# API URL (leave empty for same-origin proxy)
NEXT_PUBLIC_API_URL=
# Photo gallery service (photo-api) origin, used by the /api/photos rewrite
# and server-side rendering of /photos pages.
# Dev default: http://localhost:3003 — production: http://127.0.0.1:3020
PHOTO_API_URL=
# Google OAuth (optional - leave empty to hide Google Sign-In button)
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
# 1. Create a new OAuth 2.0 Client ID (Web application)
+11
View File
@@ -4,6 +4,10 @@
// being hardcoded to localhost.
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
// The standalone photo gallery service (photo-api/). Must be rewritten
// before the generic /api rule below — Next rewrites are order-sensitive.
const PHOTO_API_URL = process.env.PHOTO_API_URL || 'http://localhost:3003';
// Extra image hosts can be allowed via a comma-separated env var (e.g. a CDN).
const extraImageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS || '')
.split(',')
@@ -20,6 +24,9 @@ const securityHeaders = [
];
const nextConfig = {
// Overridable so a production build can run while `next dev` holds .next
// (e.g. NEXT_DIST_DIR=.next-build npm run build).
distDir: process.env.NEXT_DIST_DIR || '.next',
images: {
// Restrict remote image sources to a known allowlist instead of allowing any
// https host (which let the Next image optimizer be used as an open proxy).
@@ -39,6 +46,10 @@ const nextConfig = {
},
async rewrites() {
return [
{
source: '/api/photos/:path*',
destination: `${PHOTO_API_URL}/api/photos/:path*`,
},
{
source: '/api/:path*',
destination: `${BACKEND_URL}/api/:path*`,
@@ -8,11 +8,17 @@ import {
} from '@heroicons/react/24/outline';
import type { PaymentMethod, BookingResult } from '../_types';
export const rucPattern = /^\d{6,10}$/;
// Paraguayan RUC: 5-8 digit base + "-" + 1 check digit (DV), e.g. 1234567-9 or 80012345-0
export const rucPattern = /^\d{5,8}-\d$/;
/** Format RUC input: digits only, max 10. */
/** Sanitize RUC input: digits and a single user-typed dash, max 10 chars. No dash is auto-inserted. */
export function formatRuc(value: string): string {
return value.replace(/\D/g, '').slice(0, 10);
const cleaned = value.replace(/[^\d-]/g, '');
const firstDash = cleaned.indexOf('-');
const oneDash = firstDash === -1
? cleaned
: cleaned.slice(0, firstDash + 1) + cleaned.slice(firstDash + 1).replace(/-/g, '');
return oneDash.slice(0, 10);
}
/** Truncate a long invoice string for display. */
@@ -226,25 +226,10 @@ export function BookingFormStep({
onBlur={handleRucBlur}
placeholder={t('booking.form.rucPlaceholder')}
error={errors.ruc}
inputMode="numeric"
maxLength={10}
aria-label={t('booking.form.ruc')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('booking.form.preferredLanguage')}
</label>
<select
value={formData.preferredLanguage}
onChange={(e) => setFormData({ ...formData, preferredLanguage: e.target.value as 'en' | 'es' })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
>
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
</div>
</Card>
@@ -12,7 +12,6 @@ export interface BookingFormData {
lastName: string;
email: string;
phone: string;
preferredLanguage: 'en' | 'es';
// Empty until the user explicitly picks a method (no default selection).
paymentMethod: PaymentMethod | '';
ruc: string;
@@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
import { formatDateLong, formatTime } from '@/lib/utils';
import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
import { isSafeExternalUrl } from '@/lib/safeRedirect';
import toast from 'react-hot-toast';
import type {
@@ -56,7 +56,6 @@ export default function BookingPage() {
lastName: '',
email: '',
phone: '',
preferredLanguage: locale as 'en' | 'es',
paymentMethod: '',
ruc: '',
});
@@ -78,11 +77,10 @@ export default function BookingPage() {
}
};
// Validate RUC on blur (optional field: 610 digits)
// Validate RUC on blur (optional field: base + "-" + check digit)
const handleRucBlur = () => {
if (!formData.ruc) return;
const digits = formData.ruc.replace(/\D/g, '');
if (digits.length > 0 && !rucPattern.test(digits)) {
if (!rucPattern.test(formData.ruc)) {
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') });
}
};
@@ -110,16 +108,15 @@ export default function BookingPage() {
return;
}
const bookedCount = eventRes.event.bookedCount ?? 0;
const capacity = eventRes.event.capacity ?? 0;
const soldOut = bookedCount >= capacity;
if (soldOut) {
// Server-authoritative availability — same formula the booking API
// enforces, so a sold-out event is caught here, not at submit time.
if (isEventSoldOut(eventRes.event)) {
toast.error(t('events.details.soldOut'));
router.push(`/events/${eventRes.event.slug}`);
return;
}
const spotsLeft = Math.max(0, capacity - bookedCount);
const spotsLeft = eventSpotsLeft(eventRes.event);
setEvent(eventRes.event);
// Cap quantity by available spots (never allow requesting more than spotsLeft)
setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft)));
@@ -152,8 +149,7 @@ export default function BookingPage() {
lastName: prev.lastName || lastName,
email: prev.email || user.email || '',
phone: prev.phone || user.phone || '',
preferredLanguage: (user.languagePreference as 'en' | 'es') || prev.preferredLanguage,
ruc: prev.ruc || user.rucNumber || '',
ruc: prev.ruc || formatRucDisplay(user.rucNumber),
};
});
}
@@ -197,12 +193,9 @@ export default function BookingPage() {
newErrors.phone = t('booking.form.errors.phoneTooShort');
}
// RUC validation (optional field - 610 digits if filled)
if (formData.ruc.trim()) {
const digits = formData.ruc.replace(/\D/g, '');
if (!/^\d{6,10}$/.test(digits)) {
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
}
// RUC validation (optional field - base + "-" + check digit if filled)
if (formData.ruc.trim() && !rucPattern.test(formData.ruc.trim())) {
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
}
// Payment method must be explicitly chosen and currently enabled
@@ -301,9 +294,9 @@ export default function BookingPage() {
lastName: formData.lastName,
email: formData.email,
phone: formData.phone,
preferredLanguage: formData.preferredLanguage,
preferredLanguage: locale as 'en' | 'es',
paymentMethod: formData.paymentMethod as PaymentMethod,
...(formData.ruc.trim() && { ruc: formData.ruc.replace(/\D/g, '') }),
...(formData.ruc.trim() && { ruc: formData.ruc.trim() }),
// Include attendees array for multi-ticket bookings
...(allAttendees.length > 1 && { attendees: allAttendees }),
});
@@ -372,6 +365,23 @@ export default function BookingPage() {
}
} catch (error: any) {
toast.error(error.message || t('booking.form.errors.bookingFailed'));
// Capacity race on the last seats: refresh availability so the page
// reflects reality (sold-out block / lower quantity cap) instead of the
// stale counts loaded when the form was opened.
const message = String(error?.message || '');
if (/sold out|seats available/i.test(message)) {
try {
const { event: freshEvent } = await eventsApi.getById(params.eventId as string);
setEvent(freshEvent);
const freshSpots = eventSpotsLeft(freshEvent);
if (freshSpots > 0) {
setTicketQuantity((q) => Math.min(q, freshSpots));
setAttendees((prev) => prev.slice(0, Math.max(0, freshSpots - 1)));
}
} catch {
// Keep the stale event state if the refresh fails
}
}
} finally {
setSubmitting(false);
}
@@ -394,8 +404,8 @@ export default function BookingPage() {
return null;
}
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
const spotsLeft = eventSpotsLeft(event);
const isSoldOut = isEventSoldOut(event);
// Paying step - waiting for Lightning payment (compact design)
if (step === 'paying' && bookingResult && bookingResult.lightningInvoice) {
@@ -5,7 +5,7 @@ import Link from 'next/link';
import Image from 'next/image';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import ShareButtons from '@/components/ShareButtons';
@@ -43,9 +43,10 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
.catch(console.error);
}, [eventId]);
// Spots left: never negative; sold out when confirmed >= capacity
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
// Server-authoritative availability (paid + claimed seats count; abandoned
// pending bookings don't) — matches the booking API's sold-out check exactly.
const spotsLeft = eventSpotsLeft(event);
const isSoldOut = isEventSoldOut(event);
const maxTickets = isSoldOut ? 0 : Math.min(MAX_TICKETS_PER_PERSON, Math.max(1, spotsLeft));
useEffect(() => {
@@ -0,0 +1,50 @@
import type { Metadata } from 'next';
import { Suspense } from 'react';
import type { PhotoGallery, Photo } from '@/lib/api';
import GalleryClient from '@/app/(public)/photos/[slug]/GalleryClient';
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
interface PageProps {
params: { id: string }; // event slug, same param name as the parent route
}
// Public event galleries render server-side; link/ticket galleries return
// null here and are fetched client-side with the viewer's token.
async function getEventGallery(
eventSlug: string
): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> {
try {
const res = await fetch(
`${photoApiUrl}/api/photos/public/events/${encodeURIComponent(eventSlug)}/gallery`,
{ next: { revalidate: 300 } }
);
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const data = await getEventGallery(params.id);
if (!data) {
return { title: 'Event Photos', robots: { index: false } };
}
return {
title: `${data.gallery.title} Photos`,
description:
data.gallery.description ||
`Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`,
};
}
export default async function EventGalleryPage({ params }: PageProps) {
const initial = await getEventGallery(params.id);
return (
// Suspense boundary required by useSearchParams() in the client child.
<Suspense>
<GalleryClient eventSlug={params.id} initial={initial} />
</Suspense>
);
}
@@ -117,7 +117,10 @@ function generateEventJsonLd(event: Event) {
'@type': 'Offer',
price: event.price,
priceCurrency: event.currency,
availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0
availability:
(typeof event.availableSeats === 'number'
? event.availableSeats
: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0))) > 0
? 'https://schema.org/InStock'
: 'https://schema.org/SoldOut',
url: `${siteUrl}/events/${event.slug}`,
@@ -0,0 +1,72 @@
'use client';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import type { PhotoGallery } from '@/lib/api';
import Card from '@/components/ui/Card';
import { CameraIcon } from '@heroicons/react/24/outline';
export default function PhotosIndexClient({ galleries }: { galleries: PhotoGallery[] }) {
const { locale } = useLanguage();
const es = locale === 'es';
const formatDate = (iso?: string) => {
if (!iso) return null;
return new Date(iso).toLocaleDateString(es ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'long',
timeZone: 'America/Asuncion',
});
};
return (
<div className="section-padding">
<div className="container-page">
<h1 className="section-title text-center mb-2">
{es ? 'Galerías de Fotos' : 'Photo Galleries'}
</h1>
<p className="text-center text-gray-600 mb-10">
{es
? 'Recuerdos de nuestros intercambios de idiomas en Asunción'
: 'Memories from our language exchanges in Asunción'}
</p>
{galleries.length === 0 ? (
<div className="text-center py-16">
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-gray-600">
{es ? 'Aún no hay galerías publicadas.' : 'No galleries published yet.'}
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{galleries.map((g) => (
// Event-linked galleries live under the event's URL.
<Link key={g.id} href={g.event ? `/events/${g.event.slug}/gallery` : `/photos/${g.slug}`}>
<Card variant="elevated" className="overflow-hidden hover:shadow-card-hover transition-shadow h-full">
<div className="aspect-video bg-gray-100 flex items-center justify-center">
{g.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={g.coverUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
) : (
<CameraIcon className="w-12 h-12 text-gray-300" />
)}
</div>
<div className="p-4">
<h2 className="font-heading font-semibold text-lg text-primary-dark">
{es && g.titleEs ? g.titleEs : g.title}
</h2>
<p className="text-sm text-gray-600 mt-1">
{g.photoCount} {es ? 'fotos' : 'photos'}
{g.event?.startDatetime && <> · {formatDate(g.event.startDatetime)}</>}
</p>
</div>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,303 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { photosApi, PhotoGallery, Photo } from '@/lib/api';
import Button from '@/components/ui/Button';
import { ImageGridSkeleton } from '@/components/ui/Skeleton';
import Lightbox from '@/components/Lightbox';
import {
ArrowDownTrayIcon,
CalendarIcon,
CameraIcon,
LockClosedIcon,
TicketIcon,
} from '@heroicons/react/24/outline';
interface GalleryClientProps {
/** Fetch by gallery slug (standalone galleries at /photos/[slug]) … */
slug?: string;
/** … or by event slug (event galleries at /events/[slug]/gallery). */
eventSlug?: string;
initial: { gallery: PhotoGallery; photos: Photo[] } | null;
}
type DeniedState = 'login' | 'ticket' | 'notfound' | null;
export default function GalleryClient({ slug, eventSlug, initial }: GalleryClientProps) {
const { locale } = useLanguage();
const es = locale === 'es';
const { user, isLoading: authLoading } = useAuth();
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const shareToken = searchParams.get('token') || undefined;
const [gallery, setGallery] = useState<PhotoGallery | null>(initial?.gallery ?? null);
const [photos, setPhotos] = useState<Photo[]>(initial?.photos ?? []);
const [loading, setLoading] = useState(!initial);
const [denied, setDenied] = useState<DeniedState>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
// Server-rendered public galleries need no client fetch. Everything else
// (link/ticket/private) is fetched here with the share token and/or the
// viewer's own auth token attached.
useEffect(() => {
if (initial) return;
if (authLoading) return; // wait so the Bearer token is available
let cancelled = false;
setLoading(true);
const fetcher = eventSlug
? photosApi.getEventGallery(eventSlug, shareToken)
: photosApi.getPublicGallery(slug || '', shareToken);
fetcher
.then((data) => {
if (cancelled) return;
setGallery(data.gallery);
setPhotos(data.photos);
setDenied(null);
})
.catch((err: Error) => {
if (cancelled) return;
const msg = err.message || '';
if (msg.includes('Authentication required')) setDenied('login');
else if (msg.includes('attendees')) setDenied('ticket');
else setDenied('notfound');
})
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [slug, eventSlug, shareToken, initial, authLoading, user?.id]);
if (loading || (authLoading && !initial)) {
return (
<div className="section-padding">
<div className="container-page">
<ImageGridSkeleton />
</div>
</div>
);
}
if (denied === 'login') {
const redirect = encodeURIComponent(`${pathname}${shareToken ? `?token=${shareToken}` : ''}`);
return (
<GateMessage
icon={LockClosedIcon}
title={es ? 'Inicia sesión para ver esta galería' : 'Log in to view this gallery'}
body={
es
? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.'
: 'This gallery is for event attendees. Log in with the account you used to book.'
}
action={
<Button onClick={() => router.push(`/login?redirect=${redirect}`)}>
{es ? 'Iniciar sesión' : 'Log in'}
</Button>
}
/>
);
}
if (denied === 'ticket') {
return (
<GateMessage
icon={TicketIcon}
title={es ? 'Solo para asistentes' : 'Attendees only'}
body={
es
? 'Esta galería es para quienes asistieron al evento con una entrada confirmada.'
: 'This gallery is only available to people who attended the event with a confirmed ticket.'
}
action={
eventSlug || gallery?.event ? (
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
</Link>
) : undefined
}
/>
);
}
if (denied === 'notfound' || !gallery) {
return (
<GateMessage
icon={CameraIcon}
title={es ? 'Galería no encontrada' : 'Gallery not found'}
body={
es
? 'Este enlace no existe o ya no está disponible.'
: 'This link does not exist or is no longer available.'
}
action={
<Link href="/photos">
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
</Link>
}
/>
);
}
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
const lightboxItems = readyPhotos.map((p) => ({
id: p.id,
previewUrl: p.urls.preview || p.urls.original,
downloadUrl: p.urls.original,
filename: p.originalFilename,
}));
const title = es && gallery.titleEs ? gallery.titleEs : gallery.title;
const description = es && gallery.descriptionEs ? gallery.descriptionEs : gallery.description;
const eventTitle = gallery.event
? es && gallery.event.titleEs
? gallery.event.titleEs
: gallery.event.title
: null;
const eventDate = gallery.event?.startDatetime
? new Date(gallery.event.startDatetime).toLocaleDateString(es ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'America/Asuncion',
})
: null;
// Hero backdrop: the cover photo's preview when available, else the
// first photo. Falls back to a navy gradient with no image.
const coverPhoto =
readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null;
const heroUrl = coverPhoto ? coverPhoto.urls.preview || coverPhoto.urls.thumb : null;
return (
<div>
{/* Hero */}
<div className="relative bg-brand-navy overflow-hidden">
{heroUrl && (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={heroUrl}
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="relative container-page px-4 pt-20 pb-8 md:pt-32 md:pb-12">
<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="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>
{/* Masonry grid */}
<div className="container-page px-2 sm:px-4 py-4 md:py-8">
{readyPhotos.length === 0 ? (
<div className="text-center py-16">
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-gray-600">
{es ? 'Las fotos estarán disponibles pronto.' : 'Photos will be available soon.'}
</p>
</div>
) : (
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]">
{readyPhotos.map((photo, i) => (
<div
key={photo.id}
role="button"
tabIndex={0}
onClick={() => setLightboxIndex(i)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
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>
)}
{lightboxIndex !== null && (
<Lightbox
items={lightboxItems}
index={lightboxIndex}
onClose={() => setLightboxIndex(null)}
onNavigate={setLightboxIndex}
/>
)}
</div>
</div>
);
}
function GateMessage({
icon: Icon,
title,
body,
action,
}: {
icon: typeof CameraIcon;
title: string;
body: string;
action?: React.ReactNode;
}) {
return (
<div className="section-padding">
<div className="container-page max-w-lg mx-auto text-center py-16">
<Icon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<h1 className="text-2xl font-bold text-primary-dark mb-2">{title}</h1>
<p className="text-gray-600 mb-6">{body}</p>
{action}
</div>
</div>
);
}
@@ -0,0 +1,51 @@
import type { Metadata } from 'next';
import { Suspense } from 'react';
import type { PhotoGallery, Photo } from '@/lib/api';
import GalleryClient from './GalleryClient';
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
interface PageProps {
params: { slug: string };
}
// Public galleries render server-side (SEO + fast first paint); link and
// ticket galleries return null here and are fetched client-side with the
// viewer's token / share token.
async function getPublicGallery(
slug: string
): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> {
try {
const res = await fetch(
`${photoApiUrl}/api/photos/public/galleries/${encodeURIComponent(slug)}`,
{ next: { revalidate: 300 } }
);
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const data = await getPublicGallery(params.slug);
if (!data) {
return { title: 'Photo Gallery', robots: { index: false } };
}
return {
title: `${data.gallery.title} Photos`,
description:
data.gallery.description ||
`Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`,
};
}
export default async function GalleryPage({ params }: PageProps) {
const initial = await getPublicGallery(params.slug);
return (
// Suspense boundary required by useSearchParams() in the client child.
<Suspense>
<GalleryClient slug={params.slug} initial={initial} />
</Suspense>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from 'next';
import type { PhotoGallery } from '@/lib/api';
import PhotosIndexClient from './PhotosIndexClient';
// Server-side calls go straight to the photo-api service; the browser uses
// the same-origin /api/photos path via the Next rewrite / nginx.
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
export const metadata: Metadata = {
title: 'Event Photo Galleries',
description: 'Photos from past Spanglish language exchange events in Asunción.',
};
async function getGalleries(): Promise<PhotoGallery[]> {
try {
const res = await fetch(`${photoApiUrl}/api/photos/public/galleries`, {
next: { revalidate: 300 },
});
if (!res.ok) return [];
const data = await res.json();
return data.galleries || [];
} catch {
return [];
}
}
export default async function PhotosPage() {
const galleries = await getGalleries();
return <PhotosIndexClient galleries={galleries} />;
}
+2 -2
View File
@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import { parseDate, formatRucDisplay } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
@@ -417,7 +417,7 @@ export default function AdminBookingsPage() {
<p className="text-xs text-gray-500 truncate max-w-[200px]">{ticket.attendeeEmail || 'N/A'}</p>
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
</td>
<td className="px-4 py-3 text-sm text-gray-600">{ticket.attendeeRuc || '-'}</td>
<td className="px-4 py-3 text-sm text-gray-600">{formatRucDisplay(ticket.attendeeRuc) || '-'}</td>
<td className="px-4 py-3">
<span className="text-sm truncate max-w-[150px] block">
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
@@ -18,3 +18,23 @@ export function StatusBadge({ status, compact = false }: { status: string; compa
</span>
);
}
// Ticket payment status: Paid (revenue), Unpaid (balance due at door), Comp (free guest).
// Tickets created before the payment_status column default to unpaid via backfill.
export function PaymentBadge({ paymentStatus, compact = false }: { paymentStatus?: string; compact?: boolean }) {
if (!paymentStatus) return null;
const styles: Record<string, string> = {
paid: 'bg-emerald-100 text-emerald-700',
unpaid: 'bg-orange-100 text-orange-700',
comp: 'bg-amber-100 text-amber-700',
};
return (
<span className={clsx(
'inline-flex items-center rounded-full font-medium',
compact ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-0.5 text-xs',
styles[paymentStatus] || 'bg-gray-100 text-gray-800'
)}>
{paymentStatus === 'comp' ? 'Comp' : paymentStatus === 'unpaid' ? 'Unpaid' : 'Paid'}
</span>
);
}
@@ -0,0 +1,209 @@
import type { Dispatch, FormEvent, SetStateAction } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import clsx from 'clsx';
import {
BanknotesIcon,
CheckCircleIcon,
EnvelopeIcon,
LinkIcon,
StarIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import type { AddTicketType, AddTicketFormState } from '../_types';
interface AddTicketModalProps {
open: boolean;
onClose: () => void;
form: AddTicketFormState;
setForm: Dispatch<SetStateAction<AddTicketFormState>>;
onSubmit: (e: FormEvent) => void;
submitting: boolean;
eventPriceLabel: string;
}
const TYPE_OPTIONS: { value: AddTicketType; label: string }[] = [
{ value: 'paid', label: 'Paid' },
{ value: 'unpaid', label: 'Unpaid' },
{ value: 'guest', label: 'Guest' },
];
const SUBMIT_LABELS: Record<AddTicketType, string> = {
paid: 'Create & send ticket',
unpaid: 'Create & send pay link',
guest: 'Invite guest',
};
const SUBMIT_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
paid: EnvelopeIcon,
unpaid: LinkIcon,
guest: StarIcon,
};
// Live "what happens" preview lines for the selected type / email / check-in combo
function previewLines(form: AddTicketFormState, eventPriceLabel: string): string[] {
const hasEmail = !!form.email.trim();
const lines: string[] = [];
if (form.type === 'paid') {
lines.push(`Payment of ${eventPriceLabel} recorded as paid — counts toward revenue`);
lines.push('Confirmation email with QR ticket sent');
} else if (form.type === 'unpaid') {
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(hasEmail
? 'Bancard (TPago) payment link emailed to the attendee'
: 'No email — no pay link sent, payment collected at the door');
} else {
lines.push('Free guest ticket (comp) — not counted in revenue');
lines.push('Auto-confirmed with QR code');
lines.push(hasEmail ? 'Confirmation email sent' : 'No email — nothing is sent');
}
if (form.checkinNow) {
lines.push('Checked in immediately');
}
return lines;
}
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' },
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' },
};
const PREVIEW_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
paid: CheckCircleIcon,
unpaid: BanknotesIcon,
guest: StarIcon,
};
export function AddTicketModal({
open,
onClose,
form,
setForm,
onSubmit,
submitting,
eventPriceLabel,
}: AddTicketModalProps) {
if (!open) return null;
const emailRequired = form.type === 'paid';
const style = PREVIEW_STYLES[form.type];
const PreviewIcon = PREVIEW_ICONS[form.type];
const SubmitIcon = SUBMIT_ICONS[form.type];
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={onClose}
role="presentation"
>
<Card
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<h2 className="text-base font-bold">Add Ticket</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={onSubmit} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
{/* Segmented ticket-type control */}
<div className="flex rounded-btn bg-gray-100 p-1">
{TYPE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setForm((f) => ({ ...f, type: option.value }))}
className={clsx(
'flex-1 px-3 py-2 text-sm font-medium rounded-btn min-h-[36px] transition-colors',
form.type === option.value
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-500 hover:text-gray-700'
)}
>
{option.label}
</button>
))}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={form.firstName}
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"
placeholder="First name" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
<input type="text" value={form.lastName}
onChange={(e) => setForm((f) => ({ ...f, lastName: 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"
placeholder="Last name" />
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Email {emailRequired && '*'}</label>
<input type="email" required={emailRequired} value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: 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"
placeholder={emailRequired ? 'email@example.com' : 'email@example.com (optional)'} />
<p className="text-[10px] text-gray-500 mt-1">
{form.type === 'paid' && 'Ticket will be sent to this email'}
{form.type === 'unpaid' && 'If provided, the payment link is sent here'}
{form.type === 'guest' && 'If provided, a confirmation email will be sent'}
</p>
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: 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"
placeholder="+595 981 123456" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Admin Note</label>
<textarea value={form.adminNote}
onChange={(e) => setForm((f) => ({ ...f, adminNote: 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"
rows={2} placeholder="Internal note..." />
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="checkinNow" checked={form.checkinNow}
onChange={(e) => setForm((f) => ({ ...f, checkinNow: e.target.checked }))}
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
<label htmlFor="checkinNow" className="text-sm font-medium">Check in now</label>
</div>
{/* Live preview of what this submission does */}
<div className={clsx('border rounded-lg p-3', style.box)}>
<div className="flex items-start gap-2">
<PreviewIcon className={clsx('w-4 h-4 mt-0.5 flex-shrink-0', style.icon)} />
<div className={clsx('text-xs', style.text)}>
<p className="font-medium">What happens:</p>
<ul className="list-disc ml-4 mt-0.5 space-y-0.5">
{previewLines(form, eventPriceLabel).map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
<SubmitIcon className="w-4 h-4 mr-1.5" />
{SUBMIT_LABELS[form.type]}
</Button>
</div>
</form>
</Card>
</div>
);
}
@@ -1,17 +1,16 @@
import type { Dispatch, FormEvent, SetStateAction } from 'react';
import { Ticket } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { BottomSheet } from '@/components/admin/MobileComponents';
import clsx from 'clsx';
import {
BanknotesIcon,
CheckCircleIcon,
EnvelopeIcon,
PlusIcon,
StarIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import type { AttendeeStatusFilter, AttendeeFormState, AddAtDoorFormState } from '../_types';
import type { AttendeeStatusFilter, AddTicketType } from '../_types';
interface EventModalsProps {
// counts + filter
@@ -29,6 +28,7 @@ interface EventModalsProps {
// add ticket sheet
showAddTicketSheet: boolean;
setShowAddTicketSheet: (value: boolean) => void;
openAddTicket: (type: AddTicketType) => void;
// export sheets
showExportSheet: boolean;
setShowExportSheet: (value: boolean) => void;
@@ -36,24 +36,6 @@ interface EventModalsProps {
showTicketExportSheet: boolean;
setShowTicketExportSheet: (value: boolean) => void;
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void;
// add at door
showAddAtDoorModal: boolean;
setShowAddAtDoorModal: (value: boolean) => void;
addAtDoorForm: AddAtDoorFormState;
setAddAtDoorForm: Dispatch<SetStateAction<AddAtDoorFormState>>;
handleAddAtDoor: (e: FormEvent) => void;
// manual ticket
showManualTicketModal: boolean;
setShowManualTicketModal: (value: boolean) => void;
manualTicketForm: AttendeeFormState;
setManualTicketForm: Dispatch<SetStateAction<AttendeeFormState>>;
handleManualTicket: (e: FormEvent) => void;
// invite guest
showInviteGuestModal: boolean;
setShowInviteGuestModal: (value: boolean) => void;
inviteGuestForm: AttendeeFormState;
setInviteGuestForm: Dispatch<SetStateAction<AttendeeFormState>>;
handleInviteGuest: (e: FormEvent) => void;
// shared submit flag
submitting: boolean;
// note modal
@@ -83,27 +65,13 @@ export function EventModals(props: EventModalsProps) {
setMobileFilterOpen,
showAddTicketSheet,
setShowAddTicketSheet,
openAddTicket,
showExportSheet,
setShowExportSheet,
handleExportAttendees,
showTicketExportSheet,
setShowTicketExportSheet,
handleExportTickets,
showAddAtDoorModal,
setShowAddAtDoorModal,
addAtDoorForm,
setAddAtDoorForm,
handleAddAtDoor,
showManualTicketModal,
setShowManualTicketModal,
manualTicketForm,
setManualTicketForm,
handleManualTicket,
showInviteGuestModal,
setShowInviteGuestModal,
inviteGuestForm,
setInviteGuestForm,
handleInviteGuest,
submitting,
showNoteModal,
setShowNoteModal,
@@ -156,27 +124,27 @@ export function EventModals(props: EventModalsProps) {
>
<div className="space-y-1">
<button
onClick={() => { setShowManualTicketModal(true); setShowAddTicketSheet(false); }}
onClick={() => { openAddTicket('paid'); 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"
>
<EnvelopeIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Manual Ticket</p>
<p className="text-xs text-gray-500">Send confirmation email with ticket</p>
<p className="font-medium">Paid Ticket</p>
<p className="text-xs text-gray-500">Send confirmation email with QR ticket</p>
</div>
</button>
<button
onClick={() => { setShowAddAtDoorModal(true); 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"
>
<PlusIcon className="w-5 h-5 text-gray-500" />
<BanknotesIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Add at Door</p>
<p className="text-xs text-gray-500">Quick add with optional auto check-in</p>
<p className="font-medium">Unpaid Ticket</p>
<p className="text-xs text-gray-500">Pay link now or collect at the door</p>
</div>
</button>
<button
onClick={() => { setShowInviteGuestModal(true); setShowAddTicketSheet(false); }}
onClick={() => { openAddTicket('guest'); 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"
>
<StarIcon className="w-5 h-5 text-gray-500" />
@@ -237,243 +205,6 @@ export function EventModals(props: EventModalsProps) {
</div>
</BottomSheet>
{/* Add at Door Modal */}
{showAddAtDoorModal && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={() => setShowAddAtDoorModal(false)}
role="presentation"
>
<Card
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<h2 className="text-base font-bold">Add Attendee at Door</h2>
<button
onClick={() => setShowAddAtDoorModal(false)}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleAddAtDoor} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={addAtDoorForm.firstName}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, 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"
placeholder="First name" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
<input type="text" value={addAtDoorForm.lastName}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, lastName: 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"
placeholder="Last name" />
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Email</label>
<input type="email" value={addAtDoorForm.email}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, email: 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"
placeholder="email@example.com" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={addAtDoorForm.phone}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, phone: 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"
placeholder="+595 981 123456" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Admin Note</label>
<textarea value={addAtDoorForm.adminNote}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, adminNote: 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"
rows={2} placeholder="Internal note..." />
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="autoCheckin" checked={addAtDoorForm.autoCheckin}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, autoCheckin: e.target.checked })}
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
<label htmlFor="autoCheckin" className="text-sm font-medium">Auto check-in immediately</label>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setShowAddAtDoorModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">Add Attendee</Button>
</div>
</form>
</Card>
</div>
)}
{/* Manual Ticket Modal */}
{showManualTicketModal && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={() => setShowManualTicketModal(false)}
role="presentation"
>
<Card
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<div>
<h2 className="text-base font-bold">Create Manual Ticket</h2>
<p className="text-xs text-gray-500">Confirmation email will be sent</p>
</div>
<button onClick={() => setShowManualTicketModal(false)}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleManualTicket} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={manualTicketForm.firstName}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, 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"
placeholder="First name" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
<input type="text" value={manualTicketForm.lastName}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, lastName: 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"
placeholder="Last name" />
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Email *</label>
<input type="email" required value={manualTicketForm.email}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, email: 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"
placeholder="email@example.com" />
<p className="text-[10px] text-gray-500 mt-1">Ticket will be sent to this email</p>
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={manualTicketForm.phone}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, phone: 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"
placeholder="+595 981 123456" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Admin Note</label>
<textarea value={manualTicketForm.adminNote}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, adminNote: 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"
rows={2} placeholder="Internal note..." />
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<EnvelopeIcon className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<div className="text-xs text-blue-800">
<p className="font-medium">This will send:</p>
<ul className="list-disc ml-4 mt-0.5 space-y-0.5">
<li>Booking confirmation email</li>
<li>Ticket with QR code</li>
</ul>
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setShowManualTicketModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
<EnvelopeIcon className="w-4 h-4 mr-1.5" />
Create & Send
</Button>
</div>
</form>
</Card>
</div>
)}
{/* Invite Guest Modal */}
{showInviteGuestModal && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={() => setShowInviteGuestModal(false)}
role="presentation"
>
<Card
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<div>
<h2 className="text-base font-bold">Invite Guest</h2>
<p className="text-xs text-gray-500">Free ticket not counted in revenue</p>
</div>
<button onClick={() => setShowInviteGuestModal(false)}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleInviteGuest} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={inviteGuestForm.firstName}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, 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"
placeholder="First name" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
<input type="text" value={inviteGuestForm.lastName}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, lastName: 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"
placeholder="Last name" />
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Email</label>
<input type="email" value={inviteGuestForm.email}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, email: 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"
placeholder="email@example.com (optional)" />
<p className="text-[10px] text-gray-500 mt-1">If provided, a confirmation email will be sent</p>
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={inviteGuestForm.phone}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, phone: 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"
placeholder="+595 981 123456" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Admin Note</label>
<textarea value={inviteGuestForm.adminNote}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, adminNote: 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"
rows={2} placeholder="Internal note..." />
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<StarIcon className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-amber-800">
Guest tickets are <strong>free</strong> and are automatically confirmed. They are not counted toward revenue or paid ticket totals.
</p>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setShowInviteGuestModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
<StarIcon className="w-4 h-4 mr-1.5" />
Invite Guest
</Button>
</div>
</form>
</Card>
</div>
)}
{/* Note Modal */}
{showNoteModal && selectedTicket && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
@@ -14,9 +14,11 @@ import {
FunnelIcon,
ChatBubbleLeftIcon,
ArrowPathIcon,
BanknotesIcon,
CheckCircleIcon,
} from '@heroicons/react/24/outline';
import { StatusBadge } from '../_components/StatusBadge';
import type { AttendeeStatusFilter, PrimaryAction } from '../_types';
import { StatusBadge, PaymentBadge } from '../_components/StatusBadge';
import type { AttendeeStatusFilter, AddTicketType, PrimaryAction } from '../_types';
interface AttendeesTabProps {
locale: string;
@@ -37,15 +39,15 @@ interface AttendeesTabProps {
showAddTicketDropdown: boolean;
setShowAddTicketDropdown: (value: boolean) => void;
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
setShowManualTicketModal: (value: boolean) => void;
setShowAddAtDoorModal: (value: boolean) => void;
setShowInviteGuestModal: (value: boolean) => void;
openAddTicket: (type: AddTicketType) => void;
setMobileFilterOpen: (value: boolean) => void;
setShowExportSheet: (value: boolean) => void;
setShowAddTicketSheet: (value: boolean) => void;
getPrimaryAction: (ticket: Ticket) => PrimaryAction | null;
handleOpenNoteModal: (ticket: Ticket) => void;
handleReactivate: (ticket: Ticket) => void;
handleMarkPaid: (ticketId: string) => void;
handleCheckin: (ticketId: string) => void;
}
export function AttendeesTab({
@@ -67,15 +69,15 @@ export function AttendeesTab({
showAddTicketDropdown,
setShowAddTicketDropdown,
handleExportAttendees,
setShowManualTicketModal,
setShowAddAtDoorModal,
setShowInviteGuestModal,
openAddTicket,
setMobileFilterOpen,
setShowExportSheet,
setShowAddTicketSheet,
getPrimaryAction,
handleOpenNoteModal,
handleReactivate,
handleMarkPaid,
handleCheckin,
}: AttendeesTabProps) {
return (
<div className="space-y-3">
@@ -143,13 +145,13 @@ export function AttendeesTab({
</Button>
}
>
<DropdownItem onClick={() => { setShowManualTicketModal(true); setShowAddTicketDropdown(false); }}>
<EnvelopeIcon className="w-4 h-4 mr-2" /> Manual Ticket
<DropdownItem onClick={() => { openAddTicket('paid'); setShowAddTicketDropdown(false); }}>
<EnvelopeIcon className="w-4 h-4 mr-2" /> Paid Ticket
</DropdownItem>
<DropdownItem onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}>
<PlusIcon className="w-4 h-4 mr-2" /> Add at Door
<DropdownItem onClick={() => { openAddTicket('unpaid'); setShowAddTicketDropdown(false); }}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Unpaid Ticket
</DropdownItem>
<DropdownItem onClick={() => { setShowInviteGuestModal(true); setShowAddTicketDropdown(false); }}>
<DropdownItem onClick={() => { openAddTicket('guest'); setShowAddTicketDropdown(false); }}>
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
</DropdownItem>
</Dropdown>
@@ -252,9 +254,7 @@ export function AttendeesTab({
<td className="px-4 py-2.5">
<div className="flex items-center gap-1 flex-wrap">
<StatusBadge status={ticket.status} compact />
{!!ticket.isGuest && (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
)}
<PaymentBadge paymentStatus={ticket.paymentStatus} compact />
</div>
{ticket.checkinAt && (
<p className="text-[10px] text-gray-400 mt-0.5">
@@ -279,6 +279,16 @@ export function AttendeesTab({
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
</DropdownItem>
)}
{ticket.paymentStatus === 'unpaid' && ticket.status === 'checked_in' && (
<DropdownItem onClick={() => handleMarkPaid(ticket.id)}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Mark as Paid
</DropdownItem>
)}
{ticket.paymentStatus === 'unpaid' && ticket.status === 'confirmed' && (
<DropdownItem onClick={() => handleCheckin(ticket.id)}>
<CheckCircleIcon className="w-4 h-4 mr-2" /> Check In (unpaid)
</DropdownItem>
)}
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
@@ -323,9 +333,7 @@ export function AttendeesTab({
</div>
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
<StatusBadge status={ticket.status} compact />
{!!ticket.isGuest && (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
)}
<PaymentBadge paymentStatus={ticket.paymentStatus} compact />
</div>
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
@@ -346,6 +354,16 @@ export function AttendeesTab({
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
</DropdownItem>
)}
{ticket.paymentStatus === 'unpaid' && ticket.status === 'checked_in' && (
<DropdownItem onClick={() => handleMarkPaid(ticket.id)}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Mark as Paid
</DropdownItem>
)}
{ticket.paymentStatus === 'unpaid' && ticket.status === 'confirmed' && (
<DropdownItem onClick={() => handleCheckin(ticket.id)}>
<CheckCircleIcon className="w-4 h-4 mr-2" /> Check In (unpaid)
</DropdownItem>
)}
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
@@ -1,5 +1,6 @@
import { Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import SensitiveValue from '@/components/admin/SensitiveValue';
import { CalendarIcon, MapPinIcon, CurrencyDollarIcon, UsersIcon } from '@heroicons/react/24/outline';
interface OverviewTabProps {
@@ -48,8 +49,8 @@ export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confir
<UsersIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">Capacity</p>
<p className="text-sm text-gray-600">{confirmedCount + checkedInCount} / {event.capacity} spots filled</p>
<p className="text-xs text-gray-500">{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining</p>
<p className="text-sm text-gray-600"><SensitiveValue>{confirmedCount + checkedInCount} / {event.capacity}</SensitiveValue> spots filled</p>
<p className="text-xs text-gray-500"><SensitiveValue>{Math.max(0, event.capacity - confirmedCount - checkedInCount)}</SensitiveValue> spots remaining</p>
</div>
</div>
</div>
+9 -5
View File
@@ -13,14 +13,18 @@ export interface PrimaryAction {
icon?: ComponentType<{ className?: string }>;
}
export interface AttendeeFormState {
// Ticket type in the unified Add Ticket modal:
// paid = confirmation + QR emailed, counts toward revenue
// unpaid = QR flagged unpaid, balance collected at door, pay link emailed if possible
// guest = free comp ticket, auto-confirmed, no revenue
export type AddTicketType = 'paid' | 'unpaid' | 'guest';
export interface AddTicketFormState {
type: AddTicketType;
firstName: string;
lastName: string;
email: string;
phone: string;
adminNote: string;
}
export interface AddAtDoorFormState extends AttendeeFormState {
autoCheckin: boolean;
checkinNow: boolean;
}
+60 -118
View File
@@ -21,7 +21,6 @@ import {
EnvelopeIcon,
PencilIcon,
EyeIcon,
EyeSlashIcon,
UserGroupIcon,
CreditCardIcon,
ChevronDownIcon,
@@ -30,14 +29,14 @@ import {
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import { useStatsPrivacy } from '@/hooks/useStatsPrivacy';
import { usePrivacy } from '@/context/PrivacyContext';
import type {
TabType,
AttendeeStatusFilter,
TicketStatusFilter,
RecipientFilter,
AttendeeFormState,
AddAtDoorFormState,
AddTicketType,
AddTicketFormState,
PrimaryAction,
} from './_types';
import { formatCurrency, downloadBlob } from './_utils/format';
@@ -49,8 +48,19 @@ import { TicketsTab } from './_tabs/TicketsTab';
import { EmailTab } from './_tabs/EmailTab';
import { PaymentsTab } from './_tabs/PaymentsTab';
import { EventModals } from './_modals/EventModals';
import { AddTicketModal } from './_modals/AddTicketModal';
import EventFormModal from '../_components/EventFormModal';
const EMPTY_ADD_TICKET_FORM: AddTicketFormState = {
type: 'paid',
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
checkinNow: false,
};
export default function AdminEventDetailPage() {
const params = useParams();
const eventId = params.id as string;
@@ -69,37 +79,21 @@ export default function AdminEventDetailPage() {
// Attendees tab state
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<AttendeeStatusFilter>('all');
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
const [showStats, , toggleStats] = useStatsPrivacy();
const { privacyMode } = usePrivacy();
const showStats = !privacyMode;
const [showNoteModal, setShowNoteModal] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [noteText, setNoteText] = useState('');
const [addAtDoorForm, setAddAtDoorForm] = useState<AddAtDoorFormState>({
firstName: '',
lastName: '',
email: '',
phone: '',
autoCheckin: true,
adminNote: '',
});
const [manualTicketForm, setManualTicketForm] = useState<AttendeeFormState>({
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
});
const [showInviteGuestModal, setShowInviteGuestModal] = useState(false);
const [inviteGuestForm, setInviteGuestForm] = useState<AttendeeFormState>({
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
});
// Unified Add Ticket modal (paid / unpaid / guest via segmented control)
const [showAddTicketModal, setShowAddTicketModal] = useState(false);
const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM);
const [submitting, setSubmitting] = useState(false);
const openAddTicket = (type: AddTicketType) => {
setAddTicketForm({ ...EMPTY_ADD_TICKET_FORM, type });
setShowAddTicketModal(true);
};
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
const [showExportDropdown, setShowExportDropdown] = useState(false); // desktop dropdown
const [showExportSheet, setShowExportSheet] = useState(false); // mobile bottom sheet
@@ -220,74 +214,27 @@ export default function AdminEventDetailPage() {
}
};
const handleAddAtDoor = async (e: React.FormEvent) => {
const handleAddTicket = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
await ticketsApi.adminCreate({
const res = await ticketsApi.adminAdd({
eventId: event.id,
firstName: addAtDoorForm.firstName,
lastName: addAtDoorForm.lastName || undefined,
email: addAtDoorForm.email,
phone: addAtDoorForm.phone,
autoCheckin: addAtDoorForm.autoCheckin,
adminNote: addAtDoorForm.adminNote || undefined,
type: addTicketForm.type,
firstName: addTicketForm.firstName,
lastName: addTicketForm.lastName || undefined,
email: addTicketForm.email || undefined,
phone: addTicketForm.phone || undefined,
checkinNow: addTicketForm.checkinNow,
adminNote: addTicketForm.adminNote || undefined,
});
toast.success(addAtDoorForm.autoCheckin ? 'Attendee added and checked in' : 'Attendee added');
setShowAddAtDoorModal(false);
setAddAtDoorForm({ firstName: '', lastName: '', email: '', phone: '', autoCheckin: true, adminNote: '' });
toast.success(res.message || 'Ticket created');
setShowAddTicketModal(false);
setAddTicketForm(EMPTY_ADD_TICKET_FORM);
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to add attendee');
} finally {
setSubmitting(false);
}
};
const handleManualTicket = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
await ticketsApi.manualCreate({
eventId: event.id,
firstName: manualTicketForm.firstName,
lastName: manualTicketForm.lastName || undefined,
email: manualTicketForm.email,
phone: manualTicketForm.phone || undefined,
adminNote: manualTicketForm.adminNote || undefined,
});
toast.success('Manual ticket created — confirmation email sent');
setShowManualTicketModal(false);
setManualTicketForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to create manual ticket');
} finally {
setSubmitting(false);
}
};
const handleInviteGuest = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
await ticketsApi.guestCreate({
eventId: event.id,
firstName: inviteGuestForm.firstName,
lastName: inviteGuestForm.lastName || undefined,
email: inviteGuestForm.email || undefined,
phone: inviteGuestForm.phone || undefined,
adminNote: inviteGuestForm.adminNote || undefined,
});
toast.success('Guest invited successfully');
setShowInviteGuestModal(false);
setInviteGuestForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to invite guest');
toast.error(error.message || 'Failed to add ticket');
} finally {
setSubmitting(false);
}
@@ -449,8 +396,11 @@ export default function AdminEventDetailPage() {
const checkedInCount = getTicketsByStatus('checked_in').length;
const cancelledCount = getTicketsByStatus('cancelled').length;
const onHoldCount = getTicketsByStatus('on_hold').length;
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length;
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length;
// Revenue counts only settled tickets: unpaid (balance due) and comp (guest)
// tickets are excluded; legacy rows without paymentStatus fall back to !isGuest
const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest);
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length;
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length;
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
@@ -467,6 +417,10 @@ export default function AdminEventDetailPage() {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
}
if (ticket.status === 'confirmed') {
// Unpaid tickets resolve their balance first; check-in stays available via scanner
if (ticket.paymentStatus === 'unpaid') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
}
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' };
}
if (ticket.status === 'checked_in') {
@@ -490,10 +444,6 @@ export default function AdminEventDetailPage() {
</div>
{/* Desktop header actions */}
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
<Button variant="outline" size="sm" onClick={toggleStats} title={showStats ? 'Hide stats' : 'Show stats'}>
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-1.5" /> : <EyeIcon className="w-4 h-4 mr-1.5" />}
{showStats ? 'Hide Stats' : 'Show Stats'}
</Button>
<Link href={`/events/${event.slug}`} target="_blank">
<Button variant="outline" size="sm">
<EyeIcon className="w-4 h-4 mr-1.5" />
@@ -522,10 +472,6 @@ export default function AdminEventDetailPage() {
<DropdownItem onClick={() => { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}>
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
</DropdownItem>
<DropdownItem onClick={() => { toggleStats(); setMobileHeaderMenuOpen(false); }}>
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-2" /> : <EyeIcon className="w-4 h-4 mr-2" />}
{showStats ? 'Hide Stats' : 'Show Stats'}
</DropdownItem>
</Dropdown>
</div>
</div>
@@ -704,15 +650,15 @@ export default function AdminEventDetailPage() {
showAddTicketDropdown={showAddTicketDropdown}
setShowAddTicketDropdown={setShowAddTicketDropdown}
handleExportAttendees={handleExportAttendees}
setShowManualTicketModal={setShowManualTicketModal}
setShowAddAtDoorModal={setShowAddAtDoorModal}
setShowInviteGuestModal={setShowInviteGuestModal}
openAddTicket={openAddTicket}
setMobileFilterOpen={setMobileFilterOpen}
setShowExportSheet={setShowExportSheet}
setShowAddTicketSheet={setShowAddTicketSheet}
getPrimaryAction={getPrimaryAction}
handleOpenNoteModal={handleOpenNoteModal}
handleReactivate={handleReactivate}
handleMarkPaid={handleMarkPaid}
handleCheckin={handleCheckin}
/>
)}
@@ -777,27 +723,13 @@ export default function AdminEventDetailPage() {
setMobileFilterOpen={setMobileFilterOpen}
showAddTicketSheet={showAddTicketSheet}
setShowAddTicketSheet={setShowAddTicketSheet}
openAddTicket={openAddTicket}
showExportSheet={showExportSheet}
setShowExportSheet={setShowExportSheet}
handleExportAttendees={handleExportAttendees}
showTicketExportSheet={showTicketExportSheet}
setShowTicketExportSheet={setShowTicketExportSheet}
handleExportTickets={handleExportTickets}
showAddAtDoorModal={showAddAtDoorModal}
setShowAddAtDoorModal={setShowAddAtDoorModal}
addAtDoorForm={addAtDoorForm}
setAddAtDoorForm={setAddAtDoorForm}
handleAddAtDoor={handleAddAtDoor}
showManualTicketModal={showManualTicketModal}
setShowManualTicketModal={setShowManualTicketModal}
manualTicketForm={manualTicketForm}
setManualTicketForm={setManualTicketForm}
handleManualTicket={handleManualTicket}
showInviteGuestModal={showInviteGuestModal}
setShowInviteGuestModal={setShowInviteGuestModal}
inviteGuestForm={inviteGuestForm}
setInviteGuestForm={setInviteGuestForm}
handleInviteGuest={handleInviteGuest}
submitting={submitting}
showNoteModal={showNoteModal}
setShowNoteModal={setShowNoteModal}
@@ -810,6 +742,16 @@ export default function AdminEventDetailPage() {
setPreviewHtml={setPreviewHtml}
/>
<AddTicketModal
open={showAddTicketModal}
onClose={() => setShowAddTicketModal(false)}
form={addTicketForm}
setForm={setAddTicketForm}
onSubmit={handleAddTicket}
submitting={submitting}
eventPriceLabel={event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
/>
<EventFormModal
open={showEditForm}
event={event}
+14 -2
View File
@@ -222,7 +222,14 @@ export default function AdminEventsPage() {
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{formatDate(event.startDatetime)}</td>
<td className="px-4 py-3 text-sm">{event.bookedCount || 0} / {event.capacity}</td>
<td className="px-4 py-3 text-sm">
{(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity}
{(event.claimedCount || 0) > 0 && (
<span className="block text-[11px] text-yellow-600">
{event.claimedCount} pending approval
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
{getStatusBadge(event.status)}
@@ -332,7 +339,12 @@ export default function AdminEventsPage() {
</div>
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-xs text-gray-500">{event.bookedCount || 0} / {event.capacity} spots</p>
<p className="text-xs text-gray-500">
{(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity} spots
{(event.claimedCount || 0) > 0 && (
<span className="text-yellow-600"> · {event.claimedCount} pending</span>
)}
</p>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Link href={`/admin/events/${event.id}`}
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
+33
View File
@@ -6,6 +6,7 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { PrivacyProvider, usePrivacy } from '@/context/PrivacyContext';
import LanguageToggle from '@/components/LanguageToggle';
import Button from '@/components/ui/Button';
import {
@@ -17,6 +18,7 @@ import {
EnvelopeIcon,
InboxIcon,
PhotoIcon,
CameraIcon,
Cog6ToothIcon,
ArrowLeftOnRectangleIcon,
Bars3Icon,
@@ -25,6 +27,8 @@ import {
QrCodeIcon,
DocumentTextIcon,
QuestionMarkCircleIcon,
EyeIcon,
EyeSlashIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useState } from 'react';
@@ -33,11 +37,24 @@ export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PrivacyProvider>
<AdminLayoutInner>{children}</AdminLayoutInner>
</PrivacyProvider>
);
}
function AdminLayoutInner({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const { t, locale } = useLanguage();
const { user, hasAdminAccess, isLoading, logout } = useAuth();
const { privacyMode, togglePrivacyMode } = usePrivacy();
const [sidebarOpen, setSidebarOpen] = useState(false);
type Role = 'admin' | 'organizer' | 'staff' | 'marketing';
@@ -54,6 +71,7 @@ export default function AdminLayout({
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon, allowedRoles: ['admin', 'organizer'] },
{ name: t('admin.nav.photos'), href: '/admin/photos', icon: CameraIcon, allowedRoles: ['admin', 'organizer'] },
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon, allowedRoles: ['admin'] },
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon, allowedRoles: ['admin'] },
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] },
@@ -218,6 +236,21 @@ export default function AdminLayout({
</button>
<div className="flex items-center gap-4 ml-auto">
<Button
variant="outline"
size="sm"
onClick={togglePrivacyMode}
title={privacyMode ? t('admin.privacy.show') : t('admin.privacy.hide')}
>
{privacyMode ? (
<EyeIcon className="w-4 h-4 sm:mr-1.5" />
) : (
<EyeSlashIcon className="w-4 h-4 sm:mr-1.5" />
)}
<span className="hidden sm:inline">
{privacyMode ? t('admin.privacy.show') : t('admin.privacy.hide')}
</span>
</Button>
<LanguageToggle />
<Link href="/">
<Button variant="outline" size="sm">
+48 -20
View File
@@ -4,8 +4,10 @@ import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { usePrivacy } from '@/context/PrivacyContext';
import { adminApi, DashboardData } from '@/lib/api';
import Card from '@/components/ui/Card';
import SensitiveValue from '@/components/admin/SensitiveValue';
import {
UsersIcon,
CalendarIcon,
@@ -15,11 +17,12 @@ import {
UserGroupIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline';
import { parseDate } from '@/lib/utils';
import { parseDate, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
export default function AdminDashboardPage() {
const { t, locale } = useLanguage();
const { user } = useAuth();
const { privacyMode } = usePrivacy();
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
@@ -89,6 +92,7 @@ export default function AdminDashboardPage() {
</div>
{/* Stats Grid */}
{!privacyMode && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{statCards.map((stat) => (
<Link key={stat.label} href={stat.href}>
@@ -106,22 +110,22 @@ export default function AdminDashboardPage() {
</Link>
))}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Alerts */}
<Card className="p-6">
<h2 className="font-semibold text-lg mb-4">Alerts</h2>
<div className="space-y-3">
{/* Low capacity warnings */}
{/* Low capacity warnings (availableSeats accounts for paid + claimed seats) */}
{data?.upcomingEvents
.filter(event => {
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
const percentFull = ((event.bookedCount || 0) / event.capacity) * 100;
return percentFull >= 80 && spotsLeft > 0;
const spotsLeft = eventSpotsLeft(event);
return event.capacity > 0 && spotsLeft > 0 && spotsLeft / event.capacity <= 0.2;
})
.map(event => {
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100);
const spotsLeft = eventSpotsLeft(event);
const percentFull = Math.round(((event.capacity - spotsLeft) / event.capacity) * 100);
return (
<Link
key={event.id}
@@ -132,7 +136,7 @@ export default function AdminDashboardPage() {
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
<div>
<span className="text-sm font-medium">{event.title}</span>
<p className="text-xs text-gray-500">Only {spotsLeft} spots left ({percentFull}% full)</p>
<p className="text-xs text-gray-500"><SensitiveValue>Only {spotsLeft} spots left ({percentFull}% full)</SensitiveValue></p>
</div>
</div>
<span className="badge badge-warning">Low capacity</span>
@@ -142,7 +146,7 @@ export default function AdminDashboardPage() {
{/* Sold out events */}
{data?.upcomingEvents
.filter(event => Math.max(0, event.capacity - (event.bookedCount || 0)) === 0)
.filter(event => isEventSoldOut(event))
.map(event => (
<Link
key={event.id}
@@ -160,16 +164,30 @@ export default function AdminDashboardPage() {
</Link>
))}
{data && data.stats.pendingPayments > 0 && (
<Link
{/* Actionable: customer says they paid, needs verification */}
{data && (data.stats.awaitingApprovalPayments ?? 0) > 0 && (
<Link
href="/admin/payments"
className="flex items-center justify-between p-3 bg-yellow-50 rounded-btn hover:bg-yellow-100 transition-colors"
>
<div className="flex items-center gap-3">
<CurrencyDollarIcon className="w-5 h-5 text-yellow-600" />
<span className="text-sm">Pending payments</span>
<span className="text-sm">Payments awaiting verification</span>
</div>
<span className="badge badge-warning">{data.stats.pendingPayments}</span>
<span className="badge badge-warning"><SensitiveValue>{data.stats.awaitingApprovalPayments}</SensitiveValue></span>
</Link>
)}
{/* Informational: opened checkouts that never paid — hold no seats */}
{data && data.stats.pendingPayments > 0 && (
<Link
href="/admin/payments"
className="flex items-center justify-between p-3 bg-gray-50 rounded-btn hover:bg-gray-100 transition-colors"
>
<div className="flex items-center gap-3">
<CurrencyDollarIcon className="w-5 h-5 text-gray-400" />
<span className="text-sm text-gray-500">Unpaid started bookings</span>
</div>
<span className="badge badge-info"><SensitiveValue>{data.stats.pendingPayments}</SensitiveValue></span>
</Link>
)}
{data && data.stats.newContacts > 0 && (
@@ -186,10 +204,11 @@ export default function AdminDashboardPage() {
)}
{/* No alerts */}
{data &&
data.stats.pendingPayments === 0 &&
data.stats.newContacts === 0 &&
!data.upcomingEvents.some(e => ((e.bookedCount || 0) / e.capacity) >= 0.8) && (
{data &&
data.stats.pendingPayments === 0 &&
(data.stats.awaitingApprovalPayments ?? 0) === 0 &&
data.stats.newContacts === 0 &&
!data.upcomingEvents.some(e => e.capacity > 0 && eventSpotsLeft(e) / e.capacity <= 0.2) && (
<p className="text-gray-500 text-sm text-center py-2">No alerts at this time</p>
)}
</div>
@@ -217,9 +236,16 @@ export default function AdminDashboardPage() {
<p className="font-medium text-sm">{event.title}</p>
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
</div>
<span className="text-sm text-gray-600">
{event.bookedCount || 0}/{event.capacity}
</span>
{!privacyMode && (
<span className="text-sm text-gray-600">
{(event.bookedCount || 0) + (event.claimedCount || 0)}/{event.capacity}
{(event.claimedCount || 0) > 0 && (
<span className="text-xs text-yellow-600 block text-right">
{event.claimedCount} pending approval
</span>
)}
</span>
)}
</Link>
))}
</div>
@@ -227,6 +253,7 @@ export default function AdminDashboardPage() {
</Card>
{/* Quick Stats */}
{!privacyMode && (
<Card className="p-6">
<h2 className="font-semibold text-lg mb-4">Quick Stats</h2>
<div className="grid grid-cols-2 gap-4">
@@ -242,6 +269,7 @@ export default function AdminDashboardPage() {
</div>
</div>
</Card>
)}
</div>
</div>
);
+208 -47
View File
@@ -3,7 +3,8 @@
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import { isManualProvider } from '@/lib/api/payments';
import { parseDate, formatRucDisplay } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
@@ -36,6 +37,9 @@ export default function AdminPaymentsPage() {
const { t, locale } = useLanguage();
const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
const [pendingApprovalPayments, setPendingApprovalPayments] = useState<PaymentWithDetails[]>([]);
// Manual-gateway payments still in bare 'pending': the customer may have paid
// without clicking "I've paid" — approvable directly from the approval tab.
const [unclaimedManualPayments, setUnclaimedManualPayments] = useState<PaymentWithDetails[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
@@ -69,17 +73,19 @@ export default function AdminPaymentsPage() {
const loadData = async () => {
try {
setLoading(true);
const [pendingRes, allRes, eventsRes] = await Promise.all([
const [pendingRes, allRes, unclaimedRes, eventsRes] = await Promise.all([
paymentsApi.getPendingApproval(),
paymentsApi.getAll({
status: statusFilter || undefined,
paymentsApi.getAll({
status: statusFilter || undefined,
provider: providerFilter || undefined,
eventIds: eventFilter.length > 0 ? eventFilter : undefined,
}),
paymentsApi.getAll({ status: 'pending' }),
eventsApi.getAll(),
]);
setPendingApprovalPayments(pendingRes.payments);
setPayments(allRes.payments);
setUnclaimedManualPayments(unclaimedRes.payments.filter(p => isManualProvider(p.provider)));
setEvents(eventsRes.events);
} catch (error) {
toast.error('Failed to load payments');
@@ -88,15 +94,35 @@ export default function AdminPaymentsPage() {
}
};
// Approve with over-capacity confirmation: the backend rejects an approval
// that would overbook the event unless the admin explicitly allows it.
const approveWithCapacityConfirm = async (id: string, note?: string, email?: boolean) => {
try {
await paymentsApi.approve(id, note, email);
} catch (error: any) {
if (error?.code !== 'EVENT_OVER_CAPACITY') throw error;
const seatsLeft = error?.data?.availableSeats ?? 0;
const requested = error?.data?.requestedSeats ?? 1;
const message = locale === 'es'
? `El evento está lleno (quedan ${seatsLeft} lugares, esta reserva necesita ${requested}). ¿Aprobar de todas formas y sobrevender?`
: `This event is full (${seatsLeft} seat(s) left, this booking needs ${requested}). Approve anyway and overbook?`;
if (!confirm(message)) return false;
await paymentsApi.approve(id, note, email, true);
}
return true;
};
const handleApprove = async (payment: PaymentWithDetails) => {
setProcessing(true);
try {
await paymentsApi.approve(payment.id, noteText, sendEmail);
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
setSelectedPayment(null);
setNoteText('');
setSendEmail(true);
loadData();
const approved = await approveWithCapacityConfirm(payment.id, noteText, sendEmail);
if (approved) {
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
setSelectedPayment(null);
setNoteText('');
setSendEmail(true);
loadData();
}
} catch (error: any) {
toast.error(error.message || 'Failed to approve payment');
} finally {
@@ -140,11 +166,13 @@ export default function AdminPaymentsPage() {
const handleConfirmPayment = async (id: string) => {
try {
await paymentsApi.approve(id);
toast.success('Payment confirmed');
loadData();
} catch (error) {
toast.error('Failed to confirm payment');
const approved = await approveWithCapacityConfirm(id);
if (approved) {
toast.success('Payment confirmed');
loadData();
}
} catch (error: any) {
toast.error(error.message || 'Failed to confirm payment');
}
};
@@ -162,6 +190,22 @@ export default function AdminPaymentsPage() {
}
};
const handleReopen = async (payment: PaymentWithDetails) => {
setProcessing(true);
try {
await paymentsApi.reopen(payment.id, noteText);
toast.success(locale === 'es' ? 'Pago cambiado a pendiente' : 'Payment changed to pending');
setSelectedPayment(null);
setNoteText('');
setSendEmail(true);
loadData();
} catch (error: any) {
toast.error(error.message || (locale === 'es' ? 'No se pudo reabrir el pago' : 'Failed to reopen payment'));
} finally {
setProcessing(false);
}
};
const handleRefund = async (id: string) => {
if (!confirm('Are you sure you want to process this refund?')) return;
@@ -205,7 +249,7 @@ export default function AdminPaymentsPage() {
p.createdAt,
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
p.attendeeEmail || '',
p.attendeeRuc || '',
formatRucDisplay(p.attendeeRuc) || '',
p.eventTitle,
p.eventDate,
]);
@@ -282,6 +326,33 @@ export default function AdminPaymentsPage() {
return labels[provider] || provider;
};
// Manual gateways need admin verification; automatic ones confirm themselves.
const getProviderKindBadge = (provider: string) => (
isManualProvider(provider) ? (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-orange-50 text-orange-600">
{locale === 'es' ? 'Manual' : 'Manual'}
</span>
) : (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-blue-50 text-blue-600">
{locale === 'es' ? 'Automático' : 'Auto'}
</span>
)
);
// Age of a claim/booking, e.g. "3h" / "2d"; used to surface rotting approvals.
const getAgeInfo = (dateStr?: string | null) => {
if (!dateStr) return null;
const ms = Date.now() - parseDate(dateStr).getTime();
if (ms < 0) return null;
const hours = Math.floor(ms / (60 * 60 * 1000));
const label = hours < 1
? (locale === 'es' ? 'hace <1 h' : '<1h ago')
: hours < 48
? (locale === 'es' ? `hace ${hours} h` : `${hours}h ago`)
: (locale === 'es' ? `hace ${Math.floor(hours / 24)} días` : `${Math.floor(hours / 24)}d ago`);
return { hours, label, stale: hours >= 48 };
};
// Helper to get booking info for a payment (ticket count and total)
const getBookingInfo = (payment: PaymentWithDetails) => {
if (!payment.ticket?.bookingId) {
@@ -315,6 +386,22 @@ export default function AdminPaymentsPage() {
});
})();
// Manual payments never claimed by the customer — they may have paid and
// forgotten to press "I've paid", so they stay directly approvable here.
// Hidden once the event has ended (same rule as pending approvals above).
const visibleUnclaimedManualPayments = (() => {
const now = new Date();
return unclaimedManualPayments.filter((payment) => {
const eventId = payment.event?.id;
const fullEvent = eventId ? events.find((e) => e.id === eventId) : undefined;
const endIso = fullEvent?.endDatetime
|| fullEvent?.startDatetime
|| payment.event?.startDatetime;
if (!endIso) return true;
return parseDate(endIso).getTime() >= now.getTime();
});
})();
// Get booking info for pending approval payments
const getPendingBookingInfo = (payment: PaymentWithDetails) => {
if (!payment.ticket?.bookingId) {
@@ -332,9 +419,15 @@ export default function AdminPaymentsPage() {
};
};
// Calculate totals (sum all individual payment amounts)
const totalPending = payments
.filter(p => p.status === 'pending' || p.status === 'pending_approval')
// Calculate totals (sum all individual payment amounts).
// Claimed ('pending_approval') money is probably already in the account and
// just needs verification; bare 'pending' money may never arrive — keep the
// two apart so the totals don't overstate what's owed.
const totalAwaitingVerification = payments
.filter(p => p.status === 'pending_approval')
.reduce((sum, p) => sum + Number(p.amount), 0);
const totalUnclaimed = payments
.filter(p => p.status === 'pending')
.reduce((sum, p) => sum + Number(p.amount), 0);
const totalPaid = payments
.filter(p => p.status === 'paid')
@@ -354,9 +447,6 @@ export default function AdminPaymentsPage() {
return count;
};
const pendingBookingsCount = getUniqueBookingsCount(
payments.filter(p => p.status === 'pending' || p.status === 'pending_approval')
);
const paidBookingsCount = getUniqueBookingsCount(
payments.filter(p => p.status === 'paid')
);
@@ -431,7 +521,7 @@ export default function AdminPaymentsPage() {
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeePhone}</p>
)}
{selectedPayment.ticket.attendeeRuc && (
<p className="text-sm text-gray-600">RUC: {selectedPayment.ticket.attendeeRuc}</p>
<p className="text-sm text-gray-600">RUC: {formatRucDisplay(selectedPayment.ticket.attendeeRuc)}</p>
)}
</div>
)}
@@ -503,19 +593,32 @@ export default function AdminPaymentsPage() {
</div>
)}
<div className="flex gap-3">
<Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
<CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Aprobar' : 'Approve'}
</Button>
<Button variant="outline" onClick={() => handleReject(selectedPayment)} isLoading={processing}
className="flex-1 border-red-300 text-red-600 hover:bg-red-50 min-h-[44px]">
<XCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Rechazar' : 'Reject'}
</Button>
</div>
{selectedPayment.status === 'failed' ? (
<div className="flex gap-3">
<Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
<CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Confirmar pago' : 'Confirm payment'}
</Button>
<Button variant="outline" onClick={() => handleReopen(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
<ArrowPathIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Cambiar a pendiente' : 'Change to pending'}
</Button>
</div>
) : (
<div className="flex gap-3">
<Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
<CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Aprobar' : 'Approve'}
</Button>
<Button variant="outline" onClick={() => handleReject(selectedPayment)} isLoading={processing}
className="flex-1 border-red-300 text-red-600 hover:bg-red-50 min-h-[44px]">
<XCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Rechazar' : 'Reject'}
</Button>
</div>
)}
{selectedPayment.status !== 'on_hold' && (
{!['on_hold', 'failed'].includes(selectedPayment.status) && (
<div className="pt-2 border-t">
<Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]">
<EnvelopeIcon className="w-5 h-5 mr-2" />
@@ -692,9 +795,12 @@ export default function AdminPaymentsPage() {
<ClockIcon className="w-5 h-5 text-gray-600" />
</div>
<div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
<p className="text-xl font-bold">{formatCurrency(totalPending, 'PYG')}</p>
<p className="text-xs text-gray-400">{pendingBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}</p>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Por Verificar' : 'Awaiting Verification'}</p>
<p className="text-xl font-bold text-yellow-600">{formatCurrency(totalAwaitingVerification, 'PYG')}</p>
<p className="text-xs text-gray-400">
{locale === 'es' ? 'Sin pagar (sin reclamar): ' : 'Unpaid (unclaimed): '}
{formatCurrency(totalUnclaimed, 'PYG')}
</p>
</div>
</div>
</Card>
@@ -788,13 +894,18 @@ export default function AdminPaymentsPage() {
<span className="flex items-center gap-1">
{getProviderIcon(payment.provider)}
{getProviderLabel(payment.provider)}
{getProviderKindBadge(payment.provider)}
</span>
{payment.userMarkedPaidAt && (
<span className="flex items-center gap-1">
<ClockIcon className="w-3 h-3" />
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
</span>
)}
{payment.userMarkedPaidAt && (() => {
const age = getAgeInfo(payment.userMarkedPaidAt);
return (
<span className={clsx('flex items-center gap-1', age?.stale && 'text-amber-600 font-medium')}>
<ClockIcon className="w-3 h-3" />
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
{age && <span>({age.label})</span>}
</span>
);
})()}
</div>
{payment.payerName && (
<p className="text-xs text-amber-600 mt-1 font-medium">
@@ -812,6 +923,55 @@ export default function AdminPaymentsPage() {
})}
</div>
)}
{/* Manual payments the customer never confirmed they may have paid
(bank transfer / TPago received) without pressing "I've paid".
Approving one claims a seat, so it goes through the same
over-capacity confirmation as any approval. */}
{visibleUnclaimedManualPayments.length > 0 && (
<details className="mt-8">
<summary className="cursor-pointer text-sm font-medium text-gray-600 select-none">
{locale === 'es'
? `Pagos manuales sin confirmar por el cliente (${visibleUnclaimedManualPayments.length})`
: `Manual payments not yet confirmed by customer (${visibleUnclaimedManualPayments.length})`}
<span className="block text-xs font-normal text-gray-400 mt-0.5">
{locale === 'es'
? 'Puede que hayan pagado sin presionar "Ya pagué". No reservan lugar hasta ser aprobados.'
: 'They may have paid without pressing "I\'ve paid". These hold no seat until approved.'}
</span>
</summary>
<div className="space-y-3 mt-4">
{visibleUnclaimedManualPayments.map((payment) => {
const age = getAgeInfo(payment.createdAt);
return (
<Card key={payment.id} className="p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
{getProviderIcon(payment.provider)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{payment.ticket?.attendeeFirstName} {payment.ticket?.attendeeLastName}
<span className="text-gray-400 font-normal"> · {formatCurrency(payment.amount, payment.currency)}</span>
</p>
<p className="text-xs text-gray-400 truncate">
{payment.event?.title}
{' · '}{getProviderLabel(payment.provider)}
{age && <span> · {age.label}</span>}
</p>
</div>
</div>
<Button onClick={() => setSelectedPayment(payment)} size="sm" variant="outline" className="flex-shrink-0 min-h-[40px]">
{locale === 'es' ? 'Revisar' : 'Review'}
</Button>
</div>
</Card>
);
})}
</div>
</details>
)}
</>
)}
@@ -975,12 +1135,13 @@ export default function AdminPaymentsPage() {
<td className="px-4 py-3">
<div className="flex items-center gap-1.5 text-xs text-gray-600">
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}
{getProviderKindBadge(payment.provider)}
</div>
</td>
<td className="px-4 py-3">{getStatusBadge(payment.status)}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
{(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold') && (
{(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold' || payment.status === 'failed') && (
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2 py-1">
{locale === 'es' ? 'Revisar' : 'Review'}
</Button>
@@ -1034,7 +1195,7 @@ export default function AdminPaymentsPage() {
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
<span className="font-medium text-gray-700">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</span>
<span className="text-gray-300">|</span>
<span className="flex items-center gap-1">{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}</span>
<span className="flex items-center gap-1">{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)} {getProviderKindBadge(payment.provider)}</span>
{bookingInfo.ticketCount > 1 && (
<><span className="text-gray-300">|</span><span className="text-purple-600">{bookingInfo.ticketCount} tickets</span></>
)}
@@ -1042,7 +1203,7 @@ export default function AdminPaymentsPage() {
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">{formatDate(payment.createdAt)}</p>
<div className="flex items-center gap-1">
{(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold') && (
{(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold' || payment.status === 'failed') && (
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
{locale === 'es' ? 'Revisar' : 'Review'}
</Button>
@@ -0,0 +1,32 @@
'use client';
import { useLanguage } from '@/context/LanguageContext';
import type { GalleryVisibility } from '@/lib/api';
import {
GlobeAltIcon,
LockClosedIcon,
LinkIcon,
TicketIcon,
} from '@heroicons/react/24/outline';
const visibilityMeta: Record<
GalleryVisibility,
{ icon: typeof GlobeAltIcon; className: string; en: string; es: string }
> = {
public: { icon: GlobeAltIcon, className: 'bg-green-100 text-green-700', en: 'Public', es: 'Pública' },
private: { icon: LockClosedIcon, className: 'bg-gray-200 text-gray-700', en: 'Private', es: 'Privada' },
link: { icon: LinkIcon, className: 'bg-blue-100 text-blue-700', en: 'Link only', es: 'Solo enlace' },
ticket: { icon: TicketIcon, className: 'bg-yellow-100 text-yellow-800', en: 'Ticket holders', es: 'Con entrada' },
};
export default function VisibilityBadge({ visibility }: { visibility: GalleryVisibility }) {
const { locale } = useLanguage();
const meta = visibilityMeta[visibility] || visibilityMeta.private;
const Icon = meta.icon;
return (
<span className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded ${meta.className}`}>
<Icon className="w-3.5 h-3.5" />
{locale === 'es' ? meta.es : meta.en}
</span>
);
}
+617
View File
@@ -0,0 +1,617 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { photosApi, eventsApi, PhotoGallery, Photo, Event, GalleryVisibility } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton';
import Lightbox from '@/components/Lightbox';
import VisibilityBadge from '../VisibilityBadge';
import {
ArrowLeftIcon,
ArrowUpTrayIcon,
ArrowPathIcon,
CheckIcon,
CheckCircleIcon,
ChevronDownIcon,
ChevronUpIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
LinkIcon,
PhotoIcon,
StarIcon,
TrashIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
import toast from 'react-hot-toast';
interface UploadItem {
key: string;
name: string;
sizeBytes: number;
progress: number; // 0..1 while uploading
status: 'queued' | 'uploading' | 'processing' | 'error';
error?: string;
photoId?: string;
}
function formatBytes(n: number) {
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
if (n >= 1 << 10) return `${Math.round(n / (1 << 10))} KB`;
return `${n} B`;
}
export default function AdminGalleryDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const { locale } = useLanguage();
const es = locale === 'es';
const [gallery, setGallery] = useState<PhotoGallery | null>(null);
const [photos, setPhotos] = useState<Photo[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
const [dragId, setDragId] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Uploader panel state (Google Drive style: one row per file).
const [uploads, setUploads] = useState<UploadItem[]>([]);
const [panelCollapsed, setPanelCollapsed] = useState(false);
const uploadQueue = useRef<{ key: string; file: File }[]>([]);
const pumpRunning = useRef(false);
const load = useCallback(async () => {
try {
const [detail, ev] = await Promise.all([photosApi.getGallery(id), eventsApi.getAll()]);
setGallery(detail.gallery);
setPhotos(detail.photos);
setEvents(ev.events);
} catch {
toast.error(es ? 'No se pudo cargar la galería' : 'Failed to load gallery');
router.push('/admin/photos');
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
useEffect(() => {
load();
}, [load]);
// Poll while any photo is still processing so thumbnails (and the
// uploader panel rows) update as the worker finishes them.
const processingCount = photos.filter((p) => p.status === 'queued' || p.status === 'processing').length;
useEffect(() => {
if (processingCount === 0) return;
const timer = setInterval(async () => {
try {
const detail = await photosApi.getGallery(id);
setGallery(detail.gallery);
setPhotos(detail.photos);
} catch {
/* transient; next tick retries */
}
}, 3000);
return () => clearInterval(timer);
}, [processingCount, id]);
const patchUpload = (key: string, patch: Partial<UploadItem>) => {
setUploads((prev) => prev.map((u) => (u.key === key ? { ...u, ...patch } : u)));
};
// Sequential upload pump: one file at a time, byte progress per file.
const pump = useCallback(async () => {
if (pumpRunning.current) return;
pumpRunning.current = true;
while (uploadQueue.current.length > 0) {
const { key, file } = uploadQueue.current.shift()!;
patchUpload(key, { status: 'uploading', progress: 0 });
try {
const { photos: added } = await photosApi.uploadPhotoWithProgress(id, file, (fraction) =>
patchUpload(key, { progress: fraction })
);
setPhotos((prev) => [...prev, ...added]);
patchUpload(key, { status: 'processing', progress: 1, photoId: added[0]?.id });
} catch (err) {
patchUpload(key, {
status: 'error',
error: err instanceof Error ? err.message : 'Upload failed',
});
}
}
pumpRunning.current = false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const handleUpload = (files: FileList | File[]) => {
const list = Array.from(files);
if (list.length === 0) return;
const items: UploadItem[] = list.map((file) => ({
key: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: file.name,
sizeBytes: file.size,
progress: 0,
status: 'queued',
}));
uploadQueue.current.push(...items.map((item, i) => ({ key: item.key, file: list[i] })));
setUploads((prev) => [...prev, ...items]);
setPanelCollapsed(false);
pump();
if (fileInputRef.current) fileInputRef.current.value = '';
};
// A row is "done" once its photo finished processing; the panel derives
// this from the photos list instead of tracking it separately.
const displayStatus = (u: UploadItem): { state: string; error?: string } => {
if (u.status === 'processing' && u.photoId) {
const photo = photos.find((p) => p.id === u.photoId);
if (photo?.status === 'ready') return { state: 'done' };
if (photo?.status === 'failed') return { state: 'error', error: photo.lastError || 'Processing failed' };
}
return { state: u.status, error: u.error };
};
const activeUploads = uploads.filter((u) => {
const s = displayStatus(u).state;
return s === 'queued' || s === 'uploading' || s === 'processing';
}).length;
const saveField = async (patch: Parameters<typeof photosApi.updateGallery>[1], okMsg?: string) => {
try {
const { gallery: updated } = await photosApi.updateGallery(id, patch);
setGallery(updated);
if (okMsg) toast.success(okMsg);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Update failed');
}
};
const copyShareLink = async () => {
if (!gallery?.shareUrl) return;
try {
await navigator.clipboard.writeText(gallery.shareUrl);
setCopied(true);
toast.success(es ? 'Enlace copiado' : 'Share link copied');
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error(es ? 'No se pudo copiar' : 'Failed to copy');
}
};
const rotateToken = async () => {
if (!confirm(es ? '¿Invalidar el enlace actual y generar uno nuevo?' : 'Invalidate the current link and generate a new one?'))
return;
try {
const { gallery: updated } = await photosApi.rotateShareToken(id);
setGallery(updated);
toast.success(es ? 'Nuevo enlace generado' : 'New share link generated');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed');
}
};
const deletePhoto = async (photoId: string): Promise<boolean> => {
if (!confirm(es ? '¿Eliminar esta foto?' : 'Delete this photo?')) return false;
try {
await photosApi.deletePhoto(photoId);
setPhotos((prev) => prev.filter((p) => p.id !== photoId));
toast.success(es ? 'Foto eliminada' : 'Photo deleted');
return true;
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete');
return false;
}
};
const retryPhoto = async (photoId: string) => {
try {
const { photo } = await photosApi.retryPhoto(photoId);
setPhotos((prev) => prev.map((p) => (p.id === photoId ? photo : p)));
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to retry');
}
};
const deleteGallery = async () => {
if (!confirm(es ? '¿Eliminar toda la galería y sus fotos?' : 'Delete the whole gallery and its photos?')) return;
try {
await photosApi.deleteGallery(id);
toast.success(es ? 'Galería eliminada' : 'Gallery deleted');
router.push('/admin/photos');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete');
}
};
// HTML5 drag-and-drop reorder; persisted on drop.
const onDropReorder = async (targetId: string) => {
if (!dragId || dragId === targetId) return;
const ids = photos.map((p) => p.id);
const from = ids.indexOf(dragId);
const to = ids.indexOf(targetId);
if (from < 0 || to < 0) return;
ids.splice(to, 0, ids.splice(from, 1)[0]);
const reordered = ids
.map((pid) => photos.find((p) => p.id === pid))
.filter((p): p is Photo => !!p);
setPhotos(reordered);
setDragId(null);
try {
await photosApi.reorderPhotos(id, ids);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to save order');
load();
}
};
if (loading || !gallery) {
return (
<div>
<Skeleton className="h-8 w-64 mb-6" />
<ImageGridSkeleton />
</div>
);
}
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
const lightboxItems = readyPhotos.map((p) => ({
id: p.id,
previewUrl: p.urls.preview || p.urls.original,
downloadUrl: p.urls.original,
filename: p.originalFilename,
}));
const openLightboxFor = (photoId: string) => {
const idx = readyPhotos.findIndex((p) => p.id === photoId);
if (idx >= 0) setLightboxIndex(idx);
};
return (
<div
onDragOver={(e) => {
if (e.dataTransfer.types.includes('Files')) e.preventDefault();
}}
onDrop={(e) => {
if (e.dataTransfer.files.length > 0) {
e.preventDefault();
handleUpload(e.dataTransfer.files);
}
}}
>
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-3 min-w-0">
<Link href="/admin/photos" className="text-gray-400 hover:text-gray-600">
<ArrowLeftIcon className="w-6 h-6" />
</Link>
<h1 className="text-2xl font-bold text-primary-dark truncate">
{es && gallery.titleEs ? gallery.titleEs : gallery.title}
</h1>
<VisibilityBadge visibility={gallery.visibility} />
</div>
<div className="flex items-center gap-2">
<Button onClick={() => fileInputRef.current?.click()}>
<ArrowUpTrayIcon className="w-5 h-5 mr-2" />
{es ? 'Subir fotos' : 'Upload photos'}
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,image/heic,.heic"
multiple
onChange={(e) => e.target.files && handleUpload(e.target.files)}
className="hidden"
/>
</div>
</div>
{/* Settings */}
<Card className="p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-1">{es ? 'Visibilidad' : 'Visibility'}</label>
<select
value={gallery.visibility}
onChange={(e) =>
saveField(
{ visibility: e.target.value as GalleryVisibility },
es ? 'Visibilidad actualizada' : 'Visibility updated'
)
}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="private">{es ? 'Privada (solo admins)' : 'Private (admins only)'}</option>
<option value="public">{es ? 'Pública (listada)' : 'Public (listed)'}</option>
<option value="link">{es ? 'Solo con enlace' : 'Link only'}</option>
<option value="ticket">{es ? 'Con entrada del evento' : 'Ticket holders of the event'}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">{es ? 'Evento vinculado' : 'Linked event'}</label>
<select
value={gallery.eventId || ''}
onChange={(e) =>
saveField({ eventId: e.target.value }, es ? 'Evento actualizado' : 'Event updated')
}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">{es ? 'Ninguno' : 'None'}</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.title}
</option>
))}
</select>
{gallery.visibility === 'ticket' && !gallery.eventId && (
<p className="text-xs text-red-600 mt-1">
{es
? 'El modo "con entrada" necesita un evento vinculado'
: 'Ticket mode needs a linked event'}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">{es ? 'Enlace para compartir' : 'Share link'}</label>
<div className="flex gap-2">
<input
type="text"
readOnly
value={gallery.shareUrl || ''}
className="flex-1 min-w-0 px-3 py-2 text-sm border rounded-btn bg-gray-50"
/>
<Button size="sm" onClick={copyShareLink} title={es ? 'Copiar' : 'Copy'}>
{copied ? <CheckIcon className="w-4 h-4" /> : <LinkIcon className="w-4 h-4" />}
</Button>
<Button size="sm" variant="outline" onClick={rotateToken} title={es ? 'Regenerar' : 'Regenerate'}>
<ArrowPathIcon className="w-4 h-4" />
</Button>
</div>
{gallery.event && (
<p className="text-xs text-gray-500 mt-1">
{es
? 'Publicada en la página del evento: '
: 'Published on the event page: '}
<span className="font-mono">/events/{gallery.event.slug}/gallery</span>
</p>
)}
</div>
</div>
</Card>
{processingCount > 0 && (
<p className="text-sm text-gray-600 mb-4">
{es
? `Procesando ${processingCount} foto(s)…`
: `Processing ${processingCount} photo(s)…`}
</p>
)}
{/* Photo grid */}
{photos.length === 0 ? (
<Card className="p-12 text-center">
<ArrowUpTrayIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-gray-500">
{es
? 'Arrastra fotos aquí o usa el botón de subir'
: 'Drag photos here or use the upload button'}
</p>
</Card>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{photos.map((photo) => (
<Card
key={photo.id}
draggable
onDragStart={() => setDragId(photo.id)}
onDragOver={(e) => {
if (dragId) e.preventDefault();
}}
onDrop={(e) => {
if (dragId) {
e.preventDefault();
e.stopPropagation();
onDropReorder(photo.id);
}
}}
className={`group relative overflow-hidden aspect-square cursor-grab ${
dragId === photo.id ? 'opacity-50' : ''
}`}
>
{photo.status === 'ready' && photo.urls.thumb ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={photo.urls.thumb}
alt=""
className="w-full h-full object-cover cursor-pointer"
draggable={false}
onClick={() => openLightboxFor(photo.id)}
/>
) : photo.status === 'failed' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-red-50 text-red-600 p-2 text-center">
<ExclamationTriangleIcon className="w-8 h-8 mb-1" />
<span className="text-xs line-clamp-3">{photo.lastError || 'Failed'}</span>
</div>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-100">
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full" />
</div>
)}
{gallery.coverPhotoId === photo.id && (
<StarIconSolid className="absolute top-2 left-2 w-5 h-5 text-primary-yellow drop-shadow" />
)}
<div className="absolute inset-x-0 bottom-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 py-1.5">
{photo.status === 'ready' && (
<button
onClick={() =>
saveField({ coverPhotoId: photo.id }, es ? 'Portada actualizada' : 'Cover updated')
}
className="p-1.5 text-white hover:text-primary-yellow"
title={es ? 'Usar como portada' : 'Set as cover'}
>
<StarIcon className="w-5 h-5" />
</button>
)}
{photo.status === 'failed' && (
<button
onClick={() => retryPhoto(photo.id)}
className="p-1.5 text-white hover:text-primary-yellow"
title={es ? 'Reintentar' : 'Retry'}
>
<ArrowPathIcon className="w-5 h-5" />
</button>
)}
<button
onClick={() => deletePhoto(photo.id)}
className="p-1.5 text-white hover:text-red-400"
title={es ? 'Eliminar' : 'Delete'}
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
</Card>
))}
</div>
)}
{/* Danger zone */}
<div className="mt-10 pt-6 border-t border-secondary-light-gray flex justify-end">
<Button variant="danger" onClick={deleteGallery}>
<TrashIcon className="w-5 h-5 mr-2" />
{es ? 'Eliminar galería' : 'Delete gallery'}
</Button>
</div>
{/* Lightbox with admin actions */}
{lightboxIndex !== null && lightboxItems.length > 0 && (
<Lightbox
items={lightboxItems}
index={Math.min(lightboxIndex, lightboxItems.length - 1)}
onClose={() => setLightboxIndex(null)}
onNavigate={setLightboxIndex}
renderActions={(item) => (
<>
<button
onClick={() =>
saveField({ coverPhotoId: item.id }, es ? 'Portada actualizada' : 'Cover updated')
}
className="text-white hover:text-primary-yellow"
title={es ? 'Usar como portada' : 'Set as cover'}
>
{gallery.coverPhotoId === item.id ? (
<StarIconSolid className="w-7 h-7 text-primary-yellow" />
) : (
<StarIcon className="w-7 h-7" />
)}
</button>
<button
onClick={async () => {
const before = readyPhotos.length;
const deleted = await deletePhoto(item.id);
if (!deleted) return;
if (before <= 1) setLightboxIndex(null);
else setLightboxIndex((i) => (i === null ? null : Math.max(0, Math.min(i, before - 2))));
}}
className="text-white hover:text-red-400"
title={es ? 'Eliminar' : 'Delete'}
>
<TrashIcon className="w-7 h-7" />
</button>
</>
)}
/>
)}
{/* Uploader panel (Google Drive style) */}
{uploads.length > 0 && (
<div className="fixed bottom-4 right-4 z-40 w-96 max-w-[calc(100vw-2rem)]">
<div className="bg-white rounded-card shadow-card-hover border border-secondary-light-gray overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-primary-dark text-white">
<p className="text-sm font-medium">
{activeUploads > 0
? es
? `Subiendo ${uploads.length - activeUploads}/${uploads.length}`
: `Uploading ${uploads.length - activeUploads}/${uploads.length}`
: es
? `${uploads.length} subida(s) completada(s)`
: `${uploads.length} upload(s) complete`}
</p>
<div className="flex items-center gap-1">
<button
onClick={() => setPanelCollapsed((c) => !c)}
className="p-1 hover:text-primary-yellow"
aria-label={panelCollapsed ? 'Expand' : 'Collapse'}
>
{panelCollapsed ? <ChevronUpIcon className="w-5 h-5" /> : <ChevronDownIcon className="w-5 h-5" />}
</button>
{activeUploads === 0 && (
<button
onClick={() => setUploads([])}
className="p-1 hover:text-primary-yellow"
aria-label="Close"
>
<XMarkIcon className="w-5 h-5" />
</button>
)}
</div>
</div>
{!panelCollapsed && (
<ul className="max-h-72 overflow-y-auto divide-y divide-secondary-light-gray">
{uploads.map((u) => {
const ds = displayStatus(u);
return (
<li key={u.key} className="px-4 py-2.5">
<div className="flex items-center gap-3">
<PhotoIcon className="w-5 h-5 text-gray-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm text-primary-dark truncate" title={u.name}>
{u.name}
</p>
<p className="text-xs text-gray-500">
{ds.state === 'uploading' && `${Math.round(u.progress * 100)}% · ${formatBytes(u.sizeBytes)}`}
{ds.state === 'queued' && (es ? 'En cola' : 'Queued')}
{ds.state === 'processing' && (es ? 'Procesando…' : 'Processing…')}
{ds.state === 'done' && formatBytes(u.sizeBytes)}
{ds.state === 'error' && (
<span className="text-red-600" title={ds.error}>
{ds.error}
</span>
)}
</p>
</div>
<span className="shrink-0">
{ds.state === 'done' && <CheckCircleIcon className="w-6 h-6 text-green-600" />}
{ds.state === 'error' && <ExclamationCircleIcon className="w-6 h-6 text-red-600" />}
{ds.state === 'processing' && (
<div className="animate-spin w-5 h-5 border-2 border-primary-yellow border-t-transparent rounded-full" />
)}
{ds.state === 'uploading' && (
<span className="text-xs text-gray-600 tabular-nums">{Math.round(u.progress * 100)}%</span>
)}
</span>
</div>
{ds.state === 'uploading' && (
<div className="mt-1.5 h-1 rounded-full bg-secondary-gray overflow-hidden">
<div
className="h-full bg-primary-yellow transition-[width] duration-200"
style={{ width: `${Math.round(u.progress * 100)}%` }}
/>
</div>
)}
</li>
);
})}
</ul>
)}
</div>
</div>
)}
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { photosApi, eventsApi, PhotoGallery, Event, GalleryVisibility } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton';
import VisibilityBadge from './VisibilityBadge';
import {
CameraIcon,
PlusIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
export default function AdminPhotosPage() {
const { locale } = useLanguage();
const [galleries, setGalleries] = useState<PhotoGallery[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({
title: '',
titleEs: '',
eventId: '',
visibility: 'private' as GalleryVisibility,
});
useEffect(() => {
Promise.all([photosApi.getGalleries(), eventsApi.getAll()])
.then(([g, e]) => {
setGalleries(g.galleries);
setEvents(e.events);
})
.catch(() => toast.error(locale === 'es' ? 'Error al cargar galerías' : 'Failed to load galleries'))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.title.trim()) return;
setCreating(true);
try {
const { gallery } = await photosApi.createGallery({
title: form.title.trim(),
titleEs: form.titleEs.trim() || undefined,
eventId: form.eventId || undefined,
visibility: form.visibility,
});
setGalleries((prev) => [gallery, ...prev]);
setShowCreate(false);
setForm({ title: '', titleEs: '', eventId: '', visibility: 'private' });
toast.success(locale === 'es' ? 'Galería creada' : 'Gallery created');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create gallery');
} finally {
setCreating(false);
}
};
if (loading) {
return (
<div>
<div className="flex items-center justify-between mb-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
</div>
<ImageGridSkeleton />
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">
{locale === 'es' ? 'Fotos de Eventos' : 'Event Photos'}
</h1>
<Button onClick={() => setShowCreate(true)}>
<PlusIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Nueva Galería' : 'New Gallery'}
</Button>
</div>
{galleries.length === 0 ? (
<Card className="p-12 text-center">
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<h3 className="text-lg font-semibold text-gray-600 mb-2">
{locale === 'es' ? 'Sin galerías todavía' : 'No galleries yet'}
</h3>
<p className="text-gray-500 mb-4">
{locale === 'es'
? 'Crea una galería para compartir las fotos de un evento'
: 'Create a gallery to share photos from a past event'}
</p>
<Button onClick={() => setShowCreate(true)}>
<PlusIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Crear la primera' : 'Create the first one'}
</Button>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{galleries.map((g) => (
<Link key={g.id} href={`/admin/photos/${g.id}`}>
<Card className="overflow-hidden hover:shadow-card-hover transition-shadow cursor-pointer h-full">
<div className="aspect-video bg-gray-100 flex items-center justify-center">
{g.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={g.coverUrl} alt="" className="w-full h-full object-cover" />
) : (
<CameraIcon className="w-12 h-12 text-gray-300" />
)}
</div>
<div className="p-4">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-primary-dark truncate">
{locale === 'es' && g.titleEs ? g.titleEs : g.title}
</h3>
<VisibilityBadge visibility={g.visibility} />
</div>
<p className="text-sm text-gray-500 mt-1">
{g.photoCount} {locale === 'es' ? 'fotos' : 'photos'}
{g.event && <> · {locale === 'es' && g.event.titleEs ? g.event.titleEs : g.event.title}</>}
</p>
</div>
</Card>
</Link>
))}
</div>
)}
{showCreate && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-primary-dark">
{locale === 'es' ? 'Nueva Galería' : 'New Gallery'}
</h2>
<button onClick={() => setShowCreate(false)} className="text-gray-400 hover:text-gray-600">
<XMarkIcon className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">
{locale === 'es' ? 'Título (inglés)' : 'Title (English)'} *
</label>
<input
type="text"
required
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
placeholder="June Language Exchange"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{locale === 'es' ? 'Título (español)' : 'Title (Spanish)'}
</label>
<input
type="text"
value={form.titleEs}
onChange={(e) => setForm({ ...form, titleEs: e.target.value })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
placeholder="Intercambio de Junio"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{locale === 'es' ? 'Evento vinculado' : 'Linked event'}
</label>
<select
value={form.eventId}
onChange={(e) => setForm({ ...form, eventId: e.target.value })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">{locale === 'es' ? 'Ninguno' : 'None'}</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.title}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{locale === 'es' ? 'Visibilidad' : 'Visibility'}
</label>
<select
value={form.visibility}
onChange={(e) => setForm({ ...form, visibility: e.target.value as GalleryVisibility })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="private">{locale === 'es' ? 'Privada (solo admins)' : 'Private (admins only)'}</option>
<option value="public">{locale === 'es' ? 'Pública (listada)' : 'Public (listed)'}</option>
<option value="link">{locale === 'es' ? 'Solo con enlace' : 'Link only'}</option>
<option value="ticket">
{locale === 'es' ? 'Con entrada del evento' : 'Ticket holders of the event'}
</option>
</select>
</div>
<div className="flex gap-2 justify-end pt-2">
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</Button>
<Button type="submit" isLoading={creating}>
{locale === 'es' ? 'Crear' : 'Create'}
</Button>
</div>
</form>
</Card>
</div>
)}
</div>
);
}
+12 -1
View File
@@ -18,7 +18,7 @@ import {
VideoCameraIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import { parseDate, formatCurrency, EVENT_TIMEZONE } from '@/lib/utils';
import clsx from 'clsx';
// ─── Types ───────────────────────────────────────────────────
@@ -273,6 +273,17 @@ function ValidTicketScreen({
{validation.ticket?.attendeeEmail && (
<p className="text-emerald-100 text-lg mb-4">{validation.ticket.attendeeEmail}</p>
)}
{/* Unpaid tickets are valid for entry but the balance is collected at the door */}
{validation.ticket?.paymentStatus === 'unpaid' && (
<div className="bg-orange-500 rounded-2xl px-6 py-3 w-full max-w-sm text-center mb-3">
<p className="font-bold text-lg">UNPAID collect payment</p>
{!!validation.ticket.amountDue && (
<p className="text-orange-100 text-sm">
Balance due: {formatCurrency(validation.ticket.amountDue)}
</p>
)}
</div>
)}
<div className="bg-white/15 rounded-2xl px-6 py-4 w-full max-w-sm space-y-2 text-center">
{validation.event && (
<p className="font-semibold text-lg">{validation.event.title}</p>
+99 -7
View File
@@ -1,20 +1,34 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { usersApi, eventsApi, User, Event } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import { parseDate, formatRucDisplay } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
import Input from '@/components/ui/Input';
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
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 {
if (!range) return undefined;
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
@@ -35,6 +49,8 @@ export default function AdminUsersPage() {
const [eventFilter, setEventFilter] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [editForm, setEditForm] = useState({
name: '',
@@ -57,9 +73,20 @@ export default function AdminUsersPage() {
eventsApi.getAll().then((res) => setEvents(res.events)).catch(() => {});
}, []);
// When a filter changes, jump back to page 1 before fetching (skipping the
// fetch for the stale page); otherwise fetch for the current page/pageSize.
const filterKey = JSON.stringify([roleFilter, statusFilter, hasBookingsFilter, registeredRange, eventFilter, debouncedSearch]);
const prevFilterKey = useRef(filterKey);
useEffect(() => {
if (prevFilterKey.current !== filterKey) {
prevFilterKey.current = filterKey;
if (page !== 1) {
setPage(1);
return;
}
}
loadUsers();
}, [roleFilter, statusFilter, hasBookingsFilter, registeredRange, eventFilter, debouncedSearch]);
}, [filterKey, page, pageSize]);
const hasActiveFilters =
roleFilter || statusFilter || hasBookingsFilter || registeredRange || eventFilter || searchQuery;
@@ -82,10 +109,16 @@ export default function AdminUsersPage() {
registeredAfter: registeredAfterFromRange(registeredRange),
eventId: eventFilter || undefined,
search: debouncedSearch.trim() || undefined,
pageSize: 200,
page,
pageSize,
});
setUsers(users);
setTotal(total);
// If the current page emptied out (e.g. after deleting the last user on
// it), fall back to the new last page.
if (users.length === 0 && total > 0 && page > 1) {
setPage(Math.max(1, Math.ceil(total / pageSize)));
}
} catch (error) {
toast.error('Failed to load users');
} finally {
@@ -314,7 +347,7 @@ export default function AdminUsersPage() {
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{user.phone || '-'}</td>
<td className="px-4 py-3 text-sm text-gray-600">{user.rucNumber || '-'}</td>
<td className="px-4 py-3 text-sm text-gray-600">{formatRucDisplay(user.rucNumber) || '-'}</td>
<td className="px-4 py-3">
<select value={user.role} onChange={(e) => handleRoleChange(user.id, e.target.value)}
className="px-2 py-1 rounded border border-secondary-light-gray text-sm">
@@ -363,7 +396,7 @@ export default function AdminUsersPage() {
<p className="font-medium text-sm truncate">{user.name}</p>
<p className="text-xs text-gray-500 truncate">{user.email}</p>
{user.phone && <p className="text-[10px] text-gray-400">{user.phone}</p>}
{user.rucNumber && <p className="text-[10px] text-gray-400">RUC: {user.rucNumber}</p>}
{user.rucNumber && <p className="text-[10px] text-gray-400">RUC: {formatRucDisplay(user.rucNumber)}</p>}
</div>
{getRoleBadge(user.role)}
</div>
@@ -385,6 +418,65 @@ export default function AdminUsersPage() {
)}
</div>
{/* Pagination */}
{total > 0 && (
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-gray-600">
<label htmlFor="users-page-size" className="whitespace-nowrap">Per page</label>
<select
id="users-page-size"
value={pageSize}
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
className="px-2 py-1.5 rounded-btn border border-secondary-light-gray text-sm"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span className="text-xs text-gray-500 whitespace-nowrap">
{(page - 1) * pageSize + 1}&ndash;{Math.min(page * pageSize, total)} of {total}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(page - 1)}
disabled={page <= 1}
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
aria-label="Previous page"
>
<ChevronLeftIcon className="w-4 h-4" />
</button>
{getPageNumbers(page, Math.max(1, Math.ceil(total / pageSize))).map((p, i) =>
p === '...' ? (
<span key={`ellipsis-${i}`} className="px-1.5 text-sm text-gray-400">&hellip;</span>
) : (
<button
key={p}
onClick={() => setPage(p)}
className={clsx(
'min-h-[36px] min-w-[36px] px-2 rounded-btn text-sm',
p === page
? 'bg-primary-yellow text-primary-dark font-semibold'
: 'border border-secondary-light-gray text-gray-600 hover:bg-gray-50'
)}
aria-current={p === page ? 'page' : undefined}
>
{p}
</button>
)
)}
<button
onClick={() => setPage(page + 1)}
disabled={page >= Math.ceil(total / pageSize)}
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
aria-label="Next page"
>
<ChevronRightIcon className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
<div className="space-y-4">
+39 -2
View File
@@ -2,6 +2,26 @@ import { MetadataRoute } from 'next';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
interface SitemapPhotoGallery {
slug: string;
updatedAt: string;
event?: { slug: string };
}
/** Public photo galleries from the photo-api service (public visibility only). */
async function getPublicPhotoGalleries(): Promise<SitemapPhotoGallery[]> {
try {
const res = await fetch(`${photoApiUrl}/api/photos/public/galleries`, {
next: { revalidate: 3600 },
});
if (!res.ok) return [];
return ((await res.json()).galleries as SitemapPhotoGallery[]) || [];
} catch {
return [];
}
}
interface SitemapEvent {
id: string;
@@ -41,7 +61,10 @@ async function getIndexableEvents(): Promise<SitemapEvent[]> {
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const events = await getIndexableEvents();
const [events, photoGalleries] = await Promise.all([
getIndexableEvents(),
getPublicPhotoGalleries(),
]);
const now = new Date();
// Static pages
@@ -88,6 +111,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: 'monthly',
priority: 0.6,
},
{
url: `${siteUrl}/photos`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.6,
},
// Legal pages
{
url: `${siteUrl}/legal/terms-policy`,
@@ -120,5 +149,13 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
};
});
return [...staticPages, ...eventPages];
const photoPages: MetadataRoute.Sitemap = photoGalleries.map((g) => ({
// Event-linked galleries live under the event's URL.
url: g.event ? `${siteUrl}/events/${g.event.slug}/gallery` : `${siteUrl}/photos/${g.slug}`,
lastModified: g.updatedAt ? new Date(g.updatedAt) : now,
changeFrequency: 'monthly' as const,
priority: 0.5,
}));
return [...staticPages, ...eventPages, ...photoPages];
}
+145
View File
@@ -0,0 +1,145 @@
'use client';
import { useEffect, useCallback, useState } from 'react';
import {
XMarkIcon,
ChevronLeftIcon,
ChevronRightIcon,
ArrowDownTrayIcon,
} from '@heroicons/react/24/outline';
export interface LightboxItem {
id: string;
previewUrl: string;
downloadUrl: string;
filename?: string;
}
interface LightboxProps {
items: LightboxItem[];
index: number;
onClose: () => void;
onNavigate: (index: number) => void;
/** Extra per-item action buttons rendered in the top bar (admin use). */
renderActions?: (item: LightboxItem, index: number) => React.ReactNode;
}
/**
* Full-screen photo lightbox with keyboard and swipe navigation, in the
* style of the admin gallery preview modal (fixed inset-0 bg-black/90).
*/
export default function Lightbox({ items, index, onClose, onNavigate, renderActions }: LightboxProps) {
const [touchStartX, setTouchStartX] = useState<number | null>(null);
const item = items[index];
const prev = useCallback(() => {
onNavigate(index > 0 ? index - 1 : items.length - 1);
}, [index, items.length, onNavigate]);
const next = useCallback(() => {
onNavigate(index < items.length - 1 ? index + 1 : 0);
}, [index, items.length, onNavigate]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowLeft') prev();
if (e.key === 'ArrowRight') next();
};
window.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [onClose, prev, next]);
if (!item) return null;
return (
<div
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center"
onClick={onClose}
onTouchStart={(e) => setTouchStartX(e.touches[0].clientX)}
onTouchEnd={(e) => {
if (touchStartX === null) return;
const dx = e.changedTouches[0].clientX - touchStartX;
if (dx > 60) prev();
if (dx < -60) next();
setTouchStartX(null);
}}
>
<button
className="absolute top-4 right-4 text-white hover:text-gray-300 z-10 p-2 -m-2"
onClick={onClose}
aria-label="Close"
>
<XMarkIcon className="w-8 h-8" />
</button>
<div
className="absolute top-4 left-4 z-10 flex items-center gap-4"
onClick={(e) => e.stopPropagation()}
>
<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)}
</div>
{items.length > 1 && (
<>
<button
className="absolute left-2 md:left-4 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
prev();
}}
aria-label="Previous"
>
<ChevronLeftIcon className="w-9 h-9" />
</button>
<button
className="absolute right-2 md:right-4 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
next();
}}
aria-label="Next"
>
<ChevronRightIcon className="w-9 h-9" />
</button>
</>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.previewUrl}
alt=""
className="max-w-[95vw] max-h-[90vh] object-contain select-none"
onClick={(e) => e.stopPropagation()}
draggable={false}
/>
<div className="absolute bottom-3 inset-x-0 text-center text-white/70 text-sm">
{index + 1} / {items.length}
</div>
{/* Preload neighbours so prev/next feels instant. */}
<div className="hidden" aria-hidden>
{items.length > 1 && (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={items[(index + 1) % items.length].previewUrl} alt="" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={items[(index - 1 + items.length) % items.length].previewUrl} alt="" />
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,22 @@
'use client';
import { ReactNode } from 'react';
import { usePrivacy } from '@/context/PrivacyContext';
export const PRIVACY_MASK = '••••';
/**
* Renders its children normally, but shows a mask while privacy mode is on.
* Use for inline sensitive numbers that can't be hidden without breaking
* the surrounding row/badge.
*/
export default function SensitiveValue({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const { privacyMode } = usePrivacy();
return <span className={className}>{privacyMode ? PRIVACY_MASK : children}</span>;
}
+66
View File
@@ -0,0 +1,66 @@
'use client';
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
interface PrivacyContextType {
/** true = stats and sensitive data are hidden */
privacyMode: boolean;
setPrivacyMode: (value: boolean) => void;
togglePrivacyMode: () => void;
}
const PrivacyContext = createContext<PrivacyContextType | undefined>(undefined);
// Same key the old per-page useStatsPrivacy hook used ('true' = hidden), so
// existing operators keep their saved preference.
const STORAGE_KEY = 'spanglish-admin-stats-hidden';
export function PrivacyProvider({ children }: { children: ReactNode }) {
const [privacyMode, setPrivacyModeState] = useState(false);
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) {
setPrivacyModeState(stored === 'true');
}
} catch {
// localStorage unavailable (private mode etc.) - keep default
}
}, []);
const setPrivacyMode = useCallback((value: boolean) => {
setPrivacyModeState(value);
try {
localStorage.setItem(STORAGE_KEY, String(value));
} catch {
// ignore
}
}, []);
const togglePrivacyMode = useCallback(() => {
setPrivacyModeState((prev) => {
const next = !prev;
try {
localStorage.setItem(STORAGE_KEY, String(next));
} catch {
// ignore
}
return next;
});
}, []);
return (
<PrivacyContext.Provider value={{ privacyMode, setPrivacyMode, togglePrivacyMode }}>
{children}
</PrivacyContext.Provider>
);
}
export function usePrivacy() {
const context = useContext(PrivacyContext);
if (context === undefined) {
throw new Error('usePrivacy must be used within a PrivacyProvider');
}
return context;
}
-41
View File
@@ -1,41 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
const STORAGE_KEY = 'spanglish-admin-stats-hidden';
export function useStatsPrivacy() {
const [showStats, setShowStatsState] = useState(true);
useEffect(() => {
if (typeof window === 'undefined') return;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) {
setShowStatsState(stored !== 'true');
}
} catch {
// ignore
}
}, []);
const setShowStats = useCallback((value: boolean | ((prev: boolean) => boolean)) => {
setShowStatsState((prev) => {
const next = typeof value === 'function' ? value(prev) : value;
try {
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, String(!next));
}
} catch {
// ignore
}
return next;
});
}, []);
const toggleStats = useCallback(() => {
setShowStats((prev) => !prev);
}, [setShowStats]);
return [showStats, setShowStats, toggleStats] as const;
}
+5
View File
@@ -255,6 +255,10 @@
}
},
"admin": {
"privacy": {
"hide": "Hide Stats",
"show": "Show Stats"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back",
@@ -275,6 +279,7 @@
"contacts": "Messages",
"emails": "Emails",
"gallery": "Gallery",
"photos": "Photos",
"settings": "Settings"
},
"events": {
+5
View File
@@ -255,6 +255,10 @@
}
},
"admin": {
"privacy": {
"hide": "Ocultar Datos",
"show": "Mostrar Datos"
},
"dashboard": {
"title": "Panel de Control",
"welcome": "Bienvenido de nuevo",
@@ -275,6 +279,7 @@
"contacts": "Mensajes",
"emails": "Emails",
"gallery": "Galería",
"photos": "Fotos",
"settings": "Configuración"
},
"events": {
+6 -1
View File
@@ -34,7 +34,12 @@ export async function fetchApi<T>(
const errorMessage = typeof errorData.error === 'string'
? errorData.error
: (errorData.message || JSON.stringify(errorData) || 'Request failed');
throw new Error(errorMessage);
const error = new Error(errorMessage);
// Preserve structured error info (e.g. code: 'EVENT_OVER_CAPACITY') so
// callers can react beyond the message text.
(error as any).code = errorData.code;
(error as any).data = errorData;
throw error;
}
return res.json();
+8
View File
@@ -17,3 +17,11 @@ export { siteSettingsApi } from './siteSettings';
export { legalSettingsApi } from './legalSettings';
export { legalPagesApi } from './legalPages';
export { faqApi } from './faq';
export { photosApi } from './photos';
export type {
Photo,
PhotoGallery,
PhotoGalleryEvent,
GalleryVisibility,
CreateGalleryInput,
} from './photos';
+16 -2
View File
@@ -1,6 +1,14 @@
import { fetchApi } from './client';
import type { Payment, PaymentWithDetails } from './types';
// Mirrors backend/src/lib/paymentProviders.ts: manual gateways need an admin to
// verify the money arrived; automatic ones (lightning) confirm themselves.
export const MANUAL_PAYMENT_PROVIDERS = ['tpago', 'bank_transfer', 'card', 'cash'];
export function isManualProvider(provider: string): boolean {
return MANUAL_PAYMENT_PROVIDERS.includes(provider);
}
export const paymentsApi = {
getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean; eventId?: string; eventIds?: string[] }) => {
const query = new URLSearchParams();
@@ -21,10 +29,10 @@ export const paymentsApi = {
body: JSON.stringify(data),
}),
approve: (id: string, adminNote?: string, sendEmail: boolean = true) =>
approve: (id: string, adminNote?: string, sendEmail: boolean = true, allowOverCapacity: boolean = false) =>
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/approve`, {
method: 'POST',
body: JSON.stringify({ adminNote, sendEmail }),
body: JSON.stringify({ adminNote, sendEmail, allowOverCapacity }),
}),
reject: (id: string, adminNote?: string, sendEmail: boolean = true) =>
@@ -51,4 +59,10 @@ export const paymentsApi = {
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reactivate`, {
method: 'POST',
}),
reopen: (id: string, adminNote?: string) =>
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reopen`, {
method: 'POST',
body: JSON.stringify({ adminNote }),
}),
};
+176
View File
@@ -0,0 +1,176 @@
import { fetchApi, API_BASE, getToken } from './client';
// Client for the standalone photo-api Go service (photo-api/), reachable
// under /api/photos via the Next rewrite (dev) or nginx (prod).
export type GalleryVisibility = 'public' | 'private' | 'link' | 'ticket';
export interface PhotoGalleryEvent {
id: string;
slug: string;
title: string;
titleEs?: string;
startDatetime: string;
status: string;
}
export interface PhotoGallery {
id: string;
slug: string;
title: string;
titleEs?: string;
description?: string;
descriptionEs?: string;
eventId?: string;
visibility: GalleryVisibility;
coverPhotoId?: string;
photoCount: number;
coverUrl?: string;
createdAt: string;
updatedAt: string;
event?: PhotoGalleryEvent;
// Present on admin responses only:
shareToken?: string;
shareUrl?: string;
}
export interface Photo {
id: string;
galleryId: string;
position: number;
originalFilename?: string;
contentType: string;
sizeBytes: number;
width?: number;
height?: number;
takenAt?: string;
status: 'queued' | 'processing' | 'ready' | 'failed';
lastError?: string;
createdAt: string;
urls: {
thumb?: string;
preview?: string;
original: string;
};
}
export interface CreateGalleryInput {
title: string;
titleEs?: string;
description?: string;
descriptionEs?: string;
eventId?: string;
visibility?: GalleryVisibility;
}
export const photosApi = {
// Admin (requires admin/organizer token)
createGallery: (data: CreateGalleryInput) =>
fetchApi<{ gallery: PhotoGallery }>('/api/photos/galleries', {
method: 'POST',
body: JSON.stringify(data),
}),
getGalleries: (eventId?: string) => {
const query = eventId ? `?eventId=${encodeURIComponent(eventId)}` : '';
return fetchApi<{ galleries: PhotoGallery[] }>(`/api/photos/galleries${query}`);
},
getGallery: (id: string) =>
fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(`/api/photos/galleries/${id}`),
updateGallery: (id: string, data: Partial<CreateGalleryInput> & { coverPhotoId?: string }) =>
fetchApi<{ gallery: PhotoGallery }>(`/api/photos/galleries/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
deleteGallery: (id: string) =>
fetchApi<{ message: string }>(`/api/photos/galleries/${id}`, { method: 'DELETE' }),
rotateShareToken: (id: string) =>
fetchApi<{ gallery: PhotoGallery }>(`/api/photos/galleries/${id}/share-token`, {
method: 'POST',
}),
uploadPhoto: async (galleryId: string, file: File) => {
const token = getToken();
const formData = new FormData();
formData.append('files', file);
const res = await fetch(`${API_BASE}/api/photos/galleries/${galleryId}/photos`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Upload failed' }));
throw new Error(errorData.error || 'Upload failed');
}
return res.json() as Promise<{ photos: Photo[] }>;
},
/**
* Upload with byte-level progress (0..1). fetch() cannot report upload
* progress, so this uses XMLHttpRequest; used by the admin uploader panel.
*/
uploadPhotoWithProgress: (
galleryId: string,
file: File,
onProgress: (fraction: number) => void
) =>
new Promise<{ photos: Photo[] }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE}/api/photos/galleries/${galleryId}/photos`);
const token = getToken();
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && e.total > 0) onProgress(e.loaded / e.total);
};
xhr.onload = () => {
try {
const body = JSON.parse(xhr.responseText || '{}');
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
else reject(new Error(body.error || `Upload failed (${xhr.status})`));
} catch {
reject(new Error(`Upload failed (${xhr.status})`));
}
};
xhr.onerror = () => reject(new Error('Network error during upload'));
xhr.onabort = () => reject(new Error('Upload cancelled'));
const formData = new FormData();
formData.append('files', file);
xhr.send(formData);
}),
reorderPhotos: (galleryId: string, photoIds: string[]) =>
fetchApi<{ message: string }>(`/api/photos/galleries/${galleryId}/order`, {
method: 'PATCH',
body: JSON.stringify({ photoIds }),
}),
deletePhoto: (photoId: string) =>
fetchApi<{ message: string }>(`/api/photos/photos/${photoId}`, { method: 'DELETE' }),
retryPhoto: (photoId: string) =>
fetchApi<{ photo: Photo }>(`/api/photos/photos/${photoId}/retry`, { method: 'POST' }),
// Viewer (anonymous or member token; share token via query param)
getPublicGalleries: () =>
fetchApi<{ galleries: PhotoGallery[] }>('/api/photos/public/galleries'),
getPublicGallery: (slug: string, token?: string) => {
const query = token ? `?token=${encodeURIComponent(token)}` : '';
return fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(
`/api/photos/public/galleries/${encodeURIComponent(slug)}${query}`
);
},
/** Newest gallery linked to an event; backs /events/[slug]/gallery. */
getEventGallery: (eventSlug: string, token?: string) => {
const query = token ? `?token=${encodeURIComponent(token)}` : '';
return fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(
`/api/photos/public/events/${encodeURIComponent(eventSlug)}/gallery${query}`
);
},
};
+6 -16
View File
@@ -105,30 +105,20 @@ export const ticketsApi = {
body: JSON.stringify(data),
}),
manualCreate: (data: {
eventId: string;
firstName: string;
lastName?: string;
email: string;
phone?: string;
preferredLanguage?: 'en' | 'es';
adminNote?: string;
}) =>
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/manual', {
method: 'POST',
body: JSON.stringify(data),
}),
guestCreate: (data: {
// Unified add-attendee endpoint behind the single Add Ticket modal
// (paid = confirmation + QR, unpaid = pay link + door collection, guest = free comp)
adminAdd: (data: {
eventId: string;
type: 'paid' | 'unpaid' | 'guest';
firstName: string;
lastName?: string;
email?: string;
phone?: string;
preferredLanguage?: 'en' | 'es';
checkinNow?: boolean;
adminNote?: string;
}) =>
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/guest', {
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/add', {
method: 'POST',
body: JSON.stringify(data),
}),
+9 -2
View File
@@ -18,8 +18,9 @@ export interface Event {
bannerUrl?: string;
externalBookingEnabled?: boolean;
externalBookingUrl?: string;
bookedCount?: number;
availableSeats?: number;
bookedCount?: number; // paid seats (confirmed + checked_in)
claimedCount?: number; // "I've paid" claims awaiting admin verification (hold seats)
availableSeats?: number; // capacity - booked - claimed; the server-authoritative number
isFeatured?: boolean;
createdAt: string;
updatedAt: string;
@@ -52,6 +53,7 @@ export interface Ticket {
qrCode: string;
adminNote?: string;
isGuest?: boolean;
paymentStatus?: 'paid' | 'unpaid' | 'comp';
createdAt: string;
event?: Event;
payment?: Payment;
@@ -69,6 +71,8 @@ export interface TicketValidationResult {
attendeeEmail?: string;
attendeePhone?: string;
status: string;
paymentStatus?: 'paid' | 'unpaid' | 'comp';
amountDue?: number;
checkinAt?: string;
checkedInBy?: string;
};
@@ -221,7 +225,10 @@ export interface DashboardData {
totalEvents: number;
totalTickets: number;
confirmedTickets: number;
/** Checkouts opened but never paid nor claimed — informational, holds no seat */
pendingPayments: number;
/** Customer says they paid; needs admin verification — actionable */
awaitingApprovalPayments: number;
totalRevenue: number;
newContacts: number;
totalSubscribers: number;
+42
View File
@@ -166,6 +166,18 @@ export function formatCurrency(amount: number, currency: string = 'PYG'): string
return formatPrice(amount, currency);
}
/**
* Format a Paraguayan RUC for display as "base-checkdigit" (e.g. 1234567-9).
* Legacy records stored digits only; insert the dash before the check digit.
*/
export function formatRucDisplay(ruc: string | null | undefined): string {
if (!ruc) return '';
if (ruc.includes('-')) return ruc;
const digits = ruc.replace(/\D/g, '');
if (digits.length < 2) return ruc;
return `${digits.slice(0, -1)}-${digits.slice(-1)}`;
}
// ---------------------------------------------------------------------------
// Payment helpers
// ---------------------------------------------------------------------------
@@ -193,3 +205,33 @@ export function getTpagoLink(
const key = (count <= 1 ? 'tpagoLink' : `tpagoLink${count}`) as keyof TpagoLinkConfig;
return config[key] || config.tpagoLink || null;
}
/**
* Spots left for an event, trusting the server's `availableSeats` (which uses
* the same seat-holding formula the booking API enforces: paid + claimed
* seats count, abandoned pending bookings don't). Falls back to deriving it
* from the counts for older API responses.
*/
export function eventSpotsLeft(event: {
capacity: number;
bookedCount?: number;
claimedCount?: number;
availableSeats?: number;
}): number {
if (typeof event.availableSeats === 'number') {
return Math.max(0, event.availableSeats);
}
return Math.max(
0,
(event.capacity ?? 0) - (event.bookedCount ?? 0) - (event.claimedCount ?? 0)
);
}
export function isEventSoldOut(event: {
capacity: number;
bookedCount?: number;
claimedCount?: number;
availableSeats?: number;
}): boolean {
return eventSpotsLeft(event) <= 0;
}
+18 -4
View File
@@ -1,6 +1,10 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -18,9 +22,19 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next-build/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+5 -1
View File
@@ -4,12 +4,16 @@
"private": true,
"description": "Spanglish - Language Exchange Event Platform",
"scripts": {
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\" \"npm run dev:photos\"",
"dev:backend": "npm run dev --workspace=backend",
"dev:frontend": "npm run dev --workspace=frontend",
"dev:photos": "cd photo-api && go run ./cmd/photo-api",
"build": "npm run build --workspaces",
"build:backend": "npm run build --workspace=backend",
"build:frontend": "npm run build --workspace=frontend",
"build:photos": "cd photo-api && go build -o bin/photo-api ./cmd/photo-api",
"test:photos": "cd photo-api && go test ./...",
"migrate:photos": "cd photo-api && go run ./cmd/photo-api migrate",
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
"start:backend": "npm run start --workspace=backend",
"start:frontend": "npm run start --workspace=frontend",
+43
View File
@@ -0,0 +1,43 @@
# Spanglish photo-api configuration
# Copy to .env — the service loads it on start (systemd also passes it via
# EnvironmentFile). This service has its own .env; nothing is shared with
# backend/.env except the VALUES of JWT_SECRET and DATABASE_URL.
# Port (dev default 3003; the systemd unit overrides to 3020 in production)
PORT=3003
# Database — the SAME database the backend uses. photo-api owns only the
# photos_* tables (created by `photo-api migrate`) and reads users/events/
# tickets/payments for auth and access checks.
# DB_TYPE must match the backend's engine: postgres | sqlite
DB_TYPE=postgres
DATABASE_URL=postgresql://spanglish:password@localhost:5432/spanglish_db
# For sqlite instead:
# DB_TYPE=sqlite
# DATABASE_URL=../backend/data/spanglish.db
# MUST be identical to JWT_SECRET in backend/.env — photo-api validates the
# same HS256 tokens the backend issues.
JWT_SECRET=
# Public site origin, used to build share links and allow dev CORS
FRONTEND_URL=https://spanglishcommunity.com
# Photo storage. Local disk by default; setting BOTH S3_ENDPOINT and
# S3_BUCKET switches to S3 (same convention as the backend's media storage).
# Use a photos-specific bucket — do not reuse the backend's media bucket.
STORAGE_PATH=./data/photos
#S3_ENDPOINT=
#S3_REGION=auto
#S3_BUCKET=spanglish-photos
#S3_ACCESS_KEY_ID=
#S3_SECRET_ACCESS_KEY=
#S3_FORCE_PATH_STYLE=true
# Upload and processing tuning
MAX_UPLOAD_MB=50
WORKER_CONCURRENCY=2
# HEIC conversion shells out to a host-installed CLI (autodetects `vips`
# then `heif-convert`; Debian: apt install libvips-tools). Set to override.
#HEIC_CONVERTER=
+25
View File
@@ -0,0 +1,25 @@
# Secondary deployment path (the primary is bare-metal systemd, see
# deploy/spanglish-photos.service). Built for the docker-compose "scale"
# setup or any container host.
#
# docker build -t spanglish-photo-api photo-api/
# docker run --env-file photo-api/.env -p 3020:3020 spanglish-photo-api
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /photo-api ./cmd/photo-api
# debian-slim rather than distroless: HEIC conversion needs the libvips CLI.
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-tools ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /photo-api /usr/local/bin/photo-api
RUN useradd -r -u 10001 photoapi && mkdir -p /data/photos && chown photoapi /data/photos
USER photoapi
ENV STORAGE_PATH=/data/photos
EXPOSE 3020
ENTRYPOINT ["/usr/local/bin/photo-api"]
+23
View File
@@ -0,0 +1,23 @@
.PHONY: start build test migrate clean help
BINARY := bin/photo-api
CMD := ./cmd/photo-api
help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
start: ## Run the photo-api server (go run)
go run $(CMD)
build: ## Build binary to bin/photo-api
go build -o $(BINARY) $(CMD)
test: ## Run all Go tests
go test ./...
migrate: ## Apply pending photos_* migrations
go run $(CMD) migrate
clean: ## Remove built binary
rm -rf bin
+334
View File
@@ -0,0 +1,334 @@
# Photo API — Implementation Plan
Event photo galleries for the Spanglish platform, built as a standalone Go service in `photo-api/`.
Written 2026-07-24 after a read-only investigation of the repo.
> **Revised 2026-07-24 after review.** Decisions from the review that amend the sections below:
> 1. **Both Postgres and SQLite are supported from day 1**, mirroring the backend's `DB_TYPE`/`DATABASE_URL` convention. Consequence: the dedicated `photos` Postgres schema is dropped (SQLite has no schemas); instead every table carries a **`photos_` name prefix** in both engines, which still cannot collide with any Drizzle-owned table name. Migrations are dual-dialect (`*.pg.sql` / `*.sqlite.sql`), SQLite driver is `modernc.org/sqlite` (pure Go, keeps the cgo-free static binary), and queue claiming uses `FOR UPDATE SKIP LOCKED` on Postgres and a plain claim-UPDATE on SQLite.
> 2. Deployment stays **systemd-first with the Dockerfile as the secondary path** (confirmed).
> 3. **Free events**: any `confirmed`/`checked_in` ticket grants access when the event's `price = 0`; paid events still require a `payments.status='paid'` row (§7 SQL updated).
> 4. Admin nav keeps the name **"Photos"** alongside the existing "Gallery" item (confirmed).
> 5. **HEIC is supported**, not rejected: uploads sniffed as HEIC are converted to JPEG for the variant pipeline by shelling out to a host-installed converter (`vips` from libvips-tools, falling back to `heif-convert`); the Go binary stays cgo-free and the original `.heic` is stored byte-identical for download. If no converter is installed, HEIC uploads fail with a clear 415 and a startup warning is logged.
> 6. Photo admin surface is **`admin`/`organizer` only** (confirmed).
> 7. **Local disk and S3 both supported from day 1** (was already planned).
> 8. The service has **its own `photo-api/.env`** (was already planned).
---
## 0. Investigation summary (what the codebase actually is)
**Database.** Drizzle schema lives in one file, `backend/src/db/schema.ts`, with *dual* SQLite/Postgres table definitions selected at runtime by `DB_TYPE` (bottom-of-file conditional exports, lines ~716738). `drizzle.config.ts` + `npm run db:generate` exist, but the migration that actually runs is the hand-written idempotent script `backend/src/db/migrate.ts` (`npm run db:migrate`), which is **not** run automatically at server start. The local `backend/.env` sets `DB_TYPE=postgres` with `DATABASE_URL=postgresql://spanglish:...@localhost:5432/spanglish_db` (driver: `pg` via `drizzle-orm/node-postgres`, everything in the `public` schema). Relevant tables:
- `events`: `id` (uuid in PG), `slug`, bilingual title/description fields, `start_datetime`, `status` (`draft|published|unlisted|cancelled|completed|archived`), `banner_url`, …
- `tickets`: `id`, `booking_id`, `user_id`, `event_id`, attendee fields, `status` (`pending|confirmed|cancelled|checked_in|on_hold`), `is_guest`, …
- `payments`: one row per ticket (`ticket_id` FK), `status` (`pending|pending_approval|paid|refunded|failed|cancelled|on_hold`), `provider` (`bancard|lightning|cash|bank_transfer|tpago`), `paid_at`.
**A ticket is "paid" when its `payments` row has `status='paid'`** (set in `backend/src/routes/payments.ts` on admin approval/PATCH, and in `backend/src/routes/lnbits.ts` for Lightning), at which point the ticket itself is set to `confirmed`. "Paid" lives on the payment, not the ticket.
**Auth.** Stateless JWT, library `jose`, **HS256** with the shared symmetric secret `JWT_SECRET`, issuer `spanglish`, audience `spanglish-app`, 1-day expiry (`backend/src/lib/auth.ts`, `createToken` lines 236246). Payload: `{ sub: userId, email, role, tokenVersion, iat, exp }`. Transport is `Authorization: Bearer <token>`; the frontend stores it in `localStorage` under `spanglish-token` (`frontend/src/lib/api/client.ts`). Roles live in a single `users.role` column: `admin|organizer|staff|marketing|user`; enforcement is `requireAuth(roles?)` (`auth.ts:332`), and media/admin routes use `['admin','organizer']`. Revocation is DB-backed: `users.token_version` must match the token's `tokenVersion` claim, and `account_status` must be `active`. **A Go service can validate tokens independently** with `JWT_SECRET` + HS256 + iss/aud checks; honoring revocation additionally needs one indexed read of `users`. (There is a `user_sessions` table but it is a session *list*, not the auth mechanism.)
**Backend conventions.** Hono on Node (`@hono/node-server`), entry `backend/src/index.ts`. One Hono sub-router per domain in `src/routes/*`, mounted under `/api/<domain>`; shared helpers in `src/lib/*`; no controller/service layering. Validation: zod via `@hono/zod-validator`. Config: `dotenv/config` + ad-hoc `process.env.*` with inline defaults, no env schema. Responses: success is the resource keyed by name (`{ media, url }`, `{ contacts: [...] }`) or `{ message }`; errors are `{ error: string }` with the status as the second arg; central `app.onError``{ error: 'Internal Server Error' }` and `app.notFound``{ error: 'Not Found' }`.
**Existing media code (do not touch).** `backend/src/routes/media.ts` (mounted at `/api/media`): `POST /api/media/upload` (auth `['admin','organizer']`, Hono `parseBody`, magic-byte sniffing, UUID filename), plus GET/DELETE/list. Storage abstraction `backend/src/lib/storage.ts`: local `./uploads` flat dir (served at `/uploads/*` from `index.ts:1877`) or S3 when `S3_ENDPOINT` + `S3_BUCKET` are set (`S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_PUBLIC_URL`, `S3_FORCE_PATH_STYLE`). One generic `media` table (`id, file_url, type, related_id, related_type, created_at`). **No image resizing anywhere** (no sharp). Note: despite the feature name, there is no gallery *table* or backend gallery feature — `frontend/src/app/admin/gallery/page.tsx` is an admin page that uploads via `/api/media` with `relatedType: 'gallery'`. Event flyers/banners are just `events.banner_url` strings pointing at `/uploads/...`.
**Frontend.** Next.js **14.2** App Router (`frontend/src/app`), React 18, TS 5.5. Admin shell + nav: `frontend/src/app/admin/layout.tsx` with a `navigationWithRoles` array (Heroicons); protection = `middleware.ts` checking a non-authoritative `spanglish-auth=1` cookie + client-side `useAuth()` guard; the API is the real gate. API calls: `fetchApi` wrapper in `src/lib/api/client.ts` (`API_BASE = NEXT_PUBLIC_API_URL || ''`, Bearer from localStorage); per-domain modules in `src/lib/api/*`. Same-origin `/api/*` and `/uploads/*` are rewritten to `BACKEND_URL` in `next.config.js`. Data fetching: server components with `revalidate` for public SEO pages handing off to `*Client.tsx`; plain `useEffect` in admin (no react-query). Tailwind 3.4 with custom theme (`primary.yellow #FBB82B`, `brand.navy`, Poppins, `rounded-card` 16px, `shadow-card`); custom UI primitives in `src/components/ui/` (`Button`, `Card`, `Input`, `Skeleton` incl. `ImageGridSkeleton`); no shadcn. Custom i18n: `src/i18n/` (en/es JSON) + `LanguageContext` with `t()`. No public gallery page exists today; there is a homepage carousel fed from `public/images/carrousel/`.
**Infra.** Real deployment is **bare-metal systemd + host nginx**, not Docker: `deploy/spanglish-backend.service` (node `dist/index.js`, port 3018, `EnvironmentFile=backend/.env`, hardened with `ProtectSystem=strict` + `ReadWritePaths` for `uploads`/`data`) and `deploy/spanglish-frontend.service` (`next start -p 3019`). Host nginx (`deploy/front-end_nginx.conf`, `back-end_nginx.conf`, `spanglish_upstreams.conf`) terminates TLS (Let's Encrypt) and routes `/api` and `/uploads` → backend :3018, everything else → frontend :3019; `client_max_body_size 20m`. `deploy/docker-compose.scale.yml` is a documented-as-non-turnkey alternative that references a `backend/Dockerfile` **which does not exist**. **There is no CI at all** (no `.github/`). Secrets come from gitignored per-service `.env` files referenced by `EnvironmentFile=`.
### Where your description and the repo disagree (flagged, not worked around)
1. **"Shared Postgres"** — the backend is dual-engine and *defaults* to SQLite. The local `backend/.env` says `DB_TYPE=postgres`, so this plan assumes production is Postgres, but I cannot verify the production host's env from the repo. **If production actually runs SQLite, the DB and ticket-check parts of this plan change materially** (see Risks).
2. **"Its own container"** — nothing in this repo is deployed in a container; there are zero Dockerfiles and the compose file is explicitly a non-production starting point. The plan makes the systemd path primary (a static Go binary fits it perfectly) and includes a Dockerfile so the service *can* run as a container in the compose-scale setup.
3. **"backend/ already has gallery … logic"** — there is no gallery model in the backend; only the generic `media` table and an admin frontend page. The collision surface is smaller than described: it's the `/api/media` routes, the `/uploads` path/dir, and the `S3_*`/`MEDIA_MAX_UPLOAD_MB` env names. This plan avoids all of them.
4. **Ticket "paid" status** lives on the `payments` table, not the ticket — the check must join both.
---
## 1. Architecture
A single Go 1.26 binary (`photo-api`) exposing HTTP under the path prefix **`/api/photos`**, sitting behind the same host nginx, sharing the existing Postgres **database** but owning its own **schema** (`photos`), and validating the existing HS256 JWTs itself.
Why this shape:
- **Same-origin path prefix instead of a new subdomain.** The frontend already reaches the backend via same-origin `/api/*` (nginx in prod, `next.config.js` rewrites in dev). Adding one more-specific nginx `location /api/photos/` and one more-specific Next rewrite keeps `fetchApi` working unchanged — no CORS, no new cert, no new env in the browser. Backend mounts (`index.ts:1901-1916`) include no `/api/photos` route, so there is no collision.
- **Shared DB, own schema.** The ticket-visibility feature *requires* reading `tickets`/`payments`/`users`; that data lives in `spanglish_db`. Putting photo tables in a `photos` schema in the same database gives one-query access checks, one backup story, and zero drizzle-kit interaction (drizzle introspects/manages only the tables it defines, all in `public`; it never touches other schemas).
- **Systemd-first deployment**, mirroring `deploy/spanglish-backend.service`: a new `spanglish-photos.service` running a static binary on port **3020** (prod; 3002 in dev, following the 3018/3019 prod vs 3001/3000 dev split).
- **Stdlib `net/http`** with Go 1.22+ method/pattern routing (`mux.HandleFunc("GET /api/photos/...")`). The backend's Hono style — one router per domain, per-route middleware, `{ error }` JSON — maps cleanly onto this without importing a framework. Boring, zero-dep, matches "small service".
- **Same response conventions as Hono backend**: resource-keyed success bodies (`{ gallery }`, `{ galleries }`, `{ photos }`), `{ error: "..." }` failures, `Authorization: Bearer` auth, roles `['admin','organizer']` for admin surface — so the frontend treats it exactly like another backend domain module.
Request path: browser → nginx (`location ^~ /api/photos/`) → photo-api :3020 → Postgres (`photos.*` for its data, read-only use of `public.users/tickets/payments/events` for auth + access checks) → local disk or S3 for bytes.
## 2. Database: schema and migrations
**Owned by the Go service. drizzle-kit never sees it.** All photo tables carry a `photos_` name prefix (`photos_galleries`, `photos_photos`, `photos_schema_migrations`) in the same database the backend uses — Postgres or SQLite per `DB_TYPE` — created and migrated by embedded, versioned, dual-dialect SQL files (`migrations/NNNN_*.pg.sql` + `NNNN_*.sqlite.sql`) applied by a ~60-line runner inside the binary (`photo-api migrate` subcommand). This matches the repo's existing convention — the backend also ignores drizzle-kit for application and runs a hand-written migration script manually at deploy time. The runner:
- on Postgres takes `pg_advisory_lock` on a fixed key (safe if two instances start); SQLite's file lock covers the single-host case,
- creates `photos_schema_migrations (version int primary key, applied_at)`,
- applies each migration in a transaction, recording versions.
It is invoked by systemd as `ExecStartPre=/…/photo-api migrate` so deploys stay one-step, while Drizzle's tables are never touched. On SQLite the service opens the same database file as the backend (`WAL` is already enabled by the backend; photo-api sets `busy_timeout` and keeps write transactions short).
`migrations/0001_init.pg.sql` (the `.sqlite.sql` twin mirrors the backend's SQLite conventions: `text` ids generated in Go, `text` RFC3339 timestamps, `integer` booleans):
```sql
CREATE TABLE photos_galleries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug varchar(160) NOT NULL UNIQUE,
title varchar(200) NOT NULL,
title_es varchar(200),
description text,
description_es text,
event_id uuid, -- references public.events(id); soft reference, no FK (see note)
visibility varchar(10) NOT NULL DEFAULT 'private'
CHECK (visibility IN ('public','private','link','ticket')),
share_token varchar(64) NOT NULL UNIQUE, -- 32 random bytes, base64url; used only for 'link' mode
cover_photo_id uuid, -- set after photos exist; soft reference to photos_photos
created_by uuid, -- public.users(id); soft reference
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX photos_galleries_event_idx ON photos_galleries (event_id);
CREATE INDEX photos_galleries_visibility_idx ON photos_galleries (visibility);
CREATE TABLE photos_photos (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
gallery_id uuid NOT NULL REFERENCES photos_galleries(id) ON DELETE CASCADE,
position integer NOT NULL DEFAULT 0,
original_key text NOT NULL, -- storage key of the untouched original
original_filename text,
content_type varchar(50) NOT NULL,
size_bytes bigint NOT NULL,
width integer,
height integer,
thumb_key text, -- set when variant is ready
preview_key text,
taken_at timestamptz, -- from EXIF when present
status varchar(12) NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','processing','ready','failed')),
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
last_error text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX photos_photos_gallery_pos_idx ON photos_photos (gallery_id, position);
CREATE INDEX photos_photos_queue_idx ON photos_photos (status, next_attempt_at) WHERE status IN ('queued','failed');
```
**No foreign keys into Drizzle-owned tables** (`events`/`users`), deliberately: an FK from `photos_*` into a Drizzle-owned table would make future Drizzle migrations (drop/recreate, type changes) fail in confusing ways — exactly the "two tools fighting" scenario. Referential integrity for `event_id` is enforced in the handler (verify the event exists on create/update); a dangling `event_id` degrades gracefully (ticket-gated gallery just denies access; event card omitted).
The photo rows themselves are the processing queue (`status/attempts/next_attempt_at`) — no separate jobs table, no Redis. Queue claiming: `FOR UPDATE SKIP LOCKED` on Postgres, plain claim-UPDATE on SQLite (single host there anyway). Query text uses `?` placeholders rebound to `$n` for Postgres by a small helper; ids are generated in Go (`uuid`), timestamps bound as Go `time.Time` (timestamptz on PG, RFC3339 text on SQLite, matching the backend's dual-schema style).
## 3. API surface
All routes under `/api/photos`. Auth = `Authorization: Bearer <existing JWT>`. "Admin" = role `admin` or `organizer` (mirrors `backend/src/routes/media.ts`). Errors are `{ error: string }` with 400/401/403/404/413/500.
### Admin (JWT, role admin|organizer)
| Method & path | Purpose | Response |
|---|---|---|
| `POST /api/photos/galleries` | Create gallery. Body: `{ title, titleEs?, description?, descriptionEs?, eventId?, visibility }`. Slug generated from title (suffix on collision), `share_token` generated always. | `201 { gallery }` |
| `GET /api/photos/galleries` | List all galleries (any visibility) with photo counts, cover thumb URL. Query: `eventId?`, `limit/offset`. | `{ galleries: [...] }` |
| `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 }` |
| `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: [...] }` |
| `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 }` |
| `POST /api/photos/galleries/:id/share-token` | Rotate share token (invalidate old links). | `{ gallery }` |
| `POST /api/photos/photos/:photoId/retry` | Re-queue a `failed` photo. | `{ photo }` |
The share link the admin copies is a frontend URL: `https://spanglishcommunity.com/photos/<slug>?token=<share_token>`.
### Viewer (public / optional JWT)
| Method & path | Purpose | Response |
|---|---|---|
| `GET /api/photos/public/galleries` | Public index: `visibility='public'` only, with cover thumb + count + linked-event summary. | `{ galleries: [...] }` |
| `GET /api/photos/public/galleries/:slug?token=` | Single gallery incl. `ready` photos in order. Access check per matrix (§7); optional Bearer honored. | `{ gallery, photos: [...] }` or 401/403/404 |
| `GET /api/photos/files/:photoId/:variant?token=` | The bytes. `variant ∈ thumb|preview|original`. Same access check as its gallery. Local: streamed with long-lived `Cache-Control` (keyed URLs are stable); S3: 302 to a presigned GET (15 min). `original` adds `Content-Disposition: attachment; filename="<original_filename>"`. | image bytes / 302 |
| `GET /api/photos/health` | Liveness (also mapped at `/health` on the port for systemd checks). | `{ status: "ok" }` |
Photo objects in responses carry ready-to-use URLs (`/api/photos/files/<id>/thumb` etc., with `?token=` appended by the client when in link mode) so the frontend never constructs storage paths.
## 4. `photo-api/` layout and module setup
```
photo-api/
go.mod # module github.com/Michilis/spanglish/photo-api
.env.example
Dockerfile # secondary path; multi-stage, static binary, distroless
cmd/photo-api/main.go # flag/subcommand: serve (default) | migrate
migrations/ # embedded SQL (embed.FS)
0001_init.sql
internal/config/config.go # env parsing with defaults, mirrors backend's ad-hoc style but centralized
internal/auth/auth.go # JWT verify (HS256, iss/aud), tokenVersion+account_status check, role helpers
internal/store/ # pgx queries: galleries.go, photos.go, access.go (ticket check), migrate.go
internal/storage/ # storage.go (interface), local.go, s3.go
internal/imaging/ # decode, EXIF orient, resize, encode JPEG
internal/worker/worker.go # variant-processing loop
internal/httpapi/ # router.go, middleware.go, galleries.go, photos_upload.go, files.go, public.go, respond.go
```
**Plain `go.mod`, no `go.work`.** There is exactly one Go module and no cross-module Go imports, so a workspace file adds nothing. Dependencies (each justified, all boring):
- `github.com/jackc/pgx/v5` (via its `stdlib` adapter) — canonical Postgres driver; advisory locks.
- `modernc.org/sqlite` — pure-Go SQLite driver (no cgo), for `DB_TYPE=sqlite` parity with the backend.
- `github.com/golang-jwt/jwt/v5` — HS256 verification of the existing tokens.
- `github.com/aws/aws-sdk-go-v2` (s3 + presign) — same SDK family the backend uses for S3; needed for the S3 storage backend and presigned URLs.
- `golang.org/x/image` — high-quality scaling (`draw.CatmullRom`).
- `github.com/rwcarlsen/goexif` — EXIF orientation + `taken_at`. (Tiny, stable, read-only.)
**Monorepo integration.** npm workspaces stay `["backend","frontend"]` — a Go dir cannot be a workspace and nothing should pretend it is. Root `package.json` gets convenience scripts only:
```json
"dev:photos": "cd photo-api && go run ./cmd/photo-api",
"build:photos": "cd photo-api && go build -o bin/photo-api ./cmd/photo-api",
"test:photos": "cd photo-api && go test ./..."
```
and root `dev` adds the third `concurrently` entry. `.gitignore` additions: `photo-api/bin/`, `photo-api/data/`, (`.env` already covered). There is **no CI in this repo**, so nothing to wire; if CI is added later, path-filter Go steps on `photo-api/**`. Note: `pnpm-lock.yaml` sits untracked next to the committed `package-lock.json` — unrelated to this feature but worth resolving; scripts above are package-manager-agnostic.
## 5. Storage abstraction
```go
type Storage interface {
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
Open(ctx context.Context, key string) (io.ReadCloser, int64, error) // local streaming
Delete(ctx context.Context, key string) error
// PresignGet returns ("", ErrNoPresign) on local storage; handler then streams via Open.
PresignGet(ctx context.Context, key, downloadFilename string, expiry time.Duration) (string, error)
}
```
Key layout (both backends): `galleries/<galleryID>/orig/<photoID>.<ext>`, `.../thumb/<photoID>.jpg`, `.../preview/<photoID>.jpg`.
- **`local.go`** — root dir from `STORAGE_PATH` (default `./data/photos`), `0600` files, atomic write via temp+rename. **Not** `backend/uploads` — completely disjoint tree, and nothing is nginx-static-served: all reads go through the access-checked `/api/photos/files/` handler.
- **`s3.go`** — same selection convention as `backend/src/lib/storage.ts`: S3 active when `S3_ENDPOINT` and `S3_BUCKET` are both set; honors `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_FORCE_PATH_STYLE` (default true, Garage/MinIO-friendly). Serving uses presigned GETs so private galleries stay private even on S3 — the bucket is never public.
Env var names deliberately reuse the backend's vocabulary (`S3_*`, `DATABASE_URL`, `JWT_SECRET`, `PORT`) because each systemd service loads its **own** `EnvironmentFile` (`photo-api/.env`) — same conventions, zero collision. The one shared-by-value var is `JWT_SECRET` (must equal the backend's) and `DATABASE_URL` (same DB). Recommend a *different* `S3_BUCKET` (e.g. `spanglish-photos`) than any backend bucket. Full key list in §9.
## 6. Image processing
**Library: pure Go for the pipeline, external CLI only for HEIC decode** — stdlib `image/jpeg|png|gif`, `golang.org/x/image` (webp decode, CatmullRom scaler), `goexif`. Reasoning: the real deployment is a hardened systemd unit on a bare host; govips/bimg would add a libvips system dependency, cgo, and non-static binaries, complicating both the host install and the (secondary) container image for a service whose write volume is "admin uploads a batch after an event", not a hot path. Pure Go costs ~13 s/photo per variant on large JPEGs — fine for an async queue.
- **HEIC (required per review): decoded by shelling out**, since no viable pure-Go HEVC decoder exists. The worker converts HEIC→JPEG (quality 95, temp file in the service's data dir) via the first available of: `HEIC_CONVERTER` (explicit path/command), `vips copy` (Debian package `libvips-tools`), `heif-convert` (package `libheif-examples`), then feeds the JPEG into the normal pure-Go pipeline. The Go binary itself stays cgo-free/static; the converter is a runtime host dependency (added to the systemd host setup and the Dockerfile — which therefore uses `debian:bookworm-slim` rather than distroless). Missing converter ⇒ HEIC uploads get a clear 415 and startup logs a warning. Original `.heic` is stored byte-identical and served for download with its real content type. The sniffer recognizes `ftyp` brands `heic|heix|hevc|hevx|mif1|msf1`.
- AVIF: accepted at upload only if decodable; stdlib has no AVIF decoder, so in practice AVIF is rejected with an explicit message (can ride the same converter escape hatch later if needed).
**Variants** (originals stored byte-identical, never touched):
| Variant | Long edge | Format | Quality | Purpose |
|---|---|---|---|---|
| `thumb` | 512 px | JPEG | 78 | grid |
| `preview` | 2048 px | JPEG | 85 | lightbox |
| `original` | — | as uploaded | — | download |
Re-encoding variants strips EXIF (including GPS — a privacy win for published pages); orientation is applied to pixels first. `width/height/taken_at` recorded on the row from the original.
**Queue/retry:** the `photos.photos` row is the job. Worker = single goroutine pool (size `WORKER_CONCURRENCY`, default 2) polling with `SELECT ... WHERE status IN ('queued','failed') AND attempts < 5 AND next_attempt_at <= now() ORDER BY created_at LIMIT n FOR UPDATE SKIP LOCKED`, plus an in-process channel nudge after each upload so the poll interval (10 s) is only a fallback. On error: `attempts+1`, exponential `next_attempt_at`, `last_error`; after 5 attempts stays `failed` and the admin UI shows a retry button (`/photos/:id/retry`). `FOR UPDATE SKIP LOCKED` makes multi-instance safe for free. Crash recovery: on startup, rows stuck in `processing` older than 10 min are re-queued.
## 7. Access control matrix
Caller types: **anon** (no/invalid JWT), **member** (valid JWT, any role), **ticket-holder** (member with paid ticket for the gallery's linked event), **link-holder** (anyone presenting the gallery's `share_token`), **admin** (JWT role `admin|organizer`).
| Visibility | anon | member | ticket-holder | link-holder | admin |
|---|---|---|---|---|---|
| `public` | ✅ view/download | ✅ | ✅ | ✅ | ✅ + manage |
| `private` | ❌ 404 | ❌ 404 | ❌ 404 | ❌ 404 (token ignored) | ✅ + manage |
| `link` | ❌ 404 without token | ❌ 404 without token | ❌ 404 without token | ✅ with valid token | ✅ + manage |
| `ticket` | ❌ 401 | ❌ 403 | ✅ | valid token also grants ✅ (link is a superset escape hatch; token exists on every gallery but is only honored for `link` and `ticket` modes) | ✅ + manage |
- Listing: only `public` galleries ever appear in `/api/photos/public/galleries`. `link`/`ticket`/`private` are unlisted; non-public misses return **404, not 403**, so gallery existence isn't probeable (exception: `ticket` mode returns 401/403 so the frontend can prompt login/purchase — the gallery's existence is intentionally discoverable via its event page).
- Enforcement lives in **one function**, `store.Authorize(ctx, gallery, caller, token)`, called in exactly two places: the gallery-view handler and the **file handler** — every byte served re-checks; there are no capability URLs except the explicit share token, and S3 presigned URLs are short-lived (15 min) so a leaked presign expires.
- **Ticket check** (the `ticket` row + `payments` join, per how "paid" actually works here):
```sql
SELECT 1
FROM tickets t
JOIN events e ON e.id = t.event_id
LEFT JOIN payments p ON p.ticket_id = t.id
WHERE t.user_id = ? AND t.event_id = ?
AND t.status IN ('confirmed','checked_in')
AND (p.status = 'paid' OR e.price = 0) -- review decision: free events need only a confirmed ticket
LIMIT 1;
```
Chosen over the alternative (forwarding the user's JWT to the backend's existing `GET /api/dashboard/tickets`, `backend/src/routes/dashboard.ts:98`) because: we are already in the same database for our own tables, it is one indexed query instead of an HTTP hop + JSON parse, it has no runtime dependency on the backend being up, and it needs zero backend changes. The cost is read-coupling to four `public` columns (`tickets.user_id/event_id/status`, `payments.ticket_id/status`) — mitigated by confining every `public.*` query to `internal/store/access.go` and a startup sanity check that those columns exist (fail loud, not wrong).
- **JWT verification** in Go: HS256 + `iss=spanglish` + `aud=spanglish-app`, then one `SELECT role, token_version, account_status FROM public.users WHERE id=$1` to enforce the same revocation semantics as `getAuthUser` (`backend/src/lib/auth.ts:320-327`). Skipping the DB check would silently weaken logout-everywhere/suspension for photo routes; since we're in the DB anyway, we match the backend exactly.
## 8. Frontend work
New files (all following existing patterns — `fetchApi`, `useLanguage().t`, Tailwind theme, `ui/` primitives):
- `src/lib/api/photos.ts` — API module (types + calls for every endpoint in §3), re-exported from `src/lib/api/index.ts`.
- **Admin**`src/app/admin/photos/page.tsx`: gallery list (cards: cover thumb, title, visibility badge, event, count; create-gallery modal). `src/app/admin/photos/[id]/page.tsx`: single gallery manager — multi-file upload (input + drag-drop, per-file progress, `status` polling for processing), thumbnail grid with drag-to-reorder (HTML5 DnD, no new dep; persists via `PATCH .../order`), visibility selector, event linker (reuses events list API), copy-share-link button (`navigator.clipboard`, `react-hot-toast`), set-cover, delete, retry-failed. Modeled on `admin/gallery/page.tsx` + `admin/events/[id]/` structurally.
- **Public**`src/app/(public)/photos/page.tsx`: server component (fetch public index, `revalidate: 300`, metadata) → `PhotosIndexClient.tsx` grid. `src/app/(public)/photos/[slug]/page.tsx`: server component fetching the gallery *without* token (public galleries get SSR/SEO; link/ticket galleries fall through) → `GalleryClient.tsx` which re-fetches client-side with `?token=` from `useSearchParams()` and the Bearer token, and renders: masonry-ish thumb grid (`ImageGridSkeleton` while loading), lightbox (new shared `src/components/Lightbox.tsx` — full-screen `fixed inset-0 bg-black/90`, keyboard/swipe nav, preview variant, download button → original URL; pattern extracted from the inline modal in `admin/gallery/page.tsx`), download-original per photo, ticket-mode gates (401 → login redirect with `?redirect=`, 403 → "this gallery is for attendees" message linking the event).
- i18n: new `photos.*` and `admin.nav.photos` keys in `src/i18n/locales/{en,es}.json`.
Existing files changed (complete list):
| File | Change |
|---|---|
| `src/app/admin/layout.tsx` | one entry in `navigationWithRoles`: `{ name: t('admin.nav.photos'), href: '/admin/photos', icon: CameraIcon, allowedRoles: ['admin','organizer'] }` (CameraIcon, since PhotoIcon is taken by the existing Gallery item) |
| `next.config.js` | prepend rewrite `{ source: '/api/photos/:path*', destination: PHOTO_API_URL + '/api/photos/:path*' }` **before** the generic `/api/:path*` rule (rewrites are order-sensitive); `PHOTO_API_URL` default `http://localhost:3003` |
| `src/lib/api/index.ts` | re-export `photos` module |
| `src/i18n/locales/en.json`, `es.json` | new keys |
| `src/app/sitemap.ts` | add `/photos` + public gallery slugs (nice-to-have, phase 7) |
Naming note: the admin nav will then contain both **"Gallery"** (existing homepage-media page at `/admin/gallery`) and **"Photos"** (new). Requirement says "Photos"; flagged in Questions since admins may find the pair confusing.
## 9. Deployment and config
New files in `deploy/` (mirroring existing ones; no existing file is *replaced* — the two nginx confs get additive location blocks):
- `deploy/spanglish-photos.service` — copy of the backend unit shape: user `spanglish`, `ExecStartPre=…/photo-api/bin/photo-api migrate`, `ExecStart=…/photo-api/bin/photo-api`, `EnvironmentFile=…/photo-api/.env`, `PORT=3020`, hardening incl. `ReadWritePaths=…/photo-api/data`.
- `deploy/spanglish_upstreams.conf` — add `upstream spanglish_photos { server 127.0.0.1:3020; }`.
- `deploy/front-end_nginx.conf` — add, above `location /api`: `location ^~ /api/photos/ { proxy_pass http://spanglish_photos; client_max_body_size 100m; proxy_request_buffering off; }` (uploads are big; the vhost default is 20m). Same block in `deploy/back-end_nginx.conf` if the api subdomain should serve it too.
- `photo-api/Dockerfile` — secondary/container path: `golang:1.26` build stage (`CGO_ENABLED=0`), `gcr.io/distroless/static` runtime; and a commented `photos` service example for `deploy/docker-compose.scale.yml` (compose file itself untouched until that path is actually used).
`photo-api/.env.example`:
```bash
PORT=3003 # dev; systemd unit overrides to 3020
DB_TYPE=postgres # postgres | sqlite — must match the backend's engine
DATABASE_URL=postgresql://... # SAME database as backend (photos_* tables created by migrate);
# for sqlite: path to the backend's db file, e.g. ../backend/data/spanglish.db
JWT_SECRET= # MUST equal backend/.env JWT_SECRET
HEIC_CONVERTER= # optional; default autodetects `vips`, then `heif-convert`
STORAGE_PATH=./data/photos # local storage root (ignored when S3 is on)
S3_ENDPOINT= # + S3_BUCKET both set => S3 backend (same convention as backend)
S3_REGION=auto
S3_BUCKET= # use a photos-specific bucket
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=true
MAX_UPLOAD_MB=50 # per file
WORKER_CONCURRENCY=2
FRONTEND_URL=https://spanglishcommunity.com # for building share links
```
Frontend `.env` gains `PHOTO_API_URL=http://localhost:3003` (dev; prod nginx routes directly so the rewrite is unused there, matching how `/api` works today).
## 10. Build sequence
1. **Scaffold + DB.** `go.mod`, config, `cmd/photo-api` with `serve`/`migrate`, migration runner + `0001_init.sql`, `/health`, root `package.json` scripts. *Test: `photo-api migrate` creates `photos.*` against the dev DB; `psql \dn` shows the schema; re-run is a no-op; `drizzle-kit generate` still produces no diff.*
2. **Auth + gallery CRUD.** JWT middleware (incl. `token_version` check), all `/galleries` admin endpoints. *Test: curl with a real token from the running backend login; non-admin gets 403; CRUD round-trips.*
3. **Upload + local storage + worker.** Multipart handler, sniffing, `LocalStorage`, imaging pipeline, queue/retry, `files/:id/:variant` for admins. *Test: upload a batch of real photos incl. a portrait-orientation iPhone JPEG (EXIF rotation) and a deliberately corrupt file; variants appear; corrupt file lands in `failed` with `last_error`; retry works.*
4. **Access control + public endpoints.** `Authorize`, ticket-check query, `public/galleries`, `public/galleries/:slug`, token handling, presign-vs-stream in file handler. *Test: matrix in §7 as a Go table test against seeded DB rows (paid/pending/refunded payments), plus curl for each caller type.*
5. **S3 backend.** `S3Storage` + presigned GETs, verified against a local MinIO/Garage. *Test: same upload/download flows with `S3_*` set; private gallery file URL fails without presign.*
6. **Admin frontend.** `/admin/photos` pages, nav item, api module, i18n. *Test: create → upload → reorder → visibility → copy link, in-browser against dev services.*
7. **Public frontend.** `/photos`, `/photos/[slug]`, Lightbox, download, gated states, sitemap. *Test: all four visibility modes in-browser, incl. link URL in an incognito window and ticket mode with a paid vs pending test user.*
8. **Deploy.** systemd unit, nginx blocks, `.env.example`s, Dockerfile, README section in `photo-api/`. *Test: on the host — `systemctl start spanglish-photos`, nginx reload, end-to-end over HTTPS.*
## 11. Risks and open questions
**Risks / could-not-determine:**
- **Production DB engine is unverifiable from the repo** (local `backend/.env` says `DB_TYPE=postgres`; prod env is gitignored). Mitigated by the review decision to support both engines — whichever prod runs, photo-api points at the same database with matching `DB_TYPE`. On SQLite, two processes share one file: WAL is already on, photo-api adds `busy_timeout` and short write transactions, but write contention under heavy simultaneous booking + photo-processing load is a residual (small) risk.
- **Schema read-coupling.** The ticket/user queries read five `public` columns Drizzle owns. A future rename breaks photo access checks at runtime; mitigated by the startup column sanity-check (fail fast) and by the queries living in one file.
- **Free events.** If a PYG-0 event issues `confirmed` tickets whose payment row is not `'paid'` (e.g. no payment or `cash`/`pending`), the paid-ticket join denies attendees. I could not confirm from the code what a free booking writes into `payments`. May need `OR (e.price = 0 AND t.status IN ('confirmed','checked_in'))`.
- **Guest attendees** (`tickets.is_guest`, tickets bought by someone else): access is keyed on `tickets.user_id`, so a +1 without their own account cannot pass `ticket` gating. Workaround exists (admin shares the link token), but it's a product gap to acknowledge.
- **HEIC depends on a host-installed converter** (`vips` or `heif-convert`). If the prod host lacks the package, HEIC uploads 415 until it's installed; startup logs a warning so this is visible, and the deploy notes include the `apt install libvips-tools` step.
- **Nginx body size / timeouts** for 100 MB batch uploads over spotty Paraguayan mobile connections; `proxy_request_buffering off` planned, but real-world testing is in phase 8.
- **1-day JWT expiry with no working refresh flow** (refresh tokens are minted but no endpoint consumes them) means long admin upload sessions can hit 401 mid-batch; the admin UI must surface that as "session expired, log in again" rather than a generic failure.
**Questions — answered in review 2026-07-24** (see the revision note at the top): dual Postgres+SQLite support; systemd-first confirmed; free events count any confirmed ticket; nav name "Photos" kept; HEIC supported via converter shell-out; `admin`/`organizer` only; local disk and S3 both supported from day 1; the service keeps its own `.env`. The production engine check (and, if Postgres, connectivity for the `spanglish` role) is still on the user's side.
+92
View File
@@ -0,0 +1,92 @@
# photo-api
Standalone Go service for event photo galleries: admins upload photos from
past events, group them into galleries, and share them with the community.
Lives in this monorepo but is its own module, binary, and deployable unit.
Design/decisions: [PLAN.md](./PLAN.md).
## What it does
- Galleries with four visibility modes: `public` (listed on /photos),
`private` (admins only), `link` (share-token URL), `ticket` (logged-in
users with a confirmed ticket for the linked event — any confirmed ticket
when the event is free).
- Originals are stored byte-identical for download; a worker generates JPEG
variants (thumb 512px q78, preview 2048px q85, EXIF stripped/orientation
applied) queued in the DB with retries.
- Storage: local disk (`STORAGE_PATH`) or S3/Garage/MinIO (set `S3_ENDPOINT`
+ `S3_BUCKET`), same selection convention as the backend. S3 downloads use
short-lived presigned URLs; nothing in the bucket is public.
- Auth: validates the backend's HS256 JWTs with the shared `JWT_SECRET`
(issuer `spanglish`, audience `spanglish-app`) including the DB-backed
tokenVersion/account-status revocation check. Admin surface is
`admin`/`organizer` only.
- Database: the same Postgres or SQLite database as the backend (`DB_TYPE`,
`DATABASE_URL`). This service owns only the `photos_*` tables via its own
embedded migrations (`photo-api migrate`); drizzle-kit never sees them.
Reads of backend tables are confined to `internal/store/access.go` and
sanity-checked at startup.
## Develop
```bash
cp .env.example .env # set JWT_SECRET + DATABASE_URL to match backend/.env
go run ./cmd/photo-api migrate
go run ./cmd/photo-api # serves on :3003 (dev)
go test ./... # SQLite; add PHOTO_TEST_PG=<url> to also run on Postgres
```
From the repo root: `npm run dev:photos`, `npm run build:photos`,
`npm run test:photos`. The Next dev server rewrites `/api/photos/*` to
`PHOTO_API_URL` (default `http://localhost:3003`), so the frontend needs no
extra config in dev.
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
message and a startup warning is logged.
## API
Everything under `/api/photos`. Errors are `{"error": string}`.
Admin (Bearer token, role admin/organizer):
| Method | Path | Purpose |
|---|---|---|
| POST | `/api/photos/galleries` | create gallery |
| GET | `/api/photos/galleries` | list all (`?eventId=`) |
| GET | `/api/photos/galleries/:id` | gallery + photos (all statuses) |
| PATCH | `/api/photos/galleries/:id` | update title/visibility/event/cover |
| DELETE | `/api/photos/galleries/:id` | delete gallery + objects |
| POST | `/api/photos/galleries/:id/photos` | multipart upload (`files`) |
| PATCH | `/api/photos/galleries/:id/order` | reorder (`{photoIds}`) |
| POST | `/api/photos/galleries/:id/share-token` | rotate share token |
| DELETE | `/api/photos/photos/:photoId` | delete photo |
| POST | `/api/photos/photos/:photoId/retry` | requeue failed photo |
Viewer (anonymous or member token; `?token=` carries the share token):
| Method | Path | Purpose |
|---|---|---|
| GET | `/api/photos/public/galleries` | public index |
| GET | `/api/photos/public/galleries/:slug` | gallery view (access-checked) |
| GET | `/api/photos/public/events/:eventSlug/gallery` | newest gallery of an event (access-checked) |
| GET | `/api/photos/files/:photoId/:variant` | bytes; `thumb\|preview\|original` |
| GET | `/api/photos/health` | liveness |
## Deploy (systemd, primary)
```bash
cd photo-api && go build -o bin/photo-api ./cmd/photo-api
sudo apt install libvips-tools
sudo cp ../deploy/spanglish-photos.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now spanglish-photos
sudo nginx -t && sudo systemctl reload nginx # after installing the updated confs
```
nginx routes `location ^~ /api/photos/``127.0.0.1:3020` (see
`deploy/front-end_nginx.conf`, `deploy/back-end_nginx.conf`,
`deploy/spanglish_upstreams.conf`). The frontend's production `.env` should
set `PHOTO_API_URL=http://127.0.0.1:3020` for server-side rendering of the
public gallery pages. A `Dockerfile` is included as a secondary path for the
compose-scale setup.
+106
View File
@@ -0,0 +1,106 @@
// photo-api serves event photo galleries for the Spanglish platform.
//
// photo-api start the HTTP server
// photo-api migrate apply pending photos_* migrations and exit
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
"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/imaging"
"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/worker"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("photo-api: ")
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
db, err := store.Open(cfg)
if err != nil {
log.Fatalf("database: %v", err)
}
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 {
log.Fatalf("unknown subcommand %q (expected: migrate)", os.Args[1])
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := db.PingContext(ctx); err != nil {
log.Fatalf("database ping: %v", err)
}
// Fail fast if the columns read from Drizzle-owned tables changed.
if err := db.CheckMainSchema(ctx); err != nil {
log.Fatalf("%v", err)
}
st, err := storage.New(cfg)
if err != nil {
log.Fatalf("storage: %v", err)
}
if cfg.S3Enabled() {
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)
if heic == nil {
log.Println("WARNING: no HEIC converter found (install libvips-tools or libheif-examples); HEIC uploads will be rejected")
} else {
log.Printf("HEIC converter: %s", heic.Name())
}
if err := os.MkdirAll(cfg.StoragePath, 0o755); err != nil {
log.Fatalf("storage path: %v", err)
}
wrk := worker.New(db, st, heic, cfg.StoragePath, cfg.WorkerConcurrency)
wrk.Run(ctx)
server := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: httpapi.New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk).Handler(),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Printf("listening on :%d", cfg.Port)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("serve: %v", err)
}
}()
<-ctx.Done()
log.Println("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
}
+46
View File
@@ -0,0 +1,46 @@
module git.azzamo.net/Michilis/Spanglish/photo-api
go 1.26
require (
github.com/aws/aws-sdk-go-v2 v1.43.0
github.com/aws/aws-sdk-go-v2/config v1.32.31
github.com/aws/aws-sdk-go-v2/credentials v1.19.30
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
golang.org/x/image v0.44.0
modernc.org/sqlite v1.54.0
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect
github.com/aws/smithy-go v1.27.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.40.0 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+117
View File
@@ -0,0 +1,117 @@
github.com/aws/aws-sdk-go-v2 v1.43.0 h1:fharf/WhbRAVZ1du0QL7roNFxZ6T/sWr+4Ni617bwSI=
github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
github.com/aws/aws-sdk-go-v2/config v1.32.31 h1:n4nY9O3QKoHIkL85EX+V8RcMFtOhlpTFhGArg915PXk=
github.com/aws/aws-sdk-go-v2/config v1.32.31/go.mod h1:PN0NYDCCoOpGGsZ2+elDUidmHfQBPyYzN2GCgl8HEBs=
github.com/aws/aws-sdk-go-v2/credentials v1.19.30 h1:TTCvvzFU6gXa4iJecNG/0F/B0oYTiazoRECr2XyLHrY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 h1:kfVL5wAunCJycL6MOQ6aNh6PlAYEymflcjuKmrWUA0o=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31/go.mod h1:nWfRNDAppujCQgOUd43lKT4yeLv9z3nJ3bw1G3BgQKo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 h1:Z8F3hfCY33IGpJjFAnv0wvtv1FIKj1GHmRDEYqy64tw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31/go.mod h1:aVyUoytEyOViR6jhq6jula0xkc5NfBE2hgeF6BvOrao=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 h1:hyOxUyXdh3AyjE93gBgsfziJag9ACwcs+ZpDBLzi8mw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31/go.mod h1:OERqI9k0draSLB8O8woxY3q25ZWTELRK4RRoLMuMZFo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 h1:0MrUL35H/Y4kdFfItoR5jCgtDQ4Z/8LudAoIHRfA4hE=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32/go.mod h1:2tNZkuWz54arj8mHVf+8Y7cKkcD8Wr/fBpENgEXpjLc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 h1:mdPwDQPqxlw9Sc62Nt15yjEcARaDbPXkjRYtXsUripo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24/go.mod h1:ls5ytnwLTcQaUu32fMYXFI3MjpKuTwL840PAm9iqyEg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 h1:w2SIhW92DZPFrSL4ksVCr8IYff5OZwIcxg8+95tzvAI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31/go.mod h1:wAhpCQbkov+IcvjozJbd2xRCoZybUEHNkcFunssNACg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 h1:jWXtZdCnhXa9sGFixRaU2AxT4DIVse9HS4E2f+/KwV0=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32/go.mod h1:9JS1UpfVvyD/ZPX8GsKb/Pq8scEM+7GP5fqh9SwH7po=
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 h1:7QZWVJZWzHivHWIa+5TELLaBBkbuoj0GPwQtMlJ0sqk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0/go.mod h1:fcvq5L7dK+5cQFicEJwpI6e6Wn8NY2i6yT5wRLYVc7s=
github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 h1:OHH5iTQvVGmfHjX/5Q+vFuA/Rf2x6/95aJ/75QCQSm4=
github.com/aws/aws-sdk-go-v2/service/signin v1.5.0/go.mod h1:mCF3AK9PpL49oOrhniUXWAfhVBVQ/XbytoE5eccZUIs=
github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 h1:CaJyYhxBE0M/HJX/YvSaSmQlsI91VHB0lKU8LtLxL3A=
github.com/aws/aws-sdk-go-v2/service/sso v1.33.0/go.mod h1:+e6BMRMPjBQoCw/WovYR9GLy2IU0z4Q77smOB1DraSg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 h1:tC323YV77QdafeBr6LUhLDTsboyuyHLNRwAyCP44kGU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0/go.mod h1:SfLK1sgviHmbI+MozR9iDwDjL4cdCVZtahsjoR+z7wg=
github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 h1:Pd6PNlp4t8PTXxqzstICl52Wsy78vpjFZ7PRUj44mJc=
github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg=
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+99
View File
@@ -0,0 +1,99 @@
// Package auth validates the JWTs minted by backend/src/lib/auth.ts:
// HS256 with the shared JWT_SECRET, issuer "spanglish", audience
// "spanglish-app", plus the same DB-backed tokenVersion / account_status
// revocation check the backend's getAuthUser performs.
package auth
import (
"errors"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
const (
issuer = "spanglish"
audience = "spanglish-app"
)
var (
ErrNoToken = errors.New("no bearer token")
ErrInvalidToken = errors.New("invalid token")
)
// AdminRoles mirrors backend/src/routes/media.ts: photo administration is
// admin/organizer only (review decision #6).
var AdminRoles = []string{"admin", "organizer"}
type User struct {
ID string
Email string
Role string
}
func (u User) IsAdmin() bool {
for _, r := range AdminRoles {
if u.Role == r {
return true
}
}
return false
}
type claims struct {
Email string `json:"email"`
Role string `json:"role"`
TokenVersion *int `json:"tokenVersion"`
jwt.RegisteredClaims
}
type Verifier struct {
secret []byte
db *store.DB
}
func NewVerifier(secret string, db *store.DB) *Verifier {
return &Verifier{secret: []byte(secret), db: db}
}
// FromRequest returns the authenticated user, ErrNoToken when no
// Authorization header is present, or ErrInvalidToken for anything bad.
func (v *Verifier) FromRequest(r *http.Request) (User, error) {
header := r.Header.Get("Authorization")
if header == "" {
return User{}, ErrNoToken
}
raw, ok := strings.CutPrefix(header, "Bearer ")
if !ok {
return User{}, ErrInvalidToken
}
var c claims
_, err := jwt.ParseWithClaims(raw, &c, func(*jwt.Token) (any, error) { return v.secret, nil },
jwt.WithValidMethods([]string{"HS256"}),
jwt.WithIssuer(issuer),
jwt.WithAudience(audience),
jwt.WithExpirationRequired(),
)
if err != nil || c.Subject == "" {
return User{}, ErrInvalidToken
}
// DB-backed revocation, same semantics as backend getAuthUser
// (backend/src/lib/auth.ts:320-327).
ua, err := v.db.GetUserAuth(r.Context(), c.Subject)
if err != nil {
return User{}, ErrInvalidToken
}
if ua.AccountStatus != "active" {
return User{}, ErrInvalidToken
}
if c.TokenVersion != nil && *c.TokenVersion != ua.TokenVersion {
return User{}, ErrInvalidToken
}
// Role from the DB, not the token, so demotions apply immediately.
return User{ID: ua.ID, Email: c.Email, Role: ua.Role}, nil
}
+126
View File
@@ -0,0 +1,126 @@
// Package config loads photo-api configuration from the environment,
// following the backend's convention of a per-service .env file with
// ad-hoc defaults (backend/src/index.ts uses dotenv the same way).
package config
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
type Config struct {
Port int
DBType string // "postgres" | "sqlite", same convention as backend DB_TYPE
DatabaseURL string
JWTSecret string
StoragePath string
S3Endpoint string
S3Region string
S3Bucket string
S3AccessKeyID string
S3SecretKey string
S3ForcePathStyle bool
MaxUploadMB int
WorkerConcurrency int
FrontendURL string
HeicConverter string // optional explicit converter command; autodetected when empty
}
// S3Enabled mirrors backend/src/lib/storage.ts: S3 is active when both
// S3_ENDPOINT and S3_BUCKET are set.
func (c Config) S3Enabled() bool {
return c.S3Endpoint != "" && c.S3Bucket != ""
}
func Load() (Config, error) {
loadDotenv(".env")
cfg := Config{
Port: envInt("PORT", 3003),
DBType: strings.ToLower(env("DB_TYPE", "sqlite")),
DatabaseURL: env("DATABASE_URL", ""),
JWTSecret: env("JWT_SECRET", ""),
StoragePath: env("STORAGE_PATH", "./data/photos"),
S3Endpoint: env("S3_ENDPOINT", ""),
S3Region: env("S3_REGION", "auto"),
S3Bucket: env("S3_BUCKET", ""),
S3AccessKeyID: env("S3_ACCESS_KEY_ID", ""),
S3SecretKey: env("S3_SECRET_ACCESS_KEY", ""),
S3ForcePathStyle: envBool("S3_FORCE_PATH_STYLE", true),
MaxUploadMB: envInt("MAX_UPLOAD_MB", 50),
WorkerConcurrency: envInt("WORKER_CONCURRENCY", 2),
FrontendURL: strings.TrimRight(env("FRONTEND_URL", "http://localhost:3000"), "/"),
HeicConverter: env("HEIC_CONVERTER", ""),
}
if cfg.DBType != "postgres" && cfg.DBType != "sqlite" {
return cfg, fmt.Errorf("DB_TYPE must be postgres or sqlite, got %q", cfg.DBType)
}
if cfg.DatabaseURL == "" {
return cfg, fmt.Errorf("DATABASE_URL is required")
}
if cfg.JWTSecret == "" {
return cfg, fmt.Errorf("JWT_SECRET is required (must match backend/.env)")
}
return cfg, nil
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func envBool(key string, def bool) bool {
if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return def
}
// loadDotenv is a minimal KEY=VALUE loader (comments and blank lines
// ignored, optional surrounding quotes stripped, existing env wins).
func loadDotenv(path string) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
if len(val) >= 2 && (val[0] == '"' || val[0] == '\'') && val[len(val)-1] == val[0] {
val = val[1 : len(val)-1]
}
if _, exists := os.LookupEnv(key); !exists {
os.Setenv(key, val)
}
}
}
+55
View File
@@ -0,0 +1,55 @@
package httpapi
import (
"net/http"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// accessDenial describes why a viewer may not see a gallery. Non-public
// galleries return 404 (not 403) so their existence is not probeable — the
// exception is ticket mode, whose 401/403 lets the frontend prompt login
// or explain the attendee requirement (its existence is already public via
// the event).
type accessDenial struct {
status int
msg string
}
// authorize is the single access-control decision point (PLAN.md §7); it is
// called from both the gallery-view handler and the file handler so every
// byte served re-checks.
func (s *Server) authorize(r *http.Request, g store.Gallery, user *auth.User, token string) *accessDenial {
if user != nil && user.IsAdmin() {
return nil
}
switch g.Visibility {
case store.VisibilityPublic:
return nil
case store.VisibilityLink:
if token != "" && token == g.ShareToken {
return nil
}
return &accessDenial{http.StatusNotFound, "Gallery not found"}
case store.VisibilityTicket:
// The share token is honored as an escape hatch (e.g. attendee +1s
// without accounts, at the admin's discretion).
if token != "" && token == g.ShareToken {
return nil
}
if user == nil {
return &accessDenial{http.StatusUnauthorized, "Authentication required"}
}
if g.EventID == "" {
return &accessDenial{http.StatusForbidden, "This gallery is only available to event attendees"}
}
ok, err := s.db.HasPaidTicket(r.Context(), user.ID, g.EventID)
if err != nil || !ok {
return &accessDenial{http.StatusForbidden, "This gallery is only available to event attendees"}
}
return nil
default: // private
return &accessDenial{http.StatusNotFound, "Gallery not found"}
}
}
+504
View File
@@ -0,0 +1,504 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"image"
"image/color"
"image/jpeg"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
"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"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker"
)
const testSecret = "test-secret"
// Seed ids are UUID-formatted because the Postgres dialect uses uuid columns.
const (
uAdmin = "11111111-1111-1111-1111-111111111111"
uMember = "22222222-2222-2222-2222-222222222222"
uBuyer = "33333333-3333-3333-3333-333333333333"
uPending = "44444444-4444-4444-4444-444444444444"
uFree = "55555555-5555-5555-5555-555555555555"
evPaid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
evFree = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
)
type testEnv struct {
handler http.Handler
db *store.DB
worker *worker.Worker
}
// setup migrates a scratch DB (SQLite by default; Postgres when
// PHOTO_TEST_PG holds a connection URL), seeds the minimal slice of the
// main app schema that access.go reads, and returns a ready handler.
func setup(t *testing.T) *testEnv {
t.Helper()
dir := t.TempDir()
cfg := config.Config{
Port: 0,
DBType: "sqlite",
DatabaseURL: filepath.Join(dir, "test.db"),
JWTSecret: testSecret,
StoragePath: filepath.Join(dir, "photos"),
MaxUploadMB: 5,
WorkerConcurrency: 1,
FrontendURL: "http://localhost:3000",
}
if pgURL := os.Getenv("PHOTO_TEST_PG"); pgURL != "" {
cfg.DBType = "postgres"
cfg.DatabaseURL = pgURL
}
db, err := store.Open(cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if cfg.DBType == "postgres" {
// The scratch Postgres DB persists across tests; start clean.
if _, err := db.Exec(`DROP TABLE IF EXISTS photos_photos, photos_galleries, photos_schema_migrations, users, events, tickets, payments CASCADE`); err != nil {
t.Fatal(err)
}
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatal(err)
}
idType := "text"
if cfg.DBType == "postgres" {
idType = "uuid"
}
seed := []string{
`CREATE TABLE users (id ` + idType + ` PRIMARY KEY, email text, name text, role text, token_version integer NOT NULL DEFAULT 0, account_status text NOT NULL DEFAULT 'active')`,
`CREATE TABLE events (id ` + idType + ` PRIMARY KEY, slug text, title text, title_es text, start_datetime text, status text, price real)`,
`CREATE TABLE tickets (id text PRIMARY KEY, user_id ` + idType + `, event_id ` + idType + `, status text)`,
`CREATE TABLE payments (id text PRIMARY KEY, ticket_id text, status text)`,
`INSERT INTO users VALUES ('` + uAdmin + `','a@x.py','Admin','admin',0,'active')`,
`INSERT INTO users VALUES ('` + uMember + `','m@x.py','Member','user',0,'active')`,
`INSERT INTO users VALUES ('` + uBuyer + `','b@x.py','Buyer','user',0,'active')`,
`INSERT INTO users VALUES ('` + uPending + `','p@x.py','Pending','user',0,'active')`,
`INSERT INTO users VALUES ('` + uFree + `','f@x.py','Free','user',0,'active')`,
`INSERT INTO events VALUES ('` + evPaid + `','fiesta','Fiesta','Fiesta ES','2026-06-01T20:00:00.000Z','completed',50000)`,
`INSERT INTO events VALUES ('` + evFree + `','gratis','Gratis','Gratis ES','2026-06-02T20:00:00.000Z','completed',0)`,
`INSERT INTO tickets VALUES ('t1','` + uBuyer + `','` + evPaid + `','confirmed')`,
`INSERT INTO payments VALUES ('p1','t1','paid')`,
`INSERT INTO tickets VALUES ('t2','` + uPending + `','` + evPaid + `','pending')`,
`INSERT INTO payments VALUES ('p2','t2','pending')`,
`INSERT INTO tickets VALUES ('t3','` + uFree + `','` + evFree + `','confirmed')`,
}
for _, q := range seed {
if _, err := db.Exec(q); err != nil {
t.Fatalf("seed %q: %v", q, err)
}
}
st, err := storage.New(cfg)
if err != nil {
t.Fatal(err)
}
wrk := worker.New(db, st, nil, cfg.StoragePath, 1)
srv := New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk)
return &testEnv{handler: srv.Handler(), db: db, worker: wrk}
}
func makeToken(t *testing.T, sub, email, role string) string {
t.Helper()
tv := 0
claims := jwt.MapClaims{
"sub": sub, "email": email, "role": role, "tokenVersion": tv,
"iss": "spanglish", "aud": "spanglish-app",
"iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(),
}
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(testSecret))
if err != nil {
t.Fatal(err)
}
return s
}
func (e *testEnv) request(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
reader = bytes.NewReader(b)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, reader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
e.handler.ServeHTTP(w, req)
return w
}
func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T {
t.Helper()
var v T
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
t.Fatalf("decode %s: %v", w.Body.String(), err)
}
return v
}
func testJPEG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 800, 600))
for x := 0; x < 800; x += 10 {
for y := 0; y < 600; y++ {
img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: 128, A: 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, nil); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestHealth(t *testing.T) {
e := setup(t)
if w := e.request(t, "GET", "/api/photos/health", "", nil); w.Code != 200 {
t.Fatalf("health: %d", w.Code)
}
}
func TestAdminGate(t *testing.T) {
e := setup(t)
body := map[string]string{"title": "Test"}
if w := e.request(t, "POST", "/api/photos/galleries", "", body); w.Code != 401 {
t.Fatalf("anon create: want 401, got %d", w.Code)
}
member := makeToken(t, uMember, "m@x.py", "user")
if w := e.request(t, "POST", "/api/photos/galleries", member, body); w.Code != 403 {
t.Fatalf("member create: want 403, got %d", w.Code)
}
unknown := makeToken(t, newID(), "g@x.py", "admin")
if w := e.request(t, "POST", "/api/photos/galleries", unknown, body); w.Code != 401 {
t.Fatalf("unknown-user token: want 401, got %d", w.Code)
}
}
type galleryResp struct {
Gallery galleryJSON `json:"gallery"`
Photos []photoJSON `json:"photos"`
}
func createGallery(t *testing.T, e *testEnv, admin string, body map[string]any) galleryJSON {
t.Helper()
w := e.request(t, "POST", "/api/photos/galleries", admin, body)
if w.Code != 201 {
t.Fatalf("create gallery: %d %s", w.Code, w.Body.String())
}
return decode[galleryResp](t, w).Gallery
}
func uploadPhoto(t *testing.T, e *testEnv, admin, galleryID string, file []byte) photoJSON {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, _ := mw.CreateFormFile("files", "test photo.jpg")
fw.Write(file)
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())
}
photos := decode[struct {
Photos []photoJSON `json:"photos"`
}](t, w).Photos
if len(photos) != 1 {
t.Fatalf("want 1 photo, got %d", len(photos))
}
return photos[0]
}
// processQueue runs the worker until the photo is ready or failed.
func processQueue(t *testing.T, e *testEnv, photoID string) store.Photo {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
e.worker.Run(ctx)
e.worker.Nudge()
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
p, err := e.db.GetPhoto(ctx, photoID)
if err != nil {
t.Fatal(err)
}
if p.Status == "ready" || (p.Status == "failed" && p.Attempts > 0) {
return p
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("timeout waiting for processing")
return store.Photo{}
}
func TestUploadProcessAndServe(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
g := createGallery(t, e, admin, map[string]any{"title": "Fiesta de Junio", "visibility": "public"})
if g.Slug != "fiesta-de-junio" {
t.Fatalf("slug: %q", g.Slug)
}
// Public galleries share a clean URL: the token grants nothing there.
if g.ShareToken == "" {
t.Fatalf("share token missing: %+v", g)
}
if strings.Contains(g.ShareURL, "token=") {
t.Fatalf("public gallery share url must not carry a token: %s", g.ShareURL)
}
if !strings.HasSuffix(g.ShareURL, "/photos/"+g.Slug) {
t.Fatalf("public share url: %s", g.ShareURL)
}
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
if p.Status != "queued" || p.ContentType != "image/jpeg" {
t.Fatalf("uploaded photo: %+v", p)
}
done := processQueue(t, e, p.ID)
if done.Status != "ready" {
t.Fatalf("processing failed: %s", done.LastError)
}
if done.Width != 800 || done.Height != 600 {
t.Fatalf("dimensions: %dx%d", done.Width, done.Height)
}
for _, variant := range []string{"thumb", "preview", "original"} {
w := e.request(t, "GET", "/api/photos/files/"+p.ID+"/"+variant, "", nil)
if w.Code != 200 {
t.Fatalf("%s: %d", variant, w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Fatalf("%s content type: %s", variant, ct)
}
}
w := e.request(t, "GET", "/api/photos/files/"+p.ID+"/original", "", nil)
if cd := w.Header().Get("Content-Disposition"); !strings.Contains(cd, "test photo.jpg") {
t.Fatalf("original disposition: %q", cd)
}
// Bad upload is rejected by magic bytes.
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, _ := mw.CreateFormFile("files", "notes.txt")
fw.Write([]byte("not an image at all"))
mw.Close()
req := httptest.NewRequest("POST", "/api/photos/galleries/"+g.ID+"/photos", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+admin)
rec := httptest.NewRecorder()
e.handler.ServeHTTP(rec, req)
if rec.Code != 415 {
t.Fatalf("text upload: want 415, got %d %s", rec.Code, rec.Body.String())
}
}
func TestAccessMatrix(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
member := makeToken(t, uMember, "m@x.py", "user")
buyer := makeToken(t, uBuyer, "b@x.py", "user")
pending := makeToken(t, uPending, "p@x.py", "user")
freeguy := makeToken(t, uFree, "f@x.py", "user")
get := func(slug, token, bearer string) int {
path := "/api/photos/public/galleries/" + slug
if token != "" {
path += "?token=" + token
}
return e.request(t, "GET", path, bearer, nil).Code
}
// private
priv := createGallery(t, e, admin, map[string]any{"title": "Privada", "visibility": "private"})
for name, code := range map[string]int{"anon": get(priv.Slug, "", ""), "member": get(priv.Slug, "", member),
"with-token": get(priv.Slug, priv.ShareToken, "")} {
if code != 404 {
t.Errorf("private/%s: want 404, got %d", name, code)
}
}
if got := get(priv.Slug, "", admin); got != 200 {
t.Errorf("private/admin: want 200, got %d", got)
}
// link
link := createGallery(t, e, admin, map[string]any{"title": "Enlace", "visibility": "link"})
if got := get(link.Slug, "", ""); got != 404 {
t.Errorf("link/anon: want 404, got %d", got)
}
if got := get(link.Slug, "wrong-token", ""); got != 404 {
t.Errorf("link/bad-token: want 404, got %d", got)
}
if got := get(link.Slug, link.ShareToken, ""); got != 200 {
t.Errorf("link/token: want 200, got %d", got)
}
// ticket, paid event
tick := createGallery(t, e, admin, map[string]any{"title": "Con Entrada", "visibility": "ticket", "eventId": evPaid})
if got := get(tick.Slug, "", ""); got != 401 {
t.Errorf("ticket/anon: want 401, got %d", got)
}
if got := get(tick.Slug, "", member); got != 403 {
t.Errorf("ticket/no-ticket: want 403, got %d", got)
}
if got := get(tick.Slug, "", pending); got != 403 {
t.Errorf("ticket/pending-payment: want 403, got %d", got)
}
if got := get(tick.Slug, "", buyer); got != 200 {
t.Errorf("ticket/paid: want 200, got %d", got)
}
if got := get(tick.Slug, tick.ShareToken, ""); got != 200 {
t.Errorf("ticket/share-token: want 200, got %d", got)
}
// ticket, free event: any confirmed ticket counts (review decision)
free := createGallery(t, e, admin, map[string]any{"title": "Evento Gratis", "visibility": "ticket", "eventId": evFree})
if got := get(free.Slug, "", freeguy); got != 200 {
t.Errorf("ticket/free-event-confirmed: want 200, got %d", got)
}
if got := get(free.Slug, "", member); got != 403 {
t.Errorf("ticket/free-event-no-ticket: want 403, got %d", got)
}
// public index lists only public galleries
pub := createGallery(t, e, admin, map[string]any{"title": "Publica", "visibility": "public"})
idx := decode[struct {
Galleries []galleryJSON `json:"galleries"`
}](t, e.request(t, "GET", "/api/photos/public/galleries", "", nil))
if len(idx.Galleries) != 1 || idx.Galleries[0].ID != pub.ID {
t.Errorf("public index: %+v", idx.Galleries)
}
for _, g := range idx.Galleries {
if g.ShareToken != "" {
t.Errorf("public index leaks share token")
}
}
}
func TestReorderAndVisibilityUpdate(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
g := createGallery(t, e, admin, map[string]any{"title": "Orden", "visibility": "public"})
jpg := testJPEG(t)
p1 := uploadPhoto(t, e, admin, g.ID, jpg)
p2 := uploadPhoto(t, e, admin, g.ID, jpg)
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 {
t.Fatalf("reorder: %d %s", w.Code, w.Body.String())
}
detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil))
if len(detail.Photos) != 2 || detail.Photos[0].ID != p2.ID {
t.Fatalf("order not applied: %+v", detail.Photos)
}
if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin,
map[string]any{"photoIds": []string{p1.ID}}); w.Code != 400 {
t.Fatalf("partial reorder: want 400, got %d", w.Code)
}
if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID, admin,
map[string]any{"visibility": "banana"}); w.Code != 400 {
t.Fatalf("bad visibility: want 400, got %d", w.Code)
}
if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID, admin,
map[string]any{"visibility": "link"}); w.Code != 200 {
t.Fatalf("set link: %d %s", w.Code, w.Body.String())
}
if w := e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil); w.Code != 404 {
t.Fatalf("after switch to link, anon: want 404, got %d", w.Code)
}
}
func TestEventGalleryRoute(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
buyer := makeToken(t, uBuyer, "b@x.py", "user")
g := createGallery(t, e, admin, map[string]any{"title": "Del Evento", "visibility": "ticket", "eventId": evPaid})
// Event-linked galleries share via the event URL.
if !strings.Contains(g.ShareURL, "/events/fiesta/gallery?token="+g.ShareToken) {
t.Fatalf("share url should use event route: %s", g.ShareURL)
}
// /public/events/{slug}/gallery obeys the same access rules.
if w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery", "", nil); w.Code != 401 {
t.Fatalf("event gallery anon: want 401, got %d", w.Code)
}
w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery", buyer, nil)
if w.Code != 200 {
t.Fatalf("event gallery buyer: want 200, got %d %s", w.Code, w.Body.String())
}
if got := decode[galleryResp](t, w).Gallery.ID; got != g.ID {
t.Fatalf("wrong gallery: %s != %s", got, g.ID)
}
if w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery?token="+g.ShareToken, "", nil); w.Code != 200 {
t.Fatalf("event gallery share token: want 200, got %d", w.Code)
}
// Unknown event slug and event without a gallery are both 404.
if w := e.request(t, "GET", "/api/photos/public/events/nope/gallery", buyer, nil); w.Code != 404 {
t.Fatalf("unknown event: want 404, got %d", w.Code)
}
if w := e.request(t, "GET", "/api/photos/public/events/gratis/gallery", buyer, nil); w.Code != 404 {
t.Fatalf("event without gallery: want 404, got %d", w.Code)
}
// A standalone gallery keeps the /photos share URL; link mode carries
// the token, public mode does not.
solo := createGallery(t, e, admin, map[string]any{"title": "Sin Evento", "visibility": "link"})
if !strings.Contains(solo.ShareURL, "/photos/sin-evento?token=") {
t.Fatalf("standalone share url: %s", solo.ShareURL)
}
pubEvent := createGallery(t, e, admin, map[string]any{"title": "Publica Con Evento", "visibility": "public", "eventId": evFree})
if !strings.HasSuffix(pubEvent.ShareURL, "/events/gratis/gallery") {
t.Fatalf("public event gallery share url should be the clean event link: %s", pubEvent.ShareURL)
}
}
func TestSlugCollision(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
a := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"})
b := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"})
if a.Slug == b.Slug {
t.Fatalf("slug collision: %s", a.Slug)
}
if b.Slug != "misma-fiesta-2" {
t.Fatalf("second slug: %s", b.Slug)
}
if a.Visibility != "private" {
t.Fatalf("default visibility: %s", a.Visibility)
}
}
+153
View File
@@ -0,0 +1,153 @@
package httpapi
import (
"context"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// JSON shapes are camelCase like the backend's responses.
type eventJSON struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
TitleEs string `json:"titleEs,omitempty"`
StartDatetime string `json:"startDatetime"`
Status string `json:"status"`
}
type galleryJSON struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
TitleEs string `json:"titleEs,omitempty"`
Description string `json:"description,omitempty"`
DescriptionEs string `json:"descriptionEs,omitempty"`
EventID string `json:"eventId,omitempty"`
Visibility string `json:"visibility"`
CoverPhotoID string `json:"coverPhotoId,omitempty"`
PhotoCount int `json:"photoCount"`
CoverURL string `json:"coverUrl,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Event *eventJSON `json:"event,omitempty"`
// Admin-only fields:
ShareToken string `json:"shareToken,omitempty"`
ShareURL string `json:"shareUrl,omitempty"`
}
type photoURLs struct {
Thumb string `json:"thumb,omitempty"`
Preview string `json:"preview,omitempty"`
Original string `json:"original"`
}
type photoJSON struct {
ID string `json:"id"`
GalleryID string `json:"galleryId"`
Position int `json:"position"`
OriginalFilename string `json:"originalFilename,omitempty"`
ContentType string `json:"contentType"`
SizeBytes int64 `json:"sizeBytes"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
TakenAt string `json:"takenAt,omitempty"`
Status string `json:"status"`
LastError string `json:"lastError,omitempty"` // admin only
CreatedAt string `json:"createdAt"`
URLs photoURLs `json:"urls"`
}
func isoTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02T15:04:05.000Z")
}
// fileURL builds the access-checked file endpoint URL; token is appended
// for link-mode viewers so the client can use URLs verbatim.
func fileURL(photoID, variant, token string) string {
u := "/api/photos/files/" + photoID + "/" + variant
if token != "" {
u += "?token=" + token
}
return u
}
func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON {
out := photoJSON{
ID: p.ID,
GalleryID: p.GalleryID,
Position: p.Position,
OriginalFilename: p.OriginalFilename,
ContentType: p.ContentType,
SizeBytes: p.SizeBytes,
Width: p.Width,
Height: p.Height,
TakenAt: isoTime(p.TakenAt),
Status: p.Status,
CreatedAt: isoTime(p.CreatedAt),
URLs: photoURLs{Original: fileURL(p.ID, "original", token)},
}
if p.ThumbKey != "" {
out.URLs.Thumb = fileURL(p.ID, "thumb", token)
}
if p.PreviewKey != "" {
out.URLs.Preview = fileURL(p.ID, "preview", token)
}
if admin {
out.LastError = p.LastError
}
return out
}
func (s *Server) galleryToJSON(ctx context.Context, g store.Gallery, token string, admin bool) galleryJSON {
out := galleryJSON{
ID: g.ID,
Slug: g.Slug,
Title: g.Title,
TitleEs: g.TitleEs,
Description: g.Description,
DescriptionEs: g.DescriptionEs,
EventID: g.EventID,
Visibility: g.Visibility,
CoverPhotoID: g.CoverPhotoID,
PhotoCount: g.PhotoCount,
CreatedAt: isoTime(g.CreatedAt),
UpdatedAt: isoTime(g.UpdatedAt),
}
if coverID, _ := s.db.GalleryCoverKey(ctx, g); coverID != "" {
out.CoverURL = fileURL(coverID, "thumb", token)
}
if g.EventID != "" {
if ev, err := s.db.GetEventSummary(ctx, g.EventID); err == nil {
out.Event = &eventJSON{
ID: ev.ID,
Slug: ev.Slug,
Title: ev.Title,
TitleEs: ev.TitleEs,
StartDatetime: isoTime(ev.StartDatetime),
Status: ev.Status,
}
}
}
if admin {
out.ShareToken = g.ShareToken
// Event-linked galleries live under the event's URL; standalone
// galleries keep their own /photos/<slug> URL.
page := "/photos/" + g.Slug
if out.Event != nil {
page = "/events/" + out.Event.Slug + "/gallery"
}
out.ShareURL = s.cfg.FrontendURL + page
// The token only grants anything in link/ticket mode; public and
// private galleries get a clean URL.
if g.Visibility == store.VisibilityLink || g.Visibility == store.VisibilityTicket {
out.ShareURL += "?token=" + g.ShareToken
}
}
return out
}
+117
View File
@@ -0,0 +1,117 @@
package httpapi
import (
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
)
const presignExpiry = 15 * time.Minute
// serveFile delivers photo bytes after re-running the gallery access check.
// S3: 302 to a short-lived presigned URL; local: streamed directly.
func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
variant := r.PathValue("variant")
if variant != "thumb" && variant != "preview" && variant != "original" {
writeError(w, http.StatusNotFound, "Not Found")
return
}
p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId"))
if err != nil {
writeStoreError(w, err, "Photo not found")
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
switch variant {
case "thumb":
key, contentType = p.ThumbKey, "image/jpeg"
case "preview":
key, contentType = p.PreviewKey, "image/jpeg"
case "original":
key, contentType = p.OriginalKey, p.ContentType
downloadName = p.OriginalFilename
if downloadName == "" {
downloadName = p.ID + extForContentType(p.ContentType)
}
}
if key == "" {
// Variant not generated yet (photo still processing).
writeError(w, http.StatusNotFound, "Not ready")
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 {
http.Redirect(w, r, url, http.StatusFound)
return
} else if !errors.Is(err, storage.ErrNoPresign) {
log.Printf("Error: presign %s: %v", key, err)
writeError(w, http.StatusInternalServerError, "Internal Server Error")
return
}
reader, size, err := s.storage.Open(r.Context(), key)
if err != nil {
if os.IsNotExist(err) {
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", size))
w.Header().Set("X-Content-Type-Options", "nosniff")
// Keys are immutable (new upload = new key), so private caching is safe
// even though access is checked per request.
w.Header().Set("Cache-Control", "private, max-age=86400")
if downloadName != "" {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", downloadName))
}
if _, err := io.Copy(w, reader); err != nil {
log.Printf("stream %s: %v", key, err)
}
}
func extForContentType(ct string) string {
switch ct {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
case "image/heic":
return ".heic"
}
return ""
}
+225
View File
@@ -0,0 +1,225 @@
package httpapi
import (
"log"
"net/http"
"strconv"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
func validVisibility(v string) bool {
switch v {
case store.VisibilityPublic, store.VisibilityPrivate, store.VisibilityLink, store.VisibilityTicket:
return true
}
return false
}
type createGalleryBody struct {
Title string `json:"title"`
TitleEs string `json:"titleEs"`
Description string `json:"description"`
DescriptionEs string `json:"descriptionEs"`
EventID string `json:"eventId"`
Visibility string `json:"visibility"`
}
func (s *Server) createGallery(w http.ResponseWriter, r *http.Request, user auth.User) {
var body createGalleryBody
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON body")
return
}
if body.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
if body.Visibility == "" {
body.Visibility = store.VisibilityPrivate
}
if !validVisibility(body.Visibility) {
writeError(w, http.StatusBadRequest, "visibility must be public, private, link or ticket")
return
}
if body.EventID != "" {
if _, err := s.db.GetEventSummary(r.Context(), body.EventID); err != nil {
writeStoreError(w, err, "Event not found")
return
}
}
slug, err := s.uniqueSlug(r.Context(), body.Title)
if err != nil {
writeStoreError(w, err, "")
return
}
now := time.Now()
g := store.Gallery{
ID: newID(),
Slug: slug,
Title: body.Title,
TitleEs: body.TitleEs,
Description: body.Description,
DescriptionEs: body.DescriptionEs,
EventID: body.EventID,
Visibility: body.Visibility,
ShareToken: newShareToken(),
CreatedBy: user.ID,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.db.CreateGallery(r.Context(), g); err != nil {
writeStoreError(w, err, "")
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"gallery": s.galleryToJSON(r.Context(), g, "", true),
})
}
func (s *Server) listGalleries(w http.ResponseWriter, r *http.Request, _ auth.User) {
limit := queryInt(r, "limit", 100)
offset := queryInt(r, "offset", 0)
galleries, err := s.db.ListGalleries(r.Context(), r.URL.Query().Get("eventId"), limit, offset)
if err != nil {
writeStoreError(w, err, "")
return
}
out := make([]galleryJSON, 0, len(galleries))
for _, g := range galleries {
out = append(out, s.galleryToJSON(r.Context(), g, "", true))
}
writeJSON(w, http.StatusOK, map[string]any{"galleries": out})
}
func (s *Server) getGallery(w http.ResponseWriter, r *http.Request, _ auth.User) {
g, err := s.db.GetGallery(r.Context(), r.PathValue("id"))
if err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
photos, err := s.db.ListPhotos(r.Context(), g.ID, false)
if err != nil {
writeStoreError(w, err, "")
return
}
out := make([]photoJSON, 0, len(photos))
for _, p := range photos {
out = append(out, s.photoToJSON(p, "", true))
}
writeJSON(w, http.StatusOK, map[string]any{
"gallery": s.galleryToJSON(r.Context(), g, "", true),
"photos": out,
})
}
type updateGalleryBody struct {
Title *string `json:"title"`
TitleEs *string `json:"titleEs"`
Description *string `json:"description"`
DescriptionEs *string `json:"descriptionEs"`
EventID *string `json:"eventId"`
Visibility *string `json:"visibility"`
CoverPhotoID *string `json:"coverPhotoId"`
}
func (s *Server) updateGallery(w http.ResponseWriter, r *http.Request, _ auth.User) {
id := r.PathValue("id")
var body updateGalleryBody
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON body")
return
}
if body.Title != nil && *body.Title == "" {
writeError(w, http.StatusBadRequest, "title cannot be empty")
return
}
if body.Visibility != nil && !validVisibility(*body.Visibility) {
writeError(w, http.StatusBadRequest, "visibility must be public, private, link or ticket")
return
}
if body.EventID != nil && *body.EventID != "" {
if _, err := s.db.GetEventSummary(r.Context(), *body.EventID); err != nil {
writeStoreError(w, err, "Event not found")
return
}
}
if body.CoverPhotoID != nil && *body.CoverPhotoID != "" {
p, err := s.db.GetPhoto(r.Context(), *body.CoverPhotoID)
if err != nil || p.GalleryID != id {
writeError(w, http.StatusBadRequest, "coverPhotoId must be a photo of this gallery")
return
}
}
err := s.db.UpdateGallery(r.Context(), id, store.GalleryUpdate{
Title: body.Title,
TitleEs: body.TitleEs,
Description: body.Description,
DescriptionEs: body.DescriptionEs,
EventID: body.EventID,
Visibility: body.Visibility,
CoverPhotoID: body.CoverPhotoID,
})
if err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
g, err := s.db.GetGallery(r.Context(), id)
if err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"gallery": s.galleryToJSON(r.Context(), g, "", true),
})
}
func (s *Server) deleteGallery(w http.ResponseWriter, r *http.Request, _ auth.User) {
id := r.PathValue("id")
keys, err := s.db.PhotoKeys(r.Context(), id)
if err != nil {
writeStoreError(w, err, "")
return
}
if err := s.db.DeleteGallery(r.Context(), id); err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
// Object cleanup is best-effort after the rows are gone; orphaned
// objects are harmless (unreachable) and logged for manual sweep.
for _, key := range keys {
if err := s.storage.Delete(r.Context(), key); err != nil {
log.Printf("delete object %s: %v", key, err)
}
}
writeJSON(w, http.StatusOK, map[string]string{"message": "Gallery deleted"})
}
func (s *Server) rotateShareToken(w http.ResponseWriter, r *http.Request, _ auth.User) {
id := r.PathValue("id")
if err := s.db.RotateShareToken(r.Context(), id, newShareToken()); err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
g, err := s.db.GetGallery(r.Context(), id)
if err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"gallery": s.galleryToJSON(r.Context(), g, "", true),
})
}
func queryInt(r *http.Request, key string, def int) int {
if v := r.URL.Query().Get(key); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return n
}
}
return def
}
+36
View File
@@ -0,0 +1,36 @@
package httpapi
import (
"net/http"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
)
// requireAdmin mirrors the backend's requireAuth(['admin','organizer']):
// 401 {"error":"Unauthorized"} without a valid token, 403
// {"error":"Forbidden"} for valid non-admin users.
func (s *Server) requireAdmin(next func(http.ResponseWriter, *http.Request, auth.User)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := s.verifier.FromRequest(r)
if err != nil {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
if !user.IsAdmin() {
writeError(w, http.StatusForbidden, "Forbidden")
return
}
next(w, r, user)
}
}
// optionalUser returns the authenticated user or nil; an invalid token is
// treated as anonymous rather than an error, matching how public backend
// routes behave.
func (s *Server) optionalUser(r *http.Request) *auth.User {
user, err := s.verifier.FromRequest(r)
if err != nil {
return nil
}
return &user
}
+242
View File
@@ -0,0 +1,242 @@
package httpapi
import (
"bytes"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// uploadPhotos accepts multipart form data with one or more "files" parts.
// Each file is sniffed by magic bytes (client filename/Content-Type are
// untrusted, same policy as /api/media/upload), stored as the untouched
// original, and queued for variant processing.
func (s *Server) uploadPhotos(w http.ResponseWriter, r *http.Request, _ auth.User) {
galleryID := r.PathValue("id")
g, err := s.db.GetGallery(r.Context(), galleryID)
if err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
maxFile := int64(s.cfg.MaxUploadMB) << 20
// Generous request ceiling; nginx enforces its own client_max_body_size.
r.Body = http.MaxBytesReader(w, r.Body, 40*maxFile)
mr, err := r.MultipartReader()
if err != nil {
writeError(w, http.StatusBadRequest, "Expected multipart/form-data")
return
}
position, err := s.db.NextPosition(r.Context(), g.ID)
if err != nil {
writeStoreError(w, err, "")
return
}
var created []photoJSON
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
writeError(w, http.StatusBadRequest, "Malformed multipart body")
return
}
if part.FormName() != "files" && part.FormName() != "file" {
part.Close()
continue
}
photo, uploadErr := s.saveUpload(r, g, part, position, maxFile)
part.Close()
if uploadErr != nil {
// One bad file fails the request explicitly rather than silently
// skipping it; the admin UI uploads files individually.
writeError(w, uploadErr.status, uploadErr.msg)
return
}
created = append(created, s.photoToJSON(photo, "", true))
position++
}
if len(created) == 0 {
writeError(w, http.StatusBadRequest, "No file provided")
return
}
s.worker.Nudge()
writeJSON(w, http.StatusCreated, map[string]any{"photos": created})
}
type uploadError struct {
status int
msg string
}
func (s *Server) saveUpload(r *http.Request, g store.Gallery, part *multipart.Part, position int, maxFile int64) (store.Photo, *uploadError) {
head := make([]byte, 16)
n, err := io.ReadFull(part, head)
if err != nil && err != io.ErrUnexpectedEOF {
return store.Photo{}, &uploadError{http.StatusBadRequest, "Could not read file"}
}
head = head[:n]
contentType, ext, ok, reason := imaging.Sniff(head)
if !ok {
return store.Photo{}, &uploadError{http.StatusUnsupportedMediaType, reason}
}
if contentType == "image/heic" && imaging.DetectHeicConverter(s.cfg.HeicConverter) == nil {
return store.Photo{}, &uploadError{http.StatusUnsupportedMediaType,
"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
// (S3 wants a length; local rename wants a file anyway).
tmp, err := os.CreateTemp(s.cfg.StoragePath, ".incoming-*")
if err != nil {
log.Printf("Error: %v", err)
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
}
defer os.Remove(tmp.Name())
defer tmp.Close()
size, err := io.Copy(tmp, io.MultiReader(bytes.NewReader(head), io.LimitReader(part, maxFile+1)))
if err != nil {
log.Printf("Error: %v", err)
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
}
if size > maxFile {
return store.Photo{}, &uploadError{http.StatusRequestEntityTooLarge,
fmt.Sprintf("File exceeds the %d MB limit", s.cfg.MaxUploadMB)}
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
log.Printf("Error: %v", err)
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
}
photoID := newID()
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 {
log.Printf("Error: %v", err)
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
}
now := time.Now()
photo := store.Photo{
ID: photoID,
GalleryID: g.ID,
Position: position,
OriginalKey: key,
OriginalFilename: sanitizeFilename(part.FileName()),
ContentType: contentType,
SizeBytes: size,
Status: "queued",
NextAttemptAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.db.InsertPhoto(r.Context(), photo); err != nil {
s.storage.Delete(r.Context(), key)
log.Printf("Error: %v", err)
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
}
return photo, nil
}
type reorderBody struct {
PhotoIDs []string `json:"photoIds"`
}
func (s *Server) reorderPhotos(w http.ResponseWriter, r *http.Request, _ auth.User) {
galleryID := r.PathValue("id")
var body reorderBody
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON body")
return
}
existing, err := s.db.ListPhotos(r.Context(), galleryID, false)
if err != nil {
writeStoreError(w, err, "")
return
}
if len(existing) == 0 {
writeStoreError(w, store.ErrNotFound, "Gallery not found or empty")
return
}
current := map[string]bool{}
for _, p := range existing {
current[p.ID] = true
}
if len(body.PhotoIDs) != len(existing) {
writeError(w, http.StatusBadRequest, "photoIds must contain every photo of the gallery exactly once")
return
}
seen := map[string]bool{}
for _, id := range body.PhotoIDs {
if !current[id] || seen[id] {
writeError(w, http.StatusBadRequest, "photoIds must contain every photo of the gallery exactly once")
return
}
seen[id] = true
}
if err := s.db.ReorderPhotos(r.Context(), galleryID, body.PhotoIDs); err != nil {
writeStoreError(w, err, "")
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "Order updated"})
}
func (s *Server) deletePhoto(w http.ResponseWriter, r *http.Request, _ auth.User) {
p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId"))
if err != nil {
writeStoreError(w, err, "Photo not found")
return
}
if err := s.db.DeletePhoto(r.Context(), p.ID); err != nil {
writeStoreError(w, err, "Photo not found")
return
}
for _, key := range []string{p.OriginalKey, p.ThumbKey, p.PreviewKey} {
if key == "" {
continue
}
if err := s.storage.Delete(r.Context(), key); err != nil {
log.Printf("delete object %s: %v", key, err)
}
}
writeJSON(w, http.StatusOK, map[string]string{"message": "Photo deleted"})
}
func (s *Server) retryPhoto(w http.ResponseWriter, r *http.Request, _ auth.User) {
id := r.PathValue("photoId")
if err := s.db.RequeuePhoto(r.Context(), id); err != nil {
writeStoreError(w, err, "Photo not found or not failed")
return
}
s.worker.Nudge()
p, err := s.db.GetPhoto(r.Context(), id)
if err != nil {
writeStoreError(w, err, "Photo not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"photo": s.photoToJSON(p, "", true)})
}
func sanitizeFilename(name string) string {
name = filepath.Base(name)
if name == "." || name == "/" {
return ""
}
if len(name) > 200 {
name = name[len(name)-200:]
}
return name
}
+79
View File
@@ -0,0 +1,79 @@
package httpapi
import (
"net/http"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// listPublicGalleries is the public index: only visibility='public'
// galleries ever appear here.
func (s *Server) listPublicGalleries(w http.ResponseWriter, r *http.Request) {
galleries, err := s.db.ListPublicGalleries(r.Context())
if err != nil {
writeStoreError(w, err, "")
return
}
out := make([]galleryJSON, 0, len(galleries))
for _, g := range galleries {
out = append(out, s.galleryToJSON(r.Context(), g, "", false))
}
writeJSON(w, http.StatusOK, map[string]any{"galleries": out})
}
// getPublicGallery serves a single gallery to any authorized viewer.
// ?token= carries the share token for link/ticket modes.
func (s *Server) getPublicGallery(w http.ResponseWriter, r *http.Request) {
g, err := s.db.GetGalleryBySlug(r.Context(), r.PathValue("slug"))
if err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
s.respondGalleryView(w, r, g)
}
// getEventGallery serves the newest gallery linked to an event, backing the
// /events/{slug}/gallery public page. Same access rules as by-slug.
func (s *Server) getEventGallery(w http.ResponseWriter, r *http.Request) {
ev, err := s.db.GetEventSummaryBySlug(r.Context(), r.PathValue("eventSlug"))
if err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
g, err := s.db.GetGalleryByEventID(r.Context(), ev.ID)
if err != nil {
writeStoreError(w, err, "Gallery not found")
return
}
s.respondGalleryView(w, r, g)
}
func (s *Server) respondGalleryView(w http.ResponseWriter, r *http.Request, g store.Gallery) {
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
}
// Only pass the token through to file URLs when it was the granting
// credential, so it doesn't leak into public/ticket responses.
urlToken := ""
if token != "" && token == g.ShareToken {
urlToken = token
}
photos, err := s.db.ListPhotos(r.Context(), g.ID, true)
if err != nil {
writeStoreError(w, err, "")
return
}
out := make([]photoJSON, 0, len(photos))
for _, p := range photos {
out = append(out, s.photoToJSON(p, urlToken, false))
}
writeJSON(w, http.StatusOK, map[string]any{
"gallery": s.galleryToJSON(r.Context(), g, urlToken, false),
"photos": out,
})
}
+39
View File
@@ -0,0 +1,39 @@
package httpapi
import (
"encoding/json"
"log"
"net/http"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
log.Printf("write response: %v", err)
}
}
// writeError follows the backend's error shape: {"error": string}.
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// writeStoreError maps store errors: ErrNotFound -> 404, else 500 with the
// backend's generic message (details go to the log, not the client).
func writeStoreError(w http.ResponseWriter, err error, notFoundMsg string) {
if err == store.ErrNotFound {
writeError(w, http.StatusNotFound, notFoundMsg)
return
}
log.Printf("Error: %v", err)
writeError(w, http.StatusInternalServerError, "Internal Server Error")
}
func decodeJSON(r *http.Request, dst any) error {
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
dec.DisallowUnknownFields()
return dec.Decode(dst)
}
+90
View File
@@ -0,0 +1,90 @@
// Package httpapi exposes the photo-api HTTP surface under /api/photos,
// following the backend's route and response conventions (resource-keyed
// success bodies, {"error": string} failures, Bearer auth).
package httpapi
import (
"log"
"net/http"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
"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"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker"
)
type Server struct {
cfg config.Config
db *store.DB
storage storage.Storage
verifier *auth.Verifier
worker *worker.Worker
}
func New(cfg config.Config, db *store.DB, st storage.Storage, verifier *auth.Verifier, w *worker.Worker) *Server {
return &Server{cfg: cfg, db: db, storage: st, verifier: verifier, worker: w}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
health := func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
mux.HandleFunc("GET /health", health)
mux.HandleFunc("GET /api/photos/health", health)
// Admin surface: role admin|organizer only (mirrors /api/media).
mux.HandleFunc("POST /api/photos/galleries", s.requireAdmin(s.createGallery))
mux.HandleFunc("GET /api/photos/galleries", s.requireAdmin(s.listGalleries))
mux.HandleFunc("GET /api/photos/galleries/{id}", s.requireAdmin(s.getGallery))
mux.HandleFunc("PATCH /api/photos/galleries/{id}", s.requireAdmin(s.updateGallery))
mux.HandleFunc("DELETE /api/photos/galleries/{id}", s.requireAdmin(s.deleteGallery))
mux.HandleFunc("POST /api/photos/galleries/{id}/photos", s.requireAdmin(s.uploadPhotos))
mux.HandleFunc("PATCH /api/photos/galleries/{id}/order", s.requireAdmin(s.reorderPhotos))
mux.HandleFunc("POST /api/photos/galleries/{id}/share-token", s.requireAdmin(s.rotateShareToken))
mux.HandleFunc("DELETE /api/photos/photos/{photoId}", s.requireAdmin(s.deletePhoto))
mux.HandleFunc("POST /api/photos/photos/{photoId}/retry", s.requireAdmin(s.retryPhoto))
// Viewer surface: optional auth, checked per gallery visibility.
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/events/{eventSlug}/gallery", s.getEventGallery)
mux.HandleFunc("GET /api/photos/files/{photoId}/{variant}", s.serveFile)
return s.withCommon(mux)
}
// withCommon adds request logging and a same-spirit CORS allowance as the
// backend's cors() (origin = FRONTEND_URL); in production nginx serves
// same-origin so this mostly matters in development.
func (s *Server) withCommon(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" && origin == s.cfg.FrontendURL {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Vary", "Origin")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond))
})
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
+72
View File
@@ -0,0 +1,72 @@
package httpapi
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"strings"
"unicode"
"github.com/google/uuid"
)
// slugify follows the spirit of backend/src/lib/slugify.ts: lowercase,
// ASCII-fold common Spanish characters, dashes for everything else.
func slugify(s string) string {
replacer := strings.NewReplacer(
"á", "a", "é", "e", "í", "i", "ó", "o", "ú", "u", "ü", "u", "ñ", "n",
"Á", "a", "É", "e", "Í", "i", "Ó", "o", "Ú", "u", "Ü", "u", "Ñ", "n")
s = replacer.Replace(strings.ToLower(strings.TrimSpace(s)))
var b strings.Builder
prevDash := true // avoids a leading dash
for _, r := range s {
switch {
case unicode.IsLetter(r) && r < 128, unicode.IsDigit(r):
b.WriteRune(r)
prevDash = false
default:
if !prevDash {
b.WriteByte('-')
prevDash = true
}
}
}
out := strings.Trim(b.String(), "-")
if len(out) > 140 {
out = strings.Trim(out[:140], "-")
}
if out == "" {
out = "gallery"
}
return out
}
// uniqueSlug appends -2, -3, ... until the slug is free.
func (s *Server) uniqueSlug(ctx context.Context, title string) (string, error) {
base := slugify(title)
slug := base
for i := 2; ; i++ {
exists, err := s.db.SlugExists(ctx, slug)
if err != nil {
return "", err
}
if !exists {
return slug, nil
}
slug = fmt.Sprintf("%s-%d", base, i)
}
}
func newID() string {
return uuid.NewString()
}
// newShareToken returns 32 random bytes, base64url (43 chars, no padding).
func newShareToken() string {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
panic(err) // crypto/rand failure is unrecoverable
}
return base64.RawURLEncoding.EncodeToString(buf)
}
+60
View File
@@ -0,0 +1,60 @@
package imaging
import (
"fmt"
"os/exec"
)
// HEIC cannot be decoded in pure Go (HEVC), so conversion shells out to a
// host-installed CLI. The Go binary itself stays cgo-free; the converter is
// a runtime dependency (Debian: libvips-tools or libheif-examples).
type HeicConverter struct {
kind string // "vips" | "heif-convert" | "custom"
path string
}
// DetectHeicConverter honors an explicit HEIC_CONVERTER command, else
// autodetects vips then heif-convert. Returns nil when none is available;
// uploads of HEIC are then rejected with a clear message.
func DetectHeicConverter(explicit string) *HeicConverter {
if explicit != "" {
if p, err := exec.LookPath(explicit); err == nil {
return &HeicConverter{kind: "custom", path: p}
}
return nil
}
if p, err := exec.LookPath("vips"); err == nil {
return &HeicConverter{kind: "vips", path: p}
}
if p, err := exec.LookPath("heif-convert"); err == nil {
return &HeicConverter{kind: "heif-convert", path: p}
}
return nil
}
func (h *HeicConverter) Name() string { return h.path }
// ToJPEG converts src (a .heic file) to a JPEG at dst.
func (h *HeicConverter) ToJPEG(src, dst string) error {
var cmd *exec.Cmd
switch h.kind {
case "vips":
cmd = exec.Command(h.path, "copy", src, dst+"[Q=95]")
case "heif-convert":
cmd = exec.Command(h.path, "-q", "95", src, dst)
default:
// custom converter contract: <cmd> <src> <dst>
cmd = exec.Command(h.path, src, dst)
}
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("heic conversion failed: %v: %s", err, truncate(string(out), 300))
}
return nil
}
func truncate(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}

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