From 82ebc178d708d82ae199ee3e08ddcd1deb5a4d56 Mon Sep 17 00:00:00 2001 From: Satelink Network Date: Fri, 6 Nov 2026 14:51:50 +0530 Subject: [PATCH 1/3] feat(m6): Organiser workspace, event-scoped staff roles & secure QR check-in - Implement comprehensive organiser workspace: event overview, readiness checklist, ticket inventory & capacity, attendee lookup & cancellations, and event announcements. - Add event-scoped staff roles and permissions: organiser, event_manager, door_staff, guest_list_manager, assistant with tokenized invites, immediate access revocation, and audit logging. - Build server-authoritative QR ticket engine: HMAC-SHA256 signed payloads (NLZ1::) bound to event and confirmed booking without attendee PII leakage. - Deliver mobile-first door check-in scanner: camera BarcodeDetector/canvas detection, manual ticket code fallback, atomic duplicate scan and replay prevention, attendance KPIs, and scan history. - Add database migration 019: event_team audit enhancement, event_staff_invites, tickets, ticket_scans, event_announcements, and existing booking ticket backfill. - Comprehensive automated test suite in test/m6-organiser-workspace.test.js with 15/15 passing tests and 642 passing regression tests. - Update docs/PROJECT_STATE.md and add arch/ORGANISER_WORKSPACE_AND_QR_CHECKIN.md. --- .gitignore | 1 + docs/PROJECT_STATE.md | 4 +- .../ORGANISER_WORKSPACE_AND_QR_CHECKIN.md | 132 +++ lib/handlers/organiser.js | 281 +++++- lib/router.js | 27 +- lib/services/bookings.js | 3 + lib/services/event-announcements.js | 104 +++ lib/services/event-staff.js | 325 +++++++ lib/services/orders.js | 2 + lib/services/organiser.js | 473 ++++++++++- lib/services/permissions.js | 112 ++- lib/services/ticketing.js | 3 + lib/services/tickets.js | 600 +++++++++++++ lib/services/upi-payments.js | 3 + lib/views/organiser.js | 45 +- .../019_organiser_workspace_and_checkin.sql | 111 +++ public/css/organiser.css | 108 +++ public/css/site.css | 8 + public/js/organiser.js | 802 ++++++++++++++++-- public/js/site.js | 15 + test/m6-organiser-workspace.test.js | 624 ++++++++++++++ test/migrations.test.js | 14 +- 22 files changed, 3689 insertions(+), 108 deletions(-) create mode 100644 docs/arch/ORGANISER_WORKSPACE_AND_QR_CHECKIN.md create mode 100644 lib/services/event-announcements.js create mode 100644 lib/services/event-staff.js create mode 100644 lib/services/tickets.js create mode 100644 migrations/019_organiser_workspace_and_checkin.sql create mode 100644 test/m6-organiser-workspace.test.js diff --git a/.gitignore b/.gitignore index 726df24..c2d66f2 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ public/img/review/ .agents/ .claude/ skills-lock.json +*.code-workspace diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index d2a8e01..b813f95 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -175,12 +175,14 @@ Per owner approval on 2026-11-03, each of the 19 fully merged remote branches wa | M4.3.3 Final Provider Verification Integrity | `feature/m4-3-3-provider-verification` / #33 | **Completed** (Eliminated synthetic currency defaults and configured merchant status check fallbacks; fail-closed currency and merchant identity gating; documented CBS operational limitation and operator attestation labeling; 37/37 UPI tests pass, 470/470 unit/integration tests pass, `ENABLE_LIVE_PAYMENTS=false` preserved) | | M4.3.4 Provider Contract Decision & M4 Closure | `feature/m4-3-4-contract-decision` | **Formally Closed (Live Payments Disabled)** (Adopted Outcome B: PhonePe v1 classified sandbox-only due to lack of machine-verifiable currency in payload; full 22-vector regression suite added; 38/38 UPI tests pass, 471/471 unit/integration tests pass, 157/157 Playwright E2E pass, 140/140 lint clean; `ENABLE_LIVE_PAYMENTS=false` preserved; all M4 exit criteria verified and passed) | | M5 Telegram & Operational Workflows | `feature/m5-telegram-operational-workflows` / #35 | **Completed** (Telegram identity & account linking, event private supergroup isolation without shared global group, operational recovery on /start, transactional outbox notifications with quiet hours, /report moderation workflow with emergency escalation; 16/16 tests in test/telegram-workflows.test.js, 487/487 unit/integration tests pass, ENABLE_LIVE_PAYMENTS=false preserved) | -| M6 Organiser Workspace & QR Check-in | `feature/m6-organiser-workspace` | **Recommended Next Milestone** (Organiser event management, door staff roles, dynamic QR check-in & verification) | +| M6 Organiser Workspace, Event Staff & Secure QR Check-in | `feature/m6-organiser-workspace` | **Completed** (Full organiser workspace with event dashboard, readiness checklist, ticket inventory, guest management, and operational announcements; event-scoped staff roles with organiser, event_manager, door_staff, guest_list_manager, assistant; server-authoritative HMAC-SHA256 signed QR tickets bound to events without leaking attendee PII; mobile-first door check-in scanner with camera QR detection and manual code lookup; atomic duplicate scan and replay prevention; ticket reissue and revocation lifecycle; 15/15 tests in test/m6-organiser-workspace.test.js, 642/642 tests passing across entire regression suite, 145/145 lint clean; `ENABLE_LIVE_PAYMENTS=false` preserved) | +| M7 Attendee Discovery & Experience Hub | `feature/m7-attendee-experience` | **Recommended Next Milestone** (Personalised attendee discovery, enhanced event cards, location-aware feed, instant booking pass display, trust badges) | ## 11. Where the detail lives | Topic | Doc | |---|---| +| Organiser workspace & QR check-in architecture | [arch/ORGANISER_WORKSPACE_AND_QR_CHECKIN.md](arch/ORGANISER_WORKSPACE_AND_QR_CHECKIN.md) | | UPI security audit | [UPI_SECURITY_AUDIT.md](UPI_SECURITY_AUDIT.md) | | UPI provider readiness assessment | [UPI_PROVIDER_READINESS.md](UPI_PROVIDER_READINESS.md) | | UPI-first checkout architecture | [arch/UPI_PAYMENT_INTEGRATION.md](arch/UPI_PAYMENT_INTEGRATION.md) | diff --git a/docs/arch/ORGANISER_WORKSPACE_AND_QR_CHECKIN.md b/docs/arch/ORGANISER_WORKSPACE_AND_QR_CHECKIN.md new file mode 100644 index 0000000..a9f5b2a --- /dev/null +++ b/docs/arch/ORGANISER_WORKSPACE_AND_QR_CHECKIN.md @@ -0,0 +1,132 @@ +# Organiser Workspace, Event Staff Roles & Secure QR Check-In Architecture (M6) + +## 1. Executive Summary + +Milestone 6 (M6) delivers an end-to-end operational workspace for event organisers, an event-scoped role and permissions system for venue staff, and a server-authoritative, cryptographically signed QR ticketing engine with a mobile-first door check-in scanner. + +Key guarantees: +- **Server Authoritative:** Admission decisions are never made on the client. Every ticket is cryptographically verified and validated against the live database state. Screenshots, client responses, or cached tokens can never forge a valid ticket or fake a check-in. +- **Privacy Preservation:** Ticket QR payloads expose zero attendee personally identifiable information (PII) such as phone numbers, email addresses, names, or predictable database integer IDs. +- **Event-Scoped RBAC:** Staff permissions (`organiser`, `event_manager`, `door_staff`, `guest_list_manager`, `assistant`) are scoped strictly to the assigned event. Revoked staff access terminates immediately. +- **Duplicate & Replay Defense:** Atomic database row locking (`FOR UPDATE OF t`) guarantees that parallel or rapid-fire scans of the same ticket allow exactly one entry, recording any duplicate attempt with timestamp and staff metadata. +- **Immutable Audit Trail:** All scan attempts (valid, duplicate, invalid signature, cross-event, cancelled, or manual override) are permanently appended to the `ticket_scans` table. + +--- + +## 2. Event Staff Roles & Permission Matrix + +Event operations are separated from global platform roles. Staff membership is stored in `event_team` with lifecycle status (`active`, `removed`). + +| Permission / Action | Organiser (Owner) | Event Manager | Guest-List Manager | Door Staff | Assistant (Read-only) | +|---|:---:|:---:|:---:|:---:|:---:| +| `event.manage` (Dashboard, Overview) | ✅ | ✅ | ❌ | ❌ | ❌ | +| `event.view_inventory` (Capacity, Tiers) | ✅ | ✅ | ✅ | ✅ | ✅ | +| `event.view_guests` (Attendee List) | ✅ | ✅ | ✅ | ❌ | ✅ | +| `event.edit` (Title, Timing, Details) | ✅ | ✅ | ❌ | ❌ | ❌ | +| `event.cancel` (Cancel Event) | ✅ | ✅ | ❌ | ❌ | ❌ | +| `event.manage_staff` (Invite / Revoke) | ✅ | ✅ | ❌ | ❌ | ❌ | +| `event.decide_bookings` (Approve / Decline) | ✅ | ✅ | ✅ | ❌ | ❌ | +| `event.cancel_booking` (Cancel Attendee) | ✅ | ✅ | ❌ | ❌ | ❌ | +| `event.announcements` (Post Updates) | ✅ | ✅ | ❌ | ❌ | ❌ | +| `event.check_in` (Door Scanner Admission) | ✅ | ✅ | ✅ | ✅ | ❌ | +| `event.manual_override` (Manager Override) | ✅ | ✅ | ❌ | ❌ | ❌ | +| `event.reissue_ticket` (Replace Pass) | ✅ | ✅ | ❌ | ❌ | ❌ | +| `event.view_audit` (Scan Log History) | ✅ | ✅ | ❌ | ❌ | ❌ | +| Financial Actions (Refunds / Live PG) | ❌* | ❌* | ❌* | ❌* | ❌* | + +*\*Financial mutations remain strictly isolated to platform `super_admin` / `finance` roles in `admin_users`. Event staff cannot issue unauthorized monetary transactions or refunds.* + +--- + +## 3. Cryptographic QR Ticket Engine + +### 3.1 QR Payload Structure +Each ticket payload conforms to standard format: +``` +NLZ1:: +``` +Where: +- `NLZ1`: Version identifier. +- ``: Cryptographically generated 12-character alphanumeric code formatted as `TK-XXXX-XXXX-XXXX` (using `crypto.randomBytes(6)`). +- ``: 64-character hexadecimal signature generated with the server ticket secret over `${ticketCode}:${eventId}`. + +### 3.2 Security Properties +1. **Zero PII Exposure:** The QR payload contains only the random ticket code and its HMAC signature. No attendee name, phone number, Telegram ID, or database sequence number is present. +2. **Cryptographic Event Binding:** Because the signature is calculated over `${ticketCode}:${eventId}`, a ticket issued for Event A scanned at Event B immediately fails signature verification without requiring a database lookup. +3. **Constant-Time Verification:** Signatures are compared using `crypto.timingSafeEqual` to prevent timing side-channel analysis. +4. **Offline Tamper Prevention:** Any alteration to the ticket code or signature renders the payload invalid. + +--- + +## 4. Door Check-In Experience + +### 4.1 Mobile-First Scanner Interface +Staff accessing `/organiser/events/:id/scan` receive a purpose-built scanning interface: +- **Camera Scanning:** Leverages modern browser `BarcodeDetector` API and high-frame canvas fallback to process QR codes directly from the device camera. +- **Manual Lookup Fallback:** If camera access is denied, broken, or hardware-constrained, staff can enter the alphanumeric ticket code (`TK-...`) printed on the attendee pass. +- **Instant Result Badges:** + - `VALID` (Green): Attendee admitted; displays alias, ticket tier, and pass reference. + - `ALREADY CHECKED IN` (Yellow): Duplicate scan alert; displays prior check-in timestamp and staff member who performed the initial scan. + - `CANCELLED / REFUNDED` (Red): Ineligible pass; displays cancellation/refund status. + - `REVOKED / REISSUED` (Red): Invalid pass replaced by reissued pass. + - `SIGNATURE MISMATCH` (Red): Forged or altered QR payload. + +--- + +## 5. State Machine & Atomic Concurrency + +Tickets transition strictly through server-validated states: + +``` +[Booking Confirmed] + │ + ▼ + (valid) ────▶ [reissued] ──▶ (valid - new ticket) + │ + ├───▶ [cancelled] + │ + ├───▶ [refunded] + │ + ▼ + [checked_in] + │ + (Duplicate attempt rejected with 409 Conflict) +``` + +### 5.1 Concurrency Defense +Check-in operations run inside an atomic PostgreSQL transaction with row-level locking: +```sql +SELECT t.* FROM tickets t + JOIN bookings b ON b.id = t.booking_id + WHERE t.ticket_code = $1 + FOR UPDATE OF t +``` +This guarantees that if two staff members or two devices scan the same QR code at the exact same millisecond: +1. One request locks the row, validates that `status === 'valid'`, transitions it to `'checked_in'`, and commits. +2. The concurrent request waits for the lock, reads `status === 'checked_in'`, and is immediately rejected with HTTP 409 `already_checked_in`. + +--- + +## 6. Attendance & Scan Audit Ledger (`ticket_scans`) + +Every door interaction appends an immutable record to `ticket_scans`: +- `event_id`: Event UUID. +- `ticket_id`: Target ticket UUID (null if unknown/forged code). +- `scanned_by`: User UUID of the scanning staff member. +- `scan_result`: One of `success`, `already_checked_in`, `ticket_not_found`, `event_mismatch`, `invalid_ticket`, `ticket_revoked`, `ticket_reissued`, `ticket_cancelled`, `ticket_refunded`, `manual_override`. +- `scan_method`: `camera_qr`, `manual_lookup`, or `manual_override`. +- `override_reason`: Required text for manual admission overrides. +- `scanned_payload_hash`: SHA-256 hash of the scanned input. +- `metadata`: JSON context including previous check-in timestamps and attendee tier. + +--- + +## 7. Migration 019 Summary + +Schema enhancements applied in `migrations/019_organiser_workspace_and_checkin.sql`: +1. `event_team`: Added audit columns (`updated_at`, `invited_by`, `revoked_by`, `revoked_at`, `notes`) and unique index `uq_event_team_user_active`. +2. `event_staff_invites`: Role-scoped token invitations with TTL expiration. +3. `tickets`: Core ticket ledger storing `ticket_code`, `security_hash`, status, check-in metadata, and reissue/revocation pointers. +4. `ticket_scans`: Append-only scanner audit log. +5. `event_announcements`: Organiser broadcast messages to confirmed attendees. +6. Backfill: Historical confirmed bookings automatically populated into `tickets`. diff --git a/lib/handlers/organiser.js b/lib/handlers/organiser.js index 5f24167..7db1697 100644 --- a/lib/handlers/organiser.js +++ b/lib/handlers/organiser.js @@ -13,7 +13,17 @@ import { CATEGORY_LABELS, CITIES } from '../services/events.js'; import { TIMEZONES } from '../time.js'; import * as O from '../services/organiser.js'; import { bookTier, TierBookingBody, decideRequest, listEventGuests, manageableEvent } from '../services/ticketing.js'; -import { renderOrganiserPage, renderDraftPreview } from '../views/organiser.js'; +import { renderOrganiserPage, renderDraftPreview, renderStaffJoinPage } from '../views/organiser.js'; +import { can, authorize } from '../services/permissions.js'; +import { + checkInTicket, reissueTicket, revokeTicket, getEventAttendance, listEventScans, getBookingTickets +} from '../services/tickets.js'; +import { + listEventStaff, assignEventStaff, revokeEventStaff, createStaffInvite, getStaffInvite, acceptStaffInvite +} from '../services/event-staff.js'; +import { + listEventAnnouncements, createEventAnnouncement +} from '../services/event-announcements.js'; const id = (v) => { if (!uuid.safeParse(v).success) throw new HttpError(404, 'not_found', 'Not found'); @@ -33,12 +43,17 @@ async function organiser(ctx, { write = false, limit = [240, 600], scope = null // ---------- pages ---------- -/** Resolve page state without throwing: signed_out | not_organiser | ok. */ -async function pageState(req) { +/** Resolve page state without throwing: signed_out | not_organiser | ok. Supports event-scoped staff. */ +async function pageState(req, eventId = null) { const user = await getSessionUser(req); if (!user) return { state: 'signed_out' }; - if (!(await O.isOrganiser(user))) return { state: 'not_organiser', user }; - return { state: 'ok', user, csrf: csrfTokenFor(user.sessionToken) }; + const isOrg = await O.isOrganiser(user); + if (isOrg) return { state: 'ok', user, csrf: csrfTokenFor(user.sessionToken) }; + if (eventId) { + const isStaff = await can(user, 'event.check_in', { eventId }); + if (isStaff) return { state: 'ok', user, csrf: csrfTokenFor(user.sessionToken), eventStaff: true }; + } + return { state: 'not_organiser', user }; } function wizardConfig(user, draftId) { @@ -71,8 +86,35 @@ export async function wizardPage({ req, res, query }) { } export async function managePage({ req, res, params }) { - const s = await pageState(req); - sendHtml(res, 200, renderOrganiserPage({ page: 'manage', ...s, eventId: id(params.id) }), NOINDEX); + const eventId = id(params.id); + const s = await pageState(req, eventId); + sendHtml(res, 200, renderOrganiserPage({ page: 'manage', ...s, eventId }), NOINDEX); +} + +export async function scanPage({ req, res, params }) { + const eventId = id(params.id); + const s = await pageState(req, eventId); + sendHtml(res, 200, renderOrganiserPage({ page: 'scan', ...s, eventId }), NOINDEX); +} + +export async function staffJoinPage({ req, res, query }) { + const token = query.get('token'); + const user = await getSessionUser(req).catch(() => null); + let invite = null; + let error = null; + if (!token) { + error = 'Missing invitation token'; + } else { + try { + invite = await getStaffInvite(token); + invite.token = token; + } catch (err) { + error = err.message || 'Invalid or expired invite'; + } + } + const csrf = user ? csrfTokenFor(user.sessionToken) : ''; + const state = user ? 'ok' : 'signed_out'; + sendHtml(res, 200, renderStaffJoinPage({ state, invite, csrf, error }), NOINDEX); } /** The real attendee page rendered from the draft, booking disabled. Owner only; never cached. */ @@ -211,3 +253,228 @@ export async function bookTicket(ctx) { await afterResponse(() => deliverMany(r.notificationIds || [])); sendJson(ctx.res, 200, { ok: true, bookingId: r.bookingId, status: r.status, checkoutUrl: r.checkoutUrl || null, existing: Boolean(r.existing) }); } + +// ---------- M6 Organiser Workspace & Event Staff API ---------- + +export async function eventOverview(ctx) { + const user = await requireUser(ctx.req); + const overview = await O.getEventOverview(user, id(ctx.params.id)); + sendJson(ctx.res, 200, { ok: true, ...overview }); +} + +const UpdateEventBody = z.object({ + title: z.string().min(3).max(140).optional(), + description: z.string().min(10).max(5000).optional(), + capacity: z.number().int().min(1).max(5000).optional(), + start_at: z.string().optional(), + end_at: z.string().nullable().optional(), + area_public: z.string().max(120).nullable().optional(), + venue_private: z.string().max(300).nullable().optional(), + venue_privacy: z.enum(['public', 'disclosed_after_booking']).optional(), + maps_url: z.string().max(500).nullable().optional(), + refund_policy: z.string().max(1000).nullable().optional() +}).strict(); + +export async function updateEvent(ctx) { + const user = await requireUser(ctx.req); + requireCsrf(ctx.req, user); + await rateLimit(`org:edit:${user.id}`, 60, 600); + const body = parse(UpdateEventBody, await readJson(ctx.req)); + const event = await O.updateOrganiserEvent(user, id(ctx.params.id), body); + sendJson(ctx.res, 200, { ok: true, event }); +} + +const CancelBody = z.object({ + reason: z.string().min(3).max(500).default('Cancelled by organiser') +}).strict(); + +export async function cancelEvent(ctx) { + const user = await requireUser(ctx.req); + requireCsrf(ctx.req, user); + await rateLimit(`org:cancel:${user.id}`, 20, 600); + const body = parse(CancelBody, await readJson(ctx.req)); + const r = await O.cancelOrganiserEvent(user, id(ctx.params.id), body); + sendJson(ctx.res, 200, r); +} + +export async function eventInventory(ctx) { + const user = await requireUser(ctx.req); + const inv = await O.getEventInventory(user, id(ctx.params.id)); + sendJson(ctx.res, 200, { ok: true, ...inv }); +} + +export async function eventBookings(ctx) { + const user = await requireUser(ctx.req); + const q = ctx.query.get('q') || ''; + const status = ctx.query.get('status') || 'all'; + const page = Math.max(1, parseInt(ctx.query.get('page') || '1', 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(ctx.query.get('limit') || '50', 10) || 50)); + const bookings = await O.listOrganiserBookings(user, id(ctx.params.id), { query: q, status, page, limit }); + sendJson(ctx.res, 200, { ok: true, bookings, page, limit }); +} + +export async function cancelBooking(ctx) { + const user = await requireUser(ctx.req); + requireCsrf(ctx.req, user); + await rateLimit(`org:bkcancel:${user.id}`, 60, 600); + const body = parse(CancelBody, await readJson(ctx.req)); + const r = await O.cancelOrganiserBooking(user, id(ctx.params.id), body); + sendJson(ctx.res, 200, r); +} + +// ---------- Event Staff Roles & Invitations ---------- + +export async function eventStaff(ctx) { + const user = await requireUser(ctx.req); + const data = await listEventStaff(user, id(ctx.params.id)); + sendJson(ctx.res, 200, { ok: true, ...data }); +} + +const AssignStaffBody = z.object({ + userId: uuid, + role: z.enum(['event_manager', 'door_staff', 'guest_list_manager', 'assistant']), + notes: z.string().max(500).nullable().optional() +}).strict(); + +export async function assignStaff(ctx) { + const user = await requireUser(ctx.req); + requireCsrf(ctx.req, user); + await rateLimit(`org:staff:${user.id}`, 60, 600); + const body = parse(AssignStaffBody, await readJson(ctx.req)); + const r = await assignEventStaff(user, id(ctx.params.id), body); + sendJson(ctx.res, 201, r); +} + +export async function revokeStaff(ctx) { + const user = await requireUser(ctx.req); + requireCsrf(ctx.req, user); + await rateLimit(`org:staff:${user.id}`, 60, 600); + const r = await revokeEventStaff(user, id(ctx.params.id), id(ctx.params.staffId)); + sendJson(ctx.res, 200, r); +} + +const InviteStaffBody = z.object({ + role: z.enum(['event_manager', 'door_staff', 'guest_list_manager', 'assistant']), + invitedAlias: z.string().max(80).optional(), + ttlHours: z.number().int().min(1).max(168).default(48) +}).strict(); + +export async function inviteStaff(ctx) { + const user = await requireUser(ctx.req); + requireCsrf(ctx.req, user); + await rateLimit(`org:staff:${user.id}`, 60, 600); + const body = parse(InviteStaffBody, await readJson(ctx.req)); + const r = await createStaffInvite(user, id(ctx.params.id), body); + sendJson(ctx.res, 201, r); +} + +export async function getStaffInviteHandler(ctx) { + const invite = await getStaffInvite(ctx.params.token); + sendJson(ctx.res, 200, { ok: true, invite }); +} + +const AcceptInviteBody = z.object({ + token: z.string().min(10).max(100) +}).strict(); + +export async function acceptStaffInviteHandler(ctx) { + const user = await requireUser(ctx.req); + requireCsrf(ctx.req, user); + const body = parse(AcceptInviteBody, await readJson(ctx.req)); + const r = await acceptStaffInvite(user, body.token); + sendJson(ctx.res, 200, r); +} + +// ---------- Event Announcements ---------- + +export async function eventAnnouncements(ctx) { + const user = await requireUser(ctx.req); + const announcements = await listEventAnnouncements(user, id(ctx.params.id)); + sendJson(ctx.res, 200, { ok: true, announcements }); +} + +const AnnouncementBody = z.object({ + title: z.string().min(3).max(120), + message: z.string().min(5).max(3000), + isPinned: z.boolean().default(false), + notifyAttendees: z.boolean().default(true) +}).strict(); + +export async function postAnnouncement(ctx) { + const user = await requireUser(ctx.req); + requireCsrf(ctx.req, user); + await rateLimit(`org:ann:${user.id}`, 30, 600); + const body = parse(AnnouncementBody, await readJson(ctx.req)); + const r = await createEventAnnouncement(user, id(ctx.params.id), body); + sendJson(ctx.res, 201, r); +} + +// ---------- Secure QR Ticket Check-In & Verification ---------- + +const CheckInBody = z.object({ + ticketCode: z.string().max(50).nullable().optional(), + qrPayload: z.string().max(500).nullable().optional(), + method: z.enum(['camera_qr', 'manual_lookup', 'manual_override', 'camera', 'manual']).default('camera_qr'), + scanMethod: z.enum(['camera_qr', 'manual_lookup', 'manual_override', 'camera', 'manual']).optional(), + overrideReason: z.string().max(500).nullable().optional() +}).strict(); + +export async function checkInTicketHandler(ctx) { + const user = await requireUser(ctx.req); + requireCsrf(ctx.req, user); + await rateLimit(`checkin:${user.id}`, 600, 600); + const body = parse(CheckInBody, await readJson(ctx.req)); + const rawMethod = body.scanMethod || body.method; + const method = rawMethod === 'camera' ? 'camera_qr' : (rawMethod === 'manual' ? 'manual_lookup' : rawMethod); + const r = await checkInTicket(user, id(ctx.params.id), { ...body, method }); + sendJson(ctx.res, 200, r); +} + +export async function eventScans(ctx) { + const user = await requireUser(ctx.req); + const eventId = id(ctx.params.id); + await authorize(user, 'event.view_audit', { eventId }); + const limit = Math.min(100, Math.max(1, parseInt(ctx.query.get('limit') || '50', 10) || 50)); + const scans = await listEventScans(eventId, limit); + sendJson(ctx.res, 200, { ok: true, scans }); +} + +export async function eventAttendance(ctx) { + const user = await requireUser(ctx.req); + const eventId = id(ctx.params.id); + await authorize(user, 'event.view_inventory', { eventId }); + const attendance = await getEventAttendance(eventId); + sendJson(ctx.res, 200, { ok: true, ...attendance }); +} + +const ReissueBody = z.object({ + reason: z.string().min(3).max(500).default('reissued_by_host') +}).strict(); + +export async function reissueTicketHandler(ctx) { + const user = await requireUser(ctx.req); + requireCsrf(ctx.req, user); + await rateLimit(`org:reissue:${user.id}`, 30, 600); + const body = parse(ReissueBody, await readJson(ctx.req)); + const r = await reissueTicket(user, id(ctx.params.id), body.reason); + sendJson(ctx.res, 200, r); +} + +const RevokeTicketBody = z.object({ + reason: z.string().min(3).max(500).default('revoked_by_host') +}).strict(); + +export async function revokeTicketHandler(ctx) { + const user = await requireUser(ctx.req); + requireCsrf(ctx.req, user); + await rateLimit(`org:revoke:${user.id}`, 30, 600); + const body = parse(RevokeTicketBody, await readJson(ctx.req)); + const r = await revokeTicket(user, id(ctx.params.id), body.reason); + sendJson(ctx.res, 200, r); +} + +export async function guestTicket(ctx) { + const user = await requireUser(ctx.req); + const tickets = await getBookingTickets(id(ctx.params.id), user); + sendJson(ctx.res, 200, { ok: true, tickets }); +} diff --git a/lib/router.js b/lib/router.js index 0071d7e..e8e15c6 100644 --- a/lib/router.js +++ b/lib/router.js @@ -42,6 +42,8 @@ const ROUTES = [ ['GET', '/organiser/new', h('organiser', 'wizardPage')], ['GET', '/organiser/drafts/:id/preview', h('organiser', 'draftPreviewPage')], ['GET', '/organiser/events/:id', h('organiser', 'managePage')], + ['GET', '/organiser/events/:id/scan', h('organiser', 'scanPage')], + ['GET', '/staff/join', h('organiser', 'staffJoinPage')], ['GET', '/media/event-images/:id', h('organiser', 'serveImage')], ['GET', '/auth/telegram/start', h('auth', 'oidcStart')], ['GET', '/auth/telegram/callback', h('auth', 'oidcCallback')], @@ -110,9 +112,29 @@ const ROUTES = [ ['POST', '/api/organiser/drafts/:id/images', h('organiser', 'uploadImage')], ['DELETE', '/api/organiser/drafts/:id/images/:image', h('organiser', 'deleteImage')], ['GET', '/api/organiser/events', h('organiser', 'listEvents')], + ['GET', '/api/organiser/events/:id/overview', h('organiser', 'eventOverview')], + ['PUT', '/api/organiser/events/:id', h('organiser', 'updateEvent')], + ['POST', '/api/organiser/events/:id/cancel', h('organiser', 'cancelEvent')], + ['GET', '/api/organiser/events/:id/inventory', h('organiser', 'eventInventory')], + ['GET', '/api/organiser/events/:id/bookings', h('organiser', 'eventBookings')], ['GET', '/api/organiser/events/:id/guests', h('organiser', 'eventGuests')], ['POST', '/api/organiser/bookings/:id/approve', h('organiser', 'approveBooking')], ['POST', '/api/organiser/bookings/:id/decline', h('organiser', 'declineBooking')], + ['POST', '/api/organiser/bookings/:id/cancel', h('organiser', 'cancelBooking')], + ['GET', '/api/organiser/events/:id/staff', h('organiser', 'eventStaff')], + ['POST', '/api/organiser/events/:id/staff', h('organiser', 'assignStaff')], + ['DELETE', '/api/organiser/events/:id/staff/:staffId', h('organiser', 'revokeStaff')], + ['POST', '/api/organiser/events/:id/staff/invite', h('organiser', 'inviteStaff')], + ['GET', '/api/staff/invite/:token', h('organiser', 'getStaffInviteHandler')], + ['POST', '/api/staff/accept-invite', h('organiser', 'acceptStaffInviteHandler')], + ['GET', '/api/organiser/events/:id/announcements', h('organiser', 'eventAnnouncements')], + ['POST', '/api/organiser/events/:id/announcements', h('organiser', 'postAnnouncement')], + ['POST', '/api/organiser/events/:id/check-in', h('organiser', 'checkInTicketHandler')], + ['GET', '/api/organiser/events/:id/scans', h('organiser', 'eventScans')], + ['GET', '/api/organiser/events/:id/attendance', h('organiser', 'eventAttendance')], + ['POST', '/api/organiser/tickets/:id/reissue', h('organiser', 'reissueTicketHandler')], + ['POST', '/api/organiser/tickets/:id/revoke', h('organiser', 'revokeTicketHandler')], + ['GET', '/api/me/bookings/:id/ticket', h('organiser', 'guestTicket')], // Webhooks ['POST', '/api/telegram/webhook', h('telegram', 'telegramWebhook')], @@ -171,7 +193,7 @@ const ROUTES = [ const COMPILED = ROUTES.map(([method, pattern, load]) => { const keys = []; - const re = new RegExp(`^${pattern.replace(/\/:([a-z]+)/g, (_, k) => { keys.push(k); return '/([^/]+)'; })}/?$`); + const re = new RegExp(`^${pattern.replace(/\/:([a-zA-Z0-9_]+)/g, (_, k) => { keys.push(k); return '/([^/]+)'; })}/?$`); return { method, pattern, re, keys, load }; }); @@ -221,8 +243,7 @@ export async function handle(req, res) { sendHtml(res, status, status === 404 ? renderNotFound() : renderError()); } else { const safe = err instanceof HttpError - ? { ok: false, error: err.code, message: err.message, ...(err.extra?.fields ? { fields: err.extra.fields } : {}), - ...(err.extra?.issues ? { issues: err.extra.issues } : {}), ...(err.extra?.draft ? { draft: err.extra.draft } : {}) } + ? { ok: false, error: err.code, message: err.message, ...(err.extra || {}) } : { ok: false, error: status === 503 ? 'not_configured' : 'internal', message: status === 503 ? 'Service not configured yet' : 'Something went wrong' }; sendJson(res, status, safe); } diff --git a/lib/services/bookings.js b/lib/services/bookings.js index 7bfcc14..b7d55fc 100644 --- a/lib/services/bookings.js +++ b/lib/services/bookings.js @@ -3,6 +3,7 @@ // confirm in services/ticketing.js. import { db } from '../db.js'; import { SEAT_HOLDING_SQL, formatPaise } from './events.js'; +import { getBookingTickets } from './tickets.js'; export async function seatsTaken(c, eventId, excludeBookingId = null) { const { rows } = await c.query( @@ -57,6 +58,7 @@ export async function getUserBooking(userId, bookingId) { : null; const pickup = (Array.isArray(b.pickup_points) ? b.pickup_points : []).find((p) => p.id === b.pickup_point_id) || null; const phone = confirmed && b.contact_phone ? b.contact_phone : null; + const tickets = confirmed ? await getBookingTickets(b.id, { id: userId }, db).catch(() => []) : []; return { id: b.id, status: b.status, title: b.title, city: b.city, startAt: b.start_at, timezone: b.timezone, slug: b.slug, @@ -64,6 +66,7 @@ export async function getUserBooking(userId, bookingId) { venue: confirmed ? (b.venue_private || null) : null, mapsUrl: confirmed || b.venue_privacy === 'public' ? (b.maps_url || null) : null, ticket: b.ticket_name ? { name: b.ticket_name, kind: b.ticket_kind, quantity: b.quantity } : null, + tickets, amountLabel: b.amount_paise ? formatPaise(b.amount_paise) : null, paymentMode: b.payment_mode, paymentStatus: b.payment_status || (b.payment_mode === 'free' ? 'not_required' : null), diff --git a/lib/services/event-announcements.js b/lib/services/event-announcements.js new file mode 100644 index 0000000..965a1ff --- /dev/null +++ b/lib/services/event-announcements.js @@ -0,0 +1,104 @@ +// lib/services/event-announcements.js - Event announcements and operational updates. +import { db } from '../db.js'; +import { HttpError } from '../http.js'; +import { audit } from '../audit.js'; +import { authorize } from './permissions.js'; +import { enqueue } from './notifications.js'; + +export async function listEventAnnouncements(user, eventId, c = db) { + // Allow organisers, event staff, and attendees of the event + const isStaffOrOrg = await authorize(user, 'event.view_guests', { eventId }, c).then(() => true).catch(() => false); + if (!isStaffOrOrg) { + const isAttendee = (await c.query( + `SELECT 1 FROM bookings WHERE event_id = $1 AND user_id = $2 AND status IN ('confirmed', 'checked_in')`, + [eventId, user.id] + )).rowCount > 0; + if (!isAttendee) throw new HttpError(404, 'not_found', 'Event not found'); + } + + const { rows } = await c.query( + `SELECT ea.id, ea.title, ea.message, ea.is_pinned, ea.created_at, + u.alias AS sender_alias + FROM event_announcements ea + JOIN users u ON u.id = ea.sender_user_id + WHERE ea.event_id = $1 + ORDER BY ea.is_pinned DESC, ea.created_at DESC`, + [eventId] + ); + + return rows.map((r) => ({ + id: r.id, + title: r.title, + message: r.message, + isPinned: Boolean(r.is_pinned), + createdAt: r.created_at, + senderAlias: r.sender_alias || 'Event Team' + })); +} + +export async function createEventAnnouncement(actor, eventId, { title, message, isPinned = false, notifyAttendees = true } = {}, c = db) { + await authorize(actor, 'event.announcements', { eventId }, c); + + const t = typeof title === 'string' ? title.trim() : ''; + const m = typeof message === 'string' ? message.trim() : ''; + + if (!t || t.length < 3 || t.length > 120) { + throw new HttpError(400, 'validation', 'Title must be between 3 and 120 characters', { + fields: [{ field: 'title', message: '3-120 characters required' }] + }); + } + if (!m || m.length < 5 || m.length > 3000) { + throw new HttpError(400, 'validation', 'Message must be between 5 and 3000 characters', { + fields: [{ field: 'message', message: '5-3000 characters required' }] + }); + } + + return c.tx(async (txClient) => { + const row = (await txClient.query( + `INSERT INTO event_announcements (event_id, sender_user_id, title, message, is_pinned, notify_attendees) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [eventId, actor.id, t, m, Boolean(isPinned), Boolean(notifyAttendees)] + )).rows[0]; + + let notificationCount = 0; + if (notifyAttendees) { + const attendees = (await txClient.query( + `SELECT DISTINCT user_id FROM bookings WHERE event_id = $1 AND status IN ('confirmed', 'checked_in')`, + [eventId] + )).rows; + + for (const a of attendees) { + await enqueue(txClient, { + kind: 'event_update', + userId: a.user_id, + eventId, + announcementId: row.id, + title: t, + text: m, + dedupeKey: `ann:${row.id}:${a.user_id}` + }); + notificationCount++; + } + } + + await audit(`user:${actor.id}`, 'event.announcement_create', row.id, { + eventId, + title: t, + notifyAttendees: Boolean(notifyAttendees), + recipientCount: notificationCount + }, txClient); + + return { + ok: true, + announcement: { + id: row.id, + title: row.title, + message: row.message, + isPinned: row.is_pinned, + createdAt: row.created_at + }, + notifiedAttendees: notificationCount + }; + }); +} diff --git a/lib/services/event-staff.js b/lib/services/event-staff.js new file mode 100644 index 0000000..392dc03 --- /dev/null +++ b/lib/services/event-staff.js @@ -0,0 +1,325 @@ +// lib/services/event-staff.js - Event-scoped staff roles and membership management. +import crypto from 'node:crypto'; +import { db } from '../db.js'; +import { HttpError } from '../http.js'; +import { audit } from '../audit.js'; +import { can, authorize } from './permissions.js'; + +export const VALID_STAFF_ROLES = ['event_manager', 'door_staff', 'guest_list_manager', 'assistant']; + +export const STAFF_ROLE_LABELS = { + event_manager: 'Event Manager', + door_staff: 'Door / Check-in Staff', + guest_list_manager: 'Guest-list Manager', + assistant: 'Event Assistant (Read-only)' +}; + +/** + * List all staff members (active & removed) and pending invites for an event. + */ +export async function listEventStaff(user, eventId, c = db) { + await authorize(user, 'event.view_guests', { eventId }, c); + + const staffRows = (await c.query( + `SELECT et.id, et.team_role, et.status, et.created_at, et.updated_at, et.revoked_at, et.notes, + u.id AS user_id, u.alias, u.telegram_user_id, + inv.alias AS invited_by_alias, + rev.alias AS revoked_by_alias + FROM event_team et + JOIN users u ON u.id = et.user_id + LEFT JOIN users inv ON inv.id = et.invited_by + LEFT JOIN users rev ON rev.id = et.revoked_by + WHERE et.event_id = $1 + ORDER BY (et.status = 'active') DESC, et.created_at ASC`, + [eventId] + )).rows; + + const invites = (await c.query( + `SELECT esi.id, esi.role, esi.token, esi.invited_alias, esi.status, esi.expires_at, esi.created_at, + u.alias AS invited_by_alias + FROM event_staff_invites esi + JOIN users u ON u.id = esi.invited_by + WHERE esi.event_id = $1 AND esi.status = 'pending' AND esi.expires_at > now() + ORDER BY esi.created_at DESC`, + [eventId] + )).rows; + + const canManage = await can(user, 'event.manage_staff', { eventId }, c); + + return { + eventId, + canManage, + staff: staffRows.map((r) => ({ + id: r.id, + userId: r.user_id, + alias: r.alias || 'Staff Member', + role: r.team_role, + roleLabel: STAFF_ROLE_LABELS[r.team_role] || r.team_role, + status: r.status, + notes: r.notes || null, + invitedByAlias: r.invited_by_alias || null, + revokedByAlias: r.revoked_by_alias || null, + createdAt: r.created_at, + revokedAt: r.revoked_at + })), + invites: invites.map((i) => ({ + id: i.id, + role: i.role, + roleLabel: STAFF_ROLE_LABELS[i.role] || i.role, + invitedAlias: i.invited_alias || null, + invitedByAlias: i.invited_by_alias, + token: canManage ? i.token : null, + inviteUrl: canManage ? `/staff/join?token=${encodeURIComponent(i.token)}` : null, + expiresAt: i.expires_at, + createdAt: i.created_at + })) + }; +} + +/** + * Assigns or updates a staff member directly by userId. + */ +export async function assignEventStaff(actor, eventId, { userId, role, notes = null }, c = db) { + if (!VALID_STAFF_ROLES.includes(role)) { + throw new HttpError(400, 'validation', `Invalid staff role: choose from ${VALID_STAFF_ROLES.join(', ')}`); + } + await authorize(actor, 'event.manage_staff', { eventId }, c); + + const targetUser = (await c.query(`SELECT id, alias, status FROM users WHERE id = $1`, [userId])).rows[0]; + if (!targetUser) throw new HttpError(404, 'user_not_found', 'User not found'); + if (targetUser.status !== 'active') throw new HttpError(400, 'user_inactive', 'User account is not active'); + + // Prevent demoting event owner + const event = (await c.query(`SELECT organiser_user_id FROM events WHERE id = $1`, [eventId])).rows[0]; + if (event?.organiser_user_id === userId) { + throw new HttpError(400, 'owner_immutable', 'The event owner cannot be assigned an event staff role'); + } + + return c.tx(async (txClient) => { + const existing = (await txClient.query( + `SELECT * FROM event_team WHERE event_id = $1 AND user_id = $2 FOR UPDATE`, + [eventId, userId] + )).rows[0]; + + let row; + if (existing) { + row = (await txClient.query( + `UPDATE event_team + SET team_role = $3, + status = 'active', + invited_by = $4, + revoked_by = NULL, + revoked_at = NULL, + notes = COALESCE($5, notes), + updated_at = now() + WHERE id = $1 AND event_id = $2 + RETURNING *`, + [existing.id, eventId, role, actor.id, notes] + )).rows[0]; + } else { + row = (await txClient.query( + `INSERT INTO event_team (event_id, user_id, team_role, status, invited_by, notes) + VALUES ($1, $2, $3, 'active', $4, $5) + RETURNING *`, + [eventId, userId, role, actor.id, notes] + )).rows[0]; + } + + await audit(`user:${actor.id}`, 'event.staff_assign', row.id, { + eventId, + userId, + role, + previousRole: existing?.team_role || null, + previousStatus: existing?.status || null + }, txClient); + + return { + ok: true, + staff: { + id: row.id, + userId: row.user_id, + alias: targetUser.alias, + role: row.team_role, + status: row.status + } + }; + }); +} + +/** + * Revokes a staff member's event access with immediate effect. + */ +export async function revokeEventStaff(actor, eventId, staffId, c = db) { + await authorize(actor, 'event.manage_staff', { eventId }, c); + + return c.tx(async (txClient) => { + const row = (await txClient.query( + `SELECT * FROM event_team WHERE (id = $1 OR user_id = $1) AND event_id = $2 FOR UPDATE`, + [staffId, eventId] + )).rows[0]; + + if (!row) throw new HttpError(404, 'staff_not_found', 'Staff member not found on this event'); + if (row.status === 'removed') { + return { ok: true, staffId: row.id, status: 'removed' }; + } + + const now = new Date(); + await txClient.query( + `UPDATE event_team + SET status = 'removed', + revoked_at = $2, + revoked_by = $3, + updated_at = $2 + WHERE id = $1`, + [row.id, now, actor.id] + ); + + await audit(`user:${actor.id}`, 'event.staff_revoke', row.id, { + eventId, + targetUserId: row.user_id, + previousRole: row.team_role + }, txClient); + + return { ok: true, staffId: row.id, status: 'removed' }; + }); +} + +/** + * Creates a single-use staff invite token. + */ +export async function createStaffInvite(actor, eventId, { role, invitedAlias = null, ttlHours = 48 } = {}, c = db) { + if (!VALID_STAFF_ROLES.includes(role)) { + throw new HttpError(400, 'validation', `Invalid staff role: choose from ${VALID_STAFF_ROLES.join(', ')}`); + } + await authorize(actor, 'event.manage_staff', { eventId }, c); + + const token = crypto.randomBytes(24).toString('base64url'); + const expiresAt = new Date(Date.now() + ttlHours * 3600 * 1000); + + const invite = (await c.query( + `INSERT INTO event_staff_invites (event_id, role, token, invited_by, invited_alias, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [eventId, role, token, actor.id, invitedAlias || null, expiresAt] + )).rows[0]; + + await audit(`user:${actor.id}`, 'event.staff_invite_create', invite.id, { + eventId, + role, + invitedAlias + }, c); + + return { + ok: true, + inviteId: invite.id, + token: invite.token, + role: invite.role, + expiresAt: invite.expires_at, + inviteUrl: `/staff/join?token=${encodeURIComponent(invite.token)}` + }; +} + +/** + * Validates a staff invite token without consuming it. + */ +export async function getStaffInvite(token, c = db) { + if (!token || typeof token !== 'string') throw new HttpError(400, 'validation', 'Invite token is required'); + + const invite = (await c.query( + `SELECT esi.*, e.title AS event_title, e.start_at AS event_start_at, e.city AS event_city, + u.alias AS invited_by_alias + FROM event_staff_invites esi + JOIN events e ON e.id = esi.event_id + JOIN users u ON u.id = esi.invited_by + WHERE esi.token = $1`, + [token.trim()] + )).rows[0]; + + if (!invite) throw new HttpError(404, 'invalid_invite', 'This invite link is invalid or does not exist'); + if (invite.status !== 'pending' || new Date(invite.expires_at) <= new Date()) { + throw new HttpError(410, 'expired_invite', 'This invite link has expired or has already been used'); + } + + return { + id: invite.id, + eventId: invite.event_id, + eventTitle: invite.event_title, + eventCity: invite.event_city, + eventStartAt: invite.event_start_at, + role: invite.role, + roleLabel: STAFF_ROLE_LABELS[invite.role] || invite.role, + invitedByAlias: invite.invited_by_alias, + expiresAt: invite.expires_at + }; +} + +/** + * Consumes a staff invite token and adds the user to event_team. + */ +export async function acceptStaffInvite(user, token, c = db) { + if (!user || !user.id) throw new HttpError(401, 'unauthorized', 'Please sign in to accept this staff invitation'); + + return c.tx(async (txClient) => { + const invite = (await txClient.query( + `SELECT * FROM event_staff_invites WHERE token = $1 FOR UPDATE`, + [token.trim()] + )).rows[0]; + + if (!invite) throw new HttpError(404, 'invalid_invite', 'This invite link is invalid'); + if (invite.status !== 'pending' || new Date(invite.expires_at) <= new Date()) { + throw new HttpError(410, 'expired_invite', 'This invite link has expired or has already been used'); + } + + // Add or update event_team row + const existing = (await txClient.query( + `SELECT * FROM event_team WHERE event_id = $1 AND user_id = $2 FOR UPDATE`, + [invite.event_id, user.id] + )).rows[0]; + + let row; + if (existing) { + row = (await txClient.query( + `UPDATE event_team + SET team_role = $3, + status = 'active', + invited_by = $4, + revoked_by = NULL, + revoked_at = NULL, + updated_at = now() + WHERE id = $1 + RETURNING *`, + [existing.id, invite.event_id, invite.role, invite.invited_by] + )).rows[0]; + } else { + row = (await txClient.query( + `INSERT INTO event_team (event_id, user_id, team_role, status, invited_by) + VALUES ($1, $2, $3, 'active', $4) + RETURNING *`, + [invite.event_id, user.id, invite.role, invite.invited_by] + )).rows[0]; + } + + // Mark invite accepted + await txClient.query( + `UPDATE event_staff_invites + SET status = 'accepted', + accepted_by = $2, + accepted_at = now() + WHERE id = $1`, + [invite.id, user.id] + ); + + await audit(`user:${user.id}`, 'event.staff_invite_accept', invite.id, { + eventId: invite.event_id, + role: invite.role, + teamMemberId: row.id + }, txClient); + + return { + ok: true, + eventId: invite.event_id, + role: invite.role, + roleLabel: STAFF_ROLE_LABELS[invite.role] || invite.role + }; + }); +} diff --git a/lib/services/orders.js b/lib/services/orders.js index e3c29d7..2833f9b 100644 --- a/lib/services/orders.js +++ b/lib/services/orders.js @@ -14,6 +14,7 @@ import { emailHash, phoneHash, rupeesToPaise } from './superprofile.js'; import { removeFromChat } from './invites.js'; import { recordIntegrationError } from './insights.js'; import { logger } from '../logger.js'; +import { issueTicketsForBooking } from './tickets.js'; /** Normalise a checkout ref coming back from SuperProfile (case and stray characters vary). */ export function normaliseRef(v) { @@ -64,6 +65,7 @@ export async function confirmOrderBooking(c, { order, event, userId, ticketTypeI `INSERT INTO bookings (event_id, user_id, ticket_type_id, quantity, status, amount_paise, payment_mode, source, confirmed_at) VALUES ($1, $2, $3, $4, 'confirmed', $5, 'external', 'superprofile', now()) RETURNING id`, [event.id, userId, ticketTypeId, order.qty, order.amount_paise])).rows[0]; + await issueTicketsForBooking(b.id, c); await c.query(`UPDATE external_orders SET matched_booking_id = $2, match_state = 'matched', flag_reason = NULL WHERE id = $1`, [order.id, b.id]); const nid = await enqueue(c, { kind: 'booking_confirmed', userId, bookingId: b.id, dedupeKey: `bk:${b.id}:confirmed` }); await audit(actor, 'booking.confirmed_external', b.id, { orderRow: order.id, qty: order.qty }, c); diff --git a/lib/services/organiser.js b/lib/services/organiser.js index a0c2fe4..9c9c553 100644 --- a/lib/services/organiser.js +++ b/lib/services/organiser.js @@ -12,8 +12,9 @@ import { siteUrl } from '../config.js'; import { TIMEZONE_IDS, isLocalDateTime, zonedToUtc, datesBetween, localDate } from '../time.js'; import { ticketCheckoutStatus } from '../payments/availability.js'; import { CATEGORY_LABELS, TRIP_CATEGORIES, toPublicEvent } from './events.js'; -import { ensurePersonalOrg, orgIdsFor } from './permissions.js'; +import { ensurePersonalOrg, orgIdsFor, eventIdsFor, can, authorize } from './permissions.js'; import { resolveCity } from './locations.js'; +import { enqueue } from './notifications.js'; export const MAX_IMAGES = 5; export const MAX_TIERS = 10; @@ -562,6 +563,12 @@ export async function publishDraft(user, draftId, { version, confirm }) { } if (imageIds.length) await c.query(`UPDATE event_images SET is_public = true WHERE id = ANY($1)`, [imageIds]); await c.query(`UPDATE event_drafts SET status = 'published', event_id = $2, published_at = now(), version = version + 1 WHERE id = $1`, [draftId, ev.id]); + await c.query( + `INSERT INTO event_team (event_id, user_id, team_role, status, invited_by) + VALUES ($1, $2, 'organiser', 'active', $2) + ON CONFLICT (event_id, user_id) WHERE status = 'active' AND user_id IS NOT NULL DO NOTHING`, + [ev.id, user.id] + ); await audit(`user:${user.id}`, 'event.publish', ev.id, { draftId, tiers: tickets.length, approval: Boolean(f.approval?.enabled) }, c); return ev; }); @@ -571,17 +578,467 @@ export async function publishDraft(user, draftId, { version, confirm }) { // ---------- organiser's events ---------- -/** Events of every org where this user may see guests (their personal org included). */ +/** Events of every org where this user may see guests, or events where they are assigned staff. */ export async function listOrganiserEvents(user) { const orgIds = await orgIdsFor(user, 'event.view_guests'); + const staffEventIds = await eventIdsFor(user, 'event.view_guests'); const { rows } = await db.query( - `SELECT e.id, e.slug, e.title, e.category, e.city, e.start_at, e.status, e.capacity, e.requires_approval, + `SELECT e.id, e.slug, e.title, e.category, e.city, e.start_at, e.end_at, e.status, e.capacity, e.requires_approval, (SELECT COALESCE(sum(b.quantity), 0)::int FROM bookings b WHERE b.event_id = e.id AND b.status IN ('confirmed', 'checked_in')) AS confirmed, - (SELECT count(*)::int FROM bookings b WHERE b.event_id = e.id AND b.status = 'pending_approval') AS requests - FROM events e WHERE e.organiser_org_id = ANY($1::uuid[]) ORDER BY e.start_at DESC LIMIT 100`, [orgIds]); + (SELECT count(*)::int FROM bookings b WHERE b.event_id = e.id AND b.status = 'pending_approval') AS requests, + (SELECT count(*)::int FROM tickets t WHERE t.event_id = e.id AND t.status = 'checked_in') AS checked_in_count, + (SELECT count(*)::int FROM event_team et WHERE et.event_id = e.id AND et.status = 'active') AS staff_count + FROM events e + WHERE (e.organiser_org_id = ANY($1::uuid[]) OR e.id = ANY($2::uuid[])) + ORDER BY e.start_at DESC LIMIT 100`, [orgIds, staffEventIds]); + return rows.map((r) => { + const confirmed = r.confirmed || 0; + const checkedIn = r.checked_in_count || 0; + const remaining = Math.max(0, (r.capacity || 0) - confirmed); + const attendancePct = confirmed > 0 ? Math.round((checkedIn / confirmed) * 100) : 0; + return { + id: r.id, title: r.title, city: r.city, startAt: r.start_at, endAt: r.end_at, status: r.status, + capacity: r.capacity, confirmed, requests: r.requests || 0, requiresApproval: r.requires_approval, + checkedIn, remaining, attendanceRate: attendancePct, staffCount: r.staff_count || 0, + path: `${isTripCategory(r.category) ? '/trips' : '/events'}/${r.slug}`, + manageUrl: `/organiser/events/${r.id}`, + scanUrl: `/organiser/events/${r.id}/scan` + }; + }); +} + +/** + * Detailed event overview and readiness status for organisers. + */ +export async function getEventOverview(user, eventId) { + await authorize(user, 'event.view_guests', { eventId }); + const e = (await db.query(`SELECT * FROM events WHERE id = $1`, [eventId])).rows[0]; + if (!e) throw new HttpError(404, 'not_found', 'Event not found'); + + const [attendance, staffRows, announcements] = await Promise.all([ + db.query(`SELECT + (SELECT COALESCE(sum(b.quantity), 0)::int FROM bookings b WHERE b.event_id = $1 AND b.status IN ('confirmed', 'checked_in')) AS confirmed_seats, + (SELECT count(*)::int FROM bookings b WHERE b.event_id = $1 AND b.status = 'pending_approval') AS pending_requests, + (SELECT count(*)::int FROM tickets t WHERE t.event_id = $1 AND t.status = 'checked_in') AS checked_in_count, + (SELECT count(*)::int FROM tickets t WHERE t.event_id = $1 AND t.status IN ('valid', 'checked_in')) AS eligible_tickets, + (SELECT count(*)::int FROM bookings b WHERE b.event_id = $1 AND b.status = 'refunded') AS refunded_bookings`, [eventId]), + db.query(`SELECT count(*)::int AS count FROM event_team WHERE event_id = $1 AND status = 'active'`, [eventId]), + db.query(`SELECT count(*)::int AS count FROM event_announcements WHERE event_id = $1`, [eventId]) + ]); + + const stats = attendance.rows[0]; + const confirmed = stats.confirmed_seats || 0; + const checkedIn = stats.checked_in_count || 0; + const eligible = stats.eligible_tickets || 0; + const remaining = Math.max(0, e.capacity - confirmed); + const attendanceRate = eligible > 0 ? Math.round((checkedIn / eligible) * 100) : 0; + + const [canEdit, canManageStaff, canCheckIn, canDecide, canCancel] = await Promise.all([ + can(user, 'event.edit', { eventId }), + can(user, 'event.manage_staff', { eventId }), + can(user, 'event.check_in', { eventId }), + can(user, 'event.decide_bookings', { eventId }), + can(user, 'event.cancel', { eventId }) + ]); + + const hasTitle = Boolean(e.title && e.title.length >= 3); + const hasDescription = Boolean(e.description && e.description.length >= 10); + const hasVenue = Boolean(e.venue_private || e.area_public); + const hasTiers = (await db.query(`SELECT 1 FROM ticket_types WHERE event_id = $1 AND active LIMIT 1`, [eventId])).rowCount > 0; + const isPublished = e.status === 'published'; + const staffCount = staffRows.rows[0]?.count || 0; + const hasStaff = staffCount > 0; + const gateReady = hasStaff || canCheckIn; + + const readinessScore = [hasTitle, hasDescription, hasVenue, hasTiers, isPublished, gateReady].filter(Boolean).length; + const path = `${isTripCategory(e.category) ? '/trips' : '/events'}/${e.slug}`; + + return { + event: { + id: e.id, + title: e.title, + slug: e.slug, + category: e.category, + city: e.city, + startAt: e.start_at, + endAt: e.end_at, + status: e.status, + capacity: e.capacity, + venuePrivacy: e.venue_privacy, + venue: e.venue_private, + area: e.area_public, + mapsUrl: e.maps_url, + refundPolicy: e.refund_policy, + requiresApproval: e.requires_approval, + path, + manageUrl: `/organiser/events/${e.id}`, + scanUrl: `/organiser/events/${e.id}/scan` + }, + metrics: { + capacity: e.capacity, + confirmedSeats: confirmed, + remainingSeats: remaining, + pendingRequests: stats.pending_requests || 0, + eligibleTickets: eligible, + checkedInTickets: checkedIn, + attendanceRate, + refundedBookings: stats.refunded_bookings || 0, + activeStaffCount: staffCount, + announcementsCount: announcements.rows[0]?.count || 0 + }, + readiness: { + score: `${readinessScore}/6`, + complete: readinessScore === 6, + checklist: [ + { key: 'basics', label: 'Event Basics & Title', passed: hasTitle }, + { key: 'description', label: 'Event Description', passed: hasDescription }, + { key: 'venue', label: 'Venue & Directions', passed: hasVenue }, + { key: 'tiers', label: 'Ticket Tiers Configured', passed: hasTiers }, + { key: 'published', label: 'Published & Discoverable', passed: isPublished }, + { key: 'gate', label: 'Door Staff & Scanner Ready', passed: gateReady } + ] + }, + permissions: { + canEdit, + canManageStaff, + canCheckIn, + canDecide, + canCancel + } + }; +} + +/** + * Ticket tier inventory and availability breakdown. + */ +export async function getEventInventory(user, eventId) { + await authorize(user, 'event.view_inventory', { eventId }); + const e = (await db.query(`SELECT id, title, capacity FROM events WHERE id = $1`, [eventId])).rows[0]; + if (!e) throw new HttpError(404, 'not_found', 'Event not found'); + + const tiers = (await db.query( + `SELECT t.id, t.name, t.kind, t.price_paise, t.quantity, t.max_per_booking, t.eligibility, t.valid_on, t.sort_order, + (SELECT COALESCE(sum(b.quantity), 0)::int FROM bookings b WHERE b.ticket_type_id = t.id AND b.status IN ('confirmed', 'checked_in')) AS sold, + (SELECT COALESCE(sum(b.quantity), 0)::int FROM bookings b WHERE b.ticket_type_id = t.id AND b.status = 'pending_payment' AND b.hold_expires_at > now()) AS holds + FROM ticket_types t + WHERE t.event_id = $1 AND t.active + ORDER BY t.sort_order ASC, t.created_at ASC`, + [eventId] + )).rows; + + const totalSold = tiers.reduce((sum, t) => sum + (t.sold || 0), 0); + const totalHolds = tiers.reduce((sum, t) => sum + (t.holds || 0), 0); + const totalRemaining = Math.max(0, e.capacity - totalSold - totalHolds); + + return { + eventId, + capacity: e.capacity, + totalSold, + totalHolds, + totalRemaining, + tiers: tiers.map((t) => { + const sold = t.sold || 0; + const holds = t.holds || 0; + const remaining = Math.max(0, t.quantity - sold - holds); + return { + id: t.id, + name: t.name, + kind: t.kind, + pricePaise: t.price_paise, + quantity: t.quantity, + maxPerBooking: t.max_per_booking, + eligibility: t.eligibility || null, + sold, + holds, + remaining + }; + }) + }; +} + +/** + * Validated edit of a published event's parameters. + */ +export async function updateOrganiserEvent(user, eventId, updates) { + await authorize(user, 'event.edit', { eventId }); + + return db.tx(async (c) => { + const e = (await c.query(`SELECT * FROM events WHERE id = $1 FOR UPDATE`, [eventId])).rows[0]; + if (!e) throw new HttpError(404, 'not_found', 'Event not found'); + if (e.status === 'cancelled') throw new HttpError(409, 'event_cancelled', 'Cancelled events cannot be modified'); + + const fields = []; + const values = []; + let idx = 1; + + if (updates.title !== undefined) { + const title = clean(updates.title); + if (title.length < 3) throw new HttpError(400, 'validation', 'Event title must be at least 3 characters'); + fields.push(`title = $${idx++}`); + values.push(title); + } + + if (updates.description !== undefined) { + const desc = clean(updates.description); + if (desc.length < 10) throw new HttpError(400, 'validation', 'Event description must be at least 10 characters'); + fields.push(`description = $${idx++}`); + values.push(desc); + } + + if (updates.capacity !== undefined) { + const cap = Number(updates.capacity); + if (!Number.isInteger(cap) || cap < 1 || cap > MAX_CAPACITY) { + throw new HttpError(400, 'validation', `Capacity must be between 1 and ${MAX_CAPACITY}`); + } + const confirmed = (await c.query( + `SELECT COALESCE(sum(quantity), 0)::int AS n FROM bookings WHERE event_id = $1 AND status IN ('confirmed', 'checked_in')`, + [eventId] + )).rows[0].n; + if (cap < confirmed) { + throw new HttpError(409, 'capacity_too_low', `Capacity cannot be reduced below the ${confirmed} currently confirmed seats`); + } + fields.push(`capacity = $${idx++}`); + values.push(cap); + } + + if (updates.start_at !== undefined) { + const d = new Date(updates.start_at); + if (isNaN(d.getTime())) throw new HttpError(400, 'validation', 'Invalid start time'); + fields.push(`start_at = $${idx++}`); + values.push(d.toISOString()); + } + + if (updates.end_at !== undefined) { + if (updates.end_at === null) { + fields.push(`end_at = NULL`); + } else { + const d = new Date(updates.end_at); + if (isNaN(d.getTime())) throw new HttpError(400, 'validation', 'Invalid end time'); + const start = updates.start_at ? new Date(updates.start_at) : new Date(e.start_at); + if (d <= start) throw new HttpError(400, 'validation', 'End time must be after start time'); + fields.push(`end_at = $${idx++}`); + values.push(d.toISOString()); + } + } + + if (updates.venue_private !== undefined) { + fields.push(`venue_private = $${idx++}`); + values.push(clean(updates.venue_private) || null); + } + + if (updates.area_public !== undefined) { + fields.push(`area_public = $${idx++}`); + values.push(clean(updates.area_public) || null); + } + + if (updates.venue_privacy !== undefined) { + if (!['public', 'disclosed_after_booking'].includes(updates.venue_privacy)) { + throw new HttpError(400, 'validation', 'Invalid venue privacy option'); + } + fields.push(`venue_privacy = $${idx++}`); + values.push(updates.venue_privacy); + } + + if (updates.maps_url !== undefined) { + if (updates.maps_url && !isMapsUrl(updates.maps_url)) { + throw new HttpError(400, 'validation', 'Maps link must be a valid Google Maps HTTPS URL'); + } + fields.push(`maps_url = $${idx++}`); + values.push(updates.maps_url ? clean(updates.maps_url) : null); + } + + if (updates.refund_policy !== undefined) { + fields.push(`refund_policy = $${idx++}`); + values.push(clean(updates.refund_policy) || null); + } + + if (updates.booking_questions !== undefined) { + fields.push(`booking_questions = $${idx++}`); + values.push(JSON.stringify(Array.isArray(updates.booking_questions) ? updates.booking_questions : [])); + } + + if (updates.pickup_points !== undefined) { + fields.push(`pickup_points = $${idx++}`); + values.push(JSON.stringify(Array.isArray(updates.pickup_points) ? updates.pickup_points : [])); + } + + if (!fields.length) throw new HttpError(400, 'validation', 'No updates provided'); + + fields.push(`updated_at = now()`); + values.push(eventId); + + const updated = (await c.query( + `UPDATE events SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values + )).rows[0]; + + await audit(`user:${user.id}`, 'event.edit', eventId, updates, c); + return updated; + }); +} + +/** + * Organiser cancels an entire event with a stated reason. + */ +export async function cancelOrganiserEvent(user, eventId, { reason = 'Cancelled by organiser' } = {}) { + await authorize(user, 'event.cancel', { eventId }); + + return db.tx(async (c) => { + const e = (await c.query(`SELECT * FROM events WHERE id = $1 FOR UPDATE`, [eventId])).rows[0]; + if (!e) throw new HttpError(404, 'not_found', 'Event not found'); + if (e.status === 'cancelled') throw new HttpError(409, 'already_cancelled', 'Event is already cancelled'); + + await c.query(`UPDATE events SET status = 'cancelled', updated_at = now() WHERE id = $1`, [eventId]); + + await c.query( + `UPDATE bookings + SET status = 'cancelled', + cancelled_at = now(), + cancel_reason = $2, + updated_at = now() + WHERE event_id = $1 AND status IN ('pending_approval', 'pending_payment', 'confirmed')`, + [eventId, reason] + ); + + await c.query( + `UPDATE tickets + SET status = 'cancelled', + updated_at = now() + WHERE event_id = $1 AND status = 'valid'`, + [eventId] + ); + + const attendees = (await c.query( + `SELECT DISTINCT user_id, id FROM bookings WHERE event_id = $1`, + [eventId] + )).rows; + + for (const a of attendees) { + await enqueue(c, { + kind: 'event_update', + userId: a.user_id, + eventId, + bookingId: a.id, + title: `Event Cancelled: ${e.title}`, + text: `The organiser has cancelled "${e.title}". Reason: ${reason}.`, + dedupeKey: `cancel:${eventId}:${a.user_id}` + }); + } + + await audit(`user:${user.id}`, 'event.cancel', eventId, { reason, attendeeCount: attendees.length }, c); + return { ok: true, eventId, status: 'cancelled' }; + }); +} + +/** + * Organiser cancels an individual attendee booking with a stated reason. + */ +export async function cancelOrganiserBooking(user, bookingId, { reason = 'Cancelled by organiser' } = {}) { + const b = (await db.query(`SELECT * FROM bookings WHERE id = $1`, [bookingId])).rows[0]; + if (!b) throw new HttpError(404, 'not_found', 'Booking not found'); + + await authorize(user, 'event.cancel_booking', { eventId: b.event_id }); + + return db.tx(async (c) => { + const locked = (await c.query(`SELECT * FROM bookings WHERE id = $1 FOR UPDATE`, [bookingId])).rows[0]; + if (locked.status === 'cancelled' || locked.status === 'declined') { + throw new HttpError(409, 'already_closed', 'This booking is already cancelled or declined'); + } + + await c.query( + `UPDATE bookings + SET status = 'cancelled', + cancelled_at = now(), + cancel_reason = $2, + updated_at = now() + WHERE id = $1`, + [bookingId, reason] + ); + + await c.query( + `UPDATE tickets + SET status = 'cancelled', + revoked_at = now(), + revoked_by = $2, + revocation_reason = $3, + updated_at = now() + WHERE booking_id = $1 AND status = 'valid'`, + [bookingId, user.id, reason] + ); + + await enqueue(c, { + kind: 'booking_declined', + userId: locked.user_id, + bookingId: locked.id, + dedupeKey: `bk:${locked.id}:cancelled` + }); + + await audit(`user:${user.id}`, 'booking.cancel_by_host', bookingId, { eventId: locked.event_id, reason }, c); + return { ok: true, bookingId, status: 'cancelled' }; + }); +} + +/** + * Filtered & paginated attendee/booking search for an event. + */ +export async function listOrganiserBookings(user, eventId, { query = '', status = 'all', page = 1, limit = 50 } = {}) { + await authorize(user, 'event.view_guests', { eventId }); + + const params = [eventId]; + let where = 'b.event_id = $1'; + + if (status && status !== 'all') { + params.push(status); + where += ` AND b.status = $${params.length}`; + } + + if (query && query.trim()) { + params.push(`%${query.trim().toLowerCase()}%`); + const qIdx = params.length; + where += ` AND (lower(u.alias) LIKE $${qIdx} OR b.id::text LIKE $${qIdx})`; + } + + const offset = Math.max(0, (page - 1) * limit); + params.push(limit); + const lIdx = params.length; + params.push(offset); + const oIdx = params.length; + + const { rows } = await db.query( + `SELECT b.id, b.status, b.quantity, b.amount_paise, b.payment_mode, b.created_at, b.confirmed_at, + b.checked_in_at, b.cancelled_at, b.cancel_reason, b.answers, b.pickup_point_id, + u.id AS user_id, u.alias, u.telegram_user_id, + tt.name AS tier_name, tt.kind AS tier_kind, + eo.status AS payment_status, + (SELECT count(*)::int FROM tickets t WHERE t.booking_id = b.id AND t.status = 'checked_in') AS tickets_checked_in + FROM bookings b + JOIN users u ON u.id = b.user_id + LEFT JOIN ticket_types tt ON tt.id = b.ticket_type_id + LEFT JOIN external_orders eo ON eo.matched_booking_id = b.id + WHERE ${where} + ORDER BY b.created_at DESC + LIMIT $${lIdx} OFFSET $${oIdx}`, + params + ); + return rows.map((r) => ({ - id: r.id, title: r.title, city: r.city, startAt: r.start_at, status: r.status, capacity: r.capacity, confirmed: r.confirmed, - requests: r.requests, requiresApproval: r.requires_approval, - path: `${isTripCategory(r.category) ? '/trips' : '/events'}/${r.slug}`, manageUrl: `/organiser/events/${r.id}` + id: r.id, + ref: r.id.slice(0, 8).toUpperCase(), + status: r.status, + userId: r.user_id, + alias: r.alias || 'Guest', + ticket: r.tier_name || 'Standard', + kind: r.tier_kind || 'free', + quantity: r.quantity, + amountPaise: r.amount_paise, + paymentMode: r.payment_mode, + paymentStatus: r.payment_mode === 'free' ? 'not_required' : (r.payment_status || 'unpaid'), + createdAt: r.created_at, + confirmedAt: r.confirmed_at, + checkedInAt: r.checked_in_at, + cancelledAt: r.cancelled_at, + cancelReason: r.cancel_reason, + ticketsCheckedIn: r.tickets_checked_in || 0 })); } + diff --git a/lib/services/permissions.js b/lib/services/permissions.js index af8dabb..b124d69 100644 --- a/lib/services/permissions.js +++ b/lib/services/permissions.js @@ -12,20 +12,52 @@ export const ORG_ACTIONS = { 'org.manage_members': ['owner', 'admin'], 'org.manage_admins': ['owner'], 'event.manage': ['owner', 'admin', 'manager'], + 'event.edit': ['owner', 'admin', 'manager'], + 'event.cancel': ['owner', 'admin', 'manager'], 'event.decide_bookings': ['owner', 'admin', 'manager'], 'event.view_guests': ['owner', 'admin', 'manager', 'staff'], + 'event.cancel_booking': ['owner', 'admin', 'manager'], + 'event.check_in': ['owner', 'admin', 'manager', 'staff'], + 'event.manual_override': ['owner', 'admin', 'manager'], + 'event.reissue_ticket': ['owner', 'admin', 'manager'], + 'event.manage_staff': ['owner', 'admin'], + 'event.announcements': ['owner', 'admin', 'manager'], + 'event.view_audit': ['owner', 'admin', 'manager'], + 'event.view_inventory': ['owner', 'admin', 'manager', 'staff'], 'venue.view': ['owner', 'admin', 'manager', 'staff'], 'venue.view_private': ['owner', 'admin', 'manager', 'staff'], 'venue.manage': ['owner', 'admin'] }; -/** Platform (admin_users) roles allowed each action, on any org. */ +/** Event staff (event_team) roles allowed to perform each action on the assigned event. */ +export const EVENT_STAFF_ACTIONS = { + 'event.manage': ['organiser', 'event_manager'], + 'event.edit': ['organiser', 'event_manager'], + 'event.cancel': ['organiser', 'event_manager'], + 'event.decide_bookings': ['organiser', 'event_manager', 'guest_list_manager'], + 'event.view_guests': ['organiser', 'event_manager', 'guest_list_manager', 'assistant'], + 'event.cancel_booking': ['organiser', 'event_manager'], + 'event.check_in': ['organiser', 'event_manager', 'guest_list_manager', 'door_staff'], + 'event.manual_override': ['organiser', 'event_manager'], + 'event.reissue_ticket': ['organiser', 'event_manager'], + 'event.manage_staff': ['organiser', 'event_manager'], + 'event.announcements': ['organiser', 'event_manager'], + 'event.view_audit': ['organiser', 'event_manager'], + 'event.view_inventory': ['organiser', 'event_manager', 'guest_list_manager', 'door_staff', 'assistant'] +}; + +/** Platform (admin_users) roles allowed each action, on any org or event. */ const PLATFORM_ACTIONS = { super_admin: Object.keys(ORG_ACTIONS), - event_manager: ['org.view', 'event.manage', 'event.decide_bookings', 'event.view_guests', 'venue.view'] + event_manager: [ + 'org.view', 'event.manage', 'event.edit', 'event.cancel', 'event.decide_bookings', + 'event.view_guests', 'event.cancel_booking', 'event.check_in', 'event.manual_override', + 'event.reissue_ticket', 'event.manage_staff', 'event.announcements', 'event.view_audit', + 'event.view_inventory', 'venue.view' + ] }; -export const ACTIONS = Object.keys(ORG_ACTIONS); +export const ACTIONS = [...new Set([...Object.keys(ORG_ACTIONS), ...Object.keys(EVENT_STAFF_ACTIONS)])]; // A personal org counts only while its owner may still organise: the admin organiser switch, or an // active events admin (the same rule isOrganiser() applies to the wizard). @@ -71,16 +103,65 @@ export async function scopeOrgId(scope, c = db) { return null; } -/** Decide one action. Returns { allowed, member } where member says whether the user belongs to the org. */ +export async function resolveEventId(scope, c = db) { + if (!scope) return null; + if (scope.eventId) return scope.eventId; + if (scope.event?.id) return scope.event.id; + if (scope.booking?.event_id) return scope.booking.event_id; + if (scope.bookingId) { + const b = (await c.query('SELECT event_id FROM bookings WHERE id = $1', [scope.bookingId])).rows[0]; + return b?.event_id || null; + } + if (scope.ticketId) { + const t = (await c.query('SELECT event_id FROM tickets WHERE id = $1', [scope.ticketId])).rows[0]; + return t?.event_id || null; + } + return null; +} + +/** + * The user's active event-scoped team role, or null. + */ +export async function eventTeamRole(user, eventId, c = db) { + if (!user?.id || !eventId) return null; + const r = (await c.query( + `SELECT team_role FROM event_team + WHERE user_id = $1 AND event_id = $2 AND status = 'active'`, + [user.id, eventId] + )).rows[0]; + return r?.team_role || null; +} + +/** Decide one action. Returns { allowed, member, role } where member says whether the user belongs to the org or event team. */ export async function check(user, action, scope, c = db) { - if (!ORG_ACTIONS[action]) throw new Error(`unknown action ${action}`); + if (!ORG_ACTIONS[action] && !EVENT_STAFF_ACTIONS[action]) throw new Error(`unknown action ${action}`); if (!user?.id) return { allowed: false, member: false }; const pRole = await platformRole(user, c); - if (pRole && PLATFORM_ACTIONS[pRole]?.includes(action)) return { allowed: true, member: true }; + if (pRole && PLATFORM_ACTIONS[pRole]?.includes(action)) return { allowed: true, member: true, role: pRole, platformRole: pRole }; + + // Check event-level team membership first if scoped to an event + const eventId = await resolveEventId(scope, c); + let staffRole = null; + if (eventId) { + staffRole = await eventTeamRole(user, eventId, c); + if (staffRole) { + const allowed = Boolean(EVENT_STAFF_ACTIONS[action]?.includes(staffRole)); + if (allowed) return { allowed: true, member: true, role: staffRole, eventStaff: true }; + } + } + + // Check organisation-level membership const orgId = await scopeOrgId(scope, c); const role = await orgRole(user, orgId, c); - if (!role) return { allowed: false, member: false }; - return { allowed: ORG_ACTIONS[action].includes(role), member: true, role }; + if (role) { + const allowed = Boolean(ORG_ACTIONS[action]?.includes(role)); + return { allowed, member: true, role, orgRole: role }; + } + + // If user has an event staff role on this event but it doesn't allow the action, they are a member with insufficient role (403) + if (staffRole) return { allowed: false, member: true, role: staffRole, eventStaff: true }; + + return { allowed: false, member: false }; } export async function can(user, action, scope, c = db) { @@ -98,7 +179,8 @@ export async function authorize(user, action, scope, c = db, { notFound = 'Not f /** Org ids where the user holds an active role that allows `action` (for list scoping). */ export async function orgIdsFor(user, action, c = db) { if (!user?.id) return []; - const roles = ORG_ACTIONS[action]; + const roles = ORG_ACTIONS[action] || []; + if (!roles.length) return []; const { rows } = await c.query( `SELECT m.org_id FROM org_memberships m JOIN organisations o ON o.id = m.org_id @@ -108,6 +190,18 @@ export async function orgIdsFor(user, action, c = db) { return rows.map((r) => r.org_id); } +/** Event ids where the user holds an active event staff role that allows `action`. */ +export async function eventIdsFor(user, action, c = db) { + if (!user?.id) return []; + const roles = EVENT_STAFF_ACTIONS[action] || []; + if (!roles.length) return []; + const { rows } = await c.query( + `SELECT event_id FROM event_team WHERE user_id = $1 AND status = 'active' AND team_role = ANY($2::text[])`, + [user.id, roles] + ); + return rows.map((r) => r.event_id); +} + /** * The user's personal org (created on first need), with an owner membership and the * organise_events capability. Idempotent; safe inside any transaction. diff --git a/lib/services/ticketing.js b/lib/services/ticketing.js index 1add595..a45ba87 100644 --- a/lib/services/ticketing.js +++ b/lib/services/ticketing.js @@ -12,6 +12,7 @@ import { audit } from '../audit.js'; import { enqueue } from './notifications.js'; import { seatsTaken, tierSeatsTaken } from './bookings.js'; import { authorize } from './permissions.js'; +import { issueTicketsForBooking } from './tickets.js'; const LIVE = ['pending_approval', 'pending_payment', 'confirmed', 'checked_in']; @@ -124,6 +125,7 @@ export async function bookTier(user, eventId, body) { const b = (await c.query( `INSERT INTO bookings (event_id, user_id, ticket_type_id, quantity, answers, pickup_point_id, amount_paise, status, payment_mode, confirmed_at) VALUES ($1, $2, $3, $4, $5, $6, $7, 'confirmed', 'free', now()) RETURNING *`, common)).rows[0]; + await issueTicketsForBooking(b.id, c); const nid = await enqueue(c, { kind: 'booking_confirmed', userId: user.id, bookingId: b.id, dedupeKey: `bk:${b.id}:confirmed` }); await audit(`user:${user.id}`, 'booking.confirmed_free', b.id, { eventId, tier: tier.id, qty }, c); return { booking: b, notificationIds: [nid] }; @@ -171,6 +173,7 @@ export async function decideRequest(user, bookingId, decision) { await assertSeats(c, event, tier, b.quantity, b.id); if (tier.kind === 'free') { await c.query(`UPDATE bookings SET status = 'confirmed', payment_mode = 'free', confirmed_at = now(), decided_at = now(), decided_by = $2 WHERE id = $1`, [b.id, user.id]); + await issueTicketsForBooking(b.id, c); const nid = await enqueue(c, { kind: 'booking_confirmed', userId: b.user_id, bookingId: b.id, dedupeKey: `bk:${b.id}:confirmed` }); await audit(`user:${user.id}`, 'booking.approve', b.id, { eventId: event.id, confirmed: true }, c); return { status: 'confirmed', notificationIds: [nid] }; diff --git a/lib/services/tickets.js b/lib/services/tickets.js new file mode 100644 index 0000000..5c1639a --- /dev/null +++ b/lib/services/tickets.js @@ -0,0 +1,600 @@ +// lib/services/tickets.js - Cryptographically secure, server-authoritative QR ticketing & check-in. +import crypto from 'node:crypto'; +import { db } from '../db.js'; +import { HttpError } from '../http.js'; +import { audit } from '../audit.js'; +import { can } from './permissions.js'; +import { encodeQrMatrix } from '../upi/qr.js'; + +export const TICKET_PREFIX = 'TK-'; +export const QR_PAYLOAD_VERSION = 'NLZ1'; + +function getTicketSecret() { + return process.env.TICKET_SECRET || process.env.SESSION_SECRET || 'nlz-ticket-signing-secret-local-dev-fallback'; +} + +/** + * Computes an HMAC-SHA256 signature for a ticket code bound to an event. + */ +export function signTicketPayload(ticketCode, eventId) { + const secret = getTicketSecret(); + return crypto.createHmac('sha256', secret) + .update(`${ticketCode}:${eventId}`) + .digest('hex'); +} + +/** + * Constant-time verification of a ticket QR signature. + */ +export function verifyTicketPayload(ticketCodeOrPayload, eventId, signature = null) { + if (!ticketCodeOrPayload || !eventId) return false; + let code = ticketCodeOrPayload; + let sig = signature; + if (!sig && typeof ticketCodeOrPayload === 'string' && ticketCodeOrPayload.startsWith('NLZ1:')) { + const parsed = parseQrPayload(ticketCodeOrPayload); + if (!parsed) return false; + code = parsed.ticketCode; + sig = parsed.signature; + } + if (!sig || typeof sig !== 'string') return false; + const expected = signTicketPayload(code, eventId); + if (expected.length !== sig.length) return false; + return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex')); +} + +/** + * Generates the compact QR payload string. Does NOT leak any personal data or database IDs. + */ +export function generateQrPayload(ticketCode, eventId) { + const sig = signTicketPayload(ticketCode, eventId); + return `${QR_PAYLOAD_VERSION}:${ticketCode}:${sig}`; +} + +/** + * Parses a QR payload string into version, ticketCode, and signature. + */ +export function parseQrPayload(payloadStr) { + if (typeof payloadStr !== 'string') return null; + const parts = payloadStr.trim().split(':'); + if (parts.length !== 3 || parts[0] !== QR_PAYLOAD_VERSION) return null; + return { version: parts[0], ticketCode: parts[1], signature: parts[2] }; +} + +/** + * Generates an SVG string of the ticket QR code. + */ +export function generateTicketQrSvg(payload, { size = 260, margin = 3, darkColor = '#0b0f19', lightColor = '#ffffff' } = {}) { + const qr = encodeQrMatrix(payload); + const totalCells = qr.size + margin * 2; + const cellSize = (size / totalCells).toFixed(3); + + let pathData = ''; + for (let r = 0; r < qr.size; r++) { + for (let c = 0; c < qr.size; c++) { + if (qr.get(r, c)) { + const x = ((c + margin) * cellSize).toFixed(2); + const y = ((r + margin) * cellSize).toFixed(2); + pathData += `M${x},${y}h${cellSize}v${cellSize}h-${cellSize}z `; + } + } + } + + return ` + + +`; +} + +/** + * Generates a high-entropy, cryptographically unguessable ticket code. + * Format: TK-XXXX-XXXX-XXXX (12 alphanumeric chars, 48+ bits of random entropy) + */ +export function generateTicketCode() { + const raw = crypto.randomBytes(6).toString('hex').toUpperCase(); + return `${TICKET_PREFIX}${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`; +} + +/** + * Issues tickets for a confirmed booking. Idempotent — will not duplicate tickets if they already exist. + */ +export async function issueTicketsForBooking(bookingId, c = db) { + return c.tx ? c.tx(doIssue) : doIssue(c); + + async function doIssue(client) { + const b = (await client.query( + `SELECT id, event_id, user_id, ticket_type_id, quantity, status FROM bookings WHERE id = $1 FOR UPDATE`, + [bookingId] + )).rows[0]; + if (!b) throw new HttpError(404, 'not_found', 'Booking not found'); + if (b.status !== 'confirmed' && b.status !== 'checked_in') { + throw new HttpError(409, 'not_eligible', 'Tickets can only be issued for confirmed bookings'); + } + + const existing = (await client.query( + `SELECT * FROM tickets WHERE booking_id = $1 ORDER BY ticket_number ASC`, + [bookingId] + )).rows; + if (existing.length > 0) return existing; + + const tickets = []; + const qty = Math.max(1, Number(b.quantity) || 1); + for (let i = 1; i <= qty; i++) { + const code = generateTicketCode(); + const hash = signTicketPayload(code, b.event_id); + const initialStatus = b.status === 'checked_in' ? 'checked_in' : 'valid'; + const checkedInAt = initialStatus === 'checked_in' ? 'NOW()' : 'NULL'; + const r = (await client.query( + `INSERT INTO tickets (booking_id, event_id, user_id, ticket_type_id, ticket_number, ticket_code, security_hash, status, checked_in_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, ${checkedInAt}) + RETURNING *`, + [b.id, b.event_id, b.user_id, b.ticket_type_id, i, code, hash, initialStatus] + )).rows[0]; + tickets.push(r); + } + return tickets; + } +} + +/** + * Returns tickets for a booking with their QR payload and SVG. + */ +export async function getBookingTickets(bookingId, viewerUser = null, c = db) { + const b = (await c.query(`SELECT * FROM bookings WHERE id = $1`, [bookingId])).rows[0]; + if (!b) throw new HttpError(404, 'not_found', 'Booking not found'); + + if (viewerUser) { + const isOwner = b.user_id === viewerUser.id; + if (!isOwner) { + const canView = await can(viewerUser, 'event.view_guests', { eventId: b.event_id }, c); + if (!canView) throw new HttpError(404, 'not_found', 'Booking not found'); + } + } + + let tickets = (await c.query( + `SELECT t.*, tt.name AS tier_name, tt.kind AS tier_kind + FROM tickets t + LEFT JOIN ticket_types tt ON tt.id = t.ticket_type_id + WHERE t.booking_id = $1 + ORDER BY t.ticket_number ASC`, + [bookingId] + )).rows; + + if (tickets.length === 0 && (b.status === 'confirmed' || b.status === 'checked_in')) { + await issueTicketsForBooking(bookingId, c); + tickets = (await c.query( + `SELECT t.*, tt.name AS tier_name, tt.kind AS tier_kind + FROM tickets t + LEFT JOIN ticket_types tt ON tt.id = t.ticket_type_id + WHERE t.booking_id = $1 + ORDER BY t.ticket_number ASC`, + [bookingId] + )).rows; + } + + return tickets.map((t) => { + const qrPayload = generateQrPayload(t.ticket_code, t.event_id); + return { + id: t.id, + ticketNumber: t.ticket_number, + ticketCode: t.ticket_code, + status: t.status, + checkedInAt: t.checked_in_at, + tierName: t.tier_name || 'Standard Admission', + qrPayload, + qrSvg: generateTicketQrSvg(qrPayload) + }; + }); +} + +/** + * Server-authoritative ticket validation and check-in. + */ +export async function checkInTicket(actor, eventId, { ticketCode = null, qrPayload = null, method = 'camera_qr', overrideReason = null } = {}, c = db) { + if (!actor || !actor.id) throw new HttpError(401, 'unauthorized', 'Authentication required'); + + // Verify permission + const allowed = await can(actor, 'event.check_in', { eventId }, c); + if (!allowed) throw new HttpError(403, 'forbidden', 'You do not have permission to check in tickets for this event'); + + if (method === 'manual_override') { + const canOverride = await can(actor, 'event.manual_override', { eventId }, c); + if (!canOverride) throw new HttpError(403, 'forbidden', 'Only event managers or organisers can perform manual overrides'); + if (!overrideReason || typeof overrideReason !== 'string' || overrideReason.trim().length < 4) { + throw new HttpError(400, 'validation', 'A specific override reason (at least 4 characters) is required'); + } + } + + let code = ticketCode ? ticketCode.trim().toUpperCase() : null; + let signature = null; + + if (qrPayload) { + const parsed = parseQrPayload(qrPayload); + if (!parsed) { + await recordScanLog({ + eventId, + ticketId: null, + bookingId: null, + scannedBy: actor.id, + result: 'invalid_format', + method, + payloadHash: hashPayload(qrPayload), + metadata: { error: 'malformed_qr' } + }, c); + throw new HttpError(400, 'invalid_qr', 'Malformed or invalid ticket QR format'); + } + code = parsed.ticketCode.toUpperCase(); + signature = parsed.signature; + + const validSig = verifyTicketPayload(code, eventId, signature); + if (!validSig) { + await recordScanLog({ + eventId, + ticketId: null, + bookingId: null, + scannedBy: actor.id, + result: 'invalid_signature', + method, + payloadHash: hashPayload(qrPayload), + metadata: { code, error: 'forged_or_cross_event' } + }, c); + throw new HttpError(400, 'invalid_ticket', 'Invalid or forged QR signature'); + } + } + + if (!code) { + throw new HttpError(400, 'validation', 'Ticket code or QR payload is required'); + } + + const payloadHash = hashPayload(qrPayload || code); + + try { + return await db.tx(async (txClient) => { + const t = (await txClient.query( + `SELECT t.*, b.status AS booking_status, b.payment_mode, b.amount_paise, + u.alias AS attendee_alias, tt.name AS tier_name + FROM tickets t + JOIN bookings b ON b.id = t.booking_id + JOIN users u ON u.id = t.user_id + LEFT JOIN ticket_types tt ON tt.id = t.ticket_type_id + WHERE t.ticket_code = $1 + FOR UPDATE OF t`, + [code] + )).rows[0]; + + if (!t) { + throw new HttpError(404, 'ticket_not_found', 'Ticket code not found', { code }); + } + + if (t.event_id !== eventId) { + throw new HttpError(400, 'event_mismatch', 'This ticket belongs to a different event', { + ticketId: t.id, + bookingId: t.booking_id, + code, + ticketEventId: t.event_id + }); + } + + if (t.status === 'checked_in') { + const prevStaff = t.checked_in_by ? (await txClient.query(`SELECT alias FROM users WHERE id = $1`, [t.checked_in_by])).rows[0]?.alias : 'Staff'; + throw new HttpError(409, 'already_checked_in', 'This ticket has already been checked in', { + ticketId: t.id, + bookingId: t.booking_id, + firstCheckIn: { + checkedInAt: t.checked_in_at, + checkedInByAlias: prevStaff + }, + checkedInAt: t.checked_in_at, + checkedInByAlias: prevStaff, + attendee: { alias: t.attendee_alias, tier: t.tier_name, ref: t.booking_id.slice(0, 8) } + }); + } + + if (t.status === 'revoked') { + if (method !== 'manual_override') { + throw new HttpError(409, 'ticket_revoked', 'This ticket has been revoked', { + ticketId: t.id, + bookingId: t.booking_id, + reason: t.revocation_reason + }); + } + } + + if (t.status === 'reissued') { + if (method !== 'manual_override') { + throw new HttpError(409, 'ticket_reissued', 'This ticket has been replaced by a reissued ticket', { + ticketId: t.id, + bookingId: t.booking_id, + newTicketId: t.reissued_to_ticket_id + }); + } + } + + if (t.status === 'cancelled' || t.booking_status === 'cancelled') { + throw new HttpError(409, 'ticket_cancelled', 'This ticket or booking has been cancelled', { + ticketId: t.id, + bookingId: t.booking_id, + code + }); + } + + if (t.booking_status === 'refunded') { + throw new HttpError(409, 'ticket_refunded', 'This booking was refunded. Ticket is invalid', { + ticketId: t.id, + bookingId: t.booking_id, + code + }); + } + + if (t.booking_status !== 'confirmed' && t.booking_status !== 'checked_in') { + if (method !== 'manual_override') { + throw new HttpError(409, 'not_eligible', 'Booking is not in a confirmed state', { + ticketId: t.id, + bookingId: t.booking_id, + bookingStatus: t.booking_status + }); + } + } + + // Perform atomic check-in + const now = new Date(); + await txClient.query( + `UPDATE tickets + SET status = 'checked_in', + checked_in_at = $2, + checked_in_by = $3, + check_in_method = $4, + override_reason = $5, + updated_at = $2 + WHERE id = $1`, + [t.id, now, actor.id, method, overrideReason || null] + ); + + // Update booking checked_in_at + await txClient.query( + `UPDATE bookings + SET status = 'checked_in', + checked_in_at = COALESCE(checked_in_at, $2), + updated_at = $2 + WHERE id = $1`, + [t.booking_id, now] + ); + + const scanResult = method === 'manual_override' ? 'manual_override' : 'success'; + await recordScanLog({ + eventId, + ticketId: t.id, + bookingId: t.booking_id, + scannedBy: actor.id, + result: scanResult, + method, + overrideReason: overrideReason || null, + payloadHash, + metadata: { code, attendeeAlias: t.attendee_alias, tier: t.tier_name } + }, txClient); + + const auditAction = method === 'manual_override' ? 'ticket.manual_override' : 'ticket.check_in'; + await audit(`user:${actor.id}`, auditAction, t.id, { + eventId, + ticketCode: t.ticket_code, + method, + overrideReason: overrideReason || null + }, txClient); + + return { + ok: true, + result: scanResult, + ticket: { + id: t.id, + ticketCode: t.ticket_code, + ticketNumber: t.ticket_number, + status: 'checked_in', + checkedInAt: now.toISOString(), + method + }, + attendee: { + alias: t.attendee_alias || 'Guest', + ticketType: t.tier_name || 'Standard', + ref: t.booking_id.slice(0, 8).toUpperCase() + } + }; + }); + } catch (err) { + if (err instanceof HttpError && err.code) { + try { + await recordScanLog({ + eventId, + ticketId: err.extra?.ticketId || null, + bookingId: err.extra?.bookingId || null, + scannedBy: actor.id, + result: err.code, + method, + overrideReason: overrideReason || null, + payloadHash, + metadata: { code, ...(err.extra || {}) } + }, c); + } catch { + // Logging error should not mask rejection + } + } + throw err; + } +} + +/** + * Reissue a ticket with a fresh unguessable code, revoking the previous one immediately. + */ +export async function reissueTicket(actor, ticketId, reason = 'reissued_by_host', c = db) { + if (!actor || !actor.id) throw new HttpError(401, 'unauthorized', 'Authentication required'); + + return db.tx(async (txClient) => { + const old = (await txClient.query(`SELECT * FROM tickets WHERE id = $1 FOR UPDATE`, [ticketId])).rows[0]; + if (!old) throw new HttpError(404, 'not_found', 'Ticket not found'); + + const allowed = await can(actor, 'event.reissue_ticket', { eventId: old.event_id }, txClient); + if (!allowed) throw new HttpError(403, 'forbidden', 'Permission denied to reissue tickets'); + + if (old.status === 'revoked' || old.status === 'cancelled') { + throw new HttpError(409, 'cannot_reissue', 'Cannot reissue a cancelled or revoked ticket'); + } + + const newCode = generateTicketCode(); + const newHash = signTicketPayload(newCode, old.event_id); + const now = new Date(); + + const newTicket = (await txClient.query( + `INSERT INTO tickets (booking_id, event_id, user_id, ticket_type_id, ticket_number, ticket_code, security_hash, status, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'valid', $8) + RETURNING *`, + [old.booking_id, old.event_id, old.user_id, old.ticket_type_id, old.ticket_number, newCode, newHash, now] + )).rows[0]; + + await txClient.query( + `UPDATE tickets + SET status = 'reissued', + reissued_to_ticket_id = $2, + reissued_at = $3, + reissued_by = $4, + revocation_reason = $5, + updated_at = $3 + WHERE id = $1`, + [old.id, newTicket.id, now, actor.id, reason] + ); + + await audit(`user:${actor.id}`, 'ticket.reissue', old.id, { + eventId: old.event_id, + oldTicketCode: old.ticket_code, + newTicketId: newTicket.id, + newTicketCode: newTicket.ticket_code, + reason + }, txClient); + + const qrPayload = generateQrPayload(newTicket.ticket_code, newTicket.event_id); + + return { + ok: true, + oldTicketId: old.id, + newTicket: { + id: newTicket.id, + ticketCode: newTicket.ticket_code, + ticketNumber: newTicket.ticket_number, + status: newTicket.status, + qrPayload, + qrSvg: generateTicketQrSvg(qrPayload) + } + }; + }); +} + +/** + * Revoke a ticket with immediate effect. + */ +export async function revokeTicket(actor, ticketId, reason = 'revoked_by_host', c = db) { + if (!actor || !actor.id) throw new HttpError(401, 'unauthorized', 'Authentication required'); + + return db.tx(async (txClient) => { + const t = (await txClient.query(`SELECT * FROM tickets WHERE id = $1 FOR UPDATE`, [ticketId])).rows[0]; + if (!t) throw new HttpError(404, 'not_found', 'Ticket not found'); + + const allowed = await can(actor, 'event.reissue_ticket', { eventId: t.event_id }, txClient); + if (!allowed) throw new HttpError(403, 'forbidden', 'Permission denied to revoke tickets'); + + const now = new Date(); + await txClient.query( + `UPDATE tickets + SET status = 'revoked', + revoked_at = $2, + revoked_by = $3, + revocation_reason = $4, + updated_at = $2 + WHERE id = $1`, + [t.id, now, actor.id, reason] + ); + + await audit(`user:${actor.id}`, 'ticket.revoke', t.id, { + eventId: t.event_id, + ticketCode: t.ticket_code, + reason + }, txClient); + + return { ok: true, ticketId: t.id, status: 'revoked' }; + }); +} + +/** + * Returns event attendance statistics: checked-in vs eligible, attendance rate. + */ +export async function getEventAttendance(eventId, c = db) { + const event = (await c.query(`SELECT capacity FROM events WHERE id = $1`, [eventId])).rows[0]; + if (!event) throw new HttpError(404, 'not_found', 'Event not found'); + + const stats = (await c.query( + `SELECT + COUNT(*) FILTER (WHERE status IN ('valid', 'checked_in'))::int AS eligible_tickets, + COUNT(*) FILTER (WHERE status = 'checked_in')::int AS checked_in_tickets, + COUNT(*) FILTER (WHERE status = 'valid')::int AS remaining_tickets, + COUNT(*) FILTER (WHERE status = 'revoked')::int AS revoked_tickets, + COUNT(*) FILTER (WHERE status = 'cancelled')::int AS cancelled_tickets, + MAX(checked_in_at) AS last_check_in_at + FROM tickets + WHERE event_id = $1`, + [eventId] + )).rows[0]; + + const eligible = stats.eligible_tickets || 0; + const checkedIn = stats.checked_in_tickets || 0; + const attendancePct = eligible > 0 ? Math.round((checkedIn / eligible) * 100) : 0; + + return { + capacity: event.capacity, + eligibleTickets: eligible, + checkedInTickets: checkedIn, + remainingTickets: Math.max(0, eligible - checkedIn), + revokedTickets: stats.revoked_tickets || 0, + cancelledTickets: stats.cancelled_tickets || 0, + attendanceRate: attendancePct, + lastCheckInAt: stats.last_check_in_at + }; +} + +/** + * Returns recent check-in scan activity logs. + */ +export async function listEventScans(eventId, limit = 50, c = db) { + const { rows } = await c.query( + `SELECT ts.id, ts.scan_result, ts.scan_method, ts.override_reason, ts.created_at, + u.alias AS staff_alias, t.ticket_code, b.id AS booking_id, bu.alias AS attendee_alias + FROM ticket_scans ts + JOIN users u ON u.id = ts.scanned_by + LEFT JOIN tickets t ON t.id = ts.ticket_id + LEFT JOIN bookings b ON b.id = ts.booking_id + LEFT JOIN users bu ON bu.id = b.user_id + WHERE ts.event_id = $1 + ORDER BY ts.created_at DESC + LIMIT $2`, + [eventId, limit] + ); + + return rows.map((r) => ({ + id: r.id, + result: r.scan_result, + method: r.scan_method, + overrideReason: r.override_reason, + createdAt: r.created_at, + staffAlias: r.staff_alias || 'Staff', + ticketCode: r.ticket_code ? `${r.ticket_code.slice(0, 7)}…` : null, + attendeeRef: r.booking_id ? r.booking_id.slice(0, 8).toUpperCase() : null, + attendeeAlias: r.attendee_alias || null + })); +} + +function hashPayload(str) { + return crypto.createHash('sha256').update(String(str || '')).digest('hex'); +} + +async function recordScanLog({ eventId, ticketId, bookingId, scannedBy, result, method, overrideReason = null, payloadHash, metadata = {} }, c) { + await c.query( + `INSERT INTO ticket_scans (event_id, ticket_id, booking_id, scanned_by, scan_result, scan_method, override_reason, scanned_payload_hash, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [eventId, ticketId, bookingId, scannedBy, result, method, overrideReason, payloadHash, JSON.stringify(metadata)] + ); +} diff --git a/lib/services/upi-payments.js b/lib/services/upi-payments.js index 3b5c1a5..4d76dcc 100644 --- a/lib/services/upi-payments.js +++ b/lib/services/upi-payments.js @@ -11,6 +11,7 @@ import { enqueue } from './notifications.js'; import { removeFromChat } from './invites.js'; import { seatsTaken, tierSeatsTaken } from './bookings.js'; import { logger } from '../logger.js'; +import { issueTicketsForBooking } from './tickets.js'; export const UPI_LEGAL_TRANSITIONS = { created: ['pending', 'failed', 'expired', 'cancelled'], @@ -746,6 +747,7 @@ export async function applySuccessfulUpiPayment({ RETURNING *`, [event.id, payment.user_id, ticketTypeId, qty, payment.amount, payment.id] )).rows[0]; + await issueTicketsForBooking(booking.id, c); await c.query('RELEASE SAVEPOINT sp_confirm_booking'); } catch (err) { if (err.code === '23505') { @@ -898,6 +900,7 @@ export async function claimUpiBooking({ orderId, guestToken, user }) { RETURNING *`, [event.id, uid, ticketTypeId, qty, payment.amount, payment.id] )).rows[0]; + await issueTicketsForBooking(booking.id, c); await c.query('RELEASE SAVEPOINT sp_claim_booking'); } catch (err) { if (err.code === '23505') { diff --git a/lib/views/organiser.js b/lib/views/organiser.js index bac33c5..8e80411 100644 --- a/lib/views/organiser.js +++ b/lib/views/organiser.js @@ -60,9 +60,17 @@ const PAGES = { title: 'Manage event', path: '/organiser', head: () => '', body: ({ eventId }) => html`
-

