Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fcb2b1f
MER-126 admin planning doc
SujiChen Feb 24, 2026
6da36e0
Merge branch 'main' of github.com:Study-Compass/Study-Compass into ME…
SujiChen Feb 27, 2026
56cc3c2
MER-155: Add student attribute schema (department, enrollment options…
SujiChen Feb 27, 2026
cd3abd3
MER-157: Add OutreachAudience, OutreachMessage, OutreachReceipt schem…
SujiChen Feb 27, 2026
0e8a230
MER-158: Add studentTargetingService with filter DSL and resolveAudie…
SujiChen Feb 27, 2026
f813eee
MER-156: Add SYSTEM_PERMISSIONS.ADMIN_OUTREACH for outreach route pro…
SujiChen Feb 27, 2026
a05c5c3
MER-161: Implement email delivery in NotificationService and add admi…
SujiChen Feb 27, 2026
36a51f5
MER-159/160: Add admin and student outreach routes and mount under /a…
SujiChen Feb 27, 2026
8698060
MER-164: Normalize QR fg/bg hex colors and support foregroundColorHex…
SujiChen Feb 27, 2026
10e6061
Merge branch 'main' of github.com:Study-Compass/Study-Compass into ME…
SujiChen Mar 13, 2026
df67b2f
MER-126-Admin-Outreach setting up admin outreach page
SujiChen Mar 13, 2026
4b4e047
MER-126-Admin-Outreach finish fronend of campaign page
SujiChen Mar 17, 2026
5792b66
MER-126-Admin-Outreach edit wording on the campaign page
SujiChen Mar 17, 2026
8896191
Merge branch 'main' of github.com:Study-Compass/Study-Compass into ME…
SujiChen Mar 17, 2026
94e0100
MER-126-Admin-Outreach finiished the who receives this message sectio…
SujiChen Mar 24, 2026
cb87758
MER-126-Admin-Outreach finished the Message section of the new outrea…
SujiChen Mar 24, 2026
461eca9
MER-126-Admin-Outreach finished the configuration page
SujiChen Apr 3, 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
4 changes: 4 additions & 0 deletions backend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ function createApp() {
const orgMessageRoutes = require('./routes/orgMessageRoutes.js');
const roomRoutes = require('./routes/roomRoutes.js');
const adminRoutes = require('./routes/adminRoutes.js');
const adminOutreachRoutes = require('./routes/adminOutreachRoutes.js');
const studentOutreachRoutes = require('./routes/studentOutreachRoutes.js');
const eventsRoutes = require('./events/index.js');
const notificationRoutes = require('./routes/notificationRoutes.js');
const qrRoutes = require('./routes/qrRoutes.js');
Expand Down Expand Up @@ -143,6 +145,8 @@ function createApp() {
app.use('/org-event-management', orgEventManagementRoutes);
app.use('/admin', roomRoutes);
app.use(adminRoutes);
app.use('/admin/outreach', adminOutreachRoutes);
app.use('/me', studentOutreachRoutes);
app.use(formRoutes);
app.use('/notifications', notificationRoutes);
app.use('/api/qr', qrRoutes);
Expand Down
3 changes: 2 additions & 1 deletion backend/constants/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ const SYSTEM_PERMISSIONS = {
MANAGE_SYSTEM_SETTINGS: 'manage_system_settings',
VIEW_SYSTEM_ANALYTICS: 'view_system_analytics',
MANAGE_GLOBAL_USERS: 'manage_global_users',
ACCESS_ADMIN_PANEL: 'access_admin_panel'
ACCESS_ADMIN_PANEL: 'access_admin_panel',
ADMIN_OUTREACH: 'admin_outreach'
};

// Permission groups for easier management
Expand Down
300 changes: 300 additions & 0 deletions backend/routes/adminOutreachRoutes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
const express = require('express');
const router = express.Router();
const getModels = require('../services/getModelService');
const { verifyToken, authorizeRoles } = require('../middlewares/verifyToken');
const { resolveAudience } = require('../services/studentTargetingService');
const { sendOutreachMessage, getMessageAnalytics } = require('../services/adminOutreachService');

const OUTREACH_ROLES = ['admin', 'root', 'oie'];

router.use(verifyToken);
router.use(authorizeRoles(...OUTREACH_ROLES));

/**
* POST /admin/outreach/audiences — create a saved audience
*/
router.post('/audiences', async (req, res) => {
try {
const { OutreachAudience } = getModels(req, 'OutreachAudience');
const { name, description, filterDefinition } = req.body;
if (!name || !filterDefinition || !filterDefinition.conditions || !Array.isArray(filterDefinition.conditions)) {
return res.status(400).json({
success: false,
message: 'name and filterDefinition.conditions are required',
code: 'VALIDATION_ERROR'
});
}
const audience = new OutreachAudience({
name: name.trim(),
description: (description || '').trim(),
filterDefinition,
createdBy: req.user.userId
});
await audience.save();
return res.status(201).json({ success: true, data: audience, message: 'Audience created' });
} catch (err) {
console.error('POST /admin/outreach/audiences', err);
return res.status(500).json({ success: false, message: err.message });
}
});

/**
* GET /admin/outreach/audiences — list audiences with pagination
*/
router.get('/audiences', async (req, res) => {
try {
const { OutreachAudience } = getModels(req, 'OutreachAudience');
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(50, Math.max(1, parseInt(req.query.limit) || 20));
const skip = (page - 1) * limit;
Comment on lines +47 to +49
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

declare all in one line for readability please (example const {page, limit, skip}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, do we really need all 3 pagination parameters (would just two suffice)

const search = (req.query.search || '').trim();
const query = search ? { name: new RegExp(escapeRegex(search), 'i') } : {};
const [items, total] = await Promise.all([
OutreachAudience.find(query).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(),
OutreachAudience.countDocuments(query)
]);
return res.json({
success: true,
data: items,
pagination: { page, limit, total, pages: Math.ceil(total / limit) }
});
} catch (err) {
console.error('GET /admin/outreach/audiences', err);
return res.status(500).json({ success: false, message: err.message });
}
});

function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/**
* GET /admin/outreach/audiences/:id — fetch a single audience
*/
router.get('/audiences/:id', async (req, res) => {
try {
const { OutreachAudience } = getModels(req, 'OutreachAudience');
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please clearly declare parameters

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try not to reference directly

const audience = await OutreachAudience.findById(req.params.id).lean();
if (!audience) {
return res.status(404).json({ success: false, message: 'Audience not found', code: 'NOT_FOUND' });
}
return res.json({ success: true, data: audience });
} catch (err) {
console.error('GET /admin/outreach/audiences/:id', err);
return res.status(500).json({ success: false, message: err.message });
}
});

/**
* POST /admin/outreach/audiences/preview — preview count/sample for a filter (no save)
*/
router.post('/audiences/preview', async (req, res) => {
try {
const filterDefinition = req.body.filterDefinition || req.body;
if (!filterDefinition.conditions || !Array.isArray(filterDefinition.conditions)) {
return res.status(400).json({
success: false,
message: 'filterDefinition.conditions required',
code: 'VALIDATION_ERROR'
});
}
const limit = Math.min(20, parseInt(req.body.limit) || 10);
const result = await resolveAudience(req, filterDefinition, { preview: true, limit });
return res.json({ success: true, data: result });
} catch (err) {
console.error('POST /admin/outreach/audiences/preview', err);
return res.status(500).json({ success: false, message: err.message });
}
});

/**
* PUT /admin/outreach/audiences/:id — update audience
*/
router.put('/audiences/:id', async (req, res) => {
try {
const { OutreachAudience } = getModels(req, 'OutreachAudience');
const { name, description, filterDefinition } = req.body;
const audience = await OutreachAudience.findById(req.params.id);
if (!audience) {
return res.status(404).json({ success: false, message: 'Audience not found', code: 'NOT_FOUND' });
}
if (name != null) audience.name = name.trim();
if (description != null) audience.description = description.trim();
if (filterDefinition != null) audience.filterDefinition = filterDefinition;
await audience.save();
return res.json({ success: true, data: audience, message: 'Audience updated' });
} catch (err) {
console.error('PUT /admin/outreach/audiences/:id', err);
return res.status(500).json({ success: false, message: err.message });
}
});

/**
* DELETE /admin/outreach/audiences/:id
*/
router.delete('/audiences/:id', async (req, res) => {
try {
const { OutreachAudience } = getModels(req, 'OutreachAudience');
const deleted = await OutreachAudience.findByIdAndDelete(req.params.id);
if (!deleted) {
return res.status(404).json({ success: false, message: 'Audience not found', code: 'NOT_FOUND' });
}
return res.json({ success: true, message: 'Audience deleted' });
} catch (err) {
console.error('DELETE /admin/outreach/audiences/:id', err);
return res.status(500).json({ success: false, message: err.message });
}
});

/**
* POST /admin/outreach/messages — create a draft message
*/
router.post('/messages', async (req, res) => {
try {
const { OutreachMessage, OutreachAudience } = getModels(req, 'OutreachMessage', 'OutreachAudience');
const { title, subject, body, channels, audienceId, filterDefinition } = req.body;
if (!title || !body) {
return res.status(400).json({
success: false,
message: 'title and body are required',
code: 'VALIDATION_ERROR'
});
}
if (audienceId) {
const aud = await OutreachAudience.findById(audienceId);
if (!aud) return res.status(400).json({ success: false, message: 'Audience not found', code: 'NOT_FOUND' });
} else if (!filterDefinition || !filterDefinition.conditions || !Array.isArray(filterDefinition.conditions)) {
return res.status(400).json({
success: false,
message: 'audienceId or filterDefinition.conditions required',
code: 'VALIDATION_ERROR'
});
}
const message = new OutreachMessage({
title: title.trim(),
subject: (subject || title).trim(),
body,
channels: Array.isArray(channels) ? channels : ['in_app'],
audienceId: audienceId || null,
filterDefinition: filterDefinition || null,
createdBy: req.user.userId,
status: 'draft'
});
await message.save();
return res.status(201).json({ success: true, data: message, message: 'Message created' });
} catch (err) {
console.error('POST /admin/outreach/messages', err);
return res.status(500).json({ success: false, message: err.message });
}
});

/**
* PUT /admin/outreach/messages/:id — update a draft message
*/
router.put('/messages/:id', async (req, res) => {
try {
const { OutreachMessage } = getModels(req, 'OutreachMessage');
const message = await OutreachMessage.findById(req.params.id);
if (!message) {
return res.status(404).json({ success: false, message: 'Message not found', code: 'NOT_FOUND' });
}
if (message.status !== 'draft') {
return res.status(400).json({ success: false, message: 'Only draft messages can be updated', code: 'INVALID_STATE' });
}
const { title, subject, body, channels, audienceId, filterDefinition } = req.body;
if (title != null) message.title = title.trim();
if (subject != null) message.subject = subject.trim();
if (body != null) message.body = body;
if (channels != null) message.channels = Array.isArray(channels) ? channels : message.channels;
if (audienceId != null) message.audienceId = audienceId;
if (filterDefinition != null) message.filterDefinition = filterDefinition;
await message.save();
return res.json({ success: true, data: message, message: 'Message updated' });
} catch (err) {
console.error('PUT /admin/outreach/messages/:id', err);
return res.status(500).json({ success: false, message: err.message });
}
});

/**
* POST /admin/outreach/messages/:id/send — trigger send
*/
router.post('/messages/:id/send', async (req, res) => {
try {
const result = await sendOutreachMessage(req, req.params.id);
return res.json({ success: true, data: result, message: 'Message sent' });
} catch (err) {
if (err.message === 'Outreach message not found') {
return res.status(404).json({ success: false, message: err.message, code: 'NOT_FOUND' });
}
if (err.message === 'Message already sent') {
return res.status(400).json({ success: false, message: err.message, code: 'INVALID_STATE' });
}
if (err.message === 'Audience not found' || err.message === 'Message has no audience or inline filter') {
return res.status(400).json({ success: false, message: err.message, code: 'VALIDATION_ERROR' });
}
console.error('POST /admin/outreach/messages/:id/send', err);
return res.status(500).json({ success: false, message: err.message });
}
});

/**
* GET /admin/outreach/messages — list messages with pagination
*/
router.get('/messages', async (req, res) => {
try {
const { OutreachMessage } = getModels(req, 'OutreachMessage');
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(50, Math.max(1, parseInt(req.query.limit) || 20));
const skip = (page - 1) * limit;
const status = req.query.status;
const query = status ? { status } : {};
const [items, total] = await Promise.all([
OutreachMessage.find(query).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(),
OutreachMessage.countDocuments(query)
]);
return res.json({
success: true,
data: items,
pagination: { page, limit, total, pages: Math.ceil(total / limit) }
});
} catch (err) {
console.error('GET /admin/outreach/messages', err);
return res.status(500).json({ success: false, message: err.message });
}
});

/**
* GET /admin/outreach/messages/:id — fetch one message
*/
router.get('/messages/:id', async (req, res) => {
try {
const { OutreachMessage } = getModels(req, 'OutreachMessage');
const message = await OutreachMessage.findById(req.params.id).lean();
if (!message) {
return res.status(404).json({ success: false, message: 'Message not found', code: 'NOT_FOUND' });
}
return res.json({ success: true, data: message });
} catch (err) {
console.error('GET /admin/outreach/messages/:id', err);
return res.status(500).json({ success: false, message: err.message });
}
});

/**
* GET /admin/outreach/messages/:id/analytics — aggregate metrics
*/
router.get('/messages/:id/analytics', async (req, res) => {
try {
const analytics = await getMessageAnalytics(req, req.params.id);
if (!analytics) {
return res.status(404).json({ success: false, message: 'Message not found', code: 'NOT_FOUND' });
}
return res.json({ success: true, data: analytics });
} catch (err) {
console.error('GET /admin/outreach/messages/:id/analytics', err);
return res.status(500).json({ success: false, message: err.message });
}
});

module.exports = router;
Loading