diff --git a/backend/migrations/sendPivotWeeklyPush.js b/backend/migrations/sendPivotWeeklyPush.js index 51f4353e..884af8cc 100644 --- a/backend/migrations/sendPivotWeeklyPush.js +++ b/backend/migrations/sendPivotWeeklyPush.js @@ -22,6 +22,7 @@ const { isPivotTenant, resolvePivotDropInstant, } = require('../utilities/pivotDropSchedule'); +const { PIVOT_FEED_INGEST_STATUS } = require('../utilities/pivotIngestStatus'); const CONFIG_KEY = 'default'; const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send'; @@ -66,7 +67,7 @@ async function countPublishedEvents(req, batchWeek) { const { Event } = getModels(req, 'Event'); return Event.countDocuments({ 'customFields.pivot.batchWeek': batchWeek, - 'customFields.pivot.ingestStatus': 'published', + 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS, }); } diff --git a/backend/routes/pivotAdminRoutes.js b/backend/routes/pivotAdminRoutes.js index b08e424a..b7eb797b 100644 --- a/backend/routes/pivotAdminRoutes.js +++ b/backend/routes/pivotAdminRoutes.js @@ -5,7 +5,36 @@ const { rebuildWeeklySnapshot, getWeeklySnapshot, } = require('../services/pivotWeeklySnapshotService'); -const { getPivotOverview } = require('../services/pivotAdminOverviewService'); +const { + getPivotOverview, + getTenantOverview, + getTenantEventPerformance, +} = require('../services/pivotAdminOverviewService'); +const { getTenantInsights } = require('../services/pivotTenantInsightsService'); +const { + releaseBatch, + unreleaseBatch, +} = require('../services/pivotBatchReleaseService'); +const { getBatchReadiness } = require('../services/pivotBatchReadinessService'); +const { + listCurationJobs, + createCurationJob, + updateCurationJob, + deleteCurationJob, +} = require('../services/pivotCurationJobService'); +const { + startCurationJobRun, + getCurationRun, +} = require('../services/pivotCurationRunService'); +const { + getJourneyOverview, + getJourneyFunnel, + getJourneyPath, + searchJourneyUsers, + getUserJourneyHistory, + wipeUserWeekIntents, +} = require('../services/pivotTenantJourneyService'); +const { getTenantOpsBundle } = require('../services/pivotTenantOpsService'); const { getPivotRetention } = require('../services/pivotRetentionService'); const { listPivotLabEvents } = require('../services/pivotLabEventsService'); const { @@ -13,6 +42,7 @@ const { saveInterviewNotes, } = require('../services/pivotLabNotesService'); const { previewIngestUrl } = require('../services/pivotIngestPreviewService'); +const { annotateImportDuplicates } = require('../services/pivotIngestDuplicateService'); const { publishIngestEvent, publishBatchIngestEvents, @@ -189,6 +219,696 @@ router.get('/overview', verifyToken, requirePlatformAdmin, async (req, res) => { } }); +router.get( + '/tenants/:tenantKey/overview', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await getTenantOverview(req, { + tenantKey: req.params.tenantKey, + batchWeek: req.query?.batchWeek, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('GET /admin/pivot/tenants/:tenantKey/overview', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to load tenant pivot overview.', + }); + } + }, +); + +router.get( + '/tenants/:tenantKey/ops', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await getTenantOpsBundle(req, { + tenantKey: req.params.tenantKey, + batchWeek: req.query?.batchWeek, + include: req.query?.include, + performanceLimit: req.query?.performanceLimit ?? req.query?.limit, + retentionWeeks: req.query?.retentionWeeks ?? req.query?.weeks, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('GET /admin/pivot/tenants/:tenantKey/ops', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to load tenant ops bundle.', + }); + } + }, +); + +router.get( + '/tenants/:tenantKey/events/performance', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await getTenantEventPerformance(req, { + tenantKey: req.params.tenantKey, + batchWeek: req.query?.batchWeek, + limit: req.query?.limit, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('GET /admin/pivot/tenants/:tenantKey/events/performance', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to load tenant event performance.', + }); + } + }, +); + +router.get( + '/tenants/:tenantKey/insights', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await getTenantInsights(req, { + tenantKey: req.params.tenantKey, + batchWeek: req.query?.batchWeek, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('GET /admin/pivot/tenants/:tenantKey/insights', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to load tenant pivot insights.', + }); + } + }, +); + +router.post( + '/tenants/:tenantKey/batches/:batchWeek/release', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await releaseBatch(req, { + tenantKey: req.params.tenantKey, + batchWeek: req.params.batchWeek, + eventIds: req.body?.eventIds, + rebuildSnapshot: req.body?.rebuildSnapshot, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError( + 'POST /admin/pivot/tenants/:tenantKey/batches/:batchWeek/release', + err, + req, + ); + return res.status(500).json({ + success: false, + message: 'Unable to release pivot batch.', + }); + } + }, +); + +router.post( + '/tenants/:tenantKey/batches/:batchWeek/unrelease', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await unreleaseBatch(req, { + tenantKey: req.params.tenantKey, + batchWeek: req.params.batchWeek, + confirm: req.body?.confirm, + eventIds: req.body?.eventIds, + rebuildSnapshot: req.body?.rebuildSnapshot, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError( + 'POST /admin/pivot/tenants/:tenantKey/batches/:batchWeek/unrelease', + err, + req, + ); + return res.status(500).json({ + success: false, + message: 'Unable to unrelease pivot batch.', + }); + } + }, +); + +router.get( + '/tenants/:tenantKey/batches/:batchWeek/readiness', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await getBatchReadiness(req, { + tenantKey: req.params.tenantKey, + batchWeek: req.params.batchWeek, + benchmarkWeeks: req.query?.benchmarkWeeks, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError( + 'GET /admin/pivot/tenants/:tenantKey/batches/:batchWeek/readiness', + err, + req, + ); + return res.status(500).json({ + success: false, + message: 'Unable to load batch readiness.', + }); + } + }, +); + +router.get( + '/tenants/:tenantKey/curation-jobs', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await listCurationJobs(req, { + tenantKey: req.params.tenantKey, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('GET /admin/pivot/tenants/:tenantKey/curation-jobs', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to list curation jobs.', + }); + } + }, +); + +router.post( + '/tenants/:tenantKey/curation-jobs', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await createCurationJob(req, { + tenantKey: req.params.tenantKey, + label: req.body?.label, + url: req.body?.url, + provider: req.body?.provider, + defaultBatchWeekStrategy: req.body?.defaultBatchWeekStrategy, + defaultTags: req.body?.defaultTags, + enabled: req.body?.enabled, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError('POST /admin/pivot/tenants/:tenantKey/curation-jobs', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to create curation job.', + }); + } + }, +); + +router.patch( + '/tenants/:tenantKey/curation-jobs/:jobId', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await updateCurationJob(req, { + tenantKey: req.params.tenantKey, + jobId: req.params.jobId, + label: req.body?.label, + url: req.body?.url, + provider: req.body?.provider, + defaultBatchWeekStrategy: req.body?.defaultBatchWeekStrategy, + defaultTags: req.body?.defaultTags, + enabled: req.body?.enabled, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError( + 'PATCH /admin/pivot/tenants/:tenantKey/curation-jobs/:jobId', + err, + req, + ); + return res.status(500).json({ + success: false, + message: 'Unable to update curation job.', + }); + } + }, +); + +router.delete( + '/tenants/:tenantKey/curation-jobs/:jobId', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await deleteCurationJob(req, { + tenantKey: req.params.tenantKey, + jobId: req.params.jobId, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError( + 'DELETE /admin/pivot/tenants/:tenantKey/curation-jobs/:jobId', + err, + req, + ); + return res.status(500).json({ + success: false, + message: 'Unable to delete curation job.', + }); + } + }, +); + +router.post( + '/tenants/:tenantKey/curation-jobs/:jobId/run', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await startCurationJobRun(req, { + tenantKey: req.params.tenantKey, + jobId: req.params.jobId, + batchWeek: req.body?.batchWeek, + forceBatchWeek: req.body?.forceBatchWeek, + maxEvents: req.body?.maxEvents, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError( + 'POST /admin/pivot/tenants/:tenantKey/curation-jobs/:jobId/run', + err, + req, + ); + return res.status(500).json({ + success: false, + message: 'Unable to start curation job run.', + }); + } + }, +); + +router.get( + '/tenants/:tenantKey/curation-runs/:runId', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await getCurationRun(req, { + tenantKey: req.params.tenantKey, + runId: req.params.runId, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError( + 'GET /admin/pivot/tenants/:tenantKey/curation-runs/:runId', + err, + req, + ); + return res.status(500).json({ + success: false, + message: 'Unable to load curation run.', + }); + } + }, +); + +router.get( + '/tenants/:tenantKey/journeys/overview', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await getJourneyOverview(req, { + tenantKey: req.params.tenantKey, + batchWeek: req.query?.batchWeek, + range: req.query?.range, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError( + 'GET /admin/pivot/tenants/:tenantKey/journeys/overview', + err, + req, + ); + return res.status(500).json({ + success: false, + message: 'Unable to load journey overview.', + }); + } + }, +); + +router.get( + '/tenants/:tenantKey/journeys/funnel', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await getJourneyFunnel(req, { + tenantKey: req.params.tenantKey, + batchWeek: req.query?.batchWeek, + steps: req.query?.steps, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError( + 'GET /admin/pivot/tenants/:tenantKey/journeys/funnel', + err, + req, + ); + return res.status(500).json({ + success: false, + message: 'Unable to load journey funnel.', + }); + } + }, +); + +router.get( + '/tenants/:tenantKey/journeys/path', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await getJourneyPath(req, { + tenantKey: req.params.tenantKey, + batchWeek: req.query?.batchWeek, + startingPoint: req.query?.startingPoint, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError( + 'GET /admin/pivot/tenants/:tenantKey/journeys/path', + err, + req, + ); + return res.status(500).json({ + success: false, + message: 'Unable to load journey path.', + }); + } + }, +); + +router.get( + '/tenants/:tenantKey/journeys/users', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await searchJourneyUsers(req, { + tenantKey: req.params.tenantKey, + query: req.query?.query ?? req.query?.q, + batchWeek: req.query?.batchWeek, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError( + 'GET /admin/pivot/tenants/:tenantKey/journeys/users', + err, + req, + ); + return res.status(500).json({ + success: false, + message: 'Unable to search journey users.', + }); + } + }, +); + +router.get( + '/tenants/:tenantKey/journeys/users/:userId/history', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await getUserJourneyHistory(req, { + tenantKey: req.params.tenantKey, + userId: req.params.userId, + batchWeek: req.query?.batchWeek, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError( + 'GET /admin/pivot/tenants/:tenantKey/journeys/users/:userId/history', + err, + req, + ); + return res.status(500).json({ + success: false, + message: 'Unable to load user journey history.', + }); + } + }, +); + +router.post( + '/tenants/:tenantKey/users/:userId/wipe-week', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const result = await wipeUserWeekIntents(req, { + tenantKey: req.params.tenantKey, + userId: req.params.userId, + batchWeek: req.body?.batchWeek, + confirm: req.body?.confirm, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + logPivotRouteError( + 'POST /admin/pivot/tenants/:tenantKey/users/:userId/wipe-week', + err, + req, + ); + return res.status(500).json({ + success: false, + message: 'Unable to wipe user week intents.', + }); + } + }, +); + router.get('/retention', verifyToken, requirePlatformAdmin, async (req, res) => { try { const result = await getPivotRetention(req, { @@ -371,13 +1091,36 @@ router.post('/ingest/preview', verifyToken, requirePlatformAdmin, async (req, re } }); +router.post('/ingest/annotate-duplicates', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await annotateImportDuplicates(req, { + tenantKey: req.body?.tenantKey, + drafts: req.body?.drafts, + }); + + return res.status(200).json({ + success: true, + data: result, + }); + } catch (err) { + logPivotRouteError('POST /admin/pivot/ingest/annotate-duplicates', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to check for duplicate events.', + }); + } +}); + router.post('/ingest', verifyToken, requirePlatformAdmin, async (req, res) => { try { const result = await publishIngestEvent(req, { tenantKey: req.body?.tenantKey, url: req.body?.url, batchWeek: req.body?.batchWeek, + forceBatchWeek: req.body?.forceBatchWeek, overrides: req.body?.overrides, + releaseNow: req.body?.releaseNow, + confirm: req.body?.confirm, }); if (result.error) { logPivotServiceReject('POST /admin/pivot/ingest', result, req, { @@ -394,9 +1137,11 @@ router.post('/ingest', verifyToken, requirePlatformAdmin, async (req, res) => { logPivotServiceSuccess('POST /admin/pivot/ingest', req, { tenantKey: req.body?.tenantKey, - batchWeek: req.body?.batchWeek, + batchWeek: result.data?.batchWeek || req.body?.batchWeek, + batchWeekSource: result.data?.batchWeekSource, eventId: result.data?.event?._id, eventName: result.data?.event?.name, + ingestStatus: result.data?.ingestStatus, }); return res.status(200).json({ @@ -407,7 +1152,7 @@ router.post('/ingest', verifyToken, requirePlatformAdmin, async (req, res) => { logPivotRouteError('POST /admin/pivot/ingest', err, req); return res.status(500).json({ success: false, - message: 'Unable to publish pivot catalog event.', + message: 'Unable to stage pivot catalog event.', }); } }); @@ -417,7 +1162,10 @@ router.post('/ingest/batch', verifyToken, requirePlatformAdmin, async (req, res) const result = await publishBatchIngestEvents(req, { tenantKey: req.body?.tenantKey, batchWeek: req.body?.batchWeek, + forceBatchWeek: req.body?.forceBatchWeek, events: req.body?.events, + releaseNow: req.body?.releaseNow, + confirm: req.body?.confirm, }); if (result.error && !result.data?.published?.length) { logPivotServiceReject('POST /admin/pivot/ingest/batch', result, req, { @@ -436,8 +1184,11 @@ router.post('/ingest/batch', verifyToken, requirePlatformAdmin, async (req, res) logPivotServiceSuccess('POST /admin/pivot/ingest/batch', req, { tenantKey: req.body?.tenantKey, batchWeek: req.body?.batchWeek, + batchWeekCounts: result.data?.batchWeekCounts, + forceBatchWeek: result.data?.forceBatchWeek, publishedCount: result.data?.publishedCount ?? result.data?.published?.length ?? 0, failedCount: result.data?.failedCount ?? result.data?.failures?.length ?? 0, + ingestStatus: result.data?.ingestStatus, }); return res.status(200).json({ @@ -448,7 +1199,7 @@ router.post('/ingest/batch', verifyToken, requirePlatformAdmin, async (req, res) logPivotRouteError('POST /admin/pivot/ingest/batch', err, req); return res.status(500).json({ success: false, - message: 'Unable to publish pivot catalog events.', + message: 'Unable to stage pivot catalog events.', }); } }); diff --git a/backend/schemas/pivotBatch.js b/backend/schemas/pivotBatch.js new file mode 100644 index 00000000..9e119a00 --- /dev/null +++ b/backend/schemas/pivotBatch.js @@ -0,0 +1,51 @@ +const mongoose = require('mongoose'); +const { isValidIsoWeek } = require('../utilities/pivotIsoWeek'); + +/** + * Per-city week ops progress for Curation readiness and release auditing. + * Tenant-scoped (city DB). Does not gate the feed under Choice A — + * feed keys only off event `customFields.pivot.ingestStatus === 'published'`. + */ +const PIVOT_BATCH_STATUSES = Object.freeze(['curating', 'ready', 'released']); + +const pivotBatchSchema = new mongoose.Schema( + { + batchWeek: { + type: String, + required: true, + trim: true, + validate: { + validator(value) { + return isValidIsoWeek(value); + }, + message: 'batchWeek must be ISO week format YYYY-Www', + }, + }, + status: { + type: String, + enum: PIVOT_BATCH_STATUSES, + required: true, + default: 'curating', + }, + targetEventCount: { + type: Number, + min: 0, + default: 40, + }, + releasedAt: { + type: Date, + default: null, + }, + releasedBy: { + type: String, + default: null, + trim: true, + }, + }, + { timestamps: true }, +); + +pivotBatchSchema.index({ batchWeek: 1 }, { unique: true }); + +module.exports = pivotBatchSchema; +module.exports.PIVOT_BATCH_STATUSES = PIVOT_BATCH_STATUSES; diff --git a/backend/schemas/pivotCurationJob.js b/backend/schemas/pivotCurationJob.js new file mode 100644 index 00000000..98422393 --- /dev/null +++ b/backend/schemas/pivotCurationJob.js @@ -0,0 +1,100 @@ +const mongoose = require('mongoose'); + +const CURATION_PROVIDERS = ['partiful', 'luma', 'manual-json']; +const BATCH_WEEK_STRATEGIES = ['explicit', 'next-drop', 'current-iso']; +const RUN_STATUSES = ['queued', 'running', 'completed', 'failed']; + +const lastRunStatsSchema = new mongoose.Schema( + { + discovered: { type: Number, default: 0 }, + upserted: { type: Number, default: 0 }, + skipped: { type: Number, default: 0 }, + failed: { type: Number, default: 0 }, + message: { type: String, default: null, trim: true }, + }, + { _id: false }, +); + +const pivotCurationJobSchema = new mongoose.Schema( + { + tenantKey: { + type: String, + required: true, + trim: true, + lowercase: true, + }, + label: { + type: String, + required: true, + trim: true, + }, + url: { + type: String, + default: null, + trim: true, + }, + provider: { + type: String, + required: true, + enum: CURATION_PROVIDERS, + }, + defaultBatchWeekStrategy: { + type: String, + enum: BATCH_WEEK_STRATEGIES, + default: 'next-drop', + }, + defaultTags: { + type: [String], + default: [], + }, + enabled: { + type: Boolean, + default: true, + }, + lastRunAt: { + type: Date, + default: null, + }, + lastRunStatus: { + type: String, + enum: RUN_STATUSES, + default: null, + }, + lastRunStats: { + type: lastRunStatsSchema, + default: null, + }, + createdBy: { + type: String, + default: null, + trim: true, + }, + }, + { timestamps: true }, +); + +pivotCurationJobSchema.pre('validate', function normalizeFields() { + if (this.tenantKey) { + this.tenantKey = String(this.tenantKey).trim().toLowerCase(); + } + if (this.label) { + this.label = String(this.label).trim(); + } + if (this.url != null) { + const trimmed = String(this.url).trim(); + this.url = trimmed || null; + } + if (Array.isArray(this.defaultTags)) { + this.defaultTags = this.defaultTags + .map((tag) => String(tag || '').trim()) + .filter(Boolean); + } +}); + +pivotCurationJobSchema.index({ tenantKey: 1, createdAt: -1 }); +pivotCurationJobSchema.index({ tenantKey: 1, enabled: 1 }); + +module.exports = pivotCurationJobSchema; +module.exports.CURATION_PROVIDERS = CURATION_PROVIDERS; +module.exports.BATCH_WEEK_STRATEGIES = BATCH_WEEK_STRATEGIES; +module.exports.RUN_STATUSES = RUN_STATUSES; diff --git a/backend/schemas/pivotCurationRun.js b/backend/schemas/pivotCurationRun.js new file mode 100644 index 00000000..b2b8cb51 --- /dev/null +++ b/backend/schemas/pivotCurationRun.js @@ -0,0 +1,135 @@ +const mongoose = require('mongoose'); +const { isValidIsoWeek } = require('../utilities/pivotIsoWeek'); +const { RUN_STATUSES } = require('./pivotCurationJob'); + +const CURATION_RUN_STATUSES = RUN_STATUSES; + +const runStatsSchema = new mongoose.Schema( + { + discovered: { type: Number, default: 0 }, + upserted: { type: Number, default: 0 }, + skipped: { type: Number, default: 0 }, + failed: { type: Number, default: 0 }, + updated: { type: Number, default: 0 }, + /** Plain map of batchWeek → upsert count for multi-week crawls. */ + byBatchWeek: { type: mongoose.Schema.Types.Mixed, default: null }, + message: { type: String, default: null, trim: true }, + }, + { _id: false }, +); + +const runFailureSchema = new mongoose.Schema( + { + sourceUrl: { type: String, default: null, trim: true }, + name: { type: String, default: null, trim: true }, + message: { type: String, default: null, trim: true }, + code: { type: String, default: null, trim: true }, + }, + { _id: false }, +); + +const pivotCurationRunSchema = new mongoose.Schema( + { + tenantKey: { + type: String, + required: true, + trim: true, + lowercase: true, + }, + jobId: { + type: mongoose.Schema.Types.ObjectId, + required: true, + ref: 'PivotCurationJob', + }, + batchWeek: { + type: String, + required: true, + trim: true, + validate: { + validator: isValidIsoWeek, + message: 'batchWeek must be ISO week format YYYY-Www', + }, + }, + /** + * When true, every discovered event is forced into `batchWeek`. + * When false (default), each event lands in the ISO week of its start date; + * `batchWeek` is only a fallback for undated events / UI focus week. + */ + forceBatchWeek: { + type: Boolean, + default: false, + }, + status: { + type: String, + enum: CURATION_RUN_STATUSES, + default: 'queued', + required: true, + }, + maxEvents: { + type: Number, + default: null, + }, + provider: { + type: String, + default: null, + trim: true, + }, + url: { + type: String, + default: null, + trim: true, + }, + startedAt: { + type: Date, + default: null, + }, + finishedAt: { + type: Date, + default: null, + }, + stats: { + type: runStatsSchema, + default: () => ({ + discovered: 0, + upserted: 0, + skipped: 0, + failed: 0, + updated: 0, + message: null, + }), + }, + failures: { + type: [runFailureSchema], + default: [], + }, + error: { + type: String, + default: null, + trim: true, + }, + errorCode: { + type: String, + default: null, + trim: true, + }, + createdBy: { + type: String, + default: null, + trim: true, + }, + }, + { timestamps: true }, +); + +pivotCurationRunSchema.pre('validate', function normalizeFields() { + if (this.tenantKey) { + this.tenantKey = String(this.tenantKey).trim().toLowerCase(); + } +}); + +pivotCurationRunSchema.index({ tenantKey: 1, createdAt: -1 }); +pivotCurationRunSchema.index({ jobId: 1, createdAt: -1 }); +pivotCurationRunSchema.index({ tenantKey: 1, status: 1 }); + +module.exports = pivotCurationRunSchema; +module.exports.CURATION_RUN_STATUSES = CURATION_RUN_STATUSES; diff --git a/backend/services/getGlobalModelService.js b/backend/services/getGlobalModelService.js index 7879a493..8fc7fdaf 100644 --- a/backend/services/getGlobalModelService.js +++ b/backend/services/getGlobalModelService.js @@ -9,6 +9,8 @@ const pivotWeeklySnapshotSchema = require('../schemas/pivotWeeklySnapshot'); const pivotLabNotesSchema = require('../schemas/pivotLabNotes'); const pivotTagCatalogSchema = require('../schemas/pivotTagCatalog'); const pivotPosterTemplateSchema = require('../schemas/pivotPosterTemplate'); +const pivotCurationJobSchema = require('../schemas/pivotCurationJob'); +const pivotCurationRunSchema = require('../schemas/pivotCurationRun'); /** * Get models from the global/platform DB (cross-tenant data). @@ -16,7 +18,7 @@ const pivotPosterTemplateSchema = require('../schemas/pivotPosterTemplate'); * Requires req.globalDb to be set (see app.js middleware). * * @param {object} req - request with req.globalDb - * @param {...string} names - model names: 'GlobalUser', 'PlatformRole', 'TenantMembership', 'Session', 'TenantConfig', 'PivotReferralCode', 'PivotReferralRedemption', 'PivotWeeklySnapshot', 'PivotLabNotes', 'PivotTagCatalog', 'PivotPosterTemplate' + * @param {...string} names - model names: 'GlobalUser', 'PlatformRole', 'TenantMembership', 'Session', 'TenantConfig', 'PivotReferralCode', 'PivotReferralRedemption', 'PivotWeeklySnapshot', 'PivotLabNotes', 'PivotTagCatalog', 'PivotPosterTemplate', 'PivotCurationJob', 'PivotCurationRun' * @returns {object} map of requested models */ const getGlobalModels = (req, ...names) => { @@ -49,6 +51,16 @@ const getGlobalModels = (req, ...names) => { pivotPosterTemplateSchema, 'pivot_poster_templates' ), + PivotCurationJob: db.model( + 'PivotCurationJob', + pivotCurationJobSchema, + 'pivot_curation_jobs' + ), + PivotCurationRun: db.model( + 'PivotCurationRun', + pivotCurationRunSchema, + 'pivot_curation_runs' + ), }; return names.reduce((acc, name) => { diff --git a/backend/services/getModelService.js b/backend/services/getModelService.js index b93ca429..84126656 100644 --- a/backend/services/getModelService.js +++ b/backend/services/getModelService.js @@ -65,6 +65,7 @@ const orgEquipmentSchema = require('../schemas/OrgEquipment'); const analyticsEventSchema = require('../events/schemas/analyticsEvent'); const eventQRSchema = require('../events/schemas/eventQR'); const pivotEventIntentSchema = require('../schemas/pivotEventIntent'); +const pivotBatchSchema = require('../schemas/pivotBatch'); const registeredConnections = new WeakSet(); const MODEL_DEFINITIONS = Object.freeze({ BadgeGrant: { modelName: 'BadgeGrant', schema: badgeGrantSchema, collection: 'badgegrants' }, @@ -132,6 +133,11 @@ const MODEL_DEFINITIONS = Object.freeze({ schema: pivotEventIntentSchema, collection: 'pivotEventIntents', }, + PivotBatch: { + modelName: 'PivotBatch', + schema: pivotBatchSchema, + collection: 'pivotBatches', + }, 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/pivotAdminOverviewService.js b/backend/services/pivotAdminOverviewService.js index 40d34c06..3ffddb81 100644 --- a/backend/services/pivotAdminOverviewService.js +++ b/backend/services/pivotAdminOverviewService.js @@ -11,6 +11,28 @@ const { aggregateEngagementMetrics, } = require('./pivotWeeklySnapshotService'); const { buildDropSchedulePayload } = require('./pivotConfigService'); +const { resolvePivotTenant } = require('./pivotIngestPublishService'); +const { loadIntentStatsByEventId, labEventsQuery } = require('./pivotLabEventsService'); +const { shiftIsoWeek } = require('../utilities/pivotIsoWeek'); +const { PIVOT_EVENT_STATUSES } = require('./pivotFeedService'); + +const DEFAULT_PERFORMANCE_LIMIT = 20; +const MAX_PERFORMANCE_LIMIT = 100; + +/** KPI fields compared week-over-week on the tenant overview. */ +const DELTA_KPI_KEYS = [ + 'activeUsers', + 'eventCount', + 'interestedCount', + 'registeredCount', + 'externalOpenCount', + 'externalOpenUsers', + 'swipeCount', + 'calendarAdds', + 'inviteShares', + 'interestsSaved', + 'feedbackCount', +]; async function loadReferralCodesForTenant(req, tenantKey) { const { PivotReferralCode } = getGlobalModels(req, 'PivotReferralCode'); @@ -67,7 +89,94 @@ async function aggregateRegisteredFeedback(PivotEventIntent, UniversalFeedback, }; } -async function aggregateTenantOverview(req, tenant, batchWeek) { +/** + * Catalog events for a batch week (any ingestStatus), excluding soft-deleted rows. + * Broader than PUBLISHED_EVENT_QUERY — used for status breakdowns and performance lists. + */ +function catalogEventsQuery(batchWeek) { + return { + ...labEventsQuery(batchWeek), + status: { $in: PIVOT_EVENT_STATUSES }, + }; +} + +async function aggregateEventCountsByStatus(Event, batchWeek) { + const rows = await Event.aggregate([ + { $match: catalogEventsQuery(batchWeek) }, + { + $group: { + _id: { $ifNull: ['$customFields.pivot.ingestStatus', 'unknown'] }, + count: { $sum: 1 }, + }, + }, + ]); + + const byStatus = { draft: 0, staged: 0, published: 0, other: 0 }; + for (const row of rows) { + const key = row._id; + if (key === 'draft' || key === 'staged' || key === 'published') { + byStatus[key] = row.count; + } else { + byStatus.other += row.count; + } + } + + return { + draft: byStatus.draft, + staged: byStatus.staged, + published: byStatus.published, + other: byStatus.other, + total: byStatus.draft + byStatus.staged + byStatus.published + byStatus.other, + }; +} + +/** Funnel stages matching PivotLabOverview FunnelChart definitions. */ +function buildFunnelStages({ + swipeCount, + interestedCount, + registeredCount, + externalOpenUsers, +}) { + const interestedSurvivors = interestedCount + registeredCount; + return [ + { key: 'swipes', label: 'Swipes', value: swipeCount, hint: 'cards acted on' }, + { key: 'interested', label: 'Interested', value: interestedSurvivors, hint: 'right swipes' }, + { key: 'openers', label: 'Ticket openers', value: externalOpenUsers, hint: 'unique users' }, + { key: 'going', label: 'Going', value: registeredCount, hint: 'self-confirmed' }, + ]; +} + +function buildVsPrevWeek(current, previous) { + if (!previous) { + return null; + } + + const deltas = {}; + for (const key of DELTA_KPI_KEYS) { + const curr = current[key] ?? 0; + const prev = previous[key] ?? 0; + deltas[key] = { + current: curr, + previous: prev, + delta: curr - prev, + }; + } + + const currAvg = current.feedbackAvg; + const prevAvg = previous.feedbackAvg; + deltas.feedbackAvg = { + current: currAvg, + previous: prevAvg, + delta: + currAvg != null && prevAvg != null + ? Math.round((currAvg - prevAvg) * 100) / 100 + : null, + }; + + return deltas; +} + +async function aggregateTenantOverview(req, tenant, batchWeek, options = {}) { const tenantKey = tenant.tenantKey; const db = await connectToDatabase(tenantKey); const tenantReq = { db }; @@ -79,9 +188,15 @@ async function aggregateTenantOverview(req, tenant, batchWeek) { ); const eventQuery = PUBLISHED_EVENT_QUERY(batchWeek); - const [eventCount, events] = await Promise.all([ + const includeStatusBreakdown = options.includeStatusBreakdown === true; + const includeReferralCodes = options.includeReferralCodes !== false; + + const [eventCount, events, eventCountsByStatus] = await Promise.all([ Event.countDocuments(eventQuery), Event.find(eventQuery).select('_id').lean(), + includeStatusBreakdown + ? aggregateEventCountsByStatus(Event, batchWeek) + : Promise.resolve(null), ]); const eventIds = events.map((event) => event._id); @@ -108,12 +223,14 @@ async function aggregateTenantOverview(req, tenant, batchWeek) { PivotEventIntent.distinct('userId', { ...intentFilter, externalOpenAt: { $ne: null } }), aggregateEngagementMetrics(tenantReq, batchWeek), aggregateRegisteredFeedback(PivotEventIntent, UniversalFeedback, batchWeek, eventIds), - loadReferralCodesForTenant(req, tenantKey), + includeReferralCodes + ? loadReferralCodesForTenant(req, tenantKey) + : Promise.resolve(undefined), ]); const swipeCount = passedCount + interestedCount + registeredCount; - return { + const row = { tenantKey, cityDisplayName: tenant.location || tenant.name || tenantKey, eventCount, @@ -128,9 +245,67 @@ async function aggregateTenantOverview(req, tenant, batchWeek) { feedbackCount: feedback.feedbackCount, feedbackAvg: feedback.feedbackAvg, activeUsers: activeUserIds.length, - referralCodes, dropSchedule: buildDropSchedulePayload(tenant, batchWeek), }; + + if (includeReferralCodes) { + row.referralCodes = referralCodes; + } + if (eventCountsByStatus) { + row.eventCountsByStatus = eventCountsByStatus; + } + + return row; +} + +function rateOrNull(numerator, denominator) { + if (!denominator) { + return null; + } + return Math.round((numerator / denominator) * 1000) / 1000; +} + +/** + * Primary sort for event performance: right-swipe survivors (interested + registered). + * Ties break on external opens, then name. + */ +function comparePerformanceRows(a, b) { + const interestDiff = b.interestedTotal - a.interestedTotal; + if (interestDiff !== 0) return interestDiff; + const openDiff = b.externalOpen - a.externalOpen; + if (openDiff !== 0) return openDiff; + return (a.name || '').localeCompare(b.name || ''); +} + +function serializePerformanceEvent(event, stats) { + const interested = stats.interested ?? 0; + const registered = stats.registered ?? 0; + const passed = stats.passed ?? 0; + const externalOpen = stats.externalOpens ?? 0; + const externalOpenUsers = stats.externalOpenUsers ?? 0; + const interestedTotal = interested + registered; + const swipeTotal = interestedTotal + passed; + const pivot = event.customFields?.pivot || {}; + + return { + eventId: String(event._id), + name: event.name || '', + image: event.image || null, + start_time: event.start_time || null, + ingestStatus: pivot.ingestStatus || null, + tags: Array.isArray(pivot.tags) ? pivot.tags : [], + interested, + registered, + passed, + externalOpen, + externalOpenUsers, + /** Right-swipe survivors — primary ranking metric. */ + interestedTotal, + /** People who swiped on this card (interested + registered + passed). */ + reached: swipeTotal, + interestRate: rateOrNull(interestedTotal, swipeTotal), + ticketOpenRate: rateOrNull(externalOpenUsers, interestedTotal), + }; } async function getPivotOverview(req, options = {}) { @@ -186,9 +361,170 @@ async function getPivotOverview(req, options = {}) { }; } +/** + * Single-tenant overview for the Pivot tenant ops dashboard. + * Does not loop other cities — use getPivotOverview for fleet Lab. + */ +async function getTenantOverview(req, options = {}) { + const normalized = normalizeBatchWeek(options.batchWeek, options.now); + if (normalized.error) { + return normalized; + } + + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) { + return tenantResult; + } + + const { batchWeek } = normalized; + const { tenant } = tenantResult; + const includePrevWeek = options.includePrevWeek !== false; + + const current = await aggregateTenantOverview(req, tenant, batchWeek, { + includeStatusBreakdown: true, + includeReferralCodes: true, + }); + + let vsPrevWeek = null; + let previousBatchWeek = null; + if (includePrevWeek) { + previousBatchWeek = shiftIsoWeek(batchWeek, -1); + try { + const previous = await aggregateTenantOverview(req, tenant, previousBatchWeek, { + includeStatusBreakdown: false, + includeReferralCodes: false, + }); + vsPrevWeek = buildVsPrevWeek(current, previous); + } catch (error) { + console.error( + `[pivotAdminOverview] prev-week aggregate failed tenant=${tenant.tenantKey} batchWeek=${previousBatchWeek}:`, + error, + ); + } + } + + const funnel = buildFunnelStages(current); + + return { + data: { + tenantKey: current.tenantKey, + cityDisplayName: current.cityDisplayName, + batchWeek, + previousBatchWeek, + kpis: { + activeUsers: current.activeUsers, + eventCount: current.eventCount, + eventCountsByStatus: current.eventCountsByStatus, + interestedCount: current.interestedCount, + registeredCount: current.registeredCount, + externalOpenCount: current.externalOpenCount, + externalOpenUsers: current.externalOpenUsers, + swipeCount: current.swipeCount, + feedbackCount: current.feedbackCount, + feedbackAvg: current.feedbackAvg, + calendarAdds: current.calendarAdds, + inviteShares: current.inviteShares, + interestsSaved: current.interestsSaved, + }, + funnel, + vsPrevWeek, + dropSchedule: current.dropSchedule, + referralCodes: current.referralCodes, + }, + }; +} + +function parsePerformanceLimit(raw) { + if (raw == null || raw === '') { + return DEFAULT_PERFORMANCE_LIMIT; + } + const parsed = Number.parseInt(String(raw), 10); + if (!Number.isFinite(parsed) || parsed < 1) { + return { + error: 'limit must be a positive integer.', + status: 400, + code: 'INVALID_LIMIT', + }; + } + return Math.min(parsed, MAX_PERFORMANCE_LIMIT); +} + +/** + * Ranked per-event performance for one pivot city + batch week. + * Sorted by interestedTotal (interested + registered), then externalOpen. + */ +async function getTenantEventPerformance(req, options = {}) { + const normalized = normalizeBatchWeek(options.batchWeek, options.now); + if (normalized.error) { + return normalized; + } + + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) { + return tenantResult; + } + + const limitResult = parsePerformanceLimit(options.limit); + if (limitResult?.error) { + return limitResult; + } + const limit = limitResult; + + const { batchWeek } = normalized; + const { tenant } = tenantResult; + const tenantKey = tenant.tenantKey; + const db = await connectToDatabase(tenantKey); + const tenantReq = { db }; + const { Event, PivotEventIntent } = getModels(tenantReq, 'Event', 'PivotEventIntent'); + + const events = await Event.find(catalogEventsQuery(batchWeek)) + .select('name image start_time customFields.pivot') + .lean(); + + const intentStatsByEventId = await loadIntentStatsByEventId( + PivotEventIntent, + events.map((event) => event._id), + { batchWeek }, + ); + + const ranked = events + .map((event) => + serializePerformanceEvent( + event, + intentStatsByEventId.get(String(event._id)) || { + interested: 0, + registered: 0, + passed: 0, + externalOpens: 0, + externalOpenUsers: 0, + }, + ), + ) + .sort(comparePerformanceRows); + + return { + data: { + tenantKey, + cityDisplayName: tenant.location || tenant.name || tenantKey, + batchWeek, + sortBy: 'interestedTotal', + limit, + total: ranked.length, + events: ranked.slice(0, limit), + }, + }; +} + module.exports = { aggregateTenantOverview, aggregateRegisteredFeedback, + aggregateEventCountsByStatus, + buildFunnelStages, + buildVsPrevWeek, getPivotOverview, + getTenantOverview, + getTenantEventPerformance, loadReferralCodesForTenant, + serializePerformanceEvent, + comparePerformanceRows, }; diff --git a/backend/services/pivotBatchReadinessService.js b/backend/services/pivotBatchReadinessService.js new file mode 100644 index 00000000..1a139579 --- /dev/null +++ b/backend/services/pivotBatchReadinessService.js @@ -0,0 +1,448 @@ +const getModels = require('./getModelService'); +const { connectToDatabase } = require('../connectionsManager'); +const { normalizeBatchWeek } = require('./pivotWeeklySnapshotService'); +const { resolvePivotTenant } = require('./pivotIngestPublishService'); +const { labEventsQuery } = require('./pivotLabEventsService'); +const { PIVOT_EVENT_STATUSES } = require('./pivotFeedService'); +const { + DEFAULT_TARGET_EVENT_COUNT, + getPivotBatch, + serializePivotBatch, +} = require('./pivotBatchService'); +const { buildDropSchedulePayload } = require('./pivotConfigService'); +const { curationHref } = require('./pivotTenantInsightsService'); + +const FORMULA_VERSION = 'v0'; +const DEFAULT_BENCHMARK_WEEKS = 4; +const TIME_BUFFER_FULL_HOURS = 72; +const BENCHMARK_TOLERANCE = 0.05; + +const WEIGHTS = { + eventCount: 0.4, + tagCoverage: 0.25, + hostCompleteness: 0.2, + diversity: 0.1, + timeBuffer: 0.05, +}; + +function catalogEventsQuery(batchWeek) { + return { + ...labEventsQuery(batchWeek), + status: { $in: PIVOT_EVENT_STATUSES }, + }; +} + +function isDeckEligible(ingestStatus) { + return ingestStatus === 'published' || ingestStatus === 'staged'; +} + +function round2(n) { + return Math.round(n * 100) / 100; +} + +function avg(values) { + if (!values.length) return null; + return values.reduce((sum, v) => sum + v, 0) / values.length; +} + +/** + * Pure catalog metrics for a week's events (lean Event docs with customFields.pivot). + */ +function computeCatalogMetrics(events = []) { + const rows = Array.isArray(events) ? events : []; + const deck = rows.filter((e) => isDeckEligible(e.customFields?.pivot?.ingestStatus)); + const staged = rows.filter((e) => e.customFields?.pivot?.ingestStatus === 'staged'); + const draft = rows.filter((e) => e.customFields?.pivot?.ingestStatus === 'draft'); + const published = rows.filter((e) => e.customFields?.pivot?.ingestStatus === 'published'); + + const tagged = deck.filter((e) => (e.customFields?.pivot?.tags || []).length > 0); + const withHost = deck.filter((e) => + String(e.customFields?.pivot?.host?.name || '').trim(), + ); + const withQuality = deck.filter( + (e) => String(e.description || '').trim() && e.image, + ); + + const tagCounts = new Map(); + for (const e of deck) { + for (const tag of e.customFields?.pivot?.tags || []) { + const slug = String(tag || '').trim().toLowerCase(); + if (!slug) continue; + tagCounts.set(slug, (tagCounts.get(slug) || 0) + 1); + } + } + const uniqueTags = tagCounts.size; + const topTagCount = tagCounts.size ? Math.max(...tagCounts.values()) : 0; + const diversityRatio = deck.length ? uniqueTags / Math.max(deck.length, 1) : 0; + const concentrationRatio = deck.length ? topTagCount / deck.length : 0; + + return { + draftCount: draft.length, + stagedCount: staged.length, + publishedCount: published.length, + deckCount: deck.length, + readyCount: draft.length + staged.length + published.length, + tagCoveragePct: deck.length ? tagged.length / deck.length : 0, + hostCompletenessPct: deck.length ? withHost.length / deck.length : 0, + qualityPct: deck.length ? withQuality.length / deck.length : 0, + uniqueTags, + diversityRatio, + concentrationRatio, + untaggedCount: Math.max(0, deck.length - tagged.length), + missingHostCount: Math.max(0, deck.length - withHost.length), + }; +} + +function compareToBenchmark(value, benchmark, { relative = false } = {}) { + if (benchmark == null || Number.isNaN(benchmark)) { + return { + value, + benchmark: null, + delta: null, + status: 'on', + }; + } + const delta = value - benchmark; + let status = 'on'; + if (relative) { + const denom = Math.abs(benchmark) || 1; + const ratio = delta / denom; + if (ratio < -BENCHMARK_TOLERANCE) status = 'below'; + else if (ratio > BENCHMARK_TOLERANCE) status = 'above'; + } else if (delta < -BENCHMARK_TOLERANCE) { + status = 'below'; + } else if (delta > BENCHMARK_TOLERANCE) { + status = 'above'; + } + return { + value: round2(value), + benchmark: round2(benchmark), + delta: round2(delta), + status, + }; +} + +function normalizeEventCount(readyCount, targetEventCount) { + if (!targetEventCount) return 0; + return Math.min(1, readyCount / targetEventCount); +} + +function normalizeTimeBuffer(hoursUntilDrop) { + if (hoursUntilDrop == null || Number.isNaN(hoursUntilDrop)) return 0; + if (hoursUntilDrop <= 0) return 0; + return Math.min(1, hoursUntilDrop / TIME_BUFFER_FULL_HOURS); +} + +function buildComponents({ + metrics, + targetEventCount, + hoursUntilDrop, + benchmarks, +}) { + const eventNorm = normalizeEventCount(metrics.readyCount, targetEventCount); + const timeNorm = normalizeTimeBuffer(hoursUntilDrop); + + const eventCmp = compareToBenchmark( + metrics.readyCount, + benchmarks?.readyCount, + { relative: true }, + ); + const tagCmp = compareToBenchmark( + metrics.tagCoveragePct, + benchmarks?.tagCoveragePct, + ); + const hostCmp = compareToBenchmark( + metrics.hostCompletenessPct, + benchmarks?.hostCompletenessPct, + ); + const divCmp = compareToBenchmark( + metrics.diversityRatio, + benchmarks?.diversityRatio, + ); + const timeCmp = compareToBenchmark( + hoursUntilDrop ?? 0, + benchmarks?.hoursUntilDrop, + { relative: true }, + ); + + return [ + { + key: 'eventCount', + label: 'Catalog events (draft+staged+published)', + weight: WEIGHTS.eventCount, + normalized: round2(eventNorm), + target: targetEventCount, + ...eventCmp, + }, + { + key: 'tagCoverage', + label: 'Tag coverage (staged+published)', + weight: WEIGHTS.tagCoverage, + normalized: round2(metrics.tagCoveragePct), + unit: 'ratio', + ...tagCmp, + }, + { + key: 'hostCompleteness', + label: 'Host completeness', + weight: WEIGHTS.hostCompleteness, + normalized: round2(metrics.hostCompletenessPct), + unit: 'ratio', + ...hostCmp, + }, + { + key: 'diversity', + label: 'Tag diversity', + weight: WEIGHTS.diversity, + normalized: round2(Math.min(1, metrics.diversityRatio)), + unit: 'ratio', + uniqueTags: metrics.uniqueTags, + ...divCmp, + }, + { + key: 'timeBuffer', + label: 'Hours until drop', + weight: WEIGHTS.timeBuffer, + normalized: round2(timeNorm), + unit: 'hours', + ...timeCmp, + }, + ]; +} + +function scoreFromComponents(components) { + const raw = components.reduce( + (sum, c) => sum + (c.normalized || 0) * (c.weight || 0), + 0, + ); + return Math.max(0, Math.min(100, Math.round(raw * 100))); +} + +function buildCtas({ tenantKey, batchWeek, metrics, targetEventCount }) { + const ctas = []; + const shortfall = Math.max(0, targetEventCount - metrics.readyCount); + if (shortfall > 0) { + ctas.push({ + id: 'add-events', + label: `Add ${shortfall} more event${shortfall === 1 ? '' : 's'}`, + href: curationHref(tenantKey, batchWeek), + action: { type: 'open_curation', label: 'Open curation' }, + }); + } + if (metrics.untaggedCount > 0) { + ctas.push({ + id: 'tag-events', + label: `Tag ${metrics.untaggedCount} event${metrics.untaggedCount === 1 ? '' : 's'}`, + href: curationHref(tenantKey, batchWeek, 'untagged'), + action: { type: 'open_curation', filter: 'untagged', label: 'Fix tags' }, + }); + } + if (metrics.missingHostCount > 0) { + ctas.push({ + id: 'fix-hosts', + label: `Add host on ${metrics.missingHostCount} event${metrics.missingHostCount === 1 ? '' : 's'}`, + href: curationHref(tenantKey, batchWeek, 'missing-host'), + action: { type: 'open_curation', filter: 'missing-host', label: 'Fix hosts' }, + }); + } + if (metrics.draftCount > 0) { + ctas.push({ + id: 'stage-drafts', + label: `Stage ${metrics.draftCount} draft${metrics.draftCount === 1 ? '' : 's'}`, + href: curationHref(tenantKey, batchWeek, 'draft'), + action: { type: 'open_curation', filter: 'draft', label: 'Review drafts' }, + }); + } + return ctas; +} + +/** + * Pure readiness builder — unit-testable without DB. + */ +function buildBatchReadiness(context) { + const { + tenantKey, + batchWeek, + metrics, + targetEventCount = DEFAULT_TARGET_EVENT_COUNT, + hoursUntilDrop = 0, + dropSchedule = null, + batch = null, + benchmarks = null, + benchmarkWeeksUsed = 0, + } = context; + + const components = buildComponents({ + metrics, + targetEventCount, + hoursUntilDrop, + benchmarks, + }); + const score = scoreFromComponents(components); + const ctas = buildCtas({ + tenantKey, + batchWeek, + metrics, + targetEventCount, + }); + + return { + tenantKey, + batchWeek, + score, + targetEventCount, + hoursUntilDrop: round2(hoursUntilDrop), + dropSchedule, + batch, + metrics: { + readyCount: metrics.readyCount, + draftCount: metrics.draftCount, + stagedCount: metrics.stagedCount, + publishedCount: metrics.publishedCount, + deckCount: metrics.deckCount, + tagCoveragePct: round2(metrics.tagCoveragePct), + hostCompletenessPct: round2(metrics.hostCompletenessPct), + qualityPct: round2(metrics.qualityPct), + uniqueTags: metrics.uniqueTags, + diversityRatio: round2(metrics.diversityRatio), + untaggedCount: metrics.untaggedCount, + missingHostCount: metrics.missingHostCount, + }, + formula: { + version: FORMULA_VERSION, + weights: { ...WEIGHTS }, + timeBufferFullHours: TIME_BUFFER_FULL_HOURS, + description: + 'Weighted sum of normalized components: event count vs target (40%), tag coverage (25%), host completeness (20%), tag diversity (10%), hours-until-drop buffer (5%, full credit at 72h).', + }, + components, + ctas, + benchmarkWeeksUsed, + }; +} + +async function loadWeekEvents(Event, batchWeek) { + return Event.find(catalogEventsQuery(batchWeek)) + .select('description image customFields.pivot') + .lean(); +} + +async function loadBenchmarkMetrics(Event, PivotBatch, { excludeWeek, limit, now }) { + const released = await PivotBatch.find({ status: 'released' }) + .sort({ batchWeek: -1 }) + .limit(Math.max(limit * 2, limit)) + .select('batchWeek') + .lean(); + + const weeks = []; + for (const row of released) { + if (!row?.batchWeek || row.batchWeek === excludeWeek) continue; + weeks.push(row.batchWeek); + if (weeks.length >= limit) break; + } + + const metricsList = []; + for (const week of weeks) { + const events = await loadWeekEvents(Event, week); + metricsList.push(computeCatalogMetrics(events)); + } + + if (!metricsList.length) { + return { benchmarks: null, benchmarkWeeksUsed: 0 }; + } + + return { + benchmarks: { + readyCount: avg(metricsList.map((m) => m.readyCount)), + tagCoveragePct: avg(metricsList.map((m) => m.tagCoveragePct)), + hostCompletenessPct: avg(metricsList.map((m) => m.hostCompletenessPct)), + diversityRatio: avg(metricsList.map((m) => m.diversityRatio)), + // Past released weeks are already past drop — no meaningful hours buffer avg. + hoursUntilDrop: null, + }, + benchmarkWeeksUsed: metricsList.length, + now, + }; +} + +async function getBatchReadiness(req, options = {}) { + const normalized = normalizeBatchWeek(options.batchWeek, options.now); + if (normalized.error) return normalized; + + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) return tenantResult; + + const tenantKey = tenantResult.tenant.tenantKey; + const { batchWeek } = normalized; + const now = options.now || new Date(); + + let benchmarkWeeks = DEFAULT_BENCHMARK_WEEKS; + if (options.benchmarkWeeks != null && options.benchmarkWeeks !== '') { + const n = Number(options.benchmarkWeeks); + if (!Number.isFinite(n) || n < 1 || n > 12) { + return { + error: 'benchmarkWeeks must be an integer from 1 to 12.', + status: 400, + code: 'INVALID_BENCHMARK_WEEKS', + }; + } + benchmarkWeeks = Math.floor(n); + } + + const db = await connectToDatabase(tenantKey); + const tenantReq = { db }; + const { Event, PivotBatch } = getModels(tenantReq, 'Event', 'PivotBatch'); + + const [events, batchResult, benchmarkResult] = await Promise.all([ + loadWeekEvents(Event, batchWeek), + getPivotBatch(tenantReq, batchWeek), + loadBenchmarkMetrics(Event, PivotBatch, { + excludeWeek: batchWeek, + limit: benchmarkWeeks, + now, + }), + ]); + + if (batchResult.error) return batchResult; + + const metrics = computeCatalogMetrics(events); + const targetEventCount = + batchResult.data?.targetEventCount ?? DEFAULT_TARGET_EVENT_COUNT; + + let dropSchedule = null; + let hoursUntilDrop = 0; + try { + dropSchedule = buildDropSchedulePayload(tenantResult.tenant, batchWeek, now); + hoursUntilDrop = Math.max( + 0, + (new Date(dropSchedule.nextDropAt).getTime() - now.getTime()) / 3_600_000, + ); + } catch { + dropSchedule = null; + hoursUntilDrop = 0; + } + + const data = buildBatchReadiness({ + tenantKey, + batchWeek, + metrics, + targetEventCount, + hoursUntilDrop, + dropSchedule, + batch: batchResult.data || serializePivotBatch(null), + benchmarks: benchmarkResult.benchmarks, + benchmarkWeeksUsed: benchmarkResult.benchmarkWeeksUsed, + }); + + return { data }; +} + +module.exports = { + getBatchReadiness, + buildBatchReadiness, + computeCatalogMetrics, + WEIGHTS, + FORMULA_VERSION, + DEFAULT_BENCHMARK_WEEKS, + TIME_BUFFER_FULL_HOURS, +}; diff --git a/backend/services/pivotBatchReleaseService.js b/backend/services/pivotBatchReleaseService.js new file mode 100644 index 00000000..44f82cb6 --- /dev/null +++ b/backend/services/pivotBatchReleaseService.js @@ -0,0 +1,333 @@ +const mongoose = require('mongoose'); +const getModels = require('./getModelService'); +const { connectToDatabase } = require('../connectionsManager'); +const { resolvePivotTenant } = require('./pivotIngestPublishService'); +const { normalizeBatchWeek, rebuildWeeklySnapshot } = require('./pivotWeeklySnapshotService'); +const { + ensurePivotBatch, + serializePivotBatch, + DEFAULT_TARGET_EVENT_COUNT, +} = require('./pivotBatchService'); +const { PIVOT_FEED_INGEST_STATUS } = require('../utilities/pivotIngestStatus'); +const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger'); + +const UNRELEASE_CONFIRM_TOKEN = 'UNRELEASE'; +const STAGED_STATUS = 'staged'; + +function resolveReleasedBy(req) { + return ( + (typeof req.user?.email === 'string' && req.user.email.trim()) || + (typeof req.user?.globalUserId === 'string' && req.user.globalUserId.trim()) || + (typeof req.user?.userId === 'string' && req.user.userId.trim()) || + 'platform-admin' + ); +} + +/** + * Normalize optional eventIds body field. + * @returns {{ eventIds: import('mongoose').Types.ObjectId[]|null } | { error: string, status: number, code: string }} + */ +function normalizeEventIds(raw) { + if (raw === undefined || raw === null) { + return { eventIds: null }; + } + if (!Array.isArray(raw)) { + return { + error: 'eventIds must be an array of event id strings.', + status: 400, + code: 'INVALID_EVENT_IDS', + }; + } + if (!raw.length) { + return { + error: 'eventIds must be a non-empty array when provided.', + status: 400, + code: 'INVALID_EVENT_IDS', + }; + } + + const eventIds = []; + const seen = new Set(); + for (const value of raw) { + const id = String(value || '').trim(); + if (!mongoose.Types.ObjectId.isValid(id)) { + return { + error: `Invalid eventId: ${id || '(empty)'}`, + status: 400, + code: 'INVALID_EVENT_IDS', + }; + } + if (seen.has(id)) continue; + seen.add(id); + eventIds.push(new mongoose.Types.ObjectId(id)); + } + + return { eventIds }; +} + +function catalogWeekBaseQuery(batchWeek) { + return { + 'customFields.pivot.batchWeek': batchWeek, + isDeleted: { $ne: true }, + 'customFields.pivot': { $exists: true }, + }; +} + +async function openTenantDb(tenantKey) { + const db = await connectToDatabase(tenantKey); + return { db, school: tenantKey }; +} + +/** + * Choice A release: flip staged → published for a city week. + * Optional `eventIds` limits the flip (partial release). Omit to release all staged. + * + * @param {object} req — needs globalDb for optional snapshot rebuild + * @param {{ tenantKey: string, batchWeek: string, eventIds?: string[], rebuildSnapshot?: boolean, now?: Date }} options + */ +async function releaseBatch(req, options = {}) { + const normalized = normalizeBatchWeek(options.batchWeek, options.now); + if (normalized.error) { + return normalized; + } + + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) { + return tenantResult; + } + + const idsResult = normalizeEventIds(options.eventIds); + if (idsResult.error) { + return idsResult; + } + + const { batchWeek } = normalized; + const { tenant } = tenantResult; + const tenantKey = tenant.tenantKey; + const now = options.now || new Date(); + const releasedBy = resolveReleasedBy(req); + + const tenantReq = await openTenantDb(tenantKey); + const { Event, PivotBatch } = getModels(tenantReq, 'Event', 'PivotBatch'); + + const match = { + ...catalogWeekBaseQuery(batchWeek), + 'customFields.pivot.ingestStatus': STAGED_STATUS, + }; + if (idsResult.eventIds) { + match._id = { $in: idsResult.eventIds }; + } + + const updateResult = await Event.updateMany(match, { + $set: { 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS }, + }); + const releasedCount = updateResult.modifiedCount ?? updateResult.nModified ?? 0; + + let skippedCount = 0; + if (idsResult.eventIds) { + skippedCount = Math.max(0, idsResult.eventIds.length - releasedCount); + } + + await ensurePivotBatch(tenantReq, { + batchWeek, + status: 'curating', + targetEventCount: DEFAULT_TARGET_EVENT_COUNT, + }); + + const batchDoc = await PivotBatch.findOneAndUpdate( + { batchWeek }, + { + $set: { + status: 'released', + releasedAt: now, + releasedBy, + }, + }, + { new: true, runValidators: true }, + ).lean(); + + let snapshot = null; + const shouldRebuild = options.rebuildSnapshot !== false; + if (shouldRebuild && releasedCount > 0) { + try { + const snapResult = await rebuildWeeklySnapshot(req, { batchWeek, now }); + if (!snapResult.error) { + snapshot = { rebuilt: true, batchWeek }; + } else { + logPivot('warn', 'batch release snapshot rebuild failed', { + ...pivotRequestContext(req), + tenantKey, + batchWeek, + message: snapResult.error, + code: snapResult.code, + }); + snapshot = { rebuilt: false, error: snapResult.error, code: snapResult.code }; + } + } catch (error) { + logPivot('warn', 'batch release snapshot rebuild threw', { + ...pivotRequestContext(req), + tenantKey, + batchWeek, + message: error.message, + }); + snapshot = { rebuilt: false, error: error.message }; + } + } + + logPivot('info', 'batch released', { + ...pivotRequestContext(req), + tenantKey, + batchWeek, + releasedCount, + skippedCount, + partial: Boolean(idsResult.eventIds), + releasedBy, + }); + + return { + data: { + tenantKey, + batchWeek, + releasedCount, + skippedCount, + batchStatus: batchDoc?.status || 'released', + batch: serializePivotBatch(batchDoc), + partial: Boolean(idsResult.eventIds), + snapshot, + }, + }; +} + +/** + * Emergency pull-back: published → staged for a city week. + * Requires typed confirm token `UNRELEASE` — users may have already swiped. + * + * Optional `eventIds` limits the pull-back (partial unrelease). + * + * @param {object} req + * @param {{ tenantKey: string, batchWeek: string, confirm: string, eventIds?: string[], rebuildSnapshot?: boolean, now?: Date }} options + */ +async function unreleaseBatch(req, options = {}) { + const confirm = String(options.confirm || '').trim(); + if (confirm !== UNRELEASE_CONFIRM_TOKEN) { + return { + error: `Type ${UNRELEASE_CONFIRM_TOKEN} to confirm. Unrelease pulls published events out of the live feed; users may have already swiped.`, + status: 400, + code: 'CONFIRMATION_REQUIRED', + }; + } + + const normalized = normalizeBatchWeek(options.batchWeek, options.now); + if (normalized.error) { + return normalized; + } + + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) { + return tenantResult; + } + + const idsResult = normalizeEventIds(options.eventIds); + if (idsResult.error) { + return idsResult; + } + + const { batchWeek } = normalized; + const { tenant } = tenantResult; + const tenantKey = tenant.tenantKey; + const now = options.now || new Date(); + + const tenantReq = await openTenantDb(tenantKey); + const { Event, PivotBatch } = getModels(tenantReq, 'Event', 'PivotBatch'); + + const match = { + ...catalogWeekBaseQuery(batchWeek), + 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS, + }; + if (idsResult.eventIds) { + match._id = { $in: idsResult.eventIds }; + } + + const updateResult = await Event.updateMany(match, { + $set: { 'customFields.pivot.ingestStatus': STAGED_STATUS }, + }); + const unreleasedCount = updateResult.modifiedCount ?? updateResult.nModified ?? 0; + + let skippedCount = 0; + if (idsResult.eventIds) { + skippedCount = Math.max(0, idsResult.eventIds.length - unreleasedCount); + } + + await ensurePivotBatch(tenantReq, { + batchWeek, + status: 'curating', + targetEventCount: DEFAULT_TARGET_EVENT_COUNT, + }); + + const remainingPublished = await Event.countDocuments({ + ...catalogWeekBaseQuery(batchWeek), + 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS, + }); + + const nextStatus = remainingPublished > 0 ? 'released' : 'curating'; + const batchSet = + nextStatus === 'released' + ? { status: 'released' } + : { status: 'curating', releasedAt: null, releasedBy: null }; + + const batchDoc = await PivotBatch.findOneAndUpdate( + { batchWeek }, + { $set: batchSet }, + { new: true, runValidators: true }, + ).lean(); + + let snapshot = null; + const shouldRebuild = options.rebuildSnapshot !== false; + if (shouldRebuild && unreleasedCount > 0) { + try { + const snapResult = await rebuildWeeklySnapshot(req, { batchWeek, now }); + if (!snapResult.error) { + snapshot = { rebuilt: true, batchWeek }; + } else { + snapshot = { rebuilt: false, error: snapResult.error, code: snapResult.code }; + } + } catch (error) { + snapshot = { rebuilt: false, error: error.message }; + } + } + + logPivot('info', 'batch unreleased', { + ...pivotRequestContext(req), + tenantKey, + batchWeek, + unreleasedCount, + skippedCount, + remainingPublished, + batchStatus: nextStatus, + partial: Boolean(idsResult.eventIds), + }); + + return { + data: { + tenantKey, + batchWeek, + unreleasedCount, + skippedCount, + batchStatus: batchDoc?.status || nextStatus, + batch: serializePivotBatch(batchDoc), + remainingPublished, + partial: Boolean(idsResult.eventIds), + snapshot, + warning: + 'Unrelease removes events from the live feed. Users who already swiped may retain intents for those events.', + }, + }; +} + +module.exports = { + releaseBatch, + unreleaseBatch, + UNRELEASE_CONFIRM_TOKEN, + normalizeEventIds, + resolveReleasedBy, +}; diff --git a/backend/services/pivotBatchService.js b/backend/services/pivotBatchService.js new file mode 100644 index 00000000..6cc903c4 --- /dev/null +++ b/backend/services/pivotBatchService.js @@ -0,0 +1,97 @@ +const getModels = require('./getModelService'); +const { isValidIsoWeek } = require('../utilities/pivotIsoWeek'); +const pivotBatchSchema = require('../schemas/pivotBatch'); + +const PIVOT_BATCH_STATUSES = pivotBatchSchema.PIVOT_BATCH_STATUSES; +const DEFAULT_TARGET_EVENT_COUNT = 40; + +function invalidBatchWeek() { + return { + error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).', + status: 400, + code: 'INVALID_BATCH_WEEK', + }; +} + +/** + * Upsert a PivotBatch for the city DB on first curation / release activity. + * Call with `{ db }` after `connectToDatabase(tenantKey)` (or mobile `req`). + * + * @param {{ db: import('mongoose').Connection }} reqLike + * @param {{ batchWeek: string, status?: string, targetEventCount?: number }} options + */ +async function ensurePivotBatch(reqLike, options = {}) { + const batchWeek = String(options.batchWeek || '').trim(); + if (!isValidIsoWeek(batchWeek)) { + return invalidBatchWeek(); + } + + const status = options.status || 'curating'; + if (!PIVOT_BATCH_STATUSES.includes(status)) { + return { + error: 'status must be curating, ready, or released.', + status: 400, + code: 'INVALID_BATCH_STATUS', + }; + } + + const { PivotBatch } = getModels(reqLike, 'PivotBatch'); + const setOnInsert = { + batchWeek, + status, + targetEventCount: + options.targetEventCount != null + ? options.targetEventCount + : DEFAULT_TARGET_EVENT_COUNT, + releasedAt: null, + releasedBy: null, + }; + + const doc = await PivotBatch.findOneAndUpdate( + { batchWeek }, + { $setOnInsert: setOnInsert }, + { upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true }, + ).lean(); + + return { data: serializePivotBatch(doc) }; +} + +/** + * @param {{ db: import('mongoose').Connection }} reqLike + * @param {string} batchWeek + */ +async function getPivotBatch(reqLike, batchWeek) { + const week = String(batchWeek || '').trim(); + if (!isValidIsoWeek(week)) { + return invalidBatchWeek(); + } + + const { PivotBatch } = getModels(reqLike, 'PivotBatch'); + const doc = await PivotBatch.findOne({ batchWeek: week }).lean(); + if (!doc) { + return { data: null }; + } + return { data: serializePivotBatch(doc) }; +} + +function serializePivotBatch(doc) { + if (!doc) return null; + return { + id: String(doc._id), + batchWeek: doc.batchWeek, + status: doc.status, + targetEventCount: doc.targetEventCount ?? DEFAULT_TARGET_EVENT_COUNT, + releasedAt: doc.releasedAt || null, + releasedBy: doc.releasedBy || null, + createdAt: doc.createdAt || null, + updatedAt: doc.updatedAt || null, + }; +} + +module.exports = { + DEFAULT_TARGET_EVENT_COUNT, + ensurePivotBatch, + getPivotBatch, + serializePivotBatch, + PIVOT_BATCH_STATUSES, +}; diff --git a/backend/services/pivotCurationJobService.js b/backend/services/pivotCurationJobService.js new file mode 100644 index 00000000..22d21878 --- /dev/null +++ b/backend/services/pivotCurationJobService.js @@ -0,0 +1,292 @@ +const mongoose = require('mongoose'); +const getGlobalModels = require('./getGlobalModelService'); +const { resolvePivotTenant } = require('./pivotIngestPublishService'); +const { normalizeUrl } = require('./pivotIngestPreviewService'); +const { + CURATION_PROVIDERS, + BATCH_WEEK_STRATEGIES, +} = require('../schemas/pivotCurationJob'); + +function actorFromReq(req) { + return req?.user?.email || req?.user?.globalUserId || req?.user?.userId || null; +} + +function serializeCurationJob(doc) { + const row = doc?.toObject ? doc.toObject() : doc; + return { + _id: String(row._id), + tenantKey: row.tenantKey, + label: row.label, + url: row.url || null, + provider: row.provider, + defaultBatchWeekStrategy: row.defaultBatchWeekStrategy || 'next-drop', + defaultTags: Array.isArray(row.defaultTags) ? row.defaultTags : [], + enabled: row.enabled !== false, + lastRunAt: row.lastRunAt || null, + lastRunStatus: row.lastRunStatus || null, + lastRunStats: row.lastRunStats || null, + createdBy: row.createdBy || null, + createdAt: row.createdAt || null, + updatedAt: row.updatedAt || null, + }; +} + +function normalizeTags(raw) { + if (raw == null) return []; + if (!Array.isArray(raw)) { + return { error: 'defaultTags must be an array of strings.', status: 400, code: 'INVALID_TAGS' }; + } + return raw.map((tag) => String(tag || '').trim()).filter(Boolean); +} + +function normalizeProvider(raw) { + const provider = String(raw || '').trim().toLowerCase(); + if (!CURATION_PROVIDERS.includes(provider)) { + return { + error: `provider must be one of: ${CURATION_PROVIDERS.join(', ')}.`, + status: 400, + code: 'INVALID_PROVIDER', + }; + } + return { provider }; +} + +function normalizeStrategy(raw, { required = false } = {}) { + if (raw == null || raw === '') { + if (required) { + return { + error: `defaultBatchWeekStrategy must be one of: ${BATCH_WEEK_STRATEGIES.join(', ')}.`, + status: 400, + code: 'INVALID_STRATEGY', + }; + } + return { strategy: undefined }; + } + const strategy = String(raw).trim().toLowerCase(); + if (!BATCH_WEEK_STRATEGIES.includes(strategy)) { + return { + error: `defaultBatchWeekStrategy must be one of: ${BATCH_WEEK_STRATEGIES.join(', ')}.`, + status: 400, + code: 'INVALID_STRATEGY', + }; + } + return { strategy }; +} + +/** + * Validate URL + provider pairing. + * - partiful/luma: require allowlisted host URL; provider must match detected host. + * - manual-json: URL optional; if present must be http(s) (no Partiful/Luma host requirement). + */ +function validateJobUrlAndProvider({ url, provider }) { + const providerResult = normalizeProvider(provider); + if (providerResult.error) return providerResult; + const { provider: normalizedProvider } = providerResult; + + if (normalizedProvider === 'manual-json') { + const trimmed = url == null ? '' : String(url).trim(); + if (!trimmed) { + return { url: null, provider: normalizedProvider }; + } + let parsed; + try { + parsed = new URL(trimmed); + } catch { + return { error: 'Invalid URL.', status: 400, code: 'INVALID_URL' }; + } + if (!['http:', 'https:'].includes(parsed.protocol)) { + return { error: 'Only HTTP(S) URLs are supported.', status: 400, code: 'INVALID_URL' }; + } + return { url: parsed.toString(), provider: normalizedProvider }; + } + + const normalized = normalizeUrl(url); + if (normalized.error) { + return { + error: normalized.error, + status: normalized.status || 400, + code: normalized.code || 'INVALID_URL', + }; + } + if (normalized.provider && normalized.provider !== normalizedProvider) { + return { + error: `URL host is ${normalized.provider}, but provider was set to ${normalizedProvider}.`, + status: 400, + code: 'PROVIDER_MISMATCH', + }; + } + return { url: normalized.url, provider: normalizedProvider }; +} + +function parseJobId(jobId) { + const id = String(jobId || '').trim(); + if (!id || !mongoose.Types.ObjectId.isValid(id)) { + return { error: 'Invalid curation job id.', status: 400, code: 'INVALID_JOB_ID' }; + } + return { jobId: id }; +} + +async function listCurationJobs(req, options = {}) { + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) return tenantResult; + + const tenantKey = tenantResult.tenant.tenantKey; + const { PivotCurationJob } = getGlobalModels(req, 'PivotCurationJob'); + const docs = await PivotCurationJob.find({ tenantKey }).sort({ createdAt: -1 }).lean(); + + return { + data: { + tenantKey, + jobs: docs.map(serializeCurationJob), + }, + }; +} + +async function createCurationJob(req, options = {}) { + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) return tenantResult; + + const tenantKey = tenantResult.tenant.tenantKey; + const label = String(options.label || '').trim(); + if (!label) { + return { error: 'label is required.', status: 400, code: 'LABEL_REQUIRED' }; + } + + const providerInput = + options.provider || + (options.url ? undefined : 'manual-json'); + + let provider = providerInput; + if (!provider && options.url) { + const detected = normalizeUrl(options.url); + if (detected.error) { + return { + error: detected.error, + status: detected.status || 400, + code: detected.code || 'INVALID_URL', + }; + } + provider = detected.provider; + } + if (!provider) { + return { + error: `provider must be one of: ${CURATION_PROVIDERS.join(', ')}.`, + status: 400, + code: 'INVALID_PROVIDER', + }; + } + + const urlResult = validateJobUrlAndProvider({ url: options.url, provider }); + if (urlResult.error) return urlResult; + + const strategyResult = normalizeStrategy( + options.defaultBatchWeekStrategy ?? 'next-drop', + { required: true }, + ); + if (strategyResult.error) return strategyResult; + + const tagsResult = normalizeTags(options.defaultTags); + if (tagsResult.error) return tagsResult; + + const enabled = options.enabled === undefined ? true : Boolean(options.enabled); + + const { PivotCurationJob } = getGlobalModels(req, 'PivotCurationJob'); + const doc = await PivotCurationJob.create({ + tenantKey, + label, + url: urlResult.url, + provider: urlResult.provider, + defaultBatchWeekStrategy: strategyResult.strategy, + defaultTags: tagsResult, + enabled, + createdBy: actorFromReq(req), + }); + + return { data: { job: serializeCurationJob(doc) } }; +} + +async function updateCurationJob(req, options = {}) { + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) return tenantResult; + + const idResult = parseJobId(options.jobId); + if (idResult.error) return idResult; + + const tenantKey = tenantResult.tenant.tenantKey; + const { PivotCurationJob } = getGlobalModels(req, 'PivotCurationJob'); + const doc = await PivotCurationJob.findOne({ _id: idResult.jobId, tenantKey }); + if (!doc) { + return { error: 'Curation job not found.', status: 404, code: 'JOB_NOT_FOUND' }; + } + + if (options.label !== undefined) { + const label = String(options.label || '').trim(); + if (!label) { + return { error: 'label cannot be empty.', status: 400, code: 'LABEL_REQUIRED' }; + } + doc.label = label; + } + + const nextProvider = options.provider !== undefined ? options.provider : doc.provider; + const nextUrl = options.url !== undefined ? options.url : doc.url; + if (options.provider !== undefined || options.url !== undefined) { + const urlResult = validateJobUrlAndProvider({ url: nextUrl, provider: nextProvider }); + if (urlResult.error) return urlResult; + doc.url = urlResult.url; + doc.provider = urlResult.provider; + } + + if (options.defaultBatchWeekStrategy !== undefined) { + const strategyResult = normalizeStrategy(options.defaultBatchWeekStrategy, { required: true }); + if (strategyResult.error) return strategyResult; + doc.defaultBatchWeekStrategy = strategyResult.strategy; + } + + if (options.defaultTags !== undefined) { + const tagsResult = normalizeTags(options.defaultTags); + if (tagsResult.error) return tagsResult; + doc.defaultTags = tagsResult; + } + + if (options.enabled !== undefined) { + doc.enabled = Boolean(options.enabled); + } + + await doc.save(); + return { data: { job: serializeCurationJob(doc) } }; +} + +/** + * Delete is idempotent: missing jobs still return success for the tenant scope. + */ +async function deleteCurationJob(req, options = {}) { + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) return tenantResult; + + const idResult = parseJobId(options.jobId); + if (idResult.error) return idResult; + + const tenantKey = tenantResult.tenant.tenantKey; + const { PivotCurationJob } = getGlobalModels(req, 'PivotCurationJob'); + const doc = await PivotCurationJob.findOneAndDelete({ + _id: idResult.jobId, + tenantKey, + }); + + return { + data: { + tenantKey, + jobId: idResult.jobId, + deleted: Boolean(doc), + }, + }; +} + +module.exports = { + listCurationJobs, + createCurationJob, + updateCurationJob, + deleteCurationJob, + serializeCurationJob, + validateJobUrlAndProvider, +}; diff --git a/backend/services/pivotCurationRunService.js b/backend/services/pivotCurationRunService.js new file mode 100644 index 00000000..ab77c136 --- /dev/null +++ b/backend/services/pivotCurationRunService.js @@ -0,0 +1,668 @@ +const mongoose = require('mongoose'); +const { connectToDatabase, connectToGlobalDatabase } = require('../connectionsManager'); +const getGlobalModels = require('./getGlobalModelService'); +const { resolvePivotTenant, publishIngestEvent } = require('./pivotIngestPublishService'); +const { + previewIngestUrl, + MAX_CRAWL_BATCH_EVENTS, + resolveBatchLimit, +} = require('./pivotIngestPreviewService'); +const { normalizeBatchWeek } = require('./pivotWeeklySnapshotService'); +const { ensurePivotBatch } = require('./pivotBatchService'); +const { toIsoWeek, shiftIsoWeek } = require('../utilities/pivotIsoWeek'); +const { resolvePivotDropInstant } = require('../utilities/pivotDropSchedule'); +const { logPivot } = require('../utilities/pivotLogger'); + +const MAX_FAILURES_STORED = 50; + +function actorFromReq(req) { + return req?.user?.email || req?.user?.globalUserId || req?.user?.userId || null; +} + +function parseJobId(jobId) { + const id = String(jobId || '').trim(); + if (!id || !mongoose.Types.ObjectId.isValid(id)) { + return { error: 'Invalid curation job id.', status: 400, code: 'INVALID_JOB_ID' }; + } + return { jobId: id }; +} + +function parseRunId(runId) { + const id = String(runId || '').trim(); + if (!id || !mongoose.Types.ObjectId.isValid(id)) { + return { error: 'Invalid curation run id.', status: 400, code: 'INVALID_RUN_ID' }; + } + return { runId: id }; +} + +function serializeCurationRun(doc) { + const row = doc?.toObject ? doc.toObject() : doc; + return { + _id: String(row._id), + tenantKey: row.tenantKey, + jobId: String(row.jobId), + batchWeek: row.batchWeek, + forceBatchWeek: Boolean(row.forceBatchWeek), + status: row.status, + maxEvents: row.maxEvents ?? null, + provider: row.provider || null, + url: row.url || null, + startedAt: row.startedAt || null, + finishedAt: row.finishedAt || null, + stats: { + discovered: row.stats?.discovered || 0, + upserted: row.stats?.upserted || 0, + skipped: row.stats?.skipped || 0, + failed: row.stats?.failed || 0, + updated: row.stats?.updated || 0, + byBatchWeek: row.stats?.byBatchWeek || null, + message: row.stats?.message || null, + }, + failures: Array.isArray(row.failures) + ? row.failures.map((f) => ({ + sourceUrl: f.sourceUrl || null, + name: f.name || null, + message: f.message || null, + code: f.code || null, + })) + : [], + error: row.error || null, + errorCode: row.errorCode || null, + createdBy: row.createdBy || null, + createdAt: row.createdAt || null, + updatedAt: row.updatedAt || null, + }; +} + +function emptyStats(message = null) { + return { + discovered: 0, + upserted: 0, + skipped: 0, + failed: 0, + updated: 0, + byBatchWeek: null, + message, + }; +} + +/** + * Resolve batchWeek for a run. + * Prefer explicit body batchWeek; else job strategy (next-drop | current-iso | explicit). + */ +function resolveRunBatchWeek({ batchWeek, strategy, tenant, now = new Date() }) { + if (batchWeek != null && String(batchWeek).trim()) { + return normalizeBatchWeek(batchWeek, now); + } + + const resolvedStrategy = strategy || 'next-drop'; + if (resolvedStrategy === 'current-iso') { + return { batchWeek: toIsoWeek(now) }; + } + if (resolvedStrategy === 'explicit') { + return { + error: 'batchWeek is required when defaultBatchWeekStrategy is explicit.', + status: 400, + code: 'BATCH_WEEK_REQUIRED', + }; + } + + // next-drop: ISO week of the next upcoming drop instant (or current week if drop is later today). + const currentWeek = toIsoWeek(now); + try { + const currentDrop = resolvePivotDropInstant(tenant, currentWeek, now); + if (currentDrop.dropAt.getTime() > now.getTime()) { + return { batchWeek: currentWeek }; + } + return { batchWeek: shiftIsoWeek(currentWeek, 1) }; + } catch { + return { batchWeek: shiftIsoWeek(currentWeek, 1) }; + } +} + +function resolveMaxEvents(raw) { + // Default: no artificial cap — take every event found in the page HTML. + if (raw == null || raw === '') { + return { maxEvents: null }; + } + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) { + return { + error: 'maxEvents must be a positive number.', + status: 400, + code: 'INVALID_MAX_EVENTS', + }; + } + return { maxEvents: resolveBatchLimit(n) }; +} + +async function buildWorkerReq(tenantKey, createdBy) { + const [globalDb, db] = await Promise.all([ + connectToGlobalDatabase(), + connectToDatabase(tenantKey), + ]); + return { + globalDb, + db, + school: tenantKey, + user: createdBy ? { email: createdBy } : {}, + }; +} + +async function updateRunDoc(reqLike, runId, patch) { + const { PivotCurationRun } = getGlobalModels(reqLike, 'PivotCurationRun'); + return PivotCurationRun.findByIdAndUpdate( + runId, + { $set: patch }, + { new: true, runValidators: true }, + ).lean(); +} + +async function syncJobLastRun(reqLike, jobId, { status, stats, finishedAt }) { + const { PivotCurationJob } = getGlobalModels(reqLike, 'PivotCurationJob'); + await PivotCurationJob.findByIdAndUpdate(jobId, { + $set: { + lastRunAt: finishedAt || new Date(), + lastRunStatus: status, + lastRunStats: { + discovered: stats.discovered || 0, + upserted: stats.upserted || 0, + skipped: stats.skipped || 0, + failed: stats.failed || 0, + message: stats.message || null, + }, + }, + }); +} + +function pickIngestStatus(defaultTags) { + return Array.isArray(defaultTags) && defaultTags.length > 0 ? 'staged' : 'draft'; +} + +/** + * Upsert one discovered explore draft into the city catalog. + * Default: batchWeek from the event's start date (one crawl can fill many weeks). + * Override: forceBatchWeek pins every event to the run's batchWeek. + */ +async function upsertDiscoveredEntry( + req, + { tenantKey, batchWeek, forceBatchWeek = false, entry, defaultTags }, +) { + const draft = entry?.draft || {}; + const sourceUrl = entry?.sourceUrl || draft.sourceUrl || null; + if (!sourceUrl) { + return { + skipped: true, + code: 'MISSING_SOURCE_URL', + message: 'Discovered event has no source URL.', + name: draft.name || null, + sourceUrl: null, + }; + } + + const tags = Array.isArray(defaultTags) ? defaultTags : []; + const ingestStatus = pickIngestStatus(tags); + + const result = await publishIngestEvent(req, { + tenantKey, + batchWeek, + forceBatchWeek: Boolean(forceBatchWeek), + url: sourceUrl, + draft, + tagsRequired: false, + overrides: { + name: draft.name, + description: draft.description, + image: draft.image, + location: draft.location, + start_time: draft.start_time, + end_time: draft.end_time, + hostName: draft.hostName, + source: draft.source, + sourceUrl, + tags, + ingestStatus, + }, + }); + + if (result.error) { + const skipCodes = new Set([ + 'MISSING_REQUIRED_FIELDS', + 'INVALID_START_TIME', + 'DUPLICATE_EVENT', + ]); + if (skipCodes.has(result.code)) { + return { + skipped: true, + code: result.code, + message: result.error, + name: draft.name || null, + sourceUrl, + }; + } + return { + failed: true, + code: result.code || 'UPSERT_FAILED', + message: result.error, + name: draft.name || null, + sourceUrl, + }; + } + + return { + upserted: true, + updated: Boolean(result.data?.updated), + eventId: result.data?.event?._id || result.data?.event?.id || null, + batchWeek: result.data?.batchWeek || result.data?.event?.batchWeek || null, + batchWeekSource: result.data?.batchWeekSource || null, + sourceUrl, + }; +} + +async function executeCurationRun(runId) { + let workerReq; + let tenantKey; + let jobId; + + try { + const globalDb = await connectToGlobalDatabase(); + const bootstrapReq = { globalDb }; + const { PivotCurationRun, PivotCurationJob } = getGlobalModels( + bootstrapReq, + 'PivotCurationRun', + 'PivotCurationJob', + ); + + const run = await PivotCurationRun.findById(runId).lean(); + if (!run) { + logPivot('error', 'curation run missing at execute', { runId: String(runId) }); + return; + } + + tenantKey = run.tenantKey; + jobId = run.jobId; + workerReq = await buildWorkerReq(tenantKey, run.createdBy); + + const startedAt = new Date(); + await updateRunDoc(workerReq, runId, { + status: 'running', + startedAt, + error: null, + errorCode: null, + }); + + const job = await PivotCurationJob.findById(jobId).lean(); + if (!job || job.tenantKey !== tenantKey) { + const stats = emptyStats('Curation job not found.'); + const finishedAt = new Date(); + await updateRunDoc(workerReq, runId, { + status: 'failed', + finishedAt, + error: 'Curation job not found.', + errorCode: 'JOB_NOT_FOUND', + stats, + }); + return; + } + + if (job.provider === 'manual-json') { + const stats = emptyStats( + 'manual-json jobs do not support crawl runs; use Lab JSON import.', + ); + const finishedAt = new Date(); + await updateRunDoc(workerReq, runId, { + status: 'failed', + finishedAt, + error: stats.message, + errorCode: 'PROVIDER_NOT_CRAWLABLE', + stats, + }); + await syncJobLastRun(workerReq, jobId, { + status: 'failed', + stats, + finishedAt, + }); + return; + } + + if (!job.url) { + const stats = emptyStats('Job has no URL to crawl.'); + const finishedAt = new Date(); + await updateRunDoc(workerReq, runId, { + status: 'failed', + finishedAt, + error: stats.message, + errorCode: 'URL_REQUIRED', + stats, + }); + await syncJobLastRun(workerReq, jobId, { + status: 'failed', + stats, + finishedAt, + }); + return; + } + + const maxEvents = run.maxEvents != null ? run.maxEvents : null; + const preview = await previewIngestUrl(workerReq, { + url: job.url, + ...(maxEvents != null ? { maxEvents } : {}), + tenantKey, + }); + + if (preview.error) { + const stats = emptyStats(preview.error); + const finishedAt = new Date(); + await updateRunDoc(workerReq, runId, { + status: 'failed', + finishedAt, + error: preview.error, + errorCode: preview.code || 'PREVIEW_FAILED', + stats, + }); + await syncJobLastRun(workerReq, jobId, { + status: 'failed', + stats, + finishedAt, + }); + return; + } + + let entries = []; + if (preview.data?.mode === 'batch') { + entries = preview.data.drafts || []; + } else if (preview.data?.mode === 'single' && preview.data.draft) { + entries = [ + { + draft: preview.data.draft, + warnings: preview.data.warnings || [], + sourceUrl: preview.data.draft.sourceUrl || job.url, + }, + ]; + } + + const stats = emptyStats( + preview.data?.truncated + ? preview.data?.discoverSource === 'luma-discover-api' + ? `Luma discover results truncated (${preview.data.discoveredTotal || entries.length} events across ${preview.data.discoverPages || '?'} pages).` + : `Source HTML listed more events than the crawl limit (${maxEvents}); provider pagination/scroll not yet supported.` + : preview.data?.discoverSource === 'luma-discover-api' + ? `Fetched via Luma discover API (${preview.data.discoverPages || 1} page(s)).` + : null, + ); + // Note: when maxEvents is null we take every event embedded in the HTML / + // returned by the Luma discover API; Partiful explore still has no API pagination. + stats.discovered = entries.length; + stats.byBatchWeek = {}; + + await updateRunDoc(workerReq, runId, { stats }); + + const forceBatchWeek = Boolean(run.forceBatchWeek); + // When forcing, ensure the pinned week exists up front. Otherwise ensure + // each event's resolved week as we upsert. + if (forceBatchWeek) { + await ensurePivotBatch(workerReq, { + batchWeek: run.batchWeek, + status: 'curating', + }); + } + + const failures = []; + const defaultTags = Array.isArray(job.defaultTags) ? job.defaultTags : []; + const ensuredWeeks = new Set(forceBatchWeek ? [run.batchWeek] : []); + + for (const entry of entries) { + try { + const outcome = await upsertDiscoveredEntry(workerReq, { + tenantKey, + batchWeek: run.batchWeek, + forceBatchWeek, + entry, + defaultTags, + }); + + if (outcome.upserted) { + stats.upserted += 1; + if (outcome.updated) stats.updated += 1; + const week = outcome.batchWeek || run.batchWeek; + if (week) { + stats.byBatchWeek[week] = (stats.byBatchWeek[week] || 0) + 1; + if (!ensuredWeeks.has(week)) { + await ensurePivotBatch(workerReq, { + batchWeek: week, + status: 'curating', + }); + ensuredWeeks.add(week); + } + } + } else if (outcome.skipped) { + stats.skipped += 1; + if (failures.length < MAX_FAILURES_STORED) { + failures.push({ + sourceUrl: outcome.sourceUrl, + name: outcome.name, + message: outcome.message, + code: outcome.code, + }); + } + } else if (outcome.failed) { + stats.failed += 1; + if (failures.length < MAX_FAILURES_STORED) { + failures.push({ + sourceUrl: outcome.sourceUrl, + name: outcome.name, + message: outcome.message, + code: outcome.code, + }); + } + } + } catch (err) { + stats.failed += 1; + if (failures.length < MAX_FAILURES_STORED) { + failures.push({ + sourceUrl: entry?.sourceUrl || null, + name: entry?.draft?.name || null, + message: err.message || 'Unexpected upsert error.', + code: 'UPSERT_EXCEPTION', + }); + } + logPivot('warn', 'curation run entry failed', { + runId: String(runId), + tenantKey, + sourceUrl: entry?.sourceUrl || null, + error: err.message, + }); + } + + // Persist progress periodically so UI polling sees movement. + if ((stats.upserted + stats.skipped + stats.failed) % 10 === 0) { + await updateRunDoc(workerReq, runId, { stats, failures }); + } + } + + const weekKeys = Object.keys(stats.byBatchWeek || {}); + if (!forceBatchWeek && weekKeys.length > 1) { + const summary = weekKeys + .sort() + .map((w) => `${w}:${stats.byBatchWeek[w]}`) + .join(', '); + const multiMsg = `Assigned by event date across ${weekKeys.length} weeks (${summary}).`; + stats.message = stats.message ? `${stats.message} ${multiMsg}` : multiMsg; + } + + const finishedAt = new Date(); + const status = 'completed'; + await updateRunDoc(workerReq, runId, { + status, + finishedAt, + stats, + failures, + error: null, + errorCode: null, + }); + await syncJobLastRun(workerReq, jobId, { status, stats, finishedAt }); + + logPivot('info', 'curation run completed', { + runId: String(runId), + tenantKey, + batchWeek: run.batchWeek, + forceBatchWeek, + byBatchWeek: stats.byBatchWeek, + ...stats, + }); + } catch (err) { + logPivot('error', 'curation run crashed', { + runId: String(runId), + tenantKey, + error: err.message, + }); + try { + const reqLike = + workerReq || + (await buildWorkerReq(tenantKey || 'www', null)); + const stats = emptyStats(err.message || 'Curation run failed.'); + const finishedAt = new Date(); + await updateRunDoc(reqLike, runId, { + status: 'failed', + finishedAt, + error: err.message || 'Curation run failed.', + errorCode: 'RUN_CRASHED', + stats, + }); + if (jobId) { + await syncJobLastRun(reqLike, jobId, { + status: 'failed', + stats, + finishedAt, + }); + } + } catch (persistErr) { + console.error('[curation-run] failed to persist crash state:', persistErr.message); + } + } +} + +function scheduleCurationRun(runId) { + setImmediate(() => { + executeCurationRun(runId).catch((err) => { + console.error('[curation-run] executeCurationRun error:', err); + }); + }); +} + +async function startCurationJobRun(req, options = {}) { + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) return tenantResult; + + const idResult = parseJobId(options.jobId); + if (idResult.error) return idResult; + + const tenantKey = tenantResult.tenant.tenantKey; + const { PivotCurationJob, PivotCurationRun } = getGlobalModels( + req, + 'PivotCurationJob', + 'PivotCurationRun', + ); + + const job = await PivotCurationJob.findOne({ + _id: idResult.jobId, + tenantKey, + }).lean(); + if (!job) { + return { error: 'Curation job not found.', status: 404, code: 'JOB_NOT_FOUND' }; + } + if (job.enabled === false) { + return { error: 'Curation job is disabled.', status: 400, code: 'JOB_DISABLED' }; + } + if (job.provider === 'manual-json') { + return { + error: 'manual-json jobs cannot be crawled; use Lab JSON import.', + status: 400, + code: 'PROVIDER_NOT_CRAWLABLE', + }; + } + if (!job.url) { + return { error: 'Job has no URL to crawl.', status: 400, code: 'URL_REQUIRED' }; + } + + const weekResult = resolveRunBatchWeek({ + batchWeek: options.batchWeek, + strategy: job.defaultBatchWeekStrategy, + tenant: tenantResult.tenant, + now: options.now, + }); + if (weekResult.error) return weekResult; + + const forceBatchWeek = Boolean(options.forceBatchWeek); + + const maxResult = resolveMaxEvents(options.maxEvents); + if (maxResult.error) return maxResult; + const maxEvents = maxResult.maxEvents; + + const createdBy = actorFromReq(req); + const runDoc = await PivotCurationRun.create({ + tenantKey, + jobId: job._id, + batchWeek: weekResult.batchWeek, + forceBatchWeek, + status: 'queued', + maxEvents, + provider: job.provider, + url: job.url, + createdBy, + stats: emptyStats( + forceBatchWeek + ? `All events forced into ${weekResult.batchWeek}.` + : 'Events assigned to the ISO week of their start date.', + ), + failures: [], + }); + + await PivotCurationJob.findByIdAndUpdate(job._id, { + $set: { + lastRunAt: new Date(), + lastRunStatus: 'queued', + lastRunStats: emptyStats(), + }, + }); + + scheduleCurationRun(runDoc._id); + + return { + data: { + run: serializeCurationRun(runDoc), + }, + }; +} + +async function getCurationRun(req, options = {}) { + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) return tenantResult; + + const idResult = parseRunId(options.runId); + if (idResult.error) return idResult; + + const tenantKey = tenantResult.tenant.tenantKey; + const { PivotCurationRun } = getGlobalModels(req, 'PivotCurationRun'); + const doc = await PivotCurationRun.findOne({ + _id: idResult.runId, + tenantKey, + }).lean(); + + if (!doc) { + return { error: 'Curation run not found.', status: 404, code: 'RUN_NOT_FOUND' }; + } + + return { data: { run: serializeCurationRun(doc) } }; +} + +module.exports = { + startCurationJobRun, + getCurationRun, + executeCurationRun, + scheduleCurationRun, + serializeCurationRun, + resolveRunBatchWeek, + upsertDiscoveredEntry, + MAX_CRAWL_BATCH_EVENTS, +}; diff --git a/backend/services/pivotFeedService.js b/backend/services/pivotFeedService.js index cbb1a912..e80febb4 100644 --- a/backend/services/pivotFeedService.js +++ b/backend/services/pivotFeedService.js @@ -13,6 +13,7 @@ const { resolvePivotCoverImageUrl, } = require('../utilities/pivotMovieMetadata'); const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger'); +const { PIVOT_FEED_INGEST_STATUS } = require('../utilities/pivotIngestStatus'); const FRIEND_CAP = 5; const PIVOT_EVENT_STATUSES = ['approved', 'not-applicable']; @@ -507,9 +508,10 @@ async function getPivotFeed(req, options = {}) { const { Event } = getModels(req, 'Event'); const excludeEventIds = normalizeExcludeEventIds(options.excludeEventIds); + // Choice A: draft/staged never appear; only published after explicit Release. const query = { 'customFields.pivot.batchWeek': batchWeek, - 'customFields.pivot.ingestStatus': 'published', + 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS, status: { $in: PIVOT_EVENT_STATUSES }, isDeleted: { $ne: true }, 'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] }, @@ -624,7 +626,7 @@ async function getPivotEventFriends(req, eventId) { const { Event } = getModels(req, 'Event'); const event = await Event.findOne({ _id: eventKey, - 'customFields.pivot.ingestStatus': 'published', + 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS, status: { $in: PIVOT_EVENT_STATUSES }, isDeleted: { $ne: true }, 'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] }, diff --git a/backend/services/pivotFeedbackService.js b/backend/services/pivotFeedbackService.js index dbbaa492..feea64dc 100644 --- a/backend/services/pivotFeedbackService.js +++ b/backend/services/pivotFeedbackService.js @@ -6,6 +6,7 @@ const { serializeRecapEvent, } = require('./pivotIntentService'); const { resolveDisplayHost, PIVOT_EVENT_STATUSES } = require('./pivotFeedService'); +const { PIVOT_FEED_INGEST_STATUS } = require('../utilities/pivotIngestStatus'); const PIVOT_EVENT_FEATURE = 'pivot_event'; const RECAP_EVENT_FIELDS = @@ -56,7 +57,7 @@ async function getPendingEventFeedback(req, options = {}) { const events = await Event.find({ _id: { $in: eventIds }, end_time: { $lt: now }, - 'customFields.pivot.ingestStatus': 'published', + 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS, status: { $in: PIVOT_EVENT_STATUSES }, isDeleted: { $ne: true }, 'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] }, diff --git a/backend/services/pivotIngestDuplicateService.js b/backend/services/pivotIngestDuplicateService.js index 4cc7c6f9..9fdd877e 100644 --- a/backend/services/pivotIngestDuplicateService.js +++ b/backend/services/pivotIngestDuplicateService.js @@ -106,7 +106,7 @@ function findCatalogDuplicate(index, candidate) { if (fingerprint) { const byFingerprint = index.find((row) => row.fingerprint && row.fingerprint === fingerprint); if (byFingerprint) { - return duplicateSummary(byFingerprint, 'fingerprint'); + return duplicateSummary(byFingerprint, 'fingerprint', { willUpdate: true }); } } @@ -178,7 +178,7 @@ function formatDuplicateWarning(duplicate, candidateName) { return `${label} already exists in catalog and will update the existing row.`; } if (duplicate.matchType === 'fingerprint') { - return `${label} looks like a duplicate of "${duplicate.existingName}" (same title, time, and location).`; + return `${label} matches existing "${duplicate.existingName}" (same title, time, and location) — publishing will update it.`; } if (duplicate.matchType === 'batchSourceUrl') { return `${label} duplicates another row in this import batch (same source URL).`; @@ -191,8 +191,23 @@ function formatDuplicateWarning(duplicate, candidateName) { function isBlockingDuplicate(duplicate) { if (!duplicate) return false; - if (duplicate.matchType === 'sourceUrl') return false; - return true; + // sourceUrl and fingerprint matches resolve against an existing catalog event, so + // publishing updates it in place. Only collisions between two rows of the same import + // batch have nothing to update against and must block. + return ( + duplicate.matchType === 'batchSourceUrl' || duplicate.matchType === 'batchFingerprint' + ); +} + +async function annotateImportDuplicates(req, options = {}) { + const tenantKey = options.tenantKey?.trim()?.toLowerCase(); + const drafts = Array.isArray(options.drafts) ? options.drafts : []; + if (!tenantKey || !drafts.length) { + return { drafts, duplicateWarnings: [] }; + } + + const catalogIndex = await loadCatalogDuplicateIndex(tenantKey); + return annotateImportDrafts(drafts, catalogIndex); } async function resolveImportDuplicate(req, { tenantKey, candidate }) { @@ -212,6 +227,7 @@ module.exports = { loadCatalogDuplicateIndex, findCatalogDuplicate, annotateImportDrafts, + annotateImportDuplicates, formatDuplicateWarning, isBlockingDuplicate, resolveImportDuplicate, diff --git a/backend/services/pivotIngestPreviewService.js b/backend/services/pivotIngestPreviewService.js index 3e9f55e2..66dba95d 100644 --- a/backend/services/pivotIngestPreviewService.js +++ b/backend/services/pivotIngestPreviewService.js @@ -8,10 +8,25 @@ const { } = require('./pivotIngestDuplicateService'); const FETCH_TIMEOUT_MS = 10_000; -const MAX_BATCH_EVENTS = 50; -const MAX_BATCH_ENRICH = MAX_BATCH_EVENTS; +/** + * Optional hard ceiling only when callers pass an explicit `maxEvents`. + * Default (null / omitted) = take every event found in the page HTML / API. + */ +const MAX_BATCH_EVENTS_CEILING = 10_000; +/** @deprecated Unlimited by default; kept for older imports that expect a number export. */ +const DEFAULT_PREVIEW_BATCH_LIMIT = null; +/** @deprecated Unlimited by default; prefer omitting maxEvents. */ +const MAX_CRAWL_BATCH_EVENTS = null; +/** @deprecated Alias of DEFAULT_PREVIEW_BATCH_LIMIT. */ +const MAX_BATCH_EVENTS = DEFAULT_PREVIEW_BATCH_LIMIT; const HOST_ENRICH_CONCURRENCY = 4; +/** Unauthenticated Luma city/category discover API (paginated JSON). */ +const LUMA_DISCOVER_API_URL = 'https://api.luma.com/discover/get-paginated-events'; +const LUMA_DISCOVER_PAGE_SIZE = 20; +/** Safety rail against runaway pagination. */ +const MAX_LUMA_DISCOVER_PAGES = 100; + const ALLOWED_HOST_SUFFIXES = ['partiful.com', 'lu.ma', 'luma.com']; const PROVIDER_LABELS = { @@ -19,6 +34,44 @@ const PROVIDER_LABELS = { luma: 'Luma', }; +const LUMA_RESERVED_SLUGS = new Set([ + 'user', + 'discover', + 'signin', + 'signup', + 'home', + 'login', + 'e', + 'event', + 'calendar', + 'create', + 'settings', +]); + +/** + * @param {unknown} maxEvents + * @returns {number|null} Positive limit, or null for no artificial cap. + */ +function resolveBatchLimit(maxEvents) { + if (maxEvents == null || maxEvents === '') { + return null; + } + const n = Number(maxEvents); + if (!Number.isFinite(n) || n <= 0) { + return null; + } + return Math.min(Math.floor(n), MAX_BATCH_EVENTS_CEILING); +} + +function sliceToBatchLimit(items, limit) { + if (limit == null) return items; + return items.slice(0, limit); +} + +function isBatchTruncated(discoveredTotal, limit) { + return limit != null && discoveredTotal > limit; +} + function decodeHtmlEntities(value) { if (!value) return value; return value @@ -634,15 +687,19 @@ function extractPartifulExploreEvents(html) { return events; } -function parsePartifulExploreBatch(html, sourceUrl) { - const events = extractPartifulExploreEvents(html).slice(0, MAX_BATCH_EVENTS); +function parsePartifulExploreBatch(html, sourceUrl, options = {}) { + const limit = resolveBatchLimit(options.maxEvents); + const allEvents = extractPartifulExploreEvents(html); + const events = sliceToBatchLimit(allEvents, limit); const drafts = events.map((event) => buildPartifulExploreDraft(event)); const listLabel = extractMetaContent(html, 'og:title') || 'Partiful explore'; return { listLabel, drafts, - truncated: extractPartifulExploreEvents(html).length > MAX_BATCH_EVENTS, + truncated: isBatchTruncated(allEvents.length, limit), + discoveredTotal: allEvents.length, + limit, }; } @@ -815,38 +872,231 @@ function buildLumaDiscoverDraftFromNextData(entry) { return { draft, warnings: draftWarnings(draft), sourceUrl }; } -function parseLumaDiscoverBatch(html, sourceUrl) { +/** + * City/category slug from a Luma discover URL (`https://luma.com/sf` → `sf`). + * Calendar/event paths return null (not discover-API scoped). + */ +function extractLumaDiscoverSlug(parsedOrUrl) { + let parsed = parsedOrUrl; + if (typeof parsedOrUrl === 'string') { + try { + parsed = new URL(parsedOrUrl); + } catch { + return null; + } + } + if (!parsed?.pathname) return null; + const path = parsed.pathname.replace(/\/+$/, '') || '/'; + const match = path.match(/^\/([^/]+)$/i); + if (!match) return null; + const slug = decodeURIComponent(match[1]).trim().toLowerCase(); + if (!slug || LUMA_RESERVED_SLUGS.has(slug)) return null; + return slug; +} + +async function fetchLumaDiscoverApiPage({ + slug, + cursor = null, + pageSize = LUMA_DISCOVER_PAGE_SIZE, + latitude = null, + longitude = null, +} = {}) { + if (!slug) { + return { + error: 'Luma discover slug is required.', + status: 400, + code: 'INVALID_LUMA_SLUG', + }; + } + + const params = { + slug, + pagination_limit: Math.min( + Math.max(1, Number(pageSize) || LUMA_DISCOVER_PAGE_SIZE), + LUMA_DISCOVER_PAGE_SIZE, + ), + }; + if (cursor) params.pagination_cursor = cursor; + if (latitude != null && longitude != null && latitude !== '' && longitude !== '') { + params.latitude = latitude; + params.longitude = longitude; + } + + try { + const response = await axios.get(LUMA_DISCOVER_API_URL, { + params, + timeout: FETCH_TIMEOUT_MS, + headers: { + 'User-Agent': + 'MeridianPivotLab/1.0 (+https://meridian.study; event ingest preview)', + Accept: 'application/json', + }, + validateStatus: (status) => status >= 200 && status < 500, + }); + + if (response.status >= 400 || response.data?.message) { + return { + error: + response.data?.message || + `Luma discover API returned ${response.status}.`, + status: response.status >= 400 ? response.status : 422, + code: response.data?.code || 'LUMA_DISCOVER_FAILED', + }; + } + + return { + entries: Array.isArray(response.data?.entries) ? response.data.entries : [], + hasMore: Boolean(response.data?.has_more), + nextCursor: response.data?.next_cursor || null, + }; + } catch (err) { + if (err.code === 'ECONNABORTED') { + return { + error: 'Luma discover API timed out.', + status: 504, + code: 'FETCH_TIMEOUT', + }; + } + return { + error: err.message || 'Failed to reach Luma discover API.', + status: 502, + code: 'LUMA_DISCOVER_UNREACHABLE', + }; + } +} + +/** + * Paginate Luma's public discover API for a city/category slug until exhausted + * (or optional maxEvents). Falls through to HTML scrape when this returns empty/error. + */ +async function fetchLumaDiscoverApiBatch(options = {}) { + const slug = String(options.slug || '') + .trim() + .toLowerCase(); + if (!slug) { + return { + error: 'Luma discover slug is required.', + status: 400, + code: 'INVALID_LUMA_SLUG', + }; + } + + const limit = resolveBatchLimit(options.maxEvents); + const drafts = []; + const seen = new Set(); + let cursor = null; + let pages = 0; + let truncated = false; + let hitPageCap = false; + + while (pages < MAX_LUMA_DISCOVER_PAGES) { + if (limit != null && drafts.length >= limit) { + truncated = true; + break; + } + + const remaining = + limit == null ? LUMA_DISCOVER_PAGE_SIZE : Math.min(LUMA_DISCOVER_PAGE_SIZE, limit - drafts.length); + const page = await fetchLumaDiscoverApiPage({ + slug, + cursor, + pageSize: remaining, + latitude: options.latitude, + longitude: options.longitude, + }); + + if (page.error) { + if (pages === 0) return page; + // Partial success — return what we have. + break; + } + + pages += 1; + for (const entry of page.entries) { + if (limit != null && drafts.length >= limit) { + truncated = true; + break; + } + const built = buildLumaDiscoverDraftFromNextData(entry); + if (!built?.sourceUrl) continue; + if (seen.has(built.sourceUrl)) continue; + seen.add(built.sourceUrl); + drafts.push(built); + } + + if (truncated || !page.hasMore || !page.nextCursor) { + if (page.hasMore && limit != null && drafts.length >= limit) { + truncated = true; + } + break; + } + cursor = page.nextCursor; + } + + if (pages >= MAX_LUMA_DISCOVER_PAGES) { + hitPageCap = true; + truncated = true; + } + + return { + listLabel: options.listLabel || `Luma · ${slug}`, + drafts, + truncated, + discoveredTotal: drafts.length, + limit, + source: 'luma-discover-api', + pages, + hitPageCap, + }; +} + +function parseLumaDiscoverBatch(html, sourceUrl, options = {}) { + const limit = resolveBatchLimit(options.maxEvents); const nextData = extractLumaDiscoverEventsFromNextData(html); if (nextData.events.length) { - const drafts = nextData.events - .slice(0, MAX_BATCH_EVENTS) + const drafts = sliceToBatchLimit(nextData.events, limit) .map((entry) => buildLumaDiscoverDraftFromNextData(entry)) .filter(Boolean); return { listLabel: nextData.listLabel || extractMetaContent(html, 'og:title') || 'Luma discover', drafts, - truncated: nextData.events.length > MAX_BATCH_EVENTS, + truncated: isBatchTruncated(nextData.events.length, limit), + discoveredTotal: nextData.events.length, + limit, + source: 'luma-html', }; } const jsonLdNodes = extractJsonLdBlocks(html).flatMap(flattenJsonLdNodes); const itemList = jsonLdNodes.find((node) => hasType(node, 'ItemList')); if (!itemList?.itemListElement?.length) { - return { listLabel: null, drafts: [], truncated: false }; + return { + listLabel: null, + drafts: [], + truncated: false, + discoveredTotal: 0, + limit, + source: 'luma-html', + }; } const eventNodes = itemList.itemListElement .map((entry) => entry?.item || entry) .filter((node) => node && hasType(node, 'Event')); - const drafts = eventNodes.slice(0, MAX_BATCH_EVENTS).map((eventNode) => buildLumaDiscoverDraft(eventNode)); + const drafts = sliceToBatchLimit(eventNodes, limit).map((eventNode) => + buildLumaDiscoverDraft(eventNode), + ); const listLabel = itemList.name || extractMetaContent(html, 'og:title') || 'Luma discover'; return { listLabel, drafts, - truncated: eventNodes.length > MAX_BATCH_EVENTS, + truncated: isBatchTruncated(eventNodes.length, limit), + discoveredTotal: eventNodes.length, + limit, + source: 'luma-html', }; } @@ -869,15 +1119,17 @@ async function mapWithConcurrency(items, limit, iteratee) { return results; } -async function enrichPartifulBatchDrafts(entries) { - const toEnrich = entries - .filter((entry) => { - if (!entry.sourceUrl) return false; - const needsHost = !entry.draft.hostName; - const needsImage = isInaccessiblePartifulImage(entry.draft.image); - return needsHost || needsImage; - }) - .slice(0, MAX_BATCH_ENRICH); +async function enrichPartifulBatchDrafts(entries, options = {}) { + const enrichLimit = resolveBatchLimit( + options.maxEnrich != null ? options.maxEnrich : options.maxEvents, + ); + const needingEnrichment = entries.filter((entry) => { + if (!entry.sourceUrl) return false; + const needsHost = !entry.draft.hostName; + const needsImage = isInaccessiblePartifulImage(entry.draft.image); + return needsHost || needsImage; + }); + const toEnrich = sliceToBatchLimit(needingEnrichment, enrichLimit); await mapWithConcurrency(toEnrich, HOST_ENRICH_CONCURRENCY, async (entry) => { const fetched = await fetchEventPage(entry.sourceUrl); @@ -1060,28 +1312,64 @@ async function previewIngestUrl(_req, options = {}) { return normalized; } - const fetched = await fetchEventPage(normalized.url); - if (fetched.error) { - return fetched; - } - + const batchLimit = resolveBatchLimit(options.maxEvents); const classification = classifyIngestUrl(normalized.parsed, normalized.provider); + const parseOptions = batchLimit == null ? {} : { maxEvents: batchLimit }; let batchResult = null; + let pageHtml = null; + + // Luma city/category discover: prefer paginated JSON API over SSR HTML (~20 events). + const lumaDiscoverSlug = + normalized.provider === 'luma' && + (classification.kind === 'batch' || classification.kind === 'batch-candidate') + ? extractLumaDiscoverSlug(normalized.parsed) + : null; + + if (lumaDiscoverSlug) { + const apiBatch = await fetchLumaDiscoverApiBatch({ + slug: lumaDiscoverSlug, + maxEvents: batchLimit, + latitude: options.latitude, + longitude: options.longitude, + }); + if (!apiBatch.error && apiBatch.drafts?.length) { + batchResult = apiBatch; + } + } + + if (!batchResult) { + const fetched = await fetchEventPage(normalized.url); + if (fetched.error) { + // City discover with a failed HTML fallback still surfaces the fetch error. + return fetched; + } + pageHtml = fetched.html; - if (normalized.provider === 'partiful' && classification.kind === 'batch') { - batchResult = parsePartifulExploreBatch(fetched.html, normalized.url); - } else if (normalized.provider === 'luma') { - batchResult = parseLumaDiscoverBatch(fetched.html, normalized.url); + if (normalized.provider === 'partiful' && classification.kind === 'batch') { + batchResult = parsePartifulExploreBatch(pageHtml, normalized.url, parseOptions); + } else if (normalized.provider === 'luma') { + batchResult = parseLumaDiscoverBatch(pageHtml, normalized.url, parseOptions); + } } if (batchResult?.drafts?.length) { if (normalized.provider === 'partiful') { - await enrichPartifulBatchDrafts(batchResult.drafts); + await enrichPartifulBatchDrafts(batchResult.drafts, parseOptions); } const batchWarnings = []; if (batchResult.truncated) { - batchWarnings.push(`Only the first ${MAX_BATCH_EVENTS} events were imported from this page.`); + if (batchResult.source === 'luma-discover-api' && batchResult.hitPageCap) { + batchWarnings.push( + `Stopped after ${MAX_LUMA_DISCOVER_PAGES} Luma discover pages (${batchResult.drafts.length} events).`, + ); + } else if (batchResult.limit != null) { + batchWarnings.push( + `Only the first ${batchResult.limit} events were imported from this page.`, + ); + } else { + batchWarnings.push('Discover results were truncated.'); + } } const missingOrganizerCount = batchResult.drafts.filter((entry) => !entry.draft.hostName).length; if (missingOrganizerCount) { @@ -1100,6 +1388,10 @@ async function previewIngestUrl(_req, options = {}) { provider: normalized.provider, providerLabel: PROVIDER_LABELS[normalized.provider] || normalized.provider, truncated: batchResult.truncated, + discoveredTotal: batchResult.discoveredTotal, + limit: batchResult.limit, + discoverSource: batchResult.source || null, + discoverPages: batchResult.pages || null, }, options, ), @@ -1114,8 +1406,18 @@ async function previewIngestUrl(_req, options = {}) { }; } + // Luma single-segment URLs are batch-candidates; if discover API + HTML list + // parsing found nothing, treat the URL as a single event page. + if (!pageHtml) { + const fetched = await fetchEventPage(normalized.url); + if (fetched.error) { + return fetched; + } + pageHtml = fetched.html; + } + const { draft, warnings, providerLabel } = buildDraft({ - html: fetched.html, + html: pageHtml, provider: normalized.provider, sourceUrl: normalized.url, }); @@ -1141,9 +1443,15 @@ module.exports = { classifyIngestUrl, parsePartifulExploreBatch, parseLumaDiscoverBatch, + extractLumaDiscoverSlug, + fetchLumaDiscoverApiPage, + fetchLumaDiscoverApiBatch, extractPartifulExploreEvents, buildPartifulExploreDraft, buildLumaDiscoverDraft, + enrichPartifulBatchDrafts, + resolveBatchLimit, + sliceToBatchLimit, isInvalidHostName, firstPlausibleHostName, extractMetaContent, @@ -1153,4 +1461,11 @@ module.exports = { extractPartifulSourceTags, FETCH_TIMEOUT_MS, MAX_BATCH_EVENTS, + DEFAULT_PREVIEW_BATCH_LIMIT, + MAX_CRAWL_BATCH_EVENTS, + MAX_BATCH_EVENTS_CEILING, + LUMA_DISCOVER_API_URL, + LUMA_DISCOVER_PAGE_SIZE, + MAX_LUMA_DISCOVER_PAGES, + HOST_ENRICH_CONCURRENCY, }; diff --git a/backend/services/pivotIngestPublishService.js b/backend/services/pivotIngestPublishService.js index 77a930d9..18e2d90f 100644 --- a/backend/services/pivotIngestPublishService.js +++ b/backend/services/pivotIngestPublishService.js @@ -6,6 +6,7 @@ const { const { isPivotTenant } = require('./pivotReferralCodeService'); const { connectToDatabase } = require('../connectionsManager'); const { normalizeBatchWeek } = require('./pivotWeeklySnapshotService'); +const { resolveEventBatchWeek } = require('../utilities/pivotIsoWeek'); const { previewIngestUrl, sanitizeEventPosterImage } = require('./pivotIngestPreviewService'); const { formatDuplicateWarning, @@ -20,8 +21,16 @@ const { applyMovieListingDefaults, } = require('../utilities/pivotMovieMetadata'); const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger'); +const { + normalizeIngestStatus, + PIVOT_FEED_INGEST_STATUS, +} = require('../utilities/pivotIngestStatus'); const DEFAULT_DURATION_MS = 2 * 60 * 60 * 1000; +/** Default for new Lab / URL / JSON ingest — not live until Release (Task 3.2). */ +const DEFAULT_INGEST_STATUS = 'staged'; +/** Typed confirm for emergency ingest that writes `published` immediately. */ +const RELEASE_NOW_CONFIRM_TOKEN = 'RELEASE_NOW'; function trimString(value) { return typeof value === 'string' ? value.trim() : ''; @@ -214,7 +223,7 @@ function validateMergedDraft(merged) { }; } -function buildPivotMetadata(merged, { batchWeek, sourceUrl, importedBy, tags }) { +function buildPivotMetadata(merged, { batchWeek, sourceUrl, importedBy, tags, ingestStatus }) { const host = { name: merged.hostName, ...(merged.hostProfileUrl ? { profileUrl: merged.hostProfileUrl } : {}), @@ -228,13 +237,51 @@ function buildPivotMetadata(merged, { batchWeek, sourceUrl, importedBy, tags }) tags: tags || [], ...(merged.timeSlots?.length ? { timeSlots: merged.timeSlots } : {}), ...(merged.movie ? { movie: merged.movie } : {}), - ingestStatus: 'published', + ingestStatus: ingestStatus || DEFAULT_INGEST_STATUS, importedAt: new Date().toISOString(), importedBy, }; } -function buildEventPayload(merged, { catalogOrgId, sourceUrl, batchWeek, importedBy, tags }) { +/** + * Resolve ingestStatus for a new catalog write. + * Default: staged (hidden from feed until Release). + * Emergency: releaseNow + confirm RELEASE_NOW → published. + * Overrides may set draft|staged only (not published without releaseNow). + */ +function resolveCreateIngestStatus(options = {}, overrides = {}) { + if (options.releaseNow) { + const confirm = String(options.confirm || '').trim(); + if (confirm !== RELEASE_NOW_CONFIRM_TOKEN) { + return { + error: `Type ${RELEASE_NOW_CONFIRM_TOKEN} to confirm stage & release now. This puts the event in the live feed immediately.`, + status: 400, + code: 'CONFIRMATION_REQUIRED', + }; + } + return { ingestStatus: PIVOT_FEED_INGEST_STATUS }; + } + + if (overrides.ingestStatus !== undefined) { + const statusResult = normalizeIngestStatus(overrides.ingestStatus); + if (statusResult.error) { + return statusResult; + } + if (statusResult.ingestStatus === PIVOT_FEED_INGEST_STATUS) { + return { + error: + 'New ingest cannot set ingestStatus to published. Stage the event, then use Release — or pass releaseNow with confirm RELEASE_NOW.', + status: 400, + code: 'RELEASE_CONFIRM_REQUIRED', + }; + } + return statusResult; + } + + return { ingestStatus: DEFAULT_INGEST_STATUS }; +} + +function buildEventPayload(merged, { catalogOrgId, sourceUrl, batchWeek, importedBy, tags, ingestStatus }) { const listingUrl = trimString(sourceUrl) || null; return { name: merged.name, @@ -253,15 +300,34 @@ function buildEventPayload(merged, { catalogOrgId, sourceUrl, batchWeek, importe isDeleted: false, ...(merged.image ? { image: merged.image } : {}), customFields: { - pivot: buildPivotMetadata(merged, { batchWeek, sourceUrl, importedBy, tags }), + pivot: buildPivotMetadata(merged, { + batchWeek, + sourceUrl, + importedBy, + tags, + ingestStatus, + }), }, }; } -async function savePublishedCatalogEvent(tenantReq, eventPayload, sourceUrl) { +async function savePublishedCatalogEvent(tenantReq, eventPayload, sourceUrl, updateEventId) { const { Event } = getModels(tenantReq, 'Event'); - const listingUrl = trimString(sourceUrl); + // A fuzzy (fingerprint) duplicate resolves to a specific existing event that may have a + // different or no source URL, so update it by id rather than upserting on sourceUrl. + if (updateEventId) { + const updated = await Event.findByIdAndUpdate( + updateEventId, + { $set: eventPayload }, + { new: true, runValidators: true }, + ).lean(); + if (updated) { + return updated; + } + } + + const listingUrl = trimString(sourceUrl); if (listingUrl) { return Event.findOneAndUpdate( { 'customFields.pivot.sourceUrl': listingUrl }, @@ -275,11 +341,6 @@ async function savePublishedCatalogEvent(tenantReq, eventPayload, sourceUrl) { } async function publishIngestEvent(req, options = {}) { - const batchNormalized = normalizeBatchWeek(options.batchWeek, options.now); - if (batchNormalized.error) { - return batchNormalized; - } - const tenantResult = await resolvePivotTenant(req, options.tenantKey); if (tenantResult.error) { return tenantResult; @@ -292,7 +353,10 @@ async function publishIngestEvent(req, options = {}) { } let previewDraft = {}; - if (urlNormalized.url && urlNormalized.provider) { + if (options.draft && typeof options.draft === 'object') { + // Crawler / batch path: reuse already-parsed explore drafts (skip per-URL refetch). + previewDraft = options.draft; + } else if (urlNormalized.url && urlNormalized.provider) { const previewResult = await previewIngestUrl(req, { url: urlNormalized.url }); if (previewResult.error) { return previewResult; @@ -329,7 +393,21 @@ async function publishIngestEvent(req, options = {}) { return validated; } - const tagResult = await validatePivotEventTags(req, mergedInput.tags, { required: true }); + // Default: batchWeek from the event's actual start date. Override with forceBatchWeek. + const weekResolved = resolveEventBatchWeek({ + forceBatchWeek: options.forceBatchWeek, + batchWeek: options.batchWeek, + startTime: validated.merged.startTime || validated.merged.start_time, + timeSlots: validated.merged.timeSlots, + now: options.now, + }); + if (weekResolved.error) { + return weekResolved; + } + const resolvedBatchWeek = weekResolved.batchWeek; + + const tagsRequired = options.tagsRequired !== false; + const tagResult = await validatePivotEventTags(req, mergedInput.tags, { required: tagsRequired }); if (tagResult.error) { return tagResult; } @@ -345,6 +423,7 @@ async function publishIngestEvent(req, options = {}) { }, }); + // Batch-internal collisions (two rows of the same import) have nothing to update against. if (isBlockingDuplicate(duplicate)) { return { error: formatDuplicateWarning(duplicate, validated.merged.name), @@ -354,42 +433,105 @@ async function publishIngestEvent(req, options = {}) { }; } + // sourceUrl and fingerprint matches resolve to an existing catalog event — update it. + const updateEventId = + duplicate?.willUpdate && duplicate?.existingEventId ? duplicate.existingEventId : null; + + const ingestStatusResult = resolveCreateIngestStatus(options, overrides); + if (ingestStatusResult.error) { + return ingestStatusResult; + } + const catalogResult = await resolveCatalogOrgId(req, tenantResult.tenant); const importedBy = resolveImportedBy(req); + + const db = await connectToDatabase(tenantResult.tenant.tenantKey); + const tenantReq = { db }; + const { Event } = getModels(tenantReq, 'Event'); + + // Re-import must not demote a live published event back to staged unless ops + // explicitly asked for draft/staged or emergency releaseNow. + let ingestStatus = ingestStatusResult.ingestStatus; + if ( + updateEventId && + !options.releaseNow && + overrides.ingestStatus === undefined + ) { + const existing = await Event.findById(updateEventId) + .select('customFields.pivot.ingestStatus') + .lean(); + const existingStatus = existing?.customFields?.pivot?.ingestStatus; + if (existingStatus === PIVOT_FEED_INGEST_STATUS || existingStatus === 'draft' || existingStatus === 'staged') { + ingestStatus = existingStatus; + } + } else if ( + !updateEventId && + listingUrl && + !options.releaseNow && + overrides.ingestStatus === undefined + ) { + const existingByUrl = await Event.findOne({ + 'customFields.pivot.sourceUrl': listingUrl, + }) + .select('customFields.pivot.ingestStatus') + .lean(); + const existingStatus = existingByUrl?.customFields?.pivot?.ingestStatus; + if (existingStatus === PIVOT_FEED_INGEST_STATUS || existingStatus === 'draft' || existingStatus === 'staged') { + ingestStatus = existingStatus; + } + } + const eventPayload = buildEventPayload(validated.merged, { catalogOrgId: catalogResult.orgId, sourceUrl: listingUrl, - batchWeek: batchNormalized.batchWeek, + batchWeek: resolvedBatchWeek, importedBy, tags: tagResult.tags, + ingestStatus, }); - const db = await connectToDatabase(tenantResult.tenant.tenantKey); - const tenantReq = { db }; - const event = await savePublishedCatalogEvent(tenantReq, eventPayload, listingUrl); + const event = await savePublishedCatalogEvent(tenantReq, eventPayload, listingUrl, updateEventId); + const updatedExisting = Boolean(updateEventId); - logPivot('info', 'catalog event published', { + logPivot('info', updatedExisting ? 'catalog event updated' : 'catalog event staged', { tenantKey: tenantResult.tenant.tenantKey, - batchWeek: batchNormalized.batchWeek, + batchWeek: resolvedBatchWeek, + batchWeekSource: weekResolved.source, eventId: String(event._id), name: event.name, source: mergedInput.source, + ingestStatus, + releaseNow: Boolean(options.releaseNow), timeSlotCount: validated.merged.timeSlots?.length ?? 0, + duplicateMatch: duplicate?.matchType || null, importedBy, }); return { data: { event: serializeLabEvent(event), - created: true, + created: !updatedExisting, + updated: updatedExisting, + ingestStatus, + batchWeek: resolvedBatchWeek, + batchWeekSource: weekResolved.source, }, }; } async function publishBatchIngestEvents(req, options = {}) { - const batchNormalized = normalizeBatchWeek(options.batchWeek, options.now); - if (batchNormalized.error) { - return batchNormalized; + const forceBatchWeek = Boolean(options.forceBatchWeek); + if (forceBatchWeek) { + const batchNormalized = normalizeBatchWeek(options.batchWeek, options.now); + if (batchNormalized.error) { + return batchNormalized; + } + } else if (options.batchWeek != null && String(options.batchWeek).trim()) { + // Optional fallback week when an event has no start date. + const batchNormalized = normalizeBatchWeek(options.batchWeek, options.now); + if (batchNormalized.error) { + return batchNormalized; + } } const tenantResult = await resolvePivotTenant(req, options.tenantKey); @@ -408,16 +550,27 @@ async function publishBatchIngestEvents(req, options = {}) { const published = []; const failures = []; + let updatedCount = 0; + const batchWeekCounts = {}; for (const entry of events) { const url = trimString(entry?.url) || undefined; + const entryForce = + entry?.forceBatchWeek !== undefined + ? Boolean(entry.forceBatchWeek) + : forceBatchWeek; + const entryWeek = + entry?.batchWeek !== undefined ? entry.batchWeek : options.batchWeek; const result = await publishIngestEvent(req, { tenantKey: options.tenantKey, - batchWeek: batchNormalized.batchWeek, + batchWeek: entryWeek, + forceBatchWeek: entryForce, url, overrides: entry.overrides || {}, now: options.now, + releaseNow: options.releaseNow, + confirm: options.confirm, }); if (result.error) { @@ -425,23 +578,33 @@ async function publishBatchIngestEvents(req, options = {}) { continue; } + if (result.data.updated) { + updatedCount += 1; + } published.push(result.data.event); + const week = result.data.batchWeek || result.data.event?.batchWeek; + if (week) { + batchWeekCounts[week] = (batchWeekCounts[week] || 0) + 1; + } } if (!published.length) { return { - error: failures[0]?.message || 'Unable to publish any events.', + error: failures[0]?.message || 'Unable to stage any events.', status: 400, code: failures[0]?.code || 'BATCH_PUBLISH_FAILED', data: { published, failures }, }; } - logPivot('info', 'batch catalog publish complete', { + logPivot('info', 'batch catalog stage complete', { tenantKey: tenantResult.tenant.tenantKey, - batchWeek: batchNormalized.batchWeek, + forceBatchWeek, + batchWeekCounts, publishedCount: published.length, + updatedCount, failedCount: failures.length, + releaseNow: Boolean(options.releaseNow), }); return { @@ -449,7 +612,12 @@ async function publishBatchIngestEvents(req, options = {}) { published, failures, publishedCount: published.length, + stagedCount: published.length, + updatedCount, failedCount: failures.length, + batchWeekCounts, + forceBatchWeek, + ingestStatus: options.releaseNow ? PIVOT_FEED_INGEST_STATUS : DEFAULT_INGEST_STATUS, }, }; } @@ -548,15 +716,11 @@ async function updateIngestEvent(req, options = {}) { const pivotPatch = { ...pivot, host }; if (overrides.ingestStatus !== undefined) { - const ingestStatus = trimString(overrides.ingestStatus); - if (!['draft', 'published'].includes(ingestStatus)) { - return { - error: 'ingestStatus must be draft or published.', - status: 400, - code: 'INVALID_INGEST_STATUS', - }; + const statusResult = normalizeIngestStatus(overrides.ingestStatus); + if (statusResult.error) { + return statusResult; } - pivotPatch.ingestStatus = ingestStatus; + pivotPatch.ingestStatus = statusResult.ingestStatus; } if (overrides.batchWeek !== undefined) { @@ -668,4 +832,8 @@ module.exports = { validateMergedDraft, buildEventPayload, normalizePublishUrl, + resolvePivotTenant, + resolveCreateIngestStatus, + DEFAULT_INGEST_STATUS, + RELEASE_NOW_CONFIRM_TOKEN, }; diff --git a/backend/services/pivotIntentService.js b/backend/services/pivotIntentService.js index 02efb09d..a823cc6e 100644 --- a/backend/services/pivotIntentService.js +++ b/backend/services/pivotIntentService.js @@ -14,6 +14,7 @@ const { findTimeSlotById, eventHasTimeSlots, } = require('../utilities/pivotTimeSlots'); +const { PIVOT_FEED_INGEST_STATUS } = require('../utilities/pivotIngestStatus'); const FEED_ACTION_TO_STATUS = { interested: 'interested', @@ -38,7 +39,7 @@ async function findPublishedPivotEvent(req, eventId, { now, requireWindow } = {} const baseQuery = { _id: eventId, - 'customFields.pivot.ingestStatus': 'published', + 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS, status: { $in: PIVOT_EVENT_STATUSES }, isDeleted: { $ne: true }, 'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] }, @@ -347,7 +348,7 @@ async function getWeekRecap(req, options = {}) { const events = await Event.find({ _id: { $in: eventIds }, - 'customFields.pivot.ingestStatus': 'published', + 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS, status: { $in: PIVOT_EVENT_STATUSES }, isDeleted: { $ne: true }, 'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] }, diff --git a/backend/services/pivotLabEventsService.js b/backend/services/pivotLabEventsService.js index 08a5ae6a..65160546 100644 --- a/backend/services/pivotLabEventsService.js +++ b/backend/services/pivotLabEventsService.js @@ -58,14 +58,22 @@ function serializeLabEvent(event, intentStatsByEventId) { }; } -/** Per-event intent counts so Lab can see which catalog events earned the swipes. */ -async function loadIntentStatsByEventId(PivotEventIntent, eventIds) { +/** + * Per-event intent counts so Lab / tenant ops can see which catalog events earned the swipes. + * Optional `batchWeek` scopes intents to that ISO week (preferred for performance rankings). + */ +async function loadIntentStatsByEventId(PivotEventIntent, eventIds, options = {}) { if (!eventIds.length) { return new Map(); } + const match = { eventId: { $in: eventIds } }; + if (options.batchWeek) { + match.batchWeek = options.batchWeek; + } + const rows = await PivotEventIntent.aggregate([ - { $match: { eventId: { $in: eventIds } } }, + { $match: match }, { $group: { _id: '$eventId', diff --git a/backend/services/pivotTagSuggestService.js b/backend/services/pivotTagSuggestService.js index 86310140..038d836b 100644 --- a/backend/services/pivotTagSuggestService.js +++ b/backend/services/pivotTagSuggestService.js @@ -318,7 +318,6 @@ async function suggestPivotEventTagsBatch(req, rawEvents = []) { }); if (suggestedCount === 0 && failures.length > 0) { - console.log('failures', failures); return { error: failures[0].message, status: failures[0].code === 'LLM_NOT_CONFIGURED' ? 503 : 400, diff --git a/backend/services/pivotTenantInsightsService.js b/backend/services/pivotTenantInsightsService.js new file mode 100644 index 00000000..ceae1aba --- /dev/null +++ b/backend/services/pivotTenantInsightsService.js @@ -0,0 +1,314 @@ +const getModels = require('./getModelService'); +const { connectToDatabase } = require('../connectionsManager'); +const { normalizeBatchWeek } = require('./pivotWeeklySnapshotService'); +const { resolvePivotTenant } = require('./pivotIngestPublishService'); +const { loadIntentStatsByEventId, labEventsQuery } = require('./pivotLabEventsService'); +const { + aggregateTenantOverview, + serializePerformanceEvent, +} = require('./pivotAdminOverviewService'); +const { shiftIsoWeek } = require('../utilities/pivotIsoWeek'); +const { PIVOT_EVENT_STATUSES } = require('./pivotFeedService'); + +const DEFAULT_TARGET_EVENT_COUNT = 40; +const INTEREST_NO_TICKET_MIN = 3; +const TAG_CONCENTRATION_RATIO = 0.4; +const ACTIVE_USER_DROP_RATIO = 0.2; +const FEEDBACK_AVG_DROP = 0.5; + +const SEVERITY_RANK = { critical: 0, warn: 1, info: 2 }; + +function catalogEventsQuery(batchWeek) { + return { + ...labEventsQuery(batchWeek), + status: { $in: PIVOT_EVENT_STATUSES }, + }; +} + +function curationHref(tenantKey, batchWeek, filter) { + const params = new URLSearchParams({ page: '1', batchWeek }); + if (filter) params.set('filter', filter); + return `/platform-admin/pivot/${encodeURIComponent(tenantKey)}?${params.toString()}`; +} + +function journeysHref(tenantKey, batchWeek) { + const params = new URLSearchParams({ page: '2', batchWeek }); + return `/platform-admin/pivot/${encodeURIComponent(tenantKey)}?${params.toString()}`; +} + +function isDeckEligible(ingestStatus) { + return ingestStatus === 'published' || ingestStatus === 'staged'; +} + +/** + * Pure insight rules for tenant ops Overview. + * Returns only cards that fire — empty array is a calm "all clear". + */ +function buildTenantInsights(context) { + const { + tenantKey, + batchWeek, + targetEventCount = DEFAULT_TARGET_EVENT_COUNT, + eventCountsByStatus, + performanceEvents = [], + catalogEvents = [], + vsPrevWeek, + feedbackAvg, + prevFeedbackAvg, + } = context; + + const insights = []; + const status = eventCountsByStatus || { + draft: 0, + staged: 0, + published: 0, + other: 0, + total: 0, + }; + const readyCount = (status.draft || 0) + (status.staged || 0) + (status.published || 0); + + // 1. Thin catalog vs next-drop target + if (readyCount < targetEventCount) { + const shortfall = targetEventCount - readyCount; + insights.push({ + id: 'thin-catalog', + severity: readyCount < Math.ceil(targetEventCount * 0.5) ? 'critical' : 'warn', + title: 'Catalog below drop target', + body: `${readyCount} events in ${batchWeek} (draft/staged/published); target is ${targetEventCount}. Add about ${shortfall} more.`, + metric: { value: readyCount, target: targetEventCount, shortfall }, + href: curationHref(tenantKey, batchWeek), + action: { type: 'open_curation', label: 'Open curation' }, + }); + } + + // 2. High interest, zero ticket opens + const interestNoTicket = performanceEvents.filter( + (row) => (row.interestedTotal ?? 0) >= INTEREST_NO_TICKET_MIN && (row.externalOpen ?? 0) === 0, + ); + if (interestNoTicket.length) { + const top = interestNoTicket[0]; + insights.push({ + id: 'interest-no-ticket', + severity: interestNoTicket.length >= 3 ? 'critical' : 'warn', + title: 'Interest without ticket opens', + body: `${interestNoTicket.length} event${interestNoTicket.length === 1 ? '' : 's'} have ≥${INTEREST_NO_TICKET_MIN} right-swipes but no ticket opens${ + top?.name ? ` (e.g. “${top.name}”)` : '' + }. Check links or host copy.`, + metric: { count: interestNoTicket.length, minInterested: INTEREST_NO_TICKET_MIN }, + href: journeysHref(tenantKey, batchWeek), + action: { type: 'open_journeys', label: 'Inspect journeys' }, + }); + } + + // 3. Published/staged missing tags + const untagged = catalogEvents.filter((event) => { + const pivot = event.customFields?.pivot || {}; + if (!isDeckEligible(pivot.ingestStatus)) return false; + return !Array.isArray(pivot.tags) || pivot.tags.length === 0; + }); + if (untagged.length) { + insights.push({ + id: 'untagged-events', + severity: untagged.length >= 5 ? 'critical' : 'warn', + title: 'Events missing tags', + body: `${untagged.length} published/staged event${untagged.length === 1 ? '' : 's'} have no tags. Tag coverage helps matching and readiness.`, + metric: { count: untagged.length }, + href: curationHref(tenantKey, batchWeek, 'untagged'), + action: { type: 'open_curation', filter: 'untagged', label: 'Fix untagged' }, + }); + } + + // 4. Tag concentration + const taggedDeck = catalogEvents.filter((event) => { + const pivot = event.customFields?.pivot || {}; + return isDeckEligible(pivot.ingestStatus) && Array.isArray(pivot.tags) && pivot.tags.length; + }); + if (taggedDeck.length >= 3) { + const tagCounts = new Map(); + for (const event of taggedDeck) { + for (const tag of event.customFields.pivot.tags) { + const key = String(tag); + tagCounts.set(key, (tagCounts.get(key) || 0) + 1); + } + } + let topTag = null; + let topCount = 0; + for (const [tag, count] of tagCounts) { + if (count > topCount) { + topTag = tag; + topCount = count; + } + } + const ratio = topCount / taggedDeck.length; + if (topTag && ratio > TAG_CONCENTRATION_RATIO) { + const pct = Math.round(ratio * 100); + insights.push({ + id: 'tag-concentration', + severity: ratio > 0.6 ? 'warn' : 'info', + title: 'Tag concentration', + body: `“${topTag}” appears on ${pct}% of tagged deck events (${topCount}/${taggedDeck.length}). Consider diversifying the week.`, + metric: { tag: topTag, count: topCount, total: taggedDeck.length, ratio }, + href: curationHref(tenantKey, batchWeek), + action: { type: 'open_curation', label: 'Review tags' }, + }); + } + } + + // 5. Feedback avg below prior week + if ( + typeof feedbackAvg === 'number' && + typeof prevFeedbackAvg === 'number' && + feedbackAvg < prevFeedbackAvg - FEEDBACK_AVG_DROP + ) { + const drop = Math.round((prevFeedbackAvg - feedbackAvg) * 100) / 100; + insights.push({ + id: 'low-feedback', + severity: feedbackAvg < 3 ? 'critical' : 'warn', + title: 'Feedback below last week', + body: `Average rating is ${feedbackAvg} vs ${prevFeedbackAvg} last week (−${drop}). Spot-check going events and hosts.`, + metric: { current: feedbackAvg, previous: prevFeedbackAvg, drop }, + href: journeysHref(tenantKey, batchWeek), + action: { type: 'open_journeys', label: 'Review journeys' }, + }); + } + + // 6. Week-over-week active user drop + const activeDelta = vsPrevWeek?.activeUsers; + if ( + activeDelta && + typeof activeDelta.previous === 'number' && + activeDelta.previous >= 5 && + typeof activeDelta.current === 'number' && + activeDelta.current < activeDelta.previous * (1 - ACTIVE_USER_DROP_RATIO) + ) { + const dropPct = Math.round( + ((activeDelta.previous - activeDelta.current) / activeDelta.previous) * 100, + ); + insights.push({ + id: 'active-users-drop', + severity: dropPct >= 40 ? 'critical' : 'warn', + title: 'Active users down week-over-week', + body: `${activeDelta.current} active vs ${activeDelta.previous} last week (−${dropPct}%). Check drop timing, push, and deck quality.`, + metric: { + current: activeDelta.current, + previous: activeDelta.previous, + dropPct, + }, + href: journeysHref(tenantKey, batchWeek), + action: { type: 'open_journeys', label: 'Open journeys' }, + }); + } + + return insights.sort( + (a, b) => (SEVERITY_RANK[a.severity] ?? 9) - (SEVERITY_RANK[b.severity] ?? 9), + ); +} + +async function getTenantInsights(req, options = {}) { + const normalized = normalizeBatchWeek(options.batchWeek, options.now); + if (normalized.error) { + return normalized; + } + + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) { + return tenantResult; + } + + const { batchWeek } = normalized; + const { tenant } = tenantResult; + const tenantKey = tenant.tenantKey; + const targetEventCount = + Number.isFinite(Number(options.targetEventCount)) && Number(options.targetEventCount) > 0 + ? Math.trunc(Number(options.targetEventCount)) + : DEFAULT_TARGET_EVENT_COUNT; + + const previousBatchWeek = shiftIsoWeek(batchWeek, -1); + + const [current, previous] = await Promise.all([ + aggregateTenantOverview(req, tenant, batchWeek, { + includeStatusBreakdown: true, + includeReferralCodes: false, + }), + aggregateTenantOverview(req, tenant, previousBatchWeek, { + includeStatusBreakdown: false, + includeReferralCodes: false, + }).catch((error) => { + console.error( + `[pivotTenantInsights] prev-week aggregate failed tenant=${tenantKey} batchWeek=${previousBatchWeek}:`, + error, + ); + return null; + }), + ]); + + const db = await connectToDatabase(tenantKey); + const tenantReq = { db }; + const { Event, PivotEventIntent } = getModels(tenantReq, 'Event', 'PivotEventIntent'); + + const catalogEvents = await Event.find(catalogEventsQuery(batchWeek)) + .select('name start_time customFields.pivot') + .lean(); + + const intentStatsByEventId = await loadIntentStatsByEventId( + PivotEventIntent, + catalogEvents.map((event) => event._id), + { batchWeek }, + ); + + const performanceEvents = catalogEvents.map((event) => + serializePerformanceEvent( + event, + intentStatsByEventId.get(String(event._id)) || { + interested: 0, + registered: 0, + passed: 0, + externalOpens: 0, + externalOpenUsers: 0, + }, + ), + ); + + const vsPrevWeek = previous + ? { + activeUsers: { + current: current.activeUsers ?? 0, + previous: previous.activeUsers ?? 0, + delta: (current.activeUsers ?? 0) - (previous.activeUsers ?? 0), + }, + } + : null; + + const insights = buildTenantInsights({ + tenantKey, + batchWeek, + targetEventCount, + eventCountsByStatus: current.eventCountsByStatus, + performanceEvents, + catalogEvents, + vsPrevWeek, + feedbackAvg: current.feedbackAvg, + prevFeedbackAvg: previous?.feedbackAvg ?? null, + }); + + return { + data: { + tenantKey, + cityDisplayName: tenant.location || tenant.name || tenantKey, + batchWeek, + previousBatchWeek, + targetEventCount, + insights, + }, + }; +} + +module.exports = { + getTenantInsights, + buildTenantInsights, + curationHref, + journeysHref, + DEFAULT_TARGET_EVENT_COUNT, + INTEREST_NO_TICKET_MIN, + TAG_CONCENTRATION_RATIO, +}; diff --git a/backend/services/pivotTenantJourneyService.js b/backend/services/pivotTenantJourneyService.js new file mode 100644 index 00000000..9342fb8d --- /dev/null +++ b/backend/services/pivotTenantJourneyService.js @@ -0,0 +1,777 @@ +const mongoose = require('mongoose'); +const getModels = require('./getModelService'); +const { connectToDatabase } = require('../connectionsManager'); +const { resolvePivotTenant } = require('./pivotIngestPublishService'); +const { normalizeBatchWeek } = require('./pivotWeeklySnapshotService'); +const { + aggregateTenantOverview, + buildFunnelStages, +} = require('./pivotAdminOverviewService'); +const { isoWeekToUtcRange } = require('../utilities/pivotIsoWeek'); +const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger'); + +const WIPE_CONFIRM_TOKEN = 'WIPE'; +const SEARCH_RESULT_LIMIT = 20; +const MIN_QUERY_LENGTH = 2; +const HISTORY_ANALYTICS_LIMIT = 100; +const PATH_NEXT_LIMIT = 5; + +/** Plan aliases → real mobile analytics event names (MVP Task 4.2). */ +const FUNNEL_STEP_ALIASES = { + deck_open: 'pivot_card_view', + card_view: 'pivot_card_view', + card_interested: 'pivot_card_interested', + interested: 'pivot_card_interested', + external_open: 'pivot_external_open', + registered: 'pivot_confirm_registered', + confirm_registered: 'pivot_confirm_registered', +}; + +const DEFAULT_FUNNEL_STEPS = [ + 'deck_open', + 'card_interested', + 'external_open', + 'registered', +]; + +const PIVOT_ANALYTICS_EVENTS = [ + 'pivot_card_view', + 'pivot_card_pass', + 'pivot_card_interested', + 'pivot_external_open', + 'pivot_confirm_registered', + 'pivot_feedback_submit', + 'pivot_calendar_add', + 'pivot_invite_share', + 'pivot_invite_copy', +]; + +function openTenantDb(tenantKey) { + return connectToDatabase(tenantKey).then((db) => ({ db, school: tenantKey })); +} + +function escapeRegex(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function buildNameUsernameQuery(term) { + const regex = new RegExp(escapeRegex(term), 'i'); + return { + $or: [{ name: { $regex: regex } }, { username: { $regex: regex } }], + }; +} + +function rateOrNull(numerator, denominator) { + if (!denominator) return null; + return Math.round((numerator / denominator) * 1000) / 1000; +} + +function median(values) { + if (!values.length) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 0) { + return (sorted[mid - 1] + sorted[mid]) / 2; + } + return sorted[mid]; +} + +function resolveFunnelEventName(raw) { + const key = String(raw || '').trim(); + if (!key) return null; + if (FUNNEL_STEP_ALIASES[key]) return FUNNEL_STEP_ALIASES[key]; + if (key.startsWith('pivot_')) return key; + return null; +} + +/** + * Parse `steps` query: comma-separated aliases or real event names. + * @returns {{ steps: { key: string, event: string }[] } | { error, status, code }} + */ +function parseFunnelSteps(raw) { + const parts = + raw == null || raw === '' + ? DEFAULT_FUNNEL_STEPS + : String(raw) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + if (!parts.length) { + return { + error: 'steps must include at least one funnel step.', + status: 400, + code: 'INVALID_STEPS', + }; + } + + const steps = []; + for (const part of parts) { + const event = resolveFunnelEventName(part); + if (!event) { + return { + error: `Unknown funnel step: ${part}`, + status: 400, + code: 'INVALID_STEPS', + }; + } + steps.push({ key: part, event }); + } + return { steps }; +} + +function parseUserId(raw) { + const id = String(raw || '').trim(); + if (!mongoose.Types.ObjectId.isValid(id)) { + return { + error: 'userId must be a valid ObjectId.', + status: 400, + code: 'INVALID_USER_ID', + }; + } + return { userId: new mongoose.Types.ObjectId(id) }; +} + +/** + * Unique users who fired each analytics event in order (closed funnel by user_id). + * Filters by properties.batchWeek when present; also bounds ts to the ISO week. + */ +async function aggregateAnalyticsClosedFunnel(AnalyticsEvent, batchWeek, stepEvents) { + const { start, end } = isoWeekToUtcRange(batchWeek); + const baseMatch = { + ts: { $gte: start, $lt: end }, + event: { $in: stepEvents }, + user_id: { $ne: null }, + $or: [ + { 'properties.batchWeek': batchWeek }, + { 'properties.batchWeek': { $exists: false } }, + { 'properties.batchWeek': null }, + ], + }; + + const sessionsStream = await AnalyticsEvent.aggregate([ + { $match: baseMatch }, + { $sort: { user_id: 1, ts: 1 } }, + { + $group: { + _id: '$user_id', + stream: { $push: '$event' }, + }, + }, + ]); + + const closedCounts = stepEvents.map(() => 0); + for (const row of sessionsStream) { + const stream = row.stream || []; + let nextExpected = 0; + for (const lbl of stream) { + if (lbl === stepEvents[nextExpected]) { + closedCounts[nextExpected] += 1; + nextExpected += 1; + if (nextExpected >= stepEvents.length) break; + } + } + } + + return closedCounts; +} + +async function aggregateMedianCardsSeen(AnalyticsEvent, batchWeek) { + try { + const { start, end } = isoWeekToUtcRange(batchWeek); + const rows = await AnalyticsEvent.aggregate([ + { + $match: { + event: 'pivot_card_view', + ts: { $gte: start, $lt: end }, + user_id: { $ne: null }, + $or: [ + { 'properties.batchWeek': batchWeek }, + { 'properties.batchWeek': { $exists: false } }, + { 'properties.batchWeek': null }, + ], + }, + }, + { $group: { _id: '$user_id', cardsSeen: { $sum: 1 } } }, + ]); + return median(rows.map((r) => r.cardsSeen)); + } catch (error) { + console.error( + `[pivotTenantJourney] medianCardsSeen failed batchWeek=${batchWeek}:`, + error, + ); + return null; + } +} + +/** + * Compact journey KPIs for a city week. + * Intent metrics are source of truth; medianCardsSeen is best-effort from analytics. + */ +async function getJourneyOverview(req, options = {}) { + const normalized = normalizeBatchWeek(options.batchWeek, options.now); + if (normalized.error) return normalized; + + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) return tenantResult; + + const { batchWeek } = normalized; + const { tenant } = tenantResult; + const tenantKey = tenant.tenantKey; + + const overview = await aggregateTenantOverview(req, tenant, batchWeek, { + includeStatusBreakdown: false, + includeReferralCodes: false, + }); + + const funnel = buildFunnelStages(overview); + const interestedSurvivors = overview.interestedCount + overview.registeredCount; + + const tenantReq = await openTenantDb(tenantKey); + const { AnalyticsEvent } = getModels(tenantReq, 'AnalyticsEvent'); + const medianCardsSeen = await aggregateMedianCardsSeen(AnalyticsEvent, batchWeek); + + return { + data: { + tenantKey, + cityDisplayName: overview.cityDisplayName, + batchWeek, + kpis: { + activeUsers: overview.activeUsers, + medianCardsSeen, + swipeCount: overview.swipeCount, + interestedCount: interestedSurvivors, + externalOpenUsers: overview.externalOpenUsers, + registeredCount: overview.registeredCount, + }, + conversionRates: { + interestRate: rateOrNull(interestedSurvivors, overview.swipeCount), + ticketOpenRate: rateOrNull(overview.externalOpenUsers, interestedSurvivors), + registerRate: rateOrNull(overview.registeredCount, overview.externalOpenUsers), + }, + funnel, + }, + }; +} + +/** + * Pivot funnel for a city week. + * Primary: closed analytics funnel on mapped event names. + * Also returns intent-based stages (same as Overview) for ops alignment. + */ +async function getJourneyFunnel(req, options = {}) { + const normalized = normalizeBatchWeek(options.batchWeek, options.now); + if (normalized.error) return normalized; + + const stepsResult = parseFunnelSteps(options.steps); + if (stepsResult.error) return stepsResult; + + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) return tenantResult; + + const { batchWeek } = normalized; + const { tenant } = tenantResult; + const tenantKey = tenant.tenantKey; + const { steps } = stepsResult; + const stepEvents = steps.map((s) => s.event); + + const tenantReq = await openTenantDb(tenantKey); + const { AnalyticsEvent } = getModels(tenantReq, 'AnalyticsEvent'); + + let closedCounts; + try { + closedCounts = await aggregateAnalyticsClosedFunnel( + AnalyticsEvent, + batchWeek, + stepEvents, + ); + } catch (error) { + console.error( + `[pivotTenantJourney] analytics funnel failed tenant=${tenantKey} batchWeek=${batchWeek}:`, + error, + ); + closedCounts = stepEvents.map(() => 0); + } + + const entered = closedCounts[0] || 0; + const analyticsSteps = steps.map((step, idx) => { + const count = closedCounts[idx] || 0; + const prev = idx === 0 ? count : closedCounts[idx - 1] || 0; + return { + key: step.key, + event: step.event, + index: idx + 1, + count, + conversionRate: entered > 0 ? Math.round((count / entered) * 1000) / 10 : 0, + dropOff: idx === 0 ? 0 : Math.max(0, prev - count), + }; + }); + + const overview = await aggregateTenantOverview(req, tenant, batchWeek, { + includeStatusBreakdown: false, + includeReferralCodes: false, + }); + const intentFunnel = buildFunnelStages(overview); + + return { + data: { + tenantKey, + batchWeek, + steps: analyticsSteps, + totalEntered: entered, + totalConverted: closedCounts[closedCounts.length - 1] || 0, + overallConversionRate: + entered > 0 + ? Math.round( + ((closedCounts[closedCounts.length - 1] || 0) / entered) * 1000, + ) / 10 + : 0, + intentFunnel, + intentActiveUsers: overview.activeUsers, + }, + }; +} + +/** + * Thin path exploration: top next pivot events after a starting event within sessions. + */ +async function getJourneyPath(req, options = {}) { + const normalized = normalizeBatchWeek(options.batchWeek, options.now); + if (normalized.error) return normalized; + + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) return tenantResult; + + const { batchWeek } = normalized; + const tenantKey = tenantResult.tenant.tenantKey; + + const rawStart = String(options.startingPoint || 'deck_open').trim(); + const startingEvent = resolveFunnelEventName(rawStart) || rawStart; + if (!startingEvent.startsWith('pivot_') && startingEvent !== 'PivotWeek') { + return { + error: `Unknown startingPoint: ${rawStart}`, + status: 400, + code: 'INVALID_STARTING_POINT', + }; + } + + const tenantReq = await openTenantDb(tenantKey); + const { AnalyticsEvent } = getModels(tenantReq, 'AnalyticsEvent'); + const { start, end } = isoWeekToUtcRange(batchWeek); + + const baseMatch = { + ts: { $gte: start, $lt: end }, + $or: [ + { 'properties.batchWeek': batchWeek }, + { 'properties.batchWeek': { $exists: false } }, + { 'properties.batchWeek': null }, + ], + }; + + const startMatch = + startingEvent === 'PivotWeek' + ? { ...baseMatch, event: 'screen_view', 'context.screen': 'PivotWeek' } + : { ...baseMatch, event: startingEvent }; + + let startCount = 0; + let nextSteps = []; + + try { + startCount = await AnalyticsEvent.countDocuments(startMatch); + const startSessions = await AnalyticsEvent.distinct('session_id', startMatch); + + if (startSessions.length) { + const streams = await AnalyticsEvent.aggregate([ + { + $match: { + ...baseMatch, + session_id: { $in: startSessions }, + $or: [ + { event: { $in: PIVOT_ANALYTICS_EVENTS } }, + { event: 'screen_view', 'context.screen': 'PivotWeek' }, + ], + }, + }, + { $sort: { session_id: 1, ts: 1 } }, + { + $group: { + _id: '$session_id', + stream: { + $push: { + label: { + $cond: { + if: { $eq: ['$event', 'screen_view'] }, + then: { $ifNull: ['$context.screen', 'Unknown'] }, + else: '$event', + }, + }, + }, + }, + }, + }, + ]); + + const nextLabels = {}; + for (const s of streams) { + const stream = (s.stream || []).map((x) => x.label); + const startIdx = stream.indexOf(startingEvent); + if (startIdx < 0) continue; + const after = stream.slice(startIdx + 1); + for (const lbl of after) { + if (lbl !== startingEvent) { + nextLabels[lbl] = (nextLabels[lbl] || 0) + 1; + break; + } + } + } + + nextSteps = Object.entries(nextLabels) + .sort((a, b) => b[1] - a[1]) + .slice(0, PATH_NEXT_LIMIT) + .map(([event, count]) => ({ event, count })); + } + } catch (error) { + console.error( + `[pivotTenantJourney] path failed tenant=${tenantKey} batchWeek=${batchWeek}:`, + error, + ); + } + + return { + data: { + tenantKey, + batchWeek, + startingPoint: startingEvent, + startCount, + nextSteps, + }, + }; +} + +/** + * Top users by intent count for a batch week (inspector default list). + */ +async function listMostActiveJourneyUsers(tenantReq, { tenantKey, batchWeek }) { + const { User, PivotEventIntent } = getModels(tenantReq, 'User', 'PivotEventIntent'); + + const counts = await PivotEventIntent.aggregate([ + { $match: { batchWeek } }, + { $group: { _id: '$userId', count: { $sum: 1 } } }, + { $sort: { count: -1 } }, + { $limit: SEARCH_RESULT_LIMIT }, + ]); + + if (!counts.length) { + return { + data: { tenantKey, batchWeek, mode: 'active', users: [] }, + }; + } + + const userIds = counts.map((row) => row._id); + const users = await User.find({ _id: { $in: userIds } }) + .select('name username picture') + .lean(); + const userById = new Map(users.map((u) => [String(u._id), u])); + + return { + data: { + tenantKey, + batchWeek, + mode: 'active', + users: counts + .map((row) => { + const user = userById.get(String(row._id)); + if (!user) return null; + return { + userId: String(user._id), + name: user.name || '', + username: user.username || null, + picture: user.picture || null, + intentCount: row.count, + }; + }) + .filter(Boolean), + }, + }; +} + +/** + * Search pivot city users by ObjectId, display name, or username. + * Empty query + batchWeek returns most-active users for that week. + * Optional batchWeek also ranks search hits by intent count that week. + */ +async function searchJourneyUsers(req, options = {}) { + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) return tenantResult; + + const tenantKey = tenantResult.tenant.tenantKey; + const query = String(options.query || options.q || '').trim(); + + let batchWeek = null; + if (options.batchWeek) { + const normalized = normalizeBatchWeek(options.batchWeek, options.now); + if (normalized.error) return normalized; + batchWeek = normalized.batchWeek; + } + + const tenantReq = await openTenantDb(tenantKey); + + if (!query) { + if (!batchWeek) { + return { data: { tenantKey, batchWeek, mode: 'active', users: [] } }; + } + return listMostActiveJourneyUsers(tenantReq, { tenantKey, batchWeek }); + } + + const { User, PivotEventIntent } = getModels(tenantReq, 'User', 'PivotEventIntent'); + + let users = []; + if (mongoose.Types.ObjectId.isValid(query) && String(new mongoose.Types.ObjectId(query)) === query) { + const user = await User.findById(query).select('name username picture').lean(); + users = user ? [user] : []; + } else if (query.length < MIN_QUERY_LENGTH) { + return { data: { tenantKey, batchWeek, mode: 'search', users: [] } }; + } else { + users = await User.find(buildNameUsernameQuery(query)) + .select('name username picture') + .limit(SEARCH_RESULT_LIMIT) + .lean(); + } + + if (!users.length) { + return { data: { tenantKey, batchWeek, mode: 'search', users: [] } }; + } + + let intentCountByUser = new Map(); + if (batchWeek) { + const userIds = users.map((u) => u._id); + const counts = await PivotEventIntent.aggregate([ + { $match: { batchWeek, userId: { $in: userIds } } }, + { $group: { _id: '$userId', count: { $sum: 1 } } }, + ]); + intentCountByUser = new Map(counts.map((r) => [String(r._id), r.count])); + + // Prefer users active that week; keep others at the end with intentCount 0. + users = [...users].sort((a, b) => { + const ca = intentCountByUser.get(String(a._id)) || 0; + const cb = intentCountByUser.get(String(b._id)) || 0; + return cb - ca; + }); + } + + return { + data: { + tenantKey, + batchWeek, + mode: 'search', + users: users.map((user) => ({ + userId: String(user._id), + name: user.name || '', + username: user.username || null, + picture: user.picture || null, + intentCount: batchWeek + ? intentCountByUser.get(String(user._id)) || 0 + : undefined, + })), + }, + }; +} + +/** + * Intent + key analytics history for one user (optional week filter). + */ +async function getUserJourneyHistory(req, options = {}) { + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) return tenantResult; + + const userIdResult = parseUserId(options.userId); + if (userIdResult.error) return userIdResult; + + let batchWeek = null; + if (options.batchWeek) { + const normalized = normalizeBatchWeek(options.batchWeek, options.now); + if (normalized.error) return normalized; + batchWeek = normalized.batchWeek; + } + + const tenantKey = tenantResult.tenant.tenantKey; + const { userId } = userIdResult; + const tenantReq = await openTenantDb(tenantKey); + const { User, PivotEventIntent, Event, AnalyticsEvent } = getModels( + tenantReq, + 'User', + 'PivotEventIntent', + 'Event', + 'AnalyticsEvent', + ); + + const user = await User.findById(userId).select('name username picture').lean(); + if (!user) { + return { + error: 'User not found in this city.', + status: 404, + code: 'USER_NOT_FOUND', + }; + } + + const intentFilter = { userId }; + if (batchWeek) intentFilter.batchWeek = batchWeek; + + const intents = await PivotEventIntent.find(intentFilter) + .select( + 'eventId batchWeek status timeSlotId externalOpenAt externalOpenCount updatedAt createdAt', + ) + .sort({ updatedAt: -1 }) + .lean(); + + const eventIds = [...new Set(intents.map((i) => String(i.eventId)))]; + const events = eventIds.length + ? await Event.find({ _id: { $in: eventIds } }) + .select('name start_time customFields.pivot.batchWeek customFields.pivot.ingestStatus') + .lean() + : []; + const eventById = new Map(events.map((e) => [String(e._id), e])); + + const serializedIntents = intents.map((intent) => { + const event = eventById.get(String(intent.eventId)); + return { + eventId: String(intent.eventId), + eventName: event?.name || null, + eventStartTime: event?.start_time || null, + ingestStatus: event?.customFields?.pivot?.ingestStatus || null, + batchWeek: intent.batchWeek, + status: intent.status, + timeSlotId: intent.timeSlotId || null, + externalOpenAt: intent.externalOpenAt || null, + externalOpenCount: intent.externalOpenCount || 0, + updatedAt: intent.updatedAt, + createdAt: intent.createdAt, + }; + }); + + let analytics = []; + try { + const analyticsFilter = { + user_id: userId, + event: { $in: PIVOT_ANALYTICS_EVENTS }, + }; + if (batchWeek) { + const { start, end } = isoWeekToUtcRange(batchWeek); + analyticsFilter.ts = { $gte: start, $lt: end }; + analyticsFilter.$or = [ + { 'properties.batchWeek': batchWeek }, + { 'properties.batchWeek': { $exists: false } }, + { 'properties.batchWeek': null }, + ]; + } + + const rows = await AnalyticsEvent.find(analyticsFilter) + .select('event ts properties.eventId properties.batchWeek session_id') + .sort({ ts: -1 }) + .limit(HISTORY_ANALYTICS_LIMIT) + .lean(); + + analytics = rows.map((row) => ({ + event: row.event, + ts: row.ts, + eventId: row.properties?.eventId ? String(row.properties.eventId) : null, + batchWeek: row.properties?.batchWeek || null, + sessionId: row.session_id || null, + })); + } catch (error) { + console.error( + `[pivotTenantJourney] history analytics failed tenant=${tenantKey} userId=${userId}:`, + error, + ); + } + + return { + data: { + tenantKey, + batchWeek, + user: { + userId: String(user._id), + name: user.name || '', + username: user.username || null, + picture: user.picture || null, + }, + intents: serializedIntents, + analytics, + }, + }; +} + +/** + * Platform-admin wipe of PivotEventIntent rows for one user + week. + * v0: intents only (analytics retained). Requires confirm: "WIPE". + */ +async function wipeUserWeekIntents(req, options = {}) { + if (options.confirm !== WIPE_CONFIRM_TOKEN) { + return { + error: 'Confirmation required. Send confirm: "WIPE".', + status: 400, + code: 'CONFIRM_REQUIRED', + }; + } + + const normalized = normalizeBatchWeek(options.batchWeek, options.now); + if (normalized.error) return normalized; + + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) return tenantResult; + + const userIdResult = parseUserId(options.userId || options.targetUserId); + if (userIdResult.error) return userIdResult; + + const { batchWeek } = normalized; + const tenantKey = tenantResult.tenant.tenantKey; + const { userId } = userIdResult; + + const tenantReq = await openTenantDb(tenantKey); + const { PivotEventIntent, User } = getModels(tenantReq, 'PivotEventIntent', 'User'); + + const userExists = await User.exists({ _id: userId }); + if (!userExists) { + return { + error: 'User not found in this city.', + status: 404, + code: 'USER_NOT_FOUND', + }; + } + + const result = await PivotEventIntent.deleteMany({ userId, batchWeek }); + const deletedCount = result.deletedCount ?? 0; + + logPivot('info', 'admin wipe week intents', { + ...pivotRequestContext(req), + tenantKey, + targetUserId: String(userId), + batchWeek, + deletedCount, + }); + + return { + data: { + tenantKey, + userId: String(userId), + batchWeek, + deletedCount, + }, + }; +} + +module.exports = { + WIPE_CONFIRM_TOKEN, + FUNNEL_STEP_ALIASES, + DEFAULT_FUNNEL_STEPS, + parseFunnelSteps, + resolveFunnelEventName, + getJourneyOverview, + getJourneyFunnel, + getJourneyPath, + searchJourneyUsers, + getUserJourneyHistory, + wipeUserWeekIntents, + rateOrNull, + median, +}; diff --git a/backend/services/pivotTenantOpsService.js b/backend/services/pivotTenantOpsService.js new file mode 100644 index 00000000..26bbabb4 --- /dev/null +++ b/backend/services/pivotTenantOpsService.js @@ -0,0 +1,350 @@ +const { resolvePivotTenant } = require('./pivotIngestPublishService'); +const { normalizeBatchWeek } = require('./pivotWeeklySnapshotService'); +const { + getTenantOverview, + getTenantEventPerformance, +} = require('./pivotAdminOverviewService'); +const { getTenantInsights } = require('./pivotTenantInsightsService'); +const { getBatchReadiness } = require('./pivotBatchReadinessService'); +const { + getJourneyOverview, + getJourneyFunnel, +} = require('./pivotTenantJourneyService'); +const { + aggregateTenantRetention, + normalizeWeeksParam, +} = require('./pivotRetentionService'); +const { listPivotLabEvents } = require('./pivotLabEventsService'); +const { listCurationJobs } = require('./pivotCurationJobService'); +const { buildDropSchedulePayload } = require('./pivotConfigService'); +const { resolvePivotDropInstant } = require('../utilities/pivotDropSchedule'); +const { + toIsoWeek, + shiftIsoWeek, + isValidIsoWeek, + isoWeekToUtcRange, +} = require('../utilities/pivotIsoWeek'); + +const ALL_SECTIONS = Object.freeze([ + 'overview', + 'performance', + 'insights', + 'readiness', + 'retention', + 'journey', + 'funnel', + 'catalog', + 'jobs', +]); + +const PRESETS = Object.freeze({ + overview: ['overview', 'performance', 'insights', 'readiness', 'retention'], + journeys: ['journey', 'funnel'], + /** Expanded server-side from resolved stage for the requested batchWeek. */ + curation: ['__curation__'], +}); + +const DEFAULT_PERFORMANCE_LIMIT = 10; +const CURATION_PERFORMANCE_LIMIT = 100; + +function formatIsoWeekRangeLabel(batchWeek) { + let range; + try { + range = isoWeekToUtcRange(batchWeek); + } catch { + return null; + } + const { start, end } = range; + const lastDay = new Date(end.getTime() - 1); + const sameYear = start.getUTCFullYear() === lastDay.getUTCFullYear(); + const startLabel = start.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + ...(sameYear ? {} : { year: 'numeric' }), + timeZone: 'UTC', + }); + const endLabel = lastDay.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }); + return `${startLabel} – ${endLabel}`; +} + +/** + * Derive live / curate weeks from now + current ISO week's drop instant. + */ +function resolveStageAnchors(tenant, now = new Date()) { + const currentWeek = toIsoWeek(now); + let dropPending = false; + let currentWeekDropAt = null; + try { + const currentDrop = resolvePivotDropInstant(tenant, currentWeek, now); + currentWeekDropAt = currentDrop.dropAt.toISOString(); + dropPending = currentDrop.dropAt.getTime() > now.getTime(); + } catch { + dropPending = false; + } + + const liveWeek = dropPending ? shiftIsoWeek(currentWeek, -1) : currentWeek; + const curateWeek = dropPending ? currentWeek : shiftIsoWeek(currentWeek, 1); + + return { + currentWeek, + liveWeek, + curateWeek, + postMortemWeek: shiftIsoWeek(liveWeek, -1), + dropPending, + currentWeekDropAt, + }; +} + +function resolveStageForWeek(batchWeek, anchors) { + if (!isValidIsoWeek(batchWeek) || !anchors?.liveWeek) return 'curate'; + if (batchWeek === anchors.liveWeek) return 'live'; + if (batchWeek > anchors.liveWeek) return 'curate'; + return 'post-mortem'; +} + +function curationSectionsForStage(stage) { + if (stage === 'live' || stage === 'post-mortem') { + return ['overview', 'performance', 'journey']; + } + return ['overview', 'readiness', 'catalog', 'jobs']; +} + +/** + * Parse include query into a unique ordered section list. + * Accepts presets (`overview`, `journeys`, `curation`) and/or section names. + */ +function parseInclude(raw, { stage } = {}) { + const tokens = String(raw || '') + .split(',') + .map((part) => part.trim().toLowerCase()) + .filter(Boolean); + + if (!tokens.length) { + return { + error: 'include is required (preset or comma-separated sections).', + status: 400, + code: 'INCLUDE_REQUIRED', + }; + } + + const sections = []; + const seen = new Set(); + + const push = (name) => { + if (seen.has(name)) return; + if (!ALL_SECTIONS.includes(name)) { + return { + error: `Unknown include section: ${name}`, + status: 400, + code: 'INVALID_INCLUDE', + }; + } + seen.add(name); + sections.push(name); + return null; + }; + + for (const token of tokens) { + if (token === 'curation' || token === '__curation__') { + for (const name of curationSectionsForStage(stage || 'curate')) { + const err = push(name); + if (err) return err; + } + continue; + } + if (PRESETS[token] && token !== 'curation') { + for (const name of PRESETS[token]) { + const err = push(name); + if (err) return err; + } + continue; + } + const err = push(token); + if (err) return err; + } + + if (!sections.length) { + return { + error: 'include resolved to no sections.', + status: 400, + code: 'INCLUDE_REQUIRED', + }; + } + + return { sections }; +} + +function wants(sections, name) { + return sections.includes(name); +} + +/** + * Single round-trip bundle for Overview / Journeys / Curation dashboards. + * + * @param {object} req + * @param {{ + * tenantKey: string, + * batchWeek?: string, + * include?: string, + * performanceLimit?: number|string, + * retentionWeeks?: number|string, + * now?: Date, + * }} options + */ +async function getTenantOpsBundle(req, options = {}) { + const now = options.now || new Date(); + const normalized = normalizeBatchWeek(options.batchWeek, now); + if (normalized.error) return normalized; + + const tenantResult = await resolvePivotTenant(req, options.tenantKey); + if (tenantResult.error) return tenantResult; + + const { batchWeek } = normalized; + const { tenant } = tenantResult; + const tenantKey = tenant.tenantKey; + const anchors = resolveStageAnchors(tenant, now); + const stage = resolveStageForWeek(batchWeek, anchors); + + const includeRaw = options.include; + const parsed = parseInclude(includeRaw, { stage }); + if (parsed.error) return parsed; + const { sections } = parsed; + + const isCurationPreset = String(includeRaw || '') + .split(',') + .map((p) => p.trim().toLowerCase()) + .includes('curation'); + + const performanceLimit = isCurationPreset + ? options.performanceLimit ?? CURATION_PERFORMANCE_LIMIT + : options.performanceLimit ?? DEFAULT_PERFORMANCE_LIMIT; + + const retentionWeeks = normalizeWeeksParam(options.retentionWeeks); + + let dropSchedule = null; + try { + dropSchedule = buildDropSchedulePayload(tenant, batchWeek, now); + } catch { + dropSchedule = null; + } + + const weekRangeLabel = formatIsoWeekRangeLabel(batchWeek); + + const tasks = {}; + + if (wants(sections, 'overview')) { + tasks.overview = getTenantOverview(req, { + tenantKey, + batchWeek, + now, + }); + } + if (wants(sections, 'performance')) { + tasks.performance = getTenantEventPerformance(req, { + tenantKey, + batchWeek, + limit: performanceLimit, + now, + }); + } + if (wants(sections, 'insights')) { + tasks.insights = getTenantInsights(req, { tenantKey, batchWeek, now }); + } + if (wants(sections, 'readiness')) { + tasks.readiness = getBatchReadiness(req, { tenantKey, batchWeek, now }); + } + if (wants(sections, 'retention')) { + const weekList = Array.from({ length: retentionWeeks }, (_, index) => + shiftIsoWeek(batchWeek, index - (retentionWeeks - 1)), + ); + tasks.retention = aggregateTenantRetention(tenant, weekList).then((row) => ({ + data: { + batchWeek, + weeks: weekList, + tenant: row, + }, + })); + } + if (wants(sections, 'journey')) { + tasks.journey = getJourneyOverview(req, { tenantKey, batchWeek, now }); + } + if (wants(sections, 'funnel')) { + tasks.funnel = getJourneyFunnel(req, { tenantKey, batchWeek, now }); + } + if (wants(sections, 'catalog')) { + tasks.catalog = listPivotLabEvents(req, { tenantKey, batchWeek, now }); + } + if (wants(sections, 'jobs')) { + tasks.jobs = listCurationJobs(req, { tenantKey }); + } + + const keys = Object.keys(tasks); + const settled = await Promise.all( + keys.map(async (key) => { + try { + const result = await tasks[key]; + return [key, result]; + } catch (error) { + console.error( + `[pivotTenantOps] section=${key} failed tenant=${tenantKey} batchWeek=${batchWeek}:`, + error, + ); + return [ + key, + { + error: 'Section failed to load.', + status: 500, + code: 'SECTION_FAILED', + }, + ]; + } + }), + ); + + const data = { + tenantKey, + cityDisplayName: tenant.location || tenant.name || tenantKey, + batchWeek, + stage, + anchors: { + liveWeek: anchors.liveWeek, + curateWeek: anchors.curateWeek, + postMortemWeek: anchors.postMortemWeek, + currentWeek: anchors.currentWeek, + }, + weekRange: { + label: weekRangeLabel, + }, + dropSchedule, + include: sections, + }; + + for (const [key, result] of settled) { + if (result?.error) { + data[key] = { + error: result.error, + code: result.code || 'SECTION_FAILED', + }; + continue; + } + data[key] = result?.data ?? null; + } + + return { data }; +} + +module.exports = { + getTenantOpsBundle, + parseInclude, + resolveStageAnchors, + resolveStageForWeek, + curationSectionsForStage, + formatIsoWeekRangeLabel, + ALL_SECTIONS, + PRESETS, +}; diff --git a/backend/services/pivotWeeklyDropService.js b/backend/services/pivotWeeklyDropService.js index 8690221d..d52adf41 100644 --- a/backend/services/pivotWeeklyDropService.js +++ b/backend/services/pivotWeeklyDropService.js @@ -10,6 +10,7 @@ const { DAY_NAMES, isPivotTenant, } = require('../utilities/pivotDropSchedule'); +const { PIVOT_FEED_INGEST_STATUS } = require('../utilities/pivotIngestStatus'); const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send'; const EXPO_BATCH_SIZE = 100; @@ -46,7 +47,7 @@ async function countPublishedEvents(tenantKey, batchWeek) { const { Event } = getModels(req, 'Event'); return Event.countDocuments({ 'customFields.pivot.batchWeek': batchWeek, - 'customFields.pivot.ingestStatus': 'published', + 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS, }); } diff --git a/backend/services/pivotWeeklySnapshotService.js b/backend/services/pivotWeeklySnapshotService.js index d2ca2047..2480ebc5 100644 --- a/backend/services/pivotWeeklySnapshotService.js +++ b/backend/services/pivotWeeklySnapshotService.js @@ -6,6 +6,7 @@ const { connectToDatabase } = require('../connectionsManager'); const { PIVOT_EVENT_STATUSES } = require('./pivotFeedService'); const { PIVOT_EVENT_FEATURE } = require('./pivotFeedbackService'); const { toIsoWeek, isValidIsoWeek, isoWeekToUtcRange } = require('../utilities/pivotIsoWeek'); +const { PIVOT_FEED_INGEST_STATUS } = require('../utilities/pivotIngestStatus'); /** Mobile analytics event names surfaced as Lab engagement metrics. */ const ENGAGEMENT_EVENTS = { @@ -41,7 +42,7 @@ async function aggregateEngagementMetrics(tenantReq, batchWeek) { const PUBLISHED_EVENT_QUERY = (batchWeek) => ({ 'customFields.pivot.batchWeek': batchWeek, - 'customFields.pivot.ingestStatus': 'published', + 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS, status: { $in: PIVOT_EVENT_STATUSES }, isDeleted: { $ne: true }, 'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] }, diff --git a/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js b/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js index 353152fb..c3516b21 100644 --- a/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js +++ b/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js @@ -22,6 +22,46 @@ jest.mock('../../services/pivotWeeklySnapshotService', () => ({ jest.mock('../../services/pivotAdminOverviewService', () => ({ getPivotOverview: jest.fn(), + getTenantOverview: jest.fn(), + getTenantEventPerformance: jest.fn(), +})); + +jest.mock('../../services/pivotTenantInsightsService', () => ({ + getTenantInsights: jest.fn(), +})); + +jest.mock('../../services/pivotBatchReleaseService', () => ({ + releaseBatch: jest.fn(), + unreleaseBatch: jest.fn(), +})); + +jest.mock('../../services/pivotBatchReadinessService', () => ({ + getBatchReadiness: jest.fn(), +})); + +jest.mock('../../services/pivotCurationJobService', () => ({ + listCurationJobs: jest.fn(), + createCurationJob: jest.fn(), + updateCurationJob: jest.fn(), + deleteCurationJob: jest.fn(), +})); + +jest.mock('../../services/pivotCurationRunService', () => ({ + startCurationJobRun: jest.fn(), + getCurationRun: jest.fn(), +})); + +jest.mock('../../services/pivotTenantJourneyService', () => ({ + getJourneyOverview: jest.fn(), + getJourneyFunnel: jest.fn(), + getJourneyPath: jest.fn(), + searchJourneyUsers: jest.fn(), + getUserJourneyHistory: jest.fn(), + wipeUserWeekIntents: jest.fn(), +})); + +jest.mock('../../services/pivotTenantOpsService', () => ({ + getTenantOpsBundle: jest.fn(), })); jest.mock('../../services/pivotRetentionService', () => ({ @@ -65,7 +105,36 @@ const { rebuildWeeklySnapshot, getWeeklySnapshot, } = require('../../services/pivotWeeklySnapshotService'); -const { getPivotOverview } = require('../../services/pivotAdminOverviewService'); +const { + getPivotOverview, + getTenantOverview, + getTenantEventPerformance, +} = require('../../services/pivotAdminOverviewService'); +const { getTenantInsights } = require('../../services/pivotTenantInsightsService'); +const { + releaseBatch, + unreleaseBatch, +} = require('../../services/pivotBatchReleaseService'); +const { getBatchReadiness } = require('../../services/pivotBatchReadinessService'); +const { + listCurationJobs, + createCurationJob, + updateCurationJob, + deleteCurationJob, +} = require('../../services/pivotCurationJobService'); +const { + startCurationJobRun, + getCurationRun, +} = require('../../services/pivotCurationRunService'); +const { + getJourneyOverview, + getJourneyFunnel, + getJourneyPath, + searchJourneyUsers, + getUserJourneyHistory, + wipeUserWeekIntents, +} = require('../../services/pivotTenantJourneyService'); +const { getTenantOpsBundle } = require('../../services/pivotTenantOpsService'); const { getPivotRetention } = require('../../services/pivotRetentionService'); const { listPivotLabEvents } = require('../../services/pivotLabEventsService'); const { @@ -203,6 +272,646 @@ describe('pivotAdminRoutes overview', () => { }); }); +describe('pivotAdminRoutes tenant overview + performance', () => { + beforeEach(() => { + getTenantOverview.mockReset(); + getTenantEventPerformance.mockReset(); + getTenantInsights.mockReset(); + getTenantOpsBundle.mockReset(); + requirePlatformAdmin.mockImplementation((req, res, next) => next()); + }); + + it('GET /admin/pivot/tenants/:tenantKey/ops returns bundled payload', async () => { + getTenantOpsBundle.mockResolvedValue({ + data: { + tenantKey: 'nyc', + batchWeek: '2026-W28', + stage: 'live', + include: ['overview', 'performance'], + overview: { kpis: { activeUsers: 3 } }, + performance: { events: [] }, + }, + }); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/nyc/ops?batchWeek=2026-W28&include=overview', + ); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.stage).toBe('live'); + expect(getTenantOpsBundle).toHaveBeenCalledWith( + expect.objectContaining({ globalDb: {} }), + expect.objectContaining({ + tenantKey: 'nyc', + batchWeek: '2026-W28', + include: 'overview', + }), + ); + }); + + it('GET /admin/pivot/tenants/:tenantKey/ops returns 400 when include missing', async () => { + getTenantOpsBundle.mockResolvedValue({ + error: 'include is required (preset or comma-separated sections).', + status: 400, + code: 'INCLUDE_REQUIRED', + }); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/nyc/ops?batchWeek=2026-W28', + ); + + expect(response.status).toBe(400); + expect(response.body.code).toBe('INCLUDE_REQUIRED'); + }); + + it('GET /admin/pivot/tenants/:tenantKey/overview returns one-tenant payload', async () => { + getTenantOverview.mockResolvedValue({ + data: { + tenantKey: 'nyc', + batchWeek: '2026-W26', + kpis: { activeUsers: 3, eventCount: 2 }, + funnel: [{ key: 'swipes', value: 12 }], + }, + }); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/nyc/overview?batchWeek=2026-W26', + ); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.tenantKey).toBe('nyc'); + expect(response.body.data.kpis.activeUsers).toBe(3); + expect(getTenantOverview).toHaveBeenCalledWith( + expect.objectContaining({ globalDb: {} }), + expect.objectContaining({ tenantKey: 'nyc', batchWeek: '2026-W26' }), + ); + }); + + it('GET /admin/pivot/tenants/:tenantKey/overview returns 404 for unknown tenant', async () => { + getTenantOverview.mockResolvedValue({ + error: 'Pivot tenant not found.', + status: 404, + code: 'TENANT_NOT_FOUND', + }); + + const response = await request(buildApp()).get('/admin/pivot/tenants/missing/overview'); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('TENANT_NOT_FOUND'); + }); + + it('GET /admin/pivot/tenants/:tenantKey/overview returns 403 for non-platform-admin', async () => { + requirePlatformAdmin.mockImplementation((_req, res) => + res.status(403).json({ message: 'Forbidden' }), + ); + + const response = await request(buildApp()).get('/admin/pivot/tenants/nyc/overview'); + expect(response.status).toBe(403); + expect(getTenantOverview).not.toHaveBeenCalled(); + }); + + it('GET /admin/pivot/tenants/:tenantKey/events/performance returns ranked events', async () => { + getTenantEventPerformance.mockResolvedValue({ + data: { + tenantKey: 'nyc', + batchWeek: '2026-W26', + sortBy: 'interestedTotal', + events: [{ eventId: 'e1', interestedTotal: 10 }], + }, + }); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/nyc/events/performance?batchWeek=2026-W26&limit=5', + ); + + expect(response.status).toBe(200); + expect(response.body.data.sortBy).toBe('interestedTotal'); + expect(response.body.data.events).toHaveLength(1); + expect(getTenantEventPerformance).toHaveBeenCalledWith( + expect.objectContaining({ globalDb: {} }), + expect.objectContaining({ + tenantKey: 'nyc', + batchWeek: '2026-W26', + limit: '5', + }), + ); + }); + + it('GET /admin/pivot/tenants/:tenantKey/events/performance returns 403 for non-admin', async () => { + requirePlatformAdmin.mockImplementation((_req, res) => + res.status(403).json({ message: 'Forbidden' }), + ); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/nyc/events/performance', + ); + expect(response.status).toBe(403); + expect(getTenantEventPerformance).not.toHaveBeenCalled(); + }); + + it('GET /admin/pivot/tenants/:tenantKey/insights returns insight cards', async () => { + getTenantInsights.mockResolvedValue({ + data: { + tenantKey: 'nyc', + batchWeek: '2026-W28', + insights: [ + { + id: 'untagged-events', + severity: 'warn', + title: 'Events missing tags', + href: '/platform-admin/pivot/nyc?page=1&batchWeek=2026-W28&filter=untagged', + }, + ], + }, + }); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/nyc/insights?batchWeek=2026-W28', + ); + + expect(response.status).toBe(200); + expect(response.body.data.insights).toHaveLength(1); + expect(getTenantInsights).toHaveBeenCalledWith( + expect.objectContaining({ globalDb: {} }), + expect.objectContaining({ tenantKey: 'nyc', batchWeek: '2026-W28' }), + ); + }); + + it('GET /admin/pivot/tenants/:tenantKey/insights returns 403 for non-admin', async () => { + requirePlatformAdmin.mockImplementation((_req, res) => + res.status(403).json({ message: 'Forbidden' }), + ); + + const response = await request(buildApp()).get('/admin/pivot/tenants/nyc/insights'); + expect(response.status).toBe(403); + expect(getTenantInsights).not.toHaveBeenCalled(); + }); +}); + +describe('pivotAdminRoutes batch release', () => { + beforeEach(() => { + releaseBatch.mockReset(); + unreleaseBatch.mockReset(); + requirePlatformAdmin.mockImplementation((req, res, next) => next()); + }); + + it('POST .../batches/:batchWeek/release returns counts', async () => { + releaseBatch.mockResolvedValue({ + data: { + tenantKey: 'nyc', + batchWeek: '2026-W28', + releasedCount: 5, + skippedCount: 0, + batchStatus: 'released', + partial: false, + }, + }); + + const response = await request(buildApp()) + .post('/admin/pivot/tenants/nyc/batches/2026-W28/release') + .send({}); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.releasedCount).toBe(5); + expect(response.body.data.batchStatus).toBe('released'); + expect(releaseBatch).toHaveBeenCalledWith( + expect.objectContaining({ globalDb: {} }), + expect.objectContaining({ + tenantKey: 'nyc', + batchWeek: '2026-W28', + }), + ); + }); + + it('POST .../batches/:batchWeek/release supports partial eventIds', async () => { + releaseBatch.mockResolvedValue({ + data: { + tenantKey: 'nyc', + batchWeek: '2026-W28', + releasedCount: 1, + skippedCount: 1, + batchStatus: 'released', + partial: true, + }, + }); + + const response = await request(buildApp()) + .post('/admin/pivot/tenants/nyc/batches/2026-W28/release') + .send({ eventIds: ['665a1b2c3d4e5f6789012345', '665a1b2c3d4e5f6789012346'] }); + + expect(response.status).toBe(200); + expect(response.body.data.partial).toBe(true); + expect(releaseBatch).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + eventIds: ['665a1b2c3d4e5f6789012345', '665a1b2c3d4e5f6789012346'], + }), + ); + }); + + it('POST .../batches/:batchWeek/release returns 404 for unknown tenant', async () => { + releaseBatch.mockResolvedValue({ + error: 'Pivot tenant not found.', + status: 404, + code: 'TENANT_NOT_FOUND', + }); + + const response = await request(buildApp()) + .post('/admin/pivot/tenants/missing/batches/2026-W28/release') + .send({}); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('TENANT_NOT_FOUND'); + }); + + it('POST .../batches/:batchWeek/release returns 403 for non-admin', async () => { + requirePlatformAdmin.mockImplementation((_req, res) => + res.status(403).json({ message: 'Forbidden' }), + ); + + const response = await request(buildApp()) + .post('/admin/pivot/tenants/nyc/batches/2026-W28/release') + .send({}); + + expect(response.status).toBe(403); + expect(releaseBatch).not.toHaveBeenCalled(); + }); + + it('POST .../batches/:batchWeek/unrelease requires confirm and returns warning', async () => { + unreleaseBatch.mockResolvedValue({ + data: { + tenantKey: 'nyc', + batchWeek: '2026-W28', + unreleasedCount: 3, + skippedCount: 0, + batchStatus: 'curating', + remainingPublished: 0, + warning: 'Unrelease removes events from the live feed.', + }, + }); + + const response = await request(buildApp()) + .post('/admin/pivot/tenants/nyc/batches/2026-W28/unrelease') + .send({ confirm: 'UNRELEASE' }); + + expect(response.status).toBe(200); + expect(response.body.data.unreleasedCount).toBe(3); + expect(response.body.data.warning).toMatch(/live feed/i); + expect(unreleaseBatch).toHaveBeenCalledWith( + expect.objectContaining({ globalDb: {} }), + expect.objectContaining({ + tenantKey: 'nyc', + batchWeek: '2026-W28', + confirm: 'UNRELEASE', + }), + ); + }); + + it('POST .../batches/:batchWeek/unrelease returns CONFIRMATION_REQUIRED', async () => { + unreleaseBatch.mockResolvedValue({ + error: 'Type UNRELEASE to confirm.', + status: 400, + code: 'CONFIRMATION_REQUIRED', + }); + + const response = await request(buildApp()) + .post('/admin/pivot/tenants/nyc/batches/2026-W28/unrelease') + .send({ confirm: 'nope' }); + + expect(response.status).toBe(400); + expect(response.body.code).toBe('CONFIRMATION_REQUIRED'); + }); +}); + +describe('pivotAdminRoutes batch readiness', () => { + beforeEach(() => { + getBatchReadiness.mockReset(); + requirePlatformAdmin.mockImplementation((req, res, next) => next()); + }); + + it('GET .../batches/:batchWeek/readiness returns score payload', async () => { + getBatchReadiness.mockResolvedValue({ + data: { + tenantKey: 'nyc', + batchWeek: '2026-W28', + score: 72, + targetEventCount: 40, + components: [{ key: 'eventCount', value: 28, status: 'below' }], + ctas: [{ id: 'add-events', label: 'Add 12 more events' }], + formula: { version: 'v0' }, + }, + }); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/nyc/batches/2026-W28/readiness', + ); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.score).toBe(72); + expect(response.body.data.formula.version).toBe('v0'); + expect(getBatchReadiness).toHaveBeenCalledWith( + expect.objectContaining({ globalDb: {} }), + expect.objectContaining({ + tenantKey: 'nyc', + batchWeek: '2026-W28', + }), + ); + }); + + it('GET readiness returns 404 for unknown tenant', async () => { + getBatchReadiness.mockResolvedValue({ + error: 'Pivot tenant not found.', + status: 404, + code: 'TENANT_NOT_FOUND', + }); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/missing/batches/2026-W28/readiness', + ); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('TENANT_NOT_FOUND'); + }); + + it('GET readiness returns 403 for non-admin', async () => { + requirePlatformAdmin.mockImplementation((_req, res) => + res.status(403).json({ message: 'Forbidden' }), + ); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/nyc/batches/2026-W28/readiness', + ); + + expect(response.status).toBe(403); + expect(getBatchReadiness).not.toHaveBeenCalled(); + }); +}); + +describe('pivotAdminRoutes curation-jobs', () => { + const JOB_ID = '665a1b2c3d4e5f6789012345'; + + beforeEach(() => { + listCurationJobs.mockReset(); + createCurationJob.mockReset(); + updateCurationJob.mockReset(); + deleteCurationJob.mockReset(); + requirePlatformAdmin.mockImplementation((req, res, next) => next()); + }); + + it('GET /tenants/:tenantKey/curation-jobs lists jobs for one city', async () => { + listCurationJobs.mockResolvedValue({ + data: { + tenantKey: 'nyc', + jobs: [ + { + _id: JOB_ID, + tenantKey: 'nyc', + label: 'Partiful explore', + provider: 'partiful', + }, + ], + }, + }); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/nyc/curation-jobs', + ); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.jobs).toHaveLength(1); + expect(listCurationJobs).toHaveBeenCalledWith( + expect.objectContaining({ globalDb: {} }), + expect.objectContaining({ tenantKey: 'nyc' }), + ); + }); + + it('GET /tenants/:tenantKey/curation-jobs returns 404 for unknown tenant', async () => { + listCurationJobs.mockResolvedValue({ + error: 'Pivot tenant not found.', + status: 404, + code: 'TENANT_NOT_FOUND', + }); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/missing/curation-jobs', + ); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('TENANT_NOT_FOUND'); + }); + + it('POST /tenants/:tenantKey/curation-jobs creates a job', async () => { + createCurationJob.mockResolvedValue({ + data: { + job: { + _id: JOB_ID, + tenantKey: 'nyc', + label: 'Partiful explore', + url: 'https://partiful.com/explore/brooklyn', + provider: 'partiful', + }, + }, + }); + + const response = await request(buildApp()) + .post('/admin/pivot/tenants/nyc/curation-jobs') + .send({ + label: 'Partiful explore', + url: 'https://partiful.com/explore/brooklyn', + provider: 'partiful', + }); + + expect(response.status).toBe(200); + expect(response.body.data.job._id).toBe(JOB_ID); + expect(createCurationJob).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + tenantKey: 'nyc', + label: 'Partiful explore', + url: 'https://partiful.com/explore/brooklyn', + provider: 'partiful', + }), + ); + }); + + it('POST /tenants/:tenantKey/curation-jobs rejects unsupported hosts', async () => { + createCurationJob.mockResolvedValue({ + error: 'URL must be a Partiful or Luma event or explore link.', + status: 400, + code: 'UNSUPPORTED_HOST', + }); + + const response = await request(buildApp()) + .post('/admin/pivot/tenants/nyc/curation-jobs') + .send({ + label: 'Bad', + url: 'https://example.com/x', + provider: 'partiful', + }); + + expect(response.status).toBe(400); + expect(response.body.code).toBe('UNSUPPORTED_HOST'); + }); + + it('PATCH /tenants/:tenantKey/curation-jobs/:jobId updates a job', async () => { + updateCurationJob.mockResolvedValue({ + data: { + job: { + _id: JOB_ID, + tenantKey: 'nyc', + label: 'Renamed', + enabled: false, + }, + }, + }); + + const response = await request(buildApp()) + .patch(`/admin/pivot/tenants/nyc/curation-jobs/${JOB_ID}`) + .send({ label: 'Renamed', enabled: false }); + + expect(response.status).toBe(200); + expect(response.body.data.job.label).toBe('Renamed'); + expect(updateCurationJob).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + tenantKey: 'nyc', + jobId: JOB_ID, + label: 'Renamed', + enabled: false, + }), + ); + }); + + it('DELETE /tenants/:tenantKey/curation-jobs/:jobId is idempotent', async () => { + deleteCurationJob.mockResolvedValue({ + data: { tenantKey: 'nyc', jobId: JOB_ID, deleted: false }, + }); + + const response = await request(buildApp()).delete( + `/admin/pivot/tenants/nyc/curation-jobs/${JOB_ID}`, + ); + + expect(response.status).toBe(200); + expect(response.body.data.deleted).toBe(false); + expect(deleteCurationJob).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ tenantKey: 'nyc', jobId: JOB_ID }), + ); + }); + + it('curation-jobs routes return 403 for non-admin', async () => { + requirePlatformAdmin.mockImplementation((_req, res) => + res.status(403).json({ message: 'Forbidden' }), + ); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/nyc/curation-jobs', + ); + + expect(response.status).toBe(403); + expect(listCurationJobs).not.toHaveBeenCalled(); + }); +}); + +describe('pivotAdminRoutes curation-runs', () => { + const JOB_ID = '665a1b2c3d4e5f6789012345'; + const RUN_ID = '665a1b2c3d4e5f6789012999'; + + beforeEach(() => { + startCurationJobRun.mockReset(); + getCurationRun.mockReset(); + requirePlatformAdmin.mockImplementation((req, res, next) => next()); + }); + + it('POST /tenants/:tenantKey/curation-jobs/:jobId/run starts a queued run', async () => { + startCurationJobRun.mockResolvedValue({ + data: { + run: { + _id: RUN_ID, + tenantKey: 'nyc', + jobId: JOB_ID, + batchWeek: '2026-W28', + status: 'queued', + maxEvents: 120, + }, + }, + }); + + const response = await request(buildApp()) + .post(`/admin/pivot/tenants/nyc/curation-jobs/${JOB_ID}/run`) + .send({ batchWeek: '2026-W28', maxEvents: 120 }); + + expect(response.status).toBe(200); + expect(response.body.data.run.status).toBe('queued'); + expect(startCurationJobRun).toHaveBeenCalledWith( + expect.objectContaining({ globalDb: {} }), + expect.objectContaining({ + tenantKey: 'nyc', + jobId: JOB_ID, + batchWeek: '2026-W28', + maxEvents: 120, + }), + ); + }); + + it('GET /tenants/:tenantKey/curation-runs/:runId returns run status', async () => { + getCurationRun.mockResolvedValue({ + data: { + run: { + _id: RUN_ID, + tenantKey: 'nyc', + status: 'running', + stats: { discovered: 40, upserted: 12, skipped: 1, failed: 0 }, + }, + }, + }); + + const response = await request(buildApp()).get( + `/admin/pivot/tenants/nyc/curation-runs/${RUN_ID}`, + ); + + expect(response.status).toBe(200); + expect(response.body.data.run.status).toBe('running'); + expect(getCurationRun).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ tenantKey: 'nyc', runId: RUN_ID }), + ); + }); + + it('GET curation-runs returns 404 for unknown run', async () => { + getCurationRun.mockResolvedValue({ + error: 'Curation run not found.', + status: 404, + code: 'RUN_NOT_FOUND', + }); + + const response = await request(buildApp()).get( + `/admin/pivot/tenants/nyc/curation-runs/${RUN_ID}`, + ); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('RUN_NOT_FOUND'); + }); + + it('POST run returns 403 for non-admin', async () => { + requirePlatformAdmin.mockImplementation((_req, res) => + res.status(403).json({ message: 'Forbidden' }), + ); + + const response = await request(buildApp()) + .post(`/admin/pivot/tenants/nyc/curation-jobs/${JOB_ID}/run`) + .send({ batchWeek: '2026-W28' }); + + expect(response.status).toBe(403); + expect(startCurationJobRun).not.toHaveBeenCalled(); + }); +}); + describe('pivotAdminRoutes retention', () => { beforeEach(() => { getPivotRetention.mockReset(); @@ -549,3 +1258,182 @@ describe('pivotAdminRoutes dev purge', () => { expect(response.body.code).toBe('NOT_FOUND'); }); }); + +describe('pivotAdminRoutes journeys', () => { + const USER_ID = '507f191e810c19729de860eb'; + + beforeEach(() => { + getJourneyOverview.mockReset(); + getJourneyFunnel.mockReset(); + getJourneyPath.mockReset(); + searchJourneyUsers.mockReset(); + getUserJourneyHistory.mockReset(); + wipeUserWeekIntents.mockReset(); + requirePlatformAdmin.mockImplementation((req, res, next) => next()); + }); + + it('GET /tenants/:tenantKey/journeys/overview returns compact KPIs', async () => { + getJourneyOverview.mockResolvedValue({ + data: { + tenantKey: 'nyc', + batchWeek: '2026-W28', + kpis: { activeUsers: 10, medianCardsSeen: 5 }, + funnel: [{ key: 'swipes', value: 40 }], + }, + }); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/nyc/journeys/overview?batchWeek=2026-W28', + ); + + expect(response.status).toBe(200); + expect(response.body.data.kpis.activeUsers).toBe(10); + expect(getJourneyOverview).toHaveBeenCalledWith( + expect.objectContaining({ globalDb: {} }), + expect.objectContaining({ tenantKey: 'nyc', batchWeek: '2026-W28' }), + ); + }); + + it('GET /tenants/:tenantKey/journeys/funnel returns pivot-named steps', async () => { + getJourneyFunnel.mockResolvedValue({ + data: { + tenantKey: 'nyc', + batchWeek: '2026-W28', + steps: [ + { key: 'deck_open', event: 'pivot_card_view', count: 10 }, + { key: 'card_interested', event: 'pivot_card_interested', count: 6 }, + { key: 'external_open', event: 'pivot_external_open', count: 3 }, + { key: 'registered', event: 'pivot_confirm_registered', count: 1 }, + ], + }, + }); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/nyc/journeys/funnel?batchWeek=2026-W28', + ); + + expect(response.status).toBe(200); + expect(response.body.data.steps).toHaveLength(4); + expect(response.body.data.steps[0].event).toBe('pivot_card_view'); + }); + + it('GET /tenants/:tenantKey/journeys/path returns thin next-steps', async () => { + getJourneyPath.mockResolvedValue({ + data: { + tenantKey: 'nyc', + batchWeek: '2026-W28', + startingPoint: 'pivot_card_view', + startCount: 12, + nextSteps: [{ event: 'pivot_card_interested', count: 7 }], + }, + }); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/nyc/journeys/path?batchWeek=2026-W28&startingPoint=deck_open', + ); + + expect(response.status).toBe(200); + expect(response.body.data.nextSteps[0].event).toBe('pivot_card_interested'); + expect(getJourneyPath).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ startingPoint: 'deck_open' }), + ); + }); + + it('GET /tenants/:tenantKey/journeys/users searches by query', async () => { + searchJourneyUsers.mockResolvedValue({ + data: { + tenantKey: 'nyc', + users: [{ userId: USER_ID, name: 'Ada', intentCount: 2 }], + }, + }); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/nyc/journeys/users?query=Ada&batchWeek=2026-W28', + ); + + expect(response.status).toBe(200); + expect(response.body.data.users[0].name).toBe('Ada'); + expect(searchJourneyUsers).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ query: 'Ada', batchWeek: '2026-W28' }), + ); + }); + + it('GET /tenants/:tenantKey/journeys/users/:userId/history returns intents', async () => { + getUserJourneyHistory.mockResolvedValue({ + data: { + tenantKey: 'nyc', + batchWeek: '2026-W28', + user: { userId: USER_ID, name: 'Ada' }, + intents: [{ eventId: '665a1b2c3d4e5f6789012345', status: 'interested' }], + analytics: [], + }, + }); + + const response = await request(buildApp()).get( + `/admin/pivot/tenants/nyc/journeys/users/${USER_ID}/history?batchWeek=2026-W28`, + ); + + expect(response.status).toBe(200); + expect(response.body.data.intents).toHaveLength(1); + expect(getUserJourneyHistory).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ userId: USER_ID, batchWeek: '2026-W28' }), + ); + }); + + it('POST /tenants/:tenantKey/users/:userId/wipe-week wipes with confirm', async () => { + wipeUserWeekIntents.mockResolvedValue({ + data: { + tenantKey: 'nyc', + userId: USER_ID, + batchWeek: '2026-W28', + deletedCount: 3, + }, + }); + + const response = await request(buildApp()) + .post(`/admin/pivot/tenants/nyc/users/${USER_ID}/wipe-week`) + .send({ batchWeek: '2026-W28', confirm: 'WIPE' }); + + expect(response.status).toBe(200); + expect(response.body.data.deletedCount).toBe(3); + expect(wipeUserWeekIntents).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + userId: USER_ID, + batchWeek: '2026-W28', + confirm: 'WIPE', + }), + ); + }); + + it('POST wipe-week returns CONFIRM_REQUIRED when confirm missing', async () => { + wipeUserWeekIntents.mockResolvedValue({ + error: 'Confirmation required. Send confirm: "WIPE".', + status: 400, + code: 'CONFIRM_REQUIRED', + }); + + const response = await request(buildApp()) + .post(`/admin/pivot/tenants/nyc/users/${USER_ID}/wipe-week`) + .send({ batchWeek: '2026-W28' }); + + expect(response.status).toBe(400); + expect(response.body.code).toBe('CONFIRM_REQUIRED'); + }); + + it('GET journeys/funnel returns 403 for non-admin', async () => { + requirePlatformAdmin.mockImplementation((_req, res) => + res.status(403).json({ message: 'Forbidden' }), + ); + + const response = await request(buildApp()).get( + '/admin/pivot/tenants/nyc/journeys/funnel', + ); + + expect(response.status).toBe(403); + expect(getJourneyFunnel).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/unit/pivotAdminOverviewService.test.js b/backend/tests/unit/pivotAdminOverviewService.test.js index 116ad2ee..fed5e2d8 100644 --- a/backend/tests/unit/pivotAdminOverviewService.test.js +++ b/backend/tests/unit/pivotAdminOverviewService.test.js @@ -22,6 +22,16 @@ jest.mock('../../services/pivotWeeklySnapshotService', () => ({ getWeeklySnapshot: jest.fn(), aggregateEngagementMetrics: jest.fn(), })); +jest.mock('../../services/pivotIngestPublishService', () => ({ + resolvePivotTenant: jest.fn(), +})); +jest.mock('../../services/pivotLabEventsService', () => ({ + labEventsQuery: jest.requireActual('../../services/pivotLabEventsService').labEventsQuery, + loadIntentStatsByEventId: jest.fn(), +})); +jest.mock('../../services/pivotFeedService', () => ({ + PIVOT_EVENT_STATUSES: ['approved', 'published'], +})); const getModels = require('../../services/getModelService'); const { connectToDatabase } = require('../../connectionsManager'); @@ -32,9 +42,17 @@ const { getWeeklySnapshot, aggregateEngagementMetrics, } = require('../../services/pivotWeeklySnapshotService'); +const { resolvePivotTenant } = require('../../services/pivotIngestPublishService'); +const { loadIntentStatsByEventId } = require('../../services/pivotLabEventsService'); const { aggregateRegisteredFeedback, + buildFunnelStages, + buildVsPrevWeek, + comparePerformanceRows, getPivotOverview, + getTenantOverview, + getTenantEventPerformance, + serializePerformanceEvent, } = require('../../services/pivotAdminOverviewService'); describe('pivotAdminOverviewService', () => { @@ -157,4 +175,262 @@ describe('pivotAdminOverviewService', () => { expect(result.data.snapshotGeneratedAt).toEqual(new Date('2026-06-26T10:00:00.000Z')); }); }); + + describe('buildFunnelStages', () => { + it('matches Lab FunnelChart definitions', () => { + expect( + buildFunnelStages({ + swipeCount: 20, + interestedCount: 5, + registeredCount: 3, + externalOpenUsers: 4, + }), + ).toEqual([ + { key: 'swipes', label: 'Swipes', value: 20, hint: 'cards acted on' }, + { key: 'interested', label: 'Interested', value: 8, hint: 'right swipes' }, + { key: 'openers', label: 'Ticket openers', value: 4, hint: 'unique users' }, + { key: 'going', label: 'Going', value: 3, hint: 'self-confirmed' }, + ]); + }); + }); + + describe('buildVsPrevWeek', () => { + it('computes deltas for KPI keys', () => { + const deltas = buildVsPrevWeek( + { activeUsers: 10, eventCount: 5, feedbackAvg: 4.5, interestedCount: 2 }, + { activeUsers: 8, eventCount: 5, feedbackAvg: 4.0, interestedCount: 3 }, + ); + expect(deltas.activeUsers).toEqual({ current: 10, previous: 8, delta: 2 }); + expect(deltas.eventCount).toEqual({ current: 5, previous: 5, delta: 0 }); + expect(deltas.feedbackAvg).toEqual({ current: 4.5, previous: 4, delta: 0.5 }); + }); + }); + + describe('serializePerformanceEvent / comparePerformanceRows', () => { + it('ranks by interestedTotal then externalOpen', () => { + const a = serializePerformanceEvent( + { _id: 'a', name: 'A', customFields: { pivot: { ingestStatus: 'published' } } }, + { interested: 2, registered: 1, passed: 1, externalOpens: 1, externalOpenUsers: 1 }, + ); + const b = serializePerformanceEvent( + { _id: 'b', name: 'B', customFields: { pivot: { ingestStatus: 'draft' } } }, + { interested: 5, registered: 0, passed: 5, externalOpens: 0, externalOpenUsers: 0 }, + ); + expect(a.interestedTotal).toBe(3); + expect(a.interestRate).toBe(0.75); + expect(a.ticketOpenRate).toBeCloseTo(0.333, 2); + expect(comparePerformanceRows(a, b)).toBeGreaterThan(0); + expect([b, a].sort(comparePerformanceRows).map((row) => row.eventId)).toEqual(['b', 'a']); + }); + }); + + describe('getTenantOverview', () => { + function mockTenantModels({ + publishedCount = 2, + statusRows = [ + { _id: 'published', count: 2 }, + { _id: 'draft', count: 1 }, + ], + } = {}) { + connectToDatabase.mockResolvedValue({}); + getModels.mockImplementation(() => ({ + Event: { + countDocuments: jest.fn().mockResolvedValue(publishedCount), + find: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue( + Array.from({ length: publishedCount }, (_, i) => ({ + _id: `665a1b2c3d4e5f678901234${i}`, + })), + ), + }), + }), + aggregate: jest.fn().mockResolvedValue(statusRows), + }, + PivotEventIntent: { + countDocuments: jest.fn().mockImplementation((filter) => { + if (filter.status === 'interested') return Promise.resolve(4); + if (filter.status === 'registered') return Promise.resolve(2); + if (filter.status === 'passed') return Promise.resolve(6); + return Promise.resolve(0); + }), + distinct: jest.fn().mockImplementation((_field, filter) => { + if (filter.externalOpenAt) { + return Promise.resolve(['u1', 'u2']); + } + return Promise.resolve(['u1', 'u2', 'u3']); + }), + aggregate: jest.fn().mockResolvedValue([{ _id: null, total: 7 }]), + find: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue([]), + }), + }), + }, + UniversalFeedback: { + find: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue([]), + }), + }), + }, + })); + getGlobalModels.mockReturnValue({ + PivotReferralCode: { + find: jest.fn().mockReturnValue({ + sort: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue([]), + }), + }), + }, + }); + } + + it('returns metrics for one tenant with funnel and status breakdown', async () => { + resolvePivotTenant.mockResolvedValue({ + tenant: { tenantKey: 'nyc', tenantType: 'pivot', location: 'New York City' }, + }); + mockTenantModels(); + + const result = await getTenantOverview( + { globalDb: {} }, + { tenantKey: 'nyc', batchWeek: '2026-W26' }, + ); + + expect(result.error).toBeUndefined(); + expect(result.data.tenantKey).toBe('nyc'); + expect(result.data.batchWeek).toBe('2026-W26'); + expect(result.data.previousBatchWeek).toBe('2026-W25'); + expect(result.data.kpis).toMatchObject({ + activeUsers: 3, + eventCount: 2, + interestedCount: 4, + registeredCount: 2, + externalOpenCount: 7, + externalOpenUsers: 2, + swipeCount: 12, + eventCountsByStatus: { + draft: 1, + staged: 0, + published: 2, + other: 0, + total: 3, + }, + }); + expect(result.data.funnel[0]).toMatchObject({ key: 'swipes', value: 12 }); + expect(result.data.funnel[1]).toMatchObject({ key: 'interested', value: 6 }); + expect(result.data.dropSchedule.batchWeek).toBe('2026-W26'); + expect(result.data.vsPrevWeek).toBeTruthy(); + expect(resolvePivotTenant).toHaveBeenCalledWith( + expect.objectContaining({ globalDb: {} }), + 'nyc', + ); + // Only one city connection — not a fleet loop. + expect(connectToDatabase.mock.calls.every(([key]) => key === 'nyc')).toBe(true); + }); + + it('returns TENANT_NOT_FOUND for unknown pivot tenant', async () => { + resolvePivotTenant.mockResolvedValue({ + error: 'Pivot tenant not found.', + status: 404, + code: 'TENANT_NOT_FOUND', + }); + + const result = await getTenantOverview( + { globalDb: {} }, + { tenantKey: 'missing', batchWeek: '2026-W26' }, + ); + + expect(result).toMatchObject({ + status: 404, + code: 'TENANT_NOT_FOUND', + }); + expect(connectToDatabase).not.toHaveBeenCalled(); + }); + + it('rejects invalid batchWeek', async () => { + const result = await getTenantOverview( + { globalDb: {} }, + { tenantKey: 'nyc', batchWeek: 'not-a-week' }, + ); + expect(result.code).toBe('INVALID_BATCH_WEEK'); + expect(resolvePivotTenant).not.toHaveBeenCalled(); + }); + }); + + describe('getTenantEventPerformance', () => { + it('returns events sorted by interestedTotal', async () => { + resolvePivotTenant.mockResolvedValue({ + tenant: { tenantKey: 'nyc', tenantType: 'pivot', location: 'New York City' }, + }); + connectToDatabase.mockResolvedValue({}); + getModels.mockReturnValue({ + Event: { + find: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue([ + { + _id: 'e-low', + name: 'Quiet Night', + customFields: { pivot: { ingestStatus: 'published' } }, + }, + { + _id: 'e-high', + name: 'Hot Show', + customFields: { pivot: { ingestStatus: 'published' } }, + }, + ]), + }), + }), + }, + PivotEventIntent: {}, + }); + loadIntentStatsByEventId.mockResolvedValue( + new Map([ + [ + 'e-low', + { interested: 1, registered: 0, passed: 4, externalOpens: 0, externalOpenUsers: 0 }, + ], + [ + 'e-high', + { interested: 8, registered: 2, passed: 1, externalOpens: 5, externalOpenUsers: 4 }, + ], + ]), + ); + + const result = await getTenantEventPerformance( + { globalDb: {} }, + { tenantKey: 'nyc', batchWeek: '2026-W26', limit: 10 }, + ); + + expect(result.data.sortBy).toBe('interestedTotal'); + expect(result.data.events.map((row) => row.eventId)).toEqual(['e-high', 'e-low']); + expect(result.data.events[0]).toMatchObject({ + interestedTotal: 10, + reached: 11, + externalOpen: 5, + interestRate: 0.909, + }); + expect(loadIntentStatsByEventId).toHaveBeenCalledWith( + expect.anything(), + ['e-low', 'e-high'], + { batchWeek: '2026-W26' }, + ); + }); + + it('returns TENANT_NOT_FOUND for wrong tenantKey', async () => { + resolvePivotTenant.mockResolvedValue({ + error: 'Pivot tenant not found.', + status: 404, + code: 'TENANT_NOT_FOUND', + }); + + const result = await getTenantEventPerformance( + { globalDb: {} }, + { tenantKey: 'rpi', batchWeek: '2026-W26' }, + ); + + expect(result.code).toBe('TENANT_NOT_FOUND'); + }); + }); }); diff --git a/backend/tests/unit/pivotBatchReadinessService.test.js b/backend/tests/unit/pivotBatchReadinessService.test.js new file mode 100644 index 00000000..568b7a77 --- /dev/null +++ b/backend/tests/unit/pivotBatchReadinessService.test.js @@ -0,0 +1,122 @@ +const { + buildBatchReadiness, + computeCatalogMetrics, + WEIGHTS, +} = require('../../services/pivotBatchReadinessService'); + +function makeEvent({ + ingestStatus = 'staged', + tags = ['nightlife'], + hostName = 'Host', + description = 'A fun night out', + image = 'https://example.com/img.jpg', +} = {}) { + return { + description, + image, + customFields: { + pivot: { + ingestStatus, + tags, + host: hostName ? { name: hostName } : {}, + }, + }, + }; +} + +describe('pivotBatchReadinessService', () => { + describe('computeCatalogMetrics', () => { + it('counts deck coverage and gaps', () => { + const metrics = computeCatalogMetrics([ + makeEvent({ ingestStatus: 'staged', tags: ['a'] }), + makeEvent({ ingestStatus: 'published', tags: ['a', 'b'] }), + makeEvent({ ingestStatus: 'draft', tags: [] }), + makeEvent({ ingestStatus: 'staged', tags: [], hostName: '' }), + ]); + + expect(metrics.readyCount).toBe(4); + expect(metrics.stagedCount).toBe(2); + expect(metrics.publishedCount).toBe(1); + expect(metrics.draftCount).toBe(1); + expect(metrics.deckCount).toBe(3); + expect(metrics.untaggedCount).toBe(1); + expect(metrics.missingHostCount).toBe(1); + expect(metrics.tagCoveragePct).toBeCloseTo(2 / 3); + expect(metrics.uniqueTags).toBe(2); + }); + }); + + describe('buildBatchReadiness', () => { + it('scores a strong catalog near 100 and emits no CTAs', () => { + const events = Array.from({ length: 40 }, (_, i) => + makeEvent({ + ingestStatus: i % 5 === 0 ? 'published' : 'staged', + tags: [`tag-${i % 8}`], + }), + ); + const metrics = computeCatalogMetrics(events); + const result = buildBatchReadiness({ + tenantKey: 'nyc', + batchWeek: '2026-W28', + metrics, + targetEventCount: 40, + hoursUntilDrop: 72, + benchmarks: { + readyCount: 35, + tagCoveragePct: 0.8, + hostCompletenessPct: 0.8, + diversityRatio: 0.2, + hoursUntilDrop: null, + }, + benchmarkWeeksUsed: 3, + }); + + expect(result.score).toBeGreaterThanOrEqual(85); + expect(result.formula.version).toBe('v0'); + expect(result.formula.weights).toEqual(WEIGHTS); + expect(result.components).toHaveLength(5); + expect(result.ctas).toHaveLength(0); + expect(result.benchmarkWeeksUsed).toBe(3); + }); + + it('fires CTAs and lowers score for a thin untagged catalog', () => { + const metrics = computeCatalogMetrics([ + makeEvent({ ingestStatus: 'draft', tags: [], hostName: '' }), + makeEvent({ ingestStatus: 'staged', tags: [], hostName: '' }), + ]); + const result = buildBatchReadiness({ + tenantKey: 'nyc', + batchWeek: '2026-W28', + metrics, + targetEventCount: 40, + hoursUntilDrop: 6, + benchmarks: { + readyCount: 36, + tagCoveragePct: 0.9, + hostCompletenessPct: 0.95, + diversityRatio: 0.4, + hoursUntilDrop: null, + }, + benchmarkWeeksUsed: 4, + }); + + expect(result.score).toBeLessThan(40); + expect(result.ctas.map((c) => c.id)).toEqual( + expect.arrayContaining(['add-events', 'tag-events', 'fix-hosts', 'stage-drafts']), + ); + const tagCta = result.ctas.find((c) => c.id === 'tag-events'); + expect(tagCta.href).toContain('filter=untagged'); + expect(tagCta.href).toContain('page=1'); + expect(tagCta.href).toContain('batchWeek=2026-W28'); + + const eventComponent = result.components.find((c) => c.key === 'eventCount'); + expect(eventComponent.status).toBe('below'); + expect(eventComponent.weight).toBe(0.4); + }); + + it('documents formula weights summing to 1', () => { + const sum = Object.values(WEIGHTS).reduce((a, b) => a + b, 0); + expect(sum).toBeCloseTo(1); + }); + }); +}); diff --git a/backend/tests/unit/pivotBatchReleaseService.test.js b/backend/tests/unit/pivotBatchReleaseService.test.js new file mode 100644 index 00000000..b0db3e6a --- /dev/null +++ b/backend/tests/unit/pivotBatchReleaseService.test.js @@ -0,0 +1,318 @@ +jest.mock('../../services/getModelService', () => jest.fn()); +jest.mock('../../connectionsManager', () => ({ + connectToDatabase: jest.fn(), +})); +jest.mock('../../services/pivotIngestPublishService', () => ({ + resolvePivotTenant: jest.fn(), +})); +jest.mock('../../services/pivotWeeklySnapshotService', () => ({ + normalizeBatchWeek: jest.requireActual('../../services/pivotWeeklySnapshotService') + .normalizeBatchWeek, + rebuildWeeklySnapshot: jest.fn(), +})); +jest.mock('../../services/pivotBatchService', () => ({ + ensurePivotBatch: jest.fn(), + serializePivotBatch: jest.requireActual('../../services/pivotBatchService').serializePivotBatch, + DEFAULT_TARGET_EVENT_COUNT: 40, +})); + +const getModels = require('../../services/getModelService'); +const { connectToDatabase } = require('../../connectionsManager'); +const { resolvePivotTenant } = require('../../services/pivotIngestPublishService'); +const { rebuildWeeklySnapshot } = require('../../services/pivotWeeklySnapshotService'); +const { ensurePivotBatch } = require('../../services/pivotBatchService'); +const { + releaseBatch, + unreleaseBatch, + UNRELEASE_CONFIRM_TOKEN, + normalizeEventIds, +} = require('../../services/pivotBatchReleaseService'); + +const TENANT = { tenantKey: 'nyc', location: 'New York City', name: 'NYC' }; +const BATCH_WEEK = '2026-W28'; +const EVENT_A = '665a1b2c3d4e5f6789012345'; +const EVENT_B = '665a1b2c3d4e5f6789012346'; +const NOW = new Date('2026-07-09T18:00:00.000Z'); + +function mockReq(overrides = {}) { + return { + globalDb: {}, + user: { + email: 'ops@meridian.app', + globalUserId: '507f191e810c19729de860ea', + }, + ...overrides, + }; +} + +describe('normalizeEventIds', () => { + it('returns null when omitted (release all)', () => { + expect(normalizeEventIds(undefined)).toEqual({ eventIds: null }); + expect(normalizeEventIds(null)).toEqual({ eventIds: null }); + }); + + it('rejects non-array and empty array', () => { + expect(normalizeEventIds('x').code).toBe('INVALID_EVENT_IDS'); + expect(normalizeEventIds([]).code).toBe('INVALID_EVENT_IDS'); + }); + + it('rejects invalid ObjectIds', () => { + expect(normalizeEventIds(['nope']).code).toBe('INVALID_EVENT_IDS'); + }); + + it('dedupes valid ids', () => { + const result = normalizeEventIds([EVENT_A, EVENT_A, EVENT_B]); + expect(result.eventIds).toHaveLength(2); + expect(String(result.eventIds[0])).toBe(EVENT_A); + }); +}); + +describe('releaseBatch', () => { + let Event; + let PivotBatch; + + beforeEach(() => { + getModels.mockReset(); + connectToDatabase.mockReset(); + resolvePivotTenant.mockReset(); + rebuildWeeklySnapshot.mockReset(); + ensurePivotBatch.mockReset(); + + connectToDatabase.mockResolvedValue({}); + resolvePivotTenant.mockResolvedValue({ tenant: TENANT }); + ensurePivotBatch.mockResolvedValue({ + data: { batchWeek: BATCH_WEEK, status: 'curating' }, + }); + rebuildWeeklySnapshot.mockResolvedValue({ + data: { batchWeek: BATCH_WEEK, tenants: [] }, + }); + + Event = { + updateMany: jest.fn().mockResolvedValue({ modifiedCount: 3 }), + countDocuments: jest.fn(), + }; + PivotBatch = { + findOneAndUpdate: jest.fn(() => ({ + lean: jest.fn().mockResolvedValue({ + _id: '665a1b2c3d4e5f6789019999', + batchWeek: BATCH_WEEK, + status: 'released', + targetEventCount: 40, + releasedAt: NOW, + releasedBy: 'ops@meridian.app', + }), + })), + }; + getModels.mockReturnValue({ Event, PivotBatch }); + }); + + it('returns 404 when tenant is not a pivot city', async () => { + resolvePivotTenant.mockResolvedValue({ + error: 'Pivot tenant not found.', + status: 404, + code: 'TENANT_NOT_FOUND', + }); + + const result = await releaseBatch(mockReq(), { + tenantKey: 'missing', + batchWeek: BATCH_WEEK, + }); + + expect(result.code).toBe('TENANT_NOT_FOUND'); + expect(Event.updateMany).not.toHaveBeenCalled(); + }); + + it('rejects invalid batchWeek', async () => { + const result = await releaseBatch(mockReq(), { + tenantKey: 'nyc', + batchWeek: 'not-a-week', + }); + expect(result.code).toBe('INVALID_BATCH_WEEK'); + }); + + it('flips all staged events to published and records audit fields', async () => { + const result = await releaseBatch(mockReq(), { + tenantKey: 'nyc', + batchWeek: BATCH_WEEK, + now: NOW, + }); + + expect(result.error).toBeUndefined(); + expect(result.data.releasedCount).toBe(3); + expect(result.data.skippedCount).toBe(0); + expect(result.data.batchStatus).toBe('released'); + expect(result.data.partial).toBe(false); + expect(result.data.batch.releasedBy).toBe('ops@meridian.app'); + expect(result.data.snapshot).toEqual({ rebuilt: true, batchWeek: BATCH_WEEK }); + + expect(Event.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + 'customFields.pivot.batchWeek': BATCH_WEEK, + 'customFields.pivot.ingestStatus': 'staged', + }), + { $set: { 'customFields.pivot.ingestStatus': 'published' } }, + ); + expect(Event.updateMany.mock.calls[0][0]._id).toBeUndefined(); + + expect(PivotBatch.findOneAndUpdate).toHaveBeenCalledWith( + { batchWeek: BATCH_WEEK }, + { + $set: { + status: 'released', + releasedAt: NOW, + releasedBy: 'ops@meridian.app', + }, + }, + expect.objectContaining({ new: true }), + ); + expect(rebuildWeeklySnapshot).toHaveBeenCalledWith( + expect.objectContaining({ globalDb: {} }), + expect.objectContaining({ batchWeek: BATCH_WEEK }), + ); + }); + + it('supports partial release via eventIds and reports skippedCount', async () => { + Event.updateMany.mockResolvedValue({ modifiedCount: 1 }); + + const result = await releaseBatch(mockReq(), { + tenantKey: 'nyc', + batchWeek: BATCH_WEEK, + eventIds: [EVENT_A, EVENT_B], + now: NOW, + rebuildSnapshot: false, + }); + + expect(result.data.releasedCount).toBe(1); + expect(result.data.skippedCount).toBe(1); + expect(result.data.partial).toBe(true); + expect(result.data.snapshot).toBeNull(); + expect(Event.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + _id: { $in: expect.any(Array) }, + 'customFields.pivot.ingestStatus': 'staged', + }), + expect.any(Object), + ); + expect(rebuildWeeklySnapshot).not.toHaveBeenCalled(); + }); +}); + +describe('unreleaseBatch', () => { + let Event; + let PivotBatch; + + beforeEach(() => { + getModels.mockReset(); + connectToDatabase.mockReset(); + resolvePivotTenant.mockReset(); + rebuildWeeklySnapshot.mockReset(); + ensurePivotBatch.mockReset(); + + connectToDatabase.mockResolvedValue({}); + resolvePivotTenant.mockResolvedValue({ tenant: TENANT }); + ensurePivotBatch.mockResolvedValue({ + data: { batchWeek: BATCH_WEEK, status: 'curating' }, + }); + rebuildWeeklySnapshot.mockResolvedValue({ + data: { batchWeek: BATCH_WEEK, tenants: [] }, + }); + + Event = { + updateMany: jest.fn().mockResolvedValue({ modifiedCount: 2 }), + countDocuments: jest.fn().mockResolvedValue(0), + }; + PivotBatch = { + findOneAndUpdate: jest.fn(() => ({ + lean: jest.fn().mockResolvedValue({ + _id: '665a1b2c3d4e5f6789019999', + batchWeek: BATCH_WEEK, + status: 'curating', + targetEventCount: 40, + releasedAt: null, + releasedBy: null, + }), + })), + }; + getModels.mockReturnValue({ Event, PivotBatch }); + }); + + it('requires typed UNRELEASE confirm token', async () => { + const result = await unreleaseBatch(mockReq(), { + tenantKey: 'nyc', + batchWeek: BATCH_WEEK, + confirm: 'yes', + }); + + expect(result.status).toBe(400); + expect(result.code).toBe('CONFIRMATION_REQUIRED'); + expect(Event.updateMany).not.toHaveBeenCalled(); + }); + + it('flips published → staged and clears batch release audit when none remain', async () => { + const result = await unreleaseBatch(mockReq(), { + tenantKey: 'nyc', + batchWeek: BATCH_WEEK, + confirm: UNRELEASE_CONFIRM_TOKEN, + now: NOW, + }); + + expect(result.error).toBeUndefined(); + expect(result.data.unreleasedCount).toBe(2); + expect(result.data.batchStatus).toBe('curating'); + expect(result.data.remainingPublished).toBe(0); + expect(result.data.warning).toMatch(/already swiped/i); + + expect(Event.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + 'customFields.pivot.batchWeek': BATCH_WEEK, + 'customFields.pivot.ingestStatus': 'published', + }), + { $set: { 'customFields.pivot.ingestStatus': 'staged' } }, + ); + expect(PivotBatch.findOneAndUpdate).toHaveBeenCalledWith( + { batchWeek: BATCH_WEEK }, + { + $set: { + status: 'curating', + releasedAt: null, + releasedBy: null, + }, + }, + expect.objectContaining({ new: true }), + ); + }); + + it('keeps batch released when some published events remain after partial unrelease', async () => { + Event.updateMany.mockResolvedValue({ modifiedCount: 1 }); + Event.countDocuments.mockResolvedValue(2); + PivotBatch.findOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: '665a1b2c3d4e5f6789019999', + batchWeek: BATCH_WEEK, + status: 'released', + targetEventCount: 40, + releasedAt: NOW, + releasedBy: 'ops@meridian.app', + }), + }); + + const result = await unreleaseBatch(mockReq(), { + tenantKey: 'nyc', + batchWeek: BATCH_WEEK, + confirm: UNRELEASE_CONFIRM_TOKEN, + eventIds: [EVENT_A], + now: NOW, + rebuildSnapshot: false, + }); + + expect(result.data.unreleasedCount).toBe(1); + expect(result.data.skippedCount).toBe(0); + expect(result.data.remainingPublished).toBe(2); + expect(result.data.batchStatus).toBe('released'); + expect(PivotBatch.findOneAndUpdate).toHaveBeenCalledWith( + { batchWeek: BATCH_WEEK }, + { $set: { status: 'released' } }, + expect.any(Object), + ); + }); +}); diff --git a/backend/tests/unit/pivotBatchService.test.js b/backend/tests/unit/pivotBatchService.test.js new file mode 100644 index 00000000..26ea4824 --- /dev/null +++ b/backend/tests/unit/pivotBatchService.test.js @@ -0,0 +1,67 @@ +jest.mock('../../services/getModelService', () => jest.fn()); + +const getModels = require('../../services/getModelService'); +const { + ensurePivotBatch, + getPivotBatch, + serializePivotBatch, +} = require('../../services/pivotBatchService'); + +describe('pivotBatchService', () => { + beforeEach(() => { + getModels.mockReset(); + }); + + it('rejects invalid batchWeek', async () => { + const result = await ensurePivotBatch({ db: {} }, { batchWeek: 'nope' }); + expect(result.status).toBe(400); + expect(result.code).toBe('INVALID_BATCH_WEEK'); + }); + + it('upserts a curating batch on first curation', async () => { + const leanDoc = { + _id: '665a1b2c3d4e5f6789019999', + batchWeek: '2026-W28', + status: 'curating', + targetEventCount: 40, + releasedAt: null, + releasedBy: null, + createdAt: new Date('2026-07-01T00:00:00.000Z'), + updatedAt: new Date('2026-07-01T00:00:00.000Z'), + }; + const findOneAndUpdate = jest.fn(() => ({ + lean: jest.fn().mockResolvedValue(leanDoc), + })); + getModels.mockReturnValue({ PivotBatch: { findOneAndUpdate } }); + + const result = await ensurePivotBatch({ db: {} }, { batchWeek: '2026-W28' }); + + expect(result.data).toEqual(serializePivotBatch(leanDoc)); + expect(findOneAndUpdate).toHaveBeenCalledWith( + { batchWeek: '2026-W28' }, + { + $setOnInsert: expect.objectContaining({ + batchWeek: '2026-W28', + status: 'curating', + targetEventCount: 40, + releasedAt: null, + releasedBy: null, + }), + }, + expect.objectContaining({ upsert: true, new: true }), + ); + }); + + it('returns null when batch does not exist', async () => { + getModels.mockReturnValue({ + PivotBatch: { + findOne: jest.fn(() => ({ + lean: jest.fn().mockResolvedValue(null), + })), + }, + }); + + const result = await getPivotBatch({ db: {} }, '2026-W28'); + expect(result.data).toBeNull(); + }); +}); diff --git a/backend/tests/unit/pivotCurationJobService.test.js b/backend/tests/unit/pivotCurationJobService.test.js new file mode 100644 index 00000000..d8974364 --- /dev/null +++ b/backend/tests/unit/pivotCurationJobService.test.js @@ -0,0 +1,280 @@ +jest.mock('../../services/getGlobalModelService', () => jest.fn()); +jest.mock('../../services/pivotIngestPublishService', () => ({ + resolvePivotTenant: jest.fn(), +})); + +const getGlobalModels = require('../../services/getGlobalModelService'); +const { resolvePivotTenant } = require('../../services/pivotIngestPublishService'); +const { + listCurationJobs, + createCurationJob, + updateCurationJob, + deleteCurationJob, +} = require('../../services/pivotCurationJobService'); + +const JOB_ID = '665a1b2c3d4e5f6789012345'; +const OTHER_JOB_ID = '665a1b2c3d4e5f6789012346'; + +function mockReq(overrides = {}) { + return { + globalDb: {}, + user: { email: 'ops@meridian.app', globalUserId: '507f191e810c19729de860ea' }, + ...overrides, + }; +} + +function leanDoc(overrides = {}) { + return { + _id: JOB_ID, + tenantKey: 'nyc', + label: 'Partiful Brooklyn explore', + url: 'https://partiful.com/explore/brooklyn', + provider: 'partiful', + defaultBatchWeekStrategy: 'next-drop', + defaultTags: ['nightlife'], + enabled: true, + lastRunAt: null, + lastRunStatus: null, + lastRunStats: null, + createdBy: 'ops@meridian.app', + createdAt: new Date('2026-07-09T12:00:00.000Z'), + updatedAt: new Date('2026-07-09T12:00:00.000Z'), + ...overrides, + }; +} + +describe('pivotCurationJobService', () => { + let PivotCurationJob; + + beforeEach(() => { + PivotCurationJob = { + find: jest.fn(), + create: jest.fn(), + findOne: jest.fn(), + findOneAndDelete: jest.fn(), + }; + getGlobalModels.mockReturnValue({ PivotCurationJob }); + resolvePivotTenant.mockResolvedValue({ + tenant: { tenantKey: 'nyc', cityDisplayName: 'New York City' }, + }); + }); + + describe('listCurationJobs', () => { + it('lists jobs filtered by tenantKey', async () => { + const docs = [leanDoc(), leanDoc({ _id: OTHER_JOB_ID, label: 'Luma' })]; + PivotCurationJob.find.mockReturnValue({ + sort: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue(docs), + }), + }); + + const result = await listCurationJobs(mockReq(), { tenantKey: 'nyc' }); + + expect(result.data.tenantKey).toBe('nyc'); + expect(result.data.jobs).toHaveLength(2); + expect(PivotCurationJob.find).toHaveBeenCalledWith({ tenantKey: 'nyc' }); + expect(getGlobalModels).toHaveBeenCalledWith(expect.any(Object), 'PivotCurationJob'); + }); + + it('propagates TENANT_NOT_FOUND', async () => { + resolvePivotTenant.mockResolvedValue({ + error: 'Pivot tenant not found.', + status: 404, + code: 'TENANT_NOT_FOUND', + }); + + const result = await listCurationJobs(mockReq(), { tenantKey: 'missing' }); + expect(result.code).toBe('TENANT_NOT_FOUND'); + expect(PivotCurationJob.find).not.toHaveBeenCalled(); + }); + }); + + describe('createCurationJob', () => { + it('creates a partiful job with allowlisted URL', async () => { + PivotCurationJob.create.mockResolvedValue(leanDoc()); + + const result = await createCurationJob(mockReq(), { + tenantKey: 'nyc', + label: 'Partiful Brooklyn explore', + url: 'https://partiful.com/explore/brooklyn', + provider: 'partiful', + defaultTags: ['nightlife', ''], + }); + + expect(result.data.job.label).toBe('Partiful Brooklyn explore'); + expect(PivotCurationJob.create).toHaveBeenCalledWith( + expect.objectContaining({ + tenantKey: 'nyc', + label: 'Partiful Brooklyn explore', + provider: 'partiful', + url: 'https://partiful.com/explore/brooklyn', + defaultBatchWeekStrategy: 'next-drop', + defaultTags: ['nightlife'], + enabled: true, + createdBy: 'ops@meridian.app', + }), + ); + }); + + it('rejects unsupported host URLs', async () => { + const result = await createCurationJob(mockReq(), { + tenantKey: 'nyc', + label: 'Bad host', + url: 'https://example.com/events', + provider: 'partiful', + }); + + expect(result.code).toBe('UNSUPPORTED_HOST'); + expect(result.status).toBe(400); + expect(PivotCurationJob.create).not.toHaveBeenCalled(); + }); + + it('rejects invalid URLs', async () => { + const result = await createCurationJob(mockReq(), { + tenantKey: 'nyc', + label: 'Bad url', + url: 'not-a-url', + provider: 'luma', + }); + + expect(result.code).toBe('INVALID_URL'); + expect(PivotCurationJob.create).not.toHaveBeenCalled(); + }); + + it('allows manual-json jobs without a URL', async () => { + PivotCurationJob.create.mockResolvedValue( + leanDoc({ + label: 'JSON paste', + url: null, + provider: 'manual-json', + defaultTags: [], + }), + ); + + const result = await createCurationJob(mockReq(), { + tenantKey: 'nyc', + label: 'JSON paste', + provider: 'manual-json', + }); + + expect(result.data.job.provider).toBe('manual-json'); + expect(PivotCurationJob.create).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'manual-json', + url: null, + }), + ); + }); + + it('infers provider from URL when omitted', async () => { + PivotCurationJob.create.mockResolvedValue(leanDoc({ provider: 'luma' })); + + await createCurationJob(mockReq(), { + tenantKey: 'nyc', + label: 'Luma discover', + url: 'https://lu.ma/nyc', + }); + + expect(PivotCurationJob.create).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'luma', + url: 'https://lu.ma/nyc', + }), + ); + }); + + it('rejects provider/host mismatch', async () => { + const result = await createCurationJob(mockReq(), { + tenantKey: 'nyc', + label: 'Mismatch', + url: 'https://partiful.com/explore/brooklyn', + provider: 'luma', + }); + + expect(result.code).toBe('PROVIDER_MISMATCH'); + expect(PivotCurationJob.create).not.toHaveBeenCalled(); + }); + }); + + describe('updateCurationJob', () => { + it('updates label and tags for a tenant-scoped job', async () => { + const doc = { + ...leanDoc(), + save: jest.fn().mockResolvedValue(undefined), + toObject() { + return leanDoc({ label: 'Renamed', defaultTags: ['film'] }); + }, + }; + Object.assign(doc, { label: 'Partiful Brooklyn explore', defaultTags: ['nightlife'] }); + PivotCurationJob.findOne.mockResolvedValue(doc); + + const result = await updateCurationJob(mockReq(), { + tenantKey: 'nyc', + jobId: JOB_ID, + label: 'Renamed', + defaultTags: ['film'], + }); + + expect(doc.label).toBe('Renamed'); + expect(doc.defaultTags).toEqual(['film']); + expect(doc.save).toHaveBeenCalled(); + expect(result.data.job.label).toBe('Renamed'); + }); + + it('returns JOB_NOT_FOUND when job belongs to another tenant', async () => { + PivotCurationJob.findOne.mockResolvedValue(null); + + const result = await updateCurationJob(mockReq(), { + tenantKey: 'nyc', + jobId: JOB_ID, + label: 'Nope', + }); + + expect(result.code).toBe('JOB_NOT_FOUND'); + expect(PivotCurationJob.findOne).toHaveBeenCalledWith({ + _id: JOB_ID, + tenantKey: 'nyc', + }); + }); + }); + + describe('deleteCurationJob', () => { + it('deletes a job scoped to the tenant', async () => { + PivotCurationJob.findOneAndDelete.mockResolvedValue(leanDoc()); + + const result = await deleteCurationJob(mockReq(), { + tenantKey: 'nyc', + jobId: JOB_ID, + }); + + expect(result.data.deleted).toBe(true); + expect(PivotCurationJob.findOneAndDelete).toHaveBeenCalledWith({ + _id: JOB_ID, + tenantKey: 'nyc', + }); + }); + + it('is idempotent when the job is already gone', async () => { + PivotCurationJob.findOneAndDelete.mockResolvedValue(null); + + const result = await deleteCurationJob(mockReq(), { + tenantKey: 'nyc', + jobId: JOB_ID, + }); + + expect(result.error).toBeUndefined(); + expect(result.data.deleted).toBe(false); + expect(result.data.jobId).toBe(JOB_ID); + }); + + it('rejects invalid job ids', async () => { + const result = await deleteCurationJob(mockReq(), { + tenantKey: 'nyc', + jobId: 'not-an-id', + }); + + expect(result.code).toBe('INVALID_JOB_ID'); + expect(PivotCurationJob.findOneAndDelete).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/tests/unit/pivotCurationRunService.test.js b/backend/tests/unit/pivotCurationRunService.test.js new file mode 100644 index 00000000..adabf123 --- /dev/null +++ b/backend/tests/unit/pivotCurationRunService.test.js @@ -0,0 +1,496 @@ +jest.mock('../../services/getGlobalModelService', () => jest.fn()); +jest.mock('../../connectionsManager', () => ({ + connectToDatabase: jest.fn(), + connectToGlobalDatabase: jest.fn(), +})); +jest.mock('../../services/pivotIngestPublishService', () => ({ + resolvePivotTenant: jest.fn(), + publishIngestEvent: jest.fn(), +})); +jest.mock('../../services/pivotIngestPreviewService', () => ({ + previewIngestUrl: jest.fn(), + MAX_CRAWL_BATCH_EVENTS: null, + resolveBatchLimit: (n) => { + if (n == null || n === '') return null; + const num = Number(n); + if (!Number.isFinite(num) || num <= 0) return null; + return Math.min(Math.floor(num), 10_000); + }, +})); +jest.mock('../../services/pivotBatchService', () => ({ + ensurePivotBatch: jest.fn(), +})); + +const getGlobalModels = require('../../services/getGlobalModelService'); +const { + connectToDatabase, + connectToGlobalDatabase, +} = require('../../connectionsManager'); +const { + resolvePivotTenant, + publishIngestEvent, +} = require('../../services/pivotIngestPublishService'); +const { previewIngestUrl } = require('../../services/pivotIngestPreviewService'); +const { ensurePivotBatch } = require('../../services/pivotBatchService'); +const { + startCurationJobRun, + getCurationRun, + executeCurationRun, + resolveRunBatchWeek, + upsertDiscoveredEntry, +} = require('../../services/pivotCurationRunService'); + +const JOB_ID = '665a1b2c3d4e5f6789012345'; +const RUN_ID = '665a1b2c3d4e5f6789012999'; + +function mockReq(overrides = {}) { + return { + globalDb: {}, + user: { email: 'ops@meridian.app', globalUserId: '507f191e810c19729de860ea' }, + ...overrides, + }; +} + +function leanJob(overrides = {}) { + return { + _id: JOB_ID, + tenantKey: 'nyc', + label: 'Partiful explore', + url: 'https://partiful.com/explore/brooklyn', + provider: 'partiful', + defaultBatchWeekStrategy: 'next-drop', + defaultTags: ['nightlife'], + enabled: true, + ...overrides, + }; +} + +describe('pivotCurationRunService', () => { + let PivotCurationJob; + let PivotCurationRun; + + beforeEach(() => { + jest.clearAllMocks(); + PivotCurationJob = { + findOne: jest.fn(), + findById: jest.fn(), + findByIdAndUpdate: jest.fn().mockResolvedValue({}), + }; + PivotCurationRun = { + create: jest.fn(), + findById: jest.fn(), + findOne: jest.fn(), + findByIdAndUpdate: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue(null), + }), + }; + getGlobalModels.mockReturnValue({ PivotCurationJob, PivotCurationRun }); + resolvePivotTenant.mockResolvedValue({ + tenant: { tenantKey: 'nyc', cityDisplayName: 'New York City', pivotPilot: true }, + }); + connectToDatabase.mockResolvedValue({}); + connectToGlobalDatabase.mockResolvedValue({}); + ensurePivotBatch.mockResolvedValue({ data: { batchWeek: '2026-W28', status: 'curating' } }); + }); + + describe('resolveRunBatchWeek', () => { + it('uses explicit batchWeek when provided', () => { + const result = resolveRunBatchWeek({ + batchWeek: '2026-W28', + strategy: 'next-drop', + tenant: { tenantKey: 'nyc', pivotPilot: true }, + }); + expect(result.batchWeek).toBe('2026-W28'); + }); + + it('requires batchWeek for explicit strategy', () => { + const result = resolveRunBatchWeek({ + strategy: 'explicit', + tenant: { tenantKey: 'nyc', pivotPilot: true }, + }); + expect(result.code).toBe('BATCH_WEEK_REQUIRED'); + }); + + it('uses current ISO week for current-iso strategy', () => { + const result = resolveRunBatchWeek({ + strategy: 'current-iso', + tenant: { tenantKey: 'nyc', pivotPilot: true }, + now: new Date('2026-07-09T12:00:00.000Z'), + }); + expect(result.batchWeek).toMatch(/^\d{4}-W\d{2}$/); + }); + }); + + describe('startCurationJobRun', () => { + it('queues a run and schedules async execution', async () => { + const immediateSpy = jest + .spyOn(global, 'setImmediate') + .mockImplementation(() => {}); + + PivotCurationJob.findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue(leanJob()), + }); + const runDoc = { + _id: RUN_ID, + tenantKey: 'nyc', + jobId: JOB_ID, + batchWeek: '2026-W28', + status: 'queued', + maxEvents: null, + provider: 'partiful', + url: 'https://partiful.com/explore/brooklyn', + stats: { + discovered: 0, + upserted: 0, + skipped: 0, + failed: 0, + updated: 0, + message: null, + }, + failures: [], + createdBy: 'ops@meridian.app', + toObject() { + return this; + }, + }; + PivotCurationRun.create.mockResolvedValue(runDoc); + + const result = await startCurationJobRun(mockReq(), { + tenantKey: 'nyc', + jobId: JOB_ID, + batchWeek: '2026-W28', + maxEvents: 120, + }); + + expect(result.data.run.status).toBe('queued'); + expect(result.data.run.batchWeek).toBe('2026-W28'); + expect(PivotCurationRun.create).toHaveBeenCalledWith( + expect.objectContaining({ + tenantKey: 'nyc', + batchWeek: '2026-W28', + status: 'queued', + maxEvents: 120, + }), + ); + expect(immediateSpy).toHaveBeenCalled(); + immediateSpy.mockRestore(); + }); + + it('defaults to unlimited maxEvents when omitted', async () => { + const immediateSpy = jest + .spyOn(global, 'setImmediate') + .mockImplementation(() => {}); + + PivotCurationJob.findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue(leanJob()), + }); + PivotCurationRun.create.mockResolvedValue({ + _id: RUN_ID, + tenantKey: 'nyc', + jobId: JOB_ID, + batchWeek: '2026-W28', + status: 'queued', + maxEvents: null, + toObject() { + return this; + }, + }); + + await startCurationJobRun(mockReq(), { + tenantKey: 'nyc', + jobId: JOB_ID, + batchWeek: '2026-W28', + }); + + expect(PivotCurationRun.create).toHaveBeenCalledWith( + expect.objectContaining({ + maxEvents: null, + }), + ); + immediateSpy.mockRestore(); + }); + + it('rejects manual-json jobs', async () => { + PivotCurationJob.findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue( + leanJob({ provider: 'manual-json', url: null }), + ), + }); + + const result = await startCurationJobRun(mockReq(), { + tenantKey: 'nyc', + jobId: JOB_ID, + batchWeek: '2026-W28', + }); + + expect(result.code).toBe('PROVIDER_NOT_CRAWLABLE'); + expect(PivotCurationRun.create).not.toHaveBeenCalled(); + }); + + it('returns JOB_NOT_FOUND for other tenant jobs', async () => { + PivotCurationJob.findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue(null), + }); + + const result = await startCurationJobRun(mockReq(), { + tenantKey: 'nyc', + jobId: JOB_ID, + batchWeek: '2026-W28', + }); + + expect(result.code).toBe('JOB_NOT_FOUND'); + expect(PivotCurationJob.findOne).toHaveBeenCalledWith({ + _id: JOB_ID, + tenantKey: 'nyc', + }); + }); + }); + + describe('getCurationRun', () => { + it('returns a tenant-scoped run', async () => { + PivotCurationRun.findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: RUN_ID, + tenantKey: 'nyc', + jobId: JOB_ID, + batchWeek: '2026-W28', + status: 'completed', + stats: { + discovered: 60, + upserted: 55, + skipped: 3, + failed: 2, + updated: 10, + message: null, + }, + failures: [], + }), + }); + + const result = await getCurationRun(mockReq(), { + tenantKey: 'nyc', + runId: RUN_ID, + }); + + expect(result.data.run.status).toBe('completed'); + expect(result.data.run.stats.discovered).toBe(60); + expect(PivotCurationRun.findOne).toHaveBeenCalledWith({ + _id: RUN_ID, + tenantKey: 'nyc', + }); + }); + + it('returns RUN_NOT_FOUND when missing', async () => { + PivotCurationRun.findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue(null), + }); + + const result = await getCurationRun(mockReq(), { + tenantKey: 'nyc', + runId: RUN_ID, + }); + + expect(result.code).toBe('RUN_NOT_FOUND'); + }); + }); + + describe('upsertDiscoveredEntry', () => { + it('upserts with draft override and optional tags', async () => { + publishIngestEvent.mockResolvedValue({ + data: { event: { _id: 'e1' }, created: true, updated: false }, + }); + + const result = await upsertDiscoveredEntry(mockReq(), { + tenantKey: 'nyc', + batchWeek: '2026-W28', + entry: { + sourceUrl: 'https://partiful.com/e/abc', + draft: { + name: 'Party', + hostName: 'Host', + location: 'BK', + start_time: '2026-07-10T20:00:00.000Z', + source: 'partiful', + sourceUrl: 'https://partiful.com/e/abc', + }, + }, + defaultTags: ['nightlife'], + }); + + expect(result.upserted).toBe(true); + expect(publishIngestEvent).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + tenantKey: 'nyc', + batchWeek: '2026-W28', + forceBatchWeek: false, + url: 'https://partiful.com/e/abc', + tagsRequired: false, + overrides: expect.objectContaining({ + tags: ['nightlife'], + ingestStatus: 'staged', + }), + }), + ); + }); + + it('passes forceBatchWeek through to publish', async () => { + publishIngestEvent.mockResolvedValue({ + data: { + event: { _id: 'e1', batchWeek: '2026-W28' }, + created: true, + batchWeek: '2026-W28', + batchWeekSource: 'forced', + }, + }); + + await upsertDiscoveredEntry(mockReq(), { + tenantKey: 'nyc', + batchWeek: '2026-W28', + forceBatchWeek: true, + entry: { + sourceUrl: 'https://partiful.com/e/abc', + draft: { + name: 'Party', + hostName: 'Host', + location: 'BK', + start_time: '2026-07-10T20:00:00.000Z', + source: 'partiful', + sourceUrl: 'https://partiful.com/e/abc', + }, + }, + defaultTags: [], + }); + + expect(publishIngestEvent).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ forceBatchWeek: true, batchWeek: '2026-W28' }), + ); + }); + + it('skips incomplete drafts without aborting', async () => { + publishIngestEvent.mockResolvedValue({ + error: 'Missing required fields after merge: hostName.', + status: 400, + code: 'MISSING_REQUIRED_FIELDS', + }); + + const result = await upsertDiscoveredEntry(mockReq(), { + tenantKey: 'nyc', + batchWeek: '2026-W28', + entry: { + sourceUrl: 'https://partiful.com/e/abc', + draft: { name: 'Incomplete', sourceUrl: 'https://partiful.com/e/abc' }, + }, + defaultTags: [], + }); + + expect(result.skipped).toBe(true); + expect(result.code).toBe('MISSING_REQUIRED_FIELDS'); + }); + }); + + describe('executeCurationRun', () => { + it('processes more than 50 discovered drafts and continues after one failure', async () => { + const drafts = Array.from({ length: 60 }, (_, i) => ({ + sourceUrl: `https://partiful.com/e/event-${i}`, + draft: { + name: `Event ${i}`, + hostName: 'Host', + location: 'NYC', + start_time: '2026-07-10T20:00:00.000Z', + source: 'partiful', + sourceUrl: `https://partiful.com/e/event-${i}`, + }, + })); + + PivotCurationRun.findById.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: RUN_ID, + tenantKey: 'nyc', + jobId: JOB_ID, + batchWeek: '2026-W28', + status: 'queued', + maxEvents: null, + createdBy: 'ops@meridian.app', + }), + }); + PivotCurationJob.findById.mockReturnValue({ + lean: jest.fn().mockResolvedValue(leanJob()), + }); + previewIngestUrl.mockResolvedValue({ + data: { + mode: 'batch', + drafts, + truncated: false, + }, + }); + + let call = 0; + publishIngestEvent.mockImplementation(async () => { + call += 1; + if (call === 2) { + return { + error: 'boom', + status: 500, + code: 'UPSERT_FAILED', + }; + } + // Alternate weeks so one crawl fills multiple batches. + const week = call % 2 === 0 ? '2026-W29' : '2026-W28'; + return { + data: { + event: { _id: `e${call}`, batchWeek: week }, + created: true, + updated: false, + batchWeek: week, + batchWeekSource: 'event-date', + }, + }; + }); + + const patches = []; + PivotCurationRun.findByIdAndUpdate.mockImplementation((_id, update) => { + patches.push(update.$set); + return { lean: jest.fn().mockResolvedValue(update.$set) }; + }); + + await executeCurationRun(RUN_ID); + + expect(previewIngestUrl).toHaveBeenCalledWith( + expect.any(Object), + expect.not.objectContaining({ maxEvents: expect.anything() }), + ); + expect(previewIngestUrl).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + url: 'https://partiful.com/explore/brooklyn', + tenantKey: 'nyc', + }), + ); + expect(publishIngestEvent).toHaveBeenCalledTimes(60); + expect(ensurePivotBatch).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ batchWeek: '2026-W28', status: 'curating' }), + ); + expect(ensurePivotBatch).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ batchWeek: '2026-W29', status: 'curating' }), + ); + + const completed = patches.find((p) => p.status === 'completed'); + expect(completed).toBeTruthy(); + expect(completed.stats.discovered).toBe(60); + expect(completed.stats.upserted).toBe(59); + expect(completed.stats.failed).toBe(1); + expect(completed.stats.byBatchWeek['2026-W28']).toBeGreaterThan(0); + expect(completed.stats.byBatchWeek['2026-W29']).toBeGreaterThan(0); + expect(PivotCurationJob.findByIdAndUpdate).toHaveBeenCalledWith( + JOB_ID, + expect.objectContaining({ + $set: expect.objectContaining({ lastRunStatus: 'completed' }), + }), + ); + }); + }); +}); diff --git a/backend/tests/unit/pivotFeedService.test.js b/backend/tests/unit/pivotFeedService.test.js index f19266ee..d170d3f4 100644 --- a/backend/tests/unit/pivotFeedService.test.js +++ b/backend/tests/unit/pivotFeedService.test.js @@ -460,6 +460,94 @@ describe('getPivotFeed', () => { expect(result.data.events).toEqual([]); }); + it('queries only published ingestStatus (Choice A: staged/draft excluded at DB filter)', async () => { + const eventFind = mockEventFind([]); + const Event = { find: jest.fn(() => eventFind) }; + getModels.mockReturnValue(withFeedModels({ + Event, + 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 }); + + expect(Event.find).toHaveBeenCalledWith( + expect.objectContaining({ + 'customFields.pivot.batchWeek': '2026-W22', + 'customFields.pivot.ingestStatus': 'published', + }), + ); + expect(Event.find.mock.calls[0][0]['customFields.pivot.ingestStatus']).not.toBe('staged'); + expect(Event.find.mock.calls[0][0]['customFields.pivot.ingestStatus']).not.toBe('draft'); + }); + + it('includes published events for the requested batchWeek after release', async () => { + const events = [ + { + _id: '665a1b2c3d4e5f6789012345', + name: 'Released Party', + start_time: new Date('2026-05-28T22:00:00.000Z'), + end_time: new Date('2026-05-29T02:00:00.000Z'), + registrationCount: 2, + customFields: { + pivot: { + batchWeek: '2026-W22', + ingestStatus: 'published', + host: { name: 'Roof Records' }, + }, + }, + }, + ]; + + const eventFind = mockEventFind(events); + getModels.mockReturnValue(withFeedModels({ + Event: { find: jest.fn(() => eventFind) }, + Friendship: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + })), + }, + PivotEventIntent: { find: jest.fn(() => mockIntentFind()) }, + User: mockUserModel(), + })); + + const result = await getPivotFeed(req, { batchWeek: '2026-W22', now }); + + expect(result.data.events).toHaveLength(1); + expect(result.data.events[0].name).toBe('Released Party'); + }); + + it('scopes feed to the requested batchWeek (wrong week excluded by query)', async () => { + const eventFind = mockEventFind([]); + const Event = { find: jest.fn(() => eventFind) }; + getModels.mockReturnValue(withFeedModels({ + Event, + 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 }); + + expect(Event.find).toHaveBeenCalledWith( + expect.objectContaining({ + 'customFields.pivot.batchWeek': '2026-W22', + }), + ); + }); + it('rejects invalid batchWeek', async () => { const result = await getPivotFeed(req, { batchWeek: '2026-W999', now }); expect(result.error).toMatch(/batchWeek/i); diff --git a/backend/tests/unit/pivotIngestDuplicateService.test.js b/backend/tests/unit/pivotIngestDuplicateService.test.js index da9ce46d..d336cfa3 100644 --- a/backend/tests/unit/pivotIngestDuplicateService.test.js +++ b/backend/tests/unit/pivotIngestDuplicateService.test.js @@ -73,7 +73,7 @@ describe('pivotIngestDuplicateService', () => { expect(isBlockingDuplicate(duplicate)).toBe(false); }); - it('flags fingerprint matches as blocking duplicates', () => { + it('treats fingerprint matches as fuzzy updates, not blockers', () => { const duplicate = findCatalogDuplicate(catalogIndex, { sourceUrl: 'https://partiful.com/e/different-slug', name: 'Sunset Listening Party', @@ -82,9 +82,11 @@ describe('pivotIngestDuplicateService', () => { }); expect(duplicate.matchType).toBe('fingerprint'); - expect(isBlockingDuplicate(duplicate)).toBe(true); + expect(duplicate.willUpdate).toBe(true); + expect(duplicate.existingEventId).toBe('existing-1'); + expect(isBlockingDuplicate(duplicate)).toBe(false); expect(formatDuplicateWarning(duplicate, 'Sunset Listening Party')).toMatch( - /duplicate of "Sunset Listening Party"/, + /will update it/, ); }); }); diff --git a/backend/tests/unit/pivotIngestPreviewService.test.js b/backend/tests/unit/pivotIngestPreviewService.test.js index 135d170b..f2fefac8 100644 --- a/backend/tests/unit/pivotIngestPreviewService.test.js +++ b/backend/tests/unit/pivotIngestPreviewService.test.js @@ -15,8 +15,11 @@ const { classifyIngestUrl, parsePartifulExploreBatch, parseLumaDiscoverBatch, + extractLumaDiscoverSlug, + fetchLumaDiscoverApiBatch, extractMetaContent, extractJsonLdBlocks, + LUMA_DISCOVER_API_URL, } = require('../../services/pivotIngestPreviewService'); const PARTIFUL_HTML = ` @@ -361,6 +364,40 @@ describe('pivotIngestPreviewService batch parsing', () => { ); }); + it('takes all discovered events by default (no 50 cap)', () => { + const manyEvents = Array.from({ length: 60 }, (_, i) => ({ + id: `event-${i}`, + title: `Event ${i}`, + description: 'desc', + startDate: '2026-07-10T20:00:00.000Z', + endDate: '2026-07-10T22:00:00.000Z', + hostName: 'Host', + locationInfo: { name: 'NYC' }, + })); + const html = ``; + + const uncapped = parsePartifulExploreBatch(html, 'https://partiful.com/explore/sf'); + expect(uncapped.drafts).toHaveLength(60); + expect(uncapped.truncated).toBe(false); + expect(uncapped.limit).toBeNull(); + expect(uncapped.discoveredTotal).toBe(60); + + const capped = parsePartifulExploreBatch(html, 'https://partiful.com/explore/sf', { + maxEvents: 50, + }); + expect(capped.drafts).toHaveLength(50); + expect(capped.truncated).toBe(true); + expect(capped.discoveredTotal).toBe(60); + + const raised = parsePartifulExploreBatch(html, 'https://partiful.com/explore/sf', { + maxEvents: 120, + }); + expect(raised.drafts).toHaveLength(60); + expect(raised.truncated).toBe(false); + }); + it('parses Luma discover events from NEXT_DATA with cover images', () => { const result = parseLumaDiscoverBatch(LUMA_DISCOVER_NEXT_DATA_HTML, 'https://luma.com/sf'); @@ -371,6 +408,122 @@ describe('pivotIngestPreviewService batch parsing', () => { expect(result.drafts[0].sourceUrl).toBe('https://luma.com/yg5x8n8b'); }); + it('extracts Luma city slugs from discover URLs', () => { + expect(extractLumaDiscoverSlug('https://luma.com/sf')).toBe('sf'); + expect(extractLumaDiscoverSlug('https://lu.ma/nyc/')).toBe('nyc'); + expect(extractLumaDiscoverSlug('https://luma.com/discover')).toBeNull(); + expect(extractLumaDiscoverSlug('https://luma.com/e/abc')).toBeNull(); + }); + + it('paginates Luma discover API until exhausted', async () => { + axios.get + .mockResolvedValueOnce({ + status: 200, + data: { + entries: [ + { + event: { + name: 'Event One', + url: 'one', + start_at: '2026-07-11T20:00:00.000Z', + cover_url: 'https://images.lumacdn.com/one.jpg', + geo_address_info: { city_state: 'San Francisco, CA' }, + }, + hosts: [{ name: 'Host A' }], + }, + ], + has_more: true, + next_cursor: 'cursor-1', + }, + }) + .mockResolvedValueOnce({ + status: 200, + data: { + entries: [ + { + event: { + name: 'Event Two', + url: 'two', + start_at: '2026-07-12T20:00:00.000Z', + cover_url: 'https://images.lumacdn.com/two.jpg', + geo_address_info: { full_address: 'SF' }, + }, + hosts: [{ name: 'Host B' }], + calendar: { name: 'Community Cal' }, + }, + ], + has_more: false, + next_cursor: null, + }, + }); + + const result = await fetchLumaDiscoverApiBatch({ slug: 'sf' }); + + expect(result.error).toBeUndefined(); + expect(result.source).toBe('luma-discover-api'); + expect(result.pages).toBe(2); + expect(result.drafts).toHaveLength(2); + expect(result.drafts[0].draft.name).toBe('Event One'); + expect(result.drafts[1].draft.hostName).toBe('Host B'); + expect(axios.get).toHaveBeenNthCalledWith( + 1, + LUMA_DISCOVER_API_URL, + expect.objectContaining({ + params: expect.objectContaining({ slug: 'sf', pagination_limit: 20 }), + }), + ); + expect(axios.get).toHaveBeenNthCalledWith( + 2, + LUMA_DISCOVER_API_URL, + expect.objectContaining({ + params: expect.objectContaining({ + slug: 'sf', + pagination_cursor: 'cursor-1', + }), + }), + ); + }); + + it('honors maxEvents across Luma discover pages', async () => { + axios.get + .mockResolvedValueOnce({ + status: 200, + data: { + entries: Array.from({ length: 20 }, (_, i) => ({ + event: { + name: `Event ${i}`, + url: `evt-${i}`, + start_at: '2026-07-11T20:00:00.000Z', + }, + hosts: [{ name: 'Host' }], + })), + has_more: true, + next_cursor: 'more', + }, + }) + .mockResolvedValueOnce({ + status: 200, + data: { + entries: Array.from({ length: 20 }, (_, i) => ({ + event: { + name: `Event ${i + 20}`, + url: `evt-${i + 20}`, + start_at: '2026-07-11T20:00:00.000Z', + }, + hosts: [{ name: 'Host' }], + })), + has_more: true, + next_cursor: 'more-2', + }, + }); + + const result = await fetchLumaDiscoverApiBatch({ slug: 'nyc', maxEvents: 25 }); + + expect(result.drafts).toHaveLength(25); + expect(result.truncated).toBe(true); + expect(result.pages).toBe(2); + }); + it('parses Luma discover ItemList events', () => { const result = parseLumaDiscoverBatch(LUMA_DISCOVER_HTML, 'https://luma.com/sf'); @@ -380,12 +533,52 @@ describe('pivotIngestPreviewService batch parsing', () => { expect(result.drafts[0].sourceUrl).toBe('https://luma.com/yg5x8n8b'); }); - it('returns batch preview for Luma discover pages', async () => { - axios.get.mockResolvedValue({ data: LUMA_DISCOVER_HTML }); + it('returns batch preview for Luma discover pages via API', async () => { + axios.get.mockResolvedValue({ + status: 200, + data: { + entries: [ + { + event: { + name: 'Founders Cowork', + url: 'yg5x8n8b', + start_at: '2026-06-28T21:00:00.000Z', + cover_url: 'https://images.lumacdn.com/uploads/xr/dc792c6b.jpg', + geo_address_info: { full_address: 'Corgi Cafe, San Francisco, CA' }, + }, + hosts: [{ name: 'Vivian Cai' }, { name: 'Adrian Yumul' }], + }, + ], + has_more: false, + }, + }); + + const result = await previewIngestUrl({}, { url: 'https://luma.com/sf' }); + + expect(result.data.mode).toBe('batch'); + expect(result.data.discoverSource).toBe('luma-discover-api'); + expect(result.data.drafts).toHaveLength(1); + expect(result.data.drafts[0].draft.name).toBe('Founders Cowork'); + expect(axios.get).toHaveBeenCalledWith( + LUMA_DISCOVER_API_URL, + expect.objectContaining({ + params: expect.objectContaining({ slug: 'sf' }), + }), + ); + }); + + it('falls back to HTML when Luma discover API misses', async () => { + axios.get + .mockResolvedValueOnce({ + status: 404, + data: { message: 'Sorry, we could not find what you were looking for.', code: null }, + }) + .mockResolvedValueOnce({ data: LUMA_DISCOVER_NEXT_DATA_HTML }); const result = await previewIngestUrl({}, { url: 'https://luma.com/sf' }); expect(result.data.mode).toBe('batch'); + expect(result.data.discoverSource).toBe('luma-html'); expect(result.data.drafts).toHaveLength(1); }); }); diff --git a/backend/tests/unit/pivotIngestPublishService.test.js b/backend/tests/unit/pivotIngestPublishService.test.js index 44aa663b..0fe5e7a6 100644 --- a/backend/tests/unit/pivotIngestPublishService.test.js +++ b/backend/tests/unit/pivotIngestPublishService.test.js @@ -102,6 +102,15 @@ describe('pivotIngestPublishService publishIngestEvent', () => { beforeEach(() => { Event = { findOneAndUpdate: jest.fn(), + findByIdAndUpdate: jest.fn(), + findById: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(null), + })), + findOne: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(null), + })), create: jest.fn(), }; getModels.mockReturnValue({ Event }); @@ -133,7 +142,7 @@ describe('pivotIngestPublishService publishIngestEvent', () => { customFields: { pivot: { batchWeek: '2026-W26', - ingestStatus: 'published', + ingestStatus: 'staged', host: { name: 'Brooklyn Board Game Cafe' }, source: 'partiful', }, @@ -142,7 +151,7 @@ describe('pivotIngestPublishService publishIngestEvent', () => { }); }); - it('creates published catalog event with display host from overrides', async () => { + it('creates staged catalog event with display host from overrides', async () => { const result = await publishIngestEvent( { user: { email: 'ops@meridian.study' }, globalDb: {} }, { @@ -154,6 +163,10 @@ describe('pivotIngestPublishService publishIngestEvent', () => { ); expect(result.data.event.organizerName).toBe('Brooklyn Board Game Cafe'); + expect(result.data.ingestStatus).toBe('staged'); + // Event start 2026-07-12 → ISO week 2026-W28 (not the fallback body week). + expect(result.data.batchWeek).toBe('2026-W28'); + expect(result.data.batchWeekSource).toBe('event-date'); expect(validatePivotEventTags).toHaveBeenCalledWith( expect.any(Object), ['board-games'], @@ -170,7 +183,8 @@ describe('pivotIngestPublishService publishIngestEvent', () => { hostingId: TENANT.pivotCatalogOrgId, customFields: expect.objectContaining({ pivot: expect.objectContaining({ - ingestStatus: 'published', + batchWeek: '2026-W28', + ingestStatus: 'staged', tags: ['live-music'], host: expect.objectContaining({ name: 'Brooklyn Board Game Cafe' }), }), @@ -182,6 +196,129 @@ describe('pivotIngestPublishService publishIngestEvent', () => { expect(provisionPivotCatalogOrg).not.toHaveBeenCalled(); }); + it('forceBatchWeek pins the event into the provided week', async () => { + Event.findOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: '507f1f77bcf86cd799439012', + name: 'Sunset Listening Party', + start_time: new Date('2026-07-12T22:00:00.000Z'), + customFields: { + pivot: { + batchWeek: '2026-W26', + ingestStatus: 'staged', + host: { name: 'Brooklyn Board Game Cafe' }, + source: 'partiful', + }, + }, + }), + }); + + const result = await publishIngestEvent( + { user: { email: 'ops@meridian.study' }, globalDb: {} }, + { + tenantKey: 'nyc', + url: 'https://partiful.com/e/sunset-listening', + batchWeek: '2026-W26', + forceBatchWeek: true, + overrides: { hostName: 'Brooklyn Board Game Cafe', tags: ['board-games'] }, + }, + ); + + expect(result.data.batchWeek).toBe('2026-W26'); + expect(result.data.batchWeekSource).toBe('forced'); + expect(Event.findOneAndUpdate).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + $set: expect.objectContaining({ + customFields: expect.objectContaining({ + pivot: expect.objectContaining({ batchWeek: '2026-W26' }), + }), + }), + }), + expect.any(Object), + ); + }); + + it('rejects published override without releaseNow confirm', async () => { + const result = await publishIngestEvent( + { user: { email: 'ops@meridian.study' }, globalDb: {} }, + { + tenantKey: 'nyc', + url: 'https://partiful.com/e/sunset-listening', + batchWeek: '2026-W26', + overrides: { + hostName: 'Brooklyn Board Game Cafe', + tags: ['board-games'], + ingestStatus: 'published', + }, + }, + ); + + expect(result.status).toBe(400); + expect(result.code).toBe('RELEASE_CONFIRM_REQUIRED'); + expect(Event.findOneAndUpdate).not.toHaveBeenCalled(); + }); + + it('publishes immediately when releaseNow + RELEASE_NOW confirm', async () => { + Event.findOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: '507f1f77bcf86cd799439012', + name: 'Sunset Listening Party', + customFields: { + pivot: { + batchWeek: '2026-W26', + ingestStatus: 'published', + host: { name: 'Brooklyn Board Game Cafe' }, + tags: ['live-music'], + }, + }, + }), + }); + + const result = await publishIngestEvent( + { user: { email: 'ops@meridian.study' }, globalDb: {} }, + { + tenantKey: 'nyc', + url: 'https://partiful.com/e/sunset-listening', + batchWeek: '2026-W26', + overrides: { hostName: 'Brooklyn Board Game Cafe', tags: ['board-games'] }, + releaseNow: true, + confirm: 'RELEASE_NOW', + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.data.ingestStatus).toBe('published'); + expect(Event.findOneAndUpdate).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + $set: expect.objectContaining({ + customFields: expect.objectContaining({ + pivot: expect.objectContaining({ ingestStatus: 'published' }), + }), + }), + }), + expect.any(Object), + ); + }); + + it('requires RELEASE_NOW confirm when releaseNow is set', async () => { + const result = await publishIngestEvent( + { user: { email: 'ops@meridian.study' }, globalDb: {} }, + { + tenantKey: 'nyc', + url: 'https://partiful.com/e/sunset-listening', + batchWeek: '2026-W26', + overrides: { hostName: 'Brooklyn Board Game Cafe', tags: ['board-games'] }, + releaseNow: true, + confirm: 'yes', + }, + ); + + expect(result.code).toBe('CONFIRMATION_REQUIRED'); + expect(Event.findOneAndUpdate).not.toHaveBeenCalled(); + }); + it('persists showtimes from overrides when publishing without provider preview', async () => { Event.findOneAndUpdate.mockReturnValue({ lean: jest.fn().mockResolvedValue({ @@ -263,11 +400,11 @@ describe('pivotIngestPublishService publishIngestEvent', () => { expect(Event.findOneAndUpdate.mock.calls[0][1].$set.hostingId).toBe('507f1f77bcf86cd799439099'); }); - it('rejects publish when a blocking duplicate is detected', async () => { + it('rejects publish when a blocking (batch-internal) duplicate is detected', async () => { isBlockingDuplicate.mockReturnValue(true); resolveImportDuplicate.mockResolvedValue({ duplicate: { - matchType: 'fingerprint', + matchType: 'batchFingerprint', existingName: 'Sunset Listening Party', }, catalogIndex: [], @@ -288,6 +425,58 @@ describe('pivotIngestPublishService publishIngestEvent', () => { expect(Event.findOneAndUpdate).not.toHaveBeenCalled(); }); + it('updates the existing event in place on a fuzzy (fingerprint) duplicate', async () => { + resolveImportDuplicate.mockResolvedValue({ + duplicate: { + matchType: 'fingerprint', + willUpdate: true, + existingEventId: '507f1f77bcf86cd799439055', + existingName: 'Sunset Listening Party', + }, + catalogIndex: [], + }); + Event.findByIdAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: '507f1f77bcf86cd799439055', + name: 'Sunset Listening Party', + start_time: new Date('2026-07-12T22:00:00.000Z'), + end_time: new Date('2026-07-13T00:00:00.000Z'), + location: 'Brooklyn Bridge Park', + customFields: { + pivot: { + batchWeek: '2026-W26', + ingestStatus: 'published', + host: { name: 'Brooklyn Board Game Cafe' }, + }, + }, + }), + }); + + const result = await publishIngestEvent( + { user: { email: 'ops@meridian.study' }, globalDb: {} }, + { + tenantKey: 'nyc', + batchWeek: '2026-W26', + overrides: { + hostName: 'Brooklyn Board Game Cafe', + name: 'Sunset Listening Party', + location: 'Brooklyn Bridge Park', + start_time: '2026-07-12T18:00:00-04:00', + tags: ['live-music'], + }, + }, + ); + + expect(result.data.updated).toBe(true); + expect(result.data.created).toBe(false); + expect(Event.findByIdAndUpdate).toHaveBeenCalledWith( + '507f1f77bcf86cd799439055', + expect.objectContaining({ $set: expect.any(Object) }), + expect.objectContaining({ new: true }), + ); + expect(Event.create).not.toHaveBeenCalled(); + }); + it('rejects publish when tags are missing', async () => { validatePivotEventTags.mockResolvedValue({ error: 'At least one catalog tag is required.', @@ -489,6 +678,59 @@ describe('pivotIngestPublishService updateIngestEvent', () => { ); }); + it('accepts staged ingestStatus', async () => { + Event.findByIdAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: '507f1f77bcf86cd799439012', + name: 'Staged Event', + customFields: { + pivot: { + ingestStatus: 'staged', + host: { name: 'New Host' }, + tags: ['live-music'], + }, + }, + }), + }); + + const result = await updateIngestEvent( + { globalDb: {} }, + { + tenantKey: 'nyc', + eventId: '507f1f77bcf86cd799439012', + overrides: { ingestStatus: 'staged' }, + }, + ); + + expect(result.error).toBeUndefined(); + expect(Event.findByIdAndUpdate).toHaveBeenCalledWith( + '507f1f77bcf86cd799439012', + expect.objectContaining({ + $set: expect.objectContaining({ + 'customFields.pivot': expect.objectContaining({ + ingestStatus: 'staged', + }), + }), + }), + expect.any(Object), + ); + }); + + it('rejects invalid ingestStatus', async () => { + const result = await updateIngestEvent( + { globalDb: {} }, + { + tenantKey: 'nyc', + eventId: '507f1f77bcf86cd799439012', + overrides: { ingestStatus: 'live' }, + }, + ); + + expect(result.status).toBe(400); + expect(result.code).toBe('INVALID_INGEST_STATUS'); + expect(Event.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + it('updates tags on existing event', async () => { validatePivotEventTags.mockResolvedValue({ tags: ['board-games', 'social'] }); diff --git a/backend/tests/unit/pivotIngestStatus.test.js b/backend/tests/unit/pivotIngestStatus.test.js new file mode 100644 index 00000000..f3e05e04 --- /dev/null +++ b/backend/tests/unit/pivotIngestStatus.test.js @@ -0,0 +1,34 @@ +const { + PIVOT_INGEST_STATUSES, + PIVOT_FEED_INGEST_STATUS, + isValidIngestStatus, + isFeedEligibleIngestStatus, + normalizeIngestStatus, +} = require('../../utilities/pivotIngestStatus'); + +describe('pivotIngestStatus', () => { + it('allows draft, staged, and published', () => { + expect(PIVOT_INGEST_STATUSES).toEqual(['draft', 'staged', 'published']); + expect(isValidIngestStatus('draft')).toBe(true); + expect(isValidIngestStatus('staged')).toBe(true); + expect(isValidIngestStatus('published')).toBe(true); + expect(isValidIngestStatus('live')).toBe(false); + }); + + it('treats only published as feed-eligible (Choice A)', () => { + expect(PIVOT_FEED_INGEST_STATUS).toBe('published'); + expect(isFeedEligibleIngestStatus('published')).toBe(true); + expect(isFeedEligibleIngestStatus('staged')).toBe(false); + expect(isFeedEligibleIngestStatus('draft')).toBe(false); + }); + + it('normalizes valid statuses and rejects others', () => { + expect(normalizeIngestStatus(' staged ')).toEqual({ ingestStatus: 'staged' }); + expect(normalizeIngestStatus('bogus')).toEqual( + expect.objectContaining({ + status: 400, + code: 'INVALID_INGEST_STATUS', + }), + ); + }); +}); diff --git a/backend/tests/unit/pivotIsoWeek.test.js b/backend/tests/unit/pivotIsoWeek.test.js index a8f173e2..ed253592 100644 --- a/backend/tests/unit/pivotIsoWeek.test.js +++ b/backend/tests/unit/pivotIsoWeek.test.js @@ -4,6 +4,8 @@ const { isoWeekToMondayUtc, isoWeekToUtcRange, shiftIsoWeek, + batchWeekFromEventDate, + resolveEventBatchWeek, } = require('../../utilities/pivotIsoWeek'); describe('pivotIsoWeek', () => { @@ -43,9 +45,57 @@ describe('pivotIsoWeek', () => { expect(shiftIsoWeek('2026-W27', 0)).toBe('2026-W27'); }); - it('crosses ISO year boundaries', () => { + it('crosses year boundaries', () => { expect(shiftIsoWeek('2026-W01', -1)).toBe('2025-W52'); - expect(shiftIsoWeek('2025-W52', 1)).toBe('2026-W01'); + }); + }); + + describe('batchWeekFromEventDate', () => { + it('derives ISO week from a start datetime', () => { + // Monday 2026-06-29 is in 2026-W27. + expect(batchWeekFromEventDate('2026-06-29T20:00:00.000Z')).toBe('2026-W27'); + expect(batchWeekFromEventDate(new Date('2026-07-10T18:00:00.000Z'))).toBe('2026-W28'); + }); + + it('returns null for invalid values', () => { + expect(batchWeekFromEventDate(null)).toBeNull(); + expect(batchWeekFromEventDate('not-a-date')).toBeNull(); + }); + }); + + describe('resolveEventBatchWeek', () => { + it('uses event start date by default', () => { + const result = resolveEventBatchWeek({ + batchWeek: '2026-W30', + startTime: '2026-06-29T20:00:00.000Z', + }); + expect(result).toEqual({ batchWeek: '2026-W27', source: 'event-date' }); + }); + + it('uses first time-slot when startTime missing', () => { + const result = resolveEventBatchWeek({ + timeSlots: [{ start_time: '2026-07-10T18:00:00.000Z' }], + }); + expect(result).toEqual({ batchWeek: '2026-W28', source: 'event-date' }); + }); + + it('honors forceBatchWeek override', () => { + const result = resolveEventBatchWeek({ + forceBatchWeek: true, + batchWeek: '2026-W30', + startTime: '2026-06-29T20:00:00.000Z', + }); + expect(result).toEqual({ batchWeek: '2026-W30', source: 'forced' }); + }); + + it('requires batchWeek when forcing', () => { + const result = resolveEventBatchWeek({ forceBatchWeek: true }); + expect(result.code).toBe('BATCH_WEEK_REQUIRED'); + }); + + it('falls back to provided batchWeek when undated', () => { + const result = resolveEventBatchWeek({ batchWeek: '2026-W30' }); + expect(result).toEqual({ batchWeek: '2026-W30', source: 'fallback' }); }); }); }); diff --git a/backend/tests/unit/pivotTenantInsightsService.test.js b/backend/tests/unit/pivotTenantInsightsService.test.js new file mode 100644 index 00000000..c0f6fe08 --- /dev/null +++ b/backend/tests/unit/pivotTenantInsightsService.test.js @@ -0,0 +1,244 @@ +jest.mock('../../services/getModelService', () => jest.fn()); +jest.mock('../../connectionsManager', () => ({ + connectToDatabase: jest.fn(), +})); +jest.mock('../../services/pivotIngestPublishService', () => ({ + resolvePivotTenant: jest.fn(), +})); +jest.mock('../../services/pivotLabEventsService', () => ({ + labEventsQuery: jest.requireActual('../../services/pivotLabEventsService').labEventsQuery, + loadIntentStatsByEventId: jest.fn(), +})); +jest.mock('../../services/pivotAdminOverviewService', () => ({ + aggregateTenantOverview: jest.fn(), + serializePerformanceEvent: jest.requireActual('../../services/pivotAdminOverviewService') + .serializePerformanceEvent, +})); +jest.mock('../../services/pivotWeeklySnapshotService', () => ({ + normalizeBatchWeek: jest.requireActual('../../services/pivotWeeklySnapshotService') + .normalizeBatchWeek, +})); +jest.mock('../../services/pivotFeedService', () => ({ + PIVOT_EVENT_STATUSES: ['approved', 'not-applicable'], +})); + +const { connectToDatabase } = require('../../connectionsManager'); +const getModels = require('../../services/getModelService'); +const { resolvePivotTenant } = require('../../services/pivotIngestPublishService'); +const { loadIntentStatsByEventId } = require('../../services/pivotLabEventsService'); +const { aggregateTenantOverview } = require('../../services/pivotAdminOverviewService'); +const { + buildTenantInsights, + getTenantInsights, + curationHref, +} = require('../../services/pivotTenantInsightsService'); + +describe('pivotTenantInsightsService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('buildTenantInsights', () => { + it('fires at least four rules on fixture data', () => { + const insights = buildTenantInsights({ + tenantKey: 'nyc', + batchWeek: '2026-W28', + targetEventCount: 40, + eventCountsByStatus: { + draft: 2, + staged: 3, + published: 5, + other: 0, + total: 10, + }, + performanceEvents: [ + { + eventId: 'e1', + name: 'Silent Disco', + interestedTotal: 8, + externalOpen: 0, + }, + { + eventId: 'e2', + name: 'Jazz Night', + interestedTotal: 4, + externalOpen: 0, + }, + ], + catalogEvents: [ + { + _id: 'a', + customFields: { pivot: { ingestStatus: 'published', tags: ['music'] } }, + }, + { + _id: 'b', + customFields: { pivot: { ingestStatus: 'staged', tags: ['music'] } }, + }, + { + _id: 'c', + customFields: { pivot: { ingestStatus: 'published', tags: ['music'] } }, + }, + { + _id: 'd', + customFields: { pivot: { ingestStatus: 'published', tags: [] } }, + }, + { + _id: 'e', + customFields: { pivot: { ingestStatus: 'staged', tags: [] } }, + }, + ], + vsPrevWeek: { + activeUsers: { current: 4, previous: 10, delta: -6 }, + }, + feedbackAvg: 3.2, + prevFeedbackAvg: 4.1, + }); + + const ids = insights.map((row) => row.id); + expect(ids).toEqual( + expect.arrayContaining([ + 'thin-catalog', + 'interest-no-ticket', + 'untagged-events', + 'tag-concentration', + 'low-feedback', + 'active-users-drop', + ]), + ); + expect(insights.length).toBeGreaterThanOrEqual(4); + + const untagged = insights.find((row) => row.id === 'untagged-events'); + expect(untagged.href).toContain('filter=untagged'); + expect(untagged.href).toContain('/platform-admin/pivot/nyc'); + expect(untagged.severity).toMatch(/warn|critical|info/); + }); + + it('returns an empty list when nothing needs attention', () => { + const insights = buildTenantInsights({ + tenantKey: 'nyc', + batchWeek: '2026-W28', + targetEventCount: 10, + eventCountsByStatus: { + draft: 0, + staged: 2, + published: 10, + other: 0, + total: 12, + }, + performanceEvents: [ + { eventId: 'e1', name: 'Show', interestedTotal: 5, externalOpen: 3 }, + ], + catalogEvents: [ + { + _id: 'a', + customFields: { pivot: { ingestStatus: 'published', tags: ['a'] } }, + }, + { + _id: 'b', + customFields: { pivot: { ingestStatus: 'published', tags: ['b'] } }, + }, + { + _id: 'c', + customFields: { pivot: { ingestStatus: 'published', tags: ['c'] } }, + }, + ], + vsPrevWeek: { + activeUsers: { current: 12, previous: 10, delta: 2 }, + }, + feedbackAvg: 4.5, + prevFeedbackAvg: 4.4, + }); + + expect(insights).toEqual([]); + }); + + it('builds curation deep links with batchWeek', () => { + expect(curationHref('brooklyn', '2026-W28', 'untagged')).toBe( + '/platform-admin/pivot/brooklyn?page=1&batchWeek=2026-W28&filter=untagged', + ); + }); + }); + + describe('getTenantInsights', () => { + it('returns TENANT_NOT_FOUND for unknown tenant', async () => { + resolvePivotTenant.mockResolvedValue({ + error: 'Pivot tenant not found.', + status: 404, + code: 'TENANT_NOT_FOUND', + }); + + const result = await getTenantInsights( + { globalDb: {} }, + { tenantKey: 'missing', batchWeek: '2026-W28' }, + ); + + expect(result.code).toBe('TENANT_NOT_FOUND'); + expect(connectToDatabase).not.toHaveBeenCalled(); + }); + + it('aggregates one city and returns insight cards', async () => { + resolvePivotTenant.mockResolvedValue({ + tenant: { tenantKey: 'nyc', tenantType: 'pivot', location: 'New York City' }, + }); + aggregateTenantOverview + .mockResolvedValueOnce({ + activeUsers: 4, + feedbackAvg: 3.0, + eventCountsByStatus: { + draft: 1, + staged: 1, + published: 2, + other: 0, + total: 4, + }, + }) + .mockResolvedValueOnce({ + activeUsers: 10, + feedbackAvg: 4.2, + }); + + connectToDatabase.mockResolvedValue({}); + getModels.mockReturnValue({ + Event: { + find: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue([ + { + _id: 'e1', + name: 'Silent Disco', + customFields: { pivot: { ingestStatus: 'published', tags: [] } }, + }, + ]), + }), + }), + }, + PivotEventIntent: {}, + }); + loadIntentStatsByEventId.mockResolvedValue( + new Map([ + [ + 'e1', + { + interested: 5, + registered: 0, + passed: 1, + externalOpens: 0, + externalOpenUsers: 0, + }, + ], + ]), + ); + + const result = await getTenantInsights( + { globalDb: {} }, + { tenantKey: 'nyc', batchWeek: '2026-W28' }, + ); + + expect(result.error).toBeUndefined(); + expect(result.data.tenantKey).toBe('nyc'); + expect(result.data.insights.length).toBeGreaterThanOrEqual(1); + expect(result.data.insights.every((row) => row.id && row.severity && row.title)).toBe(true); + expect(connectToDatabase).toHaveBeenCalledWith('nyc'); + }); + }); +}); diff --git a/backend/tests/unit/pivotTenantJourneyService.test.js b/backend/tests/unit/pivotTenantJourneyService.test.js new file mode 100644 index 00000000..af0d6305 --- /dev/null +++ b/backend/tests/unit/pivotTenantJourneyService.test.js @@ -0,0 +1,459 @@ +jest.mock('../../services/getModelService', () => jest.fn()); +jest.mock('../../connectionsManager', () => ({ + connectToDatabase: jest.fn(), +})); +jest.mock('../../services/pivotIngestPublishService', () => ({ + resolvePivotTenant: jest.fn(), +})); +jest.mock('../../services/pivotAdminOverviewService', () => ({ + aggregateTenantOverview: jest.fn(), + buildFunnelStages: jest.requireActual('../../services/pivotAdminOverviewService') + .buildFunnelStages, +})); + +const getModels = require('../../services/getModelService'); +const { connectToDatabase } = require('../../connectionsManager'); +const { resolvePivotTenant } = require('../../services/pivotIngestPublishService'); +const { aggregateTenantOverview } = require('../../services/pivotAdminOverviewService'); +const { + parseFunnelSteps, + resolveFunnelEventName, + getJourneyOverview, + getJourneyFunnel, + getUserJourneyHistory, + wipeUserWeekIntents, + searchJourneyUsers, + WIPE_CONFIRM_TOKEN, + median, +} = require('../../services/pivotTenantJourneyService'); + +const TENANT = { tenantKey: 'nyc', location: 'New York City', name: 'NYC' }; +const BATCH_WEEK = '2026-W28'; +const USER_A = '507f191e810c19729de860eb'; +const USER_B = '507f191e810c19729de860ec'; +const EVENT_A = '665a1b2c3d4e5f6789012345'; + +function mockReq(overrides = {}) { + return { + globalDb: {}, + user: { + email: 'ops@meridian.app', + globalUserId: '507f191e810c19729de860ea', + }, + ...overrides, + }; +} + +function chainFind(docs) { + return { + select: jest.fn().mockReturnThis(), + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(docs), + }; +} + +describe('parseFunnelSteps / resolveFunnelEventName', () => { + it('maps plan aliases to real pivot event names', () => { + expect(resolveFunnelEventName('deck_open')).toBe('pivot_card_view'); + expect(resolveFunnelEventName('card_interested')).toBe('pivot_card_interested'); + expect(resolveFunnelEventName('external_open')).toBe('pivot_external_open'); + expect(resolveFunnelEventName('registered')).toBe('pivot_confirm_registered'); + }); + + it('defaults to deck_open → registered steps', () => { + const result = parseFunnelSteps(); + expect(result.steps).toHaveLength(4); + expect(result.steps.map((s) => s.event)).toEqual([ + 'pivot_card_view', + 'pivot_card_interested', + 'pivot_external_open', + 'pivot_confirm_registered', + ]); + }); + + it('rejects unknown steps', () => { + expect(parseFunnelSteps('nope').code).toBe('INVALID_STEPS'); + }); +}); + +describe('median', () => { + it('returns null for empty and median for odd/even', () => { + expect(median([])).toBeNull(); + expect(median([1, 3, 5])).toBe(3); + expect(median([1, 2, 3, 4])).toBe(2.5); + }); +}); + +describe('getJourneyOverview', () => { + beforeEach(() => { + getModels.mockReset(); + connectToDatabase.mockReset(); + resolvePivotTenant.mockReset(); + aggregateTenantOverview.mockReset(); + + connectToDatabase.mockResolvedValue({}); + resolvePivotTenant.mockResolvedValue({ tenant: TENANT }); + aggregateTenantOverview.mockResolvedValue({ + tenantKey: 'nyc', + cityDisplayName: 'New York City', + activeUsers: 10, + swipeCount: 40, + interestedCount: 8, + registeredCount: 4, + externalOpenUsers: 6, + }); + getModels.mockReturnValue({ + AnalyticsEvent: { + aggregate: jest.fn().mockResolvedValue([ + { _id: USER_A, cardsSeen: 2 }, + { _id: USER_B, cardsSeen: 8 }, + ]), + }, + }); + }); + + it('returns compact KPIs and conversion rates for one tenant', async () => { + const result = await getJourneyOverview(mockReq(), { + tenantKey: 'nyc', + batchWeek: BATCH_WEEK, + }); + + expect(result.error).toBeUndefined(); + expect(result.data.tenantKey).toBe('nyc'); + expect(result.data.batchWeek).toBe(BATCH_WEEK); + expect(result.data.kpis.activeUsers).toBe(10); + expect(result.data.kpis.medianCardsSeen).toBe(5); + expect(result.data.kpis.interestedCount).toBe(12); + expect(result.data.funnel.map((s) => s.key)).toEqual([ + 'swipes', + 'interested', + 'openers', + 'going', + ]); + expect(result.data.conversionRates.interestRate).toBe(0.3); + }); + + it('returns 404 for unknown tenant', async () => { + resolvePivotTenant.mockResolvedValue({ + error: 'Pivot tenant not found.', + status: 404, + code: 'TENANT_NOT_FOUND', + }); + + const result = await getJourneyOverview(mockReq(), { + tenantKey: 'missing', + batchWeek: BATCH_WEEK, + }); + expect(result.code).toBe('TENANT_NOT_FOUND'); + }); +}); + +describe('getJourneyFunnel', () => { + beforeEach(() => { + getModels.mockReset(); + connectToDatabase.mockReset(); + resolvePivotTenant.mockReset(); + aggregateTenantOverview.mockReset(); + + connectToDatabase.mockResolvedValue({}); + resolvePivotTenant.mockResolvedValue({ tenant: TENANT }); + aggregateTenantOverview.mockResolvedValue({ + tenantKey: 'nyc', + cityDisplayName: 'New York City', + activeUsers: 5, + swipeCount: 20, + interestedCount: 6, + registeredCount: 2, + externalOpenUsers: 3, + }); + }); + + it('returns pivot-named analytics steps for the city', async () => { + getModels.mockReturnValue({ + AnalyticsEvent: { + aggregate: jest.fn().mockResolvedValue([ + { + _id: USER_A, + stream: [ + 'pivot_card_view', + 'pivot_card_interested', + 'pivot_external_open', + 'pivot_confirm_registered', + ], + }, + { + _id: USER_B, + stream: ['pivot_card_view', 'pivot_card_interested'], + }, + ]), + }, + }); + + const result = await getJourneyFunnel(mockReq(), { + tenantKey: 'nyc', + batchWeek: BATCH_WEEK, + }); + + expect(result.error).toBeUndefined(); + expect(result.data.steps.map((s) => s.event)).toEqual([ + 'pivot_card_view', + 'pivot_card_interested', + 'pivot_external_open', + 'pivot_confirm_registered', + ]); + expect(result.data.steps[0].count).toBe(2); + expect(result.data.steps[1].count).toBe(2); + expect(result.data.steps[2].count).toBe(1); + expect(result.data.steps[3].count).toBe(1); + expect(result.data.intentFunnel).toHaveLength(4); + }); +}); + +describe('getUserJourneyHistory', () => { + beforeEach(() => { + getModels.mockReset(); + connectToDatabase.mockReset(); + resolvePivotTenant.mockReset(); + + connectToDatabase.mockResolvedValue({}); + resolvePivotTenant.mockResolvedValue({ tenant: TENANT }); + }); + + it('returns intents for a week with event enrichment', async () => { + const User = { + findById: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue({ + _id: USER_A, + name: 'Ada', + username: 'ada', + picture: null, + }), + })), + }; + const PivotEventIntent = { + find: jest.fn(() => + chainFind([ + { + eventId: EVENT_A, + batchWeek: BATCH_WEEK, + status: 'interested', + timeSlotId: null, + externalOpenAt: null, + externalOpenCount: 0, + updatedAt: new Date('2026-07-08T12:00:00.000Z'), + createdAt: new Date('2026-07-08T12:00:00.000Z'), + }, + ]), + ), + }; + const Event = { + find: jest.fn(() => + chainFind([ + { + _id: EVENT_A, + name: 'Rooftop Jazz', + start_time: new Date('2026-07-10T23:00:00.000Z'), + customFields: { pivot: { batchWeek: BATCH_WEEK, ingestStatus: 'published' } }, + }, + ]), + ), + }; + const AnalyticsEvent = { + find: jest.fn(() => chainFind([])), + }; + getModels.mockReturnValue({ User, PivotEventIntent, Event, AnalyticsEvent }); + + const result = await getUserJourneyHistory(mockReq(), { + tenantKey: 'nyc', + userId: USER_A, + batchWeek: BATCH_WEEK, + }); + + expect(result.error).toBeUndefined(); + expect(result.data.user.name).toBe('Ada'); + expect(result.data.intents).toHaveLength(1); + expect(result.data.intents[0]).toMatchObject({ + eventId: EVENT_A, + eventName: 'Rooftop Jazz', + status: 'interested', + batchWeek: BATCH_WEEK, + }); + expect(PivotEventIntent.find).toHaveBeenCalledWith({ + userId: expect.anything(), + batchWeek: BATCH_WEEK, + }); + }); +}); + +describe('searchJourneyUsers', () => { + beforeEach(() => { + getModels.mockReset(); + connectToDatabase.mockReset(); + resolvePivotTenant.mockReset(); + + connectToDatabase.mockResolvedValue({}); + resolvePivotTenant.mockResolvedValue({ tenant: TENANT }); + }); + + it('searches by name and attaches intent counts for the week', async () => { + const User = { + find: jest.fn(() => + chainFind([ + { _id: USER_A, name: 'Ada Lovelace', username: 'ada', picture: null }, + { _id: USER_B, name: 'Ada Other', username: 'ada2', picture: null }, + ]), + ), + }; + const PivotEventIntent = { + aggregate: jest.fn().mockResolvedValue([{ _id: USER_A, count: 3 }]), + }; + getModels.mockReturnValue({ User, PivotEventIntent }); + + const result = await searchJourneyUsers(mockReq(), { + tenantKey: 'nyc', + query: 'Ada', + batchWeek: BATCH_WEEK, + }); + + expect(result.data.mode).toBe('search'); + expect(result.data.users[0].userId).toBe(USER_A); + expect(result.data.users[0].intentCount).toBe(3); + expect(result.data.users[1].intentCount).toBe(0); + }); + + it('lists most active users when query is empty', async () => { + const User = { + find: jest.fn(() => + chainFind([ + { _id: USER_B, name: 'Bob', username: 'bob', picture: null }, + { _id: USER_A, name: 'Ada', username: 'ada', picture: null }, + ]), + ), + }; + const PivotEventIntent = { + aggregate: jest + .fn() + .mockResolvedValue([ + { _id: USER_A, count: 5 }, + { _id: USER_B, count: 2 }, + ]), + }; + getModels.mockReturnValue({ User, PivotEventIntent }); + + const result = await searchJourneyUsers(mockReq(), { + tenantKey: 'nyc', + batchWeek: BATCH_WEEK, + }); + + expect(result.data.mode).toBe('active'); + expect(result.data.users).toEqual([ + { + userId: USER_A, + name: 'Ada', + username: 'ada', + picture: null, + intentCount: 5, + }, + { + userId: USER_B, + name: 'Bob', + username: 'bob', + picture: null, + intentCount: 2, + }, + ]); + expect(PivotEventIntent.aggregate).toHaveBeenCalledWith( + expect.arrayContaining([ + { $match: { batchWeek: BATCH_WEEK } }, + { $sort: { count: -1 } }, + ]), + ); + }); +}); + +describe('wipeUserWeekIntents', () => { + beforeEach(() => { + getModels.mockReset(); + connectToDatabase.mockReset(); + resolvePivotTenant.mockReset(); + + connectToDatabase.mockResolvedValue({}); + resolvePivotTenant.mockResolvedValue({ tenant: TENANT }); + }); + + it('requires confirm token', async () => { + const result = await wipeUserWeekIntents(mockReq(), { + tenantKey: 'nyc', + userId: USER_A, + batchWeek: BATCH_WEEK, + confirm: 'nope', + }); + expect(result.status).toBe(400); + expect(result.code).toBe('CONFIRM_REQUIRED'); + }); + + it('deletes only that user+week intents', async () => { + const deleteMany = jest.fn().mockResolvedValue({ deletedCount: 2 }); + getModels.mockReturnValue({ + User: { exists: jest.fn().mockResolvedValue(true) }, + PivotEventIntent: { deleteMany }, + }); + + const result = await wipeUserWeekIntents(mockReq(), { + tenantKey: 'nyc', + userId: USER_A, + batchWeek: BATCH_WEEK, + confirm: WIPE_CONFIRM_TOKEN, + }); + + expect(result.data).toEqual({ + tenantKey: 'nyc', + userId: USER_A, + batchWeek: BATCH_WEEK, + deletedCount: 2, + }); + expect(deleteMany).toHaveBeenCalledWith({ + userId: expect.anything(), + batchWeek: BATCH_WEEK, + }); + expect(String(deleteMany.mock.calls[0][0].userId)).toBe(USER_A); + }); + + it('does not touch other users or weeks (filter isolation)', async () => { + const deleteMany = jest.fn().mockResolvedValue({ deletedCount: 1 }); + getModels.mockReturnValue({ + User: { exists: jest.fn().mockResolvedValue(true) }, + PivotEventIntent: { deleteMany }, + }); + + await wipeUserWeekIntents(mockReq(), { + tenantKey: 'nyc', + userId: USER_A, + batchWeek: BATCH_WEEK, + confirm: 'WIPE', + }); + + const filter = deleteMany.mock.calls[0][0]; + expect(Object.keys(filter).sort()).toEqual(['batchWeek', 'userId']); + expect(String(filter.userId)).toBe(USER_A); + expect(filter.batchWeek).toBe(BATCH_WEEK); + expect(filter.userId).not.toEqual(expect.objectContaining({ $in: expect.anything() })); + }); + + it('returns 404 when user is missing', async () => { + getModels.mockReturnValue({ + User: { exists: jest.fn().mockResolvedValue(false) }, + PivotEventIntent: { deleteMany: jest.fn() }, + }); + + const result = await wipeUserWeekIntents(mockReq(), { + tenantKey: 'nyc', + userId: USER_A, + batchWeek: BATCH_WEEK, + confirm: 'WIPE', + }); + expect(result.code).toBe('USER_NOT_FOUND'); + }); +}); diff --git a/backend/tests/unit/pivotTenantOpsService.test.js b/backend/tests/unit/pivotTenantOpsService.test.js new file mode 100644 index 00000000..d2d9029f --- /dev/null +++ b/backend/tests/unit/pivotTenantOpsService.test.js @@ -0,0 +1,219 @@ +jest.mock('../../services/pivotAdminOverviewService', () => ({ + getTenantOverview: jest.fn(), + getTenantEventPerformance: jest.fn(), +})); +jest.mock('../../services/pivotTenantInsightsService', () => ({ + getTenantInsights: jest.fn(), +})); +jest.mock('../../services/pivotBatchReadinessService', () => ({ + getBatchReadiness: jest.fn(), +})); +jest.mock('../../services/pivotTenantJourneyService', () => ({ + getJourneyOverview: jest.fn(), + getJourneyFunnel: jest.fn(), +})); +jest.mock('../../services/pivotRetentionService', () => ({ + aggregateTenantRetention: jest.fn(), + normalizeWeeksParam: jest.fn((raw) => { + const n = Number(raw); + if (!Number.isFinite(n)) return 6; + return Math.min(12, Math.max(2, Math.trunc(n))); + }), +})); +jest.mock('../../services/pivotLabEventsService', () => ({ + listPivotLabEvents: jest.fn(), +})); +jest.mock('../../services/pivotCurationJobService', () => ({ + listCurationJobs: jest.fn(), +})); +jest.mock('../../services/pivotIngestPublishService', () => ({ + resolvePivotTenant: jest.fn(), +})); +jest.mock('../../services/pivotConfigService', () => ({ + buildDropSchedulePayload: jest.fn(() => ({ + batchWeek: '2026-W28', + nextDropAt: '2026-07-09T22:00:00.000Z', + nextDropFormatted: 'Thu Jul 9, 6:00 PM EDT', + })), +})); +jest.mock('../../utilities/pivotDropSchedule', () => ({ + resolvePivotDropInstant: jest.fn(), +})); + +const { resolvePivotTenant } = require('../../services/pivotIngestPublishService'); +const { + getTenantOverview, + getTenantEventPerformance, +} = require('../../services/pivotAdminOverviewService'); +const { getTenantInsights } = require('../../services/pivotTenantInsightsService'); +const { getBatchReadiness } = require('../../services/pivotBatchReadinessService'); +const { + getJourneyOverview, + getJourneyFunnel, +} = require('../../services/pivotTenantJourneyService'); +const { aggregateTenantRetention } = require('../../services/pivotRetentionService'); +const { listPivotLabEvents } = require('../../services/pivotLabEventsService'); +const { listCurationJobs } = require('../../services/pivotCurationJobService'); +const { resolvePivotDropInstant } = require('../../utilities/pivotDropSchedule'); +const { + getTenantOpsBundle, + parseInclude, + resolveStageForWeek, + curationSectionsForStage, +} = require('../../services/pivotTenantOpsService'); + +const TENANT = { + tenantKey: 'nyc', + location: 'New York', + pivotPilot: true, + pivotDropTimezone: 'America/New_York', + pivotDropDayOfWeek: 4, + pivotDropHour: 18, + pivotDropMinute: 0, +}; + +describe('pivotTenantOpsService', () => { + beforeEach(() => { + jest.clearAllMocks(); + resolvePivotTenant.mockResolvedValue({ tenant: TENANT }); + // Drop already passed for current week → live = current, curate = next + resolvePivotDropInstant.mockReturnValue({ + dropAt: new Date('2020-01-01T00:00:00.000Z'), + }); + getTenantOverview.mockResolvedValue({ + data: { tenantKey: 'nyc', batchWeek: '2026-W28', kpis: { activeUsers: 3 } }, + }); + getTenantEventPerformance.mockResolvedValue({ + data: { events: [{ eventId: 'e1' }] }, + }); + getTenantInsights.mockResolvedValue({ data: { insights: [] } }); + getBatchReadiness.mockResolvedValue({ data: { score: 80 } }); + getJourneyOverview.mockResolvedValue({ + data: { kpis: { medianCardsSeen: 4 } }, + }); + getJourneyFunnel.mockResolvedValue({ data: { steps: [] } }); + aggregateTenantRetention.mockResolvedValue({ + tenantKey: 'nyc', + weeks: [{ batchWeek: '2026-W28', activeUsers: 3 }], + }); + listPivotLabEvents.mockResolvedValue({ data: { events: [] } }); + listCurationJobs.mockResolvedValue({ data: { jobs: [] } }); + }); + + describe('parseInclude', () => { + it('expands overview preset', () => { + expect(parseInclude('overview').sections).toEqual([ + 'overview', + 'performance', + 'insights', + 'readiness', + 'retention', + ]); + }); + + it('expands curation by stage', () => { + expect(parseInclude('curation', { stage: 'live' }).sections).toEqual([ + 'overview', + 'performance', + 'journey', + ]); + expect(parseInclude('curation', { stage: 'curate' }).sections).toEqual([ + 'overview', + 'readiness', + 'catalog', + 'jobs', + ]); + }); + + it('rejects unknown sections', () => { + expect(parseInclude('nope').code).toBe('INVALID_INCLUDE'); + }); + }); + + describe('resolveStageForWeek', () => { + it('classifies past / live / future', () => { + const anchors = { liveWeek: '2026-W28' }; + expect(resolveStageForWeek('2026-W27', anchors)).toBe('post-mortem'); + expect(resolveStageForWeek('2026-W28', anchors)).toBe('live'); + expect(resolveStageForWeek('2026-W29', anchors)).toBe('curate'); + }); + }); + + describe('curationSectionsForStage', () => { + it('returns monitor vs curate sections', () => { + expect(curationSectionsForStage('post-mortem')).toContain('performance'); + expect(curationSectionsForStage('curate')).toContain('catalog'); + }); + }); + + describe('getTenantOpsBundle', () => { + it('loads overview preset sections in parallel', async () => { + const result = await getTenantOpsBundle( + { globalDb: {} }, + { + tenantKey: 'nyc', + batchWeek: '2026-W28', + include: 'overview', + now: new Date('2026-07-10T18:00:00.000Z'), + }, + ); + + expect(result.data.tenantKey).toBe('nyc'); + expect(result.data.batchWeek).toBe('2026-W28'); + expect(result.data.stage).toBeTruthy(); + expect(result.data.anchors.liveWeek).toBeTruthy(); + expect(result.data.overview.kpis.activeUsers).toBe(3); + expect(result.data.performance.events).toHaveLength(1); + expect(result.data.retention.tenant.tenantKey).toBe('nyc'); + expect(getJourneyOverview).not.toHaveBeenCalled(); + expect(listCurationJobs).not.toHaveBeenCalled(); + }); + + it('loads journeys preset', async () => { + const result = await getTenantOpsBundle( + { globalDb: {} }, + { + tenantKey: 'nyc', + batchWeek: '2026-W28', + include: 'journeys', + now: new Date('2026-07-10T18:00:00.000Z'), + }, + ); + + expect(result.data.journey.kpis.medianCardsSeen).toBe(4); + expect(result.data.funnel.steps).toEqual([]); + expect(getTenantOverview).not.toHaveBeenCalled(); + }); + + it('curation preset for future week loads catalog + jobs', async () => { + // Force live = W27 so W28 is curate + resolvePivotDropInstant.mockReturnValue({ + dropAt: new Date('2099-01-01T00:00:00.000Z'), + }); + + const result = await getTenantOpsBundle( + { globalDb: {} }, + { + tenantKey: 'nyc', + batchWeek: '2026-W28', + include: 'curation', + now: new Date('2026-07-06T12:00:00.000Z'), // Monday of W28, drop still pending + }, + ); + + expect(result.data.stage).toBe('curate'); + expect(result.data.catalog).toEqual({ events: [] }); + expect(result.data.jobs).toEqual({ jobs: [] }); + expect(result.data.readiness.score).toBe(80); + expect(getTenantEventPerformance).not.toHaveBeenCalled(); + }); + + it('returns INCLUDE_REQUIRED when missing', async () => { + const result = await getTenantOpsBundle( + { globalDb: {} }, + { tenantKey: 'nyc', batchWeek: '2026-W28' }, + ); + expect(result.code).toBe('INCLUDE_REQUIRED'); + }); + }); +}); diff --git a/backend/utilities/pivotIngestStatus.js b/backend/utilities/pivotIngestStatus.js new file mode 100644 index 00000000..bfcd1aa3 --- /dev/null +++ b/backend/utilities/pivotIngestStatus.js @@ -0,0 +1,41 @@ +/** + * Pivot catalog ingestStatus lifecycle (Choice A feed gate). + * @see Meridian-Mintlify/strategy/pivot-metadata-contract.mdx + */ + +const PIVOT_INGEST_STATUSES = Object.freeze(['draft', 'staged', 'published']); + +/** Only `published` events are eligible for GET /pivot/feed (Choice A). */ +const PIVOT_FEED_INGEST_STATUS = 'published'; + +function isValidIngestStatus(value) { + return PIVOT_INGEST_STATUSES.includes(value); +} + +function isFeedEligibleIngestStatus(value) { + return value === PIVOT_FEED_INGEST_STATUS; +} + +/** + * @param {unknown} value + * @returns {{ ingestStatus: string } | { error: string, status: number, code: string }} + */ +function normalizeIngestStatus(value) { + const ingestStatus = typeof value === 'string' ? value.trim() : ''; + if (!isValidIngestStatus(ingestStatus)) { + return { + error: 'ingestStatus must be draft, staged, or published.', + status: 400, + code: 'INVALID_INGEST_STATUS', + }; + } + return { ingestStatus }; +} + +module.exports = { + PIVOT_INGEST_STATUSES, + PIVOT_FEED_INGEST_STATUS, + isValidIngestStatus, + isFeedEligibleIngestStatus, + normalizeIngestStatus, +}; diff --git a/backend/utilities/pivotIsoWeek.js b/backend/utilities/pivotIsoWeek.js index eef56896..71dba6c6 100644 --- a/backend/utilities/pivotIsoWeek.js +++ b/backend/utilities/pivotIsoWeek.js @@ -69,11 +69,85 @@ function shiftIsoWeek(batchWeek, delta) { return toIsoWeekUtc(monday); } +/** + * Derive batchWeek (YYYY-Www) from an event's actual start datetime. + * Uses the event's local calendar date via Date getters (same as toIsoWeek). + * @param {Date|string|number|null|undefined} value + * @returns {string|null} + */ +function batchWeekFromEventDate(value) { + if (value == null || value === '') return null; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return null; + return toIsoWeek(date); +} + +/** + * Resolve which batch week an ingest should land in. + * + * Default: ISO week of the event start (or first time-slot start). + * Override: pass forceBatchWeek + batchWeek to pin every write to a week. + * Fallback: batchWeek (when no event date) → current ISO week. + * + * @param {{ + * forceBatchWeek?: boolean, + * batchWeek?: string|null, + * startTime?: Date|string|null, + * timeSlots?: Array<{ start_time?: Date|string }>, + * now?: Date, + * }} options + * @returns {{ batchWeek: string, source: 'forced'|'event-date'|'fallback'|'current' } | { error, status, code }} + */ +function resolveEventBatchWeek(options = {}) { + const now = options.now || new Date(); + const forced = Boolean(options.forceBatchWeek); + const explicit = typeof options.batchWeek === 'string' ? options.batchWeek.trim() : ''; + + if (forced) { + if (!explicit || !isValidIsoWeek(explicit)) { + return { + error: 'batchWeek is required when forceBatchWeek is set (YYYY-Www).', + status: 400, + code: 'BATCH_WEEK_REQUIRED', + }; + } + return { batchWeek: explicit, source: 'forced' }; + } + + const fromStart = batchWeekFromEventDate(options.startTime); + if (fromStart) { + return { batchWeek: fromStart, source: 'event-date' }; + } + + const slots = Array.isArray(options.timeSlots) ? options.timeSlots : []; + for (const slot of slots) { + const fromSlot = batchWeekFromEventDate(slot?.start_time); + if (fromSlot) { + return { batchWeek: fromSlot, source: 'event-date' }; + } + } + + if (explicit) { + if (!isValidIsoWeek(explicit)) { + return { + error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).', + status: 400, + code: 'INVALID_BATCH_WEEK', + }; + } + return { batchWeek: explicit, source: 'fallback' }; + } + + return { batchWeek: toIsoWeek(now), source: 'current' }; +} + module.exports = { toIsoWeek, isValidIsoWeek, isoWeekToMondayUtc, isoWeekToUtcRange, shiftIsoWeek, + batchWeekFromEventDate, + resolveEventBatchWeek, ISO_WEEK_PATTERN, }; diff --git a/frontend/src/App.js b/frontend/src/App.js index e74c5252..775ed538 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -28,6 +28,7 @@ import QR from './pages/QR/QR'; import EventQRRedirect from './pages/QR/EventQRRedirect'; import Admin from './pages/Admin/Admin'; import PlatformAdmin from './pages/PlatformAdmin/PlatformAdmin'; +import PivotTenantDashboard from './pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDashboard'; import PlatformProtectedRoute from './components/PlatformProtectedRoute/PlatformProtectedRoute'; import OIEDash from './pages/OIEDash/OIEDash'; import NewBadge from './pages/NewBadge/NewBadge'; @@ -230,6 +231,7 @@ function App() { }/> }> } /> + } /> } /> }/> diff --git a/frontend/src/assets/pivot/fonts/LesFlosSans.otf b/frontend/src/assets/pivot/fonts/LesFlosSans.otf new file mode 100644 index 00000000..2946d6a5 Binary files /dev/null and b/frontend/src/assets/pivot/fonts/LesFlosSans.otf differ diff --git a/frontend/src/assets/pivot/just-go-burst.svg b/frontend/src/assets/pivot/just-go-burst.svg new file mode 100644 index 00000000..7f0820de --- /dev/null +++ b/frontend/src/assets/pivot/just-go-burst.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/src/assets/pivot/just-go-wordmark-dark.svg b/frontend/src/assets/pivot/just-go-wordmark-dark.svg new file mode 100644 index 00000000..048e2c60 --- /dev/null +++ b/frontend/src/assets/pivot/just-go-wordmark-dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/src/components/Dashboard/Dashboard.jsx b/frontend/src/components/Dashboard/Dashboard.jsx index 6b59f94e..8691f7e6 100644 --- a/frontend/src/components/Dashboard/Dashboard.jsx +++ b/frontend/src/components/Dashboard/Dashboard.jsx @@ -594,7 +594,11 @@ function Dashboard({
- Logo + {typeof logo === 'string' || logo == null ? ( + Logo + ) : ( + logo + )}
@@ -632,7 +636,11 @@ function Dashboard({
setIsMobileMenuOpen(!isMobileMenuOpen)}>
- Logo + {typeof logo === 'string' || logo == null ? ( + Logo + ) : ( + logo + )} {user ?
{/* just to balance the space-between */} @@ -672,7 +680,11 @@ function Dashboard({ )}
- Logo + {typeof logo === 'string' || logo == null ? ( + Logo + ) : ( + logo + )}
{middleItem && middleItem} diff --git a/frontend/src/config/tenantRedirect.js b/frontend/src/config/tenantRedirect.js index a91ce81a..9a820e58 100644 --- a/frontend/src/config/tenantRedirect.js +++ b/frontend/src/config/tenantRedirect.js @@ -134,6 +134,8 @@ const WWW_ALLOWED_PATHS = [ '/login', ]; +// /platform-admin/pivot/:tenantKey is covered by the '/platform-admin' prefix check in isPathAllowedOnWww. + export function isPathAllowedOnWww(pathname) { const path = (pathname || '/').split('?')[0] || '/'; return WWW_ALLOWED_PATHS.some(allowed => { diff --git a/frontend/src/pages/PlatformAdmin/PivotLab/IngestStatusPill.jsx b/frontend/src/pages/PlatformAdmin/PivotLab/IngestStatusPill.jsx new file mode 100644 index 00000000..2461f355 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotLab/IngestStatusPill.jsx @@ -0,0 +1,16 @@ +import React from 'react'; + +function IngestStatusPill({ status }) { + if (status === 'published') { + return Published; + } + if (status === 'staged') { + return Staged; + } + if (status === 'draft') { + return Draft; + } + return ; +} + +export default IngestStatusPill; diff --git a/frontend/src/pages/PlatformAdmin/PivotLab/PivotCatalogEventEditModal.jsx b/frontend/src/pages/PlatformAdmin/PivotLab/PivotCatalogEventEditModal.jsx index 57ce5525..4b2020be 100644 --- a/frontend/src/pages/PlatformAdmin/PivotLab/PivotCatalogEventEditModal.jsx +++ b/frontend/src/pages/PlatformAdmin/PivotLab/PivotCatalogEventEditModal.jsx @@ -52,7 +52,7 @@ export function catalogEventToEditDraft(event) { startTimeLocal: isoToDatetimeLocal(event.start_time), endTimeLocal: isoToDatetimeLocal(event.end_time), timeSlots: hasShowtimes ? timeSlots : [], - ingestStatus: event.ingestStatus || 'published', + ingestStatus: event.ingestStatus || 'staged', tags: Array.isArray(event.tags) ? [...event.tags] : [], movie: event.movie || null, }; @@ -455,6 +455,7 @@ function PivotCatalogEventEditModal({ onChange={(e) => patchDraft({ ingestStatus: e.target.value })} > + diff --git a/frontend/src/pages/PlatformAdmin/PivotLab/PivotImportThumb.jsx b/frontend/src/pages/PlatformAdmin/PivotLab/PivotImportThumb.jsx new file mode 100644 index 00000000..8331c1a2 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotLab/PivotImportThumb.jsx @@ -0,0 +1,42 @@ +import React, { useEffect, useState } from 'react'; + +/** + * Small cover thumbnail for import / catalog / curation tables. + * Shows a dashed placeholder when src is missing or fails to load. + */ +function PivotImportThumb({ src, alt }) { + const [failed, setFailed] = useState(false); + + useEffect(() => { + setFailed(false); + }, [src]); + + if (!src || failed) { + return ( + + ); + } + + return ( + {alt setFailed(true)} + /> + ); +} + +export default PivotImportThumb; diff --git a/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx b/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx index aff49f05..30cc29a3 100644 --- a/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx +++ b/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx @@ -20,6 +20,8 @@ import PivotManualImportModal, { import PivotCatalogEventEditModal, { catalogEditDraftToOverrides, } from './PivotCatalogEventEditModal'; +import IngestStatusPill from './IngestStatusPill'; +import PivotImportThumb from './PivotImportThumb'; import { autoMatchTmdbMovieForEvent, autoMatchFilmsForImportEntries, @@ -32,6 +34,10 @@ import './PivotDeckCardPreview.scss'; const EMPTY_LIST = []; const NO_FETCH_CACHE = { enabled: false }; const PURGE_CONFIRM_TOKEN = 'PURGE'; +// Batch tag suggestion runs one Claude call per event server-side; sending the whole +// selection in a single request can exceed the production gateway timeout (bare 503). +// Split it into small chunks so each request stays short and results save incrementally. +const AI_TAG_CHUNK_SIZE = 4; const PIVOT_JSON_IMPORT_EXAMPLE = `{ "label": "Brooklyn week crawl", @@ -464,16 +470,18 @@ function buildDeckPreviewProps({ function isBlockingImportDuplicate(duplicate) { if (!duplicate) return false; - return duplicate.matchType !== 'sourceUrl'; + // sourceUrl and fingerprint matches update an existing catalog event; only collisions + // between two rows of the same import batch have nothing to update against. + return duplicate.matchType === 'batchSourceUrl' || duplicate.matchType === 'batchFingerprint'; } function duplicateBadgeLabel(duplicate) { if (!duplicate) return null; - if (duplicate.matchType === 'sourceUrl') return 'Will update'; if (duplicate.matchType === 'batchSourceUrl' || duplicate.matchType === 'batchFingerprint') { return 'Batch duplicate'; } - return 'Duplicate'; + // sourceUrl (exact) or fingerprint (fuzzy) → publishing updates the existing row. + return 'Will update'; } function createBatchImportRow(entry, index) { @@ -511,6 +519,7 @@ function createBatchImportRow(entry, index) { tags, timeSlots, movie, + aiTagged: false, warnings: entry?.warnings || [], duplicate, isBlockingDuplicate, @@ -557,16 +566,6 @@ function formatEventFilmStatus(draft) { return '—'; } -function IngestStatusPill({ status }) { - if (status === 'published') { - return Published; - } - if (status === 'draft') { - return Draft; - } - return ; -} - const LAB_TABS = [ { key: 'overview', label: 'Overview' }, { key: 'import', label: 'Import' }, @@ -578,6 +577,7 @@ function PivotLabPage() { const { addNotification } = useNotification(); const [activeTab, setActiveTab] = useState('overview'); const [batchWeek, setBatchWeek] = useState(() => toIsoWeek()); + const [forceBatchWeek, setForceBatchWeek] = useState(false); const [selectedTenantKey, setSelectedTenantKey] = useState(''); const [notesDraft, setNotesDraft] = useState(''); const [notesDirty, setNotesDirty] = useState(false); @@ -599,6 +599,7 @@ function PivotLabPage() { const [batchApplyTags, setBatchApplyTags] = useState([]); const [tmdbMatchLoadingKey, setTmdbMatchLoadingKey] = useState(null); const [tagSuggestLoadingKey, setTagSuggestLoadingKey] = useState(null); + const [batchTagProgress, setBatchTagProgress] = useState(null); const [tagSeeding, setTagSeeding] = useState(false); const [importName, setImportName] = useState(''); const [importLocation, setImportLocation] = useState(''); @@ -1000,7 +1001,7 @@ function PivotLabPage() { if (!selectedTenantKey) { addNotification({ title: 'Choose a city', - message: 'Select a pivot city before publishing.', + message: 'Select a pivot city before staging.', type: 'warning', }); return false; @@ -1013,6 +1014,7 @@ function PivotLabPage() { data: { tenantKey: selectedTenantKey, batchWeek, + forceBatchWeek, overrides: { hostName: entry.draft.hostName, name: entry.draft.name, @@ -1035,23 +1037,31 @@ function PivotLabPage() { if (error || !data?.success) { addNotification({ - title: 'Publish failed', - message: error || data?.message || 'Could not publish event.', + title: 'Stage failed', + message: error || data?.message || 'Could not stage event.', type: 'error', }); return false; } + const assignedWeek = data.data?.batchWeek || batchWeek; refetchEvents(); refetchOverview(); addNotification({ - title: 'Published', - message: `${data.data?.event?.name || entry.draft.name} added to ${selectedTenantKey}.`, + title: 'Staged', + message: `${data.data?.event?.name || entry.draft.name} added to ${selectedTenantKey} for ${assignedWeek} (not live until Release).`, type: 'success', }); return true; }, - [addNotification, batchWeek, refetchEvents, refetchOverview, selectedTenantKey], + [ + addNotification, + batchWeek, + forceBatchWeek, + refetchEvents, + refetchOverview, + selectedTenantKey, + ], ); const suggestTagsForManualImport = useCallback( @@ -1102,6 +1112,34 @@ function PivotLabPage() { ); }, []); + // Fuzzy/exact duplicate detection for JSON entries, matched against the selected city's + // catalog so publishing updates existing rows instead of creating duplicates. + const annotateJsonEntriesWithDuplicates = useCallback( + async (entries) => { + if (!selectedTenantKey || !entries.length) { + return { entries, duplicateWarnings: [] }; + } + + const { data, error } = await authenticatedRequest( + '/admin/pivot/ingest/annotate-duplicates', + { + method: 'POST', + data: { tenantKey: selectedTenantKey, drafts: entries }, + }, + ); + + if (error || !data?.success) { + return { entries, duplicateWarnings: [] }; + } + + return { + entries: data.data?.drafts || entries, + duplicateWarnings: data.data?.duplicateWarnings || [], + }; + }, + [selectedTenantKey], + ); + const handlePreviewJsonImport = useCallback(async () => { const result = parsePivotJsonImport(importJsonDraft); if (result.error) { @@ -1140,10 +1178,15 @@ function PivotLabPage() { } } + const { entries: annotatedEntries, duplicateWarnings } = + await annotateJsonEntriesWithDuplicates(entries); + entries = annotatedEntries; + setJsonImportPreview({ label: result.label, entries, tmdbMatch: { matched, failed, pending: pendingFilmCount }, + duplicateWarnings, }); if (pendingFilmCount) { @@ -1157,7 +1200,12 @@ function PivotLabPage() { type: matched ? (failed ? 'warning' : 'success') : 'warning', }); } - }, [addNotification, importJsonDraft, syncJsonImportDraftFromEntries]); + }, [ + addNotification, + annotateJsonEntriesWithDuplicates, + importJsonDraft, + syncJsonImportDraftFromEntries, + ]); const handleLoadJsonImport = useCallback(async () => { const result = @@ -1201,11 +1249,17 @@ function PivotLabPage() { } } + const { entries: annotatedEntries, duplicateWarnings } = + await annotateJsonEntriesWithDuplicates(entries); + entries = annotatedEntries; + const updateCount = entries.filter((entry) => entry.duplicate?.willUpdate).length; + setImportError(''); setJsonImportPreview({ label: result.label, entries, tmdbMatch: { matched, failed, pending: pendingFilmCount }, + duplicateWarnings, }); setImportMode('batch'); setImportUrl(''); @@ -1225,6 +1279,11 @@ function PivotLabPage() { `${failed} film event(s) could not be matched to TMDB — use Retry on those rows.`, ); } + if (updateCount) { + loadWarnings.push( + `${updateCount} event(s) match existing catalog rows and will update them on publish.`, + ); + } setImportWarnings(loadWarnings); setImportDuplicate(null); setDeckPreviewState(null); @@ -1235,10 +1294,18 @@ function PivotLabPage() { message: matched > 0 ? `${entries.length} event(s) loaded · ${matched} film(s) matched from TMDB.` - : `${entries.length} event(s) ready for review.`, + : `${entries.length} event(s) ready for review${ + updateCount ? ` · ${updateCount} will update existing rows` : '' + }.`, type: 'success', }); - }, [addNotification, importJsonDraft, jsonImportPreview, syncJsonImportDraftFromEntries]); + }, [ + addNotification, + annotateJsonEntriesWithDuplicates, + importJsonDraft, + jsonImportPreview, + syncJsonImportDraftFromEntries, + ]); const handleMatchTmdbForJsonEntry = useCallback( async (index) => { @@ -1460,7 +1527,7 @@ function PivotLabPage() { return; } - updateBatchImportRow(rowKey, { tags: result.tags }); + updateBatchImportRow(rowKey, { tags: result.tags, aiTagged: true }); }, [addNotification, buildTagSuggestPayload, importBatchRows, requestSuggestedTags, updateBatchImportRow], ); @@ -1468,58 +1535,105 @@ function PivotLabPage() { const suggestTagsForSelectedBatchRows = useCallback(async () => { if (!selectedBatchRows.length) return; + // Resume-friendly: target selected rows that haven't been through Claude yet. If every + // selected row is already AI-tagged, treat the click as a deliberate re-run of all of them. + const pending = selectedBatchRows.filter((row) => !row.aiTagged); + const targetRows = pending.length ? pending : selectedBatchRows; + const total = targetRows.length; + setTagSuggestLoadingKey('batch-all'); - const { data, error } = await authenticatedRequest('/admin/pivot/ingest/suggest-tags', { - method: 'POST', - data: { - events: selectedBatchRows.map((row) => - buildTagSuggestPayload({ - name: row.name, - description: row.description, - location: row.location, - organizerName: row.organizerName, - sourceTags: row.sourceTags, - }), + setBatchTagProgress({ done: 0, total }); + + let suggestedTotal = 0; + let failedTotal = 0; + let stoppedError = null; + + // Chunk the selection into several short requests so one slow/large call can't trip the + // production gateway timeout, and each chunk's tags are saved as soon as it returns. + for (let start = 0; start < targetRows.length; start += AI_TAG_CHUNK_SIZE) { + const chunk = targetRows.slice(start, start + AI_TAG_CHUNK_SIZE); + + const { data, error } = await authenticatedRequest('/admin/pivot/ingest/suggest-tags', { + method: 'POST', + data: { + events: chunk.map((row) => + buildTagSuggestPayload({ + name: row.name, + description: row.description, + location: row.location, + organizerName: row.organizerName, + sourceTags: row.sourceTags, + }), + ), + }, + }); + + if (error || !data?.success) { + // A transport-level failure (gateway 503/timeout) carries no structured body. Stop + // here so already-processed chunks stay saved and the user can click again to resume. + stoppedError = { + message: error || data?.message || 'Could not reach the tag suggestion service.', + type: data?.code === 'LLM_NOT_CONFIGURED' ? 'warning' : 'error', + }; + break; + } + + const suggestions = data.data?.suggestions || []; + failedTotal += data.data?.failedCount ?? 0; + + const tagsByKey = new Map(); + for (let i = 0; i < chunk.length; i += 1) { + const tags = suggestions[i]?.tags || []; + if (tags.length) suggestedTotal += 1; + tagsByKey.set(chunk[i].key, tags); + } + + setImportBatchRows((rows) => + rows.map((row) => + tagsByKey.has(row.key) + ? { + ...row, + tags: tagsByKey.get(row.key).length ? tagsByKey.get(row.key) : row.tags || [], + aiTagged: true, + } + : row, ), - }, - }); + ); + + setBatchTagProgress({ done: Math.min(start + chunk.length, total), total }); + } + setTagSuggestLoadingKey(null); + setBatchTagProgress(null); - if (error || !data?.success) { + if (stoppedError) { addNotification({ - title: 'Batch tag suggestion failed', - message: error || data?.message || 'Could not suggest tags.', - type: data?.code === 'LLM_NOT_CONFIGURED' ? 'warning' : 'error', + title: suggestedTotal + ? 'Tagging interrupted — partial results saved' + : 'Batch tag suggestion failed', + message: suggestedTotal + ? `${suggestedTotal} row(s) tagged before the error — click again to resume the rest. (${stoppedError.message})` + : stoppedError.message, + type: suggestedTotal ? 'warning' : stoppedError.type, }); return; } - const suggestions = data.data?.suggestions || []; - const failedCount = data.data?.failedCount ?? 0; - const suggestedCount = data.data?.suggestedCount ?? 0; - setImportBatchRows((rows) => - rows.map((row) => { - const selectedIndex = selectedBatchRows.findIndex((entry) => entry.key === row.key); - if (selectedIndex === -1) return row; - return { ...row, tags: suggestions[selectedIndex]?.tags || row.tags || [] }; - }), - ); - - if (suggestedCount === 0) { + if (suggestedTotal === 0) { addNotification({ title: 'No tags suggested', - message: data.data?.failures?.[0]?.message || 'Claude did not return valid catalog tags.', + message: 'Claude did not return valid catalog tags for the selected rows.', type: 'warning', }); return; } addNotification({ - title: failedCount ? 'Batch partially tagged' : 'Batch tags suggested', - message: failedCount - ? `${suggestedCount} row(s) tagged, ${failedCount} failed.` - : `${suggestedCount} row(s) tagged via Claude.`, - type: failedCount ? 'warning' : 'success', + title: failedTotal ? 'Batch partially tagged' : 'Batch tags suggested', + message: failedTotal + ? `${suggestedTotal} row(s) tagged, ${failedTotal} failed.` + : `${suggestedTotal} row(s) tagged via Claude.`, + type: failedTotal ? 'warning' : 'success', }); }, [addNotification, buildTagSuggestPayload, selectedBatchRows]); @@ -1642,6 +1756,7 @@ function PivotLabPage() { data: { tenantKey: selectedTenantKey, batchWeek, + forceBatchWeek, events: publishableBatchRows.map((row) => ({ url: row.sourceUrl.trim() || undefined, overrides: buildBatchPublishOverrides(row), @@ -1651,25 +1766,171 @@ function PivotLabPage() { setImportPublishLoading(false); if (error || !data?.success) { - setImportError(error || data?.message || 'Could not publish selected events.'); + setImportError(error || data?.message || 'Could not stage selected events.'); return; } const publishedCount = data.data?.publishedCount ?? data.data?.published?.length ?? 0; const failedCount = data.data?.failedCount ?? data.data?.failures?.length ?? 0; + const updatedCount = data.data?.updatedCount ?? 0; + const createdCount = Math.max(publishedCount - updatedCount, 0); + const updatedSuffix = updatedCount ? ` (${updatedCount} updated existing)` : ''; + const weekCounts = data.data?.batchWeekCounts || {}; + const weekKeys = Object.keys(weekCounts).sort(); + const weekSuffix = + weekKeys.length > 1 + ? ` Weeks: ${weekKeys.map((w) => `${w} (${weekCounts[w]})`).join(', ')}.` + : weekKeys.length === 1 + ? ` Week ${weekKeys[0]}.` + : ''; refetchEvents(); refetchOverview(); addNotification({ - title: failedCount ? 'Batch partially published' : 'Batch published', + title: failedCount ? 'Batch partially staged' : 'Batch staged', message: failedCount - ? `${publishedCount} event(s) published, ${failedCount} failed.` - : `${publishedCount} event(s) added to ${selectedTenantKey}.`, + ? `${createdCount} staged, ${updatedCount} updated, ${failedCount} failed.${weekSuffix} Release separately to go live.` + : `${publishedCount} event(s) staged for ${selectedTenantKey}${updatedSuffix}.${weekSuffix} Not live until Release.`, type: failedCount ? 'warning' : 'success', }); }, [ addNotification, batchWeek, + forceBatchWeek, + publishableBatchRows, + refetchEvents, + refetchOverview, + selectedTenantKey, + ]); + + const handleStageAndReleaseNow = useCallback(async () => { + if (importMode === 'batch') { + if (!publishableBatchRows.length || !selectedTenantKey) { + setImportError('Select events with title, organizer, location, start time, and at least one tag.'); + return; + } + const typed = window.prompt( + 'Emergency: stage & release now puts events in the live feed immediately.\n\nType RELEASE_NOW to confirm:', + ); + if (typed !== 'RELEASE_NOW') { + if (typed != null) { + setImportError('Release cancelled — type RELEASE_NOW exactly to confirm.'); + } + return; + } + + setImportPublishLoading(true); + setImportError(''); + const { data, error } = await authenticatedRequest('/admin/pivot/ingest/batch', { + method: 'POST', + data: { + tenantKey: selectedTenantKey, + batchWeek, + forceBatchWeek, + releaseNow: true, + confirm: 'RELEASE_NOW', + events: publishableBatchRows.map((row) => ({ + url: row.sourceUrl.trim() || undefined, + overrides: buildBatchPublishOverrides(row), + })), + }, + }); + setImportPublishLoading(false); + + if (error || !data?.success) { + setImportError(error || data?.message || 'Could not release selected events.'); + return; + } + + const publishedCount = data.data?.publishedCount ?? data.data?.published?.length ?? 0; + const weekCounts = data.data?.batchWeekCounts || {}; + const weekKeys = Object.keys(weekCounts).sort(); + const weekLabel = + weekKeys.length > 1 + ? weekKeys.map((w) => `${w} (${weekCounts[w]})`).join(', ') + : weekKeys[0] || batchWeek; + refetchEvents(); + refetchOverview(); + addNotification({ + title: 'Released to deck', + message: `${publishedCount} event(s) live in ${selectedTenantKey} for ${weekLabel}.`, + type: 'success', + }); + return; + } + + if (!importPreview || !selectedTenantKey) { + setImportError('Preview an event and choose a city before releasing.'); + return; + } + if (!importOrganizerName.trim()) { + setImportError('Organizer name is required.'); + return; + } + if (!importSelectedTags.length) { + setImportError('Select at least one catalog tag.'); + return; + } + + const typed = window.prompt( + 'Emergency: stage & release now puts this event in the live feed immediately.\n\nType RELEASE_NOW to confirm:', + ); + if (typed !== 'RELEASE_NOW') { + if (typed != null) { + setImportError('Release cancelled — type RELEASE_NOW exactly to confirm.'); + } + return; + } + + setImportPublishLoading(true); + setImportError(''); + const { data, error } = await authenticatedRequest('/admin/pivot/ingest', { + method: 'POST', + data: { + tenantKey: selectedTenantKey, + url: importUrl.trim(), + batchWeek, + forceBatchWeek, + releaseNow: true, + confirm: 'RELEASE_NOW', + overrides: { + hostName: importOrganizerName.trim(), + name: importName.trim() || undefined, + location: importLocation.trim() || undefined, + start_time: importStartTime.trim() || undefined, + description: importDescription.trim() || undefined, + tags: importSelectedTags, + }, + }, + }); + setImportPublishLoading(false); + + if (error || !data?.success) { + setImportError(error || data?.message || 'Could not release event.'); + return; + } + + const assignedWeek = data.data?.batchWeek || batchWeek; + refetchEvents(); + refetchOverview(); + addNotification({ + title: 'Released to deck', + message: `${data.data?.event?.name || 'Event'} is live in ${selectedTenantKey} for ${assignedWeek}.`, + type: 'success', + }); + }, [ + addNotification, + batchWeek, + forceBatchWeek, + importDescription, + importLocation, + importMode, + importName, + importOrganizerName, + importPreview, + importSelectedTags, + importStartTime, + importUrl, publishableBatchRows, refetchEvents, refetchOverview, @@ -1682,7 +1943,7 @@ function PivotLabPage() { } if (!importPreview || !selectedTenantKey) { - setImportError('Preview an event and choose a city before publishing.'); + setImportError('Preview an event and choose a city before staging.'); return; } if (!importOrganizerName.trim()) { @@ -1703,6 +1964,7 @@ function PivotLabPage() { tenantKey: selectedTenantKey, url: importUrl.trim(), batchWeek, + forceBatchWeek, overrides: { hostName: importOrganizerName.trim(), name: importName.trim() || undefined, @@ -1716,20 +1978,25 @@ function PivotLabPage() { setImportPublishLoading(false); if (error || !data?.success) { - setImportError(error || data?.message || 'Could not publish event.'); + setImportError(error || data?.message || 'Could not stage event.'); return; } refetchEvents(); refetchOverview(); + const wasUpdated = data.data?.updated; + const assignedWeek = data.data?.batchWeek || batchWeek; addNotification({ - title: 'Published', - message: `${data.data?.event?.name || 'Event'} added to ${selectedTenantKey}.`, + title: wasUpdated ? 'Updated' : 'Staged', + message: `${data.data?.event?.name || 'Event'} ${ + wasUpdated ? 'updated in' : 'staged for' + } ${selectedTenantKey} (${assignedWeek}). Not live until Release.`, type: 'success', }); }, [ addNotification, batchWeek, + forceBatchWeek, handlePublishBatchImport, importDescription, importLocation, @@ -1859,6 +2126,17 @@ function PivotLabPage() {
+
- Publishing to {selectedTenant?.cityDisplayName || selectedTenantKey || '—'} + Staging for {selectedTenant?.cityDisplayName || selectedTenantKey || '—'}
@@ -2050,7 +2330,7 @@ function PivotLabPage() {

Found {jsonImportPreview.entries.length} event(s) in{' '} {jsonImportPreview.label}. {jsonImportReadyCount} ready to - publish as-is; others need tags or missing fields. + stage as-is; others need tags or missing fields. {jsonImportPreview.tmdbMatch?.matched ? ` ${jsonImportPreview.tmdbMatch.matched} film(s) matched from TMDB.` : ''} @@ -2062,6 +2342,7 @@ function PivotLabPage() { + @@ -2070,6 +2351,7 @@ function PivotLabPage() { + @@ -2083,6 +2365,9 @@ function PivotLabPage() { key={`${draft.name || 'event'}-${index}`} className={ready ? '' : 'pivot-lab__json-preview-row--warn'} > + @@ -2097,6 +2382,26 @@ function PivotLabPage() { Needs review )} +
Image Event Organizer WhenTags Film StatusCatalog TMDB
+ + {draft.name || '—'} {draft.hostName || '—'} {formatEventWhen(draft.start_time || draft.timeSlots?.[0]?.start_time)} + {entry.duplicate ? ( + + {duplicateBadgeLabel(entry.duplicate)} + + ) : ( + '—' + )} + {draft.movie?.tmdbId ? ( '—' @@ -2120,6 +2425,13 @@ function PivotLabPage() {

+ {jsonImportPreview.duplicateWarnings?.length ? ( +
    + {jsonImportPreview.duplicateWarnings.map((warning) => ( +
  • {warning}
  • + ))} +
+ ) : null} {jsonImportPreview.entries.some((entry) => entry.warnings?.length) ? (
    {jsonImportPreview.entries.flatMap((entry, index) => @@ -2147,7 +2459,7 @@ function PivotLabPage() {

    Detected provider: {importProvider}

    ) : null}

    - Found {importBatchRows.length} event(s) from {importBatchLabel}. Select rows to publish + Found {importBatchRows.length} event(s) from {importBatchLabel}. Select rows to stage and fill any missing organizer names.

    {importWarnings.length ? ( @@ -2182,7 +2494,9 @@ function PivotLabPage() { disabled={!selectedBatchRows.length || tagSuggestLoadingKey === 'batch-all'} > {tagSuggestLoadingKey === 'batch-all' - ? 'Suggesting…' + ? batchTagProgress + ? `Suggesting ${batchTagProgress.done}/${batchTagProgress.total}…` + : 'Suggesting…' : 'Suggest tags for selected (Claude)'} @@ -2210,6 +2524,7 @@ function PivotLabPage() { }} /> + Image Event Status Organizer @@ -2246,6 +2561,9 @@ function PivotLabPage() { aria-label={`Select ${row.name || 'event'}`} /> + + + {row.name || '—'} {row.duplicateLabel ? ( @@ -2288,10 +2606,25 @@ function PivotLabPage() { type="button" className="linear-btn linear-btn--ghost pivot-lab__tag-ai-btn" onClick={() => suggestTagsForBatchRow(row.key)} - disabled={tagSuggestLoadingKey === row.key} + disabled={ + tagSuggestLoadingKey === row.key || + tagSuggestLoadingKey === 'batch-all' + } > - {tagSuggestLoadingKey === row.key ? '…' : 'AI'} + {tagSuggestLoadingKey === row.key + ? '…' + : row.aiTagged + ? 'Redo' + : 'AI'} + {row.aiTagged ? ( + + ✓ AI + + ) : null} {formatEventFilmStatus(row)} @@ -2345,8 +2678,18 @@ function PivotLabPage() { } > {importPublishLoading - ? 'Publishing…' - : `Publish ${publishableBatchRows.length} selected to ${selectedTenantKey || 'city'}`} + ? 'Staging…' + : `Stage ${publishableBatchRows.length} selected for ${selectedTenantKey || 'city'}`} + + @@ -2373,7 +2716,7 @@ function PivotLabPage() { className="linear-input" value={importOrganizerName} onChange={(e) => setImportOrganizerName(e.target.value)} - placeholder="Required before publish" + placeholder="Required before stage" /> {importPreview.image ? ( -

    - Cover image:{' '} - - preview +

    ) : null} {importWarnings.length ? (
      @@ -2454,8 +2802,21 @@ function PivotLabPage() { } > {importPublishLoading - ? 'Publishing…' - : `Publish to ${selectedTenantKey || 'city'}`} + ? 'Staging…' + : `Stage for ${selectedTenantKey || 'city'}`} + + @@ -2479,7 +2840,8 @@ function PivotLabPage() { Catalog events · {selectedTenant?.cityDisplayName || selectedTenantKey || '—'}

      - Published and draft events for {batchWeek}, with swipe performance per event. + Draft, staged, and published events for {batchWeek}. Staged events stay off the + mobile feed until Release.

      @@ -2491,7 +2853,11 @@ function PivotLabPage() { + + @@ -2510,6 +2876,21 @@ function PivotLabPage() { {events.map((event) => ( + + diff --git a/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.scss b/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.scss index e3464689..9ddcec73 100644 --- a/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.scss +++ b/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.scss @@ -1,5 +1,4 @@ .pivot-lab { - padding: 28px 32px 48px; max-width: 1200px; height: 100%; overflow-y: auto; @@ -64,6 +63,20 @@ gap: 10px; } + &__check { + display: inline-flex; + align-items: center; + gap: 8px; + margin-bottom: 2px; + font-size: 13px; + color: var(--la-text-secondary); + cursor: pointer; + + input { + margin: 0; + } + } + &__context-bar { display: flex; flex-wrap: wrap; @@ -387,6 +400,11 @@ color: #047857; } + &--info { + background: #eff6ff; + color: #1d4ed8; + } + &--warn { background: #fff7ed; color: #c2410c; @@ -677,11 +695,57 @@ } &__import-image { + display: flex; + align-items: center; + gap: 10px; margin: 0 0 8px; font-size: 13px; color: var(--la-text-secondary); } + &__import-image-label { + color: var(--la-text-tertiary); + } + + &__thumb { + width: 48px; + height: 48px; + border-radius: var(--la-radius-sm); + object-fit: cover; + display: block; + background: var(--la-bg); + border: 1px solid var(--la-border-subtle); + } + + &__thumb--empty { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--la-text-tertiary); + background: var(--la-bg); + border-style: dashed; + } + + &__thumb-cell { + width: 60px; + padding-right: 4px; + } + + &__thumb-link { + display: inline-flex; + line-height: 0; + } + + &__batch-pill { + display: inline-block; + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + color: var(--la-text-secondary); + white-space: nowrap; + } + &__import-warnings { margin: 12px 0 0; padding-left: 18px; @@ -876,6 +940,19 @@ min-height: 28px; } + &__ai-done { + display: inline-block; + margin-top: 6px; + margin-left: 6px; + padding: 2px 6px; + font-size: 11px; + font-weight: 600; + color: var(--la-accent); + background: var(--la-accent-soft); + border-radius: var(--la-radius-sm); + vertical-align: top; + } + &__tmdb-btn { padding-inline: 10px; min-height: 28px; diff --git a/frontend/src/pages/PlatformAdmin/PivotLab/PivotManualImportModal.jsx b/frontend/src/pages/PlatformAdmin/PivotLab/PivotManualImportModal.jsx index 4ff9aeec..17e71720 100644 --- a/frontend/src/pages/PlatformAdmin/PivotLab/PivotManualImportModal.jsx +++ b/frontend/src/pages/PlatformAdmin/PivotLab/PivotManualImportModal.jsx @@ -911,7 +911,7 @@ function PivotManualImportModal({ onClick={handlePublish} disabled={!canSubmit || !selectedTenantKey || publishLoading} > - {publishLoading ? 'Publishing…' : 'Publish now'} + {publishLoading ? 'Staging…' : 'Stage now'} ⌘⇧↵ {onSuggestTags ? ( @@ -930,7 +930,7 @@ function PivotManualImportModal({

      M open · 19 tags · ⌘↵ queue next ·{' '} - ⌘⇧↵ publish + ⌘⇧↵ stage

      diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotBatchWeekPicker.jsx b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotBatchWeekPicker.jsx new file mode 100644 index 00000000..35ddb120 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotBatchWeekPicker.jsx @@ -0,0 +1,278 @@ +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + isValidIsoWeek, + shiftIsoWeek, + formatIsoWeekRange, + toIsoWeek, +} from '../../../utils/pivotIsoWeek'; +import KeybindTooltip from '../../../components/Interface/KeybindTooltip/KeybindTooltip'; +import './PivotBatchWeekPicker.scss'; + +const DEFAULT_PAST = 8; +const DEFAULT_FUTURE = 8; +const MENU_GAP = 6; +const MENU_MIN_WIDTH = 280; +const VIEWPORT_PAD = 8; + +function buildWeekOptions( + centerWeek, + { past = DEFAULT_PAST, future = DEFAULT_FUTURE, extraWeeks = [] } = {}, +) { + const center = isValidIsoWeek(centerWeek) ? centerWeek : toIsoWeek(); + const byWeek = new Map(); + for (let delta = -past; delta <= future; delta += 1) { + const week = shiftIsoWeek(center, delta); + if (!week) continue; + byWeek.set(week, { + week, + rangeLabel: formatIsoWeekRange(week), + delta, + }); + } + for (const week of extraWeeks) { + if (!isValidIsoWeek(week) || byWeek.has(week)) continue; + byWeek.set(week, { + week, + rangeLabel: formatIsoWeekRange(week), + delta: week < center ? -past - 1 : future + 1, + }); + } + return Array.from(byWeek.values()).sort((a, b) => a.week.localeCompare(b.week)); +} + +function measureMenuPosition(triggerEl) { + if (!triggerEl || typeof window === 'undefined') return null; + const rect = triggerEl.getBoundingClientRect(); + const viewportW = window.innerWidth; + const viewportH = window.innerHeight; + const width = Math.max(MENU_MIN_WIDTH, rect.width); + let left = rect.left; + if (left + width > viewportW - VIEWPORT_PAD) { + left = Math.max(VIEWPORT_PAD, viewportW - VIEWPORT_PAD - width); + } + const top = rect.bottom + MENU_GAP; + const maxHeight = Math.max(120, viewportH - top - VIEWPORT_PAD); + return { top, left, width, maxHeight }; +} + +/** + * Batch week stepper with keyboard tooltips + click-to-open week menu. + * Menu is portaled to document.body so page stacking/overflow cannot cover it. + */ +function PivotBatchWeekPicker({ + batchWeek, + onChange, + disabled = false, + keyboardNavActive = null, + anchors = null, + pastWeeks = DEFAULT_PAST, + futureWeeks = DEFAULT_FUTURE, + label = 'Batch week', + showLabel = true, + pending = false, +}) { + const [open, setOpen] = useState(false); + const [menuPos, setMenuPos] = useState(null); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const listRef = useRef(null); + const valid = isValidIsoWeek(batchWeek); + + const options = useMemo( + () => + buildWeekOptions(batchWeek, { + past: pastWeeks, + future: futureWeeks, + extraWeeks: [anchors?.liveWeek, anchors?.curateWeek].filter(Boolean), + }), + [batchWeek, pastWeeks, futureWeeks, anchors?.liveWeek, anchors?.curateWeek], + ); + + const close = useCallback(() => setOpen(false), []); + + const updateMenuPosition = useCallback(() => { + setMenuPos(measureMenuPosition(triggerRef.current)); + }, []); + + useLayoutEffect(() => { + if (!open) { + setMenuPos(null); + return undefined; + } + updateMenuPosition(); + const onReposition = () => updateMenuPosition(); + window.addEventListener('resize', onReposition); + // Capture scroll from nested overflow containers too. + window.addEventListener('scroll', onReposition, true); + return () => { + window.removeEventListener('resize', onReposition); + window.removeEventListener('scroll', onReposition, true); + }; + }, [open, updateMenuPosition]); + + useEffect(() => { + if (!open) return undefined; + const onPointerDown = (event) => { + const target = event.target; + if (rootRef.current?.contains(target)) return; + if (menuRef.current?.contains(target)) return; + close(); + }; + const onKeyDown = (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + close(); + } + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open, close]); + + useEffect(() => { + if (!open || !listRef.current) return; + const selected = listRef.current.querySelector('[aria-selected="true"]'); + if (selected?.scrollIntoView) { + selected.scrollIntoView({ block: 'center' }); + } + }, [open, batchWeek]); + + const step = useCallback( + (delta) => { + if (disabled) return; + onChange((current) => { + const next = shiftIsoWeek(current, delta); + return next || current; + }); + }, + [disabled, onChange], + ); + + const selectWeek = useCallback( + (week) => { + onChange(week, { immediate: true }); + close(); + }, + [onChange, close], + ); + + const rangeHint = valid ? formatIsoWeekRange(batchWeek) : '—'; + + const menu = + open && menuPos && typeof document !== 'undefined' + ? createPortal( +
      +
      + {options.map((opt) => { + const selected = opt.week === batchWeek; + const isLive = anchors?.liveWeek === opt.week; + const isNext = anchors?.curateWeek === opt.week; + let badge = null; + if (isLive) badge = 'Live'; + else if (isNext) badge = 'Next drop'; + else if (opt.delta === 0) badge = 'Selected'; + + return ( + + ); + })} +
      +
      , + document.body, + ) + : null; + + return ( +
      + {showLabel ? ( + {label} + ) : null} +
      + + + + + +
      + + {menu} +
      + ); +} + +export default PivotBatchWeekPicker; diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotBatchWeekPicker.scss b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotBatchWeekPicker.scss new file mode 100644 index 00000000..fa6ddb89 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotBatchWeekPicker.scss @@ -0,0 +1,159 @@ +.pivot-batch-week-picker { + position: relative; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + z-index: 1; + + &.is-pending .pivot-batch-week-picker__trigger { + opacity: 0.72; + } + + &.is-open { + z-index: 50; + } + + &__label { + display: block; + } + + &__stepper { + overflow: visible; + } + + &__trigger { + appearance: none; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1px; + min-width: 148px; + max-width: 200px; + padding: 4px 10px; + border: 1px solid var(--la-border-subtle, rgba(0, 0, 0, 0.12)); + border-radius: var(--la-radius-sm, 6px); + background: var(--la-bg, #fff); + color: inherit; + cursor: pointer; + text-align: center; + line-height: 1.2; + + &:hover:not(:disabled) { + border-color: var(--la-border, rgba(0, 0, 0, 0.2)); + } + + &:focus-visible { + outline: 2px solid var(--la-accent, #5e6ad2); + outline-offset: 1px; + } + + &.is-open { + border-color: var(--la-accent, #5e6ad2); + box-shadow: inset 0 0 0 1px var(--la-accent, #5e6ad2); + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } + } + + &__week { + font-size: 13px; + font-weight: 650; + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; + } + + &__range { + font-size: 10px; + color: var(--la-text-tertiary, inherit); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + } + + &__menu { + z-index: 100; + min-width: 280px; + max-width: min(360px, 90vw); + padding: 6px; + border: 1px solid var(--la-border-subtle, rgba(0, 0, 0, 0.12)); + border-radius: var(--la-radius, 8px); + background: var(--la-bg, #fff); + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12); + box-sizing: border-box; + + &--portal { + position: fixed; + /* Above dashboard chrome (sidebar ~999–1003) and page stacking contexts. */ + z-index: 1100; + display: flex; + flex-direction: column; + max-width: min(360px, calc(100vw - 16px)); + } + } + + &__menu-scroll { + max-height: inherit; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; + } + + &__option { + appearance: none; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: baseline; + gap: 6px 10px; + width: 100%; + padding: 8px 10px; + border: none; + border-radius: 6px; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + + &:hover { + background: rgba(94, 106, 210, 0.08); + } + + &.is-selected { + background: rgba(94, 106, 210, 0.12); + } + + &.is-live .pivot-batch-week-picker__option-badge { + background: rgba(34, 140, 90, 0.14); + color: #1f7a4d; + } + } + + &__option-week { + font-size: 12px; + font-weight: 650; + font-variant-numeric: tabular-nums; + } + + &__option-range { + font-size: 11px; + color: var(--la-text-secondary, inherit); + } + + &__option-badge { + font-size: 10px; + font-weight: 650; + letter-spacing: 0.02em; + text-transform: uppercase; + padding: 2px 6px; + border-radius: 999px; + background: rgba(94, 106, 210, 0.12); + color: #4a56b0; + white-space: nowrap; + } +} diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotCurationMonitorPanel.jsx b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotCurationMonitorPanel.jsx new file mode 100644 index 00000000..fbc12d5b --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotCurationMonitorPanel.jsx @@ -0,0 +1,187 @@ +import React from 'react'; +import PivotImportThumb from '../PivotLab/PivotImportThumb'; +import IngestStatusPill from '../PivotLab/IngestStatusPill'; +import { formatEventWhen } from '../../../utils/pivotIsoWeek'; + +function formatRate(rate) { + if (rate == null || Number.isNaN(Number(rate))) return '—'; + return `${Math.round(Number(rate) * 100)}%`; +} + +function Kpi({ label, value, hint }) { + return ( +
      +

      {label}

      +

      {value ?? '—'}

      +
      + ); +} + +/** + * Live / post-mortem monitoring: week KPIs + per-event interest % and reach. + * Week navigation lives on the parent page (date-driven stage). + */ +function PivotCurationMonitorPanel({ + stage, + overview, + overviewLoading, + journey, + journeyLoading, + performanceEvents, + performanceLoading, + performanceError, +}) { + const kpis = overview?.kpis; + const swipeCount = kpis?.swipeCount ?? 0; + const interestedSurvivors = (kpis?.interestedCount ?? 0) + (kpis?.registeredCount ?? 0); + const weekInterestRate = + swipeCount > 0 ? interestedSurvivors / swipeCount : null; + const medianCardsSeen = journey?.kpis?.medianCardsSeen; + + const isPostMortem = stage === 'post-mortem'; + + return ( +
      +
      +
      +
      +

      + {isPostMortem ? 'Batch recap' : 'Live pulse'} +

      +

      + {isPostMortem + ? 'How this past drop performed after release.' + : 'In-feed performance for the batch users are swiping now.'} +

      +
      +
      + {overviewLoading && !kpis ? ( +

      Loading week metrics…

      + ) : ( +
      + + + + + + + + +
      + )} +
      + +
      +
      +
      +

      + Event performance +

      +

      + Interest % = right-swipe survivors ÷ people reached (swiped). Reached = interested + + going + passed. +

      +
      +
      + {performanceError ? ( +

      + {typeof performanceError === 'string' + ? performanceError + : 'Unable to load event performance.'} +

      + ) : null} + {performanceLoading && !performanceEvents.length ? ( +

      Loading event performance…

      + ) : performanceEvents.length ? ( +
      +
      + Image + EventBatch Organizer When Location
      + {event.externalLink || event.sourceUrl ? ( + + + + ) : ( + + )} + + {event.batchWeek || '—'} + {event.organizerName || '—'} {formatEventWhen(event.start_time)} {event.location || '—'}
      + + + + + + + + + + + + + + + + + {performanceEvents.map((event) => ( + + + + + + + + + + + + + + ))} + +
      + Image + EventWhenStatus + Reached + InterestedGoingPassed + Interest % + + Ticket % + Opens
      + + +
      + {event.name || 'Untitled'} +
      + {event.tags?.length ? ( +
      + {event.tags.slice(0, 3).join(', ')} + {event.tags.length > 3 ? '…' : ''} +
      + ) : null} +
      {formatEventWhen(event.start_time)} + + + {event.reached ?? 0} + {event.interestedTotal ?? 0}{event.registered ?? 0}{event.passed ?? 0} + {formatRate(event.interestRate)} + {formatRate(event.ticketOpenRate)} + {event.externalOpen ?? 0} + {event.externalOpenUsers + ? ` (${event.externalOpenUsers})` + : ''} +
      + + ) : !performanceLoading ? ( +

      No catalog events for this batch week.

      + ) : null} + + + ); +} + +export default PivotCurationMonitorPanel; diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotDashBurst.jsx b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotDashBurst.jsx new file mode 100644 index 00000000..8e7becba --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotDashBurst.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import justGoBurst from '../../../assets/pivot/just-go-burst.svg'; + +/** Same burst silhouette as just-go-burst / mobile scrapbook burst. */ +const BURST_PATH = + 'M 50 0 L 62 16 L 82 12 L 68 28 L 78 48 L 50 38 L 22 48 L 32 28 L 18 12 L 38 16 Z'; +const BURST_VIEWBOX = '18 0 64 48'; + +/** + * Top-left Just Go burst — same role as Meridian AdminGrad on classic dashes. + * Oversized + clipped so only the bottom-right of the burst peeks in. + * Ink burst sits behind the orange burst so the silhouette reads with depth. + */ +function PivotDashBurst() { + return ( + + ); +} + +export default PivotDashBurst; diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotJustGoLogo.jsx b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotJustGoLogo.jsx new file mode 100644 index 00000000..2bd0b407 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotJustGoLogo.jsx @@ -0,0 +1,79 @@ +import React, { useLayoutEffect, useRef, useState } from 'react'; +import justGoWordmark from '../../../assets/pivot/just-go-wordmark-dark.svg'; +import './PivotJustGoLogo.scss'; + +const SHAPE_INSET = 7; +const SHAPE_VIEWBOX = '0 0 100 50'; +/* Four-edge cut-paper scrap — slight skew, not a perfect rect. */ +const SHAPE_PATH = 'M 1 5 L 99 1 L 97 48 L 2 46 Z'; + +/** + * Just Go wordmark in a scrapbook cut-paper bound (same geometry as title strips). + * Short scrapbook frame; wordmark is centered and allowed to peek past the edges. + */ +function PivotJustGoLogo() { + const bodyRef = useRef(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + + useLayoutEffect(() => { + const el = bodyRef.current; + if (!el) return undefined; + + const measure = () => { + const width = el.offsetWidth; + const height = el.offsetHeight; + setSize((prev) => + prev.width === width && prev.height === height ? prev : { width, height }, + ); + }; + + measure(); + const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null; + observer?.observe(el); + return () => observer?.disconnect(); + }, []); + + const shapeWidth = size.width + SHAPE_INSET * 2; + const shapeHeight = size.height + SHAPE_INSET * 2; + + return ( +
      +
      + {/* {size.width > 0 && size.height > 0 ? ( + + ) : null} */} +
      + just go +
      +
      +
      + ); +} + +export default PivotJustGoLogo; diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotJustGoLogo.scss b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotJustGoLogo.scss new file mode 100644 index 00000000..63519437 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotJustGoLogo.scss @@ -0,0 +1,40 @@ +.pivot-just-go-logo { + width: 100%; + transform: rotate(-0.9deg); + filter: drop-shadow(2px 3px 0 rgba(26, 23, 20, 0.24)); +} + +.pivot-just-go-logo__body { + position: relative; + display: block; + width: 100%; + padding: 6px 10px; + box-sizing: border-box; + overflow: visible; +} + +.pivot-just-go-logo__shape { + position: absolute; + z-index: 0; + pointer-events: none; + overflow: visible; +} + +/* Short scrapbook window — mark is centered here but allowed to spill past. */ +.pivot-just-go-logo__window { + position: relative; + z-index: 1; + width: 100%; + height: 44px; + overflow: visible; +} + +.pivot-just-go-logo__mark { + position: absolute; + left: 0; + top: 50%; + width: 100%; + height: auto; + transform: translateY(-50%); + display: block; +} diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotReadinessCard.jsx b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotReadinessCard.jsx new file mode 100644 index 00000000..b513d60a --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotReadinessCard.jsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import './PivotReadinessCard.scss'; + +function formatComponentValue(component) { + if (component == null || component.value == null) return '—'; + if (component.unit === 'ratio') { + return `${Math.round(component.value * 100)}%`; + } + if (component.unit === 'hours') { + return `${Math.round(component.value)}h`; + } + return String(component.value); +} + +function formatBenchmark(component) { + if (component?.benchmark == null) return null; + if (component.unit === 'ratio') { + return `${Math.round(component.benchmark * 100)}%`; + } + if (component.unit === 'hours') { + return `${Math.round(component.benchmark)}h`; + } + return String(component.benchmark); +} + +/** + * @param {{ readiness: object|null, loading?: boolean, compact?: boolean, className?: string }} props + */ +function PivotReadinessCard({ readiness, loading = false, compact = false, className = '' }) { + if (loading && !readiness) { + return ( + + ); + } + + if (!readiness) return null; + + const score = readiness.score ?? 0; + const scoreTone = + score >= 80 ? 'good' : score >= 55 ? 'ok' : 'low'; + + return ( + + ); +} + +export default PivotReadinessCard; +export { formatComponentValue }; diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotReadinessCard.scss b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotReadinessCard.scss new file mode 100644 index 00000000..d28cd128 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotReadinessCard.scss @@ -0,0 +1,189 @@ +.pivot-readiness { + display: grid; + grid-template-columns: minmax(120px, 160px) 1fr; + gap: 16px 24px; + margin: 0 0 20px; + padding: 16px 18px; + background: var(--la-surface, rgba(0, 0, 0, 0.03)); + border: 1px solid var(--la-border-subtle, rgba(0, 0, 0, 0.08)); + border-radius: var(--la-radius, 8px); + + &--compact { + grid-template-columns: minmax(100px, 140px) 1fr; + align-items: center; + padding: 12px 14px; + margin-bottom: 16px; + } + + &--good { + border-color: rgba(34, 140, 90, 0.35); + } + + &--ok { + border-color: rgba(94, 106, 210, 0.3); + } + + &--low { + border-color: rgba(180, 100, 40, 0.4); + } + + &__loading { + margin: 0; + grid-column: 1 / -1; + font-size: 13px; + color: var(--la-text-secondary, inherit); + } + + &__label { + margin: 0 0 2px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--la-text-tertiary, inherit); + opacity: 0.75; + } + + &__score { + margin: 0; + font-size: 40px; + font-weight: 650; + line-height: 1; + color: var(--la-text, inherit); + } + + &--compact &__score { + font-size: 28px; + } + + &__score-max { + margin-left: 2px; + font-size: 14px; + font-weight: 500; + color: var(--la-text-tertiary, inherit); + } + + &__meta { + margin: 6px 0 0; + font-size: 12px; + color: var(--la-text-secondary, inherit); + } + + &__components { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; + } + + &__component { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 8px 12px; + align-items: baseline; + font-size: 12px; + } + + &__component-label { + color: var(--la-text-secondary, inherit); + } + + &__component-value { + font-weight: 600; + color: var(--la-text, inherit); + text-align: right; + } + + &__component-bench { + font-weight: 400; + color: var(--la-text-tertiary, inherit); + } + + &__component-status { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.03em; + min-width: 3.5rem; + text-align: right; + color: var(--la-text-tertiary, inherit); + } + + &__component--below &__component-status { + color: #b45a1a; + } + + &__component--above &__component-status { + color: #1f7a4d; + } + + &__ctas { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 8px; + grid-column: 1 / -1; + } + + &--compact &__ctas { + grid-column: auto; + justify-content: flex-end; + } + + &__cta { + display: inline-flex; + align-items: center; + padding: 4px 10px; + font-size: 12px; + font-weight: 500; + text-decoration: none; + color: var(--la-accent, #5e6ad2); + border: 1px solid rgba(94, 106, 210, 0.35); + border-radius: 999px; + background: rgba(94, 106, 210, 0.06); + + &:hover { + background: rgba(94, 106, 210, 0.12); + } + + &--static { + cursor: default; + } + } + + &__all-clear { + margin: 0; + grid-column: 1 / -1; + font-size: 13px; + color: var(--la-text-secondary, inherit); + } + + &--compact &__all-clear { + grid-column: auto; + text-align: right; + } + + &__formula { + margin: 0; + grid-column: 1 / -1; + font-size: 11px; + color: var(--la-text-tertiary, inherit); + } +} + +@media (max-width: 720px) { + .pivot-readiness { + grid-template-columns: 1fr; + + &--compact { + grid-template-columns: 1fr; + } + + &__ctas { + justify-content: flex-start; + } + } +} diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotScrapbookTitle.jsx b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotScrapbookTitle.jsx new file mode 100644 index 00000000..5ecc32b4 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotScrapbookTitle.jsx @@ -0,0 +1,244 @@ +import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import justGoBurst from '../../../assets/pivot/just-go-burst.svg'; +import './PivotScrapbookTitle.scss'; + +const SHAPE_INSET = 5; +const SHAPE_VIEWBOX = '0 0 100 50'; + +const COLORS = { + cream: '#FAF6EF', + ink: '#1A1714', + accent: '#FF4F1F', + tickerBar: '#4AB5FF', +}; + +/** Same strip geometry / palette as mobile `getScrapbookTitleStripLayouts`. */ +const STRIP_LAYOUTS_MD = [ + { + rotateDeg: -1.4, + marginLeft: 0, + marginTop: 0, + backgroundColor: COLORS.cream, + textColor: COLORS.ink, + strokeColor: COLORS.ink, + fontSize: 34, + paddingTop: 4, + paddingBottom: 5, + paddingLeft: 8, + paddingRight: 12, + shapePath: 'M 1 2 L 99 4 L 97 48 L 0 45 Z', + }, + { + rotateDeg: 1.1, + marginLeft: 12, + marginTop: -2, + backgroundColor: COLORS.accent, + textColor: COLORS.cream, + strokeColor: COLORS.ink, + fontSize: 30, + paddingTop: 5, + paddingBottom: 4, + paddingLeft: 10, + paddingRight: 9, + shapePath: 'M 3 1 L 100 3 L 98 49 L 0 47 Z', + }, + { + rotateDeg: -0.9, + marginLeft: 4, + marginTop: -3, + backgroundColor: COLORS.tickerBar, + textColor: COLORS.ink, + strokeColor: COLORS.ink, + fontSize: 28, + paddingTop: 4, + paddingBottom: 5, + paddingLeft: 7, + paddingRight: 11, + shapePath: 'M 0 4 L 98 1 L 100 46 L 2 48 Z', + }, +]; + +/** Compact blue strip for tenant / city meta (single bound). */ +const STRIP_LAYOUTS_SM = [ + { + rotateDeg: 0.2, + marginLeft: 2, + marginTop: 0, + backgroundColor: COLORS.tickerBar, + textColor: COLORS.ink, + strokeColor: COLORS.ink, + fontSize: 13, + fontWeight: 500, + paddingTop: 2, + paddingBottom: 2, + paddingLeft: 6, + paddingRight: 7, + shapePath: 'M 0 4 L 98 1 L 100 46 L 2 48 Z', + }, +]; + +/** Split title into 1–3 scrapbook strips (word groups, collage-style). */ +export function layoutScrapbookTitleLines(title) { + const words = String(title || '') + .trim() + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + if (words.length === 0) return ['untitled']; + if (words.length <= 3) return words; + if (words.length === 4) { + return [words.slice(0, 2).join(' '), words.slice(2).join(' ')]; + } + if (words.length === 5) { + return [words.slice(0, 2).join(' '), words[2], words.slice(3).join(' ')]; + } + const chunk = Math.ceil(words.length / 3); + return [ + words.slice(0, chunk).join(' '), + words.slice(chunk, chunk * 2).join(' '), + words.slice(chunk * 2).join(' '), + ].filter(Boolean); +} + +function ScrapbookStrip({ line, layout }) { + const bodyRef = useRef(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + + useLayoutEffect(() => { + const el = bodyRef.current; + if (!el) return undefined; + + const measure = () => { + const width = el.offsetWidth; + const height = el.offsetHeight; + setSize((prev) => + prev.width === width && prev.height === height ? prev : { width, height }, + ); + }; + + measure(); + const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null; + observer?.observe(el); + return () => observer?.disconnect(); + }, [line, layout]); + + const shapeWidth = size.width + SHAPE_INSET * 2; + const shapeHeight = size.height + SHAPE_INSET * 2; + + return ( +
      +
      + {size.width > 0 && size.height > 0 ? ( + + ) : null} + + {line} + +
      +
      + ); +} + +/** + * Neo-brutalist collage title — cut-paper strips + burst, matching mobile event detail. + * + * @param {object} props + * @param {string} props.title + * @param {'md'|'sm'} [props.size='md'] + * @param {boolean} [props.showBurst=true] + * @param {boolean} [props.splitWords=true] — when false, keep the full title on one strip + * @param {string} [props.as='h1'] + */ +function PivotScrapbookTitle({ + title, + size = 'md', + showBurst = true, + splitWords = true, + as: Tag = 'h1', +}) { + const lines = useMemo(() => { + if (!splitWords) { + const trimmed = String(title || '') + .trim() + .toLowerCase(); + return [trimmed || 'untitled']; + } + return layoutScrapbookTitleLines(title); + }, [title, splitWords]); + const layouts = size === 'sm' ? STRIP_LAYOUTS_SM : STRIP_LAYOUTS_MD; + + return ( + + {showBurst ? ( + + ) : null} + {lines.map((line, index) => ( +
      + +
      + ))} +
      + ); +} + +export default PivotScrapbookTitle; diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotScrapbookTitle.scss b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotScrapbookTitle.scss new file mode 100644 index 00000000..490e8fb0 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotScrapbookTitle.scss @@ -0,0 +1,89 @@ +@font-face { + font-family: 'Les Flos Sans'; + src: url('../../../assets/pivot/fonts/LesFlosSans.otf') format('opentype'); + font-weight: 400 700; + font-style: normal; + font-display: swap; +} + +.pivot-scrapbook-title { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: flex-start; + gap: 0; + margin: 2px 0 0; + padding: 0; + border: 0; + background: none; + font: inherit; + /* Reset page h1 defaults */ + font-size: inherit; + font-weight: inherit; + line-height: 1; + color: inherit; + text-transform: none; + letter-spacing: normal; + transition: margin 0.28s ease; + + &--sm { + margin: 12px 0 0; + } +} + +.pivot-scrapbook-title__burst { + position: absolute; + top: -12px; + left: -10px; + width: 44px; + height: auto; + z-index: 0; + pointer-events: none; + user-select: none; + transform: rotate(12deg); + opacity: 0.92; +} + +.pivot-scrapbook-title__line { + position: relative; + align-self: flex-start; +} + +.pivot-scrapbook-title__strip { + align-self: flex-start; + filter: drop-shadow(2px 3px 0 rgba(26, 23, 20, 0.22)); +} + +.pivot-scrapbook-title__strip-body { + position: relative; + display: inline-flex; + align-items: center; + align-self: flex-start; + overflow: visible; +} + +.pivot-scrapbook-title__shape { + position: absolute; + z-index: 0; + pointer-events: none; + overflow: visible; +} + +.pivot-scrapbook-title__text { + position: relative; + z-index: 1; + display: inline-block; + font-family: 'Les Flos Sans', 'Space Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, + Consolas, monospace; + font-weight: 700; + text-transform: lowercase; + letter-spacing: -0.01em; + white-space: nowrap; + transform-origin: center center; +} + +.pivot-scrapbook-title--sm .pivot-scrapbook-title__text { + font-family: 'Space Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + letter-spacing: 0; +} diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantCurationPage.jsx b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantCurationPage.jsx new file mode 100644 index 00000000..ab4b5869 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantCurationPage.jsx @@ -0,0 +1,1689 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useFetch, authenticatedRequest } from '../../../hooks/useFetch'; +import { useNotification } from '../../../NotificationContext'; +import { + toIsoWeek, + isValidIsoWeek, + shiftIsoWeek, + formatEventWhen, + formatIsoWeekRange, + resolveCurationStageWeeks, + resolveCurationStageForWeek, + CURATION_STAGE_META, +} from '../../../utils/pivotIsoWeek'; +import IngestStatusPill from '../PivotLab/IngestStatusPill'; +import PivotImportThumb from '../PivotLab/PivotImportThumb'; +import PivotTagMultiSelect from '../PivotLab/PivotTagMultiSelect'; +import PivotManualImportModal, { + manualDraftToImportEntry, +} from '../PivotLab/PivotManualImportModal'; +import PivotCatalogEventEditModal, { + catalogEditDraftToOverrides, +} from '../PivotLab/PivotCatalogEventEditModal'; +import PivotReadinessCard from './PivotReadinessCard'; +import PivotCurationMonitorPanel from './PivotCurationMonitorPanel'; +import PivotTenantPage from './PivotTenantPage'; +import PivotBatchWeekPicker from './PivotBatchWeekPicker'; +import usePivotBatchWeekState from './usePivotBatchWeekState'; +import usePivotTenantWeekKeybinds from './usePivotTenantWeekKeybinds'; +import KeybindTooltip from '../../../components/Interface/KeybindTooltip/KeybindTooltip'; +import '../PivotLab/PivotLabPage.scss'; +import './PivotTenantDashboard.scss'; +import './PivotTenantCurationPage.scss'; +import './PivotReadinessCard.scss'; +import './PivotTenantPage.scss'; + +const NO_FETCH_CACHE = { enabled: false }; +const EMPTY_LIST = []; +const RUN_POLL_MS = 2500; +const MONITOR_EVENTS_LIMIT = 100; +const FILTER_OPTIONS = [ + { value: 'all', label: 'All' }, + { value: 'draft', label: 'Draft' }, + { value: 'staged', label: 'Staged' }, + { value: 'untagged', label: 'Untagged' }, + { value: 'missing-host', label: 'Missing host' }, + { value: 'film', label: 'Film / showtimes' }, +]; + +const PROVIDER_OPTIONS = [ + { value: 'partiful', label: 'Partiful' }, + { value: 'luma', label: 'Luma' }, + { value: 'manual-json', label: 'Manual JSON' }, +]; + +const STRATEGY_OPTIONS = [ + { value: 'next-drop', label: 'Next drop week' }, + { value: 'current-iso', label: 'Current ISO week' }, + { value: 'explicit', label: 'Explicit (pass on run)' }, +]; + +function formatEventTags(tags) { + if (!Array.isArray(tags) || !tags.length) return '—'; + return tags.join(', '); +} + +function eventMatchesFilter(event, filter) { + if (!filter || filter === 'all') return true; + if (filter === 'draft') return event.ingestStatus === 'draft'; + if (filter === 'staged') return event.ingestStatus === 'staged'; + if (filter === 'untagged') { + return !Array.isArray(event.tags) || event.tags.length === 0; + } + if (filter === 'missing-host') { + return !event.organizerName?.trim(); + } + if (filter === 'film') { + return Boolean(event.movie) || (Array.isArray(event.timeSlots) && event.timeSlots.length > 0); + } + return true; +} + +function emptyJobForm() { + return { + label: '', + url: '', + provider: 'partiful', + defaultBatchWeekStrategy: 'next-drop', + defaultTags: [], + enabled: true, + }; +} + +function detectProviderFromUrl(url) { + try { + const host = new URL(url).hostname.toLowerCase(); + if (host.includes('partiful')) return 'partiful'; + if (host.includes('lu.ma') || host.includes('luma')) return 'luma'; + } catch { + /* ignore */ + } + return null; +} + +function RunStatusPill({ status }) { + if (!status) return ; + if (status === 'completed') { + return Completed; + } + if (status === 'failed') { + return Failed; + } + if (status === 'running' || status === 'queued') { + return {status}; + } + return {status}; +} + +/** + * Per-tenant Curation — mode (post-mortem / live / curate) follows the selected batch week. + */ +function PivotTenantCurationPage({ tenantKey, cityDisplayName }) { + const { addNotification } = useNotification(); + const [searchParams, setSearchParams] = useSearchParams(); + + const urlBatchWeek = searchParams.get('batchWeek'); + const urlFilter = searchParams.get('filter') || 'all'; + + const { + batchWeek, + committedWeek, + setBatchWeek, + batchWeekValid, + committedWeekValid, + weekSettled, + } = usePivotBatchWeekState( + isValidIsoWeek(urlBatchWeek) ? urlBatchWeek.trim() : toIsoWeek(), + ); + /** When true, crawl/manual ingest pins every event into `batchWeek` instead of the event's start date. */ + const [forceBatchWeek, setForceBatchWeek] = useState(false); + const [filter, setFilter] = useState( + FILTER_OPTIONS.some((opt) => opt.value === urlFilter) ? urlFilter : 'all', + ); + const [selectedIds, setSelectedIds] = useState(() => new Set()); + const [bulkTags, setBulkTags] = useState([]); + const [busyKey, setBusyKey] = useState(null); + const [activeRunId, setActiveRunId] = useState(null); + const [jobFormOpen, setJobFormOpen] = useState(false); + const [editingJobId, setEditingJobId] = useState(null); + const [jobForm, setJobForm] = useState(emptyJobForm); + const [manualImportOpen, setManualImportOpen] = useState(false); + const [manualImportSticky, setManualImportSticky] = useState({ + organizerName: '', + location: '', + scheduleMode: 'single', + startTimeLocal: '', + endTimeLocal: '', + timeSlots: [], + tags: [], + movie: null, + }); + const [manualImportPublishLoading, setManualImportPublishLoading] = useState(false); + const [editingEvent, setEditingEvent] = useState(null); + const [editSaving, setEditSaving] = useState(false); + const [tagSuggestLoadingKey, setTagSuggestLoadingKey] = useState(null); + const [urlImportValue, setUrlImportValue] = useState(''); + const [urlImportLoading, setUrlImportLoading] = useState(false); + const initializedWeekRef = useRef(false); + + // Keep committed week / filter bookmarkable (preserve page=1). Drop legacy stage= param. + useEffect(() => { + const desiredFilter = filter && filter !== 'all' ? filter : null; + const currentFilter = searchParams.get('filter'); + const currentWeek = searchParams.get('batchWeek'); + const pageOk = searchParams.get('page') === '1'; + const weekOk = !committedWeekValid || currentWeek === committedWeek; + const filterOk = desiredFilter ? currentFilter === desiredFilter : !currentFilter; + const stageCleared = !searchParams.get('stage'); + if (pageOk && weekOk && filterOk && stageCleared) return; + + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set('page', '1'); + next.delete('stage'); + if (committedWeekValid) next.set('batchWeek', committedWeek); + if (desiredFilter) next.set('filter', desiredFilter); + else next.delete('filter'); + return next; + }, + { replace: true }, + ); + }, [committedWeek, committedWeekValid, filter, searchParams, setSearchParams]); + + // Sync from deep links when the URL changes externally. + useEffect(() => { + if (isValidIsoWeek(urlBatchWeek)) { + const trimmed = urlBatchWeek.trim(); + setBatchWeek((current) => (current === trimmed ? current : trimmed), { + immediate: true, + }); + } + if (FILTER_OPTIONS.some((opt) => opt.value === urlFilter)) { + setFilter((current) => (current === urlFilter ? current : urlFilter)); + } + }, [urlBatchWeek, urlFilter, setBatchWeek]); + + const opsParams = useMemo( + () => ({ + batchWeek: committedWeek, + include: 'curation', + performanceLimit: MONITOR_EVENTS_LIMIT, + }), + [committedWeek], + ); + const opsUrl = + tenantKey && committedWeekValid + ? `/admin/pivot/tenants/${encodeURIComponent(tenantKey)}/ops` + : null; + const { + data: opsResponse, + loading: opsLoading, + error: opsError, + refetch: refetchOps, + } = useFetch(opsUrl, { params: opsParams, cache: NO_FETCH_CACHE }); + + const ops = opsResponse?.success ? opsResponse.data : null; + + const stageWeeks = useMemo(() => { + if (ops?.anchors?.liveWeek) { + return { + liveWeek: ops.anchors.liveWeek, + curateWeek: ops.anchors.curateWeek, + postMortemWeek: ops.anchors.postMortemWeek, + currentWeek: ops.anchors.currentWeek, + }; + } + return resolveCurationStageWeeks(new Date(), null); + }, [ops?.anchors]); + + const stage = ops?.stage || resolveCurationStageForWeek(committedWeek, stageWeeks); + const isCurateStage = stage === 'curate'; + const isMonitorStage = stage === 'live' || stage === 'post-mortem'; + const stageMeta = CURATION_STAGE_META[stage] || CURATION_STAGE_META.curate; + + // Default to the upcoming curate week once anchors are known (unless URL set a week). + useEffect(() => { + if (initializedWeekRef.current) return; + if (isValidIsoWeek(urlBatchWeek)) { + initializedWeekRef.current = true; + return; + } + if (!ops?.anchors?.curateWeek) return; + initializedWeekRef.current = true; + if (isValidIsoWeek(ops.anchors.curateWeek)) { + setBatchWeek(ops.anchors.curateWeek, { immediate: true }); + } + }, [ops?.anchors?.curateWeek, urlBatchWeek]); + + const overview = ops?.overview && !ops.overview.error ? ops.overview : null; + const drop = overview?.dropSchedule || ops?.dropSchedule; + const statusCounts = overview?.kpis?.eventCountsByStatus; + const weekRangeLabel = batchWeekValid + ? formatIsoWeekRange(batchWeek) + : '—'; + const dropLabel = drop?.nextDropFormatted || null; + const overviewLoading = opsLoading; + + const performanceEvents = + ops?.performance && !ops.performance.error + ? (ops.performance.events ?? EMPTY_LIST) + : EMPTY_LIST; + const performanceLoading = opsLoading && isMonitorStage && !ops?.performance; + const performanceError = ops?.performance?.error || null; + + const journey = ops?.journey && !ops.journey.error ? ops.journey : null; + const journeyLoading = opsLoading && isMonitorStage && !ops?.journey; + + const jobs = + ops?.jobs && !ops.jobs.error ? (ops.jobs.jobs ?? EMPTY_LIST) : EMPTY_LIST; + const jobsLoading = opsLoading && isCurateStage && !ops?.jobs; + const jobsError = ops?.jobs?.error || opsError || null; + + const events = + ops?.catalog && !ops.catalog.error + ? (ops.catalog.events ?? EMPTY_LIST) + : EMPTY_LIST; + const eventsLoading = opsLoading && isCurateStage && !ops?.catalog; + const eventsError = ops?.catalog?.error || null; + + const { + data: tagsResponse, + refetch: refetchTags, + } = useFetch('/admin/pivot/tags', { cache: NO_FETCH_CACHE }); + + const readiness = + ops?.readiness && !ops.readiness.error ? ops.readiness : null; + const readinessLoading = opsLoading && isCurateStage && !ops?.readiness; + + const runUrl = + tenantKey && activeRunId + ? `/admin/pivot/tenants/${encodeURIComponent(tenantKey)}/curation-runs/${encodeURIComponent(activeRunId)}` + : null; + const { + data: runResponse, + refetch: refetchRun, + } = useFetch(runUrl, { cache: NO_FETCH_CACHE }); + + const catalogTags = tagsResponse?.success + ? (tagsResponse.data?.tags ?? EMPTY_LIST) + : EMPTY_LIST; + const activeRun = runResponse?.success ? runResponse.data?.run : null; + + const filteredEvents = useMemo( + () => events.filter((event) => eventMatchesFilter(event, filter)), + [events, filter], + ); + + const reviewEvents = useMemo( + () => + filteredEvents.filter( + (event) => event.ingestStatus === 'draft' || event.ingestStatus === 'staged', + ), + [filteredEvents], + ); + + const publishedCount = useMemo( + () => events.filter((e) => e.ingestStatus === 'published').length, + [events], + ); + const stagedCount = useMemo( + () => events.filter((e) => e.ingestStatus === 'staged').length, + [events], + ); + const draftCount = useMemo( + () => events.filter((e) => e.ingestStatus === 'draft').length, + [events], + ); + + // Poll active run until terminal. + useEffect(() => { + if (!activeRunId || !activeRun) return undefined; + const status = activeRun.status; + if (status === 'completed' || status === 'failed') { + refetchOps(); + return undefined; + } + const timer = setInterval(() => { + refetchRun(); + }, RUN_POLL_MS); + return () => clearInterval(timer); + }, [activeRun, activeRunId, refetchOps, refetchRun]); + + // Clear selection when week/filter changes. + useEffect(() => { + setSelectedIds(new Set()); + }, [batchWeek, filter, tenantKey]); + + const stepBatchWeek = useCallback( + (delta) => { + setBatchWeek((current) => { + const next = shiftIsoWeek(current, delta); + return next || current; + }); + }, + [setBatchWeek], + ); + + const refreshAll = useCallback(() => { + refetchOps(); + if (activeRunId) refetchRun(); + }, [activeRunId, refetchOps, refetchRun]); + + const keybindsEnabled = + batchWeekValid && + !manualImportOpen && + !editingEvent && + !jobFormOpen; + + const { keyboardNavActive } = usePivotTenantWeekKeybinds({ + enabled: keybindsEnabled, + onStepWeek: stepBatchWeek, + onRefresh: refreshAll, + }); + + const toggleSelected = useCallback((eventId) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(eventId)) next.delete(eventId); + else next.add(eventId); + return next; + }); + }, []); + + const toggleSelectAllReview = useCallback(() => { + setSelectedIds((prev) => { + if (prev.size === reviewEvents.length && reviewEvents.length > 0) { + return new Set(); + } + return new Set(reviewEvents.map((e) => e._id)); + }); + }, [reviewEvents]); + + const selectedEvents = useMemo( + () => events.filter((e) => selectedIds.has(e._id)), + [events, selectedIds], + ); + + const buildTagSuggestPayload = useCallback( + (fields) => ({ + name: fields.name?.trim() || undefined, + description: fields.description?.trim() || undefined, + location: fields.location?.trim() || undefined, + hostName: fields.organizerName?.trim() || fields.hostName?.trim() || undefined, + sourceTags: fields.sourceTags || undefined, + }), + [], + ); + + const requestSuggestedTags = useCallback(async (payload) => { + const { data, error } = await authenticatedRequest('/admin/pivot/ingest/suggest-tags', { + method: 'POST', + data: { event: payload }, + }); + if (error || !data?.success) { + return { + error: error || data?.message || 'Could not suggest tags.', + code: data?.code, + }; + } + return { tags: data.data?.tags || [] }; + }, []); + + const openCreateJob = useCallback(() => { + setEditingJobId(null); + setJobForm(emptyJobForm()); + setJobFormOpen(true); + }, []); + + const openEditJob = useCallback((job) => { + setEditingJobId(job._id); + setJobForm({ + label: job.label || '', + url: job.url || '', + provider: job.provider || 'partiful', + defaultBatchWeekStrategy: job.defaultBatchWeekStrategy || 'next-drop', + defaultTags: Array.isArray(job.defaultTags) ? [...job.defaultTags] : [], + enabled: job.enabled !== false, + }); + setJobFormOpen(true); + }, []); + + const handleSaveJob = useCallback(async () => { + if (!tenantKey) return; + const label = jobForm.label.trim(); + if (!label) { + addNotification({ + title: 'Label required', + message: 'Give the job a short label.', + type: 'warning', + }); + return; + } + + let provider = jobForm.provider; + const url = jobForm.url.trim(); + if (!provider && url) { + provider = detectProviderFromUrl(url) || 'partiful'; + } + if (provider !== 'manual-json' && !url) { + addNotification({ + title: 'URL required', + message: 'Partiful and Luma jobs need an explore/discover URL.', + type: 'warning', + }); + return; + } + + setBusyKey(editingJobId ? `job-save-${editingJobId}` : 'job-create'); + const body = { + label, + url: url || undefined, + provider, + defaultBatchWeekStrategy: jobForm.defaultBatchWeekStrategy, + defaultTags: jobForm.defaultTags, + enabled: jobForm.enabled, + }; + + const path = editingJobId + ? `/admin/pivot/tenants/${encodeURIComponent(tenantKey)}/curation-jobs/${encodeURIComponent(editingJobId)}` + : `/admin/pivot/tenants/${encodeURIComponent(tenantKey)}/curation-jobs`; + + const { data, error } = await authenticatedRequest(path, { + method: editingJobId ? 'PATCH' : 'POST', + data: body, + }); + setBusyKey(null); + + if (error || !data?.success) { + addNotification({ + title: editingJobId ? 'Update failed' : 'Create failed', + message: error || data?.message || 'Could not save curation job.', + type: 'error', + }); + return; + } + + setJobFormOpen(false); + setEditingJobId(null); + refetchOps(); + addNotification({ + title: editingJobId ? 'Job updated' : 'Job saved', + message: `${data.data?.job?.label || label} is ready to run.`, + type: 'success', + }); + }, [addNotification, editingJobId, jobForm, refetchOps, tenantKey]); + + const handleDeleteJob = useCallback( + async (job) => { + if (!tenantKey || !job?._id) return; + if (!window.confirm(`Delete saved job “${job.label}”?`)) return; + + setBusyKey(`job-delete-${job._id}`); + const { data, error } = await authenticatedRequest( + `/admin/pivot/tenants/${encodeURIComponent(tenantKey)}/curation-jobs/${encodeURIComponent(job._id)}`, + { method: 'DELETE' }, + ); + setBusyKey(null); + + if (error || !data?.success) { + addNotification({ + title: 'Delete failed', + message: error || data?.message || 'Could not delete job.', + type: 'error', + }); + return; + } + refetchOps(); + addNotification({ title: 'Job deleted', type: 'success' }); + }, + [addNotification, refetchOps, tenantKey], + ); + + const handleRunJob = useCallback( + async (job) => { + if (!tenantKey || !job?._id || !batchWeekValid || !weekSettled) return; + if (job.provider === 'manual-json') { + addNotification({ + title: 'Not crawlable', + message: 'Manual JSON jobs cannot be crawled — use Manual add instead.', + type: 'warning', + }); + return; + } + + setBusyKey(`job-run-${job._id}`); + const { data, error } = await authenticatedRequest( + `/admin/pivot/tenants/${encodeURIComponent(tenantKey)}/curation-jobs/${encodeURIComponent(job._id)}/run`, + { + method: 'POST', + data: { + batchWeek: committedWeek, + forceBatchWeek, + }, + }, + ); + setBusyKey(null); + + if (error || !data?.success) { + addNotification({ + title: 'Run failed', + message: error || data?.message || 'Could not start crawl run.', + type: 'error', + }); + return; + } + + const run = data.data?.run; + setActiveRunId(run?._id || null); + refetchOps(); + addNotification({ + title: 'Crawl started', + message: forceBatchWeek + ? `Running “${job.label}” — all events forced into ${committedWeek}.` + : `Running “${job.label}” — events land in the week of their start date (may span multiple weeks).`, + type: 'success', + }); + }, + [ + addNotification, + committedWeek, + batchWeekValid, + forceBatchWeek, + refetchOps, + tenantKey, + weekSettled, + ], + ); + + const handleRelease = useCallback(async () => { + if (!tenantKey || !batchWeekValid || !weekSettled) return; + if (stagedCount === 0) { + addNotification({ + title: 'Nothing to release', + message: 'Stage events for this week before releasing to the live deck.', + type: 'warning', + }); + return; + } + if ( + !window.confirm( + `Release ${stagedCount} staged event(s) for ${committedWeek} to the live feed?`, + ) + ) { + return; + } + + setBusyKey('release'); + const { data, error } = await authenticatedRequest( + `/admin/pivot/tenants/${encodeURIComponent(tenantKey)}/batches/${encodeURIComponent(committedWeek)}/release`, + { method: 'POST', data: {} }, + ); + setBusyKey(null); + + if (error || !data?.success) { + addNotification({ + title: 'Release failed', + message: error || data?.message || 'Could not release batch.', + type: 'error', + }); + return; + } + + refreshAll(); + addNotification({ + title: 'Released', + message: `${data.data?.releasedCount ?? 0} event(s) are now live for ${committedWeek}.`, + type: 'success', + }); + }, [ + addNotification, + committedWeek, + batchWeekValid, + refreshAll, + stagedCount, + tenantKey, + weekSettled, + ]); + + const patchEventOverrides = useCallback( + async (eventId, overrides) => { + const { data, error } = await authenticatedRequest( + `/admin/pivot/ingest/${eventId}`, + { + method: 'PATCH', + data: { tenantKey, overrides }, + }, + ); + if (error || !data?.success) { + return { error: error || data?.message || 'Update failed.', code: data?.code }; + } + return { event: data.data?.event }; + }, + [tenantKey], + ); + + const handleBulkStage = useCallback(async () => { + const drafts = selectedEvents.filter((e) => e.ingestStatus === 'draft'); + if (!drafts.length) { + addNotification({ + title: 'No drafts selected', + message: 'Select draft events to stage.', + type: 'warning', + }); + return; + } + + setBusyKey('bulk-stage'); + let ok = 0; + let failed = 0; + for (const event of drafts) { + const result = await patchEventOverrides(event._id, { ingestStatus: 'staged' }); + if (result.error) failed += 1; + else ok += 1; + } + setBusyKey(null); + refreshAll(); + setSelectedIds(new Set()); + addNotification({ + title: failed ? 'Partial stage' : 'Staged', + message: `${ok} staged${failed ? `, ${failed} failed` : ''}.`, + type: failed ? 'warning' : 'success', + }); + }, [addNotification, patchEventOverrides, refreshAll, selectedEvents]); + + const handleBulkApplyTags = useCallback(async () => { + if (!bulkTags.length) { + addNotification({ + title: 'Pick tags', + message: 'Choose at least one tag to apply.', + type: 'warning', + }); + return; + } + if (!selectedEvents.length) { + addNotification({ + title: 'Nothing selected', + message: 'Select events in the review queue.', + type: 'warning', + }); + return; + } + + setBusyKey('bulk-tags'); + let ok = 0; + let failed = 0; + for (const event of selectedEvents) { + const result = await patchEventOverrides(event._id, { tags: bulkTags }); + if (result.error) failed += 1; + else ok += 1; + } + setBusyKey(null); + refreshAll(); + addNotification({ + title: failed ? 'Partial tag update' : 'Tags applied', + message: `${ok} updated${failed ? `, ${failed} failed` : ''}.`, + type: failed ? 'warning' : 'success', + }); + }, [addNotification, bulkTags, patchEventOverrides, refreshAll, selectedEvents]); + + const handleBulkSuggestTags = useCallback(async () => { + if (!selectedEvents.length) { + addNotification({ + title: 'Nothing selected', + message: 'Select events to suggest tags for.', + type: 'warning', + }); + return; + } + + setBusyKey('bulk-suggest'); + let ok = 0; + let failed = 0; + for (const event of selectedEvents) { + const suggest = await requestSuggestedTags( + buildTagSuggestPayload({ + name: event.name, + description: event.description, + location: event.location, + organizerName: event.organizerName, + }), + ); + if (suggest.error || !suggest.tags?.length) { + failed += 1; + continue; + } + const result = await patchEventOverrides(event._id, { tags: suggest.tags }); + if (result.error) failed += 1; + else ok += 1; + } + setBusyKey(null); + refreshAll(); + addNotification({ + title: failed ? 'Partial suggest' : 'Tags suggested', + message: `${ok} updated${failed ? `, ${failed} skipped/failed` : ''}.`, + type: failed ? 'warning' : 'success', + }); + }, [ + addNotification, + buildTagSuggestPayload, + patchEventOverrides, + refreshAll, + requestSuggestedTags, + selectedEvents, + ]); + + const handleSaveCatalogEdit = useCallback( + async (draft) => { + if (!editingEvent || !tenantKey) return false; + setEditSaving(true); + const overrides = catalogEditDraftToOverrides(draft); + const result = await patchEventOverrides(editingEvent._id, overrides); + setEditSaving(false); + if (result.error) { + addNotification({ + title: 'Update failed', + message: result.error, + type: 'error', + }); + return false; + } + setEditingEvent(null); + refreshAll(); + addNotification({ title: 'Updated', message: 'Catalog event saved.', type: 'success' }); + return true; + }, + [addNotification, editingEvent, patchEventOverrides, refreshAll, tenantKey], + ); + + const suggestTagsForEdit = useCallback( + async (draft, patchDraft) => { + setTagSuggestLoadingKey('edit'); + const result = await requestSuggestedTags( + buildTagSuggestPayload({ + name: draft.name, + description: draft.description, + location: draft.location, + organizerName: draft.organizerName, + }), + ); + setTagSuggestLoadingKey(null); + if (result.error) { + addNotification({ + title: 'Tag suggestion failed', + message: result.error, + type: 'error', + }); + return; + } + patchDraft?.({ tags: result.tags }); + }, + [addNotification, buildTagSuggestPayload, requestSuggestedTags], + ); + + const suggestTagsForManualImport = useCallback( + async (draft, patchDraft) => { + setTagSuggestLoadingKey('manual-import'); + const result = await requestSuggestedTags( + buildTagSuggestPayload({ + name: draft.name, + description: draft.description, + location: draft.location, + organizerName: draft.organizerName, + }), + ); + setTagSuggestLoadingKey(null); + if (result.error) { + addNotification({ + title: 'Tag suggestion failed', + message: result.error, + type: 'error', + }); + return; + } + patchDraft?.({ tags: result.tags }); + }, + [addNotification, buildTagSuggestPayload, requestSuggestedTags], + ); + + const handlePublishManualImport = useCallback( + async (draft) => { + if (!tenantKey) return false; + // Flush pending week so ingest targets the week shown in the picker. + setBatchWeek(batchWeek, { immediate: true }); + setManualImportPublishLoading(true); + const entry = manualDraftToImportEntry(draft); + const { data, error } = await authenticatedRequest('/admin/pivot/ingest', { + method: 'POST', + data: { + tenantKey, + batchWeek, + forceBatchWeek, + overrides: { + hostName: entry.draft.hostName, + name: entry.draft.name, + location: entry.draft.location, + start_time: entry.draft.start_time, + end_time: entry.draft.end_time || undefined, + description: entry.draft.description || undefined, + image: entry.draft.image || undefined, + source: 'manual', + sourceUrl: entry.draft.sourceUrl || undefined, + tags: entry.draft.tags, + ...(entry.draft.timeSlots?.length ? { timeSlots: entry.draft.timeSlots } : {}), + ...(entry.draft.movie ? { movie: entry.draft.movie } : {}), + }, + }, + }); + setManualImportPublishLoading(false); + + if (error || !data?.success) { + addNotification({ + title: 'Stage failed', + message: error || data?.message || 'Could not stage event.', + type: 'error', + }); + return false; + } + + const landedWeek = data.data?.batchWeek || data.data?.event?.batchWeek || batchWeek; + refreshAll(); + addNotification({ + title: 'Staged', + message: `${data.data?.event?.name || entry.draft.name} added for ${landedWeek}${ + forceBatchWeek ? ' (forced)' : '' + }.`, + type: 'success', + }); + return true; + }, + [addNotification, batchWeek, forceBatchWeek, refreshAll, setBatchWeek, tenantKey], + ); + + const handleUrlImport = useCallback(async () => { + const url = urlImportValue.trim(); + if (!url || !tenantKey || !batchWeekValid) return; + + setBatchWeek(batchWeek, { immediate: true }); + setUrlImportLoading(true); + const preview = await authenticatedRequest('/admin/pivot/ingest/preview', { + method: 'POST', + data: { url, tenantKey }, + }); + + if (preview.error || !preview.data?.success) { + setUrlImportLoading(false); + addNotification({ + title: 'Preview failed', + message: preview.error || preview.data?.message || 'Could not preview URL.', + type: 'error', + }); + return; + } + + const mode = preview.data.data?.mode; + if (mode === 'batch') { + // Prefer saving as a job for explore URLs. + const provider = + preview.data.data?.provider || detectProviderFromUrl(url) || 'partiful'; + setJobForm({ + ...emptyJobForm(), + label: preview.data.data?.listLabel || `${provider} explore`, + url, + provider, + }); + setJobFormOpen(true); + setEditingJobId(null); + setUrlImportLoading(false); + addNotification({ + title: 'Explore link detected', + message: 'Save it as a crawl job, then Run for this week.', + type: 'info', + }); + return; + } + + const draft = preview.data.data?.draft || {}; + if (!bulkTags.length) { + setUrlImportLoading(false); + addNotification({ + title: 'Tags required', + message: + 'Pick tags in the review bulk bar (or use Manual form), then import the URL again.', + type: 'warning', + }); + return; + } + + const { data, error } = await authenticatedRequest('/admin/pivot/ingest', { + method: 'POST', + data: { + tenantKey, + url, + batchWeek, + forceBatchWeek, + overrides: { + hostName: draft.hostName, + name: draft.name, + location: draft.location, + start_time: draft.start_time, + end_time: draft.end_time || undefined, + description: draft.description || undefined, + image: draft.image || undefined, + source: draft.source, + sourceUrl: draft.sourceUrl || url, + tags: bulkTags, + }, + }, + }); + setUrlImportLoading(false); + + if (error || !data?.success) { + addNotification({ + title: 'Import failed', + message: error || data?.message || 'Could not import event.', + type: 'error', + }); + return; + } + + const landedWeek = data.data?.batchWeek || data.data?.event?.batchWeek || batchWeek; + setUrlImportValue(''); + refreshAll(); + addNotification({ + title: 'Staged', + message: `${data.data?.event?.name || draft.name || 'Event'} added for ${landedWeek}${ + forceBatchWeek ? ' (forced)' : '' + }.`, + type: 'success', + }); + }, [ + addNotification, + batchWeek, + batchWeekValid, + bulkTags, + forceBatchWeek, + refreshAll, + setBatchWeek, + tenantKey, + urlImportValue, + ]); + + const displayCity = overview?.cityDisplayName || cityDisplayName || tenantKey; + const runInFlight = + activeRun && (activeRun.status === 'queued' || activeRun.status === 'running'); + const releaseDisabled = + !batchWeekValid || + !weekSettled || + stagedCount === 0 || + busyKey === 'release' || + Boolean(runInFlight); + + return ( + + + {isCurateStage ? ( + <> + + + + ) : null} + + } + > + + + {!batchWeekValid ? ( +

      Enter a valid batch week (YYYY-Www).

      + ) : null} + + {isMonitorStage ? ( + + ) : null} + + {isCurateStage ? ( + <> + + + + + {activeRun ? ( +
      +
      + Crawl {activeRun.status} + {activeRun.forceBatchWeek ? ( + · forced into {activeRun.batchWeek} + ) : ( + · by event date + )} + {activeRun.stats ? ( + + {' '} + · discovered {activeRun.stats.discovered ?? 0}, upserted{' '} + {activeRun.stats.upserted ?? 0}, skipped {activeRun.stats.skipped ?? 0}, failed{' '} + {activeRun.stats.failed ?? 0} + + ) : null} + {activeRun.stats?.byBatchWeek && + Object.keys(activeRun.stats.byBatchWeek).length > 0 ? ( + + {' '} + · weeks{' '} + {Object.keys(activeRun.stats.byBatchWeek) + .sort() + .map((w) => `${w} (${activeRun.stats.byBatchWeek[w]})`) + .join(', ')} + + ) : null} + {activeRun.stats?.message ? ( + — {activeRun.stats.message} + ) : null} + {activeRun.error ? ( + — {activeRun.error} + ) : null} +
      + {(activeRun.status === 'completed' || activeRun.status === 'failed') && ( + + )} +
      + ) : null} + +
      +
      +
      +

      + Saved jobs +

      +

      + Persist Partiful/Luma explore URLs. By default each discovered event lands in the + ISO week of its start date (one crawl can fill many weeks). Enable “Force into + review week” to pin everything to the week above. +

      +
      + +
      + + {jobsError ?

      {jobsError}

      : null} + {jobsLoading ? ( +

      Loading jobs…

      + ) : jobs.length ? ( +
      + + + + + + + + + + + + + {jobs.map((job) => ( + + + + + + + + + ))} + +
      LabelProviderURLStrategyLast runActions
      + {job.label} + {job.enabled === false ? ( + Disabled + ) : null} + {job.provider} + {job.url ? ( + + {job.url} + + ) : ( + '—' + )} + {job.defaultBatchWeekStrategy || 'next-drop'} + {job.lastRunStatus ? ( + <> + {' '} + + {job.lastRunStats + ? `${job.lastRunStats.upserted ?? 0}/${job.lastRunStats.discovered ?? 0}` + : ''} + + + ) : ( + '—' + )} + +
      + + + +
      +
      +
      + ) : ( +

      + No saved jobs yet. Add a Partiful or Luma explore URL to crawl into this week. +

      + )} + + {jobFormOpen ? ( +
      +

      + {editingJobId ? 'Edit job' : 'New job'} +

      +
      + + + + + +
      + Default tags + setJobForm((f) => ({ ...f, defaultTags: tags }))} + compact + showLabel={false} + /> +
      +
      +
      + + + {!catalogTags.length ? ( + + ) : null} +
      +
      + ) : null} +
      + +
      +
      +
      +

      + Manual add +

      +

      + Paste a single event URL, or open the manual form for JSON-free entry. +

      +
      + +
      +
      + setUrlImportValue(e.target.value)} + placeholder="https://partiful.com/e/… or explore URL" + aria-label="Event or explore URL" + /> + +
      +
      + +
      +
      +
      +

      + Review queue · {batchWeek} +

      +

      + Draft and staged events for this review week. Crawl/manual ingest assign by event + date unless forced — switch weeks to review other batches from the same crawl. +

      +
      + +
      + +
      + +
      + +
      +
      + + + +
      +
      + + {eventsError ?

      {eventsError}

      : null} + {eventsLoading ? ( +

      Loading catalog…

      + ) : filteredEvents.length ? ( +
      + + + + + + + + + + + + + + + + + {filteredEvents.map((event) => { + const selectable = + event.ingestStatus === 'draft' || event.ingestStatus === 'staged'; + return ( + + + + + + + + + + + + + ); + })} + +
      + Select + + Image + EventBatchOrganizerWhenTagsStatusSourceEdit
      + {selectable ? ( + toggleSelected(event._id)} + aria-label={`Select ${event.name}`} + /> + ) : null} + + {event.externalLink || event.sourceUrl ? ( + + + + ) : ( + + )} + + + + + {event.batchWeek || '—'} + + {event.organizerName || '—'}{formatEventWhen(event.start_time)}{formatEventTags(event.tags)} + + {event.source || '—'} + +
      +
      + ) : ( +

      + {events.length + ? 'No events match this filter.' + : 'No catalog events for this city and week yet. Run a job or add manually.'} +

      + )} +
      + + ) : null} + + setEditingEvent(null)} + catalogTags={catalogTags} + cityLabel={displayCity} + batchWeek={batchWeek} + onSave={handleSaveCatalogEdit} + saving={editSaving} + onSuggestTags={suggestTagsForEdit} + tagSuggestLoading={tagSuggestLoadingKey === 'edit'} + /> + + setManualImportOpen(false)} + catalogTags={catalogTags} + cityLabel={displayCity} + batchWeek={batchWeek} + selectedTenantKey={tenantKey} + stickyDefaults={manualImportSticky} + onStickyChange={setManualImportSticky} + onAddToBatch={() => { + addNotification({ + title: 'Use Stage', + message: 'On the tenant Curation page, stage events directly with Stage.', + type: 'info', + }); + }} + onPublish={handlePublishManualImport} + publishLoading={manualImportPublishLoading} + onSuggestTags={suggestTagsForManualImport} + tagSuggestLoading={tagSuggestLoadingKey === 'manual-import'} + /> +
      + ); +} + +export default PivotTenantCurationPage; diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantCurationPage.scss b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantCurationPage.scss new file mode 100644 index 00000000..6fbf6b40 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantCurationPage.scss @@ -0,0 +1,389 @@ +.pivot-tenant-curation { + max-width: none; + + .pivot-tenant-page__body { + max-width: 1280px; + } + + &__stages { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 0 0 20px; + + @media (max-width: 720px) { + grid-template-columns: 1fr; + } + } + + &__stage { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 12px 14px; + text-align: left; + border: 1px solid var(--la-border-subtle, rgba(0, 0, 0, 0.1)); + border-radius: var(--la-radius, 8px); + background: var(--la-surface, rgba(0, 0, 0, 0.02)); + cursor: pointer; + color: inherit; + transition: border-color 0.15s ease, background 0.15s ease; + + &:hover { + border-color: var(--la-border, rgba(0, 0, 0, 0.18)); + } + + &--active { + border-color: var(--la-accent, #5e6ad2); + background: rgba(94, 106, 210, 0.06); + box-shadow: inset 0 0 0 1px var(--la-accent, #5e6ad2); + } + } + + &__stage-label { + font-size: 13px; + font-weight: 600; + color: var(--la-text, inherit); + } + + &__stage-week { + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--la-text-secondary, inherit); + } + + &__stage-dates { + font-size: 11px; + color: var(--la-text-tertiary, inherit); + } + + &__batch-banner { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 12px 20px; + margin: 0 0 16px; + padding: 14px 16px; + border: 1px solid var(--la-border-subtle, rgba(0, 0, 0, 0.08)); + border-radius: var(--la-radius, 8px); + background: var(--la-surface, rgba(0, 0, 0, 0.03)); + overflow: visible; + position: relative; + z-index: 5; + } + + &__batch-banner-main { + min-width: 200px; + } + + &__batch-week { + margin: 0 0 2px; + font-size: 22px; + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; + color: var(--la-text, inherit); + } + + &__batch-dates { + margin: 0; + font-size: 14px; + font-weight: 500; + color: var(--la-text-secondary, inherit); + } + + &__batch-drop { + margin: 4px 0 0; + font-size: 12px; + color: var(--la-text-tertiary, inherit); + } + + &__batch-anchors { + margin: 6px 0 0; + font-size: 11px; + color: var(--la-text-tertiary, inherit); + + strong { + color: var(--la-text-secondary, inherit); + font-weight: 600; + font-variant-numeric: tabular-nums; + } + } + + &__mode-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.03em; + text-transform: uppercase; + + &--post-mortem { + background: rgba(120, 90, 40, 0.12); + color: #7a5a28; + } + + &--live { + background: rgba(34, 140, 90, 0.12); + color: #1f7a4d; + } + + &--curate { + background: rgba(94, 106, 210, 0.12); + color: #4a56b0; + } + } + + &__week-chip { + display: inline-flex; + align-items: center; + min-width: 88px; + justify-content: center; + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; + } + + &__kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; + } + + &__kpi { + padding: 12px 14px; + border: 1px solid var(--la-border-subtle, rgba(0, 0, 0, 0.08)); + border-radius: var(--la-radius, 8px); + background: var(--la-bg, #fff); + } + + &__kpi-label { + margin: 0 0 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--la-text-tertiary, inherit); + } + + &__kpi-value { + margin: 0; + font-size: 20px; + font-weight: 650; + font-variant-numeric: tabular-nums; + color: var(--la-text, inherit); + } + + &__event-name { + font-weight: 600; + } + + &__event-tags { + margin-top: 2px; + font-size: 11px; + color: var(--la-text-tertiary, inherit); + } + + &__drop { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 12px 20px; + margin: 0 0 20px; + padding: 14px 16px; + background: var(--la-surface, rgba(0, 0, 0, 0.03)); + border: 1px solid var(--la-border-subtle, rgba(0, 0, 0, 0.08)); + border-radius: var(--la-radius, 8px); + } + + &__drop-label { + margin: 0 0 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--la-text-tertiary, inherit); + opacity: 0.75; + } + + &__drop-value { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--la-text, inherit); + } + + &__drop-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + font-size: 12px; + color: var(--la-text-secondary, inherit); + + strong { + color: var(--la-text, inherit); + font-weight: 600; + } + } + + &__run-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 0 0 20px; + padding: 12px 14px; + border-radius: var(--la-radius, 8px); + border: 1px solid var(--la-border-subtle, rgba(0, 0, 0, 0.08)); + font-size: 13px; + background: var(--la-surface, rgba(0, 0, 0, 0.03)); + + &--queued, + &--running { + border-color: rgba(94, 106, 210, 0.35); + background: rgba(94, 106, 210, 0.06); + } + + &--completed { + border-color: rgba(34, 140, 90, 0.35); + background: rgba(34, 140, 90, 0.06); + } + + &--failed { + border-color: rgba(180, 60, 60, 0.35); + background: rgba(180, 60, 60, 0.06); + } + } + + &__run-msg { + color: var(--la-text-secondary, inherit); + } + + &__url-cell { + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + a { + color: var(--la-accent, #5e6ad2); + } + } + + &__muted { + font-size: 12px; + color: var(--la-text-tertiary, inherit); + } + + &__row-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + } + + &__job-form { + margin-top: 16px; + padding: 16px; + border: 1px solid var(--la-border-subtle, rgba(0, 0, 0, 0.08)); + border-radius: var(--la-radius, 8px); + background: var(--la-surface, rgba(0, 0, 0, 0.02)); + } + + &__job-form-title { + margin: 0 0 12px; + font-size: 15px; + font-weight: 600; + } + + &__job-form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px 16px; + margin-bottom: 14px; + } + + &__job-form-span { + grid-column: 1 / -1; + } + + &__check { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + cursor: pointer; + + input { + margin: 0; + } + } + + &__url-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + + .linear-input { + flex: 1 1 280px; + min-width: 200px; + } + } + + &__bulk { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 16px; + margin: 0 0 14px; + padding: 12px 14px; + border: 1px solid var(--la-border-subtle, rgba(0, 0, 0, 0.08)); + border-radius: var(--la-radius, 8px); + } + + &__bulk-tags { + flex: 1 1 220px; + min-width: 180px; + } + + &__batch-pill { + display: inline-block; + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + color: var(--la-text-secondary, #525252); + white-space: nowrap; + } + + .is-disabled td { + opacity: 0.65; + } + + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } +} + +@media (max-width: 720px) { + .pivot-tenant-curation { + &__header { + flex-direction: column; + } + } +} diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDashboard.jsx b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDashboard.jsx new file mode 100644 index 00000000..bbd62024 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDashboard.jsx @@ -0,0 +1,171 @@ +import React, { useMemo } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import Dashboard from '../../../components/Dashboard/Dashboard'; +import { useFetch } from '../../../hooks/useFetch'; +import { isPivotTenant } from '../TenantManagement/tenantPivotUtils'; +import PivotTenantOverviewPage from './PivotTenantOverviewPage'; +import PivotTenantCurationPage from './PivotTenantCurationPage'; +import PivotTenantJourneysPage from './PivotTenantJourneysPage'; +import PivotTenantDropdown from './PivotTenantDropdown'; +import PivotJustGoLogo from './PivotJustGoLogo'; +import '../../Admin/Admin.scss'; +import '../TenantManagement/TenantManagementPage.scss'; +import './PivotTenantDashboard.scss'; + +const NO_FETCH_CACHE = { enabled: false }; + +function normalizeTenantKey(value) { + return String(value || '') + .trim() + .toLowerCase(); +} + +function PivotTenantGate({ title, body, onBack }) { + return ( +
      +
      +

      {title}

      +

      {body}

      + +
      +
      + ); +} + +/** + * Per-tenant Just Go ops shell. + * Route: /platform-admin/pivot/:tenantKey?page=0|1|2 + */ +function PivotTenantDashboard() { + const navigate = useNavigate(); + const { tenantKey: tenantKeyParam } = useParams(); + const tenantKey = normalizeTenantKey(tenantKeyParam); + + const goToTenants = () => navigate('/platform-admin?page=0'); + + const { data, loading, error } = useFetch('/admin/platform/tenants', { + cache: NO_FETCH_CACHE, + }); + + const tenants = data?.success ? data.data?.tenants || [] : []; + + const tenant = useMemo(() => { + if (!tenantKey || !tenants.length) return null; + return tenants.find((row) => normalizeTenantKey(row.tenantKey) === tenantKey) || null; + }, [tenants, tenantKey]); + + const cityDisplayName = tenant?.location || tenant?.name || tenantKey; + + const menuItems = useMemo( + () => [ + { + label: 'Overview', + icon: 'ic:round-dashboard', + element: ( + + ), + }, + { + label: 'Curation', + icon: 'mdi:clipboard-edit-outline', + element: ( + + ), + }, + { + label: 'User journeys', + icon: 'mdi:graph', + element: ( + + ), + }, + ], + [tenantKey, cityDisplayName], + ); + + if (!tenantKey) { + return ( + + ); + } + + if (error && !tenants.length) { + return ( + + ); + } + + if (!loading && !tenant) { + return ( + + No tenant matches {tenantKey}. + + } + onBack={goToTenants} + /> + ); + } + + if (!loading && tenant && !isPivotTenant(tenant)) { + return ( + + {tenantKey} is not a Just Go / pivot + pilot tenant. + + } + onBack={goToTenants} + /> + ); + } + + return ( + } + middleItem={ + + } + onBack={goToTenants} + enableSubSidebar={false} + defaultPage={0} + primaryColor="black" + secondaryColor="rgba(185, 185, 185, 0.2)" + /> + ); +} + +export default PivotTenantDashboard; diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDashboard.scss b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDashboard.scss new file mode 100644 index 00000000..a8360eb9 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDashboard.scss @@ -0,0 +1,79 @@ +@import url('https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap'); + +/* Just Go flare shared across Overview / Curation / Journeys. */ +.pivot-tenant-dash { + /* Page shell owns scrolling so sticky header stays flush to the content pane. */ + .dash-content { + overflow: hidden; + } + + .dash-left { + overflow: visible; + padding-top: 8px; + + .top { + overflow: visible; + } + } + + .dash-left .logo { + width: 90%; + max-width: 176px; + margin: 0 auto 20px; + border-radius: 0; + background: transparent; + overflow: visible; + } +} + +.pivot-tenant-stub { + padding: 1.5rem 1.25rem; + max-width: 40rem; +} + +.pivot-tenant-stub__title { + margin: 0 0 0.35rem; + font-size: 1.25rem; + font-weight: 600; +} + +.pivot-tenant-stub__meta { + margin: 0 0 1rem; + font-size: 0.875rem; + opacity: 0.7; +} + +.pivot-tenant-stub__body { + margin: 0; + font-size: 0.95rem; + line-height: 1.45; + opacity: 0.85; +} + +.pivot-tenant-dash__gate { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + padding: 2.5rem 1.5rem; + max-width: 28rem; + margin: 0 auto; +} + +.pivot-tenant-dash__gate-title { + margin: 0; + font-size: 1.15rem; + font-weight: 600; +} + +.pivot-tenant-dash__gate-body { + margin: 0; + font-size: 0.95rem; + line-height: 1.45; + opacity: 0.8; +} + +.pivot-tenant-dash__gate-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.85em; +} diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDropdown.jsx b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDropdown.jsx new file mode 100644 index 00000000..69c5b410 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDropdown.jsx @@ -0,0 +1,163 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { Icon } from '@iconify-icon/react'; +import { isPivotTenant } from '../TenantManagement/tenantPivotUtils'; +import '../../ClubDash/OrgDropdown/OrgDropdown.scss'; +import '../../Admin/AdminTenantDropdown/AdminTenantDropdown.scss'; +import './PivotTenantDropdown.scss'; + +function normalizeTenantKey(value) { + return String(value || '') + .trim() + .toLowerCase(); +} + +function cityLabel(tenant) { + if (!tenant) return ''; + return tenant.location || tenant.name || tenant.tenantKey || ''; +} + +/** + * Pivot-only city switcher for the per-tenant ops dashboard. + * Navigates within /platform-admin/pivot/:tenantKey and preserves ?page= (+ filters). + */ +function PivotTenantDropdown({ + tenants = [], + currentTenantKey, + cityDisplayName, + loading = false, +}) { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [showDrop, setShowDrop] = useState(false); + const [isAnimating, setIsAnimating] = useState(false); + const [shouldRender, setShouldRender] = useState(false); + + const currentKey = normalizeTenantKey(currentTenantKey); + + const pivotTenants = useMemo(() => { + return (tenants || []) + .filter(isPivotTenant) + .slice() + .sort((a, b) => cityLabel(a).localeCompare(cityLabel(b), undefined, { sensitivity: 'base' })); + }, [tenants]); + + useEffect(() => { + if (showDrop) { + setShouldRender(true); + setIsAnimating(true); + return undefined; + } + setIsAnimating(false); + const timer = setTimeout(() => setShouldRender(false), 200); + return () => clearTimeout(timer); + }, [showDrop]); + + const displayLabel = + cityDisplayName || + cityLabel(pivotTenants.find((row) => normalizeTenantKey(row.tenantKey) === currentKey)) || + currentKey || + (loading ? 'Loading…' : 'Pivot city'); + + const handleSelectTenant = useCallback( + (tenantKey) => { + const nextKey = normalizeTenantKey(tenantKey); + if (!nextKey || nextKey === currentKey) { + setShowDrop(false); + return; + } + const query = searchParams.toString(); + navigate(`/platform-admin/pivot/${nextKey}${query ? `?${query}` : ''}`); + setShowDrop(false); + }, + [currentKey, navigate, searchParams], + ); + + return ( +
      setShowDrop(!showDrop)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setShowDrop(!showDrop); + } + }} + aria-expanded={showDrop} + aria-haspopup="listbox" + aria-label="Switch pivot city" + > + +
      +

      {displayLabel}

      + {currentKey ? {currentKey} : null} +
      + + {shouldRender ? ( +
      e.stopPropagation()} + > +
      + {loading && !pivotTenants.length ? ( +
      +

      Loading cities…

      +
      + ) : null} + {!loading && !pivotTenants.length ? ( +
      +

      No pivot cities

      +
      + ) : null} + {pivotTenants.map((tenant) => { + const key = normalizeTenantKey(tenant.tenantKey); + const selected = key === currentKey; + return ( +
      handleSelectTenant(tenant.tenantKey)} + > + +
      +

      {cityLabel(tenant)}

      + + {tenant.tenantKey} + {tenant.status && tenant.status !== 'active' + ? ` · ${String(tenant.status).replace(/_/g, ' ')}` + : ''} + +
      +
      + ); + })} +
      +
      + ) : null} +
      + ); +} + +export default PivotTenantDropdown; diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDropdown.scss b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDropdown.scss new file mode 100644 index 00000000..57272b68 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDropdown.scss @@ -0,0 +1,21 @@ +.pivot-tenant-dropdown { + &.org-dropdown { + cursor: pointer; + } + + .pivot-tenant-dropdown__lead-icon { + flex-shrink: 0; + color: var(--light-text, #64748b); + } + + .pivot-tenant-dropdown__empty { + cursor: default; + opacity: 0.7; + + p { + margin: 0; + font-size: 13px; + text-transform: none; + } + } +} diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantJourneysPage.jsx b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantJourneysPage.jsx new file mode 100644 index 00000000..e462a25b --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantJourneysPage.jsx @@ -0,0 +1,768 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; +import { useFetch, authenticatedRequest } from '../../../hooks/useFetch'; +import { useNotification } from '../../../NotificationContext'; +import { + toIsoWeek, + isValidIsoWeek, + shiftIsoWeek, + formatEventWhen, +} from '../../../utils/pivotIsoWeek'; +import PivotTenantPage from './PivotTenantPage'; +import PivotBatchWeekPicker from './PivotBatchWeekPicker'; +import usePivotBatchWeekState from './usePivotBatchWeekState'; +import usePivotTenantWeekKeybinds from './usePivotTenantWeekKeybinds'; +import KeybindTooltip from '../../../components/Interface/KeybindTooltip/KeybindTooltip'; +import '../PivotLab/PivotLabPage.scss'; +import './PivotTenantDashboard.scss'; +import './PivotTenantJourneysPage.scss'; +import './PivotTenantPage.scss'; + +const NO_FETCH_CACHE = { enabled: false }; +const WIPE_CONFIRM_TOKEN = 'WIPE'; +const SEARCH_DEBOUNCE_MS = 280; + +function formatRate(rate) { + if (rate == null || Number.isNaN(rate)) return '—'; + return `${Math.round(rate * 100)}%`; +} + +function formatPercent(numerator, denominator) { + if (!denominator) return null; + return `${Math.round((numerator / denominator) * 100)}%`; +} + +function formatConversionPct(value) { + if (value == null || Number.isNaN(value)) return '—'; + return `${Number(value).toFixed(1)}%`; +} + +function MetricCard({ label, value, hint }) { + return ( +
      + {label} + {value} + {hint ? {hint} : null} +
      + ); +} + +/** Intent funnel bars — same visual language as Overview / Lab. */ +function IntentFunnelChart({ stages }) { + const max = Math.max(1, ...(stages || []).map((stage) => stage.value ?? 0)); + if (!stages?.length) return null; + + return ( +
      + {stages.map((stage, index) => { + const prev = index > 0 ? stages[index - 1].value : null; + const conversion = prev != null ? formatPercent(stage.value, prev) : null; + return ( +
      +
      + {stage.label} + {stage.hint} +
      +
      +
      + {stage.value ?? 0} +
      + + {conversion ? `${conversion} of prev` : '\u00a0'} + +
      + ); + })} +
      + ); +} + +/** Analytics closed-funnel steps (pivot_* event names). */ +function AnalyticsFunnelSteps({ steps }) { + if (!steps?.length) return null; + const max = Math.max(1, ...steps.map((s) => s.count ?? 0)); + + return ( +
      + {steps.map((step) => ( +
      +
      + {step.key} + {step.event} +
      +
      +
      + {step.count ?? 0} +
      + + {formatConversionPct(step.conversionRate)} + {step.dropOff > 0 ? ` · −${step.dropOff}` : ''} + +
      + ))} +
      + ); +} + +function IntentStatusPill({ status }) { + if (status === 'registered') { + return Going; + } + if (status === 'interested') { + return Interested; + } + if (status === 'passed') { + return Passed; + } + return {status || '—'}; +} + +function useDebouncedValue(value, delayMs) { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(id); + }, [value, delayMs]); + return debounced; +} + +/** + * Per-tenant User journeys — compact funnel + user inspector + wipe-week. + */ +function PivotTenantJourneysPage({ tenantKey, cityDisplayName }) { + const { addNotification } = useNotification(); + const [searchParams, setSearchParams] = useSearchParams(); + + const urlBatchWeek = searchParams.get('batchWeek'); + const urlUserId = searchParams.get('userId'); + + const { + batchWeek, + committedWeek, + setBatchWeek, + batchWeekValid, + committedWeekValid, + } = usePivotBatchWeekState( + isValidIsoWeek(urlBatchWeek) ? urlBatchWeek.trim() : toIsoWeek(), + ); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedUserId, setSelectedUserId] = useState(() => urlUserId?.trim() || null); + const [wipeBusy, setWipeBusy] = useState(false); + + const debouncedQuery = useDebouncedValue(searchQuery.trim(), SEARCH_DEBOUNCE_MS); + + // Bookmark committed week + selected user (preserve page=2). + useEffect(() => { + const currentWeek = searchParams.get('batchWeek'); + const currentUser = searchParams.get('userId'); + const pageOk = searchParams.get('page') === '2'; + const weekOk = !committedWeekValid || currentWeek === committedWeek; + const userOk = selectedUserId ? currentUser === selectedUserId : !currentUser; + if (pageOk && weekOk && userOk) return; + + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set('page', '2'); + if (committedWeekValid) next.set('batchWeek', committedWeek); + if (selectedUserId) next.set('userId', selectedUserId); + else next.delete('userId'); + return next; + }, + { replace: true }, + ); + }, [committedWeek, committedWeekValid, selectedUserId, searchParams, setSearchParams]); + + // Sync from deep links / tenant switch. + useEffect(() => { + if (isValidIsoWeek(urlBatchWeek)) { + const trimmed = urlBatchWeek.trim(); + setBatchWeek((current) => (current === trimmed ? current : trimmed), { + immediate: true, + }); + } + }, [urlBatchWeek, setBatchWeek]); + + useEffect(() => { + const next = urlUserId?.trim() || null; + setSelectedUserId((current) => (current === next ? current : next)); + }, [urlUserId]); + + const opsParams = useMemo( + () => ({ + batchWeek: committedWeek, + include: 'journeys', + }), + [committedWeek], + ); + const opsUrl = + tenantKey && committedWeekValid + ? `/admin/pivot/tenants/${encodeURIComponent(tenantKey)}/ops` + : null; + const { + data: opsResponse, + loading: opsLoading, + error: opsError, + refetch: refetchOps, + } = useFetch(opsUrl, { params: opsParams, cache: NO_FETCH_CACHE }); + + const isUserSearch = debouncedQuery.length >= 2; + const usersParams = useMemo( + () => ({ + ...(isUserSearch ? { query: debouncedQuery } : {}), + ...(committedWeekValid ? { batchWeek: committedWeek } : {}), + }), + [isUserSearch, debouncedQuery, committedWeek, committedWeekValid], + ); + // Search when query is long enough; otherwise load most-active for the week. + const usersUrl = + tenantKey && (isUserSearch || committedWeekValid) + ? `/admin/pivot/tenants/${encodeURIComponent(tenantKey)}/journeys/users` + : null; + const { + data: usersResponse, + loading: usersLoading, + error: usersError, + } = useFetch(usersUrl, { params: usersParams, cache: NO_FETCH_CACHE }); + + const historyParams = useMemo( + () => (committedWeekValid ? { batchWeek: committedWeek } : {}), + [committedWeek, committedWeekValid], + ); + const historyUrl = + tenantKey && selectedUserId + ? `/admin/pivot/tenants/${encodeURIComponent(tenantKey)}/journeys/users/${encodeURIComponent(selectedUserId)}/history` + : null; + const { + data: historyResponse, + loading: historyLoading, + error: historyError, + refetch: refetchHistory, + } = useFetch(historyUrl, { + params: historyParams, + cache: NO_FETCH_CACHE, + }); + + const ops = opsResponse?.success ? opsResponse.data : null; + const overview = ops?.journey && !ops.journey.error ? ops.journey : null; + const funnel = ops?.funnel && !ops.funnel.error ? ops.funnel : null; + const users = usersResponse?.success ? usersResponse.data?.users ?? [] : []; + const usersMode = + usersResponse?.success && usersResponse.data?.mode + ? usersResponse.data.mode + : isUserSearch + ? 'search' + : 'active'; + const history = historyResponse?.success ? historyResponse.data : null; + + const overviewLoading = opsLoading; + const funnelLoading = opsLoading && !funnel; + const overviewMessage = + opsError || + (opsResponse && !opsResponse.success + ? opsResponse.message || 'Unable to load journey overview.' + : null) || + (ops?.journey?.error ? ops.journey.error : null); + const funnelMessage = + ops?.funnel?.error || + (opsResponse && !opsResponse.success && !overviewMessage + ? opsResponse.message || 'Unable to load funnel.' + : null); + const usersMessage = + usersError || + (usersResponse && !usersResponse.success + ? usersResponse.message || 'Unable to search users.' + : null); + const historyMessage = + historyError || + (historyResponse && !historyResponse.success + ? historyResponse.message || 'Unable to load history.' + : null); + + const displayCity = overview?.cityDisplayName || cityDisplayName || tenantKey; + const kpis = overview?.kpis; + const conversionRates = overview?.conversionRates; + const intentFunnel = funnel?.intentFunnel || overview?.funnel || []; + const analyticsSteps = funnel?.steps || []; + + const stepBatchWeek = useCallback( + (delta) => { + setBatchWeek((current) => { + const next = shiftIsoWeek(current, delta); + return next || current; + }); + }, + [setBatchWeek], + ); + + const refreshAll = useCallback(() => { + refetchOps(); + if (selectedUserId) refetchHistory(); + }, [refetchOps, refetchHistory, selectedUserId]); + + const { keyboardNavActive } = usePivotTenantWeekKeybinds({ + enabled: batchWeekValid, + onStepWeek: stepBatchWeek, + onRefresh: refreshAll, + }); + + const selectUser = useCallback((userId) => { + setSelectedUserId(userId); + }, []); + + const clearSelectedUser = useCallback(() => { + setSelectedUserId(null); + }, []); + + const handleWipeWeek = useCallback(async () => { + if (!tenantKey || !selectedUserId || !committedWeekValid) return; + + const intentCount = history?.intents?.length ?? 0; + if ( + !window.confirm( + `Wipe ${intentCount || 'all'} interaction(s) for this user in ${committedWeek}? This cannot be undone.`, + ) + ) { + return; + } + + const typed = window.prompt( + `Type ${WIPE_CONFIRM_TOKEN} to confirm wiping intents for ${committedWeek}.`, + '', + ); + if (typed !== WIPE_CONFIRM_TOKEN) { + if (typed != null) { + addNotification({ + title: 'Wipe cancelled', + message: `Confirmation must be exactly “${WIPE_CONFIRM_TOKEN}”.`, + type: 'warning', + }); + } + return; + } + + setWipeBusy(true); + const { data, error } = await authenticatedRequest( + `/admin/pivot/tenants/${encodeURIComponent(tenantKey)}/users/${encodeURIComponent(selectedUserId)}/wipe-week`, + { + method: 'POST', + data: { batchWeek: committedWeek, confirm: WIPE_CONFIRM_TOKEN }, + }, + ); + setWipeBusy(false); + + if (error || !data?.success) { + const code = data?.code; + addNotification({ + title: 'Wipe failed', + message: + error || + data?.message || + (code === 'CONFIRM_REQUIRED' + ? 'Confirmation token required.' + : 'Could not wipe week intents.'), + type: 'error', + }); + return; + } + + addNotification({ + title: 'Week wiped', + message: `Removed ${data.data?.deletedCount ?? 0} intent(s) for ${committedWeek}.`, + type: 'success', + }); + refetchHistory(); + refetchOps(); + }, [ + addNotification, + committedWeek, + committedWeekValid, + history?.intents?.length, + refetchHistory, + refetchOps, + selectedUserId, + tenantKey, + ]); + + const curationHref = batchWeekValid + ? `/platform-admin/pivot/${encodeURIComponent(tenantKey)}?page=1&batchWeek=${encodeURIComponent(batchWeek)}` + : `/platform-admin/pivot/${encodeURIComponent(tenantKey)}?page=1`; + + return ( + + + + + } + > + {!batchWeekValid ? ( +

      + Batch week must be ISO format YYYY-Www (e.g. {toIsoWeek()}). +

      + ) : null} + + {overviewMessage && !overview ? ( +

      + {typeof overviewMessage === 'string' + ? overviewMessage + : 'Unable to load journey overview.'} +

      + ) : null} + +
      +
      +

      + Week snapshot +

      + {overviewLoading ? ( + Loading… + ) : null} +
      +
      + + + + + + +
      +
      + +
      +
      +

      + Funnel +

      + {funnelLoading ? ( + Loading… + ) : funnel?.overallConversionRate != null ? ( + + Analytics overall {formatConversionPct(funnel.overallConversionRate)} + + ) : null} +
      + {funnelMessage ? ( +

      + {funnelMessage} +

      + ) : null} + {!funnelLoading && !intentFunnel.length && !analyticsSteps.length ? ( +

      No funnel data for this week yet.

      + ) : ( +
      +
      +

      Intent stages

      + +
      +
      +

      Analytics steps

      + {analyticsSteps.length ? ( + + ) : ( +

      + No pivot analytics events for this week. +

      + )} +
      +
      + )} +
      + +
      +
      +

      + User inspector +

      + + Open curation + +
      + +
      +
      + + {usersMessage ? ( +

      + {usersMessage} +

      + ) : null} + {searchQuery.length > 0 && searchQuery.length < 2 ? ( +

      + Type at least 2 characters to search. +

      + ) : null} + {!isUserSearch && committedWeekValid ? ( +

      + Most active · {committedWeek} +

      + ) : null} + {isUserSearch && users.length > 0 ? ( +

      Search results

      + ) : null} + {usersLoading ? ( +

      + {isUserSearch ? 'Searching…' : 'Loading active users…'} +

      + ) : null} + {!usersLoading && isUserSearch && !users.length ? ( +

      No users match “{debouncedQuery}”.

      + ) : null} + {!usersLoading && + !isUserSearch && + committedWeekValid && + usersMode === 'active' && + !users.length ? ( +

      + No users with intents in {committedWeek}. +

      + ) : null} + {users.length > 0 ? ( +
        + {users.map((user) => { + const selected = user.userId === selectedUserId; + return ( +
      • + +
      • + ); + })} +
      + ) : null} +
      + +
      + {!selectedUserId ? ( +

      + Select a user to inspect week history and wipe interactions. +

      + ) : ( + <> +
      +
      +

      + {history?.user?.name || 'User'} + {history?.user?.username ? ( + + @{history.user.username} + + ) : null} +

      + + {selectedUserId} + +
      +
      + + +
      +
      + + {historyMessage ? ( +

      + {historyMessage} +

      + ) : null} + + {historyLoading ? ( +

      Loading history…

      + ) : null} + + {!historyLoading && history ? ( + <> +

      + Intents + {batchWeekValid ? ` · ${batchWeek}` : ''} + {history.intents?.length + ? ` (${history.intents.length})` + : ''} +

      + {!history.intents?.length ? ( +

      + No intents for this user + {batchWeekValid ? ` in ${batchWeek}` : ''}. +

      + ) : ( +
        + {history.intents.map((intent) => ( +
      • +
        + +
        +

        + {intent.eventName || 'Untitled event'} +

        +

        + {formatEventWhen(intent.eventStartTime) || '—'} + {intent.externalOpenCount > 0 + ? ` · ${intent.externalOpenCount} ticket open${ + intent.externalOpenCount === 1 ? '' : 's' + }` + : ''} + {intent.timeSlotId + ? ` · slot ${intent.timeSlotId}` + : ''} +

        +
        +
        +
        + + {intent.eventId.slice(-6)} + + + Catalog + +
        +
      • + ))} +
      + )} + + {history.analytics?.length ? ( + <> +

      + Recent analytics ({history.analytics.length}) +

      +
        + {history.analytics.slice(0, 20).map((row, idx) => ( +
      • + + {row.event} + + + {row.ts + ? new Date(row.ts).toLocaleString() + : '—'} + +
      • + ))} +
      + + ) : null} + + ) : null} + + )} +
      +
      +
      +
      + ); +} + +export default PivotTenantJourneysPage; diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantJourneysPage.scss b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantJourneysPage.scss new file mode 100644 index 00000000..23c3b32f --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantJourneysPage.scss @@ -0,0 +1,241 @@ +.pivot-tenant-journeys { + max-width: none; + /* Horizontal/vertical padding owned by PivotTenantPage shell. */ + padding: 0; + + .pivot-tenant-page__body { + max-width: 1100px; + } + + &__muted { + font-size: 12px; + color: var(--la-text-tertiary, inherit); + } + + &__link { + font-size: 13px; + color: var(--la-accent, #5e6ad2); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + &__funnel-grid { + display: grid; + grid-template-columns: 1fr; + gap: 12px; + + @media (min-width: 900px) { + grid-template-columns: 1fr 1fr; + gap: 14px; + } + } + + &__analytics-funnel { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__analytics-row { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.4fr) auto; + gap: 8px 12px; + align-items: center; + } + + &__analytics-meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + &__analytics-key { + font-size: 13px; + font-weight: 500; + color: var(--la-text, inherit); + } + + &__analytics-conv { + font-size: 11px; + color: var(--la-text-tertiary, inherit); + white-space: nowrap; + } + + &__inspector { + display: grid; + grid-template-columns: 1fr; + gap: 14px; + + @media (min-width: 800px) { + grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); + gap: 16px; + align-items: start; + } + } + + &__search { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__list-label { + margin: 4px 0 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--la-text-tertiary, inherit); + } + + &__user-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + max-height: 320px; + overflow-y: auto; + } + + &__user-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + padding: 8px 10px; + text-align: left; + border: 1px solid transparent; + border-radius: var(--la-radius, 8px); + background: transparent; + cursor: pointer; + color: inherit; + font: inherit; + + &:hover { + background: var(--la-surface, rgba(0, 0, 0, 0.03)); + } + + &--selected { + background: var(--la-surface, rgba(0, 0, 0, 0.04)); + border-color: var(--la-border-subtle, rgba(0, 0, 0, 0.1)); + } + } + + &__user-name { + display: flex; + flex-direction: column; + gap: 1px; + font-size: 13px; + font-weight: 500; + min-width: 0; + } + + &__user-handle { + font-size: 11px; + font-weight: 400; + color: var(--la-text-tertiary, inherit); + } + + &__history { + min-width: 0; + } + + &__history-head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + } + + &__history-name { + margin: 0 0 4px; + font-size: 15px; + font-weight: 600; + color: var(--la-text, inherit); + + .pivot-tenant-journeys__user-handle { + margin-left: 6px; + } + } + + &__history-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &__timeline { + list-style: none; + margin: 0 0 14px; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; + } + + &__timeline-item { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 8px 14px; + padding: 8px 10px; + border: 1px solid var(--la-border-subtle, rgba(0, 0, 0, 0.08)); + border-radius: var(--la-radius, 8px); + background: transparent; + } + + &__timeline-main { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; + } + + &__timeline-side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + } + + &__event-name { + margin: 0; + font-size: 13px; + font-weight: 500; + color: var(--la-text, inherit); + } + + &__event-meta { + margin: 2px 0 0; + font-size: 11px; + color: var(--la-text-tertiary, inherit); + } + + &__analytics-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; + + li { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; + } + } +} diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantOverviewPage.jsx b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantOverviewPage.jsx new file mode 100644 index 00000000..9a5b48b1 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantOverviewPage.jsx @@ -0,0 +1,524 @@ +import React, { useCallback, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { useFetch } from '../../../hooks/useFetch'; +import AdminPlatformMetricChart from '../../Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart'; +import { + toIsoWeek, + isValidIsoWeek, + shiftIsoWeek, + formatEventWhen, +} from '../../../utils/pivotIsoWeek'; +import PivotReadinessCard from './PivotReadinessCard'; +import PivotTenantPage from './PivotTenantPage'; +import PivotBatchWeekPicker from './PivotBatchWeekPicker'; +import usePivotBatchWeekState from './usePivotBatchWeekState'; +import usePivotTenantWeekKeybinds from './usePivotTenantWeekKeybinds'; +import KeybindTooltip from '../../../components/Interface/KeybindTooltip/KeybindTooltip'; +import '../PivotLab/PivotLabPage.scss'; +import './PivotTenantDashboard.scss'; +import './PivotTenantOverviewPage.scss'; +import './PivotReadinessCard.scss'; +import './PivotTenantPage.scss'; + +const NO_FETCH_CACHE = { enabled: false }; +const CHART_COLOR = '#5e6ad2'; // --la-accent +const RETENTION_WEEKS = 6; +const TOP_EVENTS_LIMIT = 10; + +function formatRate(rate) { + if (rate == null || Number.isNaN(rate)) return '—'; + return `${Math.round(rate * 100)}%`; +} + +function InsightSeverity({ severity }) { + const label = + severity === 'critical' ? 'Critical' : severity === 'warn' ? 'Warn' : 'Info'; + const mod = + severity === 'critical' || severity === 'warn' + ? 'pivot-lab__pill--warn' + : 'pivot-lab__pill--muted'; + return {label}; +} + +function MetricCard({ label, value, hint, delta }) { + return ( +
      + {label} + {value} + {hint ? {hint} : null} + {delta != null ? ( + 0 + ? ' pivot-tenant-overview__delta--up' + : delta < 0 + ? ' pivot-tenant-overview__delta--down' + : '' + }`} + > + {delta > 0 ? '+' : ''} + {delta} vs prev + + ) : null} +
      + ); +} + +function formatPercent(numerator, denominator) { + if (!denominator) return null; + return `${Math.round((numerator / denominator) * 100)}%`; +} + +/** Funnel from API stages (Task 2.1) with Lab stage-over-stage conversion. */ +function FunnelChart({ stages }) { + const max = Math.max(1, ...(stages || []).map((stage) => stage.value ?? 0)); + + if (!stages?.length) return null; + + return ( +
      + {stages.map((stage, index) => { + const prev = index > 0 ? stages[index - 1].value : null; + const conversion = prev != null ? formatPercent(stage.value, prev) : null; + return ( +
      +
      + {stage.label} + {stage.hint} +
      +
      +
      + {stage.value ?? 0} +
      + + {conversion ? `${conversion} of prev` : '\u00a0'} + +
      + ); + })} +
      + ); +} + +function formatStatusBreakdown(counts) { + if (!counts) return null; + const parts = []; + if (counts.published) parts.push(`${counts.published} published`); + if (counts.staged) parts.push(`${counts.staged} staged`); + if (counts.draft) parts.push(`${counts.draft} draft`); + if (counts.other) parts.push(`${counts.other} other`); + return parts.length ? parts.join(' · ') : 'no catalog events'; +} + +function deltaFor(vsPrevWeek, key) { + const row = vsPrevWeek?.[key]; + if (!row || typeof row.delta !== 'number') return null; + return row.delta; +} + +/** + * Per-tenant Overview — city KPIs, funnel, active-users trend, next-drop callout, + * top events, and actionable insights. + */ +function PivotTenantOverviewPage({ tenantKey, cityDisplayName }) { + const { + batchWeek, + committedWeek, + setBatchWeek, + batchWeekValid, + committedWeekValid, + } = usePivotBatchWeekState(() => toIsoWeek()); + + const opsParams = useMemo( + () => ({ + batchWeek: committedWeek, + include: 'overview', + performanceLimit: TOP_EVENTS_LIMIT, + retentionWeeks: RETENTION_WEEKS, + }), + [committedWeek], + ); + const opsUrl = + tenantKey && committedWeekValid + ? `/admin/pivot/tenants/${encodeURIComponent(tenantKey)}/ops` + : null; + + const { + data: opsResponse, + loading: opsLoading, + error: opsError, + refetch: refetchOps, + } = useFetch(opsUrl, { + params: opsParams, + cache: NO_FETCH_CACHE, + }); + + const ops = opsResponse?.success ? opsResponse.data : null; + const overview = ops?.overview && !ops.overview.error ? ops.overview : null; + const drop = overview?.dropSchedule || ops?.dropSchedule; + const readiness = ops?.readiness && !ops.readiness.error ? ops.readiness : null; + const overviewMessage = + opsError || + (opsResponse && !opsResponse.success + ? opsResponse.message || 'Unable to load overview.' + : null) || + (ops?.overview?.error ? ops.overview.error : null); + + const performance = ops?.performance && !ops.performance.error ? ops.performance : null; + const topEvents = performance?.events ?? []; + const performanceError = ops?.performance?.error || null; + const performanceLoading = opsLoading && !performance; + + const insightsPayload = ops?.insights && !ops.insights.error ? ops.insights : null; + const insights = insightsPayload?.insights ?? []; + const insightsError = ops?.insights?.error || null; + const insightsLoading = opsLoading && !insightsPayload; + + const selectedRetention = + ops?.retention && !ops.retention.error ? ops.retention.tenant : null; + const retentionError = ops?.retention?.error || null; + const retentionLoading = opsLoading && !selectedRetention; + const readinessLoading = opsLoading && !readiness; + const overviewLoading = opsLoading; + + const activeUsersSeries = useMemo(() => { + const weeks = selectedRetention?.weeks ?? []; + if (!weeks.length) return []; + return [ + { + label: 'Active users', + color: CHART_COLOR, + data: weeks.map((week) => ({ x: week.batchWeek, y: week.activeUsers ?? 0 })), + }, + ]; + }, [selectedRetention]); + + const stepBatchWeek = useCallback((delta) => { + setBatchWeek((current) => { + const next = shiftIsoWeek(current, delta); + return next || current; + }); + }, [setBatchWeek]); + + const { keyboardNavActive } = usePivotTenantWeekKeybinds({ + enabled: batchWeekValid, + onStepWeek: stepBatchWeek, + onRefresh: refetchOps, + }); + + const kpis = overview?.kpis; + const vsPrev = overview?.vsPrevWeek; + const displayCity = overview?.cityDisplayName || cityDisplayName || tenantKey; + + const feedbackLabel = + kpis?.feedbackAvg != null + ? `${kpis.feedbackAvg} (${kpis.feedbackCount ?? 0})` + : '—'; + + const eventHint = formatStatusBreakdown(kpis?.eventCountsByStatus); + + return ( + + + + + } + > + {!batchWeekValid ? ( +

      + Batch week must be ISO format YYYY-Www (e.g. {toIsoWeek()}). +

      + ) : null} + + {drop ? ( + + ) : null} + + + + {overviewLoading && !overview ? ( +

      Loading overview…

      + ) : null} + + {overviewMessage && !overview ? ( +

      + {typeof overviewMessage === 'string' + ? overviewMessage + : 'Unable to load overview for this city.'} +

      + ) : null} + + {overview && kpis ? ( +
      +

      + {displayCity} · {overview.batchWeek || batchWeek} +

      +
      + + + + + + +
      +
      +
      +

      This week's loop

      + +
      + + + +
      +
      +
      + {retentionError ? ( +

      {retentionError}

      + ) : null} + {retentionLoading && !activeUsersSeries.length ? ( +

      Loading trend…

      + ) : ( + + )} + {selectedRetention?.weeks?.length ? ( +

      + Latest retention:{' '} + {(() => { + const last = selectedRetention.weeks[selectedRetention.weeks.length - 1]; + if (last?.retentionRate == null) return '— (need prior week)'; + return `${last.retentionRate}% returned from prior week`; + })()} +

      + ) : null} +
      +
      +
      + ) : null} + + {!overviewLoading && overview && !kpis ? ( +

      No metrics for this week yet.

      + ) : null} + +
      +
      +
      +

      + Top events +

      +

      + Ranked by right-swipe survivors (interested + registered) for{' '} + {overview?.batchWeek || batchWeek}. +

      +
      +
      + {performanceError ? ( +

      + {typeof performanceError === 'string' + ? performanceError + : 'Unable to load event performance.'} +

      + ) : null} + {performanceLoading && !topEvents.length ? ( +

      Loading top events…

      + ) : topEvents.length ? ( +
      + + + + + + + + + + + + + + + {topEvents.map((event) => ( + + + + + + + + + + + ))} + +
      EventStatusInterestedGoingPassed + Opens + + Interest % + + Ticket % +
      +
      + {event.name || 'Untitled'} +
      +
      + {formatEventWhen(event.start_time)} +
      +
      + {event.ingestStatus || '—'} + {event.interestedTotal ?? 0}{event.registered ?? 0}{event.passed ?? 0} + {event.externalOpen ?? 0} + {event.externalOpenUsers + ? ` (${event.externalOpenUsers} users)` + : ''} + {formatRate(event.interestRate)}{formatRate(event.ticketOpenRate)}
      +
      + ) : !performanceLoading ? ( +

      No catalog events for this week yet.

      + ) : null} +
      + +
      +
      +
      +

      + Needs attention +

      +

      + Server-side rules for this city and week — only issues that need a look. +

      +
      +
      + {insightsError ? ( +

      + {typeof insightsError === 'string' + ? insightsError + : 'Unable to load insights.'} +

      + ) : null} + {insightsLoading && !insightsPayload ? ( +

      Checking for issues…

      + ) : insights.length ? ( +
        + {insights.map((insight) => ( +
      • +
        + +

        {insight.title}

        +
        +

        {insight.body}

        + {insight.href ? ( + + {insight.action?.label || 'Open'} + + ) : null} +
      • + ))} +
      + ) : !insightsLoading ? ( +

      + Nothing flagged for this week. Catalog and engagement look steady. +

      + ) : null} +
      +
      + ); +} + +export default PivotTenantOverviewPage; diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantOverviewPage.scss b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantOverviewPage.scss new file mode 100644 index 00000000..3bf70aa6 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantOverviewPage.scss @@ -0,0 +1,146 @@ +.pivot-tenant-overview { + max-width: none; + + .pivot-tenant-page__body { + max-width: 1200px; + } + + &__drop { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 12px 20px; + margin: 0 0 20px; + padding: 14px 16px; + background: var(--la-surface, rgba(0, 0, 0, 0.03)); + border: 1px solid var(--la-border-subtle, rgba(0, 0, 0, 0.08)); + border-radius: var(--la-radius, 8px); + } + + &__drop-label { + margin: 0 0 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--la-text-tertiary, inherit); + opacity: 0.75; + } + + &__drop-value { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--la-text, inherit); + } + + &__drop-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + font-size: 12px; + color: var(--la-text-secondary, inherit); + + strong { + color: var(--la-text, inherit); + font-weight: 600; + } + } + + &__delta { + display: block; + margin-top: 2px; + font-size: 11px; + color: var(--la-text-tertiary, inherit); + + &--up { + color: #15803d; + } + + &--down { + color: #b45309; + } + } + + &__retention-hint { + margin: 12px 0 0; + font-size: 12px; + color: var(--la-text-secondary, inherit); + } + + &__insights-slot { + margin-top: 8px; + } + + &__event-name { + font-weight: 500; + color: var(--la-text, inherit); + } + + &__event-when { + margin-top: 2px; + font-size: 11px; + color: var(--la-text-tertiary, inherit); + } + + &__insight-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; + } + + &__insight { + padding: 14px 16px; + background: var(--la-surface, #fff); + border: 1px solid var(--la-border-subtle, rgba(0, 0, 0, 0.08)); + border-radius: var(--la-radius, 8px); + border-left-width: 3px; + + &--critical { + border-left-color: #dc2626; + } + + &--warn { + border-left-color: #d97706; + } + + &--info { + border-left-color: var(--la-accent, #5e6ad2); + } + } + + &__insight-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 6px; + } + + &__insight-title { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--la-text, inherit); + } + + &__insight-body { + margin: 0 0 10px; + font-size: 13px; + line-height: 1.45; + color: var(--la-text-secondary, inherit); + } + + &__insight-link { + display: inline-flex; + text-decoration: none; + } + + &__insights-clear { + padding: 12px 0; + } +} diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantPage.jsx b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantPage.jsx new file mode 100644 index 00000000..f68ef667 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantPage.jsx @@ -0,0 +1,107 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import PivotDashBurst from './PivotDashBurst'; +import PivotScrapbookTitle from './PivotScrapbookTitle'; +import './PivotTenantPage.scss'; +import './PivotScrapbookTitle.scss'; + +const COLLAPSE_SCROLL_PX = 28; + +function buildTenantLabel(cityDisplayName, tenantKey) { + const city = String(cityDisplayName || '').trim(); + const key = String(tenantKey || '').trim(); + if (city && key && city.toLowerCase() !== key.toLowerCase()) { + return `${city} · ${key}`; + } + return city || key; +} + +/** + * Shared Just Go ops page shell — corner burst, sticky collapsing header, scrapbook titles. + * Pass page body as children. + */ +function PivotTenantPage({ + title, + tenantKey, + cityDisplayName, + subtitle, + actions, + className = '', + children, +}) { + const pageRef = useRef(null); + const [collapsed, setCollapsed] = useState(false); + const collapsedRef = useRef(false); + + const tenantLabel = useMemo( + () => buildTenantLabel(cityDisplayName, tenantKey), + [cityDisplayName, tenantKey], + ); + const showTenantScrapbook = subtitle === undefined && Boolean(tenantLabel); + + const syncCollapsed = useCallback(() => { + const el = pageRef.current; + if (!el) return; + const next = el.scrollTop > COLLAPSE_SCROLL_PX; + if (next === collapsedRef.current) return; + collapsedRef.current = next; + setCollapsed(next); + }, []); + + useEffect(() => { + const el = pageRef.current; + if (!el) return undefined; + + let frame = 0; + const onScroll = () => { + if (frame) return; + frame = window.requestAnimationFrame(() => { + frame = 0; + syncCollapsed(); + }); + }; + + syncCollapsed(); + el.addEventListener('scroll', onScroll, { passive: true }); + return () => { + el.removeEventListener('scroll', onScroll); + if (frame) window.cancelAnimationFrame(frame); + }; + }, [syncCollapsed]); + + return ( +
      + {/* Page-level burst — unbounded until scroll; sits behind header/body. */} + +
      +
      +
      +
      + + {showTenantScrapbook ? ( + + ) : null} +
      + {subtitle !== undefined && subtitle ? ( +

      {subtitle}

      + ) : null} +
      + {actions ?
      {actions}
      : null} +
      +
      +
      {children}
      +
      + ); +} + +export default PivotTenantPage; diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantPage.scss b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantPage.scss new file mode 100644 index 00000000..2371217d --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantPage.scss @@ -0,0 +1,217 @@ +.pivot-tenant-page { + position: relative; + isolation: isolate; + width: 100%; + height: 100%; + max-height: 100%; + overflow-x: hidden; + overflow-y: auto; + box-sizing: border-box; + + /* Body above the corner burst. Do NOT set position on the sticky header. */ + > .pivot-tenant-page__body { + position: relative; + z-index: 1; + } + + &.is-collapsed { + .pivot-dash-burst { + transform: scale(0.5); + } + + .pivot-tenant-page__header { + background: var(--la-bg, #fff); + box-shadow: 0 1px 0 var(--la-border-subtle, rgba(0, 0, 0, 0.08)); + } + + .pivot-tenant-page__header-inner { + padding-top: 10px; + padding-bottom: 10px; + } + + .pivot-tenant-page__titles { + flex-direction: row; + align-items: center; + transform: scale(0.82); + } + + .pivot-scrapbook-title { + flex-direction: row; + flex-wrap: wrap; + align-items: center; + } + + .pivot-scrapbook-title--sm { + margin: 0; + } + + .pivot-tenant-page__subtitle { + max-height: 0; + margin: 0; + opacity: 0; + overflow: hidden; + } + } + + &__header { + position: sticky; + top: 0; + left: 0; + z-index: 40; + width: 100%; + margin: 0; + padding: 0; + box-sizing: border-box; + overflow: visible; + /* Transparent at rest so the burst isn't boxed; solid only after scroll. */ + background: transparent; + transition: + background-color 0.28s ease, + box-shadow 0.28s ease; + } + + /* Padding on content, not the sticky shell. */ + &__header-inner { + position: relative; + z-index: 1; + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px 20px; + padding: 28px 32px 14px; + box-sizing: border-box; + transition: padding 0.28s cubic-bezier(0.22, 1, 0.36, 1); + } + + &__heading { + position: relative; + z-index: 1; + min-width: 0; + flex: 1 1 auto; + } + + /* Stacked at rest (original); row after scroll. */ + &__titles { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + align-items: flex-start; + gap: 0; + transform: scale(1); + transform-origin: left top; + transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1); + } + + &__subtitle { + margin: 8px 0 0; + font-size: 14px; + line-height: 1.5; + color: var(--la-text-secondary, inherit); + opacity: 0.9; + max-height: 4.5em; + transition: + opacity 0.22s ease, + max-height 0.28s cubic-bezier(0.22, 1, 0.36, 1), + margin 0.28s ease; + } + + &__body { + position: relative; + z-index: 1; + padding: 4px 32px 48px; + box-sizing: border-box; + } + + .pivot-lab__controls { + position: relative; + z-index: 2; + overflow: visible; + flex: 0 1 auto; + } + + .pivot-lab__week-stepper { + overflow: visible; + } +} + +@media (prefers-reduced-motion: reduce) { + .pivot-tenant-page__header, + .pivot-tenant-page__header-inner, + .pivot-tenant-page__titles, + .pivot-tenant-page__subtitle, + .pivot-dash-burst { + transition: none !important; + } +} + +@media (max-width: 720px) { + .pivot-tenant-page__header-inner { + padding-left: 16px; + padding-right: 16px; + } + + .pivot-tenant-page__body { + padding-left: 16px; + padding-right: 16px; + } +} + +/* Host for KeybindTooltip — position + hover/focus reveal. */ +.pivot-tenant-kbd-btn { + position: relative; + overflow: visible; + + &:hover .keybind-tooltip, + &:focus-visible .keybind-tooltip { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + + &.is-key-active { + border-color: var(--la-accent, #5e6ad2); + background: rgba(94, 106, 210, 0.12); + transform: translateY(1px) scale(0.98); + } +} + +/* + * Corner burst — page-level, same placement as the original shell. + * Not clipped by the header; header only gains a solid backdrop after scroll. + */ +.pivot-dash-burst { + position: absolute; + top: -110px; + left: -120px; + width: 380px; + aspect-ratio: 64 / 48; + z-index: 0; + pointer-events: none; + user-select: none; + transform: scale(1); + transform-origin: 120px 110px; + transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1); +} + +.pivot-dash-burst__ink, +.pivot-dash-burst__orange { + position: absolute; + display: block; + pointer-events: none; +} + +.pivot-dash-burst__ink { + width: 114%; + height: 114%; + left: -10%; + top: -5%; + transform: rotate(-6deg); +} + +.pivot-dash-burst__orange { + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + transform: rotate(-12deg); +} diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/usePivotBatchWeekState.js b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/usePivotBatchWeekState.js new file mode 100644 index 00000000..4a3bbe22 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/usePivotBatchWeekState.js @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { isValidIsoWeek } from '../../../utils/pivotIsoWeek'; + +const DEFAULT_DELAY_MS = 350; + +/** + * Immediate UI week + debounced committed week for fetches / URL sync. + * Menu picks can pass `{ immediate: true }` to skip the debounce. + */ +export function usePivotBatchWeekState(initialWeek, delayMs = DEFAULT_DELAY_MS) { + const [batchWeek, setBatchWeekState] = useState(initialWeek); + const [committedWeek, setCommittedWeek] = useState(initialWeek); + const batchWeekRef = useRef(batchWeek); + batchWeekRef.current = batchWeek; + + useEffect(() => { + if (batchWeek === committedWeek) return undefined; + const id = setTimeout(() => { + setCommittedWeek(batchWeek); + }, delayMs); + return () => clearTimeout(id); + }, [batchWeek, committedWeek, delayMs]); + + const setBatchWeek = useCallback((weekOrUpdater, options = {}) => { + const current = batchWeekRef.current; + const next = + typeof weekOrUpdater === 'function' ? weekOrUpdater(current) : weekOrUpdater; + batchWeekRef.current = next; + setBatchWeekState(next); + if (options.immediate) { + setCommittedWeek(next); + } + }, []); + + return { + batchWeek, + committedWeek, + setBatchWeek, + batchWeekValid: isValidIsoWeek(batchWeek), + committedWeekValid: isValidIsoWeek(committedWeek), + weekSettled: batchWeek === committedWeek, + }; +} + +export default usePivotBatchWeekState; diff --git a/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/usePivotTenantWeekKeybinds.js b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/usePivotTenantWeekKeybinds.js new file mode 100644 index 00000000..06d37d25 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotTenantDashboard/usePivotTenantWeekKeybinds.js @@ -0,0 +1,85 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { isTypingTarget } from '../PivotLab/PivotManualImportModal'; + +const ARROW_KEY_NAV_DEBOUNCE_MS = 140; + +/** + * Shared keyboard nav for Pivot tenant ops pages (Overview / Journeys / Curation). + * ← / → step batch week; R refreshes. Skips when typing in inputs. + * + * @returns {{ keyboardNavActive: 'left'|'right'|null }} + */ +export function usePivotTenantWeekKeybinds({ + enabled = true, + onStepWeek, + onRefresh, + canStepBack = true, + canStepForward = true, +} = {}) { + const [keyboardNavActive, setKeyboardNavActive] = useState(null); + const debounceRef = useRef(0); + const flashTimeoutRef = useRef(null); + + const flash = useCallback((direction) => { + setKeyboardNavActive(direction); + if (flashTimeoutRef.current) { + clearTimeout(flashTimeoutRef.current); + } + flashTimeoutRef.current = setTimeout(() => { + setKeyboardNavActive(null); + }, 120); + }, []); + + useEffect( + () => () => { + if (flashTimeoutRef.current) { + clearTimeout(flashTimeoutRef.current); + } + }, + [], + ); + + useEffect(() => { + if (!enabled) return undefined; + + const handleKeyDown = (event) => { + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (isTypingTarget(event.target)) return; + + if (event.key === 'ArrowLeft') { + if (!canStepBack || !onStepWeek) return; + event.preventDefault(); + const now = Date.now(); + if (now - debounceRef.current < ARROW_KEY_NAV_DEBOUNCE_MS) return; + debounceRef.current = now; + flash('left'); + onStepWeek(-1); + return; + } + + if (event.key === 'ArrowRight') { + if (!canStepForward || !onStepWeek) return; + event.preventDefault(); + const now = Date.now(); + if (now - debounceRef.current < ARROW_KEY_NAV_DEBOUNCE_MS) return; + debounceRef.current = now; + flash('right'); + onStepWeek(1); + return; + } + + const key = String(event.key || '').toLowerCase(); + if (key === 'r' && onRefresh) { + event.preventDefault(); + onRefresh(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [enabled, onStepWeek, onRefresh, canStepBack, canStepForward, flash]); + + return { keyboardNavActive }; +} + +export default usePivotTenantWeekKeybinds; diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotInviteQRModal.jsx b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotInviteQRModal.jsx index f47f961a..ee87427c 100644 --- a/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotInviteQRModal.jsx +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotInviteQRModal.jsx @@ -123,7 +123,7 @@ function PivotInviteQRModal({ code, isOpen, onClose, onNotify }) { title={swatch.label} aria-label={swatch.label} > - {selected ? : null} + {selected ? : null} ); })} diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.jsx b/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.jsx index 2f6a6701..b7cae835 100644 --- a/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.jsx +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.jsx @@ -1,4 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Icon } from '@iconify-icon/react'; import { useFetch, authenticatedRequest } from '../../../hooks/useFetch'; import { setTenantConfigCache } from '../../../config/tenantRedirect'; @@ -191,11 +192,14 @@ function TenantDetail({ savingMetadata, savingStepId, }) { + const navigate = useNavigate(); const savedStatus = tenant.status || 'coming_soon'; + const isPivot = isPivotTenant(tenant); const checklistComplete = tenant.provisioningComplete === true; const showActivateCta = checklistComplete && savedStatus !== 'active'; const infraHeadingId = `tenant-infra-${tenant.tenantKey}`; + const pivotDashPath = `/platform-admin/pivot/${tenant.tenantKey}`; const openStatusDialog = (nextStatus, mode = 'status') => { if (nextStatus === savedStatus && mode !== 'message') return; @@ -282,7 +286,7 @@ function TenantDetail({
      - {isPivotTenant(tenant) ? Pivot pilot : null} + {isPivot ? Pivot pilot : null} {!checklistComplete ? ( Setup in progress ) : savedStatus !== 'active' ? ( @@ -290,6 +294,33 @@ function TenantDetail({ ) : null}
      + {isPivot ? ( +
      +

      Just Go ops

      +

      + City overview, curation, and user journeys for this pilot. +

      +
      + + +
      +
      + ) : null} +

      Infrastructure

      @@ -324,7 +355,7 @@ function TenantDetail({ Health check - {isPivotTenant(tenant) ? ( + {isPivot ? (