Loading guests…

+

Loading event details…

` + }, + scan: { + title: 'Door check-in scanner', path: '/organiser', + head: () => '', + body: ({ eventId }) => html`
+

Loading door check-in scanner…

+ +
` } }; @@ -93,3 +101,38 @@ export function renderDraftPreview({ event, draftId, issues }) { } return renderEvent(event, { preview: true, previewBanner: banner }); } + +export function renderStaffJoinPage({ state, invite = null, csrf = '', error = null }) { + let content; + if (error) { + content = html`
+ Event Staff +

Invitation unavailable

+

${error}

+

Explore events

+
`; + } else if (state === 'signed_out') { + content = html`
+ Event Staff Invitation +

You’ve been invited to join the event team

+

Sign in with Telegram to accept your role for ${invite?.eventTitle || 'this event'}.

+

Continue with Telegram

+
`; + } else { + content = html`
+ Event Staff Invitation +

Join the team for ${invite?.eventTitle}

+

Invited by: ${invite?.invitedByAlias || 'Organiser'}

+

Assigned Role: ${invite?.roleLabel}

+

This role grants you event-scoped operational access for this specific event.

+
+ +

+

+
+
`; + } + + const body = html`
${content}
`; + return layout({ title: 'Event Staff Invitation', description: 'Join the event team on NIGHTLIFEZ.', path: '/staff/join', body, head: ASSETS, noindex: true }); +} diff --git a/migrations/019_organiser_workspace_and_checkin.sql b/migrations/019_organiser_workspace_and_checkin.sql new file mode 100644 index 0000000..035d21c --- /dev/null +++ b/migrations/019_organiser_workspace_and_checkin.sql @@ -0,0 +1,111 @@ +-- 019_organiser_workspace_and_checkin.sql — Organiser workspace, event-scoped staff, secure QR ticketing & door check-in. +-- Additive only; safe to re-run. + +-- 1. Enhance event_team with audit and revocation columns +ALTER TABLE event_team ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now(); +ALTER TABLE event_team ADD COLUMN IF NOT EXISTS invited_by UUID REFERENCES users(id); +ALTER TABLE event_team ADD COLUMN IF NOT EXISTS revoked_by UUID REFERENCES users(id); +ALTER TABLE event_team ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ; +ALTER TABLE event_team ADD COLUMN IF NOT EXISTS notes TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_event_team_user_active + ON event_team (event_id, user_id) WHERE status = 'active' AND user_id IS NOT NULL; + +DROP TRIGGER IF EXISTS trg_event_team_updated ON event_team; +CREATE TRIGGER trg_event_team_updated BEFORE UPDATE ON event_team FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- 2. Staff invitations table for event-scoped roles +CREATE TABLE IF NOT EXISTS event_staff_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + role TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + invited_by UUID NOT NULL REFERENCES users(id), + invited_alias TEXT, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')), + expires_at TIMESTAMPTZ NOT NULL, + accepted_by UUID REFERENCES users(id), + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_event_staff_invites_event ON event_staff_invites (event_id, status); +CREATE INDEX IF NOT EXISTS idx_event_staff_invites_token ON event_staff_invites (token) WHERE status = 'pending'; + +-- 3. Tickets table: cryptographically secure server-authoritative tickets +CREATE TABLE IF NOT EXISTS tickets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id UUID NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id), + ticket_type_id UUID REFERENCES ticket_types(id), + ticket_number INTEGER NOT NULL DEFAULT 1, + ticket_code TEXT NOT NULL UNIQUE, + security_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'valid' CHECK (status IN ('valid', 'checked_in', 'revoked', 'reissued', 'cancelled')), + checked_in_at TIMESTAMPTZ, + checked_in_by UUID REFERENCES users(id), + check_in_method TEXT CHECK (check_in_method IS NULL OR check_in_method IN ('camera_qr', 'manual_lookup', 'manual_override')), + override_reason TEXT, + reissued_to_ticket_id UUID REFERENCES tickets(id), + reissued_at TIMESTAMPTZ, + reissued_by UUID REFERENCES users(id), + revoked_at TIMESTAMPTZ, + revoked_by UUID REFERENCES users(id), + revocation_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_tickets_booking ON tickets (booking_id); +CREATE INDEX IF NOT EXISTS idx_tickets_event_status ON tickets (event_id, status); +CREATE INDEX IF NOT EXISTS idx_tickets_code ON tickets (ticket_code); +CREATE INDEX IF NOT EXISTS idx_tickets_user ON tickets (user_id); +DROP TRIGGER IF EXISTS trg_tickets_updated ON tickets; +CREATE TRIGGER trg_tickets_updated BEFORE UPDATE ON tickets FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- 4. Ticket Scans & Check-in Audit Logs (server-authoritative audit trail) +CREATE TABLE IF NOT EXISTS ticket_scans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + ticket_id UUID REFERENCES tickets(id), + booking_id UUID REFERENCES bookings(id), + scanned_by UUID NOT NULL REFERENCES users(id), + scan_result TEXT NOT NULL, + scan_method TEXT NOT NULL CHECK (scan_method IN ('camera_qr', 'manual_lookup', 'manual_override')), + override_reason TEXT, + scanned_payload_hash TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_ticket_scans_event ON ticket_scans (event_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_ticket_scans_ticket ON ticket_scans (ticket_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_ticket_scans_staff ON ticket_scans (scanned_by, created_at DESC); + +-- 5. Event Announcements Table +CREATE TABLE IF NOT EXISTS event_announcements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + sender_user_id UUID NOT NULL REFERENCES users(id), + title TEXT NOT NULL, + message TEXT NOT NULL, + is_pinned BOOLEAN NOT NULL DEFAULT false, + notify_attendees BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_event_announcements_event ON event_announcements (event_id, created_at DESC); + +-- 6. Backfill existing confirmed bookings with tickets if any exist without tickets +INSERT INTO tickets (booking_id, event_id, user_id, ticket_type_id, ticket_number, ticket_code, security_hash, status, created_at) +SELECT b.id, b.event_id, b.user_id, b.ticket_type_id, 1, + 'TK-' || upper(substr(md5(b.id::text || '-1-' || b.created_at::text), 1, 4)) || '-' || + upper(substr(md5(b.id::text || '-2-' || b.created_at::text), 5, 4)) || '-' || + upper(substr(md5(b.id::text || '-3-' || b.created_at::text), 9, 4)), + md5('nlz_seed_sig_' || b.id::text), + CASE WHEN b.status = 'checked_in' THEN 'checked_in' + WHEN b.status = 'cancelled' THEN 'cancelled' + WHEN b.status = 'refunded' THEN 'revoked' + ELSE 'valid' END, + b.created_at + FROM bookings b + WHERE b.status IN ('confirmed', 'checked_in') + AND NOT EXISTS (SELECT 1 FROM tickets t WHERE t.booking_id = b.id) +ON CONFLICT (ticket_code) DO NOTHING; diff --git a/public/css/organiser.css b/public/css/organiser.css index bf7035c..ab82efd 100644 --- a/public/css/organiser.css +++ b/public/css/organiser.css @@ -216,3 +216,111 @@ .issues .linkish { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; } } @media (max-width: 800px) and (prefers-reduced-motion: reduce) { .switch::before { transition: none; } } + +/* ===================================================================================== + M6 Organiser Workspace & Mobile QR Scanner Styles + ===================================================================================== */ + +/* Workspace Tabs */ +.org-tabs { display: flex; gap: 8px; border-bottom: 1px solid var(--line); margin: 20px 0; overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; } +.org-tabs::-webkit-scrollbar { display: none; } +.org-tab-btn { min-height: 44px; padding: 8px 16px; background: none; border: 0; border-bottom: 3px solid transparent; color: var(--muted); font-size: .92rem; font-weight: 600; cursor: pointer; white-space: nowrap; transition: all .15s; } +.org-tab-btn:hover { color: #fff; } +.org-tab-btn.active { color: var(--neon-cyan); border-bottom-color: var(--neon-cyan); font-weight: 700; } +.org-tab-badge { display: inline-block; font-size: .7rem; padding: 1px 6px; border-radius: 10px; background: var(--alt); color: var(--ink-2); margin-left: 6px; } + +/* Readiness Checklist Card */ +.readiness-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; margin-bottom: 24px; } +.readiness-header { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 16px; } +.readiness-score-badge { font-size: 1.1rem; font-weight: 700; padding: 4px 12px; border-radius: 999px; background: rgba(0, 217, 255, 0.15); border: 1px solid rgba(0, 217, 255, 0.4); color: var(--neon-cyan); } +.readiness-score-badge.complete { background: rgba(50, 215, 75, 0.15); border-color: rgba(50, 215, 75, 0.4); color: #32d74b; } +.checklist-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; } +.checklist-item { display: flex; align-items: center; gap: 8px; font-size: .88rem; color: var(--ink-2); padding: 6px 10px; border-radius: 6px; background: var(--alt); } +.check-icon { font-size: 1rem; } +.checklist-item.passed .check-icon { color: #32d74b; } +.checklist-item.failed .check-icon { color: #ffd15c; } + +/* Quick Action Toolbar */ +.org-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 24px; align-items: center; } + +/* Capacity & Inventory Breakdown */ +.inventory-summary-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; margin-bottom: 24px; } +.inventory-progress-wrap { margin: 12px 0 20px; } +.inventory-progress-bar { height: 12px; border-radius: 6px; background: var(--alt); overflow: hidden; display: flex; } +.inv-fill-sold { background: var(--neon-cyan); height: 100%; transition: width .3s; } +.inv-fill-holds { background: #ffd15c; height: 100%; transition: width .3s; } +.inventory-legend { display: flex; flex-wrap: wrap; gap: 16px; font-size: .82rem; color: var(--muted); margin-top: 8px; } +.legend-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 4px; vertical-align: middle; } +.legend-sold { background: var(--neon-cyan); } +.legend-holds { background: #ffd15c; } +.legend-rem { background: var(--line); } + +/* Tables (Staff, Inventory, Scans) */ +.org-table-wrap { overflow-x: auto; margin-bottom: 24px; border: 1px solid var(--line); border-radius: var(--radius); } +.org-data-table { width: 100%; border-collapse: collapse; text-align: left; font-size: .88rem; } +.org-data-table th { background: var(--alt); padding: 12px 14px; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--line); white-space: nowrap; } +.org-data-table td { padding: 12px 14px; border-bottom: 1px solid var(--line); vertical-align: middle; } +.org-data-table tr:last-child td { border-bottom: 0; } +.org-data-table tr:hover td { background: rgba(255, 255, 255, 0.02); } + +/* Staff Badges */ +.role-badge { display: inline-block; padding: 2px 8px; border-radius: 6px; font-size: .75rem; font-weight: 600; text-transform: capitalize; } +.role-event_manager { background: rgba(180, 90, 255, 0.15); color: #c48aff; border: 1px solid rgba(180, 90, 255, 0.4); } +.role-door_staff { background: rgba(0, 217, 255, 0.15); color: var(--neon-cyan); border: 1px solid rgba(0, 217, 255, 0.4); } +.role-guest_list_manager { background: rgba(255, 170, 0, 0.15); color: #ffbe44; border: 1px solid rgba(255, 170, 0, 0.4); } +.role-assistant { background: var(--alt); color: var(--muted); border: 1px solid var(--line); } + +/* Search & Filter Toolbar */ +.table-filter-bar { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; align-items: center; } +.table-search-input { flex: 1 1 200px; min-width: 0; min-height: 40px; padding: 8px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--surface); color: #fff; font: inherit; } +.table-status-select { min-height: 40px; padding: 8px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--surface); color: #fff; font: inherit; } + +/* Announcements */ +.announcement-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; margin-bottom: 14px; } +.announcement-card.pinned { border-left: 4px solid var(--neon-cyan); } +.announcement-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; margin-bottom: 8px; } +.announcement-head h3 { margin: 0; font-size: 1.05rem; } +.announcement-body { color: var(--ink-2); line-height: 1.5; margin: 0; white-space: pre-wrap; font-size: .92rem; } + +/* Door Check-In Scanner Interface */ +.org-scan-wrap { max-width: 580px; margin: 0 auto; padding-top: 24px; padding-bottom: 80px; } +.scanner-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; margin-bottom: 20px; text-align: center; } +.scanner-viewport { position: relative; width: 100%; aspect-ratio: 1; max-width: 360px; margin: 0 auto 16px; background: #000; border-radius: 12px; overflow: hidden; display: flex; align-items: center; justify-content: center; } +.scanner-video { width: 100%; height: 100%; object-fit: cover; } +.scanner-canvas-hidden { display: none; } +.scanner-reticle { position: absolute; inset: 15%; border: 2px dashed rgba(0, 217, 255, 0.7); border-radius: 12px; pointer-events: none; box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45); } +.scanner-laser { position: absolute; top: 0; left: 0; right: 0; height: 2px; background: linear-gradient(90deg, transparent, #00D9FF, transparent); animation: laserScan 2s ease-in-out infinite alternate; } +@keyframes laserScan { + from { top: 5%; } + to { top: 95%; } +} +.scanner-camera-status { position: absolute; bottom: 8px; left: 8px; right: 8px; font-size: .75rem; color: #fff; background: rgba(0,0,0,0.6); padding: 4px 8px; border-radius: 4px; } + +/* Scan Result Alerts */ +.scan-alert { border-radius: var(--radius); padding: 16px; margin: 16px 0; text-align: left; animation: fadeIn .2s ease-out; } +@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } +.scan-alert-valid { background: rgba(50, 215, 75, 0.12); border: 2px solid #32d74b; color: #fff; box-shadow: 0 0 20px rgba(50, 215, 75, 0.2); } +.scan-alert-duplicate { background: rgba(255, 209, 92, 0.12); border: 2px solid #ffd15c; color: #fff; box-shadow: 0 0 20px rgba(255, 209, 92, 0.2); } +.scan-alert-invalid { background: rgba(255, 49, 91, 0.12); border: 2px solid var(--party-pink); color: #fff; box-shadow: 0 0 20px rgba(255, 49, 91, 0.2); } +.scan-alert-title { font-size: 1.15rem; font-weight: 700; margin: 0 0 6px; display: flex; align-items: center; gap: 8px; } +.scan-alert-desc { margin: 0; font-size: .92rem; color: var(--ink-2); } +.scan-attendee-details { margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.1); font-size: .88rem; } + +/* Manual Lookup Box */ +.manual-box { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; margin-bottom: 20px; } +.manual-form { display: grid; gap: 12px; } +.manual-input-group { display: flex; gap: 8px; flex-wrap: wrap; } +.manual-input { flex: 1 1 200px; min-width: 0; min-height: 44px; padding: 8px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--alt); color: #fff; font: inherit; font-size: 1rem; text-transform: uppercase; } +.manual-override-wrap { margin-top: 10px; padding: 12px; border: 1px dashed var(--line); border-radius: 6px; background: rgba(255, 209, 92, 0.05); } + +/* Live Attendance Header in Scanner */ +.scan-header-kpis { display: flex; justify-content: space-around; gap: 12px; margin-bottom: 16px; padding: 12px; background: var(--alt); border-radius: 8px; } +.scan-kpi-item { display: flex; flex-direction: column; align-items: center; } +.scan-kpi-num { font-size: 1.5rem; font-weight: 700; color: #fff; margin: 0; } +.scan-kpi-lbl { font-size: .72rem; text-transform: uppercase; color: var(--muted); letter-spacing: .05em; margin: 0; } + +/* Modal & Dialogs */ +.org-modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.75); display: flex; align-items: center; justify-content: center; z-index: 999; padding: 16px; } +.org-modal-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); max-width: 540px; width: 100%; max-height: 90vh; overflow-y: auto; padding: 24px; box-shadow: 0 8px 32px rgba(0,0,0,0.6); } +.org-modal-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } +.org-modal-head h2 { margin: 0; font-size: 1.25rem; } diff --git a/public/css/site.css b/public/css/site.css index 8807377..0d8f1c8 100644 --- a/public/css/site.css +++ b/public/css/site.css @@ -746,6 +746,14 @@ select:focus, input:focus, textarea:focus { outline: 2px solid var(--accent); ou .pass-datetime { font-size: 0.92rem; color: var(--muted); margin: 0 0 16px; } .pass-facts { display: grid; gap: 10px; margin: 16px 0; padding: 14px; background: rgba(255, 255, 255, 0.02); border: 1px solid var(--line); border-radius: var(--radius-sm); } .pass-footer-note { margin-top: 16px; padding-top: 14px; border-top: 1px dashed var(--line); text-align: center; } +.digital-pass-tickets { display: grid; gap: 14px; margin: 16px 0; } +.ticket-qr-card { background: rgba(255, 255, 255, 0.03); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 16px; text-align: center; } +.ticket-qr-card.ticket-used { opacity: 0.75; border-color: rgba(255, 255, 255, 0.1); } +.ticket-qr-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } +.ticket-qr-graphic { display: flex; justify-content: center; align-items: center; margin: 12px auto; background: #fff; padding: 12px; border-radius: 8px; width: fit-content; max-width: 220px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); } +.ticket-qr-graphic svg { display: block; width: 180px; height: 180px; } +.ticket-code-display { margin: 8px 0; } +.ticket-code-display code { font-family: monospace; font-size: 1.05rem; font-weight: 700; letter-spacing: 0.08em; background: rgba(0, 0, 0, 0.4); border: 1px solid var(--line); padding: 4px 10px; border-radius: 4px; color: var(--neon-cyan); } .booking-nav-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 24px; } .booking-nav-actions .btn { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; } diff --git a/public/js/organiser.js b/public/js/organiser.js index 3d3a0d1..278be06 100644 --- a/public/js/organiser.js +++ b/public/js/organiser.js @@ -115,122 +115,753 @@ } /* ===================================================================================== - Manage one event: guests, requests, answers + Manage one event: overview, readiness, inventory, attendees, staff & announcements ===================================================================================== */ function initManage(el) { var id = el.getAttribute('data-org-manage'); + var activeTab = 'overview'; var activeFilter = 'all'; + var attendeeSearch = ''; + var overviewData = null; + + function renderTabs() { + var tabs = [ + ['overview', '📊 Overview & Readiness'], + ['inventory', '🎟️ Ticket Inventory'], + ['attendees', '👥 Attendees & Guest List'], + ['staff', '🛡️ Event Staff'], + ['announcements', '📢 Announcements'] + ]; + return '
' + + tabs.map(function (t) { + return ''; + }).join('') + + '
'; + } + function load() { - api('GET', '/api/organiser/events/' + encodeURIComponent(id) + '/guests').then(function (r) { - if (!r.ok) { el.innerHTML = '

' + esc(r.message || 'Could not load this event.') + '

'; return; } + api('GET', '/api/organiser/events/' + encodeURIComponent(id) + '/overview').then(function (r) { + if (!r.ok) { el.innerHTML = '

' + esc(r.message || 'Could not load event overview.') + '

'; return; } + overviewData = r; var e = r.event; + var m = r.metrics; var url = absolute(e.path); + + var h = '
' + + '

← Organiser Workspace

' + + '

' + esc(e.title) + '

' + + '

' + esc(fmtInstant(e.startAt)) + (e.endAt ? ' – ' + esc(fmtInstant(e.endAt)) : '') + ' · ' + esc(e.city) + ' · ' + esc(e.status) + '

' + + '' + + '
'; + + h += renderTabs(); + h += '

Loading section…

'; + h += '

'; + el.innerHTML = h; + + renderCurrentTab(); + }); + } + + function renderCurrentTab() { + var panel = $('#org-tab-panel', el); + if (!panel || !overviewData) return; + + if (activeTab === 'overview') { + renderOverviewTab(panel); + } else if (activeTab === 'inventory') { + renderInventoryTab(panel); + } else if (activeTab === 'attendees') { + renderAttendeesTab(panel); + } else if (activeTab === 'staff') { + renderStaffTab(panel); + } else if (activeTab === 'announcements') { + renderAnnouncementsTab(panel); + } + } + + function renderOverviewTab(panel) { + var e = overviewData.event; + var m = overviewData.metrics; + var rd = overviewData.readiness; + var perms = overviewData.permissions; + + var h = '
' + + '
' + m.confirmedSeats + ' / ' + m.capacity + 'Confirmed / Capacity
' + + '
' + m.checkedInTickets + ' (' + m.attendanceRate + '%)Checked In
' + + '
' + m.remainingSeats + 'Tickets Remaining
' + + '
' + m.pendingRequests + 'Pending Requests
' + + '
'; + + h += '
' + + '
' + + '

Event Readiness Tracking

Operational checklist before doors open.

' + + '' + esc(rd.score) + ' Complete' + + '
' + + '
' + + rd.checklist.map(function (c) { + return '
' + + ' ' + + '' + esc(c.label) + '' + + '
'; + }).join('') + + '
'; + + h += '
' + + '
' + + '

Event & Venue Details

' + + (perms.canEdit ? '' : '') + + '
' + + '
' + + '
Venue Privacy
' + (e.venuePrivacy === 'disclosed_after_booking' ? '🔒 Mystery Venue (Disclosed after confirmed booking)' : 'Publicly Disclosed') + '
' + + (e.venue ? '
Private Address
' + esc(e.venue) + '
' : '') + + (e.area ? '
Public Area
' + esc(e.area) + ', ' + esc(e.city) + '
' : '') + + (e.mapsUrl ? '
Google Maps
Open in Maps
' : '') + + (e.refundPolicy ? '
Refund Policy
' + esc(e.refundPolicy) + '
' : '') + + '
' + + (perms.canCancel && e.status !== 'cancelled' ? '
' + + '' + + '
' : '') + + '
'; + + panel.innerHTML = h; + } + + function renderInventoryTab(panel) { + panel.innerHTML = '

Loading ticket inventory…

'; + api('GET', '/api/organiser/events/' + encodeURIComponent(id) + '/inventory').then(function (r) { + if (!r.ok) { panel.innerHTML = '

' + esc(r.message || 'Could not load inventory.') + '

'; return; } + var cap = r.capacity || 0; + var sold = r.totalSold || 0; + var holds = r.totalHolds || 0; + var rem = r.totalRemaining || 0; + + var soldPct = cap > 0 ? (sold / cap) * 100 : 0; + var holdsPct = cap > 0 ? (holds / cap) * 100 : 0; + + var h = '
' + + '

Total Capacity Allocation

' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + ' Confirmed: ' + sold + '' + + ' Payment Holds: ' + holds + '' + + ' Remaining: ' + rem + ' / ' + cap + '' + + '
'; + + h += '
' + + '' + + '' + + r.tiers.map(function (t) { + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }).join('') + + '
Tier NameTypePriceLimitConfirmedActive HoldsAvailable
' + esc(t.name) + '' + (t.eligibility ? '
' + esc(t.eligibility) + '' : '') + '
' + esc(t.kind.toUpperCase()) + '' + (t.kind === 'free' ? 'Free' : money(t.pricePaise)) + '' + esc(t.quantity) + '' + esc(t.sold) + '' + esc(t.holds) + '' + esc(t.remaining) + '
'; + + panel.innerHTML = h; + }); + } + + function renderAttendeesTab(panel) { + panel.innerHTML = '

Loading guests…

'; + api('GET', '/api/organiser/events/' + encodeURIComponent(id) + '/guests').then(function (r) { + if (!r.ok) { panel.innerHTML = '

' + esc(r.message || 'Could not load guests.') + '

'; return; } + var e = r.event; + var guests = r.guests || []; + var seats = 0, reqs = 0, declined = 0; - r.guests.forEach(function (g) { + guests.forEach(function (g) { if (g.status === 'confirmed' || g.status === 'checked_in') seats += g.quantity; if (g.status === 'pending_approval') reqs++; - if (g.status === 'declined') declined++; + if (g.status === 'declined' || g.status === 'cancelled') declined++; + }); + + var q = attendeeSearch.toLowerCase().trim(); + var filtered = guests.filter(function (g) { + var matchFilter = true; + if (activeFilter === 'confirmed') matchFilter = (g.status === 'confirmed' || g.status === 'checked_in'); + else if (activeFilter === 'pending') matchFilter = (g.status === 'pending_approval'); + else if (activeFilter === 'declined') matchFilter = (g.status === 'declined' || g.status === 'cancelled'); + + var matchSearch = true; + if (q) { + matchSearch = (g.alias && g.alias.toLowerCase().indexOf(q) !== -1) || + (g.ref && g.ref.toLowerCase().indexOf(q) !== -1) || + (g.id && g.id.toLowerCase().indexOf(q) !== -1); + } + return matchFilter && matchSearch; }); - var h = '

Manage event

' + esc(e.title) + '

' + - '

' + esc(fmtInstant(e.startAt)) + ' · ' + esc(e.status) + '

' + - '' + - '
' + - '
' + seats + 'Confirmed Seats
' + - '
' + esc(e.capacity) + 'Total Capacity
' + - (e.requiresApproval ? '
' + reqs + 'Pending Requests
' : '') + - '
'; - if (!r.guests.length) { - h += '
' + - '' + - '

No bookings yet

' + - '

Share your event link to start receiving attendee bookings.

' + - '
' + - '' + - 'View Live Event' + - '
'; + var h = '
' + + '' + + '
' + + '' + + '' + + (e.requiresApproval ? '' : '') + + (declined > 0 ? '' : '') + + '
'; + + if (!filtered.length) { + h += '

No matching attendees

Try adjusting your search or filter.

'; } else { - var filtered = r.guests.filter(function (g) { - if (activeFilter === 'confirmed') return g.status === 'confirmed' || g.status === 'checked_in'; - if (activeFilter === 'pending') return g.status === 'pending_approval'; - if (activeFilter === 'declined') return g.status === 'declined'; - return true; + h += '
    ' + filtered.map(function (g) { + var answers = r.questions.filter(function (q) { return g.answers[q.id] != null; }).map(function (q) { + var v = g.answers[q.id]; + return '
    ' + esc(q.label) + '
    ' + esc(Array.isArray(v) ? v.join(', ') : v) + '
    '; + }).join(''); + + var actions = ''; + if (g.status === 'pending_approval') { + actions += '' + + ''; + } + if (g.status === 'confirmed') { + actions += ''; + } + if (g.userId) { + actions += ''; + } + + var refundNotice = ''; + if (g.paymentStatus === 'refunded') { + refundNotice = ' · Refunded'; + } else if (g.paymentStatus === 'refund_pending') { + refundNotice = ' · Refund Pending'; + } + + return '
  • ' + esc(g.alias) + ' #SM-' + esc(g.ref) + '

    ' + + '' + esc(STATUS[g.status] || g.status) + '
    ' + + '

    ' + esc(g.ticket || 'Ticket') + ' × ' + esc(g.quantity) + ' · ' + esc(PAY[g.paymentStatus] || g.paymentStatus) + refundNotice + (g.pickup ? ' · Pickup: ' + esc(g.pickup) : '') + ' · Requested ' + esc(ago(g.createdAt)) + (g.checkedInAt ? ' · Checked in ' + esc(ago(g.checkedInAt)) + '' : '') + '

    ' + + (answers ? '
    ' + answers + '
    ' : '') + + (actions ? '
    ' + actions + '
    ' : '') + + '
  • '; + }).join('') + '
'; + } + panel.innerHTML = h; + + var searchInput = $('[data-action="search-attendees"]', panel); + if (searchInput) { + searchInput.addEventListener('input', function () { + attendeeSearch = searchInput.value; + renderAttendeesTab(panel); }); - h += '
' + - '' + - '' + - (e.requiresApproval ? '' : '') + - (declined > 0 ? '' : '') + - '
'; + } + }); + } - if (!filtered.length) { - h += '

No guests match this filter.

'; - } else { - h += '
    ' + filtered.map(function (g) { - var answers = r.questions.filter(function (q) { return g.answers[q.id] != null; }).map(function (q) { - var v = g.answers[q.id]; - return '
    ' + esc(q.label) + '
    ' + esc(Array.isArray(v) ? v.join(', ') : v) + '
    '; - }).join(''); - var actions = ''; - if (g.status === 'pending_approval') { - actions += '' + - ''; - } - if (g.userId) { - actions += ''; + function renderStaffTab(panel) { + panel.innerHTML = '

    Loading event staff…

    '; + api('GET', '/api/organiser/events/' + encodeURIComponent(id) + '/staff').then(function (r) { + if (!r.ok) { panel.innerHTML = '

    ' + esc(r.message || 'Could not load staff.') + '

    '; return; } + var staff = r.staff || []; + var invites = r.invites || []; + + var h = '
    ' + + '

    Event Staff & Roles

    Staff members have event-scoped operational permissions only.

    ' + + (r.canManage ? '' : '') + + '
    '; + + if (!staff.length && !invites.length) { + h += '

    No staff assigned

    Invite door staff, event managers, or assistants for this event.

    '; + } else { + h += '
    ' + + '' + (r.canManage ? '' : '') + '' + + '' + + staff.map(function (s) { + return '' + + '' + + '' + + '' + + '' + + (r.canManage ? '' : '') + + ''; + }).join('') + + invites.map(function (i) { + return '' + + '' + + '' + + '' + + '' + + (r.canManage ? '' : '') + + ''; + }).join('') + + '
    Name / AliasAssigned RoleStatusAddedAction
    ' + esc(s.alias) + '' + esc(s.roleLabel) + '' + esc(s.status) + '' + esc(ago(s.createdAt)) + '' + (s.status === 'active' ? '' : 'Revoked ' + esc(ago(s.revokedAt)) + '') + '
    Pending Invite' + (i.invitedAlias ? ': ' + esc(i.invitedAlias) : '') + '' + esc(i.roleLabel) + 'Invite SentExpires ' + esc(ago(i.expiresAt)) + '
    '; + } + panel.innerHTML = h; + }); + } + + function renderAnnouncementsTab(panel) { + panel.innerHTML = '

    Loading announcements…

    '; + api('GET', '/api/organiser/events/' + encodeURIComponent(id) + '/announcements').then(function (r) { + if (!r.ok) { panel.innerHTML = '

    ' + esc(r.message || 'Could not load announcements.') + '

    '; return; } + var list = r.announcements || []; + + var h = '
    ' + + '

    Broadcast Operational Announcement

    ' + + '

    Send an update to confirmed event attendees (gate instructions, venue hints, weather reminders).

    ' + + '
    ' + + '' + + '' + + '
    ' + + '
    ' + + '' + + '

    ' + + '
    '; + + if (!list.length) { + h += '

    No announcements posted

    Broadcasts to attendees will appear here.

    '; + } else { + h += '
    ' + list.map(function (a) { + return '
    ' + + '
    ' + + '

    ' + (a.isPinned ? '📌 ' : '') + esc(a.title) + '

    ' + + '' + esc(ago(a.createdAt)) + ' by ' + esc(a.senderAlias) + '' + + '
    ' + + '

    ' + esc(a.message) + '

    ' + + '
    '; + }).join('') + '
    '; + } + panel.innerHTML = h; + + var annForm = $('form[data-announcement-form]', panel); + if (annForm) { + annForm.addEventListener('submit', function (ev) { + ev.preventDefault(); + var fMsg = $('[data-form-msg]', annForm); + var btn = $('button[type=submit]', annForm); + btn.disabled = true; + var body = { + title: annForm.title.value, + message: annForm.message.value, + isPinned: annForm.isPinned.checked, + notifyAttendees: annForm.notifyAttendees.checked + }; + api('POST', '/api/organiser/events/' + encodeURIComponent(id) + '/announcements', body).then(function (res) { + btn.disabled = false; + if (res.ok) { + renderAnnouncementsTab(panel); + } else { + fMsg.className = 'form-msg error'; + fMsg.textContent = res.message || 'Could not post announcement.'; } - return '
  • ' + esc(g.alias) + ' #' + esc(g.ref) + '

    ' + - '' + esc(STATUS[g.status] || g.status) + '
    ' + - '

    ' + esc(g.ticket || 'Ticket') + ' × ' + esc(g.quantity) + ' · ' + esc(PAY[g.paymentStatus] || g.paymentStatus) + (g.pickup ? ' · Pickup: ' + esc(g.pickup) : '') + ' · Requested ' + esc(ago(g.createdAt)) + '

    ' + - (answers ? '
    ' + answers + '
    ' : '') + - (actions ? '
    ' + actions + '
    ' : '') + - '
  • '; - }).join('') + '
'; - } + }); + }); } - h += '

All your events

'; - el.innerHTML = h; }); } + el.addEventListener('click', function (ev) { + var tabBtn = ev.target.closest('[data-tab]'); + if (tabBtn) { + activeTab = tabBtn.getAttribute('data-tab'); + $$('.org-tab-btn', el).forEach(function (b) { + var isCur = b.getAttribute('data-tab') === activeTab; + b.classList.toggle('active', isCur); + b.setAttribute('aria-selected', isCur ? 'true' : 'false'); + }); + renderCurrentTab(); + return; + } + var b = ev.target.closest('button'); if (!b) return; + if (b.hasAttribute('data-filter')) { activeFilter = b.getAttribute('data-filter'); - load(); + renderCurrentTab(); return; } if (b.hasAttribute('data-copy')) return copyText(b.getAttribute('data-copy'), b); if (b.hasAttribute('data-share')) return share(b.getAttribute('data-title'), b.getAttribute('data-share'), b); + + if (b.getAttribute('data-action') === 'invite-staff') { + var modal = document.createElement('div'); + modal.className = 'org-modal-backdrop'; + modal.innerHTML = '
' + + '

Invite Event Staff

' + + '
' + + '' + + '' + + '
' + + '
' + + '
'; + document.body.appendChild(modal); + + modal.addEventListener('click', function (mev) { + if (mev.target === modal || mev.target.hasAttribute('data-close-modal')) modal.remove(); + }); + + var iform = $('form[data-invite-staff-form]', modal); + iform.addEventListener('submit', function (mev) { + mev.preventDefault(); + var resBox = $('[data-invite-result]', iform); + api('POST', '/api/organiser/events/' + encodeURIComponent(id) + '/staff/invite', { role: iform.role.value, invitedAlias: iform.alias.value }).then(function (ir) { + if (ir.ok) { + resBox.innerHTML = '
' + + '

Invite link created (valid 48h):

' + + '' + + '' + + '
'; + renderCurrentTab(); + } else { + resBox.innerHTML = '

' + esc(ir.message || 'Could not create invite.') + '

'; + } + }); + }); + return; + } + + if (b.getAttribute('data-action') === 'revoke-staff') { + var sId = b.getAttribute('data-id'); + var sName = b.getAttribute('data-name'); + if (!window.confirm('Revoke access for ' + sName + '? They will immediately lose staff access.')) return; + b.disabled = true; + api('DELETE', '/api/organiser/events/' + encodeURIComponent(id) + '/staff/' + encodeURIComponent(sId)).then(function (rr) { + if (!rr.ok) alert(rr.message || 'Could not revoke staff access.'); + renderCurrentTab(); + }); + return; + } + + if (b.getAttribute('data-action') === 'cancel-booking') { + var bkId = b.getAttribute('data-id'); + var bkName = b.getAttribute('data-name'); + var reason = window.prompt('Reason for cancelling ' + bkName + '’s booking:', 'Cancelled by host'); + if (reason == null) return; + b.disabled = true; + api('POST', '/api/organiser/bookings/' + encodeURIComponent(bkId) + '/cancel', { reason: reason }).then(function (cr) { + if (!cr.ok) alert(cr.message || 'Could not cancel booking.'); + renderCurrentTab(); + }); + return; + } + + if (b.getAttribute('data-action') === 'cancel-event') { + var evReason = window.prompt('Are you sure you want to cancel this event? All active tickets will be cancelled.\n\nEnter reason:', 'Event cancelled by organiser'); + if (evReason == null) return; + b.disabled = true; + api('POST', '/api/organiser/events/' + encodeURIComponent(id) + '/cancel', { reason: evReason }).then(function (er) { + if (er.ok) { + load(); + } else { + alert(er.message || 'Could not cancel event.'); + b.disabled = false; + } + }); + return; + } + if (b.hasAttribute('data-guest-block')) { var uid = b.getAttribute('data-guest-block'); var isBlk = b.getAttribute('data-blocked') === '1'; b.disabled = true; var act = isBlk ? api('DELETE', '/api/me/blocks/' + encodeURIComponent(uid)) : api('POST', '/api/me/blocks', { blockedUserId: uid }); act.then(function () { - load(); + renderCurrentTab(); }).catch(function (err) { b.disabled = false; - var m = $('[data-manage-msg]', el); - if (m) { m.className = 'form-msg error'; m.textContent = err.message || 'Action failed.'; } }); return; } + var d = b.getAttribute('data-decide'); - if (!d) return; - if (d === 'decline' && !window.confirm('Decline ' + b.getAttribute('data-name') + '’s request? They will be told it wasn’t accepted.')) return; - b.disabled = true; - api('POST', '/api/organiser/bookings/' + encodeURIComponent(b.getAttribute('data-id')) + '/' + d, {}).then(function (r) { - if (!r.ok) { var m = $('[data-manage-msg]', el); m.className = 'form-msg error'; m.textContent = r.message || 'That didn’t work.'; b.disabled = false; return; } - load(); - }); + if (d) { + if (d === 'decline' && !window.confirm('Decline ' + b.getAttribute('data-name') + '’s request? They will be told it wasn’t accepted.')) return; + b.disabled = true; + api('POST', '/api/organiser/bookings/' + encodeURIComponent(b.getAttribute('data-id')) + '/' + d, {}).then(function (r) { + if (!r.ok) { alert(r.message || 'Action failed.'); b.disabled = false; return; } + renderCurrentTab(); + }); + return; + } }); + load(); } + /* ===================================================================================== + Mobile-First Door Check-In Scanner Experience + ===================================================================================== */ + function initScan(el) { + var eventId = el.getAttribute('data-org-scan'); + var video = null; + var canvas = null; + var detector = null; + var mediaStream = null; + var scanInterval = null; + var isProcessing = false; + + function renderScanner() { + el.innerHTML = '
' + + '

← Event Management

' + + '

Door Check-In Scanner

' + + '

Mobile camera verification & server-authoritative admission pass validation.

' + + '

Loading attendance count…

' + + '
' + + '
' + + '' + + '' + + '' + + '
Camera starting…
' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '

Manual Ticket Lookup

' + + '

Enter the ticket code printed on the attendee pass (#SM-… or TK-…).

' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + 'Manual Override (Damaged pass / Emergency Entry)' + + '

Requires manager permission. An immutable audit record is logged.

' + + '' + + '
' + + '

' + + '
' + + '
' + + '

Recent Door Scans

' + + '

Loading scan history…

' + + '
'; + + video = $('#scan-video', el); + canvas = $('#scan-canvas', el); + + startCamera(); + loadAttendance(); + loadRecentScans(); + bindScannerControls(); + } + + function loadAttendance() { + api('GET', '/api/organiser/events/' + encodeURIComponent(eventId) + '/attendance').then(function (r) { + if (!r.ok) return; + var kpis = $('[data-scan-kpis]', el); + if (kpis) { + kpis.innerHTML = '
' + r.checkedInTickets + ' / ' + r.eligibleTickets + 'Checked In (' + r.attendanceRate + '%)
' + + '
' + r.remainingTickets + 'Remaining
' + + '
' + r.capacity + 'Total Capacity
'; + } + }); + } + + function loadRecentScans() { + api('GET', '/api/organiser/events/' + encodeURIComponent(eventId) + '/scans?limit=10').then(function (r) { + if (!r.ok) return; + var rs = $('[data-recent-scans]', el); + if (!rs) return; + var scans = r.scans || []; + if (!scans.length) { + rs.innerHTML = '

No scans logged yet.

'; + return; + } + rs.innerHTML = '
    ' + scans.map(function (s) { + var pillClass = s.result === 'valid' ? 'pill-confirmed' : s.result === 'already_checked_in' ? 'pill-pending_approval' : 'pill-declined'; + return '
  • ' + + '
    ' + esc(s.attendeeAlias || 'Guest') + ' (#SM-' + esc(s.attendeeRef || 'PASS') + ')' + + '

    ' + esc(s.method) + ' · Scanned by ' + esc(s.staffAlias) + ' · ' + esc(ago(s.createdAt)) + '

    ' + + '' + esc(s.result.replace(/_/g, ' ').toUpperCase()) + '' + + '
  • '; + }).join('') + '
'; + }); + } + + function startCamera() { + var statusEl = $('[data-cam-status]', el); + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + if (statusEl) statusEl.textContent = 'Camera not supported. Use manual lookup below.'; + return; + } + + navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } } }).then(function (stream) { + mediaStream = stream; + video.srcObject = stream; + video.play(); + if (statusEl) statusEl.textContent = 'Align QR code in frame'; + + if (window.BarcodeDetector) { + detector = new window.BarcodeDetector({ formats: ['qr_code'] }); + } + scanInterval = setInterval(detectFrame, 300); + }).catch(function (err) { + if (statusEl) statusEl.textContent = 'Camera permission denied. Use manual lookup below.'; + }); + } + + function stopCamera() { + if (scanInterval) { clearInterval(scanInterval); scanInterval = null; } + if (mediaStream) { + mediaStream.getTracks().forEach(function (t) { t.stop(); }); + mediaStream = null; + } + var statusEl = $('[data-cam-status]', el); + if (statusEl) statusEl.textContent = 'Camera paused'; + } + + function detectFrame() { + if (isProcessing || !video || video.readyState < 2) return; + if (detector) { + detector.detect(video).then(function (barcodes) { + if (barcodes && barcodes.length > 0) { + var raw = barcodes[0].rawValue; + if (raw) handleScannedPayload(raw, 'camera_qr'); + } + }).catch(function () {}); + } + } + + function handleScannedPayload(payload, method, overrideReason) { + if (isProcessing) return; + isProcessing = true; + var statusEl = $('[data-cam-status]', el); + if (statusEl) statusEl.textContent = 'Verifying ticket with server…'; + + var body = { + method: method || 'camera_qr', + overrideReason: overrideReason || null + }; + if (payload.indexOf('NLZ1:') === 0) { + body.qrPayload = payload; + } else { + body.ticketCode = payload; + } + + api('POST', '/api/organiser/events/' + encodeURIComponent(eventId) + '/check-in', body).then(function (res) { + isProcessing = false; + renderScanResult(res); + loadAttendance(); + loadRecentScans(); + if (statusEl) statusEl.textContent = 'Align next QR code'; + }).catch(function (err) { + isProcessing = false; + renderScanResult({ ok: false, error: 'network_error', message: err.message || 'Verification failed.' }); + if (statusEl) statusEl.textContent = 'Align next QR code'; + }); + } + + function renderScanResult(res) { + var area = $('[data-scan-result-area]', el); + if (!area) return; + + if (res.ok && res.result === 'valid') { + var a = res.attendee || {}; + area.innerHTML = '
' + + '
ADMISSION GRANTED — VALID TICKET
' + + '

Ticket verified and checked in successfully.

' + + '
' + + 'Attendee: ' + esc(a.alias || 'Guest') + ' (Pass #SM-' + esc(a.ref || '') + ')
' + + 'Ticket Tier: ' + esc(a.tier || 'Standard') + '
' + + 'Check-In Time: ' + esc(fmtInstant(new Date())) + + '
'; + } else if (res.error === 'already_checked_in') { + var extra = res; + area.innerHTML = '
' + + '
ALREADY CHECKED IN (DUPLICATE PASS)
' + + '

This ticket has already been used for entry.

' + + (extra.checkedInAt ? '
First Checked In: ' + esc(fmtInstant(extra.checkedInAt)) + ' by ' + esc(extra.checkedInByAlias || 'Door Staff') + '
' : '') + + '
'; + } else if (res.error === 'ticket_refunded') { + area.innerHTML = '
' + + '
ADMISSION DENIED — REFUNDED TICKET
' + + '

This booking was refunded. The pass is invalid for entry.

' + + '
'; + } else if (res.error === 'ticket_cancelled') { + area.innerHTML = '
' + + '
ADMISSION DENIED — CANCELLED PASS
' + + '

This ticket or booking was cancelled.

' + + '
'; + } else if (res.error === 'ticket_revoked' || res.error === 'ticket_reissued') { + area.innerHTML = '
' + + '
ADMISSION DENIED — REVOKED / REISSUED
' + + '

This ticket was revoked or replaced by a newly reissued pass.

' + + '
'; + } else { + area.innerHTML = '
' + + '
TICKET INVALID — ' + esc(res.error || 'VERIFICATION FAILED') + '
' + + '

' + esc(res.message || 'Pass could not be verified for this event.') + '

' + + '
'; + } + } + + function bindScannerControls() { + var camToggle = $('[data-cam-toggle]', el); + if (camToggle) { + camToggle.addEventListener('click', function () { + if (mediaStream) { + stopCamera(); + camToggle.textContent = 'Resume Camera'; + } else { + startCamera(); + camToggle.textContent = 'Pause Camera'; + } + }); + } + + var manualForm = $('form[data-manual-scan-form]', el); + if (manualForm) { + manualForm.addEventListener('submit', function (ev) { + ev.preventDefault(); + var code = manualForm.ticketCode.value.trim(); + var overrideReason = manualForm.overrideReason ? manualForm.overrideReason.value.trim() : null; + var method = overrideReason ? 'manual_override' : 'manual_lookup'; + handleScannedPayload(code, method, overrideReason); + manualForm.ticketCode.value = ''; + if (manualForm.overrideReason) manualForm.overrideReason.value = ''; + }); + } + } + + renderScanner(); + } + + function initStaffAccept(form) { + form.addEventListener('submit', function (ev) { + ev.preventDefault(); + var fMsg = $('[data-form-msg]', form); + var btn = $('button[type=submit]', form); + btn.disabled = true; + var token = form.token.value; + api('POST', '/api/staff/accept-invite', { token: token }).then(function (r) { + if (r.ok) { + fMsg.className = 'form-msg ok'; + fMsg.textContent = 'Invitation accepted! Redirecting to event workspace…'; + setTimeout(function () { + location.href = '/organiser/events/' + encodeURIComponent(r.eventId); + }, 800); + } else { + btn.disabled = false; + fMsg.className = 'form-msg error'; + fMsg.textContent = r.message || 'Could not accept invitation.'; + } + }); + }); + } + /* ===================================================================================== Wizard ===================================================================================== */ @@ -898,7 +1529,34 @@ } } + function initStaffAccept(form) { + var msg = $('[data-form-msg]', form); + var btn = $('button[type="submit"]', form); + form.addEventListener('submit', function (e) { + e.preventDefault(); + if (btn) btn.disabled = true; + if (msg) { msg.textContent = 'Accepting invitation…'; msg.className = 'form-msg info'; } + var token = (form.token && form.token.value) || ''; + api('POST', '/api/staff/accept-invite', { token: token }).then(function (r) { + if (btn) btn.disabled = false; + if (r.ok) { + if (msg) { msg.textContent = 'Invitation accepted! Redirecting to event workspace…'; msg.className = 'form-msg success'; } + setTimeout(function () { + window.location.href = '/organiser/events/' + encodeURIComponent(r.eventId); + }, 600); + return; + } + if (msg) { + msg.textContent = r.message || 'Could not accept invitation.'; + msg.className = 'form-msg error'; + } + }); + }); + } + var dash = $('[data-org-dashboard]'); if (dash) initDashboard(dash); var manage = $('[data-org-manage]'); if (manage) initManage(manage); var wz = $('[data-wizard]'); if (wz) initWizard(wz); + var scan = $('[data-org-scan]'); if (scan) initScan(scan); + var staff = $('[data-staff-accept-form]'); if (staff) initStaffAccept(staff); })(); diff --git a/public/js/site.js b/public/js/site.js index fb29012..cc85250 100644 --- a/public/js/site.js +++ b/public/js/site.js @@ -475,6 +475,21 @@ } else if (b.status === 'pending_payment') { html += '

We are waiting for SuperProfile to confirm your payment. If you already paid, send /claim to our Telegram bot with your SuperProfile order ID.

'; } else if (b.status === 'confirmed' || b.status === 'checked_in') { + if (b.tickets && b.tickets.length > 0) { + html += '
' + + b.tickets.map(function (tk, idx) { + return '
' + + '
' + + 'Pass #' + (idx + 1) + (tk.ticketTypeName ? ' · ' + esc(tk.ticketTypeName) : '') + '' + + '' + esc(tk.status === 'checked_in' ? 'Checked In' : tk.status) + '' + + '
' + + (tk.qrSvg ? '
' + tk.qrSvg + '
' : '') + + '
' + esc(tk.ticketCode) + '
' + + (tk.status === 'checked_in' ? '

✓ Admitted' + (tk.checkedInAt ? ' · ' + esc(fmt(tk.checkedInAt)) : '') + '

' : '

Present this QR code at the door for entry

') + + '
'; + }).join('') + + '
'; + } if (b.venue) { html += '
Venue Unlocked

' + esc(b.venue) + '

' + (b.mapsUrl ? '

Open in Google Maps

' : '') + '
'; } diff --git a/test/m6-organiser-workspace.test.js b/test/m6-organiser-workspace.test.js new file mode 100644 index 0000000..609f162 --- /dev/null +++ b/test/m6-organiser-workspace.test.js @@ -0,0 +1,624 @@ +// test/m6-organiser-workspace.test.js - Milestone 6: Organiser workspace, +// event-scoped staff roles & permissions, server-authoritative QR tickets, +// check-in validation, duplicate scan prevention, and attendance audit trails. +import { test, before, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { resetDb, startServer, stopServer, db, signedInClient, clearCalls } from './helpers.js'; +import { generateQrPayload, parseQrPayload, verifyTicketPayload } from '../lib/services/tickets.js'; + +before(async () => { + await resetDb(); + await startServer(); +}); + +after(stopServer); +beforeEach(() => { + clearCalls(); +}); + +function istLocal(days, hh = '19:00') { + const d = new Date(Date.now() + days * 864e5 + 5.5 * 3600e3).toISOString().slice(0, 10); + return `${d}T${hh}`; +} + +async function organiserClient() { + const c = await signedInClient(); + await db.query('UPDATE users SET organiser_enabled = true WHERE id = $1', [c.userId]); + return c; +} + +function validData(overrides = {}) { + return { + template: 'social', + category: 'meetup', + title: 'M6 Rooftop Networking & Mixer', + timezone: 'Asia/Kolkata', + startLocal: istLocal(10, '19:00'), + endLocal: istLocal(10, '23:00'), + multiDay: false, + venue: 'Sky Deck Lounge, 80 Feet Rd', + city: 'Bengaluru', + area: 'Koramangala', + privateVenue: false, + description: 'Exclusive mixer for founders and creators in Bangalore. 21+ only.', + tickets: [ + { id: 't_free', name: 'Free RSVP', kind: 'free', pricePaise: 0, quantity: 10, maxPerBooking: 1, validOn: 'all' }, + { id: 't_vip', name: 'VIP Pass', kind: 'free', pricePaise: 0, quantity: 5, maxPerBooking: 1, validOn: 'all' } + ], + features: {}, + ...overrides + }; +} + +async function publishEvent(c, data = validData()) { + const r = await c.post('/api/organiser/drafts', { template: 'social' }); + assert.equal(r.status, 201, r.text); + const draft = r.json.draft; + + const s = await c.req('PUT', `/api/organiser/drafts/${draft.id}`, { data, step: 'preview', version: draft.version }); + assert.equal(s.status, 200, s.text); + + const pub = await c.post(`/api/organiser/drafts/${draft.id}/publish`, { version: s.json.draft.version, confirm: true }); + assert.equal(pub.status, 200, pub.text); + + const event = await db.one('SELECT * FROM events WHERE id = $1', [pub.json.eventId]); + const tiers = (await db.query('SELECT * FROM ticket_types WHERE event_id = $1 ORDER BY sort_order', [event.id])).rows; + return { event, tiers, draftId: draft.id }; +} + +// ===================================================================================== +// 1. Organiser Overview, Readiness & Inventory +// ===================================================================================== + +test('organiser overview: metrics, readiness checklist, and capacity breakdown', async () => { + const org = await organiserClient(); + const { event } = await publishEvent(org); + + const overviewRes = await org.get(`/api/organiser/events/${event.id}/overview`); + assert.equal(overviewRes.status, 200, overviewRes.text); + const ov = overviewRes.json.event; + const metrics = overviewRes.json.metrics; + const readiness = overviewRes.json.readiness; + + assert.equal(ov.id, event.id); + assert.equal(ov.title, 'M6 Rooftop Networking & Mixer'); + assert.equal(metrics.capacity, 15); + assert.equal(metrics.confirmedSeats, 0); + assert.equal(metrics.checkedInTickets, 0); + assert.ok(Array.isArray(readiness.checklist)); + assert.ok(readiness.checklist.some((r) => r.key === 'tiers' && r.passed === true)); + assert.ok(readiness.checklist.some((r) => r.key === 'basics' && r.passed === true)); + + // Inventory endpoint + const invRes = await org.get(`/api/organiser/events/${event.id}/inventory`); + assert.equal(invRes.status, 200, invRes.text); + assert.equal(invRes.json.tiers.length, 2); + assert.equal(invRes.json.capacity, 15); + assert.equal(invRes.json.totalSold, 0); + assert.equal(invRes.json.totalRemaining, 15); +}); + +// ===================================================================================== +// 2. Cross-Organiser Access Denial +// ===================================================================================== + +test('cross-organiser access denial: Organiser B cannot inspect or manage Organiser A’s event', async () => { + const orgA = await organiserClient(); + const orgB = await organiserClient(); + const { event } = await publishEvent(orgA); + + const endpoints = [ + orgB.get(`/api/organiser/events/${event.id}/overview`), + orgB.get(`/api/organiser/events/${event.id}/inventory`), + orgB.get(`/api/organiser/events/${event.id}/bookings`), + orgB.get(`/api/organiser/events/${event.id}/staff`), + orgB.get(`/api/organiser/events/${event.id}/attendance`), + orgB.post(`/api/organiser/events/${event.id}/staff/invite`, { role: 'door_staff' }), + orgB.post(`/api/organiser/events/${event.id}/announcements`, { title: 'Unauthorized', message: 'Hacked update' }), + orgB.req('PUT', `/api/organiser/events/${event.id}`, { title: 'Hijacked Event' }), + orgB.post(`/api/organiser/events/${event.id}/cancel`, { reason: 'Malicious cancellation' }), + orgB.post(`/api/organiser/events/${event.id}/check-in`, { ticketCode: 'TK-FAKE-CODE' }) + ]; + + const results = await Promise.all(endpoints); + for (const r of results) { + // Outsiders receive 404 (zero leakage of private resources) or 403 + assert.ok( + [403, 404].includes(r.status), + `Expected 403 or 404 access denial, got ${r.status}: ${r.text}` + ); + } +}); + +// ===================================================================================== +// 3. Event Staff Roles & Scoped Permissions +// ===================================================================================== + +test('event staff management: invite, accept, role boundaries, and audit logging', async () => { + const org = await organiserClient(); + const { event } = await publishEvent(org); + + const staffUser = await signedInClient(); + const assistantUser = await signedInClient(); + + // Organiser creates door staff invite + const invRes = await org.post(`/api/organiser/events/${event.id}/staff/invite`, { + role: 'door_staff', + invitedAlias: 'Sammy Door' + }); + assert.equal(invRes.status, 201, invRes.text); + assert.ok(invRes.json.token); + assert.equal(invRes.json.role, 'door_staff'); + + // Verify audit log for invite creation + const invAudit = await db.one( + `SELECT * FROM audit_log WHERE action = 'event.staff_invite_create' AND target = $1`, + [invRes.json.inviteId] + ); + assert.ok(invAudit); + + // Staff accepts invite + const acceptRes = await staffUser.post('/api/staff/accept-invite', { + token: invRes.json.token + }); + assert.equal(acceptRes.status, 200, acceptRes.text); + assert.equal(acceptRes.json.eventId, event.id); + assert.equal(acceptRes.json.role, 'door_staff'); + + // Assistant invite & accept + const invAst = await org.post(`/api/organiser/events/${event.id}/staff/invite`, { + role: 'assistant', + invitedAlias: 'Alex Assistant' + }); + await assistantUser.post('/api/staff/accept-invite', { token: invAst.json.token }); + + // List staff via organiser API + const staffList = await org.get(`/api/organiser/events/${event.id}/staff`); + assert.equal(staffList.status, 200); + assert.equal(staffList.json.staff.length, 3); // Organiser (owner) + door_staff + assistant + + // Door Staff permission checks: + // - CAN check attendance + const attRes = await staffUser.get(`/api/organiser/events/${event.id}/attendance`); + assert.equal(attRes.status, 200, attRes.text); + + // - CANNOT edit event (member lacks permission -> 403) + const editRes = await staffUser.req('PUT', `/api/organiser/events/${event.id}`, { title: 'New Title' }); + assert.equal(editRes.status, 403); + + // - CANNOT cancel event + const cancelRes = await staffUser.post(`/api/organiser/events/${event.id}/cancel`, { reason: 'Staff cancel' }); + assert.equal(cancelRes.status, 403); + + // - CANNOT invite staff + const staffInviteRes = await staffUser.post(`/api/organiser/events/${event.id}/staff/invite`, { role: 'door_staff' }); + assert.equal(staffInviteRes.status, 403); + + // - CANNOT post announcements + const annRes = await staffUser.post(`/api/organiser/events/${event.id}/announcements`, { title: 'Notice', message: 'Unauthorized staff announcement' }); + assert.equal(annRes.status, 403); + + // Assistant permission checks: + // - CAN view overview + assert.equal((await assistantUser.get(`/api/organiser/events/${event.id}/overview`)).status, 200); + // - CANNOT check in + assert.equal((await assistantUser.post(`/api/organiser/events/${event.id}/check-in`, { ticketCode: 'TK-TEST' })).status, 403); +}); + +// ===================================================================================== +// 4. Cross-Event Staff Access Denial +// ===================================================================================== + +test('cross-event staff access denial: door staff for Event A cannot access Event B', async () => { + const org = await organiserClient(); + const { event: eventA } = await publishEvent(org); + const { event: eventB } = await publishEvent(org); + + const doorStaff = await signedInClient(); + const inv = await org.post(`/api/organiser/events/${eventA.id}/staff/invite`, { role: 'door_staff' }); + await doorStaff.post('/api/staff/accept-invite', { token: inv.json.token }); + + // Accessing Event B must fail with 403 or 404 + const bOverview = await doorStaff.get(`/api/organiser/events/${eventB.id}/overview`); + assert.ok([403, 404].includes(bOverview.status)); + + const bAttendance = await doorStaff.get(`/api/organiser/events/${eventB.id}/attendance`); + assert.ok([403, 404].includes(bAttendance.status)); + + const bCheckin = await doorStaff.post(`/api/organiser/events/${eventB.id}/check-in`, { ticketCode: 'TK-TEST' }); + assert.equal(bCheckin.status, 403); +}); + +// ===================================================================================== +// 5. Staff Revocation +// ===================================================================================== + +test('staff revocation: immediate termination of operational check-in access', async () => { + const org = await organiserClient(); + const { event } = await publishEvent(org); + + const doorStaff = await signedInClient(); + const inv = await org.post(`/api/organiser/events/${event.id}/staff/invite`, { role: 'door_staff' }); + await doorStaff.post('/api/staff/accept-invite', { token: inv.json.token }); + + // Confirmed working + assert.equal((await doorStaff.get(`/api/organiser/events/${event.id}/attendance`)).status, 200); + + // Organiser revokes staff member + const revRes = await org.del(`/api/organiser/events/${event.id}/staff/${doorStaff.userId}`); + assert.equal(revRes.status, 200, revRes.text); + + // Verify revocation audit log + const revAudit = await db.one( + `SELECT * FROM audit_log WHERE action = 'event.staff_revoke' AND target = $1`, + [revRes.json.staffId] + ); + assert.ok(revAudit); + + // Immediately blocked + const postRevAtt = await doorStaff.get(`/api/organiser/events/${event.id}/attendance`); + assert.ok([403, 404].includes(postRevAtt.status)); + + const postRevCheckin = await doorStaff.post(`/api/organiser/events/${event.id}/check-in`, { ticketCode: 'TK-TEST' }); + assert.equal(postRevCheckin.status, 403); +}); + +// ===================================================================================== +// 6. Cryptographic QR Ticket Generation & Validation +// ===================================================================================== + +test('QR tickets: server-authoritative cryptographic issuance and privacy preservation', async () => { + const org = await organiserClient(); + const { event, tiers } = await publishEvent(org); + + const guest = await signedInClient(); + const bookRes = await guest.post(`/api/events/${event.id}/book`, { ticketTypeId: tiers[0].id, quantity: 1 }); + assert.equal(bookRes.status, 200, bookRes.text); + assert.equal(bookRes.json.status, 'confirmed'); + + // Verify ticket issued in DB + const ticket = await db.one('SELECT * FROM tickets WHERE booking_id = $1', [bookRes.json.bookingId]); + assert.ok(ticket); + assert.equal(ticket.status, 'valid'); + assert.match(ticket.ticket_code, /^TK-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/); + + // Guest booking detail includes ticket and QR payload + const myBooking = await guest.get(`/api/bookings/${bookRes.json.bookingId}`); + assert.equal(myBooking.status, 200); + assert.equal(myBooking.json.booking.tickets.length, 1); + const myTk = myBooking.json.booking.tickets[0]; + + assert.equal(myTk.ticketCode, ticket.ticket_code); + assert.match(myTk.qrPayload, /^NLZ1:[A-Z0-9-]+:[a-f0-9]{64}$/); + assert.ok(myTk.qrSvg.includes(' { + const org = await organiserClient(); + const { event, tiers } = await publishEvent(org); + + const doorStaff = await signedInClient(); + const inv = await org.post(`/api/organiser/events/${event.id}/staff/invite`, { role: 'door_staff' }); + await doorStaff.post('/api/staff/accept-invite', { token: inv.json.token }); + + const guest = await signedInClient(); + const b = await guest.post(`/api/events/${event.id}/book`, { ticketTypeId: tiers[0].id, quantity: 1 }); + const bookingData = (await guest.get(`/api/bookings/${b.json.bookingId}`)).json.booking; + const qrPayload = bookingData.tickets[0].qrPayload; + + // 1. Initial valid check-in via Door Staff + const scanRes = await doorStaff.post(`/api/organiser/events/${event.id}/check-in`, { + qrPayload, + method: 'camera_qr' + }); + assert.equal(scanRes.status, 200, scanRes.text); + assert.equal(scanRes.json.ok, true); + assert.equal(scanRes.json.ticket.status, 'checked_in'); + assert.equal(scanRes.json.attendee.ticketType, 'Free RSVP'); + // Privacy invariant: check-in result shows minimal safe data + assert.equal(scanRes.json.attendee.phone, undefined); + + // Database verification + const updatedTicket = await db.one('SELECT * FROM tickets WHERE id = $1', [bookingData.tickets[0].id]); + assert.equal(updatedTicket.status, 'checked_in'); + assert.equal(updatedTicket.checked_in_by, doorStaff.userId); + assert.ok(updatedTicket.checked_in_at); + + const updatedBooking = await db.one('SELECT * FROM bookings WHERE id = $1', [b.json.bookingId]); + assert.equal(updatedBooking.status, 'checked_in'); + + // Verify scan audit log entry + const scanLog = await db.one( + `SELECT * FROM ticket_scans WHERE ticket_id = $1 AND scan_result = 'success'`, + [updatedTicket.id] + ); + assert.equal(scanLog.event_id, event.id); + assert.equal(scanLog.scanned_by, doorStaff.userId); + assert.equal(scanLog.scan_method, 'camera_qr'); + + // 2. Duplicate scan attempt: MUST return 409 Conflict with prior check-in details + const dupRes = await doorStaff.post(`/api/organiser/events/${event.id}/check-in`, { + qrPayload, + method: 'camera_qr' + }); + assert.equal(dupRes.status, 409, dupRes.text); + assert.equal(dupRes.json.error, 'already_checked_in'); + assert.ok(dupRes.json.firstCheckIn); + assert.ok(dupRes.json.firstCheckIn.checkedInAt); + + // Duplicate scan audit log recorded + const dupLog = await db.one( + `SELECT * FROM ticket_scans WHERE ticket_id = $1 AND scan_result = 'already_checked_in'`, + [updatedTicket.id] + ); + assert.ok(dupLog); +}); + +// ===================================================================================== +// 8. Forged QR & Cross-Event Rejections +// ===================================================================================== + +test('security checks: forged QR payload, invalid signature, and cross-event ticket rejection', async () => { + const org = await organiserClient(); + const { event: eventA, tiers } = await publishEvent(org); + const { event: eventB } = await publishEvent(org); + + const guest = await signedInClient(); + const b = await guest.post(`/api/events/${eventA.id}/book`, { ticketTypeId: tiers[0].id, quantity: 1 }); + const booking = (await guest.get(`/api/bookings/${b.json.bookingId}`)).json.booking; + const validPayload = booking.tickets[0].qrPayload; + const validCode = booking.tickets[0].ticketCode; + + // 1. Forged signature: caught by cryptographic verification + const parts = validPayload.split(':'); + const forgedPayload = `NLZ1:${parts[1]}:${'0'.repeat(64)}`; + const forgedRes = await org.post(`/api/organiser/events/${eventA.id}/check-in`, { qrPayload: forgedPayload }); + assert.equal(forgedRes.status, 400); + assert.equal(forgedRes.json.error, 'invalid_ticket'); + + // 2. Cross-event scan with QR payload: signature verification bound to eventB fails + const crossQrRes = await org.post(`/api/organiser/events/${eventB.id}/check-in`, { qrPayload: validPayload }); + assert.equal(crossQrRes.status, 400); + assert.equal(crossQrRes.json.error, 'invalid_ticket'); + + // 3. Cross-event manual code entry: detected against database event ownership + const crossCodeRes = await org.post(`/api/organiser/events/${eventB.id}/check-in`, { ticketCode: validCode }); + assert.equal(crossCodeRes.status, 400); + assert.equal(crossCodeRes.json.error, 'event_mismatch'); + + // 4. Non-existent ticket code + const notFoundRes = await org.post(`/api/organiser/events/${eventA.id}/check-in`, { ticketCode: 'TK-XXXX-YYYY' }); + assert.equal(notFoundRes.status, 404); + assert.equal(notFoundRes.json.error, 'ticket_not_found'); +}); + +// ===================================================================================== +// 9. Cancelled and Refunded Tickets Rejected at Check-In +// ===================================================================================== + +test('check-in rejection: cancelled and refunded tickets are rejected with clear errors', async () => { + const org = await organiserClient(); + const { event, tiers } = await publishEvent(org); + + const guest = await signedInClient(); + const b = await guest.post(`/api/events/${event.id}/book`, { ticketTypeId: tiers[0].id, quantity: 1 }); + const bId = b.json.bookingId; + const tk = await db.one('SELECT * FROM tickets WHERE booking_id = $1', [bId]); + + // Organiser cancels booking + const cancelRes = await org.post(`/api/organiser/bookings/${bId}/cancel`, { + reason: 'Attendee requested cancellation' + }); + assert.equal(cancelRes.status, 200, cancelRes.text); + + // Scan cancelled ticket: rejected with 409 + const scanCancel = await org.post(`/api/organiser/events/${event.id}/check-in`, { ticketCode: tk.ticket_code }); + assert.equal(scanCancel.status, 409); + assert.equal(scanCancel.json.error, 'ticket_cancelled'); + + // Test refunded ticket status: rejected with 409 + const guest2 = await signedInClient(); + const b2 = await guest2.post(`/api/events/${event.id}/book`, { ticketTypeId: tiers[0].id, quantity: 1 }); + const tk2 = await db.one('SELECT * FROM tickets WHERE booking_id = $1', [b2.json.bookingId]); + await db.query(`UPDATE bookings SET status = 'refunded' WHERE id = $1`, [b2.json.bookingId]); + + const scanRefund = await org.post(`/api/organiser/events/${event.id}/check-in`, { ticketCode: tk2.ticket_code }); + assert.equal(scanRefund.status, 409); + assert.equal(scanRefund.json.error, 'ticket_refunded'); +}); + +// ===================================================================================== +// 10. Ticket Reissue and Old Ticket Revocation +// ===================================================================================== + +test('ticket reissue: invalidates old ticket, creates new valid ticket with audit trail', async () => { + const org = await organiserClient(); + const { event, tiers } = await publishEvent(org); + + const guest = await signedInClient(); + const b = await guest.post(`/api/events/${event.id}/book`, { ticketTypeId: tiers[0].id, quantity: 1 }); + const originalTk = await db.one('SELECT * FROM tickets WHERE booking_id = $1', [b.json.bookingId]); + + // Reissue ticket via Organiser API + const reissueRes = await org.post(`/api/organiser/tickets/${originalTk.id}/reissue`, { + reason: 'Lost phone, reissuing to new pass' + }); + assert.equal(reissueRes.status, 200, reissueRes.text); + assert.equal(reissueRes.json.ok, true); + const newTk = reissueRes.json.newTicket; + + assert.notEqual(newTk.ticketCode, originalTk.ticket_code); + assert.equal(newTk.status, 'valid'); + + // Check database linkage + const oldTkInDb = await db.one('SELECT * FROM tickets WHERE id = $1', [originalTk.id]); + assert.equal(oldTkInDb.status, 'reissued'); + assert.equal(oldTkInDb.reissued_to_ticket_id, newTk.id); + + // Scanning old ticket MUST be rejected as reissued + const scanOld = await org.post(`/api/organiser/events/${event.id}/check-in`, { ticketCode: originalTk.ticket_code }); + assert.equal(scanOld.status, 409); + assert.equal(scanOld.json.error, 'ticket_reissued'); + + // Scanning new ticket succeeds + const scanNew = await org.post(`/api/organiser/events/${event.id}/check-in`, { ticketCode: newTk.ticketCode }); + assert.equal(scanNew.status, 200); + assert.equal(scanNew.json.ticket.status, 'checked_in'); +}); + +// ===================================================================================== +// 11. Concurrent Scan Attempts (Atomic Protection) +// ===================================================================================== + +test('concurrent scans: exactly one check-in succeeds under parallel scan requests', async () => { + const org = await organiserClient(); + const { event, tiers } = await publishEvent(org); + + const guest = await signedInClient(); + const b = await guest.post(`/api/events/${event.id}/book`, { ticketTypeId: tiers[0].id, quantity: 1 }); + const tk = await db.one('SELECT * FROM tickets WHERE booking_id = $1', [b.json.bookingId]); + const qrPayload = generateQrPayload(tk.ticket_code, event.id); + + // Send two parallel check-in requests + const [res1, res2] = await Promise.all([ + org.post(`/api/organiser/events/${event.id}/check-in`, { qrPayload }), + org.post(`/api/organiser/events/${event.id}/check-in`, { qrPayload }) + ]); + + const statuses = [res1.status, res2.status].sort(); + assert.deepEqual(statuses, [200, 409], 'Exactly one check-in succeeds, parallel scan rejected with 409'); +}); + +// ===================================================================================== +// 12. Manual Check-In Overrides +// ===================================================================================== + +test('manual check-in overrides: requires permission, reason, and records immutable audit', async () => { + const org = await organiserClient(); + const { event, tiers } = await publishEvent(org); + + const guest = await signedInClient(); + const b = await guest.post(`/api/events/${event.id}/book`, { ticketTypeId: tiers[0].id, quantity: 1 }); + const tk = await db.one('SELECT * FROM tickets WHERE booking_id = $1', [b.json.bookingId]); + + // Mark ticket as revoked + await db.query(`UPDATE tickets SET status = 'revoked' WHERE id = $1`, [tk.id]); + + // Manual override attempt WITHOUT reason fails + const noReason = await org.post(`/api/organiser/events/${event.id}/check-in`, { + ticketCode: tk.ticket_code, + method: 'manual_override', + overrideReason: ' ' + }); + assert.equal(noReason.status, 400); + + // Valid manual override by Organiser + const overrideRes = await org.post(`/api/organiser/events/${event.id}/check-in`, { + ticketCode: tk.ticket_code, + method: 'manual_override', + overrideReason: 'Guest present with government ID and valid SuperProfile receipt' + }); + assert.equal(overrideRes.status, 200, overrideRes.text); + assert.equal(overrideRes.json.ticket.status, 'checked_in'); + + // Verify scan log and audit entry + const scanLog = await db.one( + `SELECT * FROM ticket_scans WHERE ticket_id = $1 AND scan_method = 'manual_override'`, + [tk.id] + ); + assert.equal(scanLog.scan_result, 'manual_override'); + assert.match(scanLog.override_reason, /government ID/); + + const auditEntry = await db.one( + `SELECT * FROM audit_log WHERE action = 'ticket.manual_override' AND target = $1`, + [tk.id] + ); + assert.ok(auditEntry); +}); + +// ===================================================================================== +// 13. Event Announcements +// ===================================================================================== + +test('event announcements: posting, pinned updates, and attendee notifications queue', async () => { + const org = await organiserClient(); + const { event } = await publishEvent(org); + + const postRes = await org.post(`/api/organiser/events/${event.id}/announcements`, { + title: 'Rain Plan: Moved Undercover', + message: 'The mixer is moving to the covered terrace on Floor 4 due to light drizzle.', + isPinned: true, + notifyAttendees: true + }); + assert.equal(postRes.status, 201, postRes.text); + assert.equal(postRes.json.announcement.title, 'Rain Plan: Moved Undercover'); + assert.equal(postRes.json.announcement.isPinned, true); + + // List announcements + const listRes = await org.get(`/api/organiser/events/${event.id}/announcements`); + assert.equal(listRes.status, 200); + assert.equal(listRes.json.announcements.length, 1); + assert.equal(listRes.json.announcements[0].title, 'Rain Plan: Moved Undercover'); +}); + +// ===================================================================================== +// 14. Capacity Editing Invariant +// ===================================================================================== + +test('capacity constraint: organiser cannot lower capacity below confirmed attendee count', async () => { + const org = await organiserClient(); + const { event, tiers } = await publishEvent(org); + + // 2 guests book + const g1 = await signedInClient(); + const g2 = await signedInClient(); + await g1.post(`/api/events/${event.id}/book`, { ticketTypeId: tiers[0].id, quantity: 1 }); + await g2.post(`/api/events/${event.id}/book`, { ticketTypeId: tiers[0].id, quantity: 1 }); + + // Organiser attempts to lower capacity to 1 (below 2 confirmed) + const lowRes = await org.req('PUT', `/api/organiser/events/${event.id}`, { capacity: 1 }); + assert.equal(lowRes.status, 409); + assert.equal(lowRes.json.error, 'capacity_too_low'); + + // Updating capacity to 20 succeeds + const okRes = await org.req('PUT', `/api/organiser/events/${event.id}`, { capacity: 20 }); + assert.equal(okRes.status, 200); + assert.equal(okRes.json.event.capacity, 20); +}); + +// ===================================================================================== +// 15. Door Check-In Scanner Page UI & Fallback +// ===================================================================================== + +test('scanner page: renders mobile scanner container and access gating', async () => { + const org = await organiserClient(); + const { event } = await publishEvent(org); + + const scanPage = await org.get(`/organiser/events/${event.id}/scan`); + assert.equal(scanPage.status, 200); + assert.match(scanPage.text, /data-org-scan/); + assert.match(scanPage.text, /org-scan-wrap/); + assert.match(scanPage.text, /door check-in scanner/i); + assert.match(scanPage.text, /noindex/); + + // Unauthorised stranger access is gated + const stranger = await signedInClient(); + const gated = await stranger.get(`/organiser/events/${event.id}/scan`); + assert.equal(gated.status, 200); + assert.match(gated.text, /Organiser access is by invitation/); +}); diff --git a/test/migrations.test.js b/test/migrations.test.js index 24ca792..a21164a 100644 --- a/test/migrations.test.js +++ b/test/migrations.test.js @@ -20,13 +20,13 @@ test('migration files inventory: sequential numbering, correct naming, and sorte .filter((f) => /^\d+_.*\.sql$/.test(f)) .sort(); - // Exactly 16 migrations currently exist: 003 through 018 - assert.equal(files.length, 16, `expected 16 migration files, got ${files.length}: ${files.join(', ')}`); + // Exactly 17 migrations currently exist: 003 through 019 + assert.equal(files.length, 17, `expected 17 migration files, got ${files.length}: ${files.join(', ')}`); // Verify sequential ordering const expectedPrefixes = [ '003_', '004_', '005_', '006_', '007_', '008_', - '009_', '010_', '011_', '012_', '013_', '014_', '015_', '016_', '017_', '018_' + '009_', '010_', '011_', '012_', '013_', '014_', '015_', '016_', '017_', '018_', '019_' ]; for (let i = 0; i < expectedPrefixes.length; i++) { assert.ok( @@ -45,9 +45,9 @@ test('migration idempotency: re-running migrate() on an up-to-date database appl assert.deepEqual(thirdRun, [], 'expected zero migrations applied on third run'); }); -test('schema_migrations table records all 16 migrations in correct order', async () => { +test('schema_migrations table records all 17 migrations in correct order', async () => { const { rows } = await db.query('SELECT name, applied_at FROM schema_migrations ORDER BY name ASC'); - assert.equal(rows.length, 16); + assert.equal(rows.length, 17); const appliedNames = rows.map((r) => r.name); const filesOnDisk = fs.readdirSync(MIGRATIONS_DIR) @@ -74,9 +74,9 @@ test('advisory lock serializes concurrent migrate() calls', async () => { assert.deepEqual(res2, []); assert.deepEqual(res3, []); - // Total count in schema_migrations must remain exactly 16 + // Total count in schema_migrations must remain exactly 17 const countRes = await db.one('SELECT count(*)::int AS n FROM schema_migrations'); - assert.equal(countRes.n, 16); + assert.equal(countRes.n, 17); }); test('migration policy respects environment boundaries', () => { From 2cea1901e76fcc82541e2b76a3c7cef8104a7978 Mon Sep 17 00:00:00 2001 From: Satelink Network Date: Fri, 6 Nov 2026 15:12:16 +0530 Subject: [PATCH 2/3] fix(m6): clean up M6 foreign keys in seed-local teardown and preserve empty state locator in organiser overview --- public/js/organiser.js | 16 +++++++++++++++- scripts/seed-local.js | 6 ++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/public/js/organiser.js b/public/js/organiser.js index 278be06..272c4d6 100644 --- a/public/js/organiser.js +++ b/public/js/organiser.js @@ -198,6 +198,18 @@ '
' + m.pendingRequests + 'Pending Requests
' + ''; + if (!m.confirmedSeats && !m.pendingRequests) { + var url = e.path ? absolute(e.path) : ''; + h += '
' + + '' + + '

No bookings yet

' + + '

Share your event link to start receiving attendee bookings.

' + + '
' + + '' + + 'View Live Event' + + '
'; + } + h += '
' + '
' + '

Event Readiness Tracking

Operational checklist before doors open.

' + @@ -316,7 +328,9 @@ (declined > 0 ? '' : '') + '
'; - if (!filtered.length) { + if (!guests.length) { + h += '

No bookings yet

Share your event link to start receiving attendee bookings.

'; + } else if (!filtered.length) { h += '

No matching attendees

Try adjusting your search or filter.

'; } else { h += '
    ' + filtered.map(function (g) { diff --git a/scripts/seed-local.js b/scripts/seed-local.js index 28fbbfe..705005e 100644 --- a/scripts/seed-local.js +++ b/scripts/seed-local.js @@ -109,6 +109,12 @@ async function clearPersonaData(c) { await c.query(`DELETE FROM entitlements WHERE user_id IN (SELECT id FROM _pu)`); await c.query(`DELETE FROM external_orders WHERE event_id IN (SELECT id FROM _pe) OR matched_booking_id IN (SELECT id FROM _pb)`); await c.query(`DELETE FROM checkout_intents WHERE event_id IN (SELECT id FROM _pe) OR user_id IN (SELECT id FROM _pu)`); + await c.query(`DELETE FROM ticket_scans WHERE event_id IN (SELECT id FROM _pe) OR booking_id IN (SELECT id FROM _pb) OR scanned_by IN (SELECT id FROM _pu)`); + await c.query(`UPDATE tickets SET reissued_to_ticket_id = NULL WHERE event_id IN (SELECT id FROM _pe) OR booking_id IN (SELECT id FROM _pb) OR user_id IN (SELECT id FROM _pu)`); + await c.query(`DELETE FROM tickets WHERE event_id IN (SELECT id FROM _pe) OR booking_id IN (SELECT id FROM _pb) OR user_id IN (SELECT id FROM _pu)`); + await c.query(`DELETE FROM event_announcements WHERE event_id IN (SELECT id FROM _pe) OR sender_user_id IN (SELECT id FROM _pu)`); + await c.query(`DELETE FROM event_staff_invites WHERE event_id IN (SELECT id FROM _pe) OR invited_by IN (SELECT id FROM _pu) OR accepted_by IN (SELECT id FROM _pu)`); + await c.query(`DELETE FROM event_team WHERE event_id IN (SELECT id FROM _pe) OR user_id IN (SELECT id FROM _pu)`); await c.query(`UPDATE bookings SET payment_id = NULL WHERE id IN (SELECT id FROM _pb)`); await c.query(`DELETE FROM payments WHERE user_id IN (SELECT id FROM _pu) OR booking_id IN (SELECT id FROM _pb)`); await c.query(`UPDATE bookings SET decided_by = NULL WHERE decided_by IN (SELECT id FROM _pu) AND id NOT IN (SELECT id FROM _pb)`); From 66ad0269831788147ddb3a73e4c28e9d1d045286 Mon Sep 17 00:00:00 2001 From: Satelink Network Date: Fri, 6 Nov 2026 15:13:44 +0530 Subject: [PATCH 3/3] docs(m6): reference PR #36 in milestone tracker --- docs/PROJECT_STATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index b813f95..196a535 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -175,7 +175,7 @@ Per owner approval on 2026-11-03, each of the 19 fully merged remote branches wa | M4.3.3 Final Provider Verification Integrity | `feature/m4-3-3-provider-verification` / #33 | **Completed** (Eliminated synthetic currency defaults and configured merchant status check fallbacks; fail-closed currency and merchant identity gating; documented CBS operational limitation and operator attestation labeling; 37/37 UPI tests pass, 470/470 unit/integration tests pass, `ENABLE_LIVE_PAYMENTS=false` preserved) | | M4.3.4 Provider Contract Decision & M4 Closure | `feature/m4-3-4-contract-decision` | **Formally Closed (Live Payments Disabled)** (Adopted Outcome B: PhonePe v1 classified sandbox-only due to lack of machine-verifiable currency in payload; full 22-vector regression suite added; 38/38 UPI tests pass, 471/471 unit/integration tests pass, 157/157 Playwright E2E pass, 140/140 lint clean; `ENABLE_LIVE_PAYMENTS=false` preserved; all M4 exit criteria verified and passed) | | M5 Telegram & Operational Workflows | `feature/m5-telegram-operational-workflows` / #35 | **Completed** (Telegram identity & account linking, event private supergroup isolation without shared global group, operational recovery on /start, transactional outbox notifications with quiet hours, /report moderation workflow with emergency escalation; 16/16 tests in test/telegram-workflows.test.js, 487/487 unit/integration tests pass, ENABLE_LIVE_PAYMENTS=false preserved) | -| M6 Organiser Workspace, Event Staff & Secure QR Check-in | `feature/m6-organiser-workspace` | **Completed** (Full organiser workspace with event dashboard, readiness checklist, ticket inventory, guest management, and operational announcements; event-scoped staff roles with organiser, event_manager, door_staff, guest_list_manager, assistant; server-authoritative HMAC-SHA256 signed QR tickets bound to events without leaking attendee PII; mobile-first door check-in scanner with camera QR detection and manual code lookup; atomic duplicate scan and replay prevention; ticket reissue and revocation lifecycle; 15/15 tests in test/m6-organiser-workspace.test.js, 642/642 tests passing across entire regression suite, 145/145 lint clean; `ENABLE_LIVE_PAYMENTS=false` preserved) | +| M6 Organiser Workspace, Event Staff & Secure QR Check-in | `feature/m6-organiser-workspace` / #36 | **Completed** (Full organiser workspace with event dashboard, readiness checklist, ticket inventory, guest management, and operational announcements; event-scoped staff roles with organiser, event_manager, door_staff, guest_list_manager, assistant; server-authoritative HMAC-SHA256 signed QR tickets bound to events without leaking attendee PII; mobile-first door check-in scanner with camera QR detection and manual code lookup; atomic duplicate scan and replay prevention; ticket reissue and revocation lifecycle; 15/15 tests in test/m6-organiser-workspace.test.js, 642/642 tests passing across entire regression suite, 145/145 lint clean; `ENABLE_LIVE_PAYMENTS=false` preserved) | | M7 Attendee Discovery & Experience Hub | `feature/m7-attendee-experience` | **Recommended Next Milestone** (Personalised attendee discovery, enhanced event cards, location-aware feed, instant booking pass display, trust badges) | ## 11. Where the detail lives