diff --git a/.DS_Store b/.DS_Store index 6198ba9e..1928ec1f 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/backend/app.js b/backend/app.js index 1027db21..17e6e870 100644 --- a/backend/app.js +++ b/backend/app.js @@ -12,28 +12,64 @@ const enforce = require('express-sslify'); const { connectToDatabase, connectToGlobalDatabase } = require('./connectionsManager'); const { initSocket } = require('./socket'); const getGlobalModels = require('./services/getGlobalModelService'); +const { BASE_DOMAIN } = require('./services/tenantConfigService'); const s3 = require('./aws-config'); function createApp() { + // Eager-load before route modules so circular requires never cache a partial export. + require('./services/getModelService'); + const app = express(); const server = createServer(app); let tenantConfigCache = null; let tenantConfigLastFetchedAt = 0; + let tenantKeysCache = ['rpi', 'tvcog']; + let tenantKeysLastFetchedAt = 0; + let tenantBootstrapComplete = false; - const corsOrigin = process.env.NODE_ENV === 'production' - ? ['https://www.meridian.study', 'https://meridian.study', 'https://rpi.meridian.study', 'https://tvcog.meridian.study'] - : 'http://localhost:3000'; - initSocket(server, { origin: corsOrigin }); + const staticProductionOrigins = [ + 'https://www.meridian.study', + 'https://meridian.study', + 'https://rpi.meridian.study', + 'https://tvcog.meridian.study', + ]; + + function isAllowedCorsOrigin(origin) { + if (!origin) return true; + if (process.env.NODE_ENV !== 'production') { + return origin.startsWith('http://localhost'); + } + if (staticProductionOrigins.includes(origin)) return true; + return new RegExp(`^https://[a-z0-9_-]+\\.${BASE_DOMAIN.replace('.', '\\.')}$`).test(origin); + } const corsOptions = { - origin: process.env.NODE_ENV === 'production' - ? ['https://www.meridian.study', 'https://meridian.study', 'https://rpi.meridian.study', 'https://tvcog.meridian.study'] - : 'http://localhost:3000', + origin(origin, callback) { + if (isAllowedCorsOrigin(origin)) { + callback(null, true); + } else { + callback(new Error(`Origin ${origin} not allowed by CORS`)); + } + }, credentials: true, - optionsSuccessStatus: 200 + optionsSuccessStatus: 200, }; + initSocket(server, { + origin: (origin, callback) => { + try { + if (isAllowedCorsOrigin(origin)) { + callback(null, true); + } else { + callback(new Error(`Origin ${origin} not allowed by CORS`)); + } + } catch (err) { + callback(err); + } + }, + }); + app.set('trust proxy', true); app.get('/health', (req, res) => res.status(200).json({ ok: true })); @@ -81,14 +117,19 @@ function createApp() { app.use(async (req, res, next) => { try { - // Debug logging to identify polling routes - // const timestamp = new Date().toISOString(); - // const method = req.method; - // const path = req.path || req.url; - // const userAgent = req.get('user-agent') || 'unknown'; - - // console.log(`[${timestamp}] ${method}: ${path} | School: ${req.headers.host?.split('.')[0] || 'unknown'} | User-Agent: ${userAgent.substring(0, 50)}`); - + req.globalDb = await connectToGlobalDatabase(); + + const tenantConfigService = require('./services/tenantConfigService'); + const { syncTenantUriCache, getMergedTenants } = tenantConfigService; + + if (!tenantBootstrapComplete) { + await syncTenantUriCache(req); + const tenants = await getMergedTenants(req); + tenantKeysCache = tenants.map((tenant) => tenant.tenantKey); + tenantKeysLastFetchedAt = Date.now(); + tenantBootstrapComplete = true; + } + const host = req.headers.host || ''; // Extract subdomain: for 'rpi.meridian.study' -> 'rpi', for 'localhost:5001' -> default tenant let subdomain = host.split('.')[0]; @@ -103,15 +144,19 @@ function createApp() { // Development only: allow X-Tenant header or ?school= to override tenant (for local testing) if (process.env.NODE_ENV !== 'production') { const override = req.headers['x-tenant'] || req.query.school; - const validTenants = ['rpi', 'tvcog']; // keep in sync with connectionsManager - if (override && validTenants.includes(override.toLowerCase())) { + const now = Date.now(); + if (now - tenantKeysLastFetchedAt > 30000) { + const tenants = await getMergedTenants(req); + tenantKeysCache = tenants.map((tenant) => tenant.tenantKey); + tenantKeysLastFetchedAt = now; + } + if (override && tenantKeysCache.includes(String(override).toLowerCase())) { subdomain = override.toLowerCase(); } } - + req.db = await connectToDatabase(subdomain); req.school = subdomain; - req.globalDb = await connectToGlobalDatabase(); next(); } catch (error) { console.error('Error establishing database connection:', error); @@ -135,6 +180,7 @@ function createApp() { '/error', '/select-school', '/tenant-status', + '/platform-admin', '/static', '/health', '/validate-token', @@ -144,6 +190,11 @@ function createApp() { '/api/event-system-config/analytics-config', '/api/android-tester', '/api/tenant-config', + '/pivot', + '/validate-token', + '/refresh-token', + '/admin/platform', + '/admin/pivot', ]; app.use((req, res, next) => { if (req.school !== 'www') return next(); @@ -183,22 +234,15 @@ function createApp() { return row?.status || 'active'; } + const { shouldBypassTenantStatusGate } = require('./middlewares/tenantStatusGate'); + app.use(async (req, res, next) => { try { if (req.school === 'www') return next(); const status = await getTenantStatus(req.school, req); - if (status === 'active' || status === 'hidden') return next(); + if (await shouldBypassTenantStatusGate(req, status)) return next(); const path = (req.path || req.url || '').split('?')[0]; - const allowStatusPage = path === '/tenant-status' || path.startsWith('/tenant-status/'); - const allowSharedAssets = - path.startsWith('/static') || - path.startsWith('/assets') || - path.startsWith('/favicon') || - path === '/manifest.json' || - path === '/health' || - path === '/api/tenant-config'; - if (allowStatusPage || allowSharedAssets) return next(); const acceptHeader = (req.headers.accept || '').toLowerCase(); const secFetchDest = (req.headers['sec-fetch-dest'] || '').toLowerCase(); @@ -249,6 +293,8 @@ function createApp() { const taskManagementRoutes = require('./routes/taskManagementRoutes.js'); const roomRoutes = require('./routes/roomRoutes.js'); const adminRoutes = require('./routes/adminRoutes.js'); + const platformTenantRoutes = require('./routes/platformTenantRoutes.js'); + const pivotWeeklyDropRoutes = require('./routes/pivotWeeklyDropRoutes.js'); const eventsRoutes = require('./events/index.js'); const notificationRoutes = require('./routes/notificationRoutes.js'); const qrRoutes = require('./routes/qrRoutes.js'); @@ -266,6 +312,8 @@ function createApp() { const resourcesRoutes = require('./routes/resourcesRoutes.js'); const shuttleConfigRoutes = require('./routes/shuttleConfigRoutes.js'); const noticeRoutes = require('./routes/noticeRoutes.js'); + const pivotRoutes = require('./routes/pivotRoutes.js'); + const pivotAdminRoutes = require('./routes/pivotAdminRoutes.js'); app.use(authRoutes); app.use('/auth/saml', samlRoutes); @@ -287,6 +335,8 @@ function createApp() { app.use('/org-event-management', taskManagementRoutes); app.use('/admin', roomRoutes); app.use(adminRoutes); + app.use(platformTenantRoutes); + app.use(pivotWeeklyDropRoutes); app.use(formRoutes); app.use('/notifications', notificationRoutes); app.use('/api/qr', qrRoutes); @@ -301,6 +351,8 @@ function createApp() { app.use('/api/resources', resourcesRoutes); app.use('/api/shuttle-config', shuttleConfigRoutes); app.use('/api/notice', noticeRoutes); + app.use('/pivot', pivotRoutes); + app.use('/admin/pivot', pivotAdminRoutes); app.use('/verify-affiliated-email', affiliatedEmailRoutes); app.use('/proxy-image', require('./routes/proxyImageRoutes.js')); diff --git a/backend/connectionsManager.js b/backend/connectionsManager.js index bc5a77c3..5ee813f3 100644 --- a/backend/connectionsManager.js +++ b/backend/connectionsManager.js @@ -1,62 +1,108 @@ const mongoose = require('mongoose'); -//load env require('dotenv').config(); -// Store active connections in a Map const connectionPool = new Map(); - -// Single global/platform DB connection (reused) let globalConnection = null; +let tenantUriCache = new Map(); + +const LEGACY_SCHOOL_DB_MAP = { + rpi: process.env.MONGO_URI_RPI, + tvcog: process.env.MONGO_URI_TVCOG, +}; + +function getPlatformDbUri() { + return ( + process.env.MONGO_URI_PLATFORM || + process.env.MONGO_URI_GLOBAL || + (process.env.MONGO_URI_RPI + ? process.env.MONGO_URI_RPI.replace(/\/([^/]+)(\?|$)/, '/meridian_platform$2') + : (process.env.MONGODB_URI || process.env.DEFAULT_MONGO_URI)?.replace( + /\/([^/]+)(\?|$)/, + '/meridian_platform$2' + )) + ); +} + +function getBaseMongoUri() { + return ( + process.env.MONGODB_URI || + process.env.DEFAULT_MONGO_URI || + process.env.MONGO_URI_RPI || + null + ); +} + +function deriveMongoUriForTenant(tenantKey, tenantRow = {}) { + if (tenantKey === 'www') return getPlatformDbUri(); + + const envKey = `MONGO_URI_${String(tenantKey).toUpperCase()}`; + if (process.env[envKey]) return process.env[envKey]; + + if (LEGACY_SCHOOL_DB_MAP[tenantKey]) return LEGACY_SCHOOL_DB_MAP[tenantKey]; + + if (tenantRow?.mongoUri) return tenantRow.mongoUri; + + if (tenantUriCache.has(tenantKey)) return tenantUriCache.get(tenantKey); + + const dbName = tenantRow?.mongoDatabaseName || tenantKey; + const base = getBaseMongoUri(); + if (!base) return null; + return base.replace(/\/([^/?]+)(\?|$)/, `/${dbName}$2`); +} + +function setTenantUriCache(entries = {}) { + tenantUriCache = new Map(Object.entries(entries).filter(([, uri]) => Boolean(uri))); +} + +function getRegisteredTenantKeys() { + const keys = new Set(['rpi', 'tvcog', 'www']); + tenantUriCache.forEach((_, key) => keys.add(key)); + return Array.from(keys); +} const connectToDatabase = async (school) => { - if (!connectionPool.has(school)) { - const dbUri = getDbUriForSchool(school); // A function to get the correct URI - const connection = mongoose.createConnection(dbUri, { - useNewUrlParser: true, - useUnifiedTopology: true, - }); - - connectionPool.set(school, connection); - console.log(`Created new connection for school: ${school}`); + if (!connectionPool.has(school)) { + const dbUri = deriveMongoUriForTenant(school); + if (!dbUri) { + throw new Error(`No MongoDB URI configured for tenant "${school}"`); } - return connectionPool.get(school); + const connection = mongoose.createConnection(dbUri, { + useNewUrlParser: true, + useUnifiedTopology: true, + }); + + connectionPool.set(school, connection); + console.log(`Created new connection for school: ${school}`); + } + return connectionPool.get(school); }; -/** - * Connect to the platform/global DB for cross-tenant data (GlobalUser, PlatformRole, TenantMembership). - * Uses MONGO_URI_PLATFORM or falls back to same cluster with different db name (e.g. meridian_platform). - */ const connectToGlobalDatabase = async () => { - if (!globalConnection) { - const uri = process.env.MONGO_URI_PLATFORM || process.env.MONGO_URI_GLOBAL || - (process.env.MONGO_URI_RPI - ? process.env.MONGO_URI_RPI.replace(/\/([^/]+)(\?|$)/, '/meridian_platform$2') - : (process.env.MONGODB_URI || process.env.DEFAULT_MONGO_URI)?.replace(/\/([^/]+)(\?|$)/, '/meridian_platform$2')); - globalConnection = mongoose.createConnection(uri, { - useNewUrlParser: true, - useUnifiedTopology: true, - }); - console.log('Created global/platform database connection'); - } - return globalConnection; + if (!globalConnection) { + const uri = getPlatformDbUri(); + globalConnection = mongoose.createConnection(uri, { + useNewUrlParser: true, + useUnifiedTopology: true, + }); + console.log('Created global/platform database connection'); + } + return globalConnection; }; -/** Platform/global DB URI for www (landing) - never use a tenant DB for base URL */ -const getPlatformDbUri = () => - process.env.MONGO_URI_PLATFORM || process.env.MONGO_URI_GLOBAL || - (process.env.MONGO_URI_RPI - ? process.env.MONGO_URI_RPI.replace(/\/([^/]+)(\?|$)/, '/meridian_platform$2') - : (process.env.MONGODB_URI || process.env.DEFAULT_MONGO_URI)?.replace(/\/([^/]+)(\?|$)/, '/meridian_platform$2')); +function invalidateTenantConnection(tenantKey) { + const conn = connectionPool.get(tenantKey); + if (conn) { + conn.close().catch(() => {}); + connectionPool.delete(tenantKey); + } +} -const getDbUriForSchool = (school) => { - if (school === 'www') { - return getPlatformDbUri(); - } - const schoolDbMap = { - rpi: process.env.MONGO_URI_RPI, - tvcog: process.env.MONGO_URI_TVCOG, - }; - return schoolDbMap[school] || process.env.MONGODB_URI || process.env.DEFAULT_MONGO_URI; +module.exports = { + connectToDatabase, + connectToGlobalDatabase, + getPlatformDbUri, + deriveMongoUriForTenant, + setTenantUriCache, + getRegisteredTenantKeys, + invalidateTenantConnection, }; - -module.exports = { connectToDatabase, connectToGlobalDatabase }; diff --git a/backend/constants/defaultTenants.js b/backend/constants/defaultTenants.js new file mode 100644 index 00000000..c4da4ffe --- /dev/null +++ b/backend/constants/defaultTenants.js @@ -0,0 +1,223 @@ +const TENANT_STATUSES = new Set(['active', 'coming_soon', 'maintenance', 'hidden']); +const TENANT_TYPES = new Set(['campus', 'pivot']); + +function normalizePivotDropOverrides(rows = []) { + if (!Array.isArray(rows)) return undefined; + + const normalized = rows + .map((row) => { + const batchWeek = String(row?.batchWeek || '').trim(); + if (!/^\d{4}-W\d{2}$/.test(batchWeek)) return null; + const dayOfWeek = Number(row?.dayOfWeek); + const hour = Number(row?.hour); + if (!Number.isFinite(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6) return null; + if (!Number.isFinite(hour) || hour < 0 || hour > 23) return null; + const minuteRaw = row?.minute; + const minute = + minuteRaw === undefined || minuteRaw === null + ? 0 + : Number(minuteRaw); + if (!Number.isFinite(minute) || minute < 0 || minute > 59) return null; + return { batchWeek, dayOfWeek, hour, minute }; + }) + .filter(Boolean); + + return normalized.length > 0 ? normalized : undefined; +} + +function normalizePivotDropFields(row = {}, target = {}) { + if (row.pivotDropTimezone !== undefined && row.pivotDropTimezone !== null) { + const timezone = String(row.pivotDropTimezone).trim(); + if (timezone) target.pivotDropTimezone = timezone; + } + if (row.pivotDropDayOfWeek !== undefined && row.pivotDropDayOfWeek !== null) { + const dayOfWeek = Number(row.pivotDropDayOfWeek); + if (Number.isFinite(dayOfWeek) && dayOfWeek >= 0 && dayOfWeek <= 6) { + target.pivotDropDayOfWeek = dayOfWeek; + } + } + if (row.pivotDropHour !== undefined && row.pivotDropHour !== null) { + const hour = Number(row.pivotDropHour); + if (Number.isFinite(hour) && hour >= 0 && hour <= 23) { + target.pivotDropHour = hour; + } + } + if (row.pivotDropMinute !== undefined && row.pivotDropMinute !== null) { + const minute = Number(row.pivotDropMinute); + if (Number.isFinite(minute) && minute >= 0 && minute <= 59) { + target.pivotDropMinute = minute; + } + } + const overrides = normalizePivotDropOverrides(row.pivotDropOverrides); + if (overrides) { + target.pivotDropOverrides = overrides; + } + return target; +} + +const DEFAULT_TENANTS = [ + { + tenantKey: 'rpi', + name: 'Rensselaer Polytechnic Institute', + subdomain: 'rpi', + location: 'Troy, NY', + status: 'active', + statusMessage: '', + tenantType: 'campus', + pivotPilot: false, + }, + { + tenantKey: 'tvcog', + name: 'Center of Gravity', + subdomain: 'tvcog', + location: 'Troy, NY', + status: 'active', + statusMessage: '', + tenantType: 'campus', + pivotPilot: false, + }, +]; + +function normalizeTenantRow(row = {}) { + const tenantKey = String(row?.tenantKey || '').trim().toLowerCase(); + if (!tenantKey || tenantKey === 'www') return null; + + const status = TENANT_STATUSES.has(row?.status) ? row.status : 'active'; + const tenantType = TENANT_TYPES.has(row?.tenantType) ? row.tenantType : 'campus'; + + return { + tenantKey, + name: String(row?.name || tenantKey).trim(), + subdomain: String(row?.subdomain || tenantKey).trim().toLowerCase(), + location: String(row?.location || '').trim(), + status, + statusMessage: String(row?.statusMessage || '').trim().slice(0, 240), + tenantType, + pivotPilot: row?.pivotPilot === true || tenantType === 'pivot', + mongoUri: String(row?.mongoUri || '').trim() || undefined, + mongoDatabaseName: String(row?.mongoDatabaseName || '').trim() || undefined, + pivotCatalogOrgId: String(row?.pivotCatalogOrgId || '').trim() || undefined, + provisioningConfirmations: { + dns: row?.provisioningConfirmations?.dns === true, + cors: row?.provisioningConfirmations?.cors === true, + pickerVerified: row?.provisioningConfirmations?.pickerVerified === true, + }, + }; + + return normalizePivotDropFields(row, normalized); +} + +function normalizeTenantRows(rows = []) { + return rows.map(normalizeTenantRow).filter(Boolean); +} + +/** Persisted overrides may be sparse (only changed fields). Do not fill missing keys. */ +function normalizeTenantOverride(row = {}) { + const tenantKey = String(row?.tenantKey || '').trim().toLowerCase(); + if (!tenantKey || tenantKey === 'www') return null; + + const out = { tenantKey }; + + if (row.name !== undefined && row.name !== null) { + out.name = String(row.name).trim(); + } + if (row.subdomain !== undefined && row.subdomain !== null) { + out.subdomain = String(row.subdomain).trim().toLowerCase(); + } + if (row.location !== undefined && row.location !== null) { + out.location = String(row.location).trim(); + } + if (row.status !== undefined && TENANT_STATUSES.has(row.status)) { + out.status = row.status; + } + if (row.statusMessage !== undefined && row.statusMessage !== null) { + out.statusMessage = String(row.statusMessage).trim().slice(0, 240); + } + if (row.tenantType !== undefined && TENANT_TYPES.has(row.tenantType)) { + out.tenantType = row.tenantType; + } + if (row.pivotPilot !== undefined) { + out.pivotPilot = row.pivotPilot === true; + } + if (row.mongoUri !== undefined && row.mongoUri !== null) { + const uri = String(row.mongoUri).trim(); + if (uri) out.mongoUri = uri; + } + if (row.mongoDatabaseName !== undefined && row.mongoDatabaseName !== null) { + const dbName = String(row.mongoDatabaseName).trim(); + if (dbName) out.mongoDatabaseName = dbName.toLowerCase(); + } + if (row.pivotCatalogOrgId !== undefined && row.pivotCatalogOrgId !== null) { + const orgId = String(row.pivotCatalogOrgId).trim(); + if (orgId) out.pivotCatalogOrgId = orgId; + } + if (row.provisioningConfirmations && typeof row.provisioningConfirmations === 'object') { + const pc = {}; + if (row.provisioningConfirmations.dns !== undefined) { + pc.dns = row.provisioningConfirmations.dns === true; + } + if (row.provisioningConfirmations.cors !== undefined) { + pc.cors = row.provisioningConfirmations.cors === true; + } + if (row.provisioningConfirmations.pickerVerified !== undefined) { + pc.pickerVerified = row.provisioningConfirmations.pickerVerified === true; + } + if (Object.keys(pc).length > 0) { + out.provisioningConfirmations = pc; + } + } + + normalizePivotDropFields(row, out); + + return Object.keys(out).length > 1 ? out : null; +} + +function normalizeTenantOverrides(rows = []) { + return rows.map(normalizeTenantOverride).filter(Boolean); +} + +function mergeSparseTenantOverrides(existing = {}, delta = {}) { + const merged = { ...existing, ...delta, tenantKey: delta.tenantKey || existing.tenantKey }; + if (existing.provisioningConfirmations || delta.provisioningConfirmations) { + merged.provisioningConfirmations = { + ...(existing.provisioningConfirmations || {}), + ...(delta.provisioningConfirmations || {}), + }; + } + return merged; +} + +function mergeTenantRows(baseRows = [], overrideRows = []) { + const merged = new Map(); + normalizeTenantRows(baseRows).forEach((row) => merged.set(row.tenantKey, row)); + normalizeTenantOverrides(overrideRows).forEach((row) => { + const base = merged.get(row.tenantKey) || {}; + const { provisioningConfirmations: pcPatch, ...rest } = row; + const next = { ...base, ...rest }; + if (pcPatch) { + next.provisioningConfirmations = { + dns: false, + cors: false, + pickerVerified: false, + ...(base.provisioningConfirmations || {}), + ...pcPatch, + }; + } + merged.set(row.tenantKey, next); + }); + return Array.from(merged.values()); +} + +module.exports = { + TENANT_STATUSES, + TENANT_TYPES, + DEFAULT_TENANTS, + normalizePivotDropOverrides, + normalizePivotDropFields, + normalizeTenantRow, + normalizeTenantRows, + normalizeTenantOverride, + normalizeTenantOverrides, + mergeSparseTenantOverrides, + mergeTenantRows, +}; diff --git a/backend/constants/pivotPilotReferralCodes.js b/backend/constants/pivotPilotReferralCodes.js new file mode 100644 index 00000000..1c81bcca --- /dev/null +++ b/backend/constants/pivotPilotReferralCodes.js @@ -0,0 +1,73 @@ +const { toIsoWeek } = require('../utilities/pivotIsoWeek'); + +const PILOT_TENANT_KEY = 'nyc'; + +/** Legacy Brooklyn pilot codes removed when re-seeding for NYC. */ +const LEGACY_PILOT_CODE_PREFIXES = ['BK-PILOT']; + +/** + * Seed rows for Pivot pilot referral codes (Task 0.3). + * Includes active cohort codes plus inactive/expired rows for validation testing. + */ +function getPivotPilotReferralSeedRows() { + const currentBatchWeek = toIsoWeek(); + + return [ + { + code: 'NYC-PILOT-A', + tenantKey: PILOT_TENANT_KEY, + cohortId: 'pilot-a', + maxRedemptions: 50, + redemptionCount: 0, + active: true, + batchWeek: currentBatchWeek, + expiresAt: null, + }, + { + code: 'NYC-PILOT-B', + tenantKey: PILOT_TENANT_KEY, + cohortId: 'pilot-b', + maxRedemptions: 50, + redemptionCount: 0, + active: true, + batchWeek: currentBatchWeek, + expiresAt: null, + }, + { + code: 'NYC-PILOT-C', + tenantKey: PILOT_TENANT_KEY, + cohortId: 'pilot-c', + maxRedemptions: 25, + redemptionCount: 0, + active: true, + batchWeek: null, + expiresAt: null, + }, + { + code: 'NYC-PILOT-INACTIVE', + tenantKey: PILOT_TENANT_KEY, + cohortId: 'pilot-inactive-test', + maxRedemptions: 50, + redemptionCount: 0, + active: false, + batchWeek: currentBatchWeek, + expiresAt: null, + }, + { + code: 'NYC-PILOT-EXPIRED', + tenantKey: PILOT_TENANT_KEY, + cohortId: 'pilot-expired-test', + maxRedemptions: 50, + redemptionCount: 0, + active: true, + batchWeek: currentBatchWeek, + expiresAt: new Date('2020-01-01T00:00:00.000Z'), + }, + ]; +} + +module.exports = { + PILOT_TENANT_KEY, + LEGACY_PILOT_CODE_PREFIXES, + getPivotPilotReferralSeedRows, +}; diff --git a/backend/constants/pivotTagCatalogSeed.js b/backend/constants/pivotTagCatalogSeed.js new file mode 100644 index 00000000..519ceecd --- /dev/null +++ b/backend/constants/pivotTagCatalogSeed.js @@ -0,0 +1,30 @@ +/** + * Canonical Pivot tag catalog seed rows (Task 8.1). + * Slugs are lowercase kebab-case; shared by Lab, mobile onboarding, and ranker. + */ +function getPivotTagCatalogSeedRows() { + return [ + { slug: 'live-music', label: 'live music', sortOrder: 10, active: true }, + { slug: 'board-games', label: 'board games', sortOrder: 20, active: true }, + { slug: 'food-and-drink', label: 'food & drink', sortOrder: 30, active: true }, + { slug: 'outdoors', label: 'outdoors', sortOrder: 40, active: true }, + { slug: 'art-and-culture', label: 'art & culture', sortOrder: 50, active: true }, + { slug: 'nightlife', label: 'nightlife', sortOrder: 60, active: true }, + { slug: 'fitness', label: 'fitness', sortOrder: 70, active: true }, + { slug: 'tech', label: 'tech', sortOrder: 80, active: true }, + { slug: 'comedy', label: 'comedy', sortOrder: 90, active: true }, + { slug: 'film-and-tv', label: 'film & TV', sortOrder: 100, active: true }, + { slug: 'wellness', label: 'wellness', sortOrder: 110, active: true }, + { slug: 'gaming', label: 'gaming', sortOrder: 120, active: true }, + { slug: 'dance', label: 'dance', sortOrder: 130, active: true }, + { slug: 'volunteering', label: 'volunteering', sortOrder: 140, active: true }, + { slug: 'markets-and-fairs', label: 'markets & fairs', sortOrder: 150, active: true }, + { slug: 'workshops', label: 'workshops', sortOrder: 160, active: true }, + { slug: 'family-friendly', label: 'family friendly', sortOrder: 170, active: true }, + { slug: 'social', label: 'social', sortOrder: 180, active: true }, + ]; +} + +module.exports = { + getPivotTagCatalogSeedRows, +}; diff --git a/backend/middlewares/pivotReferralValidateRateLimit.js b/backend/middlewares/pivotReferralValidateRateLimit.js new file mode 100644 index 00000000..f78793c8 --- /dev/null +++ b/backend/middlewares/pivotReferralValidateRateLimit.js @@ -0,0 +1,34 @@ +/** In-memory rate limit for unauthenticated referral validation (20 req/min/IP). */ +const WINDOW_MS = 60 * 1000; +const MAX_REQUESTS_PER_WINDOW = 20; + +const buckets = new Map(); + +function pivotReferralValidateRateLimit(req, res, next) { + const ip = req.ip || req.socket?.remoteAddress || 'unknown'; + const now = Date.now(); + let bucket = buckets.get(ip); + + if (!bucket || now - bucket.windowStart >= WINDOW_MS) { + bucket = { windowStart: now, count: 0 }; + buckets.set(ip, bucket); + } + + bucket.count += 1; + + if (bucket.count > MAX_REQUESTS_PER_WINDOW) { + return res.status(429).json({ + success: false, + message: 'Too many validation attempts. Please try again in a minute.', + code: 'REFERRAL_VALIDATE_RATE_LIMIT', + }); + } + + return next(); +} + +module.exports = { + pivotReferralValidateRateLimit, + WINDOW_MS, + MAX_REQUESTS_PER_WINDOW, +}; diff --git a/backend/middlewares/requirePlatformAdmin.js b/backend/middlewares/requirePlatformAdmin.js new file mode 100644 index 00000000..ffdbf7ed --- /dev/null +++ b/backend/middlewares/requirePlatformAdmin.js @@ -0,0 +1,29 @@ +const getGlobalModels = require('../services/getGlobalModelService'); + +/** + * Require platform_admin or root. Use after verifyToken. + */ +async function requirePlatformAdmin(req, res, next) { + if (!req.user) { + return res.status(401).json({ success: false, message: 'Authentication required' }); + } + + const tokenRoles = req.user.platformRoles || []; + let allowed = tokenRoles.includes('platform_admin') || tokenRoles.includes('root'); + + if (!allowed && req.user.globalUserId) { + const { PlatformRole } = getGlobalModels(req, 'PlatformRole'); + const pr = await PlatformRole.findOne({ globalUserId: req.user.globalUserId }).lean(); + if (pr?.roles?.includes('platform_admin') || pr?.roles?.includes('root')) { + allowed = true; + } + } + + if (!allowed) { + return res.status(403).json({ success: false, message: 'Platform admin required.' }); + } + + return next(); +} + +module.exports = { requirePlatformAdmin }; diff --git a/backend/middlewares/tenantStatusGate.js b/backend/middlewares/tenantStatusGate.js new file mode 100644 index 00000000..28d7fd83 --- /dev/null +++ b/backend/middlewares/tenantStatusGate.js @@ -0,0 +1,111 @@ +const jwt = require('jsonwebtoken'); +const getGlobalModels = require('../services/getGlobalModelService'); + +function getModels(req, ...names) { + return require('../services/getModelService')(req, ...names); +} +const authGlobalService = require('../services/authGlobalService'); +const { isAdminLevelAccount } = require('../services/adminMfaService'); + +/** Paths reachable while a tenant is coming_soon / maintenance (login + status page + assets). */ +const PUBLIC_DURING_TENANT_OUTAGE_PREFIXES = [ + '/login', + '/signup', + '/register', + '/validate-token', + '/refresh-token', + '/tenant-status', + '/auth/', + '/google-login', + '/apple-login', + '/forgot-password', + '/verify-code', + '/reset-password', + '/mfa/', + '/health', + '/api/tenant-config', + '/pivot', + '/static', + '/assets', + '/favicon', + '/manifest.json', + '/proxy-image', +]; + +function normalizePath(req) { + const raw = (req.path || req.url || '').split('?')[0]; + return raw && raw.trim() !== '' ? raw : '/'; +} + +function isPublicPathDuringTenantOutage(path) { + return PUBLIC_DURING_TENANT_OUTAGE_PREFIXES.some( + (prefix) => path === prefix || path.startsWith(`${prefix}/`) + ); +} + +function readAccessToken(req) { + return ( + req.cookies?.accessToken || + (req.headers.authorization && req.headers.authorization.split(' ')[1]) + ); +} + +/** + * True when the caller is a platform or tenant admin (used to bypass coming_soon / maintenance gates). + */ +async function isAdminLevelRequest(req) { + const token = readAccessToken(req); + if (!token || !process.env.JWT_SECRET) return false; + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + const platformRoles = Array.isArray(decoded.platformRoles) ? decoded.platformRoles : []; + + if (platformRoles.includes('platform_admin') || platformRoles.includes('root')) { + return true; + } + + if (decoded.globalUserId && req.globalDb) { + const { PlatformRole } = getGlobalModels(req, 'PlatformRole'); + const pr = await PlatformRole.findOne({ globalUserId: decoded.globalUserId }).lean(); + if (pr?.roles?.includes('platform_admin') || pr?.roles?.includes('root')) { + return true; + } + } + + let tenantUser = null; + if (decoded.globalUserId && req.db) { + const { tenantUser: resolved } = await authGlobalService.resolveTenantUserForRequest( + req, + decoded.globalUserId + ); + tenantUser = resolved; + } else if (decoded.userId && req.db) { + const { User } = getModels(req, 'User'); + tenantUser = await User.findById(decoded.userId).lean(); + } + + return isAdminLevelAccount(tenantUser, platformRoles); + } catch (_) { + return false; + } +} + +/** + * @param {string} status - tenant lifecycle status + * @returns {boolean} skip coming_soon / maintenance enforcement + */ +async function shouldBypassTenantStatusGate(req, status) { + if (!status || status === 'active' || status === 'hidden') return true; + + const path = normalizePath(req); + if (isPublicPathDuringTenantOutage(path)) return true; + + return isAdminLevelRequest(req); +} + +module.exports = { + shouldBypassTenantStatusGate, + isAdminLevelRequest, + isPublicPathDuringTenantOutage, +}; diff --git a/backend/middlewares/verifyToken.js b/backend/middlewares/verifyToken.js index 1920e284..0fd71048 100644 --- a/backend/middlewares/verifyToken.js +++ b/backend/middlewares/verifyToken.js @@ -1,6 +1,9 @@ const jwt = require('jsonwebtoken'); -const getModels = require('../services/getModelService'); const authGlobalService = require('../services/authGlobalService'); + +function getModels(req, ...names) { + return require('../services/getModelService')(req, ...names); +} const { validateSession } = require('../utilities/sessionUtils'); const { getCookieDomain } = require('../utilities/cookieUtils'); diff --git a/backend/migrations/ensureBackendNodeModules.js b/backend/migrations/ensureBackendNodeModules.js new file mode 100644 index 00000000..bf1ab323 --- /dev/null +++ b/backend/migrations/ensureBackendNodeModules.js @@ -0,0 +1,20 @@ +/** + * Events-Backend schemas are symlinked under backend/events. When Node loads + * them, bare imports (e.g. mongoose) resolve from Events-Backend/, not + * backend/node_modules. Prepend backend/node_modules via NODE_PATH before + * requiring getModelService or ../events/schemas/*. + */ +const path = require('path'); +const Module = require('module'); + +const backendNodeModules = path.resolve(__dirname, '..', 'node_modules'); +const existing = process.env.NODE_PATH + ? process.env.NODE_PATH.split(path.delimiter) + : []; + +if (!existing.includes(backendNodeModules)) { + process.env.NODE_PATH = [backendNodeModules, ...existing] + .filter(Boolean) + .join(path.delimiter); + Module._initPaths(); +} diff --git a/backend/migrations/rebuildPivotWeeklySnapshot.js b/backend/migrations/rebuildPivotWeeklySnapshot.js new file mode 100644 index 00000000..300f99a4 --- /dev/null +++ b/backend/migrations/rebuildPivotWeeklySnapshot.js @@ -0,0 +1,52 @@ +/** + * Rebuild PivotWeeklySnapshot for a batch week (global DB). + * + * Usage: + * npm run rebuild:pivot-weekly-snapshot + * npm run rebuild:pivot-weekly-snapshot -- --batchWeek=2026-W21 + */ +const { connectToGlobalDatabase } = require('../connectionsManager'); +const pivotWeeklySnapshotSchema = require('../schemas/pivotWeeklySnapshot'); +const { rebuildWeeklySnapshot } = require('../services/pivotWeeklySnapshotService'); +const { toIsoWeek } = require('../utilities/pivotIsoWeek'); + +const COLLECTION = 'pivot_weekly_snapshots'; + +function readBatchWeekArg() { + const flag = process.argv.find((arg) => arg.startsWith('--batchWeek=')); + if (flag) { + return flag.split('=')[1]; + } + return process.env.PIVOT_BATCH_WEEK || null; +} + +async function run() { + const batchWeek = readBatchWeekArg() || toIsoWeek(); + const globalDb = await connectToGlobalDatabase(); + globalDb.model('PivotWeeklySnapshot', pivotWeeklySnapshotSchema, COLLECTION); + + const req = { globalDb }; + const result = await rebuildWeeklySnapshot(req, { batchWeek }); + + if (result.error) { + throw new Error(result.error); + } + + console.log( + `[rebuild:pivot-weekly-snapshot] batchWeek=${result.data.batchWeek} generatedAt=${result.data.generatedAt} tenants=${result.data.tenants.length}`, + ); + for (const row of result.data.tenants) { + console.log( + ` ${row.tenantKey}: events=${row.eventCount} interested=${row.interestedCount} registered=${row.registeredCount} swipes=${row.swipeCount} activeUsers=${row.activeUsers}`, + ); + } +} + +run() + .catch((error) => { + console.error('[rebuild:pivot-weekly-snapshot] failed', error); + process.exitCode = 1; + }) + .finally(() => { + setTimeout(() => process.exit(process.exitCode || 0), 100); + }); diff --git a/backend/migrations/seedPivotFeedEvents.js b/backend/migrations/seedPivotFeedEvents.js new file mode 100644 index 00000000..a6d0f18d --- /dev/null +++ b/backend/migrations/seedPivotFeedEvents.js @@ -0,0 +1,522 @@ +#!/usr/bin/env node +/** + * Seed published Pivot catalog events into the NYC tenant DB for feed testing. + * + * Usage (from Meridian/backend): + * npm run seed:pivot-feed-events + * + * Requires MONGO_URI / tenant routing for `nyc` (same as local pivot pilot). + */ +require('./ensureBackendNodeModules'); +require('dotenv').config(); + +const mongoose = require('mongoose'); +const { connectToDatabase } = require('../connectionsManager'); +const getModels = require('../services/getModelService'); +const { PILOT_TENANT_KEY } = require('../constants/pivotPilotReferralCodes'); +const { toIsoWeek } = require('../utilities/pivotIsoWeek'); + +const DEMO_EVENTS = [ + { + slug: 'pivot-seed-board-games', + name: 'Friday Night Board Games', + description: 'Open tables, BYOB. All skill levels welcome.', + location: '123 Atlantic Ave, Brooklyn, NY', + dayOffset: 1, + startHour: 19, + durationHours: 4, + externalLink: 'https://partiful.com/e/pivot-seed-board-games', + host: { name: 'Brooklyn Board Game Cafe', profileUrl: 'https://partiful.com/u/bkbgcafe' }, + tags: ['board-games', 'social'], + registrationCount: 18, + }, + { + slug: 'pivot-seed-sunset-listening', + name: 'Sunset Listening Party', + description: 'Roof records spins until late.', + location: 'Williamsburg, Brooklyn, NY', + dayOffset: 1, + startHour: 20, + durationHours: 3, + externalLink: 'https://partiful.com/e/pivot-seed-sunset-listening', + host: { name: 'Roof Records' }, + tags: ['live-music'], + registrationCount: 24, + }, + { + slug: 'pivot-seed-open-mic', + name: 'Late Open Mic', + description: 'Sign up at the door. Five-minute slots.', + location: 'Lower East Side, New York, NY', + dayOffset: 2, + startHour: 21, + durationHours: 2, + externalLink: 'https://partiful.com/e/pivot-seed-open-mic', + host: { name: 'Canal Street Poetry Club' }, + tags: ['live-music', 'social'], + registrationCount: 9, + }, + { + slug: 'pivot-seed-rooftop-yoga', + name: 'Rooftop Yoga at Dusk', + description: 'All levels. Mats provided, arrive 10 min early.', + location: 'Long Island City, Queens, NY', + dayOffset: 2, + startHour: 18, + durationHours: 1.5, + externalLink: 'https://partiful.com/e/pivot-seed-rooftop-yoga', + host: { name: 'Skyline Studio LIC' }, + tags: ['fitness', 'wellness'], + registrationCount: 32, + }, + { + slug: 'pivot-seed-comedy-show', + name: 'Underground Comedy Night', + description: 'Three headliners, one mic. 21+.', + location: 'East Village, New York, NY', + dayOffset: 2, + startHour: 20, + durationHours: 2.5, + externalLink: 'https://partiful.com/e/pivot-seed-comedy-show', + host: { name: 'Basement Laughs NYC' }, + tags: ['comedy', 'nightlife'], + registrationCount: 41, + }, + { + slug: 'pivot-seed-pottery-workshop', + name: 'Hand-Building Pottery Workshop', + description: 'Make a mug. Glazing included, pickup next week.', + location: 'Gowanus, Brooklyn, NY', + dayOffset: 3, + startHour: 14, + durationHours: 3, + externalLink: 'https://partiful.com/e/pivot-seed-pottery-workshop', + host: { name: 'Clay & Co Gowanus' }, + tags: ['art-and-culture', 'workshops'], + registrationCount: 12, + }, + { + slug: 'pivot-seed-wine-tasting', + name: 'Natural Wine Tasting', + description: 'Six pours, small bites, chill vibes only.', + location: 'West Village, New York, NY', + dayOffset: 3, + startHour: 19, + durationHours: 2, + externalLink: 'https://partiful.com/e/pivot-seed-wine-tasting', + host: { name: 'Orange Peel Wine Bar' }, + tags: ['food-and-drink', 'social'], + registrationCount: 28, + }, + { + slug: 'pivot-seed-vinyl-market', + name: 'Brooklyn Vinyl Market', + description: '40+ vendors, live DJ sets all afternoon.', + location: 'Industry City, Brooklyn, NY', + dayOffset: 3, + startHour: 12, + durationHours: 6, + externalLink: 'https://partiful.com/e/pivot-seed-vinyl-market', + host: { name: 'Crate Diggers Collective' }, + tags: ['live-music', 'markets-and-fairs'], + registrationCount: 156, + }, + { + slug: 'pivot-seed-run-club', + name: 'Prospect Park Run Club', + description: '5K loop, coffee after. Pace groups at the start.', + location: 'Prospect Park, Brooklyn, NY', + dayOffset: 4, + startHour: 8, + durationHours: 1.5, + externalLink: 'https://partiful.com/e/pivot-seed-run-club', + host: { name: 'Parkside Runners' }, + tags: ['fitness', 'social'], + registrationCount: 67, + }, + { + slug: 'pivot-seed-photo-walk', + name: 'Golden Hour Photo Walk', + description: 'Bring any camera. We end at a rooftop bar.', + location: 'DUMBO, Brooklyn, NY', + dayOffset: 4, + startHour: 17, + durationHours: 2, + externalLink: 'https://partiful.com/e/pivot-seed-photo-walk', + host: { name: 'NYC Shutter Club' }, + tags: ['art-and-culture', 'social'], + registrationCount: 22, + }, + { + slug: 'pivot-seed-trivia-night', + name: 'Pub Trivia — Pop Culture Edition', + description: 'Teams of 4 max. Prizes for top three.', + location: 'Astoria, Queens, NY', + dayOffset: 4, + startHour: 20, + durationHours: 2, + externalLink: 'https://partiful.com/e/pivot-seed-trivia-night', + host: { name: 'The Astoria Taproom' }, + tags: ['social', 'board-games'], + registrationCount: 38, + }, + { + slug: 'pivot-seed-jazz-brunch', + name: 'Live Jazz Brunch', + description: 'Reservations recommended. Full menu until 3pm.', + location: 'Harlem, New York, NY', + dayOffset: 5, + startHour: 11, + durationHours: 3, + externalLink: 'https://partiful.com/e/pivot-seed-jazz-brunch', + host: { name: 'Lenox Room' }, + tags: ['live-music', 'food-and-drink'], + registrationCount: 54, + }, + { + slug: 'pivot-seed-makers-fair', + name: 'Queens Makers Fair', + description: 'Local artists, zines, and screen printing demos.', + location: 'Sunnyside, Queens, NY', + dayOffset: 5, + startHour: 13, + durationHours: 5, + externalLink: 'https://partiful.com/e/pivot-seed-makers-fair', + host: { name: 'Sunnyside Creative Guild' }, + tags: ['art-and-culture', 'markets-and-fairs'], + registrationCount: 89, + }, + { + slug: 'pivot-seed-dance-class', + name: 'Beginner Salsa Class', + description: 'No partner needed. Sneakers fine for hour one.', + location: 'Washington Heights, New York, NY', + dayOffset: 5, + startHour: 19, + durationHours: 1.5, + externalLink: 'https://partiful.com/e/pivot-seed-dance-class', + host: { name: 'Uptown Dance Loft' }, + tags: ['dance', 'fitness'], + registrationCount: 19, + }, + { + slug: 'pivot-seed-book-club', + name: 'Monthly Book Club — Sci-Fi', + description: 'This month: a short novel. Spoilers welcome after hour one.', + location: 'Fort Greene, Brooklyn, NY', + dayOffset: 6, + startHour: 18, + durationHours: 2, + externalLink: 'https://partiful.com/e/pivot-seed-book-club', + host: { name: 'Greenlight Bookstore Events' }, + tags: ['social', 'art-and-culture'], + registrationCount: 14, + }, + { + slug: 'pivot-seed-food-trucks', + name: 'Night Market — Food Trucks', + description: 'Twelve trucks, live acoustic set, cash and card.', + location: 'Flushing Meadows, Queens, NY', + dayOffset: 6, + startHour: 17, + durationHours: 4, + externalLink: 'https://partiful.com/e/pivot-seed-food-trucks', + host: { name: 'Queens Night Eats' }, + tags: ['food-and-drink', 'markets-and-fairs'], + registrationCount: 203, + }, + { + slug: 'pivot-seed-film-screening', + name: 'Indie Film Screening + Q&A', + description: 'Director in attendance. Limited seating.', + location: 'Bushwick, Brooklyn, NY', + dayOffset: 6, + startHour: 20, + durationHours: 2.5, + externalLink: 'https://partiful.com/e/pivot-seed-film-screening', + host: { name: 'Bushwick Cinema Lab' }, + tags: ['film-and-tv', 'art-and-culture'], + registrationCount: 45, + }, + { + slug: 'pivot-seed-coffee-cupping', + name: 'Coffee Cupping Session', + description: 'Taste four single origins. Take-home bag for attendees.', + location: 'Red Hook, Brooklyn, NY', + dayOffset: 7, + startHour: 10, + durationHours: 1.5, + externalLink: 'https://partiful.com/e/pivot-seed-coffee-cupping', + host: { name: 'Red Hook Roasters' }, + tags: ['food-and-drink', 'workshops'], + registrationCount: 16, + }, + { + slug: 'pivot-seed-climbing-social', + name: 'Bouldering Social Night', + description: 'Day pass included. First-timers get a quick intro.', + location: 'Greenpoint, Brooklyn, NY', + dayOffset: 7, + startHour: 18, + durationHours: 3, + externalLink: 'https://partiful.com/e/pivot-seed-climbing-social', + host: { name: 'Greenpoint Cliffs' }, + tags: ['fitness', 'social'], + registrationCount: 31, + }, + { + slug: 'pivot-seed-karaoke', + name: 'Private Room Karaoke', + description: 'Groups of 6 rotate rooms. Soft drinks on the house.', + location: 'Koreatown, New York, NY', + dayOffset: 7, + startHour: 21, + durationHours: 3, + externalLink: 'https://partiful.com/e/pivot-seed-karaoke', + host: { name: 'Midtown Mic Lounge' }, + tags: ['live-music', 'nightlife'], + registrationCount: 52, + }, + { + slug: 'pivot-seed-plant-swap', + name: 'Plant Swap & Repotting', + description: 'Bring a cutting, leave with something new. Soil provided.', + location: 'Park Slope, Brooklyn, NY', + dayOffset: 1, + startHour: 11, + durationHours: 2, + externalLink: 'https://partiful.com/e/pivot-seed-plant-swap', + host: { name: 'Slope Succulents' }, + tags: ['social', 'wellness'], + registrationCount: 27, + }, + { + slug: 'pivot-seed-chess-park', + name: 'Washington Square Chess Meetup', + description: 'Blitz and long games. Boards on the south lawn.', + location: 'Washington Square Park, New York, NY', + dayOffset: 2, + startHour: 15, + durationHours: 3, + externalLink: 'https://partiful.com/e/pivot-seed-chess-park', + host: { name: 'Village Chess Club' }, + tags: ['gaming', 'social'], + registrationCount: 11, + }, + { + slug: 'pivot-seed-ramen-pop-up', + name: 'Late-Night Ramen Pop-Up', + description: 'Limited bowls until sold out. Cash bar.', + location: 'Chinatown, New York, NY', + dayOffset: 3, + startHour: 22, + durationHours: 2, + externalLink: 'https://partiful.com/e/pivot-seed-ramen-pop-up', + host: { name: 'Midnight Noodle Lab' }, + tags: ['food-and-drink', 'nightlife'], + registrationCount: 73, + }, + { + slug: 'pivot-seed-museum-late', + name: 'Museum Late Hours — Modern Wing', + description: 'After-hours access, guided highlights tour at 7:30.', + location: 'Upper East Side, New York, NY', + dayOffset: 4, + startHour: 18, + durationHours: 3, + externalLink: 'https://partiful.com/e/pivot-seed-museum-late', + host: { name: 'City Modern Museum' }, + tags: ['art-and-culture'], + registrationCount: 98, + }, + { + slug: 'pivot-seed-coding-meetup', + name: 'Creative Coding Meetup', + description: 'Lightning talks + open project share. Laptops encouraged.', + location: 'Flatiron, New York, NY', + dayOffset: 5, + startHour: 18, + durationHours: 2.5, + externalLink: 'https://partiful.com/e/pivot-seed-coding-meetup', + host: { name: 'NYC Creative Devs' }, + tags: ['tech', 'social'], + registrationCount: 44, + }, + { + slug: 'pivot-seed-salsa-rooftop', + name: 'Rooftop Salsa Social', + description: 'Lesson at 8, open dance floor after. No experience needed.', + location: 'Midtown West, New York, NY', + dayOffset: 6, + startHour: 20, + durationHours: 3, + externalLink: 'https://partiful.com/e/pivot-seed-salsa-rooftop', + host: { name: 'Skyline Social Club' }, + tags: ['dance', 'nightlife'], + registrationCount: 61, + }, + { + slug: 'pivot-seed-volleyball-beach', + name: 'Sunset Beach Volleyball', + description: 'Pick-up games, teams formed on site. Sand socks optional.', + location: 'Rockaway Beach, Queens, NY', + dayOffset: 7, + startHour: 17, + durationHours: 2, + externalLink: 'https://partiful.com/e/pivot-seed-volleyball-beach', + host: { name: 'Rockaway Rec League' }, + tags: ['fitness', 'outdoors'], + registrationCount: 36, + }, + { + slug: 'pivot-seed-poetry-slam', + name: 'Poetry Slam Finals', + description: 'Audience judges round two. Sign-up list closes at 7:45.', + location: 'Bed-Stuy, Brooklyn, NY', + dayOffset: 1, + startHour: 20, + durationHours: 2, + externalLink: 'https://partiful.com/e/pivot-seed-poetry-slam', + host: { name: 'Bed-Stuy Word House' }, + tags: ['live-music', 'art-and-culture'], + registrationCount: 33, + }, + { + slug: 'pivot-seed-flea-market', + name: 'Brooklyn Flea — Williamsburg', + description: 'Vintage, food stalls, and live brass band.', + location: 'Williamsburg, Brooklyn, NY', + dayOffset: 2, + startHour: 10, + durationHours: 5, + externalLink: 'https://partiful.com/e/pivot-seed-flea-market', + host: { name: 'Brooklyn Flea Co.' }, + tags: ['markets-and-fairs', 'food-and-drink'], + registrationCount: 412, + }, + { + slug: 'pivot-seed-meditation', + name: 'Sound Bath & Meditation', + description: 'Mats and blankets provided. Silence phones on arrival.', + location: 'Cobble Hill, Brooklyn, NY', + dayOffset: 3, + startHour: 19, + durationHours: 1, + externalLink: 'https://partiful.com/e/pivot-seed-meditation', + host: { name: 'Still Room Brooklyn' }, + tags: ['wellness'], + registrationCount: 21, + }, +]; + +async function resolveCatalogOrgId(Org) { + const existing = await Org.findOne({ + org_name: /pivot catalog/i, + }) + .select('_id org_name') + .lean(); + + if (existing?._id) { + return existing._id; + } + + const created = await Org.create({ + org_name: 'Pivot Catalog — NYC', + org_description: 'Internal technical host for Pivot catalog imports (not shown in Pivot UI).', + visibility: 'private', + }); + + return created._id; +} + +function buildEventWindow(dayOffset, durationHours, now, startHour = 19) { + const start = new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() + dayOffset, + startHour, + 0, + 0, + ), + ); + const end = new Date(start.getTime() + durationHours * 60 * 60 * 1000); + return { start, end }; +} + +async function run() { + const now = new Date(); + const batchWeek = toIsoWeek(now); + const db = await connectToDatabase(PILOT_TENANT_KEY); + const req = { db, school: PILOT_TENANT_KEY }; + const { Event, Org } = getModels(req, 'Event', 'Org'); + const catalogOrgId = await resolveCatalogOrgId(Org); + + let upserted = 0; + for (const demo of DEMO_EVENTS) { + const { start, end } = buildEventWindow( + demo.dayOffset, + demo.durationHours, + now, + demo.startHour ?? 19, + ); + + await Event.findOneAndUpdate( + { 'customFields.pivot.sourceUrl': demo.externalLink }, + { + $set: { + name: demo.name, + description: demo.description, + type: 'social', + location: demo.location, + start_time: start, + end_time: end, + status: 'not-applicable', + visibility: 'public', + registrationEnabled: true, + registrationCount: demo.registrationCount, + externalLink: demo.externalLink, + hostingType: 'Org', + hostingId: catalogOrgId, + isDeleted: false, + customFields: { + pivot: { + batchWeek, + source: 'manual', + sourceUrl: demo.externalLink, + host: demo.host, + tags: demo.tags, + ingestStatus: 'published', + importedAt: now.toISOString(), + importedBy: 'seed:pivot-feed-events', + }, + }, + }, + }, + { upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true }, + ); + upserted += 1; + } + + const publishedCount = await Event.countDocuments({ + 'customFields.pivot.batchWeek': batchWeek, + 'customFields.pivot.ingestStatus': 'published', + isDeleted: { $ne: true }, + }); + + console.log( + `[seedPivotFeedEvents] tenant=${PILOT_TENANT_KEY} batchWeek=${batchWeek} upserted=${upserted} published_in_batch=${publishedCount}`, + ); + console.log( + `[seedPivotFeedEvents] Verify: GET /pivot/feed?batchWeek=${batchWeek} with X-Tenant: ${PILOT_TENANT_KEY}`, + ); +} + +run() + .catch((error) => { + console.error('[seedPivotFeedEvents] failed', error); + process.exitCode = 1; + }) + .finally(async () => { + await mongoose.disconnect(); + }); diff --git a/backend/migrations/seedPivotFeedbackConfig.js b/backend/migrations/seedPivotFeedbackConfig.js new file mode 100644 index 00000000..d9460d54 --- /dev/null +++ b/backend/migrations/seedPivotFeedbackConfig.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * Ensure pivot_event FeedbackConfig exists in each pivot pilot tenant DB. + * + * Usage (from Meridian/backend): + * npm run seed:pivot-feedback-config + * + * Optional env: + * PIVOT_TENANT_KEYS=nyc,brooklyn (default: nyc) + */ +require('./ensureBackendNodeModules'); +require('dotenv').config(); + +const { connectToDatabase } = require('../connectionsManager'); +const getModels = require('../services/getModelService'); +const FeedbackService = require('../services/feedbackService'); +const { PILOT_TENANT_KEY } = require('../constants/pivotPilotReferralCodes'); + +const SYSTEM_USER_ID = '000000000000000000000001'; + +function tenantKeysFromEnv() { + const raw = process.env.PIVOT_TENANT_KEYS || PILOT_TENANT_KEY; + return raw + .split(',') + .map((key) => key.trim()) + .filter(Boolean); +} + +async function seedTenant(tenantKey) { + const db = await connectToDatabase(tenantKey); + const req = { db, school: tenantKey }; + + const { User } = getModels(req, 'User'); + const seedUser = + (await User.findOne().select('_id').lean()) || + { _id: SYSTEM_USER_ID }; + + const feedbackService = new FeedbackService(req); + const config = await feedbackService.ensurePivotEventFeedbackConfig(seedUser._id); + + console.log(`[seed:pivot-feedback-config] ${tenantKey}: pivot_event ${config.version} (${config._id})`); +} + +async function main() { + const tenants = tenantKeysFromEnv(); + for (const tenantKey of tenants) { + await seedTenant(tenantKey); + } + console.log('[seed:pivot-feedback-config] done'); +} + +main().catch((err) => { + console.error('[seed:pivot-feedback-config] failed:', err); + process.exit(1); +}); diff --git a/backend/migrations/seedPivotReferralCodes.js b/backend/migrations/seedPivotReferralCodes.js new file mode 100644 index 00000000..1f97e5ba --- /dev/null +++ b/backend/migrations/seedPivotReferralCodes.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/** + * Seed Pivot pilot referral codes into the global/platform DB (NYC tenant). + * + * Usage (from Meridian/backend): + * npm run seed:pivot-referral-codes + */ +require('dotenv').config(); + +const mongoose = require('mongoose'); +const { connectToGlobalDatabase } = require('../connectionsManager'); +const pivotReferralCodeSchema = require('../schemas/pivotReferralCode'); +const { + PILOT_TENANT_KEY, + LEGACY_PILOT_CODE_PREFIXES, + getPivotPilotReferralSeedRows, +} = require('../constants/pivotPilotReferralCodes'); + +const COLLECTION = 'pivot_referral_codes'; + +async function run() { + const rows = getPivotPilotReferralSeedRows(); + + const globalDb = await connectToGlobalDatabase(); + const PivotReferralCode = + globalDb.models.PivotReferralCode || + globalDb.model('PivotReferralCode', pivotReferralCodeSchema, COLLECTION); + + const legacyDelete = await PivotReferralCode.deleteMany({ + $or: LEGACY_PILOT_CODE_PREFIXES.map((prefix) => ({ + code: new RegExp(`^${prefix}`), + })), + }); + + let upserted = 0; + for (const row of rows) { + await PivotReferralCode.findOneAndUpdate( + { code: row.code }, + { $set: row }, + { upsert: true, new: true, runValidators: true } + ); + upserted += 1; + } + + const activeCount = await PivotReferralCode.countDocuments({ + tenantKey: PILOT_TENANT_KEY, + active: true, + $or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }], + }); + + console.log( + `[seedPivotReferralCodes] tenantKey=${PILOT_TENANT_KEY} upserted=${upserted} removed_legacy=${legacyDelete.deletedCount} active_redeemable=${activeCount}` + ); + console.log( + '[seedPivotReferralCodes] Active pilot codes: NYC-PILOT-A, NYC-PILOT-B, NYC-PILOT-C' + ); + console.log( + '[seedPivotReferralCodes] Test-only codes: NYC-PILOT-INACTIVE (active=false), NYC-PILOT-EXPIRED (expiresAt in past)' + ); +} + +run() + .catch((error) => { + console.error('[seedPivotReferralCodes] failed', error); + process.exitCode = 1; + }) + .finally(async () => { + await mongoose.disconnect(); + }); diff --git a/backend/migrations/seedPivotTagCatalog.js b/backend/migrations/seedPivotTagCatalog.js new file mode 100644 index 00000000..c53edc35 --- /dev/null +++ b/backend/migrations/seedPivotTagCatalog.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * Seed the global Pivot tag catalog (Task 8.1). + * + * Usage (from Meridian/backend): + * npm run seed:pivot-tag-catalog + */ +require('dotenv').config(); + +const mongoose = require('mongoose'); +const { connectToGlobalDatabase } = require('../connectionsManager'); +const pivotTagCatalogSchema = require('../schemas/pivotTagCatalog'); +const { getPivotTagCatalogSeedRows } = require('../constants/pivotTagCatalogSeed'); + +const COLLECTION = 'pivot_tag_catalog'; + +async function run() { + const rows = getPivotTagCatalogSeedRows(); + const seedSlugs = new Set(rows.map((row) => row.slug)); + + const globalDb = await connectToGlobalDatabase(); + const PivotTagCatalog = + globalDb.models.PivotTagCatalog || + globalDb.model('PivotTagCatalog', pivotTagCatalogSchema, COLLECTION); + + let upserted = 0; + for (const row of rows) { + await PivotTagCatalog.findOneAndUpdate( + { slug: row.slug }, + { $set: row }, + { upsert: true, new: true, runValidators: true } + ); + upserted += 1; + } + + const activeCount = await PivotTagCatalog.countDocuments({ active: true }); + const totalCount = await PivotTagCatalog.countDocuments({}); + const staleCount = await PivotTagCatalog.countDocuments({ + slug: { $nin: [...seedSlugs] }, + }); + + console.log( + `[seed:pivot-tag-catalog] upserted=${upserted} active=${activeCount} total=${totalCount} legacy_not_in_seed=${staleCount}` + ); + console.log( + '[seed:pivot-tag-catalog] Inactive catalog slugs remain valid on legacy events but are hidden from GET /pivot/tags' + ); +} + +run() + .catch((error) => { + console.error('[seed:pivot-tag-catalog] failed', error); + process.exitCode = 1; + }) + .finally(async () => { + await mongoose.disconnect(); + }); diff --git a/backend/migrations/sendPivotWeeklyPush.js b/backend/migrations/sendPivotWeeklyPush.js new file mode 100644 index 00000000..8284eba9 --- /dev/null +++ b/backend/migrations/sendPivotWeeklyPush.js @@ -0,0 +1,235 @@ +#!/usr/bin/env node +/** + * Send the manual Pivot weekly drop push for a city tenant. + * + * Usage (from Meridian/backend): + * npm run send:pivot-weekly-push -- --tenantKey=nyc --batchWeek=2026-W22 + * npm run send:pivot-weekly-push -- --tenantKey=nyc --dry-run + * npm run send:pivot-weekly-push -- --tenantKey=nyc --force + */ +require('./ensureBackendNodeModules'); +require('dotenv').config(); + +const axios = require('axios'); +const mongoose = require('mongoose'); +const { connectToGlobalDatabase, connectToDatabase } = require('../connectionsManager'); +const tenantConfigSchema = require('../schemas/tenantConfig'); +const { getMergedTenants } = require('../services/tenantConfigService'); +const getModels = require('../services/getModelService'); +const { toIsoWeek, isValidIsoWeek } = require('../utilities/pivotIsoWeek'); +const { + describePivotDropSchedule, + isPivotTenant, + resolvePivotDropInstant, +} = require('../utilities/pivotDropSchedule'); + +const CONFIG_KEY = 'default'; +const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send'; +const EXPO_BATCH_SIZE = 100; +const DROP_WINDOW_MS = 30 * 60 * 1000; + +const PUSH_TITLE = 'just go*'; +const PUSH_BODY = 'What are you doing this week? Just go.'; + +function readArg(prefix) { + const flag = process.argv.find((arg) => arg.startsWith(`${prefix}=`)); + return flag ? flag.slice(prefix.length + 1) : null; +} + +function hasFlag(flag) { + return process.argv.includes(flag); +} + +function buildWeeklyDropPushMessage(pushToken, batchWeek) { + return { + to: pushToken, + sound: 'default', + title: PUSH_TITLE, + body: PUSH_BODY, + data: { + type: 'pivot_week', + edition: 'pivot', + appEdition: 'pivot', + batchWeek, + navigation: { + type: 'navigate', + route: 'PivotWeek', + deepLink: 'meridian://pivot/week', + }, + }, + priority: 'default', + channelId: 'default', + }; +} + +async function countPublishedEvents(req, batchWeek) { + const { Event } = getModels(req, 'Event'); + return Event.countDocuments({ + 'customFields.pivot.batchWeek': batchWeek, + 'customFields.pivot.ingestStatus': 'published', + }); +} + +async function loadPivotPushRecipients(req) { + const { User } = getModels(req, 'User'); + return User.find({ + pushToken: { $exists: true, $nin: [null, ''] }, + pushAppEdition: 'pivot', + }) + .select('_id pushToken') + .lean(); +} + +async function sendExpoBatch(messages) { + const response = await axios.post(EXPO_PUSH_URL, messages, { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Accept-Encoding': 'gzip, deflate', + }, + }); + + const tickets = Array.isArray(response.data?.data) + ? response.data.data + : [response.data?.data].filter(Boolean); + + let ok = 0; + let errors = 0; + for (const ticket of tickets) { + if (ticket?.status === 'ok') { + ok += 1; + } else { + errors += 1; + if (ticket?.message) { + console.warn(`[send:pivot-weekly-push] Expo ticket error: ${ticket.message}`); + } + } + } + + return { ok, errors, tickets }; +} + +async function sendAllMessages(messages) { + let sent = 0; + let failed = 0; + + for (let index = 0; index < messages.length; index += EXPO_BATCH_SIZE) { + const batch = messages.slice(index, index + EXPO_BATCH_SIZE); + const result = await sendExpoBatch(batch); + sent += result.ok; + failed += result.errors; + } + + return { sent, failed }; +} + +async function run() { + const tenantKey = readArg('--tenantKey'); + const batchWeek = readArg('--batchWeek') || toIsoWeek(); + const dryRun = hasFlag('--dry-run'); + const force = hasFlag('--force'); + + if (!tenantKey) { + throw new Error('Missing required flag --tenantKey='); + } + if (!isValidIsoWeek(batchWeek)) { + throw new Error(`Invalid --batchWeek "${batchWeek}" — expected YYYY-Www`); + } + + const globalDb = await connectToGlobalDatabase(); + globalDb.model('TenantConfig', tenantConfigSchema, 'tenant_configs'); + const req = { globalDb, school: 'www' }; + const tenants = await getMergedTenants(req); + const tenant = tenants.find((row) => row.tenantKey === tenantKey); + + if (!tenant) { + throw new Error(`Unknown tenantKey "${tenantKey}"`); + } + if (!isPivotTenant(tenant)) { + throw new Error(`Tenant "${tenantKey}" is not a pivot city`); + } + + const resolved = resolvePivotDropInstant(tenant, batchWeek); + const schedule = describePivotDropSchedule(resolved); + const now = new Date(); + const deltaMs = Math.abs(now.getTime() - resolved.dropAt.getTime()); + + console.log(`[send:pivot-weekly-push] tenantKey=${tenantKey} batchWeek=${batchWeek}`); + console.log( + `[send:pivot-weekly-push] Next drop (${schedule.sourceLabel}): ${schedule.formatted} (${schedule.localTime})` + ); + console.log(`[send:pivot-weekly-push] Resolved dropAt UTC: ${resolved.dropAt.toISOString()}`); + + if (resolved.usingPilotDefaults) { + console.warn( + '[send:pivot-weekly-push] WARNING: tenant has no stored weekly drop config — using pilot defaults (Thu 18:00 America/New_York). Set fields in Platform Admin before production drops.' + ); + } + + const tenantDb = await connectToDatabase(tenantKey); + const tenantReq = { db: tenantDb, school: tenantKey }; + const publishedCount = await countPublishedEvents(tenantReq, batchWeek); + console.log( + `[send:pivot-weekly-push] Published catalog events for ${batchWeek}: ${publishedCount}` + ); + + if (publishedCount === 0) { + console.warn( + `[send:pivot-weekly-push] WARNING: no published events for ${batchWeek}. Publish the catalog in Pivot Lab before sending the drop push.` + ); + } + + if (!force && deltaMs > DROP_WINDOW_MS) { + const minutesAway = Math.round(deltaMs / 60000); + console.warn( + `[send:pivot-weekly-push] WARNING: now is ${minutesAway} minutes from resolved dropAt. Re-check Platform Admin / Pivot Lab "Next drop" or pass --force to send anyway.` + ); + if (!dryRun) { + throw new Error('Aborting send — outside drop window (use --force to override).'); + } + } + + const recipients = await loadPivotPushRecipients(tenantReq); + console.log( + `[send:pivot-weekly-push] Pivot push recipients (pushAppEdition=pivot): ${recipients.length}` + ); + + if (recipients.length === 0) { + console.warn('[send:pivot-weekly-push] No pivot push tokens found — nothing to send.'); + return; + } + + const messages = recipients.map((recipient) => + buildWeeklyDropPushMessage(recipient.pushToken, batchWeek) + ); + + if (dryRun) { + console.log('[send:pivot-weekly-push] dry-run — would send:'); + console.log( + JSON.stringify( + { + title: PUSH_TITLE, + body: PUSH_BODY, + recipientCount: messages.length, + sample: messages[0], + }, + null, + 2 + ) + ); + return; + } + + const { sent, failed } = await sendAllMessages(messages); + console.log(`[send:pivot-weekly-push] sent=${sent} failed=${failed}`); +} + +run() + .catch((error) => { + console.error('[send:pivot-weekly-push] failed', error.message || error); + process.exitCode = 1; + }) + .finally(async () => { + await mongoose.disconnect(); + setTimeout(() => process.exit(process.exitCode || 0), 100); + }); diff --git a/backend/package.json b/backend/package.json index 3c16312a..fbd2678e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -48,7 +48,13 @@ "test:routes": "NODE_ENV=test jest --runInBand --testPathPatterns=tests/route-outcomes", "test:coverage": "NODE_ENV=test jest --runInBand --coverage", "setup-saml": "node scripts/setupSAML.js", - "migrate:org-member-roles": "node scripts/backfillOrgMemberRoles.js" + "migrate:org-member-roles": "node scripts/backfillOrgMemberRoles.js", + "seed:pivot-referral-codes": "node migrations/seedPivotReferralCodes.js", + "seed:pivot-feed-events": "node migrations/seedPivotFeedEvents.js", + "seed:pivot-feedback-config": "node migrations/seedPivotFeedbackConfig.js", + "seed:pivot-tag-catalog": "node migrations/seedPivotTagCatalog.js", + "rebuild:pivot-weekly-snapshot": "node migrations/rebuildPivotWeeklySnapshot.js", + "send:pivot-weekly-push": "node migrations/sendPivotWeeklyPush.js" }, "devDependencies": { "jest": "^30.3.0", diff --git a/backend/routes/adminRoutes.js b/backend/routes/adminRoutes.js index da6f7845..418fe775 100644 --- a/backend/routes/adminRoutes.js +++ b/backend/routes/adminRoutes.js @@ -9,58 +9,18 @@ const { connectToDatabase } = require('../connectionsManager'); const { getConnections, disconnectSocket, disconnectAll } = require('../socket'); const { createSession } = require('../utilities/sessionUtils'); const { getCookieDomain } = require('../utilities/cookieUtils'); +const getGlobalModels = require('../services/getGlobalModelService'); +const { + DEFAULT_TENANTS, + normalizeTenantRows, + normalizeTenantOverride, + mergeTenantRows, +} = require('../constants/defaultTenants'); const ACCESS_TOKEN_EXPIRY = '1m'; const REFRESH_TOKEN_EXPIRY = '30d'; const ACCESS_TOKEN_EXPIRY_MS = 60 * 1000; const REFRESH_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; -const TENANT_STATUSES = new Set(['active', 'coming_soon', 'maintenance', 'hidden']); -const DEFAULT_TENANTS = [ - { - tenantKey: 'rpi', - name: 'Rensselaer Polytechnic Institute', - subdomain: 'rpi', - location: 'Troy, NY', - status: 'active', - statusMessage: '', - }, - { - tenantKey: 'tvcog', - name: 'Center of Gravity', - subdomain: 'tvcog', - location: 'Troy, NY', - status: 'active', - statusMessage: '', - }, -]; - -function normalizeTenantRows(rows = []) { - return rows - .map((row) => { - const tenantKey = String(row?.tenantKey || '').trim().toLowerCase(); - if (!tenantKey) return null; - const status = TENANT_STATUSES.has(row?.status) ? row.status : 'active'; - return { - tenantKey, - name: String(row?.name || tenantKey).trim(), - subdomain: String(row?.subdomain || tenantKey).trim().toLowerCase(), - location: String(row?.location || '').trim(), - status, - statusMessage: String(row?.statusMessage || '').trim().slice(0, 240), - }; - }) - .filter(Boolean); -} - -function mergeTenantRows(baseRows = [], overrideRows = []) { - const merged = new Map(); - normalizeTenantRows(baseRows).forEach((row) => merged.set(row.tenantKey, row)); - normalizeTenantRows(overrideRows).forEach((row) => { - const base = merged.get(row.tenantKey) || {}; - merged.set(row.tenantKey, { ...base, ...row }); - }); - return Array.from(merged.values()); -} router.get('/health', async (req, res) => { try { @@ -281,8 +241,6 @@ router.get('/admin/user/:userId/analytics', verifyToken, requireAdmin, async (re } }); -const getGlobalModels = require('../services/getGlobalModelService'); - /** * GET /admin/platform-admins – list platform admins (GlobalUsers with platform_admin role) */ @@ -452,18 +410,23 @@ router.put('/admin/tenant-config', verifyToken, requireAdmin, async (req, res) = } const incoming = normalizeTenantRows(req.body.tenants); const incomingByKey = new Map(incoming.map((row) => [row.tenantKey, row])); - const nextTenants = mergeTenantRows( - DEFAULT_TENANTS, - DEFAULT_TENANTS.map((row) => { - const update = incomingByKey.get(row.tenantKey); - if (!update) return row; - return { - ...row, - status: update.status || row.status, - statusMessage: update.statusMessage || '', - }; - }) + const { TenantConfig } = getGlobalModels(req, 'TenantConfig'); + const existingDoc = await TenantConfig.findOne({ configKey: 'default' }).lean(); + const storedRows = existingDoc?.tenants || []; + const storedDynamic = storedRows.filter( + (row) => !DEFAULT_TENANTS.some((base) => base.tenantKey === row.tenantKey) ); + const defaultOverrides = DEFAULT_TENANTS.map((row) => { + const update = incomingByKey.get(row.tenantKey); + if (!update) return null; + return normalizeTenantOverride({ + tenantKey: row.tenantKey, + status: update.status || row.status, + statusMessage: update.statusMessage || '', + }); + }).filter(Boolean); + const nextStored = [...storedDynamic, ...defaultOverrides]; + const nextTenants = mergeTenantRows(DEFAULT_TENANTS, nextStored); const activeCount = nextTenants.filter((tenant) => tenant.status === 'active').length; if (activeCount < 1) { return res.status(400).json({ @@ -473,11 +436,10 @@ router.put('/admin/tenant-config', verifyToken, requireAdmin, async (req, res) = }); } - const { TenantConfig } = getGlobalModels(req, 'TenantConfig'); const updatedBy = req.user.globalUserId || req.user.userId || null; const doc = await TenantConfig.findOneAndUpdate( { configKey: 'default' }, - { $set: { tenants: nextTenants, updatedBy } }, + { $set: { tenants: nextStored, updatedBy } }, { new: true, upsert: true, setDefaultsOnInsert: true } ).lean(); diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js index 4134c0f9..a8476b60 100644 --- a/backend/routes/authRoutes.js +++ b/backend/routes/authRoutes.js @@ -11,8 +11,61 @@ const { verifyToken } = require('../middlewares/verifyToken.js'); const { authenticateWithGoogle, authenticateWithApple, loginUser, registerUser, authenticateWithGoogleIdToken } = require('../services/userServices.js'); const { sendUserRegisteredEvent } = require('../inngest/events.js'); -const getModels = require('../services/getModelService.js'); const getGlobalModels = require('../services/getGlobalModelService.js'); + +function getModels(req, ...names) { + return require('../services/getModelService.js')(req, ...names); +} + +async function bindAuthTenant(req) { + const { connectToDatabase } = require('../connectionsManager'); + const { getTenantByKey } = require('../services/tenantConfigService'); + + let tenantKey = null; + if (req.school === 'www') { + tenantKey = req.body?.school ? String(req.body.school).trim().toLowerCase() : null; + if (!tenantKey) { + return { + status: 400, + body: { + success: false, + message: 'Please select your school or use your school’s login page.', + code: 'SCHOOL_REQUIRED', + }, + }; + } + } else { + const override = req.headers['x-tenant'] || req.body?.tenantKey || req.body?.school; + tenantKey = override ? String(override).trim().toLowerCase() : req.school; + } + + if (!tenantKey || tenantKey === 'www') { + return { + status: 400, + body: { + success: false, + message: 'Tenant is required for sign-in.', + code: 'TENANT_REQUIRED', + }, + }; + } + + const tenant = await getTenantByKey(req, tenantKey); + if (!tenant) { + return { + status: 404, + body: { + success: false, + message: `Unknown tenant "${tenantKey}".`, + code: 'TENANT_NOT_FOUND', + }, + }; + } + + req.school = tenantKey; + req.db = await connectToDatabase(tenantKey); + return null; +} const { getFriendRequests } = require('../utilities/friendUtils'); const { createSession, validateSession, deleteSession, deleteAllUserSessions, getUserSessions, getUserSessionsForGlobalUser, deleteSessionById, deleteSessionByIdForGlobalUser, revokeAllOtherSessionsForGlobalUser } = require('../utilities/sessionUtils'); const { getCookieDomain } = require('../utilities/cookieUtils'); @@ -489,12 +542,22 @@ router.get('/validate-token', verifyToken, async (req, res) => { // On www we only return communities (no tenant DB); frontend uses this to redirect to tenant. if (req.school === 'www') { if (!req.user || !req.user.globalUserId) { - return res.json({ success: true, message: 'Token is valid', data: { user: null, communities: [] } }); + return res.json({ success: true, message: 'Token is valid', data: { user: null, communities: [], platformRoles: req.user?.platformRoles || [] } }); } const { TenantMembership } = getGlobalModels(req, 'TenantMembership'); const memberships = await TenantMembership.find({ globalUserId: req.user.globalUserId, status: 'active' }).lean(); const communities = memberships.map(m => m.tenantKey); - return res.json({ success: true, message: 'Token is valid', data: { user: null, communities } }); + let platformRoles = req.user.platformRoles || []; + if (!platformRoles.length) { + const { PlatformRole } = getGlobalModels(req, 'PlatformRole'); + const pr = await PlatformRole.findOne({ globalUserId: req.user.globalUserId }).lean(); + platformRoles = pr?.roles || []; + } + return res.json({ + success: true, + message: 'Token is valid', + data: { user: null, communities, platformRoles }, + }); } const { User, Friendship } = getModels(req, 'User', 'Friendship'); @@ -565,12 +628,17 @@ router.get('/validate-token', verifyToken, async (req, res) => { const data = { user: user, friendRequests: friendRequests, - pendingOrgInvites: pendingOrgInvites + pendingOrgInvites: pendingOrgInvites, + platformRoles: req.user.platformRoles || [], }; if (req.user.globalUserId) { - const { TenantMembership } = getGlobalModels(req, 'TenantMembership'); + const { TenantMembership, PlatformRole } = getGlobalModels(req, 'TenantMembership', 'PlatformRole'); const memberships = await TenantMembership.find({ globalUserId: req.user.globalUserId, status: 'active' }).lean(); data.communities = memberships.map(m => m.tenantKey); + if (!data.platformRoles.length) { + const pr = await PlatformRole.findOne({ globalUserId: req.user.globalUserId }).lean(); + data.platformRoles = pr?.roles || []; + } } res.json({ success: true, @@ -684,13 +752,9 @@ router.post('/verify-email', async (req, res) => { }); router.post('/google-login', async (req, res) => { - if (req.school === 'www') { - const school = (req.body && req.body.school) ? String(req.body.school).trim().toLowerCase() : null; - if (!school) { - return res.status(400).json({ success: false, message: 'Please select your school or use your school’s login page.', code: 'SCHOOL_REQUIRED' }); - } - req.school = school; - req.db = await require('../connectionsManager').connectToDatabase(school); + const tenantError = await bindAuthTenant(req); + if (tenantError) { + return res.status(tenantError.status).json(tenantError.body); } const { code, codeVerifier, isRegister, url, idToken } = req.body; @@ -743,13 +807,9 @@ router.post('/google-login', async (req, res) => { }); router.post('/apple-login', async (req, res) => { - if (req.school === 'www') { - const school = (req.body && req.body.school) ? String(req.body.school).trim().toLowerCase() : null; - if (!school) { - return res.status(400).json({ success: false, message: 'Please select your school or use your school’s login page.', code: 'SCHOOL_REQUIRED' }); - } - req.school = school; - req.db = await require('../connectionsManager').connectToDatabase(school); + const tenantError = await bindAuthTenant(req); + if (tenantError) { + return res.status(tenantError.status).json(tenantError.body); } const { idToken, user } = req.body; diff --git a/backend/routes/pivotAdminRoutes.js b/backend/routes/pivotAdminRoutes.js new file mode 100644 index 00000000..1ffa2ee2 --- /dev/null +++ b/backend/routes/pivotAdminRoutes.js @@ -0,0 +1,397 @@ +const express = require('express'); +const { verifyToken } = require('../middlewares/verifyToken'); +const { requirePlatformAdmin } = require('../middlewares/requirePlatformAdmin'); +const { + rebuildWeeklySnapshot, + getWeeklySnapshot, +} = require('../services/pivotWeeklySnapshotService'); +const { getPivotOverview } = require('../services/pivotAdminOverviewService'); +const { listPivotLabEvents } = require('../services/pivotLabEventsService'); +const { + getInterviewNotes, + saveInterviewNotes, +} = require('../services/pivotLabNotesService'); +const { previewIngestUrl } = require('../services/pivotIngestPreviewService'); +const { + publishIngestEvent, + publishBatchIngestEvents, + updateIngestEvent, +} = require('../services/pivotIngestPublishService'); +const { purgePivotCatalog } = require('../services/pivotCatalogPurgeService'); +const { listPivotTags } = require('../services/pivotTagCatalogService'); +const { + suggestPivotEventTags, + suggestPivotEventTagsBatch, +} = require('../services/pivotTagSuggestService'); + +const router = express.Router(); + +router.get('/tags', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await listPivotTags(req); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('GET /admin/pivot/tags failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to load pivot tag catalog.', + }); + } +}); + +router.get('/events', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await listPivotLabEvents(req, { + tenantKey: req.query?.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) { + console.error('GET /admin/pivot/events failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to load pivot catalog events.', + }); + } +}); + +router.get('/interview-notes', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await getInterviewNotes(req, { 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) { + console.error('GET /admin/pivot/interview-notes failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to load interview notes.', + }); + } +}); + +router.put('/interview-notes', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await saveInterviewNotes(req, { + batchWeek: req.body?.batchWeek ?? req.query?.batchWeek, + notes: req.body?.notes, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('PUT /admin/pivot/interview-notes failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to save interview notes.', + }); + } +}); + +router.get('/overview', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await getPivotOverview(req, { 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) { + console.error('GET /admin/pivot/overview failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to load pivot overview.', + }); + } +}); + +router.post('/snapshots/rebuild', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const batchWeek = req.body?.batchWeek ?? req.query?.batchWeek; + const result = await rebuildWeeklySnapshot(req, { 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) { + console.error('POST /admin/pivot/snapshots/rebuild failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to rebuild pivot weekly snapshot.', + }); + } +}); + +router.post('/ingest/suggest-tags', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const events = req.body?.events; + const isBatch = Array.isArray(events); + console.log('[pivotTagSuggest] route request', { + mode: isBatch ? 'batch' : 'single', + eventCount: isBatch ? events.length : 1, + hasGlobalDb: Boolean(req.globalDb), + hasApiKey: Boolean(process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_API_KEY), + model: process.env.CLAUDE_MODEL || process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6', + }); + + const result = isBatch + ? await suggestPivotEventTagsBatch(req, events) + : await suggestPivotEventTags(req, req.body?.event || req.body); + + if (result.error) { + console.warn('[pivotTagSuggest] route error', { + mode: isBatch ? 'batch' : 'single', + code: result.code, + message: result.error, + suggestedCount: result.data?.suggestedCount, + failedCount: result.data?.failedCount, + }); + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + data: result.data, + }); + } + + console.log('[pivotTagSuggest] route success', { + mode: isBatch ? 'batch' : 'single', + tags: result.data?.tags, + suggestedCount: result.data?.suggestedCount, + failedCount: result.data?.failedCount, + }); + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('POST /admin/pivot/ingest/suggest-tags failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to suggest pivot tags.', + }); + } +}); + +router.post('/ingest/preview', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await previewIngestUrl(req, { + url: req.body?.url, + tenantKey: req.body?.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) { + console.error('POST /admin/pivot/ingest/preview failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to preview event import.', + }); + } +}); + +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, + overrides: req.body?.overrides, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('POST /admin/pivot/ingest failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to publish pivot catalog event.', + }); + } +}); + +router.post('/ingest/batch', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await publishBatchIngestEvents(req, { + tenantKey: req.body?.tenantKey, + batchWeek: req.body?.batchWeek, + events: req.body?.events, + }); + if (result.error && !result.data?.published?.length) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + data: result.data, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('POST /admin/pivot/ingest/batch failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to publish pivot catalog events.', + }); + } +}); + +router.patch('/ingest/:eventId', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await updateIngestEvent(req, { + eventId: req.params.eventId, + tenantKey: req.body?.tenantKey, + overrides: req.body?.overrides, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('PATCH /admin/pivot/ingest/:eventId failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to update pivot catalog event.', + }); + } +}); + +router.post('/dev/purge-catalog', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await purgePivotCatalog(req, { + tenantKey: req.body?.tenantKey, + confirm: req.body?.confirm, + clearSnapshots: req.body?.clearSnapshots, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('POST /admin/pivot/dev/purge-catalog failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to purge pivot catalog data.', + }); + } +}); + +router.get('/snapshots/:batchWeek', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await getWeeklySnapshot(req, { batchWeek: req.params.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) { + console.error('GET /admin/pivot/snapshots/:batchWeek failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to load pivot weekly snapshot.', + }); + } +}); + +module.exports = router; diff --git a/backend/routes/pivotRoutes.js b/backend/routes/pivotRoutes.js new file mode 100644 index 00000000..0f243327 --- /dev/null +++ b/backend/routes/pivotRoutes.js @@ -0,0 +1,601 @@ +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const { validateReferralCode, redeemReferralCode } = require('../services/pivotReferralCodeService'); +const { getPivotFeed, getPivotEventFriends } = require('../services/pivotFeedService'); +const { + recordFeedAction, + recordExternalOpen, + confirmRegistered, + getWeekRecap, + resetWeekActions, +} = require('../services/pivotIntentService'); +const { + getPendingEventFeedback, + submitEventFeedback, + listUserPivotEventFeedback, +} = require('../services/pivotFeedbackService'); +const { getPivotConfig } = require('../services/pivotConfigService'); +const { listPivotTags } = require('../services/pivotTagCatalogService'); +const { + getPivotProfileInterests, + updatePivotProfileInterests, +} = require('../services/pivotProfileService'); +const { + searchPivotFriends, + sendPivotFriendRequest, + listPivotFriends, + listPivotFriendRequests, + acceptPivotFriendRequest, + declinePivotFriendRequest, +} = require('../services/pivotFriendService'); +const { + pivotReferralValidateRateLimit, +} = require('../middlewares/pivotReferralValidateRateLimit'); + +const { verifyToken } = require('../middlewares/verifyToken'); + +const router = express.Router(); + +router.post('/referral/validate', pivotReferralValidateRateLimit, async (req, res) => { + try { + const result = await validateReferralCode(req, req.body?.code); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('POST /pivot/referral/validate failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to validate referral code.', + }); + } +}); + +router.post('/referral/redeem', verifyToken, async (req, res) => { + try { + const result = await redeemReferralCode(req, req.body?.code); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('POST /pivot/referral/redeem failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to redeem referral code.', + }); + } +}); + +router.get('/tags', verifyToken, async (req, res) => { + try { + const result = await listPivotTags(req); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('GET /pivot/tags failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to load pivot tags.', + }); + } +}); + +router.get('/profile/interests', verifyToken, async (req, res) => { + try { + const result = await getPivotProfileInterests(req); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('GET /pivot/profile/interests failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to load pivot interests.', + }); + } +}); + +router.put('/profile/interests', verifyToken, async (req, res) => { + try { + const result = await updatePivotProfileInterests(req, req.body); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('PUT /pivot/profile/interests failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to save pivot interests.', + }); + } +}); + +router.get('/config', verifyToken, async (req, res) => { + try { + const result = await getPivotConfig(req, { 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) { + console.error('GET /pivot/config failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to load pivot config.', + }); + } +}); + +router.get('/feed', verifyToken, async (req, res) => { + try { + const result = await getPivotFeed(req, { + batchWeek: req.query.batchWeek, + excludeEventIds: req.query.excludeEventIds, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('GET /pivot/feed failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to load pivot feed.', + }); + } +}); + +router.post('/feed/action', verifyToken, async (req, res) => { + try { + const result = await recordFeedAction(req, req.body); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('POST /pivot/feed/action failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to record pivot intent.', + }); + } +}); + +router.post('/intent/:eventId/external-open', verifyToken, async (req, res) => { + try { + const result = await recordExternalOpen(req, req.params.eventId, req.body); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('POST /pivot/intent/:eventId/external-open failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to record external open.', + }); + } +}); + +router.post('/intent/:eventId/registered', verifyToken, async (req, res) => { + try { + const result = await confirmRegistered(req, req.params.eventId); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('POST /pivot/intent/:eventId/registered failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to confirm registration.', + }); + } +}); + +router.get('/friends', verifyToken, async (req, res) => { + try { + const result = await listPivotFriends(req); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('GET /pivot/friends failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to load friends.', + }); + } +}); + +router.get('/friends/requests', verifyToken, async (req, res) => { + try { + const result = await listPivotFriendRequests(req); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('GET /pivot/friends/requests failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to load friend requests.', + }); + } +}); + +router.post('/friends/requests/:friendshipId/accept', verifyToken, async (req, res) => { + try { + const result = await acceptPivotFriendRequest(req, req.params.friendshipId); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('POST /pivot/friends/requests/:friendshipId/accept failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to accept friend request.', + }); + } +}); + +router.post('/friends/requests/:friendshipId/decline', verifyToken, async (req, res) => { + try { + const result = await declinePivotFriendRequest(req, req.params.friendshipId); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('POST /pivot/friends/requests/:friendshipId/decline failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to decline friend request.', + }); + } +}); + +router.get('/friends/search', verifyToken, async (req, res) => { + try { + const result = await searchPivotFriends(req, { q: req.query.q }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('GET /pivot/friends/search failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to search friends.', + }); + } +}); + +router.post('/friends/request', verifyToken, async (req, res) => { + try { + const result = await sendPivotFriendRequest(req, req.body); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(201).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('POST /pivot/friends/request failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to send friend request.', + }); + } +}); + +router.get('/events/:eventId/friends', verifyToken, async (req, res) => { + try { + const result = await getPivotEventFriends(req, req.params.eventId); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('GET /pivot/events/:eventId/friends failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to load event friends.', + }); + } +}); + +router.get('/week-recap', verifyToken, async (req, res) => { + try { + const result = await getWeekRecap(req, { 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) { + console.error('GET /pivot/week-recap failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to load week recap.', + }); + } +}); + +router.get('/feedback/pending', verifyToken, async (req, res) => { + try { + const result = await getPendingEventFeedback(req); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('GET /pivot/feedback/pending failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to load pending feedback.', + }); + } +}); + +router.post('/feedback', [ + verifyToken, + body('eventId').trim().notEmpty().withMessage('eventId is required'), + body('rating').isInt({ min: 1, max: 5 }).withMessage('rating must be 1–5'), + body('comment').optional().isString().trim().isLength({ max: 500 }), +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: 'Validation errors', + errors: errors.array(), + code: 'VALIDATION_ERROR', + }); + } + + const result = await submitEventFeedback(req, req.body); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('POST /pivot/feedback failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to submit feedback.', + }); + } +}); + +router.get('/dev/feedback', verifyToken, async (req, res) => { + if (process.env.NODE_ENV !== 'development') { + return res.status(404).json({ + success: false, + message: 'Not found.', + }); + } + + try { + const result = await listUserPivotEventFeedback(req, { + 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) { + console.error('GET /pivot/dev/feedback failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to load pivot feedback.', + }); + } +}); + +router.post('/dev/reset-week-actions', verifyToken, async (req, res) => { + if (process.env.NODE_ENV !== 'development') { + return res.status(404).json({ + success: false, + message: 'Not found.', + }); + } + + try { + const result = await resetWeekActions(req, { batchWeek: req.body?.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) { + console.error('POST /pivot/dev/reset-week-actions failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to reset week actions.', + }); + } +}); + +module.exports = router; diff --git a/backend/routes/pivotWeeklyDropRoutes.js b/backend/routes/pivotWeeklyDropRoutes.js new file mode 100644 index 00000000..e5d8455b --- /dev/null +++ b/backend/routes/pivotWeeklyDropRoutes.js @@ -0,0 +1,80 @@ +const express = require('express'); +const { verifyToken } = require('../middlewares/verifyToken'); +const { requirePlatformAdmin } = require('../middlewares/requirePlatformAdmin'); +const { + getWeeklyDropStatus, + updateWeeklyDropConfig, + sendWeeklyDropPush, +} = require('../services/pivotWeeklyDropService'); + +const router = express.Router(); + +router.get( + '/admin/platform/tenants/:tenantKey/pivot-weekly-drop', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const batchWeek = req.query.batchWeek ? String(req.query.batchWeek).trim().toUpperCase() : undefined; + const result = await getWeeklyDropStatus(req, tenantKey, batchWeek); + if (result.error) { + return res.status(result.status || 400).json({ success: false, message: result.error }); + } + res.json({ success: true, data: result }); + } catch (err) { + console.error('GET pivot-weekly-drop failed:', err); + res.status(500).json({ success: false, message: err.message }); + } + } +); + +router.put( + '/admin/platform/tenants/:tenantKey/pivot-weekly-drop', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const updatedBy = req.user.globalUserId || req.user.userId || null; + const result = await updateWeeklyDropConfig(req, tenantKey, req.body, updatedBy); + if (result.error) { + return res.status(result.status || 400).json({ success: false, message: result.error }); + } + res.json({ success: true, data: result }); + } catch (err) { + console.error('PUT pivot-weekly-drop failed:', err); + res.status(500).json({ success: false, message: err.message }); + } + } +); + +router.post( + '/admin/platform/tenants/:tenantKey/pivot-weekly-drop/send', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const result = await sendWeeklyDropPush(req, tenantKey, { + batchWeek: req.body?.batchWeek, + dryRun: req.body?.dryRun === true, + force: req.body?.force === true, + }); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + data: result.data, + }); + } + res.json({ success: true, data: result }); + } catch (err) { + console.error('POST pivot-weekly-drop/send failed:', err); + res.status(500).json({ success: false, message: err.message }); + } + } +); + +module.exports = router; diff --git a/backend/routes/platformTenantRoutes.js b/backend/routes/platformTenantRoutes.js new file mode 100644 index 00000000..9c4f0c0b --- /dev/null +++ b/backend/routes/platformTenantRoutes.js @@ -0,0 +1,326 @@ +const express = require('express'); +const { verifyToken } = require('../middlewares/verifyToken'); +const { requirePlatformAdmin } = require('../middlewares/requirePlatformAdmin'); +const { + getMergedTenants, + getTenantByKey, + pingTenantDatabase, + provisionPivotCatalogOrg, + serializeTenantForAdmin, + validateNewTenantPayload, + validateTenantMetadataUpdate, + upsertStoredTenantRow, + syncTenantUriCache, +} = require('../services/tenantConfigService'); +const { buildDropSchedulePayload } = require('../services/pivotConfigService'); +const { toIsoWeek } = require('../utilities/pivotIsoWeek'); +const { isPivotTenant } = require('../utilities/pivotDropSchedule'); +const { + normalizePivotDropFields, + normalizePivotDropOverrides, +} = require('../constants/defaultTenants'); +const { invalidateTenantConnection } = require('../connectionsManager'); +const { + listReferralCodesForTenant, + createReferralCode, + updateReferralCode, + deleteReferralCode, +} = require('../services/pivotReferralCodeService'); + +const router = express.Router(); + +function enrichTenantForAdmin(tenant, extras = {}) { + const serialized = serializeTenantForAdmin(tenant, extras); + if (isPivotTenant(tenant)) { + const batchWeek = extras.batchWeek || toIsoWeek(); + serialized.dropSchedule = buildDropSchedulePayload(tenant, batchWeek); + } + return serialized; +} + +async function listTenantsWithHealth(req) { + const tenants = await getMergedTenants(req); + return Promise.all( + tenants.map(async (tenant) => { + const health = await pingTenantDatabase(tenant.tenantKey, tenant); + return enrichTenantForAdmin(tenant, { health }); + }) + ); +} + +router.get('/admin/platform/tenants', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const tenants = await listTenantsWithHealth(req); + res.json({ success: true, data: { tenants } }); + } catch (err) { + console.error('GET /admin/platform/tenants failed:', err); + res.status(500).json({ success: false, message: err.message }); + } +}); + +router.get('/admin/platform/tenants/:tenantKey', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const tenant = await getTenantByKey(req, tenantKey); + if (!tenant) { + return res.status(404).json({ success: false, message: 'Tenant not found.' }); + } + const health = await pingTenantDatabase(tenantKey, tenant); + res.json({ success: true, data: enrichTenantForAdmin(tenant, { health }) }); + } catch (err) { + console.error('GET /admin/platform/tenants/:tenantKey failed:', err); + res.status(500).json({ success: false, message: err.message }); + } +}); + +router.post('/admin/platform/tenants', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const validation = validateNewTenantPayload(req.body); + if (validation.error) { + return res.status(400).json({ success: false, message: validation.error }); + } + + const existing = await getTenantByKey(req, validation.row.tenantKey); + if (existing) { + return res.status(409).json({ + success: false, + message: `Tenant "${validation.row.tenantKey}" already exists.`, + code: 'TENANT_EXISTS', + }); + } + + const updatedBy = req.user.globalUserId || req.user.userId || null; + invalidateTenantConnection(validation.row.tenantKey); + + let saved = await upsertStoredTenantRow(req, validation.row, updatedBy); + let health = await pingTenantDatabase(saved.tenantKey, saved); + let pivotCatalog = null; + + if (saved.pivotPilot && health.ok) { + try { + pivotCatalog = await provisionPivotCatalogOrg(req, saved.tenantKey, saved); + saved = await upsertStoredTenantRow( + req, + { ...saved, pivotCatalogOrgId: pivotCatalog.orgId }, + updatedBy + ); + } catch (catalogErr) { + console.warn('Pivot catalog auto-provision failed:', catalogErr.message); + } + } + + health = await pingTenantDatabase(saved.tenantKey, saved); + res.status(201).json({ + success: true, + data: enrichTenantForAdmin(saved, { health, pivotCatalog }), + }); + } catch (err) { + console.error('POST /admin/platform/tenants failed:', err); + res.status(500).json({ success: false, message: err.message }); + } +}); + +router.put('/admin/platform/tenants/:tenantKey', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const existing = await getTenantByKey(req, tenantKey); + if (!existing) { + return res.status(404).json({ success: false, message: 'Tenant not found.' }); + } + + const metadataValidation = validateTenantMetadataUpdate(req.body); + if (metadataValidation.error) { + return res.status(400).json({ success: false, message: metadataValidation.error }); + } + + const confirmations = { + ...(existing.provisioningConfirmations || {}), + ...(req.body.provisioningConfirmations || {}), + }; + + const updated = { + ...existing, + ...(req.body.name !== undefined ? { name: req.body.name } : {}), + ...(req.body.subdomain !== undefined ? { subdomain: req.body.subdomain } : {}), + ...(req.body.location !== undefined ? { location: req.body.location } : {}), + ...(req.body.status !== undefined ? { status: req.body.status } : {}), + ...(req.body.statusMessage !== undefined ? { statusMessage: req.body.statusMessage } : {}), + ...(req.body.tenantType !== undefined ? { tenantType: req.body.tenantType } : {}), + ...(req.body.pivotPilot !== undefined ? { pivotPilot: req.body.pivotPilot } : {}), + ...(req.body.mongoUri !== undefined ? { mongoUri: req.body.mongoUri } : {}), + ...(req.body.mongoDatabaseName !== undefined ? { mongoDatabaseName: req.body.mongoDatabaseName } : {}), + provisioningConfirmations: confirmations, + }; + + const dropPatch = {}; + normalizePivotDropFields(req.body, dropPatch); + if (req.body.pivotDropOverrides !== undefined) { + dropPatch.pivotDropOverrides = normalizePivotDropOverrides(req.body.pivotDropOverrides) || []; + } + Object.assign(updated, dropPatch); + + const mergedPreview = (await getMergedTenants(req)).map((row) => + row.tenantKey === tenantKey ? updated : row + ); + const activeCount = mergedPreview.filter((t) => t.status === 'active').length; + if (activeCount < 1) { + return res.status(400).json({ + success: false, + message: 'At least one tenant must remain active.', + code: 'AT_LEAST_ONE_ACTIVE_TENANT_REQUIRED', + }); + } + + const updatedBy = req.user.globalUserId || req.user.userId || null; + invalidateTenantConnection(tenantKey); + const saved = await upsertStoredTenantRow(req, updated, updatedBy); + const health = await pingTenantDatabase(tenantKey, saved); + + res.json({ success: true, data: enrichTenantForAdmin(saved, { health }) }); + } catch (err) { + console.error('PUT /admin/platform/tenants/:tenantKey failed:', err); + res.status(500).json({ success: false, message: err.message }); + } +}); + +router.post('/admin/platform/tenants/:tenantKey/health-check', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const tenant = await getTenantByKey(req, tenantKey); + if (!tenant) { + return res.status(404).json({ success: false, message: 'Tenant not found.' }); + } + const health = await pingTenantDatabase(tenantKey, tenant); + res.json({ success: true, data: enrichTenantForAdmin(tenant, { health }) }); + } catch (err) { + console.error('POST health-check failed:', err); + res.status(500).json({ success: false, message: err.message }); + } +}); + +router.post('/admin/platform/tenants/:tenantKey/provision-pivot-catalog', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const tenant = await getTenantByKey(req, tenantKey); + if (!tenant) { + return res.status(404).json({ success: false, message: 'Tenant not found.' }); + } + + const health = await pingTenantDatabase(tenantKey, tenant); + if (!health.ok) { + return res.status(400).json({ + success: false, + message: 'Database connection must be healthy before provisioning Pivot Catalog org.', + data: { health }, + }); + } + + const pivotCatalog = await provisionPivotCatalogOrg(req, tenantKey, tenant); + const updatedBy = req.user.globalUserId || req.user.userId || null; + const saved = await upsertStoredTenantRow( + req, + { ...tenant, pivotCatalogOrgId: pivotCatalog.orgId }, + updatedBy + ); + + res.json({ + success: true, + data: { + ...enrichTenantForAdmin(saved, { health }), + pivotCatalog, + }, + }); + } catch (err) { + console.error('POST provision-pivot-catalog failed:', err); + res.status(500).json({ success: false, message: err.message }); + } +}); + +router.get( + '/admin/platform/tenants/:tenantKey/pivot-referral-codes', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const result = await listReferralCodesForTenant(req, tenantKey); + if (result.error) { + return res.status(result.status || 400).json({ success: false, message: result.error }); + } + res.json({ success: true, data: result }); + } catch (err) { + console.error('GET pivot-referral-codes failed:', err); + res.status(500).json({ success: false, message: err.message }); + } + } +); + +router.post( + '/admin/platform/tenants/:tenantKey/pivot-referral-codes', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const result = await createReferralCode(req, tenantKey, req.body); + if (result.error) { + return res.status(result.status || 400).json({ success: false, message: result.error }); + } + res.status(201).json({ success: true, data: result }); + } catch (err) { + console.error('POST pivot-referral-codes failed:', err); + res.status(500).json({ success: false, message: err.message }); + } + } +); + +router.put( + '/admin/platform/tenants/:tenantKey/pivot-referral-codes/:codeId', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const codeId = String(req.params.codeId || '').trim(); + const result = await updateReferralCode(req, tenantKey, codeId, req.body); + if (result.error) { + return res.status(result.status || 400).json({ success: false, message: result.error }); + } + res.json({ success: true, data: result }); + } catch (err) { + console.error('PUT pivot-referral-codes failed:', err); + res.status(500).json({ success: false, message: err.message }); + } + } +); + +router.delete( + '/admin/platform/tenants/:tenantKey/pivot-referral-codes/:codeId', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const codeId = String(req.params.codeId || '').trim(); + const result = await deleteReferralCode(req, tenantKey, codeId); + if (result.error) { + return res.status(result.status || 400).json({ success: false, message: result.error }); + } + res.json({ success: true, data: result }); + } catch (err) { + console.error('DELETE pivot-referral-codes failed:', err); + res.status(500).json({ success: false, message: err.message }); + } + } +); + +router.post('/admin/platform/tenants/sync-cache', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const cache = await syncTenantUriCache(req); + res.json({ success: true, data: { tenantKeys: Object.keys(cache) } }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +module.exports = router; diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js index 9a207c06..76e94236 100644 --- a/backend/routes/userRoutes.js +++ b/backend/routes/userRoutes.js @@ -817,7 +817,7 @@ router.post('/get-user-by-username', verifyToken, async (req, res) => { // Register push notification token router.post('/register-push-token', verifyToken, async (req, res) => { const { User } = getModels(req, 'User'); - const { pushToken } = req.body; + const { pushToken, appEdition } = req.body; try { if (!pushToken) { @@ -836,12 +836,16 @@ router.post('/register-push-token', verifyToken, async (req, res) => { } user.pushToken = pushToken; + user.pushAppEdition = appEdition === 'pivot' ? 'pivot' : 'campus'; await user.save(); - console.log(`POST: /register-push-token ${req.user.userId} successful`); + console.log(`POST: /register-push-token ${req.user.userId} successful (edition=${user.pushAppEdition})`); return res.status(200).json({ success: true, - message: 'Push token registered successfully' + message: 'Push token registered successfully', + data: { + appEdition: user.pushAppEdition, + }, }); } catch (error) { console.log(`POST: /register-push-token ${req.user.userId} failed:`, error); diff --git a/backend/schemas/pivotEventIntent.js b/backend/schemas/pivotEventIntent.js new file mode 100644 index 00000000..d209aa22 --- /dev/null +++ b/backend/schemas/pivotEventIntent.js @@ -0,0 +1,44 @@ +const mongoose = require('mongoose'); + +/** Tenant-scoped attendee intent for Pivot catalog (not campus RSVP). */ +const pivotEventIntentSchema = new mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + eventId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + }, + batchWeek: { + type: String, + required: true, + trim: true, + }, + status: { + type: String, + enum: ['interested', 'registered', 'passed'], + required: true, + }, + /** Last time the user opened the external ticket link (analytics / Lab funnel). */ + externalOpenAt: { + type: Date, + default: null, + }, + /** Count of external ticket-link opens (countable in Pivot Lab). */ + externalOpenCount: { + type: Number, + default: 0, + }, + }, + { timestamps: true }, +); + +pivotEventIntentSchema.index({ userId: 1, eventId: 1 }, { unique: true }); +pivotEventIntentSchema.index({ eventId: 1, status: 1 }); +pivotEventIntentSchema.index({ batchWeek: 1, userId: 1, status: 1 }); + +module.exports = pivotEventIntentSchema; diff --git a/backend/schemas/pivotLabNotes.js b/backend/schemas/pivotLabNotes.js new file mode 100644 index 00000000..0ff22bac --- /dev/null +++ b/backend/schemas/pivotLabNotes.js @@ -0,0 +1,31 @@ +const mongoose = require('mongoose'); +const { isValidIsoWeek } = require('../utilities/pivotIsoWeek'); + +const pivotLabNotesSchema = new mongoose.Schema( + { + batchWeek: { + type: String, + required: true, + trim: true, + validate: { + validator: isValidIsoWeek, + message: 'batchWeek must be ISO week format YYYY-Www', + }, + }, + notes: { + type: String, + default: '', + trim: true, + }, + updatedBy: { + type: String, + default: null, + trim: true, + }, + }, + { timestamps: true }, +); + +pivotLabNotesSchema.index({ batchWeek: 1 }, { unique: true }); + +module.exports = pivotLabNotesSchema; diff --git a/backend/schemas/pivotReferralCode.js b/backend/schemas/pivotReferralCode.js new file mode 100644 index 00000000..95559f85 --- /dev/null +++ b/backend/schemas/pivotReferralCode.js @@ -0,0 +1,81 @@ +const mongoose = require('mongoose'); +const { isValidIsoWeek } = require('../utilities/pivotIsoWeek'); + +const pivotReferralCodeSchema = new mongoose.Schema( + { + code: { + type: String, + required: true, + trim: true, + uppercase: true, + }, + tenantKey: { + type: String, + required: true, + trim: true, + lowercase: true, + }, + cohortId: { + type: String, + required: true, + trim: true, + }, + maxRedemptions: { + type: Number, + required: true, + min: 0, + default: 100, + }, + redemptionCount: { + type: Number, + required: true, + min: 0, + default: 0, + }, + expiresAt: { + type: Date, + default: null, + }, + active: { + type: Boolean, + default: true, + }, + batchWeek: { + type: String, + trim: true, + default: null, + validate: { + validator(value) { + if (value == null || value === '') return true; + return isValidIsoWeek(value); + }, + message: 'batchWeek must be ISO week format YYYY-Www', + }, + }, + }, + { timestamps: true } +); + +pivotReferralCodeSchema.pre('validate', function normalizeFields() { + if (this.code) { + this.code = String(this.code).trim().toUpperCase(); + } + if (this.tenantKey) { + this.tenantKey = String(this.tenantKey).trim().toLowerCase(); + } + if (this.cohortId) { + this.cohortId = String(this.cohortId).trim(); + } +}); + +pivotReferralCodeSchema.methods.isRedeemable = function isRedeemable(now = new Date()) { + if (!this.active) return false; + if (this.expiresAt && this.expiresAt < now) return false; + if (this.redemptionCount >= this.maxRedemptions) return false; + return true; +}; + +pivotReferralCodeSchema.index({ code: 1 }, { unique: true }); +pivotReferralCodeSchema.index({ tenantKey: 1, active: 1 }); + +module.exports = pivotReferralCodeSchema; diff --git a/backend/schemas/pivotReferralRedemption.js b/backend/schemas/pivotReferralRedemption.js new file mode 100644 index 00000000..c098f02d --- /dev/null +++ b/backend/schemas/pivotReferralRedemption.js @@ -0,0 +1,27 @@ +const mongoose = require('mongoose'); + +/** One row per successful server-side redemption (idempotent via unique index). */ +const pivotReferralRedemptionSchema = new mongoose.Schema( + { + globalUserId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'GlobalUser', + required: true, + }, + code: { + type: String, + required: true, + trim: true, + uppercase: true, + }, + pivotReferralCodeId: { + type: mongoose.Schema.Types.ObjectId, + required: true, + }, + }, + { timestamps: true } +); + +pivotReferralRedemptionSchema.index({ globalUserId: 1, code: 1 }, { unique: true }); + +module.exports = pivotReferralRedemptionSchema; diff --git a/backend/schemas/pivotTagCatalog.js b/backend/schemas/pivotTagCatalog.js new file mode 100644 index 00000000..3ec30beb --- /dev/null +++ b/backend/schemas/pivotTagCatalog.js @@ -0,0 +1,50 @@ +const mongoose = require('mongoose'); + +const PIVOT_TAG_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +const pivotTagCatalogSchema = new mongoose.Schema( + { + slug: { + type: String, + required: true, + trim: true, + lowercase: true, + validate: { + validator(value) { + return PIVOT_TAG_SLUG_PATTERN.test(value); + }, + message: 'slug must be lowercase kebab-case (e.g. live-music)', + }, + }, + label: { + type: String, + required: true, + trim: true, + }, + sortOrder: { + type: Number, + required: true, + default: 0, + }, + active: { + type: Boolean, + default: true, + }, + }, + { timestamps: true } +); + +pivotTagCatalogSchema.pre('validate', function normalizeSlug() { + if (this.slug) { + this.slug = String(this.slug).trim().toLowerCase(); + } + if (this.label) { + this.label = String(this.label).trim(); + } +}); + +pivotTagCatalogSchema.index({ slug: 1 }, { unique: true }); +pivotTagCatalogSchema.index({ active: 1, sortOrder: 1 }); + +module.exports = pivotTagCatalogSchema; +module.exports.PIVOT_TAG_SLUG_PATTERN = PIVOT_TAG_SLUG_PATTERN; diff --git a/backend/schemas/pivotWeeklySnapshot.js b/backend/schemas/pivotWeeklySnapshot.js new file mode 100644 index 00000000..2f0f9521 --- /dev/null +++ b/backend/schemas/pivotWeeklySnapshot.js @@ -0,0 +1,89 @@ +const mongoose = require('mongoose'); +const { isValidIsoWeek } = require('../utilities/pivotIsoWeek'); + +const pivotWeeklySnapshotTenantSchema = new mongoose.Schema( + { + tenantKey: { + type: String, + required: true, + trim: true, + lowercase: true, + }, + cityDisplayName: { + type: String, + default: '', + trim: true, + }, + eventCount: { + type: Number, + required: true, + min: 0, + default: 0, + }, + interestedCount: { + type: Number, + required: true, + min: 0, + default: 0, + }, + registeredCount: { + type: Number, + required: true, + min: 0, + default: 0, + }, + externalOpenCount: { + type: Number, + required: true, + min: 0, + default: 0, + }, + swipeCount: { + type: Number, + required: true, + min: 0, + default: 0, + }, + feedbackAvg: { + type: Number, + default: null, + min: 1, + max: 5, + }, + activeUsers: { + type: Number, + required: true, + min: 0, + default: 0, + }, + }, + { _id: false }, +); + +const pivotWeeklySnapshotSchema = new mongoose.Schema( + { + batchWeek: { + type: String, + required: true, + trim: true, + validate: { + validator: isValidIsoWeek, + message: 'batchWeek must be ISO week format YYYY-Www', + }, + }, + generatedAt: { + type: Date, + required: true, + }, + tenants: { + type: [pivotWeeklySnapshotTenantSchema], + default: [], + }, + }, + { timestamps: true }, +); + +pivotWeeklySnapshotSchema.index({ batchWeek: 1 }, { unique: true }); +pivotWeeklySnapshotSchema.index({ generatedAt: -1 }); + +module.exports = pivotWeeklySnapshotSchema; diff --git a/backend/schemas/tenantConfig.js b/backend/schemas/tenantConfig.js index f85a0d44..4d72ee9f 100644 --- a/backend/schemas/tenantConfig.js +++ b/backend/schemas/tenantConfig.js @@ -1,5 +1,15 @@ const mongoose = require('mongoose'); +const pivotDropOverrideSchema = new mongoose.Schema( + { + batchWeek: { type: String, required: true, trim: true }, + dayOfWeek: { type: Number, required: true, min: 0, max: 6 }, + hour: { type: Number, required: true, min: 0, max: 23 }, + minute: { type: Number, default: 0, min: 0, max: 59 }, + }, + { _id: false } +); + const tenantEntrySchema = new mongoose.Schema( { tenantKey: { type: String, required: true, trim: true, lowercase: true }, @@ -12,6 +22,25 @@ const tenantEntrySchema = new mongoose.Schema( default: 'active', }, statusMessage: { type: String, default: '', trim: true, maxlength: 240 }, + tenantType: { + type: String, + enum: ['campus', 'pivot'], + default: 'campus', + }, + pivotPilot: { type: Boolean, default: false }, + mongoUri: { type: String, default: null, trim: true }, + mongoDatabaseName: { type: String, default: null, trim: true, lowercase: true }, + pivotCatalogOrgId: { type: String, default: null, trim: true }, + pivotDropTimezone: { type: String, default: null, trim: true }, + pivotDropDayOfWeek: { type: Number, default: null, min: 0, max: 6 }, + pivotDropHour: { type: Number, default: null, min: 0, max: 23 }, + pivotDropMinute: { type: Number, default: 0, min: 0, max: 59 }, + pivotDropOverrides: { type: [pivotDropOverrideSchema], default: undefined }, + provisioningConfirmations: { + dns: { type: Boolean, default: false }, + cors: { type: Boolean, default: false }, + pickerVerified: { type: Boolean, default: false }, + }, }, { _id: false } ); diff --git a/backend/schemas/user.js b/backend/schemas/user.js index 72628527..513ed268 100644 --- a/backend/schemas/user.js +++ b/backend/schemas/user.js @@ -218,6 +218,12 @@ const userSchema = new mongoose.Schema({ default: null, trim: true }, + /** Last registered mobile app edition for push targeting (`campus` | `pivot`). */ + pushAppEdition: { + type: String, + enum: ['campus', 'pivot'], + default: 'campus', + }, /** When true, password login and API access (verifyToken) are blocked for this tenant user. */ accessSuspended: { type: Boolean, @@ -227,6 +233,11 @@ const userSchema = new mongoose.Schema({ type: Date, default: null, }, + /** Pivot catalog tag slugs selected for feed personalization (max 8). */ + pivotInterestTags: { + type: [String], + default: [], + }, // you can add more fields here if needed, like 'createdAt', 'updatedAt', etc. diff --git a/backend/services/authGlobalService.js b/backend/services/authGlobalService.js index 613e6d33..068c39d7 100644 --- a/backend/services/authGlobalService.js +++ b/backend/services/authGlobalService.js @@ -4,11 +4,14 @@ */ const jwt = require('jsonwebtoken'); const { randomUUID } = require('crypto'); -const getModels = require('./getModelService'); const getGlobalModels = require('./getGlobalModelService'); const { createGlobalSession } = require('../utilities/sessionUtils'); const { getCookieDomain } = require('../utilities/cookieUtils'); +function getModels(req, ...names) { + return require('./getModelService')(req, ...names); +} + const ACCESS_TOKEN_EXPIRY = process.env.ACCESS_TOKEN_EXPIRY || '15m'; const REFRESH_TOKEN_EXPIRY = process.env.REFRESH_TOKEN_EXPIRY || '30d'; const REFRESH_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; diff --git a/backend/services/feedbackService.js b/backend/services/feedbackService.js index 9e742d2c..37bf5031 100644 --- a/backend/services/feedbackService.js +++ b/backend/services/feedbackService.js @@ -228,10 +228,74 @@ class FeedbackService { if (!existingEvent) { results.push(await eventConfig.save()); } + + const existingPivotEvent = await FeedbackConfig.findOne({ + feature: 'pivot_event', + version: 'v1.0', + }); + + if (!existingPivotEvent) { + results.push(await this.createPivotEventFeedbackConfig(userId, systemVersion)); + } return results; } + /** Idempotent — ensures pivot_event v1.0 exists for POST /pivot/feedback. */ + async ensurePivotEventFeedbackConfig(userId) { + const { FeedbackConfig } = this.models; + const existing = await FeedbackConfig.findOne({ + feature: 'pivot_event', + version: 'v1.0', + }); + if (existing) { + return existing; + } + + const systemVersion = await this.getCurrentSystemVersion(); + return this.createPivotEventFeedbackConfig(userId, systemVersion); + } + + createPivotEventFeedbackConfig(userId, systemVersion) { + const { FeedbackConfig } = this.models; + const pivotEventConfig = new FeedbackConfig({ + feature: 'pivot_event', + version: 'v1.0', + systemVersion, + name: 'Pivot Event Feedback', + description: 'Quick post-event rating after Just Go events', + fields: [ + { + fieldId: 'rating', + fieldType: 'rating', + label: 'Overall rating', + description: 'Rate this event from 1 to 5', + required: true, + validation: { + min: 1, + max: 5, + }, + order: 1, + }, + { + fieldId: 'comment', + fieldType: 'text', + label: 'Optional comment', + required: false, + validation: { + maxLength: 500, + }, + order: 2, + }, + ], + isActive: true, + weight: 100, + targetUsers: 'all', + createdBy: userId, + }); + return pivotEventConfig.save(); + } + // Get feedback form configuration for frontend async getFeedbackForm(feature, userType = 'all') { const config = await this.getFeedbackConfig(feature, userType); diff --git a/backend/services/getGlobalModelService.js b/backend/services/getGlobalModelService.js index 27ff3f3f..b7212b3b 100644 --- a/backend/services/getGlobalModelService.js +++ b/backend/services/getGlobalModelService.js @@ -3,6 +3,11 @@ const platformRoleSchema = require('../schemas/platformRole'); const tenantMembershipSchema = require('../schemas/tenantMembership'); const globalSessionSchema = require('../schemas/globalSession'); const tenantConfigSchema = require('../schemas/tenantConfig'); +const pivotReferralCodeSchema = require('../schemas/pivotReferralCode'); +const pivotReferralRedemptionSchema = require('../schemas/pivotReferralRedemption'); +const pivotWeeklySnapshotSchema = require('../schemas/pivotWeeklySnapshot'); +const pivotLabNotesSchema = require('../schemas/pivotLabNotes'); +const pivotTagCatalogSchema = require('../schemas/pivotTagCatalog'); /** * Get models from the global/platform DB (cross-tenant data). @@ -10,7 +15,7 @@ const tenantConfigSchema = require('../schemas/tenantConfig'); * 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' + * @param {...string} names - model names: 'GlobalUser', 'PlatformRole', 'TenantMembership', 'Session', 'TenantConfig', 'PivotReferralCode', 'PivotReferralRedemption', 'PivotWeeklySnapshot', 'PivotLabNotes', 'PivotTagCatalog' * @returns {object} map of requested models */ const getGlobalModels = (req, ...names) => { @@ -25,6 +30,19 @@ const getGlobalModels = (req, ...names) => { TenantMembership: db.model('TenantMembership', tenantMembershipSchema, 'tenant_memberships'), Session: db.model('Session', globalSessionSchema, 'sessions'), TenantConfig: db.model('TenantConfig', tenantConfigSchema, 'tenant_config'), + PivotReferralCode: db.model('PivotReferralCode', pivotReferralCodeSchema, 'pivot_referral_codes'), + PivotReferralRedemption: db.model( + 'PivotReferralRedemption', + pivotReferralRedemptionSchema, + 'pivot_referral_redemptions' + ), + PivotWeeklySnapshot: db.model( + 'PivotWeeklySnapshot', + pivotWeeklySnapshotSchema, + 'pivot_weekly_snapshots' + ), + PivotLabNotes: db.model('PivotLabNotes', pivotLabNotesSchema, 'pivot_lab_notes'), + PivotTagCatalog: db.model('PivotTagCatalog', pivotTagCatalogSchema, 'pivot_tag_catalog'), }; return names.reduce((acc, name) => { diff --git a/backend/services/getModelService.js b/backend/services/getModelService.js index c2d7b2a3..b93ca429 100644 --- a/backend/services/getModelService.js +++ b/backend/services/getModelService.js @@ -64,6 +64,7 @@ const eventEquipmentSchema = require('../schemas/EventEquipment'); const orgEquipmentSchema = require('../schemas/OrgEquipment'); const analyticsEventSchema = require('../events/schemas/analyticsEvent'); const eventQRSchema = require('../events/schemas/eventQR'); +const pivotEventIntentSchema = require('../schemas/pivotEventIntent'); const registeredConnections = new WeakSet(); const MODEL_DEFINITIONS = Object.freeze({ BadgeGrant: { modelName: 'BadgeGrant', schema: badgeGrantSchema, collection: 'badgegrants' }, @@ -126,6 +127,11 @@ const MODEL_DEFINITIONS = Object.freeze({ OrgEquipment: { modelName: 'OrgEquipment', schema: orgEquipmentSchema, collection: 'orgEquipment' }, AnalyticsEvent: { modelName: 'AnalyticsEvent', schema: analyticsEventSchema, collection: 'analytics_events' }, EventQR: { modelName: 'EventQR', schema: eventQRSchema, collection: 'event_qrs' }, + PivotEventIntent: { + modelName: 'PivotEventIntent', + schema: pivotEventIntentSchema, + collection: 'pivotEventIntents', + }, 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/notificationService.js b/backend/services/notificationService.js index d366674c..5c7efb09 100644 --- a/backend/services/notificationService.js +++ b/backend/services/notificationService.js @@ -217,9 +217,20 @@ class NotificationService { }; // Build navigation instructions from notification - const navigationInstructions = this.buildNavigationInstructions(notification); + const notificationKind = this.resolveNotificationKind(notification); + const navigationInstructions = this.buildNavigationInstructions(notification, { + pushAppEdition: recipient.pushAppEdition, + }); + const friendshipId = notification.template?.variables?.friendshipId; + const pushNotificationType = ['friend_request', 'friend_accepted', 'friend_activity'].includes( + notificationKind, + ) + ? notificationKind + : (notification.type || 'system'); console.log(`📱 [PushNotification] Building navigation for notification ${notification._id}:`, { notificationType: notification.type, + notificationKind, + pushNotificationType, hasMetadataNavigation: !!(notification.metadata && notification.metadata.navigation), builtNavigation: navigationInstructions, metadata: notification.metadata @@ -233,8 +244,11 @@ class NotificationService { body: stripHtml(notification.message) || '', data: { notificationId: notification._id?.toString() || notification._id, - type: notification.type || 'system', + type: pushNotificationType, + edition: recipient.pushAppEdition || 'campus', + appEdition: recipient.pushAppEdition || 'campus', navigation: navigationInstructions, // Backend-controlled navigation + ...(friendshipId ? { friendshipId: String(friendshipId) } : {}), ...(notification.metadata || {}), ...(notification.actions ? { actions: notification.actions } : {}) }, @@ -275,36 +289,72 @@ class NotificationService { } } + /** + * Resolve the logical notification kind (template name wins over stored type). + */ + resolveNotificationKind(notification) { + return notification?.template?.name || notification?.type || 'system'; + } + + /** + * Pivot edition routes for friend notifications (Task 7.6). + */ + applyPivotEditionNavigation(notificationKind, navigation) { + switch (notificationKind) { + case 'friend_request': + return { + type: 'navigate', + route: 'PivotFriendRequests', + params: {}, + deepLink: 'meridian://pivot/friends/requests', + }; + case 'friend_accepted': + return { + type: 'navigate', + route: 'PivotFriends', + params: {}, + deepLink: 'meridian://pivot/friends', + }; + default: + return navigation; + } + } + /** * Build navigation instructions for mobile app from notification * This allows the backend to control what happens when a notification is tapped */ - buildNavigationInstructions(notification) { + buildNavigationInstructions(notification, { pushAppEdition } = {}) { console.log(`🧭 [Backend] buildNavigationInstructions called for notification:`, { notificationId: notification._id, notificationType: notification.type, + notificationKind: this.resolveNotificationKind(notification), + pushAppEdition, hasMetadata: !!notification.metadata, metadataNavigation: notification.metadata?.navigation, }); + let navigation; + // If navigation is explicitly set in metadata, use it if (notification.metadata && notification.metadata.navigation) { console.log(`🧭 [Backend] Using explicit navigation from metadata:`, notification.metadata.navigation); - return notification.metadata.navigation; - } - - console.log(`🧭 [Backend] Building navigation from notification type:`, notification.type); + navigation = notification.metadata.navigation; + } else { + console.log(`🧭 [Backend] Building navigation from notification type:`, notification.type); - // Otherwise, build navigation based on notification type and metadata - const navigation = { - type: 'navigate', // 'navigate', 'deep_link', 'api_call', or 'none' - route: null, - params: {}, - deepLink: null - }; + // Otherwise, build navigation based on notification type and metadata + navigation = { + type: 'navigate', // 'navigate', 'deep_link', 'api_call', or 'none' + route: null, + params: {}, + deepLink: null + }; + + const notificationKind = this.resolveNotificationKind(notification); - // Build navigation based on notification type - switch (notification.type) { + // Build navigation based on notification kind + switch (notificationKind) { case 'event': case 'event_reminder': case 'event_update': @@ -366,6 +416,14 @@ class NotificationService { navigation.route = 'Events'; navigation.deepLink = 'meridian://'; } + } + + if (pushAppEdition === 'pivot') { + navigation = this.applyPivotEditionNavigation( + this.resolveNotificationKind(notification), + navigation, + ); + } return navigation; } @@ -666,6 +724,8 @@ class NotificationService { channels: template.channels || ['in_app'], metadata: { ...(template.navigation ? { navigation: this.interpolateObject(template.navigation, variables) } : {}), + ...(variables.friendshipId ? { friendshipId: String(variables.friendshipId) } : {}), + ...(variables.sender ? { sender: String(variables.sender) } : {}), ...(variables.metadata || {}) } }; @@ -1105,6 +1165,8 @@ class NotificationService { channels: template.channels || ['in_app'], metadata: { ...(template.navigation ? { navigation: this.interpolateObject(template.navigation, variables) } : {}), + ...(variables.friendshipId ? { friendshipId: String(variables.friendshipId) } : {}), + ...(variables.sender ? { sender: String(variables.sender) } : {}), ...(variables.metadata || {}) } }; diff --git a/backend/services/pivotAdminOverviewService.js b/backend/services/pivotAdminOverviewService.js new file mode 100644 index 00000000..c725f975 --- /dev/null +++ b/backend/services/pivotAdminOverviewService.js @@ -0,0 +1,181 @@ +const getGlobalModels = require('./getGlobalModelService'); +const getModels = require('./getModelService'); +const { getMergedTenants } = require('./tenantConfigService'); +const { isPivotTenant, serializePivotReferralCode } = require('./pivotReferralCodeService'); +const { connectToDatabase } = require('../connectionsManager'); +const { PIVOT_EVENT_FEATURE } = require('./pivotFeedbackService'); +const { + normalizeBatchWeek, + PUBLISHED_EVENT_QUERY, + getWeeklySnapshot, +} = require('./pivotWeeklySnapshotService'); +const { buildDropSchedulePayload } = require('./pivotConfigService'); + +async function loadReferralCodesForTenant(req, tenantKey) { + const { PivotReferralCode } = getGlobalModels(req, 'PivotReferralCode'); + const docs = await PivotReferralCode.find({ tenantKey }).sort({ active: -1, code: 1 }).lean(); + return docs.map(serializePivotReferralCode); +} + +async function aggregateRegisteredFeedback(PivotEventIntent, UniversalFeedback, batchWeek, eventIds) { + if (!eventIds.length) { + return { feedbackCount: 0, feedbackAvg: null }; + } + + const registeredIntents = await PivotEventIntent.find({ + batchWeek, + status: 'registered', + eventId: { $in: eventIds }, + }) + .select('userId eventId') + .lean(); + + if (!registeredIntents.length) { + return { feedbackCount: 0, feedbackAvg: null }; + } + + const registeredKeys = new Set( + registeredIntents.map( + (intent) => `${String(intent.userId)}:${String(intent.eventId)}`, + ), + ); + + const feedbackRows = await UniversalFeedback.find({ + feature: PIVOT_EVENT_FEATURE, + processId: { $in: eventIds }, + }) + .select('user processId responses.rating') + .lean(); + + const registeredFeedback = feedbackRows.filter((row) => + registeredKeys.has(`${String(row.user)}:${String(row.processId)}`), + ); + + const ratings = registeredFeedback + .map((row) => row.responses?.rating) + .filter((rating) => typeof rating === 'number' && rating >= 1 && rating <= 5); + + if (!ratings.length) { + return { feedbackCount: registeredFeedback.length, feedbackAvg: null }; + } + + const sum = ratings.reduce((acc, rating) => acc + rating, 0); + return { + feedbackCount: registeredFeedback.length, + feedbackAvg: Math.round((sum / ratings.length) * 100) / 100, + }; +} + +async function aggregateTenantOverview(req, tenant, batchWeek) { + const tenantKey = tenant.tenantKey; + const db = await connectToDatabase(tenantKey); + const tenantReq = { db }; + const { Event, PivotEventIntent, UniversalFeedback } = getModels( + tenantReq, + 'Event', + 'PivotEventIntent', + 'UniversalFeedback', + ); + + const eventQuery = PUBLISHED_EVENT_QUERY(batchWeek); + const [eventCount, events] = await Promise.all([ + Event.countDocuments(eventQuery), + Event.find(eventQuery).select('_id').lean(), + ]); + const eventIds = events.map((event) => event._id); + + const intentFilter = { batchWeek }; + const [ + interestedCount, + registeredCount, + passedCount, + activeUserIds, + externalOpenAgg, + feedback, + referralCodes, + ] = await Promise.all([ + PivotEventIntent.countDocuments({ ...intentFilter, status: 'interested' }), + PivotEventIntent.countDocuments({ ...intentFilter, status: 'registered' }), + PivotEventIntent.countDocuments({ ...intentFilter, status: 'passed' }), + PivotEventIntent.distinct('userId', intentFilter), + PivotEventIntent.aggregate([ + { $match: intentFilter }, + { $group: { _id: null, total: { $sum: { $ifNull: ['$externalOpenCount', 0] } } } }, + ]), + aggregateRegisteredFeedback(PivotEventIntent, UniversalFeedback, batchWeek, eventIds), + loadReferralCodesForTenant(req, tenantKey), + ]); + + const swipeCount = passedCount + interestedCount + registeredCount; + + return { + tenantKey, + cityDisplayName: tenant.location || tenant.name || tenantKey, + eventCount, + interestedCount, + registeredCount, + externalOpenCount: externalOpenAgg[0]?.total ?? 0, + swipeCount, + feedbackCount: feedback.feedbackCount, + feedbackAvg: feedback.feedbackAvg, + activeUsers: activeUserIds.length, + referralCodes, + dropSchedule: buildDropSchedulePayload(tenant, batchWeek), + }; +} + +async function getPivotOverview(req, options = {}) { + const normalized = normalizeBatchWeek(options.batchWeek, options.now); + if (normalized.error) { + return normalized; + } + + const { batchWeek } = normalized; + const pivotTenants = (await getMergedTenants(req)).filter(isPivotTenant); + const tenants = []; + + for (const tenant of pivotTenants) { + try { + tenants.push(await aggregateTenantOverview(req, tenant, batchWeek)); + } catch (error) { + console.error( + `[pivotAdminOverview] aggregate failed tenant=${tenant.tenantKey} batchWeek=${batchWeek}:`, + error, + ); + tenants.push({ + tenantKey: tenant.tenantKey, + cityDisplayName: tenant.location || tenant.name || tenant.tenantKey, + eventCount: 0, + interestedCount: 0, + registeredCount: 0, + externalOpenCount: 0, + swipeCount: 0, + feedbackCount: 0, + feedbackAvg: null, + activeUsers: 0, + referralCodes: await loadReferralCodesForTenant(req, tenant.tenantKey).catch(() => []), + dropSchedule: buildDropSchedulePayload(tenant, batchWeek), + error: 'AGGREGATION_FAILED', + }); + } + } + + const snapshotResult = await getWeeklySnapshot(req, { batchWeek }); + const snapshotGeneratedAt = + snapshotResult.data?.generatedAt ?? null; + + return { + data: { + batchWeek, + snapshotGeneratedAt, + tenants, + }, + }; +} + +module.exports = { + aggregateTenantOverview, + aggregateRegisteredFeedback, + getPivotOverview, + loadReferralCodesForTenant, +}; diff --git a/backend/services/pivotCatalogPurgeService.js b/backend/services/pivotCatalogPurgeService.js new file mode 100644 index 00000000..fb60b0b1 --- /dev/null +++ b/backend/services/pivotCatalogPurgeService.js @@ -0,0 +1,161 @@ +const getModels = require('./getModelService'); +const getGlobalModels = require('./getGlobalModelService'); +const { getMergedTenants } = require('./tenantConfigService'); +const { isPivotTenant } = require('./pivotReferralCodeService'); +const { connectToDatabase } = require('../connectionsManager'); +const { PIVOT_EVENT_FEATURE } = require('./pivotFeedbackService'); + +const PURGE_CONFIRM_TOKEN = 'PURGE'; +const PIVOT_CATALOG_EVENT_QUERY = { 'customFields.pivot': { $exists: true } }; + +function isDevEnvironment() { + return process.env.NODE_ENV !== 'production'; +} + +async function purgeTenantPivotCatalog(tenantKey) { + const db = await connectToDatabase(tenantKey); + const tenantReq = { db }; + const { + Event, + PivotEventIntent, + UniversalFeedback, + FormResponse, + EventAnalytics, + EventQR, + AnalyticsEvent, + } = getModels( + tenantReq, + 'Event', + 'PivotEventIntent', + 'UniversalFeedback', + 'FormResponse', + 'EventAnalytics', + 'EventQR', + 'AnalyticsEvent', + ); + + const events = await Event.find(PIVOT_CATALOG_EVENT_QUERY).select('_id').lean(); + const eventIds = events.map((event) => event._id); + const eventIdStrings = eventIds.map(String); + + const deleted = { + events: 0, + intents: 0, + feedback: 0, + formResponses: 0, + eventAnalytics: 0, + eventQr: 0, + analyticsEvents: 0, + }; + + const intentResult = await PivotEventIntent.deleteMany( + eventIds.length ? { eventId: { $in: eventIds } } : {}, + ); + deleted.intents = intentResult.deletedCount || 0; + + const feedbackResult = await UniversalFeedback.deleteMany({ + feature: PIVOT_EVENT_FEATURE, + ...(eventIds.length ? { processId: { $in: eventIds } } : {}), + }); + deleted.feedback = feedbackResult.deletedCount || 0; + + if (eventIds.length) { + const [formResult, analyticsResult, qrResult, analyticsEventsResult] = await Promise.all([ + FormResponse.deleteMany({ event: { $in: eventIds } }), + EventAnalytics.deleteMany({ eventId: { $in: eventIds } }), + EventQR.deleteMany({ eventId: { $in: eventIds } }), + AnalyticsEvent.deleteMany({ + 'properties.event_id': { $in: eventIdStrings }, + }), + ]); + + deleted.formResponses = formResult.deletedCount || 0; + deleted.eventAnalytics = analyticsResult.deletedCount || 0; + deleted.eventQr = qrResult.deletedCount || 0; + deleted.analyticsEvents = analyticsEventsResult.deletedCount || 0; + + const eventResult = await Event.deleteMany({ _id: { $in: eventIds } }); + deleted.events = eventResult.deletedCount || 0; + } + + return deleted; +} + +async function purgeGlobalPivotSnapshots(req) { + const { PivotWeeklySnapshot } = getGlobalModels(req, 'PivotWeeklySnapshot'); + const result = await PivotWeeklySnapshot.deleteMany({}); + return { weeklySnapshots: result.deletedCount || 0 }; +} + +async function purgePivotCatalog(req, options = {}) { + if (!isDevEnvironment()) { + return { + error: 'Not available in production.', + status: 404, + code: 'NOT_FOUND', + }; + } + + const confirm = options.confirm?.trim(); + if (confirm !== PURGE_CONFIRM_TOKEN) { + return { + error: `Type ${PURGE_CONFIRM_TOKEN} to confirm.`, + status: 400, + code: 'CONFIRMATION_REQUIRED', + }; + } + + const pivotTenants = (await getMergedTenants(req)).filter(isPivotTenant); + const tenantKeyFilter = options.tenantKey?.trim()?.toLowerCase(); + + let tenantsToPurge = pivotTenants; + if (tenantKeyFilter) { + const tenant = pivotTenants.find((row) => row.tenantKey === tenantKeyFilter); + if (!tenant) { + return { + error: 'Pivot tenant not found.', + status: 404, + code: 'TENANT_NOT_FOUND', + }; + } + tenantsToPurge = [tenant]; + } + + const tenantResults = []; + for (const tenant of tenantsToPurge) { + const counts = await purgeTenantPivotCatalog(tenant.tenantKey); + tenantResults.push({ + tenantKey: tenant.tenantKey, + cityDisplayName: tenant.location || tenant.name || tenant.tenantKey, + deleted: counts, + }); + } + + const globalDeleted = + options.clearSnapshots === false ? {} : await purgeGlobalPivotSnapshots(req); + + const totals = tenantResults.reduce( + (acc, row) => { + Object.entries(row.deleted).forEach(([key, value]) => { + acc[key] = (acc[key] || 0) + value; + }); + return acc; + }, + { weeklySnapshots: globalDeleted.weeklySnapshots || 0 }, + ); + + return { + data: { + tenants: tenantResults, + totals, + }, + }; +} + +module.exports = { + purgePivotCatalog, + purgeTenantPivotCatalog, + PURGE_CONFIRM_TOKEN, + PIVOT_CATALOG_EVENT_QUERY, + isDevEnvironment, +}; diff --git a/backend/services/pivotConfigService.js b/backend/services/pivotConfigService.js new file mode 100644 index 00000000..88f055db --- /dev/null +++ b/backend/services/pivotConfigService.js @@ -0,0 +1,59 @@ +const { getTenantByKey } = require('./tenantConfigService'); +const { isValidIsoWeek, toIsoWeek } = require('../utilities/pivotIsoWeek'); +const { + describePivotDropSchedule, + isPivotTenant, + resolvePivotDropInstant, +} = require('../utilities/pivotDropSchedule'); + +function buildDropSchedulePayload(tenant, batchWeek, now = new Date()) { + const resolved = resolvePivotDropInstant(tenant, batchWeek, now); + const description = describePivotDropSchedule(resolved); + + return { + batchWeek, + timezone: resolved.timezone, + dayOfWeek: resolved.dayOfWeek, + hour: resolved.hour, + minute: resolved.minute, + nextDropAt: resolved.dropAt.toISOString(), + nextDropFormatted: description.formatted, + localSchedule: description.localTime, + source: resolved.source, + usingPilotDefaults: resolved.usingPilotDefaults, + }; +} + +async function getPivotConfig(req, options = {}) { + const tenantKey = req.school || options.tenantKey; + if (!tenantKey) { + return { error: 'Tenant context required.', status: 400 }; + } + + const tenant = await getTenantByKey(req, tenantKey); + if (!tenant) { + return { error: 'Tenant not found.', status: 404 }; + } + if (!isPivotTenant(tenant)) { + return { error: 'Pivot config is only available for pivot city tenants.', status: 400 }; + } + + const now = options.now || new Date(); + const batchWeek = options.batchWeek?.trim() || toIsoWeek(now); + if (options.batchWeek && !isValidIsoWeek(batchWeek)) { + return { error: 'batchWeek must be ISO format YYYY-Www.', status: 400, code: 'INVALID_BATCH_WEEK' }; + } + + return { + data: { + tenantKey: tenant.tenantKey, + cityDisplayName: tenant.location || tenant.name || tenant.tenantKey, + dropSchedule: buildDropSchedulePayload(tenant, batchWeek, now), + }, + }; +} + +module.exports = { + buildDropSchedulePayload, + getPivotConfig, +}; diff --git a/backend/services/pivotFeedService.js b/backend/services/pivotFeedService.js new file mode 100644 index 00000000..19a94c5d --- /dev/null +++ b/backend/services/pivotFeedService.js @@ -0,0 +1,571 @@ +const mongoose = require('mongoose'); +const getModels = require('./getModelService'); +const { getTenantByKey } = require('./tenantConfigService'); +const { toIsoWeek, isValidIsoWeek } = require('../utilities/pivotIsoWeek'); +const { PIVOT_TAG_SLUG_PATTERN } = require('../schemas/pivotTagCatalog'); + +const FRIEND_CAP = 5; +const PIVOT_EVENT_STATUSES = ['approved', 'not-applicable']; +const LOW_FEEDBACK_RATING_THRESHOLD = 3; +const PUBLIC_EVENT_FIELDS = + 'name description location start_time end_time externalLink type registrationCount image customFields.pivot'; + +function getPilotWindow(now = new Date()) { + const windowStart = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1), + ); + const windowEnd = new Date(windowStart.getTime() + 7 * 24 * 60 * 60 * 1000); + return { windowStart, windowEnd }; +} + +/** True when the event has not ended yet — deck should not surface past plans. */ +function isUpcomingPivotEvent(event, now = new Date()) { + if (!event) { + return false; + } + + const end = + event.end_time != null && event.end_time !== '' + ? new Date(event.end_time) + : null; + if (end && !Number.isNaN(end.getTime())) { + return end > now; + } + + const start = + event.start_time != null && event.start_time !== '' + ? new Date(event.start_time) + : null; + if (start && !Number.isNaN(start.getTime())) { + return start > now; + } + + return false; +} + +function getUpcomingEventTimeFilter(now = new Date()) { + return { + $or: [ + { end_time: { $gt: now } }, + { + end_time: { $in: [null] }, + start_time: { $gt: now }, + }, + { + end_time: { $exists: false }, + start_time: { $gt: now }, + }, + ], + }; +} + +function resolveDisplayHost(pivotMeta) { + const host = pivotMeta?.host; + const name = host?.name?.trim(); + if (!name) { + return null; + } + + return { + name, + ...(host.imageUrl ? { imageUrl: host.imageUrl } : {}), + ...(host.profileUrl ? { profileUrl: host.profileUrl } : {}), + }; +} + +function serializePivotFeedEvent(event, extras) { + const pivot = event.customFields?.pivot || {}; + const coverImageUrl = + typeof event.image === 'string' && event.image.trim() ? event.image.trim() : null; + + return { + _id: String(event._id), + name: event.name, + description: event.description, + location: event.location, + start_time: event.start_time, + end_time: event.end_time, + externalLink: event.externalLink, + type: event.type, + registrationCount: event.registrationCount ?? 0, + tags: Array.isArray(pivot.tags) ? pivot.tags : [], + ...(coverImageUrl ? { coverImageUrl } : {}), + displayHost: extras.displayHost, + userIntent: extras.userIntent, + friendsInterested: extras.friendsInterested, + friendsGoing: extras.friendsGoing, + // Total counts (uncapped) so the client can render "N friends interested" + // even when the preview arrays above are capped at FRIEND_CAP. + friendsInterestedCount: extras.friendsInterestedCount, + friendsGoingCount: extras.friendsGoingCount, + }; +} + +async function getAcceptedFriendIds(Friendship, userId) { + const rows = await Friendship.find({ + status: 'accepted', + $or: [{ requester: userId }, { recipient: userId }], + }) + .select('requester recipient') + .lean(); + + const uid = String(userId); + return rows.map((row) => + String(row.requester) === uid ? row.recipient : row.requester, + ); +} + +function mapFriendPreview(user) { + return { + id: String(user._id), + name: user.name || user.username || 'friend', + picture: user.picture || null, + }; +} + +function makeEmptySocialMap(eventIds) { + return new Map( + eventIds.map((id) => [ + String(id), + { + friendsInterested: [], + friendsGoing: [], + friendInterestedCount: 0, + friendRegisteredCount: 0, + }, + ]), + ); +} + +async function loadFriendSocial(req, userId, eventIds, previewCap = FRIEND_CAP, batchWeek = null) { + const emptySocial = makeEmptySocialMap(eventIds); + + if (!eventIds.length) { + return { userIntents: new Map(), socialByEvent: emptySocial }; + } + + const { Friendship, PivotEventIntent, User } = getModels( + req, + 'Friendship', + 'PivotEventIntent', + 'User', + ); + + const userIntentQuery = { + userId, + eventId: { $in: eventIds }, + }; + if (batchWeek) { + userIntentQuery.batchWeek = batchWeek; + } + + const userIntentRows = await PivotEventIntent.find(userIntentQuery) + .select('eventId status') + .lean(); + + const userIntents = new Map( + userIntentRows.map((row) => [String(row.eventId), row.status]), + ); + + const friendIds = await getAcceptedFriendIds(Friendship, userId); + if (!friendIds.length) { + return { userIntents, socialByEvent: emptySocial }; + } + + const friendIntentRows = await PivotEventIntent.find({ + eventId: { $in: eventIds }, + userId: { $in: friendIds }, + status: { $in: ['interested', 'registered'] }, + }) + .select('eventId userId status') + .lean(); + + if (!friendIntentRows.length) { + return { userIntents, socialByEvent: emptySocial }; + } + + const friendUserIds = [ + ...new Set(friendIntentRows.map((row) => String(row.userId))), + ]; + const users = await User.find({ _id: { $in: friendUserIds } }) + .select('name username picture') + .lean(); + const userById = new Map(users.map((user) => [String(user._id), user])); + + const socialByEvent = makeEmptySocialMap(eventIds); + + for (const row of friendIntentRows) { + const eventKey = String(row.eventId); + const bucket = socialByEvent.get(eventKey); + const friend = userById.get(String(row.userId)); + if (!bucket || !friend) { + continue; + } + + const preview = mapFriendPreview(friend); + if (row.status === 'registered') { + bucket.friendRegisteredCount += 1; + bucket.friendInterestedCount += 1; + if (bucket.friendsGoing.length < previewCap) { + bucket.friendsGoing.push(preview); + } + if (bucket.friendsInterested.length < previewCap) { + bucket.friendsInterested.push(preview); + } + } else if (row.status === 'interested') { + bucket.friendInterestedCount += 1; + if (bucket.friendsInterested.length < previewCap) { + bucket.friendsInterested.push(preview); + } + } + } + + return { userIntents, socialByEvent }; +} + +function normalizeExcludeEventIds(rawExcludeEventIds) { + if (!rawExcludeEventIds) { + return []; + } + + const raw = Array.isArray(rawExcludeEventIds) + ? rawExcludeEventIds + : String(rawExcludeEventIds).split(','); + + const seen = new Set(); + for (const value of raw) { + const id = String(value).trim(); + if (id && mongoose.Types.ObjectId.isValid(id)) { + seen.add(id); + } + } + + return [...seen]; +} + +function normalizeInterestTagSet(rawTags) { + if (!Array.isArray(rawTags)) { + return new Set(); + } + + const tags = new Set(); + for (const raw of rawTags) { + if (typeof raw !== 'string') { + continue; + } + const slug = raw.trim().toLowerCase(); + if (slug) { + tags.add(slug); + } + } + return tags; +} + +function countInterestOverlap(event, userInterestTags) { + if (!userInterestTags.size) { + return 0; + } + + const eventTags = event.customFields?.pivot?.tags; + if (!Array.isArray(eventTags) || !eventTags.length) { + return 0; + } + + let overlap = 0; + for (const raw of eventTags) { + if (typeof raw !== 'string') { + continue; + } + const slug = raw.trim().toLowerCase(); + if (slug && userInterestTags.has(slug)) { + overlap += 1; + } + } + + return overlap; +} + +function countNegativeTagOverlap(event, negativeFeedbackTags) { + if (!negativeFeedbackTags.size) { + return 0; + } + + const eventTags = event.customFields?.pivot?.tags; + if (!Array.isArray(eventTags) || !eventTags.length) { + return 0; + } + + let overlap = 0; + for (const raw of eventTags) { + if (typeof raw !== 'string') { + continue; + } + const slug = raw.trim().toLowerCase(); + if (slug && negativeFeedbackTags.has(slug)) { + overlap += 1; + } + } + + return overlap; +} + +function compareByFeedRank( + socialByEvent, + userInterestTags, + negativeFeedbackTags = new Set(), +) { + return (a, b) => { + const sa = socialByEvent.get(String(a._id)); + const sb = socialByEvent.get(String(b._id)); + const aRegistered = sa?.friendRegisteredCount || 0; + const bRegistered = sb?.friendRegisteredCount || 0; + if (aRegistered !== bRegistered) { + return bRegistered - aRegistered; + } + + const aInterested = sa?.friendInterestedCount || 0; + const bInterested = sb?.friendInterestedCount || 0; + if (aInterested !== bInterested) { + return bInterested - aInterested; + } + + const aOverlap = countInterestOverlap(a, userInterestTags); + const bOverlap = countInterestOverlap(b, userInterestTags); + if (aOverlap !== bOverlap) { + return bOverlap - aOverlap; + } + + const aPenalty = countNegativeTagOverlap(a, negativeFeedbackTags); + const bPenalty = countNegativeTagOverlap(b, negativeFeedbackTags); + if (aPenalty !== bPenalty) { + return aPenalty - bPenalty; + } + + const aStart = new Date(a.start_time).getTime() || 0; + const bStart = new Date(b.start_time).getTime() || 0; + return aStart - bStart; + }; +} + +async function loadUserInterestTags(req, userId) { + const { User } = getModels(req, 'User'); + const user = await User.findById(userId).select('pivotInterestTags').lean(); + return normalizeInterestTagSet(user?.pivotInterestTags); +} + +function collectCatalogTagsFromEvents(events) { + const tags = new Set(); + for (const event of events) { + const eventTags = event.customFields?.pivot?.tags; + if (!Array.isArray(eventTags)) { + continue; + } + for (const raw of eventTags) { + if (typeof raw !== 'string') { + continue; + } + const slug = raw.trim().toLowerCase(); + if (slug && PIVOT_TAG_SLUG_PATTERN.test(slug)) { + tags.add(slug); + } + } + } + return tags; +} + +async function loadNegativeFeedbackTags(req, userId) { + const { PIVOT_EVENT_FEATURE } = require('./pivotFeedbackService'); + const { UniversalFeedback, Event } = getModels(req, 'UniversalFeedback', 'Event'); + + const lowRatings = await UniversalFeedback.find({ + user: userId, + feature: PIVOT_EVENT_FEATURE, + 'responses.rating': { $lt: LOW_FEEDBACK_RATING_THRESHOLD }, + }) + .select('processId') + .lean(); + + if (!lowRatings.length) { + return new Set(); + } + + const eventIds = lowRatings.map((row) => row.processId); + const events = await Event.find({ + _id: { $in: eventIds }, + isDeleted: { $ne: true }, + }) + .select('customFields.pivot.tags') + .lean(); + + return collectCatalogTagsFromEvents(events); +} + +async function getPivotFeed(req, options = {}) { + const userId = req.user?.userId; + if (!userId) { + return { + error: 'Authentication required.', + status: 401, + code: 'UNAUTHORIZED', + }; + } + + const now = options.now || new Date(); + const batchWeek = options.batchWeek?.trim() || toIsoWeek(now); + if (options.batchWeek && !isValidIsoWeek(batchWeek)) { + return { + error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).', + status: 400, + code: 'INVALID_BATCH_WEEK', + }; + } + + const { Event } = getModels(req, 'Event'); + const { windowStart, windowEnd } = getPilotWindow(now); + const excludeEventIds = normalizeExcludeEventIds(options.excludeEventIds); + + const query = { + 'customFields.pivot.batchWeek': batchWeek, + 'customFields.pivot.ingestStatus': 'published', + status: { $in: PIVOT_EVENT_STATUSES }, + isDeleted: { $ne: true }, + start_time: { $gte: windowStart, $lt: windowEnd }, + 'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] }, + $and: [getUpcomingEventTimeFilter(now)], + }; + if (excludeEventIds.length) { + query._id = { $nin: excludeEventIds }; + } + + const events = await Event.find(query) + .select(PUBLIC_EVENT_FIELDS) + .sort({ registrationCount: -1, start_time: 1 }) + .lean(); + + const validEvents = events.filter( + (event) => + resolveDisplayHost(event.customFields?.pivot) && + isUpcomingPivotEvent(event, now), + ); + const eventIds = validEvents.map((event) => event._id); + const { userIntents, socialByEvent } = await loadFriendSocial( + req, + userId, + eventIds, + FRIEND_CAP, + batchWeek, + ); + + const userInterestTags = await loadUserInterestTags(req, userId); + const negativeFeedbackTags = await loadNegativeFeedbackTags(req, userId); + validEvents.sort( + compareByFeedRank(socialByEvent, userInterestTags, negativeFeedbackTags), + ); + + const tenant = await getTenantByKey(req, req.school); + const cityDisplayName = tenant?.location || tenant?.name || req.school; + + return { + data: { + batchWeek, + cityDisplayName, + events: validEvents.map((event) => { + const id = String(event._id); + const social = socialByEvent.get(id) || { + friendsInterested: [], + friendsGoing: [], + friendInterestedCount: 0, + friendRegisteredCount: 0, + }; + + return serializePivotFeedEvent(event, { + displayHost: resolveDisplayHost(event.customFields.pivot), + userIntent: userIntents.get(id) || null, + friendsInterested: social.friendsInterested, + friendsGoing: social.friendsGoing, + friendsInterestedCount: social.friendInterestedCount || 0, + friendsGoingCount: social.friendRegisteredCount || 0, + }); + }), + }, + }; +} + +async function getPivotEventFriends(req, eventId) { + const userId = req.user?.userId; + if (!userId) { + return { + error: 'Authentication required.', + status: 401, + code: 'UNAUTHORIZED', + }; + } + + const eventKey = String(eventId || '').trim(); + if (!mongoose.Types.ObjectId.isValid(eventKey)) { + return { + error: 'A valid eventId is required.', + status: 400, + code: 'INVALID_EVENT_ID', + }; + } + + const { Event } = getModels(req, 'Event'); + const event = await Event.findOne({ + _id: eventKey, + 'customFields.pivot.ingestStatus': 'published', + status: { $in: PIVOT_EVENT_STATUSES }, + isDeleted: { $ne: true }, + 'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] }, + }) + .select('_id') + .lean(); + + if (!event) { + return { + error: 'Event not found.', + status: 404, + code: 'EVENT_NOT_FOUND', + }; + } + + const { socialByEvent } = await loadFriendSocial( + req, + userId, + [eventKey], + Number.POSITIVE_INFINITY, + ); + const social = socialByEvent.get(eventKey) || { + friendsInterested: [], + friendsGoing: [], + }; + + return { + data: { + interested: social.friendsInterested, + going: social.friendsGoing, + }, + }; +} + +module.exports = { + getPivotFeed, + getPivotEventFriends, + getPilotWindow, + isUpcomingPivotEvent, + getUpcomingEventTimeFilter, + resolveDisplayHost, + serializePivotFeedEvent, + normalizeExcludeEventIds, + normalizeInterestTagSet, + countInterestOverlap, + countNegativeTagOverlap, + compareByFeedRank, + loadFriendSocial, + loadUserInterestTags, + loadNegativeFeedbackTags, + collectCatalogTagsFromEvents, + mapFriendPreview, + LOW_FEEDBACK_RATING_THRESHOLD, + PIVOT_EVENT_STATUSES, +}; diff --git a/backend/services/pivotFeedbackService.js b/backend/services/pivotFeedbackService.js new file mode 100644 index 00000000..dbbaa492 --- /dev/null +++ b/backend/services/pivotFeedbackService.js @@ -0,0 +1,247 @@ +const mongoose = require('mongoose'); +const getModels = require('./getModelService'); +const FeedbackService = require('./feedbackService'); +const { + findPublishedPivotEvent, + serializeRecapEvent, +} = require('./pivotIntentService'); +const { resolveDisplayHost, PIVOT_EVENT_STATUSES } = require('./pivotFeedService'); + +const PIVOT_EVENT_FEATURE = 'pivot_event'; +const RECAP_EVENT_FIELDS = + 'name description location start_time end_time externalLink type customFields.pivot'; + +function unauthorized() { + return { error: 'Authentication required.', status: 401, code: 'UNAUTHORIZED' }; +} + +function serializePendingEvent(event, userIntent) { + const base = serializeRecapEvent(event, userIntent); + return { + _id: base._id, + name: base.name, + end_time: base.end_time, + displayHost: base.displayHost, + batchWeek: event.customFields?.pivot?.batchWeek || null, + }; +} + +async function getPendingEventFeedback(req, options = {}) { + const userId = req.user?.userId; + if (!userId) { + return unauthorized(); + } + + const now = options.now || new Date(); + const { PivotEventIntent, Event, UniversalFeedback } = getModels( + req, + 'PivotEventIntent', + 'Event', + 'UniversalFeedback', + ); + + const intents = await PivotEventIntent.find({ + userId, + status: 'registered', + }) + .select('eventId') + .lean(); + + if (!intents.length) { + return { data: { events: [] } }; + } + + const eventIds = intents.map((intent) => intent.eventId); + + const events = await Event.find({ + _id: { $in: eventIds }, + end_time: { $lt: now }, + 'customFields.pivot.ingestStatus': 'published', + status: { $in: PIVOT_EVENT_STATUSES }, + isDeleted: { $ne: true }, + 'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] }, + }) + .select(RECAP_EVENT_FIELDS) + .sort({ end_time: 1 }) + .lean(); + + const eligible = events.filter((event) => resolveDisplayHost(event.customFields?.pivot)); + if (!eligible.length) { + return { data: { events: [] } }; + } + + const eligibleIds = eligible.map((event) => event._id); + const submitted = await UniversalFeedback.find({ + user: userId, + feature: PIVOT_EVENT_FEATURE, + processId: { $in: eligibleIds }, + }) + .select('processId') + .lean(); + + const submittedIds = new Set(submitted.map((row) => String(row.processId))); + const pending = eligible.filter((event) => !submittedIds.has(String(event._id))); + + return { + data: { + events: pending.map((event) => serializePendingEvent(event, 'registered')), + }, + }; +} + +async function submitEventFeedback(req, body = {}) { + const userId = req.user?.userId; + if (!userId) { + return unauthorized(); + } + + const eventId = String(body.eventId || '').trim(); + const rating = body.rating; + const comment = + typeof body.comment === 'string' ? body.comment.trim() : undefined; + + if (!mongoose.Types.ObjectId.isValid(eventId)) { + return { + error: 'A valid eventId is required.', + status: 400, + code: 'INVALID_EVENT_ID', + }; + } + + const ratingNumber = Number(rating); + if (!Number.isInteger(ratingNumber) || ratingNumber < 1 || ratingNumber > 5) { + return { + error: 'rating must be an integer from 1 to 5.', + status: 400, + code: 'INVALID_RATING', + }; + } + + const now = body.now || new Date(); + const event = await findPublishedPivotEvent(req, eventId); + if (!event) { + return { + error: 'Event is not an active Pivot catalog event.', + status: 404, + code: 'EVENT_NOT_FOUND', + }; + } + + if (!event.end_time || new Date(event.end_time) >= now) { + return { + error: 'Feedback is only available after the event ends.', + status: 403, + code: 'EVENT_NOT_ENDED', + }; + } + + const { PivotEventIntent } = getModels(req, 'PivotEventIntent'); + const intent = await PivotEventIntent.findOne({ + userId, + eventId, + status: 'registered', + }).lean(); + + if (!intent) { + return { + error: 'Only users who confirmed a ticket can leave feedback.', + status: 403, + code: 'NOT_REGISTERED', + }; + } + + const responses = { rating: ratingNumber }; + if (comment) { + responses.comment = comment; + } + + const batchWeek = event.customFields?.pivot?.batchWeek || intent.batchWeek || null; + const metadata = { batchWeek, source: 'pivot_mobile' }; + + try { + const feedbackService = new FeedbackService(req); + await feedbackService.ensurePivotEventFeedbackConfig(userId); + const feedback = await feedbackService.submitFeedback( + userId, + PIVOT_EVENT_FEATURE, + eventId, + responses, + metadata, + ); + + return { + data: { + eventId: String(feedback.processId), + rating: ratingNumber, + submittedAt: feedback.submittedAt, + }, + }; + } catch (err) { + if (err.message?.includes('No feedback configuration')) { + return { + error: 'Pivot event feedback is not configured for this tenant.', + status: 503, + code: 'FEEDBACK_NOT_CONFIGURED', + }; + } + if (err.message?.includes('Validation errors')) { + return { + error: err.message, + status: 400, + code: 'VALIDATION_ERROR', + }; + } + throw err; + } +} + +async function listUserPivotEventFeedback(req, options = {}) { + const userId = req.user?.userId; + if (!userId) { + return unauthorized(); + } + + const limit = Math.min(Math.max(Number(options.limit) || 20, 1), 50); + const { UniversalFeedback, Event } = getModels(req, 'UniversalFeedback', 'Event'); + + const rows = await UniversalFeedback.find({ + user: userId, + feature: PIVOT_EVENT_FEATURE, + }) + .sort({ submittedAt: -1 }) + .limit(limit) + .lean(); + + if (!rows.length) { + return { data: { feedback: [] } }; + } + + const eventIds = rows.map((row) => row.processId); + const events = await Event.find({ _id: { $in: eventIds } }) + .select('name customFields.pivot.host') + .lean(); + const eventById = new Map(events.map((event) => [String(event._id), event])); + + const feedback = rows.map((row) => { + const event = eventById.get(String(row.processId)); + const hostName = event?.customFields?.pivot?.host?.name || null; + return { + eventId: String(row.processId), + eventName: event?.name || null, + hostName, + rating: row.responses?.rating ?? null, + comment: row.responses?.comment ?? null, + batchWeek: row.metadata?.batchWeek ?? null, + submittedAt: row.submittedAt, + }; + }); + + return { data: { feedback } }; +} + +module.exports = { + getPendingEventFeedback, + submitEventFeedback, + listUserPivotEventFeedback, + PIVOT_EVENT_FEATURE, +}; diff --git a/backend/services/pivotFriendService.js b/backend/services/pivotFriendService.js new file mode 100644 index 00000000..5d221571 --- /dev/null +++ b/backend/services/pivotFriendService.js @@ -0,0 +1,320 @@ +const mongoose = require('mongoose'); +const getModels = require('./getModelService'); +const NotificationService = require('./notificationService'); +const { getFriendRequests } = require('../utilities/friendUtils'); + +const SEARCH_RESULT_LIMIT = 20; +const MIN_QUERY_LENGTH = 2; + +function unauthorized() { + return { error: 'Authentication required.', status: 401, code: 'UNAUTHORIZED' }; +} + +function normalizeQuery(q) { + return String(q || '').trim(); +} + +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function buildNameUsernameQuery(term) { + const regex = new RegExp(escapeRegex(term), 'i'); + return { + $or: [{ name: { $regex: regex } }, { username: { $regex: regex } }], + }; +} + +function resolveFriendshipStatus(friendship, currentUserId) { + if (!friendship) return 'none'; + if (friendship.status === 'accepted') return 'accepted'; + if (friendship.status !== 'pending') return 'none'; + + return friendship.requester.toString() === currentUserId.toString() + ? 'pending_outgoing' + : 'pending_incoming'; +} + +function serializeSearchUser(user, friendshipStatus) { + const row = { + id: user._id.toString(), + name: user.name || '', + picture: user.picture || null, + friendshipStatus, + }; + + if (user.username) { + row.username = user.username; + } + + return row; +} + +/** + * Search for users in the current pilot city tenant by display name or username. + * Scoped to req.db (derived from req.school subdomain) — not cross-tenant. + */ +async function searchPivotFriends(req, options = {}) { + const userId = req.user?.userId; + if (!userId) { + return unauthorized(); + } + + const query = normalizeQuery(options.q); + if (query.length < MIN_QUERY_LENGTH) { + return { data: { users: [] } }; + } + + const { User, Friendship } = getModels(req, 'User', 'Friendship'); + + const users = await User.find({ + ...buildNameUsernameQuery(query), + _id: { $ne: userId }, + }) + .select('name picture username') + .limit(SEARCH_RESULT_LIMIT) + .lean(); + + if (!users.length) { + return { data: { users: [] } }; + } + + const hitIds = users.map((user) => user._id); + const friendships = await Friendship.find({ + $or: [ + { requester: userId, recipient: { $in: hitIds } }, + { requester: { $in: hitIds }, recipient: userId }, + ], + }) + .select('requester recipient status') + .lean(); + + const friendshipByOtherId = new Map(); + for (const friendship of friendships) { + const otherId = + friendship.requester.toString() === userId.toString() + ? friendship.recipient.toString() + : friendship.requester.toString(); + friendshipByOtherId.set(otherId, friendship); + } + + const results = users.map((user) => + serializeSearchUser(user, resolveFriendshipStatus(friendshipByOtherId.get(user._id.toString()), userId)), + ); + + return { data: { users: results } }; +} + +/** + * Send a friend request to another user in the current pilot city tenant by userId. + * Pivot users may lack a campus username; this avoids POST /friend-request/:username. + */ +async function sendPivotFriendRequest(req, body = {}) { + const requesterId = req.user?.userId; + if (!requesterId) { + return unauthorized(); + } + + const recipientId = String(body.userId || '').trim(); + if (!mongoose.Types.ObjectId.isValid(recipientId)) { + return { + error: 'A valid userId is required.', + status: 400, + code: 'INVALID_USER_ID', + }; + } + + if (recipientId === requesterId.toString()) { + return { + error: 'Cannot send friend request to self.', + status: 400, + code: 'SELF_REQUEST', + }; + } + + const { User, Friendship, Notification } = getModels(req, 'User', 'Friendship', 'Notification'); + + const recipient = await User.findById(recipientId).select('name username').lean(); + if (!recipient) { + return { error: 'User not found.', status: 404, code: 'USER_NOT_FOUND' }; + } + + const existingFriendship = await Friendship.findOne({ + $or: [ + { requester: requesterId, recipient: recipientId }, + { requester: recipientId, recipient: requesterId }, + ], + }); + + if (existingFriendship) { + if (existingFriendship.status === 'pending') { + return { + error: 'Friend request already sent.', + status: 400, + code: 'REQUEST_PENDING', + }; + } + if (existingFriendship.status === 'accepted') { + return { + error: 'You are already friends with this user.', + status: 400, + code: 'ALREADY_FRIENDS', + }; + } + } + + const requesterUser = await User.findById(requesterId).select('name username').lean(); + if (!requesterUser) { + return { error: 'User not found.', status: 404, code: 'REQUESTER_NOT_FOUND' }; + } + + const newFriendship = await new Friendship({ + requester: requesterId, + recipient: recipientId, + status: 'pending', + }).save(); + + const notificationService = NotificationService.withModels({ Notification, User }); + const senderName = + requesterUser.name?.trim() || + requesterUser.username?.trim() || + 'Someone'; + + await notificationService.createSystemNotification(recipientId, 'User', 'friend_request', { + senderName, + friendshipId: newFriendship._id, + sender: requesterId, + }); + + return { + data: { + friendshipId: newFriendship._id.toString(), + friendshipStatus: 'pending_outgoing', + }, + }; +} + +/** + * List accepted friends for the current pilot city tenant. + * Uses the same tenant-scoped Friendship/User models as campus /getFriends. + */ +async function listPivotFriends(req) { + const userId = req.user?.userId; + if (!userId) { + return unauthorized(); + } + + const { User, Friendship } = getModels(req, 'User', 'Friendship'); + + const friendships = await Friendship.find({ + $or: [ + { requester: userId, status: 'accepted' }, + { recipient: userId, status: 'accepted' }, + ], + }).populate('requester recipient', 'username name picture email'); + + const friendIds = friendships.map((friendship) => + friendship.requester._id.toString() === userId.toString() + ? friendship.recipient._id + : friendship.requester._id, + ); + + const friends = friendIds.length + ? await User.find({ _id: { $in: friendIds } }).select('name username picture email partners') + : []; + + return { data: { friends } }; +} + +/** + * List pending friend requests (received + sent) for the current pilot city tenant. + */ +async function listPivotFriendRequests(req) { + const userId = req.user?.userId; + if (!userId) { + return unauthorized(); + } + + const { Friendship } = getModels(req, 'Friendship'); + const friendRequests = await getFriendRequests(Friendship, userId, { + receivedFields: 'username name picture email _id', + sentFields: 'username name picture email _id', + lean: true, + }); + + return { data: friendRequests }; +} + +async function acceptPivotFriendRequest(req, friendshipId) { + const recipientId = req.user?.userId; + if (!recipientId) { + return unauthorized(); + } + + const id = String(friendshipId || '').trim(); + if (!mongoose.Types.ObjectId.isValid(id)) { + return { + error: 'A valid friendshipId is required.', + status: 400, + code: 'INVALID_FRIENDSHIP_ID', + }; + } + + const { User, Friendship } = getModels(req, 'User', 'Friendship'); + + const friendship = await Friendship.findById(id); + if (!friendship) { + return { error: 'Friendship not found.', status: 404, code: 'FRIENDSHIP_NOT_FOUND' }; + } + if (friendship.recipient.toString() !== recipientId.toString()) { + return { error: 'Not authorized to accept request.', status: 403, code: 'FORBIDDEN' }; + } + + friendship.status = 'accepted'; + await friendship.save(); + await User.updateOne({ _id: friendship.requester }, { $inc: { partners: 1 } }); + await User.updateOne({ _id: friendship.recipient }, { $inc: { partners: 1 } }); + + return { data: { friendshipId: friendship._id.toString(), status: 'accepted' } }; +} + +async function declinePivotFriendRequest(req, friendshipId) { + const recipientId = req.user?.userId; + if (!recipientId) { + return unauthorized(); + } + + const id = String(friendshipId || '').trim(); + if (!mongoose.Types.ObjectId.isValid(id)) { + return { + error: 'A valid friendshipId is required.', + status: 400, + code: 'INVALID_FRIENDSHIP_ID', + }; + } + + const { Friendship } = getModels(req, 'Friendship'); + + const friendship = await Friendship.findById(id); + if (!friendship) { + return { error: 'Friendship not found.', status: 404, code: 'FRIENDSHIP_NOT_FOUND' }; + } + if (friendship.recipient.toString() !== recipientId.toString()) { + return { error: 'Not authorized to reject request.', status: 403, code: 'FORBIDDEN' }; + } + + await Friendship.deleteOne({ _id: friendship._id }); + + return { data: { friendshipId: id, status: 'declined' } }; +} + +module.exports = { + searchPivotFriends, + sendPivotFriendRequest, + listPivotFriends, + listPivotFriendRequests, + acceptPivotFriendRequest, + declinePivotFriendRequest, + SEARCH_RESULT_LIMIT, + MIN_QUERY_LENGTH, +}; diff --git a/backend/services/pivotIngestDuplicateService.js b/backend/services/pivotIngestDuplicateService.js new file mode 100644 index 00000000..4cc7c6f9 --- /dev/null +++ b/backend/services/pivotIngestDuplicateService.js @@ -0,0 +1,218 @@ +const getModels = require('./getModelService'); +const { connectToDatabase } = require('../connectionsManager'); + +function trimString(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function parseDateTime(value) { + if (!value) return null; + const parsed = value instanceof Date ? value : new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +/** Canonical Partiful/Luma ingest URL for duplicate checks. */ +function normalizeIngestSourceUrl(raw) { + const trimmed = trimString(raw); + if (!trimmed) return null; + + try { + const parsed = new URL(trimmed); + let host = parsed.hostname.toLowerCase().replace(/^www\./, ''); + if (host === 'lu.ma') host = 'luma.com'; + const path = parsed.pathname.replace(/\/+$/, '') || '/'; + return `${host}${path}`.toLowerCase(); + } catch { + return trimmed.toLowerCase(); + } +} + +function normalizeEventText(value) { + return trimString(value) + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** Name + start minute + location fingerprint for near-duplicate detection. */ +function buildEventFingerprint({ name, start_time, location }) { + const title = normalizeEventText(name); + const place = normalizeEventText(location); + const start = parseDateTime(start_time); + const startKey = start ? start.toISOString().slice(0, 16) : ''; + + if (!title && !startKey && !place) { + return null; + } + + return `${title}|${startKey}|${place}`; +} + +function summarizeCatalogEvent(event) { + const pivot = event.customFields?.pivot || {}; + const host = pivot.host || {}; + + return { + _id: String(event._id), + name: event.name || '', + batchWeek: pivot.batchWeek || null, + organizerName: host.name || '', + sourceKey: normalizeIngestSourceUrl(pivot.sourceUrl || event.externalLink), + fingerprint: buildEventFingerprint({ + name: event.name, + start_time: event.start_time, + location: event.location, + }), + }; +} + +async function loadCatalogDuplicateIndex(tenantKey) { + const db = await connectToDatabase(tenantKey); + const { Event } = getModels({ db }, 'Event'); + + const events = await Event.find({ + 'customFields.pivot': { $exists: true }, + isDeleted: { $ne: true }, + }) + .select('name start_time location externalLink customFields.pivot') + .lean(); + + return events.map(summarizeCatalogEvent); +} + +function duplicateSummary(existing, matchType, { willUpdate = false } = {}) { + return { + matchType, + willUpdate, + existingEventId: existing._id, + existingName: existing.name, + existingBatchWeek: existing.batchWeek, + existingOrganizerName: existing.organizerName, + }; +} + +function findCatalogDuplicate(index, candidate) { + const sourceKey = normalizeIngestSourceUrl(candidate.sourceUrl); + const fingerprint = buildEventFingerprint(candidate); + + if (sourceKey) { + const bySource = index.find((row) => row.sourceKey && row.sourceKey === sourceKey); + if (bySource) { + return duplicateSummary(bySource, 'sourceUrl', { willUpdate: true }); + } + } + + if (fingerprint) { + const byFingerprint = index.find((row) => row.fingerprint && row.fingerprint === fingerprint); + if (byFingerprint) { + return duplicateSummary(byFingerprint, 'fingerprint'); + } + } + + return null; +} + +function annotateImportDrafts(drafts, catalogIndex = []) { + const seenSourceKeys = new Map(); + const seenFingerprints = new Map(); + const duplicateWarnings = []; + + const annotated = drafts.map((entry, index) => { + const candidate = { + name: entry.draft?.name, + start_time: entry.draft?.start_time, + location: entry.draft?.location, + sourceUrl: entry.sourceUrl || entry.draft?.sourceUrl, + }; + + const sourceKey = normalizeIngestSourceUrl(candidate.sourceUrl); + const fingerprint = buildEventFingerprint(candidate); + let duplicate = findCatalogDuplicate(catalogIndex, candidate); + + if (!duplicate && sourceKey && seenSourceKeys.has(sourceKey)) { + duplicate = { + matchType: 'batchSourceUrl', + willUpdate: false, + existingEventId: null, + existingName: seenSourceKeys.get(sourceKey).name, + existingBatchWeek: null, + existingOrganizerName: null, + batchIndex: seenSourceKeys.get(sourceKey).index, + }; + } else if (!duplicate && fingerprint && seenFingerprints.has(fingerprint)) { + duplicate = { + matchType: 'batchFingerprint', + willUpdate: false, + existingEventId: null, + existingName: seenFingerprints.get(fingerprint).name, + existingBatchWeek: null, + existingOrganizerName: null, + batchIndex: seenFingerprints.get(fingerprint).index, + }; + } + + if (!seenSourceKeys.has(sourceKey) && sourceKey) { + seenSourceKeys.set(sourceKey, { index, name: candidate.name || entry.sourceUrl }); + } + if (!seenFingerprints.has(fingerprint) && fingerprint) { + seenFingerprints.set(fingerprint, { index, name: candidate.name || 'event' }); + } + + if (duplicate) { + duplicateWarnings.push(formatDuplicateWarning(duplicate, candidate.name)); + } + + return { + ...entry, + duplicate, + }; + }); + + return { drafts: annotated, duplicateWarnings }; +} + +function formatDuplicateWarning(duplicate, candidateName) { + const label = candidateName || 'Event'; + if (duplicate.matchType === 'sourceUrl') { + 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).`; + } + if (duplicate.matchType === 'batchSourceUrl') { + return `${label} duplicates another row in this import batch (same source URL).`; + } + if (duplicate.matchType === 'batchFingerprint') { + return `${label} duplicates another row in this import batch (same title, time, and location).`; + } + return `${label} looks like a duplicate.`; +} + +function isBlockingDuplicate(duplicate) { + if (!duplicate) return false; + if (duplicate.matchType === 'sourceUrl') return false; + return true; +} + +async function resolveImportDuplicate(req, { tenantKey, candidate }) { + if (!tenantKey) { + return { duplicate: null, catalogIndex: [] }; + } + + const catalogIndex = await loadCatalogDuplicateIndex(tenantKey); + const duplicate = findCatalogDuplicate(catalogIndex, candidate); + return { duplicate, catalogIndex }; +} + +module.exports = { + normalizeIngestSourceUrl, + buildEventFingerprint, + summarizeCatalogEvent, + loadCatalogDuplicateIndex, + findCatalogDuplicate, + annotateImportDrafts, + formatDuplicateWarning, + isBlockingDuplicate, + resolveImportDuplicate, +}; diff --git a/backend/services/pivotIngestPreviewService.js b/backend/services/pivotIngestPreviewService.js new file mode 100644 index 00000000..3e9f55e2 --- /dev/null +++ b/backend/services/pivotIngestPreviewService.js @@ -0,0 +1,1156 @@ +const axios = require('axios'); +const { + annotateImportDrafts, + formatDuplicateWarning, + isBlockingDuplicate, + loadCatalogDuplicateIndex, + resolveImportDuplicate, +} = require('./pivotIngestDuplicateService'); + +const FETCH_TIMEOUT_MS = 10_000; +const MAX_BATCH_EVENTS = 50; +const MAX_BATCH_ENRICH = MAX_BATCH_EVENTS; +const HOST_ENRICH_CONCURRENCY = 4; + +const ALLOWED_HOST_SUFFIXES = ['partiful.com', 'lu.ma', 'luma.com']; + +const PROVIDER_LABELS = { + partiful: 'Partiful', + luma: 'Luma', +}; + +function decodeHtmlEntities(value) { + if (!value) return value; + return value + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'"); +} + +function extractMetaContent(html, key) { + const patterns = [ + new RegExp( + `]*(?:property|name)=["']${key}["'][^>]*content=["']([^"']*)["']`, + 'i', + ), + new RegExp( + `]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${key}["']`, + 'i', + ), + ]; + + for (const pattern of patterns) { + const match = html.match(pattern); + if (match?.[1]) { + return decodeHtmlEntities(match[1].trim()); + } + } + + return null; +} + +function extractJsonLdBlocks(html) { + const blocks = []; + const pattern = + /]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; + + let match = pattern.exec(html); + while (match) { + const raw = match[1]?.trim(); + if (raw) { + try { + blocks.push(JSON.parse(raw)); + } catch { + // Skip malformed JSON-LD blocks. + } + } + match = pattern.exec(html); + } + + return blocks; +} + +function flattenJsonLdNodes(block) { + const nodes = []; + + function walk(value) { + if (!value) return; + if (Array.isArray(value)) { + value.forEach(walk); + return; + } + if (typeof value !== 'object') return; + + nodes.push(value); + if (Array.isArray(value['@graph'])) { + value['@graph'].forEach(walk); + } + } + + walk(block); + return nodes; +} + +function hasType(node, typeName) { + const type = node['@type']; + if (Array.isArray(type)) { + return type.some((entry) => String(entry).toLowerCase() === typeName.toLowerCase()); + } + return String(type || '').toLowerCase() === typeName.toLowerCase(); +} + +function organizerNameFromNode(node) { + if (!node || typeof node !== 'object') return null; + if (typeof node.name === 'string' && node.name.trim()) { + return node.name.trim(); + } + return null; +} + +function isInvalidHostName(name) { + if (typeof name !== 'string') return true; + const normalized = name.trim().toLowerCase(); + return ( + !normalized || + normalized === 'partiful.com' || + normalized === 'luma.com' || + normalized === 'lu.ma' || + normalized === 'partiful' || + normalized === 'luma' + ); +} + +function firstPlausibleHostName(...values) { + for (const value of values) { + if (typeof value === 'string' && value.trim() && !isInvalidHostName(value)) { + return value.trim(); + } + } + return null; +} + +function isProfileOrAvatarImageUrl(raw) { + const normalized = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + if (!normalized) return false; + + return ( + /(?:^|[/])(?:avatars|profileimages)(?:[/]|$)/.test(normalized) || + normalized.includes('cdn.lu.ma/avatars') || + (normalized.includes('lumacdn.com/avatars') && normalized.includes('/uc/')) + ); +} + +function sanitizeEventPosterImage(raw) { + const trimmed = typeof raw === 'string' ? raw.trim() : ''; + if (!trimmed || isProfileOrAvatarImageUrl(trimmed)) { + return null; + } + return trimmed; +} + +function joinHostNames(names, limit = 3) { + const unique = []; + for (const name of names) { + const trimmed = typeof name === 'string' ? name.trim() : ''; + if (!trimmed || isInvalidHostName(trimmed)) continue; + if (!unique.some((existing) => existing.toLowerCase() === trimmed.toLowerCase())) { + unique.push(trimmed); + } + } + + if (!unique.length) return null; + return unique.slice(0, limit).join(' & '); +} + +function organizerNamesFromNodes(organizer) { + if (!organizer) return null; + + const nodes = Array.isArray(organizer) ? organizer : [organizer]; + const organizationNames = nodes + .filter((node) => hasType(node, 'Organization')) + .map(organizerNameFromNode) + .filter(Boolean); + if (organizationNames.length) { + return joinHostNames(organizationNames, 2); + } + + return joinHostNames(nodes.map(organizerNameFromNode).filter(Boolean)); +} + +function hostNamesFromPartifulHosts(hosts) { + if (!Array.isArray(hosts) || !hosts.length) { + return { hostName: null, hostImageUrl: null }; + } + + const normalized = hosts + .map((host) => ({ + name: typeof host?.name === 'string' ? host.name.trim() : '', + isManaged: host?.isManaged === true, + host, + })) + .filter((entry) => entry.name && !isInvalidHostName(entry.name)); + + if (!normalized.length) { + return { hostName: null, hostImageUrl: null }; + } + + const managed = normalized.filter((entry) => entry.isManaged); + const chosen = managed.length ? managed : normalized; + const hostName = joinHostNames( + chosen.map((entry) => entry.name), + managed.length ? 2 : 3, + ); + const primary = chosen[0]; + + return { + hostName, + hostImageUrl: null, + }; +} + +function hostNamesFromLumaHosts(hosts) { + if (!Array.isArray(hosts) || !hosts.length) { + return { hostName: null, hostImageUrl: null }; + } + + const normalized = hosts + .map((host) => { + if (typeof host === 'string') { + return { name: host.trim(), avatarUrl: null }; + } + + const name = + host?.name?.trim() || + [host?.first_name, host?.last_name] + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter(Boolean) + .join(' ') + .trim(); + + return { + name, + avatarUrl: host?.avatar_url?.trim() || null, + }; + }) + .filter((entry) => entry.name && !isInvalidHostName(entry.name)); + + if (!normalized.length) { + return { hostName: null, hostImageUrl: null }; + } + + return { + hostName: joinHostNames( + normalized.map((entry) => entry.name), + 3, + ), + hostImageUrl: null, + }; +} + +function parsePartifulPageProps(html) { + const match = html.match(/ + + +`; + +const LUMA_HTML = ` + + + + + + + + +`; + +describe('pivotIngestPreviewService normalizeUrl', () => { + it('accepts Partiful URLs', () => { + const result = normalizeUrl('https://partiful.com/e/sunset-listening'); + expect(result.url).toBe('https://partiful.com/e/sunset-listening'); + expect(result.provider).toBe('partiful'); + }); + + it('accepts Luma URLs', () => { + const result = normalizeUrl('https://lu.ma/open-mic-night'); + expect(result.provider).toBe('luma'); + }); + + it('rejects garbage URLs', () => { + const result = normalizeUrl('not-a-url'); + expect(result.error).toBeTruthy(); + expect(result.status).toBe(400); + expect(result.code).toBe('INVALID_URL'); + }); + + it('rejects unsupported hosts', () => { + const result = normalizeUrl('https://example.com/event'); + expect(result.code).toBe('UNSUPPORTED_HOST'); + expect(result.status).toBe(400); + }); +}); + +describe('pivotIngestPreviewService buildDraft', () => { + it('parses Partiful Open Graph and JSON-LD including hostName', () => { + const { draft, warnings } = buildDraft({ + html: PARTIFUL_HTML, + provider: 'partiful', + sourceUrl: 'https://partiful.com/e/sunset-listening', + }); + + expect(draft.name).toBe('Sunset Listening Party'); + expect(draft.description).toContain('blanket'); + expect(draft.image).toContain('sunset.jpg'); + expect(draft.hostName).toBe('Brooklyn Board Game Cafe'); + expect(draft.image).toContain('sunset.jpg'); + expect(draft.hostImageUrl).toBeNull(); + expect(draft.location).toBe('Brooklyn Bridge Park'); + expect(draft.start_time).toContain('2026-07-12'); + expect(warnings).toEqual([]); + }); + + it('parses Luma draft with organizer name', () => { + const { draft } = buildDraft({ + html: LUMA_HTML, + provider: 'luma', + sourceUrl: 'https://lu.ma/open-mic-night', + }); + + expect(draft.name).toBe('Open Mic Night'); + expect(draft.hostName).toBe('Luma Host Collective'); + expect(draft.location).toBe('East Village Studio'); + }); + + it('adds warnings when fields are missing', () => { + const { warnings } = buildDraft({ + html: 'Empty', + provider: 'partiful', + sourceUrl: 'https://partiful.com/e/empty', + }); + + expect(warnings.some((entry) => entry.includes('title'))).toBe(true); + expect(warnings.some((entry) => entry.includes('organizer'))).toBe(true); + }); + + it('ignores Next.js hostname and reads JSON-LD organizer arrays', () => { + const html = ` + + + + + + + +`; + + const { draft } = buildDraft({ + html, + provider: 'partiful', + sourceUrl: 'https://partiful.com/e/sunset-party', + }); + + expect(draft.hostName).toBe('Brooklyn Board Game Cafe'); + expect(draft.hostName).not.toBe('partiful.com'); + }); + + it('joins multiple JSON-LD organizers when no organization is present', () => { + const html = ` + + + + + +`; + + const { draft } = buildDraft({ + html, + provider: 'partiful', + sourceUrl: 'https://partiful.com/e/co-hosted', + }); + + expect(draft.hostName).toBe('Alice & Bob'); + }); + + it('parses Partiful single-event NEXT_DATA when JSON-LD is absent', () => { + const html = ` + + + + + + +`; + + const { draft, warnings } = buildDraft({ + html, + provider: 'partiful', + sourceUrl: 'https://partiful.com/e/abc123', + }); + + expect(draft.name).toBe('Sunset Listening Party'); + expect(draft.location).toBe('Williamsburg, Brooklyn'); + expect(draft.hostName).toBe('Roof Records'); + expect(draft.start_time).toContain('2026-07-12'); + expect(draft.sourceTags).toEqual(['Music', 'Nightlife']); + expect(warnings).toEqual([]); + }); + + it('reads Partiful multi-host pages from NEXT_DATA hosts array', () => { + const html = ` + + + + + +`; + + const { draft } = buildDraft({ + html, + provider: 'partiful', + sourceUrl: 'https://partiful.com/e/golden-gate', + }); + + expect(draft.hostName).toBe('(un)PTO'); + }); +}); + +describe('pivotIngestPreviewService helpers', () => { + it('extractMetaContent reads og tags', () => { + expect(extractMetaContent(PARTIFUL_HTML, 'og:title')).toBe('Sunset Listening Party'); + }); + + it('extractJsonLdBlocks parses script payloads', () => { + const blocks = extractJsonLdBlocks(PARTIFUL_HTML); + expect(blocks).toHaveLength(1); + expect(blocks[0]['@type']).toBe('Event'); + }); +}); + +describe('pivotIngestPreviewService previewIngestUrl', () => { + beforeEach(() => { + axios.get.mockReset(); + }); + + it('returns draft for a valid Partiful URL', async () => { + axios.get.mockResolvedValue({ data: PARTIFUL_HTML }); + + const result = await previewIngestUrl({}, { + url: 'https://partiful.com/e/sunset-listening', + }); + + expect(result.data.mode).toBe('single'); + expect(result.data.draft.hostName).toBe('Brooklyn Board Game Cafe'); + expect(result.data.draft.source).toBe('partiful'); + expect(axios.get).toHaveBeenCalledWith( + 'https://partiful.com/e/sunset-listening', + expect.objectContaining({ timeout: 10000 }), + ); + }); + + it('returns 504 on fetch timeout', async () => { + const timeoutError = new Error('timeout'); + timeoutError.code = 'ECONNABORTED'; + axios.get.mockRejectedValue(timeoutError); + + const result = await previewIngestUrl({}, { + url: 'https://partiful.com/e/slow', + }); + + expect(result.status).toBe(504); + expect(result.code).toBe('FETCH_TIMEOUT'); + }); + + it('returns 400 for garbage URL without fetching', async () => { + const result = await previewIngestUrl({}, { url: '%%%' }); + expect(result.status).toBe(400); + expect(axios.get).not.toHaveBeenCalled(); + }); +}); + +const LUMA_DISCOVER_HTML = ` + + + + + +`; + +const PARTIFUL_EXPLORE_HTML = ` + + + + + + +`; + +const LUMA_DISCOVER_NEXT_DATA_HTML = ` + + + + + + +`; + +describe('pivotIngestPreviewService batch parsing', () => { + it('classifies Partiful explore URLs as batch links', () => { + const normalized = normalizeUrl('https://partiful.com/explore/sf'); + expect(normalized.provider).toBe('partiful'); + expect(classifyIngestUrl(normalized.parsed, normalized.provider).kind).toBe('batch'); + }); + + it('parses Partiful explore events from NEXT_DATA', () => { + const result = parsePartifulExploreBatch( + PARTIFUL_EXPLORE_HTML, + 'https://partiful.com/explore/sf', + ); + + expect(result.drafts).toHaveLength(1); + expect(result.drafts[0].draft.name).toBe('Golden Gate Park Loop'); + expect(result.drafts[0].sourceUrl).toBe('https://partiful.com/e/et4Dy1XUkStHaCOavIfo'); + expect(result.drafts[0].draft.hostName).toBe('Trail Club'); + expect(result.drafts[0].draft.image).toBe( + 'https://partiful.imgix.net/external/user/bdCN1QDusxUzOvf2utH7X2jNx0o1/L6dpZYZxU-hmzRYVUklAT?w=598&h=642&fit=clip', + ); + }); + + it('parses Luma discover events from NEXT_DATA with cover images', () => { + const result = parseLumaDiscoverBatch(LUMA_DISCOVER_NEXT_DATA_HTML, 'https://luma.com/sf'); + + expect(result.drafts).toHaveLength(1); + expect(result.drafts[0].draft.name).toBe('Founders Cowork'); + expect(result.drafts[0].draft.hostName).toBe('Vivian Cai & Adrian Yumul'); + expect(result.drafts[0].draft.image).toBe('https://images.lumacdn.com/uploads/xr/dc792c6b.jpg'); + expect(result.drafts[0].sourceUrl).toBe('https://luma.com/yg5x8n8b'); + }); + + it('parses Luma discover ItemList events', () => { + const result = parseLumaDiscoverBatch(LUMA_DISCOVER_HTML, 'https://luma.com/sf'); + + expect(result.drafts).toHaveLength(1); + expect(result.drafts[0].draft.name).toBe('Founders Cowork'); + expect(result.drafts[0].draft.hostName).toBe('Vivian Cai'); + 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 }); + + const result = await previewIngestUrl({}, { url: 'https://luma.com/sf' }); + + expect(result.data.mode).toBe('batch'); + expect(result.data.drafts).toHaveLength(1); + }); +}); diff --git a/backend/tests/unit/pivotIngestPublishService.test.js b/backend/tests/unit/pivotIngestPublishService.test.js new file mode 100644 index 00000000..bf16d719 --- /dev/null +++ b/backend/tests/unit/pivotIngestPublishService.test.js @@ -0,0 +1,352 @@ +jest.mock('../../services/getModelService', () => jest.fn()); +jest.mock('../../services/tenantConfigService', () => ({ + getMergedTenants: jest.fn(), + provisionPivotCatalogOrg: jest.fn(), +})); +jest.mock('../../services/pivotReferralCodeService', () => ({ + isPivotTenant: (tenant) => tenant.pivotPilot === true || tenant.tenantType === 'pivot', +})); +jest.mock('../../connectionsManager', () => ({ + connectToDatabase: jest.fn(), +})); +jest.mock('../../services/pivotIngestPreviewService', () => ({ + previewIngestUrl: jest.fn(), + normalizeUrl: jest.fn(), + sanitizeEventPosterImage: (raw) => (typeof raw === 'string' && raw.trim() ? raw.trim() : null), +})); +jest.mock('../../services/pivotIngestDuplicateService', () => ({ + formatDuplicateWarning: jest.fn((duplicate, name) => `${name} is a duplicate.`), + isBlockingDuplicate: jest.fn(() => false), + resolveImportDuplicate: jest.fn().mockResolvedValue({ duplicate: null, catalogIndex: [] }), +})); +jest.mock('../../services/pivotWeeklySnapshotService', () => ({ + normalizeBatchWeek: (raw, now = new Date()) => { + const batchWeek = raw?.trim() || '2026-W26'; + if (!/^\d{4}-W\d{2}$/.test(batchWeek)) { + return { error: 'invalid', status: 400, code: 'INVALID_BATCH_WEEK' }; + } + return { batchWeek }; + }, +})); +jest.mock('../../services/pivotTagCatalogService', () => ({ + validatePivotEventTags: jest.fn(), +})); + +const getModels = require('../../services/getModelService'); +const { getMergedTenants, provisionPivotCatalogOrg } = require('../../services/tenantConfigService'); +const { connectToDatabase } = require('../../connectionsManager'); +const { previewIngestUrl, normalizeUrl } = require('../../services/pivotIngestPreviewService'); +const { resolveImportDuplicate, isBlockingDuplicate } = require('../../services/pivotIngestDuplicateService'); +const { validatePivotEventTags } = require('../../services/pivotTagCatalogService'); +const { + publishIngestEvent, + updateIngestEvent, + mergeDraftWithOverrides, + validateMergedDraft, +} = require('../../services/pivotIngestPublishService'); + +const TENANT = { + tenantKey: 'nyc', + name: 'NYC', + location: 'New York', + pivotPilot: true, + pivotCatalogOrgId: '507f1f77bcf86cd799439011', +}; + +describe('pivotIngestPublishService merge helpers', () => { + it('overrides win over preview draft', () => { + const merged = mergeDraftWithOverrides( + { name: 'Draft title', hostName: 'Draft Host' }, + { hostName: 'Brooklyn Board Game Cafe', location: 'Brooklyn, NY' }, + ); + + expect(merged.name).toBe('Draft title'); + expect(merged.hostName).toBe('Brooklyn Board Game Cafe'); + expect(merged.location).toBe('Brooklyn, NY'); + }); + + it('rejects publish when hostName missing after merge', () => { + const result = validateMergedDraft({ + name: 'Event', + location: 'NYC', + start_time: '2026-07-12T18:00:00.000Z', + hostName: '', + }); + + expect(result.code).toBe('MISSING_REQUIRED_FIELDS'); + }); +}); + +describe('pivotIngestPublishService publishIngestEvent', () => { + let Event; + + beforeEach(() => { + Event = { + findOneAndUpdate: jest.fn(), + }; + getModels.mockReturnValue({ Event }); + getMergedTenants.mockResolvedValue([TENANT]); + connectToDatabase.mockResolvedValue({}); + resolveImportDuplicate.mockResolvedValue({ duplicate: null, catalogIndex: [] }); + isBlockingDuplicate.mockReturnValue(false); + validatePivotEventTags.mockResolvedValue({ tags: ['live-music'] }); + normalizeUrl.mockReturnValue({ + url: 'https://partiful.com/e/sunset-listening', + provider: 'partiful', + }); + previewIngestUrl.mockResolvedValue({ + data: { + draft: { + name: 'Sunset Listening Party', + description: 'Bring a blanket.', + location: 'Brooklyn Bridge Park', + start_time: '2026-07-12T18:00:00-04:00', + hostName: 'Brooklyn Board Game Cafe', + source: 'partiful', + }, + }, + }); + Event.findOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: '507f1f77bcf86cd799439012', + 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', + externalLink: 'https://partiful.com/e/sunset-listening', + customFields: { + pivot: { + batchWeek: '2026-W26', + ingestStatus: 'published', + host: { name: 'Brooklyn Board Game Cafe' }, + source: 'partiful', + }, + }, + }), + }); + }); + + it('creates published catalog event with display host from overrides', 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'] }, + }, + ); + + expect(result.data.event.organizerName).toBe('Brooklyn Board Game Cafe'); + expect(validatePivotEventTags).toHaveBeenCalledWith( + expect.any(Object), + ['board-games'], + { required: true }, + ); + expect(Event.findOneAndUpdate).toHaveBeenCalledWith( + { 'customFields.pivot.sourceUrl': 'https://partiful.com/e/sunset-listening' }, + expect.objectContaining({ + $set: expect.objectContaining({ + status: 'not-applicable', + visibility: 'public', + registrationEnabled: true, + hostingType: 'Org', + hostingId: TENANT.pivotCatalogOrgId, + customFields: expect.objectContaining({ + pivot: expect.objectContaining({ + ingestStatus: 'published', + tags: ['live-music'], + host: expect.objectContaining({ name: 'Brooklyn Board Game Cafe' }), + }), + }), + }), + }), + expect.objectContaining({ upsert: true }), + ); + expect(provisionPivotCatalogOrg).not.toHaveBeenCalled(); + }); + + it('provisions catalog org when tenant row lacks pivotCatalogOrgId', async () => { + getMergedTenants.mockResolvedValue([{ ...TENANT, pivotCatalogOrgId: null }]); + provisionPivotCatalogOrg.mockResolvedValue({ orgId: '507f1f77bcf86cd799439099' }); + + 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'] }, + }, + ); + + expect(provisionPivotCatalogOrg).toHaveBeenCalled(); + expect(Event.findOneAndUpdate.mock.calls[0][1].$set.hostingId).toBe('507f1f77bcf86cd799439099'); + }); + + it('rejects publish when a blocking duplicate is detected', async () => { + isBlockingDuplicate.mockReturnValue(true); + resolveImportDuplicate.mockResolvedValue({ + duplicate: { + matchType: 'fingerprint', + existingName: 'Sunset Listening Party', + }, + catalogIndex: [], + }); + + 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'] }, + }, + ); + + expect(result.code).toBe('DUPLICATE_EVENT'); + expect(result.status).toBe(409); + expect(Event.findOneAndUpdate).not.toHaveBeenCalled(); + }); + + it('rejects publish when tags are missing', async () => { + validatePivotEventTags.mockResolvedValue({ + error: 'At least one catalog tag is required.', + status: 400, + code: 'TAGS_REQUIRED', + }); + + 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: [] }, + }, + ); + + expect(result.code).toBe('TAGS_REQUIRED'); + expect(Event.findOneAndUpdate).not.toHaveBeenCalled(); + }); + + it('rejects publish when tag slug is invalid', async () => { + validatePivotEventTags.mockResolvedValue({ + error: 'Unknown catalog tag(s): not-a-tag', + status: 400, + code: 'INVALID_TAG', + }); + + 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: ['not-a-tag'] }, + }, + ); + + expect(result.code).toBe('INVALID_TAG'); + expect(Event.findOneAndUpdate).not.toHaveBeenCalled(); + }); +}); + +describe('pivotIngestPublishService updateIngestEvent', () => { + let Event; + + beforeEach(() => { + Event = { + findOne: jest.fn(), + findByIdAndUpdate: jest.fn(), + }; + getModels.mockReturnValue({ Event }); + getMergedTenants.mockResolvedValue([TENANT]); + connectToDatabase.mockResolvedValue({}); + validatePivotEventTags.mockResolvedValue({ tags: ['live-music'] }); + Event.findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: '507f1f77bcf86cd799439012', + name: 'Sunset Listening Party', + customFields: { + pivot: { + ingestStatus: 'published', + host: { name: 'Old Host' }, + tags: ['live-music'], + }, + }, + }), + }); + Event.findByIdAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: '507f1f77bcf86cd799439012', + name: 'Updated Event', + customFields: { + pivot: { + ingestStatus: 'draft', + host: { name: 'New Host' }, + tags: ['board-games', 'social'], + }, + }, + }), + }); + }); + + it('updates host name and ingest status', async () => { + const result = await updateIngestEvent( + { globalDb: {} }, + { + tenantKey: 'nyc', + eventId: '507f1f77bcf86cd799439012', + overrides: { + name: 'Updated Event', + hostName: 'New Host', + ingestStatus: 'draft', + }, + }, + ); + + expect(result.data.event.organizerName).toBe('New Host'); + expect(Event.findByIdAndUpdate).toHaveBeenCalledWith( + '507f1f77bcf86cd799439012', + expect.objectContaining({ + $set: expect.objectContaining({ + name: 'Updated Event', + 'customFields.pivot': expect.objectContaining({ + ingestStatus: 'draft', + host: expect.objectContaining({ name: 'New Host' }), + }), + }), + }), + expect.any(Object), + ); + }); + + it('updates tags on existing event', async () => { + validatePivotEventTags.mockResolvedValue({ tags: ['board-games', 'social'] }); + + const result = await updateIngestEvent( + { globalDb: {} }, + { + tenantKey: 'nyc', + eventId: '507f1f77bcf86cd799439012', + overrides: { + tags: ['board-games', 'social'], + }, + }, + ); + + expect(result.data.event.tags).toEqual(['board-games', 'social']); + expect(Event.findByIdAndUpdate).toHaveBeenCalledWith( + '507f1f77bcf86cd799439012', + expect.objectContaining({ + $set: expect.objectContaining({ + 'customFields.pivot': expect.objectContaining({ + tags: ['board-games', 'social'], + }), + }), + }), + expect.any(Object), + ); + }); +}); diff --git a/backend/tests/unit/pivotIntentService.test.js b/backend/tests/unit/pivotIntentService.test.js new file mode 100644 index 00000000..d7f3cf51 --- /dev/null +++ b/backend/tests/unit/pivotIntentService.test.js @@ -0,0 +1,300 @@ +jest.mock('../../services/getModelService', () => jest.fn()); + +const getModels = require('../../services/getModelService'); +const { + recordFeedAction, + recordExternalOpen, + confirmRegistered, + getWeekRecap, + resetWeekActions, + serializeRecapEvent, +} = require('../../services/pivotIntentService'); + +const userId = '507f191e810c19729de860eb'; +const eventId = '665a1b2c3d4e5f6789012345'; +const now = new Date('2026-05-26T12:00:00.000Z'); +const req = { user: { userId }, school: 'nyc' }; + +function mockEventFindOne(event) { + return { + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(event), + }; +} + +function publishedEvent(overrides = {}) { + return { + _id: eventId, + start_time: new Date('2026-05-28T19:00:00.000Z'), + externalLink: 'https://partiful.com/e/example', + customFields: { pivot: { batchWeek: '2026-W22', host: { name: 'Venue' } } }, + ...overrides, + }; +} + +describe('recordFeedAction', () => { + beforeEach(() => { + getModels.mockReset(); + }); + + it('rejects an invalid eventId', async () => { + const result = await recordFeedAction(req, { eventId: 'nope', action: 'interested' }); + expect(result.status).toBe(400); + expect(result.code).toBe('INVALID_EVENT_ID'); + }); + + it('rejects an unsupported action', async () => { + const result = await recordFeedAction(req, { eventId, action: 'registered' }); + expect(result.status).toBe(400); + expect(result.code).toBe('INVALID_ACTION'); + }); + + it('returns 404 when the event is not an active pivot catalog event', async () => { + getModels.mockReturnValue({ + Event: { findOne: jest.fn(() => mockEventFindOne(null)) }, + }); + + const result = await recordFeedAction( + { ...req, body: {} }, + { eventId, action: 'interested', now }, + ); + expect(result.status).toBe(404); + expect(result.code).toBe('EVENT_NOT_FOUND'); + }); + + it('maps pass to passed and upserts intent with event batchWeek', async () => { + const findOneAndUpdate = jest.fn(() => ({ + lean: jest + .fn() + .mockResolvedValue({ eventId, status: 'passed', batchWeek: '2026-W22' }), + })); + getModels.mockImplementation((_req, ...names) => { + if (names.includes('Event')) { + return { Event: { findOne: jest.fn(() => mockEventFindOne(publishedEvent())) } }; + } + return { PivotEventIntent: { findOneAndUpdate } }; + }); + + const result = await recordFeedAction(req, { eventId, action: 'pass', now }); + + expect(result.data).toEqual({ + eventId, + status: 'passed', + batchWeek: '2026-W22', + }); + expect(findOneAndUpdate).toHaveBeenCalledWith( + { userId, eventId }, + { $set: { status: 'passed', batchWeek: '2026-W22' } }, + expect.objectContaining({ upsert: true }), + ); + }); +}); + +describe('recordExternalOpen', () => { + beforeEach(() => { + getModels.mockReset(); + }); + + it('increments externalOpenCount and defaults a new row to interested', async () => { + const findOneAndUpdate = jest.fn(() => ({ + lean: jest.fn().mockResolvedValue({ + eventId, + status: 'interested', + batchWeek: '2026-W22', + externalOpenCount: 1, + externalOpenAt: new Date('2026-05-26T12:00:00.000Z'), + }), + })); + getModels.mockImplementation((_req, ...names) => { + if (names.includes('Event')) { + return { Event: { findOne: jest.fn(() => mockEventFindOne(publishedEvent())) } }; + } + return { PivotEventIntent: { findOneAndUpdate } }; + }); + + const result = await recordExternalOpen(req, eventId, {}); + + expect(result.data.externalOpenCount).toBe(1); + const [, update, opts] = findOneAndUpdate.mock.calls[0]; + expect(update.$inc).toEqual({ externalOpenCount: 1 }); + expect(update.$setOnInsert).toEqual({ status: 'interested', batchWeek: '2026-W22' }); + expect(update.$set).toHaveProperty('externalOpenAt'); + expect(opts).toEqual(expect.objectContaining({ upsert: true })); + }); + + it('returns 404 for non-pivot events', async () => { + getModels.mockReturnValue({ + Event: { findOne: jest.fn(() => mockEventFindOne(null)) }, + }); + + const result = await recordExternalOpen(req, eventId, {}); + expect(result.status).toBe(404); + }); + + it('rejects an invalid eventId', async () => { + const result = await recordExternalOpen(req, 'nope', {}); + expect(result.status).toBe(400); + expect(result.code).toBe('INVALID_EVENT_ID'); + }); +}); + +describe('confirmRegistered', () => { + beforeEach(() => { + getModels.mockReset(); + }); + + it('sets status registered idempotently', async () => { + const findOneAndUpdate = jest.fn(() => ({ + lean: jest + .fn() + .mockResolvedValue({ eventId, status: 'registered', batchWeek: '2026-W22' }), + })); + getModels.mockImplementation((_req, ...names) => { + if (names.includes('Event')) { + return { Event: { findOne: jest.fn(() => mockEventFindOne(publishedEvent())) } }; + } + return { PivotEventIntent: { findOneAndUpdate } }; + }); + + const result = await confirmRegistered(req, eventId); + + expect(result.data.status).toBe('registered'); + expect(findOneAndUpdate).toHaveBeenCalledWith( + { userId, eventId }, + { $set: { status: 'registered', batchWeek: '2026-W22' } }, + expect.objectContaining({ upsert: true }), + ); + }); + + it('returns 404 for non-pivot events', async () => { + getModels.mockReturnValue({ + Event: { findOne: jest.fn(() => mockEventFindOne(null)) }, + }); + + const result = await confirmRegistered(req, eventId); + expect(result.status).toBe(404); + }); +}); + +describe('getWeekRecap', () => { + beforeEach(() => { + getModels.mockReset(); + }); + + it('lists interested + registered events, excluding passed', async () => { + const intentFind = { + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([ + { eventId, status: 'interested' }, + { eventId: '665a1b2c3d4e5f6789012346', status: 'registered' }, + ]), + }; + const eventFind = { + select: jest.fn().mockReturnThis(), + sort: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([ + publishedEvent(), + publishedEvent({ + _id: '665a1b2c3d4e5f6789012346', + name: 'Second', + customFields: { + pivot: { batchWeek: '2026-W22', host: { name: 'Venue 2' } }, + }, + }), + ]), + }; + + getModels.mockReturnValue({ + PivotEventIntent: { find: jest.fn(() => intentFind) }, + Event: { find: jest.fn(() => eventFind) }, + }); + + const result = await getWeekRecap(req, { batchWeek: '2026-W22', now }); + + expect(result.data.batchWeek).toBe('2026-W22'); + expect(result.data.events).toHaveLength(2); + const statuses = result.data.events.map((e) => e.userIntent); + expect(statuses).toContain('interested'); + expect(statuses).toContain('registered'); + expect(intentFind.lean).toHaveBeenCalled(); + expect( + getModels.mock.results[0].value.PivotEventIntent.find, + ).toHaveBeenCalledWith({ + userId, + batchWeek: '2026-W22', + status: { $in: ['interested', 'registered'] }, + }); + }); + + it('returns empty events when user has no intents', async () => { + getModels.mockReturnValue({ + PivotEventIntent: { + find: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + })), + }, + Event: { find: jest.fn() }, + }); + + const result = await getWeekRecap(req, { batchWeek: '2026-W22', now }); + expect(result.data.events).toEqual([]); + }); + + it('rejects an invalid batchWeek', async () => { + const result = await getWeekRecap(req, { batchWeek: '2026-W99x', now }); + expect(result.status).toBe(400); + expect(result.code).toBe('INVALID_BATCH_WEEK'); + }); +}); + +describe('resetWeekActions', () => { + beforeEach(() => { + getModels.mockReset(); + }); + + it('deletes all intents for the batch week (interested, registered, passed)', async () => { + const deleteMany = jest.fn().mockResolvedValue({ deletedCount: 3 }); + getModels.mockReturnValue({ + PivotEventIntent: { deleteMany }, + }); + + const result = await resetWeekActions(req, { batchWeek: '2026-W22', now }); + + expect(result.data).toEqual({ batchWeek: '2026-W22', deletedCount: 3 }); + expect(deleteMany).toHaveBeenCalledWith({ + userId, + batchWeek: '2026-W22', + }); + }); + + it('rejects an invalid batchWeek', async () => { + const result = await resetWeekActions(req, { batchWeek: '2026-W99x', now }); + expect(result.status).toBe(400); + expect(result.code).toBe('INVALID_BATCH_WEEK'); + }); +}); + +describe('serializeRecapEvent', () => { + it('exposes displayHost, externalLink, and userIntent without hosting fields', () => { + const payload = serializeRecapEvent( + { + _id: eventId, + name: 'Recap Event', + externalLink: 'https://luma.com/e/x', + hostingId: 'internal-org', + customFields: { pivot: { host: { name: 'Real Venue' }, tags: ['music'] } }, + }, + 'interested', + ); + + expect(payload).toMatchObject({ + _id: eventId, + externalLink: 'https://luma.com/e/x', + displayHost: { name: 'Real Venue' }, + userIntent: 'interested', + tags: ['music'], + }); + expect(payload).not.toHaveProperty('hostingId'); + }); +}); diff --git a/backend/tests/unit/pivotIsoWeek.test.js b/backend/tests/unit/pivotIsoWeek.test.js new file mode 100644 index 00000000..cfaa1927 --- /dev/null +++ b/backend/tests/unit/pivotIsoWeek.test.js @@ -0,0 +1,13 @@ +const { toIsoWeek, isValidIsoWeek } = require('../../utilities/pivotIsoWeek'); + +describe('pivotIsoWeek', () => { + it('formats known date as ISO week', () => { + expect(toIsoWeek(new Date('2026-05-26T12:00:00.000Z'))).toMatch(/^2026-W\d{2}$/); + }); + + it('validates ISO week pattern', () => { + expect(isValidIsoWeek('2026-W21')).toBe(true); + expect(isValidIsoWeek('2026-W1')).toBe(false); + expect(isValidIsoWeek('bad')).toBe(false); + }); +}); diff --git a/backend/tests/unit/pivotLabNotesService.test.js b/backend/tests/unit/pivotLabNotesService.test.js new file mode 100644 index 00000000..f6964ddc --- /dev/null +++ b/backend/tests/unit/pivotLabNotesService.test.js @@ -0,0 +1,60 @@ +jest.mock('../../services/getGlobalModelService', () => jest.fn()); +jest.mock('../../services/pivotWeeklySnapshotService', () => ({ + normalizeBatchWeek: (raw, now = new Date()) => { + const batchWeek = raw?.trim() || '2026-W26'; + if (!/^\d{4}-W\d{2}$/.test(batchWeek)) { + return { error: 'invalid', status: 400, code: 'INVALID_BATCH_WEEK' }; + } + return { batchWeek }; + }, +})); + +const getGlobalModels = require('../../services/getGlobalModelService'); +const { + getInterviewNotes, + saveInterviewNotes, +} = require('../../services/pivotLabNotesService'); + +describe('pivotLabNotesService', () => { + let PivotLabNotes; + + beforeEach(() => { + PivotLabNotes = { + findOne: jest.fn(), + findOneAndUpdate: jest.fn(), + }; + getGlobalModels.mockReturnValue({ PivotLabNotes }); + }); + + it('returns empty notes when doc is missing', async () => { + PivotLabNotes.findOne.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) }); + + const result = await getInterviewNotes({ globalDb: {} }, { batchWeek: '2026-W26' }); + + expect(result.data.batchWeek).toBe('2026-W26'); + expect(result.data.notes).toBe(''); + }); + + it('saves notes with upsert', async () => { + PivotLabNotes.findOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + batchWeek: '2026-W26', + notes: 'Themes', + updatedBy: 'ops@meridian.study', + updatedAt: new Date('2026-06-26T12:00:00.000Z'), + }), + }); + + const result = await saveInterviewNotes( + { globalDb: {}, user: { email: 'ops@meridian.study' } }, + { batchWeek: '2026-W26', notes: 'Themes' }, + ); + + expect(PivotLabNotes.findOneAndUpdate).toHaveBeenCalledWith( + { batchWeek: '2026-W26' }, + { notes: 'Themes', updatedBy: 'ops@meridian.study' }, + { upsert: true, new: true, setDefaultsOnInsert: true }, + ); + expect(result.data.notes).toBe('Themes'); + }); +}); diff --git a/backend/tests/unit/pivotProfileService.test.js b/backend/tests/unit/pivotProfileService.test.js new file mode 100644 index 00000000..e3916d3b --- /dev/null +++ b/backend/tests/unit/pivotProfileService.test.js @@ -0,0 +1,119 @@ +jest.mock('../../services/getModelService', () => jest.fn()); +jest.mock('../../services/pivotTagCatalogService', () => ({ + validatePivotInterestTags: jest.fn(), +})); + +const getModels = require('../../services/getModelService'); +const { validatePivotInterestTags } = require('../../services/pivotTagCatalogService'); +const { + getPivotProfileInterests, + updatePivotProfileInterests, +} = require('../../services/pivotProfileService'); + +describe('pivotProfileService', () => { + let User; + + const req = { + user: { userId: '507f191e810c19729de860eb' }, + globalDb: {}, + }; + + beforeEach(() => { + User = { + findById: jest.fn(), + }; + getModels.mockReturnValue({ User }); + validatePivotInterestTags.mockReset(); + }); + + it('returns unauthorized when user id is missing', async () => { + const result = await getPivotProfileInterests({ globalDb: {} }); + + expect(result.code).toBe('UNAUTHORIZED'); + expect(result.status).toBe(401); + }); + + it('returns saved interest tags for the authenticated user', async () => { + User.findById.mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + pivotInterestTags: ['live-music', 'board-games'], + }), + }), + }); + + const result = await getPivotProfileInterests(req); + + expect(User.findById).toHaveBeenCalledWith(req.user.userId); + expect(result.data.interestTags).toEqual(['live-music', 'board-games']); + }); + + it('returns empty array when user has no saved interests', async () => { + User.findById.mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue({}), + }), + }); + + const result = await getPivotProfileInterests(req); + + expect(result.data.interestTags).toEqual([]); + }); + + it('persists validated interest tags on update', async () => { + validatePivotInterestTags.mockResolvedValue({ + tags: ['live-music', 'social'], + }); + + const save = jest.fn().mockResolvedValue(undefined); + User.findById.mockResolvedValue({ + pivotInterestTags: [], + save, + }); + + const result = await updatePivotProfileInterests(req, { + interestTags: ['live-music', 'social'], + }); + + expect(validatePivotInterestTags).toHaveBeenCalledWith(req, ['live-music', 'social']); + expect(save).toHaveBeenCalled(); + expect(result.data.interestTags).toEqual(['live-music', 'social']); + }); + + it('allows clearing interests with an empty array', async () => { + validatePivotInterestTags.mockResolvedValue({ tags: [] }); + + const save = jest.fn().mockResolvedValue(undefined); + User.findById.mockResolvedValue({ + pivotInterestTags: ['live-music'], + save, + }); + + const result = await updatePivotProfileInterests(req, { interestTags: [] }); + + expect(result.data.interestTags).toEqual([]); + expect(save).toHaveBeenCalled(); + }); + + it('returns validation errors from catalog validation', async () => { + validatePivotInterestTags.mockResolvedValue({ + error: 'Unknown catalog tag(s): fake-tag', + status: 400, + code: 'INVALID_TAG', + }); + + const result = await updatePivotProfileInterests(req, { + interestTags: ['fake-tag'], + }); + + expect(result.code).toBe('INVALID_TAG'); + expect(User.findById).not.toHaveBeenCalled(); + }); + + it('requires interestTags in request body', async () => { + const result = await updatePivotProfileInterests(req, {}); + + expect(result.code).toBe('VALIDATION_ERROR'); + expect(validatePivotInterestTags).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/unit/pivotReferralCode.test.js b/backend/tests/unit/pivotReferralCode.test.js new file mode 100644 index 00000000..b255c21a --- /dev/null +++ b/backend/tests/unit/pivotReferralCode.test.js @@ -0,0 +1,73 @@ +const mongoose = require('mongoose'); +const pivotReferralCodeSchema = require('../../schemas/pivotReferralCode'); + +describe('PivotReferralCode schema', () => { + const PivotReferralCode = + mongoose.models.TestPivotReferralCode || + mongoose.model('TestPivotReferralCode', pivotReferralCodeSchema); + + it('normalizes code and tenantKey on validate', async () => { + const doc = new PivotReferralCode({ + code: ' nyc-pilot-a ', + tenantKey: ' NYC ', + cohortId: 'pilot-a', + maxRedemptions: 10, + }); + + await doc.validate(); + expect(doc.code).toBe('NYC-PILOT-A'); + expect(doc.tenantKey).toBe('nyc'); + }); + + it('isRedeemable respects active, expiry, and max redemptions', () => { + const active = new PivotReferralCode({ + code: 'TEST-ACTIVE', + tenantKey: 'nyc', + cohortId: 'a', + maxRedemptions: 2, + redemptionCount: 1, + active: true, + }); + expect(active.isRedeemable()).toBe(true); + + const inactive = new PivotReferralCode({ + code: 'TEST-INACTIVE', + tenantKey: 'nyc', + cohortId: 'a', + maxRedemptions: 10, + active: false, + }); + expect(inactive.isRedeemable()).toBe(false); + + const expired = new PivotReferralCode({ + code: 'TEST-EXPIRED', + tenantKey: 'nyc', + cohortId: 'a', + maxRedemptions: 10, + expiresAt: new Date('2020-01-01'), + active: true, + }); + expect(expired.isRedeemable()).toBe(false); + + const maxed = new PivotReferralCode({ + code: 'TEST-MAXED', + tenantKey: 'nyc', + cohortId: 'a', + maxRedemptions: 1, + redemptionCount: 1, + active: true, + }); + expect(maxed.isRedeemable()).toBe(false); + }); + + it('rejects invalid batchWeek format', async () => { + const doc = new PivotReferralCode({ + code: 'TEST-BAD-WEEK', + tenantKey: 'nyc', + cohortId: 'a', + batchWeek: '2026-21', + }); + + await expect(doc.validate()).rejects.toThrow(/batchWeek/); + }); +}); diff --git a/backend/tests/unit/pivotReferralCodeService.test.js b/backend/tests/unit/pivotReferralCodeService.test.js new file mode 100644 index 00000000..78926149 --- /dev/null +++ b/backend/tests/unit/pivotReferralCodeService.test.js @@ -0,0 +1,40 @@ +const { + validateCreatePayload, + validateUpdatePayload, + isPivotTenant, +} = require('../../services/pivotReferralCodeService'); + +describe('pivotReferralCodeService', () => { + describe('isPivotTenant', () => { + it('returns true for pivot tenant types', () => { + expect(isPivotTenant({ tenantType: 'pivot' })).toBe(true); + expect(isPivotTenant({ pivotPilot: true })).toBe(true); + expect(isPivotTenant({ tenantType: 'campus', pivotPilot: false })).toBe(false); + }); + }); + + describe('validateCreatePayload', () => { + it('accepts valid payload', () => { + const out = validateCreatePayload({ + code: 'nyc-pilot-a', + cohortId: 'pilot-a', + batchWeek: '2026-W21', + }); + expect(out.error).toBeUndefined(); + expect(out.row.code).toBe('nyc-pilot-a'); + expect(out.row.batchWeek).toBe('2026-W21'); + }); + + it('rejects invalid batch week', () => { + const out = validateCreatePayload({ code: 'X', cohortId: 'a', batchWeek: 'bad' }); + expect(out.error).toMatch(/batchWeek/); + }); + }); + + describe('validateUpdatePayload', () => { + it('rejects empty patch', () => { + const out = validateUpdatePayload({}); + expect(out.error).toMatch(/No valid fields/); + }); + }); +}); diff --git a/backend/tests/unit/pivotReferralRedeemService.test.js b/backend/tests/unit/pivotReferralRedeemService.test.js new file mode 100644 index 00000000..8810f783 --- /dev/null +++ b/backend/tests/unit/pivotReferralRedeemService.test.js @@ -0,0 +1,177 @@ +jest.mock('../../services/getGlobalModelService', () => jest.fn()); + +const mongoose = require('mongoose'); +const getGlobalModels = require('../../services/getGlobalModelService'); +const { redeemReferralCode } = require('../../services/pivotReferralCodeService'); + +describe('pivotReferralCodeService.redeemReferralCode', () => { + const globalUserId = new mongoose.Types.ObjectId(); + const codeId = new mongoose.Types.ObjectId(); + + function makeReq({ school = 'nyc', gid = globalUserId } = {}) { + return { + school, + user: gid ? { globalUserId: String(gid) } : {}, + }; + } + + let redemptionFindOne; + let codeFindOne; + let create; + let findOneAndUpdate; + let updateOne; + + beforeEach(() => { + redemptionFindOne = jest.fn(); + codeFindOne = jest.fn(); + create = jest.fn(); + findOneAndUpdate = jest.fn(); + updateOne = jest.fn(); + getGlobalModels.mockReturnValue({ + PivotReferralCode: { + findOne: codeFindOne, + findOneAndUpdate, + updateOne, + }, + PivotReferralRedemption: { + findOne: redemptionFindOne, + create, + }, + }); + }); + + it('rejects legacy sessions without globalUserId', async () => { + const result = await redeemReferralCode({ school: 'nyc', user: {} }, 'NYC-PILOT-A'); + expect(result.status).toBe(403); + expect(result.code).toBe('GLOBAL_USER_REQUIRED'); + }); + + it('returns alreadyRedeemed when row exists without incrementing', async () => { + redemptionFindOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + globalUserId, + code: 'NYC-PILOT-A', + }), + }); + + codeFindOne.mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue({ redemptionCount: 3, maxRedemptions: 50 }), + }), + }); + + const result = await redeemReferralCode(makeReq(), 'nyc-pilot-a'); + + expect(result.data.alreadyRedeemed).toBe(true); + expect(findOneAndUpdate).not.toHaveBeenCalled(); + }); + + it('increments and creates redemption for first-time user', async () => { + redemptionFindOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue(null), + }); + + const referralDoc = { + _id: codeId, + code: 'NYC-PILOT-A', + tenantKey: 'nyc', + active: true, + expiresAt: null, + redemptionCount: 2, + maxRedemptions: 50, + cohortId: 'a', + }; + + codeFindOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue(referralDoc), + }); + + findOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: codeId, + redemptionCount: 3, + maxRedemptions: 50, + }), + }); + + create.mockResolvedValue({}); + + const result = await redeemReferralCode(makeReq(), 'NYC-PILOT-A'); + + expect(result.data.redeemed).toBe(true); + expect(result.data.redemptionCount).toBe(3); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'NYC-PILOT-A', + pivotReferralCodeId: codeId, + }), + ); + }); + + it('rolls back increment when duplicate creation races', async () => { + redemptionFindOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue(null), + }); + + const referralDoc = { + _id: codeId, + code: 'NYC-PILOT-A', + tenantKey: 'nyc', + active: true, + expiresAt: null, + redemptionCount: 0, + maxRedemptions: 50, + }; + + codeFindOne + .mockReturnValueOnce({ + lean: jest.fn().mockResolvedValue(referralDoc), + }) + .mockReturnValueOnce({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue({ redemptionCount: 5, maxRedemptions: 50 }), + }), + }); + + findOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: codeId, + redemptionCount: 1, + maxRedemptions: 50, + }), + }); + + const dup = new Error('duplicate'); + dup.code = 11000; + create.mockRejectedValue(dup); + + const result = await redeemReferralCode(makeReq(), 'NYC-PILOT-A'); + + expect(result.data.alreadyRedeemed).toBe(true); + expect(updateOne).toHaveBeenCalledWith( + { _id: codeId }, + { $inc: { redemptionCount: -1 } }, + ); + }); + + it('rejects tenant mismatch', async () => { + redemptionFindOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue(null), + }); + codeFindOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: codeId, + code: 'NYC-PILOT-A', + tenantKey: 'nyc', + active: true, + expiresAt: null, + redemptionCount: 0, + maxRedemptions: 50, + }), + }); + + const result = await redeemReferralCode(makeReq({ school: 'rpi' }), 'NYC-PILOT-A'); + expect(result.status).toBe(403); + expect(result.code).toBe('TENANT_MISMATCH'); + }); +}); diff --git a/backend/tests/unit/pivotReferralValidateService.test.js b/backend/tests/unit/pivotReferralValidateService.test.js new file mode 100644 index 00000000..410c4dee --- /dev/null +++ b/backend/tests/unit/pivotReferralValidateService.test.js @@ -0,0 +1,128 @@ +jest.mock('../../services/getGlobalModelService', () => jest.fn()); +jest.mock('../../services/tenantConfigService', () => ({ + getTenantByKey: jest.fn(), +})); + +const getGlobalModels = require('../../services/getGlobalModelService'); +const { getTenantByKey } = require('../../services/tenantConfigService'); +const { + normalizeReferralCodeInput, + validateReferralCode, +} = require('../../services/pivotReferralCodeService'); + +describe('pivotReferralCodeService.validateReferralCode', () => { + const req = {}; + let findOne; + + beforeEach(() => { + findOne = jest.fn(); + getGlobalModels.mockReturnValue({ + PivotReferralCode: { findOne }, + }); + getTenantByKey.mockReset(); + }); + + it('normalizes code input', () => { + expect(normalizeReferralCodeInput(' nyc-pilot-a ')).toBe('NYC-PILOT-A'); + }); + + it('rejects missing code', async () => { + const result = await validateReferralCode(req, ' '); + expect(result.status).toBe(400); + expect(result.code).toBe('REFERRAL_CODE_REQUIRED'); + }); + + it('returns tenant metadata for redeemable code', async () => { + findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + code: 'NYC-PILOT-A', + tenantKey: 'nyc', + cohortId: 'pilot-a', + active: true, + expiresAt: null, + redemptionCount: 0, + maxRedemptions: 50, + batchWeek: '2026-W21', + }), + }); + getTenantByKey.mockResolvedValue({ + tenantKey: 'nyc', + subdomain: 'nyc', + location: 'New York City', + name: 'NYC Pivot', + tenantType: 'pivot', + }); + + const result = await validateReferralCode(req, 'nyc-pilot-a'); + + expect(findOne).toHaveBeenCalledWith({ code: 'NYC-PILOT-A' }); + expect(result.data).toEqual({ + tenantKey: 'nyc', + subdomain: 'nyc', + cohortId: 'pilot-a', + cityDisplayName: 'New York City', + batchWeek: '2026-W21', + }); + }); + + it('returns 404 for unknown code', async () => { + findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue(null), + }); + + const result = await validateReferralCode(req, 'NO-SUCH-CODE'); + expect(result.status).toBe(404); + expect(result.code).toBe('REFERRAL_CODE_NOT_FOUND'); + }); + + it('returns 403 for inactive code', async () => { + findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + code: 'NYC-PILOT-INACTIVE', + tenantKey: 'nyc', + active: false, + expiresAt: null, + redemptionCount: 0, + maxRedemptions: 50, + }), + }); + + const result = await validateReferralCode(req, 'NYC-PILOT-INACTIVE'); + expect(result.status).toBe(403); + expect(result.code).toBe('REFERRAL_CODE_INACTIVE'); + }); + + it('returns 403 for expired code', async () => { + findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + code: 'NYC-PILOT-EXPIRED', + tenantKey: 'nyc', + active: true, + expiresAt: new Date('2020-01-01T00:00:00.000Z'), + redemptionCount: 0, + maxRedemptions: 50, + }), + }); + + const result = await validateReferralCode(req, 'NYC-PILOT-EXPIRED'); + expect(result.status).toBe(403); + expect(result.code).toBe('REFERRAL_CODE_EXPIRED'); + }); + + it('returns 403 when redemption limit reached', async () => { + findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + code: 'NYC-PILOT-A', + tenantKey: 'nyc', + active: true, + expiresAt: null, + redemptionCount: 50, + maxRedemptions: 50, + }), + }); + + const result = await validateReferralCode(req, 'NYC-PILOT-A'); + expect(result.status).toBe(403); + expect(result.code).toBe('REFERRAL_CODE_MAXED'); + }); +}); diff --git a/backend/tests/unit/pivotTagCatalogService.test.js b/backend/tests/unit/pivotTagCatalogService.test.js new file mode 100644 index 00000000..4c5582d0 --- /dev/null +++ b/backend/tests/unit/pivotTagCatalogService.test.js @@ -0,0 +1,114 @@ +jest.mock('../../services/getGlobalModelService', () => jest.fn()); + +const getGlobalModels = require('../../services/getGlobalModelService'); +const { + listPivotTags, + validatePivotInterestTags, +} = require('../../services/pivotTagCatalogService'); + +describe('pivotTagCatalogService', () => { + let PivotTagCatalog; + + beforeEach(() => { + PivotTagCatalog = { + find: jest.fn(), + }; + getGlobalModels.mockReturnValue({ PivotTagCatalog }); + }); + + it('returns active tags sorted by sortOrder', async () => { + const sort = jest.fn().mockReturnThis(); + const select = jest.fn().mockReturnThis(); + const lean = jest.fn().mockResolvedValue([ + { slug: 'live-music', label: 'live music', sortOrder: 10, active: true }, + { slug: 'board-games', label: 'board games', sortOrder: 20, active: true }, + ]); + PivotTagCatalog.find.mockReturnValue({ sort, select, lean }); + + const result = await listPivotTags({ globalDb: {} }); + + expect(PivotTagCatalog.find).toHaveBeenCalledWith({ active: true }); + expect(sort).toHaveBeenCalledWith({ sortOrder: 1, slug: 1 }); + expect(result.data.tags).toEqual([ + { slug: 'live-music', label: 'live music' }, + { slug: 'board-games', label: 'board games' }, + ]); + }); + + it('returns error when global DB context is missing', async () => { + const result = await listPivotTags({}); + + expect(result.error).toMatch(/Global database context required/); + expect(result.status).toBe(500); + }); +}); + +describe('pivotTagCatalogService validatePivotInterestTags', () => { + let PivotTagCatalog; + + beforeEach(() => { + PivotTagCatalog = { + find: jest.fn(), + }; + getGlobalModels.mockReturnValue({ PivotTagCatalog }); + }); + + it('accepts empty interest tags', async () => { + const result = await validatePivotInterestTags({ globalDb: {} }, []); + + expect(result.tags).toEqual([]); + expect(PivotTagCatalog.find).not.toHaveBeenCalled(); + }); + + it('normalizes, dedupes, and validates active catalog slugs', async () => { + const select = jest.fn().mockReturnThis(); + const lean = jest.fn().mockResolvedValue([ + { slug: 'live-music' }, + { slug: 'board-games' }, + ]); + PivotTagCatalog.find.mockReturnValue({ select, lean }); + + const result = await validatePivotInterestTags( + { globalDb: {} }, + [' Live-Music ', 'live-music', 'board-games'], + ); + + expect(PivotTagCatalog.find).toHaveBeenCalledWith({ active: true }); + expect(result.tags).toEqual(['live-music', 'board-games']); + }); + + it('rejects unknown catalog slugs', async () => { + const select = jest.fn().mockReturnThis(); + const lean = jest.fn().mockResolvedValue([{ slug: 'live-music' }]); + PivotTagCatalog.find.mockReturnValue({ select, lean }); + + const result = await validatePivotInterestTags( + { globalDb: {} }, + ['live-music', 'fake-tag'], + ); + + expect(result.code).toBe('INVALID_TAG'); + expect(result.status).toBe(400); + }); + + it('rejects more than eight interest tags', async () => { + const result = await validatePivotInterestTags( + { globalDb: {} }, + [ + 'tag-one', + 'tag-two', + 'tag-three', + 'tag-four', + 'tag-five', + 'tag-six', + 'tag-seven', + 'tag-eight', + 'tag-nine', + ], + ); + + expect(result.code).toBe('INTEREST_TAGS_LIMIT'); + expect(result.status).toBe(400); + expect(PivotTagCatalog.find).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/unit/pivotTagSuggestService.test.js b/backend/tests/unit/pivotTagSuggestService.test.js new file mode 100644 index 00000000..61cb9a7b --- /dev/null +++ b/backend/tests/unit/pivotTagSuggestService.test.js @@ -0,0 +1,138 @@ +jest.mock('axios'); +jest.mock('../../services/pivotTagCatalogService', () => ({ + listPivotTags: jest.fn(), + validatePivotEventTags: jest.fn(), +})); + +const axios = require('axios'); +const { listPivotTags, validatePivotEventTags } = require('../../services/pivotTagCatalogService'); +const { + suggestPivotEventTags, + suggestPivotEventTagsBatch, + parseTagsFromClaudeText, + buildTagSuggestionPrompt, + resolveAnthropicApiKey, +} = require('../../services/pivotTagSuggestService'); + +describe('pivotTagSuggestService', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv, ANTHROPIC_API_KEY: 'test-key' }; + listPivotTags.mockResolvedValue({ + data: { + tags: [ + { slug: 'live-music', label: 'live music' }, + { slug: 'board-games', label: 'board games' }, + ], + }, + }); + validatePivotEventTags.mockResolvedValue({ tags: ['live-music'] }); + axios.post.mockReset(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('returns LLM_NOT_CONFIGURED when API key is missing', async () => { + delete process.env.ANTHROPIC_API_KEY; + delete process.env.CLAUDE_API_KEY; + + const result = await suggestPivotEventTags({ globalDb: {} }, { name: 'Jazz Night' }); + + expect(result.code).toBe('LLM_NOT_CONFIGURED'); + expect(result.status).toBe(503); + }); + + it('parses JSON tag slugs from Claude text', () => { + expect(parseTagsFromClaudeText('{"tags":["live-music","social"]}')).toEqual([ + 'live-music', + 'social', + ]); + }); + + it('builds prompt with catalog and listing hints', () => { + const prompt = buildTagSuggestionPrompt( + { + name: 'Sunset Listening Party', + description: 'Vinyl on the roof', + location: 'Brooklyn', + hostName: 'Roof Records', + sourceTags: ['Music'], + }, + [{ slug: 'live-music', label: 'live music' }], + ); + + expect(prompt).toContain('live-music (live music)'); + expect(prompt).toContain('Sunset Listening Party'); + expect(prompt).toContain('Music'); + }); + + it('suggests validated catalog tags via Claude', async () => { + axios.post.mockResolvedValue({ + data: { + content: [{ type: 'text', text: '{"tags":["live-music"]}' }], + }, + }); + + const result = await suggestPivotEventTags( + { globalDb: {} }, + { + name: 'Sunset Listening Party', + description: 'Vinyl on the roof', + location: 'Brooklyn', + hostName: 'Roof Records', + }, + ); + + expect(result.data.tags).toEqual(['live-music']); + expect(validatePivotEventTags).toHaveBeenCalled(); + expect(axios.post).toHaveBeenCalledWith( + 'https://api.anthropic.com/v1/messages', + expect.objectContaining({ + messages: [expect.objectContaining({ role: 'user' })], + }), + expect.objectContaining({ + headers: expect.objectContaining({ 'x-api-key': 'test-key' }), + }), + ); + }); + + it('batch suggestion returns one entry per event', async () => { + axios.post.mockResolvedValue({ + data: { + content: [{ type: 'text', text: '{"tags":["board-games"]}' }], + }, + }); + + const result = await suggestPivotEventTagsBatch( + { globalDb: {} }, + [{ name: 'Game Night' }, { name: 'Open Mic' }], + ); + + expect(result.data.suggestions).toHaveLength(2); + expect(result.data.suggestedCount).toBe(2); + }); + + it('batch suggestion returns error when every event fails', async () => { + delete process.env.ANTHROPIC_API_KEY; + delete process.env.CLAUDE_API_KEY; + + const result = await suggestPivotEventTagsBatch( + { globalDb: {} }, + [{ name: 'Game Night' }], + ); + + expect(result.error).toMatch(/ANTHROPIC_API_KEY/); + expect(result.code).toBe('LLM_NOT_CONFIGURED'); + expect(result.data.failedCount).toBe(1); + expect(result.data.suggestedCount).toBe(0); + }); + + it('resolveAnthropicApiKey accepts CLAUDE_API_KEY alias', () => { + delete process.env.ANTHROPIC_API_KEY; + process.env.CLAUDE_API_KEY = 'alias-key'; + expect(resolveAnthropicApiKey()).toBe('alias-key'); + }); +}); diff --git a/backend/tests/unit/pivotWeeklyDropService.test.js b/backend/tests/unit/pivotWeeklyDropService.test.js new file mode 100644 index 00000000..f25da2ed --- /dev/null +++ b/backend/tests/unit/pivotWeeklyDropService.test.js @@ -0,0 +1,97 @@ +jest.mock('../../connectionsManager', () => ({ + connectToDatabase: jest.fn(), +})); + +jest.mock('../../services/getModelService', () => jest.fn()); + +jest.mock('../../services/tenantConfigService', () => ({ + getTenantByKey: jest.fn(), + upsertStoredTenantRow: jest.fn(), + serializeTenantForAdmin: jest.fn((tenant) => tenant), +})); + +const { connectToDatabase } = require('../../connectionsManager'); +const getModels = require('../../services/getModelService'); +const { getTenantByKey, upsertStoredTenantRow } = require('../../services/tenantConfigService'); +const { + getWeeklyDropStatus, + sendWeeklyDropPush, + updateWeeklyDropConfig, +} = require('../../services/pivotWeeklyDropService'); + +describe('pivotWeeklyDropService', () => { + const nycTenant = { + tenantKey: 'nyc', + tenantType: 'pivot', + pivotDropTimezone: 'America/New_York', + pivotDropDayOfWeek: 4, + pivotDropHour: 18, + pivotDropMinute: 0, + }; + + beforeEach(() => { + jest.clearAllMocks(); + connectToDatabase.mockResolvedValue({}); + getModels.mockImplementation(() => ({ + Event: { countDocuments: jest.fn().mockResolvedValue(3) }, + User: { + countDocuments: jest.fn().mockResolvedValue(2), + find: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue([ + { _id: '1', pushToken: 'ExponentPushToken[a]' }, + { _id: '2', pushToken: 'ExponentPushToken[b]' }, + ]), + }), + }), + }, + })); + }); + + it('getWeeklyDropStatus returns resolved drop schedule', async () => { + getTenantByKey.mockResolvedValue(nycTenant); + + const result = await getWeeklyDropStatus({}, 'nyc', '2026-W23'); + + expect(result.dropSchedule.batchWeek).toBe('2026-W23'); + expect(result.dropSchedule.nextDropFormatted).toMatch(/Thu Jun 4/); + expect(result.publishedEventCount).toBe(3); + expect(result.pivotPushRecipientCount).toBe(2); + }); + + it('updateWeeklyDropConfig persists drop fields', async () => { + getTenantByKey.mockResolvedValue(nycTenant); + upsertStoredTenantRow.mockResolvedValue({ + ...nycTenant, + pivotDropHour: 17, + }); + + const result = await updateWeeklyDropConfig( + {}, + 'nyc', + { pivotDropHour: 17, batchWeek: '2026-W23' }, + 'admin-id' + ); + + expect(upsertStoredTenantRow).toHaveBeenCalledWith( + {}, + expect.objectContaining({ pivotDropHour: 17 }), + 'admin-id' + ); + expect(result.dropSchedule.hour).toBe(17); + }); + + it('sendWeeklyDropPush dry-run does not call Expo', async () => { + getTenantByKey.mockResolvedValue(nycTenant); + + const result = await sendWeeklyDropPush({}, 'nyc', { + batchWeek: '2026-W23', + dryRun: true, + force: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.pivotPushRecipientCount).toBe(2); + expect(result.sampleMessage?.data?.type).toBe('pivot_week'); + }); +}); diff --git a/backend/tests/unit/pivotWeeklySnapshotService.test.js b/backend/tests/unit/pivotWeeklySnapshotService.test.js new file mode 100644 index 00000000..88c54669 --- /dev/null +++ b/backend/tests/unit/pivotWeeklySnapshotService.test.js @@ -0,0 +1,197 @@ +jest.mock('../../services/getModelService', () => jest.fn()); +jest.mock('../../connectionsManager', () => ({ + connectToDatabase: jest.fn(), +})); +jest.mock('../../services/getGlobalModelService', () => jest.fn()); +jest.mock('../../services/tenantConfigService', () => ({ + getMergedTenants: jest.fn(), +})); +jest.mock('../../services/pivotReferralCodeService', () => ({ + isPivotTenant: jest.fn(), +})); + +const getModels = require('../../services/getModelService'); +const { connectToDatabase } = require('../../connectionsManager'); +const getGlobalModels = require('../../services/getGlobalModelService'); +const { getMergedTenants } = require('../../services/tenantConfigService'); +const { isPivotTenant } = require('../../services/pivotReferralCodeService'); +const { + aggregateTenantMetrics, + rebuildWeeklySnapshot, + getWeeklySnapshot, + normalizeBatchWeek, +} = require('../../services/pivotWeeklySnapshotService'); + +describe('pivotWeeklySnapshotService', () => { + beforeEach(() => { + jest.clearAllMocks(); + isPivotTenant.mockImplementation( + (tenant) => tenant?.pivotPilot === true || tenant?.tenantType === 'pivot', + ); + }); + + describe('normalizeBatchWeek', () => { + it('defaults to current ISO week when omitted', () => { + const now = new Date('2026-06-26T12:00:00.000Z'); + expect(normalizeBatchWeek(undefined, now)).toEqual({ batchWeek: '2026-W26' }); + }); + + it('rejects invalid batch week', () => { + expect(normalizeBatchWeek('bad-week')).toMatchObject({ + code: 'INVALID_BATCH_WEEK', + status: 400, + }); + }); + }); + + describe('aggregateTenantMetrics', () => { + it('aggregates tenant metrics for a batch week', async () => { + const eventIds = ['665a1b2c3d4e5f6789012345', '665a1b2c3d4e5f6789012346']; + connectToDatabase.mockResolvedValue({}); + + getModels.mockReturnValue({ + Event: { + countDocuments: jest.fn().mockResolvedValue(2), + find: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue(eventIds.map((_id) => ({ _id }))), + }), + }), + }, + PivotEventIntent: { + countDocuments: jest + .fn() + .mockResolvedValueOnce(3) + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(4), + distinct: jest.fn().mockResolvedValue(['u1', 'u2', 'u3']), + aggregate: jest.fn().mockResolvedValue([{ total: 7 }]), + }, + UniversalFeedback: { + find: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue([ + { responses: { rating: 4 } }, + { responses: { rating: 5 } }, + ]), + }), + }), + }, + }); + + const result = await aggregateTenantMetrics( + { tenantKey: 'nyc', name: 'NYC', location: 'New York City', tenantType: 'pivot' }, + '2026-W26', + ); + + expect(result).toEqual({ + tenantKey: 'nyc', + cityDisplayName: 'New York City', + eventCount: 2, + interestedCount: 3, + registeredCount: 2, + externalOpenCount: 7, + swipeCount: 9, + feedbackAvg: 4.5, + activeUsers: 3, + }); + }); + }); + + describe('rebuildWeeklySnapshot', () => { + it('upserts snapshot for pivot tenants', async () => { + const generatedAt = new Date('2026-06-26T12:00:00.000Z'); + const savedDoc = { + batchWeek: '2026-W26', + generatedAt, + tenants: [ + { + tenantKey: 'nyc', + cityDisplayName: 'New York City', + eventCount: 1, + interestedCount: 0, + registeredCount: 0, + externalOpenCount: 0, + swipeCount: 0, + feedbackAvg: null, + activeUsers: 0, + }, + ], + updatedAt: generatedAt, + createdAt: generatedAt, + }; + + getMergedTenants.mockResolvedValue([ + { tenantKey: 'rpi', tenantType: 'campus' }, + { tenantKey: 'nyc', tenantType: 'pivot', location: 'New York City' }, + ]); + + connectToDatabase.mockResolvedValue({}); + getModels.mockReturnValue({ + Event: { + countDocuments: jest.fn().mockResolvedValue(1), + find: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue([{ _id: '665a1b2c3d4e5f6789012345' }]), + }), + }), + }, + PivotEventIntent: { + countDocuments: jest.fn().mockResolvedValue(0), + distinct: jest.fn().mockResolvedValue([]), + aggregate: jest.fn().mockResolvedValue([]), + }, + UniversalFeedback: { + find: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue([]), + }), + }), + }, + }); + + const findOneAndUpdate = jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue(savedDoc), + }); + getGlobalModels.mockReturnValue({ + PivotWeeklySnapshot: { findOneAndUpdate }, + }); + + const result = await rebuildWeeklySnapshot( + { globalDb: {} }, + { batchWeek: '2026-W26', now: generatedAt }, + ); + + expect(isPivotTenant).toHaveBeenCalled(); + expect(findOneAndUpdate).toHaveBeenCalledWith( + { batchWeek: '2026-W26' }, + expect.objectContaining({ + $set: expect.objectContaining({ + generatedAt, + tenants: expect.arrayContaining([ + expect.objectContaining({ tenantKey: 'nyc', eventCount: 1 }), + ]), + }), + }), + expect.objectContaining({ upsert: true }), + ); + expect(result.data.batchWeek).toBe('2026-W26'); + expect(result.data.generatedAt).toEqual(generatedAt); + }); + }); + + describe('getWeeklySnapshot', () => { + it('returns 404 when snapshot missing', async () => { + getGlobalModels.mockReturnValue({ + PivotWeeklySnapshot: { + findOne: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue(null), + }), + }, + }); + + const result = await getWeeklySnapshot({ globalDb: {} }, { batchWeek: '2026-W26' }); + expect(result).toMatchObject({ code: 'SNAPSHOT_NOT_FOUND', status: 404 }); + }); + }); +}); diff --git a/backend/tests/unit/reservationMetricsService.test.js b/backend/tests/unit/reservationMetricsService.test.js index 16159b6e..93ac7572 100644 --- a/backend/tests/unit/reservationMetricsService.test.js +++ b/backend/tests/unit/reservationMetricsService.test.js @@ -28,6 +28,6 @@ describe('reservationMetricsService', () => { conflictRate: 0.333 }); expect(csv).toMatch(/totalReservations,3/); - expect(csv).toMatch(/conflictRate,0.333/); + expect(csv).toMatch(/conflictRate,0.333/); }); }); diff --git a/backend/utilities/pivotDropSchedule.js b/backend/utilities/pivotDropSchedule.js new file mode 100644 index 00000000..472de23e --- /dev/null +++ b/backend/utilities/pivotDropSchedule.js @@ -0,0 +1,217 @@ +const { isValidIsoWeek } = require('./pivotIsoWeek'); + +/** Pilot suggestion when a pivot tenant has no drop config stored yet (not a runtime constant). */ +const PIVOT_DROP_PILOT_DEFAULTS = Object.freeze({ + pivotDropTimezone: 'America/New_York', + pivotDropDayOfWeek: 4, + pivotDropHour: 18, + pivotDropMinute: 0, +}); + +const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +function isPivotTenant(tenant = {}) { + return tenant.pivotPilot === true || tenant.tenantType === 'pivot'; +} + +function normalizeDropMinute(value, fallback = 0) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(59, Math.max(0, Math.trunc(parsed))); +} + +function normalizeDropHour(value, fallback = 18) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(23, Math.max(0, Math.trunc(parsed))); +} + +function normalizeDropDayOfWeek(value, fallback = 4) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(6, Math.max(0, Math.trunc(parsed))); +} + +function resolvePivotDropConfig(tenant = {}) { + const timezone = String(tenant.pivotDropTimezone || '').trim(); + const hasStoredConfig = + Boolean(timezone) && + tenant.pivotDropDayOfWeek !== undefined && + tenant.pivotDropHour !== undefined; + + const defaults = hasStoredConfig + ? { + pivotDropTimezone: timezone, + pivotDropDayOfWeek: normalizeDropDayOfWeek(tenant.pivotDropDayOfWeek), + pivotDropHour: normalizeDropHour(tenant.pivotDropHour), + pivotDropMinute: normalizeDropMinute(tenant.pivotDropMinute, 0), + } + : { ...PIVOT_DROP_PILOT_DEFAULTS }; + + return { + timezone: defaults.pivotDropTimezone, + dayOfWeek: defaults.pivotDropDayOfWeek, + hour: defaults.pivotDropHour, + minute: defaults.pivotDropMinute, + overrides: Array.isArray(tenant.pivotDropOverrides) ? tenant.pivotDropOverrides : [], + usingPilotDefaults: !hasStoredConfig, + }; +} + +function isoWeekToMondayUtc(batchWeek) { + if (!isValidIsoWeek(batchWeek)) { + throw new Error(`Invalid batchWeek "${batchWeek}" — expected YYYY-Www`); + } + + const [, yearStr, weekStr] = batchWeek.match(/^(\d{4})-W(\d{2})$/); + const year = Number(yearStr); + const week = Number(weekStr); + const jan4 = new Date(Date.UTC(year, 0, 4)); + const jan4IsoDay = jan4.getUTCDay() || 7; + const monday = new Date(jan4); + monday.setUTCDate(jan4.getUTCDate() - jan4IsoDay + 1 + (week - 1) * 7); + return monday; +} + +function daysFromIsoMonday(dayOfWeek) { + return dayOfWeek === 0 ? 6 : dayOfWeek - 1; +} + +function getTimeZoneOffsetMs(timeZone, date) { + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + formatter + .formatToParts(date) + .filter((part) => part.type !== 'literal') + .map((part) => [part.type, part.value]) + ); + + const asUtc = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + + return asUtc - date.getTime(); +} + +function zonedLocalToUtc({ year, month, day, hour, minute, timeZone }) { + const utcGuess = Date.UTC(year, month - 1, day, hour, minute, 0); + const firstPass = new Date(utcGuess - getTimeZoneOffsetMs(timeZone, new Date(utcGuess))); + return new Date(utcGuess - getTimeZoneOffsetMs(timeZone, firstPass)); +} + +function resolvePivotDropInstant(tenant, batchWeek, now = new Date()) { + if (!isPivotTenant(tenant)) { + throw new Error(`Tenant "${tenant?.tenantKey || 'unknown'}" is not a pivot city`); + } + + const config = resolvePivotDropConfig(tenant); + const override = config.overrides.find((row) => row?.batchWeek === batchWeek); + const schedule = override + ? { + dayOfWeek: normalizeDropDayOfWeek(override.dayOfWeek, config.dayOfWeek), + hour: normalizeDropHour(override.hour, config.hour), + minute: normalizeDropMinute(override.minute, config.minute), + source: 'override', + } + : { + dayOfWeek: config.dayOfWeek, + hour: config.hour, + minute: config.minute, + source: 'default', + }; + + const monday = isoWeekToMondayUtc(batchWeek); + const dropDate = new Date(monday); + dropDate.setUTCDate(monday.getUTCDate() + daysFromIsoMonday(schedule.dayOfWeek)); + + const dropAt = zonedLocalToUtc({ + year: dropDate.getUTCFullYear(), + month: dropDate.getUTCMonth() + 1, + day: dropDate.getUTCDate(), + hour: schedule.hour, + minute: schedule.minute, + timeZone: config.timezone, + }); + + return { + dropAt, + batchWeek, + timezone: config.timezone, + dayOfWeek: schedule.dayOfWeek, + hour: schedule.hour, + minute: schedule.minute, + source: schedule.source, + usingPilotDefaults: config.usingPilotDefaults, + resolvedAt: now, + }; +} + +function formatPivotDropInstant(dropAt, timeZone) { + const weekday = new Intl.DateTimeFormat('en-US', { + timeZone, + weekday: 'short', + }).format(dropAt); + + const datePart = new Intl.DateTimeFormat('en-US', { + timeZone, + month: 'short', + day: 'numeric', + }).format(dropAt); + + const timePart = new Intl.DateTimeFormat('en-US', { + timeZone, + hour: 'numeric', + minute: '2-digit', + hour12: true, + }).format(dropAt); + + const zonePart = new Intl.DateTimeFormat('en-US', { + timeZone, + timeZoneName: 'short', + }) + .formatToParts(dropAt) + .find((part) => part.type === 'timeZoneName')?.value; + + return `${weekday} ${datePart}, ${timePart}${zonePart ? ` ${zonePart}` : ''}`; +} + +function describePivotDropSchedule(resolved) { + const dayLabel = DAY_NAMES[resolved.dayOfWeek] || `day ${resolved.dayOfWeek}`; + const minuteLabel = String(resolved.minute).padStart(2, '0'); + const sourceLabel = resolved.source === 'override' ? 'override' : 'tenant default'; + const localTime = `${dayLabel} ${resolved.hour}:${minuteLabel} ${resolved.timezone}`; + const formatted = formatPivotDropInstant(resolved.dropAt, resolved.timezone); + + return { + localTime, + formatted, + sourceLabel, + }; +} + +module.exports = { + PIVOT_DROP_PILOT_DEFAULTS, + DAY_NAMES, + isPivotTenant, + resolvePivotDropConfig, + resolvePivotDropInstant, + formatPivotDropInstant, + describePivotDropSchedule, + isoWeekToMondayUtc, + zonedLocalToUtc, +}; diff --git a/backend/utilities/pivotIsoWeek.js b/backend/utilities/pivotIsoWeek.js new file mode 100644 index 00000000..a94e0e66 --- /dev/null +++ b/backend/utilities/pivotIsoWeek.js @@ -0,0 +1,24 @@ +/** + * ISO 8601 week date string (YYYY-Www) for Pivot batchWeek fields. + * @param {Date} [date] + * @returns {string} + */ +function toIsoWeek(date = new Date()) { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const week = Math.ceil((((d - yearStart) / 86400000) + 1) / 7); + return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`; +} + +const ISO_WEEK_PATTERN = /^\d{4}-W\d{2}$/; + +function isValidIsoWeek(value) { + return typeof value === 'string' && ISO_WEEK_PATTERN.test(value.trim()); +} + +module.exports = { + toIsoWeek, + isValidIsoWeek, + ISO_WEEK_PATTERN, +}; diff --git a/backend/utilities/sessionUtils.js b/backend/utilities/sessionUtils.js index b0290e77..a23c8fe1 100644 --- a/backend/utilities/sessionUtils.js +++ b/backend/utilities/sessionUtils.js @@ -1,7 +1,10 @@ const jwt = require('jsonwebtoken'); -const getModels = require('../services/getModelService'); const getGlobalModels = require('../services/getGlobalModelService'); +function getModels(req, ...names) { + return require('../services/getModelService')(req, ...names); +} + const REFRESH_TOKEN_EXPIRY_DAYS = 30; const REFRESH_TOKEN_EXPIRY_MS = REFRESH_TOKEN_EXPIRY_DAYS * 24 * 60 * 60 * 1000; @@ -110,18 +113,14 @@ async function createGlobalSession(globalUserId, refreshToken, req) { * Validate a refresh token and return the session (tenant DB - legacy) */ async function validateSession(refreshToken, req) { - const { Session, User } = getModels(req, 'Session', 'User'); - try { - // First verify the JWT token const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET); - // New tokens use globalUserId; legacy use userId if (decoded.globalUserId) { return validateGlobalSession(refreshToken, req); } - // Find the session in database (tenant) + const { Session, User } = getModels(req, 'Session', 'User'); const session = await Session.findOne({ refreshToken }); if (!session) { diff --git a/frontend/design/EventDashboard/Meridian/EventDashboard Redesign.html b/frontend/design/EventDashboard/Meridian/EventDashboard Redesign.html new file mode 100644 index 00000000..e4bd9d50 --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/EventDashboard Redesign.html @@ -0,0 +1,41 @@ + + + + +EventDashboard — Lifecycle Redesigns + + + + + + + + + + +
+ + + + + + + + + + diff --git a/frontend/design/EventDashboard/Meridian/EventDashboard Spec.md b/frontend/design/EventDashboard/Meridian/EventDashboard Spec.md new file mode 100644 index 00000000..0defbe80 --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/EventDashboard Spec.md @@ -0,0 +1,388 @@ +# EventDashboard — Lifecycle Redesign Spec + +**Status:** Ready for implementation +**Owner:** Design → Eng handoff +**Scope:** Replace the current monolithic `EventDashboard` (and the parallel `EventDashboardFocused` variant) with four state-aware surfaces driven by a single component that branches on event lifecycle. + +--- + +## 1. Problem statement + +The current `EventDashboard` is a single dense surface that renders the same nine-tab layout regardless of where an event is in its lifecycle. This causes: + +1. **Decision fatigue** — 9 tabs + 5 header CTAs visible at all times. +2. **No focus** — equal-weight panels for unrelated tasks (Event Preview vs. Registration Chart). +3. **Status-blind chrome** — a concluded event still pushes "Send announcement" and "Preview" as primaries. +4. **Stat noise** — 3+ stats above the chart with similar visual weight. +5. **Wasted vertical space** — sidebar + ambient header + title + meta strip + banner + tabs ≈ 480px before content. +6. **No "what now" surface** — pending follow-ups (feedback, thank-yous, returns) hide inside tabs. + +## 2. Solution overview + +The dashboard becomes **four products that share a data layer**: + +| Code | Lifecycle state | Trigger condition | Primary jobs | +|---|---|---|---| +| `created` | Just created (draft) | `event.status === 'draft'` and required fields incomplete | Complete setup checklist; reach a publishable state | +| `preparing` | Published, future-dated | `status === 'published'` && `start_time > now` | Outreach, tasks, run-of-show prep | +| `live` | Day-of, in progress | `start_time <= now <= end_time` | Check-in, agenda advance, issue triage | +| `concluded` | Past | `end_time < now` | Post-mortem, feedback collection, wrap-up tasks | + +A single `EventDashboard` component reads `dashboardData.stats.operationalStatus` (and existing `event.status`) and routes to one of four `DashboardShell` variants. The existing `useFetch('/org-event-management/${orgId}/events/${eventId}/dashboard')` call is unchanged. + +## 3. Visual reference + +Designs live in `EventDashboard Redesign.html` (this project). Each artboard is 1440×980 and is the source of truth. Take inspiration from styling but re-imagine it in Meridian's existing design language + +### 4.3 Components to build (shared across all four states) + +Build these as small presentational components in `components/EventDashboard/components/shared/`: + +- **``** — left rail of workspace areas, replaces `TabbedContainer`. Each item: `{key, label, count?, active?, disabled?, alert?, live?}`. Width 184px. Disabled items show "LOCKED" mono microlabel. +- **``** — status badge. Live tone has pulsing 0 0 0 3px tint shadow on dot. +- **``** — Fraunces big number + tinted delta chip. +- **``** — 4–6px filled bar. +- **``** — mono 10.5px uppercase eyebrow. +- **``** — used in "Needs attention" / "Wrap-up" panels. +- **``** — for stat rows in readiness panels. + +### 4.4 Header pattern (all four states) + +Top of canvas, padding `24px 40px 0`: + +``` +[← back btn] [breadcrumb mono] +[H1 Fraunces 44px] [StatusPill] +[meta strip: date · time · venue · host (state-dependent)] + [secondary btns] [primary CTA] +``` + +Primary CTA is **state-specific** (see each state below). Secondary buttons collapse into a "More" overflow on viewports < 1280px. + +## 5. State-by-state spec + +### 5.1 `created` — Just created (empty event) + +**Trigger:** `event.status === 'draft'`. + +**Header** +- Breadcrumb: `EVENTS / NEW · DRAFT` +- StatusPill: `tone="draft"`, label = `Draft · not visible to anyone` +- Meta: `Created {relative time} · Auto-saved` +- Secondary: `Discard` +- Primary: `Publish · {N} steps left` — **disabled** until checklist complete; computes `N = totalSteps - completedSteps`. + +**WorkspaceRail items** +```js +[ + { key: 'overview', label: 'Setup', count: '2/8', active: true }, + { key: 'schedule', label: 'Schedule', disabled: true }, + { key: 'tasks', label: 'Tasks', disabled: true }, + { key: 'people', label: 'People', disabled: true, count: 0 }, + { key: 'jobs', label: 'Jobs', disabled: true }, + { key: 'comms', label: 'Communications', disabled: true }, + { key: 'insights', label: 'Insights', disabled: true }, +] +``` + +**Body — two stacked panels:** + +1. **Progress hero** (white card) + - Eyebrow `SETUP · {done} OF {total}` + - H2 Fraunces 28px: `"You're ~{minutes} minutes away from a publishable event."` (computed from sum of remaining `mins`) + - 8-segment progress meter (filled = green primary, unfilled = bg-soft) + - Right-aligned: small empty poster placeholder (replaced when uploaded). + +2. **Setup checklist card** (white card, divider rows) + Each row: `[circle: checkmark / number / current dot] [title + sub] [~Xmin] [CTA]` + - Done rows: title strikethrough, ink-3. + - Current row: full row tinted `--ed-primary-tint`, primary "Continue →" CTA on right. + - Future rows: muted, "Open" ghost button. + - Final row tagged `FINAL STEP` mono microlabel. + + Steps (this order, this copy): + 1. Name your event + 2. Pick a date + 3. Add a location *(required to publish)* + 4. Write a description + 5. Upload a poster or hero image *(optional but events with a poster see 3× more registrations)* + 6. Build a registration form + 7. Set capacity & expected attendance + 8. Publish + +**Footer note:** `ⓘ Tasks, registrations, communications and insights unlock once your event is published.` + +**Backend hooks** +- Each step maps to a field on the event document. "Done" rule: + - `name`: `event.event_name?.trim().length > 0` + - `date`: `event.start_time && event.end_time` + - `location`: `event.location?.trim().length > 0` + - `description`: `event.description?.length >= 100` + - `poster`: `event.poster_image_url` + - `regForm`: `event.registrationFormId` + - `capacity`: `event.expectedAttendance > 0` + - `publish`: requires above + `event.status !== 'draft'` +- "Add location" CTA opens the existing `EventEditorTab` scrolled to the location field. + +--- + +### 5.2 `preparing` — Before the event + +**Trigger:** `event.status === 'published'` and `start_time > now`. + +**Header** +- StatusPill: `tone="prep"`, label = `"Published · {daysOut} days out"` (or hours if < 1 day). +- Meta: full date/time/venue. +- Secondary: `Preview page` +- Primary: `Send announcement` (paper-airplane icon). + +**WorkspaceRail** +```js +[ + { key: 'overview', label: 'Overview', active: true }, + { key: 'schedule', label: 'Schedule', count: agenda.items.length }, + { key: 'tasks', label: 'Tasks', count: openTaskCount, alert: hasOverdueTasks }, + { key: 'people', label: 'People', count: registrationCount }, + { key: 'jobs', label: 'Jobs', count: openJobCount }, + { key: 'comms', label: 'Communications', count: pendingAnnouncementCount }, + { key: 'insights', label: 'Insights' }, +] +``` + +**Body:** + +1. **Countdown hero** (white card, two columns split by vertical divider) + - **Left column (1.1fr):** + - Eyebrow `EVENT STARTS IN` + - Big number: days remaining (96px Fraunces) + - Inline: `days` + `{hours} hrs · {minutes} min` + - 30-segment progress bar showing position from creation date → start date. Filled segments = primary. + - Footer: `{createdDate} · created` — `today` — `{startDate} · live` + - **Right column (1fr):** "This week — {N} items need you" + - Up to 4 task rows: `[avatar circle][body][due chip]`. Avatar background = warn-soft for urgent, bg-soft for normal. + - Pull from `tasks` filtered to `dueDate <= now + 7d`, ordered by due date. + +2. **Three-panel row (1.3fr / 1fr / 1fr):** + - **Registration pace** — title, big number `registrationCount`, sub `of {expected} expected · {pct}%`, delta line (this week + on-track copy), small sparkline with goal dashed line. Reuse existing `EventDashboardChart` rendered at small height (~110–130px) with no header/legend. + - **Outreach** — list of `Announcement` rows: initial / mid-cycle / final push / day-of. Done rows show date sent + reach; current row primary-bordered; future rows muted with suggested send date. Wires to `Communications` data. + - **Run-of-show readiness** — 5-row checklist of: Schedule blocks, Speakers confirmed, Volunteer roles, Equipment booked, Venue walkthrough. Each row: label + ratio in primary (good) or warn (incomplete). + +--- + +### 5.3 `live` — During the event + +**Trigger:** `start_time <= now <= end_time`. + +**Visual identity:** dark slim live header, lighter body. This is the only state with a colored chrome. + +**Header (replaces standard header — full-width dark bar, padding `14px 32px`):** +- Background `#1c2520`, color white. +- Left: back button (transparent border) + live pulse pill (`LIVE · DAY {N}`) + event name (medium weight, opacity 0.85) + `{wallClockTime} · {elapsed} elapsed` (mono). +- Right: `Page volunteers` (ghost) + `Send announcement` (orange `--ed-live-accent` solid). + +**WorkspaceRail** (label `LIVE OPS`, white card panel) +```js +[ + { key: 'live', label: 'Live ops', active: true, live: true, count: 'NOW' }, + { key: 'checkin', label: 'Check-in', count: checkedInCount, live: true }, + { key: 'schedule', label: 'Schedule', count: `${currentBlockIdx}/${totalBlocks}` }, + { key: 'tasks', label: 'Tasks', count: liveTaskCount, alert: true }, + { key: 'people', label: 'People', count: registrationCount }, + { key: 'jobs', label: 'Jobs', count: jobCount }, + { key: 'comms', label: 'Communications', count: 0 }, +] +``` + +**Body:** + +1. **"Happening now" command bar** (dark gradient card) + - Eyebrow `HAPPENING NOW · {room}` + - H2 Fraunces 28px: current agenda block title + speaker + - Sub: `{startTime} – {endTime} · ends in {N} minutes` (red-tinted if running over) + - Right: `View slot` (ghost) + `Advance →` (mint solid, advances to next agenda item). + +2. **4-tile readiness row** (white cards, equal width) + - `Checked in`: count / total registered, meter, `{pct}%` accent. + - `Capacity`: `{checkedIn}/{venueCapacity}` ratio. + - `On schedule`: `+{minutes}` running over (warn tone if > 0). Meter shows position in day. + - `Issues`: count of open ops issues, warn tone, sub = top issue summary. + +3. **Two-column row (1.2fr / 1fr):** + - **Check-in flow · last 60 min** — sparkline (`color="#c4533a"`), three sub-stats: peak rate, current rate, no-shows. `Open scanner` button. + - **Up next** — agenda from current block onward (next 6 items). The next block has primary-tint background + `NEXT` mono badge. Each row: `[time mono][title + room]`. + +**Polling:** check-in count and "happening now" should poll every 15s while user is on this view. + +--- + +### 5.4 `concluded` — Post-mortem + +**Trigger:** `end_time < now`. + +**Header** +- Breadcrumb: `EVENTS / RETROSPECTIVE` +- StatusPill: `tone="past"`, label = `"Concluded · {daysAgo} days ago"` +- Meta: full date/time/venue. +- Secondary: `Duplicate for {nextYear}` +- Primary: `Share report` (dark `--ed-ink` solid, NOT primary green). + +**WorkspaceRail** +```js +[ + { key: 'overview', label: 'Retrospective', active: true }, + { key: 'feedback', label: 'Feedback', count: feedbackResponseCount }, + { key: 'people', label: 'Attendees', count: checkedInCount }, + { key: 'tasks', label: 'Wrap-up', count: openWrapupTasks, alert: true }, + { key: 'insights', label: 'Insights' }, + { key: 'archive', label: 'Archive' }, +] +``` + +**Body:** + +1. **Outcome statement** (white card, 1.4fr / auto) + - Eyebrow `OUTCOME` (primary green) + - H1 Fraunces 36px: one-sentence summary template: + `"{registrationCount} {audience} showed up — {deltaPct}% over your {expectedAttendance}-attendee goal, with a {showRate}% show-rate and {avgRating} / 5 average rating."` + - Where `{audience}` is auto-picked from event template/tags ("builders", "guests", "attendees", default "people"). + - Sub: list of remaining wrap-up follow-ups in plain English. + - Right: poster thumbnail tilted 2°, drop-shadow. + +2. **Scoreboard** (white card, 4 columns separated by `1px solid --ed-line`) + Each column: eyebrow / Fraunces 44px value / delta chip / "expected X" / italic 12px explainer. + Required metrics: + - Registrations (vs. expected). + - Showed up (count + show-rate %). + - Avg. rating (from feedback). + - NPS (warn tone if response rate < 30%). + +3. **Two-column row (1.4fr / 1fr):** + - **What attendees said** — top 4 feedback themes as horizontal bar rows: `{themeText} ··· {pct}%`. Bar color: primary (>50% positive), `#c4a44a` mixed, warn negative. Theme extraction = backend job (out of scope here; for v1, use response tags). + - **Wrap-up** — `` list. Top item primary-tinted ("Send feedback follow-up to {N} silent attendees"), rest are neutral ("$X prize disbursement", "Equipment return — {N} items"). + +## 6. Architecture + +### 6.1 File structure + +``` +components/EventDashboard/ + EventDashboard.jsx ← thin router + EventDashboard.scss ← tokens + base + shells/ + CreatedShell.jsx + PreparingShell.jsx + LiveShell.jsx + ConcludedShell.jsx + components/shared/ + WorkspaceRail.jsx + StatusPill.jsx + HeroNumber.jsx + MeterBar.jsx + EyebrowLabel.jsx + AttentionItem.jsx + DashboardHeader.jsx ← shared layout, slots for state-specific bits + state/ + useEventLifecycleState.js ← derives 'created' | 'preparing' | 'live' | 'concluded' + useSetupChecklist.js ← created-state checklist completeness + useThisWeekTasks.js + useLiveOpsPolling.js +``` + +### 6.2 Routing logic + +```js +function getLifecycleState(event, stats) { + if (event.status === 'draft') return 'created'; + if (stats?.operationalStatus === 'completed') return 'concluded'; + const now = Date.now(); + const start = new Date(event.start_time).getTime(); + const end = new Date(event.end_time || event.start_time).getTime(); + if (now >= start && now <= end) return 'live'; + if (start > now) return 'preparing'; + return 'concluded'; +} +``` + +`EventDashboard.jsx` keeps the data fetch, onboarding, error handling, and announcement spotlight. The router only chooses a shell: + +```jsx +const state = getLifecycleState(event, dashboardData?.stats); +const Shell = { created: CreatedShell, preparing: PreparingShell, + live: LiveShell, concluded: ConcludedShell }[state]; +return ; +``` + +### 6.3 Migration + +- `EventDashboardFocused` is deprecated and routed through the new component. Delete after one release. +- The `overlayRegistry.js` `default` and `focused` variants both resolve to the new `EventDashboard`. +- `useDashboardOverlay.showEventDashboardFocused` becomes an alias for `showEventDashboard`. +- The current `EventDashboardHeader` is retired; only `DashboardHeader` (shared) remains. +- The old tab system (`TabbedContainer` of 9 tabs) is replaced with the rail. All nine tab bodies are kept and rendered when the corresponding rail item is active. Routing inside the dashboard remains URL-driven (`?tab=` param). + +## 7. Behaviour & data + +### 7.1 Existing endpoints reused + +- `GET /org-event-management/${orgId}/events/${eventId}/dashboard` — primary fetch (unchanged). +- `GET /org-event-management/${orgId}/events/${eventId}/registrations/growth` — for sparklines. +- `GET .../checkins/recent?window=60m` — **new lightweight endpoint** for the Live state's check-in flow chart. +- `GET .../tasks?dueWithin=7d` — for "this week" panel (preparing state). + +### 7.2 New endpoints + +- `GET /org-event-management/${orgId}/events/${eventId}/setup-progress` — returns `{ completed: [...stepKeys], total: N, etaMinutes: M }`. The frontend can compute this client-side too; surface it as an endpoint so it can be cached. +- `POST .../advance-agenda` — advances the current agenda block. Used by Live state's `Advance →` button. +- `GET .../feedback-themes` — returns top 4 themes with pct + tone for the concluded state. v1 may compute this from response tags; v2 = ML. + +### 7.3 Polling + +- Live state polls `/dashboard` and `/checkins/recent` every 15s. All other states do not poll. +- Pause polling when `document.hidden`. + +### 7.4 Empty / loading / error + +- Loading: keep the existing skeleton but adapt per shell (e.g., the Created shell shows skeletons of checklist rows). +- Errors: existing `addNotification` toast + retry on dashboard fetch; preserved from current implementation. + +## 8. Accessibility + +- All state pills have `aria-label` including the readable status ("Event status: live, day 1"). +- Live pulse uses `prefers-reduced-motion: reduce` to disable the box-shadow pulse animation. +- WorkspaceRail items are `` with `aria-current="page"` on the active item. Disabled items use `aria-disabled="true"` and are non-tabbable. +- All big-number tiles have a hidden `` describing the relationship ("177 registrations, 77% over goal"). +- Color is never the sole indicator — every warn-toned item has a text label or icon. + +## 9. Analytics + +Add events: +- `event_dashboard_state_view` `{state, eventId, orgId}` — fired once per state per session. +- `event_dashboard_state_transition` `{from, to, eventId}` — for Live ↔ Concluded transitions. +- `event_dashboard_setup_step_complete` `{step, eventId}` — Created state. +- `event_dashboard_advance_agenda` `{fromBlockId, toBlockId}` — Live state. + +Existing `event_workspace_view` and `event_workspace_tab_view` are kept; rename the latter to `event_dashboard_rail_view` if convenient. + +## 10. Acceptance criteria + +- [ ] Lifecycle state is correctly derived from `event.status`, `start_time`, `end_time`, and `stats.operationalStatus`. +- [ ] Each shell matches the corresponding artboard at 1440×980 within 4px tolerance. +- [ ] No header has more than one primary CTA. +- [ ] WorkspaceRail count badges reflect live data. +- [ ] Created state's `Publish` button is disabled until all required steps complete. +- [ ] Live state polls and pauses when tab is hidden. +- [ ] Concluded state's outcome sentence renders correctly when any of `expectedAttendance`, `feedbackCount`, or `avgRating` is missing (graceful fallback copy: "showed up — feedback collection in progress"). +- [ ] Existing `EventDashboardOnboarding`, announcement spotlight, and post-mortem overlay continue to work. +- [ ] Existing analytics events continue to fire; new events fire once per state. +- [ ] `EventDashboardFocused` consumers redirect to the new `EventDashboard` with no behavioral regression. + +## 11. Out of scope (v1) + +- ML-powered feedback theme extraction (v1 = response tags). +- Multi-day live state UX (Day 2 chips, between-days "lull" view) — v1 treats every day-of as Day 1. +- Mobile layout — v1 is desktop-only; the existing mobile preview component is unchanged. +- Operator/admin variants (`AdminEventOperatorPage`) — out of scope; they will continue using the legacy components until a follow-up. + +--- + +**Reference:** see `EventDashboard Redesign.html` in this project for the four artboards, the diagnosis card, and the shared chrome components. diff --git a/frontend/design/EventDashboard/Meridian/app.jsx b/frontend/design/EventDashboard/Meridian/app.jsx new file mode 100644 index 00000000..89e8a3a1 --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/app.jsx @@ -0,0 +1,33 @@ +const { DesignCanvas, DCSection, DCArtboard } = window; + +function App() { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} + +ReactDOM.createRoot(document.getElementById('root')).render(); diff --git a/frontend/design/EventDashboard/Meridian/design-canvas.jsx b/frontend/design/EventDashboard/Meridian/design-canvas.jsx new file mode 100644 index 00000000..5e685a6d --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/design-canvas.jsx @@ -0,0 +1,936 @@ + +// DesignCanvas.jsx — Figma-ish design canvas wrapper +// Warm gray grid bg + Sections + Artboards + PostIt notes. +// Artboards are reorderable (grip-drag), deletable, labels/titles are +// inline-editable, and any artboard can be opened in a fullscreen focus +// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar +// via the host bridge. No assets, no deps. +// +// Usage: +// +// +// +// +// +// + +const DC = { + bg: '#f0eee9', + grid: 'rgba(0,0,0,0.06)', + label: 'rgba(60,50,40,0.7)', + title: 'rgba(40,30,20,0.85)', + subtitle: 'rgba(60,50,40,0.6)', + postitBg: '#fef4a8', + postitText: '#5a4a2a', + font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', +}; + +// One-time CSS injection (classes are dc-prefixed so they don't collide with +// the hosted design's own styles). +if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) { + const s = document.createElement('style'); + s.id = 'dc-styles'; + s.textContent = [ + '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}', + '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}', + '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}', + '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}', + '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}', + // isolation:isolate contains artboard content's z-indexes so a + // z-indexed child (sticky navbar etc.) can't paint over .dc-header or + // the .dc-menu popover that drops into the top of the card. + '.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}', + '.dc-card *{scrollbar-width:none}', + '.dc-card *::-webkit-scrollbar{display:none}', + // Per-artboard header: grip + label on the left, delete/expand on the + // right. Single flex row; when the artboard's on-screen width is too + // narrow for both the label yields (ellipsis, then hidden entirely below + // ~4ch via the container query) and the buttons stay on the row. + '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;', + ' display:flex;align-items:center;container-type:inline-size}', + '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}', + '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}', + '.dc-grip:hover{background:rgba(0,0,0,.08)}', + '.dc-grip:active{cursor:grabbing}', + '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;', + ' display:flex;align-items:center;transition:background .12s;overflow:hidden}', + // Below ~4ch of label room: hide the label entirely, and drop the grip to + // hover-only (same reveal rule as .dc-btns) so a narrow header is clean + // until the card is moused. + '@container (max-width: 110px){', + ' .dc-labeltext{display:none}', + ' .dc-grip{opacity:0}', + ' [data-dc-slot]:hover .dc-grip{opacity:1}', + '}', + '.dc-labeltext:hover{background:rgba(0,0,0,.05)}', + '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}', + '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}', + '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}', + '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}', + '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;', + ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;', + ' font:inherit;transition:background .12s,color .12s}', + '.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}', + // Slot hosting an open menu floats above later siblings (which otherwise + // paint on top — same z-index:auto, later DOM order) so the popup isn't + // clipped by the next card. + '[data-dc-slot]:has(.dc-menu){z-index:10}', + '.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;', + ' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}', + '.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;', + ' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;', + ' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}', + '.dc-menu button:hover{background:rgba(0,0,0,.05)}', + '.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}', + '.dc-menu .dc-danger{color:#c96442}', + '.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}', + // Chrome (titles / labels / buttons) counter-scales against the viewport + // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by + // DCViewport on every transform update and inherits to all descendants — + // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use + // it the same way. + // + // The header uses transform:scale (out-of-flow, so layout impact doesn't + // matter) with its world-space width set to card-width / inv-zoom so that + // after counter-scaling its on-screen width exactly matches the card's — + // that's what lets the container query + text-overflow behave against the + // card's visible edge at every zoom level. + // + // The section head uses CSS zoom instead of transform so its layout box + // grows with the counter-scale, pushing the card row down — otherwise the + // constant-screen-size title would overflow into the (shrinking) world- + // space gap and overlap the artboard headers at low zoom. + '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));', + ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}', + '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}', + ].join('\n'); + document.head.appendChild(s); +} + +const DCCtx = React.createContext(null); + +// ───────────────────────────────────────────────────────────── +// DesignCanvas — stateful wrapper around the pan/zoom viewport. +// Owns runtime state (per-section order, renamed titles/labels, hidden +// artboards, focused artboard). Order/titles/labels/hidden persist to a +// .design-canvas.state.json +// sidecar next to the HTML. Reads go via plain fetch() so the saved +// arrangement is visible anywhere the HTML + sidecar are served together +// (omelette preview, direct link, downloaded zip). Writes go through the +// host's window.omelette bridge — editing requires the omelette runtime. +// Focus is ephemeral. +// ───────────────────────────────────────────────────────────── +const DC_STATE_FILE = '.design-canvas.state.json'; + +function DesignCanvas({ children, minScale, maxScale, style }) { + const [state, setState] = React.useState({ sections: {}, focus: null }); + // Hold rendering until the sidecar read settles so the saved order/titles + // appear on first paint (no source-order flash). didRead gates writes until + // the read settles so the empty initial state can't clobber a slow read; + // skipNextWrite suppresses the one echo-write that would otherwise follow + // hydration. + const [ready, setReady] = React.useState(false); + const didRead = React.useRef(false); + const skipNextWrite = React.useRef(false); + + React.useEffect(() => { + let off = false; + fetch('./' + DC_STATE_FILE) + .then((r) => (r.ok ? r.json() : null)) + .then((saved) => { + if (off || !saved || !saved.sections) return; + skipNextWrite.current = true; + setState((s) => ({ ...s, sections: saved.sections })); + }) + .catch(() => {}) + .finally(() => { didRead.current = true; if (!off) setReady(true); }); + const t = setTimeout(() => { if (!off) setReady(true); }, 150); + return () => { off = true; clearTimeout(t); }; + }, []); + + React.useEffect(() => { + if (!didRead.current) return; + if (skipNextWrite.current) { skipNextWrite.current = false; return; } + const t = setTimeout(() => { + window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {}); + }, 250); + return () => clearTimeout(t); + }, [state.sections]); + + // Build registries synchronously from children so FocusOverlay can read + // them in the same render. Only direct DCSection > DCArtboard children are + // walked — wrapping them in other elements opts out of focus/reorder. + const registry = {}; // slotId -> { sectionId, artboard } + const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] } + const sectionOrder = []; + React.Children.forEach(children, (sec) => { + if (!sec || sec.type !== DCSection) return; + const sid = sec.props.id ?? sec.props.title; + if (!sid) return; + sectionOrder.push(sid); + const persisted = state.sections[sid] || {}; + const abs = []; + React.Children.forEach(sec.props.children, (ab) => { + if (!ab || ab.type !== DCArtboard) return; + const aid = ab.props.id ?? ab.props.label; + if (aid) abs.push([aid, ab]); + }); + // hidden is scoped to one source revision — when the agent regenerates + // (artboard-ID set changes), prior deletes don't apply to new content. + const srcKey = abs.map(([k]) => k).join('\x1f'); + const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : []; + const srcIds = []; + abs.forEach(([aid, ab]) => { + if (hidden.includes(aid)) return; + registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab }; + srcIds.push(aid); + }); + const kept = (persisted.order || []).filter((k) => srcIds.includes(k)); + sectionMeta[sid] = { + title: persisted.title ?? sec.props.title, + subtitle: sec.props.subtitle, + slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))], + }; + }); + + const api = React.useMemo(() => ({ + state, + section: (id) => state.sections[id] || {}, + patchSection: (id, p) => setState((s) => ({ + ...s, + sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } }, + })), + setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })), + }), [state]); + + // Esc exits focus; any outside pointerdown commits an in-progress rename. + React.useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); }; + const onPd = (e) => { + const ae = document.activeElement; + if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur(); + }; + document.addEventListener('keydown', onKey); + document.addEventListener('pointerdown', onPd, true); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('pointerdown', onPd, true); + }; + }, [api]); + + return ( + + {ready && children} + {state.focus && registry[state.focus] && ( + + )} + + ); +} + +// ───────────────────────────────────────────────────────────── +// DCViewport — transform-based pan/zoom (internal) +// +// Input mapping (Figma-style): +// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events) +// • trackpad scroll → pan (two-finger) +// • mouse wheel → zoom (notched; distinguished from trackpad scroll) +// • middle-drag / primary-drag-on-bg → pan +// +// Transform state lives in a ref and is written straight to the DOM +// (translate3d + will-change) so wheel ticks don't go through React — +// keeps pans at 60fps on dense canvases. +// ───────────────────────────────────────────────────────────── +function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) { + const vpRef = React.useRef(null); + const worldRef = React.useRef(null); + const tf = React.useRef({ x: 0, y: 0, scale: 1 }); + // Persist viewport across reloads so the user lands back where they were + // after an agent edit or browser refresh. The sandbox origin is already + // per-project; pathname keeps multiple canvas files in one project apart. + const tfKey = 'dc-viewport:' + location.pathname; + const saveT = React.useRef(0); + + const lastPostedScale = React.useRef(); + const apply = React.useCallback(() => { + const { x, y, scale } = tf.current; + const el = worldRef.current; + if (!el) return; + el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; + // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel). + el.style.setProperty('--dc-inv-zoom', String(1 / scale)); + // Keep the host toolbar's % readout in sync with the canvas scale. Pan + // ticks leave scale unchanged — skip the cross-frame post for those. + if (lastPostedScale.current !== scale) { + lastPostedScale.current = scale; + window.parent.postMessage({ type: '__dc_zoom', scale }, '*'); + } + clearTimeout(saveT.current); + saveT.current = setTimeout(() => { + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }, 200); + }, [tfKey]); + + React.useLayoutEffect(() => { + const flush = () => { + clearTimeout(saveT.current); + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }; + try { + const s = JSON.parse(localStorage.getItem(tfKey) || 'null'); + if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) { + tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) }; + apply(); + } + } catch {} + // Flush on pagehide and unmount so a reload within the 200ms debounce + // window doesn't drop the last pan/zoom. + window.addEventListener('pagehide', flush); + return () => { window.removeEventListener('pagehide', flush); flush(); }; + }, []); + + React.useEffect(() => { + const vp = vpRef.current; + if (!vp) return; + + const zoomAt = (cx, cy, factor) => { + const r = vp.getBoundingClientRect(); + const px = cx - r.left, py = cy - r.top; + const t = tf.current; + const next = Math.min(maxScale, Math.max(minScale, t.scale * factor)); + const k = next / t.scale; + // keep the world point under the cursor fixed + t.x = px - (px - t.x) * k; + t.y = py - (py - t.y) * k; + t.scale = next; + apply(); + }; + + // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends + // line-mode deltas (Firefox) or large integer pixel deltas with no X + // component (Chrome/Safari, typically multiples of 100/120). Trackpad + // two-finger scroll sends small/fractional pixel deltas, often with + // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch. + const isMouseWheel = (e) => + e.deltaMode !== 0 || + (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40); + + const onWheel = (e) => { + e.preventDefault(); + if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels + if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) { + // trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched + // wheels fall through to the fixed-step branch below. + zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01)); + } else if (isMouseWheel(e)) { + // notched mouse wheel — fixed-ratio step per click + zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18)); + } else { + // trackpad two-finger scroll — pan + tf.current.x -= e.deltaX; + tf.current.y -= e.deltaY; + apply(); + } + }; + + // Safari sends native gesture* events for trackpad pinch with a smooth + // e.scale; preferring these over the ctrl+wheel fallback gives a much + // better feel there. No-ops on other browsers. Safari also fires + // ctrlKey wheel events during the same pinch — isGesturing makes + // onWheel drop those entirely so they neither zoom nor pan. + let gsBase = 1; + let isGesturing = false; + const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; }; + const onGestureChange = (e) => { + e.preventDefault(); + zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale); + }; + const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; }; + + // Drag-pan: middle button anywhere, or primary button on canvas + // background (anything that isn't an artboard or an inline editor). + let drag = null; + const onPointerDown = (e) => { + const onBg = !e.target.closest('[data-dc-slot], .dc-editable'); + if (!(e.button === 1 || (e.button === 0 && onBg))) return; + e.preventDefault(); + vp.setPointerCapture(e.pointerId); + drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY }; + vp.style.cursor = 'grabbing'; + }; + const onPointerMove = (e) => { + if (!drag || e.pointerId !== drag.id) return; + tf.current.x += e.clientX - drag.lx; + tf.current.y += e.clientY - drag.ly; + drag.lx = e.clientX; drag.ly = e.clientY; + apply(); + }; + const onPointerUp = (e) => { + if (!drag || e.pointerId !== drag.id) return; + vp.releasePointerCapture(e.pointerId); + drag = null; + vp.style.cursor = ''; + }; + + // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the + // visible midpoint stays fixed — matching the host's iframe-zoom feel. + const onHostMsg = (e) => { + const d = e.data; + if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') { + const r = vp.getBoundingClientRect(); + zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale); + } else if (d && d.type === '__dc_probe') { + // Host's [readyGen] reset asks whether a canvas is present; it + // fires on the iframe's native 'load', which for canvases with + // images/fonts is after our mount-time announce, so re-announce. + // Clear the pan-tick guard so apply() re-posts the current scale + // even if it's unchanged — the host just reset dcScale to 1. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + } + }; + window.addEventListener('message', onHostMsg); + // Announce canvas mode so the host toolbar proxies its % control here + // instead of scaling the iframe element (which would just shrink the + // viewport window of an infinite canvas). The apply() that follows emits + // the initial __dc_zoom so the toolbar % is correct before first pinch. + // lastPostedScale reset mirrors the __dc_probe handler: the layout + // effect's restore-path apply() may already have posted the restored + // scale (before __dc_present), so clear the guard to re-post it in order. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + + vp.addEventListener('wheel', onWheel, { passive: false }); + vp.addEventListener('gesturestart', onGestureStart, { passive: false }); + vp.addEventListener('gesturechange', onGestureChange, { passive: false }); + vp.addEventListener('gestureend', onGestureEnd, { passive: false }); + vp.addEventListener('pointerdown', onPointerDown); + vp.addEventListener('pointermove', onPointerMove); + vp.addEventListener('pointerup', onPointerUp); + vp.addEventListener('pointercancel', onPointerUp); + return () => { + window.removeEventListener('message', onHostMsg); + vp.removeEventListener('wheel', onWheel); + vp.removeEventListener('gesturestart', onGestureStart); + vp.removeEventListener('gesturechange', onGestureChange); + vp.removeEventListener('gestureend', onGestureEnd); + vp.removeEventListener('pointerdown', onPointerDown); + vp.removeEventListener('pointermove', onPointerMove); + vp.removeEventListener('pointerup', onPointerUp); + vp.removeEventListener('pointercancel', onPointerUp); + }; + }, [apply, minScale, maxScale]); + + const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`; + return ( +
+
+
+ {children} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// DCSection — editable title + h-row of artboards in persisted order +// ───────────────────────────────────────────────────────────── +function DCSection({ id, title, subtitle, children, gap = 48 }) { + const ctx = React.useContext(DCCtx); + const sid = id ?? title; + const all = React.Children.toArray(children); + const artboards = all.filter((c) => c && c.type === DCArtboard); + const rest = all.filter((c) => !(c && c.type === DCArtboard)); + const sec = (ctx && sid && ctx.section(sid)) || {}; + // Must match DesignCanvas's srcKey computation exactly (it filters falsy + // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes. + const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean); + const srcKey = allIds.join('\x1f'); + const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : []; + const srcOrder = allIds.filter((k) => !hidden.includes(k)); + + const order = React.useMemo(() => { + const kept = (sec.order || []).filter((k) => srcOrder.includes(k)); + return [...kept, ...srcOrder.filter((k) => !kept.includes(k))]; + }, [sec.order, srcOrder.join('|')]); + + const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a])); + + // marginBottom counter-scales so the on-screen gap between sections stays + // constant — otherwise at low zoom the (world-space) gap collapses while + // the screen-constant sectionhead below it doesn't, and the title reads as + // belonging to the section above. paddingBottom below is just enough for + // the 24px artboard-header (abs-positioned above each card) plus ~8px, so + // the title sits tight against its own row at every zoom. + return ( +
+
+
+ ctx && sid && ctx.patchSection(sid, { title: v })} + style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> + {subtitle &&
{subtitle}
} +
+
+
+ {order.map((k) => ( + ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} + onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} + onDelete={() => ctx && ctx.patchSection(sid, (x) => ({ + hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k], + srcKey, + }))} + onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> + ))} +
+ {rest} +
+ ); +} + +// DCArtboard — marker; rendered by DCArtboardFrame via DCSection. +function DCArtboard() { return null; } + +// Per-artboard export (kind: 'png' | 'html'). Both paths share the same +// self-contained clone: computed styles baked in, @font-face / / +// inline-style background-image urls inlined as data URIs. PNG wraps the +// clone in foreignObject→canvas at 3× the artboard's natural width×height +// (same pipeline the host uses for page captures); HTML wraps it in a +// minimal standalone document. Both are independent of viewport zoom. +async function dcExport(node, w, h, name, kind) { + try { await document.fonts.ready; } catch {} + const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => { + const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b); + })).catch(() => url); + + // Collect @font-face rules. ss.cssRules throws SecurityError on + // cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch + // the CSS text directly (those endpoints send ACAO:*) and regex-extract + // the blocks. @import and @media/@supports are walked so nested + // @font-face rules aren't missed. + const fontRules = [], pending = [], seen = new Set(); + const scrapeCss = (href) => { + if (seen.has(href)) return; seen.add(href); + pending.push(fetch(href).then((r) => r.text()).then((css) => { + for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href }); + for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g)) + scrapeCss(new URL(m[1], href).href); + }).catch(() => {})); + }; + const walk = (rules, base) => { + for (const r of rules) { + if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base }); + else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) { + const ibase = r.styleSheet.href || base; + try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); } + } else if (r.cssRules) walk(r.cssRules, base); + } + }; + for (const ss of document.styleSheets) { + const base = ss.href || location.href; + try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); } + } + while (pending.length) await pending.shift(); + const fontCss = (await Promise.all(fontRules.map(async (rule) => { + let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g; + while ((m = re.exec(rule.css))) { + if (m[2].indexOf('data:') === 0) continue; + let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; } + out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")'); + } + return out; + }))).join('\n'); + + const cloneStyled = (src) => { + if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode(''); + const dst = src.cloneNode(false); + if (src.nodeType === 1) { + const cs = getComputedStyle(src); let txt = ''; + for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';'; + dst.setAttribute('style', txt + 'animation:none;transition:none;'); + if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {} + } + for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c)); + return dst; + }; + const clone = cloneStyled(node); + clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml'); + // Drop the card's own shadow/radius so the export is a flush w×h rect; + // the artboard's own background (if any) is already in the computed style. + clone.style.boxShadow = 'none'; clone.style.borderRadius = '0'; + + const jobs = []; + clone.querySelectorAll('img').forEach((el) => { + const s = el.getAttribute('src'); + if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d))); + }); + [clone, ...clone.querySelectorAll('*')].forEach((el) => { + const bg = el.style.backgroundImage; if (!bg) return; + let m; const re = /url\(["']?([^"')]+)["']?\)/g; + while ((m = re.exec(bg))) { + const tok = m[0], url = m[1]; + if (url.indexOf('data:') === 0) continue; + jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); })); + } + }); + await Promise.all(jobs); + + const xml = new XMLSerializer().serializeToString(clone); + const save = (blob, ext) => { + if (!blob) return; + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + }; + + if (kind === 'html') { + const html = '' + name + '' + + (fontCss ? '' : '') + + '' + xml + ''; + return save(new Blob([html], { type: 'text/html' }), 'html'); + } + + // PNG: the SVG's own width/height must be the output resolution — an + // -loaded SVG rasterizes at its intrinsic size, so sizing it at 1× + // and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the + // w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders + // the HTML at full resolution. + const px = 3; + const svg = '' + + (fontCss ? '' : '') + xml + ''; + const img = new Image(); + await new Promise((res, rej) => { + img.onload = res; img.onerror = () => rej(new Error('svg load failed')); + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); + }); + const cv = document.createElement('canvas'); + cv.width = w * px; cv.height = h * px; + cv.getContext('2d').drawImage(img, 0, 0); + cv.toBlob((blob) => save(blob, 'png'), 'image/png'); +} + +function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) { + const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props; + const id = rawId ?? rawLabel; + const ref = React.useRef(null); + const cardRef = React.useRef(null); + const menuRef = React.useRef(null); + const [menuOpen, setMenuOpen] = React.useState(false); + const [confirming, setConfirming] = React.useState(false); + + // ⋯ menu: close on any outside pointerdown. Two-click delete lives inside + // the menu — first click arms the row, second commits; closing disarms. + React.useEffect(() => { + if (!menuOpen) { setConfirming(false); return; } + const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); }; + document.addEventListener('pointerdown', off, true); + return () => document.removeEventListener('pointerdown', off, true); + }, [menuOpen]); + + const doExport = (kind) => { + setMenuOpen(false); + if (!cardRef.current) return; + const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_'); + dcExport(cardRef.current, width, height, name, kind) + .catch((e) => console.error('[design-canvas] export failed:', e)); + }; + + // Live drag-reorder: dragged card sticks to cursor; siblings slide into + // their would-be slots in real time via transforms. DOM order only + // changes on drop. + const onGripDown = (e) => { + e.preventDefault(); e.stopPropagation(); + const me = ref.current; + // translateX is applied in local (pre-scale) space but pointer deltas and + // getBoundingClientRect().left are screen-space — divide by the viewport's + // current scale so the dragged card tracks the cursor at any zoom level. + const scale = me.getBoundingClientRect().width / me.offsetWidth || 1; + const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`)); + const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left })); + const slotXs = homes.map((h) => h.x); + const startIdx = order.indexOf(id); + const startX = e.clientX; + let liveOrder = order.slice(); + me.classList.add('dc-dragging'); + + const layout = () => { + for (const h of homes) { + if (h.id === id) continue; + const slot = liveOrder.indexOf(h.id); + h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`; + } + }; + + const move = (ev) => { + const dx = ev.clientX - startX; + me.style.transform = `translateX(${dx / scale}px)`; + const cur = homes[startIdx].x + dx; + let nearest = 0, best = Infinity; + for (let i = 0; i < slotXs.length; i++) { + const d = Math.abs(slotXs[i] - cur); + if (d < best) { best = d; nearest = i; } + } + if (liveOrder.indexOf(id) !== nearest) { + liveOrder = order.filter((k) => k !== id); + liveOrder.splice(nearest, 0, id); + layout(); + } + }; + + const up = () => { + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', up); + const finalSlot = liveOrder.indexOf(id); + me.classList.remove('dc-dragging'); + me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`; + // After the settle transition, kill transitions + clear transforms + + // commit the reorder in the same frame so there's no visual snap-back. + setTimeout(() => { + for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; } + if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder); + requestAnimationFrame(() => requestAnimationFrame(() => { + for (const h of homes) h.el.style.transition = ''; + })); + }, 180); + }; + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', up); + }; + + return ( +
+
e.stopPropagation()}> +
+
+ +
+
+ e.stopPropagation()} + style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
+
+
+
+ + {menuOpen && ( +
e.stopPropagation()}> + + +
+ +
+ )} +
+ +
+
+
+ {children ||
{id}
} +
+
+ ); +} + +// Inline rename — commits on blur or Enter. +function DCEditable({ value, onChange, style, tag = 'span', onClick }) { + const T = tag; + return ( + e.stopPropagation()} + onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} + style={style}>{value} + ); +} + +// ───────────────────────────────────────────────────────────── +// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across +// sections, Esc or backdrop click to exit. +// ───────────────────────────────────────────────────────────── +function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) { + const ctx = React.useContext(DCCtx); + const { sectionId, artboard } = entry; + const sec = ctx.section(sectionId); + const meta = sectionMeta[sectionId]; + const peers = meta.slotIds; + const aid = artboard.props.id ?? artboard.props.label; + const idx = peers.indexOf(aid); + const secIdx = sectionOrder.indexOf(sectionId); + + const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); }; + const goSection = (d) => { + // Sections whose artboards are all deleted have slotIds:[] — step past + // them to the next non-empty section so ↑/↓ doesn't dead-end. + const n = sectionOrder.length; + for (let i = 1; i < n; i++) { + const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n]; + const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; + if (first) { ctx.setFocus(`${ns}/${first}`); return; } + } + }; + + React.useEffect(() => { + const k = (e) => { + if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); } + if (e.key === 'ArrowRight') { e.preventDefault(); go(1); } + if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); } + if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); } + }; + document.addEventListener('keydown', k); + return () => document.removeEventListener('keydown', k); + }); + + const { width = 260, height = 480, children } = artboard.props; + const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight }); + React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []); + const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2)); + + const [ddOpen, setDd] = React.useState(false); + const Arrow = ({ dir, onClick }) => ( + + ); + + // Portal to body so position:fixed is the real viewport regardless of any + // transform on DesignCanvas's ancestors (including the canvas zoom itself). + return ReactDOM.createPortal( +
ctx.setFocus(null)} + onWheel={(e) => e.preventDefault()} + style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)', + fontFamily: DC.font, color: '#fff' }}> + + {/* top bar: section dropdown (left) · close (right) */} +
e.stopPropagation()} + style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> +
+ + {ddOpen && ( +
+ {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => ( + + ))} +
+ )} +
+
+ +
+ + {/* card centered, label + index below — only the card itself stops + propagation so any backdrop click (including the margins around + the card) exits focus */} +
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> +
+ {children ||
{aid}
} +
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> + {(sec.labels || {})[aid] ?? artboard.props.label} + {idx + 1} / {peers.length} +
+
+ + go(-1)} /> + go(1)} /> + + {/* dots */} +
e.stopPropagation()} + style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> + {peers.map((p, i) => ( +
+
, + document.body, + ); +} + +// ───────────────────────────────────────────────────────────── +// Post-it — absolute-positioned sticky note +// ───────────────────────────────────────────────────────────── +function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { + return ( +
{children}
+ ); +} + +Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); + diff --git a/frontend/design/EventDashboard/Meridian/diagnosis.jsx b/frontend/design/EventDashboard/Meridian/diagnosis.jsx new file mode 100644 index 00000000..f3ba90e9 --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/diagnosis.jsx @@ -0,0 +1,99 @@ +/* Diagnosis card — the four lifecycle states and the design problem of each */ +function Diagnosis() { + const states = [ + { + n: '01', tag: 'JUST CREATED', title: 'Empty event', + problem: 'A blank shell. The user has a name and not much else. Today\'s dashboard hides the gaps inside tab navigation — they have to click around to discover what\'s missing.', + goal: 'Make the next setup step obvious, and the second one too. Defer everything else until it earns its place.', + tone: 'neutral', + }, + { + n: '02', tag: 'PREPARING', title: 'Before the event', + problem: 'Foundations are done. Now it\'s outreach, tasks, and last-mile prep. Today\'s dashboard treats Mar 14 the same whether it\'s 30 days out or 30 hours out — no urgency, no time-aware cues.', + goal: 'A countdown view. What needs to happen this week, what\'s blocked, what\'s been sent.', + tone: 'work', + }, + { + n: '03', tag: 'LIVE', title: 'During the event', + problem: 'The current dashboard is a planning tool. On the day-of, organizers need a different surface entirely: check-in pace, capacity, mid-event announcements, ops issues — not a Registrations chart.', + goal: 'A live operations console. Big numbers, fast actions, almost no chrome.', + tone: 'live', + }, + { + n: '04', tag: 'CONCLUDED', title: 'Post-mortem', + problem: 'The event is over but the chrome still pushes "Send announcement" and "Preview". The post-mortem is hidden behind a banner CTA.', + goal: 'Pivot to retrospective: outcome vs. expectation, what attendees said, what tasks remain to close out.', + tone: 'past', + }, + ]; + return ( +
+
+
Four dashboards, not one
+
+
+ The EventDashboard tries to be the same surface across the entire lifecycle of an event. That's the root of the decision fatigue, density, and lack of focus — a "just-created" event and a "post-mortem" event have nothing in common except the data model — they should look like different products. +
+ + {/* Lifecycle bar */} +
+ {states.map((s, i) => ( +
+
+
{s.n}
+
+
+
{s.tag}
+
{s.title}
+
{s.problem}
+
→ {s.goal}
+
+ ))} +
+ + {/* Recurring flaws — what every state inherits */} +
What every state should fix
+
+ {[ + {tag: 'Tabs → workspace', body: '9 horizontal tabs become a 6-item workspace rail with counts. Same areas, less competition.'}, + {tag: 'Header → status-aware', body: 'The big block of CTAs is a function of state. A new event needs "Publish"; a live event needs "Open check-in"; a concluded event needs "Generate post-mortem".'}, + {tag: 'One primary, always', body: 'There is exactly one primary action per state, surfaced in a single dedicated zone — not five buttons in a top-right cluster.'}, + ].map((f, i) => ( +
+
{f.tag}
+
{f.body}
+
+ ))} +
+
+ ); +} +window.Diagnosis = Diagnosis; diff --git a/frontend/design/EventDashboard/Meridian/v1-focus.jsx b/frontend/design/EventDashboard/Meridian/v1-focus.jsx new file mode 100644 index 00000000..93d49762 --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/v1-focus.jsx @@ -0,0 +1,169 @@ +/* Shared chrome — AppSidebar, Sparkline, PosterPlaceholder + WorkspaceRail */ + +const Sparkline = ({color = 'var(--primary)', flat = false}) => ( + + + + + + + + + + +); + +const PosterPlaceholder = ({w = 160, h = 220, empty = false}) => ( + empty ? ( +
+
NO POSTER
+
+ ) : ( +
+
180 ? 32 : 26, fontWeight: 600, lineHeight: 0.95, letterSpacing: '-0.02em'}}>CHANGE
THE
WORLD
+
SAT MAR 14 · 9:30 AM
BIO & INTERDIS
+
180 ? 38 : 32, fontStyle: 'italic', color: '#f8d4a0', fontWeight: 600}}>$5,000
+
+ ) +); + +function AppSidebar({active = 'Events'}) { + const items = [ + {label: 'Dashboard', icon: 'M3 13h8V3H3zM13 21h8V11h-8zM3 21h8v-6H3zM13 3v6h8V3z'}, + {label: 'Events', icon: 'M19 4h-1V2h-2v2H8V2H6v2H5C3.9 4 3 4.9 3 6v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14z'}, + {label: 'Tasks', icon: 'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'}, + {label: 'Announcements', icon: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10s10-4.48 10-10S17.52 2 12 2zm1 17h-2v-6h2zm0-8h-2V7h2z'}, + {label: 'Members', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3z'}, + {label: 'Settings', icon: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z'}, + ]; + return ( +
+
+
+
+
+
Meridian
+
ATLAS
+
+
+
+
+ +
+
+
+
+
+
James
+
@James
+
+
+
+ ); +} + +/* Workspace rail used by all four states. Items can be disabled (state 1) or alerted (state 2/3). */ +function WorkspaceRail({items, label = 'WORKSPACE'}) { + return ( + + ); +} + +/* Status pill helper */ +function StatusPill({tone, label, dot = true}) { + const tones = { + draft: {bg: 'var(--bg-soft)', fg: 'var(--ink-3)', dot: 'var(--ink-4)'}, + prep: {bg: 'var(--primary-tint)', fg: 'var(--primary)', dot: 'var(--primary)'}, + live: {bg: '#fde7e1', fg: '#a8412c', dot: '#c4533a'}, + past: {bg: 'var(--bg-soft)', fg: 'var(--ink-3)', dot: 'var(--ink-4)'}, + }; + const t = tones[tone] || tones.prep; + return ( + + {dot && } + {label} + + ); +} + +window.Sparkline = Sparkline; +window.PosterPlaceholder = PosterPlaceholder; +window.AppSidebar = AppSidebar; +window.WorkspaceRail = WorkspaceRail; +window.StatusPill = StatusPill; diff --git a/frontend/design/EventDashboard/Meridian/v2-cockpit.jsx b/frontend/design/EventDashboard/Meridian/v2-cockpit.jsx new file mode 100644 index 00000000..d7a564e5 --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/v2-cockpit.jsx @@ -0,0 +1,157 @@ +/* State 1 — JUST CREATED. Empty event. The user just clicked "Create event". + Goals: + - Make the next setup step obvious (and the second one too). + - Defer everything that hasn't earned its place: tabs are mostly locked. + - The screen reads like a checklist, not a dashboard. */ + +function StateCreated() { + const items = [ + {key: 'overview', label: 'Setup', count: '2/8', active: true}, + {key: 'schedule', label: 'Schedule', disabled: true}, + {key: 'tasks', label: 'Tasks', disabled: true}, + {key: 'people', label: 'People', count: 0, disabled: true}, + {key: 'jobs', label: 'Jobs', disabled: true}, + {key: 'comms', label: 'Communications', disabled: true}, + {key: 'insights', label: 'Insights', disabled: true}, + ]; + + const checklist = [ + {done: true, title: 'Name your event', sub: '"Change the World Innovation Weekend"', mins: 1}, + {done: true, title: 'Pick a date', sub: 'Saturday, March 14, 2026 · 9:30 AM – 6:00 PM', mins: 1}, + {done: false, current: true, title: 'Add a location', sub: 'Where is this happening? Required to publish.', mins: 2, cta: 'Add location'}, + {done: false, title: 'Write a description', sub: 'Tell people why they should come. 2–4 paragraphs is plenty.', mins: 5}, + {done: false, title: 'Upload a poster or hero image', sub: 'Optional, but events with a poster see 3× more registrations.', mins: 2}, + {done: false, title: 'Build a registration form', sub: 'Default is name + email. Add custom fields if needed.', mins: 4}, + {done: false, title: 'Set capacity & expected attendance', sub: 'Used for waitlists and analytics goals.', mins: 1}, + {done: false, title: 'Publish', sub: 'Sends to your org\'s feed. You can still edit after publishing.', mins: 1, terminal: true}, + ]; + + return ( +
+ +
+
+
+
+
+ +
EVENTS / NEW · DRAFT
+
+
+

Change the World Innovation Weekend

+ +
+
+ Created 4 minutes ago · Auto-saved +
+
+
+ + +
+
+
+ +
+ + +
+ {/* Progress hero */} +
+
+
SETUP · 2 OF 8
+
+ You are ~15 minutes away from a publishable event. +
+
+ {Array.from({length: 8}).map((_, i) => ( +
+ ))} +
+
+
+ +
+
+ + {/* Checklist — the whole content */} +
+ {checklist.map((it, i) => ( +
+
+ {it.done ? ( + + ) : ( + {i + 1} + )} +
+
+
+ {it.title} + {it.terminal && FINAL STEP} +
+
{it.sub}
+
+
+ ~{it.mins} min + {it.current && ( + + )} + {!it.current && !it.done && ( + + )} +
+
+ ))} +
+ +
+ + Tasks, registrations, communications and insights unlock once your event is published. +
+
+
+
+
+ ); +} + +window.StateCreated = StateCreated; diff --git a/frontend/design/EventDashboard/Meridian/v3-retrospective.jsx b/frontend/design/EventDashboard/Meridian/v3-retrospective.jsx new file mode 100644 index 00000000..c8c22e11 --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/v3-retrospective.jsx @@ -0,0 +1,180 @@ +/* State 2 — PREPARING. Foundation done. T-minus countdown. + Goals: + - Time-aware: a countdown front and center; "this week" framing. + - Outreach + tasks are the headline activities. + - Registration chart is here, but kept as a small read-only snapshot. */ + +function StatePreparing() { + const items = [ + {key: 'overview', label: 'Overview', active: true}, + {key: 'schedule', label: 'Schedule', count: 12}, + {key: 'tasks', label: 'Tasks', count: 4, alert: true}, + {key: 'people', label: 'People', count: 64}, + {key: 'jobs', label: 'Jobs', count: 5}, + {key: 'comms', label: 'Communications', count: 1}, + {key: 'insights', label: 'Insights'}, + ]; + + return ( +
+ +
+ {/* Header */} +
+
+
+
+ +
EVENTS / CHANGE THE WORLD INNOVATION WEEKEND
+
+
+

Change the World Innovation Weekend

+ +
+
+ Sat, Mar 14, 2026 + · + 9:30 AM – 6:00 PM + · + Biotechnology & Interdis Bldg +
+
+
+ + +
+
+
+ +
+ + +
+ {/* Countdown hero — combines T-minus + this-week framing */} +
+
+
EVENT STARTS IN
+
+
11
+
+
days
+
4 hrs · 26 min
+
+
+
+ {Array.from({length: 30}).map((_, i) => ( +
+ ))} +
+
+ Feb 13 · created + today + Mar 14 · live +
+
+
+
THIS WEEK · 4 ITEMS NEED YOU
+
+ {[ + {who: 'You', body: 'Send the second outreach email', due: 'by Tue', tone: 'urgent'}, + {who: 'You', body: 'Confirm catering count with vendor', due: 'by Wed', tone: 'urgent'}, + {who: 'Sam', body: 'Finalize judging rubric', due: 'by Thu', tone: 'normal'}, + {who: 'Devi', body: 'Book A/V equipment', due: 'by Fri', tone: 'normal'}, + ].map((t, i) => ( +
+
{t.who[0]}
+
{t.body}
+
{t.due}
+
+ ))} +
+
+
+ + {/* Three secondary panels: registration pace, outreach, ops */} +
+
+
+
Registration pace
+ 30 DAYS +
+
+
64
+
of 100 expected · 64%
+
+
+12 this week · on track to hit goal
+
+ +
+
goal · 100
+
+
+ +
+
Outreach
+ {[ + {label: 'Initial announcement', sub: 'Sent Feb 14 · 412 reached', done: true}, + {label: 'Mid-cycle reminder', sub: 'Sent Mar 1 · 38 new RSVPs', done: true}, + {label: 'Final push', sub: 'Suggested: Wed Mar 11', done: false, current: true}, + {label: 'Day-of confirmation', sub: 'Auto · Mar 14 6 AM', done: false}, + ].map((s, i) => ( +
+
+ {s.done && } +
+
+
{s.label}
+
{s.sub}
+
+
+ ))} +
+ +
+
Run-of-show readiness
+ {[ + {k: 'Schedule blocks', v: '12 / 12', good: true}, + {k: 'Speakers confirmed', v: '6 / 8', good: false}, + {k: 'Volunteer roles', v: '5 / 5', good: true}, + {k: 'Equipment booked', v: '3 / 5', good: false}, + {k: 'Venue walkthrough', v: 'Mar 12', good: true}, + ].map((r, i) => ( +
+ {r.k} + {r.v} +
+ ))} +
+
+
+
+
+
+ ); +} + +window.StatePreparing = StatePreparing; diff --git a/frontend/design/EventDashboard/Meridian/v4-live.jsx b/frontend/design/EventDashboard/Meridian/v4-live.jsx new file mode 100644 index 00000000..9251aaf0 --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/v4-live.jsx @@ -0,0 +1,153 @@ +/* State 3 — LIVE. Day-of, event in progress. + Goals: + - Operations console. Big numbers. Fast actions. Almost no chrome. + - Live check-in pace, capacity meter, current agenda block. + - One-tap actions: send announcement, open scanner, page volunteers. */ + +function StateLive() { + const items = [ + {key: 'live', label: 'Live ops', active: true, live: true, count: 'NOW'}, + {key: 'checkin', label: 'Check-in', count: 96, live: true}, + {key: 'schedule', label: 'Schedule', count: '4/12'}, + {key: 'tasks', label: 'Tasks', count: 1, alert: true}, + {key: 'people', label: 'People', count: 177}, + {key: 'jobs', label: 'Jobs', count: 5}, + {key: 'comms', label: 'Communications', count: 0}, + ]; + + return ( +
+ +
+ {/* Slim live header */} +
+
+ + + + LIVE · DAY 1 + + Change the World Innovation Weekend + 11:24 AM · 4h 36m elapsed +
+
+ + +
+
+ +
+
+ +
+ +
+ {/* The single 'now' panel — current schedule block + actions */} +
+
+
HAPPENING NOW · ROOM A
+
Opening keynote — Dr. Patel
+
10:00 AM – 11:30 AM · ends in 6 minutes
+
+
+ + +
+
+ + {/* Big live numbers row */} +
+ {[ + {k: 'Checked in', v: '139', sub: 'of 177 registered', meter: 0.785, accent: 'primary'}, + {k: 'Capacity', v: '78%', sub: '139 / 200 max', meter: 0.78, accent: 'primary'}, + {k: 'On schedule', v: '+0:06', sub: 'running 6 min over', meter: 0.5, accent: 'warn'}, + {k: 'Issues', v: '1', sub: 'AV in Room B', meter: 1, accent: 'warn'}, + ].map((s, i) => ( +
+
{s.k}
+
+
{s.v}
+
+
{s.sub}
+
+
+
+
+ ))} +
+ + {/* Two columns: live check-in + agenda */} +
+
+
+
Check-in flow · last 60 min
+
+ +
+
+
+ +
+
+ {[ + {k: 'Peak rate', v: '14 / min', t: '9:42 AM'}, + {k: 'Now', v: '2 / min', t: 'live'}, + {k: 'No-shows', v: '38', t: '21.5%'}, + ].map((m, i) => ( +
+
{m.k.toUpperCase()}
+
{m.v}
+
{m.t}
+
+ ))} +
+
+ +
+
+
Up next
+ +
+
+ {[ + {time: '11:30 AM', title: 'Track kickoff — Climate', room: 'Room A', accent: true}, + {time: '11:30 AM', title: 'Track kickoff — Health', room: 'Room B'}, + {time: '12:00 PM', title: 'Lunch · catered', room: 'Atrium'}, + {time: '01:00 PM', title: 'Build sprint 1', room: 'All rooms'}, + {time: '03:00 PM', title: 'Mentor office hours', room: 'Lounge'}, + {time: '05:30 PM', title: 'Day 1 wrap', room: 'Room A'}, + ].map((s, i) => ( +
+
{s.time}
+
+
{s.title}
+
{s.room}
+
+ {s.accent && NEXT} +
+ ))} +
+
+
+
+
+
+
+ ); +} + +window.StateLive = StateLive; diff --git a/frontend/design/EventDashboard/Meridian/v5-concluded.jsx b/frontend/design/EventDashboard/Meridian/v5-concluded.jsx new file mode 100644 index 00000000..92c7f71a --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/v5-concluded.jsx @@ -0,0 +1,159 @@ +/* State 4 — CONCLUDED. Post-mortem mode. + Goals: + - Pivot to retrospective: outcomes vs. expectations. + - Voices (feedback) and wrap-up tasks share the screen. + - The live-event chrome is fully retired. */ + +function StateConcluded() { + const items = [ + {key: 'overview', label: 'Retrospective', active: true}, + {key: 'feedback', label: 'Feedback', count: 42}, + {key: 'people', label: 'Attendees', count: 139}, + {key: 'tasks', label: 'Wrap-up', count: 3, alert: true}, + {key: 'insights', label: 'Insights'}, + {key: 'archive', label: 'Archive'}, + ]; + + return ( +
+ +
+
+
+
+
+ +
EVENTS / RETROSPECTIVE
+
+
+

Change the World Innovation Weekend

+ +
+
+ Sat, Mar 14, 2026 · 9:30 AM – 6:00 PM · Biotechnology & Interdis Bldg +
+
+
+ + +
+
+
+ +
+ + +
+ {/* One-line outcome statement, editorial */} +
+
+
OUTCOME
+
+ 177 builders showed up — 77% over your 100-attendee goal, with a 78.5% show-rate and 4.6 / 5 average rating. +
+
+ 3 follow-ups remain: feedback collection (24% response rate), prize disbursement, equipment return. +
+
+
+ +
+
+ + {/* Scoreboard — expected vs actual */} +
+ {[ + {k: 'Registrations', v: '177', expect: 'expected 100', delta: '+77%', tone: 'primary', explain: 'Final-week acceleration drove most of the overage.'}, + {k: 'Showed up', v: '139', expect: 'of 177 registered', delta: '78.5%', tone: 'primary', explain: 'Above the 65% campus average for free events.'}, + {k: 'Avg. rating', v: '4.6', expect: '/ 5 · 42 responses', delta: 'top quartile', tone: 'primary', explain: 'Highest praise: structure of the prize tracks.'}, + {k: 'NPS', v: '+58', expect: '24% response rate', delta: 'collect more', tone: 'warn', explain: 'Confidence interval is wide. Send a follow-up?'}, + ].map((s, i) => ( +
+
{s.k}
+
+
{s.v}
+
{s.delta}
+
+
{s.expect}
+
{s.explain}
+
+ ))} +
+ + {/* Voices + Wrap-up */} +
+
+
+

What attendees said

+ +
+
+ {[ + {pct: 71, label: 'Prize structure was motivating', tone: 'good'}, + {pct: 64, label: 'Mentor availability was excellent', tone: 'good'}, + {pct: 38, label: 'Wished for longer build time', tone: 'mixed'}, + {pct: 21, label: 'Lunch logistics were confusing', tone: 'bad'}, + ].map((th, i) => ( +
+
+ {th.label} + {th.pct}% +
+
+
+
+
+ ))} +
+
+ +
+
+

Wrap-up

+ 3 OPEN +
+ {[ + {tag: 'COLLECT', body: 'Send feedback follow-up to the 135 silent attendees.', cta: 'Send', primary: true}, + {tag: 'DISBURSE', body: '$5,000 prize awaiting confirmation for winning team.', cta: 'Confirm'}, + {tag: 'RETURN', body: 'Equipment return to facilities — 3 items outstanding.', cta: 'Mark done'}, + ].map((it, i) => ( +
+
{it.tag}
+
{it.body}
+ +
+ ))} +
+
+
+
+
+
+ ); +} + +window.StateConcluded = StateConcluded; diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg index 435542fa..790b5ed1 100644 --- a/frontend/public/icon.svg +++ b/frontend/public/icon.svg @@ -1,17 +1,16 @@ - - - - - - + + + + + - - + + - - + + diff --git a/frontend/src/App.js b/frontend/src/App.js index 09e48258..13966a4e 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -26,6 +26,8 @@ import DeveloperOnboard from './pages/DeveloperOnboarding/DeveloperOnboarding'; 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 PlatformProtectedRoute from './components/PlatformProtectedRoute/PlatformProtectedRoute'; import OIEDash from './pages/OIEDash/OIEDash'; import NewBadge from './pages/NewBadge/NewBadge'; import CreateOrg from './pages/CreateOrg/CreateOrg'; @@ -44,7 +46,7 @@ import TermsOfService from './pages/TermsOfService/TermsOfService'; import ChildSafetyStandards from './pages/ChildSafetyStandards/ChildSafetyStandards'; import SAMLCallback from './components/SAMLCallback/SAMLCallback'; import EmailVerification from './pages/EmailVerification'; -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { AuthProvider } from './AuthContext'; import { CacheProvider } from './CacheContext'; import { GoogleOAuthProvider } from '@react-oauth/google'; @@ -225,6 +227,10 @@ function App() { }/> }/> }/> + }> + } /> + } /> + }/> }/> }/> diff --git a/frontend/src/AuthContext.js b/frontend/src/AuthContext.js index ba3814d4..2c270487 100644 --- a/frontend/src/AuthContext.js +++ b/frontend/src/AuthContext.js @@ -127,7 +127,13 @@ export const AuthProvider = ({ children }) => { window.location.href = `/select-school${next}`; return; } - setUser(response.data.user); + setUser( + response.data.user + ? { ...response.data.user, platformRoles: response.data.platformRoles || [] } + : (response.data.platformRoles?.length + ? { platformRoles: response.data.platformRoles, roles: [] } + : null) + ); // Set friend requests if provided if (response.data.friendRequests) { setFriendRequests({ @@ -145,6 +151,10 @@ export const AuthProvider = ({ children }) => { } // Determine auth method from user data (only when user exists) const u = response.data.user; + const platformRoles = response.data.platformRoles || []; + if (u || platformRoles.length) { + setIsAuthenticated(true); + } if (u) { if (u.samlProvider) { setAuthMethod('saml'); @@ -176,7 +186,6 @@ export const AuthProvider = ({ children }) => { // non-fatal: leave anonymous regs in storage to retry next time } } - setIsAuthenticated(true); getCheckedIn(); // Root-configurable onboarding gate for all users missing newly required/unseen steps. diff --git a/frontend/src/assets/Brand Image/BEACON.svg b/frontend/src/assets/Brand Image/BEACON.svg index 03870788..f645c8b6 100644 --- a/frontend/src/assets/Brand Image/BEACON.svg +++ b/frontend/src/assets/Brand Image/BEACON.svg @@ -1,13 +1,5 @@ - - - - - - - - - - - - + + + + diff --git a/frontend/src/assets/Brand Image/BEACON2.svg b/frontend/src/assets/Brand Image/BEACON2.svg new file mode 100644 index 00000000..03870788 --- /dev/null +++ b/frontend/src/assets/Brand Image/BEACON2.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/src/assets/Brand Image/Globe.svg b/frontend/src/assets/Brand Image/Globe.svg index aa852db1..e1684307 100644 --- a/frontend/src/assets/Brand Image/Globe.svg +++ b/frontend/src/assets/Brand Image/Globe.svg @@ -1,5 +1,11 @@ - - - - + + + + + + + + + + diff --git a/frontend/src/components/Dashboard/Dashboard.jsx b/frontend/src/components/Dashboard/Dashboard.jsx index f8301610..6b59f94e 100644 --- a/frontend/src/components/Dashboard/Dashboard.jsx +++ b/frontend/src/components/Dashboard/Dashboard.jsx @@ -50,6 +50,8 @@ function Dashboard({ const isRestoringOverlayRef = useRef(false); const urlHadOverlayParamsRef = useRef(false); const overlayContentRef = useRef(overlayContent); + const overlayClassName = overlayContent?.props?.className || ''; + const isViewportOverlay = overlayClassName.includes('full-width-event-dashboard-focused'); // Wrapper so closing the overlay also clears persist overlay params from the URL const handleSetOverlayContent = useCallback((content) => { @@ -758,7 +760,7 @@ function Dashboard({ }
-
+
+
{overlayContent}
)} diff --git a/frontend/src/components/Dashboard/Dashboard.scss b/frontend/src/components/Dashboard/Dashboard.scss index bb23e9fe..74c48231 100644 --- a/frontend/src/components/Dashboard/Dashboard.scss +++ b/frontend/src/components/Dashboard/Dashboard.scss @@ -411,6 +411,10 @@ overflow: hidden; z-index: 1; + &.dash-right--overlay-fullscreen { + z-index: 1002; + } + &.maximized{ width:100%; max-width: 100%; @@ -441,6 +445,14 @@ overflow-x: hidden; -webkit-overflow-scrolling: touch; + &.dashboard-overlay--viewport { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + z-index: 1003; + } + .full-width-event-viewer { height: 100%; width: 100%; diff --git a/frontend/src/components/PlatformProtectedRoute/PlatformProtectedRoute.jsx b/frontend/src/components/PlatformProtectedRoute/PlatformProtectedRoute.jsx new file mode 100644 index 00000000..e5297b00 --- /dev/null +++ b/frontend/src/components/PlatformProtectedRoute/PlatformProtectedRoute.jsx @@ -0,0 +1,32 @@ +import React, { useEffect } from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; +import useAuth from '../../hooks/useAuth'; +import { useNotification } from '../../NotificationContext'; + +function isPlatformAdmin(user) { + const roles = user?.platformRoles || []; + return roles.includes('platform_admin') || roles.includes('root'); +} + +const PlatformProtectedRoute = () => { + const { isAuthenticated, isAuthenticating, user } = useAuth(); + const { addNotification } = useNotification(); + + useEffect(() => { + if (!isAuthenticating && isAuthenticated && !isPlatformAdmin(user)) { + addNotification({ + title: 'Unauthorized', + message: 'Platform admin access is required.', + type: 'error', + }); + } + }, [isAuthenticated, isAuthenticating, user, addNotification]); + + if (isAuthenticating) return null; + if (!isAuthenticated) return ; + if (!isPlatformAdmin(user)) return ; + + return ; +}; + +export default PlatformProtectedRoute; diff --git a/frontend/src/components/Popup/Popup.jsx b/frontend/src/components/Popup/Popup.jsx index ba07a534..fee234ae 100644 --- a/frontend/src/components/Popup/Popup.jsx +++ b/frontend/src/components/Popup/Popup.jsx @@ -1,64 +1,88 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; -import './Popup.scss'; // Assuming this contains your animation and styling +import './Popup.scss'; import useOutsideClick from '../../hooks/useClickOutside'; -import X from '../../assets/x.svg'; import { Icon } from '@iconify-icon/react/dist/iconify.mjs'; -const Popup = ({ children, isOpen, onClose, newStyling=false, defaultStyling=true, customClassName="", overlayClassName="", popout=false, waitForLoad=false, hideCloseButton=false, disableOutsideClick=false}) => { - const [render, setRender] = useState(isOpen); - const [show, setShow] = useState(false); - const isClosingRef = useRef(false); +const CLOSE_MS = 300; - const [topPosition, setTopPosition] = useState(null); - const [rightPosition, setRightPosition] = useState(null); +const Popup = ({ + children, + isOpen, + onClose, + newStyling = false, + defaultStyling = true, + customClassName = '', + overlayClassName = '', + popout = false, + hideCloseButton = false, + disableOutsideClick = false, +}) => { + const [mounted, setMounted] = useState(isOpen); + const [visible, setVisible] = useState(false); + const isClosingRef = useRef(false); + const closeTimerRef = useRef(null); + const [topPosition, setTopPosition] = useState(null); + const [rightPosition, setRightPosition] = useState(null); const ref = useRef(); - const handleClose = () => { - if (isClosingRef.current) return; // Prevent multiple calls - isClosingRef.current = true; - setShow(false); - setTimeout(() => { - onClose(); // Trigger the actual unmount after animation ends - setRender(false); - isClosingRef.current = false; - }, 300); // Match the exit animation duration - }; + const handleClose = useCallback(() => { + if (!isOpen || isClosingRef.current) return; + onClose?.(); + }, [isOpen, onClose]); - useOutsideClick(ref, ()=>{ - if (!disableOutsideClick) handleClose(); - }); + useOutsideClick( + ref, + () => { + if (!disableOutsideClick) handleClose(); + } + ); useEffect(() => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + if (isOpen) { - setRender(true); - isClosingRef.current = false; // Reset closing state when opening - } else if (!isOpen && render && !isClosingRef.current) { - // isOpen became false, trigger close animation - handleClose(); + isClosingRef.current = false; + setMounted(true); + const frame = requestAnimationFrame(() => setVisible(true)); + return () => cancelAnimationFrame(frame); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen]); - useEffect(()=>{ - setTimeout(() => { - setShow(true); - }, 100); - },[render]); + if (!mounted) return undefined; - useEffect(() => { - setTimeout(() => { - if(ref.current){ - const rect = ref.current.getBoundingClientRect(); - setTopPosition(rect.top); - setRightPosition(rect.right); - } - }, 300); + setVisible(false); + isClosingRef.current = true; + closeTimerRef.current = setTimeout(() => { + setMounted(false); + isClosingRef.current = false; + closeTimerRef.current = null; + }, CLOSE_MS); + + return () => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }; + }, [isOpen, mounted]); - }, [show, ref.current]); + useEffect(() => { + if (!visible) return undefined; + const timer = setTimeout(() => { + if (ref.current) { + const rect = ref.current.getBoundingClientRect(); + setTopPosition(rect.top); + setRightPosition(rect.right); + } + }, CLOSE_MS); + return () => clearTimeout(timer); + }, [visible]); - if (!isOpen && !render) { + if (!mounted) { return null; } @@ -66,29 +90,43 @@ const Popup = ({ children, isOpen, onClose, newStyling=false, defaultStyling=tru if (React.isValidElement(children)) { return React.cloneElement(children, { handleClose }); } - return children; // In case children are not valid React elements - }; + return children; + }; return ReactDOM.createPortal( -
- {popout && !hideCloseButton && } - -
- {newStyling - ? -
- {!popout && !hideCloseButton && } - {renderChildrenWithClose()} {/* Render children with handleClose prop */} -
- : - (<> - {!popout && !hideCloseButton && } - {renderChildrenWithClose()} {/* Render children with handleClose prop */} - +
+ {popout && !hideCloseButton ? ( + + ) : null} + +
+ {newStyling ? ( +
+ {!popout && !hideCloseButton ? ( + + ) : null} + {renderChildrenWithClose()} +
+ ) : ( + <> + {!popout && !hideCloseButton ? ( + + ) : null} + {renderChildrenWithClose()} + )}
, - document.body ); + document.body + ); }; export default Popup; diff --git a/frontend/src/components/TaskBoard/SharedTaskBoard.scss b/frontend/src/components/TaskBoard/SharedTaskBoard.scss index 98e02bd1..0a3b1e3f 100644 --- a/frontend/src/components/TaskBoard/SharedTaskBoard.scss +++ b/frontend/src/components/TaskBoard/SharedTaskBoard.scss @@ -6,7 +6,7 @@ --task-board-card-gap: 0.42rem; --task-board-transition: 220ms ease; --task-board-column-border: var(--lighterborder); - --task-board-column-bg: var(--lightbackground); + --task-board-column-bg: var(--background); --task-board-muted: var(--light-text); --task-board-token-bg: var(--background); --task-board-drop-border: rgba(77, 170, 87, 0.5); diff --git a/frontend/src/components/TaskBoard/cards/EventTasksTaskKanbanCard.scss b/frontend/src/components/TaskBoard/cards/EventTasksTaskKanbanCard.scss index b7949b50..0b9e7b5c 100644 --- a/frontend/src/components/TaskBoard/cards/EventTasksTaskKanbanCard.scss +++ b/frontend/src/components/TaskBoard/cards/EventTasksTaskKanbanCard.scss @@ -18,7 +18,7 @@ background: var(--background); padding: 0.52rem 0.58rem 0.48rem; cursor: grab; - box-shadow: 0 1px 2px rgba(15, 18, 22, 0.04); + box-shadow: 0px 1px 1px #35353640, 0px 0px 1px #1E1F214F; transition: border-color 220ms ease, opacity 220ms ease, background-color 220ms ease, box-shadow 220ms ease; &:hover { diff --git a/frontend/src/config/tenantRedirect.js b/frontend/src/config/tenantRedirect.js index b50a386c..2a9ef2a6 100644 --- a/frontend/src/config/tenantRedirect.js +++ b/frontend/src/config/tenantRedirect.js @@ -29,12 +29,18 @@ const TENANT_DISPLAY_NAMES = DEFAULT_TENANTS.reduce((acc, tenant) => { return acc; }, {}); +/** Pivot pilot cities use referral/onboarding — not the campus institution picker. */ +export function isPivotTenant(tenant) { + return tenant?.pivotPilot === true || tenant?.tenantType === 'pivot'; +} + function normalizeTenantRows(rows = []) { return rows .map((row) => { const tenantKey = String(row?.tenantKey || '').trim().toLowerCase(); if (!tenantKey) return null; const status = String(row?.status || 'active').trim(); + const tenantType = row?.tenantType === 'pivot' ? 'pivot' : 'campus'; return { tenantKey, name: String(row?.name || tenantKey).trim(), @@ -42,6 +48,8 @@ function normalizeTenantRows(rows = []) { location: String(row?.location || '').trim(), status: ['active', 'coming_soon', 'maintenance', 'hidden'].includes(status) ? status : 'active', statusMessage: String(row?.statusMessage || '').trim(), + tenantType, + pivotPilot: row?.pivotPilot === true || tenantType === 'pivot', }; }) .filter(Boolean); @@ -86,8 +94,12 @@ export function setTenantConfigCache(tenants = []) { export function getTenantDefinitions(options = {}) { const includeHidden = !!options.includeHidden; + const includePivot = !!options.includePivot; const cached = getCachedTenantConfig(); - const merged = mergeTenantRows(DEFAULT_TENANTS, cached?.tenants || []); + let merged = mergeTenantRows(DEFAULT_TENANTS, cached?.tenants || []); + if (!includePivot) { + merged = merged.filter((tenant) => !isPivotTenant(tenant)); + } if (includeHidden) return merged; return merged.filter((tenant) => VISIBLE_STATUSES.has(tenant.status)); } @@ -116,6 +128,9 @@ const WWW_ALLOWED_PATHS = [ '/error', '/select-school', '/tenant-status', + '/platform-admin', + '/admin/pivot', + '/login', ]; export function isPathAllowedOnWww(pathname) { @@ -206,7 +221,7 @@ export function getCurrentTenantKey() { /** Get display name for current tenant. */ export function getCurrentTenantDisplayName() { const key = getCurrentTenantKey(); - const tenantMap = getTenantDefinitions({ includeHidden: true }).reduce((acc, tenant) => { + const tenantMap = getTenantDefinitions({ includeHidden: true, includePivot: true }).reduce((acc, tenant) => { acc[tenant.tenantKey] = tenant.name; return acc; }, {}); diff --git a/frontend/src/hooks/useDashboardOverlay.js b/frontend/src/hooks/useDashboardOverlay.js index a2641cdf..9ba47de2 100644 --- a/frontend/src/hooks/useDashboardOverlay.js +++ b/frontend/src/hooks/useDashboardOverlay.js @@ -3,6 +3,16 @@ import { useDashboardOptional } from '../contexts/DashboardContext'; import { buildOverlaySearchParams } from '../utils/overlayRegistry'; const EVENT_DASHBOARD_OVERLAY_KEY = 'event-dashboard'; +/** + * Debug/testing switch for which dashboard opens when callers use the default API. + * Flip to 'classic' to open the legacy dashboard by default. + */ +const DEFAULT_EVENT_DASHBOARD_VARIANT = 'focused'; +const EVENT_DASHBOARD_VARIANT_OVERLAY_KEYS = { + default: EVENT_DASHBOARD_OVERLAY_KEY, + focused: 'event-dashboard-focused', + classic: 'event-dashboard-classic' +}; /** * Custom hook for easy overlay management in Dashboard components. @@ -80,7 +90,9 @@ export const useDashboardOverlay = () => { const showEventDashboard = (event, orgId, options = {}) => { if (!hasOverlay || !showOverlay) return; const { - className = 'full-width-event-dashboard', + className = DEFAULT_EVENT_DASHBOARD_VARIANT === 'classic' + ? 'full-width-event-dashboard' + : 'full-width-event-dashboard-focused', persistInUrl = false } = options; @@ -92,9 +104,13 @@ export const useDashboardOverlay = () => { setSearchParams(next, { replace: false }); } - import('../pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard').then(({ default: EventDashboard }) => { + const loadDashboard = DEFAULT_EVENT_DASHBOARD_VARIANT === 'classic' + ? import('../pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard') + : import('../pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused'); + + loadDashboard.then(({ default: EventDashboardComponent }) => { showOverlay( - { }); }; + /** + * Show an EventDashboard overlay variant. + * @param {Object} event + * @param {string} orgId + * @param {Object} options + * @param {'default'|'focused'|'classic'} options.variant + * @param {boolean} options.persistInUrl + * @param {string} options.className + */ + const showEventDashboardVariant = (event, orgId, options = {}) => { + if (!hasOverlay || !showOverlay) return; + const { variant = 'default', persistInUrl = false } = options; + if (variant === 'default') { + showEventDashboard(event, orgId, options); + return; + } + + if (variant === 'focused') { + const className = options.className || 'full-width-event-dashboard-focused'; + if (persistInUrl && event?._id && orgId) { + const next = buildOverlaySearchParams( + searchParams, + EVENT_DASHBOARD_VARIANT_OVERLAY_KEYS.focused, + { eventId: event._id, orgId } + ); + setSearchParams(next, { replace: false }); + } + import('../pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused').then(({ default: EventDashboardFocused }) => { + showOverlay( + + ); + }); + return; + } + + const overlayKey = EVENT_DASHBOARD_VARIANT_OVERLAY_KEYS[variant] || EVENT_DASHBOARD_OVERLAY_KEY; + const className = options.className || ( + variant === 'classic' ? 'full-width-event-dashboard' : `full-width-event-dashboard-${variant}` + ); + + if (persistInUrl && event?._id && orgId) { + const next = buildOverlaySearchParams(searchParams, overlayKey, { + eventId: event._id, + orgId, + }); + setSearchParams(next, { replace: false }); + } + + if (variant === 'classic') { + import('../pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard').then(({ default: EventDashboard }) => { + showOverlay( + + ); + }); + return; + } + + showEventDashboard(event, orgId, options); + }; + + /** + * Convenience wrapper for the focused dashboard variant. + * @param {Object} event + * @param {string} orgId + * @param {Object} options + */ + const showEventDashboardFocused = (event, orgId, options = {}) => { + showEventDashboardVariant(event, orgId, { ...options, variant: 'focused' }); + }; + /** * Show an EventPostMortem overlay (for past events) * @param {Object} event - The event object @@ -150,6 +246,8 @@ export const useDashboardOverlay = () => { showEventViewer, showEventWorkspace, showEventDashboard, + showEventDashboardVariant, + showEventDashboardFocused, showEventPostMortem, showAdminEventOperator, }; diff --git a/frontend/src/hooks/useFetch.js b/frontend/src/hooks/useFetch.js index 2e18e65f..fcc9ed91 100644 --- a/frontend/src/hooks/useFetch.js +++ b/frontend/src/hooks/useFetch.js @@ -68,25 +68,41 @@ export const authenticatedRequest = async (url, options = {}) => { }; /** - * @param options.params For GET requests, pass a stable object (e.g. from useMemo([])). Inline `{ ... }` changes - * identity every render and will refetch in a tight loop because params is in the memo dependency array. + * @param options.params Query params. Inline `{ ... }` is safe — compared by serialized value, not reference. + * @param options.cache Cache config. Inline `{ enabled: false }` is safe — compared by enabled/ttlMs, not reference. */ export const useFetch = (url, options = { method: "GET", data: null }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // Memoize the options to prevent unnecessary re-renders - const memoizedOptions = useMemo(() => ({ - method: options.method || "GET", - data: options.data || null, - headers: options.headers || {}, - params: options.params || {}, - cache: options.cache || null, - }), [options.method, options.data, options.headers, options.params, options.cache]); + const paramsKey = useMemo(() => stableSerialize(options.params || {}), [options.params]); + const dataKey = useMemo(() => stableSerialize(options.data ?? null), [options.data]); + const headersKey = useMemo(() => stableSerialize(options.headers || {}), [options.headers]); + const cacheEnabled = Boolean(options.cache?.enabled); + const cacheTtlMs = + typeof options.cache?.ttlMs === "number" && options.cache.ttlMs > 0 + ? options.cache.ttlMs + : null; + + const memoizedOptions = useMemo( + () => ({ + method: options.method || "GET", + data: options.data || null, + headers: options.headers || {}, + params: options.params || {}, + cache: options.cache + ? { + enabled: cacheEnabled, + ...(cacheTtlMs != null ? { ttlMs: cacheTtlMs } : {}), + } + : null, + }), + [options.method, dataKey, headersKey, paramsKey, cacheEnabled, cacheTtlMs], + ); - const fetchData = useCallback(async (options = {}) => { - const { silent = false, bypassCache = false } = options; + const fetchData = useCallback(async (fetchOpts = {}) => { + const { silent = false, bypassCache = false, signal } = fetchOpts; // Don't fetch if URL is null or undefined if (!url) { setLoading(false); @@ -123,12 +139,16 @@ export const useFetch = (url, options = { method: "GET", data: null }) => { headers: memoizedOptions.headers, withCredentials: true, params: memoizedOptions.params, + signal, }); setData(response.data); if (useCache && cacheKey) { fetchResponseCache.set(cacheKey, { data: response.data, timestamp: Date.now() }); } } catch (err) { + if (axios.isCancel(err) || err.code === "ERR_CANCELED") { + return; + } if (err.response?.status === 401 && (err.response?.data?.code === 'TOKEN_EXPIRED' || err.response?.data?.code === 'NO_TOKEN')) { try { @@ -141,6 +161,7 @@ export const useFetch = (url, options = { method: "GET", data: null }) => { headers: memoizedOptions.headers, withCredentials: true, params: memoizedOptions.params, + signal, }); setData(retryResponse.data); if (useCache && cacheKey) { @@ -166,12 +187,14 @@ export const useFetch = (url, options = { method: "GET", data: null }) => { setError(err.message); } } finally { - if (!silent) setLoading(false); + if (!silent && !signal?.aborted) setLoading(false); } }, [url, memoizedOptions]); useEffect(() => { - fetchData(); + const controller = new AbortController(); + fetchData({ signal: controller.signal }); + return () => controller.abort(); }, [fetchData]); const refetch = useCallback((opts = {}) => fetchData({ bypassCache: true, ...opts }), [fetchData]); diff --git a/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx b/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx index 2dcfac58..09ec838f 100644 --- a/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx +++ b/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx @@ -162,7 +162,7 @@ function OperatorHubMode() { const [confirmVariant, setConfirmVariant] = useState(null); const [typedPhrase, setTypedPhrase] = useState(''); const [phraseError, setPhraseError] = useState(''); - const { data: configData, refetch, loading } = useFetch('/org-management/config', { + const { data: configData, refetch, loading} = useFetch('/org-management/config', { cache: { enabled: true, ttlMs: ADMIN_PAGE_CACHE_TTL_MS }, }); const config = configData?.data; diff --git a/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx b/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx index a37a4530..8a7671c1 100644 --- a/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx +++ b/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx @@ -1,7 +1,6 @@ -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback } from 'react'; import { Icon } from '@iconify-icon/react'; import { useFetch, authenticatedRequest } from '../../../hooks/useFetch'; -import { setTenantConfigCache } from '../../../config/tenantRedirect'; import { useNotification } from '../../../NotificationContext'; import { useGradient } from '../../../hooks/useGradient'; import apiRequest from '../../../utils/postRequest'; @@ -16,8 +15,6 @@ function PlatformAdminsPage() { const [addEmail, setAddEmail] = useState(''); const [adding, setAdding] = useState(false); const [mutationError, setMutationError] = useState(null); - const [tenantDrafts, setTenantDrafts] = useState({}); - const [savingTenants, setSavingTenants] = useState(false); const [savingAutoClaim, setSavingAutoClaim] = useState(false); const [runningOwnerRoleMigration, setRunningOwnerRoleMigration] = useState(false); @@ -25,15 +22,6 @@ function PlatformAdminsPage() { cache: { enabled: true, ttlMs: ADMIN_PAGE_CACHE_TTL_MS }, }); const list = listResponse?.success ? (listResponse.data || []) : []; - const { - data: tenantConfigResponse, - loading: tenantConfigLoading, - error: tenantConfigFetchError, - refetch: refetchTenantConfig, - } = useFetch('/admin/tenant-config', { - cache: { enabled: true, ttlMs: ADMIN_PAGE_CACHE_TTL_MS }, - }); - const tenantRows = tenantConfigResponse?.success ? (tenantConfigResponse.data?.tenants || []) : []; const { data: orgConfigResponse, refetch: refetchOrgConfig } = useFetch('/org-management/config', { cache: { enabled: true, ttlMs: ADMIN_PAGE_CACHE_TTL_MS }, @@ -58,17 +46,6 @@ function PlatformAdminsPage() { } }, [addNotification, refetchOrgConfig]); - useEffect(() => { - const nextDrafts = {}; - tenantRows.forEach((tenant) => { - nextDrafts[tenant.tenantKey] = { - status: tenant.status || 'active', - statusMessage: tenant.statusMessage || '', - }; - }); - setTenantDrafts(nextDrafts); - }, [tenantConfigResponse]); - const handleAdd = useCallback(async (e) => { e.preventDefault(); const email = addEmail.trim(); @@ -105,43 +82,6 @@ function PlatformAdminsPage() { else setMutationError(data?.message || 'Failed to remove'); }, [refetch]); - const handleTenantDraftChange = useCallback((tenantKey, patch) => { - setTenantDrafts((prev) => ({ - ...prev, - [tenantKey]: { - ...(prev[tenantKey] || {}), - ...patch, - }, - })); - }, []); - - const handleTenantConfigSave = useCallback(async () => { - if (tenantRows.length === 0) return; - setSavingTenants(true); - setMutationError(null); - const tenants = tenantRows.map((tenant) => ({ - tenantKey: tenant.tenantKey, - status: tenantDrafts[tenant.tenantKey]?.status || tenant.status || 'active', - statusMessage: tenantDrafts[tenant.tenantKey]?.statusMessage || '', - })); - const { data, error } = await authenticatedRequest('/admin/tenant-config', { - method: 'PUT', - data: { tenants }, - headers: { 'Content-Type': 'application/json' }, - }); - setSavingTenants(false); - if (error) { - setMutationError(data?.message || error); - return; - } - if (data?.success) { - setTenantConfigCache(data?.data?.tenants || []); - refetchTenantConfig(); - } else { - setMutationError(data?.message || 'Failed to save tenant settings'); - } - }, [refetchTenantConfig, tenantDrafts, tenantRows]); - const handleOwnerRoleMigration = useCallback(async () => { if (runningOwnerRoleMigration) return; setRunningOwnerRoleMigration(true); @@ -172,7 +112,7 @@ function PlatformAdminsPage() { } }, [addNotification, runningOwnerRoleMigration]); - const error = fetchError || tenantConfigFetchError || mutationError; + const error = fetchError || mutationError; return (
@@ -231,50 +171,6 @@ function PlatformAdminsPage() { /> -
-
-

Tenant visibility

-

- Control how tenants appear on the school picker: - active, coming soon, under maintenance, or hidden. -

-
- {tenantConfigLoading ? ( -

Loading tenant settings…

- ) : ( - <> -
- {tenantRows.map((tenant) => ( -
-
-

{tenant.name}

-

{tenant.subdomain}.meridian.study

-
- - handleTenantDraftChange(tenant.tenantKey, { statusMessage: e.target.value })} - maxLength={240} - /> -
- ))} -
- - - )} -
{loading ? (

Loading…

) : ( diff --git a/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.scss b/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.scss index a209761b..9225cecc 100644 --- a/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.scss +++ b/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.scss @@ -58,68 +58,6 @@ } } } - .platform-admins-tenants { - margin-bottom: 1.5rem; - padding: 1rem; - background: #f8f9fa; - border-radius: 8px; - border: 1px solid #e9ecef; - .platform-admins-tenants-header { - margin-bottom: 0.8rem; - h2 { - margin: 0; - font-size: 1.05rem; - } - p { - margin: 0.4rem 0 0 0; - color: #666; - font-size: 0.9rem; - } - } - .platform-admins-tenants-list { - display: flex; - flex-direction: column; - gap: 0.7rem; - margin-bottom: 0.9rem; - } - .platform-admins-tenant-row { - display: grid; - grid-template-columns: minmax(180px, 1fr) 180px minmax(220px, 1fr); - gap: 0.6rem; - align-items: center; - .platform-admins-tenant-meta { - .name { - margin: 0; - font-weight: 600; - } - .domain { - margin: 0.2rem 0 0 0; - color: #666; - font-size: 0.85rem; - } - } - select, - input { - width: 100%; - padding: 0.5rem 0.65rem; - border: 1px solid #ccc; - border-radius: 4px; - background: #fff; - } - } - button { - padding: 0.5rem 1rem; - background: var(--primary, #2563eb); - color: #fff; - border: none; - border-radius: 4px; - cursor: pointer; - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } - } .platform-admins-list { list-style: none; padding: 0; diff --git a/frontend/src/pages/ClubDash/EventsManagement/EventsManagement.jsx b/frontend/src/pages/ClubDash/EventsManagement/EventsManagement.jsx index 2bcb58da..dd7ed071 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/EventsManagement.jsx +++ b/frontend/src/pages/ClubDash/EventsManagement/EventsManagement.jsx @@ -37,7 +37,7 @@ function truncatePreviewDescription(text, max = 450) { function EventsManagement({ orgId, expandedClass, orgData: orgDataProp }) { const { addNotification } = useNotification(); - const { showEventDashboard, hideOverlay } = useDashboardOverlay(); + const { showEventDashboardFocused } = useDashboardOverlay(); const [refreshTrigger, setRefreshTrigger] = useState(0); const [collaborationInvites, setCollaborationInvites] = useState([]); const [loadingInvites, setLoadingInvites] = useState(false); @@ -99,8 +99,8 @@ function EventsManagement({ orgId, expandedClass, orgData: orgDataProp }) { const handleViewEvent = (event) => { const orgIdForDashboard = orgData?.org?.overview?._id; if (orgIdForDashboard) { - showEventDashboard(event, orgIdForDashboard, { - className: 'full-width-event-dashboard', + showEventDashboardFocused(event, orgIdForDashboard, { + className: 'full-width-event-dashboard-focused', persistInUrl: true, }); } diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/CommunicationsTab/CommunicationsTab.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/CommunicationsTab/CommunicationsTab.scss index f8093c91..5ee3f84a 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/CommunicationsTab/CommunicationsTab.scss +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/CommunicationsTab/CommunicationsTab.scss @@ -2,8 +2,6 @@ display: flex; flex-direction: column; gap: 1.25rem; - padding-bottom: 2rem; - padding-top: 2rem; } // Hero: content only (HeaderContainer provides the box) diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaBuilder.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaBuilder.scss index c60b64c9..84c8c0fb 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaBuilder.scss +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaBuilder.scss @@ -77,7 +77,7 @@ } .agenda-header { - background: var(--background); + // background: var(--background); display: flex; justify-content: space-between; align-items: center; @@ -347,6 +347,7 @@ .agenda-calendar-wrapper { margin-top: 1rem; + overflow-x: auto; .calendar-zoom-controls { display: flex; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/AgendaDailyCalendar.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/AgendaDailyCalendar.jsx index ac190347..49071148 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/AgendaDailyCalendar.jsx +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/AgendaDailyCalendar.jsx @@ -1,11 +1,18 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useMemo } from 'react'; import AgendaItemCalendarEvent from '../AgendaItemCalendarEvent/AgendaItemCalendarEvent'; import '../../../../../../OIEDash/EventsCalendar/Week/WeeklyCalendar/WeeklyCalendar.scss'; +import { + computeDayRange, + computeColumns, + getSegmentLayout, + groupIntoClusters, + MINUTES_PER_DAY, + splitItemIntoDaySegments, + toCalendarEvent +} from './agendaCalendarUtils'; import './AgendaDailyCalendar.scss'; const DEFAULT_MINUTE_HEIGHT = 4; -const MINUTES_PER_DAY = 24 * 60; -const RANGE_PADDING_MINUTES = 30; function AgendaDailyCalendar({ agendaItems = [], @@ -15,265 +22,183 @@ function AgendaDailyCalendar({ minuteHeight = DEFAULT_MINUTE_HEIGHT, onEditItem }) { - const [width, setWidth] = useState(0); - const ref = React.useRef(null); - - const { days, totalHeight, rangeStartMinutes, rangeEndMinutes, minutesInRange } = useMemo(() => { - const eventStart = event?.start_time ? new Date(event.start_time) : new Date(); - const eventEnd = event?.end_time ? new Date(event.end_time) : new Date(eventStart); - eventEnd.setDate(eventEnd.getDate() + 1); - - let first = dayStart ? new Date(dayStart) : eventStart; - let last = dayEnd ? new Date(dayEnd) : eventEnd; - - if (agendaItems.length > 0) { - const firstStart = agendaItems.reduce((earliest, item) => { - const itemStart = item.startTime ? new Date(item.startTime) : null; - if (!itemStart) return earliest; - return !earliest || itemStart < earliest ? itemStart : earliest; - }, null); - const lastEnd = agendaItems.reduce((latest, item) => { - const itemEnd = item.endTime ? new Date(item.endTime) : null; - if (!itemEnd) return latest; - return !latest || itemEnd > latest ? itemEnd : latest; - }, null); - if (firstStart) first = firstStart < first ? firstStart : first; - if (lastEnd) last = lastEnd > last ? lastEnd : last; - } - - const firstDay = new Date(first); - firstDay.setHours(0, 0, 0, 0); - const lastDay = new Date(last); - lastDay.setHours(0, 0, 0, 0); - - const days = []; - for (let d = new Date(firstDay); d <= lastDay; d.setDate(d.getDate() + 1)) { - days.push(new Date(d)); - } - - // Only show event hours, not full 24h - use min start and max end across all items - const allStarts = [first]; - const allEnds = [last]; - agendaItems.forEach((item) => { - if (item.startTime) allStarts.push(new Date(item.startTime)); - if (item.endTime) allEnds.push(new Date(item.endTime)); - }); - let rangeStartMinutes = Math.min(...allStarts.map((d) => d.getHours() * 60 + d.getMinutes())); - let rangeEndMinutes = Math.max(...allEnds.map((d) => d.getHours() * 60 + d.getMinutes())); - - if (rangeEndMinutes <= rangeStartMinutes) { - rangeEndMinutes = rangeStartMinutes + 60; // fallback: 1 hour - } - - // Add padding and round to 30-min boundaries - rangeStartMinutes = Math.floor((rangeStartMinutes - RANGE_PADDING_MINUTES) / 30) * 30; - rangeEndMinutes = Math.ceil((rangeEndMinutes + RANGE_PADDING_MINUTES) / 30) * 30; - rangeStartMinutes = Math.max(0, rangeStartMinutes); - rangeEndMinutes = Math.min(MINUTES_PER_DAY, rangeEndMinutes); - - const minutesInRange = rangeEndMinutes - rangeStartMinutes; - const totalHeight = days.length * minutesInRange * minuteHeight; + const { days, minutesInRange, perDayBounds } = useMemo( + () => computeDayRange(agendaItems, event, dayStart, dayEnd), + [agendaItems, event, dayStart, dayEnd] + ); - return { - days, - totalHeight: Math.max(totalHeight, minutesInRange * minuteHeight), - rangeStartMinutes, - rangeEndMinutes, - minutesInRange - }; - }, [agendaItems, event, dayStart, dayEnd, minuteHeight]); + const columnHeight = minutesInRange * minuteHeight; + const isMultiDay = days.length > 1; - const calendarEvents = useMemo(() => { - return agendaItems + const segmentsByDay = useMemo(() => { + const calendarEvents = agendaItems .filter((item) => item.startTime && item.endTime) - .map((item) => ({ - ...item, - start_time: typeof item.startTime === 'string' ? item.startTime : item.startTime?.toISOString?.() ?? new Date(item.startTime).toISOString(), - end_time: typeof item.endTime === 'string' ? item.endTime : item.endTime?.toISOString?.() ?? new Date(item.endTime).toISOString() - })); - }, [agendaItems]); + .map(toCalendarEvent); + + const byDay = days.map(() => []); + calendarEvents.forEach((item) => { + const segments = splitItemIntoDaySegments(item, days); + segments.forEach((seg) => { + byDay[seg.dayIndex].push(seg); + }); + }); + return byDay; + }, [agendaItems, days]); - useEffect(() => { - if (ref.current) { - setWidth(ref.current.clientWidth); + const renderTimeGrid = () => { + const lines = []; + for (let m = 0; m < MINUTES_PER_DAY; m += 30) { + const isHour = m % 60 === 0; + lines.push( +
+ ); } - }, [ref, agendaItems]); - - const getMinutesFromStart = (date) => { - const base = new Date(days[0]); - base.setHours(0, 0, 0, 0); - const d = new Date(date); - const dayOffset = Math.floor((d - base) / (1000 * 60 * 60 * 24)); - const minutesInDay = d.getHours() * 60 + d.getMinutes(); - const minutesFromRangeStart = minutesInDay - rangeStartMinutes; - return dayOffset * minutesInRange + minutesFromRangeStart; + return lines; }; - const groupIntoClusters = (events) => { - if (events.length === 0) return []; - const sortedEvents = [...events].sort((a, b) => new Date(a.start_time) - new Date(b.start_time)); - const clusters = []; - let currentCluster = [sortedEvents[0]]; - let maxEnd = new Date(sortedEvents[0].end_time); - - for (let i = 1; i < sortedEvents.length; i++) { - const ev = sortedEvents[i]; - const evStart = new Date(ev.start_time); - if (evStart < maxEnd) { - currentCluster.push(ev); - const evEnd = new Date(ev.end_time); - maxEnd = evEnd > maxEnd ? evEnd : maxEnd; - } else { - clusters.push(currentCluster); - currentCluster = [ev]; - maxEnd = new Date(ev.end_time); - } + const renderTimeLabels = () => { + const labels = []; + for (let hour = 0; hour < 24; hour++) { + const hourMinutes = hour * 60; + labels.push( +
+ {new Date(0, 0, 0, hour).toLocaleTimeString([], { hour: '2-digit' })} +
+ ); } - clusters.push(currentCluster); - return clusters; + return labels; }; - const computeColumns = (cluster) => { - const sortedCluster = [...cluster].sort((a, b) => new Date(a.start_time) - new Date(b.start_time)); - const columns = []; - const eventsWithColumns = []; - - for (const ev of sortedCluster) { - const evStart = new Date(ev.start_time); - const evEnd = new Date(ev.end_time); - let columnIndex = -1; - - for (let i = 0; i < columns.length; i++) { - if (evStart >= columns[i]) { - columnIndex = i; - break; - } - } + const renderInactiveRegions = (dayIndex) => { + const { activeStartMinutes, activeEndMinutes } = + perDayBounds[dayIndex] ?? { activeStartMinutes: 0, activeEndMinutes: MINUTES_PER_DAY }; - if (columnIndex === -1) { - columnIndex = columns.length; - columns.push(evEnd); - } else { - columns[columnIndex] = evEnd; - } - - eventsWithColumns.push({ ...ev, column: columnIndex }); + const regions = []; + if (activeStartMinutes > 0) { + regions.push( +
+ ); } - - const columnsInCluster = columns.length; - eventsWithColumns.forEach((ev) => { - ev.columnsInCluster = columnsInCluster; - }); - - return eventsWithColumns; + if (activeEndMinutes < MINUTES_PER_DAY) { + regions.push( +
+ ); + } + return regions; }; - const renderEvents = () => { - const clusters = groupIntoClusters(calendarEvents); + const renderDayEvents = (dayIndex) => { + const daySegments = segmentsByDay[dayIndex] || []; + const clusters = groupIntoClusters(daySegments); const processedEvents = []; for (const cluster of clusters) { - const eventsWithColumns = computeColumns(cluster); - processedEvents.push(...eventsWithColumns); + processedEvents.push(...computeColumns(cluster)); } - return processedEvents.map((ev, index) => { - const start = new Date(ev.start_time); - const end = new Date(ev.end_time); - const topMinutes = getMinutesFromStart(start); - const durationMinutes = (end - start) / (1000 * 60); - const eventHeight = Math.max(durationMinutes * minuteHeight, 24); + return processedEvents.map((seg) => { + const layout = getSegmentLayout(seg.segmentStart, seg.segmentEnd, minuteHeight); + if (!layout) return null; + + const spanClass = [ + seg.continuesFromPrev ? 'continues-from-prev' : '', + seg.continuesToNext ? 'continues-to-next' : '', + isMultiDay && (seg.continuesFromPrev || seg.continuesToNext) ? 'multi-day-span' : '' + ] + .filter(Boolean) + .join(' '); return (
); }); }; - const renderTimeGrid = () => { - const lines = []; - days.forEach((day, dayIndex) => { - for (let m = rangeStartMinutes; m < rangeEndMinutes; m += 30) { - const isHour = m % 60 === 0; - const top = (dayIndex * minutesInRange + (m - rangeStartMinutes)) * minuteHeight; - lines.push( -
- ); - } - }); - return lines; - }; - - const renderDayLines = () => { - return days.map((day, index) => ( -
- - {day.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })} - -
- )); - }; - - const renderTimeLabels = () => { - const labels = []; - const startHour = Math.floor(rangeStartMinutes / 60); - const endHour = Math.ceil(rangeEndMinutes / 60); - days.forEach((day, dayIndex) => { - for (let hour = startHour; hour < endHour; hour++) { - const hourMinutes = hour * 60; - if (hourMinutes >= rangeEndMinutes) break; - const top = (dayIndex * minutesInRange + (hourMinutes - rangeStartMinutes)) * minuteHeight; - labels.push( -
- {new Date(0, 0, 0, hour).toLocaleTimeString([], { hour: '2-digit' })} -
- ); - } - }); - return labels; - }; - return (
-
-
- {renderTimeLabels()} +
+
+
+ {days.map((day, index) => ( +
+ + {day.toLocaleDateString('en-US', { weekday: 'short' })} + + + {day.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} + +
+ ))}
+
-
- {renderDayLines()} - {renderTimeGrid()} - {renderEvents()} +
+
{renderTimeLabels()}
+ +
+ {days.map((day, index) => ( +
+ {renderInactiveRegions(index)} + {renderTimeGrid()} + {renderDayEvents(index)} +
+ ))}
diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/AgendaDailyCalendar.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/AgendaDailyCalendar.scss index 0d016e09..68dfa8d2 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/AgendaDailyCalendar.scss +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/AgendaDailyCalendar.scss @@ -1,51 +1,170 @@ .agenda-daily-calendar { height: auto; border-top: 1px solid var(--lighterborder); + width: 100%; - &.multi-day { - .calendar-header { - display: none; + &.multi-day-columns { + overflow-x: auto; + + .days-header, + .days-container { + min-width: max(100%, calc(var(--day-count, 2) * 140px)); + } + + .day-header, + .day-column { + min-width: 140px; + } + } + + .calendar-header { + display: flex; + position: sticky; + top: 0; + z-index: 20; + background-color: var(--background); + } + + .time-header { + width: 60px; + flex-shrink: 0; + border: 1px solid var(--lighterborder); + border-radius: 10px 0 0 0; + } + + .days-header { + display: flex; + flex: 1; + } + + .day-header { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: 8px 6px; + border: 1px solid var(--lighterborder); + border-left: none; + background: var(--background); + + .day-name { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: var(--light-text); + } + + .day-date { + font-size: 0.85rem; + font-weight: 600; + color: var(--text); + } + + &:last-child { + border-radius: 0 10px 0 0; } } .calendar-body { + display: flex; position: relative; - padding-top: 12px; + padding-top: 0; overflow: visible; } - .day-column { + .time-column { + width: 60px; + flex-shrink: 0; position: relative; + border-left: 1px solid var(--lighterborder); + border-right: 1px solid var(--lighterborder); } - .day-line { - position: absolute; - left: 0; - right: 0; - height: 3px; - background: linear-gradient(90deg, rgba(77, 170, 87, 0.4), rgba(77, 170, 87, 0.15)); - z-index: 10; - pointer-events: none; - - .day-line-label { + .days-container { + display: flex; + flex: 1; + position: relative; + min-width: 0; + } + + .day-column { + flex: 1; + min-width: 0; + position: relative; + border-right: 1px solid var(--lighterborder); + background-color: var(--bounding-color, #fbfbfb); + + &:last-child { + border-right: none; + } + + .inactive-region { position: absolute; - left: 8px; - top: -10px; - font-size: 0.8rem; - font-weight: 700; - color: var(--text); - background: var(--background); - padding: 2px 8px; - border: 1px solid rgba(77, 170, 87, 0.5); - border-radius: 4px; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); - z-index: 11; + left: 0; + right: 0; + z-index: 1; + pointer-events: none; + background: repeating-linear-gradient( + -45deg, + transparent, + transparent 4px, + rgba(0, 0, 0, 0.03) 4px, + rgba(0, 0, 0, 0.03) 8px + ); + background-color: rgba(0, 0, 0, 0.04); + + &.before { + border-bottom: 1px dashed var(--lighterborder); + } + + &.after { + border-top: 1px dashed var(--lighterborder); + } } } + // Legacy vertical multi-day styles (removed stacked day lines) + .day-line { + display: none; + } + + .time-grid-line { + z-index: 2; + } + .event.agenda-event { position: absolute; - margin:0; - min-height: 32px; + margin: 0; + min-height: 24px; + z-index: 4; + + &:hover { + z-index: 5; + } + + &.multi-day-span { + border-left-width: 3px; + } + + &.continues-from-prev .agenda-item-calendar-event { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-top-style: dashed; + } + + &.continues-to-next .agenda-item-calendar-event { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom-style: dashed; + } + } +} + +.agenda-calendar-wrapper { + .agenda-daily-calendar.multi-day-columns { + --day-count: 2; } } diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/agendaCalendarUtils.js b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/agendaCalendarUtils.js new file mode 100644 index 00000000..17c53901 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/agendaCalendarUtils.js @@ -0,0 +1,204 @@ +const MS_PER_DAY = 24 * 60 * 60 * 1000; +export const MINUTES_PER_DAY = 24 * 60; + +export function startOfDay(date) { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +} + +export function endOfDay(date) { + const d = new Date(date); + d.setHours(23, 59, 59, 999); + return d; +} + +export function getMinutesInDay(date) { + return date.getHours() * 60 + date.getMinutes(); +} + +export function toCalendarEvent(item) { + return { + ...item, + start_time: + typeof item.startTime === 'string' + ? item.startTime + : item.startTime?.toISOString?.() ?? new Date(item.startTime).toISOString(), + end_time: + typeof item.endTime === 'string' + ? item.endTime + : item.endTime?.toISOString?.() ?? new Date(item.endTime).toISOString() + }; +} + +/** Split an agenda item into one segment per calendar day it intersects. */ +export function splitItemIntoDaySegments(item, days) { + const start = new Date(item.start_time); + const end = new Date(item.end_time); + if (isNaN(start.getTime()) || isNaN(end.getTime()) || end <= start) { + return []; + } + + const segments = []; + days.forEach((day, dayIndex) => { + const dayStart = startOfDay(day); + const dayEnd = endOfDay(day); + const segStart = start > dayStart ? start : dayStart; + const segEnd = end < dayEnd ? end : dayEnd; + + if (segStart < segEnd) { + segments.push({ + ...item, + dayIndex, + segmentStart: segStart, + segmentEnd: segEnd, + continuesFromPrev: segStart > start, + continuesToNext: segEnd < end, + segmentKey: `${item.id}-day-${dayIndex}` + }); + } + }); + + return segments; +} + +/** Active (non-grey) minutes on a day column: event start → event end, clipped to that day. */ +export function getDayActiveBounds(day, eventStart, eventEnd) { + const dayStart = startOfDay(day); + const dayEnd = endOfDay(day); + + if (eventEnd <= dayStart || eventStart >= dayEnd) { + return { activeStartMinutes: 0, activeEndMinutes: 0 }; + } + + const activeStartMinutes = eventStart > dayStart ? getMinutesInDay(eventStart) : 0; + const activeEndMinutes = eventEnd < dayEnd ? getMinutesInDay(eventEnd) : MINUTES_PER_DAY; + + return { activeStartMinutes, activeEndMinutes }; +} + +export function computeDayRange(agendaItems, event, dayStart, dayEnd) { + const eventStart = event?.start_time ? new Date(event.start_time) : new Date(); + const eventEnd = event?.end_time ? new Date(event.end_time) : new Date(eventStart); + if (isNaN(eventEnd.getTime()) || eventEnd <= eventStart) { + eventEnd.setDate(eventEnd.getDate() + 1); + } + + let first = dayStart ? new Date(dayStart) : eventStart; + let last = dayEnd ? new Date(dayEnd) : eventEnd; + + if (agendaItems.length > 0) { + const firstStart = agendaItems.reduce((earliest, item) => { + const itemStart = item.startTime ? new Date(item.startTime) : null; + if (!itemStart) return earliest; + return !earliest || itemStart < earliest ? itemStart : earliest; + }, null); + const lastEnd = agendaItems.reduce((latest, item) => { + const itemEnd = item.endTime ? new Date(item.endTime) : null; + if (!itemEnd) return latest; + return !latest || itemEnd > latest ? itemEnd : latest; + }, null); + if (firstStart) first = firstStart < first ? firstStart : first; + if (lastEnd) last = lastEnd > last ? lastEnd : last; + } + + const firstDay = startOfDay(first); + const lastDay = startOfDay(last); + + const days = []; + for (let d = new Date(firstDay); d <= lastDay; d.setDate(d.getDate() + 1)) { + days.push(new Date(d)); + } + + const perDayBounds = days.map((day) => getDayActiveBounds(day, eventStart, eventEnd)); + + return { + days, + perDayBounds, + minutesInRange: MINUTES_PER_DAY + }; +} + +export function getSegmentLayout(segmentStart, segmentEnd, minuteHeight) { + let startMinutes = getMinutesInDay(segmentStart); + let endMinutes = getMinutesInDay(segmentEnd); + + if (endMinutes === 0 && segmentEnd > segmentStart) { + const daySpan = segmentEnd - segmentStart; + if (daySpan >= MS_PER_DAY - 60000) { + endMinutes = MINUTES_PER_DAY; + } + } + + if (endMinutes <= startMinutes) { + return null; + } + + const top = startMinutes * minuteHeight; + const height = Math.max((endMinutes - startMinutes) * minuteHeight, 24); + + return { top, height, displayStart: segmentStart, displayEnd: segmentEnd }; +} + +export function groupIntoClusters(events) { + if (events.length === 0) return []; + const sortedEvents = [...events].sort( + (a, b) => new Date(a.segmentStart) - new Date(b.segmentStart) + ); + const clusters = []; + let currentCluster = [sortedEvents[0]]; + let maxEnd = new Date(sortedEvents[0].segmentEnd); + + for (let i = 1; i < sortedEvents.length; i++) { + const ev = sortedEvents[i]; + const evStart = new Date(ev.segmentStart); + if (evStart < maxEnd) { + currentCluster.push(ev); + const evEnd = new Date(ev.segmentEnd); + maxEnd = evEnd > maxEnd ? evEnd : maxEnd; + } else { + clusters.push(currentCluster); + currentCluster = [ev]; + maxEnd = new Date(ev.segmentEnd); + } + } + clusters.push(currentCluster); + return clusters; +} + +export function computeColumns(cluster) { + const sortedCluster = [...cluster].sort( + (a, b) => new Date(a.segmentStart) - new Date(b.segmentStart) + ); + const columns = []; + const eventsWithColumns = []; + + for (const ev of sortedCluster) { + const evStart = new Date(ev.segmentStart); + const evEnd = new Date(ev.segmentEnd); + let columnIndex = -1; + + for (let i = 0; i < columns.length; i++) { + if (evStart >= columns[i]) { + columnIndex = i; + break; + } + } + + if (columnIndex === -1) { + columnIndex = columns.length; + columns.push(evEnd); + } else { + columns[columnIndex] = evEnd; + } + + eventsWithColumns.push({ ...ev, column: columnIndex }); + } + + const columnsInCluster = columns.length; + eventsWithColumns.forEach((ev) => { + ev.columnsInCluster = columnsInCluster; + }); + + return eventsWithColumns; +} diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaItemCalendarEvent/AgendaItemCalendarEvent.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaItemCalendarEvent/AgendaItemCalendarEvent.jsx index ecc941b3..b5bd0437 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaItemCalendarEvent/AgendaItemCalendarEvent.jsx +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaItemCalendarEvent/AgendaItemCalendarEvent.jsx @@ -33,7 +33,7 @@ function getDisplayType(item) { return item?.type || 'Activity'; } -function AgendaItemCalendarEvent({ item, onEdit, event }) { +function AgendaItemCalendarEvent({ item, onEdit, event, showContinuationHint = false }) { const formatTime = (date) => { if (!date) return ''; const d = date instanceof Date ? date : new Date(date); @@ -68,6 +68,9 @@ function AgendaItemCalendarEvent({ item, onEdit, event }) {
{item.title || 'Untitled'}
+ {showContinuationHint && ( + Spans multiple days + )}
{getDisplayType(item)} {item.location && ( diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaItemCalendarEvent/AgendaItemCalendarEvent.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaItemCalendarEvent/AgendaItemCalendarEvent.scss index b7dc075f..7a6293dc 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaItemCalendarEvent/AgendaItemCalendarEvent.scss +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaItemCalendarEvent/AgendaItemCalendarEvent.scss @@ -32,6 +32,15 @@ margin-bottom: 4px; } + .continuation-hint { + display: block; + font-size: 0.7em; + font-style: italic; + color: var(--event-accent, var(--red)); + margin-bottom: 4px; + opacity: 0.85; + } + .event-details { display: flex; flex-direction: column; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAnalyticsDetail.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAnalyticsDetail.jsx index ab99f8ad..59bf395a 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAnalyticsDetail.jsx +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAnalyticsDetail.jsx @@ -26,7 +26,7 @@ function EventAnalyticsDetail({ event, stats, orgId, onRefresh }) { const actualRegistrations = stats?.registrationCount ?? analytics?.platform?.registrations ?? analytics?.platform?.uniqueRegistrations ?? analytics?.registrations ?? analytics?.uniqueRegistrations ?? 0; const actualCheckIns = stats?.checkIn?.totalCheckedIn ?? analytics?.platform?.checkins ?? analytics?.platform?.uniqueCheckins ?? 0; const [exporting, setExporting] = useState(false); - const [timeRange, setTimeRange] = useState('30d'); + const [timeRange, setTimeRange] = useState('90d'); // Fetch detailed analytics using the correct route const { data: analyticsData, refetch } = useFetch( diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.scss index 08ff8259..7d0bb8f0 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.scss +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.scss @@ -2,7 +2,6 @@ width: 100%; max-width: 100%; min-height: 100%; - background: var(--background); display: flex; flex-direction: column; @@ -583,7 +582,6 @@ } .event-overview { - padding: 1.5rem 0; width: 100%; max-width: 100%; overflow-x: hidden; // Prevent horizontal scrolling @@ -681,7 +679,6 @@ background: var(--lightbackground); border: 1px solid var(--lighterborder); border-radius: 18px; - padding: 1rem; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); } @@ -692,6 +689,8 @@ .overview-chart-box { flex: 1 1 auto; min-width: 0; + background-color: var(--background); + overflow: hidden; } .overview-preview-box-header, @@ -746,11 +745,10 @@ } .overview-chart-box-body { - padding: 0.3rem; .rsvp-growth-chart { padding: 0; - background: transparent; + background: var(--background); border: none; border-radius: 0; box-shadow: none; @@ -876,6 +874,7 @@ gap: 1.5rem; align-items: flex-start; width: 100%; + .rsvp-stats-card { flex: 0 0 auto; @@ -1353,304 +1352,6 @@ cursor: pointer; } -// RSVP Growth Chart Styles -.rsvp-growth-chart { - width: 100%; - max-width: 100%; - padding: 1.5rem; - background: var(--lightbackground); - border: 1px solid var(--lighterborder); - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); - box-sizing: border-box; - overflow: hidden; // Prevent content from expanding beyond bounds - - .chart-loading, - .chart-error, - .chart-empty { - display: flex; - align-items: center; - justify-content: center; - padding: 3rem; - color: var(--light-text); - font-size: 0.95rem; - } - - .chart-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; - - .chart-stats-inline { - display: flex; - gap: 2rem; - align-items: baseline; - - .chart-stat-item { - display: flex; - flex-direction: column; - gap: 0.25rem; - - .chart-stat-value { - font-size: 1.5rem; - font-weight: 600; - color: var(--text); - } - - .chart-stat-label { - font-size: 0.85rem; - color: var(--light-text); - font-weight: 500; - display: flex; - align-items: center; - gap: 0.35rem; - } - - .chart-stat-change { - display: inline-flex; - align-items: center; - gap: 0.2rem; - font-size: 0.75rem; - font-weight: 600; - - &.up { - color: #22c55e; - } - - &.down { - color: var(--red, #fa756d); - } - } - } - } - - .chart-header-right { - display: flex; - align-items: center; - gap: 1rem; - } - - .chart-title-section { - display: flex; - align-items: center; - gap: 1rem; - - h3 { - display: flex; - align-items: center; - gap: 0.75rem; - font-size: 1.25rem; - font-weight: 600; - color: var(--text); - margin: 0; - - iconify-icon { - color: #4DAA57; - font-size: 1.5rem; - } - } - - .frozen-badge { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.4rem 0.8rem; - background: #fff3cd; - color: #856404; - border-radius: 6px; - font-size: 0.85rem; - font-weight: 500; - - iconify-icon { - font-size: 1rem; - color: #856404; - } - } - } - - .chart-controls { - display: flex; - gap: 0.5rem; - background: var(--lighterborder); - border-radius: 8px; - padding: 0.25rem; - - .toggle-btn { - padding: 0.5rem 1rem; - background: transparent; - border: none; - border-radius: 6px; - font-size: 0.85rem; - font-weight: 500; - color: var(--light-text); - cursor: pointer; - transition: all 0.2s ease; - - &:hover { - background: var(--lightbackground); - } - - &.active { - background: var(--background); - color: var(--text); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - } - } - } - } - - .chart-stats { - display: flex; - gap: 2rem; - margin-bottom: 1.5rem; - padding-bottom: 1rem; - border-bottom: 1px solid var(--lighterborder); - - .stat-item { - display: flex; - flex-direction: column; - gap: 0.25rem; - - .stat-label { - font-size: 0.85rem; - color: var(--light-text); - font-weight: 500; - } - - .stat-value { - font-size: 1.5rem; - font-weight: 600; - color: var(--text); - } - } - } - - .chart-container { - height: 350px; - width: 100%; - max-width: 100%; - position: relative; - padding: 0.5rem 0 1rem 0; - margin-bottom: 0.5rem; - box-sizing: border-box; - overflow: hidden; // Prevent chart from expanding beyond container - } - - &.rsvp-growth-chart-visx .chart-container-visx { - height: 320px; - width: 100%; - min-height: 280px; - - > div { - width: 100% !important; - } - } - - &.rsvp-growth-chart-visx .chart-controls .toggle-btn { - font-size: 0.8rem; - padding: 0.4rem 0.75rem; - } - - .chart-legend { - display: flex; - gap: 1.25rem; - margin-top: 0.75rem; - padding-top: 0.75rem; - border-top: 1px solid var(--lighterborder); - font-size: 0.8rem; - color: var(--light-text); - } - - .chart-legend-item { - display: flex; - align-items: center; - gap: 0.5rem; - } - - .chart-legend-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; - } - - .chart-legend-dot-actual { - background: #22c55e; - } - - .chart-legend-dot-required { - background: #94a3b8; - } -} - -// Reset visx tooltip wrapper so only our custom content provides styling -.rsvp-chart-tooltip-wrapper.visx-tooltip { - padding: 0; - background: transparent; - border: none; - box-shadow: none; - z-index: 9999 !important; - pointer-events: none; -} - -/* Visx renders vertical crosshair in a portal (sibling to chart), not inside .chart-container-visx */ -.visx-crosshair.visx-crosshair-vertical { - z-index: 10001 !important; - pointer-events: none; -} - -.visx-crosshair.visx-crosshair-vertical line { - stroke: #94a3b8 !important; - stroke-width: 1px !important; - stroke-opacity: 1 !important; - stroke-dasharray: 1 4 !important; - stroke-linecap: round !important; -} - -.visx-tooltip-glyph { - z-index: 10003 !important; - pointer-events: none; -} - -.rsvp-chart-tooltip { - padding: 0.5rem 0.75rem; - background: var(--background); - border: 1px solid var(--lighterborder); - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); - font-size: 0.8rem; - min-width: 120px; -} - -.rsvp-chart-tooltip-date { - font-weight: 600; - color: var(--text); - margin-bottom: 0.35rem; -} - -.rsvp-chart-tooltip-row { - display: flex; - align-items: center; - gap: 0.5rem; - color: var(--light-text); -} - -.rsvp-chart-tooltip-dot { - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; -} - -.rsvp-chart-tooltip-dot-actual { - background: #22c55e; -} - -.rsvp-chart-tooltip-dot-required { - background: #94a3b8; -} - .event-analytics-detail { padding: 1.5rem 0; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.jsx new file mode 100644 index 00000000..4a57e967 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.jsx @@ -0,0 +1,931 @@ +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { Icon } from '@iconify-icon/react'; +import { useFetch } from '../../../../../hooks/useFetch'; +import { analytics } from '../../../../../services/analytics/analytics'; +import { useNotification } from '../../../../../NotificationContext'; +import useAuth from '../../../../../hooks/useAuth'; +import apiRequest from '../../../../../utils/postRequest'; +import EventAnnouncementCompose from './EventAnnouncementCompose'; +import EventDashboardOnboarding from './EventDashboardOnboarding/EventDashboardOnboarding'; +import EventOverview from './EventOverview'; +import EventPlanningOverviewSnapshot from './EventPlanningOverviewSnapshot'; +import EventEditorTab from './EventEditorTab/EventEditorTab'; +import AgendaBuilder from './EventAgendaBuilder/AgendaBuilder'; +import JobsManager from './EventJobsManager/JobsManager'; +import EventAnalyticsDetail from './EventAnalyticsDetail'; +import EventCheckInTab from './EventCheckInTab/EventCheckInTab'; +import EventQRTab from './EventQRTab/EventQRTab'; +import RegistrationsTab from './RegistrationsTab/RegistrationsTab'; +import CommunicationsTab from './CommunicationsTab/CommunicationsTab'; +import ComingSoon from './ComingSoon'; +import EventTasksTab from './EventTasksTab'; +import Popup from '../../../../../components/Popup/Popup'; +import EventDashboardFocusedHeader from './EventDashboardFocusedHeader'; +import EventDashboardFocusedPostMortem from './EventDashboardFocusedPostMortem'; +import './EventDashboardFocused.scss'; + +const FORCE_EVENT_DASHBOARD_ONBOARDING = false; +const COLLAB_ACCEPT_BANNER_KEY = 'meridian_event_dash_collab_accept_v1'; +/** Scroll past this (px) in the main content area to animate the header into condensed mode */ +const HEADER_CONDENSE_SCROLL_THRESHOLD = 56; +const DEBUG_PHASE_OVERRIDE_STORAGE_KEY = 'eventDashFocused:debugPhaseOverrideByEvent:v1'; +const EVENT_WORKFLOW_PHASES = { + DRAFTING: 'drafting', + PLANNING: 'planning', + RUN_OF_SHOW: 'runOfShow', + POST_MORTEM: 'postMortem' +}; +const EVENT_WORKFLOW_PHASE_LABELS = { + [EVENT_WORKFLOW_PHASES.DRAFTING]: 'Drafting', + [EVENT_WORKFLOW_PHASES.PLANNING]: 'Planning', + [EVENT_WORKFLOW_PHASES.RUN_OF_SHOW]: 'Run of Show', + [EVENT_WORKFLOW_PHASES.POST_MORTEM]: 'Post Mortem' +}; + +function readCollabAcceptDismissStore() { + try { + const raw = localStorage.getItem(COLLAB_ACCEPT_BANNER_KEY); + if (!raw) return {}; + return JSON.parse(raw); + } catch { + return {}; + } +} + +function getDismissedCollabOrgIdsForEvent(eventId) { + const store = readCollabAcceptDismissStore(); + return store[eventId]?.dismissedCollabOrgIds || []; +} + +function dismissCollabAcceptsForEvent(eventId, collabOrgIds) { + const store = readCollabAcceptDismissStore(); + const prev = new Set(store[eventId]?.dismissedCollabOrgIds || []); + collabOrgIds.forEach((id) => prev.add(String(id))); + store[eventId] = { dismissedCollabOrgIds: [...prev] }; + localStorage.setItem(COLLAB_ACCEPT_BANNER_KEY, JSON.stringify(store)); +} + +function formatCollaboratorNames(names) { + if (!names || names.length === 0) return ''; + if (names.length === 1) return names[0]; + if (names.length === 2) return `${names[0]} and ${names[1]}`; + return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`; +} + +function inferWorkflowPhase(eventData, stats) { + const status = eventData?.status; + const operationalStatus = stats?.operationalStatus; + if (operationalStatus === 'completed') return EVENT_WORKFLOW_PHASES.POST_MORTEM; + if (status === 'draft' || status === 'pending' || status === 'rejected') return EVENT_WORKFLOW_PHASES.DRAFTING; + + const now = new Date(); + const start = eventData?.start_time ? new Date(eventData.start_time) : null; + const end = eventData?.end_time ? new Date(eventData.end_time) : start; + if (start && end && now >= start && now <= end) return EVENT_WORKFLOW_PHASES.RUN_OF_SHOW; + if (end && now > end) return EVENT_WORKFLOW_PHASES.POST_MORTEM; + + return EVENT_WORKFLOW_PHASES.PLANNING; +} + +function readDebugPhaseOverrideStore() { + try { + const raw = localStorage.getItem(DEBUG_PHASE_OVERRIDE_STORAGE_KEY); + if (!raw) return {}; + return JSON.parse(raw); + } catch { + return {}; + } +} + +function writeDebugPhaseOverrideStore(nextStore) { + try { + localStorage.setItem(DEBUG_PHASE_OVERRIDE_STORAGE_KEY, JSON.stringify(nextStore)); + } catch { + // Ignore debug override persistence failures. + } +} + +function EventDashboardFocused({ event, orgId, onClose, className = '' }) { + const isDevEnv = process.env.NODE_ENV === 'development'; + const { addNotification } = useNotification(); + const { user } = useAuth(); + const [dashboardData, setDashboardData] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshTrigger, setRefreshTrigger] = useState(0); + const hasNotifiedErrorRef = useRef(false); + const [activeTab, setActiveTab] = useState('overview'); + const [showOnboarding, setShowOnboarding] = useState(false); + const [showAnnouncementSpotlight, setShowAnnouncementSpotlight] = useState(false); + const [openRegistrationSettingsFromAnnouncement, setOpenRegistrationSettingsFromAnnouncement] = useState(false); + const [collabAcceptBannerTick, setCollabAcceptBannerTick] = useState(0); + const [showCancelEventConfirm, setShowCancelEventConfirm] = useState(false); + const [cancelEventConfirmText, setCancelEventConfirmText] = useState(''); + const [cancelingEvent, setCancelingEvent] = useState(false); + const [showMobileMenu, setShowMobileMenu] = useState(false); + const [isMobileView, setIsMobileView] = useState(window.innerWidth <= 768); + const [isClosing, setIsClosing] = useState(false); + const [headerCondensed, setHeaderCondensed] = useState(false); + const [forcePostMortemView, setForcePostMortemView] = useState(false); + const [debugPanelOpen, setDebugPanelOpen] = useState(false); + const [debugPhaseOverride, setDebugPhaseOverride] = useState(''); + const closeTimerRef = useRef(null); + + const { data, loading: dataLoading, error, refetch } = useFetch( + event?._id && orgId ? `/org-event-management/${orgId}/events/${event._id}/dashboard` : null + ); + + useEffect(() => { + if (data?.success) { + setDashboardData(data.data); + setLoading(false); + } else if (error || (data && !data.success)) { + if (!hasNotifiedErrorRef.current) { + hasNotifiedErrorRef.current = true; + addNotification({ + title: 'Error', + message: error || data?.message || 'Failed to load event dashboard', + type: 'error' + }); + } + setLoading(false); + } else if (dataLoading) { + setLoading(true); + } + }, [data, error, dataLoading, addNotification]); + + useEffect(() => { + if (refreshTrigger > 0) refetch(); + }, [refreshTrigger, refetch]); + + useEffect(() => { + const handleResize = () => { + const mobile = window.innerWidth <= 768; + setIsMobileView(mobile); + if (!mobile) setShowMobileMenu(false); + }; + + handleResize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + useEffect(() => () => { + if (closeTimerRef.current) clearTimeout(closeTimerRef.current); + }, []); + + useEffect(() => { + if (!isDevEnv || !event?._id) return; + const eventId = String(event._id); + const store = readDebugPhaseOverrideStore(); + const storedOverride = store[eventId]; + setDebugPhaseOverride( + Object.values(EVENT_WORKFLOW_PHASES).includes(storedOverride) + ? storedOverride + : '' + ); + }, [event?._id, isDevEnv]); + + const handleDashboardClose = useCallback(() => { + if (isClosing) return; + setIsClosing(true); + closeTimerRef.current = setTimeout(() => { + onClose?.(); + }, 220); + }, [isClosing, onClose]); + + const handleMainContentScroll = useCallback((e) => { + if (activeTab !== 'overview') return; + const y = e.currentTarget.scrollTop; + const condensed = y > HEADER_CONDENSE_SCROLL_THRESHOLD; + setHeaderCondensed((prev) => (prev === condensed ? prev : condensed)); + }, [activeTab]); + + const handleRefresh = () => setRefreshTrigger((prev) => prev + 1); + + const handleTabChange = useCallback((tabId) => { + setActiveTab(tabId); + if (isMobileView) setShowMobileMenu(false); + if (event?._id && orgId) { + analytics.track('event_workspace_tab_view', { + event_id: event._id, + org_id: orgId, + tab: tabId + }); + } + }, [event?._id, isMobileView, orgId]); + + useEffect(() => { + if (event?._id && orgId && dashboardData && !loading) { + analytics.screen('Event Workspace Focused', { event_id: event._id, org_id: orgId }); + analytics.track('event_workspace_view', { event_id: event._id, org_id: orgId, variant: 'focused' }); + analytics.track('event_workspace_tab_view', { + event_id: event._id, + org_id: orgId, + tab: activeTab + }); + } + }, [event?._id, orgId, dashboardData, loading, activeTab]); + + useEffect(() => { + if (loading || !dashboardData) return; + const urlParams = new URLSearchParams(window.location.search); + const isTestMode = urlParams.get('test-event-onboarding') === 'true'; + const hasSeen = localStorage.getItem('eventDashboardOnboardingSeen'); + if (FORCE_EVENT_DASHBOARD_ONBOARDING || isTestMode || !hasSeen) { + setShowOnboarding(true); + } + }, [loading, dashboardData]); + + const handleOnboardingClose = useCallback(() => { + const urlParams = new URLSearchParams(window.location.search); + const isTestMode = urlParams.get('test-event-onboarding') === 'true'; + if (!FORCE_EVENT_DASHBOARD_ONBOARDING && !isTestMode) { + localStorage.setItem('eventDashboardOnboardingSeen', 'true'); + } + setShowOnboarding(false); + }, []); + + const handleOpenRegistrationSettings = useCallback(() => { + setActiveTab('registrations'); + setOpenRegistrationSettingsFromAnnouncement(true); + }, []); + + const handleAnnouncementSent = useCallback(() => { + addNotification({ + title: 'Announcement sent', + message: 'Event attendees have been notified.', + type: 'success' + }); + handleRefresh(); + }, [addNotification]); + + const eventForPhase = dashboardData?.event || event; + const workspaceEvent = dashboardData?.event || event; + const workspaceStats = dashboardData?.stats || {}; + const workspaceAgenda = dashboardData?.agenda || []; + const isEventCompleted = dashboardData?.stats?.operationalStatus === 'completed'; + const approvalStatus = dashboardData?.event?.status || ''; + const inferredWorkflowPhase = inferWorkflowPhase(eventForPhase, dashboardData?.stats); + const activeWorkflowPhase = debugPhaseOverride || inferredWorkflowPhase; + const isPostMortemMode = forcePostMortemView || activeWorkflowPhase === EVENT_WORKFLOW_PHASES.POST_MORTEM; + + const approvalStatusConfig = useMemo(() => { + if (approvalStatus === 'pending') { + return { tone: 'pending', title: 'Pending review', message: 'This event is currently in an approval or acknowledgement workflow.' }; + } + if (approvalStatus === 'rejected') { + return { tone: 'rejected', title: 'Needs changes', message: 'This event was rejected. Update details and re-submit for review.' }; + } + if (approvalStatus === 'approved') { + return { tone: 'approved', title: 'Approved for publishing', message: 'This event is clear to publish and appear in public experiences.' }; + } + return null; + }, [approvalStatus]); + + const collaborationAcceptBanner = useMemo(() => { + if (!dashboardData?.event || !orgId) return null; + const ev = dashboardData.event; + if (ev.hostingType !== 'Org') return null; + const hostId = String(ev.hostingId?._id || ev.hostingId); + if (String(orgId) !== hostId) return null; + + const eventKey = String(ev._id); + const dismissed = getDismissedCollabOrgIdsForEvent(eventKey); + const unseen = (ev.collaboratorOrgs || []).filter((entry) => { + const cid = String(entry.orgId?._id || entry.orgId); + return entry.status === 'active' && entry.acceptedAt && !dismissed.includes(cid); + }); + if (unseen.length === 0) return null; + return { + names: unseen.map((entry) => entry.orgId?.org_name || 'An organization'), + collabIds: unseen.map((entry) => String(entry.orgId?._id || entry.orgId)), + eventKey + }; + }, [dashboardData, orgId, collabAcceptBannerTick]); + + const handlePostMortem = useCallback(() => { + setForcePostMortemView(true); + }, []); + + const handleDebugPhaseChange = useCallback((nextPhase) => { + if (!isDevEnv || !event?._id) return; + const eventId = String(event._id); + const normalized = + nextPhase && Object.values(EVENT_WORKFLOW_PHASES).includes(nextPhase) + ? nextPhase + : ''; + setDebugPhaseOverride(normalized); + setForcePostMortemView(false); + + const store = readDebugPhaseOverrideStore(); + if (normalized) { + store[eventId] = normalized; + } else { + delete store[eventId]; + } + writeDebugPhaseOverrideStore(store); + }, [event?._id, isDevEnv]); + + const handleCancelEvent = useCallback(async () => { + if (!dashboardData?.event?._id || !orgId || cancelingEvent) return; + if (cancelEventConfirmText.trim().toLowerCase() !== 'cancel event') return; + + setCancelingEvent(true); + try { + const response = await apiRequest( + `/org-event-management/${orgId}/events/${dashboardData.event._id}`, + {}, + { method: 'DELETE' } + ); + if (response?.success) { + addNotification({ + title: 'Event cancelled', + message: 'The event has been permanently deleted.', + type: 'success' + }); + setShowCancelEventConfirm(false); + setCancelEventConfirmText(''); + onClose?.(); + return; + } + addNotification({ + title: 'Cancel failed', + message: response?.message || response?.error || 'Unable to cancel this event.', + type: 'error' + }); + } catch (err) { + addNotification({ + title: 'Cancel failed', + message: err?.message || 'Unable to cancel this event.', + type: 'error' + }); + } finally { + setCancelingEvent(false); + } + }, [addNotification, cancelEventConfirmText, cancelingEvent, dashboardData?.event?._id, onClose, orgId, dashboardData?.event?._id]); + + if (loading && !isPostMortemMode) { + return ( +
+
+ +

Loading focused dashboard...

+
+
+ ); + } + + if (!dashboardData && !isPostMortemMode) { + return ( +
+
+ +

Failed to load focused dashboard

+ +
+
+ ); + } + + const tabs = [ + { + id: 'overview', + label: 'Overview', + icon: 'mingcute:chart-bar-fill', + phases: [ + EVENT_WORKFLOW_PHASES.DRAFTING, + EVENT_WORKFLOW_PHASES.PLANNING, + EVENT_WORKFLOW_PHASES.RUN_OF_SHOW + ], + content: ( +
+ {activeWorkflowPhase === EVENT_WORKFLOW_PHASES.PLANNING && ( + handleTabChange('tasks')} + /> + )} + +
+ ) + }, + { + id: 'agenda', + label: 'Agenda', + icon: 'mdi:calendar-clock', + phases: [ + EVENT_WORKFLOW_PHASES.DRAFTING, + EVENT_WORKFLOW_PHASES.PLANNING, + EVENT_WORKFLOW_PHASES.RUN_OF_SHOW + ], + content: ( + + ) + }, + { + id: 'jobs', + label: 'Jobs', + icon: 'mdi:briefcase', + phases: [ + EVENT_WORKFLOW_PHASES.DRAFTING, + EVENT_WORKFLOW_PHASES.PLANNING, + EVENT_WORKFLOW_PHASES.RUN_OF_SHOW + ], + content: + }, + { + id: 'tasks', + label: 'Tasks', + icon: 'mdi:check-circle-outline', + phases: [ + EVENT_WORKFLOW_PHASES.DRAFTING, + EVENT_WORKFLOW_PHASES.PLANNING, + EVENT_WORKFLOW_PHASES.RUN_OF_SHOW + ], + content: + }, + { + id: 'analytics', + label: 'Analytics', + icon: 'mingcute:chart-line-fill', + phases: [ + EVENT_WORKFLOW_PHASES.PLANNING, + EVENT_WORKFLOW_PHASES.RUN_OF_SHOW, + EVENT_WORKFLOW_PHASES.POST_MORTEM + ], + content: ( + + ) + }, + { + id: 'edit', + label: 'Details', + icon: 'mdi:pencil', + phases: [EVENT_WORKFLOW_PHASES.DRAFTING, EVENT_WORKFLOW_PHASES.PLANNING], + content: ( +
+ +
+
+ +
+ Cancel event + Permanently deletes this event and associated workspace data. This cannot be undone. +
+
+ +
+
+ ) + }, + { + id: 'registrations', + label: 'Registrations', + icon: 'mdi:clipboard-list-outline', + phases: [EVENT_WORKFLOW_PHASES.PLANNING, EVENT_WORKFLOW_PHASES.RUN_OF_SHOW], + content: ( + setOpenRegistrationSettingsFromAnnouncement(false)} + /> + ) + }, + { + id: 'communications', + label: 'Communications', + icon: 'mdi:message-text', + phases: [EVENT_WORKFLOW_PHASES.PLANNING, EVENT_WORKFLOW_PHASES.RUN_OF_SHOW], + content: ( + setShowAnnouncementSpotlight(true)} + onOpenRegistrationSettings={handleOpenRegistrationSettings} + onNavigateToAnalytics={() => handleTabChange('analytics')} + /> + ) + }, + { + id: 'checkin', + label: 'Check-In', + icon: 'uil:qrcode-scan', + phases: [EVENT_WORKFLOW_PHASES.RUN_OF_SHOW], + content: ( + + ) + }, + { + id: 'qr', + label: 'QR Codes', + icon: 'mdi:qrcode', + phases: [EVENT_WORKFLOW_PHASES.PLANNING, EVENT_WORKFLOW_PHASES.RUN_OF_SHOW], + content: + }, + { + id: 'equipment', + label: 'Equipment', + icon: 'mdi:package-variant', + phases: [EVENT_WORKFLOW_PHASES.PLANNING, EVENT_WORKFLOW_PHASES.RUN_OF_SHOW], + content: + } + ]; + + const sidebarSectionsByPhase = { + [EVENT_WORKFLOW_PHASES.DRAFTING]: [ + { + id: 'drafting-core', + label: 'Drafting', + tabIds: ['overview', 'edit', 'agenda'] + }, + { + id: 'drafting-alignment', + label: 'Alignment', + tabIds: ['jobs', 'tasks'] + } + ], + [EVENT_WORKFLOW_PHASES.PLANNING]: [ + { + id: 'planning', + label: 'Planning', + tabIds: ['overview', 'agenda', 'jobs', 'tasks', 'edit'] + }, + { + id: 'audience', + label: 'Audience', + tabIds: ['registrations', 'communications', 'qr'] + }, + { + id: 'insights', + label: 'Insights', + tabIds: ['analytics'] + }, + { + id: 'resources', + label: 'Resources', + tabIds: ['equipment'] + } + ], + [EVENT_WORKFLOW_PHASES.RUN_OF_SHOW]: [ + { + id: 'live-operations', + label: 'Live Operations', + tabIds: ['overview', 'checkin', 'communications', 'tasks', 'jobs'] + }, + { + id: 'attendees', + label: 'Attendees', + tabIds: ['registrations', 'qr'] + }, + { + id: 'monitoring', + label: 'Monitoring', + tabIds: ['analytics', 'agenda'] + } + ], + [EVENT_WORKFLOW_PHASES.POST_MORTEM]: [ + { + id: 'retrospective', + label: 'Retrospective', + tabIds: ['analytics', 'overview'] + }, + { + id: 'records', + label: 'Records', + tabIds: ['communications', 'registrations'] + } + ] + }; + + const tabsById = {}; + tabs.forEach((tab) => { + tabsById[tab.id] = tab; + }); + + const visibleTabs = tabs.filter((tab) => !tab.phases || tab.phases.includes(activeWorkflowPhase)); + const visibleTabIds = new Set(visibleTabs.map((tab) => tab.id)); + const phaseSidebarSections = sidebarSectionsByPhase[activeWorkflowPhase] || []; + + const resolvedSidebarSections = phaseSidebarSections + .map((section) => ({ + ...section, + tabs: section.tabIds.map((tabId) => tabsById[tabId]).filter((tab) => tab && visibleTabIds.has(tab.id)) + })) + .filter((section) => section.tabs.length > 0); + + const activeTabConfig = visibleTabs.find((tab) => tab.id === activeTab) || visibleTabs[0] || tabs[0]; + const effectiveActiveTab = activeTabConfig?.id || activeTab; + + return ( + <> +
+ {isPostMortemMode ? ( + + ) : ( + <> + setShowAnnouncementSpotlight(true)} + onPostMortem={handlePostMortem} + showPostMortem={isEventCompleted} + /> + +
+ + +
+
+ {isMobileView && ( +
+ +
+ )} + + {collaborationAcceptBanner && ( +
+

+ {formatCollaboratorNames(collaborationAcceptBanner.names)} accepted your collaboration invite. +

+
+ + +
+
+ )} + + {approvalStatusConfig && ( +
+ {approvalStatusConfig.title} + {approvalStatusConfig.message} +
+ )} + +
+ {visibleTabs.map((tab) => ( +
+ {tab.content} +
+ ))} +
+
+
+
+ + )} + + {isDevEnv && event?._id && ( +
+ + {debugPanelOpen && ( +
+

Dashboard State Debugger

+ + +
+ Active: {EVENT_WORKFLOW_PHASE_LABELS[activeWorkflowPhase]} + Inferred: {EVENT_WORKFLOW_PHASE_LABELS[inferredWorkflowPhase]} +
+
+ )} +
+ )} + + {!isPostMortemMode && isMobileView && showMobileMenu && ( +
setShowMobileMenu(false)} + role="presentation" + > +
e.stopPropagation()} + > + {resolvedSidebarSections.map((section) => ( +
+

{section.label}

+
+ {section.tabs.map((tab) => ( + + ))} +
+
+ ))} +
+
+ )} +
+ + + + + + setShowAnnouncementSpotlight(false)} + orgId={orgId} + eventId={event?._id} + eventName={dashboardData?.event?.name} + eventStartTime={dashboardData?.event?.start_time} + orgName={dashboardData?.event?.hostingId?.org_name} + orgProfileImage={dashboardData?.event?.hostingId?.org_profile_image} + organizerName={user?.name || user?.username} + organizerPicture={user?.picture} + onSent={handleAnnouncementSent} + onOpenRegistrationSettings={handleOpenRegistrationSettings} + /> + + { + if (cancelingEvent) return; + setShowCancelEventConfirm(false); + setCancelEventConfirmText(''); + }} + customClassName="event-cancel-confirm-popup" + > +
+
+ +

Cancel Event

+
+
+

Warning: this action is destructive and cannot be undone.

+

This will permanently delete {dashboardData?.event?.name || 'this event'}.

+
+
+ + setCancelEventConfirmText(e.target.value)} + placeholder="cancel event" + autoFocus + /> +
+
+ + +
+
+
+ + ); +} + +export default EventDashboardFocused; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.scss new file mode 100644 index 00000000..7ff901b6 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.scss @@ -0,0 +1,636 @@ +.event-dashboard-focused { + --dash-bg: var(--background); + + --page-margins: 6vw; + z-index: 1000; + width: 100%; + min-height: 100%; + display: flex; + flex-direction: column; + background: + radial-gradient(120% 72% at 0% 0%, rgba(77, 170, 87, 0.2) 0%, rgba(77, 170, 87, 0.12) 22%, rgba(77, 170, 87, 0.05) 42%, transparent 65%), + var(--dash-bg); + color: var(--text); + animation: eventDashFocusedIn 260ms cubic-bezier(0.22, 1, 0.36, 1); + + @media (min-width: 1401px) { + --page-margins: 7.5vw; + } + + // .event-dashboard{ + // padding-right: calc(var(--page-margins) - 18px); + // box-sizing: border-box; + // } + + + &.full-width-event-dashboard-focused { + box-sizing: border-box; + position: fixed; + inset: 0; + z-index: 1100; + width: 100vw; + min-height: 100vh; + height: 100vh; + max-width: none; + overflow: hidden; + } + + &__body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 250px 1fr; + align-items: stretch; + } + + &.event-dashboard-focused--closing { + animation: eventDashFocusedOut 220ms cubic-bezier(0.4, 0, 1, 1) forwards; + pointer-events: none; + } + + .loading-container, + .error-container { + min-height: 50vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + color: var(--text); + } + + &__sidebar { + padding: 18px 14px 18px var(--page-margins); + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 14px; + position: relative; + min-height: 0; + height: 100%; + overflow-y: auto; + background: transparent; + transition: width 0.2s ease, padding 0.2s ease; + animation: eventDashSidebarIn 280ms cubic-bezier(0.22, 1, 0.36, 1); + } + + &__sidebar-header { + display: none; + justify-content: flex-end; + } + + &__sidebar-toggle { + border: 1px solid var(--lighterborder); + background: var(--lightbackground); + color: var(--text); + border-radius: 8px; + padding: 6px 10px; + display: inline-flex; + align-items: center; + gap: 6px; + justify-content: center; + cursor: pointer; + + &:hover { + background: rgba(77, 170, 87, 0.12); + border-color: rgba(77, 170, 87, 0.35); + } + } + + &__event-card { + border-radius: 14px; + padding: 12px; + background: var(--background); + } + + &__event-name-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; + + h1 { + margin: 0; + font-size: 1rem; + font-weight: 700; + color: var(--text); + } + } + + &__event-meta { + display: flex; + flex-direction: column; + gap: 6px; + + p { + margin: 0; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + color: var(--light-text); + } + } + + &__nav { + display: flex; + flex-direction: column; + gap: 12px; + } + + &__nav-section { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__nav-section-title { + margin: 0; + padding: 0 4px; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--light-text); + } + + &__nav-section-items { + display: flex; + flex-direction: column; + gap: 2px; + } + + &__nav-item { + border-radius: 10px; + background: transparent; + color: var(--text); + text-align: left; + padding: 8px 12px; + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + + &.is-active { + background: rgba(77, 170, 87, 0.14); + color: var(--primary-color); + } + + &:hover { + background: rgba(77, 170, 87, 0.12); + border-color: rgba(77, 170, 87, 0.35); + color: var(--primary-color); + } + } + + &__main { + min-width: 0; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + animation: eventDashMainIn 300ms cubic-bezier(0.22, 1, 0.36, 1); + box-sizing: border-box; + background-color: transparent; + padding-right: calc(var(--page-margins) - 18px); + } + &__main-content { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + background-color: transparent; + overflow: hidden; + } + + &__mobile-menu-overlay { + display: none; + } + + &__mobile-menu-trigger { + border: 1px solid var(--lighterborder); + background: var(--background); + color: var(--text); + border-radius: 8px; + padding: 6px 10px; + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + cursor: pointer; + } + + &__mobile-nav-bar { + display: none; + flex-shrink: 0; + padding: 10px 12px 0; + } + + &__banner, + &__approval-banner { + border-radius: 10px; + border: 1px solid var(--lighterborder); + background: var(--lightbackground); + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + &__banner { + p { + margin: 0; + font-size: 0.9rem; + color: var(--text); + } + } + + &__banner-actions { + display: flex; + gap: 8px; + + button { + border: 1px solid var(--lighterborder); + background: var(--background); + color: var(--text); + border-radius: 8px; + padding: 5px 8px; + cursor: pointer; + + &:hover { + background: #4DAA57; + color: white; + border-color: #4DAA57; + } + } + } + + &__approval-banner { + strong { + color: var(--text); + } + + span { + color: var(--light-text); + font-size: 0.88rem; + } + } + + &__content { + min-width: 0; + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px 18px 22px; + background-color: transparent; + + } + + &__tab-panel { + min-width: 0; + z-index:999; + + &.is-active { + display: block; + } + } + + &__debug-panel { + position: fixed; + right: max(18px, env(safe-area-inset-right)); + bottom: max(92px, calc(env(safe-area-inset-bottom) + 92px)); + z-index: 1302; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + } + + &__debug-panel-trigger { + border: 1px solid rgba(77, 170, 87, 0.34); + background: rgba(17, 25, 20, 0.9); + color: #e7f6ea; + border-radius: 999px; + padding: 8px 12px; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25); + + &:hover { + background: rgba(28, 42, 33, 0.95); + } + } + + &__debug-panel-body { + width: min(320px, calc(100vw - 36px)); + border: 1px solid rgba(77, 170, 87, 0.24); + background: rgba(16, 24, 20, 0.94); + color: #e7f6ea; + border-radius: 12px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + box-shadow: 0 14px 26px rgba(0, 0, 0, 0.3); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + + label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: rgba(231, 246, 234, 0.75); + font-weight: 700; + } + + select { + width: 100%; + border-radius: 8px; + border: 1px solid rgba(231, 246, 234, 0.22); + background: rgba(8, 12, 10, 0.6); + color: #f3fff5; + padding: 8px 10px; + font-size: 0.86rem; + font-weight: 600; + } + } + + &__debug-panel-title { + margin: 0; + font-size: 0.83rem; + font-weight: 700; + color: #f1fff3; + } + + &__debug-panel-meta { + display: grid; + gap: 4px; + font-size: 0.74rem; + color: rgba(231, 246, 234, 0.78); + } + + .event-status-bubble { + border-radius: 999px; + padding: 3px 8px; + font-size: 0.72rem; + border: 1px solid var(--lighterborder); + + &.draft { + background: rgba(245, 158, 11, 0.12); + color: #b45309; + } + &.upcoming { + background: rgba(77, 170, 87, 0.1); + color: #4DAA57; + border-color: rgba(77, 170, 87, 0.25); + } + &.live { + background: rgba(76, 175, 80, 0.2); + color: #2e7d32; + border-color: rgba(76, 175, 80, 0.4); + } + &.passed { + background: rgba(108, 117, 125, 0.1); + color: #6c757d; + border-color: rgba(108, 117, 125, 0.2); + } + } + + @media (max-width: 800px) { + &__body { + grid-template-columns: 1fr; + } + + &__sidebar { + position: relative; + top: auto; + height: auto; + max-height: 42vh; + border-right: 0; + border-bottom: 1px solid var(--lighterborder); + } + + &__main { + height: auto; + min-height: 280px; + overflow: visible; + margin: 0; + padding: 0; + } + + &__nav { + gap: 10px; + } + + &__nav-section-items { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + } + } + + @media (max-width: 768px) { + height: 100vh; + min-height: 100vh; + overflow: hidden; + + &__body { + grid-template-columns: 1fr; + flex: 1; + min-height: 0; + } + + &__sidebar { + display: none; + } + + &__mobile-nav-bar { + display: flex; + } + + &__mobile-menu-trigger { + width: 100%; + justify-content: space-between; + } + + &__event-name-row h1 { + font-size: 0.95rem; + } + + &__event-meta p { + font-size: 0.8rem; + } + + &__main { + margin: 0; + flex: 1; + min-height: 0; + height: auto; + padding: 0; + box-sizing: border-box; + overflow: hidden; + } + + &__main-content { + overflow: hidden; + } + + &__content { + -webkit-overflow-scrolling: touch; + } + + &__banner, + &__approval-banner { + flex-direction: column; + align-items: flex-start; + } + + &__mobile-menu-overlay { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.38); + z-index: 1205; + animation: eventDashMobileOverlayIn 170ms ease-out; + } + + &__mobile-menu-sheet { + position: absolute; + left: 10px; + right: 10px; + top: clamp(120px, 22vh, 200px); + max-height: min(68vh, 520px); + overflow-y: auto; + background: var(--background); + border: 1px solid var(--lighterborder); + border-radius: 12px; + box-shadow: var(--shadow); + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; + animation: eventDashMobileSheetIn 220ms cubic-bezier(0.22, 1, 0.36, 1); + } + + &__mobile-menu-section { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__mobile-menu-section-title { + margin: 0; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--light-text); + } + + &__mobile-menu-items { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + } + + &__mobile-menu-item { + border: 1px solid var(--lighterborder); + border-radius: 10px; + background: var(--lightbackground); + color: var(--text); + text-align: left; + padding: 9px 10px; + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + + &.is-active { + background: rgba(77, 170, 87, 0.12); + border-color: rgba(77, 170, 87, 0.4); + } + } + + &__debug-panel { + right: 12px; + bottom: calc(env(safe-area-inset-bottom) + 78px); + } + + } +} + +@keyframes eventDashFocusedIn { + from { + opacity: 0; + transform: translateY(10px) scale(0.995); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes eventDashFocusedOut { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(10px) scale(0.992); + } +} + +@keyframes eventDashSidebarIn { + from { + opacity: 0; + transform: translateX(-14px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes eventDashMainIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes eventDashMobileOverlayIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes eventDashMobileSheetIn { + from { + opacity: 0; + transform: translateY(-10px) scale(0.99); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedHeader.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedHeader.jsx new file mode 100644 index 00000000..3eac0350 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedHeader.jsx @@ -0,0 +1,373 @@ +import React, { useMemo, useState } from 'react'; +import { Icon } from '@iconify-icon/react'; +import { useGradient } from '../../../../../hooks/useGradient'; +import { useNotification } from '../../../../../NotificationContext'; +import apiRequest from '../../../../../utils/postRequest'; +import defaultAvatar from '../../../../../assets/defaultAvatar.svg'; +import './EventDashboardFocusedHeader.scss'; + +/** + * Header used only by EventDashboardFocused — keeps styles isolated so the legacy + * EventDashboardHeader + EventDashboard.scss stack can revert unchanged. + */ +function EventDashboardFocusedHeader({ + condensed = false, + event, + stats, + onClose, + onRefresh, + orgId, + onSendAnnouncement, + onPostMortem, + showPostMortem +}) { + const [publishing, setPublishing] = useState(false); + const { AtlasMain } = useGradient(); + const { addNotification } = useNotification(); + + const getEventStatus = () => { + if (!event?.start_time) return null; + const now = new Date(); + const start = new Date(event.start_time); + const end = new Date(event.end_time || event.start_time); + if (start > now) return 'upcoming'; + if (end < now) return 'passed'; + return 'live'; + }; + + const formatDate = (dateString) => { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }; + + const formatTime = (dateString) => { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); + }; + + const getTimeUntilEvent = () => { + if (!event?.start_time) return ''; + const now = new Date(); + const start = new Date(event.start_time); + const end = new Date(event.end_time || event.start_time); + const diff = start - now; + + if (diff < 0) { + if (now <= end) return 'Happening now'; + return 'Event has ended'; + } + + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + + if (days > 0) { + return `${days} day${days !== 1 ? 's' : ''} until event`; + } else if (hours > 0) { + return `${hours} hour${hours !== 1 ? 's' : ''} until event`; + } else { + return `${minutes} minute${minutes !== 1 ? 's' : ''} until event`; + } + }; + + const handlePreview = () => { + if (!event?._id) return; + const eventUrl = `${window.location.origin}/event/${event._id}`; + window.open(eventUrl, '_blank', 'noopener,noreferrer'); + }; + + const handlePublish = async () => { + if (!event?._id) return; + setPublishing(true); + try { + const res = await apiRequest(`/publish-event/${event._id}`, {}, { method: 'POST' }); + if (res.success) { + addNotification({ + title: 'Event Published', + message: res.status === 'pending' ? 'Event submitted for approval.' : 'Event published successfully.', + type: 'success' + }); + onRefresh?.(); + } else { + throw new Error(res.message || res.error); + } + } catch (err) { + addNotification({ + title: 'Publish Failed', + message: err.message || 'Failed to publish event.', + type: 'error' + }); + } finally { + setPublishing(false); + } + }; + + const handleShare = async () => { + if (!event?._id) return; + const eventUrl = `${window.location.origin}/event/${event._id}`; + try { + await navigator.clipboard.writeText(eventUrl); + addNotification({ + title: 'Success', + message: 'Event link copied to clipboard', + type: 'success' + }); + } catch { + const textArea = document.createElement('textarea'); + textArea.value = eventUrl; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + document.body.appendChild(textArea); + textArea.select(); + try { + document.execCommand('copy'); + addNotification({ + title: 'Success', + message: 'Event link copied to clipboard', + type: 'success' + }); + } catch (copyErr) { + addNotification({ + title: 'Error', + message: 'Failed to copy link to clipboard', + type: 'error' + }); + } + document.body.removeChild(textArea); + } + }; + + const eventStatus = getEventStatus(); + const eventImageUrl = event?.image || event?.previewImage; + + const collaborationOrgs = useMemo(() => { + if (event?.hostingType !== 'Org') return []; + + const currentOrgId = orgId ? String(orgId) : ''; + const map = new Map(); + const hostIdRaw = event.hostingId?._id || event.hostingId; + const hostId = hostIdRaw ? String(hostIdRaw) : ''; + + if (hostId && hostId !== currentOrgId) { + map.set(hostId, { + id: hostId, + name: event.hostingId?.org_name || 'Host organization', + image: event.hostingId?.org_profile_image || defaultAvatar, + role: 'host', + status: 'active' + }); + } + + (event.collaboratorOrgs || []).forEach((entry) => { + const collaboratorIdRaw = entry?.orgId?._id || entry?.orgId; + const collaboratorId = collaboratorIdRaw ? String(collaboratorIdRaw) : ''; + if (!collaboratorId || collaboratorId === currentOrgId || map.has(collaboratorId)) return; + map.set(collaboratorId, { + id: collaboratorId, + name: entry?.orgId?.org_name || 'Organization', + image: entry?.orgId?.org_profile_image || defaultAvatar, + role: 'collaborator', + status: entry?.status === 'active' ? 'active' : 'pending' + }); + }); + + return Array.from(map.values()); + }, [event, orgId]); + + return ( +
+
+ +
+
+
+ +
+ {event?.status === 'draft' && ( + + )} + + + {onSendAnnouncement && event?._id && orgId && ( + + )} + + {showPostMortem && ( + + )} +
+
+
+
+
+ {eventImageUrl ? ( + + ) : null} +
+

{event?.name || 'Event'}

+
+ {event?.status === 'draft' && ( + + Draft + + )} + {event?.status === 'pending' && ( + + Pending Review + + )} + {event?.status === 'rejected' && ( + + Rejected + + )} + {eventStatus && !['draft', 'pending', 'rejected'].includes(event?.status) && ( + + {eventStatus === 'upcoming' && 'Upcoming'} + {eventStatus === 'live' && 'Live'} + {eventStatus === 'passed' && 'Passed'} + + )} +
+
+
+ {event?.hostingType === 'Org' && ( +
+ {collaborationOrgs.length === 0 ? null : ( + <> + with +
    + {collaborationOrgs.map((org, index) => { + const lastIndex = collaborationOrgs.length - 1; + const isLast = index === lastIndex; + const needsAnd = collaborationOrgs.length > 1 && isLast; + const needsComma = index > 0 && !isLast; + return ( +
  • + {needsComma && ( + , + )} + {needsAnd && ( + and + )} + {org.name + {org.name} +
  • + ); + })} +
+ + )} +
+ )} +
+
+
+ +
+ {stats?.registrationCount ?? 0} + Registrations +
+
+
+ +
+ {getTimeUntilEvent() || 'N/A'} + Time Until +
+
+
+
+
+
+ + {formatDate(event?.start_time)} +
+
+ + {formatTime(event?.start_time)} - {formatTime(event?.end_time)} +
+
+ + {event?.location || 'TBD'} +
+
+
+
+ ); +} + +export default EventDashboardFocusedHeader; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedHeader.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedHeader.scss new file mode 100644 index 00000000..94f7d034 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedHeader.scss @@ -0,0 +1,589 @@ +/** + * Styles for EventDashboardFocusedHeader only — does not affect legacy EventDashboard / + * EventDashboardHeader. + */ + +.event-dashboard-focused-header { + position: relative; + width: 100%; + flex-shrink: 0; + background: transparent; + /* Full-bleed gradient; horizontal padding handled on __content */ + padding-top: 2rem; + padding-bottom: 2rem; + padding-left: 0; + padding-right: 0; + box-sizing: border-box; + transition: + padding-top 0.38s cubic-bezier(0.22, 1, 0.36, 1), + padding-bottom 0.38s cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 0.35s ease; + + &__background { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.1; + transition: opacity 0.35s ease; + z-index: 0; + pointer-events: none; + + img { + width: 100%; + // height: 100%; + object-fit: cover; + } + } + + &__content { + position: relative; + z-index: 1; + padding-left: var(--page-margins); + padding-right: var(--page-margins); + box-sizing: border-box; + } + + &__top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + transition: margin-bottom 0.35s cubic-bezier(0.22, 1, 0.36, 1); + } + + &__close { + background: transparent; + border: none; + color: var(--text); + cursor: pointer; + padding: 0.5rem; + border-radius: 8px; + transition: all 0.2s ease, font-size 0.32s cubic-bezier(0.22, 1, 0.36, 1); + font-size: 1.5rem; + + &:hover { + background: var(--lightbackground); + color: #dc3545; + } + } + + &__actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + transition: gap 0.3s ease; + } + + &__btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--lightbackground); + border: 1px solid var(--lighterborder); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease, padding 0.32s cubic-bezier(0.22, 1, 0.36, 1), font-size 0.32s cubic-bezier(0.22, 1, 0.36, 1); + font-size: 0.9rem; + color: var(--text); + + &:hover { + background: #4daa57; + color: white; + border-color: #4daa57; + transform: translateY(-2px); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + iconify-icon { + font-size: 1.2rem; + + &.spinner { + animation: eventDashboardFocusedHeaderSpin 1s linear infinite; + } + } + } + + &__btn--icon-only { + padding: 0.5rem; + } + + &__btn--publish, + &__btn--announcement, + &__btn--post-mortem { + padding: 0.5rem 1rem; + } + + &__main { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 2rem; + transition: + margin-bottom 0.35s cubic-bezier(0.22, 1, 0.36, 1), + gap 0.35s cubic-bezier(0.22, 1, 0.36, 1), + align-items 0.25s ease; + } + + &__title-section { + flex: 1; + min-width: 300px; + display: flex; + flex-direction: column; + gap: 0.5rem; + transition: gap 0.3s ease, min-width 0.3s ease; + } + + &__title-row { + display: flex; + align-items: flex-start; + flex-wrap: nowrap; + gap: 0.85rem; + min-width: 0; + + @media (max-width: 520px) { + flex-wrap: wrap; + } + } + + &__title-thumb { + width: 4.5rem; + height: 4.5rem; + object-fit: cover; + border-radius: 10px; + flex-shrink: 0; + border: 1px solid var(--lighterborder); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); + background: var(--lightbackground); + transition: + width 0.38s cubic-bezier(0.22, 1, 0.36, 1), + height 0.38s cubic-bezier(0.22, 1, 0.36, 1), + border-radius 0.32s cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 0.35s ease; + } + + &__title-heading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.6rem; + min-width: 0; + flex: 1; + + h1 { + font-size: 2.5rem; + font-weight: 700; + color: var(--text); + margin: 0; + transition: font-size 0.38s cubic-bezier(0.22, 1, 0.36, 1), line-height 0.38s cubic-bezier(0.22, 1, 0.36, 1); + } + } + + &__meta { + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; + } + + &__status { + padding: 0.4rem 0.9rem; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; + transition: padding 0.32s cubic-bezier(0.22, 1, 0.36, 1), font-size 0.32s cubic-bezier(0.22, 1, 0.36, 1); + text-transform: capitalize; + display: inline-flex; + align-items: center; + gap: 0.4rem; + + &--draft { + background: rgba(245, 158, 11, 0.12); + color: #b45309; + border: 1px solid rgba(245, 158, 11, 0.35); + } + + &--pending { + background: rgba(77, 170, 87, 0.1); + color: #4daa57; + border: 1px solid rgba(77, 170, 87, 0.2); + } + + &--rejected { + background: rgba(108, 117, 125, 0.1); + color: #6c757d; + border: 1px solid rgba(108, 117, 125, 0.2); + } + + &--upcoming { + background: rgba(77, 170, 87, 0.1); + color: #4daa57; + border: 1px solid rgba(77, 170, 87, 0.2); + } + + &--live { + background: rgba(76, 175, 80, 0.2); + color: #2e7d32; + border: 1px solid rgba(76, 175, 80, 0.4); + } + + &--passed { + background: rgba(108, 117, 125, 0.1); + color: #6c757d; + border: 1px solid rgba(108, 117, 125, 0.2); + } + } + + &__collab { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + min-height: 22px; + max-height: 8rem; + opacity: 1; + overflow: hidden; + transition: + max-height 0.44s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.28s ease, + min-height 0.3s ease; + } + + &__collab-label { + font-size: 0.85rem; + color: var(--light-text); + white-space: nowrap; + } + + &__collab-list { + margin: 0; + padding: 0; + list-style: none; + display: inline-flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + } + + &__collab-item { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0; + line-height: 1.2; + } + + &__collab-avatar { + width: 18px; + height: 18px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + border: 1px solid var(--lighterborder); + } + + &__collab-name { + font-size: 0.82rem; + color: var(--text); + } + + &__collab-sep { + font-size: 0.82rem; + color: var(--light-text); + margin: 0 0.15rem 0 0.05rem; + } + + &__stats { + display: flex; + gap: 1rem; + flex-wrap: wrap; + transition: gap 0.35s cubic-bezier(0.22, 1, 0.36, 1); + } + + &__stat { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1.25rem; + background: var(--lightbackground); + border-radius: 10px; + border: 1px solid var(--lighterborder); + min-width: 140px; + transition: all 0.2s ease, gap 0.32s cubic-bezier(0.22, 1, 0.36, 1), padding 0.32s cubic-bezier(0.22, 1, 0.36, 1), min-width 0.32s cubic-bezier(0.22, 1, 0.36, 1); + + &:hover { + border-color: rgba(77, 170, 87, 0.3); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + } + } + + &__stat-icon { + font-size: 1.75rem; + color: #4daa57; + flex-shrink: 0; + transition: font-size 0.32s cubic-bezier(0.22, 1, 0.36, 1); + } + + &__stat-text { + display: flex; + flex-direction: column; + gap: 0.125rem; + } + + &__stat-value { + font-size: 1.15rem; + font-weight: 700; + color: var(--text); + line-height: 1.2; + transition: font-size 0.32s cubic-bezier(0.22, 1, 0.36, 1); + } + + &__stat-label { + font-size: 0.8rem; + color: var(--light-text); + font-weight: 500; + transition: font-size 0.32s cubic-bezier(0.22, 1, 0.36, 1); + } + + &__details { + display: flex; + gap: 2rem; + flex-wrap: wrap; + max-height: 32rem; + opacity: 1; + overflow: hidden; + transition: + max-height 0.46s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.3s ease, + gap 0.35s cubic-bezier(0.22, 1, 0.36, 1); + } + + &__detail { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text); + font-size: 0.95rem; + + iconify-icon { + font-size: 1.2rem; + color: #4daa57; + } + } + + &--condensed { + padding-top: 0.875rem; + padding-bottom: 0.875rem; + // box-shadow: 0 8px 28px rgba(0, 0, 0, 0.07); + + .event-dashboard-focused-header__background { + opacity: 0.05; + } + + .event-dashboard-focused-header__top { + margin-bottom: 0.5rem; + } + + .event-dashboard-focused-header__close { + font-size: 1.2rem; + } + + .event-dashboard-focused-header__actions { + gap: 0.45rem; + } + + .event-dashboard-focused-header__btn { + padding: 0.375rem 0.72rem; + font-size: 0.82rem; + + iconify-icon { + font-size: 1.05rem; + } + } + + .event-dashboard-focused-header__btn--icon-only { + padding: 0.35rem; + } + + .event-dashboard-focused-header__btn--publish, + .event-dashboard-focused-header__btn--announcement, + .event-dashboard-focused-header__btn--post-mortem { + padding: 0.375rem 0.72rem; + } + + .event-dashboard-focused-header__main { + align-items: center; + margin-bottom: 0; + gap: 1.15rem; + } + + .event-dashboard-focused-header__title-section { + gap: 0.3rem; + min-width: 0; + flex: 1 1 220px; + } + + .event-dashboard-focused-header__title-row { + align-items: center; + } + + .event-dashboard-focused-header__title-thumb { + width: 2.45rem; + height: 2.45rem; + border-radius: 7px; + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.05); + } + + .event-dashboard-focused-header__title-heading h1 { + font-size: 1.35rem; + line-height: 1.2; + } + + .event-dashboard-focused-header__status { + padding: 0.25rem 0.65rem; + font-size: 0.74rem; + } + + .event-dashboard-focused-header__collab { + max-height: 0; + min-height: 0; + opacity: 0; + margin-bottom: 0; + } + + .event-dashboard-focused-header__stats { + gap: 0.55rem; + } + + .event-dashboard-focused-header__stat { + gap: 0.45rem; + padding: 0.45rem 0.72rem; + min-width: 96px; + } + + .event-dashboard-focused-header__stat-icon { + font-size: 1.2rem; + } + + .event-dashboard-focused-header__stat-value { + font-size: 0.92rem; + } + + .event-dashboard-focused-header__stat-label { + font-size: 0.68rem; + } + + .event-dashboard-focused-header__details { + max-height: 0; + opacity: 0; + gap: 0; + } + } +} + +@media (prefers-reduced-motion: reduce) { + .event-dashboard-focused-header { + transition-duration: 0.01ms !important; + + &__top, + &__main, + &__title-thumb, + &__title-heading h1, + &__collab, + &__details, + &__stats, + &__stat, + &__stat-icon, + &__stat-value, + &__stat-label, + &__status, + &__close, + &__btn, + &__actions, + &__background { + transition-duration: 0.01ms !important; + } + } +} + +@media (max-width: 768px) { + .event-dashboard-focused-header { + padding-top: 1.5rem; + padding-bottom: 1.5rem; + + &__content { + padding-left: max(5vw, 12px); + padding-right: max(5vw, 12px); + } + + &__top { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + &__actions { + width: 100%; + justify-content: flex-start; + } + + &__main { + flex-direction: column; + } + + &__title-heading h1 { + font-size: 2rem; + } + + &--condensed { + padding-top: 0.75rem; + padding-bottom: 0.75rem; + + .event-dashboard-focused-header__title-heading h1 { + font-size: 1.15rem; + line-height: 1.2; + } + } + + &__stats { + width: 100%; + + .event-dashboard-focused-header__stat { + flex: 1; + min-width: auto; + } + } + + &__details { + flex-direction: column; + gap: 1rem; + } + + &__collab-list { + gap: 0.35rem; + } + + &__collab-item { + max-width: 100%; + } + } +} + +@keyframes eventDashboardFocusedHeaderSpin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortem.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortem.jsx new file mode 100644 index 00000000..8d2d1eb3 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortem.jsx @@ -0,0 +1,390 @@ +import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react'; +import { Icon } from '@iconify-icon/react'; +import { useFetch } from '../../../../../hooks/useFetch'; +import TabbedContainer from '../../../../../components/TabbedContainer/TabbedContainer'; +import FeedbackSlide from '../EventPostMortem/slides/FeedbackSlide'; +import RegistrationsTab from './RegistrationsTab/RegistrationsTab'; +import EventDashboardFocusedPostMortemOutcomes from './EventDashboardFocusedPostMortemOutcomes'; +import { useGradient } from '../../../../../hooks/useGradient'; +import './EventDashboardFocusedPostMortem.scss'; + +function formatNumber(value) { + return new Intl.NumberFormat('en-US').format(Number(value) || 0); +} + +function formatDateRangeLabel(startAt, endAt) { + if (!startAt) return 'Date TBD'; + const start = new Date(startAt); + const end = endAt ? new Date(endAt) : null; + const startLabel = start.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + if (!end) return startLabel; + const endLabel = end.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + if (startLabel === endLabel) return startLabel; + return `${startLabel} - ${endLabel}`; +} + +function EventDashboardFocusedPostMortem({ + dashboardData, + fallbackEvent, + orgId, + onClose, + onRefresh, + isDashboardLoading = false, + dashboardLoadError = false +}) { + const { AtlasMain } = useGradient(); + const [headerCondensed, setHeaderCondensed] = useState(false); + const postMortemRef = useRef(null); + const heroRef = useRef(null); + const [tabsStickyTop, setTabsStickyTop] = useState(0); + + const postMortemSummary = useMemo(() => { + const eventData = dashboardData?.event || fallbackEvent; + const stats = dashboardData?.stats || {}; + const expectedAttendance = Number(eventData?.expectedAttendance) || 0; + + return { + eventData, + stats, + expectedAttendance, + uniqueViewers: stats?.views?.unique ?? stats?.analytics?.uniqueViews ?? 0, + formOpens: stats?.registrations?.formOpens ?? stats?.analytics?.formOpens ?? 0, + referrerSources: stats?.traffic?.referrerSources || stats?.analytics?.referrerSources, + referrerRegistrations: stats?.traffic?.referrerRegistrations || stats?.analytics?.referrerRegistrations, + qrReferrerSources: stats?.traffic?.qrReferrerSources || stats?.analytics?.qrReferrerSources, + rsvpGrowth: stats?.rsvpGrowth || stats?.analytics?.rsvpGrowth + }; + }, [dashboardData, fallbackEvent]); + const eventId = postMortemSummary.eventData?._id; + const timezone = typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : undefined; + const analyticsUrl = eventId ? `/event-analytics/event/${eventId}?timeRange=90d` : null; + const rsvpGrowthUrl = eventId && orgId && timezone + ? `/org-event-management/${orgId}/events/${eventId}/rsvp-growth?timezone=${encodeURIComponent(timezone)}` + : null; + const registrationResponsesUrl = eventId && orgId + ? `/org-event-management/${orgId}/events/${eventId}/registration-responses` + : null; + + const { data: analyticsData } = useFetch(analyticsUrl); + const { data: rsvpGrowthData } = useFetch(rsvpGrowthUrl); + + const analytics = analyticsData?.success ? analyticsData.data : null; + const rsvpGrowth = rsvpGrowthData?.success ? rsvpGrowthData.data : null; + const platform = analytics?.platform || {}; + + const actualRegistrations = postMortemSummary.stats?.registrationCount + ?? platform?.registrations + ?? platform?.uniqueRegistrations + ?? analytics?.registrations + ?? analytics?.uniqueRegistrations + ?? 0; + + const actualCheckIns = postMortemSummary.stats?.checkIn?.totalCheckedIn + ?? platform?.checkins + ?? platform?.uniqueCheckins + ?? 0; + + const uniqueViewsTotal = (analytics?.uniqueViews || 0) + (analytics?.uniqueAnonymousViews || 0); + const uniqueViewersForConversion = (platform?.uniqueEventViews || 0) > 0 ? platform.uniqueEventViews : uniqueViewsTotal; + const registrationsForConversion = (platform?.uniqueRegistrations || 0) > 0 ? platform.uniqueRegistrations : actualRegistrations; + const hasCheckInTracking = postMortemSummary.stats?.checkIn?.totalCheckedIn != null + || platform?.checkins != null + || platform?.uniqueCheckins != null; + const canonicalMetrics = useMemo(() => { + const registrations = Number(actualRegistrations) || 0; + const checkIns = hasCheckInTracking ? (Number(actualCheckIns) || 0) : 0; + const showRate = hasCheckInTracking && registrations > 0 + ? (checkIns / registrations) * 100 + : null; + const noShows = hasCheckInTracking + ? Math.max(registrations - checkIns, 0) + : null; + const expectedAttendance = Number(postMortemSummary.expectedAttendance) || 0; + const expectedVariance = expectedAttendance > 0 + ? (((registrations - expectedAttendance) / expectedAttendance) * 100) + : null; + const conversionRate = uniqueViewersForConversion > 0 + ? (registrationsForConversion / uniqueViewersForConversion) * 100 + : null; + + return { + registrations, + checkIns, + showRate, + noShows, + expectedAttendance, + expectedVariance, + conversionRate, + hasCheckInTracking + }; + }, [actualCheckIns, actualRegistrations, hasCheckInTracking, postMortemSummary.expectedAttendance, registrationsForConversion, uniqueViewersForConversion]); + + const funnelData = useMemo(() => { + const steps = [{ label: 'Unique viewers', value: uniqueViewersForConversion }]; + if (postMortemSummary.eventData?.registrationFormId) { + steps.push({ label: 'Opened form', value: platform?.uniqueFormOpens || 0 }); + } + steps.push({ label: 'Registrations', value: canonicalMetrics.registrations }); + if (canonicalMetrics.hasCheckInTracking) { + steps.push({ label: 'Check-ins', value: canonicalMetrics.checkIns }); + } + return steps; + }, [canonicalMetrics.checkIns, canonicalMetrics.hasCheckInTracking, canonicalMetrics.registrations, platform?.uniqueFormOpens, postMortemSummary.eventData?.registrationFormId, uniqueViewersForConversion]); + + const outcomesLoading = analyticsData === undefined || rsvpGrowthData === undefined; + const outcomesHasError = analyticsData?.success === false || rsvpGrowthData?.success === false; + + const postMortemTabs = useMemo(() => { + const outcomesContent = ( +
+ {outcomesLoading ? ( +
+ +

Loading post-mortem analytics...

+
+ ) : outcomesHasError ? ( +
+ +

Unable to load full post-mortem analytics data.

+
+ ) : ( +
+ +
+ )} +
+ ); + + const peopleContent = ( +
+
+ {postMortemSummary.eventData?.feedbackFormId ? ( + + ) : ( +

+ No attendee feedback form was set up for this event. +

+ )} +
+
+ +
+
+ ); + + const detailsContent = ( +
+
+
+

Event Details

+

Name{postMortemSummary.eventData?.name || 'Event'}

+

Status{postMortemSummary.eventData?.status || 'n/a'}

+

Location{postMortemSummary.eventData?.location || 'TBD'}

+
+
+

Planning Targets

+

Expected Attendance{canonicalMetrics.expectedAttendance ? formatNumber(canonicalMetrics.expectedAttendance) : 'n/a'}

+

Variance{canonicalMetrics.expectedVariance != null ? `${canonicalMetrics.expectedVariance >= 0 ? '+' : ''}${canonicalMetrics.expectedVariance.toFixed(0)}%` : 'n/a'}

+

Attendance Tracking{canonicalMetrics.hasCheckInTracking ? 'Captured' : 'Not captured'}

+
+
+

Operational Snapshot

+

Operational Status{postMortemSummary.stats?.operationalStatus || 'n/a'}

+

Workflow PhasePost Mortem

+

Host{postMortemSummary.eventData?.hostingId?.org_name || 'n/a'}

+
+
+
+ ); + + return [ + { id: 'outcomes', label: 'Outcomes', icon: 'mdi:chart-line', content: outcomesContent }, + { id: 'people', label: 'People', icon: 'mdi:account-group-outline', content: peopleContent }, + { id: 'details', label: 'Details', icon: 'mdi:file-document-outline', content: detailsContent } + ]; + }, [canonicalMetrics, eventId, funnelData, onRefresh, orgId, outcomesHasError, outcomesLoading, platform, postMortemSummary, registrationResponsesUrl, registrationsForConversion, rsvpGrowth, uniqueViewersForConversion]); + + const handlePostMortemScroll = useCallback((e) => { + const condensed = e.currentTarget.scrollTop > 56; + setHeaderCondensed((prev) => (prev === condensed ? prev : condensed)); + }, []); + + const handlePostMortemTabChange = useCallback(() => { + requestAnimationFrame(() => { + const root = postMortemRef.current; + if (!root) return; + root.scrollTo({ + top: 0, + behavior: 'smooth' + }); + }); + }, []); + + const effectiveTabsStickyTop = Math.max(0, tabsStickyTop - 8); + + useEffect(() => { + const heroElement = heroRef.current; + if (!heroElement) return undefined; + + const updateStickyTop = () => { + const next = Math.max(0, Math.ceil(heroElement.getBoundingClientRect().height)); + setTabsStickyTop((prev) => (prev === next ? prev : next)); + }; + + updateStickyTop(); + + const observer = new ResizeObserver(updateStickyTop); + observer.observe(heroElement); + window.addEventListener('resize', updateStickyTop); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', updateStickyTop); + }; + }, [headerCondensed]); + + return ( +
+
+ +
+
+
+
+
+
+ +
+
+

Retrospective

+

{(postMortemSummary.eventData?.name || 'Event').toUpperCase()}

+

+ {canonicalMetrics.hasCheckInTracking + ? `${formatNumber(canonicalMetrics.checkIns)} attendees showed up.` + : `${formatNumber(canonicalMetrics.registrations)} people registered interest.`} +

+
+
+
+

+ {postMortemSummary.eventData?.name || 'This event'} ran with{' '} + {formatNumber(canonicalMetrics.registrations)} registrations + {canonicalMetrics.hasCheckInTracking && canonicalMetrics.showRate != null ? ( + <> and a {canonicalMetrics.showRate.toFixed(1)}% show rate. + ) : ( + <>. Attendance was not tracked for this event. + )} +

+
+ + + {formatDateRangeLabel(postMortemSummary.eventData?.start_time, postMortemSummary.eventData?.end_time)} + + + + {postMortemSummary.eventData?.location || 'Location TBD'} + + {canonicalMetrics.expectedVariance != null && ( + + + {canonicalMetrics.expectedVariance >= 0 ? '+' : ''} + {canonicalMetrics.expectedVariance.toFixed(0)}% vs expected + + )} + {!canonicalMetrics.hasCheckInTracking && ( + + + Attendance not captured + + )} +
+
+
+ +
+ +
+ {isDashboardLoading && ( +
+ +

Loading post-mortem workspace...

+
+ )} + {dashboardLoadError && ( +
+ +

Some dashboard data failed to load. Showing available post-mortem data.

+
+ )} + +
+
+
+ ); +} + +export default EventDashboardFocusedPostMortem; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortem.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortem.scss new file mode 100644 index 00000000..728ed65f --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortem.scss @@ -0,0 +1,761 @@ +.event-dashboard-focused__post-mortem { + position: relative; + overflow-y: auto; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + background: transparent; + animation: eventDashFocusedPostMortemEnter 460ms cubic-bezier(0.16, 0.84, 0.24, 1) both; +} + +.event-dashboard-focused__post-mortem-background { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.1; + z-index: 0; + pointer-events: none; + + img { + width: 100%; + object-fit: cover; + } +} + +.event-dashboard-focused__post-mortem-content { + --pm-page-gutter: 6vw; + + @media (min-width: 1401px) { + --pm-page-gutter: 7.5vw; + } + + position: relative; + z-index: 1; + flex: 0 0 auto; + min-height: 0; + padding: 18px var(--pm-page-gutter) 28px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 0; + overflow: visible; +} + +.event-dashboard-focused__post-mortem-hero { + display: flex; + gap: 20px; + align-items: stretch; + position: sticky; + flex-wrap:wrap; + top: 0; + z-index: 12; + padding-bottom: 0; + margin-left: calc(var(--pm-page-gutter) * -1); + margin-right: calc(var(--pm-page-gutter) * -1); + padding-left: var(--pm-page-gutter); + padding-right: var(--pm-page-gutter); + background: transparent; + animation: eventDashFocusedPostMortemSectionEnter 520ms cubic-bezier(0.16, 0.84, 0.24, 1) 60ms both; +} + +.event-dashboard-focused__post-mortem-hero-main { + flex: 1; + padding: 16px 20px 18px; + display: flex; + flex-direction: column; + gap: 12px; + border-radius: 12px; +} + +.event-dashboard-focused__post-mortem-header { + display: block; +} + +.event-dashboard-focused__post-mortem-header-top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.15rem; +} + +.event-dashboard-focused__post-mortem-close { + background: transparent; + border: none; + color: var(--text); + padding: 0.5rem; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + iconify-icon { + font-size: 1.45rem; + } + + &:hover { + background: var(--lightbackground); + color: #dc3545; + } +} + +.event-dashboard-focused__post-mortem-heading { + flex: 1; + + p { + margin: 0 0 10px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--light-text); + } + + h2 { + margin: 0 0 20px; + font-size: 1rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: #2f7d46; + } + + h1 { + margin: 0; + font-size: clamp(1.55rem, 3.1vw, 2.55rem); + line-height: 0.99; + color: var(--text); + letter-spacing: -0.02em; + max-height: 200px; + overflow: hidden; + transform: translateY(0); + + em { + font-style: italic; + font-weight: 500; + color: rgba(17, 17, 17, 0.76); + } + } +} + +.event-dashboard-focused__post-mortem-intro { + max-height: 240px; + overflow: hidden; + transform: translateY(0); + + p { + margin: 0 0 10px; + color: var(--text); + line-height: 1.45; + font-size: 1.05rem; + max-width: 65ch; + } +} + +.event-dashboard-focused__post-mortem-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + + span { + margin: 0; + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--lighterborder); + background: var(--background); + border-radius: 999px; + padding: 5px 10px; + color: var(--light-text); + font-size: 0.82rem; + } +} + +.event-dashboard-focused__post-mortem-poster { + border-radius: 20px; + overflow: hidden; + box-shadow: + 0 30px 40px rgba(0, 0, 0, 0.22), + 0 6px 18px rgba(0, 0, 0, 0.12); + background: white; + min-height: 300px; + width: 250px; + flex: 0 0 250px; + display: flex; + position: relative; + transform: rotate(1.5deg); + transform-origin: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } +} + +.event-dashboard-focused__post-mortem-poster-caption { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 12px 12px 11px; + background: linear-gradient(180deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.68) 100%); + color: #fff; + + p { + margin: 0 0 4px; + font-size: 0.9rem; + font-weight: 700; + line-height: 1.2; + } + + span { + margin: 0; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.86); + } +} + +.event-dashboard-focused__post-mortem-poster-fallback { + width: 100%; + padding: 18px; + background: linear-gradient(140deg, #1a1a1a, #373737); + color: #f5f5f5; + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 8px; + + p { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + } + + span { + color: rgba(245, 245, 245, 0.82); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + } +} + +.event-dashboard-focused__post-mortem-kpi-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-radius: 16px; + overflow: hidden; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + + article { + padding: 12px 14px; + border-right: 1px solid var(--lighterborder); + display: flex; + flex-direction: column; + gap: 6px; + + &:last-child { + border-right: 0; + } + } + + span { + color: var(--light-text); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + } + + strong { + color: var(--text); + font-size: 1.8rem; + line-height: 1; + } + + &--below-hero { + display: none; + } +} + +.event-dashboard-focused__post-mortem-hero-main > .event-dashboard-focused__post-mortem-kpi-strip { + margin-top: 4px; +} + +@media (max-width: 1000px) { + .event-dashboard-focused__post-mortem-kpi-strip--hero-main { + display: none; + } + + .event-dashboard-focused__post-mortem-kpi-strip--below-hero { + display: grid; + } +} + +.event-dashboard-focused__post-mortem-main { + flex: 0 0 auto; + min-height: 0; + margin-top: -1px; + border-radius: 16px; + border-top-left-radius: 0; + border-top-right-radius: 0; + overflow: visible; + position: relative; + z-index: 1; + animation: eventDashFocusedPostMortemSectionEnter 560ms cubic-bezier(0.16, 0.84, 0.24, 1) 120ms both; +} + +.event-dashboard-focused__post-mortem-inline-loading, +.event-dashboard-focused__post-mortem-inline-error { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--lighterborder); + background: rgba(255, 255, 255, 0.8); + color: var(--light-text); + + p { + margin: 0; + font-size: 0.9rem; + } +} + +.event-dashboard-focused__post-mortem-inline-error { + color: #8f2d2d; + background: rgba(255, 243, 243, 0.9); +} + +@media (prefers-reduced-motion: reduce) { + .event-dashboard-focused__post-mortem, + .event-dashboard-focused__post-mortem-hero, + .event-dashboard-focused__post-mortem-main { + animation: none; + } +} + +.event-dashboard-focused__post-mortem-tabs { + height: auto; +} + +.event-dashboard-focused__post-mortem-tabs .tabbed-container__body { + padding: 0; +} + +.event-dashboard-focused__post-mortem-tabs .tabbed-container, +.event-dashboard-focused__post-mortem-tabs .tabbed-container__main, +.event-dashboard-focused__post-mortem-tabs .tabbed-container__content { + height: auto; + min-height: 0; +} + +.event-dashboard-focused__post-mortem-tabs .tabbed-container__content { + overflow: visible; +} + +.event-dashboard-focused__post-mortem-tabs .tabbed-container__tabs-wrapper { + margin-top: 0; + margin-bottom: -1px; + border-bottom: 1px solid var(--lighterborder); + background: rgba(255, 255, 255, 0.88); + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); +} + +.event-dashboard-focused__pm-tab { + padding: 20px; + display: flex; + flex-direction: column; + gap: 18px; + min-height: 420px; +} + +.event-dashboard-focused__pm-outcomes-full { + min-height: 320px; +} + +.event-dashboard-focused__pm-loading, +.event-dashboard-focused__pm-error { + min-height: 220px; + border: 1px solid var(--lighterborder); + border-radius: 12px; + background: var(--lightbackground); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--light-text); + + .spinner { + animation: eventDashFocusedPostMortemSpin 1s linear infinite; + } +} + +.event-dashboard-focused__pm-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.event-dashboard-focused__pm-kpi-card { + border: 1px solid var(--lighterborder); + border-radius: 12px; + background: var(--lightbackground); + padding: 12px; + + p { + margin: 0 0 7px; + color: var(--light-text); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + } + + strong { + color: var(--text); + font-size: 1.4rem; + line-height: 1.1; + } +} + +.event-dashboard-focused__pm-insights h3 { + margin: 0 0 10px; + font-size: 1rem; +} + +.event-dashboard-focused__pm-insight-list { + display: grid; + gap: 8px; +} + +.event-dashboard-focused__pm-insight-list--condensed { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.event-dashboard-focused__pm-insight-item { + border: 1px solid var(--lighterborder); + border-radius: 10px; + padding: 10px; + background: var(--lightbackground); + display: flex; + align-items: flex-start; + gap: 10px; + + p { + margin: 0; + color: var(--text); + font-weight: 600; + } + + span { + color: var(--light-text); + font-size: 0.88rem; + } +} + +.event-dashboard-focused__pm-feedback { + border: 1px solid var(--lighterborder); + border-radius: 12px; + background: var(--lightbackground); + padding: 12px; +} + +.event-dashboard-focused__pm-registrants { + border: 1px solid var(--lighterborder); + border-radius: 12px; + background: var(--lightbackground); + padding: 12px; +} + +.event-dashboard-focused__pm-registrants-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; + + h3 { + margin: 0; + font-size: 1rem; + } + + span { + color: var(--light-text); + font-size: 0.86rem; + } +} + +.event-dashboard-focused__pm-registrants-table-wrap { + overflow-x: auto; +} + +.event-dashboard-focused__pm-registrants-table { + width: 100%; + border-collapse: collapse; + min-width: 560px; + + th, + td { + padding: 9px 10px; + text-align: left; + border-bottom: 1px solid var(--lighterborder); + font-size: 0.88rem; + color: var(--text); + } + + th { + color: var(--light-text); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.7rem; + font-weight: 700; + } + + tbody tr:last-child td { + border-bottom: 0; + } +} + +.event-dashboard-focused__pm-registrants-empty { + min-height: 96px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--light-text); +} + +.event-dashboard-focused__pm-empty { + margin: 0; + color: var(--light-text); +} + +.event-dashboard-focused__pm-details-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.event-dashboard-focused__pm-detail-card { + border: 1px solid var(--lighterborder); + border-radius: 12px; + background: var(--lightbackground); + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + + h3 { + margin: 0 0 4px; + font-size: 0.95rem; + } + + p { + margin: 0; + display: flex; + justify-content: space-between; + gap: 10px; + color: var(--light-text); + font-size: 0.88rem; + } + + strong { + color: var(--text); + text-transform: capitalize; + text-align: right; + } +} + +@media (max-width: 768px) { + .event-dashboard-focused__post-mortem-content { + --pm-page-gutter: 12px; + padding: 12px; + } + + .event-dashboard-focused__post-mortem-hero { + flex-direction: column; + } + + .event-dashboard-focused__post-mortem-poster { + width: 100%; + flex-basis: auto; + min-height: 220px; + max-height: 320px; + transform: none; + } + + .event-dashboard-focused__pm-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .event-dashboard-focused__post-mortem-kpi-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + + article:nth-child(2n) { + border-right: 0; + } + } + + .event-dashboard-focused__pm-details-grid { + grid-template-columns: 1fr; + } + + .event-dashboard-focused__pm-insight-list--condensed { + grid-template-columns: 1fr; + } +} + +.event-dashboard-focused__post-mortem { + transition: padding-top 0.58s cubic-bezier(0.2, 0.82, 0.22, 1); +} + +.event-dashboard-focused__post-mortem-hero-main, +.event-dashboard-focused__post-mortem-poster, +.event-dashboard-focused__post-mortem-heading h1, +.event-dashboard-focused__post-mortem-heading h2, +.event-dashboard-focused__post-mortem-intro, +.event-dashboard-focused__post-mortem-kpi-strip { + transition: + transform 0.58s cubic-bezier(0.2, 0.82, 0.22, 1), + opacity 0.54s ease, + margin 0.58s cubic-bezier(0.2, 0.82, 0.22, 1), + font-size 0.58s cubic-bezier(0.2, 0.82, 0.22, 1), + padding 0.58s cubic-bezier(0.2, 0.82, 0.22, 1), + max-height 0.54s ease, + width 0.58s cubic-bezier(0.2, 0.82, 0.22, 1), + flex-basis 0.58s cubic-bezier(0.2, 0.82, 0.22, 1), + border-width 0.42s ease, + box-shadow 0.42s ease; +} + +.event-dashboard-focused__post-mortem-tabs .tabbed-container__tabs-wrapper--sticky { + top: var(--pm-tabs-sticky-top, 0px); + z-index: 11; + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); + background:transparent; +} + +.event-dashboard-focused__post-mortem--condensed { + .event-dashboard-focused__post-mortem-tabs .tabbed-container__tabs-wrapper--sticky { + margin-left: calc(var(--pm-page-gutter, 0px) * -1); + margin-right: calc(var(--pm-page-gutter, 0px) * -1); + padding-left: var(--pm-page-gutter, 0px); + padding-right: var(--pm-page-gutter, 0px); + background-color: var(--background); + } + + .event-dashboard-focused__post-mortem-hero { + gap: 10px; + padding-bottom: 0; + background: rgba(255, 255, 255, 0.95); + } + + .event-dashboard-focused__post-mortem-hero-main { + padding-top: 8px; + padding-bottom: 8px; + gap: 6px; + } + + .event-dashboard-focused__post-mortem-header-top { + margin-bottom: 0.15rem; + } + + .event-dashboard-focused__post-mortem-heading p { + margin-bottom: 2px; + font-size: 0.62rem; + } + + .event-dashboard-focused__post-mortem-heading h2 { + margin-bottom: 0; + font-size: 1rem; + } + + .event-dashboard-focused__post-mortem-heading h1 { + opacity: 0; + max-height: 0; + overflow: hidden; + margin: 0; + font-size: 0; + line-height: 0; + transform: translateY(-8px); + } + + .event-dashboard-focused__post-mortem-intro { + opacity: 0; + max-height: 0; + overflow: hidden; + margin: 0; + padding: 0; + transform: translateY(-8px); + } + + .event-dashboard-focused__post-mortem-kpi-strip { + transform: translateY(-2px); + grid-template-columns: repeat(4, minmax(0, 1fr)); + + article { + padding: 6px 8px; + gap: 2px; + } + + span { + font-size: 0.6rem; + } + + strong { + font-size: 0.92rem; + } + } + + .event-dashboard-focused__post-mortem-poster { + opacity: 0; + width: 0; + flex-basis: 0; + min-height: 0; + max-height: 0; + margin: 0; + border-width: 0; + box-shadow: none; + transform: translateX(12px) scale(0.96); + pointer-events: none; + } +} + +@keyframes eventDashFocusedPostMortemSpin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes eventDashFocusedPostMortemEnter { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes eventDashFocusedPostMortemSectionEnter { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortemOutcomes.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortemOutcomes.jsx new file mode 100644 index 00000000..af372921 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortemOutcomes.jsx @@ -0,0 +1,326 @@ +import React, { useMemo, useRef, useState, useEffect, useCallback } from 'react'; +import { Icon } from '@iconify-icon/react'; +import { usePostMortemInsights } from '../EventPostMortem/usePostMortemInsights'; +import RSVPGrowthChart from './RSVPGrowthChart'; +import FunnelChart from './FunnelChart'; +import './EventDashboardFocusedPostMortemOutcomes.scss'; + +function EventDashboardFocusedPostMortemOutcomes({ + event, + metrics, + uniqueViewersForConversion, + registrationsForConversion, + rsvpGrowth, + funnelData, + platform, + formatNumber +}) { + const hasCheckInTracking = metrics?.hasCheckInTracking; + const sectionRefs = useRef({}); + const [visibleSections, setVisibleSections] = useState({}); + const registerSection = useCallback((id) => (node) => { + if (node) { + sectionRefs.current[id] = node; + } + }, []); + + useEffect(() => { + const entries = Object.entries(sectionRefs.current); + if (entries.length === 0) return undefined; + + const observer = new IntersectionObserver( + (observerEntries) => { + observerEntries.forEach((entry) => { + if (!entry.isIntersecting) return; + const id = entry.target.getAttribute('data-story-section'); + if (!id) return; + setVisibleSections((prev) => (prev[id] ? prev : { ...prev, [id]: true })); + observer.unobserve(entry.target); + }); + }, + { threshold: 0.2, rootMargin: '0px 0px -10% 0px' } + ); + + entries.forEach(([, node]) => observer.observe(node)); + + return () => { + observer.disconnect(); + }; + }, []); + const insights = usePostMortemInsights({ + registrations: metrics?.registrations || 0, + checkIns: metrics?.checkIns || 0, + uniqueViewers: uniqueViewersForConversion, + rsvpGrowth, + referrerSources: platform?.referrerSources, + referrerRegistrations: platform?.referrerRegistrations, + qrReferrerSources: platform?.qrReferrerSources, + formOpens: platform?.uniqueFormOpens, + hasForm: !!event?.registrationFormId, + hasCheckInTracking, + formatNumber, + expectedAttendance: event?.expectedAttendance ?? 0 + }); + + const conversionRate = uniqueViewersForConversion > 0 ? (registrationsForConversion / uniqueViewersForConversion) * 100 : null; + const formCompletionRate = event?.registrationFormId && platform?.uniqueFormOpens > 0 + ? ((metrics?.registrations || 0) / platform.uniqueFormOpens) * 100 + : null; + + const sortedTraffic = useMemo(() => { + const sources = [ + { key: 'org_page', label: 'Org Page' }, + { key: 'explore', label: 'Explore' }, + { key: 'direct', label: 'Direct' }, + { key: 'email', label: 'Email' } + ]; + + const base = sources.map((source) => { + const views = platform?.referrerSources?.[source.key] ?? 0; + const registrations = platform?.referrerRegistrations?.[source.key] ?? 0; + return { + id: source.key, + label: source.label, + views, + registrations, + conversion: views > 0 ? (registrations / views) * 100 : 0 + }; + }).filter((source) => source.views > 0 || source.registrations > 0); + + const qr = (platform?.qrReferrerSources || []).map((source) => ({ + id: `qr-${source.qr_id || source.name}`, + label: source.name || 'QR Code', + views: source.count ?? 0, + registrations: source.registrations ?? 0, + conversion: (source.count ?? 0) > 0 ? ((source.registrations ?? 0) / source.count) * 100 : 0 + })).filter((source) => source.views > 0 || source.registrations > 0); + + return [...base, ...qr].sort((a, b) => b.views - a.views); + }, [platform]); + + const topTrafficSource = sortedTraffic[0] || null; + const learningInsights = useMemo(() => { + const list = []; + const add = (item) => { + if (item) list.push(item); + }; + + add(insights.byCategory.expectedVsActual); + add(insights.byCategory.conversion); + add(insights.byCategory.formCompletion); + add(insights.byCategory.funnelBottleneck); + if (hasCheckInTracking) { + add(insights.byCategory.checkIn); + } + return list; + }, [hasCheckInTracking, insights.byCategory]); + + const actionInsights = useMemo(() => { + const strategic = Array.isArray(insights.byCategory.strategic) ? insights.byCategory.strategic : []; + const list = []; + if (insights.byCategory.trafficInvestment) { + list.push(insights.byCategory.trafficInvestment); + } + return [...list, ...strategic]; + }, [insights.byCategory]); + + return ( +
+
+

Post-Mortem

+

+ {hasCheckInTracking + ? `${formatNumber(metrics?.checkIns || 0)} attendees showed up.` + : `${formatNumber(metrics?.registrations || 0)} registrations captured.`} +

+

+ {formatNumber(metrics?.registrations || 0)} registrations + {' · '} + {conversionRate != null ? `${conversionRate.toFixed(1)}%` : 'n/a'} viewer conversion + {hasCheckInTracking && metrics?.showRate != null + ? ` · ${metrics.showRate.toFixed(1)}% show rate` + : ' · attendance not tracked'} + . +

+
+ +
+
+
+

Insights

+ {learningInsights.length > 0 ? ( +
+ {learningInsights.map((insight, index) => ( +
+ +
+

{insight.text}

+ {insight.sub ? {insight.sub} : null} +
+
+ ))} +
+ ) : ( +

Not enough data to generate insights.

+ )} +
+
+

Recommendations

+ {actionInsights.length > 0 ? ( +
+ {actionInsights.map((insight, index) => ( +
+ +
+

{insight.text}

+ {insight.sub ? {insight.sub} : null} +
+
+ ))} +
+ ) : ( +

No additional actions were generated from this dataset.

+ )} + {!hasCheckInTracking && ( +
+ +
+

Measurement gap

+ Check-ins were not used for this event. +
+
+ )} +
+
+
+ +
+
+
+

Snapshot

+
+
+ Registrations + {formatNumber(metrics?.registrations || 0)} +
+
+ Viewer Conversion + {conversionRate != null ? `${conversionRate.toFixed(1)}%` : 'n/a'} +
+ {hasCheckInTracking ? ( +
+ Check-Ins + {formatNumber(metrics?.checkIns || 0)} +
+ ) : ( +
+ Attendance Capture + Not tracked +
+ )} +
+
+
+ {hasCheckInTracking ? ( +
+ Check-In Rate + {metrics?.showRate != null ? `${metrics.showRate.toFixed(1)}%` : 'n/a'} +
+ ) : ( +
+ Attendance Status + Not captured +
+ )} + {formCompletionRate != null && ( +
+ Form Completion + {formCompletionRate.toFixed(1)}% +
+ )} + {metrics?.expectedVariance != null && ( +
+ Vs Expected Attendance + {metrics.expectedVariance >= 0 ? '+' : ''}{metrics.expectedVariance.toFixed(0)}% +
+ )} +
+
+
+ +
+
+
+
+

Registration Trends

+ {rsvpGrowth ? ( +
+ +
+ ) : ( +

No registration trend data available.

+ )} +
+
+

Audience Funnel

+
+ +
+
+
+
+
+ +
+
+ {topTrafficSource ? ( +
+

{topTrafficSource.label} led discovery.

+ + {formatNumber(topTrafficSource.views)} views, {formatNumber(topTrafficSource.registrations)} registrations, {topTrafficSource.conversion.toFixed(1)}% conversion + +
+ ) : null} + {sortedTraffic.length > 0 ? ( +
+ {sortedTraffic.map((source) => ( +
+ {source.label} + {formatNumber(source.views)} views + {formatNumber(source.registrations)} regs + {source.conversion.toFixed(1)}% +
+ ))} +
+ ) : ( +

No traffic source data available.

+ )} +
+
+
+ ); +} + +export default EventDashboardFocusedPostMortemOutcomes; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortemOutcomes.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortemOutcomes.scss new file mode 100644 index 00000000..33b2f299 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortemOutcomes.scss @@ -0,0 +1,381 @@ +.event-dashboard-focused-pm-outcomes { + display: flex; + flex-direction: column; + gap: 36px; + padding-bottom: 8px; +} + +.event-dashboard-focused-pm-outcomes__lead { + padding: 8px 4px 0; +} + +.event-dashboard-focused-pm-outcomes__eyebrow { + margin: 0 0 8px; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #2f7d46; + font-weight: 700; +} + +.event-dashboard-focused-pm-outcomes__headline { + margin: 0; + font-size: clamp(1.65rem, 2.6vw, 2.45rem); + line-height: 1.04; + letter-spacing: -0.02em; + color: var(--text); + font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Palatino, 'Times New Roman', serif; +} + +.event-dashboard-focused-pm-outcomes__lede { + margin: 12px 0 0; + max-width: 74ch; + font-size: 1rem; + line-height: 1.52; + color: var(--light-text); +} + +.event-dashboard-focused-pm-outcomes__story-section { + opacity: 0; + transform: translateY(24px); + transition: + opacity 620ms cubic-bezier(0.2, 0.8, 0.2, 1), + transform 620ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.event-dashboard-focused-pm-outcomes__story-section.is-visible { + opacity: 1; + transform: translateY(0); +} + +.event-dashboard-focused-pm-outcomes__story-heading { + margin-bottom: 14px; + + p { + margin: 0 0 6px; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: #2f7d46; + font-weight: 700; + } + + h3 { + margin: 0; + font-size: clamp(1.1rem, 1.8vw, 1.45rem); + letter-spacing: -0.01em; + color: var(--text); + } +} + +.event-dashboard-focused-pm-outcomes__section { + border: 0; + border-radius: 22px; + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.52)), + radial-gradient(circle at top right, rgba(47, 125, 70, 0.1), transparent 55%); + padding: 20px; + box-shadow: + 0 22px 40px rgba(17, 17, 17, 0.08), + inset 0 0 0 1px rgba(255, 255, 255, 0.65); +} + +.event-dashboard-focused-pm-outcomes__section-title { + margin: 0 0 12px; + font-size: 1.04rem; + color: var(--text); +} + +.event-dashboard-focused-pm-outcomes__section h4 { + margin: 0 0 10px; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--light-text); +} + +.event-dashboard-focused-pm-outcomes__section--split { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 16px; +} + +.event-dashboard-focused-pm-outcomes__feature-card { + border-radius: 16px; + background: rgba(255, 255, 255, 0.66); + padding: 16px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.68); + + h3 { + margin: 0 0 12px; + font-size: 0.98rem; + color: var(--text); + } +} + +.event-dashboard-focused-pm-outcomes__feature-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + + div { + border-radius: 14px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 6px; + background: rgba(255, 255, 255, 0.72); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.68); + } + + span { + font-size: 0.72rem; + color: var(--light-text); + text-transform: uppercase; + letter-spacing: 0.08em; + } + + strong { + font-size: 1.2rem; + color: var(--text); + line-height: 1.1; + } +} + +.event-dashboard-focused-pm-outcomes__metrics-column { + display: grid; + gap: 10px; +} + +.event-dashboard-focused-pm-outcomes__metric-pill { + border-radius: 14px; + background: rgba(255, 255, 255, 0.68); + padding: 12px; + display: flex; + flex-direction: column; + gap: 6px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.72); + + span { + font-size: 0.74rem; + color: var(--light-text); + text-transform: uppercase; + letter-spacing: 0.08em; + } + + strong { + font-size: 1.28rem; + color: var(--text); + line-height: 1.05; + } +} + +.event-dashboard-focused-pm-outcomes__metric-pill--neutral { + background: rgba(247, 251, 247, 0.86); +} + +.event-dashboard-focused-pm-outcomes__drivers-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 14px; +} + +.event-dashboard-focused-pm-outcomes__driver-card { + border-radius: 16px; + background: rgba(255, 255, 255, 0.72); + padding: 12px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.7); +} + +.event-dashboard-focused-pm-outcomes__chart-shell { + border-radius: 12px; + background: rgba(255, 255, 255, 0.84); + padding: 8px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.72); +} + +.event-dashboard-focused-pm-outcomes__funnel-shell { + border-radius: 12px; + background: rgba(255, 255, 255, 0.84); + padding: 10px; + min-height: 230px; + height: 230px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.72); +} + +.event-dashboard-focused-pm-outcomes__traffic-lead { + margin: 0 0 10px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.72); + padding: 12px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.7); + + p { + margin: 0 0 4px; + color: var(--text); + font-weight: 700; + } + + span { + color: var(--light-text); + font-size: 0.84rem; + } +} + +.event-dashboard-focused-pm-outcomes__traffic-table { + display: grid; + gap: 8px; +} + +.event-dashboard-focused-pm-outcomes__traffic-row { + display: grid; + grid-template-columns: 1.4fr 1fr 1fr auto; + gap: 8px; + align-items: center; + border-radius: 12px; + background: rgba(255, 255, 255, 0.66); + padding: 10px 12px; + color: var(--light-text); + font-size: 0.85rem; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.68); + + strong { + color: var(--text); + font-size: 0.88rem; + } +} + +.event-dashboard-focused-pm-outcomes__insights { + display: grid; + grid-template-columns: 1fr; + gap: 12px; + + article { + border-radius: 14px; + background: rgba(255, 255, 255, 0.7); + padding: 13px; + display: flex; + align-items: flex-start; + gap: 10px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.7); + + iconify-icon { + margin-top: 2px; + font-size: 1.05rem; + color: #2f7d46; + } + } + + p { + margin: 0; + color: var(--text); + font-weight: 600; + font-size: 0.92rem; + } + + span { + color: var(--light-text); + font-size: 0.84rem; + } +} + +.event-dashboard-focused-pm-outcomes__story-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 14px; +} + +.event-dashboard-focused-pm-outcomes__story-card { + border-radius: 18px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.62)); + padding: 16px; + box-shadow: + 0 12px 28px rgba(17, 17, 17, 0.06), + inset 0 0 0 1px rgba(255, 255, 255, 0.75); +} + +.event-dashboard-focused-pm-outcomes__adoption-nudge { + margin-top: 12px; + border-radius: 14px; + background: rgba(244, 250, 245, 0.84); + padding: 12px; + display: flex; + align-items: flex-start; + gap: 10px; + box-shadow: inset 0 0 0 1px rgba(47, 125, 70, 0.16); + + iconify-icon { + margin-top: 2px; + font-size: 1.1rem; + color: #2f7d46; + } + + p { + margin: 0 0 4px; + color: var(--text); + font-size: 0.9rem; + font-weight: 700; + } + + span { + color: var(--light-text); + font-size: 0.84rem; + line-height: 1.45; + } +} + +.event-dashboard-focused-pm-outcomes__empty { + margin: 0; + color: var(--light-text); +} + +@media (max-width: 1024px) { + .event-dashboard-focused-pm-outcomes__section--split { + grid-template-columns: 1fr; + } + + .event-dashboard-focused-pm-outcomes__story-grid { + grid-template-columns: 1fr; + } + + .event-dashboard-focused-pm-outcomes__drivers-grid { + grid-template-columns: 1fr; + } + + .event-dashboard-focused-pm-outcomes__feature-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 768px) { + .event-dashboard-focused-pm-outcomes__headline { + font-size: 1.65rem; + } + + .event-dashboard-focused-pm-outcomes__section { + padding: 14px; + } + + .event-dashboard-focused-pm-outcomes__feature-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .event-dashboard-focused-pm-outcomes__traffic-row { + grid-template-columns: 1fr; + gap: 4px; + } + + .event-dashboard-focused-pm-outcomes__funnel-shell { + min-height: 190px; + height: 190px; + } +} + +@media (prefers-reduced-motion: reduce) { + .event-dashboard-focused-pm-outcomes__story-section { + opacity: 1; + transform: none; + transition: none; + } +} diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventJobsManager/JobsManager.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventJobsManager/JobsManager.scss index 7b925974..b7f4935d 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventJobsManager/JobsManager.scss +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventJobsManager/JobsManager.scss @@ -1,5 +1,4 @@ .roles-manager { - padding: 1.5rem 0; &.loading { display: flex; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventOverview.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventOverview.jsx index a2efab23..fabf553e 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventOverview.jsx +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventOverview.jsx @@ -149,12 +149,6 @@ function EventOverview({ )} {event && (
-
-

- - Registration Chart -

-
diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventPlanningOverviewSnapshot.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventPlanningOverviewSnapshot.jsx new file mode 100644 index 00000000..c9bc62d0 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventPlanningOverviewSnapshot.jsx @@ -0,0 +1,356 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useFetch } from '../../../../../hooks/useFetch'; +import apiRequest from '../../../../../utils/postRequest'; +import TaskDetailPanel from '../../../../../components/TaskWorkspace/TaskDetailPanel'; +import TaskDetailSheet from '../../../../../components/TaskWorkspace/TaskDetailSheet'; +import TaskDetailFull from '../../../../../components/TaskWorkspace/TaskDetailFull'; +import { buildTaskDraft } from '../../../../../components/TaskWorkspace/taskWorkspaceUtils'; +import { DEFAULT_TASK_BOARD_STATUSES } from '../../../../../constants/taskBoardDefaults'; +import './EventPlanningOverviewSnapshot.scss'; + +function formatMonthDay(value) { + if (!value) return 'TBD'; + return new Date(value).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }); +} + +function formatDueDay(value) { + if (!value) return 'TBD'; + return new Date(value).toLocaleDateString('en-US', { weekday: 'short' }); +} + +function startOfDay(date) { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +} + +function isSameOrAfterDay(a, b) { + return a.getTime() >= b.getTime(); +} + +function isSameOrBeforeDay(a, b) { + return a.getTime() <= b.getTime(); +} + +function resolveEventCreatedAt(event) { + return ( + event?.createdAt + || event?.created_at + || event?.createdOn + || event?.created_on + || event?.timestamps?.createdAt + || null + ); +} + +function toDisplayTitle(task) { + return task?.title || task?.name || 'Untitled task'; +} + +function toTaskStatus(task) { + return task?.effectiveStatus || task?.status || ''; +} + +function isDoneStatus(task) { + return /(done|completed|cancelled|canceled)/i.test(toTaskStatus(task)); +} + +function getTaskOwnerId(task) { + const ownerRaw = task?.ownerUserId || task?.assigneeUserId || task?.assignedToUserId || null; + if (!ownerRaw) return ''; + if (typeof ownerRaw === 'object') { + return String(ownerRaw?._id || ownerRaw?.id || ''); + } + return String(ownerRaw); +} + +function EventPlanningOverviewSnapshot({ event, orgId, userId, onOpenTasks }) { + const [detailMode, setDetailMode] = useState('closed'); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [taskDraft, setTaskDraft] = useState(() => buildTaskDraft({ title: '', status: 'todo' }, () => 'todo')); + const [detailSaving, setDetailSaving] = useState(false); + const [detailError, setDetailError] = useState(''); + const tasksUrl = event?._id && orgId + ? `/org-event-management/${orgId}/events/${event._id}/tasks?status=all&priority=all` + : null; + + const { data: tasksData, refetch: refetchTasks } = useFetch(tasksUrl); + const tasks = tasksData?.data?.tasks || []; + const membersEndpoint = orgId ? `/org-roles/${orgId}/members` : null; + const { data: membersData } = useFetch(membersEndpoint); + const members = membersData?.members || []; + const boardStatusesEndpoint = orgId ? `/org-event-management/${orgId}/task-board-statuses` : null; + const { data: boardStatusesData } = useFetch(boardStatusesEndpoint); + const boardStatuses = useMemo(() => { + const list = boardStatusesData?.data?.statuses; + return Array.isArray(list) && list.length ? list : DEFAULT_TASK_BOARD_STATUSES; + }, [boardStatusesData]); + + const now = new Date(); + const eventStart = event?.start_time ? new Date(event.start_time) : null; + const eventEndRaw = event?.end_time ? new Date(event.end_time) : null; + const eventEnd = eventEndRaw || eventStart; + const createdAt = resolveEventCreatedAt(event); + const timelineStart = createdAt ? startOfDay(createdAt) : null; + const timelineEnd = eventEnd ? startOfDay(eventEnd) : null; + const liveStartDay = eventStart ? startOfDay(eventStart) : null; + const liveEndDay = eventEnd ? startOfDay(eventEnd) : liveStartDay; + const todayStart = startOfDay(now); + const currentUserId = userId ? String(userId) : ''; + const countdownMs = eventStart ? Math.max(eventStart.getTime() - now.getTime(), 0) : 0; + const days = Math.floor(countdownMs / (1000 * 60 * 60 * 24)); + const hours = Math.floor((countdownMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((countdownMs % (1000 * 60 * 60)) / (1000 * 60)); + const totalHours = Math.floor(countdownMs / (1000 * 60 * 60)); + + const countdownDisplay = useMemo(() => { + if (!eventStart) { + return { + primaryValue: '--', + primaryLabel: 'days', + secondaryText: 'Time TBD' + }; + } + if (countdownMs >= 1000 * 60 * 60 * 24) { + return { + primaryValue: days, + primaryLabel: 'days', + secondaryText: `${hours} hrs - ${minutes} min` + }; + } + return { + primaryValue: totalHours, + primaryLabel: 'hrs', + secondaryText: `${minutes} min` + }; + }, [countdownMs, days, eventStart, hours, minutes, totalHours]); + + const upcomingTasks = useMemo(() => { + const activeTasks = tasks.filter((task) => { + if (isDoneStatus(task)) return false; + if (!currentUserId) return false; + return getTaskOwnerId(task) === currentUserId; + }); + activeTasks.sort((a, b) => { + const aDue = a?.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY; + const bDue = b?.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY; + if (aDue !== bDue) return aDue - bDue; + const aCreated = a?.createdAt ? new Date(a.createdAt).getTime() : 0; + const bCreated = b?.createdAt ? new Date(b.createdAt).getTime() : 0; + return aCreated - bCreated; + }); + return activeTasks.slice(0, 4); + }, [tasks, currentUserId]); + + const timelineDots = useMemo(() => { + if (!timelineStart || !timelineEnd || timelineEnd < timelineStart) return []; + + const dots = []; + const current = new Date(timelineStart); + while (current <= timelineEnd) { + const dotDay = new Date(current); + dotDay.setHours(0, 0, 0, 0); + dots.push({ + key: dotDay.toISOString(), + isPast: dotDay < todayStart, + isToday: dotDay.getTime() === todayStart.getTime(), + isLive: + liveStartDay + && liveEndDay + && isSameOrAfterDay(dotDay, liveStartDay) + && isSameOrBeforeDay(dotDay, liveEndDay) + }); + current.setDate(current.getDate() + 1); + } + return dots; + }, [timelineStart, timelineEnd, todayStart, liveStartDay, liveEndDay]); + + const detailTaskUrl = + event?._id && orgId && selectedTaskId && detailMode !== 'closed' + ? `/org-event-management/${orgId}/events/${event._id}/tasks/${selectedTaskId}` + : null; + const { data: detailTaskData, refetch: refetchDetailTask } = useFetch(detailTaskUrl); + + const taskForDetailPanel = useMemo(() => { + if (!selectedTaskId) return null; + const id = String(selectedTaskId); + const fromFetch = detailTaskData?.data?.task; + if (fromFetch && String(fromFetch._id) === id) return fromFetch; + return tasks.find((task) => String(task?._id) === id) || null; + }, [detailTaskData, selectedTaskId, tasks]); + + const openTaskDetail = useCallback( + (task) => { + if (!task?._id) return; + setSelectedTaskId(String(task._id)); + setDetailMode('full'); + setDetailError(''); + setTaskDraft(buildTaskDraft(task, toTaskStatus)); + }, + [] + ); + + const closeTaskDetail = useCallback(() => { + setDetailMode('closed'); + setSelectedTaskId(null); + setDetailError(''); + }, []); + + const handleDetailSave = useCallback(async () => { + if (!orgId || !event?._id || !selectedTaskId || !taskDraft.title.trim()) return; + setDetailSaving(true); + setDetailError(''); + try { + const payload = { + title: taskDraft.title.trim(), + description: taskDraft.description.trim(), + status: taskDraft.status, + priority: taskDraft.priority, + isCritical: Boolean(taskDraft.isCritical), + ownerUserId: taskDraft.ownerUserId || null, + dueAt: taskDraft.dueAt ? new Date(taskDraft.dueAt).toISOString() : null + }; + const response = await apiRequest( + `/org-event-management/${orgId}/events/${event._id}/tasks/${selectedTaskId}`, + payload, + { method: 'PUT' } + ); + if (!response?.success) { + throw new Error(response?.message || response?.error || 'Failed to save task'); + } + await refetchTasks?.({ silent: true }); + await refetchDetailTask?.({ silent: true }); + } catch (saveErr) { + setDetailError(saveErr.message || 'Unable to save.'); + } finally { + setDetailSaving(false); + } + }, [event?._id, orgId, refetchDetailTask, refetchTasks, selectedTaskId, taskDraft]); + + return ( +
+
+

Event starts in

+
+ {countdownDisplay.primaryValue} +
+ {countdownDisplay.primaryLabel} + {countdownDisplay.secondaryText} +
+
+
+
+ {timelineDots.map((dot) => ( + + ))} +
+
+ {formatMonthDay(createdAt)} - created + today + + {formatMonthDay(eventStart)} - live + +
+
+
+ +
+
+

This week - {upcomingTasks.length} item{upcomingTasks.length === 1 ? '' : 's'} need you

+ {onOpenTasks ? ( + + ) : null} +
+
    + {upcomingTasks.length > 0 ? ( + upcomingTasks.map((task) => ( +
  • + +
  • + )) + ) : ( +
  • +

    No active tasks assigned to you yet.

    +
  • + )} +
+
+ + + {taskForDetailPanel && ( + setDetailMode('full')} + onSave={handleDetailSave} + saving={detailSaving} + saveError={detailError} + /> + )} + + + + {taskForDetailPanel && ( + setDetailMode('sheet')} + onSave={handleDetailSave} + saving={detailSaving} + saveError={detailError} + /> + )} + +
+ ); +} + +export default EventPlanningOverviewSnapshot; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventPlanningOverviewSnapshot.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventPlanningOverviewSnapshot.scss new file mode 100644 index 00000000..050f3878 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventPlanningOverviewSnapshot.scss @@ -0,0 +1,230 @@ +.event-planning-overview-snapshot { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); + border: 1px solid var(--lighterborder); + border-radius: 16px; + background: rgba(255, 255, 255, 0.92); + overflow: hidden; + margin: 0 0 14px; +} + +.event-planning-overview-snapshot__countdown{ + padding: 18px 20px; +} +.event-planning-overview-snapshot__tasks { + padding: 14px 20px; +} + +.event-planning-overview-snapshot__tasks { + border-left: 1px solid var(--lighterborder); +} + +.event-planning-overview-snapshot__eyebrow { + margin: 0 0 10px; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--light-text); + font-weight: 700; +} + +.event-planning-overview-snapshot__clock { + display: flex; + align-items: flex-end; + gap: 10px; + margin-bottom: 14px; + + strong { + font-size: clamp(3rem, 5vw, 4.5rem); + line-height: 0.85; + // font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Palatino, 'Times New Roman', serif; + font-weight: 600; + color: var(--text); + } + + span { + display: block; + font-size: 2rem; + line-height: 1; + color: var(--text); + // font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Palatino, 'Times New Roman', serif; + } + + small { + display: block; + margin-top: 6px; + color: var(--light-text); + font-size: 0.95rem; + } +} + +.event-planning-overview-snapshot__timeline-dots { + display: grid; + align-items: center; + margin: 0 0 8px; + min-height: 10px; + width: 100%; + gap: 2px; +} + +.event-planning-overview-snapshot__day-segment { + width: 100%; + height: 4px; + border-radius: 999px; + background: rgba(77, 170, 87, 0.16); + + &.is-past { + background: rgba(77, 170, 87, 0.46); + } + + &.is-today { + background: #4DAA57; + height: 6px; + } + + &.is-live { + background: rgba(45, 126, 214, 0.55); + } + + &.is-live.is-past { + background: rgba(45, 126, 214, 0.35); + } + + &.is-live.is-today { + background: #2d7ed6; + } +} + +.event-planning-overview-snapshot__timeline-labels { + display: flex; + justify-content: space-between; + gap: 10px; + + span { + color: var(--light-text); + font-size: 0.78rem; + text-transform: lowercase; + } +} + +.event-planning-overview-snapshot__timeline-label-live { + color: #2d7ed6; + font-weight: 600; +} + +.event-planning-overview-snapshot__tasks-head { + display: flex; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; + align-items: center; + + p { + margin: 0; + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--light-text); + font-weight: 700; + } + + button { + border: 1px solid var(--lighterborder); + background: var(--background); + color: var(--text); + border-radius: 8px; + font-size: 0.76rem; + font-weight: 600; + padding: 4px 8px; + cursor: pointer; + } +} + +.event-planning-overview-snapshot__tasks ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; +} + +.event-planning-overview-snapshot__tasks li { + border-bottom: 1px solid var(--lighterborder); + padding: 4px 0; + + &:last-child { + border-bottom: 0; + padding-bottom: 0; + } + + p { + margin: 0; + color: var(--text); + font-weight: 600; + font-size: 1rem; + } + + small { + color: #9a7a3a; + font-size: 0.82rem; + font-weight: 600; + } +} + +.event-planning-overview-snapshot__task-button { + width: 100%; + display: grid; + grid-template-columns: 24px 1fr auto; + align-items: center; + gap: 10px; + border: 0; + background: transparent; + padding: 8px 4px; + margin: 0; + text-align: left; + cursor: pointer; + border-radius: 8px; + + &:hover { + background:var(--lightest) + } +} + +.event-planning-overview-snapshot__task-chip { + width: 22px; + height: 22px; + border-radius: 999px; + // background: rgba(154, 122, 58, 0.1); + // color: #9a7a3a; + background: var(--background); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.68rem; + font-weight: 700; + overflow:hidden; + + img{ + width:100%; + height:100%; + } +} + +.event-planning-overview-snapshot__empty { + grid-template-columns: 1fr !important; + + p { + color: var(--light-text); + font-weight: 500; + } +} + +@media (max-width: 960px) { + .event-planning-overview-snapshot { + grid-template-columns: 1fr; + } + + .event-planning-overview-snapshot__tasks { + border-left: 0; + border-top: 1px solid var(--lighterborder); + } +} diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/RSVPGrowthChart.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/RSVPGrowthChart.jsx index 6e90c345..888c81c3 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/RSVPGrowthChart.jsx +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/RSVPGrowthChart.jsx @@ -10,9 +10,10 @@ import { GlyphSeries, buildChartTheme, Tooltip, + DataContext, } from '@visx/xychart'; import { curveMonotoneX } from '@visx/curve'; -import './EventDashboard.scss'; +import './RSVPGrowthChart.scss'; const accessors = { xAccessor: (d) => d.x, @@ -107,6 +108,44 @@ function getSparseTickValues(values, maxTicks = MAX_X_TICKS) { return result; } +function SnapshotGoalGuide({ goalValue, label }) { + const { xScale, yScale } = React.useContext(DataContext) || {}; + if (!xScale || !yScale || !Number.isFinite(goalValue) || goalValue <= 0) return null; + const xRange = xScale.range?.() || []; + const yRange = yScale.range?.() || []; + if (xRange.length < 2 || yRange.length < 2) return null; + const xLeft = Math.min(...xRange); + const xRight = Math.max(...xRange); + const y = Number(yScale(goalValue)); + const yTop = Math.min(...yRange); + const yBottom = Math.max(...yRange); + if (!Number.isFinite(y) || y < yTop || y > yBottom) return null; + return ( + + + + {label} + + + ); +} + function RSVPGrowthChart({ eventId, orgId, @@ -114,6 +153,7 @@ function RSVPGrowthChart({ registrationCount, rsvpGrowth: rsvpGrowthProp, report = false, + variant = 'default', /** Full path to RSVP growth API (e.g. tenant-operator route); timezone query appended when absent */ rsvpGrowthUrlOverride, }) { @@ -265,6 +305,7 @@ function RSVPGrowthChart({ ? 100 : 0; const hasChartData = dailyData && dailyData.length > 0; + const isSnapshotVariant = variant === 'snapshot'; if (!hasChartData) { return ( @@ -322,6 +363,120 @@ function RSVPGrowthChart({ const cap = displayExpected > 0 ? displayExpected : Math.max(actualMax, requiredMax); return Math.ceil(Math.max(actualMax, requiredMax, cap) * 1.1) || 10; })(); + const registrationPct = displayExpected > 0 ? Math.round((currentRSVPs / displayExpected) * 100) : 0; + const last7DaysGain = dailyData.slice(-7).reduce((sum, day) => sum + (day?.dailyRSVPs || 0), 0); + const isOnTrack = displayExpected > 0 ? currentRSVPs >= displayExpected * 0.6 : currentRSVPs > 0; + const snapshotSeries = (() => { + const base = actualData.length > 0 ? actualData : [{ x: todayStr, y: 0 }]; + const withIndex = base.map((point, idx) => ({ ...point, ix: idx })); + if (withIndex.length === 1) { + return [ + withIndex[0], + { + ...withIndex[0], + ix: 1 + } + ]; + } + return withIndex; + })(); + + if (isSnapshotVariant) { + const chartRangeDays = dailyData.length; + return ( +
+
+
+

Registration pace

+ {chartRangeDays} days +
+ +
+ {currentRSVPs} +

+ of {displayExpected || 0} expected · {registrationPct}% +

+
+ +

+ {last7DaysGain >= 0 ? `+${last7DaysGain}` : last7DaysGain} this week ·{' '} + {isOnTrack ? 'on track to hit goal' : 'behind pace'} +

+
+ +
+ + + + + + + + d.ix} + yAccessor={accessors.yAccessor} + curve={curveMonotoneX} + fillOpacity={0.9} + fill="url(#actual-gradient-snapshot)" + /> + d.ix} + yAccessor={accessors.yAccessor} + curve={curveMonotoneX} + stroke="#2f8f4a" + strokeWidth={3} + strokeLinecap="round" + strokeLinejoin="round" + /> + + { + if (!tooltipData?.datumByKey) return null; + const actualDatum = + tooltipData.datumByKey?.['actual-snapshot-line']?.datum + || tooltipData.datumByKey?.['actual-snapshot-area']?.datum + || tooltipData?.nearestDatum?.datum; + if (!actualDatum) return null; + const dateLabel = actualDatum?.x ? formatSemanticDate(actualDatum.x) : 'Current'; + return ( +
+
{dateLabel}
+
+ + Registrations: {Math.round(actualDatum?.y || 0)} +
+
+ + Goal: {displayExpected || 0} +
+
+ ); + }} + /> +
+
+
+ ); + } return (
diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/RSVPGrowthChart.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/RSVPGrowthChart.scss new file mode 100644 index 00000000..ebad8e29 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/RSVPGrowthChart.scss @@ -0,0 +1,366 @@ +.rsvp-growth-chart { + width: 100%; + max-width: 100%; + padding: 1.5rem; + background: var(--lightbackground); + border: 1px solid var(--lighterborder); + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + box-sizing: border-box; + overflow: hidden; + + .chart-loading, + .chart-error, + .chart-empty { + display: flex; + align-items: center; + justify-content: center; + padding: 3rem; + color: var(--light-text); + font-size: 0.95rem; + } + + .chart-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + + .chart-stats-inline { + display: flex; + gap: 2rem; + align-items: baseline; + + .chart-stat-item { + display: flex; + flex-direction: column; + gap: 0.25rem; + + .chart-stat-value { + font-size: 1.5rem; + font-weight: 600; + color: var(--text); + } + + .chart-stat-label { + font-size: 0.85rem; + color: var(--light-text); + font-weight: 500; + display: flex; + align-items: center; + gap: 0.35rem; + } + + .chart-stat-change { + display: inline-flex; + align-items: center; + gap: 0.2rem; + font-size: 0.75rem; + font-weight: 600; + + &.up { + color: #22c55e; + } + + &.down { + color: var(--red, #fa756d); + } + } + } + } + + .chart-header-right { + display: flex; + align-items: center; + gap: 1rem; + } + + .chart-title-section { + display: flex; + align-items: center; + gap: 1rem; + + h3 { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.25rem; + font-weight: 600; + color: var(--text); + margin: 0; + + iconify-icon { + color: #4DAA57; + font-size: 1.5rem; + } + } + + .frozen-badge { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.8rem; + background: #fff3cd; + color: #856404; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 500; + + iconify-icon { + font-size: 1rem; + color: #856404; + } + } + } + + .chart-controls { + display: flex; + gap: 0.5rem; + background: var(--lighterborder); + border-radius: 8px; + padding: 0.25rem; + + .toggle-btn { + padding: 0.5rem 1rem; + background: transparent; + border: none; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 500; + color: var(--light-text); + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: var(--lightbackground); + } + + &.active { + background: var(--background); + color: var(--text); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + } + } + } + + .chart-stats { + display: flex; + gap: 2rem; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--lighterborder); + + .stat-item { + display: flex; + flex-direction: column; + gap: 0.25rem; + + .stat-label { + font-size: 0.85rem; + color: var(--light-text); + font-weight: 500; + } + + .stat-value { + font-size: 1.5rem; + font-weight: 600; + color: var(--text); + } + } + } + + .chart-container { + height: 350px; + width: 100%; + max-width: 100%; + position: relative; + padding: 0.5rem 0 1rem 0; + margin-bottom: 0.5rem; + box-sizing: border-box; + overflow: hidden; + } + + &.rsvp-growth-chart-visx .chart-container-visx { + height: 320px; + width: 100%; + min-height: 280px; + + > div { + width: 100% !important; + } + } + + &.rsvp-growth-chart-visx .chart-controls .toggle-btn { + font-size: 0.8rem; + padding: 0.4rem 0.75rem; + } + + .chart-legend { + display: flex; + gap: 1.25rem; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--lighterborder); + font-size: 0.8rem; + color: var(--light-text); + } + + .chart-legend-item { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .chart-legend-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + .chart-legend-dot-actual { + background: #22c55e; + } + + .chart-legend-dot-required { + background: #94a3b8; + } +} + +.rsvp-growth-chart.rsvp-growth-chart--snapshot { + padding: 0.15rem 0.1rem 0.1rem; + border: 0; + box-shadow: none; + background: transparent; + + .rsvp-growth-chart__snapshot-header { + padding: 18px 20px; + } + + .rsvp-growth-chart__snapshot-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 0.6rem; + + h4 { + margin: 0; + font-size: 1rem; + font-weight: 650; + letter-spacing: -0.01em; + color: #1f2b24; + } + + span { + text-transform: uppercase; + letter-spacing: 0.2em; + font-size: 0.68rem; + font-weight: 700; + color: rgba(33, 46, 38, 0.42); + } + } + + .rsvp-growth-chart__snapshot-kpi { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 0.1rem; + + strong { + font-size: clamp(2.6rem, 4.1vw, 3.7rem); + line-height: 0.88; + font-weight: 600; + color: #1a2a22; + // font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Palatino, 'Times New Roman', serif; + } + + p { + margin: 0; + font-size: 0.95rem; + line-height: 1.25; + color: rgba(23, 31, 27, 0.6); + font-weight: 600; + } + } + + .rsvp-growth-chart__snapshot-delta { + margin: 0 0 0.45rem; + font-size: 0.95rem; + color: #3f9f5b; + font-weight: 600; + } + + .chart-container.chart-container-visx { + height: 152px; + min-height: 152px; + width: calc(100% + 0.2rem); + margin: 0 -0.1rem; + padding: 0; + } +} + +.rsvp-chart-tooltip-wrapper.visx-tooltip { + padding: 0; + background: transparent; + border: none; + box-shadow: none; + z-index: 9999 !important; + pointer-events: none; +} + +.visx-crosshair.visx-crosshair-vertical { + z-index: 10001 !important; + pointer-events: none; +} + +.visx-crosshair.visx-crosshair-vertical line { + stroke: #94a3b8 !important; + stroke-width: 1px !important; + stroke-opacity: 1 !important; + stroke-dasharray: 1 4 !important; + stroke-linecap: round !important; +} + +.visx-tooltip-glyph { + z-index: 10003 !important; + pointer-events: none; +} + +.rsvp-chart-tooltip { + padding: 0.5rem 0.75rem; + background: var(--background); + border: 1px solid var(--lighterborder); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + font-size: 0.8rem; + min-width: 120px; +} + +.rsvp-chart-tooltip-date { + font-weight: 600; + color: var(--text); + margin-bottom: 0.35rem; +} + +.rsvp-chart-tooltip-row { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--light-text); +} + +.rsvp-chart-tooltip-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.rsvp-chart-tooltip-dot-actual { + background: #22c55e; +} + +.rsvp-chart-tooltip-dot-required { + background: #94a3b8; +} diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventPostMortem/usePostMortemInsights.js b/frontend/src/pages/ClubDash/EventsManagement/components/EventPostMortem/usePostMortemInsights.js index f2011a9d..df0109c1 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventPostMortem/usePostMortemInsights.js +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventPostMortem/usePostMortemInsights.js @@ -11,6 +11,7 @@ export const INSIGHT_CATEGORIES = { funnelBottleneck: 'funnelBottleneck', trafficInvestment: 'trafficInvestment', strategic: 'strategic', + measurement: 'measurement', }; /** @@ -28,6 +29,7 @@ export function usePostMortemInsights({ qrReferrerSources, formOpens, hasForm, + hasCheckInTracking = true, formatNumber, expectedAttendance, }) { @@ -43,6 +45,7 @@ export function usePostMortemInsights({ [INSIGHT_CATEGORIES.funnelBottleneck]: null, [INSIGHT_CATEGORIES.trafficInvestment]: null, [INSIGHT_CATEGORIES.strategic]: [], + [INSIGHT_CATEGORIES.measurement]: null, }; if (expectedAttendance > 0 && registrations > 0) { @@ -64,7 +67,7 @@ export function usePostMortemInsights({ byCategory[INSIGHT_CATEGORIES.expectedVsActual] = item; } - if (registrations > 0 && checkIns > 0) { + if (hasCheckInTracking && registrations > 0 && checkIns > 0) { const rate = ((checkIns / registrations) * 100).toFixed(0); const item = { icon: 'mdi:check-circle', @@ -75,6 +78,16 @@ export function usePostMortemInsights({ byCategory[INSIGHT_CATEGORIES.checkIn] = item; } + if (!hasCheckInTracking && registrations > 0) { + const measurementItem = { + icon: 'mdi:clipboard-check-outline', + text: 'Attendance was not captured for this event', + sub: 'Enable check-ins next time to measure true attendance and improve reminder strategy.', + }; + all.push(measurementItem); + byCategory[INSIGHT_CATEGORIES.measurement] = measurementItem; + } + if (uniqueViewers > 0 && registrations > 0) { const rate = ((registrations / uniqueViewers) * 100).toFixed(0); const item = { @@ -182,12 +195,14 @@ export function usePostMortemInsights({ if (uniqueViewers > 0 && (hasForm ? formOpens > 0 : true)) { const viewToForm = hasForm && formOpens > 0 ? (formOpens / uniqueViewers) * 100 : 0; const formToReg = hasForm && formOpens > 0 && registrations > 0 ? (registrations / formOpens) * 100 : 0; - const regToCheckIn = registrations > 0 ? (checkIns / registrations) * 100 : 0; + const regToCheckIn = hasCheckInTracking && registrations > 0 ? (checkIns / registrations) * 100 : 0; const drops = []; if (hasForm && formOpens > 0) drops.push({ stage: 'viewer→form', rate: viewToForm, label: 'Viewers opening form' }); if (hasForm && formOpens > 0 && registrations > 0) drops.push({ stage: 'form→reg', rate: formToReg, label: 'Form opens → registrations' }); - if (registrations > 0) drops.push({ stage: 'reg→checkin', rate: regToCheckIn, label: 'Registrations → check-ins' }); + if (hasCheckInTracking && registrations > 0) { + drops.push({ stage: 'reg→checkin', rate: regToCheckIn, label: 'Registrations → check-ins' }); + } const bottleneck = drops.length > 0 ? drops.reduce((min, d) => (d.rate < min.rate ? d : min)) : null; if (bottleneck && bottleneck.rate < 70) { @@ -364,5 +379,5 @@ export function usePostMortemInsights({ } return { all, byCategory }; - }, [registrations, checkIns, uniqueViewers, rsvpGrowth, referrerSources, referrerRegistrations, qrReferrerSources, formOpens, hasForm, formatNumber, expectedAttendance]); + }, [registrations, checkIns, uniqueViewers, rsvpGrowth, referrerSources, referrerRegistrations, qrReferrerSources, formOpens, hasForm, hasCheckInTracking, formatNumber, expectedAttendance]); } diff --git a/frontend/src/pages/PlatformAdmin/PivotLab/PivotDeckCardPreview.jsx b/frontend/src/pages/PlatformAdmin/PivotLab/PivotDeckCardPreview.jsx new file mode 100644 index 00000000..b1d3e84e --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PivotLab/PivotDeckCardPreview.jsx @@ -0,0 +1,135 @@ +import React, { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import './PivotDeckCardPreview.scss'; + +const STACK_BEHIND = [ + { depth: 2, rotate: -2.2, scale: 0.976, offsetY: 10, offsetX: -6 }, + { depth: 1, rotate: 2.6, scale: 0.984, offsetY: 5, offsetX: 5 }, +]; + +function normalizeDeckCopy(value) { + return typeof value === 'string' ? value.trim().toLowerCase() : ''; +} + +export function PivotDeckCardPreview({ + title, + hostName, + whenLabel, + locationLabel, + description, + imageUrl, + tagLabel, + className = '', +}) { + const displayTitle = normalizeDeckCopy(title) || 'untitled event'; + const displayHost = normalizeDeckCopy(hostName) || 'organizer tbd'; + + return ( +
+
+
+ {tagLabel ? {normalizeDeckCopy(tagLabel)} : null} +

{displayTitle}

+

{displayHost}

+ {whenLabel || locationLabel ? ( +
+ {whenLabel ? {whenLabel} : null} + {locationLabel ? ( + {locationLabel} + ) : null} +
+ ) : null} + {description ?

{description.trim()}

: null} +
+
+ ); +} + +export function PivotDeckPhonePreview({ + title, + hostName, + whenLabel, + locationLabel, + description, + imageUrl, + tagLabel, + label = 'Mobile deck preview', + hint = 'Matches the swipe deck card in the Pivot app.', +}) { + return ( +
+
+

{label}

+ {hint ?

{hint}

: null} +
+