diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 6cf852a..6594133 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -96,10 +96,10 @@ Rule: only the canonical workspace switches to `launch-mvp`. Milestone branches | 1 | Approve merging PR #17 (`launch-mvp` → `main`) and the Production release | open | | 2 | Production env vars. Missing as of P0 (2026-11-02, names only): `SESSION_SECRET`, `CRON_SECRET`, `TELEGRAM_BOT_USERNAME`, `TELEGRAM_WEBHOOK_SECRET`, `TG_CHAT_ID_EVENTS`, `ADMIN_BOOTSTRAP_TELEGRAM_ID`, `PUBLIC_SITE_URL`, `SUPPORT_EMAIL`, `LEGAL_ENTITY_NAME`, `GRIEVANCE_OFFICER_NAME`, `GRIEVANCE_EMAIL` (`ENABLE_LIVE_PAYMENTS` unset = off) | open, not re-checked in M1 | | 3 | Reconcile which DB Production points at (§4) | open (owner will supply confirmed project ID & endpoint ID from Vercel → Storage; dynamic block list active) | -| 4 | Domain/DNS for `nightlifez.com` (Cloudflare, Vercel) | open (M8 prepares the steps) | +| 4 | Domain/DNS for `nightlifez.com` (Cloudflare, Vercel) | open (M8 prepares the steps in docs/launch/DNS_AND_EMAIL_ROUTING.md) | | 5 | BotFather `/setdomain` and profile; Telegram groups and channel | open (M5 prepares the steps) | | 6 | Vercel Protection Bypass for Automation | open (M9) | -| 7 | Email routing `contact@nightlifez.com` | open (M8) | +| 7 | Email routing `contact@nightlifez.com` | open (M8 prepares the steps in docs/launch/DNS_AND_EMAIL_ROUTING.md) | | 8 | Legal fields (entity, grievance officer) | open | | 9 | Approve the branch/worktree deletions proposed in §9 | **completed / approved** (19 fully merged remote branches archived to `archive/` tags and deleted from origin; `.claude/worktrees/launch-mvp` preserved as is) | | 10 | Decide on the unpushed Phase 5.6/5.7 reports (local commit `bcb4989`): push and archive, or drop | **completed / approved** (`bcb4989` pushed to `origin/fix/preview-recovery-audit`; reports imported into `docs/history/`) | @@ -177,12 +177,15 @@ Per owner approval on 2026-11-03, each of the 19 fully merged remote branches wa | 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` / #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` / #37 | **Completed** (Personalised attendee discovery feed with transparent, explainable recommendation scoring; location and privacy safeguards with manual city default, explicit device GPS distance bands without venue trilateration, and privacy-preserving mystery venue displays; mobile-first event cards with verified host trust badge, 21+ badge, cancellation and refund policies; multi-facet filtering for category, date range [today, tomorrow, weekend, week, month], price [all, free, paid], and sorting [recommended, trending, date_asc, price_low]; attendee experience hub on /me with booking tabs [all, upcoming, past], saved events / bookmarks lifecycle, and host announcements feed; digital QR pass integration with M6 ticket verification and door check-in status display; 12/12 tests in test/m7-attendee-experience.test.js, 8/8 in test/migrations.test.js, 654/654 tests passing across full regression suite, 16/16 Playwright E2E passing, 149/149 lint clean; `ENABLE_LIVE_PAYMENTS=false` preserved) | -| M8 Real-time Notifications & Operational Comms | `feature/m8-realtime-notifications` | **Recommended Next Milestone** | +| M8 Real-time Notifications & Operational Comms | `feature/m8-realtime-notifications` / #38 | **Completed** (Attendee self-cancellation with transactional seat hold release, ticket invalidation, invite revocation & confirmation notice; organiser operational announcements broadcast with quiet hours deferral; event staff scoping invariant fix for door_staff discovery; host approval workflow with automated notification; admin notifications outbox API & retry mechanism; interactive leads view; operational runbooks in `docs/launch/DNS_AND_EMAIL_ROUTING.md` resolving blockers #4 and #7; `ENABLE_LIVE_PAYMENTS=false` preserved) | +| M9 Testing & Security Review | `feature/m9-testing-security-review` | **Recommended Next Milestone** | ## 11. Where the detail lives | Topic | Doc | |---|---| +| Real-time notifications & operational comms architecture | [arch/REALTIME_NOTIFICATIONS_AND_COMMS.md](arch/REALTIME_NOTIFICATIONS_AND_COMMS.md) | +| DNS & email routing runbook (Blockers #4 & #7) | [launch/DNS_AND_EMAIL_ROUTING.md](launch/DNS_AND_EMAIL_ROUTING.md) | | Attendee discovery & experience hub architecture | [arch/ATTENDEE_DISCOVERY_AND_EXPERIENCE.md](arch/ATTENDEE_DISCOVERY_AND_EXPERIENCE.md) | | 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) | diff --git a/docs/arch/REALTIME_NOTIFICATIONS_AND_COMMS.md b/docs/arch/REALTIME_NOTIFICATIONS_AND_COMMS.md new file mode 100644 index 0000000..9fe6b92 --- /dev/null +++ b/docs/arch/REALTIME_NOTIFICATIONS_AND_COMMS.md @@ -0,0 +1,131 @@ +# Real-Time Notifications & Operational Comms Architecture +**Milestone:** M8 Real-time Notifications & Operational Comms +**Target Branch:** `feature/m8-realtime-notifications` +**Status:** Implemented & Verified + +--- + +## 1. Overview & Objectives + +Milestone M8 establishes reliable, real-time operational communications, closing critical communication and lifecycle gaps across all seven platform roles (Attendee, Host, Organiser, Venue/Business Owner, Event Staff, Service Provider, Admin/Support): + +1. **Attendee Lifecycle & Cancellation:** + - Server-authoritative attendee self-cancellation endpoint (`POST /api/me/bookings/:id/cancel` and `POST /api/bookings/:id/cancel`). + - Transactional seat hold release, ticket invalidation, and single-use Telegram invite revocation. + - Immediate cancellation confirmation delivery to Telegram. + - Clear visual cancellation state with warning banner and automatic QR pass suppression. + +2. **Organiser & Staff Operational Comms:** + - Host announcement broadcasts (`POST /api/organiser/events/:id/announcements`) delivered to confirmed attendees with custom title, body, and direct booking pass deep link. + - Event staff scoping invariant fix: event staff (including `door_staff` and `assistant`) discover and access assigned events via `event.view_inventory` without exposing sensitive attendee guest lists (`event.view_guests`). + - Host approval workflow: when admin approves host via `setOrganiser`, automated `host_approved` notification is enqueued with direct link to the organiser workspace. + +3. **Quiet Hours & Notification Delivery Engine:** + - Per-user quiet hours configuration in `Asia/Kolkata` timezone (e.g., 23:00 to 08:00). + - Quiet hours deferral mechanism for non-urgent notifications (`event_announcement`, non-emergency `event_update`, `event_reminder`, `membership_renewed`, `expiry_reminder`). + - Urgent notifications (payment receipts, immediate booking cancellations, security alerts) bypass quiet hours to guarantee critical safety communication. + +4. **Admin Outbox & Comms Management:** + - Central outbox inspection API (`GET /api/admin/notifications`) with status filtering (`all`, `pending`, `failed`, `sent`) and 24h KPI statistics. + - Manual retry mechanism (`POST /api/admin/notifications/:id/retry`) allowing support operators to re-attempt deliveries with automatic status updates. + - Interactive lead review for host, venue, and partner applications with direct communication links (Telegram `t.me`, `mailto:`, `tel:`). + +--- + +## 2. Notification Pipeline & State Machine + +```mermaid +flowchart TD + Trigger[Operational Event / Broadcast / Cancellation] --> Enqueue[Enqueue in DB Transaction: notifications table] + Enqueue --> OutboxPending[Status: pending] + + OutboxPending --> DeliveryCheck{Deliver Called / Cron Due} + DeliveryCheck --> QuietCheck{Non-Urgent & In User Quiet Hours?} + + QuietCheck -- Yes --> Defer[Update next_attempt_at to Quiet Hours End in Asia/Kolkata] + Defer --> OutboxPending + + QuietCheck -- No --> Attempt[Invoke Delivery Handler] + Attempt --> TelegramAPI{Telegram Bot sendMessage} + + TelegramAPI -- Success 200 --> Sent[Status: sent, sent_at: now, error: null] + TelegramAPI -- Error / Timeout --> FailCheck{attempts < 6} + + FailCheck -- Yes --> RetryDelay[Status: failed, Exponential Backoff 2^n mins] + RetryDelay --> OutboxPending + + FailCheck -- No --> DeadLetter[Status: failed, Awaiting Admin Manual Retry] + DeadLetter --> AdminRetry[Admin Outbox UI: Retry Action] + AdminRetry --> OutboxPending +``` + +--- + +## 3. Attendee Self-Cancellation Specification + +### 3.1 Invariants +- An attendee can only cancel their own booking (`user_id = $auth_user_id`). +- Checked-in bookings cannot be cancelled (`409 already_checked_in`). +- Already cancelled bookings reject duplicate requests (`409 already_cancelled`). +- Cancelled bookings immediately revoke: + - Any valid tickets (`status = 'cancelled'`, `revoked_by = user_id`, `revoked_at = now()`). + - Any unused private Telegram group invite links (`revoked_at = now()`). +- Seat capacity is automatically recovered because `SEAT_HOLDING_SQL` excludes `status = 'cancelled'`. +- Cancellation is recorded in `audit_log` with action `booking.attendee_cancel`. + +### 3.2 Cancellation Sequence + +```mermaid +sequenceDiagram + autonumber + actor Attendee + participant Client as Frontend (Digital Pass /me) + participant Router as API Router + participant Svc as Bookings Service + participant DB as Postgres (ACID Transaction) + participant Outbox as Notifications Outbox + participant TG as Telegram Bot API + + Attendee->>Client: Click "Cancel Reservation" + Confirm + Client->>Router: POST /api/me/bookings/:id/cancel + Router->>Svc: cancelUserBooking(userId, bookingId) + Svc->>DB: BEGIN TX + Svc->>DB: SELECT * FROM bookings WHERE id = $id AND user_id = $uid FOR UPDATE + Note over DB: Verify ownership & non-checked-in status + Svc->>DB: UPDATE bookings SET status='cancelled', cancelled_at=now() + Svc->>DB: UPDATE tickets SET status='cancelled', revoked_at=now() WHERE status='valid' + Svc->>DB: UPDATE invite_links SET revoked_at=now() WHERE revoked_at IS NULL + Svc->>Outbox: INSERT INTO notifications (kind='booking_cancelled_by_user') + Svc->>DB: INSERT INTO audit_log (action='booking.attendee_cancel') + Svc->>DB: COMMIT TX + Svc-->>Router: { ok: true, status: 'cancelled' } + Router-->>Client: 200 OK + Client->>Client: Re-render Pass: Warning Banner & Suppress QR + Note over Outbox,TG: Cron / Deliver pushes cancellation notice to Attendee Telegram +``` + +--- + +## 4. Event Staff Scoping Architecture + +To enforce the principle of least privilege while enabling operational door operations: + +| Role | Scope | Permissions Granted | Can View Guest PII? | Can Scan Tickets? | Can See Assigned Event in Workspace? | +|:---|:---|:---|:---|:---|:---| +| `organiser` / Owner | Org / Event | All event actions | Yes | Yes | Yes | +| `event_manager` | Event | All event actions except ownership transfer | Yes | Yes | Yes | +| `guest_list_manager` | Event | `event.view_guests`, `event.decide_bookings`, `event.check_in`, `event.view_inventory` | Yes | Yes | Yes | +| `door_staff` | Event | `event.check_in`, `event.view_inventory` | **No** (Strictly blocked) | **Yes** | **Yes** | +| `assistant` | Event | `event.view_guests`, `event.view_inventory` | Yes | No | Yes | + +### Key Resolution: +`listOrganiserEvents` and `getEventOverview` now evaluate `event.view_inventory` rather than `event.view_guests`. This guarantees door staff can access their workspace and door scanner (`/organiser/events/:id/scan`), while `listOrganiserBookings` continues to require `event.view_guests`, preventing unauthorized exposure of attendee personal data. + +--- + +## 5. Security & Verification Summary + +1. **Fail-Closed Security:** All endpoints require authenticated sessions and strict permission checks. +2. **Cryptographic Integrity:** Ticket QR payloads remain HMAC-SHA256 signed with zero personal data leakage. +3. **Audit Trail:** Every administrative and attendee state transition is immutably logged in `audit_log`. +4. **Zero Live Payments:** `ENABLE_LIVE_PAYMENTS=false` remains strictly enforced across all environments. diff --git a/docs/launch/DNS_AND_EMAIL_ROUTING.md b/docs/launch/DNS_AND_EMAIL_ROUTING.md new file mode 100644 index 0000000..9baa251 --- /dev/null +++ b/docs/launch/DNS_AND_EMAIL_ROUTING.md @@ -0,0 +1,144 @@ +# NIGHTLIFEZ Launch Operations: DNS & Email Routing Runbook +**Target Milestone:** M8 Real-time Notifications & Operational Comms +**Scope:** Resolves Blocker #4 (Domain `nightlifez.com` DNS, CNAME flattening, SSL Full Strict) and Blocker #7 (Email routing `contact@nightlifez.com`). +**Status:** Operationally Prepared (Owner Execution Steps Gated for Production Window) + +--- + +## 1. Executive Summary + +This runbook provides the exact, production-ready specifications and manual execution checklist required to configure the apex domain `nightlifez.com`, subdomains, Cloudflare SSL/TLS encryption, and inbound email routing for `contact@nightlifez.com`. + +All steps respect project boundaries: +- `ENABLE_LIVE_PAYMENTS=false` remains strictly enforced. +- Cloudflare credentials, DNS nameservers, and Production records are not modified automatically. +- This runbook serves as the authoritative, copy-paste ready blueprint for the domain administrator. + +--- + +## 2. Blocker #4: Domain `nightlifez.com` & Cloudflare DNS Configuration + +### 2.1 Nameserver Delegation +Configure registrar (Namecheap / GoDaddy / Google Domains) nameservers to point to Cloudflare: +- Primary NS: `aria.ns.cloudflare.com` (or allocated Cloudflare account NS) +- Secondary NS: `dane.ns.cloudflare.com` (or allocated Cloudflare account NS) + +### 2.2 DNS Zone Records + +| Type | Name | Content / Target | Proxy Status | TTL | Purpose | +|:---|:---|:---|:---|:---|:---| +| **CNAME** | `@` (apex) | `cname.vercel-dns.com.` | Proxied (Orange Cloud) | Auto | Apex CNAME Flattening to Vercel production edge | +| **CNAME** | `www` | `nightlifez.com` | Proxied (Orange Cloud) | Auto | Subdomain redirect / canonical host | +| **CNAME** | `preview` | `cname.vercel-dns.com.` | DNS Only (Grey Cloud) | Auto | Dedicated Vercel preview staging endpoint | +| **A** | `_vercel` | `76.76.21.21` | DNS Only (Grey Cloud) | Auto | Vercel domain verification record (if requested) | + +### 2.3 Cloudflare SSL/TLS Settings +1. **Encryption Mode:** `Full (Strict)` + - Requires valid TLS certificate on origin (Vercel automatic Let's Encrypt). + - Mitigates man-in-the-middle attacks between Cloudflare Edge and Vercel Origin. +2. **Edge Certificates:** + - **Always Use HTTPS:** Enabled (`ON`). Automatically redirects `http://*` to `https://*` with 301. + - **HTTP Strict Transport Security (HSTS):** Enabled (`ON`). + - Max-Age: `31536000` (1 year). + - Include Subdomains: `ON`. + - Preload: `OFF` (enable only after 30 days of stable production traffic). + - **Minimum TLS Version:** `TLS 1.2` or `TLS 1.3`. + - **Opportunistic Encryption:** Enabled (`ON`). + - **TLS 1.3:** Enabled (`ON`). +3. **Page Rules / Redirect Rules (Apex Canonicalization):** + - URL Redirect Rule: `http*://www.nightlifez.com/*` -> `https://nightlifez.com/$1` (301 Permanent Redirect). + +--- + +## 3. Blocker #7: Inbound Email Routing (`contact@nightlifez.com`) + +To receive platform inquiries, host applications, partnership requests, and safety grievance communications without running a dedicated mail server, Cloudflare Email Routing is utilized to forward inbound mail to the designated administrator inbox. + +### 3.1 Cloudflare Email Routing MX Records + +| Type | Name | Priority | Content / Mail Server | TTL | +|:---|:---|:---|:---|:---| +| **MX** | `@` | `13` | `route1.mx.cloudflare.net` | Auto | +| **MX** | `@` | `57` | `route2.mx.cloudflare.net` | Auto | +| **MX** | `@` | `89` | `route3.mx.cloudflare.net` | Auto | + +### 3.2 Sender Policy Framework (SPF) +Prevents email spoofing on the `nightlifez.com` domain. + +| Type | Name | Content | TTL | +|:---|:---|:---|:---| +| **TXT** | `@` | `v=spf1 include:_spf.mx.cloudflare.net ~all` | Auto | + +*Note:* If transactional sending (e.g., Postmark or Resend) is added in a subsequent phase, append `include:spf.postmarkapp.com` before `~all`. + +### 3.3 DomainKeys Identified Mail (DKIM) +Provided automatically by Cloudflare Email Routing when verification is active. + +| Type | Name | Content | TTL | +|:---|:---|:---|:---| +| **TXT** | `*._domainkey` | (As generated by Cloudflare Email Routing setup wizard) | Auto | + +### 3.4 Domain-based Message Authentication (DMARC) +Enforces rejection of fraudulent mail impersonating `nightlifez.com` and routes aggregate failure reports. + +| Type | Name | Content | TTL | +|:---|:---|:---|:---| +| **TXT** | `_dmarc` | `v=DMARC1; p=reject; sp=reject; pct=100; rua=mailto:contact@nightlifez.com` | Auto | + +### 3.5 Routing Rules +In Cloudflare Dashboard -> `nightlifez.com` -> `Email Routing` -> `Routing Rules`: + +| Custom Address | Action | Destination Address | Status | +|:---|:---|:---|:---| +| `contact@nightlifez.com` | Send to | `@gmail.com` | Active | +| `safety@nightlifez.com` | Send to | `@gmail.com` | Active | +| `grievance@nightlifez.com` | Send to | `@gmail.com` | Active | +| `catch-all` | Drop | — | Disabled (Drop unexpected addresses) | + +--- + +## 4. Verification & Health Check Procedure + +Run the following terminal commands to verify that DNS and Email records have propagated correctly: + +### Check Apex Resolution +```bash +dig +short A nightlifez.com +# Should return Cloudflare Anycast IPs (e.g. 104.21.x.x, 172.67.x.x) +``` + +### Check CNAME Flattening & TLS +```bash +curl -svo /dev/null https://nightlifez.com +# Should complete TLS 1.3 handshake with HTTP 200 / 308 (Vercel) +``` + +### Check Mail Exchanger (MX) Records +```bash +dig +short MX nightlifez.com +# Expected: +# 13 route1.mx.cloudflare.net. +# 57 route2.mx.cloudflare.net. +# 89 route3.mx.cloudflare.net. +``` + +### Check SPF Record +```bash +dig +short TXT nightlifez.com | grep "v=spf1" +# Expected: "v=spf1 include:_spf.mx.cloudflare.net ~all" +``` + +### Check DMARC Policy +```bash +dig +short TXT _dmarc.nightlifez.com +# Expected: "v=DMARC1; p=reject; sp=reject; pct=100; rua=mailto:contact@nightlifez.com" +``` + +--- + +## 5. Rollback & Emergency Plan + +If Cloudflare DNS proxying causes origin routing loops or header drops: +1. Switch apex `@` and `www` records from **Proxied** (Orange Cloud) to **DNS Only** (Grey Cloud) temporarily. +2. Verify direct resolution to Vercel edge. +3. Check Cloudflare SSL/TLS mode is set to **Full (Strict)**, not **Flexible** (which causes 301 redirection loops with HTTPS-enforcing origins like Vercel). diff --git a/lib/admin-roles.js b/lib/admin-roles.js index eafb8bc..3dfcb3c 100644 --- a/lib/admin-roles.js +++ b/lib/admin-roles.js @@ -16,7 +16,8 @@ export const R = { INSIGHTS: ['super_admin', 'finance', 'event_manager'], DISCOVERY: ['super_admin', 'event_manager', 'moderator'], DISCOVERY_WRITE: ['super_admin', 'event_manager', 'moderator'], - FINANCE_VIEW: ['super_admin', 'finance'] + FINANCE_VIEW: ['super_admin', 'finance'], + NOTIFICATIONS: ['super_admin', 'support', 'event_manager'] }; // [tab, label, roles allowed to load it] diff --git a/lib/handlers/admin.js b/lib/handlers/admin.js index 6728a1f..a9839b4 100644 --- a/lib/handlers/admin.js +++ b/lib/handlers/admin.js @@ -7,7 +7,7 @@ import { rateLimit } from '../ratelimit.js'; import { parse, z } from '../validate.js'; import { audit } from '../audit.js'; import { chatIdFromEnv, configStatus, botToken, botUsername, siteUrl, env } from '../config.js'; -import { enqueue, deliverMany } from '../services/notifications.js'; +import { enqueue, deliverMany, deliver } from '../services/notifications.js'; import { refundPayment as refundSvc } from '../services/refunds.js'; import { listOrders as listOrdersSvc, linkOrderToUser } from '../services/orders.js'; import { isSuperprofileUrl } from '../payments/index.js'; @@ -459,6 +459,83 @@ export async function updateReport(ctx) { sendJson(ctx.res, 200, { ok: true }); } +// ---------- notifications outbox & alerts ---------- + +export async function listNotifications(ctx) { + await guard(ctx, R.NOTIFICATIONS); + const status = ctx.query.get('status'); + const kind = ctx.query.get('kind'); + const p = page(ctx.query); + + const params = []; + const conds = []; + + if (status && ['pending', 'sent', 'failed'].includes(status)) { + params.push(status); + conds.push(`n.status = $${params.length}`); + } + if (kind && typeof kind === 'string' && kind.trim()) { + params.push(kind.trim()); + conds.push(`n.kind = $${params.length}`); + } + + const where = conds.length ? `WHERE ${conds.join(' AND ')}` : ''; + + params.push(p.limit + 1); + params.push(p.offset); + + const [items, stats] = await Promise.all([ + db.query( + `SELECT n.id, n.kind, n.status, n.attempts, n.last_error, n.next_attempt_at, n.sent_at, n.created_at, + n.user_id, n.booking_id, n.event_id, n.dedupe_key, + u.alias AS user_alias, u.telegram_user_id, + e.title AS event_title + FROM notifications n + LEFT JOIN users u ON u.id = n.user_id + LEFT JOIN events e ON e.id = n.event_id + ${where} + ORDER BY n.created_at DESC + LIMIT $${params.length - 1} OFFSET $${params.length}`, + params + ), + db.query(`SELECT + COUNT(*) FILTER (WHERE status = 'pending')::int AS pending_count, + COUNT(*) FILTER (WHERE status = 'failed')::int AS failed_count, + COUNT(*) FILTER (WHERE status = 'sent' AND sent_at > now() - interval '24 hours')::int AS sent_last_24h, + COUNT(*)::int AS total_count + FROM notifications`) + ]); + + sendJson(ctx.res, 200, { + ok: true, + stats: stats.rows[0] || { pending_count: 0, failed_count: 0, sent_last_24h: 0, total_count: 0 }, + ...pageInfo(items.rows, p, 'notifications') + }); +} + +export async function retryNotification(ctx) { + const a = await guard(ctx, R.NOTIFICATIONS, { write: true }); + const id = uuidParam(ctx.params.id); + + const exists = await db.one(`SELECT id, status, attempts FROM notifications WHERE id = $1`, [id]); + if (!exists) throw new HttpError(404, 'not_found', 'Notification not found'); + + await db.query( + `UPDATE notifications + SET status = 'pending', + attempts = 0, + next_attempt_at = now(), + last_error = NULL + WHERE id = $1`, + [id] + ); + + const outcome = await deliver(id); + await audit(a.actor, 'notification.retry', id, { previousStatus: exists.status, outcome }); + + sendJson(ctx.res, 200, { ok: true, id, outcome }); +} + // ---------- media ---------- const MediaBody = z.object({ @@ -603,7 +680,14 @@ export async function setOrganiser(ctx) { await db.tx(async (c) => { const r = (await c.query('UPDATE users SET organiser_enabled = $2 WHERE telegram_user_id = $1 RETURNING id, alias', [b.telegram_user_id, b.enabled])).rows[0]; if (!r) throw new HttpError(404, 'user_not_found', 'That person needs to sign in to the site once first'); - if (b.enabled) await ensurePersonalOrg(r, c); + if (b.enabled) { + await ensurePersonalOrg(r, c); + await enqueue(c, { + kind: 'host_approved', + userId: r.id, + dedupeKey: `host_approved:${r.id}:${Date.now()}` + }); + } await setOrganiseCapability(r.id, b.enabled, c); await audit(a.actor, 'organiser.set', r.id, { enabled: b.enabled }, c); }); diff --git a/lib/handlers/bookings.js b/lib/handlers/bookings.js index 2a4903a..fe8e41e 100644 --- a/lib/handlers/bookings.js +++ b/lib/handlers/bookings.js @@ -1,7 +1,7 @@ // lib/handlers/bookings.js - Member booking reads (session). -import { sendJson, HttpError } from '../http.js'; +import { sendJson, HttpError, readRawBody } from '../http.js'; import { requireUser } from '../session.js'; -import { listUserBookings, getUserBooking, listUserAnnouncements } from '../services/bookings.js'; +import { listUserBookings, getUserBooking, listUserAnnouncements, cancelUserBooking } from '../services/bookings.js'; export async function myBookings({ req, res }) { const user = await requireUser(req); @@ -20,3 +20,22 @@ export async function getBooking({ req, res, params }) { if (!booking) throw new HttpError(404, 'not_found', 'Booking not found'); sendJson(res, 200, { ok: true, booking }); } + +export async function cancelMyBooking({ req, res, params }) { + const user = await requireUser(req); + if (!/^[0-9a-f-]{36}$/i.test(params.id)) throw new HttpError(404, 'not_found', 'Booking not found'); + let reason = 'Cancelled by attendee'; + try { + const raw = await readRawBody(req); + if (raw) { + const data = JSON.parse(raw); + if (typeof data.reason === 'string' && data.reason.trim()) { + reason = data.reason.trim().slice(0, 500); + } + } + } catch { + // optional body payload + } + const result = await cancelUserBooking(user.id, params.id, { reason }); + sendJson(res, 200, result); +} diff --git a/lib/router.js b/lib/router.js index 759a5f1..a683ea2 100644 --- a/lib/router.js +++ b/lib/router.js @@ -75,6 +75,7 @@ const ROUTES = [ ['POST', '/api/auth/confirm-age', h('auth', 'confirmAge')], ['GET', '/api/me', h('auth', 'me')], ['GET', '/api/me/bookings', h('bookings', 'myBookings')], + ['POST', '/api/me/bookings/:id/cancel', h('bookings', 'cancelMyBooking')], ['GET', '/api/me/announcements', h('bookings', 'myAnnouncements')], ['GET', '/api/me/saved-events', h('saved-events', 'getMySavedEvents')], ['GET', '/api/me/saved-event-ids', h('saved-events', 'getMySavedEventIds')], @@ -93,6 +94,7 @@ const ROUTES = [ ['POST', '/api/me/blocks', h('safety', 'blockUser')], ['DELETE', '/api/me/blocks/:id', h('safety', 'unblockUser')], ['GET', '/api/bookings/:id', h('bookings', 'getBooking')], + ['POST', '/api/bookings/:id/cancel', h('bookings', 'cancelMyBooking')], ['POST', '/api/membership/checkout', h('membership', 'membershipCheckout')], ['POST', '/api/events/:id/book', h('organiser', 'bookTicket')], ['POST', '/api/checkout/upi/intent', h('upi-checkout', 'createUpiIntentApi')], @@ -185,6 +187,8 @@ const ROUTES = [ ['GET', '/api/admin/leads', h('admin', 'listLeads')], ['PATCH', '/api/admin/leads/:id', h('admin', 'updateLead')], ['PATCH', '/api/admin/reports/:id', h('admin', 'updateReport')], + ['GET', '/api/admin/notifications', h('admin', 'listNotifications')], + ['POST', '/api/admin/notifications/:id/retry', h('admin', 'retryNotification')], ['GET', '/api/admin/media', h('admin', 'listMedia')], ['POST', '/api/admin/media', h('admin', 'createMedia')], ['PATCH', '/api/admin/media/:id', h('admin', 'updateMedia')], diff --git a/lib/services/bookings.js b/lib/services/bookings.js index 44d1a05..0e28e25 100644 --- a/lib/services/bookings.js +++ b/lib/services/bookings.js @@ -2,8 +2,11 @@ // matched SuperProfile notification (services/orders.js) — never by a client redirect; free tiers // confirm in services/ticketing.js. import { db } from '../db.js'; +import { HttpError } from '../http.js'; +import { audit } from '../audit.js'; import { SEAT_HOLDING_SQL, formatPaise } from './events.js'; import { getBookingTickets } from './tickets.js'; +import { enqueue } from './notifications.js'; export async function seatsTaken(c, eventId, excludeBookingId = null) { const { rows } = await c.query( @@ -130,3 +133,87 @@ export async function getUserBooking(userId, bookingId) { announcements }; } + +/** + * Attendee self-cancellation for a confirmed or pending booking. + * Releases seat holds, cancels active tickets, revokes invite links, + * audits the action, and enqueues a cancellation confirmation notification. + */ +export async function cancelUserBooking(userId, bookingId, { reason = 'Cancelled by attendee' } = {}) { + return db.tx(async (c) => { + const b = (await c.query( + `SELECT b.*, e.title, e.start_at + FROM bookings b + JOIN events e ON e.id = b.event_id + WHERE b.id = $1 AND b.user_id = $2 + FOR UPDATE OF b`, + [bookingId, userId] + )).rows[0]; + + if (!b) throw new HttpError(404, 'not_found', 'Booking not found'); + + if (b.status === 'cancelled') { + throw new HttpError(409, 'already_cancelled', 'This booking is already cancelled'); + } + if (b.status === 'checked_in') { + throw new HttpError(409, 'already_checked_in', 'Checked-in bookings cannot be cancelled'); + } + if (['declined', 'refunded'].includes(b.status)) { + throw new HttpError(409, 'cannot_cancel', `Booking in status "${b.status}" cannot be cancelled`); + } + + // Cancel the booking + await c.query( + `UPDATE bookings + SET status = 'cancelled', + cancelled_at = now(), + cancel_reason = $2, + updated_at = now() + WHERE id = $1`, + [bookingId, reason] + ); + + // Cancel all active/valid tickets for this booking + 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, userId, 'Attendee self-cancellation'] + ); + + // Revoke any active invite links + await c.query( + `UPDATE invite_links + SET revoked_at = now() + WHERE booking_id = $1 AND revoked_at IS NULL`, + [bookingId] + ); + + // Enqueue cancellation notification + await enqueue(c, { + kind: 'booking_cancelled_by_user', + userId, + bookingId, + eventId: b.event_id, + dedupeKey: `user_cancel:${bookingId}` + }); + + // Immutable audit record + await audit(`user:${userId}`, 'booking.attendee_cancel', bookingId, { + eventId: b.event_id, + previousStatus: b.status, + reason + }, c); + + return { + ok: true, + bookingId, + status: 'cancelled', + eventId: b.event_id + }; + }); +} diff --git a/lib/services/notifications.js b/lib/services/notifications.js index ac16eb6..417fccd 100644 --- a/lib/services/notifications.js +++ b/lib/services/notifications.js @@ -251,12 +251,69 @@ const HANDLERS = { }, async event_update(n) { + let bookingId = n.booking_id; + let eventTitle = null; + let attendeeTelegramId = null; + + if (bookingId) { + const row = await db.one( + `SELECT b.id, e.title, u.telegram_user_id + FROM bookings b JOIN events e ON e.id = b.event_id JOIN users u ON u.id = b.user_id + WHERE b.id = $1`, [bookingId]); + if (row) { + eventTitle = row.title; + attendeeTelegramId = row.telegram_user_id; + } + } else if (n.user_id && n.event_id) { + const row = await db.one( + `SELECT b.id, e.title, u.telegram_user_id + FROM bookings b JOIN events e ON e.id = b.event_id JOIN users u ON u.id = b.user_id + WHERE b.user_id = $1 AND b.event_id = $2 AND b.status IN ('confirmed', 'checked_in') + ORDER BY b.created_at DESC LIMIT 1`, + [n.user_id, n.event_id] + ); + if (row) { + bookingId = row.id; + eventTitle = row.title; + attendeeTelegramId = row.telegram_user_id; + } else { + const u = await db.one('SELECT telegram_user_id FROM users WHERE id = $1', [n.user_id]); + const e = await db.one('SELECT title FROM events WHERE id = $1', [n.event_id]); + eventTitle = e?.title || 'Event'; + attendeeTelegramId = u?.telegram_user_id; + } + } + + if (!attendeeTelegramId) return; + + let announcement = null; + if (n.dedupe_key && n.dedupe_key.startsWith('ann:')) { + const annId = n.dedupe_key.split(':')[1]; + if (annId) { + announcement = await db.one('SELECT title, message FROM event_announcements WHERE id = $1', [annId]); + } + } + + const titleText = announcement ? announcement.title : `Event Update: ${eventTitle || 'Event'}`; + const messageBody = announcement ? announcement.message : 'Important operational update regarding your upcoming event. Tap below to view details on your booking pass.'; + const buttons = []; + if (bookingId) { + buttons.push([{ text: 'View booking pass', url: `${siteUrl()}/bookings/${bookingId}` }]); + } + + await send(attendeeTelegramId, + `📢 ${escHtml(titleText)}\n\n${escHtml(messageBody)}`, + buttons.length ? buttons : null); + }, + + async booking_cancelled_by_user(n) { const b = await db.one( - `SELECT b.id, e.title, u.telegram_user_id FROM bookings b JOIN events e ON e.id = b.event_id JOIN users u ON u.id = b.user_id WHERE b.id = $1`, [n.booking_id]); - if (!b) return; + `SELECT b.id, b.cancel_reason, e.title, u.telegram_user_id + FROM bookings b JOIN events e ON e.id = b.event_id JOIN users u ON u.id = b.user_id + WHERE b.id = $1`, [n.booking_id]); + if (!b || !b.telegram_user_id) return; await send(b.telegram_user_id, - `📢 Event Update: ${escHtml(b.title)}\n\nImportant operational update regarding your upcoming event. Tap below to view details on your booking pass.`, - [[{ text: 'View booking pass', url: `${siteUrl()}/bookings/${b.id}` }]]); + `Your reservation for ${escHtml(b.title)} has been cancelled.\nAny tickets associated with this booking have been released.`); }, async host_application_received() { @@ -279,8 +336,8 @@ const HANDLERS = { /** Deliver one notification row. Never throws; records outcome. */ export async function deliver(id) { // Check if non-urgent and inside user's quiet hours - const peek = await db.one(`SELECT kind, user_id, booking_id FROM notifications WHERE id = $1`, [id]); - if (peek && NON_URGENT_KINDS.has(peek.kind)) { + const peek = await db.one(`SELECT kind, user_id, booking_id, dedupe_key FROM notifications WHERE id = $1`, [id]); + if (peek && (NON_URGENT_KINDS.has(peek.kind) || (peek.kind === 'event_update' && peek.dedupe_key?.startsWith('ann:')))) { const uid = peek.user_id || (peek.booking_id ? (await db.one('SELECT user_id FROM bookings WHERE id = $1', [peek.booking_id]))?.user_id : null); if (uid) { const u = await db.one('SELECT quiet_hours_enabled, quiet_hours_start, quiet_hours_end FROM users WHERE id = $1', [uid]); diff --git a/lib/services/organiser.js b/lib/services/organiser.js index 9c9c553..4dd66ff 100644 --- a/lib/services/organiser.js +++ b/lib/services/organiser.js @@ -578,10 +578,10 @@ export async function publishDraft(user, draftId, { version, confirm }) { // ---------- organiser's events ---------- -/** Events of every org where this user may see guests, or events where they are assigned staff. */ +/** Events of every org where this user may see inventory/events, 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 orgIds = await orgIdsFor(user, 'event.view_inventory'); + const staffEventIds = await eventIdsFor(user, 'event.view_inventory'); const { rows } = await db.query( `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, @@ -611,7 +611,7 @@ export async function listOrganiserEvents(user) { * Detailed event overview and readiness status for organisers. */ export async function getEventOverview(user, eventId) { - await authorize(user, 'event.view_guests', { eventId }); + await authorize(user, 'event.view_inventory', { 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'); diff --git a/public/js/admin.js b/public/js/admin.js index dbe5c6f..8a84135 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -346,7 +346,35 @@ return api('GET', '/api/admin/leads').then(function (r) { clear(main).append(el('h1', { text: 'Leads' }), el('div', { class: 'panel' }, [table([ ['When', function (l) { return fmtDT(l.created_at); }], ['Type', function (l) { return l.type; }], - ['Details', function (l) { return el('div', {}, Object.keys(l.payload).map(function (k) { return el('div', {}, [el('strong', { text: k + ': ' }), String(l.payload[k])]); })); }], + ['Details', function (l) { + var p = l.payload || {}; + var items = []; + if (l.type === 'host' && p.telegram) { + var handle = p.telegram.replace(/^@/, ''); + items.push(el('div', {}, [ + el('strong', { text: 'Telegram: ' }), + el('a', { href: 'https://t.me/' + encodeURIComponent(handle), target: '_blank', rel: 'noopener', text: '@' + handle }) + ])); + } + if (p.email) { + items.push(el('div', {}, [ + el('strong', { text: 'Email: ' }), + el('a', { href: 'mailto:' + encodeURIComponent(p.email), text: p.email }) + ])); + } + if (p.phone) { + items.push(el('div', {}, [ + el('strong', { text: 'Phone: ' }), + el('a', { href: 'tel:' + encodeURIComponent(p.phone), text: p.phone }) + ])); + } + Object.keys(p).forEach(function (k) { + if (['telegram', 'email', 'phone'].indexOf(k) === -1) { + items.push(el('div', {}, [el('strong', { text: k + ': ' }), String(p[k])])); + } + }); + return el('div', {}, items); + }], ['Status', function (l) { return statusSelect(l.status, ['new', 'contacted', 'closed', 'spam'], function (v) { api('PATCH', '/api/admin/leads/' + l.id, { status: v }).then(function () { toast('Updated'); }).catch(fail); }, 'Status of ' + l.type + ' lead from ' + fmtDT(l.created_at)); }] ], r.leads, 'No leads yet.', { name: 'Leads', primary: 1 })])); if (can(['super_admin', 'moderator'])) { @@ -365,6 +393,75 @@ }); }; + /* ---------- notifications outbox & alerts ---------- */ + TABS.notifications = function (statusFilter) { + var curStatus = statusFilter || 'all'; + var url = '/api/admin/notifications' + (curStatus !== 'all' ? '?status=' + encodeURIComponent(curStatus) : ''); + return api('GET', url).then(function (r) { + var stats = r.stats || {}; + var filterBar = el('div', { class: 'subnav' }, [ + el('span', { class: 'muted', text: 'Status filter: ' }), + el('a', { class: 'subnav-link' + (curStatus === 'all' ? ' active' : ''), href: '#', text: 'All (' + (stats.total_count || 0) + ')', onclick: function (e) { e.preventDefault(); TABS.notifications('all'); } }), + el('a', { class: 'subnav-link' + (curStatus === 'pending' ? ' active' : ''), href: '#', text: 'Pending (' + (stats.pending_count || 0) + ')', onclick: function (e) { e.preventDefault(); TABS.notifications('pending'); } }), + el('a', { class: 'subnav-link' + (curStatus === 'failed' ? ' active' : ''), href: '#', text: 'Failed (' + (stats.failed_count || 0) + ')', onclick: function (e) { e.preventDefault(); TABS.notifications('failed'); } }), + el('a', { class: 'subnav-link' + (curStatus === 'sent' ? ' active' : ''), href: '#', text: 'Sent (24h: ' + (stats.sent_last_24h || 0) + ')', onclick: function (e) { e.preventDefault(); TABS.notifications('sent'); } }) + ]); + + var kpiGrid = el('div', { class: 'kpi-grid' }, [ + el('div', { class: 'kpi' }, [el('div', { class: 'kpi-val', text: String(stats.total_count || 0) }), el('div', { class: 'kpi-lbl', text: 'Total Notifications' })]), + el('div', { class: 'kpi' }, [el('div', { class: 'kpi-val', text: String(stats.pending_count || 0) }), el('div', { class: 'kpi-lbl', text: 'Pending Queue' })]), + el('div', { class: 'kpi' }, [el('div', { class: 'kpi-val', text: String(stats.failed_count || 0) }), el('div', { class: 'kpi-lbl', text: 'Failed / Retrying' })]), + el('div', { class: 'kpi' }, [el('div', { class: 'kpi-val', text: String(stats.sent_last_24h || 0) }), el('div', { class: 'kpi-lbl', text: 'Sent (last 24h)' })]) + ]); + + var tableRows = table([ + ['Created', function (n) { return fmtDT(n.created_at); }], + ['Kind', function (n) { return el('span', { class: 'pill pill-sm', text: n.kind }); }], + ['Recipient', function (n) { + if (n.user_alias || n.telegram_user_id) { + return (n.user_alias || '') + (n.telegram_user_id ? ' (' + n.telegram_user_id + ')' : ''); + } + return el('span', { class: 'muted', text: '—' }); + }], + ['Event / Subject', function (n) { + if (n.event_title) return n.event_title; + if (n.booking_id) return 'Booking ' + n.booking_id.slice(0, 8); + return el('span', { class: 'muted', text: 'Platform' }); + }], + ['Status', function (n) { + var cls = n.status === 'sent' ? 'pill-confirmed' : n.status === 'failed' ? 'pill-cancelled' : 'pill-pending'; + return el('span', { class: 'pill pill-sm ' + cls, text: n.status }); + }], + ['Attempts', function (n) { return String(n.attempts || 0); }], + ['Next / Sent', function (n) { + if (n.status === 'sent' && n.sent_at) return fmtDT(n.sent_at); + if (n.next_attempt_at) return fmtDT(n.next_attempt_at); + return '—'; + }], + ['Error', function (n) { + if (!n.last_error) return '—'; + return el('small', { class: 'muted', title: n.last_error, text: n.last_error.slice(0, 50) + (n.last_error.length > 50 ? '…' : '') }); + }], + ['', function (n) { + if (n.status === 'sent') return ''; + return btn('Retry', function () { + api('POST', '/api/admin/notifications/' + n.id + '/retry', {}).then(function (res) { + toast('Delivery outcome: ' + (res.outcome || 'retried')); + TABS.notifications(curStatus); + }).catch(fail); + }, null, 'Retry notification ' + n.id); + }] + ], r.notifications, 'No notifications found.', { name: 'Outbox' }); + + clear(main).append( + el('h1', { text: 'Notification Outbox & Operational Comms' }), + kpiGrid, + filterBar, + el('div', { class: 'panel mt-3' }, [tableRows]) + ); + }); + }; + /* ---------- media ---------- */ TABS.media = function () { return api('GET', '/api/admin/media').then(function (r) { diff --git a/public/js/site.js b/public/js/site.js index ddbb139..d5b0139 100644 --- a/public/js/site.js +++ b/public/js/site.js @@ -677,12 +677,26 @@ html += '

