Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
fe32754
MER-190 adding focused event dashboard
AZ0228 May 7, 2026
b65b9c9
MER-190: implement phase-aware focused event workspace
AZ0228 May 25, 2026
ddb10cc
MER-190: redesign registration pace visualization for planning snapshot
AZ0228 May 25, 2026
3552347
MER-190: polish agenda calendar readability and event task board visuals
AZ0228 May 25, 2026
87c3eef
adding new logo
AZ0228 May 25, 2026
fe80e6e
Merge branch 'main' of github.com:Study-Compass/Study-Compass into ME…
AZ0228 May 25, 2026
c77f31a
MER-192: Extend TenantConfig schema for dynamic pivot tenant provisio…
AZ0228 May 25, 2026
ad78418
MER-192: Add shared default tenant definitions and merge helpers.
AZ0228 May 25, 2026
b8e7e8e
MER-192: Add tenant config service and dynamic per-tenant Mongo routing.
AZ0228 May 25, 2026
112514a
MER-192: Add platform admin APIs for dynamic tenant provisioning.
AZ0228 May 25, 2026
bcc68ef
MER-192: Preserve dynamically provisioned tenants on legacy tenant-co…
AZ0228 May 25, 2026
3148447
MER-192: Fix global auth and session resolution for platform admin us…
AZ0228 May 25, 2026
5abc84f
MER-192: Allow admin users to bypass tenant coming_soon and maintenan…
AZ0228 May 25, 2026
33516d9
MER-192: Wire tenant lifecycle middleware, www routing, and platform …
AZ0228 May 25, 2026
72c99ba
MER-192: Add platform admin route protection and expose platform role…
AZ0228 May 25, 2026
e17e78d
MER-192: Add Platform Admin dashboard shell on www.
AZ0228 May 25, 2026
3c5e201
MER-192: Add Linear-style tenant management UI with visibility and he…
AZ0228 May 25, 2026
c4a3eb1
MER-192: Add tenant status dropdown and Popup-based lifecycle confirm…
AZ0228 May 25, 2026
145b8fa
MER-192: Move tenant visibility controls from campus admin to Platfor…
AZ0228 May 25, 2026
fbad4d6
MER-192: Add Pivot referral codes for pilot city gating
AZ0228 May 26, 2026
5c1265d
MER-192: Fix circular getModelService imports in auth middleware and …
AZ0228 May 26, 2026
4213736
MER-192: Allow all tenant subdomains via dynamic CORS and Socket.IO r…
AZ0228 May 26, 2026
b049177
MER-192: Fix default tenant metadata persistence and remove manual CO…
AZ0228 May 26, 2026
ee6d9a6
MER-192: Drop CORS warning from tenant activation checklist hints.
AZ0228 May 26, 2026
a301ac6
MER-192: Add outside-click close and isolate Activate from status dro…
AZ0228 May 26, 2026
46ff6af
MER-192: Refine tenant management sidebar layout and Platform Admin s…
AZ0228 May 26, 2026
7327107
MER-192: Add public referral validate API for Pivot mobile invite gate.
AZ0228 May 27, 2026
21d62ae
MER-192: Fix OAuth tenant binding and complete getModelService circul…
AZ0228 May 27, 2026
d09fe72
MER-192 adding pivot referral code generation and redemption
AZ0228 Jun 15, 2026
9daaac1
MER-192: Remove committed Jest coverage artifacts from backend.
AZ0228 Jul 6, 2026
97a3b29
MER-192: Add pivot Mongo schemas and model registration.
AZ0228 Jul 6, 2026
e2960d5
MER-192: Add configurable pivot weekly drop schedule (Phase 0.4).
AZ0228 Jul 6, 2026
39b4cb4
MER-192: Add pivot feed, intent, and week recap services (Phase 3).
AZ0228 Jul 6, 2026
866de4c
MER-192: Add pivot event feedback services (Phase 4).
AZ0228 Jul 6, 2026
08df4aa
MER-192: Add Pivot Lab backend: snapshots, overview, and ingest (Phas…
AZ0228 Jul 6, 2026
cb6ec83
MER-192: Add Pivot Lab admin UI at /admin/pivot (Phase 5.3).
AZ0228 Jul 6, 2026
b3e26a5
MER-192: Add pivot weekly drop ops and push edition routing (Phase 6).
AZ0228 Jul 6, 2026
1ceec96
MER-192: Add pivot friend search, requests, and event social APIs (Ph…
AZ0228 Jul 6, 2026
553685c
MER-192: Add pivot tag catalog, interests, and feed personalization (…
AZ0228 Jul 6, 2026
cfa7007
MER-192: Wire pivot attendee routes, admin mounts, and npm scripts.
AZ0228 Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .DS_Store
Binary file not shown.
112 changes: 82 additions & 30 deletions backend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down Expand Up @@ -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];
Expand All @@ -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);
Expand All @@ -135,6 +180,7 @@ function createApp() {
'/error',
'/select-school',
'/tenant-status',
'/platform-admin',
'/static',
'/health',
'/validate-token',
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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'));

Expand Down
140 changes: 93 additions & 47 deletions backend/connectionsManager.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading