diff --git a/.DS_Store b/.DS_Store index 1928ec1f..b1e51a31 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/backend/routes/adminRoutes.js b/backend/routes/adminRoutes.js index 418fe775..ee2e1c08 100644 --- a/backend/routes/adminRoutes.js +++ b/backend/routes/adminRoutes.js @@ -4,12 +4,19 @@ const { randomUUID } = require('crypto'); const router = express.Router(); const { verifyToken, authorizeRoles } = require('../middlewares/verifyToken'); const { requireAdmin } = require('../middlewares/requireAdmin'); +const { requirePlatformAdmin } = require('../middlewares/requirePlatformAdmin'); const mongoose = require('mongoose'); const { connectToDatabase } = require('../connectionsManager'); const { getConnections, disconnectSocket, disconnectAll } = require('../socket'); const { createSession } = require('../utilities/sessionUtils'); const { getCookieDomain } = require('../utilities/cookieUtils'); const getGlobalModels = require('../services/getGlobalModelService'); +const { + listPlatformAdmins, + nominatePlatformAdmin, + approvePlatformAdminInvite, + revokePlatformAdminInvite, +} = require('../services/platformAdminInviteService'); const { DEFAULT_TENANTS, normalizeTenantRows, @@ -242,22 +249,14 @@ router.get('/admin/user/:userId/analytics', verifyToken, requireAdmin, async (re }); /** - * GET /admin/platform-admins – list platform admins (GlobalUsers with platform_admin role) + * GET /admin/platform-admins – list active platform admins + open nominations + * Response: { admins, nominations } (also accepts legacy clients that only read data as array — see note). + * Platform Admin UI uses this shape; campus Admin page still works if it reads data.admins || data. */ router.get('/admin/platform-admins', verifyToken, requireAdmin, async (req, res) => { try { - const { PlatformRole, GlobalUser } = getGlobalModels(req, 'PlatformRole', 'GlobalUser'); - const roles = await PlatformRole.find({ roles: 'platform_admin' }).lean(); - const globalUserIds = roles.map(r => r.globalUserId); - const users = await GlobalUser.find({ _id: { $in: globalUserIds } }).select('email name picture createdAt').lean(); - const byId = users.reduce((acc, u) => { acc[u._id.toString()] = u; return acc; }, {}); - const list = roles.map(r => ({ - globalUserId: r.globalUserId, - email: byId[r.globalUserId.toString()]?.email, - name: byId[r.globalUserId.toString()]?.name, - picture: byId[r.globalUserId.toString()]?.picture, - })); - res.json({ success: true, data: list }); + const data = await listPlatformAdmins(req); + res.json({ success: true, data }); } catch (err) { console.error('GET /admin/platform-admins failed:', err); res.status(500).json({ success: false, message: err.message }); @@ -265,7 +264,90 @@ router.get('/admin/platform-admins', verifyToken, requireAdmin, async (req, res) }); /** - * POST /admin/platform-admins – add platform admin by email or globalUserId + * POST /admin/platform-admins/nominate – nominate by email (no live role until Approve) + * Body: { email: string } + */ +router.post( + '/admin/platform-admins/nominate', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await nominatePlatformAdmin(req, { email: req.body?.email }); + 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) { + console.error('POST /admin/platform-admins/nominate failed:', err); + return res.status(500).json({ success: false, message: err.message }); + } + }, +); + +/** + * POST /admin/platform-admins/nominations/:inviteId/approve – grant platform_admin + */ +router.post( + '/admin/platform-admins/nominations/:inviteId/approve', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await approvePlatformAdminInvite(req, { + inviteId: req.params.inviteId, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + console.log( + `Platform admin approved: ${result.data.email} by ${req.user.userId || req.user.globalUserId}`, + ); + return res.status(200).json({ success: true, data: result.data }); + } catch (err) { + console.error('POST /admin/platform-admins/nominations/:inviteId/approve failed:', err); + return res.status(500).json({ success: false, message: err.message }); + } + }, +); + +/** + * DELETE /admin/platform-admins/nominations/:inviteId – cancel open nomination + */ +router.delete( + '/admin/platform-admins/nominations/:inviteId', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await revokePlatformAdminInvite(req, { + inviteId: req.params.inviteId, + }); + 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) { + console.error('DELETE /admin/platform-admins/nominations/:inviteId failed:', err); + return res.status(500).json({ success: false, message: err.message }); + } + }, +); + +/** + * POST /admin/platform-admins – add platform admin by email or globalUserId (legacy campus Admin) * Body: { email?: string, globalUserId?: string } */ router.post('/admin/platform-admins', verifyToken, requireAdmin, async (req, res) => { diff --git a/backend/routes/pivotAdminRoutes.js b/backend/routes/pivotAdminRoutes.js index b7eb797b..ecd10e64 100644 --- a/backend/routes/pivotAdminRoutes.js +++ b/backend/routes/pivotAdminRoutes.js @@ -35,6 +35,7 @@ const { wipeUserWeekIntents, } = require('../services/pivotTenantJourneyService'); const { getTenantOpsBundle } = require('../services/pivotTenantOpsService'); +const { getPivotExplorePreview } = require('../services/pivotExploreService'); const { getPivotRetention } = require('../services/pivotRetentionService'); const { listPivotLabEvents } = require('../services/pivotLabEventsService'); const { @@ -53,6 +54,7 @@ const { listPivotTags, seedPivotTagCatalog } = require('../services/pivotTagCata const { suggestPivotEventTags, suggestPivotEventTagsBatch, + suggestAndApplyPivotEventTags, } = require('../services/pivotTagSuggestService'); const { searchTmdbMovies, @@ -286,6 +288,46 @@ router.get( }, ); +router.get( + '/tenants/:tenantKey/explore', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await getPivotExplorePreview(req, { + tenantKey: req.params.tenantKey, + batchWeek: req.query?.batchWeek, + limit: req.query?.limit, + offset: req.query?.offset, + tags: req.query?.tags, + night: req.query?.night, + friendsOnly: req.query?.friendsOnly, + excludePassed: req.query?.excludePassed, + q: req.query?.q, + sort: req.query?.sort, + }); + 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/tenants/:tenantKey/explore', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to load explore preview.', + }); + } + }, +); + router.get( '/tenants/:tenantKey/events/performance', verifyToken, @@ -1013,6 +1055,41 @@ router.post('/ingest/suggest-tags', verifyToken, requirePlatformAdmin, async (re } }); +router.post( + '/ingest/suggest-and-apply-tags', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await suggestAndApplyPivotEventTags(req, { + tenantKey: req.body?.tenantKey, + eventIds: req.body?.eventIds, + onlyTagless: req.body?.onlyTagless, + concurrency: req.body?.concurrency, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + data: result.data, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('POST /admin/pivot/ingest/suggest-and-apply-tags', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to suggest and apply pivot tags.', + }); + } + }, +); + router.get('/tmdb/search', verifyToken, requirePlatformAdmin, async (req, res) => { try { const result = await searchTmdbMovies({ diff --git a/backend/routes/pivotRoutes.js b/backend/routes/pivotRoutes.js index c2b78f99..ef3647a1 100644 --- a/backend/routes/pivotRoutes.js +++ b/backend/routes/pivotRoutes.js @@ -2,6 +2,7 @@ const express = require('express'); const { body, validationResult } = require('express-validator'); const { validateReferralCode, redeemReferralCode } = require('../services/pivotReferralCodeService'); const { getPivotFeed, getPivotEventFriends } = require('../services/pivotFeedService'); +const { getPivotExplore } = require('../services/pivotExploreService'); const { recordFeedAction, recordExternalOpen, @@ -9,6 +10,7 @@ const { getWeekRecap, resetWeekActions, } = require('../services/pivotIntentService'); +const { recordPivotImpressions, recordPivotMicroInteractions } = require('../services/pivotInteractionService'); const { getPendingEventFeedback, submitEventFeedback, @@ -270,11 +272,65 @@ router.get('/config', verifyToken, async (req, res) => { } }); +router.get('/explore', verifyToken, async (req, res) => { + try { + const result = await getPivotExplore(req, { + batchWeek: req.query.batchWeek, + limit: req.query.limit, + offset: req.query.offset, + tags: req.query.tags, + night: req.query.night, + friendsOnly: req.query.friendsOnly, + excludePassed: req.query.excludePassed, + q: req.query.q, + sort: req.query.sort, + }); + if (result.error) { + logPivotServiceReject('GET /pivot/explore', result, req, { + batchWeek: req.query.batchWeek, + limit: req.query.limit, + offset: req.query.offset, + tags: req.query.tags, + night: req.query.night, + friendsOnly: req.query.friendsOnly, + excludePassed: req.query.excludePassed, + q: req.query.q, + sort: req.query.sort, + }); + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + logPivotServiceSuccess('GET /pivot/explore', req, { + batchWeek: result.data?.batchWeek, + total: result.data?.total, + eventCount: result.data?.events?.length ?? 0, + limit: result.data?.limit, + offset: result.data?.offset, + }); + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('GET /pivot/explore', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to load pivot explore.', + }); + } +}); + router.get('/feed', verifyToken, async (req, res) => { try { const result = await getPivotFeed(req, { batchWeek: req.query.batchWeek, excludeEventIds: req.query.excludeEventIds, + refresh: req.query.refresh, }); if (result.error) { logPivotServiceReject('GET /pivot/feed', result, req, { @@ -340,6 +396,123 @@ router.post('/feed/action', verifyToken, async (req, res) => { } }); +/** + * Batch durable card impressions (Task 1.2). Fire-and-forget writes — + * always returns quickly; failures are logged server-side and never block swipe. + */ +router.post( + '/interactions/impressions', + [ + verifyToken, + body('impressions').isArray().withMessage('impressions must be an array'), + body('batchWeek').optional().isString().trim(), + body('sessionId').optional().isString().trim(), + body('requestId').optional().isString().trim(), + body('rankerVersion').optional().isString().trim(), + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: 'Validation errors', + errors: errors.array(), + code: 'VALIDATION_ERROR', + }); + } + + const result = recordPivotImpressions(req, req.body); + if (result.error) { + logPivotServiceReject('POST /pivot/interactions/impressions', result, req, { + received: Array.isArray(req.body?.impressions) + ? req.body.impressions.length + : 0, + }); + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + logPivotServiceSuccess('POST /pivot/interactions/impressions', req, { + accepted: result.data?.accepted, + skipped: result.data?.skipped, + received: result.data?.received, + }); + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('POST /pivot/interactions/impressions', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to record impressions.', + }); + } + }, +); + +/** + * Batch dwell / detail_open micro-intent rows (Task 4.3). + */ +router.post( + '/interactions/micro', + [ + verifyToken, + body('interactions').isArray().withMessage('interactions must be an array'), + body('batchWeek').optional().isString().trim(), + body('rankerVersion').optional().isString().trim(), + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: 'Validation errors', + errors: errors.array(), + code: 'VALIDATION_ERROR', + }); + } + + const result = recordPivotMicroInteractions(req, req.body); + if (result.error) { + logPivotServiceReject('POST /pivot/interactions/micro', result, req, { + received: Array.isArray(req.body?.interactions) + ? req.body.interactions.length + : 0, + }); + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + logPivotServiceSuccess('POST /pivot/interactions/micro', req, { + accepted: result.data?.accepted, + skipped: result.data?.skipped, + received: result.data?.received, + }); + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('POST /pivot/interactions/micro', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to record micro interactions.', + }); + } + }, +); + router.post('/intent/:eventId/external-open', verifyToken, async (req, res) => { try { const result = await recordExternalOpen(req, req.params.eventId, req.body); diff --git a/backend/schemas/pivotCurationJob.js b/backend/schemas/pivotCurationJob.js index 98422393..32193841 100644 --- a/backend/schemas/pivotCurationJob.js +++ b/backend/schemas/pivotCurationJob.js @@ -11,6 +11,19 @@ const lastRunStatsSchema = new mongoose.Schema( skipped: { type: Number, default: 0 }, failed: { type: Number, default: 0 }, message: { type: String, default: null, trim: true }, + byBatchWeek: { type: mongoose.Schema.Types.Mixed, default: null }, + }, + { _id: false }, +); + +const lastRunEventSchema = new mongoose.Schema( + { + eventId: { type: String, default: null, trim: true }, + name: { type: String, default: null, trim: true }, + batchWeek: { type: String, default: null, trim: true }, + sourceUrl: { type: String, default: null, trim: true }, + ingestStatus: { type: String, default: null, trim: true }, + updated: { type: Boolean, default: false }, }, { _id: false }, ); @@ -64,6 +77,11 @@ const pivotCurationJobSchema = new mongoose.Schema( type: lastRunStatsSchema, default: null, }, + /** Upserted events from the most recent completed/failed crawl (capped). */ + lastRunEvents: { + type: [lastRunEventSchema], + default: [], + }, createdBy: { type: String, default: null, diff --git a/backend/schemas/pivotCurationRun.js b/backend/schemas/pivotCurationRun.js index b2b8cb51..1d7e7071 100644 --- a/backend/schemas/pivotCurationRun.js +++ b/backend/schemas/pivotCurationRun.js @@ -28,6 +28,19 @@ const runFailureSchema = new mongoose.Schema( { _id: false }, ); +/** Upserted catalog rows from a crawl — capped for UI polling payloads. */ +const runEventSchema = new mongoose.Schema( + { + eventId: { type: String, default: null, trim: true }, + name: { type: String, default: null, trim: true }, + batchWeek: { type: String, default: null, trim: true }, + sourceUrl: { type: String, default: null, trim: true }, + ingestStatus: { type: String, default: null, trim: true }, + updated: { type: Boolean, default: false }, + }, + { _id: false }, +); + const pivotCurationRunSchema = new mongoose.Schema( { tenantKey: { @@ -102,6 +115,10 @@ const pivotCurationRunSchema = new mongoose.Schema( type: [runFailureSchema], default: [], }, + events: { + type: [runEventSchema], + default: [], + }, error: { type: String, default: null, diff --git a/backend/schemas/pivotDeckSnapshot.js b/backend/schemas/pivotDeckSnapshot.js new file mode 100644 index 00000000..8b06e439 --- /dev/null +++ b/backend/schemas/pivotDeckSnapshot.js @@ -0,0 +1,49 @@ +const mongoose = require('mongoose'); +const { isValidIsoWeek } = require('../utilities/pivotIsoWeek'); + +/** + * Frozen weekly deck order per user for offline eval and future shadow ranker mode. + * Written on first GET /pivot/feed per user/batchWeek; Explore does not read this. + * + * @see Meridian-Mintlify/strategy/just-go-explore-embeddings-plan.mdx Task 6.1 + */ +const pivotDeckSnapshotSchema = new mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + batchWeek: { + type: String, + required: true, + trim: true, + validate: { + validator(value) { + return isValidIsoWeek(value); + }, + message: 'batchWeek must be ISO week format YYYY-Www', + }, + }, + orderedEventIds: { + type: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + }, + ], + required: true, + default: [], + }, + rankerVersion: { + type: String, + required: true, + trim: true, + }, + }, + { timestamps: true }, +); + +pivotDeckSnapshotSchema.index({ userId: 1, batchWeek: 1 }, { unique: true }); + +module.exports = pivotDeckSnapshotSchema; diff --git a/backend/schemas/pivotInteraction.js b/backend/schemas/pivotInteraction.js new file mode 100644 index 00000000..670823fa --- /dev/null +++ b/backend/schemas/pivotInteraction.js @@ -0,0 +1,145 @@ +const mongoose = require('mongoose'); +const { isValidIsoWeek } = require('../utilities/pivotIsoWeek'); + +/** + * Append-only Pivot interaction log (tenant DB). + * Product state stays on PivotEventIntent; this collection is training / eval / Explore telemetry. + * + * @see Meridian-Mintlify/strategy/just-go-explore-embeddings-plan.mdx Task 1.1 + */ + +const PIVOT_INTERACTION_SURFACES = Object.freeze([ + 'deck', + 'explore', + 'recap', + 'plans', + 'detail', +]); + +const PIVOT_INTERACTION_RETRIEVALS = Object.freeze([ + 'weekly_batch', + 'filter', + 'search', + 'similar', + 'for_you_rail', + 'friends_rail', + 'tag_rail', + 'curated_rail', +]); + +const PIVOT_INTERACTION_TYPES = Object.freeze([ + 'impression', + 'dwell', + 'detail_open', + 'pass', + 'interested', + 'external_open', + 'registered', + 'rating', + 'explore_request', +]); + +const pivotInteractionSchema = new mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + /** Null for request-level rows (e.g. explore_request). */ + eventId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + default: null, + }, + batchWeek: { + type: String, + required: true, + trim: true, + validate: { + validator(value) { + return isValidIsoWeek(value); + }, + message: 'batchWeek must be ISO week format YYYY-Www', + }, + }, + surface: { + type: String, + enum: PIVOT_INTERACTION_SURFACES, + required: true, + default: 'deck', + }, + retrieval: { + type: String, + enum: PIVOT_INTERACTION_RETRIEVALS, + default: 'weekly_batch', + }, + type: { + type: String, + enum: PIVOT_INTERACTION_TYPES, + required: true, + }, + rankInFeed: { + type: Number, + min: 0, + default: null, + }, + /** Dwell duration in milliseconds (clamped by writers). */ + ms: { + type: Number, + min: 0, + default: null, + }, + section: { + type: String, + default: null, + trim: true, + }, + query: { + type: String, + default: null, + trim: true, + }, + filters: { + type: mongoose.Schema.Types.Mixed, + default: null, + }, + seedEventId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + default: null, + }, + requestId: { + type: String, + default: null, + trim: true, + }, + rankerVersion: { + type: String, + default: null, + trim: true, + }, + sessionId: { + type: String, + default: null, + trim: true, + }, + rating: { + type: Number, + min: 1, + max: 5, + default: null, + }, + }, + { timestamps: true }, +); + +pivotInteractionSchema.index({ userId: 1, batchWeek: 1, createdAt: 1 }); +pivotInteractionSchema.index({ eventId: 1, type: 1, createdAt: 1 }); +pivotInteractionSchema.index({ batchWeek: 1, surface: 1, type: 1 }); +pivotInteractionSchema.index({ requestId: 1 }, { sparse: true }); + +module.exports = pivotInteractionSchema; +module.exports.PIVOT_INTERACTION_SURFACES = PIVOT_INTERACTION_SURFACES; +module.exports.PIVOT_INTERACTION_RETRIEVALS = PIVOT_INTERACTION_RETRIEVALS; +module.exports.PIVOT_INTERACTION_TYPES = PIVOT_INTERACTION_TYPES; diff --git a/backend/schemas/platformAdminInvite.js b/backend/schemas/platformAdminInvite.js new file mode 100644 index 00000000..0cb06b9e --- /dev/null +++ b/backend/schemas/platformAdminInvite.js @@ -0,0 +1,58 @@ +const mongoose = require('mongoose'); + +const PLATFORM_ADMIN_INVITE_STATUSES = [ + 'pending_signup', + 'ready_for_approval', + 'approved', + 'revoked', +]; + +const platformAdminInviteSchema = new mongoose.Schema( + { + email: { + type: String, + required: true, + trim: true, + lowercase: true, + }, + status: { + type: String, + required: true, + enum: PLATFORM_ADMIN_INVITE_STATUSES, + default: 'pending_signup', + }, + globalUserId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'GlobalUser', + required: false, + default: null, + }, + invitedBy: { + type: mongoose.Schema.Types.ObjectId, + required: false, + default: null, + }, + approvedBy: { + type: mongoose.Schema.Types.ObjectId, + required: false, + default: null, + }, + approvedAt: { + type: Date, + required: false, + default: null, + }, + revokedAt: { + type: Date, + required: false, + default: null, + }, + }, + { timestamps: true }, +); + +platformAdminInviteSchema.index({ email: 1 }); +platformAdminInviteSchema.index({ status: 1, email: 1 }); + +module.exports = platformAdminInviteSchema; +module.exports.PLATFORM_ADMIN_INVITE_STATUSES = PLATFORM_ADMIN_INVITE_STATUSES; diff --git a/backend/services/authGlobalService.js b/backend/services/authGlobalService.js index 068c39d7..fd6b7c99 100644 --- a/backend/services/authGlobalService.js +++ b/backend/services/authGlobalService.js @@ -34,6 +34,24 @@ function globalUserFromSource(source) { return doc; } +async function syncPlatformAdminInviteReadiness(req, globalUser) { + if (!globalUser?._id || !globalUser?.email) return; + try { + const { + markPlatformAdminInvitesReadyForEmail, + } = require('./platformAdminInviteService'); + await markPlatformAdminInvitesReadyForEmail(req, { + email: globalUser.email, + globalUserId: globalUser._id, + }); + } catch (inviteErr) { + console.warn( + '[authGlobal] markPlatformAdminInvitesReadyForEmail failed:', + inviteErr?.message || inviteErr, + ); + } +} + /** * Get or create GlobalUser by email and optional provider ids. * @param {object} req - request with req.globalDb @@ -58,6 +76,7 @@ async function getOrCreateGlobalUser(req, source) { Object.assign(globalUser, updates); await globalUser.save(); } + await syncPlatformAdminInviteReadiness(req, globalUser); return globalUser; } @@ -79,11 +98,13 @@ async function getOrCreateGlobalUser(req, source) { Object.assign(globalUser, updates); await globalUser.save(); } + await syncPlatformAdminInviteReadiness(req, globalUser); return globalUser; } globalUser = new GlobalUser(globalUserFromSource(source)); await globalUser.save(); + await syncPlatformAdminInviteReadiness(req, globalUser); return globalUser; } diff --git a/backend/services/getGlobalModelService.js b/backend/services/getGlobalModelService.js index 8fc7fdaf..3720a24f 100644 --- a/backend/services/getGlobalModelService.js +++ b/backend/services/getGlobalModelService.js @@ -1,5 +1,6 @@ const globalUserSchema = require('../schemas/globalUser'); const platformRoleSchema = require('../schemas/platformRole'); +const platformAdminInviteSchema = require('../schemas/platformAdminInvite'); const tenantMembershipSchema = require('../schemas/tenantMembership'); const globalSessionSchema = require('../schemas/globalSession'); const tenantConfigSchema = require('../schemas/tenantConfig'); @@ -18,7 +19,7 @@ const pivotCurationRunSchema = require('../schemas/pivotCurationRun'); * Requires req.globalDb to be set (see app.js middleware). * * @param {object} req - request with req.globalDb - * @param {...string} names - model names: 'GlobalUser', 'PlatformRole', 'TenantMembership', 'Session', 'TenantConfig', 'PivotReferralCode', 'PivotReferralRedemption', 'PivotWeeklySnapshot', 'PivotLabNotes', 'PivotTagCatalog', 'PivotPosterTemplate', 'PivotCurationJob', 'PivotCurationRun' + * @param {...string} names - model names including 'GlobalUser', 'PlatformRole', 'PlatformAdminInvite', … * @returns {object} map of requested models */ const getGlobalModels = (req, ...names) => { @@ -30,6 +31,11 @@ const getGlobalModels = (req, ...names) => { const models = { GlobalUser: db.model('GlobalUser', globalUserSchema, 'global_users'), PlatformRole: db.model('PlatformRole', platformRoleSchema, 'platform_roles'), + PlatformAdminInvite: db.model( + 'PlatformAdminInvite', + platformAdminInviteSchema, + 'platform_admin_invites' + ), TenantMembership: db.model('TenantMembership', tenantMembershipSchema, 'tenant_memberships'), Session: db.model('Session', globalSessionSchema, 'sessions'), TenantConfig: db.model('TenantConfig', tenantConfigSchema, 'tenant_config'), diff --git a/backend/services/getModelService.js b/backend/services/getModelService.js index 84126656..401d5f75 100644 --- a/backend/services/getModelService.js +++ b/backend/services/getModelService.js @@ -66,6 +66,8 @@ const analyticsEventSchema = require('../events/schemas/analyticsEvent'); const eventQRSchema = require('../events/schemas/eventQR'); const pivotEventIntentSchema = require('../schemas/pivotEventIntent'); const pivotBatchSchema = require('../schemas/pivotBatch'); +const pivotInteractionSchema = require('../schemas/pivotInteraction'); +const pivotDeckSnapshotSchema = require('../schemas/pivotDeckSnapshot'); const registeredConnections = new WeakSet(); const MODEL_DEFINITIONS = Object.freeze({ BadgeGrant: { modelName: 'BadgeGrant', schema: badgeGrantSchema, collection: 'badgegrants' }, @@ -138,6 +140,16 @@ const MODEL_DEFINITIONS = Object.freeze({ schema: pivotBatchSchema, collection: 'pivotBatches', }, + PivotInteraction: { + modelName: 'PivotInteraction', + schema: pivotInteractionSchema, + collection: 'pivotInteractions', + }, + PivotDeckSnapshot: { + modelName: 'PivotDeckSnapshot', + schema: pivotDeckSnapshotSchema, + collection: 'pivotDeckSnapshots', + }, ResourcesConfig: { modelName: 'ResourcesConfig', schema: resourcesConfigSchema, collection: 'resourcesConfigs' }, ShuttleConfig: { modelName: 'ShuttleConfig', schema: shuttleConfigSchema, collection: 'shuttleConfigs' }, NoticeConfig: { modelName: 'NoticeConfig', schema: noticeConfigSchema, collection: 'noticeConfigs' }, diff --git a/backend/services/pivotConfigService.js b/backend/services/pivotConfigService.js index 88f055db..65345088 100644 --- a/backend/services/pivotConfigService.js +++ b/backend/services/pivotConfigService.js @@ -1,9 +1,11 @@ const { getTenantByKey } = require('./tenantConfigService'); -const { isValidIsoWeek, toIsoWeek } = require('../utilities/pivotIsoWeek'); +const { isValidIsoWeek } = require('../utilities/pivotIsoWeek'); const { describePivotDropSchedule, isPivotTenant, resolvePivotDropInstant, + resolvePivotLiveBatchWeek, + resolvePivotUpcomingDropBatchWeek, } = require('../utilities/pivotDropSchedule'); function buildDropSchedulePayload(tenant, batchWeek, now = new Date()) { @@ -39,16 +41,22 @@ async function getPivotConfig(req, options = {}) { } const now = options.now || new Date(); - const batchWeek = options.batchWeek?.trim() || toIsoWeek(now); - if (options.batchWeek && !isValidIsoWeek(batchWeek)) { + const liveBatchWeek = + options.batchWeek?.trim() || resolvePivotLiveBatchWeek(tenant, now); + if (options.batchWeek && !isValidIsoWeek(liveBatchWeek)) { return { error: 'batchWeek must be ISO format YYYY-Www.', status: 400, code: 'INVALID_BATCH_WEEK' }; } + const dropScheduleBatchWeek = options.batchWeek?.trim() + ? liveBatchWeek + : resolvePivotUpcomingDropBatchWeek(tenant, now); + return { data: { tenantKey: tenant.tenantKey, cityDisplayName: tenant.location || tenant.name || tenant.tenantKey, - dropSchedule: buildDropSchedulePayload(tenant, batchWeek, now), + liveBatchWeek, + dropSchedule: buildDropSchedulePayload(tenant, dropScheduleBatchWeek, now), }, }; } diff --git a/backend/services/pivotCurationJobService.js b/backend/services/pivotCurationJobService.js index 22d21878..cc205733 100644 --- a/backend/services/pivotCurationJobService.js +++ b/backend/services/pivotCurationJobService.js @@ -25,6 +25,16 @@ function serializeCurationJob(doc) { lastRunAt: row.lastRunAt || null, lastRunStatus: row.lastRunStatus || null, lastRunStats: row.lastRunStats || null, + lastRunEvents: Array.isArray(row.lastRunEvents) + ? row.lastRunEvents.map((event) => ({ + eventId: event?.eventId ? String(event.eventId) : null, + name: event?.name || null, + batchWeek: event?.batchWeek || null, + sourceUrl: event?.sourceUrl || null, + ingestStatus: event?.ingestStatus || null, + updated: Boolean(event?.updated), + })) + : [], createdBy: row.createdBy || null, createdAt: row.createdAt || null, updatedAt: row.updatedAt || null, diff --git a/backend/services/pivotCurationRunService.js b/backend/services/pivotCurationRunService.js index ab77c136..08f7b014 100644 --- a/backend/services/pivotCurationRunService.js +++ b/backend/services/pivotCurationRunService.js @@ -14,6 +14,7 @@ const { resolvePivotDropInstant } = require('../utilities/pivotDropSchedule'); const { logPivot } = require('../utilities/pivotLogger'); const MAX_FAILURES_STORED = 50; +const MAX_EVENTS_STORED = 100; function actorFromReq(req) { return req?.user?.email || req?.user?.globalUserId || req?.user?.userId || null; @@ -35,6 +36,17 @@ function parseRunId(runId) { return { runId: id }; } +function serializeRunEvent(row) { + return { + eventId: row?.eventId ? String(row.eventId) : null, + name: row?.name || null, + batchWeek: row?.batchWeek || null, + sourceUrl: row?.sourceUrl || null, + ingestStatus: row?.ingestStatus || null, + updated: Boolean(row?.updated), + }; +} + function serializeCurationRun(doc) { const row = doc?.toObject ? doc.toObject() : doc; return { @@ -66,6 +78,7 @@ function serializeCurationRun(doc) { code: f.code || null, })) : [], + events: Array.isArray(row.events) ? row.events.map(serializeRunEvent) : [], error: row.error || null, errorCode: row.errorCode || null, createdBy: row.createdBy || null, @@ -158,7 +171,7 @@ async function updateRunDoc(reqLike, runId, patch) { ).lean(); } -async function syncJobLastRun(reqLike, jobId, { status, stats, finishedAt }) { +async function syncJobLastRun(reqLike, jobId, { status, stats, finishedAt, events }) { const { PivotCurationJob } = getGlobalModels(reqLike, 'PivotCurationJob'); await PivotCurationJob.findByIdAndUpdate(jobId, { $set: { @@ -170,7 +183,11 @@ async function syncJobLastRun(reqLike, jobId, { status, stats, finishedAt }) { skipped: stats.skipped || 0, failed: stats.failed || 0, message: stats.message || null, + byBatchWeek: stats.byBatchWeek || null, }, + lastRunEvents: Array.isArray(events) + ? events.slice(0, MAX_EVENTS_STORED).map(serializeRunEvent) + : [], }, }); } @@ -253,8 +270,13 @@ async function upsertDiscoveredEntry( upserted: true, updated: Boolean(result.data?.updated), eventId: result.data?.event?._id || result.data?.event?.id || null, + name: draft.name || result.data?.event?.name || null, batchWeek: result.data?.batchWeek || result.data?.event?.batchWeek || null, batchWeekSource: result.data?.batchWeekSource || null, + ingestStatus: + result.data?.event?.customFields?.pivot?.ingestStatus || + ingestStatus || + null, sourceUrl, }; } @@ -321,6 +343,7 @@ async function executeCurationRun(runId) { status: 'failed', stats, finishedAt, + events: [], }); return; } @@ -339,6 +362,7 @@ async function executeCurationRun(runId) { status: 'failed', stats, finishedAt, + events: [], }); return; } @@ -364,6 +388,7 @@ async function executeCurationRun(runId) { status: 'failed', stats, finishedAt, + events: [], }); return; } @@ -408,6 +433,7 @@ async function executeCurationRun(runId) { } const failures = []; + const events = []; const defaultTags = Array.isArray(job.defaultTags) ? job.defaultTags : []; const ensuredWeeks = new Set(forceBatchWeek ? [run.batchWeek] : []); @@ -435,6 +461,16 @@ async function executeCurationRun(runId) { ensuredWeeks.add(week); } } + if (events.length < MAX_EVENTS_STORED) { + events.push({ + eventId: outcome.eventId ? String(outcome.eventId) : null, + name: outcome.name || null, + batchWeek: week || null, + sourceUrl: outcome.sourceUrl || null, + ingestStatus: outcome.ingestStatus || null, + updated: Boolean(outcome.updated), + }); + } } else if (outcome.skipped) { stats.skipped += 1; if (failures.length < MAX_FAILURES_STORED) { @@ -476,7 +512,7 @@ async function executeCurationRun(runId) { // Persist progress periodically so UI polling sees movement. if ((stats.upserted + stats.skipped + stats.failed) % 10 === 0) { - await updateRunDoc(workerReq, runId, { stats, failures }); + await updateRunDoc(workerReq, runId, { stats, failures, events }); } } @@ -497,10 +533,16 @@ async function executeCurationRun(runId) { finishedAt, stats, failures, + events, error: null, errorCode: null, }); - await syncJobLastRun(workerReq, jobId, { status, stats, finishedAt }); + await syncJobLastRun(workerReq, jobId, { + status, + stats, + finishedAt, + events, + }); logPivot('info', 'curation run completed', { runId: String(runId), @@ -534,6 +576,7 @@ async function executeCurationRun(runId) { status: 'failed', stats, finishedAt, + events: [], }); } } catch (persistErr) { @@ -616,6 +659,7 @@ async function startCurationJobRun(req, options = {}) { : 'Events assigned to the ISO week of their start date.', ), failures: [], + events: [], }); await PivotCurationJob.findByIdAndUpdate(job._id, { @@ -623,6 +667,7 @@ async function startCurationJobRun(req, options = {}) { lastRunAt: new Date(), lastRunStatus: 'queued', lastRunStats: emptyStats(), + lastRunEvents: [], }, }); diff --git a/backend/services/pivotDeckSnapshotService.js b/backend/services/pivotDeckSnapshotService.js new file mode 100644 index 00000000..fbf6c9a9 --- /dev/null +++ b/backend/services/pivotDeckSnapshotService.js @@ -0,0 +1,159 @@ +const mongoose = require('mongoose'); +const getModels = require('./getModelService'); +const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger'); +const { isValidIsoWeek } = require('../utilities/pivotIsoWeek'); + +const DECK_SNAPSHOT_ADMIN_ROLES = new Set(['admin', 'developer']); + +function normalizeDeckSnapshotRefresh(rawRefresh, userRoles = []) { + const wantsRefresh = + rawRefresh === true || + rawRefresh === 1 || + rawRefresh === '1' || + String(rawRefresh || '').trim().toLowerCase() === 'true'; + if (!wantsRefresh) { + return false; + } + + const roles = Array.isArray(userRoles) ? userRoles : []; + return roles.some((role) => DECK_SNAPSHOT_ADMIN_ROLES.has(String(role).trim())); +} + +function normalizeOrderedEventIds(orderedEventIds) { + if (!Array.isArray(orderedEventIds) || !orderedEventIds.length) { + return []; + } + + return orderedEventIds.map((rawId) => { + const id = String(rawId || '').trim(); + if (!mongoose.Types.ObjectId.isValid(id)) { + throw new Error(`Invalid orderedEventId: ${rawId}`); + } + return new mongoose.Types.ObjectId(id); + }); +} + +function serializePivotDeckSnapshot(doc) { + if (!doc) { + return null; + } + + return { + _id: String(doc._id), + userId: String(doc.userId), + batchWeek: doc.batchWeek, + orderedEventIds: (doc.orderedEventIds || []).map((id) => String(id)), + rankerVersion: doc.rankerVersion, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; +} + +async function upsertPivotDeckSnapshot(req, payload = {}) { + const userId = payload.userId || req.user?.userId; + if (!userId) { + return { + error: 'Authentication required.', + status: 401, + code: 'UNAUTHORIZED', + }; + } + + const batchWeek = String(payload.batchWeek || '').trim(); + if (!batchWeek || !isValidIsoWeek(batchWeek)) { + return { + error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).', + status: 400, + code: 'INVALID_BATCH_WEEK', + }; + } + + const rankerVersion = String(payload.rankerVersion || '').trim(); + if (!rankerVersion) { + return { + error: 'rankerVersion is required.', + status: 400, + code: 'INVALID_RANKER_VERSION', + }; + } + + let orderedEventIds; + try { + orderedEventIds = normalizeOrderedEventIds(payload.orderedEventIds); + } catch (error) { + return { + error: error.message, + status: 400, + code: 'INVALID_ORDERED_EVENT_IDS', + }; + } + + const forceRefresh = Boolean(payload.forceRefresh); + const { PivotDeckSnapshot } = getModels(req, 'PivotDeckSnapshot'); + const userObjectId = new mongoose.Types.ObjectId(userId); + const existing = await PivotDeckSnapshot.findOne({ + userId: userObjectId, + batchWeek, + }).lean(); + + if (existing && !forceRefresh) { + return { + data: serializePivotDeckSnapshot(existing), + skipped: true, + }; + } + + const doc = await PivotDeckSnapshot.findOneAndUpdate( + { userId: userObjectId, batchWeek }, + { + $set: { + orderedEventIds, + rankerVersion, + }, + }, + { + upsert: true, + new: true, + runValidators: true, + setDefaultsOnInsert: true, + }, + ).lean(); + + logPivot('info', 'deck snapshot upserted', { + ...pivotRequestContext(req), + batchWeek, + orderedCount: orderedEventIds.length, + rankerVersion, + created: !existing, + refreshed: Boolean(existing && forceRefresh), + }); + + return { + data: serializePivotDeckSnapshot(doc), + skipped: false, + created: !existing, + refreshed: Boolean(existing && forceRefresh), + }; +} + +async function recordPivotDeckSnapshot(req, payload = {}) { + try { + return await upsertPivotDeckSnapshot(req, payload); + } catch (error) { + logPivot('error', 'deck snapshot write failed', { + ...pivotRequestContext(req), + batchWeek: payload.batchWeek, + message: error.message, + }); + return { error: error.message }; + } +} + +module.exports = { + normalizeDeckSnapshotRefresh, + normalizeOrderedEventIds, + serializePivotDeckSnapshot, + upsertPivotDeckSnapshot, + recordPivotDeckSnapshot, + DECK_SNAPSHOT_ADMIN_ROLES, +}; diff --git a/backend/services/pivotExploreSectionsService.js b/backend/services/pivotExploreSectionsService.js new file mode 100644 index 00000000..147ffcef --- /dev/null +++ b/backend/services/pivotExploreSectionsService.js @@ -0,0 +1,348 @@ +const { PIVOT_INTERACTION_RETRIEVALS } = require('../schemas/pivotInteraction'); + +const EXPLORE_SECTIONS_SOURCES = Object.freeze(['rules_v0', 'curated']); + +const EXPLORE_SECTION_LAYOUTS = Object.freeze(['rail', 'grid', 'list']); + +const EXPLORE_SECTION_COPY = Object.freeze({ + trending: 'trending', +}); + +const EXPLORE_CATEGORY_MIN_EVENTS = 3; +const EXPLORE_CATEGORY_MAX_EVENTS = 4; + +const RETRIEVAL_SET = new Set(PIVOT_INTERACTION_RETRIEVALS); + +/** + * Stored curation document (future tenant DB collection). + * + * @typedef {object} PivotExploreCurationSection + * @property {string} id + * @property {string} title + * @property {string} retrieval + * @property {'rail'|'grid'|'list'} [layout] + * @property {string[]} eventIds + * @property {string} [subtitle] + * + * @typedef {object} PivotExploreCurationDoc + * @property {string} tenantKey + * @property {string} batchWeek + * @property {PivotExploreCurationSection[]} sections + */ + +function shouldBuildExploreSections(filters = {}) { + return ( + !filters.q && + !filters.friendsOnly && + !(filters.tags && filters.tags.length) && + !filters.night + ); +} + +function serializedEventSocialScore(event) { + return (event.friendsInterestedCount || 0) + (event.friendsGoingCount || 0); +} + +function serializedEventHasFriendActivity(event) { + return ( + (event.friendsInterestedCount || 0) > 0 || (event.friendsGoingCount || 0) > 0 + ); +} + +function serializedEventHasTag(event, tagSlug) { + const tags = event.tags; + if (!Array.isArray(tags) || !tags.length) { + return false; + } + + const normalized = String(tagSlug).trim().toLowerCase(); + return tags.some((tag) => String(tag).trim().toLowerCase() === normalized); +} + +function isSerializedEventTonight(event, now = new Date()) { + if (!event.start_time) { + return false; + } + + const start = new Date(event.start_time); + if (Number.isNaN(start.getTime())) { + return false; + } + + return ( + start.getFullYear() === now.getFullYear() && + start.getMonth() === now.getMonth() && + start.getDate() === now.getDate() + ); +} + +function appearanceCount(tracker, eventId) { + return tracker.get(String(eventId)) ?? 0; +} + +function markEventsShown(events, tracker) { + for (const event of events) { + const id = String(event._id); + tracker.set(id, appearanceCount(tracker, id) + 1); + } +} + +function pickFreshCategoryEvents(candidates, tracker) { + const fresh = candidates.filter( + (event) => appearanceCount(tracker, event._id) === 0, + ); + if (fresh.length < EXPLORE_CATEGORY_MIN_EVENTS) { + return null; + } + + const picked = fresh.slice(0, EXPLORE_CATEGORY_MAX_EVENTS); + markEventsShown(picked, tracker); + return picked; +} + +function tagLabelForSlug(slug, rails) { + const rail = rails.find((row) => row.id === `tag:${slug}`); + if (rail?.title?.trim()) { + return rail.title; + } + return slug.replace(/-/g, ' '); +} + +function buildTagSectionCandidates(events, rails) { + const eventsByTag = new Map(); + + for (const event of events) { + const tags = event.tags; + if (!Array.isArray(tags) || !tags.length) { + continue; + } + + const seen = new Set(); + for (const tag of tags) { + const slug = String(tag).trim().toLowerCase(); + if (!slug || seen.has(slug)) { + continue; + } + seen.add(slug); + + const bucket = eventsByTag.get(slug) ?? []; + bucket.push(event); + eventsByTag.set(slug, bucket); + } + } + + return [...eventsByTag.entries()] + .sort((left, right) => right[1].length - left[1].length) + .map(([slug, tagEvents]) => ({ + id: `tag:${slug}`, + title: tagLabelForSlug(slug, rails), + events: tagEvents, + })); +} + +function normalizeExploreSectionLayout(layout) { + const normalized = typeof layout === 'string' ? layout.trim().toLowerCase() : ''; + if (EXPLORE_SECTION_LAYOUTS.includes(normalized)) { + return normalized; + } + return 'rail'; +} + +function normalizeExploreSectionRetrieval(retrieval) { + const normalized = + typeof retrieval === 'string' ? retrieval.trim().toLowerCase() : ''; + if (RETRIEVAL_SET.has(normalized)) { + return normalized; + } + return 'curated_rail'; +} + +/** + * Default rules-based browse sections (mirrors mobile pivotExploreRails v0). + */ +function buildRulesExploreSections(events, rails, options = {}) { + if (!Array.isArray(events) || !events.length) { + return []; + } + + const maxPerCategory = Math.min( + options.maxPerSection ?? EXPLORE_CATEGORY_MAX_EVENTS, + EXPLORE_CATEGORY_MAX_EVENTS, + ); + const now = options.now instanceof Date ? options.now : new Date(); + const tracker = new Map(); + const sections = []; + + const appendCategory = (id, title, retrieval, candidates) => { + const picked = pickFreshCategoryEvents(candidates, tracker); + if (!picked?.length) { + return; + } + + sections.push({ + id, + title, + retrieval, + layout: 'rail', + events: picked.slice(0, maxPerCategory), + }); + }; + + appendCategory( + 'trending', + EXPLORE_SECTION_COPY.trending, + 'for_you_rail', + [...events].sort( + (left, right) => + serializedEventSocialScore(right) - serializedEventSocialScore(left), + ), + ); + + const friendsRail = rails.find((rail) => rail.id === 'friends'); + if (friendsRail) { + appendCategory( + friendsRail.id, + friendsRail.title, + 'friends_rail', + events.filter(serializedEventHasFriendActivity), + ); + } + + const tonightRail = rails.find((rail) => rail.id === 'tonight'); + if (tonightRail) { + appendCategory( + tonightRail.id, + tonightRail.title, + 'filter', + events.filter((event) => isSerializedEventTonight(event, now)), + ); + } + + for (const tagSection of buildTagSectionCandidates(events, rails)) { + appendCategory( + tagSection.id, + tagSection.title, + 'tag_rail', + tagSection.events, + ); + } + + return sections; +} + +function materializeCuratedSections(curation, eventsById) { + const sections = []; + + for (const row of curation.sections || []) { + const id = typeof row?.id === 'string' ? row.id.trim() : ''; + const title = typeof row?.title === 'string' ? row.title.trim() : ''; + if (!id || !title) { + continue; + } + + const eventIds = Array.isArray(row.eventIds) ? row.eventIds : []; + const events = []; + const seen = new Set(); + for (const rawId of eventIds) { + const eventId = String(rawId); + if (!eventId || seen.has(eventId)) { + continue; + } + seen.add(eventId); + const event = eventsById.get(eventId); + if (event) { + events.push(event); + } + } + + if (!events.length) { + continue; + } + + sections.push({ + id, + title, + retrieval: normalizeExploreSectionRetrieval(row.retrieval), + layout: normalizeExploreSectionLayout(row.layout), + subtitle: typeof row.subtitle === 'string' ? row.subtitle.trim() : undefined, + events, + }); + } + + return sections; +} + +/** + * Load tenant/week curation override. Returns null until a curation store exists. + * + * @param {import('express').Request} _req + * @param {{ tenantKey: string, batchWeek: string, previewMode?: boolean }} _context + * @returns {Promise} + */ +async function loadExploreCuration(_req, _context) { + return null; +} + +/** + * Resolve browse sections from curation override or default rules. + */ +async function resolveExploreSections(req, options = {}) { + const { + tenantKey, + batchWeek, + previewMode = false, + serializedEvents = [], + rails = [], + filters = {}, + now = new Date(), + } = options; + + if (!shouldBuildExploreSections(filters)) { + return { + sections: [], + sectionsSource: 'rules_v0', + }; + } + + const eventsById = new Map( + serializedEvents.map((event) => [String(event._id), event]), + ); + + const loadCuration = options.loadExploreCuration || loadExploreCuration; + const curation = await loadCuration(req, { + tenantKey, + batchWeek, + previewMode, + }); + + if (curation?.sections?.length) { + const sections = materializeCuratedSections(curation, eventsById); + if (sections.length) { + return { + sections, + sectionsSource: 'curated', + }; + } + } + + return { + sections: buildRulesExploreSections(serializedEvents, rails, { now }), + sectionsSource: 'rules_v0', + }; +} + +module.exports = { + EXPLORE_SECTIONS_SOURCES, + EXPLORE_SECTION_LAYOUTS, + EXPLORE_SECTION_COPY, + EXPLORE_CATEGORY_MIN_EVENTS, + EXPLORE_CATEGORY_MAX_EVENTS, + shouldBuildExploreSections, + buildRulesExploreSections, + materializeCuratedSections, + loadExploreCuration, + resolveExploreSections, + serializedEventSocialScore, + serializedEventHasFriendActivity, + isSerializedEventTonight, +}; diff --git a/backend/services/pivotExploreService.js b/backend/services/pivotExploreService.js new file mode 100644 index 00000000..9f1d5323 --- /dev/null +++ b/backend/services/pivotExploreService.js @@ -0,0 +1,827 @@ +const getModels = require('./getModelService'); +const { getTenantByKey } = require('./tenantConfigService'); +const { connectToDatabase } = require('../connectionsManager'); +const { resolvePivotTenant } = require('./pivotIngestPublishService'); +const { + listPivotTags, + normalizePivotTagSlugs, + validatePivotEventTags, +} = require('./pivotTagCatalogService'); +const { isValidIsoWeek } = require('../utilities/pivotIsoWeek'); +const { + normalizePivotTimeSlots, +} = require('../utilities/pivotTimeSlots'); +const { + resolvePivotDropInstant, + describePivotBatchWeekResolution, +} = require('../utilities/pivotDropSchedule'); +const { collectPivotEnrichmentSearchText } = require('../utilities/pivotEnrichment'); +const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger'); +const { PIVOT_FEED_INGEST_STATUS, PIVOT_INGEST_STATUSES } = require('../utilities/pivotIngestStatus'); +const { + getFeedPilotWindowFilter, + isUpcomingPivotEvent, + resolveDisplayHost, + serializePivotFeedEvent, + compareByFeedRank, + loadFriendSocial, + loadUserInterestTags, + loadNegativeFeedbackTags, + resolvePivotFeedBatchWeek, + PIVOT_EVENT_STATUSES, + PIVOT_FEED_RANKER_VERSION, + FRIEND_CAP, +} = require('./pivotFeedService'); +const { resolveExploreSections } = require('./pivotExploreSectionsService'); + +const PUBLIC_EVENT_FIELDS = + 'name description location start_time end_time externalLink type registrationCount image customFields.pivot'; +const DEFAULT_EXPLORE_LIMIT = 40; +const MAX_EXPLORE_LIMIT = 100; +const EXPLORE_SORT_MODES = new Set(['for_you', 'soonest']); +const DEFAULT_EXPLORE_SORT = 'for_you'; +const EXPLORE_NIGHT_SHORTDAYS = new Set(['thu', 'fri', 'sat', 'sun']); +const EXPLORE_NIGHT_WEEKDAY = { + sun: 0, + thu: 4, + fri: 5, + sat: 6, +}; +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +const EXPLORE_RAIL_COPY = { + friends: 'friends going', + tonight: 'tonight', + forYou: 'for you later', +}; + +/** Client badge precedence: registered (going) → interested → unset. */ +const EXPLORE_INTENT_BADGE_PRIORITY = Object.freeze([ + 'registered', + 'interested', + null, +]); +const EXPLORE_USER_INTENT_STATUSES = new Set([ + 'interested', + 'registered', + 'passed', +]); + +function normalizeExploreLimit(rawLimit) { + if (rawLimit == null || rawLimit === '') { + return DEFAULT_EXPLORE_LIMIT; + } + + const parsed = Number.parseInt(String(rawLimit), 10); + if (!Number.isFinite(parsed) || parsed < 1) { + return null; + } + + return Math.min(parsed, MAX_EXPLORE_LIMIT); +} + +function normalizeExploreOffset(rawOffset) { + if (rawOffset == null || rawOffset === '') { + return 0; + } + + const parsed = Number.parseInt(String(rawOffset), 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + + return parsed; +} + +function normalizeExploreBool(rawValue, defaultValue = false) { + if (rawValue == null || rawValue === '') { + return defaultValue; + } + + const normalized = String(rawValue).trim().toLowerCase(); + if (normalized === 'true' || normalized === '1') { + return true; + } + if (normalized === 'false' || normalized === '0') { + return false; + } + + return null; +} + +function normalizeExploreTagsParam(rawTags) { + if (rawTags == null || rawTags === '') { + return []; + } + + const parts = Array.isArray(rawTags) + ? rawTags + : String(rawTags).split(','); + + return normalizePivotTagSlugs(parts); +} + +function normalizeExploreSort(rawSort) { + if (rawSort == null || rawSort === '') { + return DEFAULT_EXPLORE_SORT; + } + + const sort = String(rawSort).trim().toLowerCase(); + if (EXPLORE_SORT_MODES.has(sort)) { + return sort; + } + + return undefined; +} + +function compareByStartTime(a, b) { + const aStart = new Date(a.start_time).getTime() || 0; + const bStart = new Date(b.start_time).getTime() || 0; + return aStart - bStart; +} + +function normalizeExploreQuery(rawQuery) { + if (rawQuery == null || rawQuery === '') { + return null; + } + + const query = String(rawQuery).trim(); + return query.length ? query : null; +} + +function normalizeExploreNight(rawNight) { + if (rawNight == null || rawNight === '') { + return null; + } + + const night = String(rawNight).trim().toLowerCase(); + if (EXPLORE_NIGHT_SHORTDAYS.has(night)) { + return night; + } + + if (ISO_DATE_PATTERN.test(night)) { + const parsed = new Date(`${night}T00:00:00.000Z`); + if (Number.isNaN(parsed.getTime())) { + return undefined; + } + return night; + } + + return undefined; +} + +function collectEventStartInstants(event) { + const slots = normalizePivotTimeSlots(event.customFields?.pivot?.timeSlots); + if (slots.length) { + return slots + .map((slot) => new Date(slot.start_time)) + .filter((date) => !Number.isNaN(date.getTime())); + } + + if (event.start_time == null || event.start_time === '') { + return []; + } + + const start = new Date(event.start_time); + return Number.isNaN(start.getTime()) ? [] : [start]; +} + +function getLocalWeekdayIndex(date, timeZone) { + const weekday = new Intl.DateTimeFormat('en-US', { + timeZone, + weekday: 'short', + }).format(date); + + return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekday); +} + +function formatLocalIsoDate(date, timeZone) { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date); + + const year = parts.find((part) => part.type === 'year')?.value; + const month = parts.find((part) => part.type === 'month')?.value; + const day = parts.find((part) => part.type === 'day')?.value; + + if (!year || !month || !day) { + return null; + } + + return `${year}-${month}-${day}`; +} + +function eventMatchesNight(event, night, timeZone) { + if (!night) { + return true; + } + + const instants = collectEventStartInstants(event); + if (!instants.length) { + return false; + } + + if (EXPLORE_NIGHT_WEEKDAY[night] != null) { + const targetWeekday = EXPLORE_NIGHT_WEEKDAY[night]; + return instants.some( + (instant) => getLocalWeekdayIndex(instant, timeZone) === targetWeekday, + ); + } + + return instants.some( + (instant) => formatLocalIsoDate(instant, timeZone) === night, + ); +} + +function eventMatchesQuery(event, query) { + if (!query) { + return true; + } + + const needle = query.toLowerCase(); + const hostName = event.customFields?.pivot?.host?.name || ''; + const enrichmentText = collectPivotEnrichmentSearchText(event.customFields?.pivot); + const haystack = [event.name, event.description, hostName, enrichmentText] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + return haystack.includes(needle); +} + +function eventHasAnyTag(event, tags) { + if (!tags.length) { + return true; + } + + const eventTags = event.customFields?.pivot?.tags; + if (!Array.isArray(eventTags) || !eventTags.length) { + return false; + } + + const eventTagSet = new Set( + eventTags.map((raw) => String(raw).trim().toLowerCase()).filter(Boolean), + ); + + return tags.some((tag) => eventTagSet.has(tag)); +} + +function eventHasFriendActivity(event, socialByEvent) { + const social = socialByEvent.get(String(event._id)); + return ( + (social?.friendRegisteredCount || 0) > 0 || + (social?.friendInterestedCount || 0) > 0 + ); +} + +function resolveExploreUserIntent(userIntentRow) { + const status = userIntentRow?.status; + if (!status || !EXPLORE_USER_INTENT_STATUSES.has(status)) { + return null; + } + + return status; +} + +function shouldExcludePassedExploreEvent(event, userIntents, excludePassed) { + if (!excludePassed) { + return false; + } + + const intent = userIntents.get(String(event._id)); + return intent?.status === 'passed'; +} + +function applyExploreFilters(events, filters, context) { + const { userIntents, socialByEvent, timeZone } = context; + + return events.filter((event) => { + if (shouldExcludePassedExploreEvent(event, userIntents, filters.excludePassed)) { + return false; + } + + if (filters.friendsOnly && !eventHasFriendActivity(event, socialByEvent)) { + return false; + } + + if (!eventHasAnyTag(event, filters.tags)) { + return false; + } + + if (!eventMatchesNight(event, filters.night, timeZone)) { + return false; + } + + return eventMatchesQuery(event, filters.q); + }); +} + +function collectWeekTagSlugs(events) { + const slugs = new Set(); + + for (const event of events) { + const tags = event.customFields?.pivot?.tags; + if (!Array.isArray(tags)) { + continue; + } + + for (const raw of tags) { + const slug = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + if (slug) { + slugs.add(slug); + } + } + } + + return slugs; +} + +function buildExploreRails( + catalogTagRows, + weekEvents, + userInterestTags = new Set(), + previewMode = false, +) { + const rails = [ + { + id: 'friends', + title: EXPLORE_RAIL_COPY.friends, + retrieval: 'friends_rail', + }, + { + id: 'tonight', + title: EXPLORE_RAIL_COPY.tonight, + retrieval: 'filter', + }, + { + id: 'for_you', + title: EXPLORE_RAIL_COPY.forYou, + retrieval: 'for_you_rail', + }, + ]; + + const weekTagSlugs = collectWeekTagSlugs(weekEvents); + for (const row of catalogTagRows) { + if (weekTagSlugs.has(row.slug) && (previewMode || userInterestTags.has(row.slug))) { + rails.push({ + id: `tag:${row.slug}`, + title: row.label, + retrieval: 'tag_rail', + }); + } + } + + return rails; +} + +function buildExploreFiltersPayload(filters) { + return { + tags: filters.tags, + night: filters.night, + friendsOnly: filters.friendsOnly, + excludePassed: filters.excludePassed, + q: filters.q, + sort: filters.sort, + }; +} + +function serializeExploreEvent(event, extras) { + return serializePivotFeedEvent(event, extras); +} + +function serializeExploreCatalogEvents( + catalogEvents, + { socialByEvent, userIntents, socialByEventAndSlot }, +) { + return catalogEvents.map((event) => { + const id = String(event._id); + const social = socialByEvent.get(id) || { + friendsInterested: [], + friendsGoing: [], + 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 serializeExploreEvent(event, { + displayHost: resolveDisplayHost(event.customFields.pivot), + userIntent: resolveExploreUserIntent(userIntentRow), + userTimeSlotId: userIntentRow?.timeSlotId || null, + socialByTimeSlot, + friendsInterested: social.friendsInterested, + friendsGoing: social.friendsGoing, + friendsInterestedCount: social.friendInterestedCount || 0, + friendsGoingCount: social.friendRegisteredCount || 0, + }); + }); +} + +const PUBLISHED_BATCH_WEEK_PROBE = { + 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS, + status: { $in: PIVOT_EVENT_STATUSES }, + isDeleted: { $ne: true }, + 'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] }, +}; + +async function listPublishedPivotBatchWeeks(Event) { + if (typeof Event.distinct !== 'function') { + return undefined; + } + + const weeks = await Event.distinct('customFields.pivot.batchWeek', PUBLISHED_BATCH_WEEK_PROBE); + return weeks.filter(Boolean).map(String).sort(); +} + +function summarizeExploreFilterRemovals(catalogEvents, filteredEvents, userIntents, excludePassed) { + const passedInCatalog = catalogEvents.filter( + (event) => userIntents.get(String(event._id))?.status === 'passed', + ).length; + + return { + catalogCount: catalogEvents.length, + filteredCount: filteredEvents.length, + removedPassed: excludePassed ? passedInCatalog : 0, + removedByOtherFilters: Math.max( + 0, + catalogEvents.length - passedInCatalog - filteredEvents.length, + ), + }; +} + +async function getPivotExplore(req, options = {}) { + const previewMode = Boolean(options.previewMode); + const userId = previewMode ? null : req.user?.userId; + if (!previewMode && !userId) { + return { + error: 'Authentication required.', + status: 401, + code: 'UNAUTHORIZED', + }; + } + + const now = options.now || new Date(); + const tenant = await getTenantByKey(req, req.school); + + let batchWeek; + let batchWeekPick; + let batchWeekResolution; + + if (previewMode) { + const requested = options.batchWeek?.trim(); + if (!requested) { + return { + error: 'batchWeek is required for explore preview.', + status: 400, + code: 'BATCH_WEEK_REQUIRED', + }; + } + if (!isValidIsoWeek(requested)) { + return { + error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).', + status: 400, + code: 'INVALID_BATCH_WEEK', + }; + } + batchWeek = requested; + batchWeekPick = { batchWeek, batchWeekSource: 'preview_explicit' }; + batchWeekResolution = { + ...describePivotBatchWeekResolution(tenant, now, requested), + ...batchWeekPick, + resolvedBatchWeek: batchWeek, + previewMode: true, + }; + } else { + if (options.batchWeek?.trim() && !isValidIsoWeek(options.batchWeek.trim())) { + return { + error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).', + status: 400, + code: 'INVALID_BATCH_WEEK', + }; + } + + batchWeekPick = await resolvePivotFeedBatchWeek(req, { + tenant, + now, + requestedBatchWeek: options.batchWeek, + }); + batchWeek = batchWeekPick.batchWeek; + batchWeekResolution = { + ...describePivotBatchWeekResolution(tenant, now, options.batchWeek), + ...batchWeekPick, + resolvedBatchWeek: batchWeekPick.batchWeek, + }; + } + + logPivot('info', 'explore request', { + ...pivotRequestContext(req), + ...batchWeekResolution, + }); + + const limit = normalizeExploreLimit(options.limit); + if (limit == null) { + return { + error: 'limit must be a positive integer.', + status: 400, + code: 'INVALID_LIMIT', + }; + } + + const offset = normalizeExploreOffset(options.offset); + if (offset == null) { + return { + error: 'offset must be a non-negative integer.', + status: 400, + code: 'INVALID_OFFSET', + }; + } + + const night = normalizeExploreNight(options.night); + if (options.night != null && options.night !== '' && night === undefined) { + return { + error: 'night must be thu, fri, sat, sun, or YYYY-MM-DD.', + status: 400, + code: 'INVALID_NIGHT', + }; + } + + const friendsOnly = normalizeExploreBool(options.friendsOnly, false); + if (friendsOnly == null) { + return { + error: 'friendsOnly must be true or false.', + status: 400, + code: 'INVALID_FRIENDS_ONLY', + }; + } + + const excludePassed = normalizeExploreBool(options.excludePassed, true); + if (excludePassed == null) { + return { + error: 'excludePassed must be true or false.', + status: 400, + code: 'INVALID_EXCLUDE_PASSED', + }; + } + + const filterTags = normalizeExploreTagsParam(options.tags); + if (filterTags.length) { + const tagValidation = await validatePivotEventTags(req, filterTags, { + required: false, + activeOnly: true, + }); + if (tagValidation.error) { + return { + error: tagValidation.error, + status: tagValidation.status || 400, + code: tagValidation.code || 'INVALID_TAG', + }; + } + } + + const sort = normalizeExploreSort(options.sort); + if (options.sort != null && options.sort !== '' && sort === undefined) { + return { + error: 'sort must be for_you or soonest.', + status: 400, + code: 'INVALID_SORT', + }; + } + + const queryText = normalizeExploreQuery(options.q); + const filters = { + tags: filterTags, + night, + friendsOnly, + excludePassed, + q: queryText, + sort, + }; + + const catalogResult = await listPivotTags(req); + if (catalogResult.error) { + return { + error: catalogResult.error, + status: catalogResult.status || 500, + code: 'TAG_CATALOG_UNAVAILABLE', + }; + } + const catalogTagRows = catalogResult.data?.tags || []; + + const { Event } = getModels(req, 'Event'); + + const query = { + 'customFields.pivot.batchWeek': batchWeek, + 'customFields.pivot.ingestStatus': previewMode + ? { $in: [...PIVOT_INGEST_STATUSES] } + : PIVOT_FEED_INGEST_STATUS, + status: { $in: PIVOT_EVENT_STATUSES }, + isDeleted: { $ne: true }, + 'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] }, + ...getFeedPilotWindowFilter(now), + }; + if (filterTags.length) { + query['customFields.pivot.tags'] = { $in: filterTags }; + } + + const events = await Event.find(query) + .select(PUBLIC_EVENT_FIELDS) + .lean(); + + const catalogEvents = events.filter( + (event) => + resolveDisplayHost(event.customFields?.pivot) && + isUpcomingPivotEvent(event, now), + ); + + const catalogEventIds = catalogEvents.map((event) => event._id); + let userInterestTags; + let friendSocial; + let negativeFeedbackTags; + if (previewMode) { + userInterestTags = new Set(); + friendSocial = { + userIntents: new Map(), + socialByEvent: new Map(), + socialByEventAndSlot: new Map(), + }; + negativeFeedbackTags = new Set(); + } else { + [userInterestTags, friendSocial, negativeFeedbackTags] = await Promise.all([ + loadUserInterestTags(req, userId), + loadFriendSocial(req, userId, catalogEventIds, FRIEND_CAP, batchWeek), + sort === 'for_you' + ? loadNegativeFeedbackTags(req, userId) + : Promise.resolve(new Set()), + ]); + } + const { userIntents, socialByEvent, socialByEventAndSlot } = friendSocial; + + const cityDisplayName = tenant?.location || tenant?.name || req.school; + const { timezone: timeZone } = resolvePivotDropInstant(tenant, batchWeek, now); + + const filteredEvents = applyExploreFilters(catalogEvents, filters, { + userIntents, + socialByEvent, + timeZone, + }); + + if (sort === 'for_you') { + filteredEvents.sort( + compareByFeedRank(socialByEvent, userInterestTags, negativeFeedbackTags), + ); + } else { + filteredEvents.sort(compareByStartTime); + } + + const total = filteredEvents.length; + const rails = buildExploreRails( + catalogTagRows, + catalogEvents, + userInterestTags, + previewMode, + ); + const serializedEvents = serializeExploreCatalogEvents(filteredEvents, { + socialByEvent, + userIntents, + socialByEventAndSlot, + }); + const pageEvents = serializedEvents.slice(offset, offset + limit); + const { sections, sectionsSource } = await resolveExploreSections(req, { + tenantKey: req.school, + batchWeek, + previewMode, + serializedEvents, + rails, + filters, + now, + }); + const filterRemovals = summarizeExploreFilterRemovals( + catalogEvents, + filteredEvents, + userIntents, + excludePassed, + ); + + let publishedBatchWeeks; + if (events.length === 0 || total === 0) { + publishedBatchWeeks = await listPublishedPivotBatchWeeks(Event); + } + + logPivot('info', 'explore built', { + ...pivotRequestContext(req), + ...batchWeekResolution, + batchWeek, + cityDisplayName, + candidateCount: events.length, + catalogEventCount: catalogEvents.length, + droppedBeforeCatalog: events.length - catalogEvents.length, + eventCount: total, + limit, + offset, + returnedCount: pageEvents.length, + sectionCount: sections.length, + sectionsSource, + filterRemovals, + publishedBatchWeeks, + filterTags: filterTags.length ? filterTags : undefined, + friendsOnly: friendsOnly || undefined, + excludePassed, + night: night || undefined, + q: queryText || undefined, + sort, + }); + + return { + data: { + batchWeek, + cityDisplayName, + catalogTotal: catalogEvents.length, + hiddenPassedCount: filterRemovals.removedPassed, + total, + rankerVersion: PIVOT_FEED_RANKER_VERSION, + limit, + offset, + filters: buildExploreFiltersPayload(filters), + rails, + sections, + sectionsSource, + intentBadgePriority: [...EXPLORE_INTENT_BADGE_PRIORITY], + previewMode, + events: pageEvents, + }, + }; +} + +/** + * Platform-admin explore preview for a tenant batch week (no end-user auth context). + * + * @param {object} req + * @param {{ tenantKey: string, batchWeek: string, now?: Date, limit?: string|number, offset?: string|number, tags?: string, night?: string, friendsOnly?: string|boolean, excludePassed?: string|boolean, q?: string, sort?: string }} options + */ +async function getPivotExplorePreview(req, options = {}) { + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) { + return tenantResult; + } + + const tenantKey = tenantResult.tenant.tenantKey; + const db = await connectToDatabase(tenantKey); + const tenantReq = { + ...req, + school: tenantKey, + db, + }; + + return getPivotExplore(tenantReq, { + batchWeek: options.batchWeek, + limit: options.limit, + offset: options.offset, + tags: options.tags, + night: options.night, + friendsOnly: options.friendsOnly, + excludePassed: options.excludePassed, + q: options.q, + sort: options.sort, + now: options.now, + previewMode: true, + }); +} + +module.exports = { + getPivotExplore, + getPivotExplorePreview, + normalizeExploreLimit, + normalizeExploreOffset, + normalizeExploreBool, + normalizeExploreTagsParam, + normalizeExploreQuery, + normalizeExploreNight, + normalizeExploreSort, + compareByStartTime, + eventMatchesNight, + eventMatchesQuery, + eventHasAnyTag, + eventHasFriendActivity, + resolveExploreUserIntent, + shouldExcludePassedExploreEvent, + applyExploreFilters, + buildExploreRails, + buildExploreFiltersPayload, + EXPLORE_INTENT_BADGE_PRIORITY, + DEFAULT_EXPLORE_LIMIT, + MAX_EXPLORE_LIMIT, + DEFAULT_EXPLORE_SORT, + EXPLORE_SORT_MODES, +}; diff --git a/backend/services/pivotFeedService.js b/backend/services/pivotFeedService.js index e80febb4..c913e35a 100644 --- a/backend/services/pivotFeedService.js +++ b/backend/services/pivotFeedService.js @@ -1,7 +1,13 @@ const mongoose = require('mongoose'); const getModels = require('./getModelService'); const { getTenantByKey } = require('./tenantConfigService'); -const { toIsoWeek, isValidIsoWeek } = require('../utilities/pivotIsoWeek'); +const { isValidIsoWeek, shiftIsoWeek } = require('../utilities/pivotIsoWeek'); +const { + resolvePivotLiveBatchWeek, + resolvePivotDropPendingForCalendarWeek, + resolvePivotUpcomingDropBatchWeek, + describePivotBatchWeekResolution, +} = require('../utilities/pivotDropSchedule'); const { PIVOT_TAG_SLUG_PATTERN } = require('../schemas/pivotTagCatalog'); const { normalizePivotTimeSlots, @@ -12,14 +18,104 @@ const { serializePivotMovie, resolvePivotCoverImageUrl, } = require('../utilities/pivotMovieMetadata'); +const { serializePivotEnrichment } = require('../utilities/pivotEnrichment'); const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger'); +const { + normalizeDeckSnapshotRefresh, + recordPivotDeckSnapshot, +} = require('./pivotDeckSnapshotService'); const { PIVOT_FEED_INGEST_STATUS } = require('../utilities/pivotIngestStatus'); const FRIEND_CAP = 5; const PIVOT_EVENT_STATUSES = ['approved', 'not-applicable']; const LOW_FEEDBACK_RATING_THRESHOLD = 3; +/** Ranker id stamped on feed payloads + deck impressions (Task 1.2). */ +const PIVOT_FEED_RANKER_VERSION = 'rules_v0'; const PUBLIC_EVENT_FIELDS = 'name description location start_time end_time externalLink type registrationCount image customFields.pivot'; +const CATALOG_PROBE_FIELDS = 'start_time end_time customFields.pivot'; + +const PUBLISHED_CATALOG_BASE_QUERY = { + 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS, + status: { $in: PIVOT_EVENT_STATUSES }, + isDeleted: { $ne: true }, + 'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] }, +}; + +function buildPublishedCatalogQuery(batchWeek, now) { + return { + 'customFields.pivot.batchWeek': batchWeek, + ...PUBLISHED_CATALOG_BASE_QUERY, + ...getFeedPilotWindowFilter(now), + }; +} + +function countUpcomingCatalogEvents(events, now) { + return events.filter( + (event) => + resolveDisplayHost(event.customFields?.pivot) && + isUpcomingPivotEvent(event, now), + ).length; +} + +async function countUpcomingCatalogEventsForBatchWeek(req, batchWeek, now) { + const { Event } = getModels(req, 'Event'); + const events = await Event.find(buildPublishedCatalogQuery(batchWeek, now)) + .select(CATALOG_PROBE_FIELDS) + .lean(); + return countUpcomingCatalogEvents(events, now); +} + +/** + * Pick the batchWeek for feed/explore when the client did not pass ?batchWeek. + * Prefer the current consumer week; if that catalog has no upcoming published + * events, probe adjacent ISO weeks (handles event-date vs drop-week skew). + */ +async function resolvePivotFeedBatchWeek(req, { tenant, now, requestedBatchWeek }) { + const trimmedRequest = + typeof requestedBatchWeek === 'string' ? requestedBatchWeek.trim() : ''; + + if (trimmedRequest) { + return { + batchWeek: trimmedRequest, + batchWeekSource: 'query', + catalogProbeWeeks: [trimmedRequest], + }; + } + + const preferred = resolvePivotLiveBatchWeek(tenant, now); + const dropPending = resolvePivotDropPendingForCalendarWeek(tenant, now); + const probeOrder = dropPending + ? [preferred, shiftIsoWeek(preferred, -1)] + : [preferred, shiftIsoWeek(preferred, 1), shiftIsoWeek(preferred, -1)]; + const seen = new Set(); + const catalogProbeWeeks = []; + + for (const week of probeOrder) { + if (seen.has(week)) { + continue; + } + seen.add(week); + catalogProbeWeeks.push(week); + + const matchCount = await countUpcomingCatalogEventsForBatchWeek(req, week, now); + if (matchCount > 0) { + return { + batchWeek: week, + batchWeekSource: week === preferred ? 'consumer_week' : 'catalog_fallback', + catalogProbeWeeks, + catalogMatchCount: matchCount, + }; + } + } + + return { + batchWeek: preferred, + batchWeekSource: 'consumer_week', + catalogProbeWeeks, + catalogMatchCount: 0, + }; +} function getPilotWindow(now = new Date()) { const windowStart = new Date( @@ -120,6 +216,7 @@ function serializePivotFeedEvent(event, extras) { const pivot = event.customFields?.pivot || {}; const coverImageUrl = resolvePivotCoverImageUrl(event); const movie = serializePivotMovie(pivot.movie); + const enrichment = serializePivotEnrichment(pivot); const normalizedSlots = normalizePivotTimeSlots(pivot.timeSlots); const timeSlots = normalizedSlots.length ? serializePivotTimeSlots(normalizedSlots, extras.socialByTimeSlot) @@ -139,6 +236,7 @@ function serializePivotFeedEvent(event, extras) { ...(coverImageUrl ? { coverImageUrl } : {}), ...(timeSlots ? { timeSlots } : {}), ...(movie ? { movie } : {}), + ...(enrichment ? { enrichment } : {}), displayHost: extras.displayHost, userIntent: extras.userIntent, ...(extras.userTimeSlotId ? { userTimeSlotId: extras.userTimeSlotId } : {}), @@ -148,6 +246,8 @@ function serializePivotFeedEvent(event, extras) { // even when the preview arrays above are capped at FRIEND_CAP. friendsInterestedCount: extras.friendsInterestedCount, friendsGoingCount: extras.friendsGoingCount, + /** 0-based position in the ranked feed (exposure bias / training). */ + ...(typeof extras.rankInFeed === 'number' ? { rankInFeed: extras.rankInFeed } : {}), }; } @@ -496,8 +596,9 @@ async function getPivotFeed(req, options = {}) { } const now = options.now || new Date(); - const batchWeek = options.batchWeek?.trim() || toIsoWeek(now); - if (options.batchWeek && !isValidIsoWeek(batchWeek)) { + const tenant = await getTenantByKey(req, req.school); + + if (options.batchWeek?.trim() && !isValidIsoWeek(options.batchWeek.trim())) { return { error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).', status: 400, @@ -505,10 +606,23 @@ async function getPivotFeed(req, options = {}) { }; } + const batchWeekPick = await resolvePivotFeedBatchWeek(req, { + tenant, + now, + requestedBatchWeek: options.batchWeek, + }); + const batchWeek = batchWeekPick.batchWeek; + const batchWeekResolution = { + ...describePivotBatchWeekResolution(tenant, now, options.batchWeek), + ...batchWeekPick, + resolvedBatchWeek: batchWeekPick.batchWeek, + }; + const { Event } = getModels(req, 'Event'); const excludeEventIds = normalizeExcludeEventIds(options.excludeEventIds); // Choice A: draft/staged never appear; only published after explicit Release. + // Covered by Event index `pivot_batchWeek_ingestStatus_start_time` (Task 1.4). const query = { 'customFields.pivot.batchWeek': batchWeek, 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS, @@ -546,7 +660,6 @@ async function getPivotFeed(req, options = {}) { compareByFeedRank(socialByEvent, userInterestTags, negativeFeedbackTags), ); - const tenant = await getTenantByKey(req, req.school); const cityDisplayName = tenant?.location || tenant?.name || req.school; const multiSlotEventCount = validEvents.filter( @@ -555,21 +668,43 @@ async function getPivotFeed(req, options = {}) { logPivot('info', 'feed built', { ...pivotRequestContext(req), + ...batchWeekResolution, batchWeek, cityDisplayName, candidateCount: events.length, eventCount: validEvents.length, + droppedBeforeCatalog: events.length - validEvents.length, multiSlotEventCount, excludedCount: excludeEventIds.length, interestTagCount: userInterestTags.size, negativeTagPenaltyCount: negativeFeedbackTags.size, }); + if (validEvents.length === 0 && typeof Event.distinct === 'function') { + const publishedBatchWeeks = await Event.distinct('customFields.pivot.batchWeek', { + ...PUBLISHED_CATALOG_BASE_QUERY, + }); + logPivot('warn', 'feed empty catalog — published batchWeeks in tenant', { + ...pivotRequestContext(req), + batchWeek, + publishedBatchWeeks: publishedBatchWeeks.filter(Boolean).map(String).sort(), + }); + } + + await recordPivotDeckSnapshot(req, { + userId, + batchWeek, + orderedEventIds: validEvents.map((event) => event._id), + rankerVersion: PIVOT_FEED_RANKER_VERSION, + forceRefresh: normalizeDeckSnapshotRefresh(options.refresh, req.user?.roles), + }); + return { data: { batchWeek, cityDisplayName, - events: validEvents.map((event) => { + rankerVersion: PIVOT_FEED_RANKER_VERSION, + events: validEvents.map((event, rankInFeed) => { const id = String(event._id); const social = socialByEvent.get(id) || { friendsInterested: [], @@ -598,6 +733,7 @@ async function getPivotFeed(req, options = {}) { friendsGoing: social.friendsGoing, friendsInterestedCount: social.friendInterestedCount || 0, friendsGoingCount: social.friendRegisteredCount || 0, + rankInFeed, }); }), }, @@ -680,6 +816,12 @@ module.exports = { loadNegativeFeedbackTags, collectCatalogTagsFromEvents, mapFriendPreview, + resolvePivotFeedBatchWeek, + countUpcomingCatalogEventsForBatchWeek, + buildPublishedCatalogQuery, + countUpcomingCatalogEvents, LOW_FEEDBACK_RATING_THRESHOLD, + FRIEND_CAP, PIVOT_EVENT_STATUSES, + PIVOT_FEED_RANKER_VERSION, }; diff --git a/backend/services/pivotFeedbackService.js b/backend/services/pivotFeedbackService.js index feea64dc..f483b7bc 100644 --- a/backend/services/pivotFeedbackService.js +++ b/backend/services/pivotFeedbackService.js @@ -7,6 +7,10 @@ const { } = require('./pivotIntentService'); const { resolveDisplayHost, PIVOT_EVENT_STATUSES } = require('./pivotFeedService'); const { PIVOT_FEED_INGEST_STATUS } = require('../utilities/pivotIngestStatus'); +const { + recordPivotInteraction, + pickInteractionContext, +} = require('./pivotInteractionService'); const PIVOT_EVENT_FEATURE = 'pivot_event'; const RECAP_EVENT_FIELDS = @@ -170,6 +174,15 @@ async function submitEventFeedback(req, body = {}) { metadata, ); + recordPivotInteraction(req, { + userId, + eventId, + batchWeek: batchWeek || undefined, + type: 'rating', + rating: ratingNumber, + ...pickInteractionContext(body), + }); + return { data: { eventId: String(feedback.processId), diff --git a/backend/services/pivotIngestPublishService.js b/backend/services/pivotIngestPublishService.js index 18e2d90f..2dc398c6 100644 --- a/backend/services/pivotIngestPublishService.js +++ b/backend/services/pivotIngestPublishService.js @@ -20,6 +20,7 @@ const { normalizePivotMovie, applyMovieListingDefaults, } = require('../utilities/pivotMovieMetadata'); +const { normalizePivotEnrichment } = require('../utilities/pivotEnrichment'); const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger'); const { normalizeIngestStatus, @@ -164,6 +165,10 @@ function mergeDraftWithOverrides(draft = {}, overrides = {}) { movie: normalizePivotMovie( overrides.movie !== undefined ? overrides.movie : draft.movie, ), + enrichment: + overrides.enrichment !== undefined + ? overrides.enrichment + : draft.enrichment, }; } @@ -213,12 +218,22 @@ function validateMergedDraft(merged) { endTime = new Date(startTime.getTime() + DEFAULT_DURATION_MS); } + const enrichmentResult = normalizePivotEnrichment(withMovieDefaults.enrichment); + if (enrichmentResult?.error) { + return { + error: enrichmentResult.error, + status: 400, + code: enrichmentResult.code, + }; + } + return { merged: { ...withMovieDefaults, timeSlots: slots, startTime, endTime, + ...(enrichmentResult ? { enrichment: enrichmentResult } : {}), }, }; } @@ -237,6 +252,7 @@ function buildPivotMetadata(merged, { batchWeek, sourceUrl, importedBy, tags, in tags: tags || [], ...(merged.timeSlots?.length ? { timeSlots: merged.timeSlots } : {}), ...(merged.movie ? { movie: merged.movie } : {}), + ...(merged.enrichment ? { enrichment: merged.enrichment } : {}), ingestStatus: ingestStatus || DEFAULT_INGEST_STATUS, importedAt: new Date().toISOString(), importedBy, @@ -791,6 +807,22 @@ async function updateIngestEvent(req, options = {}) { } } + if (overrides.enrichment !== undefined) { + const enrichmentResult = normalizePivotEnrichment(overrides.enrichment); + if (enrichmentResult?.error) { + return { + error: enrichmentResult.error, + status: 400, + code: enrichmentResult.code, + }; + } + if (enrichmentResult) { + pivotPatch.enrichment = enrichmentResult; + } else { + delete pivotPatch.enrichment; + } + } + const nextIngestStatus = pivotPatch.ingestStatus ?? pivot.ingestStatus; const nextTags = pivotPatch.tags ?? pivot.tags ?? []; if (nextIngestStatus === 'published' && nextTags.length === 0) { diff --git a/backend/services/pivotIntentService.js b/backend/services/pivotIntentService.js index a823cc6e..230ac39e 100644 --- a/backend/services/pivotIntentService.js +++ b/backend/services/pivotIntentService.js @@ -15,12 +15,22 @@ const { eventHasTimeSlots, } = require('../utilities/pivotTimeSlots'); const { PIVOT_FEED_INGEST_STATUS } = require('../utilities/pivotIngestStatus'); +const { + recordPivotInteraction, + pickInteractionContext, +} = require('./pivotInteractionService'); const FEED_ACTION_TO_STATUS = { interested: 'interested', pass: 'passed', }; +/** Map feed action → PivotInteraction.type (pass stays `pass`, not `passed`). */ +const FEED_ACTION_TO_INTERACTION_TYPE = { + interested: 'interested', + pass: 'pass', +}; + const RECAP_STATUSES = ['interested', 'registered']; const RECAP_EVENT_FIELDS = 'name description location start_time end_time externalLink type image customFields.pivot'; @@ -151,6 +161,15 @@ async function recordFeedAction(req, body = {}) { timeSlotId: null, }); + const interactionType = FEED_ACTION_TO_INTERACTION_TYPE[action]; + recordPivotInteraction(req, { + userId, + eventId, + batchWeek: doc.batchWeek, + type: interactionType, + ...pickInteractionContext(body), + }); + logPivot('info', 'feed action recorded', { ...pivotRequestContext(req), eventId, @@ -213,6 +232,14 @@ async function recordExternalOpen(req, rawEventId, body = {}) { { upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true }, ).lean(); + recordPivotInteraction(req, { + userId, + eventId, + batchWeek: doc.batchWeek, + type: 'external_open', + ...pickInteractionContext(body), + }); + logPivot('info', 'external ticket open recorded', { ...pivotRequestContext(req), eventId, @@ -270,6 +297,14 @@ async function confirmRegistered(req, rawEventId, body = {}) { timeSlotId: slotResolution.timeSlotId, }); + recordPivotInteraction(req, { + userId, + eventId, + batchWeek: doc.batchWeek, + type: 'registered', + ...pickInteractionContext(body), + }); + logPivot('info', 'registration confirmed', { ...pivotRequestContext(req), eventId, diff --git a/backend/services/pivotInteractionService.js b/backend/services/pivotInteractionService.js new file mode 100644 index 00000000..85a0d274 --- /dev/null +++ b/backend/services/pivotInteractionService.js @@ -0,0 +1,465 @@ +const mongoose = require('mongoose'); +const getModels = require('./getModelService'); +const { isValidIsoWeek, toIsoWeek } = require('../utilities/pivotIsoWeek'); +const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger'); +const { + PIVOT_INTERACTION_SURFACES, + PIVOT_INTERACTION_RETRIEVALS, + PIVOT_INTERACTION_TYPES, +} = require('../schemas/pivotInteraction'); + +const SURFACE_SET = new Set(PIVOT_INTERACTION_SURFACES); +const RETRIEVAL_SET = new Set(PIVOT_INTERACTION_RETRIEVALS); +const TYPE_SET = new Set(PIVOT_INTERACTION_TYPES); + +const DEFAULT_SURFACE = 'deck'; +const DEFAULT_RETRIEVAL = 'weekly_batch'; +const MAX_IMPRESSION_BATCH = 50; +const MAX_MICRO_INTERACTION_BATCH = 20; +const MAX_DWELL_MS = 5 * 60 * 1000; +const DEFAULT_IMPRESSION_RANKER_VERSION = 'rules_v0'; + +const MICRO_INTERACTION_TYPES = new Set(['dwell', 'detail_open']); + +function clampDwellMs(ms) { + if (ms == null || !Number.isFinite(Number(ms))) { + return null; + } + return Math.min(Math.max(0, Math.floor(Number(ms))), MAX_DWELL_MS); +} + +/** + * Optional interaction context from route bodies (Task 1.3). + * Invalid surface/retrieval are left as-is here — `normalizePivotInteractionPayload` + * coerces them on write so intent upserts never fail on typo'd tags. + * + * @param {object} body + * @param {{ surface?: string, retrieval?: string }} [defaults] + */ +function pickInteractionContext(body = {}, defaults = {}) { + const ctx = { + surface: body.surface != null && body.surface !== '' + ? body.surface + : defaults.surface || DEFAULT_SURFACE, + retrieval: body.retrieval != null && body.retrieval !== '' + ? body.retrieval + : defaults.retrieval || DEFAULT_RETRIEVAL, + }; + + if (body.rankInFeed != null && Number.isFinite(Number(body.rankInFeed))) { + ctx.rankInFeed = Number(body.rankInFeed); + } + if (typeof body.sessionId === 'string' && body.sessionId.trim()) { + ctx.sessionId = body.sessionId.trim(); + } + if (typeof body.requestId === 'string' && body.requestId.trim()) { + ctx.requestId = body.requestId.trim(); + } + if (typeof body.rankerVersion === 'string' && body.rankerVersion.trim()) { + ctx.rankerVersion = body.rankerVersion.trim(); + } + + return ctx; +} + +/** + * Coerce / validate a raw payload into a create-ready document. + * Invalid `surface` / `retrieval` are coerced to deck defaults (append-only log should not drop rows on typos). + * Invalid `type` or missing userId → returns `{ error }` (caller skips write). + * + * @param {object} payload + * @returns {{ doc: object } | { error: string, code: string }} + */ +function normalizePivotInteractionPayload(payload = {}) { + const type = String(payload.type || '') + .trim() + .toLowerCase(); + if (!TYPE_SET.has(type)) { + return { + error: `Invalid interaction type: ${payload.type}`, + code: 'INVALID_INTERACTION_TYPE', + }; + } + + const userIdRaw = payload.userId; + if (!userIdRaw || !mongoose.Types.ObjectId.isValid(String(userIdRaw))) { + return { + error: 'A valid userId is required.', + code: 'INVALID_USER_ID', + }; + } + + let surface = String(payload.surface || DEFAULT_SURFACE) + .trim() + .toLowerCase(); + if (!SURFACE_SET.has(surface)) { + surface = DEFAULT_SURFACE; + } + + let retrieval = String( + payload.retrieval != null && payload.retrieval !== '' + ? payload.retrieval + : DEFAULT_RETRIEVAL, + ) + .trim() + .toLowerCase(); + if (!RETRIEVAL_SET.has(retrieval)) { + retrieval = DEFAULT_RETRIEVAL; + } + + let batchWeek = + typeof payload.batchWeek === 'string' ? payload.batchWeek.trim() : ''; + if (!isValidIsoWeek(batchWeek)) { + batchWeek = toIsoWeek(new Date()); + } + + const eventIdRaw = payload.eventId; + const eventId = + eventIdRaw && mongoose.Types.ObjectId.isValid(String(eventIdRaw)) + ? String(eventIdRaw) + : null; + + const seedRaw = payload.seedEventId; + const seedEventId = + seedRaw && mongoose.Types.ObjectId.isValid(String(seedRaw)) + ? String(seedRaw) + : null; + + const doc = { + userId: String(userIdRaw), + eventId, + batchWeek, + surface, + retrieval, + type, + }; + + if (payload.rankInFeed != null && Number.isFinite(Number(payload.rankInFeed))) { + doc.rankInFeed = Math.max(0, Math.floor(Number(payload.rankInFeed))); + } + if (payload.ms != null && Number.isFinite(Number(payload.ms))) { + doc.ms = Math.max(0, Math.floor(Number(payload.ms))); + } + if (typeof payload.section === 'string' && payload.section.trim()) { + doc.section = payload.section.trim(); + } + if (typeof payload.query === 'string' && payload.query.trim()) { + doc.query = payload.query.trim(); + } + if (payload.filters != null && typeof payload.filters === 'object') { + doc.filters = payload.filters; + } + if (seedEventId) { + doc.seedEventId = seedEventId; + } + if (typeof payload.requestId === 'string' && payload.requestId.trim()) { + doc.requestId = payload.requestId.trim(); + } + if (typeof payload.rankerVersion === 'string' && payload.rankerVersion.trim()) { + doc.rankerVersion = payload.rankerVersion.trim(); + } + if (typeof payload.sessionId === 'string' && payload.sessionId.trim()) { + doc.sessionId = payload.sessionId.trim(); + } + if (payload.rating != null && Number.isFinite(Number(payload.rating))) { + const rating = Math.round(Number(payload.rating)); + if (rating >= 1 && rating <= 5) { + doc.rating = rating; + } + } + + return { doc }; +} + +/** + * Persist one interaction row. Awaitable for tests / batching. + * Returns the created lean doc, or `{ skipped: true, ... }` / `{ error }` on failure. + */ +async function writePivotInteraction(req, payload = {}) { + const normalized = normalizePivotInteractionPayload(payload); + if (normalized.error) { + return { + error: normalized.error, + code: normalized.code, + skipped: true, + }; + } + + try { + const { PivotInteraction } = getModels(req, 'PivotInteraction'); + const created = await PivotInteraction.create(normalized.doc); + return { data: created.toObject ? created.toObject() : created }; + } catch (err) { + logPivot('error', 'interaction write failed', { + ...pivotRequestContext(req), + type: normalized.doc?.type, + surface: normalized.doc?.surface, + error: err.message, + }); + return { + error: err.message, + code: 'INTERACTION_WRITE_FAILED', + skipped: true, + }; + } +} + +/** + * Fire-and-forget writer for routes. Never throws; never blocks the caller. + * Prefer this from HTTP handlers; use `writePivotInteraction` in tests / jobs. + */ +function recordPivotInteraction(req, payload = {}) { + if (!req) { + return; + } + + const enriched = { ...payload }; + if (!enriched.userId && req.user?.userId) { + enriched.userId = req.user.userId; + } + + setImmediate(() => + writePivotInteraction(req, enriched).catch((err) => { + logPivot('error', 'interaction write unexpected failure', { + ...pivotRequestContext(req), + error: err?.message || String(err), + }); + }), + ); +} + +/** + * Batch deck (or explore) impressions. Schedules fire-and-forget writes; + * returns immediately so swipe UX is never blocked. + * + * Body: `{ impressions: [{ eventId, rankInFeed, batchWeek? }], batchWeek?, sessionId?, requestId? }` + */ +function recordPivotImpressions(req, body = {}) { + const userId = req.user?.userId; + if (!userId) { + return { + error: 'Authentication required.', + status: 401, + code: 'UNAUTHORIZED', + }; + } + + const raw = Array.isArray(body.impressions) ? body.impressions : null; + if (!raw) { + return { + error: 'impressions must be an array.', + status: 400, + code: 'INVALID_IMPRESSIONS', + }; + } + + if (raw.length === 0) { + return { data: { accepted: 0, received: 0, skipped: 0 } }; + } + + if (raw.length > MAX_IMPRESSION_BATCH) { + return { + error: `At most ${MAX_IMPRESSION_BATCH} impressions per request.`, + status: 400, + code: 'IMPRESSION_BATCH_TOO_LARGE', + }; + } + + const defaultBatchWeek = + typeof body.batchWeek === 'string' && isValidIsoWeek(body.batchWeek.trim()) + ? body.batchWeek.trim() + : null; + const sessionId = + typeof body.sessionId === 'string' && body.sessionId.trim() + ? body.sessionId.trim() + : null; + const requestId = + typeof body.requestId === 'string' && body.requestId.trim() + ? body.requestId.trim() + : null; + const rankerVersion = + typeof body.rankerVersion === 'string' && body.rankerVersion.trim() + ? body.rankerVersion.trim() + : DEFAULT_IMPRESSION_RANKER_VERSION; + + let accepted = 0; + let skipped = 0; + + for (const item of raw) { + if (!item || typeof item !== 'object') { + skipped += 1; + continue; + } + + const eventId = String(item.eventId || '').trim(); + if (!mongoose.Types.ObjectId.isValid(eventId)) { + skipped += 1; + continue; + } + + const itemBatchWeek = + typeof item.batchWeek === 'string' && isValidIsoWeek(item.batchWeek.trim()) + ? item.batchWeek.trim() + : defaultBatchWeek; + + if (!itemBatchWeek) { + skipped += 1; + continue; + } + + const rankRaw = item.rankInFeed; + if (rankRaw == null || !Number.isFinite(Number(rankRaw))) { + skipped += 1; + continue; + } + + recordPivotInteraction(req, { + userId, + eventId, + batchWeek: itemBatchWeek, + type: 'impression', + surface: item.surface || DEFAULT_SURFACE, + retrieval: item.retrieval || DEFAULT_RETRIEVAL, + rankInFeed: Number(rankRaw), + rankerVersion: item.rankerVersion || rankerVersion, + sessionId: item.sessionId || sessionId || undefined, + requestId: item.requestId || requestId || undefined, + }); + accepted += 1; + } + + return { + data: { + accepted, + skipped, + received: raw.length, + }, + }; +} + +/** + * Batch dwell / detail_open rows (Task 4.3). Fire-and-forget writes. + * + * Body: `{ interactions: [{ eventId, type, ms?, batchWeek?, surface?, retrieval?, rankInFeed? }], batchWeek?, rankerVersion? }` + */ +function recordPivotMicroInteractions(req, body = {}) { + const userId = req.user?.userId; + if (!userId) { + return { + error: 'Authentication required.', + status: 401, + code: 'UNAUTHORIZED', + }; + } + + const raw = Array.isArray(body.interactions) ? body.interactions : null; + if (!raw) { + return { + error: 'interactions must be an array.', + status: 400, + code: 'INVALID_MICRO_INTERACTIONS', + }; + } + + if (raw.length === 0) { + return { data: { accepted: 0, received: 0, skipped: 0 } }; + } + + if (raw.length > MAX_MICRO_INTERACTION_BATCH) { + return { + error: `At most ${MAX_MICRO_INTERACTION_BATCH} interactions per request.`, + status: 400, + code: 'MICRO_INTERACTION_BATCH_TOO_LARGE', + }; + } + + const defaultBatchWeek = + typeof body.batchWeek === 'string' && isValidIsoWeek(body.batchWeek.trim()) + ? body.batchWeek.trim() + : null; + const rankerVersion = + typeof body.rankerVersion === 'string' && body.rankerVersion.trim() + ? body.rankerVersion.trim() + : DEFAULT_IMPRESSION_RANKER_VERSION; + + let accepted = 0; + let skipped = 0; + + for (const item of raw) { + if (!item || typeof item !== 'object') { + skipped += 1; + continue; + } + + const type = String(item.type || '') + .trim() + .toLowerCase(); + if (!MICRO_INTERACTION_TYPES.has(type)) { + skipped += 1; + continue; + } + + const eventId = String(item.eventId || '').trim(); + if (!mongoose.Types.ObjectId.isValid(eventId)) { + skipped += 1; + continue; + } + + const itemBatchWeek = + typeof item.batchWeek === 'string' && isValidIsoWeek(item.batchWeek.trim()) + ? item.batchWeek.trim() + : defaultBatchWeek; + + if (!itemBatchWeek) { + skipped += 1; + continue; + } + + const ms = type === 'dwell' ? clampDwellMs(item.ms) : null; + if (type === 'dwell' && (ms == null || ms <= 0)) { + skipped += 1; + continue; + } + + recordPivotInteraction(req, { + userId, + eventId, + batchWeek: itemBatchWeek, + type, + surface: item.surface || DEFAULT_SURFACE, + retrieval: item.retrieval || DEFAULT_RETRIEVAL, + rankInFeed: item.rankInFeed, + rankerVersion: item.rankerVersion || rankerVersion, + ms: ms ?? undefined, + sessionId: item.sessionId, + requestId: item.requestId, + }); + accepted += 1; + } + + return { + data: { + accepted, + skipped, + received: raw.length, + }, + }; +} + +module.exports = { + normalizePivotInteractionPayload, + writePivotInteraction, + recordPivotInteraction, + recordPivotImpressions, + recordPivotMicroInteractions, + pickInteractionContext, + PIVOT_INTERACTION_SURFACES, + PIVOT_INTERACTION_RETRIEVALS, + PIVOT_INTERACTION_TYPES, + DEFAULT_SURFACE, + DEFAULT_RETRIEVAL, + MAX_IMPRESSION_BATCH, + MAX_MICRO_INTERACTION_BATCH, + MAX_DWELL_MS, + DEFAULT_IMPRESSION_RANKER_VERSION, +}; diff --git a/backend/services/pivotLabEventsService.js b/backend/services/pivotLabEventsService.js index 65160546..71d0c307 100644 --- a/backend/services/pivotLabEventsService.js +++ b/backend/services/pivotLabEventsService.js @@ -6,6 +6,7 @@ const { normalizeBatchWeek, } = require('./pivotWeeklySnapshotService'); const { serializePivotMovie } = require('../utilities/pivotMovieMetadata'); +const { serializePivotEnrichment } = require('../utilities/pivotEnrichment'); function labEventsQuery(batchWeek) { return { @@ -27,6 +28,7 @@ function serializeLabEvent(event, intentStatsByEventId) { const pivot = event.customFields?.pivot || {}; const host = pivot.host || {}; const movie = serializePivotMovie(pivot.movie); + const enrichment = serializePivotEnrichment(pivot); const timeSlots = Array.isArray(pivot.timeSlots) ? pivot.timeSlots.map((slot) => ({ id: slot.id, @@ -52,6 +54,7 @@ function serializeLabEvent(event, intentStatsByEventId) { tags: Array.isArray(pivot.tags) ? pivot.tags : [], timeSlots, ...(movie ? { movie } : {}), + ...(enrichment ? { enrichment } : {}), organizerName: host.name || '', organizerImageUrl: host.imageUrl || null, intentStats: intentStatsByEventId?.get(String(event._id)) || EMPTY_INTENT_STATS, diff --git a/backend/services/pivotTagCatalogService.js b/backend/services/pivotTagCatalogService.js index 48432a30..944b3ea4 100644 --- a/backend/services/pivotTagCatalogService.js +++ b/backend/services/pivotTagCatalogService.js @@ -54,7 +54,10 @@ async function validatePivotEventTags(req, rawTags, options = {}) { } } - const slugSet = await loadCatalogSlugSet(req, { includeInactive: !activeOnly }); + const slugSet = + options.catalogSlugSet instanceof Set + ? options.catalogSlugSet + : await loadCatalogSlugSet(req, { includeInactive: !activeOnly }); const unknown = tags.filter((slug) => !slugSet.has(slug)); if (unknown.length) { return { diff --git a/backend/services/pivotTagSuggestService.js b/backend/services/pivotTagSuggestService.js index 038d836b..e25140ec 100644 --- a/backend/services/pivotTagSuggestService.js +++ b/backend/services/pivotTagSuggestService.js @@ -8,6 +8,9 @@ const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages'; const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-4-6'; const DEFAULT_MAX_TOKENS = 256; const REQUEST_TIMEOUT_MS = 20_000; +/** Cap in-flight Claude calls — memory is tiny; rate limits are the constraint. */ +const DEFAULT_BATCH_CONCURRENCY = 4; +const MAX_BATCH_CONCURRENCY = 8; const LOG_PREFIX = '[pivotTagSuggest]'; function logSuggest(level, message, meta) { @@ -37,6 +40,42 @@ function resolveClaudeModel() { return process.env.CLAUDE_MODEL || process.env.ANTHROPIC_MODEL || DEFAULT_CLAUDE_MODEL; } +function resolveBatchConcurrency(override) { + if (Number.isFinite(override) && override > 0) { + return Math.min(MAX_BATCH_CONCURRENCY, Math.max(1, Math.floor(override))); + } + const fromEnv = Number.parseInt( + process.env.PIVOT_TAG_SUGGEST_CONCURRENCY || '', + 10, + ); + if (Number.isFinite(fromEnv) && fromEnv > 0) { + return Math.min(MAX_BATCH_CONCURRENCY, Math.max(1, fromEnv)); + } + return DEFAULT_BATCH_CONCURRENCY; +} + +/** + * Run async work over items with a fixed worker pool (order-preserving results). + * Prefer this over unbounded Promise.all for external APIs with rate limits. + */ +async function mapWithConcurrency(items, concurrency, mapper) { + const list = Array.isArray(items) ? items : []; + const results = new Array(list.length); + let nextIndex = 0; + + async function worker() { + while (nextIndex < list.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await mapper(list[index], index); + } + } + + const workerCount = Math.min(Math.max(1, concurrency), Math.max(list.length, 1)); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return results; +} + function trimString(value) { return typeof value === 'string' ? value.trim() : ''; } @@ -180,18 +219,31 @@ async function suggestPivotEventTags(req, rawEvent = {}, options = {}) { }; } - const catalogResult = await listPivotTags(req); - if (catalogResult.error) { - logSuggest('warn', 'catalog lookup failed', { - message: catalogResult.error, - code: catalogResult.code, - hasGlobalDb: Boolean(req.globalDb), - }); - return catalogResult; + let catalogTags = Array.isArray(options.catalogTags) ? options.catalogTags : null; + let catalogSlugSet = + options.catalogSlugSet instanceof Set ? options.catalogSlugSet : null; + + if (!catalogTags) { + const catalogResult = await listPivotTags(req); + if (catalogResult.error) { + logSuggest('warn', 'catalog lookup failed', { + message: catalogResult.error, + code: catalogResult.code, + hasGlobalDb: Boolean(req.globalDb), + }); + return catalogResult; + } + catalogTags = catalogResult.data?.tags || []; } - const catalogTags = catalogResult.data?.tags || []; - logSuggest('info', 'catalog loaded', { activeTagCount: catalogTags.length }); + if (!catalogSlugSet && catalogTags.length) { + catalogSlugSet = new Set(catalogTags.map((tag) => tag.slug).filter(Boolean)); + } + + logSuggest('info', 'catalog loaded', { + activeTagCount: catalogTags.length, + sharedCatalog: Boolean(options.catalogTags), + }); if (!catalogTags.length) { logSuggest('warn', 'catalog empty'); return { @@ -207,7 +259,10 @@ async function suggestPivotEventTags(req, rawEvent = {}, options = {}) { const rawTags = parseTagsFromClaudeText(responseText); logSuggest('info', 'parsed claude tags', { rawTags }); - const validated = await validatePivotEventTags(req, rawTags, { required: true }); + const validated = await validatePivotEventTags(req, rawTags, { + required: true, + ...(catalogSlugSet ? { catalogSlugSet } : {}), + }); if (validated.error) { logSuggest('warn', 'tag validation failed', { code: validated.code, @@ -218,7 +273,11 @@ async function suggestPivotEventTags(req, rawEvent = {}, options = {}) { error: validated.error, status: validated.status, code: validated.code, - data: { rawTags, model: resolveClaudeModel(), responsePreview: truncateForLog(responseText, 240) }, + data: { + rawTags, + model: resolveClaudeModel(), + responsePreview: truncateForLog(responseText, 240), + }, }; } @@ -247,7 +306,10 @@ async function suggestPivotEventTags(req, rawEvent = {}, options = {}) { const status = err.response?.status; if (status === 404) { const modelMessage = err.response?.data?.error?.message || ''; - logSuggest('warn', 'claude model not found', { model: resolveClaudeModel(), modelMessage }); + logSuggest('warn', 'claude model not found', { + model: resolveClaudeModel(), + modelMessage, + }); return { error: modelMessage.includes('model:') ? `Claude model not found (${resolveClaudeModel()}). Set CLAUDE_MODEL to a current ID such as claude-sonnet-4-6 or claude-haiku-4-5-20251001.` @@ -259,11 +321,20 @@ async function suggestPivotEventTags(req, rawEvent = {}, options = {}) { if (status === 401 || status === 403) { logSuggest('warn', 'claude auth failed', { status }); return { - error: 'Claude API rejected the API key.', + error: 'Claude API authentication failed. Check ANTHROPIC_API_KEY.', status: 502, code: 'LLM_AUTH_FAILED', }; } + if (status === 429) { + logSuggest('warn', 'claude rate limited', { status }); + return { + error: + 'Claude rate limit hit. Retry shortly or lower PIVOT_TAG_SUGGEST_CONCURRENCY.', + status: 429, + code: 'LLM_RATE_LIMITED', + }; + } logSuggest('error', 'claude request failed', { status, @@ -278,9 +349,13 @@ async function suggestPivotEventTags(req, rawEvent = {}, options = {}) { } } -async function suggestPivotEventTagsBatch(req, rawEvents = []) { +async function suggestPivotEventTagsBatch(req, rawEvents = [], options = {}) { const events = Array.isArray(rawEvents) ? rawEvents : []; - logSuggest('info', 'batch suggest start', { eventCount: events.length }); + const concurrency = resolveBatchConcurrency(options.concurrency); + logSuggest('info', 'batch suggest start', { + eventCount: events.length, + concurrency, + }); if (!events.length) { return { error: 'At least one event is required.', @@ -289,32 +364,86 @@ async function suggestPivotEventTagsBatch(req, rawEvents = []) { }; } - const suggestions = []; - const failures = []; - - for (let index = 0; index < events.length; index += 1) { - const rawEvent = events[index]; - const result = await suggestPivotEventTags(req, rawEvent, { batchIndex: index }); - if (result.error) { - failures.push({ - index, - name: trimString(rawEvent?.name) || null, - message: result.error, - code: result.code, - }); - suggestions.push({ tags: [] }); - continue; - } + const apiKey = resolveAnthropicApiKey(); + if (!apiKey) { + logSuggest('warn', 'missing anthropic api key'); + return { + error: + 'Tag suggestion requires ANTHROPIC_API_KEY (or CLAUDE_API_KEY) in the environment.', + status: 503, + code: 'LLM_NOT_CONFIGURED', + data: { + suggestions: events.map(() => ({ tags: [] })), + failures: events.map((rawEvent, index) => ({ + index, + name: trimString(rawEvent?.name) || null, + message: + 'Tag suggestion requires ANTHROPIC_API_KEY (or CLAUDE_API_KEY) in the environment.', + code: 'LLM_NOT_CONFIGURED', + })), + suggestedCount: 0, + failedCount: events.length, + }, + }; + } - suggestions.push({ tags: result.data.tags, model: result.data.model }); + const catalogResult = await listPivotTags(req); + if (catalogResult.error) { + return catalogResult; } + const catalogTags = catalogResult.data?.tags || []; + if (!catalogTags.length) { + return { + error: 'Pivot tag catalog is empty. Run seed:pivot-tag-catalog first.', + status: 503, + code: 'TAG_CATALOG_EMPTY', + }; + } + const catalogSlugSet = new Set( + catalogTags.map((tag) => tag.slug).filter(Boolean), + ); + + const perEvent = await mapWithConcurrency( + events, + concurrency, + async (rawEvent, index) => { + const result = await suggestPivotEventTags(req, rawEvent, { + batchIndex: index, + catalogTags, + catalogSlugSet, + }); + if (result.error) { + return { + suggestion: { tags: [] }, + failure: { + index, + name: trimString(rawEvent?.name) || null, + message: result.error, + code: result.code, + }, + }; + } + return { + suggestion: { tags: result.data.tags, model: result.data.model }, + failure: null, + }; + }, + ); + + const suggestions = perEvent.map((entry) => entry.suggestion); + const failures = perEvent.map((entry) => entry.failure).filter(Boolean); const suggestedCount = suggestions.filter((entry) => entry.tags?.length).length; logSuggest('info', 'batch suggest complete', { eventCount: events.length, + concurrency, suggestedCount, failedCount: failures.length, - failures: failures.map((entry) => ({ index: entry.index, code: entry.code, name: entry.name })), + failures: failures.map((entry) => ({ + index: entry.index, + code: entry.code, + name: entry.name, + })), }); if (suggestedCount === 0 && failures.length > 0) { @@ -337,6 +466,212 @@ async function suggestPivotEventTagsBatch(req, rawEvents = []) { failures, suggestedCount, failedCount: failures.length, + concurrency, + }, + }; +} + +function eventHasTags(pivotTags) { + if (!Array.isArray(pivotTags)) { + return false; + } + return pivotTags.some((tag) => typeof tag === 'string' && tag.trim()); +} + +/** + * Server-side suggest + apply: Claude suggestions are written to catalog events + * in the same request so a dropped client connection cannot leave tags unapplied + * after Claude already ran. + */ +async function suggestAndApplyPivotEventTags(req, options = {}) { + const { + resolvePivotTenant, + updateIngestEvent, + } = require('./pivotIngestPublishService'); + const { connectToDatabase } = require('../connectionsManager'); + const getModels = require('./getModelService'); + const mongoose = require('mongoose'); + + const tenantKey = trimString(options.tenantKey); + if (!tenantKey) { + return { + error: 'tenantKey is required.', + status: 400, + code: 'TENANT_KEY_REQUIRED', + }; + } + + const rawIds = Array.isArray(options.eventIds) ? options.eventIds : []; + const eventIds = [ + ...new Set( + rawIds + .map((id) => trimString(id)) + .filter((id) => id && mongoose.Types.ObjectId.isValid(id)), + ), + ]; + if (!eventIds.length) { + return { + error: 'At least one valid eventId is required.', + status: 400, + code: 'EVENT_IDS_REQUIRED', + }; + } + + const onlyTagless = options.onlyTagless !== false; + const applyConcurrency = resolveBatchConcurrency(options.applyConcurrency); + + const tenantResult = await resolvePivotTenant(req, tenantKey); + if (tenantResult.error) { + return tenantResult; + } + + const db = await connectToDatabase(tenantResult.tenant.tenantKey); + const { Event } = getModels({ db }, 'Event'); + const rows = await Event.find({ + _id: { $in: eventIds }, + isDeleted: { $ne: true }, + 'customFields.pivot': { $exists: true }, + }) + .select('name description location customFields.pivot') + .lean(); + + const byId = new Map(rows.map((row) => [String(row._id), row])); + const ordered = eventIds.map((id) => byId.get(id)).filter(Boolean); + + const targets = onlyTagless + ? ordered.filter((row) => !eventHasTags(row.customFields?.pivot?.tags)) + : ordered; + + logSuggest('info', 'suggest-and-apply start', { + tenantKey, + requestedCount: eventIds.length, + foundCount: ordered.length, + targetCount: targets.length, + onlyTagless, + }); + + if (!targets.length) { + return { + data: { + attempted: 0, + updated: 0, + failed: 0, + skipped: eventIds.length, + results: [], + }, + }; + } + + const suggestPayloads = targets.map((row) => { + const pivot = row.customFields?.pivot || {}; + return { + name: row.name, + description: row.description, + location: row.location, + hostName: pivot.host?.name, + sourceTags: Array.isArray(pivot.sourceTags) ? pivot.sourceTags : undefined, + }; + }); + + const suggestResult = await suggestPivotEventTagsBatch(req, suggestPayloads, { + concurrency: options.concurrency, + }); + if ( + suggestResult.error && + !(Number(suggestResult.data?.suggestedCount) > 0) + ) { + return suggestResult; + } + + const suggestions = suggestResult.data?.suggestions || []; + const applyJobs = []; + const results = []; + + for (let index = 0; index < targets.length; index += 1) { + const row = targets[index]; + const eventId = String(row._id); + const tags = (suggestions[index]?.tags || []).filter( + (tag) => typeof tag === 'string' && tag.trim(), + ); + if (!tags.length) { + results.push({ + eventId, + name: row.name || null, + status: 'failed', + code: 'NO_TAGS_SUGGESTED', + tags: [], + }); + continue; + } + applyJobs.push({ eventId, name: row.name || null, tags, resultIndex: results.length }); + results.push({ + eventId, + name: row.name || null, + status: 'pending', + tags, + }); + } + + await mapWithConcurrency(applyJobs, applyConcurrency, async (job) => { + const patch = await updateIngestEvent(req, { + tenantKey, + eventId: job.eventId, + overrides: { tags: job.tags }, + }); + if (patch.error) { + results[job.resultIndex] = { + eventId: job.eventId, + name: job.name, + status: 'failed', + code: patch.code || 'APPLY_FAILED', + message: patch.error, + tags: job.tags, + }; + return; + } + results[job.resultIndex] = { + eventId: job.eventId, + name: job.name, + status: 'updated', + tags: job.tags, + }; + }); + + const updated = results.filter((entry) => entry.status === 'updated').length; + const failed = results.filter((entry) => entry.status === 'failed').length; + + logSuggest('info', 'suggest-and-apply complete', { + tenantKey, + attempted: targets.length, + updated, + failed, + }); + + if (updated === 0 && failed > 0) { + return { + error: results.find((entry) => entry.message)?.message || 'Unable to suggest and apply tags.', + status: suggestResult.status || 400, + code: results.find((entry) => entry.code)?.code || 'SUGGEST_AND_APPLY_FAILED', + data: { + attempted: targets.length, + updated: 0, + failed, + skipped: eventIds.length - targets.length, + results, + suggestFailures: suggestResult.data?.failures || [], + }, + }; + } + + return { + data: { + attempted: targets.length, + updated, + failed, + skipped: eventIds.length - targets.length, + results, + suggestFailures: suggestResult.data?.failures || [], + concurrency: suggestResult.data?.concurrency, }, }; } @@ -344,7 +679,10 @@ async function suggestPivotEventTagsBatch(req, rawEvents = []) { module.exports = { suggestPivotEventTags, suggestPivotEventTagsBatch, + suggestAndApplyPivotEventTags, buildTagSuggestionPrompt, parseTagsFromClaudeText, resolveAnthropicApiKey, + resolveBatchConcurrency, + mapWithConcurrency, }; diff --git a/backend/services/pivotTenantOpsService.js b/backend/services/pivotTenantOpsService.js index 26bbabb4..82beafce 100644 --- a/backend/services/pivotTenantOpsService.js +++ b/backend/services/pivotTenantOpsService.js @@ -17,13 +17,12 @@ const { const { listPivotLabEvents } = require('./pivotLabEventsService'); const { listCurationJobs } = require('./pivotCurationJobService'); const { buildDropSchedulePayload } = require('./pivotConfigService'); -const { resolvePivotDropInstant } = require('../utilities/pivotDropSchedule'); const { - toIsoWeek, - shiftIsoWeek, - isValidIsoWeek, - isoWeekToUtcRange, -} = require('../utilities/pivotIsoWeek'); + resolvePivotDropConfig, + resolvePivotStageAnchors, + resolveStageForBatchWeek, +} = require('../utilities/pivotDropSchedule'); +const { formatBatchWeekRangeLabel, shiftIsoWeek } = require('../utilities/pivotIsoWeek'); const ALL_SECTIONS = Object.freeze([ 'overview', @@ -47,68 +46,14 @@ const PRESETS = Object.freeze({ const DEFAULT_PERFORMANCE_LIMIT = 10; const CURATION_PERFORMANCE_LIMIT = 100; -function formatIsoWeekRangeLabel(batchWeek) { - let range; - try { - range = isoWeekToUtcRange(batchWeek); - } catch { - return null; +function curationSectionsForStage(stage, { releaseWindow = false } = {}) { + if (releaseWindow) { + return ['overview', 'readiness', 'catalog', 'jobs']; } - const { start, end } = range; - const lastDay = new Date(end.getTime() - 1); - const sameYear = start.getUTCFullYear() === lastDay.getUTCFullYear(); - const startLabel = start.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - ...(sameYear ? {} : { year: 'numeric' }), - timeZone: 'UTC', - }); - const endLabel = lastDay.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - timeZone: 'UTC', - }); - return `${startLabel} – ${endLabel}`; -} - -/** - * Derive live / curate weeks from now + current ISO week's drop instant. - */ -function resolveStageAnchors(tenant, now = new Date()) { - const currentWeek = toIsoWeek(now); - let dropPending = false; - let currentWeekDropAt = null; - try { - const currentDrop = resolvePivotDropInstant(tenant, currentWeek, now); - currentWeekDropAt = currentDrop.dropAt.toISOString(); - dropPending = currentDrop.dropAt.getTime() > now.getTime(); - } catch { - dropPending = false; + if (stage === 'live') { + return ['overview', 'performance', 'journey', 'readiness', 'catalog', 'jobs']; } - - const liveWeek = dropPending ? shiftIsoWeek(currentWeek, -1) : currentWeek; - const curateWeek = dropPending ? currentWeek : shiftIsoWeek(currentWeek, 1); - - return { - currentWeek, - liveWeek, - curateWeek, - postMortemWeek: shiftIsoWeek(liveWeek, -1), - dropPending, - currentWeekDropAt, - }; -} - -function resolveStageForWeek(batchWeek, anchors) { - if (!isValidIsoWeek(batchWeek) || !anchors?.liveWeek) return 'curate'; - if (batchWeek === anchors.liveWeek) return 'live'; - if (batchWeek > anchors.liveWeek) return 'curate'; - return 'post-mortem'; -} - -function curationSectionsForStage(stage) { - if (stage === 'live' || stage === 'post-mortem') { + if (stage === 'post-mortem') { return ['overview', 'performance', 'journey']; } return ['overview', 'readiness', 'catalog', 'jobs']; @@ -118,7 +63,7 @@ function curationSectionsForStage(stage) { * Parse include query into a unique ordered section list. * Accepts presets (`overview`, `journeys`, `curation`) and/or section names. */ -function parseInclude(raw, { stage } = {}) { +function parseInclude(raw, { stage, releaseWindow = false } = {}) { const tokens = String(raw || '') .split(',') .map((part) => part.trim().toLowerCase()) @@ -151,7 +96,7 @@ function parseInclude(raw, { stage } = {}) { for (const token of tokens) { if (token === 'curation' || token === '__curation__') { - for (const name of curationSectionsForStage(stage || 'curate')) { + for (const name of curationSectionsForStage(stage || 'curate', { releaseWindow })) { const err = push(name); if (err) return err; } @@ -198,20 +143,28 @@ function wants(sections, name) { */ async function getTenantOpsBundle(req, options = {}) { const now = options.now || new Date(); - const normalized = normalizeBatchWeek(options.batchWeek, now); - if (normalized.error) return normalized; const tenantResult = await resolvePivotTenant(req, options.tenantKey); if (tenantResult.error) return tenantResult; - const { batchWeek } = normalized; const { tenant } = tenantResult; const tenantKey = tenant.tenantKey; - const anchors = resolveStageAnchors(tenant, now); - const stage = resolveStageForWeek(batchWeek, anchors); + const anchors = resolvePivotStageAnchors(tenant, now); + const defaultBatchWeek = anchors.liveWeek; + const normalized = normalizeBatchWeek( + options.batchWeek?.trim() || defaultBatchWeek, + now, + ); + if (normalized.error) return normalized; + + const { batchWeek } = normalized; + const dropConfig = resolvePivotDropConfig(tenant); + const stage = resolveStageForBatchWeek(batchWeek, tenant, now); + const releaseWindow = + Boolean(anchors.dropPending) && batchWeek === anchors.curateWeek; const includeRaw = options.include; - const parsed = parseInclude(includeRaw, { stage }); + const parsed = parseInclude(includeRaw, { stage, releaseWindow }); if (parsed.error) return parsed; const { sections } = parsed; @@ -233,7 +186,10 @@ async function getTenantOpsBundle(req, options = {}) { dropSchedule = null; } - const weekRangeLabel = formatIsoWeekRangeLabel(batchWeek); + const weekRangeLabel = formatBatchWeekRangeLabel(batchWeek, { + dropDayOfWeek: dropConfig.dayOfWeek, + timeZone: dropConfig.timezone, + }); const tasks = {}; @@ -311,14 +267,19 @@ async function getTenantOpsBundle(req, options = {}) { cityDisplayName: tenant.location || tenant.name || tenantKey, batchWeek, stage, + releaseWindow, anchors: { liveWeek: anchors.liveWeek, curateWeek: anchors.curateWeek, postMortemWeek: anchors.postMortemWeek, currentWeek: anchors.currentWeek, + dropPending: anchors.dropPending, + currentWeekDropAt: anchors.currentWeekDropAt, }, weekRange: { label: weekRangeLabel, + dropDayOfWeek: dropConfig.dayOfWeek, + timeZone: dropConfig.timezone, }, dropSchedule, include: sections, @@ -341,10 +302,10 @@ async function getTenantOpsBundle(req, options = {}) { module.exports = { getTenantOpsBundle, parseInclude, - resolveStageAnchors, - resolveStageForWeek, + resolveStageAnchors: resolvePivotStageAnchors, + resolveStageForWeek: resolveStageForBatchWeek, curationSectionsForStage, - formatIsoWeekRangeLabel, + formatIsoWeekRangeLabel: formatBatchWeekRangeLabel, ALL_SECTIONS, PRESETS, }; diff --git a/backend/services/platformAdminInviteService.js b/backend/services/platformAdminInviteService.js new file mode 100644 index 00000000..cab426e0 --- /dev/null +++ b/backend/services/platformAdminInviteService.js @@ -0,0 +1,288 @@ +/** + * Platform admin nominations with manual approval. + * Nominating never grants PlatformRole; Approve does. + */ +const getGlobalModels = require('./getGlobalModelService'); + +const OPEN_STATUSES = ['pending_signup', 'ready_for_approval']; + +function normalizeEmail(value) { + return String(value || '') + .trim() + .toLowerCase(); +} + +function actorId(req) { + return req.user?.globalUserId || req.user?.userId || null; +} + +async function hasActivePlatformAdmin(PlatformRole, globalUserId) { + if (!globalUserId) return false; + const pr = await PlatformRole.findOne({ globalUserId }).lean(); + const roles = Array.isArray(pr?.roles) ? pr.roles : []; + return roles.includes('platform_admin') || roles.includes('root'); +} + +function serializeNomination(invite, globalUser) { + return { + id: String(invite._id), + email: invite.email, + status: invite.status, + globalUserId: invite.globalUserId || globalUser?._id || null, + name: globalUser?.name || null, + picture: globalUser?.picture || null, + invitedBy: invite.invitedBy || null, + createdAt: invite.createdAt || null, + updatedAt: invite.updatedAt || null, + }; +} + +function serializeAdmin(roleRow, globalUser) { + return { + globalUserId: roleRow.globalUserId, + email: globalUser?.email || null, + name: globalUser?.name || null, + picture: globalUser?.picture || null, + }; +} + +/** + * List active platform admins + open nominations. + */ +async function listPlatformAdmins(req) { + const { PlatformRole, GlobalUser, PlatformAdminInvite } = getGlobalModels( + req, + 'PlatformRole', + 'GlobalUser', + 'PlatformAdminInvite', + ); + + const roles = await PlatformRole.find({ + roles: { $in: ['platform_admin', 'root'] }, + }).lean(); + + const nominations = await PlatformAdminInvite.find({ + status: { $in: OPEN_STATUSES }, + }) + .sort({ createdAt: -1 }) + .lean(); + + const userIds = [ + ...roles.map((r) => r.globalUserId), + ...nominations.map((n) => n.globalUserId).filter(Boolean), + ]; + const users = await GlobalUser.find({ _id: { $in: userIds } }) + .select('email name picture') + .lean(); + const byId = users.reduce((acc, u) => { + acc[u._id.toString()] = u; + return acc; + }, {}); + + return { + admins: roles.map((r) => + serializeAdmin(r, byId[r.globalUserId.toString()]), + ), + nominations: nominations.map((n) => + serializeNomination( + n, + n.globalUserId ? byId[n.globalUserId.toString()] : null, + ), + ), + }; +} + +/** + * Nominate an email. Never grants PlatformRole. + */ +async function nominatePlatformAdmin(req, { email: rawEmail }) { + const email = normalizeEmail(rawEmail); + if (!email || !email.includes('@')) { + return { + error: 'A valid email is required.', + status: 400, + code: 'INVALID_EMAIL', + }; + } + + const { PlatformRole, GlobalUser, PlatformAdminInvite } = getGlobalModels( + req, + 'PlatformRole', + 'GlobalUser', + 'PlatformAdminInvite', + ); + + const globalUser = await GlobalUser.findOne({ email }).lean(); + if (globalUser && (await hasActivePlatformAdmin(PlatformRole, globalUser._id))) { + return { + error: 'This user is already a platform admin.', + status: 409, + code: 'ALREADY_PLATFORM_ADMIN', + }; + } + + let invite = await PlatformAdminInvite.findOne({ + email, + status: { $in: OPEN_STATUSES }, + }); + + const nextStatus = globalUser ? 'ready_for_approval' : 'pending_signup'; + const invitedBy = actorId(req); + + if (invite) { + invite.status = nextStatus; + invite.globalUserId = globalUser?._id || invite.globalUserId || null; + if (invitedBy) invite.invitedBy = invitedBy; + await invite.save(); + } else { + invite = await PlatformAdminInvite.create({ + email, + status: nextStatus, + globalUserId: globalUser?._id || null, + invitedBy, + }); + } + + return { + data: serializeNomination(invite.toObject ? invite.toObject() : invite, globalUser), + }; +} + +/** + * Approve a ready nomination — grants platform_admin. + */ +async function approvePlatformAdminInvite(req, { inviteId }) { + const id = String(inviteId || '').trim(); + if (!id) { + return { error: 'inviteId is required.', status: 400, code: 'INVALID_INVITE' }; + } + + const { PlatformRole, GlobalUser, PlatformAdminInvite } = getGlobalModels( + req, + 'PlatformRole', + 'GlobalUser', + 'PlatformAdminInvite', + ); + + const invite = await PlatformAdminInvite.findById(id); + if (!invite || invite.status === 'revoked' || invite.status === 'approved') { + return { + error: 'Nomination not found or no longer open.', + status: 404, + code: 'INVITE_NOT_FOUND', + }; + } + + if (invite.status !== 'ready_for_approval') { + return { + error: 'This nomination is still awaiting signup.', + status: 409, + code: 'NOT_READY_FOR_APPROVAL', + }; + } + + let globalUser = null; + if (invite.globalUserId) { + globalUser = await GlobalUser.findById(invite.globalUserId); + } + if (!globalUser) { + globalUser = await GlobalUser.findOne({ email: invite.email }); + } + if (!globalUser) { + return { + error: 'Global user not found for this nomination.', + status: 404, + code: 'GLOBAL_USER_NOT_FOUND', + }; + } + + let pr = await PlatformRole.findOne({ globalUserId: globalUser._id }); + if (!pr) { + pr = new PlatformRole({ globalUserId: globalUser._id, roles: [] }); + } + if (!pr.roles.includes('platform_admin')) { + pr.roles.push('platform_admin'); + await pr.save(); + } + + invite.status = 'approved'; + invite.globalUserId = globalUser._id; + invite.approvedBy = actorId(req); + invite.approvedAt = new Date(); + await invite.save(); + + return { + data: { + globalUserId: globalUser._id, + email: globalUser.email, + name: globalUser.name || null, + inviteId: String(invite._id), + }, + }; +} + +/** + * Cancel an open nomination. + */ +async function revokePlatformAdminInvite(req, { inviteId }) { + const id = String(inviteId || '').trim(); + if (!id) { + return { error: 'inviteId is required.', status: 400, code: 'INVALID_INVITE' }; + } + + const { PlatformAdminInvite } = getGlobalModels(req, 'PlatformAdminInvite'); + const invite = await PlatformAdminInvite.findById(id); + if (!invite || !OPEN_STATUSES.includes(invite.status)) { + return { + error: 'Open nomination not found.', + status: 404, + code: 'INVITE_NOT_FOUND', + }; + } + + invite.status = 'revoked'; + invite.revokedAt = new Date(); + await invite.save(); + + return { + data: { + id: String(invite._id), + email: invite.email, + status: invite.status, + }, + }; +} + +/** + * When a GlobalUser is created/linked, mark matching pending_signup invites ready. + * Does not grant PlatformRole. + */ +async function markPlatformAdminInvitesReadyForEmail(req, { email: rawEmail, globalUserId }) { + const email = normalizeEmail(rawEmail); + if (!email || !globalUserId) { + return { updated: 0 }; + } + + const { PlatformAdminInvite } = getGlobalModels(req, 'PlatformAdminInvite'); + const result = await PlatformAdminInvite.updateMany( + { email, status: 'pending_signup' }, + { + $set: { + status: 'ready_for_approval', + globalUserId, + }, + }, + ); + + return { updated: result.modifiedCount || result.nModified || 0 }; +} + +module.exports = { + listPlatformAdmins, + nominatePlatformAdmin, + approvePlatformAdminInvite, + revokePlatformAdminInvite, + markPlatformAdminInvitesReadyForEmail, + normalizeEmail, + OPEN_STATUSES, +}; diff --git a/backend/tests/route-outcomes/adminPlatformAdmins.outcomes.test.js b/backend/tests/route-outcomes/adminPlatformAdmins.outcomes.test.js new file mode 100644 index 00000000..739147fe --- /dev/null +++ b/backend/tests/route-outcomes/adminPlatformAdmins.outcomes.test.js @@ -0,0 +1,169 @@ +const express = require('express'); +const request = require('supertest'); + +jest.mock('../../middlewares/verifyToken', () => ({ + verifyToken: (req, _res, next) => { + req.user = req.user || { + userId: 'u1', + globalUserId: 'gu-actor', + platformRoles: ['platform_admin'], + }; + next(); + }, + authorizeRoles: () => (_req, _res, next) => next(), +})); + +jest.mock('../../middlewares/requireAdmin', () => ({ + requireAdmin: (req, res, next) => next(), +})); + +jest.mock('../../middlewares/requirePlatformAdmin', () => ({ + requirePlatformAdmin: (req, res, next) => { + const roles = req.user?.platformRoles || []; + if (!roles.includes('platform_admin') && !roles.includes('root')) { + return res.status(403).json({ success: false, message: 'Platform admin required.' }); + } + return next(); + }, +})); + +jest.mock('../../services/platformAdminInviteService', () => ({ + listPlatformAdmins: jest.fn(), + nominatePlatformAdmin: jest.fn(), + approvePlatformAdminInvite: jest.fn(), + revokePlatformAdminInvite: jest.fn(), +})); + +jest.mock('../../services/getGlobalModelService', () => jest.fn()); +jest.mock('../../connectionsManager', () => ({ + connectToDatabase: jest.fn(), +})); +jest.mock('../../socket', () => ({ + getConnections: jest.fn(() => []), + disconnectSocket: jest.fn(), + disconnectAll: jest.fn(), +})); +jest.mock('../../utilities/sessionUtils', () => ({ + createSession: jest.fn(), +})); +jest.mock('../../utilities/cookieUtils', () => ({ + getCookieDomain: jest.fn(() => undefined), +})); + +const { + listPlatformAdmins, + nominatePlatformAdmin, + approvePlatformAdminInvite, + revokePlatformAdminInvite, +} = require('../../services/platformAdminInviteService'); + +const adminRoutes = require('../../routes/adminRoutes'); + +function buildApp(userOverrides = {}) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.school = 'nyc'; + req.globalDb = {}; + req.user = { + userId: 'u1', + globalUserId: 'gu-actor', + platformRoles: ['platform_admin'], + ...userOverrides, + }; + next(); + }); + app.use(adminRoutes); + return app; +} + +describe('adminRoutes platform admin nominations', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('GET /admin/platform-admins returns admins and nominations', async () => { + listPlatformAdmins.mockResolvedValue({ + admins: [{ globalUserId: 'gu1', email: 'a@example.com' }], + nominations: [{ id: 'inv1', email: 'b@example.com', status: 'pending_signup' }], + }); + + const response = await request(buildApp()).get('/admin/platform-admins'); + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.admins).toHaveLength(1); + expect(response.body.data.nominations[0].status).toBe('pending_signup'); + }); + + it('POST /admin/platform-admins/nominate nominates email', async () => { + nominatePlatformAdmin.mockResolvedValue({ + data: { + id: 'inv1', + email: 'ops@example.com', + status: 'pending_signup', + }, + }); + + const response = await request(buildApp()) + .post('/admin/platform-admins/nominate') + .send({ email: 'ops@example.com' }); + + expect(response.status).toBe(200); + expect(nominatePlatformAdmin).toHaveBeenCalled(); + expect(response.body.data.status).toBe('pending_signup'); + }); + + it('POST nominate returns 403 for non-platform-admin', async () => { + const response = await request(buildApp({ platformRoles: [] })) + .post('/admin/platform-admins/nominate') + .send({ email: 'ops@example.com' }); + + expect(response.status).toBe(403); + expect(nominatePlatformAdmin).not.toHaveBeenCalled(); + }); + + it('POST nominations/:id/approve grants admin', async () => { + approvePlatformAdminInvite.mockResolvedValue({ + data: { globalUserId: 'gu1', email: 'ops@example.com', inviteId: 'inv1' }, + }); + + const response = await request(buildApp()).post( + '/admin/platform-admins/nominations/inv1/approve', + ); + + expect(response.status).toBe(200); + expect(approvePlatformAdminInvite).toHaveBeenCalledWith( + expect.anything(), + { inviteId: 'inv1' }, + ); + expect(response.body.data.email).toBe('ops@example.com'); + }); + + it('DELETE nominations/:id cancels nomination', async () => { + revokePlatformAdminInvite.mockResolvedValue({ + data: { id: 'inv1', email: 'ops@example.com', status: 'revoked' }, + }); + + const response = await request(buildApp()).delete( + '/admin/platform-admins/nominations/inv1', + ); + + expect(response.status).toBe(200); + expect(response.body.data.status).toBe('revoked'); + }); + + it('approve surfaces service errors', async () => { + approvePlatformAdminInvite.mockResolvedValue({ + error: 'This nomination is still awaiting signup.', + status: 409, + code: 'NOT_READY_FOR_APPROVAL', + }); + + const response = await request(buildApp()).post( + '/admin/platform-admins/nominations/inv1/approve', + ); + + expect(response.status).toBe(409); + expect(response.body.code).toBe('NOT_READY_FOR_APPROVAL'); + }); +}); diff --git a/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js b/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js index c3516b21..6ea7bdcd 100644 --- a/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js +++ b/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js @@ -89,6 +89,7 @@ jest.mock('../../services/pivotIngestPublishService', () => ({ jest.mock('../../services/pivotTagSuggestService', () => ({ suggestPivotEventTags: jest.fn(), suggestPivotEventTagsBatch: jest.fn(), + suggestAndApplyPivotEventTags: jest.fn(), })); jest.mock('../../services/pivotCatalogPurgeService', () => ({ @@ -149,6 +150,7 @@ const { const { suggestPivotEventTags, suggestPivotEventTagsBatch, + suggestAndApplyPivotEventTags, } = require('../../services/pivotTagSuggestService'); const { purgePivotCatalog } = require('../../services/pivotCatalogPurgeService'); const { listPivotTags, seedPivotTagCatalog } = require('../../services/pivotTagCatalogService'); @@ -1217,6 +1219,34 @@ describe('pivotAdminRoutes POST /admin/pivot/ingest/suggest-tags', () => { }); }); +describe('pivotAdminRoutes POST /admin/pivot/ingest/suggest-and-apply-tags', () => { + beforeEach(() => { + suggestAndApplyPivotEventTags.mockReset(); + requirePlatformAdmin.mockImplementation((req, res, next) => next()); + }); + + it('applies suggested tags server-side', async () => { + suggestAndApplyPivotEventTags.mockResolvedValue({ + data: { attempted: 2, updated: 2, failed: 0, skipped: 0, results: [] }, + }); + + const response = await request(buildApp()) + .post('/admin/pivot/ingest/suggest-and-apply-tags') + .send({ tenantKey: 'nyc', eventIds: ['a', 'b'], onlyTagless: true }); + + expect(response.status).toBe(200); + expect(response.body.data.updated).toBe(2); + expect(suggestAndApplyPivotEventTags).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + tenantKey: 'nyc', + eventIds: ['a', 'b'], + onlyTagless: true, + }), + ); + }); +}); + describe('pivotAdminRoutes dev purge', () => { beforeEach(() => { purgePivotCatalog.mockReset(); diff --git a/backend/tests/route-outcomes/pivotRoutes.outcomes.test.js b/backend/tests/route-outcomes/pivotRoutes.outcomes.test.js index 459f6af1..27978fea 100644 --- a/backend/tests/route-outcomes/pivotRoutes.outcomes.test.js +++ b/backend/tests/route-outcomes/pivotRoutes.outcomes.test.js @@ -18,6 +18,11 @@ jest.mock('../../services/pivotReferralCodeService', () => ({ jest.mock('../../services/pivotFeedService', () => ({ getPivotFeed: jest.fn(), + getPivotEventFriends: jest.fn(), +})); + +jest.mock('../../services/pivotExploreService', () => ({ + getPivotExplore: jest.fn(), })); jest.mock('../../services/pivotIntentService', () => ({ @@ -28,6 +33,10 @@ jest.mock('../../services/pivotIntentService', () => ({ resetWeekActions: jest.fn(), })); +jest.mock('../../services/pivotInteractionService', () => ({ + recordPivotImpressions: jest.fn(), +})); + jest.mock('../../services/pivotFeedbackService', () => ({ getPendingEventFeedback: jest.fn(), submitEventFeedback: jest.fn(), @@ -57,6 +66,7 @@ jest.mock('../../services/pivotFriendService', () => ({ const { validateReferralCode, redeemReferralCode } = require('../../services/pivotReferralCodeService'); const { getPivotFeed } = require('../../services/pivotFeedService'); +const { getPivotExplore } = require('../../services/pivotExploreService'); const { recordFeedAction, recordExternalOpen, @@ -64,6 +74,7 @@ const { getWeekRecap, resetWeekActions, } = require('../../services/pivotIntentService'); +const { recordPivotImpressions } = require('../../services/pivotInteractionService'); const { getPendingEventFeedback, submitEventFeedback, @@ -231,6 +242,85 @@ describe('pivotRoutes POST /pivot/referral/redeem', () => { }); }); +describe('pivotRoutes GET /pivot/explore', () => { + beforeEach(() => { + getPivotExplore.mockReset(); + }); + + it('returns 200 with explore payload', async () => { + getPivotExplore.mockResolvedValue({ + data: { + batchWeek: '2026-W22', + cityDisplayName: 'Brooklyn', + total: 24, + limit: 40, + offset: 0, + filters: { + tags: [], + night: null, + friendsOnly: false, + excludePassed: true, + q: null, + }, + rails: [ + { id: 'friends', title: 'friends going', retrieval: 'friends_rail' }, + { id: 'tonight', title: 'tonight', retrieval: 'filter' }, + ], + events: [ + { + _id: '665a1b2c3d4e5f6789012345', + name: 'Friday Night Board Games', + displayHost: { name: 'Brooklyn Board Game Cafe' }, + userIntent: null, + friendsInterested: [], + friendsGoing: [], + }, + ], + }, + }); + + const response = await request(buildBaseApp()) + .get('/pivot/explore?batchWeek=2026-W22&limit=40&offset=0') + .set('Authorization', 'Bearer test-token'); + + expect(response.statusCode).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.total).toBe(24); + expect(response.body.data.filters.excludePassed).toBe(true); + expect(response.body.data.rails).toHaveLength(2); + expect(response.body.data.events).toHaveLength(1); + expect(getPivotExplore).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.any(Object) }), + expect.objectContaining({ + batchWeek: '2026-W22', + limit: '40', + offset: '0', + tags: undefined, + night: undefined, + friendsOnly: undefined, + excludePassed: undefined, + q: undefined, + }), + ); + }); + + it('returns service error status when explore rejects', async () => { + getPivotExplore.mockResolvedValue({ + error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).', + status: 400, + code: 'INVALID_BATCH_WEEK', + }); + + const response = await request(buildBaseApp()) + .get('/pivot/explore?batchWeek=bad-week') + .set('Authorization', 'Bearer test-token'); + + expect(response.statusCode).toBe(400); + expect(response.body.success).toBe(false); + expect(response.body.code).toBe('INVALID_BATCH_WEEK'); + }); +}); + describe('pivotRoutes GET /pivot/feed', () => { beforeEach(() => { getPivotFeed.mockReset(); @@ -320,6 +410,75 @@ describe('pivotRoutes GET /pivot/feed', () => { }); }); +describe('pivotRoutes POST /pivot/interactions/impressions', () => { + beforeEach(() => { + recordPivotImpressions.mockReset(); + }); + + it('returns 200 with accepted count', async () => { + recordPivotImpressions.mockReturnValue({ + data: { accepted: 2, skipped: 0, received: 2 }, + }); + + const response = await request(buildBaseApp()) + .post('/pivot/interactions/impressions') + .set('Authorization', 'Bearer test-token') + .send({ + batchWeek: '2026-W28', + impressions: [ + { eventId: '665a1b2c3d4e5f6789012345', rankInFeed: 0 }, + { eventId: '665a1b2c3d4e5f6789012346', rankInFeed: 1 }, + ], + }); + + expect(response.statusCode).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data).toEqual({ + accepted: 2, + skipped: 0, + received: 2, + }); + expect(recordPivotImpressions).toHaveBeenCalledWith( + expect.objectContaining({ + user: expect.objectContaining({ userId: '507f191e810c19729de860eb' }), + }), + expect.objectContaining({ + batchWeek: '2026-W28', + impressions: expect.any(Array), + }), + ); + }); + + it('returns 400 when impressions is missing', async () => { + const response = await request(buildBaseApp()) + .post('/pivot/interactions/impressions') + .set('Authorization', 'Bearer test-token') + .send({ batchWeek: '2026-W28' }); + + expect(response.statusCode).toBe(400); + expect(response.body.code).toBe('VALIDATION_ERROR'); + expect(recordPivotImpressions).not.toHaveBeenCalled(); + }); + + it('returns service error status', async () => { + recordPivotImpressions.mockReturnValue({ + error: 'At most 50 impressions per request.', + status: 400, + code: 'IMPRESSION_BATCH_TOO_LARGE', + }); + + const response = await request(buildBaseApp()) + .post('/pivot/interactions/impressions') + .set('Authorization', 'Bearer test-token') + .send({ + impressions: [{ eventId: '665a1b2c3d4e5f6789012345', rankInFeed: 0 }], + }); + + expect(response.statusCode).toBe(400); + expect(response.body.code).toBe('IMPRESSION_BATCH_TOO_LARGE'); + }); +}); + describe('pivotRoutes POST /pivot/feed/action', () => { beforeEach(() => { recordFeedAction.mockReset(); diff --git a/backend/tests/unit/eventPivotIndexes.test.js b/backend/tests/unit/eventPivotIndexes.test.js new file mode 100644 index 00000000..0f50b661 --- /dev/null +++ b/backend/tests/unit/eventPivotIndexes.test.js @@ -0,0 +1,54 @@ +const eventSchema = require('../../events/schemas/event'); + +describe('Event pivot compound indexes (Task 1.4)', () => { + const indexNames = eventSchema.PIVOT_EVENT_INDEX_NAMES; + + it('exports named pivot index constants', () => { + expect(indexNames).toEqual([ + 'pivot_batchWeek_ingestStatus_start_time', + 'pivot_batchWeek_ingestStatus_tags', + ]); + }); + + it('registers feed/explore compound indexes on the Event schema', () => { + const indexes = eventSchema.indexes(); + + const byName = new Map( + indexes.map(([keys, options]) => [options?.name, { keys, options }]), + ); + + const feedIndex = byName.get('pivot_batchWeek_ingestStatus_start_time'); + expect(feedIndex).toBeDefined(); + expect(feedIndex.keys).toEqual({ + 'customFields.pivot.batchWeek': 1, + 'customFields.pivot.ingestStatus': 1, + start_time: 1, + }); + expect(feedIndex.options.partialFilterExpression).toEqual({ + 'customFields.pivot.batchWeek': { $type: 'string' }, + }); + + const tagsIndex = byName.get('pivot_batchWeek_ingestStatus_tags'); + expect(tagsIndex).toBeDefined(); + expect(tagsIndex.keys).toEqual({ + 'customFields.pivot.batchWeek': 1, + 'customFields.pivot.ingestStatus': 1, + 'customFields.pivot.tags': 1, + }); + }); + + it('covers the feed/explore equality prefix (batchWeek + ingestStatus)', () => { + // Explain-plan proxy: the leading keys of the primary index match the + // Equality fields in getPivotFeed / upcoming getPivotExplore queries. + const indexes = eventSchema.indexes(); + const feedIndex = indexes.find( + ([, options]) => options?.name === 'pivot_batchWeek_ingestStatus_start_time', + ); + const keyOrder = Object.keys(feedIndex[0]); + expect(keyOrder.slice(0, 2)).toEqual([ + 'customFields.pivot.batchWeek', + 'customFields.pivot.ingestStatus', + ]); + expect(keyOrder[2]).toBe('start_time'); + }); +}); diff --git a/backend/tests/unit/pivotDeckSnapshot.test.js b/backend/tests/unit/pivotDeckSnapshot.test.js new file mode 100644 index 00000000..76c0b83e --- /dev/null +++ b/backend/tests/unit/pivotDeckSnapshot.test.js @@ -0,0 +1,131 @@ +const mongoose = require('mongoose'); +const { + createMongoMemoryConnection, + getOrCreateModel, +} = require('../helpers/mongoMemory'); +const pivotDeckSnapshotSchema = require('../../schemas/pivotDeckSnapshot'); + +jest.mock('../../services/getModelService', () => jest.fn()); + +const getModels = require('../../services/getModelService'); +const { + normalizeDeckSnapshotRefresh, + upsertPivotDeckSnapshot, + recordPivotDeckSnapshot, +} = require('../../services/pivotDeckSnapshotService'); + +describe('PivotDeckSnapshot (Task 6.1)', () => { + let mongo; + let PivotDeckSnapshot; + let req; + const userId = new mongoose.Types.ObjectId(); + const eventA = new mongoose.Types.ObjectId(); + const eventB = new mongoose.Types.ObjectId(); + + beforeAll(async () => { + mongo = await createMongoMemoryConnection(); + PivotDeckSnapshot = getOrCreateModel( + mongo.connection, + 'PivotDeckSnapshot', + pivotDeckSnapshotSchema, + 'pivotDeckSnapshots', + ); + req = { db: mongo.connection, user: { userId: String(userId) }, school: 'nyc' }; + + getModels.mockImplementation((_req, ...names) => { + const models = { PivotDeckSnapshot }; + return names.reduce((acc, name) => { + if (models[name]) acc[name] = models[name]; + return acc; + }, {}); + }); + }); + + afterEach(async () => { + await mongo.reset(); + }); + + afterAll(async () => { + await mongo.cleanup(); + }); + + describe('normalizeDeckSnapshotRefresh', () => { + it('allows refresh only for admin or developer roles', () => { + expect(normalizeDeckSnapshotRefresh('1', ['user'])).toBe(false); + expect(normalizeDeckSnapshotRefresh('1', ['admin'])).toBe(true); + expect(normalizeDeckSnapshotRefresh('true', ['developer'])).toBe(true); + expect(normalizeDeckSnapshotRefresh(false, ['admin'])).toBe(false); + }); + }); + + describe('upsertPivotDeckSnapshot', () => { + it('writes once per user and batchWeek', async () => { + const first = await upsertPivotDeckSnapshot(req, { + userId, + batchWeek: '2026-W22', + orderedEventIds: [eventA, eventB], + rankerVersion: 'rules_v0', + }); + + expect(first.skipped).toBe(false); + expect(first.created).toBe(true); + expect(first.data.orderedEventIds).toEqual([ + String(eventA), + String(eventB), + ]); + + const second = await upsertPivotDeckSnapshot(req, { + userId, + batchWeek: '2026-W22', + orderedEventIds: [eventB, eventA], + rankerVersion: 'rules_v0', + }); + + expect(second.skipped).toBe(true); + expect(second.data.orderedEventIds).toEqual([ + String(eventA), + String(eventB), + ]); + expect(await PivotDeckSnapshot.countDocuments()).toBe(1); + }); + + it('refreshes an existing snapshot when forceRefresh is true', async () => { + await upsertPivotDeckSnapshot(req, { + userId, + batchWeek: '2026-W22', + orderedEventIds: [eventA, eventB], + rankerVersion: 'rules_v0', + }); + + const refreshed = await upsertPivotDeckSnapshot(req, { + userId, + batchWeek: '2026-W22', + orderedEventIds: [eventB, eventA], + rankerVersion: 'rules_v0', + forceRefresh: true, + }); + + expect(refreshed.skipped).toBe(false); + expect(refreshed.refreshed).toBe(true); + expect(refreshed.data.orderedEventIds).toEqual([ + String(eventB), + String(eventA), + ]); + }); + + it('recordPivotDeckSnapshot swallows write failures without throwing', async () => { + getModels.mockImplementationOnce(() => { + throw new Error('model unavailable'); + }); + + const result = await recordPivotDeckSnapshot(req, { + userId, + batchWeek: '2026-W22', + orderedEventIds: [eventA], + rankerVersion: 'rules_v0', + }); + + expect(result.error).toMatch(/model unavailable/i); + }); + }); +}); diff --git a/backend/tests/unit/pivotDropSchedule.test.js b/backend/tests/unit/pivotDropSchedule.test.js index 470cf275..55b9c6d1 100644 --- a/backend/tests/unit/pivotDropSchedule.test.js +++ b/backend/tests/unit/pivotDropSchedule.test.js @@ -4,6 +4,10 @@ const { formatPivotDropInstant, resolvePivotDropConfig, resolvePivotDropInstant, + resolvePivotLiveBatchWeek, + resolvePivotOpsLiveWeek, + resolvePivotStageAnchors, + resolveStageForBatchWeek, zonedLocalToUtc, } = require('../../utilities/pivotDropSchedule'); @@ -77,4 +81,52 @@ describe('pivotDropSchedule', () => { }); expect(dropAt.toISOString()).toBe('2026-03-08T22:00:00.000Z'); }); + + it('resolvePivotLiveBatchWeek stays on previous week before the drop instant', () => { + const tenant = {...nycTenant, pivotPilot: true}; + const now = new Date('2026-07-13T16:00:00.000Z'); + expect(resolvePivotLiveBatchWeek(tenant, now)).toBe('2026-W28'); + }); + + it('resolvePivotLiveBatchWeek advances after the drop instant', () => { + const tenant = {...nycTenant, pivotPilot: true}; + const now = new Date('2026-07-17T23:00:00.000Z'); + expect(resolvePivotLiveBatchWeek(tenant, now)).toBe('2026-W29'); + }); + + it('resolvePivotOpsLiveWeek keeps previous ISO week live before the drop instant', () => { + const tenant = {...nycTenant, pivotPilot: true}; + const now = new Date('2026-07-13T16:00:00.000Z'); + expect(resolvePivotOpsLiveWeek(tenant, now)).toBe('2026-W28'); + }); + + it('resolvePivotOpsLiveWeek switches to current week after the drop instant', () => { + const tenant = {...nycTenant, pivotPilot: true}; + const now = new Date('2026-07-17T23:00:00.000Z'); + expect(resolvePivotOpsLiveWeek(tenant, now)).toBe('2026-W29'); + }); + + it('resolvePivotStageAnchors uses drop-cycle live week before the next drop', () => { + const tenant = {...nycTenant, pivotPilot: true}; + const now = new Date('2026-07-13T16:00:00.000Z'); + const anchors = resolvePivotStageAnchors(tenant, now); + expect(anchors.currentWeek).toBe('2026-W29'); + expect(anchors.liveWeek).toBe('2026-W28'); + expect(anchors.curateWeek).toBe('2026-W29'); + expect(anchors.postMortemWeek).toBe('2026-W27'); + expect(anchors.dropPending).toBe(true); + expect(resolveStageForBatchWeek('2026-W28', tenant, now)).toBe('live'); + expect(resolveStageForBatchWeek('2026-W29', tenant, now)).toBe('curate'); + expect(resolveStageForBatchWeek('2026-W27', tenant, now)).toBe('post-mortem'); + }); + + it('resolvePivotStageAnchors advances curate week after the drop', () => { + const tenant = {...nycTenant, pivotPilot: true}; + const now = new Date('2026-07-17T23:00:00.000Z'); + const anchors = resolvePivotStageAnchors(tenant, now); + expect(anchors.liveWeek).toBe('2026-W29'); + expect(anchors.curateWeek).toBe('2026-W30'); + expect(anchors.dropPending).toBe(false); + expect(resolveStageForBatchWeek('2026-W29', tenant, now)).toBe('live'); + }); }); diff --git a/backend/tests/unit/pivotEnrichment.test.js b/backend/tests/unit/pivotEnrichment.test.js new file mode 100644 index 00000000..26528c3e --- /dev/null +++ b/backend/tests/unit/pivotEnrichment.test.js @@ -0,0 +1,59 @@ +const { + PIVOT_PRICE_BANDS, + normalizePivotEnrichment, + hasPivotEnrichmentContent, + serializePivotEnrichment, + collectPivotEnrichmentSearchText, +} = require('../../utilities/pivotEnrichment'); + +describe('pivotEnrichment', () => { + it('normalizePivotEnrichment trims vibe tags and validates priceBand', () => { + expect( + normalizePivotEnrichment({ + vibe: [' Dancey ', 'dancey', 'chill'], + priceBand: 'LOW', + neighborhood: ' Williamsburg ', + audience: '21+', + }), + ).toEqual({ + vibe: ['dancey', 'chill'], + priceBand: 'low', + neighborhood: 'Williamsburg', + audience: '21+', + }); + }); + + it('returns null when all enrichment fields are empty', () => { + expect(normalizePivotEnrichment({ vibe: [], priceBand: '', neighborhood: '' })).toBeNull(); + expect(hasPivotEnrichmentContent({})).toBe(false); + }); + + it('rejects invalid priceBand values', () => { + expect(normalizePivotEnrichment({ priceBand: 'luxury' })).toEqual({ + error: 'priceBand must be free, low, mid, or high.', + code: 'INVALID_PRICE_BAND', + }); + }); + + it('serializePivotEnrichment and search text include all strings', () => { + const pivot = { + enrichment: { + vibe: ['live-music'], + priceBand: 'mid', + neighborhood: 'downtown', + audience: 'all ages', + }, + }; + + expect(serializePivotEnrichment(pivot)).toEqual({ + vibe: ['live-music'], + priceBand: 'mid', + neighborhood: 'downtown', + audience: 'all ages', + }); + expect(collectPivotEnrichmentSearchText(pivot)).toBe( + 'live-music mid downtown all ages', + ); + expect(PIVOT_PRICE_BANDS).toEqual(['free', 'low', 'mid', 'high']); + }); +}); diff --git a/backend/tests/unit/pivotExploreSectionsService.test.js b/backend/tests/unit/pivotExploreSectionsService.test.js new file mode 100644 index 00000000..cf5d272e --- /dev/null +++ b/backend/tests/unit/pivotExploreSectionsService.test.js @@ -0,0 +1,268 @@ +const { + buildRulesExploreSections, + materializeCuratedSections, + resolveExploreSections, + shouldBuildExploreSections, + EXPLORE_CATEGORY_MIN_EVENTS, + EXPLORE_SECTION_COPY, +} = require('../../services/pivotExploreSectionsService'); + +const RAILS = [ + { id: 'friends', title: 'friends going', retrieval: 'friends_rail' }, + { id: 'tonight', title: 'tonight', retrieval: 'filter' }, + { id: 'tag:live-music', title: 'live music', retrieval: 'tag_rail' }, + { id: 'tag:board-games', title: 'board games', retrieval: 'tag_rail' }, +]; + +function mockEvent(id, overrides = {}) { + return { + _id: id, + name: `Event ${id}`, + start_time: '2026-07-10T23:00:00.000Z', + displayHost: { name: 'Host' }, + userIntent: null, + friendsInterested: [], + friendsGoing: [], + friendsInterestedCount: 0, + friendsGoingCount: 0, + ...overrides, + }; +} + +function countAppearances(sections) { + const counts = new Map(); + for (const section of sections) { + for (const event of section.events) { + counts.set(event._id, (counts.get(event._id) ?? 0) + 1); + } + } + return counts; +} + +describe('pivotExploreSectionsService', () => { + describe('shouldBuildExploreSections', () => { + it('allows default browse filters', () => { + expect( + shouldBuildExploreSections({ + tags: [], + night: null, + friendsOnly: false, + excludePassed: true, + q: null, + sort: 'for_you', + }), + ).toBe(true); + }); + + it('skips sections when search or chip filters are active', () => { + expect( + shouldBuildExploreSections({ + tags: [], + night: null, + friendsOnly: true, + q: null, + sort: 'for_you', + }), + ).toBe(false); + expect( + shouldBuildExploreSections({ + tags: ['live-music'], + night: null, + friendsOnly: false, + q: null, + sort: 'for_you', + }), + ).toBe(false); + expect( + shouldBuildExploreSections({ + tags: [], + night: 'fri', + friendsOnly: false, + q: null, + sort: 'for_you', + }), + ).toBe(false); + expect( + shouldBuildExploreSections({ + tags: [], + night: null, + friendsOnly: false, + q: 'jazz', + sort: 'for_you', + }), + ).toBe(false); + }); + }); + + describe('buildRulesExploreSections', () => { + it('only renders rows with at least three fresh events', () => { + const events = [ + mockEvent('1', { tags: ['live-music'] }), + mockEvent('2', { tags: ['live-music'] }), + mockEvent('3'), + mockEvent('4'), + ]; + + const sections = buildRulesExploreSections(events, RAILS); + + expect(sections).toHaveLength(1); + expect(sections[0].id).toBe('trending'); + expect(sections[0].title).toBe(EXPLORE_SECTION_COPY.trending); + expect(sections[0].layout).toBe('rail'); + expect(sections[0].events.length).toBeGreaterThanOrEqual( + EXPLORE_CATEGORY_MIN_EVENTS, + ); + expect(sections[0].events.length).toBeLessThanOrEqual(4); + }); + + it('caps duplicate appearances across rows', () => { + const events = [ + mockEvent('1', { friendsGoingCount: 1, tags: ['live-music'] }), + mockEvent('2', { friendsInterestedCount: 2, tags: ['live-music'] }), + mockEvent('3', { tags: ['live-music', 'board-games'] }), + mockEvent('4', { tags: ['live-music'] }), + mockEvent('5', { tags: ['board-games'] }), + ]; + + const sections = buildRulesExploreSections(events, RAILS); + const appearances = countAppearances(sections); + + for (const count of appearances.values()) { + expect(count).toBeLessThanOrEqual(2); + } + + expect(sections.find((section) => section.id === 'friends')).toBeUndefined(); + expect(sections.find((section) => section.id === 'tag:live-music')).toBeUndefined(); + }); + }); + + describe('materializeCuratedSections', () => { + it('maps ordered eventIds to serialized events and preserves layout metadata', () => { + const eventsById = new Map([ + ['a', mockEvent('a')], + ['b', mockEvent('b')], + ['c', mockEvent('c')], + ]); + + const sections = materializeCuratedSections( + { + sections: [ + { + id: 'staff-picks', + title: 'staff picks', + retrieval: 'curated_rail', + layout: 'grid', + subtitle: 'hand picked', + eventIds: ['b', 'missing', 'a', 'a'], + }, + ], + }, + eventsById, + ); + + expect(sections).toEqual([ + { + id: 'staff-picks', + title: 'staff picks', + retrieval: 'curated_rail', + layout: 'grid', + subtitle: 'hand picked', + events: [mockEvent('b'), mockEvent('a')], + }, + ]); + }); + }); + + describe('resolveExploreSections', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns rules sections by default', async () => { + const events = [ + mockEvent('1', { friendsInterestedCount: 1 }), + mockEvent('2', { friendsInterestedCount: 2 }), + mockEvent('3', { friendsInterestedCount: 3 }), + ]; + + const result = await resolveExploreSections( + { school: 'nyc' }, + { + tenantKey: 'nyc', + batchWeek: '2026-W28', + serializedEvents: events, + rails: RAILS, + filters: { + tags: [], + night: null, + friendsOnly: false, + q: null, + }, + }, + ); + + expect(result.sectionsSource).toBe('rules_v0'); + expect(result.sections[0]?.id).toBe('trending'); + }); + + it('returns empty sections when filters are active', async () => { + const result = await resolveExploreSections( + { school: 'nyc' }, + { + tenantKey: 'nyc', + batchWeek: '2026-W28', + serializedEvents: [mockEvent('1'), mockEvent('2'), mockEvent('3')], + rails: RAILS, + filters: { + tags: [], + night: null, + friendsOnly: false, + q: 'jazz', + }, + }, + ); + + expect(result).toEqual({ sections: [], sectionsSource: 'rules_v0' }); + }); + + it('prefers curated sections when loadExploreCuration returns a doc', async () => { + const events = [ + mockEvent('1'), + mockEvent('2'), + mockEvent('3'), + ]; + + const result = await resolveExploreSections( + { school: 'nyc' }, + { + tenantKey: 'nyc', + batchWeek: '2026-W28', + serializedEvents: events, + rails: RAILS, + filters: { + tags: [], + night: null, + friendsOnly: false, + q: null, + }, + loadExploreCuration: jest.fn().mockResolvedValue({ + tenantKey: 'nyc', + batchWeek: '2026-W28', + sections: [ + { + id: 'curated-row', + title: 'curated row', + retrieval: 'curated_rail', + eventIds: ['2', '1'], + }, + ], + }), + }, + ); + + expect(result.sectionsSource).toBe('curated'); + expect(result.sections).toHaveLength(1); + expect(result.sections[0].events.map((event) => event._id)).toEqual(['2', '1']); + }); + }); +}); diff --git a/backend/tests/unit/pivotExploreService.test.js b/backend/tests/unit/pivotExploreService.test.js new file mode 100644 index 00000000..39bc9414 --- /dev/null +++ b/backend/tests/unit/pivotExploreService.test.js @@ -0,0 +1,1050 @@ +jest.mock('../../services/getModelService', () => jest.fn()); +jest.mock('../../services/tenantConfigService', () => ({ + getTenantByKey: jest.fn(), +})); +jest.mock('../../services/pivotTagCatalogService', () => ({ + listPivotTags: jest.fn(), + normalizePivotTagSlugs: jest.requireActual('../../services/pivotTagCatalogService') + .normalizePivotTagSlugs, + validatePivotEventTags: jest.fn(), +})); + +const getModels = require('../../services/getModelService'); +const { getTenantByKey } = require('../../services/tenantConfigService'); +const { + listPivotTags, + validatePivotEventTags, +} = require('../../services/pivotTagCatalogService'); +const { getFeedPilotWindowFilter } = require('../../services/pivotFeedService'); +const { + getPivotExplore, + normalizeExploreLimit, + normalizeExploreOffset, + normalizeExploreNight, + normalizeExploreSort, + compareByStartTime, + eventMatchesNight, + eventMatchesQuery, + buildExploreRails, + resolveExploreUserIntent, + shouldExcludePassedExploreEvent, + EXPLORE_INTENT_BADGE_PRIORITY, + DEFAULT_EXPLORE_LIMIT, + DEFAULT_EXPLORE_SORT, +} = require('../../services/pivotExploreService'); + +const CATALOG_TAGS = [ + { slug: 'live-music', label: 'live music' }, + { slug: 'board-games', label: 'board games' }, +]; + +function mockUserModel(friendUsers = [], pivotInterestTags = []) { + return { + findById: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue({ pivotInterestTags }), + })), + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(friendUsers), + })), + }; +} + +function mockUniversalFeedbackModel(rows = []) { + return { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(rows), + })), + }; +} + +function withExploreModels(partial = {}) { + return { + UniversalFeedback: mockUniversalFeedbackModel(), + ...partial, + }; +} + +function mockEventFind(events) { + return { + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(events), + }; +} + +function mockIntentFind(rows = []) { + return { + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(rows), + }; +} + +function setupCatalogMocks() { + listPivotTags.mockResolvedValue({ data: { tags: CATALOG_TAGS } }); + validatePivotEventTags.mockImplementation(async (_req, tags, options = {}) => { + const normalized = Array.isArray(tags) ? tags : []; + if (options.required !== false && normalized.length === 0) { + return { + error: 'At least one catalog tag is required.', + status: 400, + code: 'TAGS_REQUIRED', + }; + } + if (!normalized.length) { + return { tags: [] }; + } + + const known = new Set(CATALOG_TAGS.map((row) => row.slug)); + const unknown = normalized.filter((slug) => !known.has(slug)); + if (unknown.length) { + return { + error: `Unknown catalog tag(s): ${unknown.join(', ')}`, + status: 400, + code: 'INVALID_TAG', + }; + } + + return { tags: normalized }; + }); +} + +function mockExploreModels(events, intentRows = []) { + const intentFind = jest.fn(() => mockIntentFind(intentRows)); + + getModels.mockReturnValue(withExploreModels({ + Event: { find: jest.fn(() => mockEventFind(events)) }, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + })), + }, + PivotEventIntent: { find: intentFind }, + User: mockUserModel(), + })); + + return { intentFind }; +} + +describe('pivotExploreService filter helpers', () => { + it('normalizeExploreNight accepts weekday shortcuts and ISO dates', () => { + expect(normalizeExploreNight('fri')).toBe('fri'); + expect(normalizeExploreNight('2026-05-29')).toBe('2026-05-29'); + expect(normalizeExploreNight('monday')).toBeUndefined(); + }); + + it('eventMatchesNight matches local weekday in tenant timezone', () => { + const event = { + start_time: new Date('2026-05-29T23:00:00.000Z'), + customFields: { pivot: {} }, + }; + + expect(eventMatchesNight(event, 'fri', 'America/New_York')).toBe(true); + expect(eventMatchesNight(event, 'sat', 'America/New_York')).toBe(false); + }); + + it('eventMatchesQuery searches name, description, host, and enrichment', () => { + const event = { + name: 'Sunset Listening Party', + description: 'Roof records', + customFields: { + pivot: { + host: { name: 'Roof Records' }, + enrichment: { + vibe: ['intimate'], + neighborhood: 'williamsburg', + }, + }, + }, + }; + + expect(eventMatchesQuery(event, 'roof')).toBe(true); + expect(eventMatchesQuery(event, 'sunset')).toBe(true); + expect(eventMatchesQuery(event, 'williamsburg')).toBe(true); + expect(eventMatchesQuery(event, 'intimate')).toBe(true); + expect(eventMatchesQuery(event, 'missing')).toBe(false); + }); + + it('buildExploreRails includes standard rails and interest-matched week tag rails', () => { + const rails = buildExploreRails( + CATALOG_TAGS, + [ + { + customFields: { + pivot: { tags: ['live-music', 'board-games'] }, + }, + }, + ], + new Set(['live-music']), + ); + + expect(rails).toEqual([ + { id: 'friends', title: 'friends going', retrieval: 'friends_rail' }, + { id: 'tonight', title: 'tonight', retrieval: 'filter' }, + { id: 'for_you', title: 'for you later', retrieval: 'for_you_rail' }, + { id: 'tag:live-music', title: 'live music', retrieval: 'tag_rail' }, + ]); + }); + + it('resolveExploreUserIntent returns only explore intent statuses', () => { + expect(resolveExploreUserIntent({ status: 'registered' })).toBe('registered'); + expect(resolveExploreUserIntent({ status: 'interested' })).toBe('interested'); + expect(resolveExploreUserIntent({ status: 'passed' })).toBe('passed'); + expect(resolveExploreUserIntent({ status: 'unknown' })).toBeNull(); + expect(resolveExploreUserIntent(undefined)).toBeNull(); + }); + + it('shouldExcludePassedExploreEvent respects excludePassed toggle', () => { + const userIntents = new Map([ + ['1', { status: 'passed' }], + ['2', { status: 'interested' }], + ]); + + expect( + shouldExcludePassedExploreEvent({ _id: '1' }, userIntents, true), + ).toBe(true); + expect( + shouldExcludePassedExploreEvent({ _id: '1' }, userIntents, false), + ).toBe(false); + expect( + shouldExcludePassedExploreEvent({ _id: '2' }, userIntents, true), + ).toBe(false); + }); +}); + +describe('pivotExploreService pagination helpers', () => { + it('normalizeExploreLimit defaults to 40', () => { + expect(normalizeExploreLimit(undefined)).toBe(DEFAULT_EXPLORE_LIMIT); + expect(normalizeExploreLimit('')).toBe(DEFAULT_EXPLORE_LIMIT); + }); + + it('normalizeExploreLimit rejects invalid values', () => { + expect(normalizeExploreLimit('0')).toBeNull(); + expect(normalizeExploreLimit('-1')).toBeNull(); + expect(normalizeExploreLimit('abc')).toBeNull(); + }); + + it('normalizeExploreOffset defaults to 0', () => { + expect(normalizeExploreOffset(undefined)).toBe(0); + }); + + it('normalizeExploreOffset rejects negative values', () => { + expect(normalizeExploreOffset('-1')).toBeNull(); + }); +}); + +describe('getPivotExplore', () => { + const userId = '507f191e810c19729de860eb'; + const req = { user: { userId }, school: 'nyc', globalDb: {} }; + const now = new Date('2026-05-26T12:00:00.000Z'); + + beforeEach(() => { + getModels.mockReset(); + getTenantByKey.mockReset(); + listPivotTags.mockReset(); + validatePivotEventTags.mockReset(); + setupCatalogMocks(); + getTenantByKey.mockResolvedValue({ + tenantKey: 'nyc', + name: 'New York City Pilot', + location: 'Brooklyn', + pivotPilot: true, + }); + }); + + it('returns published pivot events with displayHost and total count', async () => { + const events = [ + { + _id: '665a1b2c3d4e5f6789012345', + name: 'Friday Night Board Games', + description: 'BYOB', + location: 'Brooklyn', + start_time: new Date('2026-05-28T19:00:00.000Z'), + end_time: new Date('2026-05-28T23:00:00.000Z'), + externalLink: 'https://partiful.com/e/example', + type: 'social', + registrationCount: 12, + hostingId: '665a00000000000000000001', + customFields: { + pivot: { + batchWeek: '2026-W22', + ingestStatus: 'published', + host: { name: 'Brooklyn Board Game Cafe' }, + tags: ['board-games'], + }, + }, + }, + ]; + + const Event = { find: jest.fn(() => mockEventFind(events)) }; + getModels.mockReturnValue(withExploreModels({ + Event, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + })), + }, + PivotEventIntent: { find: jest.fn(() => mockIntentFind()) }, + User: mockUserModel([], ['board-games']), + })); + + const result = await getPivotExplore(req, { batchWeek: '2026-W22', now }); + + expect(result.data.batchWeek).toBe('2026-W22'); + expect(result.data.cityDisplayName).toBe('Brooklyn'); + expect(result.data.total).toBe(1); + expect(result.data.limit).toBe(DEFAULT_EXPLORE_LIMIT); + expect(result.data.offset).toBe(0); + expect(result.data.filters).toEqual({ + tags: [], + night: null, + friendsOnly: false, + excludePassed: true, + q: null, + sort: DEFAULT_EXPLORE_SORT, + }); + expect(result.data.rails.map((rail) => rail.id)).toEqual([ + 'friends', + 'tonight', + 'for_you', + 'tag:board-games', + ]); + expect(result.data.intentBadgePriority).toEqual(EXPLORE_INTENT_BADGE_PRIORITY); + expect(result.data.sectionsSource).toBe('rules_v0'); + expect(result.data.sections).toEqual([]); + expect(result.data.events).toHaveLength(1); + expect(result.data.events[0].displayHost).toEqual({ + name: 'Brooklyn Board Game Cafe', + }); + expect(result.data.events[0].userIntent).toBeNull(); + expect(result.data.events[0]).not.toHaveProperty('hostingId'); + expect(result.data.events[0]).not.toHaveProperty('rankInFeed'); + expect(Event.find).toHaveBeenCalledWith( + expect.objectContaining({ + 'customFields.pivot.batchWeek': '2026-W22', + 'customFields.pivot.ingestStatus': 'published', + status: { $in: ['approved', 'not-applicable'] }, + ...getFeedPilotWindowFilter(now), + }), + ); + }); + + it('queries only published ingestStatus for the requested batchWeek', async () => { + const Event = { find: jest.fn(() => mockEventFind([])) }; + getModels.mockReturnValue(withExploreModels({ + Event, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + })), + }, + PivotEventIntent: { find: jest.fn(() => mockIntentFind()) }, + User: mockUserModel([], ['board-games']), + })); + + await getPivotExplore(req, { batchWeek: '2026-W22', now }); + + expect(Event.find).toHaveBeenCalledWith( + expect.objectContaining({ + 'customFields.pivot.batchWeek': '2026-W22', + 'customFields.pivot.ingestStatus': 'published', + }), + ); + }); + + it('excludes events that have already ended', async () => { + const events = [ + { + _id: '665a1b2c3d4e5f6789012345', + name: 'Past Party', + start_time: new Date('2026-05-25T19:00:00.000Z'), + end_time: new Date('2026-05-25T23:00:00.000Z'), + customFields: { + pivot: { + batchWeek: '2026-W22', + ingestStatus: 'published', + host: { name: 'Past Venue' }, + }, + }, + }, + { + _id: '665a1b2c3d4e5f6789012346', + name: 'Upcoming Party', + start_time: new Date('2026-05-28T19:00:00.000Z'), + end_time: new Date('2026-05-28T23:00:00.000Z'), + customFields: { + pivot: { + batchWeek: '2026-W22', + ingestStatus: 'published', + host: { name: 'Future Venue' }, + }, + }, + }, + ]; + + getModels.mockReturnValue(withExploreModels({ + Event: { find: jest.fn(() => mockEventFind(events)) }, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + })), + }, + PivotEventIntent: { find: jest.fn(() => mockIntentFind()) }, + User: mockUserModel([], ['board-games']), + })); + + const result = await getPivotExplore(req, { batchWeek: '2026-W22', now }); + + expect(result.data.total).toBe(1); + expect(result.data.events).toHaveLength(1); + expect(result.data.events[0].name).toBe('Upcoming Party'); + }); + + it('sorts by friends going then friends interested then start_time', async () => { + const friendId = '507f191e810c19729de860ec'; + const events = [ + { + _id: '665a000000000000000000a1', + name: 'No Friends (popular)', + start_time: new Date('2026-05-28T18:00:00.000Z'), + registrationCount: 50, + customFields: { pivot: { host: { name: 'Venue A' } } }, + }, + { + _id: '665a000000000000000000b2', + name: 'Friend Interested', + start_time: new Date('2026-05-28T19:00:00.000Z'), + registrationCount: 5, + customFields: { pivot: { host: { name: 'Venue B' } } }, + }, + { + _id: '665a000000000000000000c3', + name: 'Friend Registered', + start_time: new Date('2026-05-28T20:00:00.000Z'), + registrationCount: 1, + customFields: { pivot: { host: { name: 'Venue C' } } }, + }, + ]; + + const PivotEventIntent = { + find: jest.fn((query) => { + const isFriendQuery = query.userId && query.userId.$in; + const rows = isFriendQuery + ? [ + { + eventId: '665a000000000000000000b2', + userId: friendId, + status: 'interested', + }, + { + eventId: '665a000000000000000000c3', + userId: friendId, + status: 'registered', + }, + ] + : []; + return mockIntentFind(rows); + }), + }; + + getModels.mockReturnValue(withExploreModels({ + Event: { find: jest.fn(() => mockEventFind(events)) }, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([ + { requester: userId, recipient: friendId }, + ]), + })), + }, + PivotEventIntent, + User: mockUserModel([{ _id: friendId, name: 'Pat', picture: null }]), + })); + + const result = await getPivotExplore(req, { batchWeek: '2026-W22', now }); + + expect(result.data.events.map((event) => event.name)).toEqual([ + 'Friend Registered', + 'Friend Interested', + 'No Friends (popular)', + ]); + expect(result.data.total).toBe(3); + }); + + it('paginates with limit and offset', async () => { + const events = Array.from({ length: 5 }, (_value, index) => ({ + _id: `665a0000000000000000000${index}`, + name: `Event ${index}`, + start_time: new Date(`2026-05-28T${10 + index}:00:00.000Z`), + customFields: { + pivot: { + batchWeek: '2026-W22', + ingestStatus: 'published', + host: { name: `Venue ${index}` }, + }, + }, + })); + + getModels.mockReturnValue(withExploreModels({ + Event: { find: jest.fn(() => mockEventFind(events)) }, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + })), + }, + PivotEventIntent: { find: jest.fn(() => mockIntentFind()) }, + User: mockUserModel([], ['board-games']), + })); + + const result = await getPivotExplore(req, { + batchWeek: '2026-W22', + now, + limit: '2', + offset: '2', + }); + + expect(result.data.total).toBe(5); + expect(result.data.limit).toBe(2); + expect(result.data.offset).toBe(2); + expect(result.data.events).toHaveLength(2); + expect(result.data.events.map((event) => event.name)).toEqual([ + 'Event 2', + 'Event 3', + ]); + }); + + it('includes userIntent from the same batchWeek', async () => { + const events = [ + { + _id: '665a1b2c3d4e5f6789012345', + name: 'Interested Event', + start_time: new Date('2026-05-28T19:00:00.000Z'), + customFields: { + pivot: { + batchWeek: '2026-W22', + host: { name: 'Venue' }, + }, + }, + }, + ]; + + mockExploreModels(events, [ + { eventId: '665a1b2c3d4e5f6789012345', status: 'interested' }, + ]); + + const result = await getPivotExplore(req, { batchWeek: '2026-W22', now }); + + expect(result.data.events[0].userIntent).toBe('interested'); + }); + + describe('excludePassed intent reconcile', () => { + const mixedEvents = [ + { + _id: '665a000000000000000000a1', + name: 'Passed Event', + start_time: new Date('2026-05-28T18:00:00.000Z'), + customFields: { pivot: { host: { name: 'Venue A' } } }, + }, + { + _id: '665a000000000000000000b2', + name: 'Interested Event', + start_time: new Date('2026-05-28T19:00:00.000Z'), + customFields: { pivot: { host: { name: 'Venue B' } } }, + }, + { + _id: '665a000000000000000000c3', + name: 'Registered Event', + start_time: new Date('2026-05-28T20:00:00.000Z'), + customFields: { pivot: { host: { name: 'Venue C' } } }, + }, + { + _id: '665a000000000000000000d4', + name: 'Fresh Event', + start_time: new Date('2026-05-28T21:00:00.000Z'), + customFields: { pivot: { host: { name: 'Venue D' } } }, + }, + ]; + + const mixedIntents = [ + { eventId: '665a000000000000000000a1', status: 'passed' }, + { eventId: '665a000000000000000000b2', status: 'interested' }, + { eventId: '665a000000000000000000c3', status: 'registered' }, + ]; + + it('excludes passed events by default', async () => { + mockExploreModels(mixedEvents, mixedIntents); + + const result = await getPivotExplore(req, { batchWeek: '2026-W22', now }); + + expect(result.data.filters.excludePassed).toBe(true); + expect(result.data.catalogTotal).toBe(4); + expect(result.data.hiddenPassedCount).toBe(1); + expect(result.data.total).toBe(3); + expect(result.data.events.map((event) => event.name)).toEqual([ + 'Interested Event', + 'Registered Event', + 'Fresh Event', + ]); + expect(result.data.events.find((event) => event.name === 'Passed Event')).toBeUndefined(); + }); + + it('keeps interested and registered events with userIntent set when excludePassed is true', async () => { + mockExploreModels(mixedEvents, mixedIntents); + + const result = await getPivotExplore(req, { + batchWeek: '2026-W22', + now, + excludePassed: 'true', + }); + + const interested = result.data.events.find((event) => event.name === 'Interested Event'); + const registered = result.data.events.find((event) => event.name === 'Registered Event'); + + expect(interested?.userIntent).toBe('interested'); + expect(registered?.userIntent).toBe('registered'); + }); + + it('includes passed events with userIntent when excludePassed=false', async () => { + mockExploreModels(mixedEvents, mixedIntents); + + const result = await getPivotExplore(req, { + batchWeek: '2026-W22', + now, + excludePassed: 'false', + }); + + expect(result.data.filters.excludePassed).toBe(false); + expect(result.data.total).toBe(4); + expect(result.data.events.find((event) => event.name === 'Passed Event')?.userIntent).toBe( + 'passed', + ); + }); + }); + + it('filters by catalog tag and adds tag constraint to the Event query', async () => { + const events = [ + { + _id: '665a000000000000000000a1', + name: 'Live Music Night', + start_time: new Date('2026-05-28T19:00:00.000Z'), + customFields: { + pivot: { + host: { name: 'Venue A' }, + tags: ['live-music'], + }, + }, + }, + { + _id: '665a000000000000000000b2', + name: 'Board Games', + start_time: new Date('2026-05-28T20:00:00.000Z'), + customFields: { + pivot: { + host: { name: 'Venue B' }, + tags: ['board-games'], + }, + }, + }, + ]; + + const Event = { find: jest.fn(() => mockEventFind(events)) }; + getModels.mockReturnValue(withExploreModels({ + Event, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + })), + }, + PivotEventIntent: { find: jest.fn(() => mockIntentFind()) }, + User: mockUserModel([], ['board-games']), + })); + + const result = await getPivotExplore(req, { + batchWeek: '2026-W22', + now, + tags: 'live-music', + }); + + expect(Event.find).toHaveBeenCalledWith( + expect.objectContaining({ + 'customFields.pivot.tags': { $in: ['live-music'] }, + }), + ); + expect(result.data.filters.tags).toEqual(['live-music']); + expect(result.data.events).toHaveLength(1); + expect(result.data.events[0].name).toBe('Live Music Night'); + }); + + it('rejects unknown catalog tags with 400', async () => { + const result = await getPivotExplore(req, { + batchWeek: '2026-W22', + now, + tags: 'not-a-real-tag', + }); + + expect(result.status).toBe(400); + expect(result.code).toBe('INVALID_TAG'); + expect(result.error).toMatch(/Unknown catalog tag/); + }); + + it('friendsOnly restricts to events with friend interested or going', async () => { + const friendId = '507f191e810c19729de860ec'; + const events = [ + { + _id: '665a000000000000000000a1', + name: 'Solo Event', + start_time: new Date('2026-05-28T18:00:00.000Z'), + customFields: { pivot: { host: { name: 'Venue A' } } }, + }, + { + _id: '665a000000000000000000b2', + name: 'Friend Event', + start_time: new Date('2026-05-28T19:00:00.000Z'), + customFields: { pivot: { host: { name: 'Venue B' } } }, + }, + ]; + + const PivotEventIntent = { + find: jest.fn((query) => { + const isFriendQuery = query.userId && query.userId.$in; + const rows = isFriendQuery + ? [ + { + eventId: '665a000000000000000000b2', + userId: friendId, + status: 'interested', + }, + ] + : []; + return mockIntentFind(rows); + }), + }; + + getModels.mockReturnValue(withExploreModels({ + Event: { find: jest.fn(() => mockEventFind(events)) }, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([ + { requester: userId, recipient: friendId }, + ]), + })), + }, + PivotEventIntent, + User: mockUserModel([{ _id: friendId, name: 'Pat', picture: null }]), + })); + + const result = await getPivotExplore(req, { + batchWeek: '2026-W22', + now, + friendsOnly: 'true', + }); + + expect(result.data.filters.friendsOnly).toBe(true); + expect(result.data.total).toBe(1); + expect(result.data.events[0].name).toBe('Friend Event'); + }); + + it('filters by metadata search query q', async () => { + const events = [ + { + _id: '665a000000000000000000a1', + name: 'Sunset Listening Party', + description: 'Roof records', + start_time: new Date('2026-05-28T19:00:00.000Z'), + customFields: { pivot: { host: { name: 'Roof Records' } } }, + }, + { + _id: '665a000000000000000000b2', + name: 'Board Game Night', + description: 'Tables open late', + start_time: new Date('2026-05-28T20:00:00.000Z'), + customFields: { pivot: { host: { name: 'Brooklyn Board Game Cafe' } } }, + }, + ]; + + getModels.mockReturnValue(withExploreModels({ + Event: { find: jest.fn(() => mockEventFind(events)) }, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + })), + }, + PivotEventIntent: { find: jest.fn(() => mockIntentFind()) }, + User: mockUserModel([], ['board-games']), + })); + + const result = await getPivotExplore(req, { + batchWeek: '2026-W22', + now, + q: 'board game', + }); + + expect(result.data.filters.q).toBe('board game'); + expect(result.data.events).toHaveLength(1); + expect(result.data.events[0].name).toBe('Board Game Night'); + }); + + it('filters by night weekday shortcut', async () => { + const events = [ + { + _id: '665a000000000000000000a1', + name: 'Thursday Jazz', + start_time: new Date('2026-05-28T23:30:00.000Z'), + customFields: { pivot: { host: { name: 'Venue A' } } }, + }, + { + _id: '665a000000000000000000b2', + name: 'Friday Night Games', + start_time: new Date('2026-05-29T23:00:00.000Z'), + customFields: { pivot: { host: { name: 'Venue B' } } }, + }, + ]; + + getModels.mockReturnValue(withExploreModels({ + Event: { find: jest.fn(() => mockEventFind(events)) }, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + })), + }, + PivotEventIntent: { find: jest.fn(() => mockIntentFind()) }, + User: mockUserModel([], ['board-games']), + })); + + const result = await getPivotExplore(req, { + batchWeek: '2026-W22', + now, + night: 'fri', + }); + + expect(result.data.filters.night).toBe('fri'); + expect(result.data.events).toHaveLength(1); + expect(result.data.events[0].name).toBe('Friday Night Games'); + }); + + it('rejects invalid batchWeek', async () => { + const result = await getPivotExplore(req, { batchWeek: '2026-W999', now }); + expect(result.error).toMatch(/batchWeek/i); + expect(result.status).toBe(400); + }); + + it('rejects invalid limit', async () => { + const result = await getPivotExplore(req, { batchWeek: '2026-W22', limit: '0', now }); + expect(result.code).toBe('INVALID_LIMIT'); + expect(result.status).toBe(400); + }); + + it('normalizeExploreSort accepts for_you and soonest', () => { + expect(normalizeExploreSort(undefined)).toBe(DEFAULT_EXPLORE_SORT); + expect(normalizeExploreSort('for_you')).toBe('for_you'); + expect(normalizeExploreSort('soonest')).toBe('soonest'); + expect(normalizeExploreSort('start_time')).toBeUndefined(); + }); + + it('sort=for_you ranks interest overlap ahead of start_time', async () => { + const events = [ + { + _id: '665a000000000000000000a1', + name: 'Earlier Generic', + start_time: new Date('2026-05-28T19:00:00.000Z'), + customFields: { + pivot: { + host: { name: 'Venue A' }, + tags: ['food'], + }, + }, + }, + { + _id: '665a000000000000000000b2', + name: 'Later Match', + start_time: new Date('2026-05-29T23:00:00.000Z'), + customFields: { + pivot: { + host: { name: 'Venue B' }, + tags: ['live-music'], + }, + }, + }, + ]; + + getModels.mockReturnValue(withExploreModels({ + Event: { find: jest.fn(() => mockEventFind(events)) }, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + })), + }, + PivotEventIntent: { find: jest.fn(() => mockIntentFind()) }, + User: mockUserModel([], ['live-music']), + })); + + const result = await getPivotExplore(req, { + batchWeek: '2026-W22', + now, + sort: 'for_you', + }); + + expect(result.data.filters.sort).toBe('for_you'); + expect(result.data.events.map((event) => event.name)).toEqual([ + 'Later Match', + 'Earlier Generic', + ]); + }); + + it('sort=soonest orders by start_time only', async () => { + const events = [ + { + _id: '665a000000000000000000a1', + name: 'Earlier Generic', + start_time: new Date('2026-05-28T19:00:00.000Z'), + customFields: { + pivot: { + host: { name: 'Venue A' }, + tags: ['food'], + }, + }, + }, + { + _id: '665a000000000000000000b2', + name: 'Later Match', + start_time: new Date('2026-05-29T23:00:00.000Z'), + customFields: { + pivot: { + host: { name: 'Venue B' }, + tags: ['live-music'], + }, + }, + }, + ]; + + getModels.mockReturnValue(withExploreModels({ + Event: { find: jest.fn(() => mockEventFind(events)) }, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + })), + }, + PivotEventIntent: { find: jest.fn(() => mockIntentFind()) }, + User: mockUserModel([], ['live-music']), + })); + + const result = await getPivotExplore(req, { + batchWeek: '2026-W22', + now, + sort: 'soonest', + }); + + expect(result.data.filters.sort).toBe('soonest'); + expect(result.data.events.map((event) => event.name)).toEqual([ + 'Earlier Generic', + 'Later Match', + ]); + }); + + it('rejects invalid sort', async () => { + const result = await getPivotExplore(req, { + batchWeek: '2026-W22', + sort: 'magic', + now, + }); + expect(result.code).toBe('INVALID_SORT'); + expect(result.status).toBe(400); + }); + + it('previewMode requires batchWeek', async () => { + const result = await getPivotExplore( + { user: null, school: 'nyc' }, + { previewMode: true, now }, + ); + expect(result.code).toBe('BATCH_WEEK_REQUIRED'); + expect(result.status).toBe(400); + }); + + it('previewMode skips end-user auth and sets previewMode flag', async () => { + const events = [ + { + _id: '665a000000000000000000a1', + name: 'Preview Event', + start_time: new Date('2026-05-28T19:00:00.000Z'), + customFields: { + pivot: { + host: { name: 'Venue A' }, + tags: ['live-music'], + }, + }, + }, + ]; + + getModels.mockReturnValue(withExploreModels({ + Event: { find: jest.fn(() => mockEventFind(events)) }, + })); + + const result = await getPivotExplore( + { user: null, school: 'nyc' }, + { previewMode: true, batchWeek: '2026-W22', now }, + ); + + expect(result.data.previewMode).toBe(true); + expect(result.data.events).toHaveLength(1); + expect(result.data.events[0].name).toBe('Preview Event'); + }); + + it('previewMode includes staged and draft catalog rows', async () => { + const events = [ + { + _id: '665a000000000000000000a1', + name: 'Published Event', + start_time: new Date('2026-05-28T19:00:00.000Z'), + customFields: { + pivot: { + host: { name: 'Venue A' }, + ingestStatus: 'published', + }, + }, + }, + { + _id: '665a000000000000000000a2', + name: 'Staged Event', + start_time: new Date('2026-05-29T19:00:00.000Z'), + customFields: { + pivot: { + host: { name: 'Venue B' }, + ingestStatus: 'staged', + }, + }, + }, + ]; + + const find = jest.fn(() => mockEventFind(events)); + getModels.mockReturnValue(withExploreModels({ + Event: { find }, + })); + + const result = await getPivotExplore( + { user: null, school: 'nyc' }, + { previewMode: true, batchWeek: '2026-W22', now }, + ); + + expect(find).toHaveBeenCalledWith( + expect.objectContaining({ + 'customFields.pivot.ingestStatus': { $in: ['draft', 'staged', 'published'] }, + }), + ); + expect(result.data.events.map((event) => event.name)).toEqual([ + 'Published Event', + 'Staged Event', + ]); + }); +}); diff --git a/backend/tests/unit/pivotFeedService.test.js b/backend/tests/unit/pivotFeedService.test.js index d170d3f4..d8665d8b 100644 --- a/backend/tests/unit/pivotFeedService.test.js +++ b/backend/tests/unit/pivotFeedService.test.js @@ -2,9 +2,15 @@ jest.mock('../../services/getModelService', () => jest.fn()); jest.mock('../../services/tenantConfigService', () => ({ getTenantByKey: jest.fn(), })); +jest.mock('../../services/pivotDeckSnapshotService', () => ({ + normalizeDeckSnapshotRefresh: jest.requireActual('../../services/pivotDeckSnapshotService') + .normalizeDeckSnapshotRefresh, + recordPivotDeckSnapshot: jest.fn().mockResolvedValue({ skipped: false }), +})); const getModels = require('../../services/getModelService'); const { getTenantByKey } = require('../../services/tenantConfigService'); +const { recordPivotDeckSnapshot } = require('../../services/pivotDeckSnapshotService'); const { getPivotFeed, getPivotEventFriends, @@ -20,6 +26,7 @@ const { compareByFeedRank, normalizeInterestTagSet, loadNegativeFeedbackTags, + resolvePivotFeedBatchWeek, } = require('../../services/pivotFeedService'); function mockUserModel(pivotInterestTags = [], friendUsers = []) { @@ -148,6 +155,11 @@ describe('pivotFeedService helpers', () => { pivot: { tags: ['board-games'], host: { name: 'Brooklyn Board Game Cafe' }, + enrichment: { + vibe: ['cozy'], + priceBand: 'low', + neighborhood: 'park slope', + }, }, }, }, @@ -167,6 +179,11 @@ describe('pivotFeedService helpers', () => { displayHost: { name: 'Brooklyn Board Game Cafe' }, userIntent: 'interested', tags: ['board-games'], + enrichment: { + vibe: ['cozy'], + priceBand: 'low', + neighborhood: 'park slope', + }, friendsInterestedCount: 0, friendsGoingCount: 0, }); @@ -266,10 +283,12 @@ describe('getPivotFeed', () => { beforeEach(() => { getModels.mockReset(); getTenantByKey.mockReset(); + recordPivotDeckSnapshot.mockClear(); getTenantByKey.mockResolvedValue({ tenantKey: 'nyc', name: 'New York City Pilot', location: 'New York City', + pivotPilot: true, }); }); @@ -330,9 +349,11 @@ describe('getPivotFeed', () => { expect(result.data.batchWeek).toBe('2026-W22'); expect(result.data.cityDisplayName).toBe('New York City'); + expect(result.data.rankerVersion).toBe('rules_v0'); expect(result.data.events).toHaveLength(1); expect(result.data.events[0].displayHost).toEqual({ name: 'Roof Records' }); expect(result.data.events[0].userIntent).toBeNull(); + expect(result.data.events[0].rankInFeed).toBe(0); expect(Event.find).toHaveBeenCalledWith( expect.objectContaining({ 'customFields.pivot.batchWeek': '2026-W22', @@ -625,6 +646,8 @@ describe('getPivotFeed', () => { 'Friend Interested', 'No Friends (popular)', ]); + expect(result.data.events.map((event) => event.rankInFeed)).toEqual([0, 1, 2]); + expect(result.data.rankerVersion).toBe('rules_v0'); const registered = result.data.events[0]; expect(registered.friendsGoing).toHaveLength(1); expect(registered.friendsInterested).toHaveLength(1); @@ -922,6 +945,105 @@ describe('getPivotFeed', () => { batchWeek: '2026-W22', }); }); + + it('records a deck snapshot matching the ranked feed order', async () => { + const events = [ + { + _id: '665a000000000000000000a1', + name: 'Earlier Generic', + start_time: new Date('2026-05-28T19:00:00.000Z'), + customFields: { + pivot: { + batchWeek: '2026-W22', + ingestStatus: 'published', + host: { name: 'Venue A' }, + tags: ['food'], + }, + }, + }, + { + _id: '665a000000000000000000b2', + name: 'Later Match', + start_time: new Date('2026-05-29T23:00:00.000Z'), + customFields: { + pivot: { + batchWeek: '2026-W22', + ingestStatus: 'published', + host: { name: 'Venue B' }, + tags: ['live-music'], + }, + }, + }, + ]; + + getModels.mockReturnValue(withFeedModels({ + Event: { find: jest.fn(() => mockEventFind(events)) }, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + })), + }, + PivotEventIntent: { find: jest.fn(() => mockIntentFind()) }, + User: mockUserModel(['live-music']), + })); + + const result = await getPivotFeed(req, { batchWeek: '2026-W22', now }); + + expect(result.data.events.map((event) => event._id)).toEqual([ + '665a000000000000000000b2', + '665a000000000000000000a1', + ]); + expect(recordPivotDeckSnapshot).toHaveBeenCalledWith( + req, + expect.objectContaining({ + userId, + batchWeek: '2026-W22', + orderedEventIds: [ + events[1]._id, + events[0]._id, + ], + rankerVersion: 'rules_v0', + forceRefresh: false, + }), + ); + }); + + it('ignores refresh=1 for non-admin users', async () => { + const events = [ + { + _id: '665a1b2c3d4e5f6789012345', + name: 'Sunset Listening Party', + start_time: new Date('2026-05-28T22:00:00.000Z'), + customFields: { + pivot: { + batchWeek: '2026-W22', + ingestStatus: 'published', + host: { name: 'Roof Records' }, + }, + }, + }, + ]; + + getModels.mockReturnValue(withFeedModels({ + Event: { find: jest.fn(() => mockEventFind(events)) }, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + })), + }, + PivotEventIntent: { find: jest.fn(() => mockIntentFind()) }, + User: mockUserModel(), + })); + + await getPivotFeed(req, { batchWeek: '2026-W22', now, refresh: '1' }); + + expect(recordPivotDeckSnapshot).toHaveBeenCalledWith( + req, + expect.objectContaining({ forceRefresh: false }), + ); + }); }); describe('getPivotEventFriends', () => { @@ -998,3 +1120,133 @@ describe('getPivotEventFriends', () => { expect(result.code).toBe('EVENT_NOT_FOUND'); }); }); + +describe('resolvePivotFeedBatchWeek', () => { + const now = new Date('2026-07-13T16:00:00.000Z'); + const nycTenant = { + tenantKey: 'nyc', + tenantType: 'pivot', + pivotPilot: true, + pivotDropTimezone: 'America/New_York', + pivotDropDayOfWeek: 4, + pivotDropHour: 18, + pivotDropMinute: 0, + }; + + function mockCatalogProbe(eventsByWeek) { + getModels.mockReturnValue({ + Event: { + find: jest.fn((query) => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue( + eventsByWeek[query['customFields.pivot.batchWeek']] || [], + ), + })), + }, + }); + } + + it('returns requested batchWeek without probing adjacent weeks', async () => { + const Event = { find: jest.fn() }; + getModels.mockReturnValue({ Event }); + const req = { school: 'nyc' }; + + const result = await resolvePivotFeedBatchWeek(req, { + tenant: nycTenant, + now, + requestedBatchWeek: '2026-W22', + }); + + expect(result).toEqual({ + batchWeek: '2026-W22', + batchWeekSource: 'query', + catalogProbeWeeks: ['2026-W22'], + }); + expect(Event.find).not.toHaveBeenCalled(); + }); + + it('keeps the live consumer week Mon–Wed before the Thursday drop', async () => { + mockCatalogProbe({ + '2026-W28': [ + { + start_time: new Date('2026-07-14T23:00:00.000Z'), + end_time: new Date('2026-07-15T03:00:00.000Z'), + customFields: { + pivot: { host: { name: 'Venue' } }, + }, + }, + ], + '2026-W29': [ + { + start_time: new Date('2026-07-16T23:00:00.000Z'), + end_time: new Date('2026-07-17T03:00:00.000Z'), + customFields: { + pivot: { host: { name: 'Venue' } }, + }, + }, + ], + }); + const req = { school: 'nyc' }; + + const result = await resolvePivotFeedBatchWeek(req, { + tenant: nycTenant, + now, + }); + + expect(result.batchWeek).toBe('2026-W28'); + expect(result.batchWeekSource).toBe('consumer_week'); + expect(result.catalogMatchCount).toBe(1); + }); + + it('does not probe the next week before the drop instant', async () => { + mockCatalogProbe({ + '2026-W28': [], + '2026-W29': [], + '2026-W30': [ + { + start_time: new Date('2026-07-23T23:00:00.000Z'), + end_time: new Date('2026-07-24T03:00:00.000Z'), + customFields: { + pivot: { host: { name: 'Venue' } }, + }, + }, + ], + }); + const req = { school: 'nyc' }; + + const result = await resolvePivotFeedBatchWeek(req, { + tenant: nycTenant, + now, + }); + + expect(result.batchWeek).toBe('2026-W28'); + expect(result.batchWeekSource).toBe('consumer_week'); + expect(result.catalogMatchCount).toBe(0); + expect(result.catalogProbeWeeks).toEqual(['2026-W28', '2026-W27']); + }); + + it('falls back to the next week after the drop instant when the live week is exhausted', async () => { + const afterDrop = new Date('2026-07-17T23:00:00.000Z'); + mockCatalogProbe({ + '2026-W29': [], + '2026-W30': [ + { + start_time: new Date('2026-07-23T23:00:00.000Z'), + end_time: new Date('2026-07-24T03:00:00.000Z'), + customFields: { + pivot: { host: { name: 'Venue' } }, + }, + }, + ], + }); + const req = { school: 'nyc' }; + + const result = await resolvePivotFeedBatchWeek(req, { + tenant: nycTenant, + now: afterDrop, + }); + + expect(result.batchWeek).toBe('2026-W30'); + expect(result.batchWeekSource).toBe('catalog_fallback'); + }); +}); diff --git a/backend/tests/unit/pivotFeedbackService.test.js b/backend/tests/unit/pivotFeedbackService.test.js index 724f86b1..359f8e0c 100644 --- a/backend/tests/unit/pivotFeedbackService.test.js +++ b/backend/tests/unit/pivotFeedbackService.test.js @@ -4,9 +4,15 @@ jest.mock('../../services/feedbackService', () => { submitFeedback: jest.fn(), })); }); +jest.mock('../../services/pivotInteractionService', () => ({ + recordPivotInteraction: jest.fn(), + pickInteractionContext: jest.requireActual('../../services/pivotInteractionService') + .pickInteractionContext, +})); const getModels = require('../../services/getModelService'); const FeedbackService = require('../../services/feedbackService'); +const { recordPivotInteraction } = require('../../services/pivotInteractionService'); const { getPendingEventFeedback, submitEventFeedback, @@ -109,6 +115,7 @@ describe('submitEventFeedback', () => { beforeEach(() => { getModels.mockReset(); FeedbackService.mockClear(); + recordPivotInteraction.mockClear(); }); it('rejects invalid rating', async () => { @@ -199,6 +206,15 @@ describe('submitEventFeedback', () => { expect.objectContaining({ batchWeek: '2026-W26' }), ); expect(ensurePivotEventFeedbackConfig).toHaveBeenCalledWith(userId); + expect(recordPivotInteraction).toHaveBeenCalledWith( + req, + expect.objectContaining({ + type: 'rating', + rating: 4, + surface: 'deck', + batchWeek: '2026-W26', + }), + ); }); }); diff --git a/backend/tests/unit/pivotIngestPublishService.test.js b/backend/tests/unit/pivotIngestPublishService.test.js index 0fe5e7a6..dcc3c89d 100644 --- a/backend/tests/unit/pivotIngestPublishService.test.js +++ b/backend/tests/unit/pivotIngestPublishService.test.js @@ -716,6 +716,109 @@ describe('pivotIngestPublishService updateIngestEvent', () => { ); }); + it('updates enrichment metadata without blocking publish', async () => { + Event.findByIdAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: '507f1f77bcf86cd799439012', + name: 'Updated Event', + customFields: { + pivot: { + ingestStatus: 'published', + host: { name: 'New Host' }, + tags: ['live-music'], + enrichment: { + vibe: ['dancey', 'loud'], + priceBand: 'mid', + neighborhood: 'downtown', + audience: '21+', + }, + }, + }, + }), + }); + + const result = await updateIngestEvent( + { globalDb: {} }, + { + tenantKey: 'nyc', + eventId: '507f1f77bcf86cd799439012', + overrides: { + enrichment: { + vibe: 'dancey, loud', + priceBand: 'mid', + neighborhood: 'downtown', + audience: '21+', + }, + }, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.data.event.enrichment).toEqual({ + vibe: ['dancey', 'loud'], + priceBand: 'mid', + neighborhood: 'downtown', + audience: '21+', + }); + expect(Event.findByIdAndUpdate).toHaveBeenCalledWith( + '507f1f77bcf86cd799439012', + expect.objectContaining({ + $set: expect.objectContaining({ + 'customFields.pivot': expect.objectContaining({ + enrichment: { + vibe: ['dancey', 'loud'], + priceBand: 'mid', + neighborhood: 'downtown', + audience: '21+', + }, + }), + }), + }), + expect.any(Object), + ); + }); + + it('still publishes when enrichment is empty', async () => { + Event.findByIdAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: '507f1f77bcf86cd799439012', + name: 'Published Event', + customFields: { + pivot: { + ingestStatus: 'published', + host: { name: 'New Host' }, + tags: ['live-music'], + }, + }, + }), + }); + + const result = await updateIngestEvent( + { globalDb: {} }, + { + tenantKey: 'nyc', + eventId: '507f1f77bcf86cd799439012', + overrides: { + ingestStatus: 'published', + enrichment: {}, + }, + }, + ); + + expect(result.error).toBeUndefined(); + expect(Event.findByIdAndUpdate).toHaveBeenCalledWith( + '507f1f77bcf86cd799439012', + expect.objectContaining({ + $set: expect.objectContaining({ + 'customFields.pivot': expect.not.objectContaining({ + enrichment: expect.anything(), + }), + }), + }), + expect.any(Object), + ); + }); + it('rejects invalid ingestStatus', async () => { const result = await updateIngestEvent( { globalDb: {} }, diff --git a/backend/tests/unit/pivotIntentService.test.js b/backend/tests/unit/pivotIntentService.test.js index 57224a1b..85b58486 100644 --- a/backend/tests/unit/pivotIntentService.test.js +++ b/backend/tests/unit/pivotIntentService.test.js @@ -1,7 +1,13 @@ jest.mock('../../services/getModelService', () => jest.fn()); +jest.mock('../../services/pivotInteractionService', () => ({ + recordPivotInteraction: jest.fn(), + pickInteractionContext: jest.requireActual('../../services/pivotInteractionService') + .pickInteractionContext, +})); const getModels = require('../../services/getModelService'); const { getFeedPilotWindowFilter } = require('../../services/pivotFeedService'); +const { recordPivotInteraction } = require('../../services/pivotInteractionService'); const { recordFeedAction, recordExternalOpen, @@ -43,6 +49,7 @@ function publishedEvent(overrides = {}) { describe('recordFeedAction', () => { beforeEach(() => { getModels.mockReset(); + recordPivotInteraction.mockClear(); }); it('rejects an invalid eventId', async () => { @@ -96,6 +103,88 @@ describe('recordFeedAction', () => { { $set: { status: 'passed', batchWeek: '2026-W22', timeSlotId: null } }, expect.objectContaining({ upsert: true }), ); + expect(recordPivotInteraction).toHaveBeenCalledWith( + req, + expect.objectContaining({ + userId, + eventId, + batchWeek: '2026-W22', + type: 'pass', + surface: 'deck', + retrieval: 'weekly_batch', + }), + ); + }); + + it('defaults surface to deck and accepts surface explore without failing intent', async () => { + const findOneAndUpdate = jest.fn(() => ({ + lean: jest.fn().mockResolvedValue({ + eventId, + status: 'interested', + batchWeek: '2026-W22', + timeSlotId: null, + }), + })); + getModels.mockImplementation((_req, ...names) => { + if (names.includes('Event')) { + return { Event: { findOne: jest.fn(() => mockEventFindOne(publishedEvent())) } }; + } + return { PivotEventIntent: { findOneAndUpdate } }; + }); + + const result = await recordFeedAction(req, { + eventId, + action: 'interested', + now, + surface: 'explore', + retrieval: 'filter', + rankInFeed: 4, + }); + + expect(result.error).toBeUndefined(); + expect(result.data.status).toBe('interested'); + expect(findOneAndUpdate).toHaveBeenCalledWith( + { userId, eventId }, + { $set: { status: 'interested', batchWeek: '2026-W22', timeSlotId: null } }, + expect.objectContaining({ upsert: true }), + ); + expect(recordPivotInteraction).toHaveBeenCalledWith( + req, + expect.objectContaining({ + type: 'interested', + surface: 'explore', + retrieval: 'filter', + rankInFeed: 4, + }), + ); + }); + + it('still upserts intent when surface is omitted (defaults on interaction)', async () => { + const findOneAndUpdate = jest.fn(() => ({ + lean: jest.fn().mockResolvedValue({ + eventId, + status: 'interested', + batchWeek: '2026-W22', + timeSlotId: null, + }), + })); + getModels.mockImplementation((_req, ...names) => { + if (names.includes('Event')) { + return { Event: { findOne: jest.fn(() => mockEventFindOne(publishedEvent())) } }; + } + return { PivotEventIntent: { findOneAndUpdate } }; + }); + + await recordFeedAction(req, { eventId, action: 'interested', now }); + + expect(recordPivotInteraction).toHaveBeenCalledWith( + req, + expect.objectContaining({ + type: 'interested', + surface: 'deck', + retrieval: 'weekly_batch', + }), + ); }); it('uses the feed pilot-window filter for swipe actions (multi-showtime aware)', async () => { @@ -161,6 +250,7 @@ describe('recordFeedAction', () => { describe('recordExternalOpen', () => { beforeEach(() => { getModels.mockReset(); + recordPivotInteraction.mockClear(); }); it('increments externalOpenCount and defaults a new row to interested', async () => { @@ -188,6 +278,14 @@ describe('recordExternalOpen', () => { expect(update.$setOnInsert).toEqual({ status: 'interested', batchWeek: '2026-W22' }); expect(update.$set).toHaveProperty('externalOpenAt'); expect(opts).toEqual(expect.objectContaining({ upsert: true })); + expect(recordPivotInteraction).toHaveBeenCalledWith( + req, + expect.objectContaining({ + type: 'external_open', + surface: 'deck', + batchWeek: '2026-W22', + }), + ); }); it('returns 404 for non-pivot events', async () => { @@ -209,6 +307,7 @@ describe('recordExternalOpen', () => { describe('confirmRegistered', () => { beforeEach(() => { getModels.mockReset(); + recordPivotInteraction.mockClear(); }); it('sets status registered idempotently', async () => { @@ -232,6 +331,14 @@ describe('confirmRegistered', () => { { $set: { status: 'registered', batchWeek: '2026-W22', timeSlotId: null } }, expect.objectContaining({ upsert: true }), ); + expect(recordPivotInteraction).toHaveBeenCalledWith( + req, + expect.objectContaining({ + type: 'registered', + surface: 'deck', + batchWeek: '2026-W22', + }), + ); }); it('returns 404 for non-pivot events', async () => { diff --git a/backend/tests/unit/pivotInteraction.test.js b/backend/tests/unit/pivotInteraction.test.js new file mode 100644 index 00000000..a9689a1d --- /dev/null +++ b/backend/tests/unit/pivotInteraction.test.js @@ -0,0 +1,382 @@ +const mongoose = require('mongoose'); +const { + createMongoMemoryConnection, + getOrCreateModel, +} = require('../helpers/mongoMemory'); +const pivotInteractionSchema = require('../../schemas/pivotInteraction'); +const pivotEventIntentSchema = require('../../schemas/pivotEventIntent'); + +jest.mock('../../services/getModelService', () => jest.fn()); + +const getModels = require('../../services/getModelService'); +const { + normalizePivotInteractionPayload, + writePivotInteraction, + recordPivotInteraction, + recordPivotImpressions, + recordPivotMicroInteractions, + pickInteractionContext, + DEFAULT_SURFACE, + DEFAULT_RETRIEVAL, +} = require('../../services/pivotInteractionService'); + +describe('PivotInteraction schema + writer (Task 1.1)', () => { + let mongo; + let PivotInteraction; + let PivotEventIntent; + let req; + + const userId = new mongoose.Types.ObjectId(); + const eventId = new mongoose.Types.ObjectId(); + + beforeAll(async () => { + mongo = await createMongoMemoryConnection(); + PivotInteraction = getOrCreateModel( + mongo.connection, + 'PivotInteraction', + pivotInteractionSchema, + 'pivotInteractions', + ); + PivotEventIntent = getOrCreateModel( + mongo.connection, + 'PivotEventIntent', + pivotEventIntentSchema, + 'pivotEventIntents', + ); + req = { db: mongo.connection, user: { userId: String(userId) }, school: 'nyc' }; + + getModels.mockImplementation((_req, ...names) => { + const models = { PivotInteraction, PivotEventIntent }; + return names.reduce((acc, name) => { + if (models[name]) acc[name] = models[name]; + return acc; + }, {}); + }); + }); + + afterEach(async () => { + await mongo.reset(); + }); + + afterAll(async () => { + await mongo.cleanup(); + }); + + describe('pickInteractionContext', () => { + it('defaults surface and retrieval when omitted', () => { + expect(pickInteractionContext({})).toEqual({ + surface: DEFAULT_SURFACE, + retrieval: DEFAULT_RETRIEVAL, + }); + }); + + it('passes through explore surface', () => { + expect( + pickInteractionContext({ + surface: 'explore', + retrieval: 'filter', + rankInFeed: 2, + }), + ).toEqual({ + surface: 'explore', + retrieval: 'filter', + rankInFeed: 2, + }); + }); + }); + + describe('normalizePivotInteractionPayload', () => { + it('coerces invalid surface to deck', () => { + const result = normalizePivotInteractionPayload({ + userId, + eventId, + batchWeek: '2026-W28', + surface: 'homepage', + type: 'impression', + }); + + expect(result.error).toBeUndefined(); + expect(result.doc.surface).toBe(DEFAULT_SURFACE); + }); + + it('coerces invalid retrieval to weekly_batch', () => { + const result = normalizePivotInteractionPayload({ + userId, + eventId, + batchWeek: '2026-W28', + surface: 'explore', + retrieval: 'magic', + type: 'impression', + }); + + expect(result.doc.retrieval).toBe(DEFAULT_RETRIEVAL); + expect(result.doc.surface).toBe('explore'); + }); + + it('rejects invalid type', () => { + const result = normalizePivotInteractionPayload({ + userId, + eventId, + batchWeek: '2026-W28', + type: 'swipe', + }); + + expect(result.error).toMatch(/Invalid interaction type/); + expect(result.code).toBe('INVALID_INTERACTION_TYPE'); + }); + }); + + describe('writePivotInteraction', () => { + it('write + read round-trip preserves fields', async () => { + const result = await writePivotInteraction(req, { + userId, + eventId, + batchWeek: '2026-W28', + surface: 'explore', + retrieval: 'filter', + type: 'impression', + rankInFeed: 3, + rankerVersion: 'rules_v0', + requestId: 'req-abc', + filters: { tags: ['live-music'], night: 'fri' }, + }); + + expect(result.error).toBeUndefined(); + expect(result.data._id).toBeDefined(); + expect(result.data.surface).toBe('explore'); + expect(result.data.retrieval).toBe('filter'); + expect(result.data.rankInFeed).toBe(3); + expect(result.data.requestId).toBe('req-abc'); + expect(result.data.filters).toEqual({ + tags: ['live-music'], + night: 'fri', + }); + + const found = await PivotInteraction.findById(result.data._id).lean(); + expect(found).toBeTruthy(); + expect(String(found.userId)).toBe(String(userId)); + expect(String(found.eventId)).toBe(String(eventId)); + expect(found.batchWeek).toBe('2026-W28'); + expect(found.type).toBe('impression'); + }); + + it('does not replace PivotEventIntent (state vs log)', async () => { + await writePivotInteraction(req, { + userId, + eventId, + batchWeek: '2026-W28', + surface: 'deck', + type: 'interested', + }); + + const intentCount = await PivotEventIntent.countDocuments(); + const interactionCount = await PivotInteraction.countDocuments(); + + expect(interactionCount).toBe(1); + expect(intentCount).toBe(0); + }); + + it('skips write when type is invalid', async () => { + const result = await writePivotInteraction(req, { + userId, + eventId, + batchWeek: '2026-W28', + type: 'not-a-type', + }); + + expect(result.skipped).toBe(true); + expect(await PivotInteraction.countDocuments()).toBe(0); + }); + }); + + describe('recordPivotInteraction', () => { + it('schedules a fire-and-forget write from req.user', async () => { + let scheduled = null; + const setImmediateSpy = jest + .spyOn(global, 'setImmediate') + .mockImplementation((fn) => { + scheduled = fn; + return 0; + }); + + recordPivotInteraction(req, { + eventId, + batchWeek: '2026-W28', + surface: 'deck', + retrieval: 'weekly_batch', + type: 'impression', + rankInFeed: 0, + }); + + expect(scheduled).toEqual(expect.any(Function)); + expect(await PivotInteraction.countDocuments()).toBe(0); + + setImmediateSpy.mockRestore(); + await scheduled(); + + const rows = await PivotInteraction.find().lean(); + expect(rows).toHaveLength(1); + expect(String(rows[0].userId)).toBe(String(userId)); + expect(rows[0].type).toBe('impression'); + expect(rows[0].rankInFeed).toBe(0); + }); + }); + + describe('recordPivotImpressions', () => { + it('schedules impression rows with rankInFeed and rules_v0', async () => { + const callbacks = []; + const setImmediateSpy = jest + .spyOn(global, 'setImmediate') + .mockImplementation((fn) => { + callbacks.push(fn); + return 0; + }); + + const result = recordPivotImpressions(req, { + batchWeek: '2026-W28', + impressions: [ + { eventId, rankInFeed: 0 }, + { eventId: new mongoose.Types.ObjectId(), rankInFeed: 2 }, + ], + }); + + expect(result.error).toBeUndefined(); + expect(result.data).toEqual({ + accepted: 2, + skipped: 0, + received: 2, + }); + expect(callbacks).toHaveLength(2); + + setImmediateSpy.mockRestore(); + await Promise.all(callbacks.map((fn) => fn())); + + const rows = await PivotInteraction.find().sort({ rankInFeed: 1 }).lean(); + expect(rows).toHaveLength(2); + expect(rows[0].type).toBe('impression'); + expect(rows[0].surface).toBe('deck'); + expect(rows[0].retrieval).toBe('weekly_batch'); + expect(rows[0].rankInFeed).toBe(0); + expect(rows[0].rankerVersion).toBe('rules_v0'); + expect(rows[1].rankInFeed).toBe(2); + }); + + it('skips invalid items without failing the batch', () => { + const result = recordPivotImpressions(req, { + batchWeek: '2026-W28', + impressions: [ + { eventId: 'not-an-id', rankInFeed: 0 }, + { eventId, rankInFeed: 1 }, + ], + }); + + expect(result.data.accepted).toBe(1); + expect(result.data.skipped).toBe(1); + expect(result.data.received).toBe(2); + }); + }); + + describe('recordPivotMicroInteractions', () => { + it('schedules dwell rows with clamped ms', async () => { + const callbacks = []; + const setImmediateSpy = jest + .spyOn(global, 'setImmediate') + .mockImplementation((fn) => { + callbacks.push(fn); + return 0; + }); + + const result = recordPivotMicroInteractions(req, { + batchWeek: '2026-W28', + interactions: [ + { + eventId, + type: 'dwell', + ms: 1200, + surface: 'deck', + retrieval: 'weekly_batch', + rankInFeed: 1, + }, + { + eventId: new mongoose.Types.ObjectId(), + type: 'detail_open', + surface: 'explore', + retrieval: 'filter', + rankInFeed: 4, + }, + ], + }); + + expect(result.error).toBeUndefined(); + expect(result.data).toEqual({ + accepted: 2, + skipped: 0, + received: 2, + }); + + setImmediateSpy.mockRestore(); + await Promise.all(callbacks.map((fn) => fn())); + + const dwell = await PivotInteraction.findOne({ type: 'dwell' }).lean(); + expect(dwell.ms).toBe(1200); + expect(dwell.surface).toBe('deck'); + + const detailOpen = await PivotInteraction.findOne({ type: 'detail_open' }).lean(); + expect(detailOpen.ms).toBeNull(); + expect(detailOpen.surface).toBe('explore'); + }); + + it('clamps dwell ms above cap and skips zero ms', async () => { + const callbacks = []; + const setImmediateSpy = jest + .spyOn(global, 'setImmediate') + .mockImplementation((fn) => { + callbacks.push(fn); + return 0; + }); + + const result = recordPivotMicroInteractions(req, { + batchWeek: '2026-W28', + interactions: [ + { + eventId, + type: 'dwell', + ms: 6 * 60 * 1000, + }, + { + eventId, + type: 'dwell', + ms: 0, + }, + { + eventId, + type: 'detail_open', + }, + ], + }); + + expect(result.data.accepted).toBe(2); + expect(result.data.skipped).toBe(1); + + setImmediateSpy.mockRestore(); + await Promise.all(callbacks.map((fn) => fn())); + + const dwell = await PivotInteraction.findOne({ type: 'dwell' }).lean(); + expect(dwell.ms).toBe(5 * 60 * 1000); + }); + }); + + describe('schema validation', () => { + it('rejects invalid surface at the mongoose layer', async () => { + const doc = new PivotInteraction({ + userId, + eventId, + batchWeek: '2026-W28', + surface: 'homepage', + type: 'impression', + }); + + await expect(doc.validate()).rejects.toThrow(/surface/); + }); + }); +}); diff --git a/backend/tests/unit/pivotIsoWeek.test.js b/backend/tests/unit/pivotIsoWeek.test.js index ed253592..cc36a779 100644 --- a/backend/tests/unit/pivotIsoWeek.test.js +++ b/backend/tests/unit/pivotIsoWeek.test.js @@ -3,6 +3,9 @@ const { isValidIsoWeek, isoWeekToMondayUtc, isoWeekToUtcRange, + batchWeekToDropCycleUtcRange, + batchWeekToEventWindowUtcRange, + formatBatchWeekRangeLabel, shiftIsoWeek, batchWeekFromEventDate, resolveEventBatchWeek, @@ -38,6 +41,34 @@ describe('pivotIsoWeek', () => { }); }); + describe('batchWeekToEventWindowUtcRange', () => { + it('covers drop day through the Wednesday before the next drop', () => { + const { start, end } = batchWeekToEventWindowUtcRange('2026-W28', 4); + expect(start.toISOString()).toBe('2026-07-09T12:00:00.000Z'); + expect(end.toISOString()).toBe('2026-07-15T12:00:00.000Z'); + }); + }); + + describe('formatBatchWeekRangeLabel', () => { + it('formats Thu–Wed drop cycle labels in tenant timezone', () => { + expect(formatBatchWeekRangeLabel('2026-W28', { dropDayOfWeek: 4, timeZone: 'UTC' })).toBe( + 'Jul 9 – Jul 15, 2026', + ); + expect( + formatBatchWeekRangeLabel('2026-W28', { + dropDayOfWeek: 4, + timeZone: 'America/New_York', + }), + ).toBe('Jul 9 – Jul 15, 2026'); + expect( + formatBatchWeekRangeLabel('2026-W29', { + dropDayOfWeek: 4, + timeZone: 'America/New_York', + }), + ).toBe('Jul 16 – Jul 22, 2026'); + }); + }); + describe('shiftIsoWeek', () => { it('shifts forward and backward', () => { expect(shiftIsoWeek('2026-W27', 1)).toBe('2026-W28'); diff --git a/backend/tests/unit/pivotTagSuggestService.test.js b/backend/tests/unit/pivotTagSuggestService.test.js index 61cb9a7b..307172ce 100644 --- a/backend/tests/unit/pivotTagSuggestService.test.js +++ b/backend/tests/unit/pivotTagSuggestService.test.js @@ -12,6 +12,8 @@ const { parseTagsFromClaudeText, buildTagSuggestionPrompt, resolveAnthropicApiKey, + mapWithConcurrency, + resolveBatchConcurrency, } = require('../../services/pivotTagSuggestService'); describe('pivotTagSuggestService', () => { @@ -113,6 +115,58 @@ describe('pivotTagSuggestService', () => { expect(result.data.suggestions).toHaveLength(2); expect(result.data.suggestedCount).toBe(2); + expect(result.data.concurrency).toBe(4); + // Shared catalog — one listPivotTags for the batch, not per event. + expect(listPivotTags).toHaveBeenCalledTimes(1); + }); + + it('batch suggestion runs Claude calls concurrently (capped)', async () => { + let inFlight = 0; + let maxInFlight = 0; + axios.post.mockImplementation( + () => + new Promise((resolve) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + setTimeout(() => { + inFlight -= 1; + resolve({ + data: { + content: [{ type: 'text', text: '{"tags":["live-music"]}' }], + }, + }); + }, 30); + }), + ); + + const events = Array.from({ length: 6 }, (_, i) => ({ name: `Event ${i}` })); + const result = await suggestPivotEventTagsBatch( + { globalDb: {} }, + events, + { concurrency: 3 }, + ); + + expect(result.data.suggestedCount).toBe(6); + expect(result.data.concurrency).toBe(3); + expect(maxInFlight).toBe(3); + expect(axios.post).toHaveBeenCalledTimes(6); + }); + + it('mapWithConcurrency preserves order with a worker pool', async () => { + const seen = []; + const results = await mapWithConcurrency([1, 2, 3, 4], 2, async (value) => { + seen.push(value); + await new Promise((resolve) => setTimeout(resolve, 5)); + return value * 10; + }); + expect(results).toEqual([10, 20, 30, 40]); + expect(seen).toHaveLength(4); + }); + + it('resolveBatchConcurrency caps and falls back', () => { + expect(resolveBatchConcurrency(3)).toBe(3); + expect(resolveBatchConcurrency(99)).toBe(8); + expect(resolveBatchConcurrency(0)).toBe(4); }); it('batch suggestion returns error when every event fails', async () => { @@ -135,4 +189,21 @@ describe('pivotTagSuggestService', () => { process.env.CLAUDE_API_KEY = 'alias-key'; expect(resolveAnthropicApiKey()).toBe('alias-key'); }); + + it('suggestAndApplyPivotEventTags requires tenantKey and eventIds', async () => { + const { + suggestAndApplyPivotEventTags, + } = require('../../services/pivotTagSuggestService'); + + await expect( + suggestAndApplyPivotEventTags({ globalDb: {} }, { eventIds: ['x'] }), + ).resolves.toMatchObject({ code: 'TENANT_KEY_REQUIRED' }); + + await expect( + suggestAndApplyPivotEventTags( + { globalDb: {} }, + { tenantKey: 'nyc', eventIds: [] }, + ), + ).resolves.toMatchObject({ code: 'EVENT_IDS_REQUIRED' }); + }); }); diff --git a/backend/tests/unit/pivotTenantOpsService.test.js b/backend/tests/unit/pivotTenantOpsService.test.js index d2d9029f..79d9f29b 100644 --- a/backend/tests/unit/pivotTenantOpsService.test.js +++ b/backend/tests/unit/pivotTenantOpsService.test.js @@ -36,9 +36,13 @@ jest.mock('../../services/pivotConfigService', () => ({ nextDropFormatted: 'Thu Jul 9, 6:00 PM EDT', })), })); -jest.mock('../../utilities/pivotDropSchedule', () => ({ - resolvePivotDropInstant: jest.fn(), -})); +jest.mock('../../utilities/pivotDropSchedule', () => { + const actual = jest.requireActual('../../utilities/pivotDropSchedule'); + return { + ...actual, + resolvePivotDropInstant: jest.fn(), + }; +}); const { resolvePivotTenant } = require('../../services/pivotIngestPublishService'); const { @@ -116,6 +120,9 @@ describe('pivotTenantOpsService', () => { 'overview', 'performance', 'journey', + 'readiness', + 'catalog', + 'jobs', ]); expect(parseInclude('curation', { stage: 'curate' }).sections).toEqual([ 'overview', @@ -131,18 +138,37 @@ describe('pivotTenantOpsService', () => { }); describe('resolveStageForWeek', () => { - it('classifies past / live / future', () => { - const anchors = { liveWeek: '2026-W28' }; - expect(resolveStageForWeek('2026-W27', anchors)).toBe('post-mortem'); - expect(resolveStageForWeek('2026-W28', anchors)).toBe('live'); - expect(resolveStageForWeek('2026-W29', anchors)).toBe('curate'); + it('classifies past / live / future relative to drop-cycle live week', () => { + expect(resolveStageForWeek('2026-W27', TENANT, new Date('2026-07-13T22:00:00.000Z'))).toBe( + 'post-mortem', + ); + expect(resolveStageForWeek('2026-W28', TENANT, new Date('2026-07-13T22:00:00.000Z'))).toBe( + 'live', + ); + expect(resolveStageForWeek('2026-W29', TENANT, new Date('2026-07-13T22:00:00.000Z'))).toBe( + 'curate', + ); + }); + + it('treats the drop-cycle live week as live after the drop instant', () => { + expect( + resolveStageForWeek('2026-W29', TENANT, new Date('2026-07-17T23:00:00.000Z')), + ).toBe('live'); }); }); describe('curationSectionsForStage', () => { - it('returns monitor vs curate sections', () => { - expect(curationSectionsForStage('post-mortem')).toContain('performance'); - expect(curationSectionsForStage('curate')).toContain('catalog'); + it('returns monitor-only sections for post-mortem', () => { + expect(curationSectionsForStage('post-mortem')).toEqual([ + 'overview', + 'performance', + 'journey', + ]); + }); + + it('returns monitor plus publish sections for live', () => { + expect(curationSectionsForStage('live')).toContain('catalog'); + expect(curationSectionsForStage('live')).toContain('performance'); }); }); @@ -161,7 +187,8 @@ describe('pivotTenantOpsService', () => { expect(result.data.tenantKey).toBe('nyc'); expect(result.data.batchWeek).toBe('2026-W28'); expect(result.data.stage).toBeTruthy(); - expect(result.data.anchors.liveWeek).toBeTruthy(); + expect(result.data.anchors.liveWeek).toBe('2026-W28'); + expect(result.data.anchors.curateWeek).toBe('2026-W29'); expect(result.data.overview.kpis.activeUsers).toBe(3); expect(result.data.performance.events).toHaveLength(1); expect(result.data.retention.tenant.tenantKey).toBe('nyc'); @@ -169,6 +196,29 @@ describe('pivotTenantOpsService', () => { expect(listCurationJobs).not.toHaveBeenCalled(); }); + it('defaults omitted batchWeek to drop-gated live week before the drop instant', async () => { + resolvePivotDropInstant.mockImplementation((_tenant, batchWeek) => ({ + dropAt: + batchWeek === '2026-W29' + ? new Date('2099-01-01T00:00:00.000Z') + : new Date('2020-01-01T00:00:00.000Z'), + })); + + const result = await getTenantOpsBundle( + { globalDb: {} }, + { + tenantKey: 'nyc', + include: 'overview', + now: new Date('2026-07-13T16:00:00.000Z'), + }, + ); + + expect(result.data.batchWeek).toBe('2026-W28'); + expect(result.data.anchors.liveWeek).toBe('2026-W28'); + expect(result.data.anchors.curateWeek).toBe('2026-W29'); + expect(result.data.anchors.dropPending).toBe(true); + }); + it('loads journeys preset', async () => { const result = await getTenantOpsBundle( { globalDb: {} }, @@ -185,8 +235,29 @@ describe('pivotTenantOpsService', () => { expect(getTenantOverview).not.toHaveBeenCalled(); }); - it('curation preset for future week loads catalog + jobs', async () => { - // Force live = W27 so W28 is curate + it('curation preset during release window loads catalog for the upcoming batch', async () => { + resolvePivotDropInstant.mockReturnValue({ + dropAt: new Date('2099-01-01T00:00:00.000Z'), + }); + + const result = await getTenantOpsBundle( + { globalDb: {} }, + { + tenantKey: 'nyc', + batchWeek: '2026-W29', + include: 'curation', + now: new Date('2026-07-13T22:00:00.000Z'), + }, + ); + + expect(result.data.stage).toBe('curate'); + expect(result.data.releaseWindow).toBe(true); + expect(result.data.catalog).toEqual({ events: [] }); + expect(result.data.jobs).toEqual({ jobs: [] }); + expect(getTenantEventPerformance).not.toHaveBeenCalled(); + }); + + it('curation preset for the live drop-cycle week loads monitor and publish sections', async () => { resolvePivotDropInstant.mockReturnValue({ dropAt: new Date('2099-01-01T00:00:00.000Z'), }); @@ -197,7 +268,30 @@ describe('pivotTenantOpsService', () => { tenantKey: 'nyc', batchWeek: '2026-W28', include: 'curation', - now: new Date('2026-07-06T12:00:00.000Z'), // Monday of W28, drop still pending + now: new Date('2026-07-13T22:00:00.000Z'), + }, + ); + + expect(result.data.stage).toBe('live'); + expect(result.data.releaseWindow).toBe(false); + expect(result.data.catalog).toEqual({ events: [] }); + expect(result.data.performance.events).toHaveLength(1); + expect(getBatchReadiness).toHaveBeenCalled(); + expect(listCurationJobs).toHaveBeenCalled(); + }); + + it('curation preset for a future week loads catalog + jobs', async () => { + resolvePivotDropInstant.mockReturnValue({ + dropAt: new Date('2099-01-01T00:00:00.000Z'), + }); + + const result = await getTenantOpsBundle( + { globalDb: {} }, + { + tenantKey: 'nyc', + batchWeek: '2026-W29', + include: 'curation', + now: new Date('2026-07-06T12:00:00.000Z'), }, ); diff --git a/backend/tests/unit/platformAdminInviteService.test.js b/backend/tests/unit/platformAdminInviteService.test.js new file mode 100644 index 00000000..e1e8b940 --- /dev/null +++ b/backend/tests/unit/platformAdminInviteService.test.js @@ -0,0 +1,228 @@ +jest.mock('../../services/getGlobalModelService', () => jest.fn()); + +const getGlobalModels = require('../../services/getGlobalModelService'); +const { + nominatePlatformAdmin, + approvePlatformAdminInvite, + revokePlatformAdminInvite, + markPlatformAdminInvitesReadyForEmail, + listPlatformAdmins, +} = require('../../services/platformAdminInviteService'); + +function makeInviteDoc(overrides = {}) { + const doc = { + _id: 'inv1', + email: 'ops@example.com', + status: 'pending_signup', + globalUserId: null, + invitedBy: null, + save: jest.fn(async function save() { + return this; + }), + toObject() { + return { ...this, save: undefined, toObject: undefined }; + }, + ...overrides, + }; + return doc; +} + +describe('platformAdminInviteService', () => { + let PlatformRole; + let GlobalUser; + let PlatformAdminInvite; + let req; + + beforeEach(() => { + jest.clearAllMocks(); + req = { user: { globalUserId: 'actor1' }, globalDb: {} }; + + PlatformRole = { + find: jest.fn(), + findOne: jest.fn(), + }; + GlobalUser = { + find: jest.fn(), + findOne: jest.fn(), + findById: jest.fn(), + }; + PlatformAdminInvite = { + find: jest.fn(), + findOne: jest.fn(), + findById: jest.fn(), + create: jest.fn(), + updateMany: jest.fn(), + }; + + getGlobalModels.mockImplementation((_req, ...names) => { + const all = { PlatformRole, GlobalUser, PlatformAdminInvite }; + return names.reduce((acc, name) => { + if (all[name]) acc[name] = all[name]; + return acc; + }, {}); + }); + }); + + function mockFindOneLean(model, value) { + model.findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue(value), + }); + } + + it('nominates unknown email as pending_signup without PlatformRole', async () => { + mockFindOneLean(GlobalUser, null); + PlatformAdminInvite.findOne.mockResolvedValue(null); + const created = makeInviteDoc({ status: 'pending_signup' }); + PlatformAdminInvite.create.mockResolvedValue(created); + + const result = await nominatePlatformAdmin(req, { email: 'Ops@Example.com' }); + + expect(result.error).toBeUndefined(); + expect(result.data.status).toBe('pending_signup'); + expect(result.data.email).toBe('ops@example.com'); + expect(PlatformAdminInvite.create).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'ops@example.com', + status: 'pending_signup', + globalUserId: null, + }), + ); + expect(PlatformRole.findOne).not.toHaveBeenCalled(); + }); + + it('nominates known email as ready_for_approval without granting role', async () => { + mockFindOneLean(GlobalUser, { + _id: 'gu1', + email: 'ops@example.com', + name: 'Ops', + }); + mockFindOneLean(PlatformRole, null); + PlatformAdminInvite.findOne.mockResolvedValue(null); + PlatformAdminInvite.create.mockResolvedValue( + makeInviteDoc({ + status: 'ready_for_approval', + globalUserId: 'gu1', + }), + ); + + const result = await nominatePlatformAdmin(req, { email: 'ops@example.com' }); + + expect(result.data.status).toBe('ready_for_approval'); + expect(PlatformRole.findOne).toHaveBeenCalledWith({ globalUserId: 'gu1' }); + expect(PlatformAdminInvite.create).toHaveBeenCalled(); + }); + + it('rejects nominate when already platform admin', async () => { + mockFindOneLean(GlobalUser, { _id: 'gu1', email: 'ops@example.com' }); + mockFindOneLean(PlatformRole, { + globalUserId: 'gu1', + roles: ['platform_admin'], + }); + + const result = await nominatePlatformAdmin(req, { email: 'ops@example.com' }); + expect(result.code).toBe('ALREADY_PLATFORM_ADMIN'); + expect(result.status).toBe(409); + }); + + it('marks pending_signup ready when GlobalUser appears (no role grant)', async () => { + PlatformAdminInvite.updateMany.mockResolvedValue({ modifiedCount: 1 }); + + const result = await markPlatformAdminInvitesReadyForEmail(req, { + email: 'ops@example.com', + globalUserId: 'gu1', + }); + + expect(result.updated).toBe(1); + expect(PlatformAdminInvite.updateMany).toHaveBeenCalledWith( + { email: 'ops@example.com', status: 'pending_signup' }, + { + $set: { + status: 'ready_for_approval', + globalUserId: 'gu1', + }, + }, + ); + }); + + it('approve grants platform_admin and marks invite approved', async () => { + const invite = makeInviteDoc({ + status: 'ready_for_approval', + globalUserId: 'gu1', + email: 'ops@example.com', + }); + PlatformAdminInvite.findById.mockResolvedValue(invite); + GlobalUser.findById.mockResolvedValue({ + _id: 'gu1', + email: 'ops@example.com', + name: 'Ops', + }); + + const pr = { + globalUserId: 'gu1', + roles: [], + save: jest.fn().mockResolvedValue(undefined), + }; + PlatformRole.findOne.mockResolvedValue(pr); + + const result = await approvePlatformAdminInvite(req, { inviteId: 'inv1' }); + + expect(result.error).toBeUndefined(); + expect(pr.roles).toContain('platform_admin'); + expect(pr.save).toHaveBeenCalled(); + expect(invite.status).toBe('approved'); + expect(invite.save).toHaveBeenCalled(); + expect(result.data.email).toBe('ops@example.com'); + }); + + it('approve rejects pending_signup nominations', async () => { + PlatformAdminInvite.findById.mockResolvedValue( + makeInviteDoc({ status: 'pending_signup' }), + ); + + const result = await approvePlatformAdminInvite(req, { inviteId: 'inv1' }); + expect(result.code).toBe('NOT_READY_FOR_APPROVAL'); + expect(result.status).toBe(409); + }); + + it('revoke cancels open nomination', async () => { + const invite = makeInviteDoc({ status: 'ready_for_approval' }); + PlatformAdminInvite.findById.mockResolvedValue(invite); + + const result = await revokePlatformAdminInvite(req, { inviteId: 'inv1' }); + expect(result.data.status).toBe('revoked'); + expect(invite.save).toHaveBeenCalled(); + }); + + it('list returns admins and open nominations', async () => { + PlatformRole.find.mockReturnValue({ + lean: jest.fn().mockResolvedValue([ + { globalUserId: 'gu1', roles: ['platform_admin'] }, + ]), + }); + PlatformAdminInvite.find.mockReturnValue({ + sort: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue([ + { + _id: 'inv2', + email: 'new@example.com', + status: 'pending_signup', + globalUserId: null, + }, + ]), + }), + }); + GlobalUser.find.mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue([ + { _id: 'gu1', email: 'admin@example.com', name: 'Admin' }, + ]), + }), + }); + + const data = await listPlatformAdmins(req); + expect(data.admins).toHaveLength(1); + expect(data.admins[0].email).toBe('admin@example.com'); + expect(data.nominations).toHaveLength(1); + expect(data.nominations[0].status).toBe('pending_signup'); + }); +}); diff --git a/backend/utilities/pivotDropSchedule.js b/backend/utilities/pivotDropSchedule.js index 173581d7..af7aa24c 100644 --- a/backend/utilities/pivotDropSchedule.js +++ b/backend/utilities/pivotDropSchedule.js @@ -1,6 +1,10 @@ -const { isoWeekToMondayUtc } = require('./pivotIsoWeek'); - -/** Pilot suggestion when a pivot tenant has no drop config stored yet (not a runtime constant). */ +const { + isoWeekToMondayUtc, + toIsoWeek, + toIsoWeekInTimeZone, + shiftIsoWeek, + isValidIsoWeek, +} = require('./pivotIsoWeek'); const PIVOT_DROP_PILOT_DEFAULTS = Object.freeze({ pivotDropTimezone: 'America/New_York', pivotDropDayOfWeek: 4, @@ -175,6 +179,153 @@ function formatPivotDropInstant(dropAt, timeZone) { return `${weekday} ${datePart}, ${timePart}${zonePart ? ` ${zonePart}` : ''}`; } +function resolvePivotConsumerWeek(tenant, now = new Date()) { + const config = resolvePivotDropConfig(tenant); + const timeZone = config.timezone || 'UTC'; + if (!tenant || !isPivotTenant(tenant)) { + return toIsoWeek(now); + } + return toIsoWeekInTimeZone(now, timeZone); +} + +/** + * Consumer-facing batchWeek when the client omits ?batchWeek. + * + * Stays on the previous ISO week until the current week's drop instant passes + * (same gate as ops live week). Calendar week alone must not advance the deck. + */ +function resolvePivotLiveBatchWeek(tenant, now = new Date()) { + return resolvePivotOpsLiveWeek(tenant, now); +} + +/** True while the tenant-local calendar week's drop is still in the future. */ +function resolvePivotDropPendingForCalendarWeek(tenant, now = new Date()) { + if (!tenant || !isPivotTenant(tenant)) { + return false; + } + + try { + const calendarWeek = resolvePivotConsumerWeek(tenant, now); + const currentDrop = resolvePivotDropInstant(tenant, calendarWeek, now); + return currentDrop.dropAt.getTime() > now.getTime(); + } catch { + return false; + } +} + +/** + * ISO week whose drop instant powers consumer countdown / next-drop copy. + * Pre-drop: calendar week (upcoming Thursday). Post-drop: next calendar week. + */ +function resolvePivotUpcomingDropBatchWeek(tenant, now = new Date()) { + const calendarWeek = resolvePivotConsumerWeek(tenant, now); + if (resolvePivotDropPendingForCalendarWeek(tenant, now)) { + return calendarWeek; + } + return shiftIsoWeek(calendarWeek, 1) || calendarWeek; +} + +/** Ops / Lab: which ISO week is "live" vs being curated before the drop. */ +function resolvePivotOpsLiveWeek(tenant, now = new Date()) { + const currentWeek = resolvePivotConsumerWeek(tenant, now); + if (!tenant || !isPivotTenant(tenant)) { + return currentWeek; + } + + try { + const currentDrop = resolvePivotDropInstant(tenant, currentWeek, now); + const dropPending = currentDrop.dropAt.getTime() > now.getTime(); + return dropPending ? shiftIsoWeek(currentWeek, -1) : currentWeek; + } catch { + return currentWeek; + } +} + +/** + * Ops / admin anchor weeks aligned to the drop cycle (Thu → Wed windows). + * + * - liveWeek: batch whose drop cycle contains now (resolvePivotOpsLiveWeek) + * - curateWeek: next batch to prepare / pre-release (liveWeek + 1) + * - currentWeek: tenant-local calendar ISO week (may differ Mon–Wed pre-drop) + */ +function resolvePivotStageAnchors(tenant, now = new Date()) { + const config = resolvePivotDropConfig(tenant); + const currentWeek = resolvePivotConsumerWeek(tenant, now); + const liveWeek = resolvePivotOpsLiveWeek(tenant, now); + let dropPending = false; + let currentWeekDropAt = null; + + if (tenant && isPivotTenant(tenant)) { + try { + const currentDrop = resolvePivotDropInstant(tenant, currentWeek, now); + currentWeekDropAt = currentDrop.dropAt.toISOString(); + dropPending = currentDrop.dropAt.getTime() > now.getTime(); + } catch { + dropPending = false; + } + } + + const curateWeek = shiftIsoWeek(liveWeek, 1); + + return { + currentWeek, + liveWeek, + curateWeek, + postMortemWeek: shiftIsoWeek(liveWeek, -1), + dropPending, + currentWeekDropAt, + feedWeek: currentWeek, + timeZone: config.timezone, + }; +} + +/** + * Curation stage for a batchWeek relative to the drop-cycle live batch. + */ +function resolveStageForBatchWeek(batchWeek, tenant, now = new Date()) { + if (!isValidIsoWeek(batchWeek)) return 'curate'; + const liveWeek = resolvePivotOpsLiveWeek(tenant, now); + if (batchWeek === liveWeek) return 'live'; + if (batchWeek < liveWeek) return 'post-mortem'; + return 'curate'; +} + +function describePivotBatchWeekResolution(tenant, now = new Date(), requestedBatchWeek) { + const config = resolvePivotDropConfig(tenant); + const calendarIsoWeek = resolvePivotConsumerWeek(tenant, now); + const consumerDefaultWeek = resolvePivotLiveBatchWeek(tenant, now); + const opsLiveWeek = resolvePivotOpsLiveWeek(tenant, now); + const trimmedRequest = + typeof requestedBatchWeek === 'string' ? requestedBatchWeek.trim() : ''; + const resolvedBatchWeek = trimmedRequest || consumerDefaultWeek; + + let dropPending = null; + let nextDropAt = null; + if (tenant && isPivotTenant(tenant)) { + try { + dropPending = resolvePivotDropPendingForCalendarWeek(tenant, now); + const upcomingDropWeek = resolvePivotUpcomingDropBatchWeek(tenant, now); + const upcomingDrop = resolvePivotDropInstant(tenant, upcomingDropWeek, now); + nextDropAt = upcomingDrop.dropAt.toISOString(); + } catch { + dropPending = null; + } + } + + return { + requestedBatchWeek: trimmedRequest || null, + resolvedBatchWeek, + batchWeekSource: trimmedRequest ? 'query' : 'consumer_week', + calendarIsoWeek, + consumerDefaultWeek, + /** @deprecated use consumerDefaultWeek — kept for log compatibility */ + liveDropWeek: consumerDefaultWeek, + opsLiveWeek, + dropPending, + nextDropAt, + }; +} + function describePivotDropSchedule(resolved) { const dayLabel = DAY_NAMES[resolved.dayOfWeek] || `day ${resolved.dayOfWeek}`; const minuteLabel = String(resolved.minute).padStart(2, '0'); @@ -197,6 +348,14 @@ module.exports = { resolvePivotDropInstant, formatPivotDropInstant, describePivotDropSchedule, + describePivotBatchWeekResolution, + resolvePivotConsumerWeek, + resolvePivotLiveBatchWeek, + resolvePivotDropPendingForCalendarWeek, + resolvePivotUpcomingDropBatchWeek, + resolvePivotOpsLiveWeek, + resolvePivotStageAnchors, + resolveStageForBatchWeek, isoWeekToMondayUtc, zonedLocalToUtc, }; diff --git a/backend/utilities/pivotEnrichment.js b/backend/utilities/pivotEnrichment.js new file mode 100644 index 00000000..ca9b0e49 --- /dev/null +++ b/backend/utilities/pivotEnrichment.js @@ -0,0 +1,110 @@ +const PIVOT_PRICE_BANDS = Object.freeze(['free', 'low', 'mid', 'high']); +const MAX_VIBE_TAGS = 12; +const MAX_ENRICHMENT_FIELD_LENGTH = 120; + +function trimEnrichmentString(value) { + if (value == null) { + return ''; + } + return String(value).trim().slice(0, MAX_ENRICHMENT_FIELD_LENGTH); +} + +function normalizeVibeTags(rawVibe) { + const parts = Array.isArray(rawVibe) + ? rawVibe + : typeof rawVibe === 'string' + ? rawVibe.split(',') + : []; + + const seen = new Set(); + const vibe = []; + for (const raw of parts) { + const slug = trimEnrichmentString(raw).toLowerCase(); + if (!slug || seen.has(slug)) { + continue; + } + seen.add(slug); + vibe.push(slug); + if (vibe.length >= MAX_VIBE_TAGS) { + break; + } + } + + return vibe; +} + +function normalizePriceBand(rawPriceBand) { + const band = trimEnrichmentString(rawPriceBand).toLowerCase(); + if (!band) { + return null; + } + return PIVOT_PRICE_BANDS.includes(band) ? band : undefined; +} + +function normalizePivotEnrichment(raw) { + if (raw == null) { + return null; + } + + if (typeof raw !== 'object' || Array.isArray(raw)) { + return null; + } + + const vibe = normalizeVibeTags(raw.vibe); + const priceBand = normalizePriceBand(raw.priceBand); + if (raw.priceBand != null && raw.priceBand !== '' && priceBand === undefined) { + return { error: 'priceBand must be free, low, mid, or high.', code: 'INVALID_PRICE_BAND' }; + } + + const neighborhood = trimEnrichmentString(raw.neighborhood); + const audience = trimEnrichmentString(raw.audience); + + if (!vibe.length && !priceBand && !neighborhood && !audience) { + return null; + } + + return { + ...(vibe.length ? { vibe } : {}), + ...(priceBand ? { priceBand } : {}), + ...(neighborhood ? { neighborhood } : {}), + ...(audience ? { audience } : {}), + }; +} + +function hasPivotEnrichmentContent(enrichment) { + const normalized = normalizePivotEnrichment(enrichment); + return Boolean(normalized && !normalized.error); +} + +function serializePivotEnrichment(pivot) { + const normalized = normalizePivotEnrichment(pivot?.enrichment); + if (!normalized || normalized.error) { + return undefined; + } + return normalized; +} + +function collectPivotEnrichmentSearchText(pivot) { + const enrichment = serializePivotEnrichment(pivot); + if (!enrichment) { + return ''; + } + + return [ + ...(enrichment.vibe || []), + enrichment.priceBand, + enrichment.neighborhood, + enrichment.audience, + ] + .filter(Boolean) + .join(' '); +} + +module.exports = { + PIVOT_PRICE_BANDS, + MAX_VIBE_TAGS, + normalizePivotEnrichment, + hasPivotEnrichmentContent, + serializePivotEnrichment, + collectPivotEnrichmentSearchText, +}; diff --git a/backend/utilities/pivotIsoWeek.js b/backend/utilities/pivotIsoWeek.js index 71dba6c6..06d6a401 100644 --- a/backend/utilities/pivotIsoWeek.js +++ b/backend/utilities/pivotIsoWeek.js @@ -48,6 +48,78 @@ function isoWeekToUtcRange(batchWeek) { return { start, end }; } +function daysFromIsoMonday(dayOfWeek) { + return dayOfWeek === 0 ? 6 : dayOfWeek - 1; +} + +function normalizeDropDayOfWeek(value, fallback = 4) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(6, Math.max(0, Math.trunc(parsed))); +} + +/** + * Drop-aligned batch window: drop weekday through the following Wednesday + * (day before the next week's drop). E.g. Thu Jul 9 – Wed Jul 15 for W28. + */ +function batchWeekToEventWindowUtcRange(batchWeek, dropDayOfWeek = 4) { + const monday = isoWeekToMondayUtc(batchWeek); + const day = normalizeDropDayOfWeek(dropDayOfWeek); + const dropDate = new Date(monday); + dropDate.setUTCDate(monday.getUTCDate() + daysFromIsoMonday(day)); + const lastDay = new Date(dropDate); + lastDay.setUTCDate(dropDate.getUTCDate() + 6); + const start = new Date( + Date.UTC(dropDate.getUTCFullYear(), dropDate.getUTCMonth(), dropDate.getUTCDate(), 12), + ); + const end = new Date( + Date.UTC(lastDay.getUTCFullYear(), lastDay.getUTCMonth(), lastDay.getUTCDate(), 12), + ); + return { start, end }; +} + +/** [dropDay 00:00 UTC, next dropDay 00:00 UTC) — used for interval queries. */ +function batchWeekToDropCycleUtcRange(batchWeek, dropDayOfWeek = 4) { + const monday = isoWeekToMondayUtc(batchWeek); + const day = normalizeDropDayOfWeek(dropDayOfWeek); + const start = new Date(monday); + start.setUTCDate(monday.getUTCDate() + daysFromIsoMonday(day)); + const end = new Date(start); + end.setUTCDate(start.getUTCDate() + 7); + return { start, end }; +} + +function formatZonedCalendarDate(date, timeZone, options = {}) { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + ...options, + timeZone, + }); +} + +/** + * Human-readable drop-cycle range, e.g. "Jul 9 – Jul 15, 2026" (Thu → Wed). + */ +function formatBatchWeekRangeLabel(batchWeek, options = {}) { + const dropDayOfWeek = normalizeDropDayOfWeek(options.dropDayOfWeek, 4); + let range; + try { + range = batchWeekToEventWindowUtcRange(batchWeek, dropDayOfWeek); + } catch { + return null; + } + + const timeZone = options.timeZone || 'UTC'; + const { start, end } = range; + const sameYear = start.getUTCFullYear() === end.getUTCFullYear(); + const startLabel = formatZonedCalendarDate(start, timeZone, { + ...(sameYear ? {} : { year: 'numeric' }), + }); + const endLabel = formatZonedCalendarDate(end, timeZone, { year: 'numeric' }); + return `${startLabel} – ${endLabel}`; +} + /** 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())); @@ -57,6 +129,27 @@ function toIsoWeekUtc(date) { return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`; } +/** + * ISO week for a calendar date in an IANA timezone (Pivot city local date). + * @param {Date} [date] + * @param {string} [timeZone='UTC'] + */ +function toIsoWeekInTimeZone(date = new Date(), timeZone = 'UTC') { + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + const parts = Object.fromEntries( + formatter.formatToParts(date).map((part) => [part.type, part.value]), + ); + const localDate = new Date( + Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day)), + ); + return toIsoWeekUtc(localDate); +} + /** * Shift an ISO week string by a number of weeks (negative = earlier). * @param {string} batchWeek - YYYY-Www @@ -143,9 +236,15 @@ function resolveEventBatchWeek(options = {}) { module.exports = { toIsoWeek, + toIsoWeekInTimeZone, isValidIsoWeek, isoWeekToMondayUtc, isoWeekToUtcRange, + batchWeekToEventWindowUtcRange, + batchWeekToDropCycleUtcRange, + formatBatchWeekRangeLabel, + daysFromIsoMonday, + normalizeDropDayOfWeek, shiftIsoWeek, batchWeekFromEventDate, resolveEventBatchWeek, diff --git a/frontend/src/components/IphoneDeviceFrame/IphoneDeviceFrame.jsx b/frontend/src/components/IphoneDeviceFrame/IphoneDeviceFrame.jsx new file mode 100644 index 00000000..aa185e2e --- /dev/null +++ b/frontend/src/components/IphoneDeviceFrame/IphoneDeviceFrame.jsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { Squircle } from '@squircle-js/react'; +import './IphoneDeviceFrame.scss'; + +const DEFAULT_WIDTH = 390; + +/** + * Reusable iPhone mockup with Dynamic Island, squircle corners, and home indicator. + * Scales via `--iphone-frame-width` (default 390px — iPhone 15 Pro logical width). + */ +function IphoneDeviceFrame({ + children, + className = '', + screenClassName = '', + label, + hint, + ariaLabel = 'iPhone preview', + width = DEFAULT_WIDTH, + maxScreenHeight, + showHomeIndicator = true, + showDynamicIsland = true, + showSideButtons = true, + showStatusBar = true, + statusBarTheme = 'light', +}) { + const bezelTotal = 'calc(var(--iphone-bezel) * 2)'; + const fullHeight = `calc((var(--iphone-frame-width) - ${bezelTotal}) * (852 / 393))`; + const frameStyle = { + '--iphone-frame-width': `${width}px`, + '--iphone-screen-height': maxScreenHeight + ? `min(${fullHeight}, ${maxScreenHeight})` + : fullHeight, + }; + + return ( +
+ {label || hint ? ( +
+ {label ?

{label}

: null} + {hint ?

{hint}

: null} +
+ ) : null} + +
+ {showSideButtons ? ( + <> +
- {(USE_FAKE_FUNNEL_DATA || totalViews > 0 || actualRegistrations > 0 || actualCheckIns > 0 || platform.registrationFormOpens > 0) && ( + {(USE_FAKE_FUNNEL_DATA || funnelLoading || funnelTotalViews > 0 || actualRegistrations > 0 || actualCheckIns > 0 || funnelPlatform.registrationFormOpens > 0) && ( Unique users at each step} + subheader={( + + Unique users at each step + {funnelDateRangeLabel && ( + · {funnelDateRangeLabel} + )} + + )} classN="analytics-card funnel-section" size="1rem" >
-
- -
+ {funnelLoading ? ( +
+ +
+ ) : ( +
+ +
+ )}
)} diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/FunnelChart.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/FunnelChart.scss index 064aa90a..c2436751 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/FunnelChart.scss +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/FunnelChart.scss @@ -16,6 +16,24 @@ height: 180px; } + .funnel-chart-loading { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 180px; + + .spinner { + font-size: 1.5rem; + animation: spin 1s linear infinite; + color: var(--org-primary, #64AB6C); + } + } + + .funnel-subtitle-range { + color: var(--light-text); + } + .funnel-chart-svg { display: block; diff --git a/frontend/src/pages/PlatformAdmin/PivotLab/PivotCatalogEventEditModal.jsx b/frontend/src/pages/PlatformAdmin/PivotLab/PivotCatalogEventEditModal.jsx index 4b2020be..6ce96080 100644 --- a/frontend/src/pages/PlatformAdmin/PivotLab/PivotCatalogEventEditModal.jsx +++ b/frontend/src/pages/PlatformAdmin/PivotLab/PivotCatalogEventEditModal.jsx @@ -15,6 +15,52 @@ import PivotTmdbLookup from './PivotTmdbLookup'; import './PivotManualImportModal.scss'; import './PivotCatalogEventEditModal.scss'; +const EMPTY_ENRICHMENT_DRAFT = Object.freeze({ + vibeText: '', + priceBand: '', + neighborhood: '', + audience: '', +}); + +export function enrichmentToDraft(enrichment) { + if (!enrichment) { + return { ...EMPTY_ENRICHMENT_DRAFT }; + } + + return { + vibeText: Array.isArray(enrichment.vibe) ? enrichment.vibe.join(', ') : '', + priceBand: enrichment.priceBand || '', + neighborhood: enrichment.neighborhood || '', + audience: enrichment.audience || '', + }; +} + +export function draftEnrichmentToPayload(draftEnrichment) { + if (!draftEnrichment) { + return {}; + } + + return { + vibe: draftEnrichment.vibeText, + priceBand: draftEnrichment.priceBand || undefined, + neighborhood: draftEnrichment.neighborhood, + audience: draftEnrichment.audience, + }; +} + +export function hasEnrichmentDraftContent(draftEnrichment) { + if (!draftEnrichment) { + return false; + } + + return Boolean( + draftEnrichment.vibeText?.trim() || + draftEnrichment.priceBand || + draftEnrichment.neighborhood?.trim() || + draftEnrichment.audience?.trim(), + ); +} + function isoToDatetimeLocal(iso) { if (!iso) return ''; const parsed = new Date(iso); @@ -55,6 +101,7 @@ export function catalogEventToEditDraft(event) { ingestStatus: event.ingestStatus || 'staged', tags: Array.isArray(event.tags) ? [...event.tags] : [], movie: event.movie || null, + enrichment: enrichmentToDraft(event.enrichment), }; } @@ -81,6 +128,7 @@ export function catalogEditDraftToOverrides(draft) { tags: Array.isArray(draft.tags) ? draft.tags : [], ...(useShowtimes ? { timeSlots: normalizedSlots } : { timeSlots: [] }), ...(draft.movie ? { movie: draft.movie } : {}), + enrichment: draftEnrichmentToPayload(draft.enrichment), }; } @@ -454,11 +502,16 @@ function PivotCatalogEventEditModal({ value={draft.ingestStatus} onChange={(e) => patchDraft({ ingestStatus: e.target.value })} > - - + + + {event?.ingestStatus === 'staged' && draft.ingestStatus === 'published' ? ( +

+ Saving as Published runs the release step so the event appears in the app feed. +

+ ) : null}