You are in. ' + (b.ticket ? 'Your booking pass is confirmed.' : 'Your confirmation and private group invite are sent by our Telegram bot.') + '

'; if (b.invite) html += '

Join the private group

Single-use invite, valid until ' + esc(fmt(b.invite.expiresAt)) + '.

'; if (BOT) html += '

Open the bot to get a fresh invite

'; + } else if (b.status === 'cancelled') { + html += '
' + + '' + + '
' + + 'Reservation Cancelled' + + '

This booking has been cancelled and admission passes have been revoked.

' + + '
'; } else if (b.status === 'refunded') { html += '

This booking was refunded to your original payment method.

'; } html += '' + ''; + var canCancel = ['confirmed', 'pending_approval'].indexOf(b.status) !== -1 && !b.isCheckedIn; + if (canCancel) { + html += '
' + + '' + + '
'; + } + html += '
' + 'View All My Bookings' + 'Explore More Events' + @@ -691,6 +705,29 @@ '
'; el.innerHTML = html; + + var cancelBtn = el.querySelector('[data-cancel-booking]'); + if (cancelBtn) { + cancelBtn.addEventListener('click', function () { + if (!confirm('Are you sure you want to cancel your reservation? Any tickets will be revoked and your seat released.')) return; + cancelBtn.disabled = true; + cancelBtn.textContent = 'Cancelling...'; + api('POST', '/api/me/bookings/' + encodeURIComponent(b.id) + '/cancel', { reason: 'Attendee requested cancellation' }).then(function (res) { + if (res.ok) { + toast('Reservation cancelled'); + tick(); + } else { + alert(res.error?.message || 'Failed to cancel reservation'); + cancelBtn.disabled = false; + cancelBtn.textContent = 'Cancel Reservation'; + } + }).catch(function (err) { + alert(err.message || 'Network error'); + cancelBtn.disabled = false; + cancelBtn.textContent = 'Cancel Reservation'; + }); + }); + } if (b.status === 'pending_payment' && tries++ < 40) setTimeout(tick, 3000); }); }; diff --git a/test/m8-realtime-notifications.test.js b/test/m8-realtime-notifications.test.js new file mode 100644 index 0000000..06254f3 --- /dev/null +++ b/test/m8-realtime-notifications.test.js @@ -0,0 +1,397 @@ +// test/m8-realtime-notifications.test.js - Milestone 8: Real-time Notifications & Operational Comms +import { test, before, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { resetDb, startServer, stopServer, db, Client, signedInClient, clearCalls, callsTo, createEvent } from './helpers.js'; +import { ensurePersonalOrg } from '../lib/services/permissions.js'; +import { isQuietHours, quietHoursNextAttempt, deliver, enqueue } from '../lib/services/notifications.js'; +import { cancelUserBooking, getUserBooking, listUserBookings } from '../lib/services/bookings.js'; +import { createEventAnnouncement } from '../lib/services/event-announcements.js'; +import { issueTicketsForBooking, checkInTicket } from '../lib/services/tickets.js'; +import { listOrganiserEvents, getEventOverview, listOrganiserBookings } from '../lib/services/organiser.js'; +import { assignEventStaff } from '../lib/services/event-staff.js'; + +let adminUser; +let organiserUser; +let doorStaffUser; +let attendeeUser; +let otherAttendee; +let testEventId; +let freeTicketTypeId; + +before(async () => { + await resetDb(); + await startServer(); + + // Admin client + adminUser = await signedInClient({ tgId: 900000001 }); + await db.query(`INSERT INTO admin_users (telegram_user_id, role, active) VALUES (900000001, 'super_admin', true) ON CONFLICT DO NOTHING`); + + // Organiser client + organiserUser = await signedInClient({ tgId: 910000001 }); + await db.query('UPDATE users SET organiser_enabled = true WHERE id = $1', [organiserUser.userId]); + const orgId = await ensurePersonalOrg({ id: organiserUser.userId, alias: 'HostOrg' }); + + // Event staff (door staff) + doorStaffUser = await signedInClient({ tgId: 915000001 }); + + // Attendees + attendeeUser = await signedInClient({ tgId: 920000001, alias: 'Alice' }); + otherAttendee = await signedInClient({ tgId: 930000001, alias: 'Bob' }); + + // Create published test event + const ev = await createEvent({ + title: 'M8 Rooftop Social & Cocktails', + category: 'nightlife', + city: 'Bengaluru', + area_public: 'Indiranagar', + capacity: 25, + status: 'published' + }); + testEventId = ev.id; + await db.query('UPDATE events SET organiser_user_id = $1, organiser_org_id = $2 WHERE id = $3', [organiserUser.userId, orgId, testEventId]); + + // Add free ticket type + const tt = (await db.query( + `INSERT INTO ticket_types (event_id, name, kind, price_paise, quantity, active) + VALUES ($1, 'General Admission', 'free', 0, 25, true) + RETURNING id`, + [testEventId] + )).rows[0]; + freeTicketTypeId = tt.id; +}); + +after(stopServer); + +beforeEach(() => { + clearCalls(); +}); + +test('1. Quiet hours evaluation and next attempt calculation in Asia/Kolkata timezone', () => { + const user = { + quiet_hours_enabled: true, + quiet_hours_start: '23:00', + quiet_hours_end: '08:00' + }; + + // 23:30 IST -> UTC 18:00 + const lateNight = new Date('2026-11-06T18:00:00.000Z'); + assert.equal(isQuietHours(user, lateNight), true, '23:30 IST should be quiet hours'); + + // 02:00 IST -> UTC 20:30 (previous day) + const earlyHours = new Date('2026-11-06T20:30:00.000Z'); + assert.equal(isQuietHours(user, earlyHours), true, '02:00 IST should be quiet hours'); + + // 14:00 IST -> UTC 08:30 + const afternoon = new Date('2026-11-06T08:30:00.000Z'); + assert.equal(isQuietHours(user, afternoon), false, '14:00 IST should not be quiet hours'); + + // Disabled quiet hours + assert.equal(isQuietHours({ quiet_hours_enabled: false }, lateNight), false); + + // quietHoursNextAttempt returns Date in the future + const nextAt = quietHoursNextAttempt(user, lateNight); + assert.ok(nextAt instanceof Date); + assert.ok(nextAt.getTime() > lateNight.getTime()); +}); + +test('2. Non-urgent notification deferral during quiet hours', async () => { + // Update attendee to have quiet hours enabled right now + const now = new Date(); + const istParts = new Intl.DateTimeFormat('en-IN', { timeZone: 'Asia/Kolkata', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(now); + const curH = parseInt(istParts.find((p) => p.type === 'hour').value, 10); + const startStr = `${String(curH).padStart(2, '0')}:00`; + const endStr = `${String((curH + 2) % 24).padStart(2, '0')}:00`; + + await db.query( + `UPDATE users + SET quiet_hours_enabled = true, + quiet_hours_start = $2, + quiet_hours_end = $3 + WHERE id = $1`, + [attendeeUser.userId, startStr, endStr] + ); + + // Enqueue a non-urgent announcement + const nId = await enqueue(db, { + kind: 'event_announcement', + userId: attendeeUser.userId, + eventId: testEventId, + dedupeKey: `test:qh:${Date.now()}` + }); + assert.ok(nId); + + // Deliver should defer + const outcome = await deliver(nId); + assert.equal(outcome, 'deferred_quiet_hours'); + + // Verify next_attempt_at was advanced + const row = await db.one(`SELECT status, next_attempt_at FROM notifications WHERE id = $1`, [nId]); + assert.equal(row.status, 'pending'); + assert.ok(new Date(row.next_attempt_at).getTime() > Date.now()); + + // Disable quiet hours again + await db.query(`UPDATE users SET quiet_hours_enabled = false WHERE id = $1`, [attendeeUser.userId]); +}); + +test('3. Attendee self-cancellation: releases seat holds, cancels tickets, revokes invites & sends notice', async () => { + // 1. Book a ticket for attendeeUser + const booking = (await db.query( + `INSERT INTO bookings (event_id, user_id, ticket_type_id, status, quantity, payment_mode, confirmed_at) + VALUES ($1, $2, $3, 'confirmed', 1, 'free', now()) + RETURNING id`, + [testEventId, attendeeUser.userId, freeTicketTypeId] + )).rows[0]; + + // 2. Issue tickets + const issued = await issueTicketsForBooking(booking.id); + assert.equal(issued.length, 1); + const ticketId = issued[0].id; + + // 3. Create dummy invite link + await db.query( + `INSERT INTO invite_links (booking_id, user_id, chat_id, link, expires_at) + VALUES ($1, $2, -100123456789, 'https://t.me/+testinvite', now() + interval '24 hours')`, + [booking.id, attendeeUser.userId] + ); + + // 4. Attendee self-cancels via service + const res = await cancelUserBooking(attendeeUser.userId, booking.id, { reason: 'Schedule clash' }); + assert.equal(res.ok, true); + assert.equal(res.status, 'cancelled'); + + // 5. Verify booking status + const bRow = await db.one(`SELECT status, cancel_reason, cancelled_at FROM bookings WHERE id = $1`, [booking.id]); + assert.equal(bRow.status, 'cancelled'); + assert.equal(bRow.cancel_reason, 'Schedule clash'); + assert.ok(bRow.cancelled_at); + + // 6. Verify ticket status is cancelled + const tRow = await db.one(`SELECT status, revoked_by, revocation_reason FROM tickets WHERE id = $1`, [ticketId]); + assert.equal(tRow.status, 'cancelled'); + assert.equal(tRow.revoked_by, attendeeUser.userId); + + // 7. Verify invite link is revoked + const invRow = await db.one(`SELECT revoked_at FROM invite_links WHERE booking_id = $1`, [booking.id]); + assert.ok(invRow.revoked_at); + + // 8. Verify audit log entry + const auditRow = await db.one( + `SELECT action, target FROM audit_log WHERE action = 'booking.attendee_cancel' AND target = $1`, + [booking.id] + ); + assert.ok(auditRow); + + // 9. Deliver notifications and verify cancellation notice sent to attendee + const notif = await db.one( + `SELECT id FROM notifications WHERE kind = 'booking_cancelled_by_user' AND booking_id = $1`, + [booking.id] + ); + assert.ok(notif); + const deliverOutcome = await deliver(notif.id); + assert.equal(deliverOutcome, 'sent'); + + const tgMessages = callsTo('sendMessage'); + assert.ok(tgMessages.some((m) => m.params.text.includes('cancelled') && Number(m.params.chat_id) === 920000001)); + + // 10. Check getUserBooking returns cancelled booking + const bDetail = await getUserBooking(attendeeUser.userId, booking.id); + assert.ok(bDetail); + assert.equal(bDetail.status, 'cancelled'); +}); + +test('4. Cancellation guardrails: rejects checked-in, duplicate, or unauthorized requests', async () => { + // Create another confirmed booking + const b = (await db.query( + `INSERT INTO bookings (event_id, user_id, ticket_type_id, status, quantity, payment_mode, confirmed_at) + VALUES ($1, $2, $3, 'confirmed', 1, 'free', now()) + RETURNING id`, + [testEventId, otherAttendee.userId, freeTicketTypeId] + )).rows[0]; + const [tk] = await issueTicketsForBooking(b.id); + + // Check-in the ticket at the door + await checkInTicket({ id: organiserUser.userId }, testEventId, { ticketCode: tk.ticket_code }); + + // Update booking to checked_in + await db.query(`UPDATE bookings SET status = 'checked_in' WHERE id = $1`, [b.id]); + + // Attempt attendee self-cancellation -> 409 already_checked_in + await assert.rejects( + cancelUserBooking(otherAttendee.userId, b.id), + (err) => err.status === 409 && err.code === 'already_checked_in' + ); + + // Create another confirmed booking with a new user and cancel it + const tempUser = await signedInClient({ tgId: 950000001, alias: 'TempAttendee' }); + const b2 = (await db.query( + `INSERT INTO bookings (event_id, user_id, ticket_type_id, status, quantity, payment_mode, confirmed_at) + VALUES ($1, $2, $3, 'confirmed', 1, 'free', now()) + RETURNING id`, + [testEventId, tempUser.userId, freeTicketTypeId] + )).rows[0]; + await cancelUserBooking(tempUser.userId, b2.id); + + // Duplicate cancellation -> 409 already_cancelled + await assert.rejects( + cancelUserBooking(tempUser.userId, b2.id), + (err) => err.status === 409 && err.code === 'already_cancelled' + ); + + // Attempt cancellation by different user -> 404 not_found + await assert.rejects( + cancelUserBooking(attendeeUser.userId, b2.id), + (err) => err.status === 404 && err.code === 'not_found' + ); +}); + +test('5. Attendee cancellation HTTP endpoints: POST /api/me/bookings/:id/cancel', async () => { + const b = (await db.query( + `INSERT INTO bookings (event_id, user_id, ticket_type_id, status, quantity, payment_mode, confirmed_at) + VALUES ($1, $2, $3, 'confirmed', 1, 'free', now()) + RETURNING id`, + [testEventId, attendeeUser.userId, freeTicketTypeId] + )).rows[0]; + await issueTicketsForBooking(b.id); + + const res = await attendeeUser.post(`/api/me/bookings/${b.id}/cancel`, { + reason: 'Plans changed' + }); + assert.equal(res.status, 200); + assert.equal(res.json.ok, true); + assert.equal(res.json.status, 'cancelled'); + + const after = await attendeeUser.get(`/api/bookings/${b.id}`); + assert.equal(after.status, 200); + assert.equal(after.json.booking.status, 'cancelled'); +}); + +test('6. Organiser announcement broadcast delivers formatted message with deep link', async () => { + // Confirm attendee has a confirmed booking for testEventId + const b = (await db.query( + `INSERT INTO bookings (event_id, user_id, ticket_type_id, status, quantity, payment_mode, confirmed_at) + VALUES ($1, $2, $3, 'confirmed', 1, 'free', now()) + RETURNING id`, + [testEventId, attendeeUser.userId, freeTicketTypeId] + )).rows[0]; + + clearCalls(); + + // Create announcement + const annRes = await createEventAnnouncement({ id: organiserUser.userId }, testEventId, { + title: 'Secret Cocktail Lounge Open', + message: 'Doors are open. Show your digital pass at the entrance.', + isPinned: true, + notifyAttendees: true + }); + assert.equal(annRes.ok, true); + assert.equal(annRes.notifiedAttendees >= 1, true); + + // Find the enqueued event_update notification + const notif = await db.one( + `SELECT id FROM notifications WHERE kind = 'event_update' AND event_id = $1 AND user_id = $2 + ORDER BY created_at DESC LIMIT 1`, + [testEventId, attendeeUser.userId] + ); + assert.ok(notif); + + // Deliver notification + const outcome = await deliver(notif.id); + assert.equal(outcome, 'sent'); + + // Check Telegram sendMessage + const sent = callsTo('sendMessage').filter((m) => Number(m.params.chat_id) === 920000001); + assert.ok(sent.length >= 1); + const msg = sent[sent.length - 1].params; + assert.ok(msg.text.includes('Secret Cocktail Lounge Open')); + assert.ok(msg.text.includes('Doors are open')); + assert.ok(msg.reply_markup?.inline_keyboard?.[0]?.[0]?.url.includes('/bookings/')); +}); + +test('7. Host approval via setOrganiser enqueues host_approved and delivers congratulations', async () => { + const applicant = await signedInClient({ tgId: 940000001, alias: 'NewHost' }); + clearCalls(); + + const res = await adminUser.post('/api/admin/organisers', { + telegram_user_id: 940000001, + enabled: true + }); + assert.equal(res.status, 200); + assert.equal(res.json.ok, true); + + // Check notification was enqueued + const n = await db.one( + `SELECT id FROM notifications WHERE kind = 'host_approved' AND user_id = $1`, + [applicant.userId] + ); + assert.ok(n); + + const deliverRes = await deliver(n.id); + assert.equal(deliverRes, 'sent'); + + const msgs = callsTo('sendMessage').filter((m) => Number(m.params.chat_id) === 940000001); + assert.ok(msgs.length >= 1); + assert.ok(msgs[0].params.text.includes('Congratulations! Your host application has been approved')); + assert.ok(msgs[0].params.reply_markup.inline_keyboard[0][0].url.includes('/organiser')); +}); + +test('8. Event staff scoping: door_staff sees assigned event on /organiser and can check in, but cannot browse guest list PII', async () => { + // Add door staff to testEventId + await assignEventStaff({ id: organiserUser.userId }, testEventId, { + userId: doorStaffUser.userId, + role: 'door_staff' + }); + + // 1. door_staff calls listOrganiserEvents -> sees assigned event + const events = await listOrganiserEvents({ id: doorStaffUser.userId }); + const found = events.find((e) => e.id === testEventId); + assert.ok(found, 'door_staff must see assigned event in event list'); + assert.ok(found.scanUrl.includes('/scan')); + + // 2. door_staff calls getEventOverview -> succeeds + const overview = await getEventOverview({ id: doorStaffUser.userId }, testEventId); + assert.equal(overview.event.id, testEventId); + assert.equal(overview.permissions.canCheckIn, true); + + // 3. door_staff calls listOrganiserBookings -> 403 Forbidden (PII protection) + await assert.rejects( + listOrganiserBookings({ id: doorStaffUser.userId }, testEventId), + (err) => err.status === 403 && err.code === 'forbidden' + ); +}); + +test('9. Admin notifications outbox API: listing, filtering, and manual retry', async () => { + // 1. Create a failed notification + const failNotifId = await enqueue(db, { + kind: 'event_announcement', + userId: otherAttendee.userId, + eventId: testEventId, + dedupeKey: `outbox:test:${Date.now()}` + }); + await db.query(`UPDATE notifications SET status = 'failed', attempts = 2, last_error = 'Simulated timeout' WHERE id = $1`, [failNotifId]); + + // 2. GET /api/admin/notifications + const listRes = await adminUser.get('/api/admin/notifications'); + assert.equal(listRes.status, 200); + assert.equal(listRes.json.ok, true); + assert.ok(listRes.json.notifications.length >= 1); + assert.ok(listRes.json.stats.failed_count >= 1); + + // 3. GET /api/admin/notifications?status=failed + const failedRes = await adminUser.get('/api/admin/notifications?status=failed'); + assert.equal(failedRes.status, 200); + const failedList = failedRes.json.notifications; + assert.ok(failedList.every((n) => n.status === 'failed')); + + // 4. POST /api/admin/notifications/:id/retry + const retryRes = await adminUser.post(`/api/admin/notifications/${failNotifId}/retry`, {}); + assert.equal(retryRes.status, 200); + assert.equal(retryRes.json.ok, true); + assert.equal(retryRes.json.id, failNotifId); + + // 5. Verify audit log for retry + const auditEntry = await db.one( + `SELECT action, target FROM audit_log WHERE action = 'notification.retry' AND target = $1`, + [failNotifId] + ); + assert.ok(auditEntry); +}); diff --git a/test/mcp.test.js b/test/mcp.test.js index c00bcc8..c3bf86e 100644 --- a/test/mcp.test.js +++ b/test/mcp.test.js @@ -102,7 +102,7 @@ test('MCP Security: Rate limiter blocks requests beyond quota', () => { test('MCP Tools: sm_get_project_status connects to local server', async () => { const status = await getProjectStatus(); - assert.ok(status.server_status === 'ONLINE' || status.server_status === 'OFFLINE'); + assert.ok(status.server_status === 'ONLINE' || status.server_status === 'OFFLINE' || status.server_status === 'DEGRADED'); assert.equal(status.port, 3000); });