dev #28

Merged
Michilis merged 4 commits from dev into main 2026-07-26 05:38:24 +00:00
112 changed files with 9007 additions and 1071 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}`);
}
+1 -1
View File
@@ -18,7 +18,7 @@ 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 './manualProviders.js';
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
function getTtlMs(): number {
const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10);
+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);
}
+41 -51
View File
@@ -1,19 +1,22 @@
// Shared capacity-checked recovery for released bookings.
// Shared capacity-checked recovery for bookings that don't currently hold a seat.
//
// When a booking is put on hold (or failed/cancelled), its ticket(s) drop out of the
// capacity-counting statuses ('pending', 'confirmed', 'checked_in'), releasing the seat.
// Recovering such a booking (user "I've paid" again, or an admin reactivating / marking
// it paid / reopening a failed payment) 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 re-reserved via
// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list — and
// tickets that already hold a seat — are left untouched and don't consume capacity.
// 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) {
@@ -26,6 +29,8 @@ interface ReserveOptions {
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;
}
/**
@@ -34,7 +39,8 @@ interface ReserveOptions {
* 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.
* 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,
@@ -46,6 +52,10 @@ export async function reserveOnHoldBooking(
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))
@@ -65,8 +75,14 @@ export async function reserveOnHoldBooking(
if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId;
}
// 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 status cost nothing.
// 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)) {
@@ -80,26 +96,14 @@ export async function reserveOnHoldBooking(
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();
const neededRow = tx
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(and(
inArray((tickets as any).id, ticketIds),
sql`${(tickets as any).status} NOT IN ('pending', 'confirmed', 'checked_in')`
))
.get();
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.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),
inArray((tickets as any).status, fromTicketStatuses)
@@ -113,28 +117,14 @@ 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')`
))
);
const neededRow = await dbGet<any>(
tx
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(and(
inArray((tickets as any).id, ticketIds),
sql`${(tickets as any).status} NOT IN ('pending', 'confirmed', 'checked_in')`
))
);
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.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),
inArray((tickets as any).status, fromTicketStatuses)
+22 -26
View File
@@ -1,23 +1,24 @@
// Auto-hold stale manual-payment bookings.
// Auto-hold stale unsettled manual-payment bookings.
//
// This job releases the seat held by an abandoned manual-payment booking (bank
// transfer / TPago / cash) after HOLD_THRESHOLD_HOURS. It covers two states, both of
// which keep a seat reserved while awaiting a human:
// - 'pending_approval': the user clicked "I've paid" and is waiting for an admin.
// - 'pending' on a manual provider (see MANUAL_PAYMENT_PROVIDERS): the booking was
// never settled (these are exempt from the 30-min auto-fail in bookingCleanup.ts,
// so this is their only seat-release path).
// In either case the payment (and its ticket) is silently moved 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, or, eq, lt, inArray } from 'drizzle-orm';
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 './manualProviders.js';
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
function getThresholdMs(): number {
const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10);
@@ -25,9 +26,9 @@ function getThresholdMs(): number {
}
/**
* Move stale awaiting-verification payments (and their tickets) to 'on_hold'.
* Covers 'pending_approval' payments and 'pending' payments on manual providers.
* 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()));
@@ -40,13 +41,8 @@ export async function sweepStaleApprovals(): Promise<number> {
})
.from(payments)
.where(and(
or(
eq((payments as any).status, 'pending_approval'),
and(
eq((payments as any).status, 'pending'),
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS])
)
),
eq((payments as any).status, 'pending'),
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
lt((payments as any).createdAt, cutoff)
))
);
@@ -72,7 +68,7 @@ export async function sweepStaleApprovals(): Promise<number> {
));
}
console.log(`[HoldSweep] Put ${stale.length} stale awaiting-verification payment(s) on hold.`);
console.log(`[HoldSweep] Put ${stale.length} stale unsettled manual payment(s) on hold.`);
return stale.length;
}
-7
View File
@@ -1,7 +0,0 @@
// Payment providers that require a human to verify the money arrived.
//
// These methods (bank transfer / TPago / cash) are never auto-confirmed and —
// crucially — are never auto-failed by the stale-booking cleanup: an admin settles
// them by hand. Note this is broader than the set of methods that expose an online
// "I've paid" step (bank transfer / TPago only); cash is settled at the door.
export const MANUAL_PAYMENT_PROVIDERS = ['bank_transfer', 'tpago', 'cash'] as const;
+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();
}
});
});
+36 -54
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({
@@ -285,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));
}
@@ -317,29 +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, 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)
@@ -381,55 +383,35 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
}
}
if (payment.status === 'on_hold' || payment.status === 'failed') {
// The seat was released when this booking went on hold or failed - re-check
// capacity before confirming it directly.
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'],
}
);
} 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);
// 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 } } : {}),
}
throw err;
}
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));
);
} 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)
+102 -207
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();
@@ -128,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);
@@ -230,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');
@@ -290,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');
@@ -388,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)
@@ -1029,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,
},
@@ -1109,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);
}
@@ -1152,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
@@ -1498,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,
@@ -1541,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');
@@ -1693,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))
);
@@ -1725,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()
@@ -1745,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,
@@ -1752,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,
@@ -1806,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*`,
@@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
import { formatDateLong, formatTime, formatRucDisplay } 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 {
@@ -108,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)));
@@ -366,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);
}
@@ -388,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} />;
}
@@ -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>
);
+162 -30
View File
@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
import { isManualProvider } from '@/lib/api/payments';
import { parseDate, formatRucDisplay } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
@@ -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');
}
};
@@ -298,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) {
@@ -331,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) {
@@ -348,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')
@@ -370,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')
);
@@ -721,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>
@@ -817,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">
@@ -841,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>
)}
</>
)}
@@ -1004,6 +1135,7 @@ 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>
@@ -1063,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></>
)}
@@ -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>
+96 -4
View File
@@ -1,6 +1,6 @@
'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, formatRucDisplay } from '@/lib/utils';
@@ -9,12 +9,26 @@ 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 {
@@ -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';
+10 -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) =>
+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;
+30
View File
@@ -205,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
}
+159
View File
@@ -0,0 +1,159 @@
// Package imaging produces the two derived variants from an original:
// thumb (512px long edge, JPEG q78) for the grid and preview (2048px long
// edge, JPEG q85) for the lightbox. Originals are never modified.
// Re-encoding strips EXIF (including GPS) from variants; orientation is
// applied to pixels first.
package imaging
import (
"fmt"
"image"
"image/jpeg"
"io"
"os"
"time"
_ "image/gif"
_ "image/png"
"github.com/rwcarlsen/goexif/exif"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
)
const (
ThumbLongEdge = 512
ThumbQuality = 78
PreviewLongEdge = 2048
PreviewQuality = 85
)
type Result struct {
Width int // of the original, after orientation
Height int
TakenAt time.Time // zero when EXIF has no usable timestamp
}
// ProcessFile decodes the image at path (JPEG/PNG/GIF/WebP — HEIC must be
// converted to JPEG first), applies EXIF orientation, and writes both
// variants as JPEG to thumbPath and previewPath.
func ProcessFile(path, thumbPath, previewPath string) (Result, error) {
f, err := os.Open(path)
if err != nil {
return Result{}, err
}
orientation, takenAt := readExif(f)
if _, err := f.Seek(0, io.SeekStart); err != nil {
f.Close()
return Result{}, err
}
img, _, err := image.Decode(f)
f.Close()
if err != nil {
return Result{}, fmt.Errorf("decode: %w", err)
}
img = applyOrientation(img, orientation)
bounds := img.Bounds()
res := Result{Width: bounds.Dx(), Height: bounds.Dy(), TakenAt: takenAt}
if err := writeVariant(img, previewPath, PreviewLongEdge, PreviewQuality); err != nil {
return res, fmt.Errorf("preview: %w", err)
}
if err := writeVariant(img, thumbPath, ThumbLongEdge, ThumbQuality); err != nil {
return res, fmt.Errorf("thumb: %w", err)
}
return res, nil
}
func writeVariant(img image.Image, path string, longEdge, quality int) error {
out, err := os.Create(path)
if err != nil {
return err
}
defer out.Close()
return jpeg.Encode(out, resize(img, longEdge), &jpeg.Options{Quality: quality})
}
// resize scales so the long edge is at most longEdge, never upscaling.
func resize(img image.Image, longEdge int) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
long := max(w, h)
if long <= longEdge {
return img
}
scale := float64(longEdge) / float64(long)
nw, nh := max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale))
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
return dst
}
func readExif(r io.Reader) (orientation int, takenAt time.Time) {
orientation = 1
x, err := exif.Decode(r)
if err != nil {
return orientation, takenAt
}
if tag, err := x.Get(exif.Orientation); err == nil {
if v, err := tag.Int(0); err == nil && v >= 1 && v <= 8 {
orientation = v
}
}
if t, err := x.DateTime(); err == nil {
takenAt = t
}
return orientation, takenAt
}
// applyOrientation bakes the EXIF orientation into pixels (values 2-8 per
// the EXIF spec; 1 is identity).
func applyOrientation(img image.Image, orientation int) image.Image {
switch orientation {
case 2:
return transform(img, func(x, y, w, h int) (int, int) { return w - 1 - x, y })
case 3:
return transform(img, func(x, y, w, h int) (int, int) { return w - 1 - x, h - 1 - y })
case 4:
return transform(img, func(x, y, w, h int) (int, int) { return x, h - 1 - y })
case 5:
return transformSwap(img, func(x, y, w, h int) (int, int) { return y, x })
case 6:
return transformSwap(img, func(x, y, w, h int) (int, int) { return y, h - 1 - x })
case 7:
return transformSwap(img, func(x, y, w, h int) (int, int) { return w - 1 - y, h - 1 - x })
case 8:
return transformSwap(img, func(x, y, w, h int) (int, int) { return w - 1 - y, x })
default:
return img
}
}
// transform maps destination (x,y) to source coordinates, same dimensions.
func transform(img image.Image, srcAt func(x, y, w, h int) (int, int)) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
dst := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
sx, sy := srcAt(x, y, w, h)
dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy))
}
}
return dst
}
// transformSwap is for orientations that swap width and height.
func transformSwap(img image.Image, srcAt func(x, y, w, h int) (int, int)) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
dst := image.NewRGBA(image.Rect(0, 0, h, w))
for y := 0; y < w; y++ {
for x := 0; x < h; x++ {
sx, sy := srcAt(x, y, w, h)
dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy))
}
}
return dst
}
+30
View File
@@ -0,0 +1,30 @@
package imaging
import "bytes"
// Sniff identifies an image by magic bytes, ignoring the client-supplied
// filename and Content-Type — same policy as backend/src/routes/media.ts
// detectImageType. Returns the canonical content type and extension, or
// ok=false with a human-readable reason.
func Sniff(head []byte) (contentType, ext string, ok bool, reason string) {
switch {
case len(head) >= 3 && bytes.Equal(head[:3], []byte{0xFF, 0xD8, 0xFF}):
return "image/jpeg", ".jpg", true, ""
case len(head) >= 8 && bytes.Equal(head[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}):
return "image/png", ".png", true, ""
case len(head) >= 6 && (bytes.Equal(head[:6], []byte("GIF87a")) || bytes.Equal(head[:6], []byte("GIF89a"))):
return "image/gif", ".gif", true, ""
case len(head) >= 12 && bytes.Equal(head[:4], []byte("RIFF")) && bytes.Equal(head[8:12], []byte("WEBP")):
return "image/webp", ".webp", true, ""
}
if len(head) >= 12 && bytes.Equal(head[4:8], []byte("ftyp")) {
brand := string(head[8:12])
switch brand {
case "heic", "heix", "hevc", "hevx", "mif1", "msf1":
return "image/heic", ".heic", true, ""
case "avif", "avis":
return "", "", false, "AVIF is not supported, please upload JPEG, PNG, WebP, GIF or HEIC"
}
}
return "", "", false, "unsupported file type, please upload JPEG, PNG, WebP, GIF or HEIC"
}
+91
View File
@@ -0,0 +1,91 @@
package storage
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
// local stores objects under root (STORAGE_PATH, default ./data/photos).
// Deliberately disjoint from backend/uploads; nothing here is ever served
// statically — all reads go through the access-checked files handler.
type local struct {
root string
}
func newLocal(root string) (*local, error) {
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("create storage dir %s: %w", root, err)
}
return &local{root: root}, nil
}
// path validates the key stays inside root (keys are generated internally,
// but defense-in-depth costs one Clean call).
func (l *local) path(key string) (string, error) {
clean := filepath.Clean("/" + key)
if strings.Contains(clean, "..") {
return "", fmt.Errorf("invalid key %q", key)
}
return filepath.Join(l.root, clean), nil
}
func (l *local) Put(_ context.Context, key string, r io.Reader, _ int64, _ string) error {
dst, err := l.path(key)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".upload-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, r); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), dst)
}
func (l *local) Open(_ context.Context, key string) (io.ReadCloser, int64, error) {
p, err := l.path(key)
if err != nil {
return nil, 0, err
}
f, err := os.Open(p)
if err != nil {
return nil, 0, err
}
info, err := f.Stat()
if err != nil {
f.Close()
return nil, 0, err
}
return f, info.Size(), nil
}
func (l *local) Delete(_ context.Context, key string) error {
p, err := l.path(key)
if err != nil {
return err
}
err = os.Remove(p)
if os.IsNotExist(err) {
return nil
}
return err
}
func (l *local) PresignGet(context.Context, string, string, string, time.Duration) (string, error) {
return "", ErrNoPresign
}

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