added New Prt option and fixed mobile number issue

This commit is contained in:
Abhijit 2026-10-22 11:11:01 +05:30
parent de373aaba3
commit 41a010748c
24 changed files with 683 additions and 44 deletions

View File

@ -5,6 +5,7 @@ export const ACL_MODULES = [
{ key: 'sessions', label: 'Sessions' }, { key: 'sessions', label: 'Sessions' },
{ key: 'onboarding', label: 'Patients' }, { key: 'onboarding', label: 'Patients' },
{ key: 'cohorts', label: 'Cohorts' }, { key: 'cohorts', label: 'Cohorts' },
{ key: 'prt_cohorts', label: 'Add PRT cohorts' },
{ key: 'holidays', label: 'Holidays' }, { key: 'holidays', label: 'Holidays' },
{ key: 'leaves', label: 'Leave' }, { key: 'leaves', label: 'Leave' },
{ key: 'matching', label: 'Matching' }, { key: 'matching', label: 'Matching' },
@ -29,6 +30,7 @@ export const SYSTEM_ROLE_PERMISSIONS = {
perm('sessions', ['view', 'add', 'edit', 'delete']), perm('sessions', ['view', 'add', 'edit', 'delete']),
perm('onboarding', ['view', 'add', 'edit', 'delete']), perm('onboarding', ['view', 'add', 'edit', 'delete']),
perm('cohorts', ['view', 'add', 'edit', 'delete']), perm('cohorts', ['view', 'add', 'edit', 'delete']),
perm('prt_cohorts', ['view', 'add', 'edit', 'delete']),
perm('holidays', ['view', 'add', 'edit', 'delete']), perm('holidays', ['view', 'add', 'edit', 'delete']),
perm('leaves', ['view', 'add', 'edit', 'delete']), perm('leaves', ['view', 'add', 'edit', 'delete']),
perm('matching', ['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('sessions', ['view', 'add', 'edit', 'delete']),
perm('onboarding', ['view', 'add', 'edit', 'delete']), perm('onboarding', ['view', 'add', 'edit', 'delete']),
perm('cohorts', ['view', 'add', 'edit', 'delete']), perm('cohorts', ['view', 'add', 'edit', 'delete']),
perm('prt_cohorts', ['view', 'add', 'edit', 'delete']),
perm('holidays', ['view', 'add', 'edit', 'delete']), perm('holidays', ['view', 'add', 'edit', 'delete']),
perm('leaves', ['view', 'add', 'edit', 'delete']), perm('leaves', ['view', 'add', 'edit', 'delete']),
perm('matching', ['view', 'add', 'edit', 'delete']), perm('matching', ['view', 'add', 'edit', 'delete']),
@ -53,6 +56,7 @@ export const SYSTEM_ROLE_PERMISSIONS = {
educator: [ educator: [
perm('onboarding', ['view', 'add', 'edit', 'delete'], 'assigned'), perm('onboarding', ['view', 'add', 'edit', 'delete'], 'assigned'),
perm('cohorts', ['view'], 'assigned'), perm('cohorts', ['view'], 'assigned'),
perm('prt_cohorts', ['view', 'add', 'edit', 'delete'], 'assigned'),
perm('holidays', ['view']), perm('holidays', ['view']),
perm('leaves', ['view', 'add', 'edit'], 'assigned'), perm('leaves', ['view', 'add', 'edit'], 'assigned'),
perm('program_sessions', ['view', 'edit'], 'assigned'), perm('program_sessions', ['view', 'edit'], 'assigned'),

View File

@ -7,6 +7,8 @@ const cohortSchema = new mongoose.Schema(
slot: { type: String, required: true, trim: true }, slot: { type: String, required: true, trim: true },
language: { type: String, default: '', trim: true }, language: { type: String, default: '', trim: true },
scheduleId: { type: String, default: '', trim: true }, // blank means Normal; otherwise an extra schedule id 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 }, diseaseType: { type: String, default: '', trim: true },
status: { status: {
type: String, type: String,

View File

@ -73,14 +73,17 @@ export function authResponse(user, token) {
} }
export function requirePermission(moduleKey, action) { 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) => { return (request, h) => {
const creds = request.auth?.credentials; const creds = request.auth?.credentials;
if (!creds?.user) throw Boom.unauthorized(); if (!creds?.user) throw Boom.unauthorized();
if (creds.isSuperAdmin) return h.continue; if (creds.isSuperAdmin) return h.continue;
const actions = creds.modules?.[moduleKey] || []; const allowed = moduleKeys.some((moduleKey) => (creds.modules?.[moduleKey] || []).includes(action));
if (!Array.isArray(actions) || !actions.includes(action)) { if (!allowed) throw Boom.forbidden('Insufficient permissions.');
throw Boom.forbidden('Insufficient permissions.');
}
return h.continue; 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) { export function cohortScopeFilter(request) {
const creds = request.auth.credentials; const creds = request.auth.credentials;
if (creds.isSuperAdmin || creds.scopes?.cohorts !== 'assigned') return {}; if (creds.isSuperAdmin) return {};
return { virtualPrt: creds.user._id }; 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) { export function leaveScopeFilter(request) {

View File

@ -7,6 +7,7 @@ import { env } from '../config/env.js';
import { User } from '../models/User.js'; import { User } from '../models/User.js';
import { sendPasswordResetEmail } from '../services/mail.js'; import { sendPasswordResetEmail } from '../services/mail.js';
import { writeAudit, clientIp } from '../services/audit.js'; import { writeAudit, clientIp } from '../services/audit.js';
import { isMobileNumber } from '../services/cryptoPii.js';
import { hitRateLimit } from '../services/rateLimit.js'; import { hitRateLimit } from '../services/rateLimit.js';
import { import {
applySessionCookies, applySessionCookies,
@ -117,6 +118,9 @@ export function authRoutes(server) {
if (!fresh || !fresh.isActive) throw Boom.unauthorized(); if (!fresh || !fresh.isActive) throw Boom.unauthorized();
fresh.firstName = request.payload.firstName; fresh.firstName = request.payload.firstName;
fresh.lastName = request.payload.lastName; 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.phone != null) fresh.phone = request.payload.phone;
if (request.payload.specializations) fresh.specializations = request.payload.specializations; if (request.payload.specializations) fresh.specializations = request.payload.specializations;
if (request.payload.availableSlots) fresh.availableSlots = request.payload.availableSlots; if (request.payload.availableSlots) fresh.availableSlots = request.payload.availableSlots;

View File

@ -1,13 +1,13 @@
import Joi from 'joi'; import Joi from 'joi';
import Boom from '@hapi/boom'; 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 { Cohort } from '../models/Cohort.js';
import { Role } from '../models/Role.js'; import { Role } from '../models/Role.js';
import { User } from '../models/User.js'; import { User } from '../models/User.js';
import { OnboardedPatient } from '../models/OnboardedPatient.js'; import { OnboardedPatient } from '../models/OnboardedPatient.js';
import { toCohortDto, userLanguages } from '../utils/serialize.js'; import { toCohortDto, userLanguages } from '../utils/serialize.js';
import { assertCohortStartDate, buildCohortName, scheduleSessionCount, scheduleWeekdays, toYmd, upcomingSessionDates, ymdToDate } from '../services/schedule.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 { activateManualCohort } from '../services/matching.js';
import { ensureSessionRecords, persistCohortSessions, rescheduleOpenSessions } from '../services/programSessions.js'; import { ensureSessionRecords, persistCohortSessions, rescheduleOpenSessions } from '../services/programSessions.js';
import { CohortSession } from '../models/CohortSession.js'; import { CohortSession } from '../models/CohortSession.js';
@ -26,8 +26,23 @@ const cohortPayload = Joi.object({
patients: Joi.array().items(objectId).default([]), patients: Joi.array().items(objectId).default([]),
durationMinutes: Joi.number().min(15).max(180).optional(), durationMinutes: Joi.number().min(15).max(180).optional(),
scheduleId: Joi.string().trim().allow('', null), 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. // Saved extra-schedule id to its weekdays. Blank means Normal.
async function chosenWeekdays(scheduleId) { async function chosenWeekdays(scheduleId) {
const id = String(scheduleId || '').trim(); const id = String(scheduleId || '').trim();
@ -88,7 +103,7 @@ export function cohortRoutes(server) {
server.route({ server.route({
method: 'GET', method: 'GET',
path: '/api/cohorts', path: '/api/cohorts',
options: { pre: [{ method: requirePermission('cohorts', 'view') }] }, options: { pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'view') }] },
handler: async (request) => { handler: async (request) => {
const { search = '', page = 1, limit = 20, status } = request.query; const { search = '', page = 1, limit = 20, status } = request.query;
const filter = { ...cohortScopeFilter(request) }; const filter = { ...cohortScopeFilter(request) };
@ -118,7 +133,7 @@ export function cohortRoutes(server) {
method: 'POST', method: 'POST',
path: '/api/cohorts', path: '/api/cohorts',
options: { options: {
pre: [{ method: requirePermission('cohorts', 'add') }], pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'add') }],
validate: { payload: cohortPayload }, validate: { payload: cohortPayload },
}, },
handler: async (request) => { handler: async (request) => {
@ -133,11 +148,11 @@ export function cohortRoutes(server) {
const language = request.payload.language || prtLanguages[0]; const language = request.payload.language || prtLanguages[0];
if (!prtLanguages.includes(language)) throw Boom.badRequest('Select a language this Virtual PRT can teach.'); if (!prtLanguages.includes(language)) throw Boom.badRequest('Select a language this Virtual PRT can teach.');
await assertPatients(patients); 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.`); throw Boom.badRequest(`A cohort can have at most ${settings.maxPatientsPerCohort || 10} patients.`);
} }
try { try {
await assertCohortStartDate(startDate, virtualPrt, language, await chosenWeekdays(scheduleId)); await assertCohortStartDate(startDate, virtualPrt, language, startWeekdaysFor(request.payload, await chosenWeekdays(scheduleId)));
} catch (err) { } catch (err) {
throw Boom.badRequest(err.message); throw Boom.badRequest(err.message);
} }
@ -149,13 +164,30 @@ export function cohortRoutes(server) {
language, language,
scheduleId, scheduleId,
diseaseType: request.payload.diseaseType || '', diseaseType: request.payload.diseaseType || '',
createdAs: request.payload.createdAs || 'cohorts',
sixDayWeek: Boolean(request.payload.sixDayWeek),
status: 'active', status: 'active',
virtualPrt, virtualPrt,
patients, patients,
durationMinutes, 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 syncCohortPatients(cohort, []);
await activateManualCohort(cohort); await activateManualCohort(cohort);
if (cohort.createdAs === 'manual_cohorts') await applyCohortToPatients(cohort);
const ready = await Cohort.findById(cohort._id) const ready = await Cohort.findById(cohort._id)
.populate('virtualPrt', 'firstName lastName email') .populate('virtualPrt', 'firstName lastName email')
.populate({ path: 'patients', match: { reviewStatus: 'confirmed' }, select: 'name mobile whatsappOptOut' }); .populate({ path: 'patients', match: { reviewStatus: 'confirmed' }, select: 'name mobile whatsappOptOut' });
@ -167,7 +199,7 @@ export function cohortRoutes(server) {
method: 'GET', method: 'GET',
path: '/api/cohorts/{id}', path: '/api/cohorts/{id}',
options: { options: {
pre: [{ method: requirePermission('cohorts', 'view') }], pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'view') }],
validate: { params: Joi.object({ id: objectId.required() }) }, validate: { params: Joi.object({ id: objectId.required() }) },
}, },
handler: async (request) => (await withSessionCounts(await findCohort(request)))[0], handler: async (request) => (await withSessionCounts(await findCohort(request)))[0],
@ -177,7 +209,7 @@ export function cohortRoutes(server) {
method: 'GET', method: 'GET',
path: '/api/cohorts/{id}/sessions', path: '/api/cohorts/{id}/sessions',
options: { options: {
pre: [{ method: requirePermission('cohorts', 'view') }], pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'view') }],
validate: { validate: {
params: Joi.object({ id: objectId.required() }), params: Joi.object({ id: objectId.required() }),
query: Joi.object({ count: Joi.number().min(1).max(60) }), query: Joi.object({ count: Joi.number().min(1).max(60) }),
@ -197,7 +229,7 @@ export function cohortRoutes(server) {
method: 'PATCH', method: 'PATCH',
path: '/api/cohorts/{id}', path: '/api/cohorts/{id}',
options: { options: {
pre: [{ method: requirePermission('cohorts', 'edit') }], pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'edit') }],
validate: { validate: {
params: Joi.object({ id: objectId.required() }), params: Joi.object({ id: objectId.required() }),
payload: cohortPayload, payload: cohortPayload,
@ -226,11 +258,11 @@ export function cohortRoutes(server) {
const language = request.payload.language || prtLanguages[0]; const language = request.payload.language || prtLanguages[0];
if (!prtLanguages.includes(language)) throw Boom.badRequest('Select a language this Virtual PRT can teach.'); if (!prtLanguages.includes(language)) throw Boom.badRequest('Select a language this Virtual PRT can teach.');
await assertPatients(patients); 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.`); throw Boom.badRequest(`A cohort can have at most ${settings.maxPatientsPerCohort || 10} patients.`);
} }
try { try {
await assertCohortStartDate(startDate, virtualPrt, language, await chosenWeekdays(scheduleId)); await assertCohortStartDate(startDate, virtualPrt, language, startWeekdaysFor(request.payload, await chosenWeekdays(scheduleId)));
} catch (err) { } catch (err) {
throw Boom.badRequest(err.message); throw Boom.badRequest(err.message);
} }
@ -240,10 +272,17 @@ export function cohortRoutes(server) {
cohort.language = language; cohort.language = language;
cohort.scheduleId = scheduleId; cohort.scheduleId = scheduleId;
cohort.diseaseType = request.payload.diseaseType || cohort.diseaseType || ''; 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.virtualPrt = virtualPrt;
cohort.patients = patients; cohort.patients = patients;
cohort.durationMinutes = durationMinutes; cohort.durationMinutes = durationMinutes;
if (cohort.status === 'proposed') cohort.status = 'active'; 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 cohort.save();
await syncCohortPatients(cohort, previous); await syncCohortPatients(cohort, previous);
const { onCohortUpdated } = await import('../services/whatsapp.js'); const { onCohortUpdated } = await import('../services/whatsapp.js');
@ -253,6 +292,7 @@ export function cohortRoutes(server) {
if (scheduleChanged) await rescheduleOpenSessions(cohort); if (scheduleChanged) await rescheduleOpenSessions(cohort);
else if (await CohortSession.countDocuments({ cohort: cohort._id })) await ensureSessionRecords(cohort); else if (await CohortSession.countDocuments({ cohort: cohort._id })) await ensureSessionRecords(cohort);
else await persistCohortSessions(cohort); else await persistCohortSessions(cohort);
if ((cohort.createdAs || '') === 'manual_cohorts') await applyCohortToPatients(cohort);
const ready = await Cohort.findById(cohort._id) const ready = await Cohort.findById(cohort._id)
.populate('virtualPrt', 'firstName lastName email') .populate('virtualPrt', 'firstName lastName email')
.populate({ path: 'patients', match: { reviewStatus: 'confirmed' }, select: 'name mobile whatsappOptOut' }); .populate({ path: 'patients', match: { reviewStatus: 'confirmed' }, select: 'name mobile whatsappOptOut' });
@ -264,7 +304,7 @@ export function cohortRoutes(server) {
method: 'DELETE', method: 'DELETE',
path: '/api/cohorts/{id}', path: '/api/cohorts/{id}',
options: { options: {
pre: [{ method: requirePermission('cohorts', 'delete') }], pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'delete') }],
validate: { params: Joi.object({ id: objectId.required() }) }, validate: { params: Joi.object({ id: objectId.required() }) },
}, },
handler: async (request) => { handler: async (request) => {
@ -280,7 +320,7 @@ export function cohortRoutes(server) {
method: 'POST', method: 'POST',
path: '/api/cohorts/{id}/whatsapp/retry', path: '/api/cohorts/{id}/whatsapp/retry',
options: { options: {
pre: [{ method: requirePermission('cohorts', 'edit') }], pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'edit') }],
validate: { params: Joi.object({ id: objectId.required() }) }, validate: { params: Joi.object({ id: objectId.required() }) },
}, },
handler: async (request) => { handler: async (request) => {
@ -297,7 +337,7 @@ export function cohortRoutes(server) {
method: 'POST', method: 'POST',
path: '/api/cohorts/{id}/complete', path: '/api/cohorts/{id}/complete',
options: { options: {
pre: [{ method: requirePermission('cohorts', 'edit') }], pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'edit') }],
validate: { params: Joi.object({ id: objectId.required() }) }, validate: { params: Joi.object({ id: objectId.required() }) },
}, },
handler: async (request) => { handler: async (request) => {
@ -322,7 +362,7 @@ export function virtualPrtRoutes(server) {
server.route({ server.route({
method: 'GET', method: 'GET',
path: '/api/users/virtual-prts', path: '/api/users/virtual-prts',
options: { pre: [{ method: requirePermission('cohorts', 'view') }] }, options: { pre: [{ method: requireAnyPermission(['cohorts', 'prt_cohorts'], 'view') }] },
handler: async () => { handler: async () => {
const role = await Role.findOne({ systemKey: 'educator' }); const role = await Role.findOne({ systemKey: 'educator' });
if (!role) return { items: [] }; if (!role) return { items: [] };

View File

@ -9,7 +9,7 @@ import { Cohort } from '../models/Cohort.js';
import { User } from '../models/User.js'; import { User } from '../models/User.js';
import { toOnboardedPatientDto, toCohortDto } from '../utils/serialize.js'; import { toOnboardedPatientDto, toCohortDto } from '../utils/serialize.js';
import { saveOnboardingScreenshot, screenshotPath, safeDownloadName } from '../services/onboardingUpload.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 { deleteScreenshot, presignGet, s3Enabled } from '../services/s3.js';
import { writeAudit } from '../services/audit.js'; import { writeAudit } from '../services/audit.js';
import { addPatientToCohort, removePatientFromCohort } from '../services/cohorts.js'; import { addPatientToCohort, removePatientFromCohort } from '../services/cohorts.js';
@ -116,7 +116,7 @@ function applyMasterPatient(fields, master, settings) {
function assertIdentity(fields) { function assertIdentity(fields) {
if (!fields.name) throw Boom.badRequest('Name is required'); 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) { function assertPlacement(fields, settings) {

View File

@ -5,6 +5,7 @@ import { Patient } from '../models/Patient.js';
import { Session } from '../models/Session.js'; import { Session } from '../models/Session.js';
import { User } from '../models/User.js'; import { User } from '../models/User.js';
import { toPatientDto, toSessionDto } from '../utils/serialize.js'; import { toPatientDto, toSessionDto } from '../utils/serialize.js';
import { isMobileNumber } from '../services/cryptoPii.js';
import { importWorkbook, payloadToBuffer } from '../services/importPatients.js'; import { importWorkbook, payloadToBuffer } from '../services/importPatients.js';
const objectId = Joi.string().hex().length(24); const objectId = Joi.string().hex().length(24);
@ -31,7 +32,8 @@ const patientPayload = Joi.object({
function applyPatientFields(patient, body, canAssign) { function applyPatientFields(patient, body, canAssign) {
patient.name = body.name; 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.age = body.age ?? null;
patient.gender = body.gender || ''; patient.gender = body.gender || '';
patient.lungCondition = body.lungCondition || ''; patient.lungCondition = body.lungCondition || '';

View File

@ -8,6 +8,7 @@ import { toUserDto, userLanguages } from '../utils/serialize.js';
import { flagPrtResignation } from '../services/matching.js'; import { flagPrtResignation } from '../services/matching.js';
import { writeAudit } from '../services/audit.js'; import { writeAudit } from '../services/audit.js';
import { revokeUserRefreshSessions } from '../services/sessionCookies.js'; import { revokeUserRefreshSessions } from '../services/sessionCookies.js';
import { isMobileNumber } from '../services/cryptoPii.js';
function normalizeLanguages(payload) { function normalizeLanguages(payload) {
const list = Array.isArray(payload?.languages) const list = Array.isArray(payload?.languages)
@ -122,6 +123,7 @@ export function userRoutes(server) {
} }
const email = request.payload.email.toLowerCase().trim(); const email = request.payload.email.toLowerCase().trim();
if (await User.findOne({ email })) throw Boom.conflict('Email already registered'); 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({ const user = await User.create({
firstName: request.payload.firstName, firstName: request.payload.firstName,
lastName: request.payload.lastName, lastName: request.payload.lastName,
@ -193,6 +195,9 @@ export function userRoutes(server) {
} }
if (body.firstName) user.firstName = body.firstName; if (body.firstName) user.firstName = body.firstName;
if (body.lastName) user.lastName = body.lastName; 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.phone != null) user.phone = body.phone;
if (body.languages != null || body.language != null) { if (body.languages != null || body.language != null) {
const languages = normalizeLanguages(body); const languages = normalizeLanguages(body);

View File

@ -1,6 +1,9 @@
import { Cohort } from '../models/Cohort.js'; import { Cohort } from '../models/Cohort.js';
import { OnboardedPatient } from '../models/OnboardedPatient.js'; import { OnboardedPatient } from '../models/OnboardedPatient.js';
import { addPatientToWhatsapp, removePatientFromWhatsapp, syncWhatsappMembership } from './whatsapp.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) { async function safeWhatsapp(label, fn) {
try { try {
@ -70,3 +73,63 @@ export async function releaseCohortPatients(cohort) {
const previous = (cohort.patients || []).map((p) => String(p._id || p)); const previous = (cohort.patients || []).map((p) => String(p._id || p));
await syncCohortPatients({ ...cohort.toObject?.() || cohort, patients: [] }, previous); 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;
}

View File

@ -70,6 +70,10 @@ export function hashName(name) {
return crypto.createHmac('sha256', pepper()).update(normalized).digest('hex'); 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) { export function applyOnboardedPii(target, fields) {
if (fields.name != null) { if (fields.name != null) {
target.name = encryptPii(fields.name); target.name = encryptPii(fields.name);

View File

@ -63,11 +63,17 @@ function recordsFor(ids) {
return ids.map((patient) => ({ patient, attended: false, pre: {}, post: {} })); 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. // Creates the session list once. If rows exist, only adds dates when the list is short.
export async function persistCohortSessions(cohort) { export async function persistCohortSessions(cohort) {
const settings = await getAppSettings(); const settings = await getAppSettings();
const ownCount = await scheduleSessionCount(cohort.scheduleId); 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 weekdays = await scheduleWeekdays(cohort.scheduleId);
const existing = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1, date: 1 }); const existing = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1, date: 1 });
const active = existing.filter((session) => session.status !== 'cancelled'); const active = existing.filter((session) => session.status !== 'cancelled');
@ -179,7 +185,7 @@ async function syncExistingMeetSeries(cohort, sessions) {
export async function rescheduleOpenSessions(cohort) { export async function rescheduleOpenSessions(cohort) {
const settings = await getAppSettings(); const settings = await getAppSettings();
const ownCount = await scheduleSessionCount(cohort.scheduleId); 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 weekdays = await scheduleWeekdays(cohort.scheduleId);
const existing = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1, date: 1 }); const existing = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1, date: 1 });
const completed = existing.filter((session) => session.status === 'completed'); const completed = existing.filter((session) => session.status === 'completed');

View File

@ -254,6 +254,8 @@ export function toCohortDto(cohort) {
slot: cohort.slot || '', slot: cohort.slot || '',
language: cohort.language || '', language: cohort.language || '',
scheduleId: cohort.scheduleId || '', scheduleId: cohort.scheduleId || '',
createdAs: cohort.createdAs || 'cohorts',
sixDayWeek: Boolean(cohort.sixDayWeek),
diseaseType: cohort.diseaseType || '', diseaseType: cohort.diseaseType || '',
status: cohort.status || 'active', status: cohort.status || 'active',
needsReassignment: Boolean(cohort.needsReassignment), needsReassignment: Boolean(cohort.needsReassignment),

View File

@ -24,6 +24,13 @@ export function RequirePermission({ module, action, children }) {
return children; return children;
} }
export function RequireAnyPermission({ checks, children }) {
const { can, loading } = useAuth();
if (loading) return <Loader height="40vh" />;
if (!checks.some(([moduleKey, action]) => can(moduleKey, action))) return <Navigate to="/error/403" replace />;
return children;
}
export function RequireAdmin({ children }) { export function RequireAdmin({ children }) {
const { isAdmin, loading } = useAuth(); const { isAdmin, loading } = useAuth();
if (loading) return <Loader height="40vh" />; if (loading) return <Loader height="40vh" />;

View File

@ -19,6 +19,14 @@ export function buildCohortName({ slot, language, startDate }) {
return `${slot} - ${right}`; 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) { export function formatInterval(days) {
const value = Number(days) || 2; const value = Number(days) || 2;
if (value === 1) return 'every day'; if (value === 1) return 'every day';

View File

@ -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());
}

View File

@ -1,7 +1,7 @@
import { lazy } from 'react'; import { lazy } from 'react';
import { Navigate } from 'react-router'; import { Navigate } from 'react-router';
import MainLayout from '@/layouts/MainLayout'; 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 Dashboard = lazy(() => import('@/views/virtualpr/dashboard'));
const Patients = lazy(() => import('@/views/virtualpr/patients')); 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 OnboardingForm = lazy(() => import('@/views/virtualpr/onboarding/form'));
const Cohorts = lazy(() => import('@/views/virtualpr/cohorts')); const Cohorts = lazy(() => import('@/views/virtualpr/cohorts'));
const CohortForm = lazy(() => import('@/views/virtualpr/cohorts/form')); 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 Holidays = lazy(() => import('@/views/virtualpr/holidays'));
const Leaves = lazy(() => import('@/views/virtualpr/leaves')); const Leaves = lazy(() => import('@/views/virtualpr/leaves'));
const Settings = lazy(() => import('@/views/virtualpr/settings')); const Settings = lazy(() => import('@/views/virtualpr/settings'));
@ -79,9 +81,9 @@ export const routes = [
{ {
path: '/cohorts', path: '/cohorts',
element: ( element: (
<RequirePermission module="cohorts" action="view"> <RequireAnyPermission checks={[['cohorts', 'view'], ['prt_cohorts', 'view']]}>
<Cohorts /> <Cohorts />
</RequirePermission> </RequireAnyPermission>
), ),
}, },
{ {
@ -92,12 +94,20 @@ export const routes = [
</RequirePermission> </RequirePermission>
), ),
}, },
{
path: '/cohorts/prt/new',
element: (
<RequirePermission module="prt_cohorts" action="add">
<PrtCohortForm />
</RequirePermission>
),
},
{ {
path: '/cohorts/:id/edit', path: '/cohorts/:id/edit',
element: ( element: (
<RequirePermission module="cohorts" action="edit"> <RequireAnyPermission checks={[['cohorts', 'edit'], ['prt_cohorts', 'edit']]}>
<CohortForm /> <EditCohort />
</RequirePermission> </RequireAnyPermission>
), ),
}, },
{ {

View File

@ -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 (
<Container fluid>
<PageBreadcrumb title="Edit cohort" subtitle="Cohorts" />
<div className="alert alert-danger">{error}</div>
</Container>
);
}
if (!cohort) return null;
const createdAs = cohort.createdAs || 'cohorts';
const allowed = createdAs === 'manual_cohorts' ? can('prt_cohorts', 'edit') : can('cohorts', 'edit');
if (!allowed) {
return (
<Container fluid>
<PageBreadcrumb title="Edit cohort" subtitle="Cohorts" />
<div className="alert alert-danger">You do not have permission to edit this cohort.</div>
</Container>
);
}
if (createdAs === 'manual_cohorts') return <PrtCohortForm />;
return <CohortForm />;
}

View File

@ -158,7 +158,7 @@ export default function CohortForm() {
return; return;
} }
setSaving(true); setSaving(true);
const payload = { ...form }; const payload = isEdit ? { ...form } : { ...form, createdAs: 'cohorts' };
try { try {
if (isEdit) await api(`/api/cohorts/${id}`, { method: 'PATCH', body: payload }); if (isEdit) await api(`/api/cohorts/${id}`, { method: 'PATCH', body: payload });
else await api('/api/cohorts', { method: 'POST', body: payload }); else await api('/api/cohorts', { method: 'POST', body: payload });

View File

@ -17,7 +17,11 @@ function formatDate(value) {
} }
export default function CohortsPage() { 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 [items, setItems] = useState([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
@ -51,7 +55,7 @@ export default function CohortsPage() {
columnHelper.accessor('name', { columnHelper.accessor('name', {
header: 'Cohort', header: 'Cohort',
cell: ({ row }) => ( cell: ({ row }) => (
can('cohorts', 'edit') canFor(row.original.createdAs, 'edit')
? <Link to={`/cohorts/${row.original.id}/edit`} className="link-reset fw-semibold">{row.original.name}</Link> ? <Link to={`/cohorts/${row.original.id}/edit`} className="link-reset fw-semibold">{row.original.name}</Link>
: <span className="fw-semibold">{row.original.name}</span> : <span className="fw-semibold">{row.original.name}</span>
), ),
@ -86,8 +90,8 @@ export default function CohortsPage() {
header: 'Actions', header: 'Actions',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="d-flex gap-1"> <div className="d-flex gap-1">
{can('cohorts', 'edit') ? <Link className="btn btn-default btn-icon btn-sm" to={`/cohorts/${row.original.id}/edit`}><TbEdit className="fs-lg" /></Link> : null} {canFor(row.original.createdAs, 'edit') ? <Link className="btn btn-default btn-icon btn-sm" to={`/cohorts/${row.original.id}/edit`}><TbEdit className="fs-lg" /></Link> : null}
{can('cohorts', 'delete') ? ( {canFor(row.original.createdAs, 'delete') ? (
<Button variant="default" size="sm" className="btn-icon" onClick={() => setDeleteId(row.original.id)}> <Button variant="default" size="sm" className="btn-icon" onClick={() => setDeleteId(row.original.id)}>
<TbTrash className="fs-lg" /> <TbTrash className="fs-lg" />
</Button> </Button>
@ -127,7 +131,12 @@ export default function CohortsPage() {
<option value="active">Active</option> <option value="active">Active</option>
</select> </select>
</form> </form>
{can('cohorts', 'add') ? ( {can('prt_cohorts', 'add') ? (
<Link className="btn btn-success flex-shrink-0" to="/cohorts/prt/new">
<TbPlus className="me-1" /> Add PRT cohorts
</Link>
) : null}
{can('cohorts', 'add') && !isVirtualPrt ? (
<Link className="btn btn-success flex-shrink-0" to="/cohorts/new"> <Link className="btn btn-success flex-shrink-0" to="/cohorts/new">
<TbPlus className="me-1" /> New cohort <TbPlus className="me-1" /> New cohort
</Link> </Link>
@ -200,7 +209,7 @@ export default function CohortsPage() {
{!(waCohort?.patients || []).length ? <div className="text-muted">No patients on this cohort.</div> : null} {!(waCohort?.patients || []).length ? <div className="text-muted">No patients on this cohort.</div> : null}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
{can('cohorts', 'edit') ? ( {canFor(waCohort?.createdAs, 'edit') ? (
<> <>
<Button <Button
variant="outline-secondary" variant="outline-secondary"

View File

@ -0,0 +1,375 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router';
import { Button, Card, CardBody, Col, Container, FormCheck, FormControl, FormLabel, Row } from 'react-bootstrap';
import { TbPlus, TbTrash } from 'react-icons/tb';
import DateField from '@/components/DateField';
import OptionsSelect from '@/components/OptionsSelect';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import SlotSelect from '@/components/SlotSelect';
import { useAuth } from '@/context/useAuthContext';
import { formatInterval, useSettings } from '@/hooks/useSettings';
import { api } from '@/lib/api';
import { isMobileNumber, mobileDigits } from '@/lib/mobile';
function prtLanguages(user) {
if (Array.isArray(user?.languages) && user.languages.length) return user.languages;
return user?.language ? [user.language] : [];
}
const emptyPatient = { name: '', mobile: '', age: '', gender: '' };
function blankRow() {
return { key: `${Date.now()}-${Math.random()}`, id: '', existing: false, ...emptyPatient };
}
function toYmd(date) {
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
// True when this calendar day can be the start date for the yes or no choice.
function dateAllowed(date, sixDayWeek) {
const day = date.getDay();
if (day === 0) return false;
if (sixDayWeek === 'yes') return true;
if (sixDayWeek === 'no') return day === 1 || day === 2;
return false;
}
// Meeting dates from the start date, using the normal repeat. Sunday is skipped.
function previewSessionDates(startDate, interval, count) {
const needed = Math.min(Math.max(Number(count) || 30, 1), 60);
const step = Math.max(1, Number(interval) || 2);
const cursor = new Date(`${String(startDate).slice(0, 10)}T00:00:00`);
if (!startDate || Number.isNaN(cursor.getTime())) return [];
const dates = [];
for (let guard = 0; guard < 400 && dates.length < needed; guard += 1) {
if (cursor.getDay() !== 0) dates.push(toYmd(cursor));
cursor.setDate(cursor.getDate() + step);
}
return dates;
}
export default function PrtCohortForm() {
const { id } = useParams();
const isEdit = Boolean(id);
const navigate = useNavigate();
const { user } = useAuth();
const { settings } = useSettings();
const isVirtualPrt = user?.role?.systemKey === 'educator';
const [prts, setPrts] = useState([]);
const [virtualPrt, setVirtualPrt] = useState('');
const [slot, setSlot] = useState('');
const [sixDayWeek, setSixDayWeek] = useState('');
const [startDate, setStartDate] = useState('');
const [language, setLanguage] = useState('');
const [diseaseType, setDiseaseType] = useState('');
const [rows, setRows] = useState([]);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const meetingCount = sixDayWeek === 'yes' ? 60 : 30;
const interval = settings?.repeatIntervalDays || 2;
const selectedPrt = isVirtualPrt ? user : prts.find((item) => item.id === virtualPrt);
const languages = prtLanguages(selectedPrt);
const prtId = isVirtualPrt ? user?.id : virtualPrt;
const chosenLanguage = languages.includes(language) ? language : (languages[0] || '');
const sessions = useMemo(
() => previewSessionDates(startDate, interval, meetingCount),
[startDate, interval, meetingCount],
);
useEffect(() => {
if (isVirtualPrt) return undefined;
let active = true;
api('/api/users/virtual-prts').then((data) => {
if (active) setPrts(data.items || []);
}).catch(() => {
if (active) setPrts([]);
});
return () => { active = false; };
}, [isVirtualPrt]);
useEffect(() => {
if (!isEdit) return undefined;
let active = true;
api(`/api/cohorts/${id}`).then((cohort) => {
if (!active) return;
setSlot(cohort.slot || '');
setStartDate(cohort.startDate ? String(cohort.startDate).slice(0, 10) : '');
setLanguage(cohort.language || '');
setDiseaseType(cohort.diseaseType || '');
setVirtualPrt(cohort.virtualPrt?.id || '');
setSixDayWeek(cohort.sixDayWeek ? 'yes' : 'no');
setRows((cohort.patients || []).map((patient) => ({
key: patient.id,
id: patient.id,
existing: true,
name: patient.name || '',
mobile: patient.mobile || '',
age: '',
gender: '',
})));
}).catch((err) => {
if (active) setError(err.message);
});
return () => { active = false; };
}, [id, isEdit]);
const chooseSixDay = (value) => {
setSixDayWeek(value);
if (!startDate) return;
const date = new Date(`${startDate}T00:00:00`);
if (!dateAllowed(date, value)) setStartDate('');
};
const updateRow = (key, patch) => {
setRows((current) => current.map((row) => (row.key === key ? { ...row, ...patch } : row)));
};
const save = async (e) => {
e.preventDefault();
setError('');
if (!prtId) {
setError('Select a Virtual PRT.');
return;
}
if (sixDayWeek !== 'yes' && sixDayWeek !== 'no') {
setError('Choose yes or no for all weekdays.');
return;
}
if (!startDate) {
setError('Select a start date.');
return;
}
if (!chosenLanguage) {
setError(isVirtualPrt ? 'Add a language on your profile before creating a cohort.' : 'Select a language this Virtual PRT can teach.');
return;
}
const started = rows.filter((row) => !row.existing && (row.name.trim() || row.mobile.trim() || String(row.age || '').trim() || row.gender));
const badRow = started.findIndex((row) => !row.name.trim() || !isMobileNumber(row.mobile));
if (badRow >= 0) {
setError(`Row ${rows.indexOf(started[badRow]) + 1} needs a name and a 10 digit mobile.`);
return;
}
const fresh = started.map((row) => ({
name: row.name.trim(),
mobile: row.mobile.trim(),
age: row.age === '' || row.age == null ? '' : row.age,
gender: row.gender || '',
whatsappOptOut: false,
}));
setSaving(true);
const payload = {
startDate,
slot,
virtualPrt: prtId,
language: chosenLanguage,
diseaseType,
patients: rows.filter((row) => row.existing && row.id).map((row) => row.id),
newPatients: fresh,
scheduleId: '',
createdAs: 'manual_cohorts',
sixDayWeek: sixDayWeek === 'yes',
};
try {
if (isEdit) await api(`/api/cohorts/${id}`, { method: 'PATCH', body: payload });
else await api('/api/cohorts', { method: 'POST', body: payload });
navigate('/cohorts');
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
return (
<Container fluid>
<PageBreadcrumb title={isEdit ? 'Edit cohort' : 'Add PRT cohorts'} subtitle="Cohorts" />
<Card>
<CardBody>
{error ? <div className="alert alert-danger">{error}</div> : null}
<form onSubmit={save}>
<Row className="g-3">
{!isVirtualPrt ? (
<Col md={6}>
<FormLabel>Virtual PRT</FormLabel>
<select
className="form-select"
value={virtualPrt}
onChange={(e) => { setVirtualPrt(e.target.value); setLanguage(''); }}
required
>
<option value="">Select PRT</option>
{prts.map((item) => (
<option key={item.id} value={item.id}>
{item.firstName} {item.lastName}{prtLanguages(item).length ? ` (${prtLanguages(item).join(', ')})` : ''}
</option>
))}
</select>
</Col>
) : null}
<Col md={3}>
<FormLabel>Slot</FormLabel>
<SlotSelect value={slot} onChange={setSlot} required />
</Col>
<Col md={3}>
<FormLabel>All weekdays</FormLabel>
<div className="d-flex gap-3 pt-2">
<FormCheck
type="radio"
name="sixDayWeek"
id="six-day-yes"
label="Yes"
checked={sixDayWeek === 'yes'}
onChange={() => chooseSixDay('yes')}
/>
<FormCheck
type="radio"
name="sixDayWeek"
id="six-day-no"
label="No"
checked={sixDayWeek === 'no'}
onChange={() => chooseSixDay('no')}
/>
</div>
</Col>
<Col md={3}>
<FormLabel>Start date</FormLabel>
<DateField
value={startDate}
onChange={setStartDate}
required
disabled={!sixDayWeek}
filterDate={(date) => dateAllowed(date, sixDayWeek)}
placeholder={sixDayWeek === 'yes' ? 'Any day except Sunday' : (sixDayWeek === 'no' ? 'Monday or Tuesday' : 'Choose all weekdays first')}
/>
</Col>
<div className="w-100" />
<Col md={6}>
<FormLabel>Category</FormLabel>
<select className="form-select" value={diseaseType} onChange={(e) => setDiseaseType(e.target.value)}>
<option value="">Any / not set</option>
{(settings?.diseaseTypes || []).map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
</Col>
<Col md={6}>
<FormLabel>Language</FormLabel>
<select
className="form-select"
value={chosenLanguage}
onChange={(e) => setLanguage(e.target.value)}
required
disabled={!languages.length}
>
<option value="">
{languages.length ? 'Select language' : (isVirtualPrt ? 'Add a language on your profile' : (prtId ? 'This Virtual PRT has no language' : 'Select a Virtual PRT first'))}
</option>
{languages.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
</Col>
<Col xs={12}>
<div className="d-flex justify-content-between align-items-center mb-2">
<FormLabel className="mb-0">Patients</FormLabel>
<Button type="button" variant="light" onClick={() => setRows((current) => [...current, blankRow()])}>
<TbPlus className="me-1" /> Add patient
</Button>
</div>
{rows.length ? (
<>
<div className="form-text mb-2">Fill one row per patient. Name and a 10 digit mobile are required. Empty rows are skipped. Save cohort creates them.</div>
<div className="table-responsive border rounded">
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Name</th>
<th>Mobile</th>
<th style={{ width: 110 }}>Age</th>
<th style={{ width: 160 }}>Gender</th>
<th style={{ width: 48 }} />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.key}>
<td>
<FormControl
value={row.name}
readOnly={row.existing}
onChange={(e) => updateRow(row.key, { name: e.target.value })}
/>
</td>
<td>
<FormControl
inputMode="numeric"
maxLength={10}
value={row.mobile}
readOnly={row.existing}
onChange={(e) => updateRow(row.key, { mobile: mobileDigits(e.target.value) })}
/>
</td>
<td>
<FormControl
type="number"
min={0}
value={row.age}
readOnly={row.existing}
onChange={(e) => updateRow(row.key, { age: e.target.value })}
/>
</td>
<td>
<OptionsSelect
value={row.gender}
disabled={row.existing}
onChange={(gender) => updateRow(row.key, { gender })}
options={settings?.genders}
placeholder="Select"
/>
</td>
<td>
<Button
type="button"
variant="outline-danger"
size="sm"
className="btn-icon"
onClick={() => setRows((current) => current.filter((item) => item.key !== row.key))}
>
<TbTrash className="fs-lg" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : null}
</Col>
</Row>
{sessions.length ? (
<div className="mt-4">
<h6>Sessions from the start date</h6>
<p className="text-muted mb-2">
{meetingCount} meetings. Repeats {formatInterval(interval)}. Sunday is skipped.
{Number(interval) === 2 ? ' A full week has 3 meetings.' : ''}
</p>
<div className="d-flex flex-wrap gap-2">
{sessions.map((date) => (
<span key={date} className="badge bg-primary-subtle text-primary">{date} {slot}</span>
))}
</div>
</div>
) : null}
<div className="d-flex gap-2 mt-4">
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save cohort'}</Button>
<Link className="btn btn-light" to="/cohorts">Cancel</Link>
</div>
</form>
</CardBody>
</Card>
</Container>
);
}

View File

@ -6,6 +6,7 @@ import PageBreadcrumb from '@/components/PageBreadcrumb';
import SlotSelect from '@/components/SlotSelect'; import SlotSelect from '@/components/SlotSelect';
import { WEEKDAY_LABELS, useSettings } from '@/hooks/useSettings'; import { WEEKDAY_LABELS, useSettings } from '@/hooks/useSettings';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { isMobileNumber, mobileDigits } from '@/lib/mobile';
const empty = { const empty = {
name: '', name: '',
@ -186,6 +187,10 @@ export default function OnboardingForm() {
setError('Without a cohort, language, slot, preferred weekday, and category are required.'); setError('Without a cohort, language, slot, preferred weekday, and category are required.');
return; return;
} }
if (!String(matched?.mobile || '').trim() && !isMobileNumber(form.mobile)) {
setError('Mobile must be 10 digits.');
return;
}
setSaving(true); setSaving(true);
const body = new FormData(); const body = new FormData();
Object.entries(form).forEach(([key, value]) => body.append(key, value ?? '')); Object.entries(form).forEach(([key, value]) => body.append(key, value ?? ''));
@ -332,7 +337,7 @@ export default function OnboardingForm() {
{!String(matched.mobile || '').trim() ? ( {!String(matched.mobile || '').trim() ? (
<Col md={6}> <Col md={6}>
<FormLabel>Mobile <span className="text-danger">*</span></FormLabel> <FormLabel>Mobile <span className="text-danger">*</span></FormLabel>
<FormControl value={form.mobile} onChange={(e) => setField('mobile', e.target.value)} required /> <FormControl inputMode="numeric" maxLength={10} value={form.mobile} onChange={(e) => setField('mobile', mobileDigits(e.target.value))} required />
</Col> </Col>
) : null} ) : null}
{placementFields} {placementFields}
@ -360,7 +365,7 @@ export default function OnboardingForm() {
</Col> </Col>
<Col md={6}> <Col md={6}>
<FormLabel>Mobile <span className="text-danger">*</span></FormLabel> <FormLabel>Mobile <span className="text-danger">*</span></FormLabel>
<FormControl value={form.mobile} onChange={(e) => setField('mobile', e.target.value)} required /> <FormControl inputMode="numeric" maxLength={10} value={form.mobile} onChange={(e) => setField('mobile', mobileDigits(e.target.value))} required />
</Col> </Col>
<Col md={3}> <Col md={3}>
<FormLabel>Age</FormLabel> <FormLabel>Age</FormLabel>

View File

@ -6,6 +6,7 @@ import PageBreadcrumb from '@/components/PageBreadcrumb';
import { useAuth } from '@/context/useAuthContext'; import { useAuth } from '@/context/useAuthContext';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { isMobileNumber, mobileDigits } from '@/lib/mobile';
const empty = { const empty = {
sourcePatientId: '', sourcePatientId: '',
@ -64,6 +65,10 @@ export default function PatientForm() {
const save = async (e) => { const save = async (e) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
if (String(form.mobile || '').trim() && !isMobileNumber(form.mobile)) {
setError('Mobile must be 10 digits.');
return;
}
const payload = { const payload = {
...form, ...form,
age: form.age === '' ? null : Number(form.age), age: form.age === '' ? null : Number(form.age),
@ -101,7 +106,7 @@ export default function PatientForm() {
</Col> </Col>
<Col md={4}> <Col md={4}>
<FormLabel>Mobile</FormLabel> <FormLabel>Mobile</FormLabel>
<FormControl value={form.mobile} onChange={(e) => setField('mobile', e.target.value)} /> <FormControl inputMode="numeric" maxLength={10} value={form.mobile} onChange={(e) => setField('mobile', mobileDigits(e.target.value))} />
</Col> </Col>
<Col md={3}> <Col md={3}>
<FormLabel>Age</FormLabel> <FormLabel>Age</FormLabel>

View File

@ -6,6 +6,7 @@ import PageBreadcrumb from '@/components/PageBreadcrumb';
import { useAuth } from '@/context/useAuthContext'; import { useAuth } from '@/context/useAuthContext';
import { useSettings, WEEKDAY_LABELS } from '@/hooks/useSettings'; import { useSettings, WEEKDAY_LABELS } from '@/hooks/useSettings';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { isMobileNumber, mobileDigits } from '@/lib/mobile';
function pad(n) { function pad(n) {
return String(n).padStart(2, '0'); return String(n).padStart(2, '0');
@ -165,6 +166,10 @@ export default function ProfilePage() {
e.preventDefault(); e.preventDefault();
setProfileError(''); setProfileError('');
setProfileOk(''); setProfileOk('');
if (String(profile.phone || '').trim() && !isMobileNumber(profile.phone)) {
setProfileError('Mobile must be 10 digits.');
return;
}
setSavingProfile(true); setSavingProfile(true);
try { try {
await api('/api/auth/profile', { await api('/api/auth/profile', {
@ -237,7 +242,7 @@ export default function ProfilePage() {
</Col> </Col>
<Col md={6}> <Col md={6}>
<FormLabel>Phone</FormLabel> <FormLabel>Phone</FormLabel>
<FormControl value={profile.phone} onChange={(e) => setProfile((p) => ({ ...p, phone: e.target.value }))} /> <FormControl inputMode="numeric" maxLength={10} value={profile.phone} onChange={(e) => setProfile((p) => ({ ...p, phone: mobileDigits(e.target.value) }))} />
</Col> </Col>
<Col md={6}> <Col md={6}>
<FormLabel>Role</FormLabel> <FormLabel>Role</FormLabel>

View File

@ -10,6 +10,7 @@ import TablePagination from '@/components/table/TablePagination';
import { useAuth } from '@/context/useAuthContext'; import { useAuth } from '@/context/useAuthContext';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { api } from '@/lib/api'; 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 }; 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.'); setError('Select at least one language for a Virtual PRT.');
return; return;
} }
if (String(form.phone || '').trim() && !isMobileNumber(form.phone)) {
setError('Mobile must be 10 digits.');
return;
}
try { try {
const payload = { ...form, maxCohorts: form.maxCohorts === '' ? null : Number(form.maxCohorts) }; const payload = { ...form, maxCohorts: form.maxCohorts === '' ? null : Number(form.maxCohorts) };
if (editing && !payload.password) delete payload.password; if (editing && !payload.password) delete payload.password;
@ -211,7 +216,7 @@ export default function UsersPage() {
</Col> </Col>
<Col md={6}> <Col md={6}>
<FormLabel>Phone</FormLabel> <FormLabel>Phone</FormLabel>
<FormControl value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /> <FormControl inputMode="numeric" maxLength={10} value={form.phone} onChange={(e) => setForm({ ...form, phone: mobileDigits(e.target.value) })} />
</Col> </Col>
<Col md={6}> <Col md={6}>
<FormLabel>Role</FormLabel> <FormLabel>Role</FormLabel>