09-lessons-learned.md

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 duration string ("7 days", "2 weeks") against the prescription date to determine if it's still active
  • Cross-reference with existing MedicineAdherence records to avoid duplicates
  • Fill gaps for time periods that don't have records yet
  • Handle the fallback case where scheduledDate might 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) and String(doctorId)
  • The senderId field in Message is a String — works for doctor UUIDs but requires String(patientId) for patients
  • The JWT payload stores id — but its type depends on the role. Comparing decoded.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

TechnologyLesson Learned
ReactuseState + useEffect for everything leads to prop-drilling and duplicated fetch logic. At this complexity, a state management library (Zustand/React Query) would reduce boilerplate.
ExpressThe 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.
PrismaExcellent 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).
PostgreSQLPartial unique indexes are a game-changer for business rules at the database level. They eliminate entire classes of race conditions.
JWTStateless auth is great for serverless but terrible for session management (no forced logout, no refresh). The payload bloats every request.
Socket.IOEasy to prototype with, hard to deploy at scale. Room-based messaging maps cleanly to chat, but the deployment model (persistent connections) conflicts with serverless.
NodemailerGmail SMTP is fine for development but has hard sending limits. Production email needs a dedicated provider (SendGrid, SES) with deliverability monitoring.
node-cronIn-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

MistakeImpactFix
Patient ID as integer, Doctor ID as UUIDType coercion bugs throughoutBand-aid: parseInt() / String() everywhere. Proper fix: migrate to UUIDs for both.
No Router — all routes in app.js186-line file of imports and route definitionsNot yet fixed. Would use Express Router.
Fallback JWT secret "mysecret"Any source-code reader can forge tokensThe warning log exists but the fallback should throw, not continue.
Duplicated isStillActive() in 3 filesLogic divergence riskCreated utils/medicationHelpers.js with canonical version, but not all callsites updated.
CORS set to origin: "*" for Socket.IOAny origin can connect to WebSocketShould be restricted to FRONTEND_URL.
@map("_id") on Prescription and PrescribedMedicineConfusing column names from MongoDB migrationCosmetic — DB works fine, but the _id column name is misleading in a PostgreSQL context.

What Production-Readiness Features Are Missing

FeatureImpactEffort
Rate limitingBrute-force attacks possibleLow (1h — add express-rate-limit)
Security headersClickjacking, MIME sniffingLow (30min — add helmet)
Input validation libraryInconsistent validationMedium (2-4h — add Zod schemas)
Error standardizationInconsistent error responsesMedium (2-4h — custom error class + handler)
LoggingNo structured logs for debuggingMedium (2h — add Winston/Pino)
Health check endpointNo way to monitor uptimeLow (15min)
Database connection healthNo circuit breakerMedium (2h)
Token refreshUsers logged out every 24hMedium (4h — refresh token flow)
Password strength policyWeak passwords acceptedLow (30min)
Admin endpoint protection/admin/* routes unprotectedLow (15min — add authenticate middleware)
Graceful shutdownProcess kill can corrupt in-flight requestsLow (1h — handle SIGTERM)
TestsZero automated tests (except concurrent booking)High (8-16h for meaningful coverage)

Roadmap: What Would Be Next

Short-term (Next sprint)

  1. Add helmet and express-rate-limit — 1 hour, biggest security ROI
  2. Protect /admin/* endpoints — 15 minutes
  3. Import medicine CSV into PostgreSQL — eliminate 32MB per-request parsing
  4. Add health check endpoint — GET /health returning DB connectivity status

Medium-term (Next month)

  1. Extract service layer — make business logic testable
  2. Add Zod validation — standardize input validation
  3. Implement token refresh — extend session without re-login
  4. Add patient notification preferences — opt-in/out of email reminders
  5. Batch the medication reminder queries — single query instead of N+1

Long-term (Next quarter)

  1. Admin dashboard — aggregate adherence stats, appointment utilization, system health
  2. Move to BullMQ + Redis for background jobs — reliable email delivery and cron
  3. Add unit and integration tests — minimum 60% coverage on business logic
  4. Deploy to Railway/Render (persistent server) — fix Socket.IO and cron
  5. Patient-side prescription history with PDF export
  6. Multi-language support for medicine names and UI