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 ; + if (!checks.some(([moduleKey, action]) => can(moduleKey, action))) return ; + return children; +} + export function RequireAdmin({ children }) { const { isAdmin, loading } = useAuth(); if (loading) return ; diff --git a/frontend/src/hooks/useSettings.js b/frontend/src/hooks/useSettings.js index 9ff9ffb..05c0498 100644 --- a/frontend/src/hooks/useSettings.js +++ b/frontend/src/hooks/useSettings.js @@ -19,6 +19,14 @@ export function buildCohortName({ slot, language, startDate }) { return `${slot} - ${right}`; } +export function buildPrtCohortName({ slot, language, weekdays }) { + const days = formatWeekdays(weekdays); + const right = [language, days].filter(Boolean).join(' '); + if (!slot) return right; + if (!right) return slot; + return `${slot} - ${right}`; +} + export function formatInterval(days) { const value = Number(days) || 2; if (value === 1) return 'every day'; diff --git a/frontend/src/lib/mobile.js b/frontend/src/lib/mobile.js new file mode 100644 index 0000000..e6cf9df --- /dev/null +++ b/frontend/src/lib/mobile.js @@ -0,0 +1,7 @@ +export function mobileDigits(value) { + return String(value || '').replace(/\D/g, '').slice(0, 10); +} + +export function isMobileNumber(value) { + return /^\d{10}$/.test(String(value || '').trim()); +} diff --git a/frontend/src/routes/index.jsx b/frontend/src/routes/index.jsx index 55d818f..449d989 100644 --- a/frontend/src/routes/index.jsx +++ b/frontend/src/routes/index.jsx @@ -1,7 +1,7 @@ import { lazy } from 'react'; import { Navigate } from 'react-router'; import MainLayout from '@/layouts/MainLayout'; -import { GuestRoute, PrivateRoute, RequirePermission, RequireSuperAdmin } from '@/components/PrivateRoute'; +import { GuestRoute, PrivateRoute, RequireAnyPermission, RequirePermission, RequireSuperAdmin } from '@/components/PrivateRoute'; const Dashboard = lazy(() => import('@/views/virtualpr/dashboard')); const Patients = lazy(() => import('@/views/virtualpr/patients')); @@ -16,6 +16,8 @@ const Onboarding = lazy(() => import('@/views/virtualpr/onboarding')); const OnboardingForm = lazy(() => import('@/views/virtualpr/onboarding/form')); const Cohorts = lazy(() => import('@/views/virtualpr/cohorts')); const CohortForm = lazy(() => import('@/views/virtualpr/cohorts/form')); +const PrtCohortForm = lazy(() => import('@/views/virtualpr/cohorts/prt-form')); +const EditCohort = lazy(() => import('@/views/virtualpr/cohorts/edit')); const Holidays = lazy(() => import('@/views/virtualpr/holidays')); const Leaves = lazy(() => import('@/views/virtualpr/leaves')); const Settings = lazy(() => import('@/views/virtualpr/settings')); @@ -83,9 +85,9 @@ export const routes = [ { path: '/cohorts', element: ( - + - + ), }, { @@ -96,12 +98,20 @@ export const routes = [ ), }, + { + path: '/cohorts/prt/new', + element: ( + + + + ), + }, { path: '/cohorts/:id/edit', element: ( - - - + + + ), }, { diff --git a/frontend/src/views/virtualpr/cohorts/edit.jsx b/frontend/src/views/virtualpr/cohorts/edit.jsx new file mode 100644 index 0000000..e426c66 --- /dev/null +++ b/frontend/src/views/virtualpr/cohorts/edit.jsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router'; +import { Container } from 'react-bootstrap'; +import PageBreadcrumb from '@/components/PageBreadcrumb'; +import { useAuth } from '@/context/useAuthContext'; +import { api } from '@/lib/api'; +import CohortForm from './form'; +import PrtCohortForm from './prt-form'; + +export default function EditCohort() { + const { id } = useParams(); + const { can } = useAuth(); + const [cohort, setCohort] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + let active = true; + api(`/api/cohorts/${id}`).then((data) => { + if (active) setCohort(data); + }).catch((err) => { + if (active) setError(err.message); + }); + return () => { active = false; }; + }, [id]); + + if (error) { + return ( + + +
{error}
+
+ ); + } + if (!cohort) return null; + const createdAs = cohort.createdAs || 'cohorts'; + const allowed = createdAs === 'manual_cohorts' ? can('prt_cohorts', 'edit') : can('cohorts', 'edit'); + if (!allowed) { + return ( + + +
You do not have permission to edit this cohort.
+
+ ); + } + if (createdAs === 'manual_cohorts') return ; + return ; +} diff --git a/frontend/src/views/virtualpr/cohorts/form.jsx b/frontend/src/views/virtualpr/cohorts/form.jsx index 28f70c7..2aed2a7 100644 --- a/frontend/src/views/virtualpr/cohorts/form.jsx +++ b/frontend/src/views/virtualpr/cohorts/form.jsx @@ -158,7 +158,7 @@ export default function CohortForm() { return; } setSaving(true); - const payload = { ...form }; + const payload = isEdit ? { ...form } : { ...form, createdAs: 'cohorts' }; try { if (isEdit) await api(`/api/cohorts/${id}`, { method: 'PATCH', body: payload }); else await api('/api/cohorts', { method: 'POST', body: payload }); diff --git a/frontend/src/views/virtualpr/cohorts/index.jsx b/frontend/src/views/virtualpr/cohorts/index.jsx index 9fa466d..5e1fa5a 100644 --- a/frontend/src/views/virtualpr/cohorts/index.jsx +++ b/frontend/src/views/virtualpr/cohorts/index.jsx @@ -17,7 +17,11 @@ function formatDate(value) { } export default function CohortsPage() { - const { can } = useAuth(); + const { can, user } = useAuth(); + const isVirtualPrt = user?.role?.systemKey === 'educator'; + const canFor = (createdAs, action) => ( + (createdAs || 'cohorts') === 'manual_cohorts' ? can('prt_cohorts', action) : can('cohorts', action) + ); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(0); @@ -51,7 +55,7 @@ export default function CohortsPage() { columnHelper.accessor('name', { header: 'Cohort', cell: ({ row }) => ( - can('cohorts', 'edit') + canFor(row.original.createdAs, 'edit') ? {row.original.name} : {row.original.name} ), @@ -86,8 +90,8 @@ export default function CohortsPage() { header: 'Actions', cell: ({ row }) => (
- {can('cohorts', 'edit') ? : null} - {can('cohorts', 'delete') ? ( + {canFor(row.original.createdAs, 'edit') ? : null} + {canFor(row.original.createdAs, 'delete') ? ( @@ -127,7 +131,12 @@ export default function CohortsPage() { - {can('cohorts', 'add') ? ( + {can('prt_cohorts', 'add') ? ( + + Add PRT cohorts + + ) : null} + {can('cohorts', 'add') && !isVirtualPrt ? ( New cohort @@ -200,7 +209,7 @@ export default function CohortsPage() { {!(waCohort?.patients || []).length ?
No patients on this cohort.
: null} - {can('cohorts', 'edit') ? ( + {canFor(waCohort?.createdAs, 'edit') ? ( <> +
+ {rows.length ? ( + <> +
Fill one row per patient. Name and a 10 digit mobile are required. Empty rows are skipped. Save cohort creates them.
+
+ + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + +
NameMobileAgeGender +
+ updateRow(row.key, { name: e.target.value })} + /> + + updateRow(row.key, { mobile: mobileDigits(e.target.value) })} + /> + + updateRow(row.key, { age: e.target.value })} + /> + + updateRow(row.key, { gender })} + options={settings?.genders} + placeholder="Select" + /> + + +
+
+ + ) : null} + + + {sessions.length ? ( +
+
Sessions from the start date
+

+ {meetingCount} meetings. Repeats {formatInterval(interval)}. Sunday is skipped. + {Number(interval) === 2 ? ' A full week has 3 meetings.' : ''} +

+
+ {sessions.map((date) => ( + {date} {slot} + ))} +
+
+ ) : null} +
+ + Cancel +
+ + + + + ); +} diff --git a/frontend/src/views/virtualpr/onboarding/form.jsx b/frontend/src/views/virtualpr/onboarding/form.jsx index 11c9c43..66f3689 100644 --- a/frontend/src/views/virtualpr/onboarding/form.jsx +++ b/frontend/src/views/virtualpr/onboarding/form.jsx @@ -6,6 +6,7 @@ import PageBreadcrumb from '@/components/PageBreadcrumb'; import SlotSelect from '@/components/SlotSelect'; import { WEEKDAY_LABELS, useSettings } from '@/hooks/useSettings'; import { api } from '@/lib/api'; +import { isMobileNumber, mobileDigits } from '@/lib/mobile'; const empty = { name: '', @@ -186,6 +187,10 @@ export default function OnboardingForm() { setError('Without a cohort, language, slot, preferred weekday, and category are required.'); return; } + if (!String(matched?.mobile || '').trim() && !isMobileNumber(form.mobile)) { + setError('Mobile must be 10 digits.'); + return; + } setSaving(true); const body = new FormData(); Object.entries(form).forEach(([key, value]) => body.append(key, value ?? '')); @@ -332,7 +337,7 @@ export default function OnboardingForm() { {!String(matched.mobile || '').trim() ? ( Mobile * - setField('mobile', e.target.value)} required /> + setField('mobile', mobileDigits(e.target.value))} required /> ) : null} {placementFields} @@ -360,7 +365,7 @@ export default function OnboardingForm() { Mobile * - setField('mobile', e.target.value)} required /> + setField('mobile', mobileDigits(e.target.value))} required /> Age diff --git a/frontend/src/views/virtualpr/patients/form.jsx b/frontend/src/views/virtualpr/patients/form.jsx index e125d09..42089d6 100644 --- a/frontend/src/views/virtualpr/patients/form.jsx +++ b/frontend/src/views/virtualpr/patients/form.jsx @@ -6,6 +6,7 @@ import PageBreadcrumb from '@/components/PageBreadcrumb'; import { useAuth } from '@/context/useAuthContext'; import { useSettings } from '@/hooks/useSettings'; import { api } from '@/lib/api'; +import { isMobileNumber, mobileDigits } from '@/lib/mobile'; const empty = { sourcePatientId: '', @@ -64,6 +65,10 @@ export default function PatientForm() { const save = async (e) => { e.preventDefault(); setError(''); + if (String(form.mobile || '').trim() && !isMobileNumber(form.mobile)) { + setError('Mobile must be 10 digits.'); + return; + } const payload = { ...form, age: form.age === '' ? null : Number(form.age), @@ -101,7 +106,7 @@ export default function PatientForm() { Mobile - setField('mobile', e.target.value)} /> + setField('mobile', mobileDigits(e.target.value))} /> Age diff --git a/frontend/src/views/virtualpr/profile.jsx b/frontend/src/views/virtualpr/profile.jsx index ba2c2a9..32fcd65 100644 --- a/frontend/src/views/virtualpr/profile.jsx +++ b/frontend/src/views/virtualpr/profile.jsx @@ -6,6 +6,7 @@ import PageBreadcrumb from '@/components/PageBreadcrumb'; import { useAuth } from '@/context/useAuthContext'; import { useSettings, WEEKDAY_LABELS } from '@/hooks/useSettings'; import { api } from '@/lib/api'; +import { isMobileNumber, mobileDigits } from '@/lib/mobile'; function pad(n) { return String(n).padStart(2, '0'); @@ -165,6 +166,10 @@ export default function ProfilePage() { e.preventDefault(); setProfileError(''); setProfileOk(''); + if (String(profile.phone || '').trim() && !isMobileNumber(profile.phone)) { + setProfileError('Mobile must be 10 digits.'); + return; + } setSavingProfile(true); try { await api('/api/auth/profile', { @@ -237,7 +242,7 @@ export default function ProfilePage() { Phone - setProfile((p) => ({ ...p, phone: e.target.value }))} /> + setProfile((p) => ({ ...p, phone: mobileDigits(e.target.value) }))} /> Role diff --git a/frontend/src/views/virtualpr/users/index.jsx b/frontend/src/views/virtualpr/users/index.jsx index 8c7648e..d7f3c9e 100644 --- a/frontend/src/views/virtualpr/users/index.jsx +++ b/frontend/src/views/virtualpr/users/index.jsx @@ -10,6 +10,7 @@ import TablePagination from '@/components/table/TablePagination'; import { useAuth } from '@/context/useAuthContext'; import { useSettings } from '@/hooks/useSettings'; import { api } from '@/lib/api'; +import { isMobileNumber, mobileDigits } from '@/lib/mobile'; const emptyForm = { firstName: '', lastName: '', email: '', phone: '', password: '', role: '', languages: [], specializations: [], availableSlots: [], availableStartWeekdays: [], maxCohorts: '', isActive: true }; @@ -84,6 +85,10 @@ export default function UsersPage() { setError('Select at least one language for a Virtual PRT.'); return; } + if (String(form.phone || '').trim() && !isMobileNumber(form.phone)) { + setError('Mobile must be 10 digits.'); + return; + } try { const payload = { ...form, maxCohorts: form.maxCohorts === '' ? null : Number(form.maxCohorts) }; if (editing && !payload.password) delete payload.password; @@ -211,7 +216,7 @@ export default function UsersPage() { Phone - setForm({ ...form, phone: e.target.value })} /> + setForm({ ...form, phone: mobileDigits(e.target.value) })} /> Role