diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9736f312..90f2a26b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,10 +36,10 @@ jobs: run: chmod +x ./bin/fetch_private_deps && ./bin/fetch_private_deps - name: Install backend deps - run: npm install --prefix backend + run: npm ci --prefix backend - name: Install frontend deps - run: npm install --prefix frontend + run: npm ci --prefix frontend - name: Build frontend run: CI=false npm --prefix frontend run build @@ -74,10 +74,10 @@ jobs: run: chmod +x ./bin/fetch_private_deps && ./bin/fetch_private_deps - name: Install backend deps - run: npm install --prefix backend + run: npm ci --prefix backend - name: Install frontend deps - run: npm install --prefix frontend + run: npm ci --prefix frontend - name: Run backend unit tests run: npm --prefix backend run test:unit @@ -121,10 +121,10 @@ jobs: run: chmod +x ./bin/fetch_private_deps && ./bin/fetch_private_deps - name: Install backend deps - run: npm install --prefix backend + run: npm ci --prefix backend - name: Install frontend deps - run: npm install --prefix frontend + run: npm ci --prefix frontend - name: Run backend comprehensive test suite with coverage run: npm --prefix backend run test:coverage -- --json --outputFile=coverage/test-results.json diff --git a/.gitignore b/.gitignore index d4064b53..5d3ca51e 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,7 @@ scripts/ dump/ # Events backend cloned at build time (replaces git submodule) -backend/events \ No newline at end of file +backend/events + +# backend coverage +backend/coverage/ \ No newline at end of file diff --git a/backend/app.js b/backend/app.js index 22614602..7da46d5b 100644 --- a/backend/app.js +++ b/backend/app.js @@ -174,6 +174,8 @@ function createApp() { '/', '/landing', '/mobile', + '/invite', + '/.well-known', '/contact', '/support', '/privacy-policy', diff --git a/backend/migrations/seedPivotFeedEvents.js b/backend/migrations/seedPivotFeedEvents.js index a6d0f18d..0a4ab34d 100644 --- a/backend/migrations/seedPivotFeedEvents.js +++ b/backend/migrations/seedPivotFeedEvents.js @@ -407,6 +407,24 @@ const DEMO_EVENTS = [ tags: ['wellness'], registrationCount: 21, }, + { + slug: 'pivot-seed-indie-film', + name: 'Indie Film Night — The Last Garden', + description: 'Limited run at Nitehawk. Grab tickets for your showtime.', + location: 'Nitehawk Cinema, Williamsburg, Brooklyn, NY', + dayOffset: 2, + startHour: 18, + durationHours: 6, + externalLink: 'https://partiful.com/e/pivot-seed-indie-film', + host: { name: 'Nitehawk Cinema' }, + tags: ['movies', 'art-and-culture'], + registrationCount: 56, + timeSlots: [ + { id: '6pm', label: '6:00 PM', startHour: 18, durationHours: 2.25 }, + { id: '8-30pm', label: '8:30 PM', startHour: 20.5, durationHours: 2.25 }, + { id: '11pm', label: '11:00 PM', startHour: 23, durationHours: 2.25 }, + ], + }, ]; async function resolveCatalogOrgId(Org) { @@ -430,13 +448,15 @@ async function resolveCatalogOrgId(Org) { } function buildEventWindow(dayOffset, durationHours, now, startHour = 19) { + const wholeHour = Math.floor(startHour); + const minutes = Math.round((startHour - wholeHour) * 60); const start = new Date( Date.UTC( now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + dayOffset, - startHour, - 0, + wholeHour, + minutes, 0, ), ); @@ -444,6 +464,27 @@ function buildEventWindow(dayOffset, durationHours, now, startHour = 19) { return { start, end }; } +function buildPivotTimeSlots(demo, now) { + if (!Array.isArray(demo.timeSlots) || !demo.timeSlots.length) { + return null; + } + + return demo.timeSlots.map((slot) => { + const { start, end } = buildEventWindow( + demo.dayOffset, + slot.durationHours ?? 2, + now, + slot.startHour, + ); + return { + id: slot.id, + label: slot.label, + start_time: start, + end_time: end, + }; + }); +} + async function run() { const now = new Date(); const batchWeek = toIsoWeek(now); @@ -461,6 +502,19 @@ async function run() { demo.startHour ?? 19, ); + const timeSlots = buildPivotTimeSlots(demo, now); + const pivotMeta = { + batchWeek, + source: 'manual', + sourceUrl: demo.externalLink, + host: demo.host, + tags: demo.tags, + ingestStatus: 'published', + importedAt: now.toISOString(), + importedBy: 'seed:pivot-feed-events', + ...(timeSlots ? { timeSlots } : {}), + }; + await Event.findOneAndUpdate( { 'customFields.pivot.sourceUrl': demo.externalLink }, { @@ -480,16 +534,7 @@ async function run() { hostingId: catalogOrgId, isDeleted: false, customFields: { - pivot: { - batchWeek, - source: 'manual', - sourceUrl: demo.externalLink, - host: demo.host, - tags: demo.tags, - ingestStatus: 'published', - importedAt: now.toISOString(), - importedBy: 'seed:pivot-feed-events', - }, + pivot: pivotMeta, }, }, }, diff --git a/backend/migrations/sendPivotWeeklyPush.js b/backend/migrations/sendPivotWeeklyPush.js index 8284eba9..51f4353e 100644 --- a/backend/migrations/sendPivotWeeklyPush.js +++ b/backend/migrations/sendPivotWeeklyPush.js @@ -222,6 +222,21 @@ async function run() { const { sent, failed } = await sendAllMessages(messages); console.log(`[send:pivot-weekly-push] sent=${sent} failed=${failed}`); + + // Freeze this week's Lab metrics now that the drop went out (best-effort). + try { + const { rebuildWeeklySnapshot } = require('../services/pivotWeeklySnapshotService'); + const rebuild = await rebuildWeeklySnapshot(req, { batchWeek }); + if (rebuild.error) { + console.warn(`[send:pivot-weekly-push] snapshot rebuild skipped: ${rebuild.error}`); + } else { + console.log(`[send:pivot-weekly-push] weekly snapshot rebuilt for ${batchWeek}`); + } + } catch (error) { + console.warn( + `[send:pivot-weekly-push] snapshot rebuild failed (send already completed): ${error.message}` + ); + } } run() diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js index 4a646d4d..21c37ad8 100644 --- a/backend/routes/authRoutes.js +++ b/backend/routes/authRoutes.js @@ -71,6 +71,7 @@ const { getFriendRequests } = require('../utilities/friendUtils'); const { createSession, validateSession, deleteSession, deleteAllUserSessions, getUserSessions, getUserSessionsForGlobalUser, deleteSessionById, deleteSessionByIdForGlobalUser, revokeAllOtherSessionsForGlobalUser } = require('../utilities/sessionUtils'); const { getCookieDomain } = require('../utilities/cookieUtils'); const authGlobalService = require('../services/authGlobalService'); +const { normalizePivotLeaveAuthUser } = require('../services/pivotProfileService'); const { isAdminLevelAccount, getMfaStatus, @@ -138,6 +139,12 @@ async function getCurrentTenantAdminUser(req) { } async function completeLoginWithAdminMfa(req, res, globalUser, tenantUser, platformRoles, message) { + if (tenantUser?._id) { + const normalized = await normalizePivotLeaveAuthUser(req, tenantUser._id, platformRoles); + if (normalized) { + tenantUser = normalized; + } + } if (tenantUser?.accessSuspended) { return { status: 403, @@ -420,7 +427,24 @@ router.post('/refresh-token', async (req, res) => { const { user, globalUser } = validation; const isMobile = isMobileClient(req); - if (user?.accessSuspended) { + if (user?._id) { + const normalized = await normalizePivotLeaveAuthUser(req, user._id); + if (normalized) { + if (normalized.accessSuspended) { + return res.status(403).json({ + success: false, + message: 'This account has been suspended.', + code: 'ACCOUNT_SUSPENDED', + }); + } + } else if (user?.accessSuspended) { + return res.status(403).json({ + success: false, + message: 'This account has been suspended.', + code: 'ACCOUNT_SUSPENDED', + }); + } + } else if (user?.accessSuspended) { return res.status(403).json({ success: false, message: 'This account has been suspended.', diff --git a/backend/routes/pivotAdminRoutes.js b/backend/routes/pivotAdminRoutes.js index 9475b9d2..8d92291f 100644 --- a/backend/routes/pivotAdminRoutes.js +++ b/backend/routes/pivotAdminRoutes.js @@ -6,6 +6,7 @@ const { getWeeklySnapshot, } = require('../services/pivotWeeklySnapshotService'); const { getPivotOverview } = require('../services/pivotAdminOverviewService'); +const { getPivotRetention } = require('../services/pivotRetentionService'); const { listPivotLabEvents } = require('../services/pivotLabEventsService'); const { getInterviewNotes, @@ -23,9 +24,21 @@ const { suggestPivotEventTags, suggestPivotEventTagsBatch, } = require('../services/pivotTagSuggestService'); +const { + searchTmdbMovies, + fetchTmdbMovieDetails, +} = require('../services/pivotTmdbService'); +const { + pivotRequestLogger, + logPivotRouteError, + logPivotServiceReject, + logPivotServiceSuccess, +} = require('../utilities/pivotLogger'); const router = express.Router(); +router.use(pivotRequestLogger); + router.get('/tags', verifyToken, requirePlatformAdmin, async (req, res) => { try { const result = await listPivotTags(req); @@ -42,7 +55,7 @@ router.get('/tags', verifyToken, requirePlatformAdmin, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /admin/pivot/tags failed:', err); + logPivotRouteError('GET /admin/pivot/tags', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot tag catalog.', @@ -66,7 +79,7 @@ router.post('/tags/seed', verifyToken, requirePlatformAdmin, async (req, res) => data: result.data, }); } catch (err) { - console.error('POST /admin/pivot/tags/seed failed:', err); + logPivotRouteError('POST /admin/pivot/tags/seed', err, req); return res.status(500).json({ success: false, message: 'Unable to seed pivot tag catalog.', @@ -93,7 +106,7 @@ router.get('/events', verifyToken, requirePlatformAdmin, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /admin/pivot/events failed:', err); + logPivotRouteError('GET /admin/pivot/events', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot catalog events.', @@ -117,7 +130,7 @@ router.get('/interview-notes', verifyToken, requirePlatformAdmin, async (req, re data: result.data, }); } catch (err) { - console.error('GET /admin/pivot/interview-notes failed:', err); + logPivotRouteError('GET /admin/pivot/interview-notes', err, req); return res.status(500).json({ success: false, message: 'Unable to load interview notes.', @@ -144,7 +157,7 @@ router.put('/interview-notes', verifyToken, requirePlatformAdmin, async (req, re data: result.data, }); } catch (err) { - console.error('PUT /admin/pivot/interview-notes failed:', err); + logPivotRouteError('PUT /admin/pivot/interview-notes', err, req); return res.status(500).json({ success: false, message: 'Unable to save interview notes.', @@ -168,7 +181,7 @@ router.get('/overview', verifyToken, requirePlatformAdmin, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /admin/pivot/overview failed:', err); + logPivotRouteError('GET /admin/pivot/overview', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot overview.', @@ -176,6 +189,33 @@ router.get('/overview', verifyToken, requirePlatformAdmin, async (req, res) => { } }); +router.get('/retention', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await getPivotRetention(req, { + batchWeek: req.query?.batchWeek, + weeks: req.query?.weeks, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('GET /admin/pivot/retention', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to load pivot retention.', + }); + } +}); + router.post('/snapshots/rebuild', verifyToken, requirePlatformAdmin, async (req, res) => { try { const batchWeek = req.body?.batchWeek ?? req.query?.batchWeek; @@ -193,7 +233,7 @@ router.post('/snapshots/rebuild', verifyToken, requirePlatformAdmin, async (req, data: result.data, }); } catch (err) { - console.error('POST /admin/pivot/snapshots/rebuild failed:', err); + logPivotRouteError('POST /admin/pivot/snapshots/rebuild', err, req); return res.status(500).json({ success: false, message: 'Unable to rebuild pivot weekly snapshot.', @@ -245,7 +285,7 @@ router.post('/ingest/suggest-tags', verifyToken, requirePlatformAdmin, async (re data: result.data, }); } catch (err) { - console.error('POST /admin/pivot/ingest/suggest-tags failed:', err); + logPivotRouteError('POST /admin/pivot/ingest/suggest-tags', err, req); return res.status(500).json({ success: false, message: 'Unable to suggest pivot tags.', @@ -253,6 +293,57 @@ router.post('/ingest/suggest-tags', verifyToken, requirePlatformAdmin, async (re } }); +router.get('/tmdb/search', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await searchTmdbMovies({ + query: req.query?.query, + year: req.query?.year, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('GET /admin/pivot/tmdb/search', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to search TMDB.', + }); + } +}); + +router.get('/tmdb/movies/:tmdbId', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await fetchTmdbMovieDetails({ tmdbId: req.params.tmdbId }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('GET /admin/pivot/tmdb/movies/:tmdbId', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to load TMDB movie.', + }); + } +}); + router.post('/ingest/preview', verifyToken, requirePlatformAdmin, async (req, res) => { try { const result = await previewIngestUrl(req, { @@ -272,7 +363,7 @@ router.post('/ingest/preview', verifyToken, requirePlatformAdmin, async (req, re data: result.data, }); } catch (err) { - console.error('POST /admin/pivot/ingest/preview failed:', err); + logPivotRouteError('POST /admin/pivot/ingest/preview', err, req); return res.status(500).json({ success: false, message: 'Unable to preview event import.', @@ -289,6 +380,11 @@ router.post('/ingest', verifyToken, requirePlatformAdmin, async (req, res) => { overrides: req.body?.overrides, }); if (result.error) { + logPivotServiceReject('POST /admin/pivot/ingest', result, req, { + tenantKey: req.body?.tenantKey, + batchWeek: req.body?.batchWeek, + eventName: req.body?.overrides?.name, + }); return res.status(result.status || 400).json({ success: false, message: result.error, @@ -296,12 +392,19 @@ router.post('/ingest', verifyToken, requirePlatformAdmin, async (req, res) => { }); } + logPivotServiceSuccess('POST /admin/pivot/ingest', req, { + tenantKey: req.body?.tenantKey, + batchWeek: req.body?.batchWeek, + eventId: result.data?.event?._id, + eventName: result.data?.event?.name, + }); + return res.status(200).json({ success: true, data: result.data, }); } catch (err) { - console.error('POST /admin/pivot/ingest failed:', err); + logPivotRouteError('POST /admin/pivot/ingest', err, req); return res.status(500).json({ success: false, message: 'Unable to publish pivot catalog event.', @@ -317,6 +420,11 @@ router.post('/ingest/batch', verifyToken, requirePlatformAdmin, async (req, res) events: req.body?.events, }); if (result.error && !result.data?.published?.length) { + logPivotServiceReject('POST /admin/pivot/ingest/batch', result, req, { + tenantKey: req.body?.tenantKey, + batchWeek: req.body?.batchWeek, + requestedCount: Array.isArray(req.body?.events) ? req.body.events.length : 0, + }); return res.status(result.status || 400).json({ success: false, message: result.error, @@ -325,12 +433,19 @@ router.post('/ingest/batch', verifyToken, requirePlatformAdmin, async (req, res) }); } + logPivotServiceSuccess('POST /admin/pivot/ingest/batch', req, { + tenantKey: req.body?.tenantKey, + batchWeek: req.body?.batchWeek, + publishedCount: result.data?.publishedCount ?? result.data?.published?.length ?? 0, + failedCount: result.data?.failedCount ?? result.data?.failures?.length ?? 0, + }); + return res.status(200).json({ success: true, data: result.data, }); } catch (err) { - console.error('POST /admin/pivot/ingest/batch failed:', err); + logPivotRouteError('POST /admin/pivot/ingest/batch', err, req); return res.status(500).json({ success: false, message: 'Unable to publish pivot catalog events.', @@ -358,7 +473,7 @@ router.patch('/ingest/:eventId', verifyToken, requirePlatformAdmin, async (req, data: result.data, }); } catch (err) { - console.error('PATCH /admin/pivot/ingest/:eventId failed:', err); + logPivotRouteError('PATCH /admin/pivot/ingest/:eventId', err, req); return res.status(500).json({ success: false, message: 'Unable to update pivot catalog event.', @@ -386,7 +501,7 @@ router.post('/dev/purge-catalog', verifyToken, requirePlatformAdmin, async (req, data: result.data, }); } catch (err) { - console.error('POST /admin/pivot/dev/purge-catalog failed:', err); + logPivotRouteError('POST /admin/pivot/dev/purge-catalog', err, req); return res.status(500).json({ success: false, message: 'Unable to purge pivot catalog data.', @@ -410,7 +525,7 @@ router.get('/snapshots/:batchWeek', verifyToken, requirePlatformAdmin, async (re data: result.data, }); } catch (err) { - console.error('GET /admin/pivot/snapshots/:batchWeek failed:', err); + logPivotRouteError('GET /admin/pivot/snapshots/:batchWeek', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot weekly snapshot.', diff --git a/backend/routes/pivotRoutes.js b/backend/routes/pivotRoutes.js index 0f243327..c2b78f99 100644 --- a/backend/routes/pivotRoutes.js +++ b/backend/routes/pivotRoutes.js @@ -19,9 +19,12 @@ const { listPivotTags } = require('../services/pivotTagCatalogService'); const { getPivotProfileInterests, updatePivotProfileInterests, + updatePivotProfileAgeVerification, + leavePivotPilot, } = require('../services/pivotProfileService'); const { searchPivotFriends, + getPivotCohortSuggestions, sendPivotFriendRequest, listPivotFriends, listPivotFriendRequests, @@ -33,9 +36,46 @@ const { } = require('../middlewares/pivotReferralValidateRateLimit'); const { verifyToken } = require('../middlewares/verifyToken'); +const { + pivotRequestLogger, + logPivotRouteError, + logPivotServiceReject, + logPivotServiceSuccess, +} = require('../utilities/pivotLogger'); const router = express.Router(); +router.use(pivotRequestLogger); + +router.get('/referral/preview', pivotReferralValidateRateLimit, async (req, res) => { + try { + const result = await validateReferralCode(req, req.query?.code); + if (result.error) { + return res.status(200).json({ + success: true, + data: { + valid: false, + cityDisplayName: null, + }, + }); + } + + return res.status(200).json({ + success: true, + data: { + valid: true, + cityDisplayName: result.data?.cityDisplayName || null, + }, + }); + } catch (err) { + logPivotRouteError('GET /pivot/referral/preview', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to preview referral code.', + }); + } +}); + router.post('/referral/validate', pivotReferralValidateRateLimit, async (req, res) => { try { const result = await validateReferralCode(req, req.body?.code); @@ -52,7 +92,7 @@ router.post('/referral/validate', pivotReferralValidateRateLimit, async (req, re data: result.data, }); } catch (err) { - console.error('POST /pivot/referral/validate failed:', err); + logPivotRouteError('POST /pivot/referral/validate', err, req); return res.status(500).json({ success: false, message: 'Unable to validate referral code.', @@ -62,7 +102,9 @@ router.post('/referral/validate', pivotReferralValidateRateLimit, async (req, re router.post('/referral/redeem', verifyToken, async (req, res) => { try { - const result = await redeemReferralCode(req, req.body?.code); + const result = await redeemReferralCode(req, req.body?.code, { + referredByUserId: req.body?.referredByUserId, + }); if (result.error) { return res.status(result.status || 400).json({ success: false, @@ -76,7 +118,7 @@ router.post('/referral/redeem', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('POST /pivot/referral/redeem failed:', err); + logPivotRouteError('POST /pivot/referral/redeem', err, req); return res.status(500).json({ success: false, message: 'Unable to redeem referral code.', @@ -100,7 +142,7 @@ router.get('/tags', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/tags failed:', err); + logPivotRouteError('GET /pivot/tags', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot tags.', @@ -124,7 +166,7 @@ router.get('/profile/interests', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/profile/interests failed:', err); + logPivotRouteError('GET /pivot/profile/interests', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot interests.', @@ -148,7 +190,7 @@ router.put('/profile/interests', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('PUT /pivot/profile/interests failed:', err); + logPivotRouteError('PUT /pivot/profile/interests', err, req); return res.status(500).json({ success: false, message: 'Unable to save pivot interests.', @@ -156,6 +198,54 @@ router.put('/profile/interests', verifyToken, async (req, res) => { } }); +router.put('/profile/age-verification', verifyToken, async (req, res) => { + try { + const result = await updatePivotProfileAgeVerification(req, req.body); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('PUT /pivot/profile/age-verification', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to verify age.', + }); + } +}); + +router.post('/leave-pilot', verifyToken, async (req, res) => { + try { + const result = await leavePivotPilot(req); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('POST /pivot/leave-pilot', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to leave pilot.', + }); + } +}); + router.get('/config', verifyToken, async (req, res) => { try { const result = await getPivotConfig(req, { batchWeek: req.query.batchWeek }); @@ -172,7 +262,7 @@ router.get('/config', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/config failed:', err); + logPivotRouteError('GET /pivot/config', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot config.', @@ -187,6 +277,9 @@ router.get('/feed', verifyToken, async (req, res) => { excludeEventIds: req.query.excludeEventIds, }); if (result.error) { + logPivotServiceReject('GET /pivot/feed', result, req, { + batchWeek: req.query.batchWeek, + }); return res.status(result.status || 400).json({ success: false, message: result.error, @@ -194,12 +287,18 @@ router.get('/feed', verifyToken, async (req, res) => { }); } + logPivotServiceSuccess('GET /pivot/feed', req, { + batchWeek: result.data?.batchWeek, + eventCount: result.data?.events?.length ?? 0, + excludeEventIds: req.query.excludeEventIds || undefined, + }); + return res.status(200).json({ success: true, data: result.data, }); } catch (err) { - console.error('GET /pivot/feed failed:', err); + logPivotRouteError('GET /pivot/feed', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot feed.', @@ -211,6 +310,10 @@ router.post('/feed/action', verifyToken, async (req, res) => { try { const result = await recordFeedAction(req, req.body); if (result.error) { + logPivotServiceReject('POST /pivot/feed/action', result, req, { + eventId: req.body?.eventId, + action: req.body?.action, + }); return res.status(result.status || 400).json({ success: false, message: result.error, @@ -218,12 +321,18 @@ router.post('/feed/action', verifyToken, async (req, res) => { }); } + logPivotServiceSuccess('POST /pivot/feed/action', req, { + eventId: result.data?.eventId, + status: result.data?.status, + batchWeek: result.data?.batchWeek, + }); + return res.status(200).json({ success: true, data: result.data, }); } catch (err) { - console.error('POST /pivot/feed/action failed:', err); + logPivotRouteError('POST /pivot/feed/action', err, req); return res.status(500).json({ success: false, message: 'Unable to record pivot intent.', @@ -235,6 +344,9 @@ router.post('/intent/:eventId/external-open', verifyToken, async (req, res) => { try { const result = await recordExternalOpen(req, req.params.eventId, req.body); if (result.error) { + logPivotServiceReject('POST /pivot/intent/:eventId/external-open', result, req, { + eventId: req.params.eventId, + }); return res.status(result.status || 400).json({ success: false, message: result.error, @@ -242,12 +354,18 @@ router.post('/intent/:eventId/external-open', verifyToken, async (req, res) => { }); } + logPivotServiceSuccess('POST /pivot/intent/:eventId/external-open', req, { + eventId: result.data?.eventId, + status: result.data?.status, + externalOpenCount: result.data?.externalOpenCount, + }); + return res.status(200).json({ success: true, data: result.data, }); } catch (err) { - console.error('POST /pivot/intent/:eventId/external-open failed:', err); + logPivotRouteError('POST /pivot/intent/:eventId/external-open', err, req); return res.status(500).json({ success: false, message: 'Unable to record external open.', @@ -257,8 +375,12 @@ router.post('/intent/:eventId/external-open', verifyToken, async (req, res) => { router.post('/intent/:eventId/registered', verifyToken, async (req, res) => { try { - const result = await confirmRegistered(req, req.params.eventId); + const result = await confirmRegistered(req, req.params.eventId, req.body); if (result.error) { + logPivotServiceReject('POST /pivot/intent/:eventId/registered', result, req, { + eventId: req.params.eventId, + timeSlotId: req.body?.timeSlotId, + }); return res.status(result.status || 400).json({ success: false, message: result.error, @@ -266,12 +388,19 @@ router.post('/intent/:eventId/registered', verifyToken, async (req, res) => { }); } + logPivotServiceSuccess('POST /pivot/intent/:eventId/registered', req, { + eventId: result.data?.eventId, + status: result.data?.status, + timeSlotId: result.data?.timeSlotId, + batchWeek: result.data?.batchWeek, + }); + return res.status(200).json({ success: true, data: result.data, }); } catch (err) { - console.error('POST /pivot/intent/:eventId/registered failed:', err); + logPivotRouteError('POST /pivot/intent/:eventId/registered', err, req); return res.status(500).json({ success: false, message: 'Unable to confirm registration.', @@ -295,7 +424,7 @@ router.get('/friends', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/friends failed:', err); + logPivotRouteError('GET /pivot/friends', err, req); return res.status(500).json({ success: false, message: 'Unable to load friends.', @@ -319,7 +448,7 @@ router.get('/friends/requests', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/friends/requests failed:', err); + logPivotRouteError('GET /pivot/friends/requests', err, req); return res.status(500).json({ success: false, message: 'Unable to load friend requests.', @@ -343,7 +472,7 @@ router.post('/friends/requests/:friendshipId/accept', verifyToken, async (req, r data: result.data, }); } catch (err) { - console.error('POST /pivot/friends/requests/:friendshipId/accept failed:', err); + logPivotRouteError('POST /pivot/friends/requests/:friendshipId/accept', err, req); return res.status(500).json({ success: false, message: 'Unable to accept friend request.', @@ -367,7 +496,7 @@ router.post('/friends/requests/:friendshipId/decline', verifyToken, async (req, data: result.data, }); } catch (err) { - console.error('POST /pivot/friends/requests/:friendshipId/decline failed:', err); + logPivotRouteError('POST /pivot/friends/requests/:friendshipId/decline', err, req); return res.status(500).json({ success: false, message: 'Unable to decline friend request.', @@ -391,7 +520,7 @@ router.get('/friends/search', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/friends/search failed:', err); + logPivotRouteError('GET /pivot/friends/search', err, req); return res.status(500).json({ success: false, message: 'Unable to search friends.', @@ -399,6 +528,30 @@ router.get('/friends/search', verifyToken, async (req, res) => { } }); +router.get('/friends/cohort', verifyToken, async (req, res) => { + try { + const result = await getPivotCohortSuggestions(req); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('GET /pivot/friends/cohort', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to load cohort suggestions.', + }); + } +}); + router.post('/friends/request', verifyToken, async (req, res) => { try { const result = await sendPivotFriendRequest(req, req.body); @@ -415,7 +568,7 @@ router.post('/friends/request', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('POST /pivot/friends/request failed:', err); + logPivotRouteError('POST /pivot/friends/request', err, req); return res.status(500).json({ success: false, message: 'Unable to send friend request.', @@ -439,7 +592,7 @@ router.get('/events/:eventId/friends', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/events/:eventId/friends failed:', err); + logPivotRouteError('GET /pivot/events/:eventId/friends', err, req); return res.status(500).json({ success: false, message: 'Unable to load event friends.', @@ -451,6 +604,9 @@ router.get('/week-recap', verifyToken, async (req, res) => { try { const result = await getWeekRecap(req, { batchWeek: req.query.batchWeek }); if (result.error) { + logPivotServiceReject('GET /pivot/week-recap', result, req, { + batchWeek: req.query.batchWeek, + }); return res.status(result.status || 400).json({ success: false, message: result.error, @@ -458,12 +614,17 @@ router.get('/week-recap', verifyToken, async (req, res) => { }); } + logPivotServiceSuccess('GET /pivot/week-recap', req, { + batchWeek: result.data?.batchWeek, + eventCount: result.data?.events?.length ?? 0, + }); + return res.status(200).json({ success: true, data: result.data, }); } catch (err) { - console.error('GET /pivot/week-recap failed:', err); + logPivotRouteError('GET /pivot/week-recap', err, req); return res.status(500).json({ success: false, message: 'Unable to load week recap.', @@ -487,7 +648,7 @@ router.get('/feedback/pending', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/feedback/pending failed:', err); + logPivotRouteError('GET /pivot/feedback/pending', err, req); return res.status(500).json({ success: false, message: 'Unable to load pending feedback.', @@ -526,7 +687,7 @@ router.post('/feedback', [ data: result.data, }); } catch (err) { - console.error('POST /pivot/feedback failed:', err); + logPivotRouteError('POST /pivot/feedback', err, req); return res.status(500).json({ success: false, message: 'Unable to submit feedback.', @@ -559,7 +720,7 @@ router.get('/dev/feedback', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('GET /pivot/dev/feedback failed:', err); + logPivotRouteError('GET /pivot/dev/feedback', err, req); return res.status(500).json({ success: false, message: 'Unable to load pivot feedback.', @@ -590,7 +751,7 @@ router.post('/dev/reset-week-actions', verifyToken, async (req, res) => { data: result.data, }); } catch (err) { - console.error('POST /pivot/dev/reset-week-actions failed:', err); + logPivotRouteError('POST /pivot/dev/reset-week-actions', err, req); return res.status(500).json({ success: false, message: 'Unable to reset week actions.', diff --git a/backend/schemas/pivotEventIntent.js b/backend/schemas/pivotEventIntent.js index d209aa22..bc90995e 100644 --- a/backend/schemas/pivotEventIntent.js +++ b/backend/schemas/pivotEventIntent.js @@ -33,6 +33,12 @@ const pivotEventIntentSchema = new mongoose.Schema( type: Number, default: 0, }, + /** Showtime id from `customFields.pivot.timeSlots` when user self-confirms. */ + timeSlotId: { + type: String, + default: null, + trim: true, + }, }, { timestamps: true }, ); diff --git a/backend/schemas/pivotReferralRedemption.js b/backend/schemas/pivotReferralRedemption.js index c098f02d..dfa9ce7e 100644 --- a/backend/schemas/pivotReferralRedemption.js +++ b/backend/schemas/pivotReferralRedemption.js @@ -18,10 +18,17 @@ const pivotReferralRedemptionSchema = new mongoose.Schema( type: mongoose.Schema.Types.ObjectId, required: true, }, + /** Pilot user who shared the invite link (global identity). Best-effort attribution. */ + referredByGlobalUserId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'GlobalUser', + default: null, + }, }, { timestamps: true } ); pivotReferralRedemptionSchema.index({ globalUserId: 1, code: 1 }, { unique: true }); +pivotReferralRedemptionSchema.index({ referredByGlobalUserId: 1, createdAt: -1 }); module.exports = pivotReferralRedemptionSchema; diff --git a/backend/schemas/pivotWeeklySnapshot.js b/backend/schemas/pivotWeeklySnapshot.js index 2f0f9521..17b383d4 100644 --- a/backend/schemas/pivotWeeklySnapshot.js +++ b/backend/schemas/pivotWeeklySnapshot.js @@ -38,6 +38,26 @@ const pivotWeeklySnapshotTenantSchema = new mongoose.Schema( min: 0, default: 0, }, + externalOpenUsers: { + type: Number, + min: 0, + default: 0, + }, + calendarAdds: { + type: Number, + min: 0, + default: 0, + }, + inviteShares: { + type: Number, + min: 0, + default: 0, + }, + interestsSaved: { + type: Number, + min: 0, + default: 0, + }, swipeCount: { type: Number, required: true, diff --git a/backend/schemas/report.js b/backend/schemas/report.js index 583d8dbb..53a53709 100644 --- a/backend/schemas/report.js +++ b/backend/schemas/report.js @@ -1,5 +1,4 @@ const mongoose = require('mongoose'); -const { report } = require('../routes/authRoutes'); const Schema = mongoose.Schema; const reportSchema = new Schema({ diff --git a/backend/schemas/user.js b/backend/schemas/user.js index 513ed268..0039b110 100644 --- a/backend/schemas/user.js +++ b/backend/schemas/user.js @@ -238,6 +238,27 @@ const userSchema = new mongoose.Schema({ type: [String], default: [], }, + /** Pilot attestation year for 18+ gate during pivot onboarding. */ + pivotBirthYear: { + type: Number, + default: null, + }, + /** Audit timestamp set when user passes the pivot 18+ onboarding gate. */ + pivotAgeVerifiedAt: { + type: Date, + default: null, + }, + /** Pilot participation lifecycle — `left` opts out without blocking Meridian login. */ + pivotParticipationStatus: { + type: String, + enum: ['active', 'left'], + default: 'active', + }, + /** Set when the user leaves the pilot via POST /pivot/leave-pilot. */ + pivotLeftAt: { + type: Date, + default: null, + }, // you can add more fields here if needed, like 'createdAt', 'updatedAt', etc. diff --git a/backend/services/pivotAdminOverviewService.js b/backend/services/pivotAdminOverviewService.js index c725f975..40d34c06 100644 --- a/backend/services/pivotAdminOverviewService.js +++ b/backend/services/pivotAdminOverviewService.js @@ -8,6 +8,7 @@ const { normalizeBatchWeek, PUBLISHED_EVENT_QUERY, getWeeklySnapshot, + aggregateEngagementMetrics, } = require('./pivotWeeklySnapshotService'); const { buildDropSchedulePayload } = require('./pivotConfigService'); @@ -91,6 +92,8 @@ async function aggregateTenantOverview(req, tenant, batchWeek) { passedCount, activeUserIds, externalOpenAgg, + externalOpenUserIds, + engagement, feedback, referralCodes, ] = await Promise.all([ @@ -102,6 +105,8 @@ async function aggregateTenantOverview(req, tenant, batchWeek) { { $match: intentFilter }, { $group: { _id: null, total: { $sum: { $ifNull: ['$externalOpenCount', 0] } } } }, ]), + PivotEventIntent.distinct('userId', { ...intentFilter, externalOpenAt: { $ne: null } }), + aggregateEngagementMetrics(tenantReq, batchWeek), aggregateRegisteredFeedback(PivotEventIntent, UniversalFeedback, batchWeek, eventIds), loadReferralCodesForTenant(req, tenantKey), ]); @@ -115,6 +120,10 @@ async function aggregateTenantOverview(req, tenant, batchWeek) { interestedCount, registeredCount, externalOpenCount: externalOpenAgg[0]?.total ?? 0, + externalOpenUsers: externalOpenUserIds.length, + calendarAdds: engagement.calendarAdds, + inviteShares: engagement.inviteShares, + interestsSaved: engagement.interestsSaved, swipeCount, feedbackCount: feedback.feedbackCount, feedbackAvg: feedback.feedbackAvg, @@ -149,6 +158,10 @@ async function getPivotOverview(req, options = {}) { interestedCount: 0, registeredCount: 0, externalOpenCount: 0, + externalOpenUsers: 0, + calendarAdds: 0, + inviteShares: 0, + interestsSaved: 0, swipeCount: 0, feedbackCount: 0, feedbackAvg: null, diff --git a/backend/services/pivotFeedService.js b/backend/services/pivotFeedService.js index 19a94c5d..cbb1a912 100644 --- a/backend/services/pivotFeedService.js +++ b/backend/services/pivotFeedService.js @@ -3,6 +3,16 @@ const getModels = require('./getModelService'); const { getTenantByKey } = require('./tenantConfigService'); const { toIsoWeek, isValidIsoWeek } = require('../utilities/pivotIsoWeek'); const { PIVOT_TAG_SLUG_PATTERN } = require('../schemas/pivotTagCatalog'); +const { + normalizePivotTimeSlots, + serializePivotTimeSlots, + isUpcomingWithTimeSlots, +} = require('../utilities/pivotTimeSlots'); +const { + serializePivotMovie, + resolvePivotCoverImageUrl, +} = require('../utilities/pivotMovieMetadata'); +const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger'); const FRIEND_CAP = 5; const PIVOT_EVENT_STATUSES = ['approved', 'not-applicable']; @@ -12,18 +22,50 @@ const PUBLIC_EVENT_FIELDS = function getPilotWindow(now = new Date()) { const windowStart = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1), + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), ); const windowEnd = new Date(windowStart.getTime() + 7 * 24 * 60 * 60 * 1000); return { windowStart, windowEnd }; } +/** Mongo filter: upcoming events with start (or any showtime) inside the 7-day pilot window. */ +function getFeedPilotWindowFilter(now = new Date()) { + const { windowStart, windowEnd } = getPilotWindow(now); + + return { + $and: [ + getUpcomingEventTimeFilter(now), + { + $or: [ + { + 'customFields.pivot.timeSlots.0': { $exists: false }, + start_time: { $gte: windowStart, $lt: windowEnd }, + }, + { + 'customFields.pivot.timeSlots.0': { $exists: true }, + 'customFields.pivot.timeSlots': { + $elemMatch: { + start_time: { $gte: windowStart, $lt: windowEnd }, + }, + }, + }, + ], + }, + ], + }; +} + /** True when the event has not ended yet — deck should not surface past plans. */ function isUpcomingPivotEvent(event, now = new Date()) { if (!event) { return false; } + const slotUpcoming = isUpcomingWithTimeSlots(event.customFields?.pivot, now); + if (slotUpcoming != null) { + return slotUpcoming; + } + const end = event.end_time != null && event.end_time !== '' ? new Date(event.end_time) @@ -75,13 +117,17 @@ function resolveDisplayHost(pivotMeta) { function serializePivotFeedEvent(event, extras) { const pivot = event.customFields?.pivot || {}; - const coverImageUrl = - typeof event.image === 'string' && event.image.trim() ? event.image.trim() : null; + const coverImageUrl = resolvePivotCoverImageUrl(event); + const movie = serializePivotMovie(pivot.movie); + const normalizedSlots = normalizePivotTimeSlots(pivot.timeSlots); + const timeSlots = normalizedSlots.length + ? serializePivotTimeSlots(normalizedSlots, extras.socialByTimeSlot) + : undefined; return { _id: String(event._id), name: event.name, - description: event.description, + description: movie?.synopsis || event.description, location: event.location, start_time: event.start_time, end_time: event.end_time, @@ -90,8 +136,11 @@ function serializePivotFeedEvent(event, extras) { registrationCount: event.registrationCount ?? 0, tags: Array.isArray(pivot.tags) ? pivot.tags : [], ...(coverImageUrl ? { coverImageUrl } : {}), + ...(timeSlots ? { timeSlots } : {}), + ...(movie ? { movie } : {}), displayHost: extras.displayHost, userIntent: extras.userIntent, + ...(extras.userTimeSlotId ? { userTimeSlotId: extras.userTimeSlotId } : {}), friendsInterested: extras.friendsInterested, friendsGoing: extras.friendsGoing, // Total counts (uncapped) so the client can render "N friends interested" @@ -141,7 +190,11 @@ async function loadFriendSocial(req, userId, eventIds, previewCap = FRIEND_CAP, const emptySocial = makeEmptySocialMap(eventIds); if (!eventIds.length) { - return { userIntents: new Map(), socialByEvent: emptySocial }; + return { + userIntents: new Map(), + socialByEvent: emptySocial, + socialByEventAndSlot: new Map(), + }; } const { Friendship, PivotEventIntent, User } = getModels( @@ -160,16 +213,26 @@ async function loadFriendSocial(req, userId, eventIds, previewCap = FRIEND_CAP, } const userIntentRows = await PivotEventIntent.find(userIntentQuery) - .select('eventId status') + .select('eventId status timeSlotId') .lean(); const userIntents = new Map( - userIntentRows.map((row) => [String(row.eventId), row.status]), + userIntentRows.map((row) => [ + String(row.eventId), + { + status: row.status, + timeSlotId: row.timeSlotId || null, + }, + ]), ); const friendIds = await getAcceptedFriendIds(Friendship, userId); if (!friendIds.length) { - return { userIntents, socialByEvent: emptySocial }; + return { + userIntents, + socialByEvent: emptySocial, + socialByEventAndSlot: new Map(), + }; } const friendIntentRows = await PivotEventIntent.find({ @@ -177,11 +240,15 @@ async function loadFriendSocial(req, userId, eventIds, previewCap = FRIEND_CAP, userId: { $in: friendIds }, status: { $in: ['interested', 'registered'] }, }) - .select('eventId userId status') + .select('eventId userId status timeSlotId') .lean(); if (!friendIntentRows.length) { - return { userIntents, socialByEvent: emptySocial }; + return { + userIntents, + socialByEvent: emptySocial, + socialByEventAndSlot: new Map(), + }; } const friendUserIds = [ @@ -193,6 +260,7 @@ async function loadFriendSocial(req, userId, eventIds, previewCap = FRIEND_CAP, const userById = new Map(users.map((user) => [String(user._id), user])); const socialByEvent = makeEmptySocialMap(eventIds); + const socialByEventAndSlot = new Map(); for (const row of friendIntentRows) { const eventKey = String(row.eventId); @@ -212,6 +280,22 @@ async function loadFriendSocial(req, userId, eventIds, previewCap = FRIEND_CAP, if (bucket.friendsInterested.length < previewCap) { bucket.friendsInterested.push(preview); } + + const slotId = row.timeSlotId ? String(row.timeSlotId).trim() : ''; + if (slotId) { + const slotKey = `${eventKey}:${slotId}`; + if (!socialByEventAndSlot.has(slotKey)) { + socialByEventAndSlot.set(slotKey, { + friendsGoing: [], + friendsGoingCount: 0, + }); + } + const slotBucket = socialByEventAndSlot.get(slotKey); + slotBucket.friendsGoingCount += 1; + if (slotBucket.friendsGoing.length < previewCap) { + slotBucket.friendsGoing.push(preview); + } + } } else if (row.status === 'interested') { bucket.friendInterestedCount += 1; if (bucket.friendsInterested.length < previewCap) { @@ -220,7 +304,7 @@ async function loadFriendSocial(req, userId, eventIds, previewCap = FRIEND_CAP, } } - return { userIntents, socialByEvent }; + return { userIntents, socialByEvent, socialByEventAndSlot }; } function normalizeExcludeEventIds(rawExcludeEventIds) { @@ -421,7 +505,6 @@ async function getPivotFeed(req, options = {}) { } const { Event } = getModels(req, 'Event'); - const { windowStart, windowEnd } = getPilotWindow(now); const excludeEventIds = normalizeExcludeEventIds(options.excludeEventIds); const query = { @@ -429,9 +512,8 @@ async function getPivotFeed(req, options = {}) { 'customFields.pivot.ingestStatus': 'published', status: { $in: PIVOT_EVENT_STATUSES }, isDeleted: { $ne: true }, - start_time: { $gte: windowStart, $lt: windowEnd }, 'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] }, - $and: [getUpcomingEventTimeFilter(now)], + ...getFeedPilotWindowFilter(now), }; if (excludeEventIds.length) { query._id = { $nin: excludeEventIds }; @@ -448,7 +530,7 @@ async function getPivotFeed(req, options = {}) { isUpcomingPivotEvent(event, now), ); const eventIds = validEvents.map((event) => event._id); - const { userIntents, socialByEvent } = await loadFriendSocial( + const { userIntents, socialByEvent, socialByEventAndSlot } = await loadFriendSocial( req, userId, eventIds, @@ -465,6 +547,22 @@ async function getPivotFeed(req, options = {}) { const tenant = await getTenantByKey(req, req.school); const cityDisplayName = tenant?.location || tenant?.name || req.school; + const multiSlotEventCount = validEvents.filter( + (event) => normalizePivotTimeSlots(event.customFields?.pivot?.timeSlots).length > 0, + ).length; + + logPivot('info', 'feed built', { + ...pivotRequestContext(req), + batchWeek, + cityDisplayName, + candidateCount: events.length, + eventCount: validEvents.length, + multiSlotEventCount, + excludedCount: excludeEventIds.length, + interestTagCount: userInterestTags.size, + negativeTagPenaltyCount: negativeFeedbackTags.size, + }); + return { data: { batchWeek, @@ -477,10 +575,23 @@ async function getPivotFeed(req, options = {}) { friendInterestedCount: 0, friendRegisteredCount: 0, }; + const userIntentRow = userIntents.get(id); + const normalizedSlots = normalizePivotTimeSlots( + event.customFields?.pivot?.timeSlots, + ); + const socialByTimeSlot = new Map(); + for (const slot of normalizedSlots) { + const slotSocial = socialByEventAndSlot.get(`${id}:${slot.id}`); + if (slotSocial) { + socialByTimeSlot.set(slot.id, slotSocial); + } + } return serializePivotFeedEvent(event, { displayHost: resolveDisplayHost(event.customFields.pivot), - userIntent: userIntents.get(id) || null, + userIntent: userIntentRow?.status || null, + userTimeSlotId: userIntentRow?.timeSlotId || null, + socialByTimeSlot, friendsInterested: social.friendsInterested, friendsGoing: social.friendsGoing, friendsInterestedCount: social.friendInterestedCount || 0, @@ -552,6 +663,7 @@ module.exports = { getPivotFeed, getPivotEventFriends, getPilotWindow, + getFeedPilotWindowFilter, isUpcomingPivotEvent, getUpcomingEventTimeFilter, resolveDisplayHost, diff --git a/backend/services/pivotFriendService.js b/backend/services/pivotFriendService.js index 5d221571..426761f6 100644 --- a/backend/services/pivotFriendService.js +++ b/backend/services/pivotFriendService.js @@ -1,10 +1,12 @@ const mongoose = require('mongoose'); const getModels = require('./getModelService'); +const getGlobalModels = require('./getGlobalModelService'); const NotificationService = require('./notificationService'); const { getFriendRequests } = require('../utilities/friendUtils'); const SEARCH_RESULT_LIMIT = 20; const MIN_QUERY_LENGTH = 2; +const COHORT_SUGGESTION_LIMIT = 30; function unauthorized() { return { error: 'Authentication required.', status: 401, code: 'UNAUTHORIZED' }; @@ -105,6 +107,163 @@ async function searchPivotFriends(req, options = {}) { return { data: { users: results } }; } +function toGlobalObjectId(value) { + return mongoose.Types.ObjectId.isValid(value) + ? new mongoose.Types.ObjectId(String(value)) + : null; +} + +function dedupePreservingOrder(ids) { + const seen = new Set(); + const ordered = []; + for (const id of ids) { + const key = id.toString(); + if (!seen.has(key)) { + seen.add(key); + ordered.push(id); + } + } + return ordered; +} + +/** + * Suggest other pilot users who joined the same **cohort** (shared referral-code cohortId) + * so a new user can seed their friend graph during onboarding ("know any of these people?"). + * + * Grouping is cohort-only (Task 0.3 `cohortId`) — never by raw code. Users whose code carries + * no cohortId, or who have no redemption record, get an empty list (the onboarding step then + * auto-skips). Excludes self and users who are already friends; pending requests remain visible + * with their status so the row shows a disabled "pending" chip. + * + * Redemptions + codes + membership live in the global DB; profiles + friendships in the tenant DB. + */ +async function getPivotCohortSuggestions(req) { + const userId = req.user?.userId; + if (!userId) { + return unauthorized(); + } + + const globalUserObjectId = toGlobalObjectId(req.user?.globalUserId); + const tenantKey = typeof req.school === 'string' ? req.school.trim().toLowerCase() : ''; + if (!globalUserObjectId || !tenantKey) { + return { data: { users: [] } }; + } + + const { PivotReferralCode, PivotReferralRedemption, TenantMembership } = getGlobalModels( + req, + 'PivotReferralCode', + 'PivotReferralRedemption', + 'TenantMembership', + ); + + // 1. Codes the requester has redeemed. + const myRedemptions = await PivotReferralRedemption.find({ globalUserId: globalUserObjectId }) + .select('code') + .lean(); + const myCodes = [...new Set(myRedemptions.map((row) => row.code).filter(Boolean))]; + if (!myCodes.length) { + return { data: { users: [] } }; + } + + // 2. cohortId(s) for those codes in the active pilot city. Cohort-only grouping. + const myCodeDocs = await PivotReferralCode.find({ code: { $in: myCodes }, tenantKey }) + .select('cohortId') + .lean(); + const cohortIds = [...new Set(myCodeDocs.map((doc) => doc.cohortId).filter(Boolean))]; + if (!cohortIds.length) { + return { data: { users: [] } }; + } + + // 3. All codes belonging to the same cohort(s) in this tenant. + const cohortCodeDocs = await PivotReferralCode.find({ cohortId: { $in: cohortIds }, tenantKey }) + .select('code') + .lean(); + const cohortCodes = [...new Set(cohortCodeDocs.map((doc) => doc.code).filter(Boolean))]; + if (!cohortCodes.length) { + return { data: { users: [] } }; + } + + // 4. globalUserIds that redeemed any cohort code (exclude self), most recent first. + const cohortRedemptions = await PivotReferralRedemption.find({ + code: { $in: cohortCodes }, + globalUserId: { $ne: globalUserObjectId }, + }) + .select('globalUserId') + .sort({ createdAt: -1 }) + .lean(); + const orderedGlobalIds = dedupePreservingOrder( + cohortRedemptions.map((row) => row.globalUserId), + ); + if (!orderedGlobalIds.length) { + return { data: { users: [] } }; + } + + // 5. Map global identities to tenant users via active membership in this city. + const memberships = await TenantMembership.find({ + globalUserId: { $in: orderedGlobalIds }, + tenantKey, + status: 'active', + }) + .select('globalUserId tenantUserId') + .lean(); + const tenantUserIdByGlobal = new Map( + memberships.map((m) => [m.globalUserId.toString(), m.tenantUserId.toString()]), + ); + + const orderedTenantUserIds = []; + for (const gid of orderedGlobalIds) { + const tenantUserId = tenantUserIdByGlobal.get(gid.toString()); + if (tenantUserId && tenantUserId !== userId.toString()) { + orderedTenantUserIds.push(tenantUserId); + } + } + if (!orderedTenantUserIds.length) { + return { data: { users: [] } }; + } + + const { User, Friendship } = getModels(req, 'User', 'Friendship'); + + const users = await User.find({ _id: { $in: orderedTenantUserIds } }) + .select('name picture username') + .lean(); + const userById = new Map(users.map((user) => [user._id.toString(), user])); + + const friendships = await Friendship.find({ + $or: [ + { requester: userId, recipient: { $in: orderedTenantUserIds } }, + { requester: { $in: orderedTenantUserIds }, recipient: userId }, + ], + }) + .select('requester recipient status') + .lean(); + + const friendshipByOtherId = new Map(); + for (const friendship of friendships) { + const otherId = + friendship.requester.toString() === userId.toString() + ? friendship.recipient.toString() + : friendship.requester.toString(); + friendshipByOtherId.set(otherId, friendship); + } + + const results = []; + for (const tenantUserId of orderedTenantUserIds) { + if (results.length >= COHORT_SUGGESTION_LIMIT) break; + const user = userById.get(tenantUserId); + if (!user) continue; + + const friendshipStatus = resolveFriendshipStatus( + friendshipByOtherId.get(tenantUserId), + userId, + ); + if (friendshipStatus === 'accepted') continue; // already friends — nothing to suggest + + results.push(serializeSearchUser(user, friendshipStatus)); + } + + return { data: { users: results } }; +} + /** * Send a friend request to another user in the current pilot city tenant by userId. * Pivot users may lack a campus username; this avoids POST /friend-request/:username. @@ -310,6 +469,7 @@ async function declinePivotFriendRequest(req, friendshipId) { module.exports = { searchPivotFriends, + getPivotCohortSuggestions, sendPivotFriendRequest, listPivotFriends, listPivotFriendRequests, @@ -317,4 +477,5 @@ module.exports = { declinePivotFriendRequest, SEARCH_RESULT_LIMIT, MIN_QUERY_LENGTH, + COHORT_SUGGESTION_LIMIT, }; diff --git a/backend/services/pivotIngestPublishService.js b/backend/services/pivotIngestPublishService.js index de032075..77a930d9 100644 --- a/backend/services/pivotIngestPublishService.js +++ b/backend/services/pivotIngestPublishService.js @@ -6,7 +6,7 @@ const { const { isPivotTenant } = require('./pivotReferralCodeService'); const { connectToDatabase } = require('../connectionsManager'); const { normalizeBatchWeek } = require('./pivotWeeklySnapshotService'); -const { previewIngestUrl, normalizeUrl, sanitizeEventPosterImage } = require('./pivotIngestPreviewService'); +const { previewIngestUrl, sanitizeEventPosterImage } = require('./pivotIngestPreviewService'); const { formatDuplicateWarning, isBlockingDuplicate, @@ -14,6 +14,12 @@ const { } = require('./pivotIngestDuplicateService'); const { serializeLabEvent } = require('./pivotLabEventsService'); const { validatePivotEventTags } = require('./pivotTagCatalogService'); +const { normalizePivotTimeSlots } = require('../utilities/pivotTimeSlots'); +const { + normalizePivotMovie, + applyMovieListingDefaults, +} = require('../utilities/pivotMovieMetadata'); +const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger'); const DEFAULT_DURATION_MS = 2 * 60 * 60 * 1000; @@ -76,7 +82,58 @@ async function resolveCatalogOrgId(req, tenant) { return { orgId: provisioned.orgId }; } +function detectIngestProvider(hostname) { + const host = hostname.toLowerCase(); + if (host.includes('partiful')) return 'partiful'; + if (host.includes('lu.ma') || host.includes('luma')) return 'luma'; + return null; +} + +function normalizePublishUrl(rawUrl) { + const trimmed = trimString(rawUrl); + if (!trimmed) { + return { url: null, provider: null }; + } + + let parsed; + try { + parsed = new URL(trimmed); + } catch { + return { error: 'Invalid URL.', status: 400, code: 'INVALID_URL' }; + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + return { error: 'Only HTTP(S) URLs are supported.', status: 400, code: 'INVALID_URL' }; + } + + return { + url: parsed.toString(), + provider: detectIngestProvider(parsed.hostname), + }; +} + +function normalizeIngestTimeSlots(rawSlots) { + if (!Array.isArray(rawSlots) || !rawSlots.length) { + return []; + } + + return normalizePivotTimeSlots(rawSlots).map((slot) => ({ + id: slot.id, + start_time: slot.start_time, + ...(slot.end_time ? { end_time: slot.end_time } : {}), + ...(slot.label ? { label: slot.label } : {}), + })); +} + function mergeDraftWithOverrides(draft = {}, overrides = {}) { + const timeSlots = normalizeIngestTimeSlots( + Array.isArray(overrides.timeSlots) + ? overrides.timeSlots + : Array.isArray(draft.timeSlots) + ? draft.timeSlots + : [], + ); + return { name: firstNonEmpty(overrides.name, draft.name), description: firstNonEmpty(overrides.description, draft.description) || '', @@ -87,18 +144,29 @@ function mergeDraftWithOverrides(draft = {}, overrides = {}) { hostName: firstNonEmpty(overrides.hostName, draft.hostName), hostImageUrl: null, hostProfileUrl: firstNonEmpty(overrides.hostProfileUrl, draft.hostProfileUrl), - source: draft.source || null, - sourceUrl: draft.sourceUrl || null, - tags: Array.isArray(overrides.tags) ? overrides.tags : [], + source: firstNonEmpty(overrides.source, draft.source), + sourceUrl: firstNonEmpty(overrides.sourceUrl, draft.sourceUrl), + tags: Array.isArray(overrides.tags) + ? overrides.tags + : Array.isArray(draft.tags) + ? draft.tags + : [], + timeSlots, + movie: normalizePivotMovie( + overrides.movie !== undefined ? overrides.movie : draft.movie, + ), }; } function validateMergedDraft(merged) { + const withMovieDefaults = applyMovieListingDefaults(merged); const missing = []; - if (!merged.hostName) missing.push('hostName'); - if (!merged.name) missing.push('name'); - if (!merged.location) missing.push('location'); - if (!merged.start_time) missing.push('start_time'); + if (!withMovieDefaults.hostName) missing.push('hostName'); + if (!withMovieDefaults.name) missing.push('name'); + if (!withMovieDefaults.location) missing.push('location'); + if (!withMovieDefaults.start_time && !withMovieDefaults.timeSlots?.length) { + missing.push('start_time'); + } if (missing.length) { return { @@ -108,7 +176,22 @@ function validateMergedDraft(merged) { }; } - const startTime = parseDateTime(merged.start_time); + const slots = normalizePivotTimeSlots(withMovieDefaults.timeSlots); + let startTime = parseDateTime(withMovieDefaults.start_time); + let endTime = parseDateTime(withMovieDefaults.end_time); + + if (slots.length) { + if (!startTime) { + startTime = slots[0].start_time; + } + if (!endTime) { + endTime = slots.reduce((latest, slot) => { + const candidate = slot.end_time || slot.start_time; + return !latest || candidate > latest ? candidate : latest; + }, null); + } + } + if (!startTime) { return { error: 'start_time must be a valid datetime.', @@ -117,12 +200,18 @@ function validateMergedDraft(merged) { }; } - let endTime = parseDateTime(merged.end_time); if (!endTime || endTime <= startTime) { endTime = new Date(startTime.getTime() + DEFAULT_DURATION_MS); } - return { merged: { ...merged, startTime, endTime } }; + return { + merged: { + ...withMovieDefaults, + timeSlots: slots, + startTime, + endTime, + }, + }; } function buildPivotMetadata(merged, { batchWeek, sourceUrl, importedBy, tags }) { @@ -137,6 +226,8 @@ function buildPivotMetadata(merged, { batchWeek, sourceUrl, importedBy, tags }) sourceUrl, host, tags: tags || [], + ...(merged.timeSlots?.length ? { timeSlots: merged.timeSlots } : {}), + ...(merged.movie ? { movie: merged.movie } : {}), ingestStatus: 'published', importedAt: new Date().toISOString(), importedBy, @@ -144,6 +235,7 @@ function buildPivotMetadata(merged, { batchWeek, sourceUrl, importedBy, tags }) } function buildEventPayload(merged, { catalogOrgId, sourceUrl, batchWeek, importedBy, tags }) { + const listingUrl = trimString(sourceUrl) || null; return { name: merged.name, description: merged.description || '', @@ -155,7 +247,7 @@ function buildEventPayload(merged, { catalogOrgId, sourceUrl, batchWeek, importe visibility: 'public', registrationEnabled: true, expectedAttendance: 0, - externalLink: sourceUrl, + ...(listingUrl ? { externalLink: listingUrl } : {}), hostingType: 'Org', hostingId: catalogOrgId, isDeleted: false, @@ -166,6 +258,22 @@ function buildEventPayload(merged, { catalogOrgId, sourceUrl, batchWeek, importe }; } +async function savePublishedCatalogEvent(tenantReq, eventPayload, sourceUrl) { + const { Event } = getModels(tenantReq, 'Event'); + const listingUrl = trimString(sourceUrl); + + if (listingUrl) { + return Event.findOneAndUpdate( + { 'customFields.pivot.sourceUrl': listingUrl }, + { $set: eventPayload }, + { upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true }, + ).lean(); + } + + const created = await Event.create(eventPayload); + return typeof created.toObject === 'function' ? created.toObject() : created; +} + async function publishIngestEvent(req, options = {}) { const batchNormalized = normalizeBatchWeek(options.batchWeek, options.now); if (batchNormalized.error) { @@ -177,32 +285,44 @@ async function publishIngestEvent(req, options = {}) { return tenantResult; } - const urlNormalized = normalizeUrl(options.url); + const overrides = options.overrides || {}; + const urlNormalized = normalizePublishUrl(options.url); if (urlNormalized.error) { return urlNormalized; } - const previewResult = await previewIngestUrl(req, { url: urlNormalized.url }); - if (previewResult.error) { - return previewResult; - } + let previewDraft = {}; + if (urlNormalized.url && urlNormalized.provider) { + const previewResult = await previewIngestUrl(req, { url: urlNormalized.url }); + if (previewResult.error) { + return previewResult; + } - const previewDraft = - previewResult.data?.mode === 'single' - ? previewResult.data.draft - : previewResult.data?.draft; + if (previewResult.data?.mode === 'batch') { + return { + error: 'Explore links must be published from batch import.', + status: 400, + code: 'BATCH_URL_REQUIRES_BATCH_PUBLISH', + }; + } - if (!previewDraft) { - return { - error: 'Explore links must be published from batch import.', - status: 400, - code: 'BATCH_URL_REQUIRES_BATCH_PUBLISH', + previewDraft = previewResult.data?.draft || {}; + } else if (urlNormalized.url) { + previewDraft = { + sourceUrl: urlNormalized.url, + source: firstNonEmpty(overrides.source) || 'manual', + }; + } else { + previewDraft = { + source: firstNonEmpty(overrides.source) || 'manual', + sourceUrl: firstNonEmpty(overrides.sourceUrl), }; } - const mergedInput = mergeDraftWithOverrides(previewDraft, options.overrides || {}); - mergedInput.sourceUrl = urlNormalized.url; - mergedInput.source = previewDraft?.source || urlNormalized.provider; + const mergedInput = mergeDraftWithOverrides(previewDraft, overrides); + mergedInput.sourceUrl = firstNonEmpty(urlNormalized.url, mergedInput.sourceUrl); + mergedInput.source = + firstNonEmpty(mergedInput.source, urlNormalized.provider) || 'manual'; const validated = validateMergedDraft(mergedInput); if (validated.error) { @@ -214,13 +334,14 @@ async function publishIngestEvent(req, options = {}) { return tagResult; } + const listingUrl = trimString(mergedInput.sourceUrl) || null; const { duplicate } = await resolveImportDuplicate(req, { tenantKey: tenantResult.tenant.tenantKey, candidate: { name: validated.merged.name, start_time: validated.merged.start_time, location: validated.merged.location, - sourceUrl: urlNormalized.url, + sourceUrl: listingUrl, }, }); @@ -237,7 +358,7 @@ async function publishIngestEvent(req, options = {}) { const importedBy = resolveImportedBy(req); const eventPayload = buildEventPayload(validated.merged, { catalogOrgId: catalogResult.orgId, - sourceUrl: urlNormalized.url, + sourceUrl: listingUrl, batchWeek: batchNormalized.batchWeek, importedBy, tags: tagResult.tags, @@ -245,13 +366,17 @@ async function publishIngestEvent(req, options = {}) { const db = await connectToDatabase(tenantResult.tenant.tenantKey); const tenantReq = { db }; - const { Event } = getModels(tenantReq, 'Event'); + const event = await savePublishedCatalogEvent(tenantReq, eventPayload, listingUrl); - const event = await Event.findOneAndUpdate( - { 'customFields.pivot.sourceUrl': urlNormalized.url }, - { $set: eventPayload }, - { upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true }, - ).lean(); + logPivot('info', 'catalog event published', { + tenantKey: tenantResult.tenant.tenantKey, + batchWeek: batchNormalized.batchWeek, + eventId: String(event._id), + name: event.name, + source: mergedInput.source, + timeSlotCount: validated.merged.timeSlots?.length ?? 0, + importedBy, + }); return { data: { @@ -285,11 +410,7 @@ async function publishBatchIngestEvents(req, options = {}) { const failures = []; for (const entry of events) { - const url = trimString(entry?.url); - if (!url) { - failures.push({ url: null, message: 'Event URL is required.' }); - continue; - } + const url = trimString(entry?.url) || undefined; const result = await publishIngestEvent(req, { tenantKey: options.tenantKey, @@ -300,7 +421,7 @@ async function publishBatchIngestEvents(req, options = {}) { }); if (result.error) { - failures.push({ url, message: result.error, code: result.code }); + failures.push({ url: url || null, message: result.error, code: result.code }); continue; } @@ -316,6 +437,13 @@ async function publishBatchIngestEvents(req, options = {}) { }; } + logPivot('info', 'batch catalog publish complete', { + tenantKey: tenantResult.tenant.tenantKey, + batchWeek: batchNormalized.batchWeek, + publishedCount: published.length, + failedCount: failures.length, + }); + return { data: { published, @@ -447,6 +575,58 @@ async function updateIngestEvent(req, options = {}) { pivotPatch.tags = tagResult.tags; } + if (overrides.timeSlots !== undefined) { + const slots = normalizeIngestTimeSlots(overrides.timeSlots); + if (slots.length) { + pivotPatch.timeSlots = slots; + if (overrides.start_time === undefined) { + setPayload.start_time = slots[0].start_time; + } + if (overrides.end_time === undefined) { + const latestEnd = slots.reduce((latest, slot) => { + const candidate = slot.end_time || slot.start_time; + return !latest || new Date(candidate).getTime() > new Date(latest).getTime() + ? candidate + : latest; + }, null); + if (latestEnd) { + setPayload.end_time = latestEnd; + } + } + } else { + delete pivotPatch.timeSlots; + } + } + + if (overrides.sourceUrl !== undefined) { + const sourceUrl = trimString(overrides.sourceUrl); + if (sourceUrl) { + pivotPatch.sourceUrl = sourceUrl; + setPayload.externalLink = sourceUrl; + } else { + delete pivotPatch.sourceUrl; + setPayload.externalLink = null; + } + } + + if (overrides.movie !== undefined) { + const movie = normalizePivotMovie(overrides.movie); + if (movie) { + pivotPatch.movie = movie; + if (overrides.name === undefined && movie.title) { + setPayload.name = movie.title; + } + if (overrides.description === undefined && movie.synopsis) { + setPayload.description = movie.synopsis; + } + if (overrides.image === undefined && movie.posterUrl) { + setPayload.image = movie.posterUrl; + } + } else { + delete pivotPatch.movie; + } + } + const nextIngestStatus = pivotPatch.ingestStatus ?? pivot.ingestStatus; const nextTags = pivotPatch.tags ?? pivot.tags ?? []; if (nextIngestStatus === 'published' && nextTags.length === 0) { @@ -487,4 +667,5 @@ module.exports = { mergeDraftWithOverrides, validateMergedDraft, buildEventPayload, + normalizePublishUrl, }; diff --git a/backend/services/pivotIntentService.js b/backend/services/pivotIntentService.js index cad75ede..02efb09d 100644 --- a/backend/services/pivotIntentService.js +++ b/backend/services/pivotIntentService.js @@ -1,11 +1,19 @@ const mongoose = require('mongoose'); const getModels = require('./getModelService'); const { - getPilotWindow, + getFeedPilotWindowFilter, resolveDisplayHost, + serializePivotFeedEvent, + loadFriendSocial, PIVOT_EVENT_STATUSES, } = require('./pivotFeedService'); const { toIsoWeek, isValidIsoWeek } = require('../utilities/pivotIsoWeek'); +const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger'); +const { + normalizePivotTimeSlots, + findTimeSlotById, + eventHasTimeSlots, +} = require('../utilities/pivotTimeSlots'); const FEED_ACTION_TO_STATUS = { interested: 'interested', @@ -28,7 +36,7 @@ function unauthorized() { async function findPublishedPivotEvent(req, eventId, { now, requireWindow } = {}) { const { Event } = getModels(req, 'Event'); - const query = { + const baseQuery = { _id: eventId, 'customFields.pivot.ingestStatus': 'published', status: { $in: PIVOT_EVENT_STATUSES }, @@ -36,28 +44,65 @@ async function findPublishedPivotEvent(req, eventId, { now, requireWindow } = {} 'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] }, }; - if (requireWindow) { - const { windowStart, windowEnd } = getPilotWindow(now); - query.start_time = { $gte: windowStart, $lt: windowEnd }; - } + const effectiveNow = now ? new Date(now) : new Date(); + const query = requireWindow + ? { $and: [baseQuery, getFeedPilotWindowFilter(effectiveNow)] } + : baseQuery; return Event.findOne(query) .select('start_time end_time externalLink customFields.pivot') .lean(); } -async function upsertIntent(req, { userId, eventId, status, batchWeek }) { +async function upsertIntent(req, { userId, eventId, status, batchWeek, timeSlotId }) { const { PivotEventIntent } = getModels(req, 'PivotEventIntent'); + const update = { status, batchWeek }; + if (timeSlotId !== undefined) { + update.timeSlotId = timeSlotId || null; + } + const doc = await PivotEventIntent.findOneAndUpdate( { userId, eventId }, - { $set: { status, batchWeek } }, + { $set: update }, { upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true }, ).lean(); return doc; } +function resolveRegisteredTimeSlotId(event, requestedTimeSlotId) { + const pivot = event.customFields?.pivot; + if (!eventHasTimeSlots(pivot)) { + return { timeSlotId: null }; + } + + const slots = normalizePivotTimeSlots(pivot?.timeSlots); + const trimmed = typeof requestedTimeSlotId === 'string' ? requestedTimeSlotId.trim() : ''; + + if (slots.length === 1) { + return { timeSlotId: trimmed || slots[0].id }; + } + + if (!trimmed) { + return { + error: 'A showtime is required for this event.', + status: 400, + code: 'TIME_SLOT_REQUIRED', + }; + } + + if (!findTimeSlotById(pivot, trimmed)) { + return { + error: 'Invalid showtime for this event.', + status: 400, + code: 'INVALID_TIME_SLOT', + }; + } + + return { timeSlotId: trimmed }; +} + async function recordFeedAction(req, body = {}) { const userId = req.user?.userId; if (!userId) { @@ -97,13 +142,28 @@ async function recordFeedAction(req, body = {}) { } const batchWeek = event.customFields?.pivot?.batchWeek || toIsoWeek(); - const doc = await upsertIntent(req, { userId, eventId, status, batchWeek }); + const doc = await upsertIntent(req, { + userId, + eventId, + status, + batchWeek, + timeSlotId: null, + }); + + logPivot('info', 'feed action recorded', { + ...pivotRequestContext(req), + eventId, + status: doc.status, + batchWeek: doc.batchWeek, + action, + }); return { data: { eventId: String(doc.eventId), status: doc.status, batchWeek: doc.batchWeek, + timeSlotId: doc.timeSlotId || null, }, }; } @@ -152,6 +212,14 @@ async function recordExternalOpen(req, rawEventId, body = {}) { { upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true }, ).lean(); + logPivot('info', 'external ticket open recorded', { + ...pivotRequestContext(req), + eventId, + status: doc.status, + externalOpenCount: doc.externalOpenCount, + batchWeek: doc.batchWeek, + }); + return { data: { eventId: String(doc.eventId), @@ -163,7 +231,7 @@ async function recordExternalOpen(req, rawEventId, body = {}) { }; } -async function confirmRegistered(req, rawEventId) { +async function confirmRegistered(req, rawEventId, body = {}) { const userId = req.user?.userId; if (!userId) { return unauthorized(); @@ -187,12 +255,25 @@ async function confirmRegistered(req, rawEventId) { }; } + const slotResolution = resolveRegisteredTimeSlotId(event, body.timeSlotId); + if (slotResolution.error) { + return slotResolution; + } + const batchWeek = event.customFields?.pivot?.batchWeek || toIsoWeek(); const doc = await upsertIntent(req, { userId, eventId, status: 'registered', batchWeek, + timeSlotId: slotResolution.timeSlotId, + }); + + logPivot('info', 'registration confirmed', { + ...pivotRequestContext(req), + eventId, + batchWeek: doc.batchWeek, + timeSlotId: doc.timeSlotId || null, }); return { @@ -200,29 +281,30 @@ async function confirmRegistered(req, rawEventId) { eventId: String(doc.eventId), status: doc.status, batchWeek: doc.batchWeek, + timeSlotId: doc.timeSlotId || null, }, }; } -function serializeRecapEvent(event, userIntent) { +function serializeRecapEvent(event, intentRow, extras = {}) { const pivot = event.customFields?.pivot || {}; - const coverImageUrl = - typeof event.image === 'string' && event.image.trim() ? event.image.trim() : null; - - return { - _id: String(event._id), - name: event.name, - description: event.description, - location: event.location, - start_time: event.start_time, - end_time: event.end_time, - externalLink: event.externalLink, - type: event.type, - tags: Array.isArray(pivot.tags) ? pivot.tags : [], - ...(coverImageUrl ? { coverImageUrl } : {}), + const status = + intentRow && typeof intentRow === 'object' ? intentRow.status : intentRow; + const userTimeSlotId = + (intentRow && typeof intentRow === 'object' ? intentRow.timeSlotId : null) || + extras.userTimeSlotId || + null; + + return serializePivotFeedEvent(event, { displayHost: resolveDisplayHost(pivot), - userIntent, - }; + userIntent: status || null, + userTimeSlotId, + socialByTimeSlot: extras.socialByTimeSlot || new Map(), + friendsInterested: extras.friendsInterested || [], + friendsGoing: extras.friendsGoing || [], + friendsInterestedCount: extras.friendsInterestedCount || 0, + friendsGoingCount: extras.friendsGoingCount || 0, + }); } async function getWeekRecap(req, options = {}) { @@ -248,7 +330,7 @@ async function getWeekRecap(req, options = {}) { batchWeek, status: { $in: RECAP_STATUSES }, }) - .select('eventId status') + .select('eventId status timeSlotId') .lean(); if (!intents.length) { @@ -256,7 +338,10 @@ async function getWeekRecap(req, options = {}) { } const intentByEvent = new Map( - intents.map((intent) => [String(intent.eventId), intent.status]), + intents.map((intent) => [ + String(intent.eventId), + { status: intent.status, timeSlotId: intent.timeSlotId || null }, + ]), ); const eventIds = [...intentByEvent.keys()]; @@ -271,11 +356,48 @@ async function getWeekRecap(req, options = {}) { .sort({ start_time: 1 }) .lean(); + const { socialByEvent, socialByEventAndSlot } = await loadFriendSocial( + req, + userId, + eventIds, + ); + const recapEvents = events .filter((event) => resolveDisplayHost(event.customFields?.pivot)) - .map((event) => - serializeRecapEvent(event, intentByEvent.get(String(event._id)) || null), - ); + .map((event) => { + const id = String(event._id); + const social = socialByEvent.get(id) || { + friendsInterested: [], + friendsGoing: [], + friendInterestedCount: 0, + friendRegisteredCount: 0, + }; + const normalizedSlots = normalizePivotTimeSlots( + event.customFields?.pivot?.timeSlots, + ); + const socialByTimeSlot = new Map(); + for (const slot of normalizedSlots) { + const slotSocial = socialByEventAndSlot.get(`${id}:${slot.id}`); + if (slotSocial) { + socialByTimeSlot.set(slot.id, slotSocial); + } + } + + return serializeRecapEvent(event, intentByEvent.get(id), { + socialByTimeSlot, + friendsInterested: social.friendsInterested, + friendsGoing: social.friendsGoing, + friendsInterestedCount: social.friendInterestedCount || 0, + friendsGoingCount: social.friendRegisteredCount || 0, + }); + }); + + logPivot('info', 'week recap built', { + ...pivotRequestContext(req), + batchWeek, + intentCount: intents.length, + eventCount: recapEvents.length, + }); return { data: { batchWeek, events: recapEvents } }; } @@ -321,4 +443,5 @@ module.exports = { resetWeekActions, findPublishedPivotEvent, serializeRecapEvent, + resolveRegisteredTimeSlotId, }; diff --git a/backend/services/pivotLabEventsService.js b/backend/services/pivotLabEventsService.js index c79412c2..08a5ae6a 100644 --- a/backend/services/pivotLabEventsService.js +++ b/backend/services/pivotLabEventsService.js @@ -5,6 +5,7 @@ const { connectToDatabase } = require('../connectionsManager'); const { normalizeBatchWeek, } = require('./pivotWeeklySnapshotService'); +const { serializePivotMovie } = require('../utilities/pivotMovieMetadata'); function labEventsQuery(batchWeek) { return { @@ -14,26 +15,85 @@ function labEventsQuery(batchWeek) { }; } -function serializeLabEvent(event) { +const EMPTY_INTENT_STATS = Object.freeze({ + interested: 0, + registered: 0, + passed: 0, + externalOpens: 0, + externalOpenUsers: 0, +}); + +function serializeLabEvent(event, intentStatsByEventId) { const pivot = event.customFields?.pivot || {}; const host = pivot.host || {}; + const movie = serializePivotMovie(pivot.movie); + const timeSlots = Array.isArray(pivot.timeSlots) + ? pivot.timeSlots.map((slot) => ({ + id: slot.id, + start_time: slot.start_time, + end_time: slot.end_time || null, + label: slot.label || null, + })) + : []; return { _id: String(event._id), name: event.name, + description: event.description || '', + image: event.image || null, start_time: event.start_time, end_time: event.end_time || null, location: event.location || '', externalLink: event.externalLink || null, + sourceUrl: pivot.sourceUrl || null, ingestStatus: pivot.ingestStatus || null, source: pivot.source || null, batchWeek: pivot.batchWeek || null, tags: Array.isArray(pivot.tags) ? pivot.tags : [], + timeSlots, + ...(movie ? { movie } : {}), organizerName: host.name || '', organizerImageUrl: host.imageUrl || null, + intentStats: intentStatsByEventId?.get(String(event._id)) || EMPTY_INTENT_STATS, }; } +/** Per-event intent counts so Lab can see which catalog events earned the swipes. */ +async function loadIntentStatsByEventId(PivotEventIntent, eventIds) { + if (!eventIds.length) { + return new Map(); + } + + const rows = await PivotEventIntent.aggregate([ + { $match: { eventId: { $in: eventIds } } }, + { + $group: { + _id: '$eventId', + interested: { $sum: { $cond: [{ $eq: ['$status', 'interested'] }, 1, 0] } }, + registered: { $sum: { $cond: [{ $eq: ['$status', 'registered'] }, 1, 0] } }, + passed: { $sum: { $cond: [{ $eq: ['$status', 'passed'] }, 1, 0] } }, + externalOpens: { $sum: { $ifNull: ['$externalOpenCount', 0] } }, + externalOpenUsers: { + $sum: { $cond: [{ $gt: [{ $ifNull: ['$externalOpenCount', 0] }, 0] }, 1, 0] }, + }, + }, + }, + ]); + + return new Map( + rows.map((row) => [ + String(row._id), + { + interested: row.interested ?? 0, + registered: row.registered ?? 0, + passed: row.passed ?? 0, + externalOpens: row.externalOpens ?? 0, + externalOpenUsers: row.externalOpenUsers ?? 0, + }, + ]), + ); +} + async function listPivotLabEvents(req, options = {}) { const normalized = normalizeBatchWeek(options.batchWeek, options.now); if (normalized.error) { @@ -62,21 +122,26 @@ async function listPivotLabEvents(req, options = {}) { const { batchWeek } = normalized; const db = await connectToDatabase(tenantKey); const tenantReq = { db }; - const { Event } = getModels(tenantReq, 'Event'); + const { Event, PivotEventIntent } = getModels(tenantReq, 'Event', 'PivotEventIntent'); const query = labEventsQuery(batchWeek); const events = await Event.find(query) - .select('name start_time end_time location externalLink customFields.pivot') + .select('name description image start_time end_time location externalLink customFields.pivot') .sort({ start_time: 1 }) .lean(); + const intentStatsByEventId = await loadIntentStatsByEventId( + PivotEventIntent, + events.map((event) => event._id), + ); + return { data: { tenantKey, cityDisplayName: tenant.location || tenant.name || tenantKey, batchWeek, - events: events.map(serializeLabEvent), + events: events.map((event) => serializeLabEvent(event, intentStatsByEventId)), }, }; } @@ -84,5 +149,6 @@ async function listPivotLabEvents(req, options = {}) { module.exports = { listPivotLabEvents, serializeLabEvent, + loadIntentStatsByEventId, labEventsQuery, }; diff --git a/backend/services/pivotProfileService.js b/backend/services/pivotProfileService.js index 47c5b159..47f765cf 100644 --- a/backend/services/pivotProfileService.js +++ b/backend/services/pivotProfileService.js @@ -1,5 +1,12 @@ -const getModels = require('./getModelService'); const { validatePivotInterestTags } = require('./pivotTagCatalogService'); +const { + deleteAllUserSessions, + deleteAllGlobalUserSessions, +} = require('../utilities/sessionUtils'); + +function getModels(req, ...names) { + return require('./getModelService')(req, ...names); +} function unauthorized() { return { error: 'Authentication required.', status: 401, code: 'UNAUTHORIZED' }; @@ -9,6 +16,116 @@ function normalizeStoredInterestTags(raw) { return Array.isArray(raw) ? raw : []; } +function currentYearUtc() { + return new Date().getUTCFullYear(); +} + +function hasPivotProfileSignals(user) { + return Boolean( + user?.pivotAgeVerifiedAt || + user?.pivotBirthYear || + user?.pivotLeftAt || + user?.pivotParticipationStatus === 'left' || + user?.pushAppEdition === 'pivot' || + (Array.isArray(user?.pivotInterestTags) && user.pivotInterestTags.length > 0), + ); +} + +function isTenantAdmin(user, platformRoles = []) { + const tenantRoles = Array.isArray(user?.roles) ? user.roles : []; + const platform = Array.isArray(platformRoles) ? platformRoles : []; + return ( + tenantRoles.includes('admin') || + tenantRoles.includes('root') || + platform.includes('platform_admin') || + platform.includes('root') + ); +} + +async function isPivotTenantRequest(req) { + try { + const { getTenantByKey } = require('./tenantConfigService'); + const tenantKey = typeof req.school === 'string' ? req.school.trim().toLowerCase() : ''; + if (!tenantKey) { + return false; + } + const tenant = await getTenantByKey(req, tenantKey); + return tenant?.pivotPilot === true || tenant?.tenantType === 'pivot'; + } catch { + return false; + } +} + +/** + * Leave pilot incorrectly used accessSuspended before pivotParticipationStatus existed. + * Clear that suspension so users can sign in again and rejoin via invite. + */ +async function normalizePivotLeaveAuthUser(req, userId, platformRoles = []) { + if (!userId) { + return null; + } + + const { User } = getModels(req, 'User'); + const user = await User.findById(userId); + if (!user) { + return null; + } + + if (!user.accessSuspended) { + return user; + } + + const onPivotTenant = await isPivotTenantRequest(req); + const isAdmin = isTenantAdmin(user, platformRoles); + const hasPivotLeaveState = + user.pivotParticipationStatus === 'left' || user.pivotLeftAt != null; + + const shouldClearPivotLeaveSuspension = + hasPivotLeaveState || + hasPivotProfileSignals(user) || + (onPivotTenant && !isAdmin); + + if (!shouldClearPivotLeaveSuspension) { + return user; + } + + const leftAt = user.pivotLeftAt || user.accessSuspendedAt || new Date(); + user.accessSuspended = false; + user.accessSuspendedAt = null; + if (user.pivotParticipationStatus !== 'left') { + user.pivotParticipationStatus = 'left'; + } + if (!user.pivotLeftAt) { + user.pivotLeftAt = leftAt; + } + await user.save(); + return user; +} + +async function reactivatePivotParticipationByGlobalUserId(req, globalUserId) { + if (!globalUserId) { + return; + } + + const authGlobalService = require('./authGlobalService'); + const { tenantUserId } = await authGlobalService.resolveTenantUserForRequest( + req, + globalUserId, + ); + if (!tenantUserId) { + return; + } + + const { User } = getModels(req, 'User'); + await User.updateOne( + { _id: tenantUserId }, + { + $set: { pivotParticipationStatus: 'active' }, + $unset: { pivotLeftAt: '' }, + }, + ); +} + async function getPivotProfileInterests(req) { const userId = req.user?.userId; if (!userId) { @@ -63,7 +180,104 @@ async function updatePivotProfileInterests(req, body = {}) { }; } +async function updatePivotProfileAgeVerification(req, body = {}) { + const userId = req.user?.userId; + if (!userId) { + return unauthorized(); + } + + if (!Object.prototype.hasOwnProperty.call(body, 'birthYear')) { + return { + error: 'birthYear is required.', + status: 400, + code: 'VALIDATION_ERROR', + }; + } + + const birthYear = Number.parseInt(String(body.birthYear), 10); + const nowYear = currentYearUtc(); + const minYear = nowYear - 120; + if (!Number.isInteger(birthYear) || birthYear < minYear || birthYear > nowYear) { + return { + error: 'birthYear must be a valid 4-digit year.', + status: 400, + code: 'VALIDATION_ERROR', + }; + } + + const age = nowYear - birthYear; + if (age < 18) { + return { + error: 'You must be 18 or older to use Just Go.', + status: 403, + code: 'UNDERAGE', + }; + } + + const { User } = getModels(req, 'User'); + const user = await User.findById(userId); + if (!user) { + return { error: 'User not found.', status: 404, code: 'USER_NOT_FOUND' }; + } + + user.pivotBirthYear = birthYear; + user.pivotAgeVerifiedAt = new Date(); + await user.save(); + + return { + data: { + birthYear: user.pivotBirthYear, + pivotAgeVerifiedAt: user.pivotAgeVerifiedAt, + }, + }; +} + +async function leavePivotPilot(req) { + const userId = req.user?.userId; + if (!userId) { + return unauthorized(); + } + + const { User } = getModels(req, 'User'); + const user = await User.findById(userId); + if (!user) { + return { error: 'User not found.', status: 404, code: 'USER_NOT_FOUND' }; + } + + user.pivotParticipationStatus = 'left'; + user.pivotLeftAt = new Date(); + if (Object.prototype.hasOwnProperty.call(user, 'refreshToken')) { + user.refreshToken = null; + } + await user.save(); + + try { + await deleteAllUserSessions(userId, req); + } catch { + // best effort session invalidation + } + if (req.user?.globalUserId) { + try { + await deleteAllGlobalUserSessions(req.user.globalUserId, req); + } catch { + // best effort session invalidation + } + } + + return { + data: { + deactivated: true, + deactivatedAt: user.pivotLeftAt, + pivotParticipationStatus: user.pivotParticipationStatus, + }, + }; +} + module.exports = { getPivotProfileInterests, updatePivotProfileInterests, + updatePivotProfileAgeVerification, + leavePivotPilot, + normalizePivotLeaveAuthUser, + reactivatePivotParticipationByGlobalUserId, }; diff --git a/backend/services/pivotReferralCodeService.js b/backend/services/pivotReferralCodeService.js index 1c4af3b5..d1d733a5 100644 --- a/backend/services/pivotReferralCodeService.js +++ b/backend/services/pivotReferralCodeService.js @@ -1,7 +1,9 @@ const mongoose = require('mongoose'); const getGlobalModels = require('./getGlobalModelService'); +const getModels = require('./getModelService'); const { getTenantByKey } = require('./tenantConfigService'); const { isValidIsoWeek, toIsoWeek } = require('../utilities/pivotIsoWeek'); +const { reactivatePivotParticipationByGlobalUserId } = require('./pivotProfileService'); function isPivotTenant(tenant) { return tenant?.pivotPilot === true || tenant?.tenantType === 'pivot'; @@ -325,13 +327,66 @@ async function validateReferralCode(req, rawCode) { }; } +function toObjectId(value) { + if (!value || !mongoose.Types.ObjectId.isValid(value)) { + return null; + } + return new mongoose.Types.ObjectId(String(value)); +} + +/** + * Resolve a tenant-scoped inviter user id (from invite `ref` param) to global identity. + * Returns null when ref is missing, invalid, unknown, or not an active member of this city. + */ +async function resolveReferredByGlobalUserId(req, referredByUserId, redeemerGlobalUserId) { + const inviterTenantId = toObjectId(referredByUserId); + if (!inviterTenantId || !redeemerGlobalUserId) { + return null; + } + + const tenantKey = typeof req.school === 'string' ? req.school.trim().toLowerCase() : ''; + if (!tenantKey) { + return null; + } + + const { TenantMembership } = getGlobalModels(req, 'TenantMembership'); + const { User } = getModels(req, 'User'); + + const inviterUser = await User.findById(inviterTenantId).select('_id').lean(); + if (!inviterUser) { + return null; + } + + const membership = await TenantMembership.findOne({ + tenantKey, + tenantUserId: inviterTenantId, + status: 'active', + }) + .select('globalUserId') + .lean(); + + const inviterGlobalUserId = toObjectId(membership?.globalUserId); + if (!inviterGlobalUserId) { + return null; + } + + if (inviterGlobalUserId.equals(redeemerGlobalUserId)) { + return null; + } + + return inviterGlobalUserId; +} + /** * Persist a redemption for authenticated global users only; increments PivotReferralCode.redemptionCount * once per (globalUserId, code). Idempotent when the user retries. * * Requires req.school to match the code's tenant (city). + * + * Optional `referredByUserId` (tenant user id from invite link `ref` param) is stored as + * `referredByGlobalUserId` on first successful redemption for invite-propagation analytics. */ -async function redeemReferralCode(req, rawCode) { +async function redeemReferralCode(req, rawCode, options = {}) { const gid = req.user?.globalUserId; if (!gid) { return { @@ -369,17 +424,25 @@ async function redeemReferralCode(req, rawCode) { }; } + const referredByGlobalUserId = await resolveReferredByGlobalUserId( + req, + options.referredByUserId, + globalUserObjectId, + ); + const existingRedemption = await PivotReferralRedemption.findOne({ globalUserId: globalUserObjectId, code, }).lean(); if (existingRedemption) { const refRow = await PivotReferralCode.findOne({ code }).select('redemptionCount maxRedemptions').lean(); + await reactivatePivotParticipationByGlobalUserId(req, globalUserObjectId); return { data: { alreadyRedeemed: true, redemptionCount: refRow?.redemptionCount ?? null, maxRedemptions: refRow?.maxRedemptions ?? null, + inviterAttributed: Boolean(existingRedemption.referredByGlobalUserId), }, }; } @@ -444,32 +507,41 @@ async function redeemReferralCode(req, rawCode) { } try { - await PivotReferralRedemption.create({ + const redemptionPayload = { globalUserId: globalUserObjectId, code, pivotReferralCodeId: updated._id, - }); + }; + if (referredByGlobalUserId) { + redemptionPayload.referredByGlobalUserId = referredByGlobalUserId; + } + await PivotReferralRedemption.create(redemptionPayload); } catch (err) { if (err?.code === 11000) { await PivotReferralCode.updateOne({ _id: updated._id }, { $inc: { redemptionCount: -1 } }); const refRow = await PivotReferralCode.findOne({ code }).select('redemptionCount maxRedemptions').lean(); + await reactivatePivotParticipationByGlobalUserId(req, globalUserObjectId); return { data: { alreadyRedeemed: true, redemptionCount: refRow?.redemptionCount ?? null, maxRedemptions: refRow?.maxRedemptions ?? null, + inviterAttributed: false, }, }; } throw err; } + await reactivatePivotParticipationByGlobalUserId(req, globalUserObjectId); + return { data: { redeemed: true, alreadyRedeemed: false, redemptionCount: updated.redemptionCount, maxRedemptions: updated.maxRedemptions, + inviterAttributed: Boolean(referredByGlobalUserId), }, }; } diff --git a/backend/services/pivotRetentionService.js b/backend/services/pivotRetentionService.js new file mode 100644 index 00000000..252d389d --- /dev/null +++ b/backend/services/pivotRetentionService.js @@ -0,0 +1,110 @@ +const getModels = require('./getModelService'); +const { getMergedTenants } = require('./tenantConfigService'); +const { isPivotTenant } = require('./pivotReferralCodeService'); +const { connectToDatabase } = require('../connectionsManager'); +const { normalizeBatchWeek } = require('./pivotWeeklySnapshotService'); +const { shiftIsoWeek } = require('../utilities/pivotIsoWeek'); + +const DEFAULT_WEEKS = 6; +const MAX_WEEKS = 12; + +function normalizeWeeksParam(raw) { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return DEFAULT_WEEKS; + return Math.min(MAX_WEEKS, Math.max(2, Math.trunc(parsed))); +} + +/** + * Week-over-week retention from PivotEventIntent activity: a user is "active" in a + * batchWeek when they have any intent row (interested / registered / passed) for it, + * and "returning" when they were also active the week before. + */ +async function aggregateTenantRetention(tenant, weekList) { + const db = await connectToDatabase(tenant.tenantKey); + const tenantReq = { db }; + const { PivotEventIntent } = getModels(tenantReq, 'PivotEventIntent'); + + const userSets = await Promise.all( + weekList.map(async (batchWeek) => { + const userIds = await PivotEventIntent.distinct('userId', { batchWeek }); + return new Set(userIds.map(String)); + }), + ); + + const weeks = weekList.map((batchWeek, index) => { + const active = userSets[index]; + const previous = index > 0 ? userSets[index - 1] : null; + const returningUsers = previous + ? [...active].filter((userId) => previous.has(userId)).length + : null; + const retentionRate = + previous && previous.size > 0 && returningUsers !== null + ? Math.round((returningUsers / previous.size) * 1000) / 10 + : null; + + return { + batchWeek, + activeUsers: active.size, + returningUsers, + retentionRate, + }; + }); + + return { + tenantKey: tenant.tenantKey, + cityDisplayName: tenant.location || tenant.name || tenant.tenantKey, + weeks, + }; +} + +async function getPivotRetention(req, options = {}) { + const normalized = normalizeBatchWeek(options.batchWeek, options.now); + if (normalized.error) { + return normalized; + } + + const { batchWeek } = normalized; + const weeksCount = normalizeWeeksParam(options.weeks); + const weekList = Array.from({ length: weeksCount }, (_, index) => + shiftIsoWeek(batchWeek, index - (weeksCount - 1)), + ); + + const pivotTenants = (await getMergedTenants(req)).filter(isPivotTenant); + const tenants = []; + + for (const tenant of pivotTenants) { + try { + tenants.push(await aggregateTenantRetention(tenant, weekList)); + } catch (error) { + console.error( + `[pivotRetention] aggregate failed tenant=${tenant.tenantKey} batchWeek=${batchWeek}:`, + error, + ); + tenants.push({ + tenantKey: tenant.tenantKey, + cityDisplayName: tenant.location || tenant.name || tenant.tenantKey, + weeks: weekList.map((week) => ({ + batchWeek: week, + activeUsers: 0, + returningUsers: null, + retentionRate: null, + })), + error: 'AGGREGATION_FAILED', + }); + } + } + + return { + data: { + batchWeek, + weeks: weekList, + tenants, + }, + }; +} + +module.exports = { + getPivotRetention, + aggregateTenantRetention, + normalizeWeeksParam, +}; diff --git a/backend/services/pivotTmdbService.js b/backend/services/pivotTmdbService.js new file mode 100644 index 00000000..bfdb5736 --- /dev/null +++ b/backend/services/pivotTmdbService.js @@ -0,0 +1,250 @@ +const axios = require('axios'); +const { + buildTmdbImageUrl, + normalizePivotMovie, +} = require('../utilities/pivotMovieMetadata'); + +const TMDB_API_BASE = 'https://api.themoviedb.org/3'; +const REQUEST_TIMEOUT_MS = 12_000; + +function trimString(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function resolveTmdbCredentials() { + const readAccessToken = + trimString(process.env.TMDB_API_READ_ACCESS_TOKEN) || + trimString(process.env.TMDB_READ_ACCESS_TOKEN) || + trimString(process.env.TMDB_ACCESS_TOKEN); + + if (readAccessToken) { + return { mode: 'bearer', token: readAccessToken }; + } + + const apiKey = trimString(process.env.TMDB_API_KEY); + if (apiKey) { + return { mode: 'api_key', key: apiKey }; + } + + return null; +} + +function buildTmdbRequestConfig(params = {}) { + const credentials = resolveTmdbCredentials(); + if (!credentials) { + return null; + } + + const config = { + timeout: REQUEST_TIMEOUT_MS, + params, + }; + + if (credentials.mode === 'bearer') { + config.headers = { + Authorization: `Bearer ${credentials.token}`, + }; + return config; + } + + config.params = { + api_key: credentials.key, + ...params, + }; + return config; +} + +function tmdbUnavailableResult() { + return { + error: + 'TMDB is not configured. Set TMDB_API_READ_ACCESS_TOKEN (recommended) or TMDB_API_KEY in the backend environment.', + status: 503, + code: 'TMDB_NOT_CONFIGURED', + }; +} + +function extractYear(releaseDate) { + const value = trimString(releaseDate); + if (!value) { + return null; + } + const year = Number(value.slice(0, 4)); + return Number.isFinite(year) ? year : null; +} + +function extractUsContentRating(releaseDates) { + const results = releaseDates?.results; + if (!Array.isArray(results)) { + return null; + } + + const us = results.find((entry) => entry.iso_3166_1 === 'US'); + const release = us?.release_dates?.find((entry) => entry.certification); + return trimString(release?.certification) || null; +} + +function mapTmdbMovieDetails(details) { + if (!details || typeof details !== 'object') { + return null; + } + + const credits = details.credits || {}; + const director = Array.isArray(credits.crew) + ? credits.crew.find((person) => person.job === 'Director')?.name + : null; + const cast = Array.isArray(credits.cast) + ? credits.cast + .slice(0, 5) + .map((person) => trimString(person.name)) + .filter(Boolean) + : []; + + const raw = { + tmdbId: details.id, + title: details.title || details.original_title, + year: extractYear(details.release_date), + synopsis: details.overview, + posterPath: details.poster_path, + backdropPath: details.backdrop_path, + runtimeMinutes: details.runtime, + genres: Array.isArray(details.genres) + ? details.genres.map((genre) => trimString(genre.name)).filter(Boolean) + : [], + contentRating: extractUsContentRating(details.release_dates), + director: director ? trimString(director) : '', + cast, + imdbId: trimString(details.external_ids?.imdb_id), + ratings: { + tmdb: { + score: details.vote_average, + voteCount: details.vote_count, + }, + }, + }; + + return normalizePivotMovie(raw); +} + +function mapTmdbSearchResult(result) { + return { + tmdbId: result.id, + title: trimString(result.title || result.original_title), + year: extractYear(result.release_date), + overview: trimString(result.overview), + posterUrl: buildTmdbImageUrl(result.poster_path, 'w185'), + voteAverage: result.vote_average ?? null, + }; +} + +async function searchTmdbMovies(options = {}) { + const requestConfig = buildTmdbRequestConfig(); + if (!requestConfig) { + return tmdbUnavailableResult(); + } + + const query = trimString(options.query); + if (!query) { + return { + error: 'query is required.', + status: 400, + code: 'TMDB_QUERY_REQUIRED', + }; + } + + const year = trimString(options.year); + + try { + const response = await axios.get(`${TMDB_API_BASE}/search/movie`, { + ...requestConfig, + params: { + ...requestConfig.params, + query, + ...(year ? { year } : {}), + include_adult: false, + language: 'en-US', + }, + }); + + const results = Array.isArray(response.data?.results) + ? response.data.results.map(mapTmdbSearchResult).filter((row) => row.tmdbId && row.title) + : []; + + return { + data: { + query, + year: year || null, + results, + }, + }; + } catch (err) { + const status = err.response?.status; + return { + error: status === 401 ? 'TMDB credentials are invalid.' : 'Unable to search TMDB.', + status: status === 401 ? 503 : 502, + code: status === 401 ? 'TMDB_AUTH_FAILED' : 'TMDB_SEARCH_FAILED', + }; + } +} + +async function fetchTmdbMovieDetails(options = {}) { + const requestConfig = buildTmdbRequestConfig(); + if (!requestConfig) { + return tmdbUnavailableResult(); + } + + const tmdbId = Number(options.tmdbId); + if (!Number.isFinite(tmdbId) || tmdbId <= 0) { + return { + error: 'tmdbId is required.', + status: 400, + code: 'TMDB_ID_REQUIRED', + }; + } + + try { + const response = await axios.get(`${TMDB_API_BASE}/movie/${tmdbId}`, { + ...requestConfig, + params: { + ...requestConfig.params, + language: 'en-US', + append_to_response: 'credits,external_ids,release_dates', + }, + }); + + const movie = mapTmdbMovieDetails(response.data); + if (!movie) { + return { + error: 'TMDB movie not found.', + status: 404, + code: 'TMDB_MOVIE_NOT_FOUND', + }; + } + + return { + data: { + movie, + }, + }; + } catch (err) { + const status = err.response?.status; + if (status === 404) { + return { + error: 'TMDB movie not found.', + status: 404, + code: 'TMDB_MOVIE_NOT_FOUND', + }; + } + return { + error: status === 401 ? 'TMDB credentials are invalid.' : 'Unable to load TMDB movie.', + status: status === 401 ? 503 : 502, + code: status === 401 ? 'TMDB_AUTH_FAILED' : 'TMDB_FETCH_FAILED', + }; + } +} + +module.exports = { + searchTmdbMovies, + fetchTmdbMovieDetails, + mapTmdbMovieDetails, + mapTmdbSearchResult, +}; diff --git a/backend/services/pivotWeeklyDropService.js b/backend/services/pivotWeeklyDropService.js index 97bd265b..8690221d 100644 --- a/backend/services/pivotWeeklyDropService.js +++ b/backend/services/pivotWeeklyDropService.js @@ -5,6 +5,7 @@ const { getTenantByKey, upsertStoredTenantRow, serializeTenantForAdmin } = requi const { normalizePivotDropFields, normalizePivotDropOverrides } = require('../constants/defaultTenants'); const { isValidIsoWeek, toIsoWeek } = require('../utilities/pivotIsoWeek'); const { buildDropSchedulePayload } = require('./pivotConfigService'); +const { rebuildWeeklySnapshot } = require('./pivotWeeklySnapshotService'); const { DAY_NAMES, isPivotTenant, @@ -273,6 +274,19 @@ async function sendWeeklyDropPush(req, tenantKey, options = {}) { errors.push(...result.errors); } + // Best-effort: freeze this week's metrics right after the drop so Lab trends + // build themselves; a snapshot failure must never mask a successful send. + let snapshotRebuilt = false; + try { + const rebuild = await rebuildWeeklySnapshot(req, { batchWeek }); + snapshotRebuilt = !rebuild.error; + } catch (error) { + console.error( + `[pivotWeeklyDrop] snapshot rebuild failed after send tenant=${tenantKey} batchWeek=${batchWeek}:`, + error, + ); + } + return { dryRun: false, dropSchedule, @@ -280,6 +294,7 @@ async function sendWeeklyDropPush(req, tenantKey, options = {}) { pivotPushRecipientCount: recipients.length, sent, failed, + snapshotRebuilt, warnings, errors: errors.slice(0, 5), }; diff --git a/backend/services/pivotWeeklySnapshotService.js b/backend/services/pivotWeeklySnapshotService.js index f5e5911b..d2ca2047 100644 --- a/backend/services/pivotWeeklySnapshotService.js +++ b/backend/services/pivotWeeklySnapshotService.js @@ -5,7 +5,39 @@ const { isPivotTenant } = require('./pivotReferralCodeService'); const { connectToDatabase } = require('../connectionsManager'); const { PIVOT_EVENT_STATUSES } = require('./pivotFeedService'); const { PIVOT_EVENT_FEATURE } = require('./pivotFeedbackService'); -const { toIsoWeek, isValidIsoWeek } = require('../utilities/pivotIsoWeek'); +const { toIsoWeek, isValidIsoWeek, isoWeekToUtcRange } = require('../utilities/pivotIsoWeek'); + +/** Mobile analytics event names surfaced as Lab engagement metrics. */ +const ENGAGEMENT_EVENTS = { + calendarAdds: ['pivot_calendar_add'], + inviteShares: ['pivot_invite_share', 'pivot_invite_copy'], + interestsSaved: ['pivot_interests_onboarding_completed', 'pivot_interests_updated'], +}; + +/** + * Client-only loop counts (calendar adds, invite shares, interests saved) from the + * tenant analytics_events collection, bounded to the ISO week's UTC range. + * Best-effort: analytics ingestion must never fail a snapshot, so errors return zeros. + */ +async function aggregateEngagementMetrics(tenantReq, batchWeek) { + const zeros = { calendarAdds: 0, inviteShares: 0, interestsSaved: 0 }; + try { + const { AnalyticsEvent } = getModels(tenantReq, 'AnalyticsEvent'); + const { start, end } = isoWeekToUtcRange(batchWeek); + const tsFilter = { ts: { $gte: start, $lt: end } }; + + const entries = await Promise.all( + Object.entries(ENGAGEMENT_EVENTS).map(async ([key, eventNames]) => [ + key, + await AnalyticsEvent.countDocuments({ event: { $in: eventNames }, ...tsFilter }), + ]), + ); + return Object.fromEntries(entries); + } catch (error) { + console.error(`[pivotWeeklySnapshot] engagement aggregate failed batchWeek=${batchWeek}:`, error); + return zeros; + } +} const PUBLISHED_EVENT_QUERY = (batchWeek) => ({ 'customFields.pivot.batchWeek': batchWeek, @@ -42,6 +74,10 @@ function serializeSnapshot(doc) { interestedCount: row.interestedCount ?? 0, registeredCount: row.registeredCount ?? 0, externalOpenCount: row.externalOpenCount ?? 0, + externalOpenUsers: row.externalOpenUsers ?? 0, + calendarAdds: row.calendarAdds ?? 0, + inviteShares: row.inviteShares ?? 0, + interestsSaved: row.interestsSaved ?? 0, swipeCount: row.swipeCount ?? 0, feedbackAvg: row.feedbackAvg ?? null, activeUsers: row.activeUsers ?? 0, @@ -76,6 +112,8 @@ async function aggregateTenantMetrics(tenant, batchWeek) { passedCount, activeUserIds, externalOpenAgg, + externalOpenUserIds, + engagement, ] = await Promise.all([ PivotEventIntent.countDocuments({ ...intentFilter, status: 'interested' }), PivotEventIntent.countDocuments({ ...intentFilter, status: 'registered' }), @@ -85,6 +123,8 @@ async function aggregateTenantMetrics(tenant, batchWeek) { { $match: intentFilter }, { $group: { _id: null, total: { $sum: { $ifNull: ['$externalOpenCount', 0] } } } }, ]), + PivotEventIntent.distinct('userId', { ...intentFilter, externalOpenAt: { $ne: null } }), + aggregateEngagementMetrics(tenantReq, batchWeek), ]); // Card swipes: pass + interested; registered users previously swiped right. @@ -116,6 +156,10 @@ async function aggregateTenantMetrics(tenant, batchWeek) { interestedCount, registeredCount, externalOpenCount: externalOpenAgg[0]?.total ?? 0, + externalOpenUsers: externalOpenUserIds.length, + calendarAdds: engagement.calendarAdds, + inviteShares: engagement.inviteShares, + interestsSaved: engagement.interestsSaved, swipeCount, feedbackAvg, activeUsers: activeUserIds.length, @@ -148,6 +192,10 @@ async function rebuildWeeklySnapshot(req, options = {}) { interestedCount: 0, registeredCount: 0, externalOpenCount: 0, + externalOpenUsers: 0, + calendarAdds: 0, + inviteShares: 0, + interestsSaved: 0, swipeCount: 0, feedbackAvg: null, activeUsers: 0, @@ -187,9 +235,11 @@ async function getWeeklySnapshot(req, options = {}) { module.exports = { aggregateTenantMetrics, + aggregateEngagementMetrics, rebuildWeeklySnapshot, getWeeklySnapshot, normalizeBatchWeek, serializeSnapshot, PUBLISHED_EVENT_QUERY, + ENGAGEMENT_EVENTS, }; diff --git a/backend/services/userServices.js b/backend/services/userServices.js index 46732437..149e4071 100644 --- a/backend/services/userServices.js +++ b/backend/services/userServices.js @@ -4,6 +4,7 @@ const jwt = require('jsonwebtoken'); const crypto = require('crypto'); const appleSignin = require('apple-signin-auth'); const { sendDiscordMessage } = require('./discordWebookService'); +const { normalizePivotLeaveAuthUser } = require('./pivotProfileService'); function getModels(req, ...names) { return require('./getModelService')(req, ...names); @@ -83,6 +84,10 @@ async function loginUser({ email, password, req }) { if (!passwordMatch) { throw new Error('Invalid credentials'); } + const normalized = await normalizePivotLeaveAuthUser(req, user._id); + if (normalized) { + user = normalized.toObject ? normalized.toObject() : normalized; + } if (user.accessSuspended) { throw new Error('This account has been suspended'); } diff --git a/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js b/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js index 1bcbd15a..353152fb 100644 --- a/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js +++ b/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js @@ -24,6 +24,10 @@ jest.mock('../../services/pivotAdminOverviewService', () => ({ getPivotOverview: jest.fn(), })); +jest.mock('../../services/pivotRetentionService', () => ({ + getPivotRetention: jest.fn(), +})); + jest.mock('../../services/pivotLabEventsService', () => ({ listPivotLabEvents: jest.fn(), })); @@ -62,6 +66,7 @@ const { getWeeklySnapshot, } = require('../../services/pivotWeeklySnapshotService'); const { getPivotOverview } = require('../../services/pivotAdminOverviewService'); +const { getPivotRetention } = require('../../services/pivotRetentionService'); const { listPivotLabEvents } = require('../../services/pivotLabEventsService'); const { getInterviewNotes, @@ -198,6 +203,56 @@ describe('pivotAdminRoutes overview', () => { }); }); +describe('pivotAdminRoutes retention', () => { + beforeEach(() => { + getPivotRetention.mockReset(); + requirePlatformAdmin.mockImplementation((req, res, next) => next()); + }); + + it('GET /admin/pivot/retention returns week-over-week rows', async () => { + getPivotRetention.mockResolvedValue({ + data: { + batchWeek: '2026-W27', + weeks: ['2026-W26', '2026-W27'], + tenants: [ + { + tenantKey: 'nyc', + cityDisplayName: 'New York City', + weeks: [ + { batchWeek: '2026-W26', activeUsers: 4, returningUsers: null, retentionRate: null }, + { batchWeek: '2026-W27', activeUsers: 3, returningUsers: 2, retentionRate: 50 }, + ], + }, + ], + }, + }); + + const response = await request(buildApp()).get( + '/admin/pivot/retention?batchWeek=2026-W27&weeks=2', + ); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.tenants[0].weeks[1].retentionRate).toBe(50); + expect(getPivotRetention).toHaveBeenCalledWith( + expect.objectContaining({ globalDb: {} }), + expect.objectContaining({ batchWeek: '2026-W27', weeks: '2' }), + ); + }); + + it('GET /admin/pivot/retention surfaces service errors', async () => { + getPivotRetention.mockResolvedValue({ + error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).', + status: 400, + code: 'INVALID_BATCH_WEEK', + }); + + const response = await request(buildApp()).get('/admin/pivot/retention?batchWeek=bad'); + expect(response.status).toBe(400); + expect(response.body.code).toBe('INVALID_BATCH_WEEK'); + }); +}); + describe('pivotAdminRoutes lab', () => { beforeEach(() => { listPivotLabEvents.mockReset(); diff --git a/backend/tests/route-outcomes/pivotRoutes.outcomes.test.js b/backend/tests/route-outcomes/pivotRoutes.outcomes.test.js index 231eae3a..459f6af1 100644 --- a/backend/tests/route-outcomes/pivotRoutes.outcomes.test.js +++ b/backend/tests/route-outcomes/pivotRoutes.outcomes.test.js @@ -89,6 +89,54 @@ function buildBaseApp() { return app; } +describe('pivotRoutes GET /pivot/referral/preview', () => { + beforeEach(() => { + validateReferralCode.mockReset(); + }); + + it('returns valid preview for redeemable code', async () => { + validateReferralCode.mockResolvedValue({ + data: { + tenantKey: 'nyc', + subdomain: 'nyc', + cohortId: 'pilot-a', + cityDisplayName: 'New York City', + batchWeek: '2026-W21', + }, + }); + + const response = await request(buildBaseApp()) + .get('/pivot/referral/preview') + .query({ code: 'NYC-PILOT-A' }); + + expect(response.statusCode).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data).toEqual({ + valid: true, + cityDisplayName: 'New York City', + }); + expect(validateReferralCode).toHaveBeenCalledWith(expect.any(Object), 'NYC-PILOT-A'); + }); + + it('returns invalid preview without leaking error details', async () => { + validateReferralCode.mockResolvedValue({ + error: 'Invalid referral code.', + status: 404, + code: 'REFERRAL_CODE_NOT_FOUND', + }); + + const response = await request(buildBaseApp()) + .get('/pivot/referral/preview') + .query({ code: 'BAD-CODE' }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toEqual({ + valid: false, + cityDisplayName: null, + }); + }); +}); + describe('pivotRoutes POST /pivot/referral/validate', () => { beforeEach(() => { validateReferralCode.mockReset(); @@ -159,7 +207,11 @@ describe('pivotRoutes POST /pivot/referral/redeem', () => { expect(response.statusCode).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.redemptionCount).toBe(1); - expect(redeemReferralCode).toHaveBeenCalledWith(expect.any(Object), 'NYC-PILOT-A'); + expect(redeemReferralCode).toHaveBeenCalledWith( + expect.any(Object), + 'NYC-PILOT-A', + expect.objectContaining({ referredByUserId: undefined }) + ); }); it('returns 403 when service rejects', async () => { @@ -356,6 +408,7 @@ describe('pivotRoutes POST /pivot/intent/:eventId/registered', () => { expect(confirmRegistered).toHaveBeenCalledWith( expect.objectContaining({ school: 'nyc' }), '665a1b2c3d4e5f6789012345', + {}, ); }); diff --git a/backend/tests/unit/pivotAdminOverviewService.test.js b/backend/tests/unit/pivotAdminOverviewService.test.js index 97078f65..116ad2ee 100644 --- a/backend/tests/unit/pivotAdminOverviewService.test.js +++ b/backend/tests/unit/pivotAdminOverviewService.test.js @@ -20,6 +20,7 @@ jest.mock('../../services/pivotWeeklySnapshotService', () => ({ normalizeBatchWeek: jest.requireActual('../../services/pivotWeeklySnapshotService').normalizeBatchWeek, PUBLISHED_EVENT_QUERY: jest.requireActual('../../services/pivotWeeklySnapshotService').PUBLISHED_EVENT_QUERY, getWeeklySnapshot: jest.fn(), + aggregateEngagementMetrics: jest.fn(), })); const getModels = require('../../services/getModelService'); @@ -27,7 +28,10 @@ const { connectToDatabase } = require('../../connectionsManager'); const getGlobalModels = require('../../services/getGlobalModelService'); const { getMergedTenants } = require('../../services/tenantConfigService'); const { isPivotTenant } = require('../../services/pivotReferralCodeService'); -const { getWeeklySnapshot } = require('../../services/pivotWeeklySnapshotService'); +const { + getWeeklySnapshot, + aggregateEngagementMetrics, +} = require('../../services/pivotWeeklySnapshotService'); const { aggregateRegisteredFeedback, getPivotOverview, @@ -40,6 +44,11 @@ describe('pivotAdminOverviewService', () => { (tenant) => tenant?.pivotPilot === true || tenant?.tenantType === 'pivot', ); getWeeklySnapshot.mockResolvedValue({ data: { generatedAt: new Date('2026-06-26T10:00:00.000Z') } }); + aggregateEngagementMetrics.mockResolvedValue({ + calendarAdds: 3, + inviteShares: 1, + interestsSaved: 2, + }); }); describe('aggregateRegisteredFeedback', () => { @@ -139,6 +148,12 @@ describe('pivotAdminOverviewService', () => { expect(result.data.tenants).toHaveLength(2); expect(result.data.tenants.map((row) => row.tenantKey)).toEqual(['nyc', 'brooklyn']); expect(result.data.tenants[0].referralCodes[0].code).toBe('NYC-PILOT-A'); + expect(result.data.tenants[0]).toMatchObject({ + externalOpenUsers: 0, + calendarAdds: 3, + inviteShares: 1, + interestsSaved: 2, + }); expect(result.data.snapshotGeneratedAt).toEqual(new Date('2026-06-26T10:00:00.000Z')); }); }); diff --git a/backend/tests/unit/pivotFeedService.test.js b/backend/tests/unit/pivotFeedService.test.js index ce0f4813..f19266ee 100644 --- a/backend/tests/unit/pivotFeedService.test.js +++ b/backend/tests/unit/pivotFeedService.test.js @@ -9,6 +9,7 @@ const { getPivotFeed, getPivotEventFriends, getPilotWindow, + getFeedPilotWindowFilter, isUpcomingPivotEvent, getUpcomingEventTimeFilter, resolveDisplayHost, @@ -69,11 +70,11 @@ describe('pivotFeedService helpers', () => { expect(resolveDisplayHost({ host: { imageUrl: 'x' } })).toBeNull(); }); - it('getPilotWindow starts tomorrow UTC for seven days', () => { + it('getPilotWindow starts today UTC for seven days', () => { const now = new Date('2026-05-26T15:00:00.000Z'); const { windowStart, windowEnd } = getPilotWindow(now); - expect(windowStart.toISOString()).toBe('2026-05-27T00:00:00.000Z'); - expect(windowEnd.toISOString()).toBe('2026-06-03T00:00:00.000Z'); + expect(windowStart.toISOString()).toBe('2026-05-26T00:00:00.000Z'); + expect(windowEnd.toISOString()).toBe('2026-06-02T00:00:00.000Z'); }); it('isUpcomingPivotEvent treats ended events as past', () => { @@ -337,11 +338,61 @@ describe('getPivotFeed', () => { 'customFields.pivot.batchWeek': '2026-W22', 'customFields.pivot.ingestStatus': 'published', status: { $in: ['approved', 'not-applicable'] }, - $and: [getUpcomingEventTimeFilter(now)], + ...getFeedPilotWindowFilter(now), }), ); }); + it('includes multi-showtime events when a later showtime is in the pilot window', async () => { + const events = [ + { + _id: '665a1b2c3d4e5f6789012347', + name: 'Film Night', + start_time: new Date('2026-05-26T18:00:00.000Z'), + end_time: new Date('2026-05-29T05:00:00.000Z'), + customFields: { + pivot: { + batchWeek: '2026-W22', + ingestStatus: 'published', + host: { name: 'Nitehawk Cinema' }, + tags: ['film-and-tv'], + timeSlots: [ + { + id: '6pm', + start_time: new Date('2026-05-26T18:00:00.000Z'), + end_time: new Date('2026-05-26T20:30:00.000Z'), + }, + { + id: '830pm', + start_time: new Date('2026-05-28T23:30:00.000Z'), + end_time: new Date('2026-05-29T02:00:00.000Z'), + }, + ], + }, + }, + }, + ]; + + const eventFind = mockEventFind(events); + getModels.mockReturnValue(withFeedModels({ + Event: { find: jest.fn(() => eventFind) }, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + })), + }, + PivotEventIntent: { find: jest.fn(() => mockIntentFind()) }, + User: mockUserModel(), + })); + + const result = await getPivotFeed(req, { batchWeek: '2026-W22', now }); + + expect(result.data.events).toHaveLength(1); + expect(result.data.events[0].name).toBe('Film Night'); + expect(result.data.events[0].timeSlots).toHaveLength(2); + }); + it('excludes events that have already ended from the deck feed', async () => { const events = [ { diff --git a/backend/tests/unit/pivotFriendService.test.js b/backend/tests/unit/pivotFriendService.test.js index 647723d9..ac317ed8 100644 --- a/backend/tests/unit/pivotFriendService.test.js +++ b/backend/tests/unit/pivotFriendService.test.js @@ -1,12 +1,15 @@ jest.mock('../../services/getModelService', () => jest.fn()); +jest.mock('../../services/getGlobalModelService', () => jest.fn()); jest.mock('../../services/notificationService', () => ({ withModels: jest.fn(), })); const getModels = require('../../services/getModelService'); +const getGlobalModels = require('../../services/getGlobalModelService'); const NotificationService = require('../../services/notificationService'); const { searchPivotFriends, + getPivotCohortSuggestions, sendPivotFriendRequest, listPivotFriends, listPivotFriendRequests, @@ -183,6 +186,111 @@ describe('searchPivotFriends', () => { }); }); +describe('getPivotCohortSuggestions', () => { + const globalSelf = '607f191e810c19729de860a0'; + const aliceGlobal = '607f191e810c19729de860a1'; + const bobGlobal = '607f191e810c19729de860a2'; + const cohortReq = { user: { userId, globalUserId: globalSelf }, school: 'nyc' }; + + let User; + let Friendship; + let PivotReferralCode; + let PivotReferralRedemption; + let TenantMembership; + + function chain(result) { + const c = { + select: jest.fn(() => c), + sort: jest.fn(() => c), + lean: jest.fn().mockResolvedValue(result), + }; + return c; + } + + beforeEach(() => { + User = { find: jest.fn() }; + Friendship = { find: jest.fn() }; + PivotReferralCode = { find: jest.fn() }; + PivotReferralRedemption = { find: jest.fn() }; + TenantMembership = { find: jest.fn() }; + getModels.mockReturnValue({ User, Friendship }); + getGlobalModels.mockReturnValue({ + PivotReferralCode, + PivotReferralRedemption, + TenantMembership, + }); + }); + + it('requires authentication', async () => { + const result = await getPivotCohortSuggestions({}); + expect(result.status).toBe(401); + expect(result.code).toBe('UNAUTHORIZED'); + }); + + it('returns empty when the user has no global identity', async () => { + const result = await getPivotCohortSuggestions({ user: { userId }, school: 'nyc' }); + expect(result.data).toEqual({ users: [] }); + expect(PivotReferralRedemption.find).not.toHaveBeenCalled(); + }); + + it('returns empty when the redeemed code carries no cohortId', async () => { + PivotReferralRedemption.find.mockReturnValueOnce(chain([{ code: 'NYC-SOLO' }])); + PivotReferralCode.find.mockReturnValueOnce(chain([{ cohortId: '' }])); + + const result = await getPivotCohortSuggestions(cohortReq); + + expect(result.data).toEqual({ users: [] }); + expect(TenantMembership.find).not.toHaveBeenCalled(); + }); + + it('suggests same-cohort joiners, excludes self and existing friends, keeps pending status', async () => { + // 1. requester redemptions → 2. their cohortId → 3. cohort codes → 4. cohort redemptions + PivotReferralRedemption.find + .mockReturnValueOnce(chain([{ code: 'NYC-A' }])) + .mockReturnValueOnce( + chain([{ globalUserId: aliceGlobal }, { globalUserId: bobGlobal }]), + ); + PivotReferralCode.find + .mockReturnValueOnce(chain([{ cohortId: 'rpi-club' }])) + .mockReturnValueOnce(chain([{ code: 'NYC-A' }, { code: 'NYC-B' }])); + TenantMembership.find.mockReturnValueOnce( + chain([ + { globalUserId: aliceGlobal, tenantUserId: aliceId }, + { globalUserId: bobGlobal, tenantUserId: bobId }, + ]), + ); + User.find.mockReturnValueOnce( + chain([ + { _id: aliceId, name: 'Alice', picture: null }, + { _id: bobId, name: 'Bob', picture: null, username: 'bob_z' }, + ]), + ); + // Alice already accepted (excluded); Bob has an outgoing pending request (kept, disabled) + Friendship.find.mockReturnValueOnce( + chain([ + { requester: userId, recipient: aliceId, status: 'accepted' }, + { requester: userId, recipient: bobId, status: 'pending' }, + ]), + ); + + const result = await getPivotCohortSuggestions(cohortReq); + + expect(PivotReferralCode.find).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ cohortId: { $in: ['rpi-club'] }, tenantKey: 'nyc' }), + ); + expect(result.data.users).toEqual([ + { + id: bobId, + name: 'Bob', + picture: null, + username: 'bob_z', + friendshipStatus: 'pending_outgoing', + }, + ]); + }); +}); + describe('sendPivotFriendRequest', () => { let User; let Friendship; diff --git a/backend/tests/unit/pivotIngestPublishService.test.js b/backend/tests/unit/pivotIngestPublishService.test.js index bf16d719..44aa663b 100644 --- a/backend/tests/unit/pivotIngestPublishService.test.js +++ b/backend/tests/unit/pivotIngestPublishService.test.js @@ -11,7 +11,6 @@ jest.mock('../../connectionsManager', () => ({ })); jest.mock('../../services/pivotIngestPreviewService', () => ({ previewIngestUrl: jest.fn(), - normalizeUrl: jest.fn(), sanitizeEventPosterImage: (raw) => (typeof raw === 'string' && raw.trim() ? raw.trim() : null), })); jest.mock('../../services/pivotIngestDuplicateService', () => ({ @@ -35,7 +34,7 @@ jest.mock('../../services/pivotTagCatalogService', () => ({ const getModels = require('../../services/getModelService'); const { getMergedTenants, provisionPivotCatalogOrg } = require('../../services/tenantConfigService'); const { connectToDatabase } = require('../../connectionsManager'); -const { previewIngestUrl, normalizeUrl } = require('../../services/pivotIngestPreviewService'); +const { previewIngestUrl } = require('../../services/pivotIngestPreviewService'); const { resolveImportDuplicate, isBlockingDuplicate } = require('../../services/pivotIngestDuplicateService'); const { validatePivotEventTags } = require('../../services/pivotTagCatalogService'); const { @@ -75,6 +74,26 @@ describe('pivotIngestPublishService merge helpers', () => { expect(result.code).toBe('MISSING_REQUIRED_FIELDS'); }); + + it('derives event window from showtimes when start_time is omitted', () => { + const merged = mergeDraftWithOverrides( + {}, + { + hostName: 'Nitehawk Cinema', + name: 'Indie Film Night', + location: 'Brooklyn, NY', + tags: ['film-and-tv'], + timeSlots: [ + { id: '6pm', start_time: '2026-05-29T22:00:00.000Z' }, + { id: '830pm', start_time: '2026-05-30T00:30:00.000Z' }, + ], + }, + ); + + const result = validateMergedDraft(merged); + expect(result.merged.timeSlots).toHaveLength(2); + expect(result.merged.startTime.toISOString()).toBe('2026-05-29T22:00:00.000Z'); + }); }); describe('pivotIngestPublishService publishIngestEvent', () => { @@ -83,6 +102,7 @@ describe('pivotIngestPublishService publishIngestEvent', () => { beforeEach(() => { Event = { findOneAndUpdate: jest.fn(), + create: jest.fn(), }; getModels.mockReturnValue({ Event }); getMergedTenants.mockResolvedValue([TENANT]); @@ -90,10 +110,6 @@ describe('pivotIngestPublishService publishIngestEvent', () => { resolveImportDuplicate.mockResolvedValue({ duplicate: null, catalogIndex: [] }); isBlockingDuplicate.mockReturnValue(false); validatePivotEventTags.mockResolvedValue({ tags: ['live-music'] }); - normalizeUrl.mockReturnValue({ - url: 'https://partiful.com/e/sunset-listening', - provider: 'partiful', - }); previewIngestUrl.mockResolvedValue({ data: { draft: { @@ -166,6 +182,69 @@ describe('pivotIngestPublishService publishIngestEvent', () => { expect(provisionPivotCatalogOrg).not.toHaveBeenCalled(); }); + it('persists showtimes from overrides when publishing without provider preview', async () => { + Event.findOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: '507f1f77bcf86cd799439012', + name: 'Indie Film Night', + start_time: new Date('2026-05-29T22:00:00.000Z'), + end_time: new Date('2026-05-30T05:15:00.000Z'), + location: 'Brooklyn, NY', + customFields: { + pivot: { + batchWeek: '2026-W26', + ingestStatus: 'published', + host: { name: 'Nitehawk Cinema' }, + tags: ['film-and-tv'], + timeSlots: [ + { id: '6pm', start_time: new Date('2026-05-29T22:00:00.000Z') }, + { id: '830pm', start_time: new Date('2026-05-30T00:30:00.000Z') }, + ], + }, + }, + }), + }); + + await publishIngestEvent( + { user: { email: 'ops@meridian.study' }, globalDb: {} }, + { + tenantKey: 'nyc', + url: 'https://example.com/events/indie-film-night', + batchWeek: '2026-W26', + overrides: { + hostName: 'Nitehawk Cinema', + name: 'Indie Film Night', + location: 'Brooklyn, NY', + tags: ['film-and-tv'], + start_time: '2026-05-29T22:00:00.000Z', + end_time: '2026-05-30T05:15:00.000Z', + timeSlots: [ + { id: '6pm', label: '6:00 PM', start_time: '2026-05-29T22:00:00.000Z' }, + { id: '830pm', label: '8:30 PM', start_time: '2026-05-30T00:30:00.000Z' }, + ], + }, + }, + ); + + expect(previewIngestUrl).not.toHaveBeenCalled(); + expect(Event.findOneAndUpdate).toHaveBeenCalledWith( + { 'customFields.pivot.sourceUrl': 'https://example.com/events/indie-film-night' }, + expect.objectContaining({ + $set: expect.objectContaining({ + customFields: expect.objectContaining({ + pivot: expect.objectContaining({ + timeSlots: expect.arrayContaining([ + expect.objectContaining({ id: '6pm' }), + expect.objectContaining({ id: '830pm' }), + ]), + }), + }), + }), + }), + expect.any(Object), + ); + }); + it('provisions catalog org when tenant row lacks pivotCatalogOrgId', async () => { getMergedTenants.mockResolvedValue([{ ...TENANT, pivotCatalogOrgId: null }]); provisionPivotCatalogOrg.mockResolvedValue({ orgId: '507f1f77bcf86cd799439099' }); @@ -249,6 +328,94 @@ describe('pivotIngestPublishService publishIngestEvent', () => { expect(result.code).toBe('INVALID_TAG'); expect(Event.findOneAndUpdate).not.toHaveBeenCalled(); + expect(Event.create).not.toHaveBeenCalled(); + }); + + it('creates manual catalog event without a listing URL', async () => { + Event.create.mockResolvedValue({ + toObject: () => ({ + _id: '507f1f77bcf86cd799439013', + name: 'Neighborhood Potluck', + start_time: new Date('2026-07-12T18:00:00.000Z'), + end_time: new Date('2026-07-12T20:00:00.000Z'), + location: 'Prospect Park', + customFields: { + pivot: { + batchWeek: '2026-W26', + ingestStatus: 'published', + source: 'manual', + host: { name: 'Park Friends' }, + tags: ['social'], + }, + }, + }), + }); + + const result = await publishIngestEvent( + { user: { email: 'ops@meridian.study' }, globalDb: {} }, + { + tenantKey: 'nyc', + batchWeek: '2026-W26', + overrides: { + hostName: 'Park Friends', + name: 'Neighborhood Potluck', + location: 'Prospect Park', + start_time: '2026-07-12T18:00:00.000Z', + tags: ['social'], + source: 'manual', + }, + }, + ); + + expect(previewIngestUrl).not.toHaveBeenCalled(); + expect(Event.create).toHaveBeenCalled(); + expect(Event.findOneAndUpdate).not.toHaveBeenCalled(); + expect(result.data.event.name).toBe('Neighborhood Potluck'); + }); + + it('publishes non-Partiful listing URLs from overrides without scraping', async () => { + Event.findOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: '507f1f77bcf86cd799439014', + name: 'Jazz on the Roof', + externalLink: 'https://eventbrite.com/e/jazz-on-the-roof', + customFields: { + pivot: { + batchWeek: '2026-W26', + ingestStatus: 'published', + source: 'manual', + sourceUrl: 'https://eventbrite.com/e/jazz-on-the-roof', + host: { name: 'Rooftop Venue' }, + tags: ['live-music'], + }, + }, + }), + }); + + const result = await publishIngestEvent( + { user: { email: 'ops@meridian.study' }, globalDb: {} }, + { + tenantKey: 'nyc', + url: 'https://eventbrite.com/e/jazz-on-the-roof', + batchWeek: '2026-W26', + overrides: { + hostName: 'Rooftop Venue', + name: 'Jazz on the Roof', + location: 'Downtown Brooklyn', + start_time: '2026-07-12T20:00:00.000Z', + tags: ['live-music'], + source: 'manual', + }, + }, + ); + + expect(previewIngestUrl).not.toHaveBeenCalled(); + expect(Event.findOneAndUpdate).toHaveBeenCalledWith( + { 'customFields.pivot.sourceUrl': 'https://eventbrite.com/e/jazz-on-the-roof' }, + expect.any(Object), + expect.objectContaining({ upsert: true }), + ); + expect(result.data.event.name).toBe('Jazz on the Roof'); }); }); diff --git a/backend/tests/unit/pivotIntentService.test.js b/backend/tests/unit/pivotIntentService.test.js index d7f3cf51..57224a1b 100644 --- a/backend/tests/unit/pivotIntentService.test.js +++ b/backend/tests/unit/pivotIntentService.test.js @@ -1,6 +1,7 @@ jest.mock('../../services/getModelService', () => jest.fn()); const getModels = require('../../services/getModelService'); +const { getFeedPilotWindowFilter } = require('../../services/pivotFeedService'); const { recordFeedAction, recordExternalOpen, @@ -8,6 +9,7 @@ const { getWeekRecap, resetWeekActions, serializeRecapEvent, + resolveRegisteredTimeSlotId, } = require('../../services/pivotIntentService'); const userId = '507f191e810c19729de860eb'; @@ -27,7 +29,13 @@ function publishedEvent(overrides = {}) { _id: eventId, start_time: new Date('2026-05-28T19:00:00.000Z'), externalLink: 'https://partiful.com/e/example', - customFields: { pivot: { batchWeek: '2026-W22', host: { name: 'Venue' } } }, + customFields: { + pivot: { + batchWeek: '2026-W22', + ingestStatus: 'published', + host: { name: 'Venue' }, + }, + }, ...overrides, }; } @@ -81,13 +89,73 @@ describe('recordFeedAction', () => { eventId, status: 'passed', batchWeek: '2026-W22', + timeSlotId: null, }); expect(findOneAndUpdate).toHaveBeenCalledWith( { userId, eventId }, - { $set: { status: 'passed', batchWeek: '2026-W22' } }, + { $set: { status: 'passed', batchWeek: '2026-W22', timeSlotId: null } }, expect.objectContaining({ upsert: true }), ); }); + + it('uses the feed pilot-window filter for swipe actions (multi-showtime aware)', async () => { + const filmNow = new Date('2026-05-28T12:00:00.000Z'); + const findOneAndUpdate = jest.fn(() => ({ + lean: jest + .fn() + .mockResolvedValue({ eventId, status: 'interested', batchWeek: '2026-W22' }), + })); + const eventFindOne = jest.fn(() => + mockEventFindOne( + publishedEvent({ + start_time: new Date('2026-05-26T18:00:00.000Z'), + end_time: new Date('2026-05-29T05:00:00.000Z'), + customFields: { + pivot: { + batchWeek: '2026-W22', + ingestStatus: 'published', + host: { name: 'Nitehawk Cinema' }, + timeSlots: [ + { + id: '6pm', + start_time: new Date('2026-05-26T18:00:00.000Z'), + end_time: new Date('2026-05-26T20:30:00.000Z'), + }, + { + id: '830pm', + start_time: new Date('2026-05-28T23:30:00.000Z'), + end_time: new Date('2026-05-29T02:00:00.000Z'), + }, + ], + }, + }, + }), + ), + ); + getModels.mockImplementation((_req, ...names) => { + if (names.includes('Event')) { + return { Event: { findOne: eventFindOne } }; + } + return { PivotEventIntent: { findOneAndUpdate } }; + }); + + const result = await recordFeedAction(req, { + eventId, + action: 'interested', + now: filmNow, + }); + + expect(result.data?.status).toBe('interested'); + expect(eventFindOne).toHaveBeenCalledWith({ + $and: [ + expect.objectContaining({ + _id: eventId, + 'customFields.pivot.ingestStatus': 'published', + }), + getFeedPilotWindowFilter(filmNow), + ], + }); + }); }); describe('recordExternalOpen', () => { @@ -161,7 +229,7 @@ describe('confirmRegistered', () => { expect(result.data.status).toBe('registered'); expect(findOneAndUpdate).toHaveBeenCalledWith( { userId, eventId }, - { $set: { status: 'registered', batchWeek: '2026-W22' } }, + { $set: { status: 'registered', batchWeek: '2026-W22', timeSlotId: null } }, expect.objectContaining({ upsert: true }), ); }); @@ -185,8 +253,12 @@ describe('getWeekRecap', () => { const intentFind = { select: jest.fn().mockReturnThis(), lean: jest.fn().mockResolvedValue([ - { eventId, status: 'interested' }, - { eventId: '665a1b2c3d4e5f6789012346', status: 'registered' }, + { eventId, status: 'interested', timeSlotId: null }, + { + eventId: '665a1b2c3d4e5f6789012346', + status: 'registered', + timeSlotId: '7pm', + }, ]), }; const eventFind = { @@ -203,10 +275,15 @@ describe('getWeekRecap', () => { }), ]), }; + const friendshipFind = { + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + }; getModels.mockReturnValue({ PivotEventIntent: { find: jest.fn(() => intentFind) }, Event: { find: jest.fn(() => eventFind) }, + Friendship: { find: jest.fn(() => friendshipFind) }, }); const result = await getWeekRecap(req, { batchWeek: '2026-W22', now }); @@ -298,3 +375,94 @@ describe('serializeRecapEvent', () => { expect(payload).not.toHaveProperty('hostingId'); }); }); + +describe('resolveRegisteredTimeSlotId', () => { + it('requires a showtime when multiple slots exist', () => { + const event = { + customFields: { + pivot: { + timeSlots: [ + { id: '7pm', start_time: '2026-05-23T23:00:00.000Z' }, + { id: '930pm', start_time: '2026-05-24T01:30:00.000Z' }, + ], + }, + }, + }; + + expect(resolveRegisteredTimeSlotId(event, '')).toMatchObject({ + code: 'TIME_SLOT_REQUIRED', + }); + expect(resolveRegisteredTimeSlotId(event, '930pm')).toEqual({ + timeSlotId: '930pm', + }); + }); + + it('auto-selects the only showtime', () => { + const event = { + customFields: { + pivot: { + timeSlots: [{ id: '7pm', start_time: '2026-05-23T23:00:00.000Z' }], + }, + }, + }; + + expect(resolveRegisteredTimeSlotId(event, undefined)).toEqual({ + timeSlotId: '7pm', + }); + }); +}); + +describe('confirmRegistered time slots', () => { + beforeEach(() => { + getModels.mockReset(); + }); + + it('persists timeSlotId when confirming registration', async () => { + const findOneAndUpdate = jest.fn(() => ({ + lean: jest.fn().mockResolvedValue({ + eventId, + status: 'registered', + batchWeek: '2026-W22', + timeSlotId: '7pm', + }), + })); + getModels.mockImplementation((_req, ...names) => { + if (names.includes('Event')) { + return { + Event: { + findOne: jest.fn(() => + mockEventFindOne( + publishedEvent({ + customFields: { + pivot: { + batchWeek: '2026-W22', + host: { name: 'Venue' }, + timeSlots: [ + { id: '7pm', start_time: '2026-05-23T23:00:00.000Z' }, + { id: '930pm', start_time: '2026-05-24T01:30:00.000Z' }, + ], + }, + }, + }), + ), + ), + }, + }; + } + return { PivotEventIntent: { findOneAndUpdate } }; + }); + + const result = await confirmRegistered(req, eventId, { timeSlotId: '7pm' }); + + expect(result.data).toMatchObject({ + eventId, + status: 'registered', + timeSlotId: '7pm', + }); + expect(findOneAndUpdate).toHaveBeenCalledWith( + { userId, eventId }, + { $set: expect.objectContaining({ timeSlotId: '7pm', status: 'registered' }) }, + expect.objectContaining({ upsert: true }), + ); + }); +}); diff --git a/backend/tests/unit/pivotIsoWeek.test.js b/backend/tests/unit/pivotIsoWeek.test.js index cfaa1927..a8f173e2 100644 --- a/backend/tests/unit/pivotIsoWeek.test.js +++ b/backend/tests/unit/pivotIsoWeek.test.js @@ -1,4 +1,10 @@ -const { toIsoWeek, isValidIsoWeek } = require('../../utilities/pivotIsoWeek'); +const { + toIsoWeek, + isValidIsoWeek, + isoWeekToMondayUtc, + isoWeekToUtcRange, + shiftIsoWeek, +} = require('../../utilities/pivotIsoWeek'); describe('pivotIsoWeek', () => { it('formats known date as ISO week', () => { @@ -10,4 +16,36 @@ describe('pivotIsoWeek', () => { expect(isValidIsoWeek('2026-W1')).toBe(false); expect(isValidIsoWeek('bad')).toBe(false); }); + + describe('isoWeekToMondayUtc', () => { + it('returns the Monday starting the week', () => { + // 2026-W27 starts Monday 2026-06-29. + expect(isoWeekToMondayUtc('2026-W27').toISOString()).toBe('2026-06-29T00:00:00.000Z'); + }); + + it('throws on invalid input', () => { + expect(() => isoWeekToMondayUtc('2026-27')).toThrow(/Invalid batchWeek/); + }); + }); + + describe('isoWeekToUtcRange', () => { + it('covers Monday through the following Monday', () => { + const { start, end } = isoWeekToUtcRange('2026-W27'); + expect(start.toISOString()).toBe('2026-06-29T00:00:00.000Z'); + expect(end.toISOString()).toBe('2026-07-06T00:00:00.000Z'); + }); + }); + + describe('shiftIsoWeek', () => { + it('shifts forward and backward', () => { + expect(shiftIsoWeek('2026-W27', 1)).toBe('2026-W28'); + expect(shiftIsoWeek('2026-W27', -1)).toBe('2026-W26'); + expect(shiftIsoWeek('2026-W27', 0)).toBe('2026-W27'); + }); + + it('crosses ISO year boundaries', () => { + expect(shiftIsoWeek('2026-W01', -1)).toBe('2025-W52'); + expect(shiftIsoWeek('2025-W52', 1)).toBe('2026-W01'); + }); + }); }); diff --git a/backend/tests/unit/pivotLabEventsService.test.js b/backend/tests/unit/pivotLabEventsService.test.js new file mode 100644 index 00000000..7a28e4b4 --- /dev/null +++ b/backend/tests/unit/pivotLabEventsService.test.js @@ -0,0 +1,116 @@ +jest.mock('../../services/getModelService', () => jest.fn()); +jest.mock('../../connectionsManager', () => ({ + connectToDatabase: jest.fn(), +})); +jest.mock('../../services/tenantConfigService', () => ({ + getMergedTenants: jest.fn(), +})); +jest.mock('../../services/pivotReferralCodeService', () => ({ + isPivotTenant: jest.fn(), +})); + +const getModels = require('../../services/getModelService'); +const { connectToDatabase } = require('../../connectionsManager'); +const { getMergedTenants } = require('../../services/tenantConfigService'); +const { isPivotTenant } = require('../../services/pivotReferralCodeService'); +const { + listPivotLabEvents, + serializeLabEvent, + loadIntentStatsByEventId, +} = require('../../services/pivotLabEventsService'); + +describe('pivotLabEventsService', () => { + beforeEach(() => { + jest.clearAllMocks(); + isPivotTenant.mockImplementation((tenant) => tenant?.tenantType === 'pivot'); + connectToDatabase.mockResolvedValue({}); + }); + + describe('serializeLabEvent', () => { + it('defaults intentStats to zeros when no stats map is provided', () => { + const row = serializeLabEvent({ _id: 'e1', name: 'Show', customFields: {} }); + expect(row.intentStats).toEqual({ + interested: 0, + registered: 0, + passed: 0, + externalOpens: 0, + externalOpenUsers: 0, + }); + }); + }); + + describe('loadIntentStatsByEventId', () => { + it('returns an empty map without querying when there are no events', async () => { + const PivotEventIntent = { aggregate: jest.fn() }; + const stats = await loadIntentStatsByEventId(PivotEventIntent, []); + expect(stats.size).toBe(0); + expect(PivotEventIntent.aggregate).not.toHaveBeenCalled(); + }); + }); + + describe('listPivotLabEvents', () => { + it('merges per-event intent stats into catalog rows', async () => { + getMergedTenants.mockResolvedValue([ + { tenantKey: 'nyc', tenantType: 'pivot', location: 'New York City' }, + ]); + + const events = [ + { + _id: 'e1', + name: 'Rooftop Jazz', + customFields: { pivot: { ingestStatus: 'published', batchWeek: '2026-W27' } }, + }, + { + _id: 'e2', + name: 'Board Games', + customFields: { pivot: { ingestStatus: 'published', batchWeek: '2026-W27' } }, + }, + ]; + + getModels.mockReturnValue({ + Event: { + find: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + sort: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue(events), + }), + }), + }), + }, + PivotEventIntent: { + aggregate: jest.fn().mockResolvedValue([ + { + _id: 'e1', + interested: 5, + registered: 2, + passed: 7, + externalOpens: 4, + externalOpenUsers: 3, + }, + ]), + }, + }); + + const result = await listPivotLabEvents( + { globalDb: {} }, + { tenantKey: 'nyc', batchWeek: '2026-W27' }, + ); + + expect(result.data.events).toHaveLength(2); + expect(result.data.events[0].intentStats).toEqual({ + interested: 5, + registered: 2, + passed: 7, + externalOpens: 4, + externalOpenUsers: 3, + }); + expect(result.data.events[1].intentStats).toEqual({ + interested: 0, + registered: 0, + passed: 0, + externalOpens: 0, + externalOpenUsers: 0, + }); + }); + }); +}); diff --git a/backend/tests/unit/pivotLogger.test.js b/backend/tests/unit/pivotLogger.test.js new file mode 100644 index 00000000..57c8e401 --- /dev/null +++ b/backend/tests/unit/pivotLogger.test.js @@ -0,0 +1,96 @@ +const { + logPivot, + pivotRequestContext, + pivotRequestLogger, + logPivotRouteError, + isPivotLoggingEnabled, +} = require('../../utilities/pivotLogger'); + +describe('pivotLogger', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv, NODE_ENV: 'development' }; + delete process.env.PIVOT_LOG; + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + it('logs info messages when enabled', () => { + logPivot('info', 'feed built', { eventCount: 3 }); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('[pivot] feed built'), + ); + }); + + it('does not log during tests', () => { + process.env.NODE_ENV = 'test'; + expect(isPivotLoggingEnabled()).toBe(false); + logPivot('info', 'hidden'); + expect(console.log).not.toHaveBeenCalled(); + }); + + it('pivotRequestLogger emits request summary on finish', (done) => { + const req = { + method: 'GET', + originalUrl: '/pivot/feed?batchWeek=2026-W22', + school: 'nyc', + user: { userId: '507f191e810c19729de860eb' }, + }; + const res = { + statusCode: 200, + on(event, handler) { + if (event === 'finish') { + this._finish = handler; + } + }, + emit(event) { + if (event === 'finish' && this._finish) { + this._finish(); + } + }, + }; + + pivotRequestLogger(req, res, () => { + res.emit('finish'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('[pivot] request'), + ); + done(); + }); + }); + + it('logPivotRouteError writes error level', () => { + logPivotRouteError('GET /pivot/feed', new Error('boom'), { + method: 'GET', + originalUrl: '/pivot/feed', + school: 'nyc', + }); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('GET /pivot/feed failed'), + ); + }); + + it('pivotRequestContext extracts tenant and user', () => { + expect( + pivotRequestContext({ + method: 'POST', + originalUrl: '/pivot/feed/action', + school: 'nyc', + user: { userId: 'abc' }, + }), + ).toEqual({ + tenant: 'nyc', + userId: 'abc', + method: 'POST', + path: '/pivot/feed/action', + }); + }); +}); diff --git a/backend/tests/unit/pivotMovieMetadata.test.js b/backend/tests/unit/pivotMovieMetadata.test.js new file mode 100644 index 00000000..61cc6feb --- /dev/null +++ b/backend/tests/unit/pivotMovieMetadata.test.js @@ -0,0 +1,139 @@ +const { + buildTmdbImageUrl, + normalizePivotMovie, + applyMovieListingDefaults, + resolvePivotCoverImageUrl, +} = require('../../utilities/pivotMovieMetadata'); +const { + mapTmdbMovieDetails, + mapTmdbSearchResult, +} = require('../../services/pivotTmdbService'); + +describe('pivotMovieMetadata', () => { + it('builds TMDB image URLs from poster paths', () => { + expect(buildTmdbImageUrl('/abc.jpg', 'w500')).toBe( + 'https://image.tmdb.org/t/p/w500/abc.jpg', + ); + }); + + it('normalizes movie metadata with TMDB ratings', () => { + const movie = normalizePivotMovie({ + tmdbId: 123, + title: 'The Last Garden', + year: 2026, + overview: 'A gardener discovers a hidden world.', + posterPath: '/poster.jpg', + runtimeMinutes: 118, + genres: ['Drama', 'Sci-Fi'], + ratings: { tmdb: { score: 7.84, voteCount: 1200 } }, + }); + + expect(movie).toMatchObject({ + tmdbId: 123, + title: 'The Last Garden', + year: 2026, + synopsis: 'A gardener discovers a hidden world.', + posterUrl: 'https://image.tmdb.org/t/p/w500/poster.jpg', + runtimeMinutes: 118, + genres: ['Drama', 'Sci-Fi'], + ratings: { tmdb: { score: 7.8, voteCount: 1200 } }, + }); + }); + + it('applies movie defaults to listing fields', () => { + const merged = applyMovieListingDefaults({ + hostName: 'Nitehawk Cinema', + location: 'Brooklyn', + movie: normalizePivotMovie({ + tmdbId: 1, + title: 'The Last Garden', + overview: 'Synopsis text', + posterUrl: 'https://example.com/poster.jpg', + }), + }); + + expect(merged.name).toBe('The Last Garden'); + expect(merged.description).toBe('Synopsis text'); + expect(merged.image).toBe('https://example.com/poster.jpg'); + }); + + it('prefers movie backdrop for cover image', () => { + const url = resolvePivotCoverImageUrl({ + image: 'https://example.com/listing.jpg', + customFields: { + pivot: { + movie: { + tmdbId: 1, + title: 'Film', + backdropUrl: 'https://example.com/backdrop.jpg', + posterUrl: 'https://example.com/poster.jpg', + }, + }, + }, + }); + + expect(url).toBe('https://example.com/backdrop.jpg'); + }); +}); + +describe('pivotTmdbService mappers', () => { + it('maps TMDB search results', () => { + const row = mapTmdbSearchResult({ + id: 42, + title: 'Garden', + release_date: '2026-05-29', + overview: 'Plot', + poster_path: '/p.jpg', + vote_average: 8.1, + }); + + expect(row).toMatchObject({ + tmdbId: 42, + title: 'Garden', + year: 2026, + posterUrl: 'https://image.tmdb.org/t/p/w185/p.jpg', + voteAverage: 8.1, + }); + }); + + it('maps TMDB movie details into pivot movie metadata', () => { + const movie = mapTmdbMovieDetails({ + id: 99, + title: 'Garden', + release_date: '2026-05-29', + overview: 'Plot summary', + poster_path: '/poster.jpg', + backdrop_path: '/backdrop.jpg', + runtime: 110, + vote_average: 7.6, + vote_count: 500, + genres: [{ name: 'Drama' }], + external_ids: { imdb_id: 'tt1234567' }, + release_dates: { + results: [ + { + iso_3166_1: 'US', + release_dates: [{ certification: 'PG-13' }], + }, + ], + }, + credits: { + crew: [{ job: 'Director', name: 'Jane Doe' }], + cast: [{ name: 'Actor One' }, { name: 'Actor Two' }], + }, + }); + + expect(movie).toMatchObject({ + tmdbId: 99, + title: 'Garden', + year: 2026, + synopsis: 'Plot summary', + runtimeMinutes: 110, + contentRating: 'PG-13', + director: 'Jane Doe', + cast: ['Actor One', 'Actor Two'], + imdbId: 'tt1234567', + ratings: { tmdb: { score: 7.6, voteCount: 500 } }, + }); + }); +}); diff --git a/backend/tests/unit/pivotProfileService.test.js b/backend/tests/unit/pivotProfileService.test.js index e3916d3b..0d9418b4 100644 --- a/backend/tests/unit/pivotProfileService.test.js +++ b/backend/tests/unit/pivotProfileService.test.js @@ -2,12 +2,23 @@ jest.mock('../../services/getModelService', () => jest.fn()); jest.mock('../../services/pivotTagCatalogService', () => ({ validatePivotInterestTags: jest.fn(), })); +jest.mock('../../utilities/sessionUtils', () => ({ + deleteAllUserSessions: jest.fn(), + deleteAllGlobalUserSessions: jest.fn(), +})); const getModels = require('../../services/getModelService'); const { validatePivotInterestTags } = require('../../services/pivotTagCatalogService'); +const { + deleteAllUserSessions, + deleteAllGlobalUserSessions, +} = require('../../utilities/sessionUtils'); const { getPivotProfileInterests, updatePivotProfileInterests, + updatePivotProfileAgeVerification, + leavePivotPilot, + normalizePivotLeaveAuthUser, } = require('../../services/pivotProfileService'); describe('pivotProfileService', () => { @@ -24,6 +35,8 @@ describe('pivotProfileService', () => { }; getModels.mockReturnValue({ User }); validatePivotInterestTags.mockReset(); + deleteAllUserSessions.mockReset(); + deleteAllGlobalUserSessions.mockReset(); }); it('returns unauthorized when user id is missing', async () => { @@ -116,4 +129,82 @@ describe('pivotProfileService', () => { expect(result.code).toBe('VALIDATION_ERROR'); expect(validatePivotInterestTags).not.toHaveBeenCalled(); }); + + it('stores age verification for 18+ users', async () => { + const save = jest.fn().mockResolvedValue(undefined); + User.findById.mockResolvedValue({ + pivotBirthYear: null, + pivotAgeVerifiedAt: null, + save, + }); + + const result = await updatePivotProfileAgeVerification(req, { birthYear: 2000 }); + + expect(save).toHaveBeenCalled(); + expect(result.data.birthYear).toBe(2000); + expect(result.data.pivotAgeVerifiedAt).toBeTruthy(); + }); + + it('rejects underage verification requests', async () => { + const birthYear = new Date().getUTCFullYear() - 17; + const result = await updatePivotProfileAgeVerification(req, { birthYear }); + + expect(result.code).toBe('UNDERAGE'); + expect(result.status).toBe(403); + expect(User.findById).not.toHaveBeenCalled(); + }); + + it('marks pivot participation left and clears sessions when leaving pilot', async () => { + const user = { + accessSuspended: false, + accessSuspendedAt: null, + pivotParticipationStatus: 'active', + pivotLeftAt: null, + refreshToken: 'legacy-refresh', + save: jest.fn().mockImplementation(function save() { + return Promise.resolve(this); + }), + }; + User.findById.mockResolvedValue(user); + + const result = await leavePivotPilot(req); + + expect(user.pivotParticipationStatus).toBe('left'); + expect(user.pivotLeftAt).toBeTruthy(); + expect(user.accessSuspended).toBe(false); + expect(user.save).toHaveBeenCalled(); + expect(result.data.deactivated).toBe(true); + expect(deleteAllUserSessions).toHaveBeenCalledWith(req.user.userId, req); + }); + + it('clears mistaken pivot leave suspension for users without onboarding data', async () => { + const pivotReq = { + ...req, + school: 'test-pivot', + }; + const user = { + _id: req.user.userId, + accessSuspended: true, + accessSuspendedAt: new Date('2026-01-01T00:00:00.000Z'), + pivotParticipationStatus: 'active', + pivotLeftAt: null, + roles: ['user'], + save: jest.fn().mockImplementation(function save() { + return Promise.resolve(this); + }), + }; + User.findById.mockResolvedValue(user); + + jest.spyOn(require('../../services/tenantConfigService'), 'getTenantByKey').mockResolvedValue({ + pivotPilot: true, + tenantType: 'pivot', + }); + + const normalized = await normalizePivotLeaveAuthUser(pivotReq, req.user.userId); + + expect(normalized?.accessSuspended).toBe(false); + expect(normalized?.pivotParticipationStatus).toBe('left'); + expect(normalized?.pivotLeftAt).toBeTruthy(); + expect(user.save).toHaveBeenCalled(); + }); }); diff --git a/backend/tests/unit/pivotReferralRedeemService.test.js b/backend/tests/unit/pivotReferralRedeemService.test.js index 8810f783..b8a2663d 100644 --- a/backend/tests/unit/pivotReferralRedeemService.test.js +++ b/backend/tests/unit/pivotReferralRedeemService.test.js @@ -1,7 +1,12 @@ jest.mock('../../services/getGlobalModelService', () => jest.fn()); +jest.mock('../../services/getModelService', () => jest.fn()); +jest.mock('../../services/pivotProfileService', () => ({ + reactivatePivotParticipationByGlobalUserId: jest.fn().mockResolvedValue(undefined), +})); const mongoose = require('mongoose'); const getGlobalModels = require('../../services/getGlobalModelService'); +const getModels = require('../../services/getModelService'); const { redeemReferralCode } = require('../../services/pivotReferralCodeService'); describe('pivotReferralCodeService.redeemReferralCode', () => { @@ -37,6 +42,14 @@ describe('pivotReferralCodeService.redeemReferralCode', () => { findOne: redemptionFindOne, create, }, + TenantMembership: { + findOne: jest.fn(), + }, + }); + getModels.mockReturnValue({ + User: { + findById: jest.fn(), + }, }); }); @@ -108,6 +121,80 @@ describe('pivotReferralCodeService.redeemReferralCode', () => { ); }); + it('stores referredByGlobalUserId when invite ref resolves to another member', async () => { + const inviterTenantId = new mongoose.Types.ObjectId(); + const inviterGlobalUserId = new mongoose.Types.ObjectId(); + + redemptionFindOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue(null), + }); + + const referralDoc = { + _id: codeId, + code: 'NYC-PILOT-A', + tenantKey: 'nyc', + active: true, + expiresAt: null, + redemptionCount: 0, + maxRedemptions: 50, + }; + + codeFindOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue(referralDoc), + }); + + findOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: codeId, + redemptionCount: 1, + maxRedemptions: 50, + }), + }); + + getModels.mockReturnValue({ + User: { + findById: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue({ _id: inviterTenantId }), + }), + }), + }, + }); + + const membershipFindOne = jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue({ globalUserId: inviterGlobalUserId }), + }), + }); + getGlobalModels.mockReturnValue({ + PivotReferralCode: { + findOne: codeFindOne, + findOneAndUpdate, + updateOne, + }, + PivotReferralRedemption: { + findOne: redemptionFindOne, + create, + }, + TenantMembership: { + findOne: membershipFindOne, + }, + }); + + create.mockResolvedValue({}); + + const result = await redeemReferralCode(makeReq(), 'NYC-PILOT-A', { + referredByUserId: String(inviterTenantId), + }); + + expect(result.data.inviterAttributed).toBe(true); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + referredByGlobalUserId: inviterGlobalUserId, + }), + ); + }); + it('rolls back increment when duplicate creation races', async () => { redemptionFindOne.mockReturnValue({ lean: jest.fn().mockResolvedValue(null), diff --git a/backend/tests/unit/pivotRetentionService.test.js b/backend/tests/unit/pivotRetentionService.test.js new file mode 100644 index 00000000..de3b2116 --- /dev/null +++ b/backend/tests/unit/pivotRetentionService.test.js @@ -0,0 +1,131 @@ +jest.mock('../../services/getModelService', () => jest.fn()); +jest.mock('../../connectionsManager', () => ({ + connectToDatabase: jest.fn(), +})); +jest.mock('../../services/tenantConfigService', () => ({ + getMergedTenants: jest.fn(), +})); +jest.mock('../../services/pivotReferralCodeService', () => ({ + isPivotTenant: jest.fn(), +})); + +const getModels = require('../../services/getModelService'); +const { connectToDatabase } = require('../../connectionsManager'); +const { getMergedTenants } = require('../../services/tenantConfigService'); +const { isPivotTenant } = require('../../services/pivotReferralCodeService'); +const { + getPivotRetention, + normalizeWeeksParam, +} = require('../../services/pivotRetentionService'); + +describe('pivotRetentionService', () => { + beforeEach(() => { + jest.clearAllMocks(); + isPivotTenant.mockImplementation( + (tenant) => tenant?.pivotPilot === true || tenant?.tenantType === 'pivot', + ); + connectToDatabase.mockResolvedValue({}); + }); + + describe('normalizeWeeksParam', () => { + it('defaults to 6 and clamps to [2, 12]', () => { + expect(normalizeWeeksParam(undefined)).toBe(6); + expect(normalizeWeeksParam('not-a-number')).toBe(6); + expect(normalizeWeeksParam('1')).toBe(2); + expect(normalizeWeeksParam('99')).toBe(12); + expect(normalizeWeeksParam('4')).toBe(4); + }); + }); + + describe('getPivotRetention', () => { + it('computes returning users against the prior week', async () => { + getMergedTenants.mockResolvedValue([ + { tenantKey: 'nyc', tenantType: 'pivot', location: 'New York City' }, + { tenantKey: 'rpi', tenantType: 'campus' }, + ]); + + const usersByWeek = { + '2026-W25': ['u1', 'u2', 'u3', 'u4'], + '2026-W26': ['u2', 'u3', 'u5'], + }; + getModels.mockReturnValue({ + PivotEventIntent: { + distinct: jest + .fn() + .mockImplementation((_field, filter) => + Promise.resolve(usersByWeek[filter.batchWeek] || []), + ), + }, + }); + + const result = await getPivotRetention( + { globalDb: {} }, + { batchWeek: '2026-W26', weeks: 2 }, + ); + + expect(result.data.batchWeek).toBe('2026-W26'); + expect(result.data.weeks).toEqual(['2026-W25', '2026-W26']); + expect(result.data.tenants).toHaveLength(1); + + const [firstWeek, secondWeek] = result.data.tenants[0].weeks; + expect(firstWeek).toEqual({ + batchWeek: '2026-W25', + activeUsers: 4, + returningUsers: null, + retentionRate: null, + }); + expect(secondWeek).toEqual({ + batchWeek: '2026-W26', + activeUsers: 3, + returningUsers: 2, + retentionRate: 50, + }); + }); + + it('spans an ISO year boundary', async () => { + getMergedTenants.mockResolvedValue([ + { tenantKey: 'nyc', tenantType: 'pivot' }, + ]); + getModels.mockReturnValue({ + PivotEventIntent: { + distinct: jest.fn().mockResolvedValue([]), + }, + }); + + const result = await getPivotRetention( + { globalDb: {} }, + { batchWeek: '2026-W02', weeks: 4 }, + ); + + expect(result.data.weeks).toEqual(['2025-W51', '2025-W52', '2026-W01', '2026-W02']); + }); + + it('marks a tenant row when aggregation fails', async () => { + getMergedTenants.mockResolvedValue([ + { tenantKey: 'nyc', tenantType: 'pivot' }, + ]); + getModels.mockReturnValue({ + PivotEventIntent: { + distinct: jest.fn().mockRejectedValue(new Error('db down')), + }, + }); + + const result = await getPivotRetention( + { globalDb: {} }, + { batchWeek: '2026-W26', weeks: 3 }, + ); + + expect(result.data.tenants[0].error).toBe('AGGREGATION_FAILED'); + expect(result.data.tenants[0].weeks).toHaveLength(3); + expect(result.data.tenants[0].weeks[2]).toMatchObject({ + batchWeek: '2026-W26', + activeUsers: 0, + }); + }); + + it('rejects an invalid batch week', async () => { + const result = await getPivotRetention({ globalDb: {} }, { batchWeek: 'nope' }); + expect(result).toMatchObject({ code: 'INVALID_BATCH_WEEK', status: 400 }); + }); + }); +}); diff --git a/backend/tests/unit/pivotTimeSlots.test.js b/backend/tests/unit/pivotTimeSlots.test.js new file mode 100644 index 00000000..aecac915 --- /dev/null +++ b/backend/tests/unit/pivotTimeSlots.test.js @@ -0,0 +1,62 @@ +const { + normalizePivotTimeSlots, + serializePivotTimeSlots, + findTimeSlotById, + isUpcomingWithTimeSlots, + resolveTimeSlotLabel, +} = require('../../utilities/pivotTimeSlots'); + +describe('pivotTimeSlots', () => { + it('normalizes and sorts slots by start time', () => { + const slots = normalizePivotTimeSlots([ + { id: 'late', start_time: '2026-05-24T02:00:00.000Z' }, + { id: 'early', startTime: '2026-05-23T23:00:00.000Z', label: '7:00 PM' }, + ]); + + expect(slots.map((slot) => slot.id)).toEqual(['early', 'late']); + expect(resolveTimeSlotLabel(slots[0])).toBe('7:00 PM'); + }); + + it('findTimeSlotById resolves stored slots', () => { + const pivot = { + timeSlots: [{ id: '7pm', start_time: '2026-05-23T23:00:00.000Z' }], + }; + expect(findTimeSlotById(pivot, '7pm')?.id).toBe('7pm'); + expect(findTimeSlotById(pivot, 'missing')).toBeNull(); + }); + + it('isUpcomingWithTimeSlots returns true when any slot is still upcoming', () => { + const now = new Date('2026-05-23T22:00:00.000Z'); + const pivot = { + timeSlots: [ + { id: 'early', start_time: '2026-05-23T21:00:00.000Z', end_time: '2026-05-23T22:30:00.000Z' }, + { id: 'late', start_time: '2026-05-24T01:00:00.000Z', end_time: '2026-05-24T03:00:00.000Z' }, + ], + }; + + expect(isUpcomingWithTimeSlots(pivot, now)).toBe(true); + }); + + it('serializePivotTimeSlots attaches per-slot friend social', () => { + const slots = normalizePivotTimeSlots([ + { id: '7pm', start_time: '2026-05-23T23:00:00.000Z' }, + ]); + const socialBySlotId = new Map([ + [ + '7pm', + { + friendsGoingCount: 2, + friendsGoing: [{ id: 'a', name: 'Alex', picture: null }], + }, + ], + ]); + + expect(serializePivotTimeSlots(slots, socialBySlotId)).toEqual([ + expect.objectContaining({ + id: '7pm', + friendsGoingCount: 2, + friendsGoing: [{ id: 'a', name: 'Alex', picture: null }], + }), + ]); + }); +}); diff --git a/backend/tests/unit/pivotWeeklyDropService.test.js b/backend/tests/unit/pivotWeeklyDropService.test.js index f25da2ed..4280ec6a 100644 --- a/backend/tests/unit/pivotWeeklyDropService.test.js +++ b/backend/tests/unit/pivotWeeklyDropService.test.js @@ -10,6 +10,16 @@ jest.mock('../../services/tenantConfigService', () => ({ serializeTenantForAdmin: jest.fn((tenant) => tenant), })); +jest.mock('../../services/pivotWeeklySnapshotService', () => ({ + rebuildWeeklySnapshot: jest.fn(), +})); + +jest.mock('axios', () => ({ + post: jest.fn(), +})); + +const axios = require('axios'); +const { rebuildWeeklySnapshot } = require('../../services/pivotWeeklySnapshotService'); const { connectToDatabase } = require('../../connectionsManager'); const getModels = require('../../services/getModelService'); const { getTenantByKey, upsertStoredTenantRow } = require('../../services/tenantConfigService'); @@ -93,5 +103,40 @@ describe('pivotWeeklyDropService', () => { expect(result.dryRun).toBe(true); expect(result.pivotPushRecipientCount).toBe(2); expect(result.sampleMessage?.data?.type).toBe('pivot_week'); + expect(rebuildWeeklySnapshot).not.toHaveBeenCalled(); + }); + + it('sendWeeklyDropPush rebuilds the weekly snapshot after a real send', async () => { + getTenantByKey.mockResolvedValue(nycTenant); + axios.post.mockResolvedValue({ + data: { data: [{ status: 'ok' }, { status: 'ok' }] }, + }); + rebuildWeeklySnapshot.mockResolvedValue({ data: { batchWeek: '2026-W23' } }); + + const req = {}; + const result = await sendWeeklyDropPush(req, 'nyc', { + batchWeek: '2026-W23', + force: true, + }); + + expect(result.sent).toBe(2); + expect(result.snapshotRebuilt).toBe(true); + expect(rebuildWeeklySnapshot).toHaveBeenCalledWith(req, { batchWeek: '2026-W23' }); + }); + + it('sendWeeklyDropPush still reports the send when snapshot rebuild fails', async () => { + getTenantByKey.mockResolvedValue(nycTenant); + axios.post.mockResolvedValue({ + data: { data: [{ status: 'ok' }, { status: 'ok' }] }, + }); + rebuildWeeklySnapshot.mockRejectedValue(new Error('global db down')); + + const result = await sendWeeklyDropPush({}, 'nyc', { + batchWeek: '2026-W23', + force: true, + }); + + expect(result.sent).toBe(2); + expect(result.snapshotRebuilt).toBe(false); }); }); diff --git a/backend/tests/unit/pivotWeeklySnapshotService.test.js b/backend/tests/unit/pivotWeeklySnapshotService.test.js index 88c54669..3314bc82 100644 --- a/backend/tests/unit/pivotWeeklySnapshotService.test.js +++ b/backend/tests/unit/pivotWeeklySnapshotService.test.js @@ -64,9 +64,21 @@ describe('pivotWeeklySnapshotService', () => { .mockResolvedValueOnce(3) .mockResolvedValueOnce(2) .mockResolvedValueOnce(4), - distinct: jest.fn().mockResolvedValue(['u1', 'u2', 'u3']), + distinct: jest + .fn() + .mockImplementation((_field, filter) => + Promise.resolve(filter?.externalOpenAt ? ['u1', 'u2'] : ['u1', 'u2', 'u3']), + ), aggregate: jest.fn().mockResolvedValue([{ total: 7 }]), }, + AnalyticsEvent: { + countDocuments: jest.fn().mockImplementation((filter) => { + const names = filter?.event?.$in || []; + if (names.includes('pivot_calendar_add')) return Promise.resolve(5); + if (names.includes('pivot_invite_share')) return Promise.resolve(2); + return Promise.resolve(4); + }), + }, UniversalFeedback: { find: jest.fn().mockReturnValue({ select: jest.fn().mockReturnValue({ @@ -91,6 +103,10 @@ describe('pivotWeeklySnapshotService', () => { interestedCount: 3, registeredCount: 2, externalOpenCount: 7, + externalOpenUsers: 2, + calendarAdds: 5, + inviteShares: 2, + interestsSaved: 4, swipeCount: 9, feedbackAvg: 4.5, activeUsers: 3, @@ -141,6 +157,9 @@ describe('pivotWeeklySnapshotService', () => { distinct: jest.fn().mockResolvedValue([]), aggregate: jest.fn().mockResolvedValue([]), }, + AnalyticsEvent: { + countDocuments: jest.fn().mockResolvedValue(0), + }, UniversalFeedback: { find: jest.fn().mockReturnValue({ select: jest.fn().mockReturnValue({ diff --git a/backend/utilities/pivotDropSchedule.js b/backend/utilities/pivotDropSchedule.js index 472de23e..173581d7 100644 --- a/backend/utilities/pivotDropSchedule.js +++ b/backend/utilities/pivotDropSchedule.js @@ -1,4 +1,4 @@ -const { isValidIsoWeek } = require('./pivotIsoWeek'); +const { isoWeekToMondayUtc } = require('./pivotIsoWeek'); /** Pilot suggestion when a pivot tenant has no drop config stored yet (not a runtime constant). */ const PIVOT_DROP_PILOT_DEFAULTS = Object.freeze({ @@ -58,21 +58,6 @@ function resolvePivotDropConfig(tenant = {}) { }; } -function isoWeekToMondayUtc(batchWeek) { - if (!isValidIsoWeek(batchWeek)) { - throw new Error(`Invalid batchWeek "${batchWeek}" — expected YYYY-Www`); - } - - const [, yearStr, weekStr] = batchWeek.match(/^(\d{4})-W(\d{2})$/); - const year = Number(yearStr); - const week = Number(weekStr); - const jan4 = new Date(Date.UTC(year, 0, 4)); - const jan4IsoDay = jan4.getUTCDay() || 7; - const monday = new Date(jan4); - monday.setUTCDate(jan4.getUTCDate() - jan4IsoDay + 1 + (week - 1) * 7); - return monday; -} - function daysFromIsoMonday(dayOfWeek) { return dayOfWeek === 0 ? 6 : dayOfWeek - 1; } diff --git a/backend/utilities/pivotIsoWeek.js b/backend/utilities/pivotIsoWeek.js index a94e0e66..eef56896 100644 --- a/backend/utilities/pivotIsoWeek.js +++ b/backend/utilities/pivotIsoWeek.js @@ -17,8 +17,63 @@ function isValidIsoWeek(value) { return typeof value === 'string' && ISO_WEEK_PATTERN.test(value.trim()); } +/** + * UTC Date for the Monday 00:00 that starts the given ISO week. + * @param {string} batchWeek - YYYY-Www + * @returns {Date} + */ +function isoWeekToMondayUtc(batchWeek) { + if (!isValidIsoWeek(batchWeek)) { + throw new Error(`Invalid batchWeek "${batchWeek}" — expected YYYY-Www`); + } + + const [, yearStr, weekStr] = batchWeek.match(/^(\d{4})-W(\d{2})$/); + const year = Number(yearStr); + const week = Number(weekStr); + const jan4 = new Date(Date.UTC(year, 0, 4)); + const jan4IsoDay = jan4.getUTCDay() || 7; + const monday = new Date(jan4); + monday.setUTCDate(jan4.getUTCDate() - jan4IsoDay + 1 + (week - 1) * 7); + return monday; +} + +/** + * [start, end) UTC range covering the given ISO week (Monday 00:00 → next Monday 00:00). + * @param {string} batchWeek - YYYY-Www + * @returns {{ start: Date, end: Date }} + */ +function isoWeekToUtcRange(batchWeek) { + const start = isoWeekToMondayUtc(batchWeek); + const end = new Date(start.getTime() + 7 * 24 * 60 * 60 * 1000); + return { start, end }; +} + +/** ISO week string from UTC calendar components (avoids local-timezone getters). */ +function toIsoWeekUtc(date) { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((d - yearStart) / 86400000 + 1) / 7); + return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`; +} + +/** + * Shift an ISO week string by a number of weeks (negative = earlier). + * @param {string} batchWeek - YYYY-Www + * @param {number} delta + * @returns {string} + */ +function shiftIsoWeek(batchWeek, delta) { + const monday = isoWeekToMondayUtc(batchWeek); + monday.setUTCDate(monday.getUTCDate() + delta * 7); + return toIsoWeekUtc(monday); +} + module.exports = { toIsoWeek, isValidIsoWeek, + isoWeekToMondayUtc, + isoWeekToUtcRange, + shiftIsoWeek, ISO_WEEK_PATTERN, }; diff --git a/backend/utilities/pivotLogger.js b/backend/utilities/pivotLogger.js new file mode 100644 index 00000000..34b98ef9 --- /dev/null +++ b/backend/utilities/pivotLogger.js @@ -0,0 +1,101 @@ +const LOG_PREFIX = '[pivot]'; + +function isPivotLoggingEnabled() { + const flag = process.env.PIVOT_LOG; + if (flag === '0' || flag === 'false') { + return false; + } + if (process.env.NODE_ENV === 'test') { + return false; + } + return true; +} + +function serializeMeta(meta) { + if (meta == null) { + return ''; + } + try { + return ` ${JSON.stringify(meta)}`; + } catch { + return ' [meta unserializable]'; + } +} + +function logPivot(level, message, meta) { + if (!isPivotLoggingEnabled()) { + return; + } + + const line = `${LOG_PREFIX} ${message}${serializeMeta(meta)}`; + if (level === 'error') { + console.error(line); + return; + } + if (level === 'warn') { + console.warn(line); + return; + } + console.log(line); +} + +function pivotRequestContext(req) { + return { + tenant: req.school || undefined, + userId: req.user?.userId ? String(req.user.userId) : undefined, + method: req.method, + path: req.originalUrl || req.url, + }; +} + +/** Express middleware — logs completed pivot requests with latency. */ +function pivotRequestLogger(req, res, next) { + if (!isPivotLoggingEnabled()) { + next(); + return; + } + + const startedAt = Date.now(); + res.on('finish', () => { + const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info'; + logPivot(level, 'request', { + ...pivotRequestContext(req), + status: res.statusCode, + ms: Date.now() - startedAt, + }); + }); + next(); +} + +function logPivotRouteError(routeLabel, err, req) { + logPivot('error', `${routeLabel} failed`, { + ...pivotRequestContext(req), + error: err?.message || String(err), + }); +} + +function logPivotServiceReject(routeLabel, result, req, extra) { + logPivot('warn', `${routeLabel} rejected`, { + ...pivotRequestContext(req), + code: result.code, + message: result.error, + ...extra, + }); +} + +function logPivotServiceSuccess(routeLabel, req, extra) { + logPivot('info', `${routeLabel} ok`, { + ...pivotRequestContext(req), + ...extra, + }); +} + +module.exports = { + logPivot, + pivotRequestContext, + pivotRequestLogger, + logPivotRouteError, + logPivotServiceReject, + logPivotServiceSuccess, + isPivotLoggingEnabled, +}; diff --git a/backend/utilities/pivotMovieMetadata.js b/backend/utilities/pivotMovieMetadata.js new file mode 100644 index 00000000..0cf4c9e2 --- /dev/null +++ b/backend/utilities/pivotMovieMetadata.js @@ -0,0 +1,157 @@ +/** + * Movie metadata stored on `customFields.pivot.movie` (TMDB-backed film listings). + */ + +const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p'; + +function trimString(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function toNumber(value) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function roundRating(value) { + const num = toNumber(value); + if (num == null) { + return null; + } + return Math.round(num * 10) / 10; +} + +function buildTmdbImageUrl(path, size = 'w500') { + const normalized = trimString(path); + if (!normalized) { + return null; + } + if (normalized.startsWith('http://') || normalized.startsWith('https://')) { + return normalized; + } + return `${TMDB_IMAGE_BASE}/${size}${normalized.startsWith('/') ? normalized : `/${normalized}`}`; +} + +function normalizeRatings(raw) { + if (!raw || typeof raw !== 'object') { + return null; + } + + const tmdbScore = roundRating(raw.tmdb?.score ?? raw.tmdbScore); + const tmdbVoteCount = toNumber(raw.tmdb?.voteCount ?? raw.tmdbVoteCount); + if (tmdbScore == null && tmdbVoteCount == null) { + return null; + } + + return { + ...(tmdbScore != null + ? { + tmdb: { + score: tmdbScore, + ...(tmdbVoteCount != null ? { voteCount: tmdbVoteCount } : {}), + }, + } + : {}), + }; +} + +function normalizeStringArray(raw) { + if (!Array.isArray(raw)) { + return []; + } + return raw.map((entry) => trimString(entry)).filter(Boolean); +} + +/** + * @param {unknown} raw + * @returns {object | null} + */ +function normalizePivotMovie(raw) { + if (!raw || typeof raw !== 'object') { + return null; + } + + const tmdbId = toNumber(raw.tmdbId); + const title = trimString(raw.title); + if (!tmdbId || !title) { + return null; + } + + const year = toNumber(raw.year); + const synopsis = trimString(raw.synopsis || raw.overview); + const posterUrl = trimString(raw.posterUrl) || buildTmdbImageUrl(raw.posterPath, 'w500'); + const backdropUrl = + trimString(raw.backdropUrl) || buildTmdbImageUrl(raw.backdropPath, 'w780'); + const runtimeMinutes = toNumber(raw.runtimeMinutes ?? raw.runtime); + const genres = normalizeStringArray(raw.genres); + const contentRating = trimString(raw.contentRating); + const director = trimString(raw.director); + const cast = normalizeStringArray(raw.cast); + const imdbId = trimString(raw.imdbId); + const ratings = normalizeRatings(raw.ratings); + + return { + tmdbId, + title, + ...(year != null ? { year } : {}), + ...(synopsis ? { synopsis } : {}), + ...(posterUrl ? { posterUrl } : {}), + ...(backdropUrl ? { backdropUrl } : {}), + ...(runtimeMinutes != null ? { runtimeMinutes } : {}), + ...(genres.length ? { genres } : {}), + ...(contentRating ? { contentRating } : {}), + ...(director ? { director } : {}), + ...(cast.length ? { cast } : {}), + ...(imdbId ? { imdbId } : {}), + ...(ratings ? { ratings } : {}), + }; +} + +function serializePivotMovie(movie) { + if (!movie || typeof movie !== 'object') { + return null; + } + + return normalizePivotMovie(movie); +} + +function applyMovieListingDefaults(merged) { + if (!merged?.movie) { + return merged; + } + + const movie = merged.movie; + return { + ...merged, + name: trimString(merged.name) || movie.title, + description: trimString(merged.description) || movie.synopsis || '', + image: trimString(merged.image) || movie.posterUrl || '', + }; +} + +function resolvePivotCoverImageUrl(event) { + const pivot = event?.customFields?.pivot || {}; + const movie = serializePivotMovie(pivot.movie); + const eventImage = trimString(event?.image); + if (movie?.backdropUrl) { + return movie.backdropUrl; + } + if (movie?.posterUrl) { + return movie.posterUrl; + } + return eventImage || null; +} + +module.exports = { + buildTmdbImageUrl, + normalizePivotMovie, + serializePivotMovie, + applyMovieListingDefaults, + resolvePivotCoverImageUrl, +}; diff --git a/backend/utilities/pivotTimeSlots.js b/backend/utilities/pivotTimeSlots.js new file mode 100644 index 00000000..7430a151 --- /dev/null +++ b/backend/utilities/pivotTimeSlots.js @@ -0,0 +1,149 @@ +/** + * Normalize and serialize Pivot catalog showtimes stored on + * `customFields.pivot.timeSlots` (movies, theatre, multi-performance events). + */ + +function trimString(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function parseDate(value) { + if (!value) { + return null; + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +/** + * @param {unknown} rawSlots + * @returns {Array<{ id: string, start_time: Date, end_time: Date | null, label: string | null }>} + */ +function normalizePivotTimeSlots(rawSlots) { + if (!Array.isArray(rawSlots) || !rawSlots.length) { + return []; + } + + const slots = []; + const seenIds = new Set(); + + for (const raw of rawSlots) { + if (!raw || typeof raw !== 'object') { + continue; + } + + const id = trimString(raw.id); + const start = parseDate(raw.start_time ?? raw.startTime); + if (!id || !start || seenIds.has(id)) { + continue; + } + + seenIds.add(id); + const end = parseDate(raw.end_time ?? raw.endTime); + const label = trimString(raw.label) || null; + + slots.push({ + id, + start_time: start, + end_time: end, + label, + }); + } + + slots.sort((a, b) => a.start_time.getTime() - b.start_time.getTime()); + return slots; +} + +function resolveTimeSlotLabel(slot) { + if (slot.label) { + return slot.label; + } + + return slot.start_time + .toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) + .toLowerCase() + .replace(/\s/g, ''); +} + +/** + * @param {Array<{ id: string, start_time: Date, end_time: Date | null, label: string | null }>} slots + * @param {Map} [socialBySlotId] + */ +function serializePivotTimeSlots(slots, socialBySlotId = new Map()) { + return slots.map((slot) => { + const social = socialBySlotId.get(slot.id) || { + friendsGoing: [], + friendsGoingCount: 0, + }; + + return { + id: slot.id, + start_time: slot.start_time.toISOString(), + ...(slot.end_time ? { end_time: slot.end_time.toISOString() } : {}), + label: resolveTimeSlotLabel(slot), + friendsGoing: social.friendsGoing, + friendsGoingCount: social.friendsGoingCount, + }; + }); +} + +function eventHasTimeSlots(pivotMeta) { + return normalizePivotTimeSlots(pivotMeta?.timeSlots).length > 0; +} + +function findTimeSlotById(pivotMeta, timeSlotId) { + const key = trimString(timeSlotId); + if (!key) { + return null; + } + return normalizePivotTimeSlots(pivotMeta?.timeSlots).find((slot) => slot.id === key) || null; +} + +function isTimeSlotUpcoming(slot, now = new Date()) { + const end = slot.end_time || slot.start_time; + return end > now; +} + +function isUpcomingWithTimeSlots(pivotMeta, now = new Date()) { + const slots = normalizePivotTimeSlots(pivotMeta?.timeSlots); + if (!slots.length) { + return null; + } + return slots.some((slot) => isTimeSlotUpcoming(slot, now)); +} + +function resolveEventEarliestStart(pivotMeta, fallbackStart) { + const slots = normalizePivotTimeSlots(pivotMeta?.timeSlots); + if (!slots.length) { + return fallbackStart; + } + return slots[0].start_time; +} + +function resolveEventLatestEnd(pivotMeta, fallbackEnd) { + const slots = normalizePivotTimeSlots(pivotMeta?.timeSlots); + if (!slots.length) { + return fallbackEnd; + } + + let latest = null; + for (const slot of slots) { + const candidate = slot.end_time || slot.start_time; + if (!latest || candidate > latest) { + latest = candidate; + } + } + return latest; +} + +module.exports = { + normalizePivotTimeSlots, + serializePivotTimeSlots, + resolveTimeSlotLabel, + eventHasTimeSlots, + findTimeSlotById, + isTimeSlotUpcoming, + isUpcomingWithTimeSlots, + resolveEventEarliestStart, + resolveEventLatestEnd, +}; diff --git a/backend/utilities/sessionUtils.js b/backend/utilities/sessionUtils.js index a23c8fe1..150e58d8 100644 --- a/backend/utilities/sessionUtils.js +++ b/backend/utilities/sessionUtils.js @@ -223,6 +223,14 @@ async function deleteAllUserSessions(userId, req) { await Session.deleteMany({ userId }); } +/** + * Delete all sessions for a global user (global DB / multi-tenant SSO sessions). + */ +async function deleteAllGlobalUserSessions(globalUserId, req) { + const { Session } = getGlobalModels(req, 'Session'); + await Session.deleteMany({ globalUserId }); +} + /** * Delete a specific session by ID (tenant DB) */ @@ -307,6 +315,7 @@ module.exports = { validateGlobalSession, deleteSession, deleteAllUserSessions, + deleteAllGlobalUserSessions, deleteSessionById, deleteSessionByIdForGlobalUser, getUserSessions, diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 00000000..521a9f7c --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/frontend/public/.well-known/apple-app-site-association b/frontend/public/.well-known/apple-app-site-association new file mode 100644 index 00000000..6922118f --- /dev/null +++ b/frontend/public/.well-known/apple-app-site-association @@ -0,0 +1,11 @@ +{ + "applinks": { + "apps": [], + "details": [ + { + "appID": "APPLE_TEAM_ID.com.meridian.mobile", + "paths": ["/invite", "/invite/*"] + } + ] + } +} diff --git a/frontend/public/.well-known/assetlinks.json b/frontend/public/.well-known/assetlinks.json new file mode 100644 index 00000000..70f6bd5f --- /dev/null +++ b/frontend/public/.well-known/assetlinks.json @@ -0,0 +1,12 @@ +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.meridian.mobile", + "sha256_cert_fingerprints": [ + "REPLACE_WITH_RELEASE_SHA256_FINGERPRINT" + ] + } + } +] diff --git a/frontend/src/App.js b/frontend/src/App.js index 13966a4e..e74c5252 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -21,6 +21,7 @@ import Org from './pages/Org/Org'; import Profile from './pages/Profile/Profile'; import Landing from './pages/Landing/Landing'; import MobileLanding from './pages/MobileLanding/MobileLanding'; +import InviteLanding from './pages/InviteLanding/InviteLanding'; import Events from './pages/Events/Events'; import DeveloperOnboard from './pages/DeveloperOnboarding/DeveloperOnboarding'; import QR from './pages/QR/QR'; @@ -236,6 +237,7 @@ function App() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/frontend/src/assets/pivot/just-go-wordmark.svg b/frontend/src/assets/pivot/just-go-wordmark.svg new file mode 100644 index 00000000..90745045 --- /dev/null +++ b/frontend/src/assets/pivot/just-go-wordmark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/src/config/tenantRedirect.js b/frontend/src/config/tenantRedirect.js index 2a9ef2a6..a91ce81a 100644 --- a/frontend/src/config/tenantRedirect.js +++ b/frontend/src/config/tenantRedirect.js @@ -118,6 +118,7 @@ const WWW_ALLOWED_PATHS = [ '/', '/landing', '/mobile', + '/invite', '/contact', '/support', '/privacy-policy', diff --git a/frontend/src/pages/InviteLanding/InviteLanding.jsx b/frontend/src/pages/InviteLanding/InviteLanding.jsx new file mode 100644 index 00000000..5dc37354 --- /dev/null +++ b/frontend/src/pages/InviteLanding/InviteLanding.jsx @@ -0,0 +1,316 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Icon } from '@iconify-icon/react/dist/iconify.mjs'; +import apiRequest from '../../utils/postRequest'; +import justGoWordmark from '../../assets/pivot/just-go-wordmark.svg'; +import './InviteLanding.scss'; + +const APP_STORE_URL = 'https://apps.apple.com/us/app/meridian-go/id6755217537'; +const INVITE_THEME_STORAGE_KEY = 'meridian.invite.theme'; + +function getSystemTheme() { + if (typeof window === 'undefined') return 'day'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'night' : 'day'; +} + +function readStoredTheme() { + try { + const stored = localStorage.getItem(INVITE_THEME_STORAGE_KEY); + if (stored === 'day' || stored === 'night') return stored; + } catch { + // ignore storage errors + } + return getSystemTheme(); +} + +function useInviteTheme() { + const [theme, setTheme] = useState(readStoredTheme); + + const toggleTheme = useCallback(() => { + setTheme((current) => { + const next = current === 'night' ? 'day' : 'night'; + try { + localStorage.setItem(INVITE_THEME_STORAGE_KEY, next); + } catch { + // ignore storage errors + } + return next; + }); + }, []); + + return { theme, toggleTheme, isNight: theme === 'night' }; +} + +function useDeviceDetection() { + return useMemo(() => { + if (typeof window === 'undefined') { + return { isAndroid: false }; + } + const ua = navigator.userAgent || navigator.vendor || ''; + const isAndroid = /android/i.test(ua); + return { isAndroid }; + }, []); +} + +function normalizeInviteCode(raw) { + return (raw || '').trim().toUpperCase(); +} + +function buildDeepLink(code, referredByUserId) { + const params = new URLSearchParams(); + params.set('code', normalizeInviteCode(code)); + const ref = (referredByUserId || '').trim(); + if (ref) { + params.set('ref', ref); + } + return `meridian://invite?${params.toString()}`; +} + +function InviteLanding() { + const [searchParams] = useSearchParams(); + const rawCode = searchParams.get('code'); + const referredByUserId = searchParams.get('ref')?.trim() || null; + const code = normalizeInviteCode(rawCode); + const { isAndroid } = useDeviceDetection(); + const { theme, toggleTheme, isNight } = useInviteTheme(); + + const [state, setState] = useState('loading'); // loading | valid | invalid | missing + const [cityDisplayName, setCityDisplayName] = useState(null); + const [copyStatus, setCopyStatus] = useState('idle'); // idle | copied | error + + useEffect(() => { + document.title = 'just go — invite'; + + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) { + meta.setAttribute('content', isNight ? '#1E1A16' : '#FAF6EF'); + } + + return () => { + document.title = 'Meridian'; + if (meta) { + meta.setAttribute('content', '#000000'); + } + }; + }, [isNight]); + + useEffect(() => { + if (!code) { + setState('missing'); + return; + } + + let cancelled = false; + setState('loading'); + + apiRequest('/pivot/referral/preview', null, { + method: 'GET', + params: { code }, + }) + .then((res) => { + if (cancelled) return; + if (res?.success && res?.data?.valid) { + setCityDisplayName(res.data.cityDisplayName || null); + setState('valid'); + return; + } + setState('invalid'); + }) + .catch(() => { + if (!cancelled) setState('invalid'); + }); + + return () => { + cancelled = true; + }; + }, [code]); + + const handleCopyCode = useCallback(async () => { + if (!code) return; + try { + await navigator.clipboard.writeText(code); + setCopyStatus('copied'); + window.setTimeout(() => setCopyStatus('idle'), 2000); + } catch { + setCopyStatus('error'); + window.setTimeout(() => setCopyStatus('idle'), 2000); + } + }, [code]); + + const deepLink = code ? buildDeepLink(code, referredByUserId) : null; + + const renderAppStoreBadge = () => ( + + Download on the App Store + + ); + + const renderError = (title, body) => ( +
+ +

