diff --git a/backend/src/config/acl.js b/backend/src/config/acl.js
index fb333c5..1216ffa 100644
--- a/backend/src/config/acl.js
+++ b/backend/src/config/acl.js
@@ -5,6 +5,7 @@ export const ACL_MODULES = [
{ key: 'sessions', label: 'Sessions' },
{ key: 'onboarding', label: 'Patients' },
{ key: 'cohorts', label: 'Cohorts' },
+ { key: 'prt_cohorts', label: 'Add PRT cohorts' },
{ key: 'holidays', label: 'Holidays' },
{ key: 'leaves', label: 'Leave' },
{ key: 'matching', label: 'Matching' },
@@ -29,6 +30,7 @@ export const SYSTEM_ROLE_PERMISSIONS = {
perm('sessions', ['view', 'add', 'edit', 'delete']),
perm('onboarding', ['view', 'add', 'edit', 'delete']),
perm('cohorts', ['view', 'add', 'edit', 'delete']),
+ perm('prt_cohorts', ['view', 'add', 'edit', 'delete']),
perm('holidays', ['view', 'add', 'edit', 'delete']),
perm('leaves', ['view', 'add', 'edit', 'delete']),
perm('matching', ['view', 'add', 'edit', 'delete']),
@@ -43,6 +45,7 @@ export const SYSTEM_ROLE_PERMISSIONS = {
perm('sessions', ['view', 'add', 'edit', 'delete']),
perm('onboarding', ['view', 'add', 'edit', 'delete']),
perm('cohorts', ['view', 'add', 'edit', 'delete']),
+ perm('prt_cohorts', ['view', 'add', 'edit', 'delete']),
perm('holidays', ['view', 'add', 'edit', 'delete']),
perm('leaves', ['view', 'add', 'edit', 'delete']),
perm('matching', ['view', 'add', 'edit', 'delete']),
@@ -53,6 +56,7 @@ export const SYSTEM_ROLE_PERMISSIONS = {
educator: [
perm('onboarding', ['view', 'add', 'edit', 'delete'], 'assigned'),
perm('cohorts', ['view'], 'assigned'),
+ perm('prt_cohorts', ['view', 'add', 'edit', 'delete'], 'assigned'),
perm('holidays', ['view']),
perm('leaves', ['view', 'add', 'edit'], 'assigned'),
perm('program_sessions', ['view', 'edit'], 'assigned'),
diff --git a/backend/src/models/Cohort.js b/backend/src/models/Cohort.js
index b003f67..2f0dc7f 100644
--- a/backend/src/models/Cohort.js
+++ b/backend/src/models/Cohort.js
@@ -7,6 +7,8 @@ const cohortSchema = new mongoose.Schema(
slot: { type: String, required: true, trim: true },
language: { type: String, default: '', trim: true },
scheduleId: { type: String, default: '', trim: true }, // blank means Normal; otherwise an extra schedule id
+ createdAs: { type: String, enum: ['cohorts', 'manual_cohorts'], default: 'cohorts' },
+ sixDayWeek: { type: Boolean, default: false }, // manual cohort: yes allows any start except Sunday
diseaseType: { type: String, default: '', trim: true },
status: {
type: String,
diff --git a/backend/src/plugins/auth.js b/backend/src/plugins/auth.js
index f0e0fa6..840e80d 100644
--- a/backend/src/plugins/auth.js
+++ b/backend/src/plugins/auth.js
@@ -73,14 +73,17 @@ export function authResponse(user, token) {
}
export function requirePermission(moduleKey, action) {
+ return requireAnyPermission([moduleKey], action);
+}
+
+// Allows the action when any one of the modules grants it.
+export function requireAnyPermission(moduleKeys, action) {
return (request, h) => {
const creds = request.auth?.credentials;
if (!creds?.user) throw Boom.unauthorized();
if (creds.isSuperAdmin) return h.continue;
- const actions = creds.modules?.[moduleKey] || [];
- if (!Array.isArray(actions) || !actions.includes(action)) {
- throw Boom.forbidden('Insufficient permissions.');
- }
+ const allowed = moduleKeys.some((moduleKey) => (creds.modules?.[moduleKey] || []).includes(action));
+ if (!allowed) throw Boom.forbidden('Insufficient permissions.');
return h.continue;
};
}
@@ -116,10 +119,31 @@ export async function onboardingScopeFilter(request) {
};
}
+// Cohorts this user may see: the type their permission allows, and only their own when that permission is assigned.
export function cohortScopeFilter(request) {
const creds = request.auth.credentials;
- if (creds.isSuperAdmin || creds.scopes?.cohorts !== 'assigned') return {};
- return { virtualPrt: creds.user._id };
+ if (creds.isSuperAdmin) return {};
+ const canView = (key) => (creds.modules?.[key] || []).includes('view');
+ const mine = { virtualPrt: creds.user._id };
+ const normalType = {
+ $or: [
+ { createdAs: 'cohorts' },
+ { createdAs: { $exists: false } },
+ { createdAs: null },
+ { createdAs: '' },
+ ],
+ };
+ const clauses = [];
+ if (canView('cohorts')) {
+ clauses.push(creds.scopes?.cohorts === 'assigned' ? { $and: [normalType, mine] } : normalType);
+ }
+ if (canView('prt_cohorts')) {
+ const manualType = { createdAs: 'manual_cohorts' };
+ clauses.push(creds.scopes?.prt_cohorts === 'assigned' ? { ...manualType, virtualPrt: creds.user._id } : manualType);
+ }
+ if (!clauses.length) return { _id: null };
+ if (clauses.length === 1) return clauses[0];
+ return { $or: clauses };
}
export function leaveScopeFilter(request) {
diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js
index 08bc72b..61e73a0 100644
--- a/backend/src/routes/auth.js
+++ b/backend/src/routes/auth.js
@@ -7,6 +7,7 @@ import { env } from '../config/env.js';
import { User } from '../models/User.js';
import { sendPasswordResetEmail } from '../services/mail.js';
import { writeAudit, clientIp } from '../services/audit.js';
+import { isMobileNumber } from '../services/cryptoPii.js';
import { hitRateLimit } from '../services/rateLimit.js';
import {
applySessionCookies,
@@ -117,6 +118,9 @@ export function authRoutes(server) {
if (!fresh || !fresh.isActive) throw Boom.unauthorized();
fresh.firstName = request.payload.firstName;
fresh.lastName = request.payload.lastName;
+ if (request.payload.phone != null && String(request.payload.phone).trim() && !isMobileNumber(request.payload.phone)) {
+ throw Boom.badRequest('Mobile must be 10 digits.');
+ }
if (request.payload.phone != null) fresh.phone = request.payload.phone;
if (request.payload.specializations) fresh.specializations = request.payload.specializations;
if (request.payload.availableSlots) fresh.availableSlots = request.payload.availableSlots;
diff --git a/backend/src/routes/cohorts.js b/backend/src/routes/cohorts.js
index d263412..fc0295c 100644
--- a/backend/src/routes/cohorts.js
+++ b/backend/src/routes/cohorts.js
@@ -1,13 +1,13 @@
import Joi from 'joi';
import Boom from '@hapi/boom';
-import { requirePermission, cohortScopeFilter, credentials } from '../plugins/auth.js';
+import { requireAnyPermission, cohortScopeFilter, credentials } from '../plugins/auth.js';
import { Cohort } from '../models/Cohort.js';
import { Role } from '../models/Role.js';
import { User } from '../models/User.js';
import { OnboardedPatient } from '../models/OnboardedPatient.js';
import { toCohortDto, userLanguages } from '../utils/serialize.js';
import { assertCohortStartDate, buildCohortName, scheduleSessionCount, scheduleWeekdays, toYmd, upcomingSessionDates, ymdToDate } from '../services/schedule.js';
-import { releaseCohortPatients, syncCohortPatients } from '../services/cohorts.js';
+import { releaseCohortPatients, syncCohortPatients, applyCohortToPatients, createManualCohortPatients } from '../services/cohorts.js';
import { activateManualCohort } from '../services/matching.js';
import { ensureSessionRecords, persistCohortSessions, rescheduleOpenSessions } from '../services/programSessions.js';
import { CohortSession } from '../models/CohortSession.js';
@@ -26,8 +26,23 @@ const cohortPayload = Joi.object({
patients: Joi.array().items(objectId).default([]),
durationMinutes: Joi.number().min(15).max(180).optional(),
scheduleId: Joi.string().trim().allow('', null),
+ createdAs: Joi.string().valid('cohorts', 'manual_cohorts'),
+ sixDayWeek: Joi.boolean(),
+ newPatients: Joi.array().items(Joi.object({
+ name: Joi.string().trim().required(),
+ mobile: Joi.string().trim().pattern(/^\d{10}$/).required(),
+ age: Joi.alternatives().try(Joi.number(), Joi.string().allow('')).optional(),
+ gender: Joi.string().trim().allow('').optional(),
+ whatsappOptOut: Joi.boolean().optional(),
+ })).default([]),
});
+// Manual cohorts start on Monday–Saturday, or only Monday and Tuesday.
+function startWeekdaysFor(payload, scheduleDays) {
+ if (payload.createdAs !== 'manual_cohorts') return scheduleDays;
+ return payload.sixDayWeek ? [1, 2, 3, 4, 5, 6] : [1, 2];
+}
+
// Saved extra-schedule id to its weekdays. Blank means Normal.
async function chosenWeekdays(scheduleId) {
const id = String(scheduleId || '').trim();
@@ -88,7 +103,7 @@ export function cohortRoutes(server) {
server.route({
method: 'GET',
path: '/api/cohorts',
- options: { pre: [{ method: requirePermission('cohorts', 'view') }] },
+ options: { pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'view') }] },
handler: async (request) => {
const { search = '', page = 1, limit = 20, status } = request.query;
const filter = { ...cohortScopeFilter(request) };
@@ -118,7 +133,7 @@ export function cohortRoutes(server) {
method: 'POST',
path: '/api/cohorts',
options: {
- pre: [{ method: requirePermission('cohorts', 'add') }],
+ pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'add') }],
validate: { payload: cohortPayload },
},
handler: async (request) => {
@@ -133,11 +148,11 @@ export function cohortRoutes(server) {
const language = request.payload.language || prtLanguages[0];
if (!prtLanguages.includes(language)) throw Boom.badRequest('Select a language this Virtual PRT can teach.');
await assertPatients(patients);
- if (patients.length > (settings.maxPatientsPerCohort || 10)) {
+ if (request.payload.createdAs !== 'manual_cohorts' && patients.length > (settings.maxPatientsPerCohort || 10)) {
throw Boom.badRequest(`A cohort can have at most ${settings.maxPatientsPerCohort || 10} patients.`);
}
try {
- await assertCohortStartDate(startDate, virtualPrt, language, await chosenWeekdays(scheduleId));
+ await assertCohortStartDate(startDate, virtualPrt, language, startWeekdaysFor(request.payload, await chosenWeekdays(scheduleId)));
} catch (err) {
throw Boom.badRequest(err.message);
}
@@ -149,13 +164,30 @@ export function cohortRoutes(server) {
language,
scheduleId,
diseaseType: request.payload.diseaseType || '',
+ createdAs: request.payload.createdAs || 'cohorts',
+ sixDayWeek: Boolean(request.payload.sixDayWeek),
status: 'active',
virtualPrt,
patients,
durationMinutes,
});
+ if (cohort.createdAs === 'manual_cohorts') {
+ let createdIds = [];
+ try {
+ createdIds = await createManualCohortPatients(cohort, request.payload.newPatients, request.auth.credentials.user._id);
+ if (createdIds.length) {
+ cohort.patients = [...(cohort.patients || []), ...createdIds];
+ await cohort.save();
+ }
+ } catch (err) {
+ if (createdIds.length) await OnboardedPatient.deleteMany({ _id: { $in: createdIds } });
+ await Cohort.deleteOne({ _id: cohort._id });
+ throw err;
+ }
+ }
await syncCohortPatients(cohort, []);
await activateManualCohort(cohort);
+ if (cohort.createdAs === 'manual_cohorts') await applyCohortToPatients(cohort);
const ready = await Cohort.findById(cohort._id)
.populate('virtualPrt', 'firstName lastName email')
.populate({ path: 'patients', match: { reviewStatus: 'confirmed' }, select: 'name mobile whatsappOptOut' });
@@ -167,7 +199,7 @@ export function cohortRoutes(server) {
method: 'GET',
path: '/api/cohorts/{id}',
options: {
- pre: [{ method: requirePermission('cohorts', 'view') }],
+ pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'view') }],
validate: { params: Joi.object({ id: objectId.required() }) },
},
handler: async (request) => (await withSessionCounts(await findCohort(request)))[0],
@@ -177,7 +209,7 @@ export function cohortRoutes(server) {
method: 'GET',
path: '/api/cohorts/{id}/sessions',
options: {
- pre: [{ method: requirePermission('cohorts', 'view') }],
+ pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'view') }],
validate: {
params: Joi.object({ id: objectId.required() }),
query: Joi.object({ count: Joi.number().min(1).max(60) }),
@@ -197,7 +229,7 @@ export function cohortRoutes(server) {
method: 'PATCH',
path: '/api/cohorts/{id}',
options: {
- pre: [{ method: requirePermission('cohorts', 'edit') }],
+ pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'edit') }],
validate: {
params: Joi.object({ id: objectId.required() }),
payload: cohortPayload,
@@ -226,11 +258,11 @@ export function cohortRoutes(server) {
const language = request.payload.language || prtLanguages[0];
if (!prtLanguages.includes(language)) throw Boom.badRequest('Select a language this Virtual PRT can teach.');
await assertPatients(patients);
- if (patients.length > (settings.maxPatientsPerCohort || 10)) {
+ if (request.payload.createdAs !== 'manual_cohorts' && patients.length > (settings.maxPatientsPerCohort || 10)) {
throw Boom.badRequest(`A cohort can have at most ${settings.maxPatientsPerCohort || 10} patients.`);
}
try {
- await assertCohortStartDate(startDate, virtualPrt, language, await chosenWeekdays(scheduleId));
+ await assertCohortStartDate(startDate, virtualPrt, language, startWeekdaysFor(request.payload, await chosenWeekdays(scheduleId)));
} catch (err) {
throw Boom.badRequest(err.message);
}
@@ -240,10 +272,17 @@ export function cohortRoutes(server) {
cohort.language = language;
cohort.scheduleId = scheduleId;
cohort.diseaseType = request.payload.diseaseType || cohort.diseaseType || '';
+ if (request.payload.createdAs) cohort.createdAs = request.payload.createdAs;
+ if (request.payload.sixDayWeek !== undefined) cohort.sixDayWeek = Boolean(request.payload.sixDayWeek);
cohort.virtualPrt = virtualPrt;
cohort.patients = patients;
cohort.durationMinutes = durationMinutes;
if (cohort.status === 'proposed') cohort.status = 'active';
+ const manual = (cohort.createdAs || request.payload.createdAs) === 'manual_cohorts';
+ if (manual) {
+ const createdIds = await createManualCohortPatients(cohort, request.payload.newPatients, request.auth.credentials.user._id);
+ if (createdIds.length) cohort.patients = [...patients, ...createdIds];
+ }
await cohort.save();
await syncCohortPatients(cohort, previous);
const { onCohortUpdated } = await import('../services/whatsapp.js');
@@ -253,6 +292,7 @@ export function cohortRoutes(server) {
if (scheduleChanged) await rescheduleOpenSessions(cohort);
else if (await CohortSession.countDocuments({ cohort: cohort._id })) await ensureSessionRecords(cohort);
else await persistCohortSessions(cohort);
+ if ((cohort.createdAs || '') === 'manual_cohorts') await applyCohortToPatients(cohort);
const ready = await Cohort.findById(cohort._id)
.populate('virtualPrt', 'firstName lastName email')
.populate({ path: 'patients', match: { reviewStatus: 'confirmed' }, select: 'name mobile whatsappOptOut' });
@@ -264,7 +304,7 @@ export function cohortRoutes(server) {
method: 'DELETE',
path: '/api/cohorts/{id}',
options: {
- pre: [{ method: requirePermission('cohorts', 'delete') }],
+ pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'delete') }],
validate: { params: Joi.object({ id: objectId.required() }) },
},
handler: async (request) => {
@@ -280,7 +320,7 @@ export function cohortRoutes(server) {
method: 'POST',
path: '/api/cohorts/{id}/whatsapp/retry',
options: {
- pre: [{ method: requirePermission('cohorts', 'edit') }],
+ pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'edit') }],
validate: { params: Joi.object({ id: objectId.required() }) },
},
handler: async (request) => {
@@ -297,7 +337,7 @@ export function cohortRoutes(server) {
method: 'POST',
path: '/api/cohorts/{id}/complete',
options: {
- pre: [{ method: requirePermission('cohorts', 'edit') }],
+ pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'edit') }],
validate: { params: Joi.object({ id: objectId.required() }) },
},
handler: async (request) => {
@@ -322,7 +362,7 @@ export function virtualPrtRoutes(server) {
server.route({
method: 'GET',
path: '/api/users/virtual-prts',
- options: { pre: [{ method: requirePermission('cohorts', 'view') }] },
+ options: { pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'view') }] },
handler: async () => {
const role = await Role.findOne({ systemKey: 'educator' });
if (!role) return { items: [] };
diff --git a/backend/src/routes/onboarding.js b/backend/src/routes/onboarding.js
index 4ee9823..314a976 100644
--- a/backend/src/routes/onboarding.js
+++ b/backend/src/routes/onboarding.js
@@ -9,7 +9,7 @@ import { Cohort } from '../models/Cohort.js';
import { User } from '../models/User.js';
import { toOnboardedPatientDto, toCohortDto } from '../utils/serialize.js';
import { saveOnboardingScreenshot, screenshotPath, safeDownloadName } from '../services/onboardingUpload.js';
-import { applyOnboardedPii, hashMobile, hashName, readOnboardedPii } from '../services/cryptoPii.js';
+import { applyOnboardedPii, hashMobile, hashName, isMobileNumber, readOnboardedPii } from '../services/cryptoPii.js';
import { deleteScreenshot, presignGet, s3Enabled } from '../services/s3.js';
import { writeAudit } from '../services/audit.js';
import { addPatientToCohort, removePatientFromCohort } from '../services/cohorts.js';
@@ -116,7 +116,7 @@ function applyMasterPatient(fields, master, settings) {
function assertIdentity(fields) {
if (!fields.name) throw Boom.badRequest('Name is required');
- if (!fields.mobile) throw Boom.badRequest('Mobile is required');
+ if (!isMobileNumber(fields.mobile)) throw Boom.badRequest('Mobile must be 10 digits.');
}
function assertPlacement(fields, settings) {
diff --git a/backend/src/routes/patients.js b/backend/src/routes/patients.js
index 8f4db96..bce592c 100644
--- a/backend/src/routes/patients.js
+++ b/backend/src/routes/patients.js
@@ -5,6 +5,7 @@ import { Patient } from '../models/Patient.js';
import { Session } from '../models/Session.js';
import { User } from '../models/User.js';
import { toPatientDto, toSessionDto } from '../utils/serialize.js';
+import { isMobileNumber } from '../services/cryptoPii.js';
import { importWorkbook, payloadToBuffer } from '../services/importPatients.js';
const objectId = Joi.string().hex().length(24);
@@ -31,7 +32,8 @@ const patientPayload = Joi.object({
function applyPatientFields(patient, body, canAssign) {
patient.name = body.name;
- patient.mobile = body.mobile || '';
+ if (body.mobile && !isMobileNumber(body.mobile)) throw Boom.badRequest('Mobile must be 10 digits.');
+ patient.mobile = body.mobile ? String(body.mobile).trim() : '';
patient.age = body.age ?? null;
patient.gender = body.gender || '';
patient.lungCondition = body.lungCondition || '';
diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js
index 98721e4..5aa2d9b 100644
--- a/backend/src/routes/users.js
+++ b/backend/src/routes/users.js
@@ -8,6 +8,7 @@ import { toUserDto, userLanguages } from '../utils/serialize.js';
import { flagPrtResignation } from '../services/matching.js';
import { writeAudit } from '../services/audit.js';
import { revokeUserRefreshSessions } from '../services/sessionCookies.js';
+import { isMobileNumber } from '../services/cryptoPii.js';
function normalizeLanguages(payload) {
const list = Array.isArray(payload?.languages)
@@ -122,6 +123,7 @@ export function userRoutes(server) {
}
const email = request.payload.email.toLowerCase().trim();
if (await User.findOne({ email })) throw Boom.conflict('Email already registered');
+ if (request.payload.phone && !isMobileNumber(request.payload.phone)) throw Boom.badRequest('Mobile must be 10 digits.');
const user = await User.create({
firstName: request.payload.firstName,
lastName: request.payload.lastName,
@@ -193,6 +195,9 @@ export function userRoutes(server) {
}
if (body.firstName) user.firstName = body.firstName;
if (body.lastName) user.lastName = body.lastName;
+ if (body.phone != null && String(body.phone).trim() && !isMobileNumber(body.phone)) {
+ throw Boom.badRequest('Mobile must be 10 digits.');
+ }
if (body.phone != null) user.phone = body.phone;
if (body.languages != null || body.language != null) {
const languages = normalizeLanguages(body);
diff --git a/backend/src/services/cohorts.js b/backend/src/services/cohorts.js
index 52bcb53..b77f3a2 100644
--- a/backend/src/services/cohorts.js
+++ b/backend/src/services/cohorts.js
@@ -1,6 +1,9 @@
import { Cohort } from '../models/Cohort.js';
import { OnboardedPatient } from '../models/OnboardedPatient.js';
import { addPatientToWhatsapp, removePatientFromWhatsapp, syncWhatsappMembership } from './whatsapp.js';
+import { applyOnboardedPii, hashMobile, isMobileNumber } from './cryptoPii.js';
+import { toYmd, weekdayOf } from './schedule.js';
+import Boom from '@hapi/boom';
async function safeWhatsapp(label, fn) {
try {
@@ -70,3 +73,63 @@ export async function releaseCohortPatients(cohort) {
const previous = (cohort.patients || []).map((p) => String(p._id || p));
await syncCohortPatients({ ...cohort.toObject?.() || cohort, patients: [] }, previous);
}
+
+// Cohort placement copied onto each patient so the record matches the cohort.
+export function cohortPatientFields(cohort) {
+ const start = toYmd(cohort.startDate);
+ return {
+ matchingStatus: 'assigned',
+ assignedEducator: cohort.virtualPrt?._id || cohort.virtualPrt,
+ cohort: cohort._id,
+ slot: cohort.slot || '',
+ preferredWeekday: start ? weekdayOf(start) : null,
+ sessionDate: cohort.startDate || null,
+ sessionLanguage: cohort.language || '',
+ diseaseType: cohort.diseaseType || '',
+ };
+}
+
+export async function applyCohortToPatients(cohort) {
+ const ids = (cohort.patients || []).map((item) => item._id || item).filter(Boolean);
+ if (!ids.length || !cohort._id) return;
+ await OnboardedPatient.updateMany({ _id: { $in: ids } }, { $set: cohortPatientFields(cohort) });
+}
+
+// Creates confirmed patients for a manual cohort and returns their ids.
+export async function createManualCohortPatients(cohort, rows, userId) {
+ const list = (Array.isArray(rows) ? rows : []).map((row) => ({
+ name: String(row?.name || '').trim(),
+ mobile: String(row?.mobile || '').trim(),
+ age: row?.age ?? '',
+ gender: String(row?.gender || '').trim(),
+ whatsappOptOut: Boolean(row?.whatsappOptOut),
+ })).filter((row) => row.name || row.mobile);
+ if (!list.length) return [];
+ if (list.some((row) => !row.name || !isMobileNumber(row.mobile))) throw Boom.badRequest('Each patient needs a name and a 10 digit mobile.');
+ const hashes = list.map((row) => hashMobile(row.mobile));
+ if (hashes.some((hash) => !hash)) throw Boom.badRequest('Each patient needs a mobile number.');
+ if (new Set(hashes).size !== hashes.length) throw Boom.badRequest('Each patient needs a different mobile.');
+ const taken = await OnboardedPatient.find({ mobileHash: { $in: hashes } }).select('_id');
+ if (taken.length) throw Boom.badRequest('A patient with that mobile is already in the system.');
+ const placement = cohortPatientFields(cohort);
+ const created = [];
+ try {
+ for (const row of list) {
+ const doc = await OnboardedPatient.create({
+ ...applyOnboardedPii({}, row),
+ ...placement,
+ paymentStatus: 'Unpaid',
+ onboardedBy: userId,
+ reviewStatus: 'confirmed',
+ reviewedBy: userId,
+ reviewedAt: new Date(),
+ whatsappOptOut: row.whatsappOptOut,
+ });
+ created.push(doc._id);
+ }
+ } catch (err) {
+ if (created.length) await OnboardedPatient.deleteMany({ _id: { $in: created } });
+ throw err;
+ }
+ return created;
+}
diff --git a/backend/src/services/cryptoPii.js b/backend/src/services/cryptoPii.js
index 54cff2a..034e583 100644
--- a/backend/src/services/cryptoPii.js
+++ b/backend/src/services/cryptoPii.js
@@ -70,6 +70,10 @@ export function hashName(name) {
return crypto.createHmac('sha256', pepper()).update(normalized).digest('hex');
}
+export function isMobileNumber(mobile) {
+ return /^\d{10}$/.test(String(mobile || '').trim());
+}
+
export function applyOnboardedPii(target, fields) {
if (fields.name != null) {
target.name = encryptPii(fields.name);
diff --git a/backend/src/services/programSessions.js b/backend/src/services/programSessions.js
index e06c96b..707c7bc 100644
--- a/backend/src/services/programSessions.js
+++ b/backend/src/services/programSessions.js
@@ -63,11 +63,17 @@ function recordsFor(ids) {
return ids.map((patient) => ({ patient, attended: false, pre: {}, post: {} }));
}
+// Add PRT cohorts use 60 meetings when all weekdays is yes, otherwise 30.
+function manualSessionCount(cohort) {
+ if (cohort?.createdAs !== 'manual_cohorts') return 0;
+ return cohort.sixDayWeek ? 60 : 30;
+}
+
// Creates the session list once. If rows exist, only adds dates when the list is short.
export async function persistCohortSessions(cohort) {
const settings = await getAppSettings();
const ownCount = await scheduleSessionCount(cohort.scheduleId);
- const needed = Math.min(Math.max(Number(ownCount || settings.upcomingSessionCount) || 30, 1), 60);
+ const needed = Math.min(Math.max(Number(manualSessionCount(cohort) || ownCount || settings.upcomingSessionCount) || 30, 1), 60);
const weekdays = await scheduleWeekdays(cohort.scheduleId);
const existing = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1, date: 1 });
const active = existing.filter((session) => session.status !== 'cancelled');
@@ -179,7 +185,7 @@ async function syncExistingMeetSeries(cohort, sessions) {
export async function rescheduleOpenSessions(cohort) {
const settings = await getAppSettings();
const ownCount = await scheduleSessionCount(cohort.scheduleId);
- const cap = Math.min(Math.max(Number(ownCount || settings.upcomingSessionCount) || 30, 1), 60);
+ const cap = Math.min(Math.max(Number(manualSessionCount(cohort) || ownCount || settings.upcomingSessionCount) || 30, 1), 60);
const weekdays = await scheduleWeekdays(cohort.scheduleId);
const existing = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1, date: 1 });
const completed = existing.filter((session) => session.status === 'completed');
diff --git a/backend/src/utils/serialize.js b/backend/src/utils/serialize.js
index 6f8fc38..4db243a 100644
--- a/backend/src/utils/serialize.js
+++ b/backend/src/utils/serialize.js
@@ -254,6 +254,8 @@ export function toCohortDto(cohort) {
slot: cohort.slot || '',
language: cohort.language || '',
scheduleId: cohort.scheduleId || '',
+ createdAs: cohort.createdAs || 'cohorts',
+ sixDayWeek: Boolean(cohort.sixDayWeek),
diseaseType: cohort.diseaseType || '',
status: cohort.status || 'active',
needsReassignment: Boolean(cohort.needsReassignment),
diff --git a/frontend/src/components/PrivateRoute.jsx b/frontend/src/components/PrivateRoute.jsx
index d48c592..a9eb205 100644
--- a/frontend/src/components/PrivateRoute.jsx
+++ b/frontend/src/components/PrivateRoute.jsx
@@ -24,6 +24,13 @@ export function RequirePermission({ module, action, children }) {
return children;
}
+export function RequireAnyPermission({ checks, children }) {
+ const { can, loading } = useAuth();
+ if (loading) return