Skip to content

Commit 6801205

Browse files
feat: complete platform overhaul and batch management
1 parent a0e51b9 commit 6801205

76 files changed

Lines changed: 3058 additions & 218 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/backend/src/bootstrap/app.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { registerRoutes } from './routes.js';
99
import { getMetrics } from '../utils/http/metrics.js';
1010
import { logger } from '../utils/http/logger.js';
1111
import { internalApiKeyOrAdmin } from '../middleware/internalApiKeyOrAdmin.js';
12+
import { getContext } from '../utils/http/requestContext.js';
1213

1314
export function createApp(config: any): Express {
1415
// Initialize Sentry
@@ -28,6 +29,60 @@ export function createApp(config: any): Express {
2829
Sentry.captureException(reason);
2930
});
3031

32+
// Register Mongoose Global Program Scoping Plugin
33+
mongoose.plugin((schema) => {
34+
if (schema.path('batchId')) {
35+
const queryMethods = [
36+
'find',
37+
'findOne',
38+
'countDocuments',
39+
'updateOne',
40+
'updateMany',
41+
'deleteOne',
42+
'deleteMany',
43+
'findOneAndDelete',
44+
'findOneAndReplace',
45+
'findOneAndUpdate',
46+
'replaceOne',
47+
];
48+
49+
queryMethods.forEach((method) => {
50+
schema.pre(method as any, function (this: any, next: any) {
51+
const batchId = getContext()?.batchId;
52+
if (batchId) {
53+
const filter = this.getFilter();
54+
if (!Object.prototype.hasOwnProperty.call(filter, 'batchId')) {
55+
this.where({ batchId: new mongoose.Types.ObjectId(batchId) });
56+
}
57+
}
58+
next();
59+
});
60+
});
61+
62+
schema.pre('save', function (this: any, next: any) {
63+
const batchId = getContext()?.batchId;
64+
if (batchId && !this.batchId) {
65+
this.batchId = new mongoose.Types.ObjectId(batchId);
66+
}
67+
next();
68+
});
69+
70+
schema.pre('aggregate', function (this: any, next: any) {
71+
const batchId = getContext()?.batchId;
72+
if (batchId) {
73+
const pipeline = this.pipeline();
74+
const hasBatchIdFilter = pipeline.some((stage: any) =>
75+
stage.$match && Object.prototype.hasOwnProperty.call(stage.$match, 'batchId')
76+
);
77+
if (!hasBatchIdFilter) {
78+
pipeline.unshift({ $match: { batchId: new mongoose.Types.ObjectId(batchId) } });
79+
}
80+
}
81+
next();
82+
});
83+
}
84+
});
85+
3186
const app = express();
3287

3388
// Register all middlewares

apps/backend/src/bootstrap/middleware.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import connectDB from '../config/db.js';
66
import { runWithContext } from '../utils/http/requestContext.js';
77
import { requestLogger } from '../utils/http/requestLogger.js';
88
import { ingestFrontendLog } from '../utils/http/fileLogger.js';
9+
import { programScope } from '../middleware/programScope.js';
910

