diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 3f10329..d2a8e01 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -37,8 +37,9 @@ Local `main` in the main clone is at `5693f7b`, an older Production commit that | #32 M4.3.2 Financial Integrity Remediation | `feature/m4-3-1-review-remediation` ← `feature/m4-3-2-financial-integrity` | `62cb0a3` | `lint, unit, e2e` ✅ · Vercel ✅ | Fail-Closed Merchant Identity & Secure Manual Refund Reconciliation | | #33 M4.3.3 Final Provider Verification Integrity | `feature/m4-3-2-financial-integrity` ← `feature/m4-3-3-provider-verification` | `4c54711` | `lint, unit, e2e` ✅ · Vercel ✅ | Removal of Currency Defaults, Elimination of Merchant Fallback, Authentic S2S Polling & Attestation | | #34 M4.3.4 Provider Contract Decision & M4 Closure | `feature/m4-3-3-provider-verification` ← `feature/m4-3-4-contract-decision` | `1e5bd19` | `lint, unit, e2e` ✅ | PhonePe Contract Determination (Outcome B), Full Regression Suite, Audit & Milestone M4 Closure | +| #35 M5 Telegram & Operational Workflows | `feature/m4-3-4-contract-decision` ← `feature/m5-telegram-operational-workflows` | `262f4f0` | `lint, unit, e2e` ✅ | Telegram Identity & Account Linking, Event Private Group Isolation (No Shared Group), Operational Notifications with Quiet Hours, /report Workflow | -**Owner gate:** PR #17 must not be merged without the owner's separate approval. `main` has no GitHub Actions runs on record. PR #34, PR #33, PR #32, PR #31, PR #30, and PR #17 must not be merged without separate owner approval. Keep `ENABLE_LIVE_PAYMENTS=false` everywhere. +**Owner gate:** PR #17 must not be merged without the owner's separate approval. `main` has no GitHub Actions runs on record. PR #35, PR #34, PR #33, PR #32, PR #31, PR #30, and PR #17 must not be merged without separate owner approval. Keep `ENABLE_LIVE_PAYMENTS=false` everywhere. PR #23 is **merged** at `7c5079e` (P3 Location Intelligence + M2 Remediation). PR #25 is **merged** at `83a30a7` (Owner Addendum Ops: dynamic DB block list, branch archiving, Phase 5.6/5.7 reports). @@ -51,6 +52,7 @@ PR #31 is **open / preserved** (`feature/m4-3-1-review-remediation`). PR #32 is **open / preserved** (`feature/m4-3-2-financial-integrity`). PR #33 is **open / preserved** (`feature/m4-3-3-provider-verification`). PR #34 is **open / preserved** (`feature/m4-3-4-contract-decision`). +PR #35 is **open / preserved** (`feature/m5-telegram-operational-workflows`). ## 4. Migrations per environment @@ -172,7 +174,8 @@ Per owner approval on 2026-11-03, each of the 19 fully merged remote branches wa | M4.3.2 Financial Integrity Remediation | `feature/m4-3-2-financial-integrity` / #32 | **Completed** (Strict fail-closed merchant identity comparison across verified, stored, and configured identities; secure manual refund reconciliation with verified admin roles and verifiable external references; 33/33 UPI tests pass, 466/466 unit/integration tests pass, `ENABLE_LIVE_PAYMENTS=false` preserved) | | 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 Zero-Trust Live Payment Provider Integration | `feature/m5-...` | **Recommended Next Milestone** (Integrate zero-trust payment provider supplying explicit ISO currency evidence and signed webhook entities, or release free-only MVP) | +| 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) | ## 11. Where the detail lives diff --git a/docs/TELEGRAM_SETUP.md b/docs/TELEGRAM_SETUP.md index bfad174..72a0ee5 100644 --- a/docs/TELEGRAM_SETUP.md +++ b/docs/TELEGRAM_SETUP.md @@ -1,64 +1,125 @@ -# Telegram setup (owner) - -Status today: **NOT CONFIGURED.** All bot, invite, Mini App and Stars code is built and tested against a stubbed Bot API; nothing is live until the steps below are done. Never paste tokens or secrets into chat — set them only as Vercel environment variables. - -## 1. Bot (BotFather) -1. In Telegram open **@BotFather** → `/newbot` (or pick your existing bot). Name: *StrangersMeetup*; username e.g. `StrangersMeetupBot` (must be one you own — the old site linked `@StrangersMeetupBot` without it being verified). -2. Set the profile photo to the site logo (`public/favicon.svg`) — Telegram shows it on the login prompt. -3. Copy the token. In Vercel → Project → Settings → Environment Variables add **`TELEGRAM_BOT_TOKEN`** (Preview + Production). - - The project currently has a variable named `Telegram_HTTP_API`. The code accepts it as a fallback *only if it is shaped exactly like a bot token*, but please rename it to `TELEGRAM_BOT_TOKEN` and add it to Preview too. -4. Add **`TELEGRAM_BOT_USERNAME`** (without `@`). -5. Generate a webhook secret (16–256 chars of `A-Z a-z 0-9 _ -`), e.g. `openssl rand -hex 32`, and add **`TELEGRAM_WEBHOOK_SECRET`**. -6. BotFather → your bot → **Bot Settings → Configure Mini App / Menu Button** → URL = your site (`PUBLIC_SITE_URL`). (`scripts/telegram-setup.js` also sets the menu button.) -7. BotFather → **Payments**: nothing to connect for Stars. For sandbox Stars testing use Telegram's *test environment* (separate test account + test bot) and set `TELEGRAM_TEST_ENV=true` on the preview that uses the test bot. - -## 2. Website sign-in (Telegram Login, OpenID Connect) -Telegram's current web login is OpenID Connect (the old iframe widget is archived). -1. BotFather mini app → select bot → **Login Widget** → add Allowed URLs: - - `https://` and `https:///auth/telegram/callback` - - the same for the preview URL you test on. -2. Copy **Client ID** → `TELEGRAM_LOGIN_CLIENT_ID`, **Client Secret** → `TELEGRAM_LOGIN_CLIENT_SECRET`. -3. Without these two, `/signin` falls back to the legacy widget (needs `/setdomain` in BotFather for the site domain). - -## 3. Private groups (one per membership tier + one for event coordination) -Create **private supergroups** (or channels for broadcast-only): - -| Purpose | Env var | -|---|---| -| Event coordination group (venue reveal for paid attendees) | `TG_CHAT_ID_EVENTS` | -| Tier 1 — Insider | `TG_CHAT_ID_TIER_1` | -| Tier 2 — Social Plus | `TG_CHAT_ID_TIER_2` | -| Tier 3 — VIP Circle | `TG_CHAT_ID_TIER_3` | - -For each chat: -1. Add the bot as **administrator** with: *Invite users via link* and *Ban users* (and *Delete messages* for moderation). Nothing else is needed. -2. Turn off "Approve new members" (invites are single-use, `member_limit: 1`, 24h expiry). -3. Get the chat id: after the webhook is set (step 4), add the bot and post any message; or temporarily add **@RawDataBot**/**@getidsbot**, read `chat.id` (starts with `-100…`), then remove it. -4. Put the id in the matching env var. A per-event group can instead be set on the event in **/admin → Events → Telegram chat id**. - -## 4. Point Telegram at the site -After the env vars are on the deployment (and `PUBLIC_SITE_URL` is set to it): -``` +# NIGHTLIFEZ — Telegram Setup & Operational Runbook (Owner) + +Status today: **NOT CONFIGURED IN PRODUCTION.** All bot updates, single-use invite links, Mini App authentication, Stars digital memberships, and operational outbox workflows are fully built and verified against the automated test suite. Nothing is live until the owner performs the BotFather and environment setup below. + +> [!IMPORTANT] +> **Owner Non-Negotiable Safeguards:** +> 1. Never paste bot tokens or webhook secrets into chat or public pull requests. Set them exclusively as Vercel environment variables. +> 2. Keep `ENABLE_LIVE_PAYMENTS=false` across all environments. +> 3. Do not create a single global attendee group for all events. Every event has its own dedicated private community group to prevent venue leaks and attendee cross-contamination. + +--- + +## 1. Owner Setup Checklist (BotFather) + +Complete these steps in Telegram by messaging **@BotFather**: + +- [ ] **1. Create the Bot**: + - Open **@BotFather** → `/newbot`. + - Name: **NIGHTLIFEZ**. + - Username: e.g. `NightlifezBot` or `nightlifez_bot` (must end with `bot`). +- [ ] **2. Set Bot Profile & Branding**: + - `/setuserpic` → Upload the NIGHTLIFEZ official logo (`public/favicon.svg` or high-resolution icon). + - `/setdescription` → *"Curated weekend parties, nightlife, social mixers, dinner clubs and trips for adults 21+ across South India."* + - `/setabouttext` → *"NIGHTLIFEZ — Curated social experiences for adults 21+ in Bangalore, Chennai, Kochi, Goa and beyond."* +- [ ] **3. Configure Mini App Menu Button**: + - `/setmenubutton` → Select your bot → URL: `https://` (configured as `PUBLIC_SITE_URL`). + - Text: **Open NIGHTLIFEZ**. +- [ ] **4. Configure Web Login (Telegram Login / OpenID Connect)**: + - Bot Settings → **Configure Mini App / Login Widget** → Add Allowed Domains: + - `https://` + - `https:///auth/telegram/callback` + - Add preview domain for testing if required. + - Record the **Client ID** (`TELEGRAM_LOGIN_CLIENT_ID`) and **Client Secret** (`TELEGRAM_LOGIN_CLIENT_SECRET`). + - Run `/setdomain` for the legacy widget fallback as well. +- [ ] **5. Copy Bot Token to Vercel Environment Variables**: + - Copy the token from BotFather (`123456789:ABC...`). + - In Vercel Project Settings → Environment Variables, add: + - **`TELEGRAM_BOT_TOKEN`** (Preview + Production) + - **`TELEGRAM_BOT_USERNAME`** (bot username without `@`) +- [ ] **6. Generate & Configure Webhook Secret**: + - Generate a 32-character random hex string: `openssl rand -hex 32` + - In Vercel Project Settings → Environment Variables, add: + - **`TELEGRAM_WEBHOOK_SECRET`** + +--- + +## 2. Event Communities & Membership Tier Groups + +### A. Dedicated Private Groups for Events (Strict Isolation) +**Rule:** There is **NO global attendee group**. Each event has its own separate private community supergroup. + +To set up an event's group: +1. Create a **private supergroup** in Telegram for the event (e.g., *"Indiranagar Mixer — 14 Nov"*). +2. Add the bot as an **Administrator** with these permissions: + - ✅ *Invite users via link* (required to generate single-use 24-hour links) + - ✅ *Ban users* (required to remove joiners who use leaked or unverified invite links, or refunded bookings) + - ✅ *Delete messages* (recommended for moderation) +3. Turn **OFF** *"Approve new members"* (each invite generated by the platform is cryptographically single-use with `member_limit: 1`). +4. Obtain the group's numeric Telegram Chat ID (starts with `-100...`): + - Add **@RawDataBot** or **@getidsbot** to read `chat.id`, then remove it. +5. In NIGHTLIFEZ Admin Dashboard (**Admin → Events → Edit Event**), enter the ID in the **Telegram chat ID** field (`events.telegram_chat_id`). + +### B. Membership Tier Private Groups +Create dedicated private supergroups for membership tiers: + +| Tier | Code | Environment Variable | +|---|---|---| +| Tier 1 — Insider | `tier_1` | `TG_CHAT_ID_TIER_1` | +| Tier 2 — Social Plus | `tier_2` | `TG_CHAT_ID_TIER_2` | +| Tier 3 — VIP Circle | `tier_3` | `TG_CHAT_ID_TIER_3` | + +Follow the same administrator setup (Invite users + Ban users, no manual approval). + +### C. Optional Public Broadcast Channel +| Purpose | Identifier / Env Var | Description | +|---|---|---| +| Public Broadcast Channel | `TELEGRAM_CHANNEL_PUBLIC` | Public channel for announcements. No secret attendee details or venue reveals are ever published here. | + +--- + +## 3. Registering the Webhook & Commands + +Once environment variables are deployed on Vercel: + +```bash +# 1. Pull environment variables locally vercel env pull .env.setup --environment=production # or preview + +# 2. Register webhook and bot commands set -a; . ./.env.setup; set +a node scripts/telegram-setup.js rm .env.setup ``` -It calls `setWebhook` (with `secret_token` and `allowed_updates` including `chat_member`, which Telegram only sends when listed explicitly), `setMyCommands`, and the Mini App menu button. Re-runnable. -## 5. Verify -- `GET /api/health` → `telegramBot`, `telegramWebhookSecret`, `telegramEventsChat` all `configured`. -- DM the bot `/start` → welcome + "Open StrangersMeetup" button. -- `/events`, `/mybookings`, `/membership`, `/report test`, `/help`, `/terms`, `/paysupport` all answer. -- Admin: add yourself via `ADMIN_BOOTSTRAP_TELEGRAM_ID` (your numeric Telegram id) and open `/admin`. +The script idempotently configures: +- Webhook URL: `https:///api/telegram/webhook` with `secret_token` +- Allowed update types: `message`, `callback_query`, `pre_checkout_query`, `chat_member`, `my_chat_member` +- Standard bot commands: + - `/start` — Open NIGHTLIFEZ + - `/events` — Upcoming meetups, mixers and trips + - `/mybookings` — Your bookings and private group invites + - `/claim` — Claim tickets bought on web / UPI + - `/membership` — Membership plans + - `/quiet_hours` — Notification quiet hours settings + - `/report` — Report a safety concern + - `/help` — Help and emergency numbers + - `/terms` — Terms and refunds + - `/paysupport` — Payment support + +--- + +## 4. Bot Workflows & Operational Behavior -## What the bot does -| Trigger | Behaviour | +| Trigger / Command | Platform Behavior | |---|---| -| `/start` | Welcome + Mini App button. `start=event_` shows that event; `start=booking_` re-issues your invite; `start=alerts` opts in to new-event alerts (`/stopalerts` to opt out). | -| Paid booking (verified webhook) | DM with event details + single-use 24h invite to the event chat. | -| Stars membership paid | DM + invite to tier chat. Renewals extend expiry (DM, no new invite). | -| 3 days before expiry | Reminder DM. | -| Expiry (+1h grace) | Removed from tier chat (ban then unban — not a permanent ban) + DM. | -| Someone joins with another person's invite | Removed automatically; invite revoked; audit log entry. | -| `/report ` | Stored in `reports`, visible in admin. | +| **`/start`** | Welcomes attendee with branded NIGHTLIFEZ intro and Mini App button. Performs **operational recovery**: automatically checks if user has confirmed bookings with undelivered invites and delivers them immediately. | +| **`/start link_`** | **Secure Account Linking**: Links authenticated web session or guest booking to this Telegram user without IDOR. | +| **`/start claim_upi__`** | Automatically claims a guest checkout booking for this Telegram user and delivers confirmation and group invite. | +| **`/start event_`** | Displays card for the specific event with direct link into the Mini App. | +| **`/start booking_`** | Re-issues active 24h single-use group invite for an eligible confirmed booking. | +| **`/quiet_hours`** | Displays current quiet hours window (e.g. 23:00 to 08:00 IST). Toggle with `/quiet_hours on` or `/quiet_hours off`, or customize with `/quiet_hours HH:MM HH:MM`. Non-urgent alerts are deferred during quiet hours. | +| **`/report `** | Submits confidential safety report directly to moderation outbox with rate-limiting protection (max 5/hr). Always displays national emergency helpline **112** and Women's Helpline **1091 / 181**. | +| **Confirmed Booking** | Sends transactional DM with start time, location, booking reference, and single-use 24h private group invite (if event group configured). | +| **Cancellation / Refund** | Automatically revokes active invite links (`revoked_at = now()`) and sends confirmation DM. If user previously joined the chat, removes them from the group. | +| **Unauthorized Joiner** | If an invite link is forwarded and joined by an unconfirmed user, bot immediately kicks the joiner, revokes the link, and writes an audit log (`invite.misuse_removed`). | diff --git a/lib/handlers/auth.js b/lib/handlers/auth.js index 619022a..e0d5060 100644 --- a/lib/handlers/auth.js +++ b/lib/handlers/auth.js @@ -12,7 +12,11 @@ import { validateInitData, validateLoginWidget, consumeNonce, oidcConfigured, pkcePair, authorizeUrl, exchangeCode, verifyIdToken } from '../telegram/auth.js'; -import { upsertTelegramUser, confirmAge as confirmAgeSvc } from '../services/users.js'; +import { + upsertTelegramUser, confirmAge as confirmAgeSvc, + createAccountLinkToken, getAccountLinkStatus, + getNotificationPreferences, updateNotificationPreferences +} from '../services/users.js'; import { createSession, setSessionCookie, clearSessionCookie, csrfTokenFor, getSessionUser, requireUser, requireCsrf, revokeSession, revokeUserSessions @@ -232,3 +236,36 @@ export async function confirmAge({ req, res }) { await confirmAgeSvc(user.id); sendJson(res, 200, { ok: true }); } + +export async function createTelegramLinkToken({ req, res }) { + const user = await getSessionUser(req).catch(() => null); + const { token, linkUrl, expiresAt } = await createAccountLinkToken(user?.id || null); + sendJson(res, 200, { ok: true, token, linkUrl, expiresAt }); +} + +export async function getTelegramLinkStatus({ res, query }) { + const token = query?.get?.('token'); + if (!token) throw new HttpError(400, 'validation', 'token is required'); + const status = await getAccountLinkStatus(token); + if (!status) throw new HttpError(404, 'not_found', 'Token not found'); + sendJson(res, 200, { ok: true, ...status }); +} + +export async function getMyNotificationPreferences({ req, res }) { + const user = await requireUser(req); + const prefs = await getNotificationPreferences(user.id); + sendJson(res, 200, { ok: true, preferences: prefs }); +} + +export async function updateMyNotificationPreferences({ req, res }) { + const user = await requireUser(req); + requireCsrf(req, user); + const body = await readJson(req); + const prefs = await updateNotificationPreferences(user.id, { + remindersEnabled: body.remindersEnabled, + quietHoursEnabled: body.quietHoursEnabled, + quietHoursStart: body.quietHoursStart, + quietHoursEnd: body.quietHoursEnd + }); + sendJson(res, 200, { ok: true, preferences: prefs }); +} diff --git a/lib/router.js b/lib/router.js index 60008e4..0071d7e 100644 --- a/lib/router.js +++ b/lib/router.js @@ -63,6 +63,8 @@ const ROUTES = [ // Auth & member ['POST', '/api/auth/telegram', h('auth', 'telegramAuth')], + ['POST', '/api/auth/telegram/link-token', h('auth', 'createTelegramLinkToken')], + ['GET', '/api/auth/telegram/link-status', h('auth', 'getTelegramLinkStatus')], ['POST', '/api/auth/dev', h('auth', 'devAuth')], ['POST', '/api/auth/preview-reviewer', h('auth', 'previewReviewerAuth')], ['POST', '/api/auth/logout', h('auth', 'logout')], @@ -72,6 +74,8 @@ const ROUTES = [ ['GET', '/api/me/bookings', h('bookings', 'myBookings')], ['GET', '/api/me/profile', h('profile', 'getProfile')], ['PATCH', '/api/me/profile', h('profile', 'updateProfile')], + ['GET', '/api/me/notification-preferences', h('auth', 'getMyNotificationPreferences')], + ['PATCH', '/api/me/notification-preferences', h('auth', 'updateMyNotificationPreferences')], ['GET', '/api/me/reports', h('safety', 'listMyReports')], ['POST', '/api/reports', h('safety', 'submitReport')], ['GET', '/api/me/blocks', h('safety', 'listBlocks')], diff --git a/lib/services/invites.js b/lib/services/invites.js index 12ee889..8aba6b2 100644 --- a/lib/services/invites.js +++ b/lib/services/invites.js @@ -14,7 +14,9 @@ export class ChatNotConfiguredError extends Error { } export function eventChatId(event) { - return event.telegram_chat_id ?? chatIdFromEnv('TG_CHAT_ID_EVENTS'); + // Separate private groups for individual events. + // Never fall back to a shared chat across multiple events. + return event.telegram_chat_id ?? null; } export function tierChatId(tier) { @@ -43,20 +45,35 @@ async function create({ chatId, userId, bookingId = null, entitlementId = null, return row; } -/** Invite for a confirmed booking → event chat. Reuses a still-valid invite. */ +/** + * Invite for a confirmed booking → event chat. + * Reuses a still-valid invite. + * Returns null if event has no dedicated private group configured (no global shared attendee group). + */ export async function inviteForBooking(bookingId) { const b = await db.one( `SELECT b.id, b.user_id, b.status, e.telegram_chat_id, e.slug, e.organiser_user_id FROM bookings b JOIN events e ON e.id = b.event_id WHERE b.id = $1`, [bookingId]); if (!b || !['confirmed', 'checked_in'].includes(b.status)) return null; - // Independent organisers' guests are never added to the platform's shared events chat. - if (b.organiser_user_id && b.telegram_chat_id === null) return null; + // Events must have their own private group configured. If none is configured, do not fall back to a shared chat. + if (b.telegram_chat_id === null) return null; const existing = await activeInvite({ bookingId }); if (existing) return existing; const chatId = eventChatId(b); - if (chatId === null) throw new ChatNotConfiguredError('events (TG_CHAT_ID_EVENTS)'); + if (chatId === null) return null; return create({ chatId, userId: b.user_id, bookingId, name: `bk ${b.slug}` }); } +/** Revoke all active invites for a booking (e.g. upon cancellation or refund). */ +export async function revokeBookingInvites(bookingId) { + const { rows } = await db.query( + `UPDATE invite_links SET revoked_at = now() + WHERE booking_id = $1 AND revoked_at IS NULL + RETURNING id, chat_id, user_id`, + [bookingId] + ); + return rows; +} + /** Invite for an active membership → that tier's chat. */ export async function inviteForEntitlement(entitlementId) { const e = await db.one( diff --git a/lib/services/notifications.js b/lib/services/notifications.js index 78b25e2..ac16eb6 100644 --- a/lib/services/notifications.js +++ b/lib/services/notifications.js @@ -8,6 +8,52 @@ import { logger } from '../logger.js'; import { formatPaise } from './events.js'; export const MAX_ATTEMPTS = 6; +export const NON_URGENT_KINDS = new Set(['event_announcement', 'event_reminder', 'membership_renewed', 'expiry_reminder']); + +/** + * Checks whether quiet hours are currently active for a user in Asia/Kolkata timezone. + */ +export function isQuietHours(user, date = new Date()) { + if (!user?.quiet_hours_enabled) return false; + const parts = new Intl.DateTimeFormat('en-IN', { + timeZone: 'Asia/Kolkata', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23' + }).formatToParts(date); + const hour = parseInt(parts.find((p) => p.type === 'hour')?.value || '0', 10); + const minute = parseInt(parts.find((p) => p.type === 'minute')?.value || '0', 10); + const currentMinutes = hour * 60 + minute; + + const [sH, sM] = String(user.quiet_hours_start || '23:00').split(':').map((v) => parseInt(v, 10)); + const [eH, eM] = String(user.quiet_hours_end || '08:00').split(':').map((v) => parseInt(v, 10)); + const startMinutes = (sH || 0) * 60 + (sM || 0); + const endMinutes = (eH || 0) * 60 + (eM || 0); + + if (startMinutes > endMinutes) { + // Window crosses midnight (e.g. 23:00 to 08:00) + return currentMinutes >= startMinutes || currentMinutes < endMinutes; + } + return currentMinutes >= startMinutes && currentMinutes < endMinutes; +} + +/** + * Calculates the exact Date when quiet hours end for the user in Asia/Kolkata timezone. + */ +export function quietHoursNextAttempt(user, date = new Date()) { + const [eH, eM] = String(user?.quiet_hours_end || '08:00').split(':').map((v) => parseInt(v, 10)); + const now = date.getTime(); + const istDateStr = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Kolkata' }).format(date); + const [y, m, d] = istDateStr.split('-').map(Number); + + // UTC = IST - 5h 30m + const todayEndUtcMs = Date.UTC(y, m - 1, d, eH - 5, eM - 30); + if (todayEndUtcMs > now) { + return new Date(todayEndUtcMs); + } + const tomorrowEndUtcMs = Date.UTC(y, m - 1, d + 1, eH - 5, eM - 30); + return new Date(tomorrowEndUtcMs); +} export async function enqueue(client, { kind, userId = null, bookingId = null, entitlementId = null, eventId = null, dedupeKey }) { const { rows } = await client.query( @@ -61,14 +107,14 @@ const HANDLERS = { inviteErr = err; } const venue = b.venue_privacy === 'public' && b.venue_private ? b.venue_private : null; - const hostEvent = Boolean(b.organiser_user_id) && b.telegram_chat_id === null; + const hasGroup = Boolean(b.telegram_chat_id); const lines = [ `You're booked! ${escHtml(b.title)}`, `${escHtml(fmt(b.start_at))} · ${escHtml(b.area_public ? `${b.area_public}, ` : '')}${escHtml(b.city)}`, - venue ? `Venue: ${escHtml(venue)}` : hostEvent ? 'The exact venue is on your booking page.' : 'The exact venue is shared inside the private group.', + venue ? `Venue: ${escHtml(venue)}` : hasGroup ? 'The exact venue is shared inside the private group.' : 'The exact venue is on your booking page.', b.payment_mode === 'free' ? 'Free ticket — no payment needed.' : b.amount_paise ? `Paid: ${formatPaise(b.amount_paise)}${b.payment_mode === 'external' ? ' on SuperProfile' : livePayments() ? '' : ' (test mode)'}` : null, `Booking ref: ${b.id.slice(0, 8)}`, - hostEvent ? null : invite ? 'Your one-time group invite is below. It works once and expires in 24 hours.' : 'Your private group invite will follow shortly.' + hasGroup ? (invite ? 'Your one-time group invite is below. It works once and expires in 24 hours.' : 'Your private group invite will follow shortly.') : null ].filter(Boolean); const buttons = []; if (invite) buttons.push([{ text: 'Join the private group', url: invite.link }]); @@ -184,11 +230,68 @@ const HANDLERS = { const path = ['weekend_trip', 'resort_getaway'].includes(e.category) ? 'trips' : 'events'; await send(u.telegram_user_id, `New: ${escHtml(e.title)}\n${escHtml(fmt(e.start_at))} · ${escHtml(e.city)}`, [[{ text: 'See details', url: `${siteUrl()}/${path}/${e.slug}` }]]); + }, + + async event_reminder(n) { + const b = await db.one( + `SELECT b.id, e.title, e.start_at, e.city, e.area_public, e.venue_private, e.venue_privacy, + e.telegram_chat_id, u.telegram_user_id, u.reminders_enabled + 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.reminders_enabled === false) return; + const venue = b.venue_privacy === 'public' && b.venue_private ? b.venue_private : null; + const lines = [ + `🔔 Upcoming Event Reminder`, + `${escHtml(b.title)}`, + `Starts: ${escHtml(fmt(b.start_at))}`, + `Location: ${escHtml(b.area_public ? `${b.area_public}, ` : '')}${escHtml(b.city)}`, + venue ? `Venue: ${escHtml(venue)}` : 'Exact venue details are inside your booking pass or private group.', + `Booking ref: ${b.id.slice(0, 8)}` + ]; + await send(b.telegram_user_id, lines.join('\n'), [[{ text: 'View booking pass', url: `${siteUrl()}/bookings/${b.id}` }]]); + }, + + async event_update(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; + 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}` }]]); + }, + + async host_application_received() { + const ids = await adminChatIds(); + if (!ids.length) throw new Error('NOT CONFIGURED: no admin Telegram ids'); + for (const id of ids) { + await send(id, `📋 New host application submitted. Review in admin dashboard: ${siteUrl()}/admin`); + } + }, + + async host_approved(n) { + const u = await db.one('SELECT telegram_user_id FROM users WHERE id = $1', [n.user_id]); + if (!u?.telegram_user_id) return; + await send(u.telegram_user_id, + `🎉 Congratulations! Your host application has been approved.\n\nYou can now create and host curated social events on NIGHTLIFEZ. Visit your organiser dashboard to get started:`, + [[{ text: 'Organiser Dashboard', url: `${siteUrl()}/organiser` }]]); } }; /** 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 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]); + if (u?.quiet_hours_enabled && isQuietHours(u)) { + const nextAttempt = quietHoursNextAttempt(u); + await db.query(`UPDATE notifications SET next_attempt_at = $2 WHERE id = $1`, [id, nextAttempt]); + return 'deferred_quiet_hours'; + } + } + } + const n = await db.one( `UPDATE notifications SET attempts = attempts + 1 WHERE id = $1 AND status <> 'sent' AND attempts < $2 RETURNING *`, [id, MAX_ATTEMPTS]); diff --git a/lib/services/users.js b/lib/services/users.js index 9953f9e..2e55081 100644 --- a/lib/services/users.js +++ b/lib/services/users.js @@ -1,6 +1,8 @@ // lib/services/users.js - Telegram-identity users. Identity always comes from a validated source. +import crypto from 'node:crypto'; import { db } from '../db.js'; import { HttpError } from '../http.js'; +import { botUsername } from '../config.js'; function aliasFrom(tgUser) { const name = [tgUser.first_name, tgUser.last_name].filter(Boolean).join(' ').trim() || tgUser.name || tgUser.username || tgUser.preferred_username; @@ -40,3 +42,109 @@ export async function userByTelegramId(telegramUserId, client = db) { const { rows } = await client.query('SELECT * FROM users WHERE telegram_user_id = $1', [telegramUserId]); return rows[0] || null; } + +/** + * Creates a cryptographically random, single-use, 15-minute account linking token. + */ +export async function createAccountLinkToken(userId = null) { + const token = crypto.randomBytes(24).toString('base64url'); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + await db.query( + `INSERT INTO account_link_tokens (user_id, token, expires_at) + VALUES ($1, $2, $3)`, + [userId, token, expiresAt] + ); + const bot = botUsername(); + const linkUrl = bot ? `https://t.me/${bot}?start=link_${token}` : null; + return { token, linkUrl, expiresAt }; +} + +/** + * Redeems an account link token from Telegram, associating the Telegram account. + */ +export async function redeemAccountLinkToken(token, tgUser) { + if (!token || typeof token !== 'string') return { ok: false, reason: 'missing_token' }; + return await db.tx(async (c) => { + const row = (await c.query( + `SELECT id, user_id, expires_at, used_at FROM account_link_tokens + WHERE token = $1 FOR UPDATE`, [token] + )).rows[0]; + if (!row) return { ok: false, reason: 'not_found' }; + if (row.used_at) return { ok: false, reason: 'already_used' }; + if (new Date(row.expires_at) < new Date()) return { ok: false, reason: 'expired' }; + + // Resolve or upsert the Telegram user + const tgDbUser = await upsertTelegramUser(tgUser, { botStarted: true }, c); + + // If token was issued for an existing web user, link identities + if (row.user_id && row.user_id !== tgDbUser.id) { + await c.query( + `INSERT INTO user_identities (user_id, provider, subject, verified_at) + VALUES ($1, 'telegram', $2, now()) + ON CONFLICT (provider, subject) DO UPDATE SET user_id = EXCLUDED.user_id, verified_at = now()`, + [row.user_id, String(tgUser.id)] + ); + // Transfer bot_started flag to the web user + await c.query(`UPDATE users SET bot_started = true WHERE id = $1`, [row.user_id]); + } + + await c.query( + `UPDATE account_link_tokens SET used_at = now(), telegram_user_id = $2 WHERE id = $1`, + [row.id, tgUser.id] + ); + + return { ok: true, userId: row.user_id || tgDbUser.id, telegramUserId: tgUser.id }; + }); +} + +/** + * Checks the status of an account link token. + */ +export async function getAccountLinkStatus(token) { + if (!token || typeof token !== 'string') return null; + const { rows } = await db.query( + `SELECT token, telegram_user_id, used_at, expires_at FROM account_link_tokens WHERE token = $1`, + [token] + ); + if (!rows.length) return null; + const r = rows[0]; + const isExpired = new Date(r.expires_at) < new Date(); + return { + token: r.token, + linked: Boolean(r.used_at), + telegramUserId: r.telegram_user_id, + usedAt: r.used_at, + expired: isExpired && !r.used_at + }; +} + +/** + * Updates notification preferences and quiet hours for a user. + */ +export async function updateNotificationPreferences(userId, { remindersEnabled, quietHoursEnabled, quietHoursStart, quietHoursEnd }) { + const sets = []; + const vals = [userId]; + let idx = 2; + if (remindersEnabled !== undefined) { sets.push(`reminders_enabled = $${idx++}`); vals.push(Boolean(remindersEnabled)); } + if (quietHoursEnabled !== undefined) { sets.push(`quiet_hours_enabled = $${idx++}`); vals.push(Boolean(quietHoursEnabled)); } + if (quietHoursStart !== undefined) { sets.push(`quiet_hours_start = $${idx++}`); vals.push(quietHoursStart); } + if (quietHoursEnd !== undefined) { sets.push(`quiet_hours_end = $${idx++}`); vals.push(quietHoursEnd); } + if (!sets.length) return getNotificationPreferences(userId); + const { rows } = await db.query( + `UPDATE users SET ${sets.join(', ')} WHERE id = $1 + RETURNING reminders_enabled, quiet_hours_enabled, quiet_hours_start, quiet_hours_end`, + vals + ); + return rows[0] || null; +} + +/** + * Retrieves notification preferences and quiet hours for a user. + */ +export async function getNotificationPreferences(userId) { + const { rows } = await db.query( + `SELECT reminders_enabled, quiet_hours_enabled, quiet_hours_start, quiet_hours_end FROM users WHERE id = $1`, + [userId] + ); + return rows[0] || null; +} diff --git a/lib/telegram/bot.js b/lib/telegram/bot.js index a99cfa9..b79945d 100644 --- a/lib/telegram/bot.js +++ b/lib/telegram/bot.js @@ -2,12 +2,14 @@ import { db } from '../db.js'; import { tg } from './api.js'; import { siteUrl, botUsername, env } from '../config.js'; -import { upsertTelegramUser } from '../services/users.js'; +import { upsertTelegramUser, redeemAccountLinkToken, updateNotificationPreferences, getNotificationPreferences } from '../services/users.js'; import { listPublicEvents, getPublicEventBySlug, listTiers } from '../services/events.js'; import { validatePreCheckout, handleSuccessfulPayment, createMembershipInvoice } from '../services/membership.js'; import { inviteForBooking, onChatMemberJoined } from '../services/invites.js'; import { deliverMany } from '../services/notifications.js'; import { claimOrder, startClaimPrompt, isClaimPromptOpen, CLAIM_LIMIT_PER_HOUR } from '../services/claims.js'; +import { claimUpiBooking } from '../services/upi-payments.js'; +import { rateLimit } from '../ratelimit.js'; import { eventPath } from '../views/components.js'; import { logger } from '../logger.js'; @@ -18,6 +20,7 @@ export const COMMANDS = [ ['claim', 'Claim a ticket you bought on SuperProfile'], ['membership', 'Membership plans'], ['report', 'Report a safety concern'], + ['quiet_hours', 'Notification quiet hours'], ['help', 'Help and support'], ['terms', 'Terms and refunds'], ['paysupport', 'Payment support'] @@ -44,6 +47,40 @@ function supportLine() { async function cmdStart(msg, user, param) { const chat = msg.chat.id; + if (param?.startsWith('link_')) { + const token = param.slice(5); + const r = await redeemAccountLinkToken(token, msg.from); + if (!r.ok) { + return reply(chat, 'This account linking link is invalid or has expired. Please generate a new link from your NIGHTLIFEZ profile.'); + } + return reply(chat, + 'Your NIGHTLIFEZ web account is now securely linked to Telegram! ✅\n\n' + + 'Booking passes and exclusive event invites will be sent directly to this chat.', + [appButton('Open NIGHTLIFEZ', '/me')]); + } + if (param?.startsWith('claim_upi_')) { + const parts = param.slice(10).split('_'); + if (parts.length >= 2) { + const orderId = parts[0]; + const guestToken = parts.slice(1).join('_'); + try { + const claimed = await claimUpiBooking({ orderId, guestToken, user }); + const bId = claimed.bookingId; + const b = bId ? await db.one('SELECT b.id, e.title FROM bookings b JOIN events e ON e.id = b.event_id WHERE b.id = $1', [bId]) : null; + const inv = bId ? await inviteForBooking(bId).catch(() => null) : null; + const buttons = []; + if (inv) buttons.push([{ text: 'Join the private group', url: inv.link }]); + if (bId) buttons.push([{ text: 'View booking pass', url: `${siteUrl()}/bookings/${bId}` }]); + return reply(chat, + `Booking claimed successfully! ✅\n${esc(b?.title || 'Event')}\n` + + `Booking ref: ${String(bId || '').slice(0, 8)}\n\n` + + (inv ? 'Your one-time private group invite is below:' : 'Your booking is confirmed.'), + buttons); + } catch (err) { + return reply(chat, `Could not claim booking: ${esc(err.message || 'invalid token or order')}`); + } + } + } if (param?.startsWith('event_')) { const e = await getPublicEventBySlug(param.slice(6)); if (e) { @@ -71,6 +108,36 @@ async function cmdStart(msg, user, param) { await db.query('UPDATE users SET alerts_opt_in = true WHERE id = $1', [user.id]); return reply(chat, 'Done — we will message you here when new events are announced. Send /stopalerts to opt out.'); } + + // Operational recovery: check if user has confirmed bookings where invite was not yet delivered + const pendingInvites = await db.query( + `SELECT b.id, e.title, e.telegram_chat_id + FROM bookings b + JOIN events e ON e.id = b.event_id + WHERE b.user_id = $1 + AND b.status IN ('confirmed', 'checked_in') + AND e.telegram_chat_id IS NOT NULL + AND COALESCE(e.end_at, e.start_at) > now() - interval '1 day' + AND NOT EXISTS ( + SELECT 1 FROM invite_links i WHERE i.booking_id = b.id AND i.revoked_at IS NULL + ) + LIMIT 1`, + [user.id] + ); + if (pendingInvites.rows.length > 0) { + const pb = pendingInvites.rows[0]; + try { + const inv = await inviteForBooking(pb.id); + if (inv) { + return reply(chat, + `Welcome to NIGHTLIFEZ!\n\nWe found a confirmed booking for ${esc(pb.title)}. Here is your private group invite (expires in 24 hours):`, + [[{ text: 'Join the private group', url: inv.link }], appButton('Open NIGHTLIFEZ')]); + } + } catch (err) { + logger.warn('recovery invite failed on /start', { reason: err.message }); + } + } + return reply(chat, 'Welcome to NIGHTLIFEZ — curated weekend parties, nightlife, social mixers, dinner clubs and trips for adults 21+ across South India.\n\n' + 'Browse and book below. Your booking confirmations and private group invites will arrive in this chat.', @@ -137,12 +204,72 @@ async function cmdMembership(chat, user) { } async function cmdReport(msg, user, text) { + const chat = msg.chat.id; const body = text.trim(); if (!body) { - return reply(msg.chat.id, 'Please send /report followed by what happened, when, and at which event. Example:\n/report At the Chennai dinner on 7 Nov, someone kept messaging me after I said no.\n\nIf you are in immediate danger, call 112.'); + return reply(chat, + '⚠️ If you are in immediate danger, call 112 or local emergency services.\n' + + 'Women’s Helpline: 1091 / 181\n\n' + + 'To report a safety issue to NIGHTLIFEZ moderators, reply with /report followed by what happened, when, and at which event.\n\n' + + 'Example:\n/report At the Indiranagar mixer on Friday, someone made unwelcome advances after being asked to stop.'); + } + + if (body.length < 10) { + return reply(chat, 'Please provide more details in your report (at least 10 characters) so our moderation team can investigate.\n\nEmergency helpline: 112.'); } - await db.query('INSERT INTO reports (user_id, body) VALUES ($1, $2)', [user.id, body.slice(0, 4000)]); - return reply(msg.chat.id, 'Thank you. Your report has been recorded and a moderator will review it. We keep reports confidential.'); + + try { + await rateLimit(`tg_report:${user.id}`, 5, 3600); + } catch (err) { + if (err.status === 429) { + return reply(chat, 'You have submitted several reports recently. For urgent assistance, please call 112 or contact support.'); + } + throw err; + } + + await db.query( + `INSERT INTO reports (user_id, category, body, status) VALUES ($1, 'safety_concern', $2, 'new')`, + [user.id, body.slice(0, 3000)] + ); + return reply(chat, + 'Thank you. Your report has been recorded and a NIGHTLIFEZ moderator will review it confidentially.\n\n' + + 'If you are in immediate danger or need urgent on-ground help, please call 112.'); +} + +async function cmdQuietHours(msg, user, arg) { + const chat = msg.chat.id; + const clean = arg.trim().toLowerCase(); + if (!clean) { + const prefs = await getNotificationPreferences(user.id); + const status = prefs?.quiet_hours_enabled ? 'ON' : 'OFF'; + const start = prefs?.quiet_hours_start ? String(prefs.quiet_hours_start).slice(0, 5) : '23:00'; + const end = prefs?.quiet_hours_end ? String(prefs.quiet_hours_end).slice(0, 5) : '08:00'; + return reply(chat, + `Quiet Hours Settings\n\n` + + `Status: ${status}\n` + + `Window: ${esc(start)} to ${esc(end)} IST\n\n` + + `During quiet hours, non-urgent notifications (announcements and reminders) are held until morning. Urgent booking and refund confirmations are always delivered immediately.\n\n` + + `To change: send /quiet_hours on, /quiet_hours off, or /quiet_hours 23:00 08:00.`); + } + + if (clean === 'on' || clean === 'enable') { + await updateNotificationPreferences(user.id, { quietHoursEnabled: true }); + return reply(chat, 'Quiet hours enabled ✅ Non-urgent alerts will be muted from 23:00 to 08:00 IST.'); + } + if (clean === 'off' || clean === 'disable') { + await updateNotificationPreferences(user.id, { quietHoursEnabled: false }); + return reply(chat, 'Quiet hours disabled. You will receive notifications as soon as they are generated.'); + } + + const times = clean.split(/\s+/); + if (times.length === 2 && /^\d{1,2}:\d{2}$/.test(times[0]) && /^\d{1,2}:\d{2}$/.test(times[1])) { + const start = times[0].padStart(5, '0'); + const end = times[1].padStart(5, '0'); + await updateNotificationPreferences(user.id, { quietHoursEnabled: true, quietHoursStart: start, quietHoursEnd: end }); + return reply(chat, `Quiet hours updated ✅ Muting non-urgent alerts between ${esc(start)} and ${esc(end)} IST.`); + } + + return reply(chat, 'Usage: /quiet_hours on, /quiet_hours off, or /quiet_hours 23:00 08:00.'); } async function onMessage(msg) { @@ -174,6 +301,7 @@ async function onMessage(msg) { case 'claim': return cmdClaim(msg, user, arg); case 'membership': return cmdMembership(msg.chat.id, user); case 'report': return cmdReport(msg, user, arg); + case 'quiet_hours': return cmdQuietHours(msg, user, arg); case 'stopalerts': await db.query('UPDATE users SET alerts_opt_in = false WHERE id = $1', [user.id]); return reply(msg.chat.id, 'You will no longer receive new-event alerts.'); @@ -185,8 +313,17 @@ async function onMessage(msg) { case 'support': default: return reply(msg.chat.id, - 'Help\n/events — upcoming events\n/mybookings — your bookings and invites\n/claim — claim a ticket you bought on SuperProfile\n/membership — plans\n/report — report a safety concern\n/paysupport — payment problems\n/terms — terms and refunds\n\n' + - `To cancel or get a refund, reply here with your booking reference. ${supportLine()}`); + 'NIGHTLIFEZ Support & Community Care\n\n' + + '/events — upcoming curated nightlife, mixers & trips\n' + + '/mybookings — your tickets and private group invites\n' + + '/claim — claim a ticket you bought on SuperProfile or UPI\n' + + '/membership — plans and perks\n' + + '/quiet_hours — mute non-urgent night alerts\n' + + '/report — report a safety concern (reviewed by moderators)\n' + + '/paysupport — payment queries\n' + + '/terms — terms, billing and refunds\n\n' + + '🚨 Emergency Services: 112 | Women’s Helpline: 1091 / 181\n\n' + + `To cancel or request a refund, reply with your booking ref. ${supportLine()}`); } } diff --git a/migrations/018_telegram_operational_workflows.sql b/migrations/018_telegram_operational_workflows.sql new file mode 100644 index 0000000..18279c2 --- /dev/null +++ b/migrations/018_telegram_operational_workflows.sql @@ -0,0 +1,25 @@ +-- 018_telegram_operational_workflows.sql — Telegram account linking tokens, user notification preferences & quiet hours. +-- Additive only. Safe to re-run. + +-- 1. User notification preferences and quiet hours +ALTER TABLE users ADD COLUMN IF NOT EXISTS reminders_enabled BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE users ADD COLUMN IF NOT EXISTS quiet_hours_enabled BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE users ADD COLUMN IF NOT EXISTS quiet_hours_start TIME NOT NULL DEFAULT '23:00'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS quiet_hours_end TIME NOT NULL DEFAULT '08:00'; + +-- 2. Account linking tokens: single-use, 15-minute cryptographically secure tokens linking web sessions to Telegram +CREATE TABLE IF NOT EXISTS account_link_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + token TEXT UNIQUE NOT NULL, + telegram_user_id BIGINT, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_account_link_tokens_token ON account_link_tokens(token) WHERE used_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_account_link_tokens_user ON account_link_tokens(user_id); + +-- 3. Index on events.telegram_chat_id for event group chat resolution +CREATE INDEX IF NOT EXISTS idx_events_telegram_chat ON events (telegram_chat_id) WHERE telegram_chat_id IS NOT NULL; diff --git a/test/helpers.js b/test/helpers.js index 27bc709..01b8eeb 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -189,12 +189,13 @@ export async function signedInClient({ ageConfirmed = true, tgId } = {}) { export async function createEvent(overrides = {}) { const slug = overrides.slug || `test-event-${crypto.randomBytes(4).toString('hex')}`; const row = await db.one( - `INSERT INTO events (slug, category, title, city, city_id, lat, lng, coords_provenance, start_at, end_at, capacity, ticket_price_paise, price_status, status, is_demo, published_at, venue_private, venue_privacy) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now() + interval '10 days', now() + interval '10 days 3 hours', $9, $10, $11, $12, $13, now(), $14, $15) RETURNING *`, + `INSERT INTO events (slug, category, title, city, city_id, lat, lng, coords_provenance, start_at, end_at, capacity, ticket_price_paise, price_status, status, is_demo, published_at, venue_private, venue_privacy, telegram_chat_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now() + interval '10 days', now() + interval '10 days 3 hours', $9, $10, $11, $12, $13, now(), $14, $15, $16) RETURNING *`, [slug, overrides.category || 'meetup', overrides.title || 'Test Meetup', overrides.city || 'Chennai', overrides.city_id ?? null, overrides.lat ?? null, overrides.lng ?? null, overrides.coords_provenance || (overrides.lat != null ? 'venue' : 'none'), overrides.capacity ?? 10, overrides.price === null ? null : (overrides.price ?? 49900), overrides.price === null ? 'tbd' : 'set', overrides.status || 'published', overrides.isDemo ?? false, - overrides.venue || 'Secret Cafe, 12 Beach Rd', overrides.venuePrivacy || 'disclosed_after_booking']); + overrides.venue || 'Secret Cafe, 12 Beach Rd', overrides.venuePrivacy || 'disclosed_after_booking', + overrides.telegram_chat_id !== undefined ? overrides.telegram_chat_id : -1001111111111]); return row; } diff --git a/test/migrations.test.js b/test/migrations.test.js index e30a092..24ca792 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 15 migrations currently exist: 003 through 017 - assert.equal(files.length, 15, `expected 15 migration files, got ${files.length}: ${files.join(', ')}`); + // Exactly 16 migrations currently exist: 003 through 018 + assert.equal(files.length, 16, `expected 16 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_' + '009_', '010_', '011_', '012_', '013_', '014_', '015_', '016_', '017_', '018_' ]; 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 15 migrations in correct order', async () => { +test('schema_migrations table records all 16 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, 15); + assert.equal(rows.length, 16); 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 15 + // Total count in schema_migrations must remain exactly 16 const countRes = await db.one('SELECT count(*)::int AS n FROM schema_migrations'); - assert.equal(countRes.n, 15); + assert.equal(countRes.n, 16); }); test('migration policy respects environment boundaries', () => { diff --git a/test/telegram-workflows.test.js b/test/telegram-workflows.test.js new file mode 100644 index 0000000..31404df --- /dev/null +++ b/test/telegram-workflows.test.js @@ -0,0 +1,454 @@ +// test/telegram-workflows.test.js - Milestone M5: Telegram identity, event community isolation, +// operational notifications, quiet hours, safety reporting, and recovery workflows. +import { test, before, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import { + resetDb, startServer, stopServer, Client, db, callsTo, clearCalls, + createEvent, signedInClient +} from './helpers.js'; +import { inviteForBooking, revokeBookingInvites, activeInvite } from '../lib/services/invites.js'; +import { enqueue, deliver, isQuietHours, quietHoursNextAttempt } from '../lib/services/notifications.js'; +import { createAccountLinkToken, redeemAccountLinkToken, getAccountLinkStatus } from '../lib/services/users.js'; + +const SECRET = { 'x-telegram-bot-api-secret-token': 'test_webhook_secret_123456' }; +let seq = 8000; +const msg = (from, text, extra = {}) => ({ + update_id: ++seq, + message: { message_id: seq, date: 0, chat: { id: from.id, type: 'private' }, from, text, ...extra } +}); + +before(async () => { + await resetDb(); + await startServer(); +}); +after(stopServer); +beforeEach(clearCalls); + +// ========================================================================= +// 1. Telegram Identity & Account Linking +// ========================================================================= + +test('account linking: generates link token and status endpoint tracks state', async () => { + const userClient = await signedInClient({ ageConfirmed: true }); + const res = await userClient.post('/api/auth/telegram/link-token', {}); + assert.equal(res.status, 200); + assert.ok(res.json.token); + assert.match(res.json.linkUrl, /start=link_/); + + const statusBefore = await userClient.get(`/api/auth/telegram/link-status?token=${res.json.token}`); + assert.equal(statusBefore.status, 200); + assert.equal(statusBefore.json.linked, false); + assert.equal(statusBefore.json.expired, false); +}); + +test('account linking: redeeming token via Telegram /start link_ links web user', async () => { + const userClient = await signedInClient({ ageConfirmed: true }); + const gen = await userClient.post('/api/auth/telegram/link-token', {}); + const token = gen.json.token; + + const tgSender = { id: 7001, first_name: 'Ananya', username: 'ananya_blr' }; + const c = new Client(); + const update = msg(tgSender, `/start link_${token}`); + const r = await c.post('/api/telegram/webhook', update, SECRET); + assert.equal(r.status, 200); + + const reply = callsTo('sendMessage').find((m) => m.params.chat_id === 7001); + assert.match(reply.params.text, /securely linked to Telegram/); + + // Status should now be linked + const statusAfter = await userClient.get(`/api/auth/telegram/link-status?token=${token}`); + assert.equal(statusAfter.status, 200); + assert.equal(statusAfter.json.linked, true); + assert.equal(statusAfter.json.telegramUserId, 7001); + + // Identity should exist in user_identities + const identity = await db.one( + `SELECT * FROM user_identities WHERE user_id = $1 AND provider = 'telegram' AND subject = '7001'`, + [userClient.userId] + ); + assert.equal(identity.subject, '7001'); + + // Re-using the same link token must fail + clearCalls(); + const reuseUpdate = msg(tgSender, `/start link_${token}`); + await c.post('/api/telegram/webhook', reuseUpdate, SECRET); + const reuseReply = callsTo('sendMessage').find((m) => m.params.chat_id === 7001); + assert.match(reuseReply.params.text, /invalid or has expired/); +}); + +test('account linking: expired link token is rejected', async () => { + const expiredToken = crypto.randomBytes(24).toString('base64url'); + await db.query( + `INSERT INTO account_link_tokens (token, expires_at) VALUES ($1, now() - interval '5 minutes')`, + [expiredToken] + ); + + const tgSender = { id: 7002, first_name: 'Vikram' }; + const c = new Client(); + await c.post('/api/telegram/webhook', msg(tgSender, `/start link_${expiredToken}`), SECRET); + const reply = callsTo('sendMessage').find((m) => m.params.chat_id === 7002); + assert.match(reply.params.text, /invalid or has expired/); +}); + +// ========================================================================= +// 2. Event Community Isolation & No Global Attendee Group +// ========================================================================= + +test('event community isolation: separate private groups per event; no global shared group', async () => { + const user = await db.one(`INSERT INTO users (telegram_user_id) VALUES (7101) RETURNING id`); + + // Event A with its own private group + const eventA = await createEvent({ telegram_chat_id: -1001234500001, title: 'Event A Mixer' }); + // Event B with its own private group + const eventB = await createEvent({ telegram_chat_id: -1001234500002, title: 'Event B Party' }); + // Event C without an assigned Telegram chat + const eventC = await createEvent({ telegram_chat_id: null, title: 'Event C Dinner' }); + + const bookingA = await db.one( + `INSERT INTO bookings (event_id, user_id, status) VALUES ($1, $2, 'confirmed') RETURNING id`, + [eventA.id, user.id] + ); + const bookingB = await db.one( + `INSERT INTO bookings (event_id, user_id, status) VALUES ($1, $2, 'confirmed') RETURNING id`, + [eventB.id, user.id] + ); + const bookingC = await db.one( + `INSERT INTO bookings (event_id, user_id, status) VALUES ($1, $2, 'confirmed') RETURNING id`, + [eventC.id, user.id] + ); + + const inviteA = await inviteForBooking(bookingA.id); + const inviteB = await inviteForBooking(bookingB.id); + const inviteC = await inviteForBooking(bookingC.id); + + assert.ok(inviteA, 'Event A should generate invite'); + assert.ok(inviteB, 'Event B should generate invite'); + assert.equal(inviteC, null, 'Event C without dedicated chat must return null (no fallback to global chat)'); + + // Verify invite links were created for separate chat IDs + const createdInvites = callsTo('createChatInviteLink'); + assert.equal(createdInvites.length, 2); + assert.equal(createdInvites[0].params.chat_id, -1001234500001); + assert.equal(createdInvites[1].params.chat_id, -1001234500002); +}); + +test('unconfirmed booking receives no invite link', async () => { + const user = await db.one(`INSERT INTO users (telegram_user_id) VALUES (7102) RETURNING id`); + const ev = await createEvent({ telegram_chat_id: -1001234500003 }); + + // Pending payment + const pendingBooking = await db.one( + `INSERT INTO bookings (event_id, user_id, status) VALUES ($1, $2, 'pending_payment') RETURNING id`, + [ev.id, user.id] + ); + assert.equal(await inviteForBooking(pendingBooking.id), null); + + // Cancelled booking + const cancelledBooking = await db.one( + `INSERT INTO bookings (event_id, user_id, status) VALUES ($1, $2, 'cancelled') RETURNING id`, + [ev.id, user.id] + ); + assert.equal(await inviteForBooking(cancelledBooking.id), null); +}); + +test('invite link reuse and duplicate protection', async () => { + const user = await db.one(`INSERT INTO users (telegram_user_id) VALUES (7103) RETURNING id`); + const ev = await createEvent({ telegram_chat_id: -1001234500004 }); + const b = await db.one( + `INSERT INTO bookings (event_id, user_id, status) VALUES ($1, $2, 'confirmed') RETURNING id`, + [ev.id, user.id] + ); + + const inv1 = await inviteForBooking(b.id); + const inv2 = await inviteForBooking(b.id); + assert.equal(inv1.link, inv2.link, 'Repeated call must reuse existing active invite link'); + assert.equal(callsTo('createChatInviteLink').length, 1, 'Only one API call to Telegram'); +}); + +test('revoking booking invites on refund/cancellation', async () => { + const user = await db.one(`INSERT INTO users (telegram_user_id) VALUES (7104) RETURNING id`); + const ev = await createEvent({ telegram_chat_id: -1001234500005 }); + const b = await db.one( + `INSERT INTO bookings (event_id, user_id, status) VALUES ($1, $2, 'confirmed') RETURNING id`, + [ev.id, user.id] + ); + + const inv = await inviteForBooking(b.id); + assert.ok(inv); + + const activeBefore = await activeInvite({ bookingId: b.id }); + assert.ok(activeBefore); + + await revokeBookingInvites(b.id); + + const activeAfter = await activeInvite({ bookingId: b.id }); + assert.equal(activeAfter, null, 'Revoked invite must no longer be active'); +}); + +// ========================================================================= +// 3. Operational Notifications & Quiet Hours +// ========================================================================= + +test('quiet hours calculation: correctly detects IST time window and midnight crossing', () => { + const user = { + quiet_hours_enabled: true, + quiet_hours_start: '23:00', + quiet_hours_end: '08:00' + }; + + // 23:30 IST -> in quiet hours (18:00 UTC) + const d1 = new Date('2026-11-05T18:00:00Z'); + assert.equal(isQuietHours(user, d1), true); + + // 05:00 IST -> in quiet hours (23:30 UTC previous day) + const d2 = new Date('2026-11-05T23:30:00Z'); + assert.equal(isQuietHours(user, d2), true); + + // 14:00 IST -> outside quiet hours (08:30 UTC) + const d3 = new Date('2026-11-05T08:30:00Z'); + assert.equal(isQuietHours(user, d3), false); + + // Quiet hours disabled + assert.equal(isQuietHours({ quiet_hours_enabled: false }, d1), false); +}); + +test('quiet hours delivery deferral for non-urgent notifications vs immediate urgent delivery', async () => { + const user = await db.one( + `INSERT INTO users (telegram_user_id, quiet_hours_enabled, quiet_hours_start, quiet_hours_end) + VALUES (7201, true, '00:00', '23:59') RETURNING id` + ); + const ev = await createEvent(); + const b = await db.one( + `INSERT INTO bookings (event_id, user_id, status) VALUES ($1, $2, 'confirmed') RETURNING id`, + [ev.id, user.id] + ); + + // 1. Non-urgent notification (event_reminder) during quiet hours is deferred + const notifId = await enqueue(db, { + kind: 'event_reminder', + userId: user.id, + bookingId: b.id, + dedupeKey: `er:test:${Date.now()}` + }); + + const res1 = await deliver(notifId); + assert.equal(res1, 'deferred_quiet_hours'); + assert.equal(callsTo('sendMessage').length, 0); + + // 2. Urgent notification (booking_confirmed) delivers immediately despite quiet hours + const urgentId = await enqueue(db, { + kind: 'booking_confirmed', + userId: user.id, + bookingId: b.id, + dedupeKey: `bc:test:${Date.now()}` + }); + + const res2 = await deliver(urgentId); + assert.equal(res2, 'sent'); + assert.equal(callsTo('sendMessage').length, 1); +}); + +test('notification preferences API: user can view and update quiet hours and reminders', async () => { + const c = await signedInClient({ ageConfirmed: true }); + + const get1 = await c.get('/api/me/notification-preferences'); + assert.equal(get1.status, 200); + assert.equal(get1.json.preferences.reminders_enabled, true); + + const update = await c.req('PATCH', '/api/me/notification-preferences', { + quietHoursEnabled: true, + quietHoursStart: '22:00', + quietHoursEnd: '07:00', + remindersEnabled: true + }); + assert.equal(update.status, 200); + assert.equal(update.json.preferences.quiet_hours_enabled, true); + assert.equal(update.json.preferences.quiet_hours_start, '22:00:00'); + assert.equal(update.json.preferences.quiet_hours_end, '07:00:00'); +}); + +// ========================================================================= +// 4. Safety & Support Workflows +// ========================================================================= + +test('Telegram /report: validates body, rate limits, and provides emergency contacts', async () => { + const c = new Client(); + const tgUser = { id: 7301, first_name: 'Tara' }; + + // 1. Empty /report returns instructions and emergency 112 info + await c.post('/api/telegram/webhook', msg(tgUser, '/report'), SECRET); + let reply = callsTo('sendMessage').pop().params.text; + assert.match(reply, /call 112/); + assert.match(reply, /1091/); + + // 2. Overly short report (<10 chars) prompts for more details + clearCalls(); + await c.post('/api/telegram/webhook', msg(tgUser, '/report short'), SECRET); + reply = callsTo('sendMessage').pop().params.text; + assert.match(reply, /at least 10 characters/); + + // 3. Valid report stores in database with category safety_concern and status new + clearCalls(); + await c.post('/api/telegram/webhook', msg(tgUser, '/report Indiranagar event: someone was harassing guests near the exit'), SECRET); + reply = callsTo('sendMessage').pop().params.text; + assert.match(reply, /recorded and a NIGHTLIFEZ moderator will review it/); + + const reportRow = await db.one(`SELECT category, body, status FROM reports WHERE body LIKE '%harassing%'`); + assert.equal(reportRow.category, 'safety_concern'); + assert.equal(reportRow.status, 'new'); +}); + +test('Telegram /quiet_hours command toggles and configures notification window', async () => { + const c = new Client(); + const tgUser = { id: 7302, first_name: 'Rahul' }; + + // View status + await c.post('/api/telegram/webhook', msg(tgUser, '/quiet_hours'), SECRET); + let reply = callsTo('sendMessage').pop().params.text; + assert.match(reply, /Quiet Hours Settings/); + + // Enable + clearCalls(); + await c.post('/api/telegram/webhook', msg(tgUser, '/quiet_hours on'), SECRET); + reply = callsTo('sendMessage').pop().params.text; + assert.match(reply, /Quiet hours enabled/); + + // Set custom hours + clearCalls(); + await c.post('/api/telegram/webhook', msg(tgUser, '/quiet_hours 22:30 07:30'), SECRET); + reply = callsTo('sendMessage').pop().params.text; + assert.match(reply, /22:30/); + assert.match(reply, /07:30/); + + // Disable + clearCalls(); + await c.post('/api/telegram/webhook', msg(tgUser, '/quiet_hours off'), SECRET); + reply = callsTo('sendMessage').pop().params.text; + assert.match(reply, /Quiet hours disabled/); +}); + +// ========================================================================= +// 5. Operational Recovery & Unreachable User Handling +// ========================================================================= + +test('operational recovery: /start delivers pending invite to previously unreachable user', async () => { + const user = await db.one(`INSERT INTO users (telegram_user_id) VALUES (7401) RETURNING id`); + const ev = await createEvent({ telegram_chat_id: -1001234500009, title: 'Bangalore Rooftop Party' }); + + // Confirmed booking exists, but user never received the invite (e.g. booked on web before starting bot) + await db.one( + `INSERT INTO bookings (event_id, user_id, status) VALUES ($1, $2, 'confirmed') RETURNING id`, + [ev.id, user.id] + ); + + const c = new Client(); + const tgUser = { id: 7401, first_name: 'Dev' }; + await c.post('/api/telegram/webhook', msg(tgUser, '/start'), SECRET); + + const reply = callsTo('sendMessage').find((m) => m.params.chat_id === 7401); + assert.match(reply.params.text, /Bangalore Rooftop Party/); + assert.match(reply.params.text, /private group invite/); + assert.ok(reply.params.reply_markup.inline_keyboard[0][0].url.startsWith('https://t.me/+test')); +}); + +test('operational recovery: /start claim_upi__ claims guest payment', async () => { + const ev = await createEvent({ telegram_chat_id: -1001234500010, title: 'Kochi Yacht Mixer' }); + const orderId = `UPI${Date.now()}`; + const guestToken = crypto.randomBytes(24).toString('base64url'); + + // Insert guest payment row (user_id is NULL) with status 'succeeded' + const pay = await db.one( + `INSERT INTO payments (provider, provider_txn_id, order_id, merchant_id, purpose, user_id, amount, currency, status, raw_ref) + VALUES ('phonepe', $1, $2, 'MERCHANT_TEST', 'ticket', NULL, 99900, 'INR', 'succeeded', $3) + RETURNING id`, + [`TXN_${Date.now()}`, orderId, JSON.stringify({ eventId: ev.id, qty: 1, guestToken })] + ); + + const c = new Client(); + const tgUser = { id: 7402, first_name: 'Pooja' }; + const r = await c.post('/api/telegram/webhook', msg(tgUser, `/start claim_upi_${orderId}_${guestToken}`), SECRET); + assert.equal(r.status, 200); + + const reply = callsTo('sendMessage').find((m) => m.params.chat_id === 7402); + assert.match(reply.params.text, /Booking claimed successfully/); + assert.match(reply.params.text, /Kochi Yacht Mixer/); + + // Booking should now be confirmed and attached to user + const updatedPay = await db.one('SELECT user_id, booking_id FROM payments WHERE id = $1', [pay.id]); + assert.ok(updatedPay.user_id); + assert.ok(updatedPay.booking_id); + const booking = await db.one('SELECT * FROM bookings WHERE id = $1', [updatedPay.booking_id]); + assert.equal(booking.status, 'confirmed'); +}); + +test('operational notification handlers: event_update, host_application_received, host_approved', async () => { + const host = await db.one(`INSERT INTO users (telegram_user_id) VALUES (7501) RETURNING id`); + const attendee = await db.one(`INSERT INTO users (telegram_user_id) VALUES (7502) RETURNING id`); + const ev = await createEvent({ telegram_chat_id: -1001234500011, title: 'Chennai Pub Crawl' }); + const b = await db.one(`INSERT INTO bookings (event_id, user_id, status) VALUES ($1, $2, 'confirmed') RETURNING id`, [ev.id, attendee.id]); + + // 1. event_update + const updateNotifId = await enqueue(db, { + kind: 'event_update', + bookingId: b.id, + dedupeKey: `eu:${ev.id}:${attendee.id}:venue_change` + }); + await deliver(updateNotifId); + let sent = callsTo('sendMessage').pop(); + assert.equal(sent.params.chat_id, 7502); + assert.match(sent.params.text, /Event Update: Chennai Pub Crawl/); + + // 2. host_approved + clearCalls(); + const hostApprovedNotifId = await enqueue(db, { + kind: 'host_approved', + userId: host.id, + dedupeKey: `ha:${host.id}` + }); + await deliver(hostApprovedNotifId); + sent = callsTo('sendMessage').pop(); + assert.equal(sent.params.chat_id, 7501); + assert.match(sent.params.text, /host application has been approved/); + + // 3. host_application_received (goes to admin bootstrap) + clearCalls(); + const hostAppNotifId = await enqueue(db, { + kind: 'host_application_received', + dedupeKey: `har:${Date.now()}` + }); + await deliver(hostAppNotifId); + sent = callsTo('sendMessage').pop(); + assert.match(sent.params.text, /New host application submitted/); +}); + +test('webhook robustness: missing secret, bad JSON, invalid update_id, and duplicate replay', async () => { + const c = new Client(); + + // Missing secret -> 401 + assert.equal((await c.post('/api/telegram/webhook', { update_id: 9991 })).status, 401); + + // Wrong secret -> 401 + assert.equal((await c.post('/api/telegram/webhook', { update_id: 9992 }, { 'x-telegram-bot-api-secret-token': 'wrong' })).status, 401); + + // Invalid JSON (using low-level request with bad body) + const badJsonRes = await c.req('POST', '/api/telegram/webhook', 'not a json', { + ...SECRET, + 'content-type': 'application/json' + }); + assert.equal(badJsonRes.status, 400); + + // Missing update_id + assert.equal((await c.post('/api/telegram/webhook', { message: {} }, SECRET)).status, 400); + + // Duplicate replay + const validUpdate = msg({ id: 7601, first_name: 'Test' }, '/events'); + const r1 = await c.post('/api/telegram/webhook', validUpdate, SECRET); + assert.equal(r1.status, 200); + + clearCalls(); + const r2 = await c.post('/api/telegram/webhook', validUpdate, SECRET); + assert.equal(r2.status, 200); + assert.equal(r2.json.duplicate, true); + assert.equal(callsTo('sendMessage').length, 0, 'Duplicate webhook replay must not trigger duplicate messages'); +});