From d3c6976b6fcfec12d7b59f8288e2b8e6bd3081c8 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:46:42 -0700 Subject: [PATCH 01/11] =?UTF-8?q?MER-193:=20Phase=201=20=E2=80=94=20PivotI?= =?UTF-8?q?nteraction=20schema=20and=20intent=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add append-only PivotInteraction collection with surface/retrieval tagging, fire-and-forget writer helpers, and mirror feed intent/feedback actions into the interaction log for embeddings-ready training data. --- backend/schemas/pivotInteraction.js | 145 ++++++ backend/services/getModelService.js | 12 + backend/services/pivotFeedbackService.js | 13 + backend/services/pivotIntentService.js | 35 ++ backend/services/pivotInteractionService.js | 465 ++++++++++++++++++ backend/tests/unit/eventPivotIndexes.test.js | 54 ++ .../tests/unit/pivotFeedbackService.test.js | 16 + backend/tests/unit/pivotIntentService.test.js | 107 ++++ backend/tests/unit/pivotInteraction.test.js | 382 ++++++++++++++ 9 files changed, 1229 insertions(+) create mode 100644 backend/schemas/pivotInteraction.js create mode 100644 backend/services/pivotInteractionService.js create mode 100644 backend/tests/unit/eventPivotIndexes.test.js create mode 100644 backend/tests/unit/pivotInteraction.test.js 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/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/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/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/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/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/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/); + }); + }); +}); From 4a056c437c347e22093eb973870de375c8e82510 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:46:42 -0700 Subject: [PATCH 02/11] =?UTF-8?q?MER-193:=20Phase=206.1=20=E2=80=94=20Deck?= =?UTF-8?q?=20order=20snapshot=20for=20shadow=20eval?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PivotDeckSnapshot collection and writer so weekly deck order is reconstructable for future embeddings shadow mode. --- backend/schemas/pivotDeckSnapshot.js | 49 ++++++ backend/services/pivotDeckSnapshotService.js | 159 +++++++++++++++++++ backend/tests/unit/pivotDeckSnapshot.test.js | 131 +++++++++++++++ 3 files changed, 339 insertions(+) create mode 100644 backend/schemas/pivotDeckSnapshot.js create mode 100644 backend/services/pivotDeckSnapshotService.js create mode 100644 backend/tests/unit/pivotDeckSnapshot.test.js 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/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/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); + }); + }); +}); From dbd4950de8b1a207ded465e56525b85b1657d3b6 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:46:42 -0700 Subject: [PATCH 03/11] =?UTF-8?q?MER-193:=20Phase=202=20=E2=80=94=20Explor?= =?UTF-8?q?e=20API,=20filters,=20and=20feed=20catalog=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET /pivot/explore with week-scoped catalog, filter/rails metadata, intent reconcile, impression and micro-intent routes; extend feed with rankInFeed, batch-week resolution, deck snapshots, and rankerVersion. --- backend/routes/pivotRoutes.js | 173 +++ .../services/pivotExploreSectionsService.js | 348 ++++++ backend/services/pivotExploreService.js | 827 +++++++++++++ backend/services/pivotFeedService.js | 152 ++- .../pivotRoutes.outcomes.test.js | 159 +++ .../unit/pivotExploreSectionsService.test.js | 268 +++++ .../tests/unit/pivotExploreService.test.js | 1050 +++++++++++++++++ backend/tests/unit/pivotFeedService.test.js | 252 ++++ 8 files changed, 3224 insertions(+), 5 deletions(-) create mode 100644 backend/services/pivotExploreSectionsService.js create mode 100644 backend/services/pivotExploreService.js create mode 100644 backend/tests/unit/pivotExploreSectionsService.test.js create mode 100644 backend/tests/unit/pivotExploreService.test.js 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/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/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/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'); + }); +}); From 3253c39368d029cf3d527cf78962f571530bac9f Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:46:42 -0700 Subject: [PATCH 04/11] =?UTF-8?q?MER-193:=20Phase=206.2=20=E2=80=94=20Inge?= =?UTF-8?q?st=20enrichment=20fields=20for=20search=20and=20embeddings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional pivot enrichment (vibe, priceBand, neighborhood, audience) on ingest/lab edit with non-blocking publish warnings and Explore q search text inclusion via feed/explore serializers. --- backend/services/pivotIngestPublishService.js | 32 ++++ backend/services/pivotLabEventsService.js | 3 + backend/tests/unit/pivotEnrichment.test.js | 59 ++++++++ .../unit/pivotIngestPublishService.test.js | 103 +++++++++++++ backend/utilities/pivotEnrichment.js | 110 ++++++++++++++ .../PivotLab/PivotCatalogEventEditModal.jsx | 143 +++++++++++++++++- .../PivotLab/PivotCatalogEventEditModal.scss | 11 ++ 7 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 backend/tests/unit/pivotEnrichment.test.js create mode 100644 backend/utilities/pivotEnrichment.js 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/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/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/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/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/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}