{title}

+

{body}

+ {code ? ( +

+ code tried: {code} +

+ ) : null} +
+ ); + + return ( +
+ +
+
+
+ just go +
+

your city, one tap at a time

+

+ just go is an experiment from Meridian. For this pilot + you'll use Meridian Go — our campus app on iPhone — + and your invite code unlocks the just go experience inside it. Android + isn't supported yet. +

+
+ + {state === 'loading' ? ( +
+
+ +

checking your invite…

+
+
+ ) : null} + + {state === 'missing' + ? renderError( + "that code didn't work", + 'this link is missing an invite code. ask your friend to resend the full link.', + ) + : null} + + {state === 'invalid' + ? renderError( + "that code didn't work", + 'double-check the code or ask for a fresh invite. codes can expire or hit their limit.', + ) + : null} + + {state === 'valid' ? ( +
+

you're invited

+

+ join just go + {cityDisplayName ? ( + <> + {' '} + in {cityDisplayName} + + ) : null} +

+ +
+ invite code + {code} + +
+ +
    +
  1. + 1 +
    +

    download Meridian Go (iPhone)

    +

    + grab Meridian Go from the App Store — it's the same Meridian + app students use on campus. your invite code switches you into + just go after install. +

    + {isAndroid ? ( +

    + just go is iPhone-only during this pilot. you'll need an + iPhone with Meridian Go to join. +

    + ) : ( + renderAppStoreBadge() + )} +
    +
  2. + +
  3. + 2 +
    +

    come back and tap open

    +

    + after installing, return here and open just go — your code + carries over. +

    + {deepLink ? ( + + open in app + + ) : null} +
    +
  4. +
+ +
+

or enter the code manually

+

+ open Meridian Go → tap{' '} + + try + + {' '} + on the Meridian welcome screen → paste {code} to + enter just go. +

+
+
+ ) : null} +
+
+ ); +} + +export default InviteLanding; diff --git a/frontend/src/pages/InviteLanding/InviteLanding.scss b/frontend/src/pages/InviteLanding/InviteLanding.scss new file mode 100644 index 00000000..79ec0068 --- /dev/null +++ b/frontend/src/pages/InviteLanding/InviteLanding.scss @@ -0,0 +1,471 @@ +@import url('https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap'); + +.invite-landing { + --jg-surface: #faf6ef; + --jg-fg: #1a1714; + --jg-fg-muted: rgba(26, 23, 20, 0.72); + --jg-light: #faf6ef; + --jg-on-fg: #faf6ef; + --jg-pop-fg: #1a1714; + --jg-accent: #ff4f1f; + --jg-burst: #ff2a2a; + --jg-pop: #ffd23f; + --jg-ticker: #4ab5ff; + --jg-wordmark-bg: #1a1714; + + color-scheme: light; + min-height: 100vh; + min-height: 100dvh; + background: var(--jg-surface); + color: var(--jg-fg); + font-family: 'Space Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + 'Liberation Mono', 'Courier New', monospace; + padding: 1.25rem 1rem 2.5rem; + box-sizing: border-box; + position: relative; + +} + +.invite-landing__frame { + max-width: 28rem; + margin: 0 auto; +} + +.invite-landing__theme-toggle { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 10; + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.55rem; + border: 2px solid var(--jg-fg); + background: var(--jg-surface); + color: var(--jg-fg); + font-family: inherit; + font-size: 0.62rem; + font-weight: 700; + text-transform: lowercase; + letter-spacing: 0.04em; + cursor: pointer; + box-shadow: 3px 3px 0 var(--jg-accent); + transform: rotate(-1.5deg); + transition: transform 140ms ease; + + iconify-icon { + font-size: 0.95rem; + } + + &:active { + transform: translate(1px, 1px); + box-shadow: 2px 2px 0 var(--jg-accent); + } + + &:hover { + transform: rotate(0deg); + } +} + +.invite-landing__header { + text-align: center; + margin-bottom: 1.25rem; +} + +.invite-landing__wordmark-panel { + display: inline-block; + max-width: min(280px, 88vw); + margin: 0 auto 0.75rem; + padding: 1rem 1.15rem 0.85rem; + background: var(--jg-wordmark-bg); + border: 3px solid var(--jg-fg); + box-shadow: 5px 5px 0 var(--jg-accent); + transform: rotate(-2deg); +} + +.invite-landing__wordmark { + display: block; + width: 100%; + height: auto; + margin: 0 auto; +} + +.invite-landing__tagline { + margin: 0 0 0.85rem; + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: lowercase; + color: var(--jg-fg-muted); + transform: rotate(-0.5deg); +} + +.invite-landing__experiment { + margin: 0; + padding: 0.75rem 0.85rem; + font-size: 0.7rem; + line-height: 1.55; + text-align: left; + color: var(--jg-fg-muted); + background: var(--jg-surface); + border: 2px dashed var(--jg-fg); + + strong { + color: var(--jg-fg); + font-weight: 700; + } +} + +.invite-landing__card { + background: var(--jg-surface); + border: 3px solid var(--jg-fg); + box-shadow: 6px 6px 0 var(--jg-fg); + padding: 1.25rem 1.1rem 1.35rem; + transform: rotate(-0.35deg); +} + +.invite-landing__card--error { + text-align: center; +} + +.invite-landing__burst { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + margin-bottom: 0.75rem; + background: var(--jg-burst); + border: 3px solid var(--jg-fg); + color: var(--jg-light); + font-size: 1.5rem; + transform: rotate(-8deg); +} + +.invite-landing__eyebrow { + margin: 0 0 0.35rem; + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--jg-accent); + font-weight: 700; +} + +.invite-landing__title { + margin: 0 0 1rem; + font-size: 1.35rem; + line-height: 1.25; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--jg-fg); +} + +.invite-landing__city { + background: var(--jg-pop); + color: var(--jg-pop-fg); + padding: 0 0.2em; + border: 2px solid var(--jg-fg); + box-decoration-break: clone; +} + +.invite-landing__body, +.invite-landing__code-hint { + margin: 0; + font-size: 0.82rem; + line-height: 1.55; + color: var(--jg-fg-muted); +} + +.invite-landing__code-hint { + margin-top: 0.75rem; +} + +.invite-landing__loading { + text-align: center; + padding: 1.5rem 0; + + p { + margin: 0.75rem 0 0; + font-size: 0.82rem; + color: var(--jg-fg-muted); + } +} + +.invite-landing__spinner { + font-size: 1.75rem; + color: var(--jg-fg); + animation: invite-spin 0.9s linear infinite; +} + +@keyframes invite-spin { + to { + transform: rotate(360deg); + } +} + +.invite-landing__code-box { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + gap: 0.25rem 0.75rem; + align-items: center; + padding: 0.75rem 0.85rem; + margin-bottom: 1.1rem; + background: var(--jg-ticker); + border: 3px solid var(--jg-fg); + box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.16); + transform: rotate(0.2deg); +} + +.invite-landing__code-label { + grid-column: 1; + font-size: 0.62rem; + letter-spacing: 0.1em; + text-transform: uppercase; + font-weight: 700; + color: var(--jg-fg); +} + +.invite-landing__code-value { + grid-column: 1; + font-size: 1.15rem; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--jg-fg); +} + +.invite-landing__copy-btn { + grid-column: 2; + grid-row: 1 / span 2; + padding: 0.45rem 0.65rem; + border: 2px solid var(--jg-fg); + background: var(--jg-surface); + color: var(--jg-fg); + font-family: inherit; + font-size: 0.68rem; + font-weight: 700; + text-transform: lowercase; + cursor: pointer; + box-shadow: 2px 2px 0 var(--jg-fg); + transition: transform 120ms ease, box-shadow 120ms ease; + + &:active { + transform: translate(1px, 1px); + box-shadow: 1px 1px 0 var(--jg-fg); + } +} + +.invite-landing__steps { + list-style: none; + margin: 0 0 1rem; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.invite-landing__step { + display: flex; + gap: 0.75rem; + align-items: flex-start; + + &:nth-child(odd) .invite-landing__step-num { + transform: rotate(-6deg); + } + + &:nth-child(even) .invite-landing__step-num { + transform: rotate(6deg); + } +} + +.invite-landing__step-num { + flex-shrink: 0; + width: 1.75rem; + height: 1.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--jg-accent); + color: var(--jg-light); + border: 2px solid var(--jg-fg); + font-size: 0.85rem; + font-weight: 700; + box-shadow: 2px 2px 0 var(--jg-fg); +} + +.invite-landing__step-body { + flex: 1; + min-width: 0; + + h2 { + margin: 0 0 0.25rem; + font-size: 0.88rem; + font-weight: 700; + text-transform: lowercase; + color: var(--jg-fg); + } + + p { + margin: 0 0 0.65rem; + font-size: 0.75rem; + line-height: 1.5; + color: var(--jg-fg-muted); + } +} + +.invite-landing__android-note { + margin: 0; + padding: 0.65rem 0.75rem; + font-size: 0.72rem; + line-height: 1.5; + color: var(--jg-pop-fg); + background: var(--jg-pop); + border: 2px solid var(--jg-fg); +} + +.invite-landing__store-badges { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.invite-landing__store-badge { + display: inline-block; + padding: 0; + border: none; + background: transparent; + line-height: 0; + cursor: pointer; + + img { + display: block; + height: auto; + max-width: 100%; + } + + &--solo img { + max-height: 44px; + } +} + +.invite-landing__cta { + display: inline-block; + padding: 0.65rem 1rem; + background: var(--jg-fg); + color: var(--jg-on-fg); + border: 3px solid var(--jg-fg); + box-shadow: 4px 4px 0 var(--jg-accent); + text-decoration: none; + font-size: 0.82rem; + font-weight: 700; + text-transform: lowercase; + transform: rotate(-1deg); + transition: transform 140ms ease; + + &:active { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--jg-accent); + } + + &:hover { + transform: rotate(0deg); + } +} + +.invite-landing__fallback { + padding-top: 0.85rem; + border-top: 2px dashed var(--jg-fg); + + h3 { + margin: 0 0 0.35rem; + font-size: 0.78rem; + font-weight: 700; + text-transform: lowercase; + color: var(--jg-fg); + } + + p { + margin: 0; + font-size: 0.72rem; + line-height: 1.55; + color: var(--jg-fg-muted); + } +} + +@media (max-width: 640px) { + .invite-landing::before { + top: 4.8rem; + left: 0.6rem; + } + + .invite-landing__theme-toggle { + top: 0.75rem; + right: 0.75rem; + } +} + +.invite-landing__cta-inline { + display: inline-flex; + align-items: center; + gap: 0.2rem; + vertical-align: middle; + padding: 0.12rem 0.4rem 0.08rem; + background: var(--jg-wordmark-bg); + border: 2px solid var(--jg-fg); + transform: rotate(-1deg); +} + +.invite-landing__cta-inline-prefix { + font-size: 0.68rem; + font-weight: 700; + color: var(--jg-light); + text-transform: lowercase; +} + +.invite-landing__cta-inline-wordmark { + display: block; + height: 0.85rem; + width: auto; +} + +.invite-landing--night { + color-scheme: dark; + --jg-surface: #1e1a16; + --jg-fg: #faf6ef; + --jg-fg-muted: rgba(250, 246, 239, 0.86); + --jg-light: #faf6ef; + --jg-on-fg: #1e1a16; + --jg-pop-fg: #1a1714; + --jg-burst: #ff3b3b; + --jg-ticker: #2e8fd4; + --jg-wordmark-bg: #0f0d0b; + + .invite-landing__card { + box-shadow: 6px 6px 0 rgba(250, 246, 239, 0.22); + } + + .invite-landing__wordmark-panel { + box-shadow: 5px 5px 0 var(--jg-accent); + } + + .invite-landing__experiment { + background: #262018; + } + + .invite-landing__copy-btn { + background: #14110e; + } + + .invite-landing__code-box { + box-shadow: 2px 2px 0 rgba(250, 246, 239, 0.18); + } + + .invite-landing__code-box { + color: var(--jg-light); + } + + .invite-landing__code-label, + .invite-landing__code-value { + color: var(--jg-light); + } +} diff --git a/frontend/src/pages/Layout/Layout.jsx b/frontend/src/pages/Layout/Layout.jsx index 3d170750..fe57f44f 100644 --- a/frontend/src/pages/Layout/Layout.jsx +++ b/frontend/src/pages/Layout/Layout.jsx @@ -62,10 +62,14 @@ function Layout() { return ; } + const hideCampusChrome = location.pathname === '/invite' || location.pathname.startsWith('/invite/'); + return (
{/* The Banner is rendered here and will appear across all pages */} - + {!hideCampusChrome ? ( + + ) : null} {/* Org invite modal - shown when user has pending invites */} {showOrgInviteModal && pendingOrgInvites?.length > 0 && ( diff --git a/frontend/src/pages/PlatformAdmin/PivotLab/PivotCatalogEventEditModal.jsx b/frontend/src/pages/PlatformAdmin/PivotLab/PivotCatalogEventEditModal.jsx new file mode 100644 index 00000000..57ce5525 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotLab/PivotCatalogEventEditModal.jsx @@ -0,0 +1,529 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import Popup from '../../../components/Popup/Popup'; +import PivotTagMultiSelect from './PivotTagMultiSelect'; +import { PivotDeckPhonePreview } from './PivotDeckCardPreview'; +import { formatPivotDeckWhen } from '../../../utils/pivotIsoWeek'; +import { + toDatetimeLocalValue, + datetimeLocalToIso, + createEmptyManualShowtimeSlot, + normalizeManualShowtimeSlots, + deriveEventWindowFromShowtimes, + applyMovieMetadataToDraft, +} from './PivotManualImportModal'; +import PivotTmdbLookup from './PivotTmdbLookup'; +import './PivotManualImportModal.scss'; +import './PivotCatalogEventEditModal.scss'; + +function isoToDatetimeLocal(iso) { + if (!iso) return ''; + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) return ''; + return toDatetimeLocalValue(parsed); +} + +function catalogTimeSlotsToDraftSlots(timeSlots) { + if (!Array.isArray(timeSlots) || !timeSlots.length) { + return []; + } + + return timeSlots.map((slot, index) => ({ + key: slot.id || `slot-${index}`, + label: slot.label || '', + startTimeLocal: isoToDatetimeLocal(slot.start_time), + endTimeLocal: isoToDatetimeLocal(slot.end_time), + })); +} + +export function catalogEventToEditDraft(event) { + if (!event) return null; + + const timeSlots = catalogTimeSlotsToDraftSlots(event.timeSlots); + const hasShowtimes = timeSlots.length > 0; + + return { + name: event.name || '', + organizerName: event.organizerName || '', + location: event.location || '', + description: event.description || '', + imageUrl: event.image || '', + sourceUrl: event.sourceUrl || event.externalLink || '', + scheduleMode: hasShowtimes ? 'showtimes' : 'single', + startTimeLocal: isoToDatetimeLocal(event.start_time), + endTimeLocal: isoToDatetimeLocal(event.end_time), + timeSlots: hasShowtimes ? timeSlots : [], + ingestStatus: event.ingestStatus || 'published', + tags: Array.isArray(event.tags) ? [...event.tags] : [], + movie: event.movie || null, + }; +} + +export function catalogEditDraftToOverrides(draft) { + const useShowtimes = draft.scheduleMode === 'showtimes'; + const normalizedSlots = useShowtimes ? normalizeManualShowtimeSlots(draft.timeSlots) : []; + const window = useShowtimes + ? deriveEventWindowFromShowtimes(normalizedSlots) + : { + start_time: datetimeLocalToIso(draft.startTimeLocal), + end_time: datetimeLocalToIso(draft.endTimeLocal), + }; + + return { + name: draft.name?.trim() || '', + hostName: draft.organizerName?.trim() || '', + location: draft.location?.trim() || '', + description: draft.description?.trim() || '', + image: draft.imageUrl?.trim() || '', + sourceUrl: draft.sourceUrl?.trim() || '', + start_time: window.start_time || undefined, + end_time: window.end_time || undefined, + ingestStatus: draft.ingestStatus, + tags: Array.isArray(draft.tags) ? draft.tags : [], + ...(useShowtimes ? { timeSlots: normalizedSlots } : { timeSlots: [] }), + ...(draft.movie ? { movie: draft.movie } : {}), + }; +} + +function validateCatalogEditDraft(draft) { + if (!draft.name?.trim()) return 'Event title is required.'; + if (!draft.organizerName?.trim()) return 'Organizer is required.'; + if (!draft.location?.trim()) return 'Location is required.'; + if (!draft.tags?.length) return 'Select at least one catalog tag.'; + if (draft.scheduleMode === 'showtimes') { + const slots = normalizeManualShowtimeSlots(draft.timeSlots); + if (!slots.length) return 'Add at least one showtime with a valid start.'; + } else if (!datetimeLocalToIso(draft.startTimeLocal)) { + return 'Start time is required.'; + } + return null; +} + +function PivotCatalogEventEditModal({ + open, + event, + onClose, + catalogTags, + cityLabel, + batchWeek, + onSave, + saving, + onSuggestTags, + tagSuggestLoading, +}) { + const [draft, setDraft] = useState(null); + const [formError, setFormError] = useState(''); + + useEffect(() => { + if (open && event) { + setDraft(catalogEventToEditDraft(event)); + setFormError(''); + } else if (!open) { + setDraft(null); + setFormError(''); + } + }, [open, event]); + + const patchDraft = useCallback((patch) => { + setDraft((current) => (current ? { ...current, ...patch } : current)); + setFormError(''); + }, []); + + const deckPreview = useMemo(() => { + if (!draft) return null; + + const useShowtimes = draft.scheduleMode === 'showtimes'; + const normalizedSlots = useShowtimes ? normalizeManualShowtimeSlots(draft.timeSlots) : []; + const window = useShowtimes + ? deriveEventWindowFromShowtimes(normalizedSlots) + : { + start_time: datetimeLocalToIso(draft.startTimeLocal), + end_time: datetimeLocalToIso(draft.endTimeLocal), + }; + + return { + title: draft.name, + hostName: draft.organizerName, + whenLabel: formatPivotDeckWhen(window.start_time, window.end_time), + locationLabel: draft.location, + description: draft.description, + imageUrl: draft.imageUrl?.trim() || undefined, + }; + }, [draft]); + + const setScheduleMode = useCallback((mode) => { + setDraft((current) => { + if (!current || current.scheduleMode === mode) return current; + + if (mode === 'showtimes') { + const seedStart = current.startTimeLocal || toDatetimeLocalValue(new Date()); + return { + ...current, + scheduleMode: 'showtimes', + timeSlots: current.timeSlots?.length + ? current.timeSlots + : [createEmptyManualShowtimeSlot(seedStart)], + }; + } + + return { + ...current, + scheduleMode: 'single', + timeSlots: [], + }; + }); + setFormError(''); + }, []); + + const addShowtime = useCallback(() => { + setDraft((current) => { + if (!current) return current; + const last = current.timeSlots?.[current.timeSlots.length - 1]; + const nextStart = last?.startTimeLocal || current.startTimeLocal || ''; + return { + ...current, + scheduleMode: 'showtimes', + timeSlots: [...(current.timeSlots || []), createEmptyManualShowtimeSlot(nextStart)], + }; + }); + setFormError(''); + }, []); + + const updateShowtime = useCallback((slotKey, patch) => { + setDraft((current) => ({ + ...current, + timeSlots: (current.timeSlots || []).map((slot) => + slot.key === slotKey ? { ...slot, ...patch } : slot, + ), + })); + setFormError(''); + }, []); + + const removeShowtime = useCallback((slotKey) => { + setDraft((current) => ({ + ...current, + timeSlots: (current.timeSlots || []).filter((slot) => slot.key !== slotKey), + })); + setFormError(''); + }, []); + + const handleMovieChange = useCallback( + (movie) => { + patchDraft(applyMovieMetadataToDraft(movie)); + }, + [patchDraft], + ); + + const handleSave = useCallback(async () => { + if (!draft) return; + + const error = validateCatalogEditDraft(draft); + if (error) { + setFormError(error); + return; + } + + const ok = await onSave?.(draft); + if (ok) { + onClose?.(); + } + }, [draft, onClose, onSave]); + + if (!open || !event) { + return null; + } + + return ( + + {draft ? ( +
+
+
+

+ Edit catalog event +

+

+ {cityLabel || 'No city'} · {batchWeek} + {event?.source ? ` · ${event.source}` : ''} +

+
+
+ +
+
+
+

Event

+ +
+ + +
+