Lessons Learned — Reflection
What Was Harder Than Expected
1. The Medication Schedule Generation Logic
What seems simple — "show today's medications" — turned into the most complex logic in the entire app. The challenge: medications don't exist as pre-generated daily records. They're derived on-the-fly from prescriptions.
The system has to:
- Parse all active prescriptions for a patient
- Check each medicine's
durationstring ("7 days", "2 weeks") against the prescription date to determine if it's still active - Cross-reference with existing
MedicineAdherencerecords to avoid duplicates - Fill gaps for time periods that don't have records yet
- Handle the fallback case where
scheduledDatemight not exist in the schema
This logic ended up being duplicated in three places (patient/medications.js, doctor/medications.js, and middleware/MissedMedicationChecker.js), each with slight variations. The isStillActive() function alone has 3 independent implementations.
Lesson: When a piece of logic is complex enough to be hard to get right once, it should absolutely be in a single shared module.
2. The Doctor ID vs. Patient ID Type Mismatch
Patient IDs are auto-incrementing integers. Doctor IDs are UUIDs. This single early design decision created a cascade of bugs:
- Every handler that touches both needs
parseInt(patientId)andString(doctorId) - The
senderIdfield in Message is a String — works for doctor UUIDs but requiresString(patientId)for patients - The JWT payload stores
id— but its type depends on the role. Comparingdecoded.id == patientId(loose equality) hides type mismatches
Lesson: Pick one ID strategy (UUID everywhere or integer everywhere) and stick with it. The 10 minutes saved by using autoincrement in the first migration cost hours of type coercion debugging later.
3. Making Socket.IO Work With the Middleware System
The missed medication checker middleware needs to emit Socket.IO events when it marks medications as missed. But the middleware is a function imported by app.js, and the Socket.IO instance is created in app.js. Solution: a configureSocketIO() function that injects the io instance into the middleware module, plus global.io for the cron jobs.
This circular dependency pattern (app creates io → passes to middleware → middleware uses io to emit events) is fragile and untestable.
Lesson: Dependency injection should be planned from the start. If a module needs access to a shared resource, the architecture should support that cleanly — not through global variables and module-level configuration functions.
4. Serverless Deployment Broke Half the Features
The backend was built assuming a long-running Node.js process (for Socket.IO connections and cron jobs). Deploying to Vercel meant:
- Cron jobs never run (no persistent process)
- Socket.IO connections can't be maintained (stateless functions)
- The "reminder on every HTTP request" middleware was a workaround for lost cron functionality
Lesson: Decide on the deployment model before writing code. If targeting serverless, use external cron services and managed WebSocket providers from the start. Retrofitting is painful.
What I'd Do Differently
1. Express Router from Day One
Instead of:
// app.js - 30+ imports, 30+ app.get/post calls
import signup from "./auth/signup.js";
app.post("/auth/signup", signup);I'd use:
// routes/auth.js
const router = express.Router();
router.post("/signup", signup);
export default router;
// app.js
app.use("/auth", authRouter);2. Service Layer Between Handlers and Database
Instead of:
// handler does everything
const prescription = async (req, res) => {
if (req.user.role !== 'doctor') return res.status(403)...
const { patientId, medicines } = req.body;
if (!patientId) return res.status(400)...
const patient = await prisma.Patient.findUnique(...)
const result = await prisma.Prescription.create(...)
return res.status(201).json(result);
};I'd have:
// service (testable without HTTP)
class PrescriptionService {
async create(doctorId, patientId, medicines) { ... }
}
// handler (thin HTTP layer)
const handler = async (req, res) => {
const result = await prescriptionService.create(req.user.id, ...req.body);
return res.status(201).json(result);
};3. Use Zod for Input Validation
Instead of manual if (!field) checks, a Zod schema validates and types the entire request body:
const CreatePrescriptionSchema = z.object({
patientId: z.number().int().positive(),
condition: z.string().default("General"),
medicines: z.array(MedicineSchema).min(1),
});4. Standardized Error Handling
Instead of inconsistent { message } vs { error } vs plain strings:
class AppError extends Error {
constructor(statusCode, message) { ... }
}
// Global error handler
app.use((err, req, res, next) => {
res.status(err.statusCode || 500).json({ error: err.message });
});What I Learned That I Didn't Expect
1. Database Constraints Are More Reliable Than Application Logic
The appointment double-booking problem taught me that application-level conflict checks (findFirst → if exists → return 409) have race conditions. Two requests can both pass the check simultaneously. The partial unique index at the PostgreSQL level is the only reliable solution — the database is the single source of truth for concurrent writes.
2. Cron Jobs Are Deployment-Model-Dependent
I assumed node-cron would "just work" everywhere. On Vercel serverless, it's dead on arrival. The distinction between "code that runs" and "code that runs where you deploy it" wasn't obvious until it broke.
3. Many-to-Many Relationships Create Implicit Tables
Prisma's Doctor[] @relation("DoctorToPatient") creates an implicit _DoctorToPatient join table with its own columns (A, B). Understanding that connect/disconnect operations manipulate this hidden table was key to making the assign/remove patient features work.
4. Emails Are a Distributed System Problem
Sending an email seems simple, but it's actually fire-and-forget with no delivery guarantee. When sendInviteEmail() fails, the patient is created but never receives the invite. There's no retry mechanism, no dead letter queue, and no way to know the email bounced. Email delivery needs to be treated as an async job with retries.
What the Project Taught Me About Each Tech
| Technology | Lesson Learned |
|---|---|
| React | useState + useEffect for everything leads to prop-drilling and duplicated fetch logic. At this complexity, a state management library (Zustand/React Query) would reduce boilerplate. |
| Express | The framework gives you nothing for free — no validation, no error handling, no structure. You have to build every cross-cutting concern yourself or import a library. |
| Prisma | Excellent for CRUD, but partial unique indexes require raw SQL migrations. The include syntax for eager loading is powerful but easy to over-use (N+1 at the ORM level). |
| PostgreSQL | Partial unique indexes are a game-changer for business rules at the database level. They eliminate entire classes of race conditions. |
| JWT | Stateless auth is great for serverless but terrible for session management (no forced logout, no refresh). The payload bloats every request. |
| Socket.IO | Easy to prototype with, hard to deploy at scale. Room-based messaging maps cleanly to chat, but the deployment model (persistent connections) conflicts with serverless. |
| Nodemailer | Gmail SMTP is fine for development but has hard sending limits. Production email needs a dedicated provider (SendGrid, SES) with deliverability monitoring. |
| node-cron | In-process cron is a single point of failure. If the process restarts, missed cron windows are gone forever. Job queues with persistence (BullMQ + Redis) are the production answer. |
Mistakes Made and How They Were Fixed
| Mistake | Impact | Fix |
|---|---|---|
| Patient ID as integer, Doctor ID as UUID | Type coercion bugs throughout | Band-aid: parseInt() / String() everywhere. Proper fix: migrate to UUIDs for both. |
| No Router — all routes in app.js | 186-line file of imports and route definitions | Not yet fixed. Would use Express Router. |
| Fallback JWT secret "mysecret" | Any source-code reader can forge tokens | The warning log exists but the fallback should throw, not continue. |
Duplicated isStillActive() in 3 files | Logic divergence risk | Created utils/medicationHelpers.js with canonical version, but not all callsites updated. |
CORS set to origin: "*" for Socket.IO | Any origin can connect to WebSocket | Should be restricted to FRONTEND_URL. |
@map("_id") on Prescription and PrescribedMedicine | Confusing column names from MongoDB migration | Cosmetic — DB works fine, but the _id column name is misleading in a PostgreSQL context. |
What Production-Readiness Features Are Missing
| Feature | Impact | Effort |
|---|---|---|
| Rate limiting | Brute-force attacks possible | Low (1h — add express-rate-limit) |
| Security headers | Clickjacking, MIME sniffing | Low (30min — add helmet) |
| Input validation library | Inconsistent validation | Medium (2-4h — add Zod schemas) |
| Error standardization | Inconsistent error responses | Medium (2-4h — custom error class + handler) |
| Logging | No structured logs for debugging | Medium (2h — add Winston/Pino) |
| Health check endpoint | No way to monitor uptime | Low (15min) |
| Database connection health | No circuit breaker | Medium (2h) |
| Token refresh | Users logged out every 24h | Medium (4h — refresh token flow) |
| Password strength policy | Weak passwords accepted | Low (30min) |
| Admin endpoint protection | /admin/* routes unprotected | Low (15min — add authenticate middleware) |
| Graceful shutdown | Process kill can corrupt in-flight requests | Low (1h — handle SIGTERM) |
| Tests | Zero automated tests (except concurrent booking) | High (8-16h for meaningful coverage) |
Roadmap: What Would Be Next
Short-term (Next sprint)
- Add
helmetandexpress-rate-limit— 1 hour, biggest security ROI - Protect
/admin/*endpoints — 15 minutes - Import medicine CSV into PostgreSQL — eliminate 32MB per-request parsing
- Add health check endpoint —
GET /healthreturning DB connectivity status
Medium-term (Next month)
- Extract service layer — make business logic testable
- Add Zod validation — standardize input validation
- Implement token refresh — extend session without re-login
- Add patient notification preferences — opt-in/out of email reminders
- Batch the medication reminder queries — single query instead of N+1
Long-term (Next quarter)
- Admin dashboard — aggregate adherence stats, appointment utilization, system health
- Move to BullMQ + Redis for background jobs — reliable email delivery and cron
- Add unit and integration tests — minimum 60% coverage on business logic
- Deploy to Railway/Render (persistent server) — fix Socket.IO and cron
- Patient-side prescription history with PDF export
- Multi-language support for medicine names and UI