Building a Multi-Tenant Healthcare Platform with Angular and Sequelize
Abhishek Sharma
Software Developer
Building a Multi-Tenant Healthcare Platform with Angular and Sequelize
Healthcare software has constraints that most SaaS applications never encounter. Patient data must be strictly isolated between facilities. Appointment scheduling must handle overlapping time slots, equipment availability, and radiologist specializations simultaneously. Payment processing must comply with local regulations. And communication channels -- email, SMS, push notifications -- each have their own authentication complexity. Prevadu Health tackles all of these for the Egyptian radiology market.
This is a technical deep-dive into the architecture of a multi-tenant healthcare platform built with Angular 14 and Sequelize ORM, covering the patterns that made it possible to manage 27 master data modules without drowning in duplicated code.
Multi-Tenant Data Isolation with Sequelize
Multi-tenancy in healthcare is not optional -- it is a compliance requirement. Each radiology center must see only its own patients, appointments, and billing data. We implemented tenant isolation at the ORM layer using Sequelize scopes and middleware, so every query is automatically filtered by the tenant context.
// middleware/tenant-context.middleware.js
const { Tenant } = require('../models');
module.exports = async function tenantContext(req, res, next) {
const tenantId = req.user?.tenantId || req.headers['x-tenant-id'];
if (!tenantId) {
return res.status(403).json({ error: 'Tenant context required' });
}
// Validate tenant exists and is active
const tenant = await Tenant.findByPk(tenantId);
if (!tenant || tenant.status !== 'active') {
return res.status(403).json({ error: 'Invalid or inactive tenant' });
}
// Attach to request for downstream use
req.tenantId = tenantId;
req.tenant = tenant;
next();
};
// models/patient.model.js
module.exports = (sequelize, DataTypes) => {
const Patient = sequelize.define('Patient', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
tenantId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: 'Tenants', key: 'id' },
},
nationalId: DataTypes.STRING,
fullName: DataTypes.STRING,
phone: DataTypes.STRING,
dateOfBirth: DataTypes.DATEONLY,
gender: DataTypes.ENUM('male', 'female'),
medicalHistory: DataTypes.JSONB,
}, {
paranoid: true, // Soft deletes for healthcare compliance
scopes: {
// Default scope: always filter by tenant
forTenant(tenantId) {
return {
where: { tenantId },
};
},
},
indexes: [
{ fields: ['tenantId', 'nationalId'], unique: true },
{ fields: ['tenantId', 'phone'] },
],
});
return Patient;
};
// Usage in service layer — tenant scoping is enforced
class PatientService {
async findAll(tenantId, filters = {}) {
return Patient.scope({ method: ['forTenant', tenantId] }).findAndCountAll({
where: filters,
order: [['createdAt', 'DESC']],
limit: filters.limit || 25,
offset: filters.offset || 0,
});
}
async findById(tenantId, patientId) {
const patient = await Patient.scope({ method: ['forTenant', tenantId] })
.findByPk(patientId);
if (!patient) throw new NotFoundException('Patient not found');
return patient;
}
}
The critical detail is the compound unique index on [tenantId, nationalId]. A patient's national ID must be unique within a single facility but can exist across facilities (the same patient might visit multiple radiology centers). Sequelize scopes guarantee that every query includes the tenant filter. Even if a developer forgets to add a WHERE clause for tenantId, the scope adds it automatically. This is a defense-in-depth approach -- the middleware validates the tenant context, the scope filters the query, and the indexes enforce uniqueness at the database level.
The Generic CRUD Controller Pattern
Prevadu Health has 27 master data modules: Centers, Doctors, Machines, Modalities, Time Slots, Procedures, Pricing Tiers, Specializations, Insurance Providers, Rooms, Technicians, Report Templates, Referral Sources, Payment Methods, Discount Rules, Notification Templates, and more. Writing individual CRUD controllers for each would mean 27 copies of nearly identical pagination, validation, and error-handling code.
Instead, we built a generic CRUD controller factory that each master module extends with its specific validation rules and relationships.
// controllers/base-crud.controller.js
class BaseCrudController {
constructor(model, options = {}) {
this.model = model;
this.searchFields = options.searchFields || ['name'];
this.includes = options.includes || [];
this.defaultOrder = options.defaultOrder || [['createdAt', 'DESC']];
}
// GET /api/:resource
getAll = async (req, res) => {
try {
const { page = 1, limit = 25, search, sortBy, sortDir = 'ASC', ...filters } = req.query;
const offset = (page - 1) * limit;
// Build search condition
const whereClause = { tenantId: req.tenantId };
if (search) {
const { Op } = require('sequelize');
whereClause[Op.or] = this.searchFields.map(field => ({
[field]: { [Op.like]: `%${search}%` },
}));
}
// Apply additional filters
Object.keys(filters).forEach(key => {
if (this.model.rawAttributes[key]) {
whereClause[key] = filters[key];
}
});
const { count, rows } = await this.model.findAndCountAll({
where: whereClause,
include: this.includes,
order: sortBy ? [[sortBy, sortDir]] : this.defaultOrder,
limit: parseInt(limit),
offset: parseInt(offset),
});
return res.json({
data: rows,
pagination: {
total: count,
page: parseInt(page),
limit: parseInt(limit),
totalPages: Math.ceil(count / limit),
},
});
} catch (error) {
return res.status(500).json({ error: error.message });
}
};
// GET /api/:resource/:id
getById = async (req, res) => {
try {
const record = await this.model.findOne({
where: { id: req.params.id, tenantId: req.tenantId },
include: this.includes,
});
if (!record) return res.status(404).json({ error: 'Not found' });
return res.json(record);
} catch (error) {
return res.status(500).json({ error: error.message });
}
};
// POST /api/:resource
create = async (req, res) => {
try {
const record = await this.model.create({
...req.body,
tenantId: req.tenantId,
createdBy: req.user.id,
});
return res.status(201).json(record);
} catch (error) {
if (error.name === 'SequelizeUniqueConstraintError') {
return res.status(409).json({ error: 'Duplicate entry', fields: error.fields });
}
return res.status(400).json({ error: error.message });
}
};
// PUT /api/:resource/:id
update = async (req, res) => {
try {
const [updated] = await this.model.update(req.body, {
where: { id: req.params.id, tenantId: req.tenantId },
returning: true,
});
if (!updated) return res.status(404).json({ error: 'Not found' });
const record = await this.model.findByPk(req.params.id, {
include: this.includes,
});
return res.json(record);
} catch (error) {
return res.status(400).json({ error: error.message });
}
};
// DELETE /api/:resource/:id (soft delete)
delete = async (req, res) => {
try {
const deleted = await this.model.destroy({
where: { id: req.params.id, tenantId: req.tenantId },
});
if (!deleted) return res.status(404).json({ error: 'Not found' });
return res.status(204).send();
} catch (error) {
return res.status(500).json({ error: error.message });
}
};
}
module.exports = BaseCrudController;
Each master module then extends this base with its specific configuration:
// controllers/doctor.controller.js
const BaseCrudController = require('./base-crud.controller');
const { Doctor, Specialization, Center } = require('../models');
class DoctorController extends BaseCrudController {
constructor() {
super(Doctor, {
searchFields: ['fullName', 'licenseNumber', 'email'],
includes: [
{ model: Specialization, as: 'specializations', through: { attributes: [] } },
{ model: Center, as: 'center', attributes: ['id', 'name'] },
],
defaultOrder: [['fullName', 'ASC']],
});
}
}
// routes/master.routes.js — register all 27 modules with 5 lines each
const router = require('express').Router();
const tenantContext = require('../middleware/tenant-context.middleware');
const authorize = require('../middleware/authorize.middleware');
function registerMasterRoutes(path, Controller, roles = ['admin', 'superadmin']) {
const ctrl = new Controller();
router.get(`/${path}`, tenantContext, authorize(roles), ctrl.getAll);
router.get(`/${path}/:id`, tenantContext, authorize(roles), ctrl.getById);
router.post(`/${path}`, tenantContext, authorize(roles), ctrl.create);
router.put(`/${path}/:id`, tenantContext, authorize(roles), ctrl.update);
router.delete(`/${path}/:id`, tenantContext, authorize(roles), ctrl.delete);
}
registerMasterRoutes('doctors', require('../controllers/doctor.controller'));
registerMasterRoutes('machines', require('../controllers/machine.controller'));
registerMasterRoutes('modalities', require('../controllers/modality.controller'));
registerMasterRoutes('procedures', require('../controllers/procedure.controller'));
registerMasterRoutes('time-slots', require('../controllers/time-slot.controller'));
// ... 22 more modules, same pattern
This pattern reduced what would have been thousands of lines of duplicated CRUD code to approximately 200 lines in the base controller plus 10-15 lines per module for configuration. When we needed to add audit logging to every create/update/delete operation, we added it once in the base controller and it applied everywhere.
Angular Module Architecture for Role-Based Dashboards
Prevadu Health has four distinct user roles, each with a completely different dashboard experience. The Angular application uses lazy-loaded feature modules to ensure that a patient never downloads the admin code and vice versa.
// app-routing.module.ts
const routes: Routes = [
{
path: 'patient',
loadChildren: () => import('./features/patient/patient.module')
.then(m => m.PatientModule),
canActivate: [AuthGuard],
data: { roles: ['patient'] },
},
{
path: 'radiologist',
loadChildren: () => import('./features/radiologist/radiologist.module')
.then(m => m.RadiologistModule),
canActivate: [AuthGuard],
data: { roles: ['radiologist'] },
},
{
path: 'admin',
loadChildren: () => import('./features/admin/admin.module')
.then(m => m.AdminModule),
canActivate: [AuthGuard],
data: { roles: ['admin', 'superadmin'] },
},
{
path: 'superadmin',
loadChildren: () => import('./features/superadmin/superadmin.module')
.then(m => m.SuperAdminModule),
canActivate: [AuthGuard],
data: { roles: ['superadmin'] },
},
];
// guards/auth.guard.ts
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(
private authService: AuthService,
private router: Router,
) {}
canActivate(route: ActivatedRouteSnapshot): boolean {
const user = this.authService.currentUser;
if (!user) {
this.router.navigate(['/login']);
return false;
}
const requiredRoles = route.data['roles'] as string[];
if (requiredRoles && !requiredRoles.includes(user.role)) {
this.router.navigate(['/unauthorized']);
return false;
}
return true;
}
}
The key decision here is using loadChildren with dynamic imports rather than eagerly loading all modules. Angular's build system splits each feature module into a separate JavaScript chunk. A patient logging in downloads approximately 180KB of JavaScript. An admin downloads approximately 650KB (due to AG-Grid and the master data management interfaces). Neither downloads the other's code.
FullCalendar for Appointment Scheduling
The radiologist dashboard centers around a calendar view showing all scheduled appointments, with drag-and-drop rescheduling and real-time availability checking. We used FullCalendar with custom event rendering.
// radiologist/components/appointment-calendar.component.ts
@Component({
selector: 'app-appointment-calendar',
template: `
<full-calendar [options]="calendarOptions"></full-calendar>
<app-appointment-modal
*ngIf="selectedSlot"
[slot]="selectedSlot"
[doctors]="availableDoctors"
[machines]="availableMachines"
(confirmed)="onAppointmentConfirmed($event)"
(closed)="selectedSlot = null">
</app-appointment-modal>
`,
})
export class AppointmentCalendarComponent implements OnInit {
calendarOptions: CalendarOptions = {
initialView: 'timeGridWeek',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay',
},
slotMinTime: '07:00:00',
slotMaxTime: '22:00:00',
slotDuration: '00:15:00',
selectable: true,
editable: true,
eventOverlap: false,
// Fetch events from API with tenant context
events: (info, successCallback, failureCallback) => {
this.appointmentService.getAppointments(
info.start.toISOString(),
info.end.toISOString(),
).subscribe({
next: (appointments) => {
successCallback(appointments.map(apt => ({
id: apt.id,
title: `${apt.patient.fullName} - ${apt.procedure.name}`,
start: apt.startTime,
end: apt.endTime,
backgroundColor: this.getStatusColor(apt.status),
extendedProps: {
patientId: apt.patient.id,
doctorId: apt.doctor.id,
machineId: apt.machine.id,
status: apt.status,
},
})));
},
error: failureCallback,
});
},
// Availability check before allowing selection
selectAllow: (selectInfo) => {
// Prevent selecting past time slots
return selectInfo.start > new Date();
},
// Create new appointment on time slot selection
select: (info) => {
this.selectedSlot = {
start: info.start,
end: info.end,
doctorId: this.selectedDoctorFilter,
};
this.loadAvailableResources(info.start, info.end);
},
// Reschedule on drag and drop
eventDrop: (info) => {
this.appointmentService.reschedule(
info.event.id,
info.event.start!.toISOString(),
info.event.end!.toISOString(),
).subscribe({
error: () => info.revert(), // Revert on failure
});
},
};
private getStatusColor(status: string): string {
const colors: Record<string, string> = {
scheduled: '#3788d8',
confirmed: '#28a745',
'in-progress': '#ffc107',
completed: '#6c757d',
cancelled: '#dc3545',
'no-show': '#e83e8c',
};
return colors[status] || '#3788d8';
}
private loadAvailableResources(start: Date, end: Date): void {
forkJoin({
doctors: this.doctorService.getAvailable(start, end),
machines: this.machineService.getAvailable(start, end),
}).subscribe(({ doctors, machines }) => {
this.availableDoctors = doctors;
this.availableMachines = machines;
});
}
}
The eventOverlap: false setting prevents double-booking at the UI level. But we also enforce this at the database level with a PostgreSQL exclusion constraint that prevents overlapping time ranges for the same machine or doctor, because UI validation alone is never sufficient for scheduling integrity.
AG-Grid for Admin Data Tables
The admin panel manages 27 master data modules with inline editing, bulk operations, and Excel export. AG-Grid handles this with a single reusable configuration.
// shared/components/master-data-grid.component.ts
@Component({
selector: 'app-master-data-grid',
template: `
<div class="grid-toolbar">
<input type="text" placeholder="Search..." (input)="onSearch($event)" />
<button (click)="onAdd()">Add New</button>
<button (click)="onExportExcel()">Export Excel</button>
</div>
<ag-grid-angular
class="ag-theme-alpine"
[rowData]="rowData"
[columnDefs]="columnDefs"
[defaultColDef]="defaultColDef"
[pagination]="true"
[paginationPageSize]="25"
[rowSelection]="'multiple'"
[enableCellTextSelection]="true"
(gridReady)="onGridReady($event)"
(cellValueChanged)="onCellValueChanged($event)"
(selectionChanged)="onSelectionChanged($event)">
</ag-grid-angular>
`,
})
export class MasterDataGridComponent implements OnInit {
@Input() resourceName!: string;
@Input() columnDefs: ColDef[] = [];
defaultColDef: ColDef = {
sortable: true,
filter: true,
resizable: true,
editable: true,
floatingFilter: true,
minWidth: 100,
};
rowData: any[] = [];
private gridApi!: GridApi;
constructor(private masterDataService: MasterDataService) {}
ngOnInit(): void {
this.loadData();
}
onGridReady(params: GridReadyEvent): void {
this.gridApi = params.api;
this.gridApi.sizeColumnsToFit();
}
onCellValueChanged(event: CellValueChangedEvent): void {
// Inline edit: auto-save on cell change
this.masterDataService.update(this.resourceName, event.data.id, event.data)
.subscribe({
error: () => {
// Revert on failure
event.node.setData({ ...event.data, [event.colDef.field!]: event.oldValue });
this.notifyService.error('Update failed');
},
});
}
onExportExcel(): void {
this.gridApi.exportDataAsExcel({
fileName: `${this.resourceName}_${new Date().toISOString().split('T')[0]}`,
});
}
onSearch(event: Event): void {
const value = (event.target as HTMLInputElement).value;
this.gridApi.setQuickFilter(value);
}
private loadData(): void {
this.masterDataService.getAll(this.resourceName)
.subscribe(response => this.rowData = response.data);
}
}
Each master module then uses this grid with its own column definitions. The inline editing auto-saves changes through the generic MasterDataService, which maps resource names to API endpoints. One grid component serves 27 different master data screens.
SuperPay IFRAME Payment Flow
SuperPay is an Egyptian payment gateway that uses an IFRAME-based checkout flow. The patient selects procedures, sees the total, and clicks pay. The frontend opens SuperPay's hosted checkout in an IFRAME. On completion, SuperPay redirects back and we verify the payment status server-side.
// Payment flow: Backend
// controllers/payment.controller.js
class PaymentController {
// Step 1: Create payment session
async initiatePayment(req, res) {
const { appointmentId, amount, currency = 'EGP' } = req.body;
// Generate unique merchant reference
const merchantRef = `PV-${req.tenantId.slice(0, 8)}-${Date.now()}`;
// Create payment record before redirecting
const payment = await Payment.create({
tenantId: req.tenantId,
appointmentId,
merchantRef,
amount,
currency,
status: 'pending',
createdBy: req.user.id,
});
// Build SuperPay IFRAME URL
const superpayUrl = new URL('https://checkout.superpay.eg/pay');
superpayUrl.searchParams.set('merchant_id', process.env.SUPERPAY_MERCHANT_ID);
superpayUrl.searchParams.set('amount', amount.toFixed(2));
superpayUrl.searchParams.set('currency', currency);
superpayUrl.searchParams.set('ref', merchantRef);
superpayUrl.searchParams.set('return_url',
`${process.env.APP_URL}/payment/callback?ref=${merchantRef}`);
superpayUrl.searchParams.set('notify_url',
`${process.env.API_URL}/api/payments/webhook`);
return res.json({
paymentId: payment.id,
checkoutUrl: superpayUrl.toString(),
merchantRef,
});
}
// Step 2: Background status polling (webhook backup)
async checkPaymentStatus(merchantRef) {
const response = await axios.get(
`https://api.superpay.eg/v1/transactions/${merchantRef}`,
{
headers: {
Authorization: `Bearer ${process.env.SUPERPAY_API_KEY}`,
},
}
);
const payment = await Payment.findOne({ where: { merchantRef } });
if (!payment) return;
if (response.data.status === 'SUCCESS' && payment.status !== 'completed') {
await payment.update({ status: 'completed', paidAt: new Date() });
await Appointment.update(
{ paymentStatus: 'paid' },
{ where: { id: payment.appointmentId } }
);
} else if (response.data.status === 'FAILED') {
await payment.update({ status: 'failed', failureReason: response.data.reason });
}
}
}
// Background job: poll pending payments every 2 minutes
// Catches cases where webhook delivery fails
const cron = require('node-cron');
cron.schedule('*/2 * * * *', async () => {
const pendingPayments = await Payment.findAll({
where: {
status: 'pending',
createdAt: { [Op.gt]: new Date(Date.now() - 30 * 60 * 1000) }, // Last 30 min only
},
});
for (const payment of pendingPayments) {
await paymentController.checkPaymentStatus(payment.merchantRef);
}
});
The background polling is essential. Payment gateways in emerging markets have less reliable webhook delivery than Stripe or PayPal. By polling pending payments every two minutes, we catch successful payments even when the webhook fails to arrive. The 30-minute window prevents polling ancient records indefinitely.
Communication Stack Architecture
Prevadu Health sends appointment reminders, report notifications, and OTP codes through three channels. Each has its own authentication complexity.
Email via Microsoft 365 OAuth: The Egyptian healthcare clients use Microsoft 365 for email. We authenticate using OAuth 2.0 client credentials flow and send through the Microsoft Graph API, not SMTP. This avoids the deliverability issues common with direct SMTP sending.
SMS via VL Serve: VL Serve is a regional SMS provider that requires mutual TLS authentication with client certificates. The Node.js HTTPS agent must present the client certificate on every request. We store the certificate and private key in environment variables (base64 encoded) and decode them at runtime.
Push via Firebase Cloud Messaging: Standard FCM integration with device token management. The Angular app registers for push notifications on login and sends the device token to the backend. The backend stores tokens per user and sends targeted notifications for appointment reminders and report availability.
// services/communication.service.js
const { ConfidentialClientApplication } = require('@azure/msal-node');
const { Client } = require('@microsoft/microsoft-graph-client');
const https = require('https');
const admin = require('firebase-admin');
class CommunicationService {
constructor() {
// M365 OAuth setup
this.msalClient = new ConfidentialClientApplication({
auth: {
clientId: process.env.M365_CLIENT_ID,
clientSecret: process.env.M365_CLIENT_SECRET,
authority: `https://login.microsoftonline.com/${process.env.M365_TENANT_ID}`,
},
});
// VL Serve SMS — mutual TLS agent
this.smsAgent = new https.Agent({
cert: Buffer.from(process.env.VL_SERVE_CERT_B64, 'base64'),
key: Buffer.from(process.env.VL_SERVE_KEY_B64, 'base64'),
ca: Buffer.from(process.env.VL_SERVE_CA_B64, 'base64'),
});
// Firebase Admin
admin.initializeApp({
credential: admin.credential.cert(JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT)),
});
}
async sendEmail(to, subject, htmlBody) {
const tokenResponse = await this.msalClient.acquireTokenByClientCredential({
scopes: ['https://graph.microsoft.com/.default'],
});
const graphClient = Client.init({
authProvider: (done) => done(null, tokenResponse.accessToken),
});
await graphClient.api(`/users/${process.env.M365_SENDER_EMAIL}/sendMail`).post({
message: {
subject,
body: { contentType: 'HTML', content: htmlBody },
toRecipients: [{ emailAddress: { address: to } }],
},
});
}
async sendSMS(phoneNumber, message) {
const response = await axios.post(
'https://api.vlserve.com/v2/sms/send',
{
to: phoneNumber,
message,
sender_id: process.env.VL_SERVE_SENDER_ID,
},
{
httpsAgent: this.smsAgent,
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.VL_SERVE_API_KEY,
},
}
);
return response.data;
}
async sendPushNotification(userId, title, body, data = {}) {
const tokens = await DeviceToken.findAll({
where: { userId, active: true },
});
if (tokens.length === 0) return;
const message = {
notification: { title, body },
data,
tokens: tokens.map(t => t.token),
};
const response = await admin.messaging().sendEachForMulticast(message);
// Clean up invalid tokens
response.responses.forEach((resp, idx) => {
if (!resp.success && resp.error?.code === 'messaging/registration-token-not-registered') {
DeviceToken.update({ active: false }, { where: { token: tokens[idx].token } });
}
});
}
}
module.exports = new CommunicationService();
Angular vs React for Enterprise Dashboards
I have built production applications with both Angular and React. For Prevadu Health, Angular was the right choice for three reasons.
First, built-in structure. Angular enforces modules, services, and dependency injection out of the box. With 27 master data modules and four role-based dashboards, this opinionated structure prevented architectural drift. In React, you have to choose and enforce patterns yourself -- state management library, folder structure conventions, dependency injection approach. With a team of varying experience levels, having the framework enforce structure was valuable.
Second, enterprise component libraries. AG-Grid, FullCalendar, and DevExtreme all have first-class Angular integrations with TypeScript typings and lifecycle management. Their React integrations are good but sometimes feel like wrappers rather than native integrations.
Third, form handling. Angular's reactive forms with built-in validators, async validators, and form arrays handle the complex healthcare forms (patient registration with medical history, multi-step appointment booking) more elegantly than any React form library I have used.
That said, for consumer-facing applications like Errandoo, I prefer React. The component model is simpler, the ecosystem is larger, and the developer pool is deeper. The right tool depends on the problem.
Healthcare Data Compliance Considerations
While Egypt does not have a HIPAA-equivalent regulation at the time of writing, we designed Prevadu Health with compliance-ready patterns that would satisfy most healthcare data regulations.
Soft deletes everywhere: The paranoid: true option in Sequelize means no patient record is ever truly deleted. Instead, a deletedAt timestamp is set, and default queries exclude soft-deleted records. This creates a complete audit trail.
Audit logging: Every create, update, and delete operation logs the user ID, timestamp, old values, and new values to an append-only audit table. This is implemented in the base CRUD controller so it applies to all 27 modules automatically.
Encryption at rest: Patient medical history and sensitive fields use application-level encryption (AES-256-GCM) before being stored in the database. The encryption key is stored in environment variables, not in the database.
Access logging: Every API request that accesses patient data is logged with the user ID, resource accessed, and IP address. This allows for post-incident investigation of who accessed what and when.
Key Takeaways
Building Prevadu Health taught me that the hardest part of healthcare software is not the technology -- it is the domain complexity. A radiology appointment is not just a calendar event. It involves a patient, a referring doctor, a radiologist, a specific machine (which has its own maintenance schedule), a modality (CT, MRI, X-ray), a procedure (each with different preparation requirements), a time slot (which must account for setup and cleanup time), insurance verification, and payment processing. Getting the data model right for this level of complexity took more time than writing the code.
The generic CRUD controller pattern and the reusable AG-Grid component were force multipliers. Adding a new master data module -- say, a new insurance provider category -- takes about two hours: define the Sequelize model, create a controller extending the base, add column definitions for the grid, register the route. Without these patterns, each new module would take two days. When you have 27 of them, that difference is the difference between a viable project and an impossible one.