diff --git a/backend/.env.example b/backend/.env.example index 8e0af46..c1339c5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -121,3 +121,9 @@ HOLD_THRESHOLD_HOURS=72 # How often the hold sweep job runs, in milliseconds (default: 900000 = 15 min) HOLD_SWEEP_INTERVAL_MS=900000 +# Ended-Event Payment Sweep +# Once an event is over, any unconfirmed payment (pending / pending_approval / +# on_hold) is auto-rejected and its ticket cancelled. No email is sent. +# How often this sweep runs, in milliseconds (default: 900000 = 15 min) +EVENT_END_SWEEP_INTERVAL_MS=900000 + diff --git a/backend/src/index.ts b/backend/src/index.ts index 69115fe..d7773c9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -27,6 +27,7 @@ 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 { getLock } from './lib/stores/lock.js'; import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js'; @@ -1936,6 +1937,9 @@ startBookingCleanup(); // Periodically put stale pending-approval payments on hold, releasing their seats. startHoldSweep(); +// Periodically auto-reject unconfirmed payments once their event is over (no email). +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. diff --git a/backend/src/lib/eventEndSweep.ts b/backend/src/lib/eventEndSweep.ts new file mode 100644 index 0000000..1b096e2 --- /dev/null +++ b/backend/src/lib/eventEndSweep.ts @@ -0,0 +1,124 @@ +// Auto-reject unconfirmed payments once their event is over. +// +// After an event ends, any booking whose payment was never confirmed +// (still 'pending', 'pending_approval', or 'on_hold') can no longer be honored. +// This job silently fails those payments and cancels their tickets so they stop +// lingering as "pending" forever. It deliberately sends NO email — unlike the +// admin reject route, this is a housekeeping sweep and users are not notified. +// +// An event is considered over when COALESCE(end_datetime, start_datetime) is in +// the past. Updates are guarded by the current status so re-running is a no-op. + +import { and, eq, inArray } from 'drizzle-orm'; +import { db, dbAll, tickets, payments, events } from '../db/index.js'; +import { getNow } from './utils.js'; +import { getLock } from './stores/lock.js'; + +// Payment statuses that represent an unconfirmed booking. +const UNCONFIRMED_PAYMENT_STATUSES = ['pending', 'pending_approval', 'on_hold']; +// Ticket statuses that are still "live" (not already confirmed/checked-in/cancelled). +const ACTIVE_TICKET_STATUSES = ['pending', 'on_hold']; + +/** + * Fail unconfirmed payments (and cancel their tickets) for events that have + * already ended. Returns the number of payments rejected. + */ +export async function rejectUnconfirmedPaymentsForEndedEvents(): Promise { + // Pull candidate rows first, then decide "ended" in JS so the comparison works + // identically for SQLite (ISO text) and Postgres (timestamp) datetime columns. + const rows = await dbAll<{ + paymentId: string; + ticketId: string; + endDatetime: string | Date | null; + startDatetime: string | Date | null; + }>( + (db as any) + .select({ + paymentId: (payments as any).id, + ticketId: (tickets as any).id, + endDatetime: (events as any).endDatetime, + startDatetime: (events as any).startDatetime, + }) + .from(payments) + .innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id)) + .innerJoin(events, eq((tickets as any).eventId, (events as any).id)) + .where(and( + inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES), + inArray((tickets as any).status, ACTIVE_TICKET_STATUSES), + )) + ); + + const nowMs = Date.now(); + const ended = rows.filter((r) => { + const ref = r.endDatetime || r.startDatetime; + if (!ref) return false; + return new Date(ref as any).getTime() < nowMs; + }); + + if (ended.length === 0) return 0; + + const paymentIds = Array.from(new Set(ended.map((r) => r.paymentId))); + const ticketIds = Array.from(new Set(ended.map((r) => r.ticketId).filter((id): id is string => !!id))); + const now = getNow(); + + // Fail the payments. The status guard keeps this idempotent and avoids + // clobbering anything that changed since we read the candidates. + await (db as any) + .update(payments) + .set({ status: 'failed', adminNote: 'Auto-rejected: event ended', updatedAt: now }) + .where(and( + inArray((payments as any).id, paymentIds), + inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES), + )); + + // Cancel the associated tickets, freeing any seats they still hold. + if (ticketIds.length > 0) { + await (db as any) + .update(tickets) + .set({ status: 'cancelled' }) + .where(and( + inArray((tickets as any).id, ticketIds), + inArray((tickets as any).status, ACTIVE_TICKET_STATUSES), + )); + } + + console.log( + `[EventEndSweep] Auto-rejected ${paymentIds.length} unconfirmed payment(s) for ended event(s); ` + + `cancelled ${ticketIds.length} ticket(s).` + ); + return paymentIds.length; +} + +let sweepTimer: ReturnType | null = null; + +/** + * Start a periodic sweep that auto-rejects unconfirmed payments for ended + * events. Each run is guarded by a distributed lock so that, across multiple + * replicas, only one instance does the work per interval. + */ +export function startEventEndSweep(): void { + const intervalMs = parseInt(process.env.EVENT_END_SWEEP_INTERVAL_MS || '900000', 10); // 15 min + + const run = () => { + getLock() + .withLock('sweep-ended-event-payments', Math.min(intervalMs, 60_000), () => + rejectUnconfirmedPaymentsForEndedEvents() + ) + .catch((err) => + console.error('[EventEndSweep] Run failed:', err?.message || err) + ); + }; + + // Run shortly after startup, then on the interval. + setTimeout(run, 60_000).unref?.(); + sweepTimer = setInterval(run, intervalMs); + sweepTimer.unref?.(); + console.log(`[EventEndSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`); +} + +export function stopEventEndSweep(): void { + if (sweepTimer) { + clearInterval(sweepTimer); + sweepTimer = null; + } +} diff --git a/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx b/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx index 46cb494..b495d7b 100644 --- a/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx +++ b/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx @@ -335,7 +335,7 @@ export function BookingFormStep({ - - - + {/* Mobile header overflow menu */}
@@ -505,7 +514,7 @@ export default function AdminEventDetailPage() { { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}> View Public - { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}> + { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}> Edit Event { toggleStats(); setMobileHeaderMenuOpen(false); }}> @@ -796,6 +805,15 @@ export default function AdminEventDetailPage() { setPreviewHtml={setPreviewHtml} /> + setShowEditForm(false)} + onSaved={() => { setShowEditForm(false); loadEventData(); }} + /> +
); diff --git a/frontend/src/app/admin/events/_components/EventFormModal.tsx b/frontend/src/app/admin/events/_components/EventFormModal.tsx new file mode 100644 index 0000000..7af3652 --- /dev/null +++ b/frontend/src/app/admin/events/_components/EventFormModal.tsx @@ -0,0 +1,392 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { eventsApi, siteSettingsApi, Event } from '@/lib/api'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; +import MediaPicker from '@/components/MediaPicker'; +import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import toast from 'react-hot-toast'; +import { useLanguage } from '@/context/LanguageContext'; +import { parseDate, EVENT_TIMEZONE } from '@/lib/utils'; + +interface EventFormData { + title: string; + titleEs: string; + slug: string; + description: string; + descriptionEs: string; + shortDescription: string; + shortDescriptionEs: string; + startDatetime: string; + endDatetime: string; + location: string; + locationUrl: string; + price: number; + currency: string; + capacity: number; + status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived'; + bannerUrl: string; + externalBookingEnabled: boolean; + externalBookingUrl: string; +} + +const EMPTY_FORM: EventFormData = { + title: '', titleEs: '', slug: '', description: '', descriptionEs: '', + shortDescription: '', shortDescriptionEs: '', + startDatetime: '', endDatetime: '', location: '', locationUrl: '', + price: 0, currency: 'PYG', capacity: 50, status: 'draft', + bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '', +}; + +function isoToLocalDatetime(isoString: string): string { + const date = parseDate(isoString); + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: EVENT_TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(date); + const get = (type: string) => parts.find(p => p.type === type)!.value; + const h = get('hour') === '24' ? '00' : get('hour'); + return `${get('year')}-${get('month')}-${get('day')}T${h}:${get('minute')}`; +} + +interface EventFormModalProps { + /** When true the modal is rendered. */ + open: boolean; + /** The event being edited, or null to create a new event. */ + event: Event | null; + /** Currently featured event id (owned by the parent page). */ + featuredEventId: string | null; + /** Notify the parent when the featured event changes. */ + onFeaturedChange: (id: string | null) => void; + /** Close the modal without saving. */ + onClose: () => void; + /** Called after a successful create/update so the parent can refresh. */ + onSaved: () => void; +} + +/** + * Shared create/edit event modal used by both the events list page and the + * single-event detail page. Owns its own form state so it can be dropped in + * anywhere; the parent only supplies the event to edit and refresh callbacks. + */ +export default function EventFormModal({ + open, + event, + featuredEventId, + onFeaturedChange, + onClose, + onSaved, +}: EventFormModalProps) { + const { t } = useLanguage(); + const [formData, setFormData] = useState(EMPTY_FORM); + const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]); + const [saving, setSaving] = useState(false); + const [settingFeatured, setSettingFeatured] = useState(false); + + useEffect(() => { + if (!open) return; + if (event) { + setFormData({ + title: event.title, titleEs: event.titleEs || '', slug: event.slug || '', + description: event.description, descriptionEs: event.descriptionEs || '', + shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '', + startDatetime: isoToLocalDatetime(event.startDatetime), + endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '', + location: event.location, locationUrl: event.locationUrl || '', + price: event.price, currency: event.currency, capacity: event.capacity, + status: event.status, bannerUrl: event.bannerUrl || '', + externalBookingEnabled: event.externalBookingEnabled || false, + externalBookingUrl: event.externalBookingUrl || '', + }); + loadSlugAliases(event.id); + } else { + setFormData(EMPTY_FORM); + setSlugAliases([]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, event]); + + const loadSlugAliases = async (eventId: string) => { + try { + const { aliases } = await eventsApi.getSlugAliases(eventId); + setSlugAliases(aliases); + } catch (error) { + setSlugAliases([]); + } + }; + + const handleRemoveAlias = async (slug: string) => { + if (!event) return; + if (!confirm(`Remove alias "${slug}"? The old URL /events/${slug} will stop working.`)) return; + try { + await eventsApi.deleteSlugAlias(event.id, slug); + toast.success('Alias removed'); + setSlugAliases((prev) => prev.filter((a) => a.slug !== slug)); + } catch (error: any) { + toast.error(error.message || 'Failed to remove alias'); + } + }; + + const handleSetFeatured = async (eventId: string | null) => { + setSettingFeatured(true); + try { + await siteSettingsApi.setFeaturedEvent(eventId); + onFeaturedChange(eventId); + toast.success(eventId ? 'Event set as featured' : 'Featured event removed'); + } catch (error: any) { + toast.error(error.message || 'Failed to update featured event'); + } finally { + setSettingFeatured(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + try { + if (formData.externalBookingEnabled && !formData.externalBookingUrl) { + toast.error('External booking URL is required when external booking is enabled'); + setSaving(false); + return; + } + if (formData.externalBookingEnabled && !formData.externalBookingUrl.startsWith('https://')) { + toast.error('External booking URL must be a valid HTTPS link'); + setSaving(false); + return; + } + const eventData: Partial = { + title: formData.title, titleEs: formData.titleEs || undefined, + description: formData.description, descriptionEs: formData.descriptionEs || undefined, + shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined, + startDatetime: formData.startDatetime, + endDatetime: formData.endDatetime || undefined, + location: formData.location, locationUrl: formData.locationUrl || undefined, + price: formData.price, currency: formData.currency, capacity: formData.capacity, + status: formData.status, bannerUrl: formData.bannerUrl || undefined, + externalBookingEnabled: formData.externalBookingEnabled, + externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined, + }; + if (event) { + // Only send slug when editing so creates still auto-generate from title + eventData.slug = formData.slug || undefined; + await eventsApi.update(event.id, eventData); + toast.success('Event updated'); + } else { + await eventsApi.create(eventData); + toast.success('Event created'); + } + onSaved(); + } catch (error: any) { + toast.error(error.message || 'Failed to save event'); + } finally { + setSaving(false); + } + }; + + if (!open) return null; + + return ( +
+ +
+

+ {event ? t('admin.events.edit') : t('admin.events.create')} +

+ +
+ +
+
+ setFormData({ ...formData, title: e.target.value })} required /> + setFormData({ ...formData, titleEs: e.target.value })} /> +
+ + {event && ( +
+ setFormData({ ...formData, slug: e.target.value })} + placeholder="auto-generated from title" /> +

+ Public URL: /events/{formData.slug || '...'} + . Changing the slug keeps the old one as a redirecting alias. +

+ {slugAliases.length > 0 && ( +
+

URL aliases

+

+ Old URLs that still redirect to the current slug. Removing one breaks those links. +

+
    + {slugAliases.map((alias) => ( +
  • + /events/{alias.slug} + +
  • + ))} +
+
+ )} +
+ )} + +
+ +