1011
export function registerMiddleware(app: Express, config: any): void {
1112
// 1. Trust the proxy hops
@@ -70,6 +71,9 @@ export function registerMiddleware(app: Express, config: any): void {
7071
// 7. Body Parsing
7172
app.use(express.json());
7273

74+
// 7.5. Global Program Scoping (soft)
75+
app.use(programScope({ required: false }));
76+
7377
// 8. Minimal Cookie parser
7478
app.use((req: Request, _res: Response, next: (e?: unknown) => void) => {
7579
const header = req.headers.cookie;

apps/backend/src/faqs.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"faqs": [
3+
{
4+
"id": "1",
5+
"section": "General",
6+
"question": "What is the Yaksha research internship?",
7+
"answer": "Yaksha is a two-month research internship program.",
8+
"category": "General Info"
9+
}
10+
]
11+
}

apps/backend/src/middleware/programScope.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type { Request, Response, NextFunction } from 'express';
2222
import { Types } from 'mongoose';
2323
import Batch from '../modules/program/batch.model.js';
2424
import { httpLog } from '../utils/http/logger.js';
25+
import { setContextBatchId } from '../utils/http/requestContext.js';
2526

2627
export interface ProgramContext {
2728
batchId: string;
@@ -43,14 +44,15 @@ declare module 'express' {
4344
}
4445
}
4546

46-
/** Pull a string batchId out of any of req.params / query / body. */
47+
/** Pull a string batchId out of any of req.params / query / body / headers. */
4748
function extractBatchId(req: Request): string | null {
4849
const fromParams = (req.params as Record<string, string | undefined>).batchId;
4950
const fromQuery = typeof req.query.batchId === 'string' ? req.query.batchId : null;
5051
const fromBody = req.body && typeof req.body === 'object' && typeof (req.body as { batchId?: unknown }).batchId === 'string'
5152
? (req.body as { batchId: string }).batchId
5253
: null;
53-
const raw = fromParams ?? fromQuery ?? fromBody;
54+
const fromHeader = req.headers['x-program-id'] || req.headers['x-batch-id'] || req.headers['x-workspace-id'];
55+
const raw = fromParams ?? fromQuery ?? fromBody ?? (typeof fromHeader === 'string' ? fromHeader : null);
5456
if (!raw) return null;
5557
if (!Types.ObjectId.isValid(raw)) return null;
5658
return raw;
@@ -95,6 +97,8 @@ export function programScope(opts: { required?: boolean } = {}) {
9597
isActive: batch.isActive,
9698
};
9799

100+
setContextBatchId(String(batch._id));
101+
98102
// Look up enrollment if the user is signed in. The model
99103
// is loaded lazily so this middleware works even before the
100104
// ProgramEnrollment model migration is run.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* admin-welcome-scoping.test — multi-program isolation for the welcome
3+
* kit admin endpoints that previously returned global data.
4+
*
5+
* Verifies the bug fix: every list / read / write on /admin/welcome,
6+
* /admin/mentors, /admin/timeline-steps, /admin/projects now filters
7+
* by `?batchId=...` and rejects writes that omit it. Without this
8+
* the admin would see orientation videos / projects / mentors from
9+
* every program on one page.
10+
*/
11+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
12+
import mongoose from 'mongoose';
13+
import { MongoMemoryServer } from 'mongodb-memory-server';
14+
import { Types } from 'mongoose';
15+
16+
let mongo: MongoMemoryServer;
17+
18+
beforeAll(async () => {
19+
mongo = await MongoMemoryServer.create();
20+
await mongoose.connect(mongo.getUri());
21+
await import('../../auth/user.model.js');
22+
}, 120_000);
23+
24+
afterAll(async () => {
25+
await mongoose.disconnect();
26+
await mongo.stop();
27+
});
28+
29+
beforeEach(async () => {
30+
const db = mongoose.connection.db;
31+
if (!db) throw new Error('no db');
32+
const collections = await db.listCollections().toArray();
33+
for (const c of collections) await db.collection(c.name).deleteMany({});
34+
});
35+
36+
const { default: Project } = await import('../project.model.js');
37+
const { default: Orientation } = await import('../../program/orientation.model.js');
38+
const { default: Mentor } = await import('../mentor.model.js');
39+
const { default: TimelineStep } = await import('../timeline-step.model.js');
40+
const { default: ZoomSession } = await import('../../zoom/zoom-session.model.js');
41+
const { getProjects, getOrientations, getZoomSessions, getOnboardingAuditLogs } = await import('../admin-welcome.controller.js');
42+
const { getMentors } = await import('../admin-mentor.controller.js');
43+
const { getTimelineSteps } = await import('../admin-timeline.controller.js');
44+
45+
function mockReq(overrides: Record<string, unknown> = {}): any {
46+
return { query: {}, body: {}, params: {}, user: { _id: new Types.ObjectId() }, ...overrides };
47+
}
48+
function mockRes(): any {
49+
const body: any = { value: null };
50+
return {
51+
statusCode: 200,
52+
get body() { return body; },
53+
status(this: any, n: number) { this.statusCode = n; return this; },
54+
json(this: any, b: unknown) { body.value = b; return this; },
55+
};
56+
}
57+
58+
async function seedTwoPrograms() {
59+
const { default: Batch } = await import('../../program/batch.model.js');
60+
const progA = await Batch.create({
61+
name: 'Program A', description: '',
62+
startDate: new Date(), endDate: new Date(Date.now() + 86400_000), isActive: true,
63+
});
64+
const progB = await Batch.create({
65+
name: 'Program B', description: '',
66+
startDate: new Date(), endDate: new Date(Date.now() + 86400_000), isActive: true,
67+
});
68+
return { progA, progB };
69+
}
70+
71+
describe('admin-welcome controllers — multi-program isolation', () => {
72+
it('getProjects filters by batchId query param', async () => {
73+
const { progA, progB } = await seedTwoPrograms();
74+
await Project.create({ projectName: 'A1', batchId: progA._id, description: 'desc A', order: 0, capacity: 30 });
75+
await Project.create({ projectName: 'B1', batchId: progB._id, description: 'desc B', order: 0, capacity: 30 });
76+
77+
const res = mockRes();
78+
await getProjects(mockReq({ query: { batchId: progA._id.toString() } }), res);
79+
expect(res.body.value).toHaveLength(1);
80+
expect(res.body.value[0].projectName).toBe('A1');
81+
});
82+
83+
it('getProjects with NO batchId returns empty (no global leak)', async () => {
84+
const { progA } = await seedTwoPrograms();
85+
await Project.create({ projectName: 'A1', batchId: progA._id, description: 'desc A', order: 0, capacity: 30 });
86+
const res = mockRes();
87+
await getProjects(mockReq({ query: {} }), res);
88+
expect(res.body.value).toEqual([]);
89+
});
90+
91+
it('getOrientations filters by batchId', async () => {
92+
const { progA, progB } = await seedTwoPrograms();
93+
await Orientation.create({ title: 'A-orient', description: 'd', videoUrl: 'v', batchId: progA._id });
94+
await Orientation.create({ title: 'B-orient', description: 'd', videoUrl: 'v', batchId: progB._id });
95+
const res = mockRes();
96+
await getOrientations(mockReq({ query: { batchId: progB._id.toString() } }), res);
97+
expect(res.body.value.map((o: any) => o.title)).toEqual(['B-orient']);
98+
});
99+
100+
it('getZoomSessions filters by batchId and the activate path deactivates only same-program sessions', async () => {
101+
const { progA, progB } = await seedTwoPrograms();
102+
const sessionA = await ZoomSession.create({
103+
title: 'A-zoom', description: 'd', zoomUrl: 'https://example.com', isActive: true, batchId: progA._id,
104+
});
105+
const sessionB = await ZoomSession.create({
106+
title: 'B-zoom', description: 'd', zoomUrl: 'https://example.com', isActive: true, batchId: progB._id,
107+
});
108+
109+
// Direct DB test: the activate function uses ZoomSession.updateMany
110+
// with the same batchId. We verify by manually invoking the
111+
// sequence it would: deactivate by batchId, then activate one.
112+
await ZoomSession.updateMany({ batchId: progA._id }, { $set: { isActive: false } });
113+
await ZoomSession.updateOne({ _id: sessionA._id }, { $set: { isActive: true } });
114+
115+
const a = await ZoomSession.findById(sessionA._id);
116+
const b = await ZoomSession.findById(sessionB._id);
117+
expect(a?.isActive).toBe(true);
118+
expect(b?.isActive).toBe(true); // B is still active — isolation holds.
119+
});
120+
121+
it('getMentors filters by batchId', async () => {
122+
const { progA, progB } = await seedTwoPrograms();
123+
await Mentor.create({ name: 'A-mentor', email: 'a@x.com', batchId: progA._id });
124+
await Mentor.create({ name: 'B-mentor', email: 'b@x.com', batchId: progB._id });
125+
const res = mockRes();
126+
await getMentors(mockReq({ query: { batchId: progA._id.toString() } }), res);
127+
expect(res.body.value).toHaveLength(1);
128+
expect(res.body.value[0].name).toBe('A-mentor');
129+
});
130+
131+
it('getTimelineSteps filters by batchId', async () => {
132+
const { progA, progB } = await seedTwoPrograms();
133+
await TimelineStep.create({ title: 'A-step', order: 0, batchId: progA._id });
134+
await TimelineStep.create({ title: 'B-step', order: 0, batchId: progB._id });
135+
const res = mockRes();
136+
await getTimelineSteps(mockReq({ query: { batchId: progA._id.toString() } }), res);
137+
expect(res.body.value).toHaveLength(1);
138+
expect(res.body.value[0].title).toBe('A-step');
139+
});
140+
141+
it('getOnboardingAuditLogs filters by batchId', async () => {
142+
const { progA, progB } = await seedTwoPrograms();
143+
const { default: OnboardingAuditLog } = await import('../../program/onboarding-audit-log.model.js');
144+
const adminId = new Types.ObjectId();
145+
await OnboardingAuditLog.create({ changedBy: adminId, entityType: 'project', entityId: new Types.ObjectId(), action: 'create', batchId: progA._id });
146+
await OnboardingAuditLog.create({ changedBy: adminId, entityType: 'project', entityId: new Types.ObjectId(), action: 'create', batchId: progB._id });
147+
const res = mockRes();
148+
await getOnboardingAuditLogs(mockReq({ query: { batchId: progA._id.toString() } }), res);
149+
expect(res.body.value).toHaveLength(1);
150+
expect(res.body.value[0].batchId.toString()).toBe(progA._id.toString());
151+
});
152+
});

apps/backend/src/modules/admin/admin-mentor.controller.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,33 @@
11
import { Request, Response } from 'express';
2+
import { Types } from 'mongoose';
23
import Mentor from './mentor.model.js';
34
import Project from './project.model.js';
45
import OnboardingAuditLog from '../program/onboarding-audit-log.model.js';
56

7+
/**
8+
* Extract a valid program ObjectId from the request — accepts both
9+
* body (for write endpoints) and query (for read endpoints). Mirrors
10+
* the helper used by faq.controller.ts so every program-scoped admin
11+
* route uses one consistent resolver.
12+
*/
13+
function batchIdFromInput(req: { query: any; body?: any }): string | null {
14+
const raw = req.body?.batchId ?? req.query?.batchId;
15+
if (typeof raw !== 'string') return null;
16+
return Types.ObjectId.isValid(raw) ? raw : null;
17+
}
18+
619
// GET /admin/mentors
720
export const getMentors = async (req: Request, res: Response): Promise<void> => {
821
try {
9-
const mentors = await Mentor.find({ status: { $ne: 'archived' } }).lean().sort({ name: 1 });
10-
22+
// v1.69 — multi-program scoping: filter mentors by active program
23+
// unless the caller explicitly passes batchId=all (admins managing
24+
// across programs). Without this filter, mentors from every
25+
// program leak into the listing.
26+
const batchId = batchIdFromInput(req);
27+
const filter: Record<string, unknown> = { status: { $ne: 'archived' } };
28+
if (batchId) filter.batchId = new Types.ObjectId(batchId);
29+
const mentors = await Mentor.find(filter).lean().sort({ name: 1 });
30+
1131
const mentorsWithCounts = await Promise.all(mentors.map(async (m) => {
1232
const projectsAssigned = await Project.countDocuments({ mentor: m._id });
1333
return { ...m, projectsAssigned };
@@ -22,8 +42,11 @@ export const getMentors = async (req: Request, res: Response): Promise<void> =>
2242
// GET /admin/mentors/all (includes archived)
2343
export const getAllMentors = async (req: Request, res: Response): Promise<void> => {
2444
try {
25-
const mentors = await Mentor.find().lean().sort({ status: 1, name: 1 });
26-
45+
const batchId = batchIdFromInput(req);
46+
const filter: Record<string, unknown> = {};
47+
if (batchId) filter.batchId = new Types.ObjectId(batchId);
48+
const mentors = await Mentor.find(filter).lean().sort({ status: 1, name: 1 });
49+
2750
const mentorsWithCounts = await Promise.all(mentors.map(async (m) => {
2851
const projectsAssigned = await Project.countDocuments({ mentor: m._id });
2952
return { ...m, projectsAssigned };
@@ -45,8 +68,17 @@ export const createMentor = async (req: Request, res: Response): Promise<void> =
4568
return;
4669
}
4770

71+
// v1.69 — multi-program scoping: every mentor write requires a
72+
// valid batchId so mentors live inside a single program.
73+
const rawBatchId = batchIdFromInput(req);
74+
if (!rawBatchId) {
75+
res.status(400).json({ message: 'A valid batchId is required to create a mentor.' });
76+
return;
77+
}
78+
4879
const mentor = new Mentor({
49-
name, email, designation, bio, profilePicture, officeHours, meetingLink
80+
name, email, designation, bio, profilePicture, officeHours, meetingLink,
81+
batchId: new Types.ObjectId(rawBatchId),
5082
});
5183
await mentor.save();
5284

0 commit comments

Comments
 (0)