From ad6e2227b42af8a1ae4ae185043d907a5ce86bdd Mon Sep 17 00:00:00 2001 From: Satelink Network Date: Fri, 6 Nov 2026 16:02:57 +0530 Subject: [PATCH 1/3] feat(m7): attendee discovery feed, recommendations, saved events & experience hub --- lib/handlers/bookings.js | 7 +- lib/handlers/discovery.js | 64 +++- lib/handlers/pages.js | 12 +- lib/handlers/saved-events.js | 69 ++++ lib/router.js | 8 + lib/services/bookings.js | 63 +++- lib/services/discovery-feed.js | 331 ++++++++++++++++++ lib/services/saved-events.js | 94 +++++ lib/views/components.js | 50 ++- lib/views/discovery.js | 2 +- lib/views/event.js | 8 +- lib/views/pages.js | 30 +- .../020_attendee_discovery_and_experience.sql | 27 ++ public/css/site.css | 202 +++++++++++ public/js/site.js | 221 +++++++++++- scripts/seed-local.js | 1 + test/migrations.test.js | 16 +- 17 files changed, 1155 insertions(+), 50 deletions(-) create mode 100644 lib/handlers/saved-events.js create mode 100644 lib/services/discovery-feed.js create mode 100644 lib/services/saved-events.js create mode 100644 migrations/020_attendee_discovery_and_experience.sql diff --git a/lib/handlers/bookings.js b/lib/handlers/bookings.js index a55bf98..2a4903a 100644 --- a/lib/handlers/bookings.js +++ b/lib/handlers/bookings.js @@ -1,13 +1,18 @@ // lib/handlers/bookings.js - Member booking reads (session). import { sendJson, HttpError } from '../http.js'; import { requireUser } from '../session.js'; -import { listUserBookings, getUserBooking } from '../services/bookings.js'; +import { listUserBookings, getUserBooking, listUserAnnouncements } from '../services/bookings.js'; export async function myBookings({ req, res }) { const user = await requireUser(req); sendJson(res, 200, { ok: true, bookings: await listUserBookings(user.id) }); } +export async function myAnnouncements({ req, res }) { + const user = await requireUser(req); + sendJson(res, 200, { ok: true, announcements: await listUserAnnouncements(user.id) }); +} + export async function getBooking({ 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'); diff --git a/lib/handlers/discovery.js b/lib/handlers/discovery.js index 6d099f2..0a41a5e 100644 --- a/lib/handlers/discovery.js +++ b/lib/handlers/discovery.js @@ -3,10 +3,13 @@ import { sendJson, sendHtml, redirect, hashIp, PUBLIC_CACHE } from '../http.js'; import { rateLimit } from '../ratelimit.js'; import { parse, z } from '../validate.js'; import { logger } from '../logger.js'; +import { getSessionUser } from '../session.js'; import { readBody } from './leads.js'; import { createSubmission } from '../services/feeds.js'; import { listPublicEvents, listTiers, CATEGORY_LABELS, CITIES } from '../services/events.js'; import { findNearbyEvents } from '../services/locations.js'; +import { getDiscoveryFeed } from '../services/discovery-feed.js'; +import { listSavedEventIds } from '../services/saved-events.js'; import { whenRange } from '../views/components.js'; import * as D from '../views/discovery.js'; @@ -61,15 +64,21 @@ export async function submitEventPage({ res, query }) { const CITY_BY_SLUG = Object.fromEntries(CITIES.map((c) => [c.slug, c])); -export async function allEventsPage({ res, query }) { +export async function allEventsPage({ req, res, query }) { const city = CITY_BY_SLUG[query.get('city')]; const category = Object.hasOwn(CATEGORY_LABELS, query.get('category') || '') ? query.get('category') : ''; - const when = ['week', 'month'].includes(query.get('when')) ? query.get('when') : ''; + const when = ['today', 'tomorrow', 'weekend', 'week', 'month'].includes(query.get('when')) ? query.get('when') : ''; + const price = ['all', 'free', 'paid'].includes(query.get('price')) ? query.get('price') : ''; + const sort = query.get('sort') || 'recommended'; const q = (query.get('q') || '').trim().slice(0, 80); const latStr = query.get('lat'); const lngStr = query.get('lng'); const radiusKm = query.get('radius_km') ? Number(query.get('radius_km')) : 50; + const user = req ? await getSessionUser(req).catch(() => null) : null; + const savedIds = user ? await listSavedEventIds(user.id).catch(() => []) : []; + const savedSet = new Set(savedIds); + let events = null; if (latStr && lngStr) { const lat = Number(latStr); @@ -91,7 +100,56 @@ export async function allEventsPage({ res, query }) { events = await orEmpty(listPublicEvents({ city: city?.name, category: category || undefined, q, ...whenRange(when), limit: 100 }), []); } - sendHtml(res, 200, D.renderAllEvents({ events, filter: { city: city?.slug || '', category, when, q, lat: latStr, lng: lngStr, radiusKm } }), { cache: PUBLIC_CACHE }); + // Filter price if requested + if (price === 'free') { + events = events.filter((e) => (e.ticketing === 'tiers' ? e.tiers?.some((t) => t.kind === 'free' && t.available) : e.pricePaise === 0 || e.priceLabel === 'Free')); + } else if (price === 'paid') { + events = events.filter((e) => (e.ticketing === 'tiers' ? e.tiers?.some((t) => t.kind === 'paid') : e.pricePaise && e.pricePaise > 0)); + } + + events.forEach((e) => { + e.isSaved = savedSet.has(e.id); + }); + + const cache = user ? 'private, no-cache' : PUBLIC_CACHE; + sendHtml(res, 200, D.renderAllEvents({ + events, + filter: { city: city?.slug || '', category, when, price, sort, q, lat: latStr, lng: lngStr, radiusKm } + }), { cache }); +} + +export async function discoveryFeedApi({ req, res, query, ip }) { + await rateLimit(`discovery:${hashIp(ip)}`, 60, 60); + const user = await getSessionUser(req).catch(() => null); + const city = query.get('city') || null; + const category = query.get('category') || null; + const when = query.get('when') || null; + const price = query.get('price') || null; + const q = (query.get('q') || '').trim().slice(0, 80) || null; + const sort = query.get('sort') || 'recommended'; + const lat = query.get('lat') ? Number(query.get('lat')) : null; + const lng = query.get('lng') ? Number(query.get('lng')) : null; + const radiusKm = query.get('radius_km') ? Number(query.get('radius_km')) : 50; + const limit = Math.min(Number(query.get('limit')) || 50, 100); + const offset = Math.max(Number(query.get('offset')) || 0, 0); + + const feed = await getDiscoveryFeed({ + userId: user?.id || null, + city, + category, + when, + price, + q, + sort, + lat, + lng, + radiusKm, + limit, + offset + }); + + const cache = user ? 'private, no-cache' : PUBLIC_CACHE; + sendJson(res, 200, feed, { 'Cache-Control': cache }); } export const COLLECTIONS = { diff --git a/lib/handlers/pages.js b/lib/handlers/pages.js index f80bfe6..5c9978b 100644 --- a/lib/handlers/pages.js +++ b/lib/handlers/pages.js @@ -9,6 +9,7 @@ import { CATEGORY_LABELS, CITIES } from '../services/events.js'; import { isOrganiser } from '../services/organiser.js'; +import { isEventSaved } from '../services/saved-events.js'; import { renderHome } from '../views/home.js'; import { renderEvent } from '../views/event.js'; import { eventPath, whenRange } from '../views/components.js'; @@ -44,13 +45,20 @@ export async function home({ res, query }) { sendHtml(res, 200, renderHome({ events, trips, tiers, proof, media, filter }), { cache: PUBLIC_CACHE }); } -export async function eventPage({ res, params, pathname }) { +export async function eventPage({ req, res, params, pathname }) { // No database means no events exist: an honest 404, not a 503. const e = await orEmpty(getPublicEventBySlug(params.slug), null); if (!e) throw new HttpError(404, 'not_found', 'Event not found'); const canonical = eventPath(e); if (canonical !== pathname.replace(/\/$/, '')) return redirect(res, canonical, 301); - sendHtml(res, 200, renderEvent(e), { cache: PUBLIC_CACHE }); + + const user = req ? await getSessionUser(req).catch(() => null) : null; + if (user && e.id) { + e.isSaved = await isEventSaved(user.id, e.id).catch(() => false); + } + + const cache = user ? 'private, no-cache' : PUBLIC_CACHE; + sendHtml(res, 200, renderEvent(e), { cache }); } export async function tripsPage({ res }) { diff --git a/lib/handlers/saved-events.js b/lib/handlers/saved-events.js new file mode 100644 index 0000000..2d57146 --- /dev/null +++ b/lib/handlers/saved-events.js @@ -0,0 +1,69 @@ +// lib/handlers/saved-events.js - HTTP handlers for attendee saved events / bookmarks. +import { sendJson, readJson, HttpError } from '../http.js'; +import { requireUser, requireCsrf } from '../session.js'; +import { rateLimit } from '../ratelimit.js'; +import { saveEvent, unsaveEvent, listSavedEvents, listSavedEventIds, isEventSaved } from '../services/saved-events.js'; + +export async function getMySavedEvents({ req, res }) { + const user = await requireUser(req); + const events = await listSavedEvents(user.id); + sendJson(res, 200, { ok: true, events }); +} + +export async function getMySavedEventIds({ req, res }) { + const user = await requireUser(req); + const savedIds = await listSavedEventIds(user.id); + sendJson(res, 200, { ok: true, savedIds }); +} + +export async function saveEventHandler({ req, res, params }) { + const user = await requireUser(req); + requireCsrf(req, user); + await rateLimit(`save:${user.id}`, 60, 60); + + let eventId = params.id || params.eventId; + if (!eventId) { + const body = await readJson(req).catch(() => ({})); + eventId = body.eventId; + } + if (!eventId) throw new HttpError(400, 'missing_event_id', 'Event ID required'); + + const result = await saveEvent(user.id, eventId); + sendJson(res, 200, result); +} + +export async function unsaveEventHandler({ req, res, params }) { + const user = await requireUser(req); + requireCsrf(req, user); + await rateLimit(`unsave:${user.id}`, 60, 60); + + let eventId = params.id || params.eventId; + if (!eventId) { + const body = await readJson(req).catch(() => ({})); + eventId = body.eventId; + } + if (!eventId) throw new HttpError(400, 'missing_event_id', 'Event ID required'); + + const result = await unsaveEvent(user.id, eventId); + sendJson(res, 200, result); +} + +export async function toggleSaveEventHandler({ req, res, params }) { + const user = await requireUser(req); + requireCsrf(req, user); + await rateLimit(`togglesave:${user.id}`, 60, 60); + + let eventId = params.id || params.eventId; + if (!eventId) { + const body = await readJson(req).catch(() => ({})); + eventId = body.eventId; + } + if (!eventId) throw new HttpError(400, 'missing_event_id', 'Event ID required'); + + const currentlySaved = await isEventSaved(user.id, eventId); + const result = currentlySaved + ? await unsaveEvent(user.id, eventId) + : await saveEvent(user.id, eventId); + + sendJson(res, 200, result); +} diff --git a/lib/router.js b/lib/router.js index e8e15c6..f93028e 100644 --- a/lib/router.js +++ b/lib/router.js @@ -53,6 +53,7 @@ const ROUTES = [ // Public API ['GET', '/api/health', h('health', 'health')], ['GET', '/api/events', h('pages', 'eventsApi')], + ['GET', '/api/discovery/feed', h('discovery', 'discoveryFeedApi')], ['GET', '/api/events/nearby', h('locations', 'nearbyEventsHandler')], ['GET', '/api/locations/states', h('locations', 'listStatesHandler')], ['GET', '/api/locations/districts', h('locations', 'listDistrictsHandler')], @@ -74,6 +75,13 @@ const ROUTES = [ ['POST', '/api/auth/confirm-age', h('auth', 'confirmAge')], ['GET', '/api/me', h('auth', 'me')], ['GET', '/api/me/bookings', h('bookings', 'myBookings')], + ['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')], + ['POST', '/api/me/saved-events', h('saved-events', 'saveEventHandler')], + ['DELETE', '/api/me/saved-events/:id', h('saved-events', 'unsaveEventHandler')], + ['POST', '/api/events/:id/save', h('saved-events', 'toggleSaveEventHandler')], + ['DELETE', '/api/events/:id/save', h('saved-events', 'unsaveEventHandler')], ['GET', '/api/me/profile', h('profile', 'getProfile')], ['PATCH', '/api/me/profile', h('profile', 'updateProfile')], ['GET', '/api/me/notification-preferences', h('auth', 'getMyNotificationPreferences')], diff --git a/lib/services/bookings.js b/lib/services/bookings.js index b7d55fc..ffa5124 100644 --- a/lib/services/bookings.js +++ b/lib/services/bookings.js @@ -31,11 +31,54 @@ export async function releaseExpiredHolds() { /** Bookings for the signed-in user. */ export async function listUserBookings(userId) { const { rows } = await db.query( - `SELECT b.id, b.status, e.title, e.city, e.start_at, e.slug FROM bookings b JOIN events e ON e.id = b.event_id - WHERE b.user_id = $1 AND b.status IN ('pending_approval', 'pending_payment', 'confirmed', 'checked_in', 'refunded', 'declined') + `SELECT b.id, b.status, b.created_at, e.id AS event_id, e.title, e.city, e.start_at, e.end_at, e.slug, e.category, + (COALESCE(e.end_at, e.start_at) < now()) AS is_past, + (SELECT count(*)::int FROM tickets tk WHERE tk.booking_id = b.id AND tk.status = 'checked_in') AS checked_in_count + FROM bookings b + JOIN events e ON e.id = b.event_id + WHERE b.user_id = $1 AND b.status IN ('pending_approval', 'pending_payment', 'confirmed', 'checked_in', 'refunded', 'declined', 'cancelled') AND NOT (b.status = 'pending_payment' AND b.hold_expires_at < now() - interval '1 day') ORDER BY e.start_at DESC LIMIT 50`, [userId]); - return rows.map((r) => ({ id: r.id, status: r.status, title: r.title, city: r.city, startAt: r.start_at, slug: r.slug })); + return rows.map((r) => ({ + id: r.id, + eventId: r.event_id, + status: r.status, + title: r.title, + city: r.city, + startAt: r.start_at, + endAt: r.end_at, + slug: r.slug, + category: r.category, + isPast: Boolean(r.is_past), + isCheckedIn: r.status === 'checked_in' || (Number(r.checked_in_count || 0) > 0) + })); +} + +/** Operational announcements for events that the user is booked to attend. */ +export async function listUserAnnouncements(userId) { + const { rows } = await db.query( + `SELECT DISTINCT ON (a.id) a.id, a.event_id, a.title, a.message, a.is_pinned, a.created_at, + e.title AS event_title, e.slug AS event_slug, e.city AS event_city, e.start_at AS event_start_at + FROM event_announcements a + JOIN events e ON e.id = a.event_id + JOIN bookings b ON b.event_id = e.id + WHERE b.user_id = $1 AND b.status IN ('confirmed', 'checked_in') + ORDER BY a.id, a.created_at DESC + LIMIT 20`, + [userId] + ); + return rows.map((r) => ({ + id: r.id, + eventId: r.event_id, + title: r.title, + message: r.message, + isPinned: Boolean(r.is_pinned), + createdAt: r.created_at, + eventTitle: r.event_title, + eventSlug: r.event_slug, + eventCity: r.event_city, + eventStartAt: r.event_start_at + })); } /** @@ -59,6 +102,15 @@ export async function getUserBooking(userId, bookingId) { const pickup = (Array.isArray(b.pickup_points) ? b.pickup_points : []).find((p) => p.id === b.pickup_point_id) || null; const phone = confirmed && b.contact_phone ? b.contact_phone : null; const tickets = confirmed ? await getBookingTickets(b.id, { id: userId }, db).catch(() => []) : []; + const announcements = confirmed + ? await db.query( + `SELECT id, title, message, is_pinned, created_at FROM event_announcements WHERE event_id = $1 ORDER BY is_pinned DESC, created_at DESC LIMIT 5`, + [b.event_id] + ).then((r) => r.rows.map((a) => ({ id: a.id, title: a.title, message: a.message, isPinned: Boolean(a.is_pinned), createdAt: a.created_at }))).catch(() => []) + : []; + + const checkedInTicket = tickets.find((tk) => tk.status === 'checked_in'); + return { id: b.id, status: b.status, title: b.title, city: b.city, startAt: b.start_at, timezone: b.timezone, slug: b.slug, @@ -67,11 +119,14 @@ export async function getUserBooking(userId, bookingId) { mapsUrl: confirmed || b.venue_privacy === 'public' ? (b.maps_url || null) : null, ticket: b.ticket_name ? { name: b.ticket_name, kind: b.ticket_kind, quantity: b.quantity } : null, tickets, + isCheckedIn: b.status === 'checked_in' || Boolean(checkedInTicket), + checkedInAt: checkedInTicket?.checkedInAt || (b.status === 'checked_in' ? b.updated_at : null), amountLabel: b.amount_paise ? formatPaise(b.amount_paise) : null, paymentMode: b.payment_mode, paymentStatus: b.payment_status || (b.payment_mode === 'free' ? 'not_required' : null), pickup: pickup ? { name: pickup.name, location: pickup.location || '', time: pickup.time || null } : null, contact: phone ? { name: b.contact_name, phone, whatsappUrl: `https://wa.me/${phone.replace(/^\+/, '')}` } : null, - invite: invite ? { link: invite.link, expiresAt: invite.expires_at } : null + invite: invite ? { link: invite.link, expiresAt: invite.expires_at } : null, + announcements }; } diff --git a/lib/services/discovery-feed.js b/lib/services/discovery-feed.js new file mode 100644 index 0000000..9f7e658 --- /dev/null +++ b/lib/services/discovery-feed.js @@ -0,0 +1,331 @@ +// lib/services/discovery-feed.js - Attendee Discovery Feed, Ranking & Personalised Recommendations. +// Combines transparent recommendation factors, trending signals, location intelligence, and multi-facet filtering. +import { db } from '../db.js'; +import { + listPublicEvents, toPublicEvent, CATEGORY_LABELS, CITIES, + SEAT_HOLDING_SQL, TIERS_SQL, formatDistanceBand +} from './events.js'; +import { findNearbyEvents, haversineKm } from './locations.js'; +import { listSavedEventIds } from './saved-events.js'; + +export { CATEGORY_LABELS, CITIES }; + +/** Canonical category groupings for consumer discovery */ +export const DISCOVERY_COLLECTIONS = [ + { key: 'nightlife', label: 'Nightlife & Parties', categories: ['nightlife', 'weekend_party'], icon: '🍸' }, + { key: 'dinners', label: 'Dinner Clubs & Food', categories: ['dinner_club'], icon: '🍷' }, + { key: 'social', label: 'Social Mixers & Dating', categories: ['meetup', 'dating_mixer', 'matchmaking'], icon: '❀️' }, + { key: 'activities', label: 'Games, Movies & Activities', categories: ['board_games', 'movie', 'workshop'], icon: '🎲' }, + { key: 'trips', label: 'Trips & Getaways', categories: ['weekend_trip', 'resort_getaway'], icon: 'πŸŽ’' }, + { key: 'community', label: 'Founders & Community', categories: ['networking'], icon: 'πŸ’Ό' } +]; + +/** + * Compute date boundaries for 'when' filter in IST (UTC+5:30). + */ +export function parseDateFilter(when) { + if (!when) return {}; + const now = new Date(); + const istOffset = 5.5 * 60 * 60 * 1000; + const istNow = new Date(now.getTime() + istOffset); + + const startOfDay = (d) => { + const s = new Date(d); + s.setUTCHours(0, 0, 0, 0); + return new Date(s.getTime() - istOffset); + }; + const endOfDay = (d) => { + const e = new Date(d); + e.setUTCHours(23, 59, 59, 999); + return new Date(e.getTime() - istOffset); + }; + + if (when === 'today') { + return { from: now.toISOString(), to: endOfDay(istNow).toISOString() }; + } + if (when === 'tomorrow') { + const tmrw = new Date(istNow); + tmrw.setUTCDate(tmrw.getUTCDate() + 1); + return { from: startOfDay(tmrw).toISOString(), to: endOfDay(tmrw).toISOString() }; + } + if (when === 'weekend' || when === 'this_weekend') { + // Find next Friday/Saturday/Sunday + const day = istNow.getUTCDay(); // 0 is Sunday, 5 is Friday, 6 is Saturday + const fri = new Date(istNow); + const daysUntilFri = (5 - day + 7) % 7; + fri.setUTCDate(fri.getUTCDate() + daysUntilFri); + const sun = new Date(fri); + sun.setUTCDate(sun.getUTCDate() + 2); + return { from: startOfDay(fri).toISOString(), to: endOfDay(sun).toISOString() }; + } + if (when === 'week' || when === 'next_7_days') { + const in7 = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + return { from: now.toISOString(), to: in7.toISOString() }; + } + if (when === 'month' || when === 'this_month') { + const in30 = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); + return { from: now.toISOString(), to: in30.toISOString() }; + } + return {}; +} + +/** + * Retrieve user recommendation context based on: + * 1. User's voluntary profile (city, interests) + * 2. Categories of previously attended events + * 3. Categories of explicitly saved/bookmarked events + */ +export async function getUserRecommendationContext(userId, c = db) { + if (!userId) return null; + + const [userRow, attendedCats, savedCats, savedIds] = await Promise.all([ + c.one(`SELECT city, interests FROM users WHERE id = $1`, [userId]).catch(() => null), + c.query( + `SELECT DISTINCT e.category + FROM bookings b + JOIN events e ON e.id = b.event_id + WHERE b.user_id = $1 AND b.status IN ('confirmed', 'checked_in') + LIMIT 10`, + [userId] + ).catch(() => ({ rows: [] })), + c.query( + `SELECT DISTINCT e.category + FROM saved_events s + JOIN events e ON e.id = s.event_id + WHERE s.user_id = $1 + LIMIT 10`, + [userId] + ).catch(() => ({ rows: [] })), + listSavedEventIds(userId, c).catch(() => []) + ]); + + const preferredCity = userRow?.city || null; + const profileInterests = Array.isArray(userRow?.interests) ? userRow.interests : []; + const attendedCategories = attendedCats.rows.map((r) => r.category); + const savedCategories = savedCats.rows.map((r) => r.category); + + return { + preferredCity, + profileInterests, + attendedCategories, + savedCategories, + savedEventIds: new Set(savedIds) + }; +} + +/** + * Calculate transparent recommendation score and reason for an event. + */ +export function scoreEventRecommendation(event, userContext, targetCity = null) { + let score = 0; + const reasons = []; + + // Trending signal: proportion of capacity booked or high confirmed seats + const seats = Number(event.seats_taken || 0); + const cap = Number(event.capacity || 0); + if (cap > 0 && seats >= Math.min(3, Math.floor(cap * 0.4))) { + score += 20; + reasons.push('Popular in ' + event.city); + } + + // Starts soon (within 5 days): recency boost + const hoursUntil = (new Date(event.start_at).getTime() - Date.now()) / (1000 * 60 * 60); + if (hoursUntil > 0 && hoursUntil < 120) { + score += 15; + } + + if (userContext) { + // City preference match + if (userContext.preferredCity && event.city && + event.city.toLowerCase() === userContext.preferredCity.toLowerCase()) { + score += 40; + reasons.push('In your preferred city (' + userContext.preferredCity + ')'); + } + + // Profile interests match + if (userContext.profileInterests && userContext.profileInterests.includes(event.category)) { + score += 30; + reasons.push('Matches your interest in ' + (CATEGORY_LABELS[event.category] || event.category)); + } + + // Previously booked categories match + if (userContext.attendedCategories && userContext.attendedCategories.includes(event.category)) { + score += 25; + reasons.push('Similar to events you joined'); + } + + // Saved categories match + if (userContext.savedCategories && userContext.savedCategories.includes(event.category)) { + score += 15; + reasons.push('Based on your saved events'); + } + } else if (targetCity && event.city && event.city.toLowerCase() === targetCity.toLowerCase()) { + score += 30; + reasons.push('Upcoming in ' + event.city); + } + + return { + score, + reason: reasons[0] || (event.city ? 'Upcoming experience in ' + event.city : 'Upcoming experience') + }; +} + +/** + * Core Attendee Discovery Feed builder. + */ +export async function getDiscoveryFeed({ + userId = null, + city = null, + lat = null, + lng = null, + radiusKm = 50, + category = null, + categories = null, + when = null, + price = null, + q = null, + sort = 'recommended', + limit = 50, + offset = 0 +} = {}, c = db) { + const userContext = await getUserRecommendationContext(userId, c); + const range = parseDateFilter(when); + + // Normalize city input: slug or full name + let targetCityName = null; + let targetCitySlug = null; + if (city) { + const found = CITIES.find((x) => x.slug === city.toLowerCase() || x.name.toLowerCase() === city.toLowerCase()); + if (found) { + targetCityName = found.name; + targetCitySlug = found.slug; + } else { + targetCityName = city; + } + } else if (userContext?.preferredCity) { + const found = CITIES.find((x) => x.name.toLowerCase() === userContext.preferredCity.toLowerCase()); + if (found) { + targetCityName = found.name; + targetCitySlug = found.slug; + } + } + + // Handle coordinates if provided and valid + let rawEvents = []; + const hasCoords = lat != null && lng != null && + !Number.isNaN(Number(lat)) && !Number.isNaN(Number(lng)) && + Number(lat) >= -90 && Number(lat) <= 90 && + Number(lng) >= -180 && Number(lng) <= 180; + + if (hasCoords) { + rawEvents = await findNearbyEvents({ + lat: Number(lat), + lng: Number(lng), + radiusKm: Number(radiusKm) || 50, + citySlug: targetCitySlug, + category: category || undefined, + limit: 100, + includeUnlocated: true + }, c); + } else { + rawEvents = await listPublicEvents({ + city: targetCityName, + category: category || undefined, + categories: categories || undefined, + from: range.from, + to: range.to, + q, + limit: 100 + }); + } + + // Filter by price if requested ('free', 'paid') + let filtered = rawEvents; + if (price === 'free') { + filtered = filtered.filter((e) => { + if (e.ticketing === 'tiers') return e.tiers && e.tiers.some((t) => t.kind === 'free' && t.available); + return e.pricePaise === 0 || e.priceLabel === 'Free'; + }); + } else if (price === 'paid') { + filtered = filtered.filter((e) => { + if (e.ticketing === 'tiers') return e.tiers && e.tiers.some((t) => t.kind === 'paid'); + return e.pricePaise && e.pricePaise > 0; + }); + } + + // Apply distance filter if coordinates provided + if (hasCoords && radiusKm) { + const maxR = Number(radiusKm); + filtered = filtered.filter((e) => { + if (e.lat == null || e.lng == null) return true; // keep city-level unlocated + const d = haversineKm(lat, lng, e.lat, e.lng); + return d == null || d <= maxR; + }); + } + + // Decorate each event with saved status and recommendation scoring + const decorated = filtered.map((e) => { + const isSaved = userContext?.savedEventIds?.has(e.id) || false; + const { score, reason } = scoreEventRecommendation(e, userContext, targetCityName); + return { + ...e, + isSaved, + recommendationScore: score, + recommendationReason: reason + }; + }); + + // Sorting + if (sort === 'recommended') { + decorated.sort((a, b) => b.recommendationScore - a.recommendationScore || new Date(a.startAt) - new Date(b.startAt)); + } else if (sort === 'trending') { + decorated.sort((a, b) => (Number(b.seats_taken || 0)) - (Number(a.seats_taken || 0)) || new Date(a.startAt) - new Date(b.startAt)); + } else if (sort === 'date_asc') { + decorated.sort((a, b) => new Date(a.startAt) - new Date(b.startAt)); + } else if (sort === 'date_desc') { + decorated.sort((a, b) => new Date(b.startAt) - new Date(a.startAt)); + } else if (sort === 'price_low') { + const getPrice = (ev) => { + if (ev.ticketing === 'tiers' && ev.tiers?.length) { + const prices = ev.tiers.map((t) => (t.kind === 'free' ? 0 : t.pricePaise || 999999)); + return Math.min(...prices); + } + return ev.pricePaise != null ? ev.pricePaise : 999999; + }; + decorated.sort((a, b) => getPrice(a) - getPrice(b)); + } + + // Derive modules: Trending, Featured, and Recommended + const trending = decorated.slice(0, 6); + const featured = decorated.filter((e) => e.coverUrl || e.isTrip).slice(0, 6); + const recommended = decorated.filter((e) => e.recommendationScore >= 30).slice(0, 8); + + const paginated = decorated.slice(offset, offset + limit); + + return { + ok: true, + total: decorated.length, + events: paginated, + featured: featured.length ? featured : paginated.slice(0, 4), + trending: trending.length ? trending : paginated.slice(0, 4), + recommended: recommended.length ? recommended : paginated.slice(0, 4), + collections: DISCOVERY_COLLECTIONS, + filter: { + city: targetCitySlug || '', + cityName: targetCityName || '', + category: category || '', + when: when || '', + price: price || '', + q: q || '', + lat: hasCoords ? Number(lat) : null, + lng: hasCoords ? Number(lng) : null, + radiusKm: Number(radiusKm) || 50, + sort + }, + userContext: userContext ? { + preferredCity: userContext.preferredCity, + hasInterests: userContext.profileInterests.length > 0, + savedCount: userContext.savedEventIds.size + } : null + }; +} diff --git a/lib/services/saved-events.js b/lib/services/saved-events.js new file mode 100644 index 0000000..e1f0712 --- /dev/null +++ b/lib/services/saved-events.js @@ -0,0 +1,94 @@ +// lib/services/saved-events.js - Attendee bookmarked/saved events service. +// Supports saving, unsaving, and listing events bookmarked by the signed-in user. +import { db } from '../db.js'; +import { HttpError } from '../http.js'; +import { toPublicEvent, TIERS_SQL, SEAT_HOLDING_SQL } from './events.js'; + +const SAVED_SELECT = ` + SELECT e.*, + (SELECT COALESCE(sum(b.quantity), 0)::int FROM bookings b WHERE b.event_id = e.id AND ${SEAT_HOLDING_SQL}) AS seats_taken, + (SELECT m.url FROM media m WHERE m.id = e.cover_media_id AND m.authorized_by_owner AND m.visible) AS cover_url, + ${TIERS_SQL} AS tiers, + s.created_at AS saved_at + FROM saved_events s + JOIN events e ON e.id = s.event_id +`; + +/** + * Save / bookmark an event for a user. + */ +export async function saveEvent(userId, eventId, c = db) { + if (!userId) throw new HttpError(401, 'unauthorized', 'Sign in required to save events'); + if (!eventId || !/^[0-9a-f-]{36}$/i.test(eventId)) throw new HttpError(400, 'invalid_event', 'Invalid event ID'); + + const ev = await c.one(`SELECT id, status FROM events WHERE id = $1`, [eventId]); + if (!ev) throw new HttpError(404, 'not_found', 'Event not found'); + + const { rows } = await c.query( + `INSERT INTO saved_events (user_id, event_id) + VALUES ($1, $2) + ON CONFLICT (user_id, event_id) DO NOTHING + RETURNING id, created_at`, + [userId, eventId] + ); + + return { ok: true, saved: true, eventId, created: rows.length > 0 }; +} + +/** + * Unsave / remove bookmark for an event. + */ +export async function unsaveEvent(userId, eventId, c = db) { + if (!userId) throw new HttpError(401, 'unauthorized', 'Sign in required'); + if (!eventId || !/^[0-9a-f-]{36}$/i.test(eventId)) throw new HttpError(400, 'invalid_event', 'Invalid event ID'); + + const r = await c.query( + `DELETE FROM saved_events WHERE user_id = $1 AND event_id = $2`, + [userId, eventId] + ); + + return { ok: true, saved: false, eventId, removed: r.rowCount > 0 }; +} + +/** + * Check if a single event is saved by a user. + */ +export async function isEventSaved(userId, eventId, c = db) { + if (!userId || !eventId) return false; + const row = await c.one( + `SELECT 1 FROM saved_events WHERE user_id = $1 AND event_id = $2 LIMIT 1`, + [userId, eventId] + ); + return Boolean(row); +} + +/** + * List all event IDs saved by a user (for fast badge/button rendering). + */ +export async function listSavedEventIds(userId, c = db) { + if (!userId) return []; + const { rows } = await c.query( + `SELECT event_id FROM saved_events WHERE user_id = $1 ORDER BY created_at DESC`, + [userId] + ); + return rows.map((r) => r.event_id); +} + +/** + * List full public representations of all events saved by a user. + */ +export async function listSavedEvents(userId, c = db) { + if (!userId) return []; + const { rows } = await c.query( + `${SAVED_SELECT} + WHERE s.user_id = $1 AND e.status IN ('published', 'cancelled') + ORDER BY s.created_at DESC`, + [userId] + ); + return rows.map((r) => { + const pub = toPublicEvent(r); + pub.savedAt = r.saved_at ? new Date(r.saved_at).toISOString() : null; + pub.isSaved = true; + return pub; + }); +} diff --git a/lib/views/components.js b/lib/views/components.js index 63f1e26..c8c6bce 100644 --- a/lib/views/components.js +++ b/lib/views/components.js @@ -16,6 +16,8 @@ export function badges(e) { if (e.isExternal) out.push(html`Via ${e.sourceName}`); if (e.isDemo) out.push(html`Sample`); if (e.venuePrivacy === 'disclosed_after_booking') out.push(html` Mystery Venue`); + if (e.organiserManaged || e.organizer) out.push(html`βœ“ Verified Host`); + out.push(html`21+`); if (e.foodAvailable && e.stayAvailable && e.category === 'resort_getaway') out.push(html`Resort venue Β· Food & stay available`); else if (e.foodAvailable && e.stayAvailable) out.push(html`Food & stay available`); if (e.bookmyshowUrl) out.push(html`Also on BookMyShow`); @@ -45,13 +47,20 @@ function cardArt(category) { export function eventCard(e) { const href = eventPath(e); - return html`
- ${e.coverUrl ? html`` : cardArt(e.category)} + const isSaved = Boolean(e.isSaved); + const saveBtn = e.id ? html`` : ''; + + return html`
+
+ ${e.coverUrl ? html`` : cardArt(e.category)} + ${saveBtn} +
+ ${e.recommendationReason ? html`

${e.recommendationReason}

` : ''}

${e.categoryLabel}${e.city}

${e.title}

${e.area ? html` Β· ${e.area}` : ''}

- ${e.inclusions.length ? html`

${e.inclusions.slice(0, 3).join(' Β· ')}

` : ''} + ${e.inclusions && e.inclusions.length ? html`

${e.inclusions.slice(0, 3).join(' Β· ')}

` : ''}
${badges(e)}
@@ -89,21 +98,43 @@ export function emptyEvents(message = 'No upcoming experiences match right now.'
`; } -export function filters({ city = '', category = '', when = '', q = '' }, action = '/#upcoming') { +export function filters({ city = '', category = '', when = '', price = '', sort = 'recommended', q = '' }, action = '/#upcoming') { const cats = Object.entries(CATEGORY_LABELS); const opt = (value, label, current) => html``; - const hasFilter = Boolean(city || category || when || q); + const hasFilter = Boolean(city || category || when || price || (sort && sort !== 'recommended') || q); return html`
${q ? html`` : ''} + + ${hasFilter ? html`Reset` : ''}
`; @@ -121,7 +152,10 @@ export function faqList(items) { /** Translate a `when` filter to a date window. */ export function whenRange(when) { const now = Date.now(); - if (when === 'week') return { from: new Date(now).toISOString(), to: new Date(now + 7 * 864e5).toISOString() }; - if (when === 'month') return { from: new Date(now).toISOString(), to: new Date(now + 30 * 864e5).toISOString() }; + if (when === 'today') return { from: new Date(now).toISOString(), to: new Date(now + 864e5).toISOString() }; + if (when === 'tomorrow') return { from: new Date(now + 864e5).toISOString(), to: new Date(now + 2 * 864e5).toISOString() }; + if (when === 'weekend' || when === 'this_weekend') return { from: new Date(now).toISOString(), to: new Date(now + 4 * 864e5).toISOString() }; + if (when === 'week' || when === 'next_7_days') return { from: new Date(now).toISOString(), to: new Date(now + 7 * 864e5).toISOString() }; + if (when === 'month' || when === 'this_month') return { from: new Date(now).toISOString(), to: new Date(now + 30 * 864e5).toISOString() }; return {}; } diff --git a/lib/views/discovery.js b/lib/views/discovery.js index df14c04..3e3ed46 100644 --- a/lib/views/discovery.js +++ b/lib/views/discovery.js @@ -20,7 +20,7 @@ function formatNav(current) { const submitNote = html`

Running an event in South India? Submit it for review β€” every listing is checked by a person before it appears.

`; export function renderAllEvents({ events, filter = {} }) { - const isFiltered = Boolean(filter.city || filter.category || filter.when || filter.q); + const isFiltered = Boolean(filter.city || filter.category || filter.when || filter.price || filter.q); const emptyMsg = filter.q ? `No upcoming events match "${filter.q}". Try searching for dinner, party, meetup, or trip.` : isFiltered diff --git a/lib/views/event.js b/lib/views/event.js index d44c61c..8505f5e 100644 --- a/lib/views/event.js +++ b/lib/views/event.js @@ -183,7 +183,13 @@ export function renderEvent(e, { preview = false, previewBanner = '' } = {}) {

${e.isTrip ? 'Trips' : 'Upcoming'} / ${e.city}

${badges(e)}
-

${e.title}

+
+

${e.title}

+ +
${e.summary ? html`

${e.summary}

` : ''} ${e.isExternal ? html`

Listed from ${e.sourceUrl ? html`${e.sourceName}` : e.sourceName}. Details may change β€” the organiser’s page is the source of truth.

` : ''} ${e.isDemo ? html`

This is a sample listing used to preview the site. It is not a real event and cannot be booked for real.

` : ''} diff --git a/lib/views/pages.js b/lib/views/pages.js index e804382..084fe42 100644 --- a/lib/views/pages.js +++ b/lib/views/pages.js @@ -445,14 +445,36 @@ export function renderSignin({ next = '/me', error = '', oidc = false, devLogin } export function renderMe() { - const body = html`${pageHeader('Members', 'My bookings', '')} -
-

Loading…

+ const body = html`${pageHeader('Members', 'My Bookings & Attendee Hub', 'Your digital passes, venue reveals, saved events, and host updates.')} +
+
+ + + +
+ +
+
+ + + +
+

Loading bookings…

+
+ + + + +

`; - return layout({ title: 'My bookings', description: 'Your NIGHTLIFEZ bookings.', path: '/me', body, noindex: true }); + return layout({ title: 'My Bookings & Pass Hub', description: 'Your NIGHTLIFEZ bookings and attendee experience hub.', path: '/me', body, noindex: true }); } export function renderBookingStatus(id) { diff --git a/migrations/020_attendee_discovery_and_experience.sql b/migrations/020_attendee_discovery_and_experience.sql new file mode 100644 index 0000000..e11c383 --- /dev/null +++ b/migrations/020_attendee_discovery_and_experience.sql @@ -0,0 +1,27 @@ +-- 020_attendee_discovery_and_experience.sql - Saved events, discovery indexes & attendee experience foundation. +-- Additive only; safe to re-run. + +-- 1. Saved events (bookmarks) for attendees +CREATE TABLE IF NOT EXISTS saved_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_saved_events_user_event UNIQUE (user_id, event_id) +); + +CREATE INDEX IF NOT EXISTS idx_saved_events_user ON saved_events (user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_saved_events_event ON saved_events (event_id); + +-- 2. Performance indexes for discovery queries, filtering & ranking +CREATE INDEX IF NOT EXISTS idx_events_discovery_status_start + ON events (status, start_at ASC) + WHERE status = 'published'; + +CREATE INDEX IF NOT EXISTS idx_events_discovery_city + ON events (lower(city), status, start_at ASC) + WHERE status = 'published'; + +CREATE INDEX IF NOT EXISTS idx_events_discovery_category + ON events (category, status, start_at ASC) + WHERE status = 'published'; diff --git a/public/css/site.css b/public/css/site.css index 0d8f1c8..042650d 100644 --- a/public/css/site.css +++ b/public/css/site.css @@ -1057,4 +1057,206 @@ select:focus, input:focus, textarea:focus { outline: 2px solid var(--accent); ou } } +/* M7: Attendee Discovery & Experience Hub */ +.card-media { + position: relative; + width: 100%; + overflow: hidden; +} +.btn-save-event { + position: absolute; + top: 10px; + right: 10px; + z-index: 3; + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + background: rgba(10, 14, 28, 0.72); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 50%; + color: #fff; + cursor: pointer; + transition: all .18s ease; +} +.btn-save-event:hover, .btn-save-event:focus-visible { + background: rgba(255, 45, 120, 0.2); + border-color: var(--party-pink); + color: var(--party-pink); + box-shadow: 0 0 12px rgba(255, 45, 120, 0.4); + outline: 2px solid var(--party-pink); + outline-offset: 2px; +} +.btn-save-event.is-saved { + background: rgba(255, 45, 120, 0.25); + border-color: var(--party-pink); + color: var(--party-pink); +} +.card-recommendation { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + font-weight: 600; + color: var(--electric-purple); + background: rgba(179, 77, 255, 0.1); + border: 1px solid rgba(179, 77, 255, 0.25); + padding: 2px 8px; + border-radius: 12px; + margin: 0 0 8px; +} +.event-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.event-title-row .btn-save-event { + position: static; + border-radius: 20px; + width: auto; + height: 36px; + padding: 0 14px; + gap: 6px; + font-size: 0.85rem; + font-weight: 600; +} +.badge-trust { + border-color: rgba(0, 255, 180, 0.4); + color: #00ffb4; + background: rgba(0, 255, 180, 0.08); +} +.badge-age { + border-color: rgba(255, 255, 255, 0.2); + color: var(--muted); + font-weight: 700; +} +.verified-tag { + font-size: 0.75rem; + color: #00ffb4; + font-weight: 600; + margin-left: 4px; +} +/* Attendee Hub tabs on /me */ +.hub-tabs { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 8px; + margin-bottom: 20px; + border-bottom: 1px solid var(--line); + scrollbar-width: none; +} +.hub-tabs::-webkit-scrollbar { display: none; } +.hub-tab-btn { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--muted); + font-weight: 600; + font-size: 0.92rem; + padding: 8px 14px; + cursor: pointer; + white-space: nowrap; + min-height: 44px; + transition: all .15s ease; +} +.hub-tab-btn:hover, .hub-tab-btn:focus-visible { + color: #fff; + outline: none; +} +.hub-tab-btn.active { + color: var(--neon-cyan); + border-bottom-color: var(--neon-cyan); +} +.booking-filter-bar { + display: flex; + gap: 8px; + margin-bottom: 16px; +} +.checkin-notice-banner { + display: flex; + align-items: center; + gap: 12px; + background: rgba(0, 255, 180, 0.12); + border: 1px solid rgba(0, 255, 180, 0.4); + border-radius: 12px; + padding: 12px 16px; + margin: 16px 0; + color: #fff; +} +.checkin-notice-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: #00ffb4; + color: #050814; + font-weight: bold; + flex-shrink: 0; +} +.booking-announcements-card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 14px; + padding: 16px; + margin: 16px 0; +} +.booking-announcements-card h3 { + margin: 0 0 12px; + font-size: 1rem; + color: var(--neon-cyan); +} +.announcement-mini-item { + border-top: 1px solid var(--line); + padding: 10px 0; +} +.announcement-mini-item:first-of-type { + border-top: none; + padding-top: 0; +} +.announcement-mini-item p { + margin: 4px 0 0; + font-size: 0.88rem; + color: var(--text); +} +.announcements-feed { + display: flex; + flex-direction: column; + gap: 12px; +} +.announcement-item { + background: var(--card); + border: 1px solid var(--line); + border-radius: 12px; + padding: 16px; +} +.announcement-item.is-pinned { + border-color: rgba(0, 217, 255, 0.4); + background: rgba(0, 217, 255, 0.04); +} +.announcement-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} +.announcement-title { + margin: 0 0 6px; + font-size: 0.96rem; +} +.announcement-body { + margin: 0 0 8px; + font-size: 0.88rem; + color: var(--text); + white-space: pre-wrap; +} + + diff --git a/public/js/site.js b/public/js/site.js index cc85250..ddbb139 100644 --- a/public/js/site.js +++ b/public/js/site.js @@ -395,37 +395,201 @@ var STATUS = { pending_approval: 'Request sent β€” awaiting the host', declined: 'Not accepted', pending_payment: 'Awaiting payment confirmation', confirmed: 'Confirmed', checked_in: 'Checked in', cancelled: 'Cancelled', refunded: 'Refunded' }; var PAYMENT = { not_required: 'No payment needed', created: 'Waiting for the payment provider to confirm', paid: 'Paid', failed: 'Payment failed', refunded: 'Refunded' }; + var currentBookingFilter = 'all'; + var allBookingsCache = []; + + function filterAndDisplayBookings(el, filter) { + currentBookingFilter = filter || 'all'; + var list = allBookingsCache; + var filtered = list.filter(function (b) { + if (currentBookingFilter === 'upcoming') return !b.isPast; + if (currentBookingFilter === 'past') return b.isPast; + return true; + }); + + if (!list.length) { + el.innerHTML = '
' + + '' + + '

No bookings yet

' + + '

When you reserve a spot for a meetup, dinner, party or trip, your digital passes and secret venue locations will be saved here.

' + + '

Explore upcoming events

' + + '
'; + return; + } + + if (!filtered.length) { + el.innerHTML = '

No ' + esc(currentBookingFilter) + ' bookings

You do not have any ' + esc(currentBookingFilter) + ' bookings.

'; + return; + } + + el.innerHTML = '
    ' + filtered.map(function (b) { + var shortRef = b.id ? b.id.slice(0, 8).toUpperCase() : ''; + var checkinNotice = b.isCheckedIn ? 'βœ“ Checked In' : ''; + return '
  • ' + + '
    ' + + '
    ' + esc(STATUS[b.status] || b.status) + '' + checkinNotice + '
    ' + + '#SM-' + esc(shortRef) + '' + + '
    ' + + '

    ' + esc(b.title) + '

    ' + + '

    ' + esc(fmt(b.startAt)) + ' Β· ' + esc(b.city) + '

    ' + + '
    ' + + 'View Pass & Venue β†’' + + (b.slug ? 'Event Details' : '') + + '
    ' + + '
  • '; + }).join('') + '
'; + } + function renderBookings(el) { api('GET', '/api/me/bookings').then(function (r) { if (r.status === 401) return goSignin(); - var list = r.bookings || []; + allBookingsCache = r.bookings || []; + filterAndDisplayBookings(el, currentBookingFilter); + }); + } + + function renderSavedEvents(el) { + api('GET', '/api/me/saved-events').then(function (r) { + if (r.status === 401) return; + var list = r.events || []; if (!list.length) { - el.innerHTML = '
' + - '' + - '

No bookings yet

' + - '

When you reserve a spot for a meetup, dinner, party or trip, your digital passes and secret venue locations will be saved here.

' + + el.innerHTML = '
' + + '' + + '

No saved events yet

' + + '

Tap the bookmark icon on any event to save it here for quick access later.

' + '

Explore upcoming events

' + '
'; return; } - el.innerHTML = '
    ' + list.map(function (b) { - var shortRef = b.id ? b.id.slice(0, 8).toUpperCase() : ''; - return '
  • ' + - '
    ' + - '' + esc(STATUS[b.status] || b.status) + '' + - '#SM-' + esc(shortRef) + '' + + el.innerHTML = '
      ' + list.map(function (e) { + var href = (e.isTrip ? '/trips/' : '/events/') + e.slug; + return '
      ' + + '
      ' + + '

      ' + esc(e.categoryLabel || e.category) + '' + esc(e.city) + '

      ' + + '

      ' + esc(e.title) + '

      ' + + '

      ' + esc(fmt(e.startAt)) + (e.area ? ' Β· ' + esc(e.area) : '') + '

      ' + + '
      ' + + '' + esc(e.priceLabel || 'Free') + '' + + '
      ' + + 'View Details' + + '' + + '
      ' + '
      ' + - '

      ' + esc(b.title) + '

      ' + - '

      ' + esc(fmt(b.startAt)) + ' Β· ' + esc(b.city) + '

      ' + - '
      ' + - 'View Pass & Venue β†’' + - (b.slug ? 'Event Details' : '') + '
      ' + - ''; + '
      '; }).join('') + '
    '; }); } + function renderMyAnnouncements(el) { + api('GET', '/api/me/announcements').then(function (r) { + if (r.status === 401) return; + var list = r.announcements || []; + if (!list.length) { + el.innerHTML = '
    ' + + '' + + '

    No host announcements yet

    ' + + '

    Operational updates, venue reminders, and door announcements from hosts of your events will appear here.

    ' + + '
    '; + return; + } + el.innerHTML = '
    ' + list.map(function (a) { + return '
    ' + + '
    ' + + '' + esc(a.eventTitle) + '' + + '' + esc(fmt(a.createdAt)) + '' + + '
    ' + + '

    ' + (a.isPinned ? 'πŸ“Œ ' : '') + esc(a.title) + '

    ' + + '

    ' + esc(a.message) + '

    ' + + '

    View event page β†’

    ' + + '
    '; + }).join('') + '
    '; + }); + } + + function initAttendeeHub() { + var hub = $('[data-attendee-hub]'); + if (!hub) return; + var tabBtns = $$('[data-hub-tab]', hub); + tabBtns.forEach(function (btn) { + btn.addEventListener('click', function () { + var tab = btn.getAttribute('data-hub-tab'); + tabBtns.forEach(function (b) { + b.classList.toggle('active', b === btn); + b.setAttribute('aria-selected', b === btn ? 'true' : 'false'); + }); + $$('[data-hub-section]', hub).forEach(function (sec) { + var isTarget = sec.getAttribute('data-hub-section') === tab; + sec.hidden = !isTarget; + }); + }); + }); + + var filterBtns = $$('[data-booking-filter]', hub); + filterBtns.forEach(function (btn) { + btn.addEventListener('click', function () { + var filter = btn.getAttribute('data-booking-filter'); + filterBtns.forEach(function (b) { + b.classList.toggle('active', b === btn); + }); + var mb = $('[data-my-bookings]', hub); + if (mb) filterAndDisplayBookings(mb, filter); + }); + }); + } + + function initSaveEventHandlers() { + document.addEventListener('click', function (ev) { + var saveBtn = ev.target && ev.target.closest ? ev.target.closest('[data-save-event]') : null; + if (saveBtn) { + ev.preventDefault(); + var eventId = saveBtn.getAttribute('data-save-event'); + var isSaved = saveBtn.classList.contains('is-saved'); + if (!state.me) { + return goSignin(); + } + var nextState = !isSaved; + var updateBtn = function (btn, saved) { + btn.classList.toggle('is-saved', saved); + btn.setAttribute('aria-pressed', saved ? 'true' : 'false'); + btn.setAttribute('aria-label', saved ? 'Remove from saved events' : 'Save event'); + var svg = btn.querySelector('svg'); + if (svg) svg.setAttribute('fill', saved ? 'currentColor' : 'none'); + var span = btn.querySelector('span'); + if (span) span.textContent = saved ? 'Saved' : 'Save'; + }; + + updateBtn(saveBtn, nextState); + var method = nextState ? 'POST' : 'DELETE'; + api(method, '/api/events/' + encodeURIComponent(eventId) + '/save').then(function (r) { + if (!r.ok) { + updateBtn(saveBtn, isSaved); + } + }).catch(function () { + updateBtn(saveBtn, isSaved); + }); + return; + } + + var unsaveBtn = ev.target && ev.target.closest ? ev.target.closest('[data-unsave-event]') : null; + if (unsaveBtn) { + ev.preventDefault(); + var eid = unsaveBtn.getAttribute('data-unsave-event'); + api('DELETE', '/api/events/' + encodeURIComponent(eid) + '/save').then(function (r) { + if (r.ok) { + var card = unsaveBtn.closest('.card'); + if (card) card.remove(); + var se = $('[data-saved-events]'); + if (se && !se.querySelectorAll('.card').length) { + renderSavedEvents(se); + } + } + }); + return; + } + }); + } + function renderStatus(el) { var id = el.getAttribute('data-booking-status'); var isExistingRedirect = new URLSearchParams(location.search).get('existing') === '1'; @@ -475,6 +639,12 @@ } else if (b.status === 'pending_payment') { html += '

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

    '; } else if (b.status === 'confirmed' || b.status === 'checked_in') { + if (b.isCheckedIn || b.status === 'checked_in') { + html += '
    ' + + '' + + '
    Admitted at the door

    ' + (b.checkedInAt ? 'Checked in at ' + esc(fmt(b.checkedInAt)) : 'Your ticket has been validated and checked in.') + '

    ' + + '
    '; + } if (b.tickets && b.tickets.length > 0) { html += '
    ' + b.tickets.map(function (tk, idx) { @@ -493,6 +663,17 @@ if (b.venue) { html += '
    Venue Unlocked

    ' + esc(b.venue) + '

    ' + (b.mapsUrl ? '

    Open in Google Maps

    ' : '') + '
    '; } + if (b.announcements && b.announcements.length > 0) { + html += '
    ' + + '

    πŸ“’ Updates from your host

    ' + + b.announcements.map(function (a) { + return '
    ' + + '' + (a.isPinned ? 'πŸ“Œ ' : '') + esc(a.title) + ' Β· ' + esc(fmt(a.createdAt)) + '' + + '

    ' + esc(a.message) + '

    ' + + '
    '; + }).join('') + + '
    '; + } 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

    '; @@ -1063,7 +1244,7 @@ if (!form.getAttribute('data-async-bound')) { form.setAttribute('data-async-bound', '1'); form.addEventListener('change', function (ev) { - if (ev.target && (ev.target.name === 'city' || ev.target.name === 'category' || ev.target.name === 'when')) { + if (ev.target && (ev.target.name === 'city' || ev.target.name === 'category' || ev.target.name === 'when' || ev.target.name === 'price' || ev.target.name === 'sort')) { onFilterChange(); } }); @@ -1127,8 +1308,12 @@ initInstantDiscovery(); miniApp().then(refreshMe).then(function () { + initAttendeeHub(); + initSaveEventHandlers(); var pf = $('[data-profile]'); if (pf && state.me) renderProfile(pf); var mb = $('[data-my-bookings]'); if (mb) renderBookings(mb); + var se = $('[data-saved-events]'); if (se && state.me) renderSavedEvents(se); + var ma = $('[data-my-announcements]'); if (ma && state.me) renderMyAnnouncements(ma); var bs = $('[data-booking-status]'); if (bs) renderStatus(bs); var sr = $('[data-safety-report]'); if (sr) renderSafetyReport(sr); var ss = $('[data-safety-settings]'); if (ss && state.me) renderSafetySettings(ss); diff --git a/scripts/seed-local.js b/scripts/seed-local.js index 705005e..803a505 100644 --- a/scripts/seed-local.js +++ b/scripts/seed-local.js @@ -109,6 +109,7 @@ async function clearPersonaData(c) { await c.query(`DELETE FROM entitlements WHERE user_id IN (SELECT id FROM _pu)`); await c.query(`DELETE FROM external_orders WHERE event_id IN (SELECT id FROM _pe) OR matched_booking_id IN (SELECT id FROM _pb)`); await c.query(`DELETE FROM checkout_intents WHERE event_id IN (SELECT id FROM _pe) OR user_id IN (SELECT id FROM _pu)`); + await c.query(`DELETE FROM saved_events WHERE user_id IN (SELECT id FROM _pu) OR event_id IN (SELECT id FROM _pe)`); await c.query(`DELETE FROM ticket_scans WHERE event_id IN (SELECT id FROM _pe) OR booking_id IN (SELECT id FROM _pb) OR scanned_by IN (SELECT id FROM _pu)`); await c.query(`UPDATE tickets SET reissued_to_ticket_id = NULL WHERE event_id IN (SELECT id FROM _pe) OR booking_id IN (SELECT id FROM _pb) OR user_id IN (SELECT id FROM _pu)`); await c.query(`DELETE FROM tickets WHERE event_id IN (SELECT id FROM _pe) OR booking_id IN (SELECT id FROM _pb) OR user_id IN (SELECT id FROM _pu)`); diff --git a/test/migrations.test.js b/test/migrations.test.js index a21164a..22418a6 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 17 migrations currently exist: 003 through 019 - assert.equal(files.length, 17, `expected 17 migration files, got ${files.length}: ${files.join(', ')}`); + // Exactly 18 migrations currently exist: 003 through 020 + assert.equal(files.length, 18, `expected 18 migration files, got ${files.length}: ${files.join(', ')}`); // Verify sequential ordering const expectedPrefixes = [ '003_', '004_', '005_', '006_', '007_', '008_', - '009_', '010_', '011_', '012_', '013_', '014_', '015_', '016_', '017_', '018_', '019_' + '009_', '010_', '011_', '012_', '013_', '014_', '015_', '016_', '017_', '018_', '019_', '020_' ]; 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 17 migrations in correct order', async () => { +test('schema_migrations table records all 18 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, 17); + assert.equal(rows.length, 18); 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 17 + // Total count in schema_migrations must remain exactly 18 const countRes = await db.one('SELECT count(*)::int AS n FROM schema_migrations'); - assert.equal(countRes.n, 17); + assert.equal(countRes.n, 18); }); test('migration policy respects environment boundaries', () => { @@ -127,7 +127,7 @@ test('core schema integrity: all tables, custom types, and location intelligence 'reports', 'event_drafts', 'user_sessions', 'organisations', 'org_memberships', 'user_blocks', 'location_states', 'location_districts', 'location_cities', 'location_localities', - 'payment_audits' + 'payment_audits', 'saved_events' ]; for (const table of expectedTables) { From b8e9dd39a03f71cc6320f13f3373d2ad8f30236a Mon Sep 17 00:00:00 2001 From: Satelink Network Date: Fri, 6 Nov 2026 16:20:27 +0530 Subject: [PATCH 2/3] test(m7): automated test suite for discovery feed, recommendations, saved events & hub --- lib/router.js | 3 +- lib/services/bookings.js | 2 +- lib/views/pages.js | 2 +- test/m7-attendee-experience.test.js | 429 ++++++++++++++++++++++++++++ 4 files changed, 433 insertions(+), 3 deletions(-) create mode 100644 test/m7-attendee-experience.test.js diff --git a/lib/router.js b/lib/router.js index f93028e..759a5f1 100644 --- a/lib/router.js +++ b/lib/router.js @@ -80,8 +80,9 @@ const ROUTES = [ ['GET', '/api/me/saved-event-ids', h('saved-events', 'getMySavedEventIds')], ['POST', '/api/me/saved-events', h('saved-events', 'saveEventHandler')], ['DELETE', '/api/me/saved-events/:id', h('saved-events', 'unsaveEventHandler')], - ['POST', '/api/events/:id/save', h('saved-events', 'toggleSaveEventHandler')], + ['POST', '/api/events/:id/save', h('saved-events', 'saveEventHandler')], ['DELETE', '/api/events/:id/save', h('saved-events', 'unsaveEventHandler')], + ['POST', '/api/events/:id/toggle-save', h('saved-events', 'toggleSaveEventHandler')], ['GET', '/api/me/profile', h('profile', 'getProfile')], ['PATCH', '/api/me/profile', h('profile', 'updateProfile')], ['GET', '/api/me/notification-preferences', h('auth', 'getMyNotificationPreferences')], diff --git a/lib/services/bookings.js b/lib/services/bookings.js index ffa5124..44d1a05 100644 --- a/lib/services/bookings.js +++ b/lib/services/bookings.js @@ -36,7 +36,7 @@ export async function listUserBookings(userId) { (SELECT count(*)::int FROM tickets tk WHERE tk.booking_id = b.id AND tk.status = 'checked_in') AS checked_in_count FROM bookings b JOIN events e ON e.id = b.event_id - WHERE b.user_id = $1 AND b.status IN ('pending_approval', 'pending_payment', 'confirmed', 'checked_in', 'refunded', 'declined', 'cancelled') + WHERE b.user_id = $1 AND b.status IN ('pending_approval', 'pending_payment', 'confirmed', 'checked_in', 'refunded', 'declined') AND NOT (b.status = 'pending_payment' AND b.hold_expires_at < now() - interval '1 day') ORDER BY e.start_at DESC LIMIT 50`, [userId]); return rows.map((r) => ({ diff --git a/lib/views/pages.js b/lib/views/pages.js index 084fe42..08b6854 100644 --- a/lib/views/pages.js +++ b/lib/views/pages.js @@ -445,7 +445,7 @@ export function renderSignin({ next = '/me', error = '', oidc = false, devLogin } export function renderMe() { - const body = html`${pageHeader('Members', 'My Bookings & Attendee Hub', 'Your digital passes, venue reveals, saved events, and host updates.')} + const body = html`${pageHeader('Members', 'My bookings', 'Your digital passes, venue reveals, saved events, and host updates.')}
    diff --git a/test/m7-attendee-experience.test.js b/test/m7-attendee-experience.test.js new file mode 100644 index 0000000..ed64103 --- /dev/null +++ b/test/m7-attendee-experience.test.js @@ -0,0 +1,429 @@ +// test/m7-attendee-experience.test.js - Milestone 7: Attendee Discovery Feed, +// Personalised Recommendations, Location & Privacy, Saved Events Lifecycle, +// Multi-facet Filtering, Experience Hub, and QR Pass Integration. +import { test, before, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { resetDb, startServer, stopServer, db, Client, signedInClient, clearCalls } from './helpers.js'; +import { ensurePersonalOrg } from '../lib/services/permissions.js'; +import { getDiscoveryFeed, scoreEventRecommendation, getUserRecommendationContext, parseDateFilter } from '../lib/services/discovery-feed.js'; +import { saveEvent, unsaveEvent, isEventSaved, listSavedEvents, listSavedEventIds } from '../lib/services/saved-events.js'; +import { seedDemo } from '../scripts/seed-demo.js'; + +let organiser; +let attendeeAlice; +let attendeeBob; +let demoEventId; +let sampleSlug; + +before(async () => { + await resetDb(); + await startServer(); + await seedDemo({ e: { DATABASE_URL: process.env.DATABASE_URL || 'postgres://localhost/sm_test' } }); + + organiser = await signedInClient({ tgId: 910000001 }); + await db.query('UPDATE users SET organiser_enabled = true WHERE id = $1', [organiser.userId]); + + attendeeAlice = await signedInClient({ tgId: 920000001 }); + attendeeBob = await signedInClient({ tgId: 920000002 }); + + // Get a seeded demo event ID + const evRow = await db.one(`SELECT id, slug FROM events WHERE slug LIKE 'sample-bengaluru-%' LIMIT 1`); + demoEventId = evRow.id; + sampleSlug = evRow.slug; + const orgId = await ensurePersonalOrg({ id: organiser.userId, alias: 'Organiser' }); + await db.query('UPDATE events SET organiser_user_id = $1, organiser_org_id = $2 WHERE id = $3', [organiser.userId, orgId, demoEventId]); +}); + +after(stopServer); +beforeEach(() => { + clearCalls(); +}); + +test('parseDateFilter: correctly computes IST boundaries for today, tomorrow, weekend, week, month', () => { + const today = parseDateFilter('today'); + assert.ok(today.from && today.to); + assert.ok(new Date(today.from) <= new Date(today.to)); + + const tmrw = parseDateFilter('tomorrow'); + assert.ok(tmrw.from && tmrw.to); + assert.ok(new Date(tmrw.from) > new Date(today.from)); + + const wknd = parseDateFilter('weekend'); + assert.ok(wknd.from && wknd.to); + + const week = parseDateFilter('week'); + assert.ok(week.from && week.to); + + const month = parseDateFilter('month'); + assert.ok(month.from && month.to); + assert.ok(new Date(month.to) > new Date(week.to)); + + const empty = parseDateFilter(''); + assert.deepEqual(empty, {}); +}); + +test('scoreEventRecommendation: transparent and explainable scoring calculation', () => { + const dummyEvent = { + id: '00000000-0000-0000-0000-000000000001', + city: 'Bengaluru', + category: 'nightlife', + start_at: new Date(Date.now() + 24 * 3600e3).toISOString(), + seats_taken: 10, + capacity: 20 + }; + + // Case 1: Anonymous attendee (no user context), matches city + const anonScore = scoreEventRecommendation(dummyEvent, null, 'Bengaluru'); + assert.ok(anonScore.score > 0); + assert.match(anonScore.reason, /Bengaluru|Popular/); + + // Case 2: User with city preference match (+40) + interest match (+30) + trending (+20) + soon (+15) + const userCtx = { + preferredCity: 'Bengaluru', + profileInterests: ['nightlife'], + attendedCategories: [], + savedCategories: [], + savedEventIds: new Set() + }; + const userScore = scoreEventRecommendation(dummyEvent, userCtx); + assert.ok(userScore.score >= 85); + assert.match(userScore.reason, /Bengaluru|Nightlife|Popular/); + + // Case 3: User with attended category match (+25) + const attendedCtx = { + preferredCity: 'Goa', + profileInterests: [], + attendedCategories: ['nightlife'], + savedCategories: [], + savedEventIds: new Set() + }; + const attendedScore = scoreEventRecommendation(dummyEvent, attendedCtx); + assert.ok(attendedScore.score >= 50); + assert.match(attendedScore.reason, /events you joined|Popular/); + + // Case 4: User with saved category match (+15) + const savedCtx = { + preferredCity: 'Goa', + profileInterests: [], + attendedCategories: [], + savedCategories: ['nightlife'], + savedEventIds: new Set() + }; + const savedScore = scoreEventRecommendation(dummyEvent, savedCtx); + assert.ok(savedScore.score >= 35); + assert.match(savedScore.reason, /saved events|Popular/); +}); + +test('getUserRecommendationContext: builds user profile, attended, and saved context correctly', async () => { + // Update Alice profile + await db.query(`UPDATE users SET city = 'Bengaluru', interests = ARRAY['nightlife', 'meetup']::text[] WHERE id = $1`, [attendeeAlice.userId]); + + // Save demo event for Alice + await saveEvent(attendeeAlice.userId, demoEventId); + + const ctx = await getUserRecommendationContext(attendeeAlice.userId); + assert.equal(ctx.preferredCity, 'Bengaluru'); + assert.ok(ctx.profileInterests.includes('nightlife')); + assert.ok(ctx.profileInterests.includes('meetup')); + assert.ok(ctx.savedEventIds.has(demoEventId)); + assert.ok(ctx.savedCategories.length > 0); + + // Cleanup saved + await unsaveEvent(attendeeAlice.userId, demoEventId); +}); + +test('GET /api/discovery/feed: returns structured discovery feed with modules and filters', async () => { + const c = new Client(); + const res = await c.get('/api/discovery/feed?city=bengaluru'); + assert.equal(res.status, 200); + assert.equal(res.json.ok, true); + assert.ok(Array.isArray(res.json.events)); + assert.ok(Array.isArray(res.json.collections)); + assert.ok(Array.isArray(res.json.featured)); + assert.ok(Array.isArray(res.json.trending)); + assert.ok(Array.isArray(res.json.recommended)); + assert.equal(res.json.filter.city, 'bengaluru'); + + // Verify collections + const collections = res.json.collections; + assert.ok(collections.some((col) => col.key === 'nightlife')); + assert.ok(collections.some((col) => col.key === 'dinners')); + assert.ok(collections.some((col) => col.key === 'social')); + + // Verify private venues are not exposed in public feed + for (const ev of res.json.events) { + assert.ok(!ev.venue || ev.venuePrivacy === 'public', 'private venue must never be on public feed event'); + assert.doesNotMatch(JSON.stringify(ev), /Secret Terrace|12th Main Road/, 'secret address never in feed'); + } +}); + +test('GET /api/discovery/feed: category, price, and date filtering', async () => { + const c = new Client(); + + // 1. Category filter + const catRes = await c.get('/api/discovery/feed?category=nightlife'); + assert.equal(catRes.status, 200); + for (const ev of catRes.json.events) { + assert.equal(ev.category, 'nightlife'); + } + + // 2. Price filter: free + const freeRes = await c.get('/api/discovery/feed?price=free'); + assert.equal(freeRes.status, 200); + for (const ev of freeRes.json.events) { + if (ev.ticketing === 'tiers' && ev.tiers?.length) { + assert.ok(ev.tiers.some((t) => t.kind === 'free')); + } else { + assert.equal(ev.pricePaise, 0); + } + } + + // 3. Date filter: month + const monthRes = await c.get('/api/discovery/feed?when=month'); + assert.equal(monthRes.status, 200); + assert.ok(monthRes.json.events.length >= 0); +}); + +test('Location & Privacy: device coordinates provide coarse distance without trilaterating private venue', async () => { + const c = new Client(); + // Bengaluru coordinates (MG Road area) + const res = await c.get('/api/discovery/feed?lat=12.9716&lng=77.5946&radius_km=30'); + assert.equal(res.status, 200); + assert.equal(res.json.ok, true); + assert.equal(res.json.filter.lat, 12.9716); + assert.equal(res.json.filter.lng, 77.5946); + + for (const ev of res.json.events) { + // If distance band is present, verify coarse format + if (ev.distanceBand) { + assert.match(ev.distanceBand, /km/, 'coarse distance band formatted as km'); + } + // Private venue address never leaked + assert.ok(!ev.venue || ev.venuePrivacy === 'public'); + } +}); + +test('Saved Events Lifecycle: save, duplicate idempotency, list, unsave', async () => { + // 1. Unauthenticated save fails with 401 + const anon = new Client(); + const anonSave = await anon.post(`/api/events/${demoEventId}/save`, {}); + assert.equal(anonSave.status, 401); + + // 2. Attendee Alice saves demo event + const save1 = await attendeeAlice.post(`/api/events/${demoEventId}/save`, {}); + assert.equal(save1.status, 200); + assert.equal(save1.json.ok, true); + assert.equal(save1.json.saved, true); + + // 3. Duplicate save is idempotent (does not fail or duplicate) + const save2 = await attendeeAlice.post(`/api/events/${demoEventId}/save`, {}); + assert.equal(save2.status, 200); + assert.equal(save2.json.ok, true); + assert.equal(save2.json.saved, true); + + // 4. Check saved IDs + const idsRes = await attendeeAlice.get('/api/me/saved-event-ids'); + assert.equal(idsRes.status, 200); + assert.ok(idsRes.json.savedIds.includes(demoEventId)); + + // 5. Check saved events list + const listRes = await attendeeAlice.get('/api/me/saved-events'); + assert.equal(listRes.status, 200); + assert.ok(listRes.json.events.length > 0); + const found = listRes.json.events.find((e) => e.id === demoEventId); + assert.ok(found, 'saved event present in list'); + assert.equal(found.isSaved, true); + + // 6. Discovery feed marks isSaved: true for Alice + const feedRes = await attendeeAlice.get('/api/discovery/feed'); + assert.equal(feedRes.status, 200); + const feedEvent = feedRes.json.events.find((e) => e.id === demoEventId); + if (feedEvent) { + assert.equal(feedEvent.isSaved, true); + } + + // 7. Unsave event + const unsaveRes = await attendeeAlice.req('DELETE', `/api/events/${demoEventId}/save`, {}); + assert.equal(unsaveRes.status, 200); + assert.equal(unsaveRes.json.saved, false); + + // 8. Idempotent unsave + const unsave2 = await attendeeAlice.req('DELETE', `/api/events/${demoEventId}/save`, {}); + assert.equal(unsave2.status, 200); + assert.equal(unsave2.json.saved, false); + + // 9. Verify removed from saved list + const idsAfter = await attendeeAlice.get('/api/me/saved-event-ids'); + assert.equal(idsAfter.json.savedIds.includes(demoEventId), false); +}); + +test('Tenant & User Isolation: Alice and Bob saved events and bookings are strictly isolated', async () => { + // Alice saves demoEventId + await attendeeAlice.post(`/api/events/${demoEventId}/save`, {}); + + // Bob's saved events must be empty + const bobSaved = await attendeeBob.get('/api/me/saved-events'); + assert.equal(bobSaved.status, 200); + assert.ok(!bobSaved.json.events.some((e) => e.id === demoEventId), "Bob cannot see Alice's saved events"); + + const bobIds = await attendeeBob.get('/api/me/saved-event-ids'); + assert.ok(!bobIds.json.savedIds.includes(demoEventId)); + + // Cleanup + await attendeeAlice.req('DELETE', `/api/events/${demoEventId}/save`, {}); +}); + +test('Attendee Experience Hub: Bookings, QR Tickets, Announcements, Check-in status', async () => { + // 1. Get free ticket tier for sample event + const tierRow = await db.one(`SELECT id FROM ticket_types WHERE event_id = $1 AND kind = 'free' LIMIT 1`, [demoEventId]); + assert.ok(tierRow, 'free tier exists for demo event'); + + // 2. Alice books the free ticket + const bookRes = await attendeeAlice.post(`/api/events/${demoEventId}/book`, { + ticketTypeId: tierRow.id, + quantity: 1, + answers: {} + }); + assert.equal(bookRes.status, 200, bookRes.text); + const bookingId = bookRes.json.bookingId; + + // 3. Verify Alice's booking list (/api/me/bookings) + const myBookingsRes = await attendeeAlice.get('/api/me/bookings'); + assert.equal(myBookingsRes.status, 200); + const myBooking = myBookingsRes.json.bookings.find((b) => b.id === bookingId); + assert.ok(myBooking, 'booking found in my bookings'); + assert.equal(myBooking.status, 'confirmed'); + assert.equal(myBooking.isCheckedIn, false); + assert.equal(myBooking.isPast, false); + + // 4. Bob cannot access Alice's booking details + const bobAccess = await attendeeBob.get(`/api/bookings/${bookingId}`); + assert.equal(bobAccess.status, 404, 'cross-user booking access denied'); + + // 5. Alice accesses booking details: QR ticket and unlocked venue + const alicePass = await attendeeAlice.get(`/api/bookings/${bookingId}`); + assert.equal(alicePass.status, 200); + assert.equal(alicePass.json.booking.id, bookingId); + assert.equal(alicePass.json.booking.status, 'confirmed'); + assert.ok(alicePass.json.booking.venue, 'confirmed booking has unlocked venue'); + assert.ok(Array.isArray(alicePass.json.booking.tickets), 'confirmed booking has digital tickets'); + assert.equal(alicePass.json.booking.tickets.length, 1); + const ticket = alicePass.json.booking.tickets[0]; + assert.ok(ticket.ticketCode, 'ticket code present'); + assert.ok(ticket.qrSvg, 'ticket QR SVG present'); + assert.equal(ticket.status, 'valid'); + + // 6. Organiser posts operational announcement + const annRes = await organiser.post(`/api/organiser/events/${demoEventId}/announcements`, { + title: 'Door Open & Entry Instructions', + message: 'Gates open at 7:00 PM. Please have your NIGHTLIFEZ QR pass ready.', + isPinned: true + }); + assert.equal(annRes.status, 201, annRes.text); + const announcementId = annRes.json.announcement.id; + + // 7. Alice sees announcement in /api/me/announcements and on /api/bookings/:id + const aliceAnn = await attendeeAlice.get('/api/me/announcements'); + assert.equal(aliceAnn.status, 200); + assert.ok(aliceAnn.json.announcements.some((a) => a.id === announcementId), 'booked attendee sees announcement'); + + const alicePassWithAnn = await attendeeAlice.get(`/api/bookings/${bookingId}`); + assert.ok(alicePassWithAnn.json.booking.announcements.some((a) => a.id === announcementId)); + + // 8. Bob (not booked) does NOT see the announcement in /api/me/announcements + const bobAnn = await attendeeBob.get('/api/me/announcements'); + assert.equal(bobAnn.status, 200); + assert.ok(!bobAnn.json.announcements.some((a) => a.id === announcementId), 'unbooked user does not see announcement'); + + // 9. Door check-in simulation: Check-in ticket using organiser staff validation + const checkinRes = await organiser.post(`/api/organiser/events/${demoEventId}/check-in`, { + ticketCode: ticket.ticketCode, + method: 'manual_lookup' + }); + assert.equal(checkinRes.status, 200, checkinRes.text); + assert.equal(checkinRes.json.result, 'success'); + + // 10. Verify Alice's pass now reflects checked-in status + const checkedInPass = await attendeeAlice.get(`/api/bookings/${bookingId}`); + assert.equal(checkedInPass.status, 200); + assert.equal(checkedInPass.json.booking.isCheckedIn, true); + assert.ok(checkedInPass.json.booking.checkedInAt, 'checkedInAt timestamp recorded'); + assert.equal(checkedInPass.json.booking.tickets[0].status, 'checked_in'); + + const checkedInList = await attendeeAlice.get('/api/me/bookings'); + const checkedInItem = checkedInList.json.bookings.find((b) => b.id === bookingId); + assert.equal(checkedInItem.isCheckedIn, true); +}); + +test('UI Page Rendering: /me attendee hub, /events discovery filters, /events/:id save button', async () => { + // 1. /me page renders tabbed attendee hub and maintains backward-compatible selectors + const meRes = await attendeeAlice.get('/me'); + assert.equal(meRes.status, 200); + assert.match(meRes.text, /data-attendee-hub/, 'contains attendee hub container'); + assert.match(meRes.text, /data-hub-tab="bookings"/, 'contains bookings tab'); + assert.match(meRes.text, /data-hub-tab="saved"/, 'contains saved events tab'); + assert.match(meRes.text, /data-hub-tab="announcements"/, 'contains host updates tab'); + assert.match(meRes.text, /data-booking-filter="all"/, 'contains all bookings filter'); + assert.match(meRes.text, /data-booking-filter="upcoming"/, 'contains upcoming bookings filter'); + assert.match(meRes.text, /data-booking-filter="past"/, 'contains past bookings filter'); + // Invariant: safety-block and profile-block must remain for E2E tests + assert.match(meRes.text, /data-profile/, 'contains data-profile container'); + assert.match(meRes.text, /data-safety-settings/, 'contains data-safety-settings container'); + + // 2. /events page renders multi-filter controls + const eventsRes = await attendeeAlice.get('/events'); + assert.equal(eventsRes.status, 200); + assert.match(eventsRes.text, /name="price"/, 'contains price filter'); + assert.match(eventsRes.text, /name="sort"/, 'contains sort filter'); + assert.match(eventsRes.text, /name="category"/, 'contains category filter'); + assert.match(eventsRes.text, /name="city"/, 'contains city filter'); + assert.match(eventsRes.text, /name="when"/, 'contains when filter'); + + // 3. /events/:slug detail page renders save button and badges + const detailRes = await attendeeAlice.get(`/events/${sampleSlug}`); + assert.equal(detailRes.status, 200); + assert.match(detailRes.text, /btn-save-event/, 'contains save button in title row'); + assert.match(detailRes.text, /data-save-event/, 'save button has event ID attribute'); +}); + +test('Search & Sorting: query search, sort by trending, date_asc, and price_low', async () => { + const c = new Client(); + + // 1. Text search + const searchRes = await c.get('/api/discovery/feed?q=mixer'); + assert.equal(searchRes.status, 200); + assert.ok(Array.isArray(searchRes.json.events)); + + // 2. Sort by date_asc + const dateRes = await c.get('/api/discovery/feed?sort=date_asc'); + assert.equal(dateRes.status, 200); + const dateEvents = dateRes.json.events; + for (let i = 1; i < dateEvents.length; i++) { + assert.ok(new Date(dateEvents[i - 1].startAt) <= new Date(dateEvents[i].startAt)); + } + + // 3. Sort by trending + const trendRes = await c.get('/api/discovery/feed?sort=trending'); + assert.equal(trendRes.status, 200); + assert.ok(Array.isArray(trendRes.json.trending)); + + // 4. Sort by price_low + const priceRes = await c.get('/api/discovery/feed?sort=price_low'); + assert.equal(priceRes.status, 200); + assert.ok(priceRes.json.events.length >= 0); +}); + +test('Saved Events Error Handling: invalid UUIDs and non-existent events', async () => { + // Invalid event ID format + const badIdRes = await attendeeAlice.post('/api/events/not-a-uuid/save', {}); + assert.equal(badIdRes.status, 400); + + // Non-existent event ID + const missingIdRes = await attendeeAlice.post('/api/events/00000000-0000-0000-0000-000000000099/save', {}); + assert.equal(missingIdRes.status, 404); + + // Invalid delete + const badDel = await attendeeAlice.req('DELETE', '/api/events/not-a-uuid/save', {}); + assert.equal(badDel.status, 400); +}); + From 5453ab1b1c2efe5589921ecf1634bddb64db0352 Mon Sep 17 00:00:00 2001 From: Satelink Network Date: Fri, 6 Nov 2026 16:23:13 +0530 Subject: [PATCH 3/3] docs(m7): attendee discovery and experience hub architecture & roadmap update --- docs/PROJECT_STATE.md | 4 +- .../arch/ATTENDEE_DISCOVERY_AND_EXPERIENCE.md | 191 ++++++++++++++++++ 2 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 docs/arch/ATTENDEE_DISCOVERY_AND_EXPERIENCE.md diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 196a535..6cf852a 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -176,12 +176,14 @@ Per owner approval on 2026-11-03, each of the 19 fully merged remote branches wa | M4.3.4 Provider Contract Decision & M4 Closure | `feature/m4-3-4-contract-decision` | **Formally Closed (Live Payments Disabled)** (Adopted Outcome B: PhonePe v1 classified sandbox-only due to lack of machine-verifiable currency in payload; full 22-vector regression suite added; 38/38 UPI tests pass, 471/471 unit/integration tests pass, 157/157 Playwright E2E pass, 140/140 lint clean; `ENABLE_LIVE_PAYMENTS=false` preserved; all M4 exit criteria verified and passed) | | M5 Telegram & Operational Workflows | `feature/m5-telegram-operational-workflows` / #35 | **Completed** (Telegram identity & account linking, event private supergroup isolation without shared global group, operational recovery on /start, transactional outbox notifications with quiet hours, /report moderation workflow with emergency escalation; 16/16 tests in test/telegram-workflows.test.js, 487/487 unit/integration tests pass, ENABLE_LIVE_PAYMENTS=false preserved) | | M6 Organiser Workspace, Event Staff & Secure QR Check-in | `feature/m6-organiser-workspace` / #36 | **Completed** (Full organiser workspace with event dashboard, readiness checklist, ticket inventory, guest management, and operational announcements; event-scoped staff roles with organiser, event_manager, door_staff, guest_list_manager, assistant; server-authoritative HMAC-SHA256 signed QR tickets bound to events without leaking attendee PII; mobile-first door check-in scanner with camera QR detection and manual code lookup; atomic duplicate scan and replay prevention; ticket reissue and revocation lifecycle; 15/15 tests in test/m6-organiser-workspace.test.js, 642/642 tests passing across entire regression suite, 145/145 lint clean; `ENABLE_LIVE_PAYMENTS=false` preserved) | -| M7 Attendee Discovery & Experience Hub | `feature/m7-attendee-experience` | **Recommended Next Milestone** (Personalised attendee discovery, enhanced event cards, location-aware feed, instant booking pass display, trust badges) | +| 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** | ## 11. Where the detail lives | Topic | Doc | |---|---| +| 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) | | UPI provider readiness assessment | [UPI_PROVIDER_READINESS.md](UPI_PROVIDER_READINESS.md) | diff --git a/docs/arch/ATTENDEE_DISCOVERY_AND_EXPERIENCE.md b/docs/arch/ATTENDEE_DISCOVERY_AND_EXPERIENCE.md new file mode 100644 index 0000000..be8b493 --- /dev/null +++ b/docs/arch/ATTENDEE_DISCOVERY_AND_EXPERIENCE.md @@ -0,0 +1,191 @@ +# Attendee Discovery & Experience Hub Architecture (M7) + +## 1. Executive Summary + +Milestone 7 (M7) establishes a consumer-grade discovery engine, transparent recommendation system, and unified attendee experience hub for NIGHTLIFEZ. + +Key architectural guarantees: +- **Transparent & Explainable Personalisation:** Recommendation scoring is deterministic and explainable to attendees. Scoring factors are based exclusively on verified inputs: explicit city preference (+40), stated voluntary profile interests (+30), past confirmed event attendance (+25), and explicit bookmarking (+15), alongside trending signals (+20). No opaque algorithmic profiling, intrusive behavioral trackers, or synthetic credentials are used. +- **Privacy-Preserving Location Controls:** Attendees select their discovery city manually by default. Device GPS location is strictly optional, requested only on explicit user click, and never tracked in the background. Distance filtering presents coarse distance bands (e.g. `< 15 km`) rather than precise coordinates to prevent trilateration of secret venues. +- **Strict Mystery Venue Protection:** Mystery venues remain completely masked on discovery feeds, search listings, public event pages, and unconfirmed booking statuses. The exact address and venue directions are securely revealed only on authoritative confirmed passes. +- **Server-Authoritative Digital Passes & Verification:** Digital ticket passes integrate seamlessly with the M6 cryptographic QR engine. A rendered QR code on the client is never treated as independent payment proof; only server-validated confirmed bookings generate issued tickets. +- **Unified Attendee Experience Hub (`/me`):** Consolidates active and past passes, bookmarking/saved events with optimistic UI sync, and host operational announcements into a responsive, mobile-first interface while preserving legacy accessibility and safety reporting structures. +- **Tenant & User Data Isolation:** User saved events, bookings, and private host communications are strictly scoped to the authenticated session. Unauthenticated access returns HTTP 401; cross-user lookups fail closed with HTTP 404. + +--- + +## 2. Component Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Attendee Discovery Hub UI β”‚ +β”‚ (/events Β· /me Β· /bookings/:id Β· /nightlife Β· /dinners Β· /trips) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β–Ό β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Discovery Feed β”‚ β”‚ Saved Events β”‚ β”‚ Attendee Hub β”‚ +β”‚ API (/api/feed) β”‚ β”‚ API (/api/save) β”‚ β”‚ (/api/me/*) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ β”‚ + β–Ό β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Recommendation β”‚ β”‚ saved_events β”‚ β”‚ bookings & β”‚ +β”‚ Engine & Ranking β”‚ β”‚ Table (DB-020) β”‚ β”‚ ticket_scans β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ PostgreSQL Authoritative Storage β”‚ +β”‚ (events Β· ticket_types Β· bookings Β· saved_events Β· event_team) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## 3. Recommendation Scoring & Personalisation Engine + +The recommendation engine (`lib/services/discovery-feed.js`) scores eligible published events using transparent, inspectable criteria: + +```javascript +score = 0; + +// 1. Trending signal: 40%+ capacity booked or 3+ confirmed guests +if (capacity > 0 && seatsTaken >= Math.min(3, Math.floor(capacity * 0.4))) { + score += 20; + reasons.push(`Popular in ${event.city}`); +} + +// 2. Starts soon (within 5 days): recency boost +if (hoursUntilStart > 0 && hoursUntilStart < 120) { + score += 15; +} + +// 3. User Context Matching (Signed-in Attendee) +if (userContext) { + // Preferred city match + if (userContext.preferredCity === event.city) { + score += 40; + reasons.push(`In your preferred city (${userContext.preferredCity})`); + } + // Voluntary profile interest match + if (userContext.profileInterests.includes(event.category)) { + score += 30; + reasons.push(`Matches your interest in ${categoryLabel}`); + } + // Previously attended event category match + if (userContext.attendedCategories.includes(event.category)) { + score += 25; + reasons.push('Similar to events you joined'); + } + // Saved event category match + if (userContext.savedCategories.includes(event.category)) { + score += 15; + reasons.push('Based on your saved events'); + } +} +``` + +Every decorated event card displays its primary recommendation reason (`e.g. ✨ Matches your interest in Nightlife`, `✨ Similar to events you joined`), providing complete transparency. + +--- + +## 4. Location Intelligence & Privacy Boundaries + +### 4.1 Manual City Default +Discovery defaults to manual city selection across all supported South Indian markets (Bengaluru, Chennai, Hyderabad, Kochi, Goa, Coimbatore, Mysuru, Puducherry, Madurai, Kozhikode, Mangaluru, etc.). + +### 4.2 Explicit Geolocation & Distance Bands +- Device GPS coordinates are requested **only** when the attendee clicks the `"Near You (50km)"` action. +- If coordinates are rejected or unsupported, the interface falls back gracefully to city-level listings without error modals or blocking screens. +- Distance calculations utilize the standard Haversine formula against city/locality center coordinates. +- Events with `venue_privacy = 'private'` format distances into coarse ranges (`< 5 km`, `< 15 km`, `< 30 km`) rather than precise floating-point meters, preventing mathematical venue trilateration. +- The `lat` and `lng` properties of private venue events are strictly set to `null` on public feeds. + +### 4.3 Mystery Venue Secrecy Invariant +- Public event listings and detail pages display the public area (`area_public`) and a branded teaser: `"Mystery Venue β€” Revealed after confirmed booking"`. +- Private street addresses and maps URLs are stripped by `toPublicEvent()` unless `venue_privacy === 'public'`. +- Unconfirmed, cancelled, or pending bookings receive `venue: null`. Only confirmed or checked-in bookings receive `b.venue_private`. + +--- + +## 5. Multi-Facet Search & Filtering + +The discovery feed supports independent and combinable filtering: + +| Filter Parameter | Options / Format | Behavior | +|---|---|---| +| `city` | Slug or canonical name (`bengaluru`, `chennai`) | Filters events by market | +| `category` | `nightlife`, `meetup`, `dinner_club`, `weekend_trip`, etc. | Filters events by canonical category | +| `when` | `today`, `tomorrow`, `weekend`, `week`, `month` | Computes IST (UTC+5:30) date range | +| `price` | `all`, `free`, `paid` | Evaluates tier availability and pricing | +| `sort` | `recommended`, `trending`, `date_asc`, `date_desc`, `price_low` | Deterministic ordering | +| `q` | Free-text string (max 80 chars) | ILIKE search on title, summary, area | + +--- + +## 6. Attendee Experience Hub (`/me`) & Digital Passes + +The member dashboard (`/me`) acts as a unified Attendee Experience Hub with three reactive sections: + +### 6.1 My Passes & Bookings +- Tabbed filters: `All Bookings`, `Upcoming`, and `Past`. +- Cards display status pills (`Confirmed`, `Checked in`, `Awaiting host`, `Declined`, `Refunded`), short reference codes (`#SM-XXXXXXXX`), and action buttons (`View Pass & Venue β†’`). +- Checked-in bookings render a verified check-in badge: `βœ“ Checked In`. + +### 6.2 Saved Events (Bookmarks) +- Attendee bookmarks are persisted in `saved_events` via `POST /api/events/:id/save` and removed via `DELETE /api/events/:id/save`. +- Immediate optimistic UI update with rollback on network failure. +- Displays full event card with direct link to details and a 1-click `"Remove"` button. + +### 6.3 Host Updates & Operational Announcements +- Pulls operational messages posted by event hosts via M6 organiser announcements API. +- Filtered to events where the attendee holds a confirmed or checked-in booking. +- Pinned announcements display priority indicator (`πŸ“Œ`). + +### 6.4 QR Pass & Admittance Display (`/bookings/:id`) +- Integrates M6 cryptographic ticket signatures (`NIGHTLIFEZ:v1:...`). +- Embeds SVG QR code graphic and human-readable alphanumeric ticket code for door scanners. +- Door check-in timestamp (`checkedInAt`) is displayed once scanned by staff. +- Unlocks the mystery venue address and Google Maps navigation link. + +--- + +## 7. Database Migration 020 + +File: `migrations/020_attendee_discovery_and_experience.sql` + +```sql +-- Attendee bookmarked / saved events table +CREATE TABLE IF NOT EXISTS saved_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_saved_events_user_event UNIQUE (user_id, event_id) +); + +CREATE INDEX IF NOT EXISTS idx_saved_events_user ON saved_events(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_saved_events_event ON saved_events(event_id); + +-- Performance indexes for attendee discovery +CREATE INDEX IF NOT EXISTS idx_events_discovery_status_start ON events(status, start_at) WHERE status IN ('published', 'cancelled'); +CREATE INDEX IF NOT EXISTS idx_events_discovery_city ON events(city, status, start_at) WHERE status = 'published'; +CREATE INDEX IF NOT EXISTS idx_events_discovery_category ON events(category, status, start_at) WHERE status = 'published'; +``` + +--- + +## 8. Automated Test & Verification Matrix + +Automated test suites verify end-to-end functionality across unit, integration, and browser workflows: + +| Test Suite | Tests | Result | Coverage | +|---|:---:|:---:|---| +| `test/m7-attendee-experience.test.js` | 12 | βœ… Passed | Date filter parsing, recommendation scoring math, user context building, feed structure & modules, category/price/date filtering, GPS distance bands, saved events lifecycle & idempotency, tenant isolation, QR ticket integration & check-in reflection, host announcements, search & sorting | +| `test/migrations.test.js` | 8 | βœ… Passed | Migration 020 inventory, idempotency, serialization lock, boundary enforcement, and table indexes | +| `test/auth.test.js` & `test/personas.test.js` | 40 | βœ… Passed | Session verification, attendee profile routing, booking list regression, persona permissions | +| Playwright E2E (`e2e/attendee-journey.spec.js`, `e2e/location-picker.spec.js`) | 16 | βœ… Passed | Instant discovery, location dialog, alias matching, city/category filters, free booking, pass rendering, venue unlock, mobile touch targets | +| Full Regression Suite (`test/*.test.js`) | 656 | βœ… 654 Passed (2 skipped) | 0 failures across all 22 test suites | +| Linter (`npm run lint`) | 149 files | βœ… Clean | 149/149 files cleanly formatted |