the Express + TypeScript API, Prisma, PostgreSQL and MongoDB behind Velora: every section is tagged with the Advanced Programming (AP) or Data Engineering (DE) week it comes from, and nothing here goes past what this semester teaches
A modular monolith: one Express app, split into modules by domain. No Redis, no queue, no PostGIS, no microservices, because none of them are taught this semester.
Week 5's activity is "refactoring an Express project as per best practices". This is that structure: each layer may only call the one below it.
server/
├─ prisma/
│ ├─ schema.prisma 9 models, enums, @@unique, @@index
│ ├─ migrations/ generated by prisma migrate dev
│ └─ seed.ts fs.promises → createMany → insertMany
├─ data/ medicines.json pharmacies.json areas.json
└─ src/
├─ server.ts connect Mongo, app.listen(PORT)
├─ app.ts middleware order + routers (§3)
├─ config/env.ts reads process.env once, fails fast
├─ routes/ auth search holds pharmacies admin
├─ controllers/ one per route file
├─ services/ AuthService SearchService HoldService
│ StockService PharmacyService AdminService
├─ middlewares/ authenticate authorize validate
│ notFound errorHandler
├─ validators/ body/params schema per route
├─ db/ prisma.ts (one client), mongo.ts
├─ types/express.d.ts adds req.user { id, role }
└─ utils/ AppError asyncHandler jwt hash geo
Week 4 covers built-in, user-created and third-party middleware. Velora uses all three kinds, and the order matters: the error handler must be last.
// src/app.ts
const app = express();
app.use(express.json({ limit: '100kb' }));
app.use(cookieParser());
app.use(morgan(env.NODE_ENV === 'production' ? 'combined' : 'dev'));
app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/search', searchRoutes);
app.use('/api/v1/holds', holdRoutes);
app.use('/api/v1/pharmacies', pharmacyRoutes);
app.use('/api/v1/admin', adminRoutes);
app.use(notFound); // AppError(404) for any unknown path
app.use(errorHandler); // (err, req, res, next): four arguments, always last
// src/routes/holds.routes.ts
router.post('/', authenticate, authorize('PATIENT'), validate(placeHold), asyncHandler(ctrl.create));
router.get('/me', authenticate, authorize('PATIENT'), asyncHandler(ctrl.mine));
router.patch('/:id/cancel', authenticate, authorize('PATIENT'), validate(idParam), asyncHandler(ctrl.cancel));
REST resources with the right HTTP method and status code for each action (week 1: methods and status codes). Lists take ?page and ?limit (default 20, max 50).
| Method + path | Who | Service method | OK | Errors |
|---|---|---|---|---|
| POST /auth/register | public | AuthService.register | 201 | 400, 409 |
| POST /auth/login | public | AuthService.login | 200 | 400, 401 |
| POST /auth/refresh | cookie | AuthService.refresh | 200 | 401 |
| POST /auth/logout | cookie | AuthService.logout | 204 | — |
| GET /auth/me | any login | AuthService.me | 200 | 401 |
| GET /search | public | SearchService.search | 200 | 400 |
| GET /search/medicines?q= | public | SearchService.suggest | 200 | — |
| GET /search/areas | public | SearchService.areas | 200 | — |
| GET /pharmacies/:id/public | public | PharmacyService.publicDetail | 200 | 404 |
| POST /holds | PATIENT | HoldService.placeHold | 201 | 401, 409 |
| GET /holds/me | PATIENT | HoldService.listMine | 200 | 401 |
| PATCH /holds/:id/cancel | PATIENT | HoldService.cancel | 200 | 403, 409 |
| POST /pharmacies | PHARMACIST | PharmacyService.register | 201 | 400, 409 |
| GET /pharmacies/mine | PHARMACIST | PharmacyService.mine | 200 | 404 |
| POST /pharmacies/:id/credentials | PHARMACIST | PharmacyService.renewLicence | 201 | 403 |
| GET /pharmacies/:id/stock | PHARMACIST | StockService.list | 200 | 403 |
| POST /pharmacies/:id/stock | PHARMACIST | StockService.add | 201 | 403, 409 |
| PATCH /pharmacies/:id/stock/:itemId | PHARMACIST | StockService.update | 200 | 403, 404 |
| PUT /pharmacies/:id/hours | PHARMACIST | PharmacyService.setHours | 200 | 400, 403 |
| GET /pharmacies/:id/holds | PHARMACIST | HoldService.incoming | 200 | 403 |
| PATCH /pharmacies/:id/holds/:holdId/collect | PHARMACIST | HoldService.collect | 200 | 403, 409 |
| GET /admin/pharmacies?status= | ADMIN | AdminService.list | 200 | 403 |
| PATCH /admin/pharmacies/:id/verify | ADMIN | AdminService.verify | 200 | 404, 409 |
| PATCH /admin/pharmacies/:id/reject | ADMIN | AdminService.reject | 200 | 400, 404 |
| PATCH /admin/pharmacies/:id/suspend | ADMIN | AdminService.suspend | 200 | 400, 404 |
| PATCH /admin/pharmacies/:id/reinstate | ADMIN | AdminService.reinstate | 200 | 404, 409 |
| GET /admin/compliance/expiring | ADMIN | AdminService.expiringLicences | 200 | 403 |
| GET /admin/compliance/missing-hours | ADMIN | AdminService.missingHours | 200 | 403 |
| GET /admin/compliance/expired-holds | ADMIN | AdminService.expiredHolds | 200 | 403 |
| POST /admin/search-index/rebuild | ADMIN | AdminService.rebuildSearch | 200 | 403 |
{ "data": ... } (lists add "meta": { page, limit, total }). Error body: { "error": { "status": 409, "message": "Just missed it", "details": ... } }. The frontend relies on this shape everywhere.Week 8: JWT, hashing, protected routes. Week 9: refresh tokens and role-based access control. The refresh token lives in PostgreSQL through Prisma, never in an external cache.
{ sub, role }; sent as Authorization: Bearer/api/v1/auth; only its SHA-256 hash is stored// middlewares/authenticate.ts
export const authenticate: RequestHandler = (req, _res, next) => {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) return next(new AppError(401, 'Log in first'));
try {
const p = jwt.verify(header.slice(7), env.JWT_ACCESS_SECRET) as JwtPayload;
req.user = { id: Number(p.sub), role: p.role };
next();
} catch {
next(new AppError(401, 'Session expired'));
}
};
// middlewares/authorize.ts
export const authorize = (...roles: Role[]): RequestHandler =>
(req, _res, next) =>
roles.includes(req.user!.role) ? next() : next(new AppError(403, 'Forbidden'));
// services/AuthService.ts: refresh with rotation
async refresh(rawToken: string | undefined) {
if (!rawToken) throw new AppError(401, 'Log in again');
const hash = sha256(rawToken);
const row = await prisma.refreshToken.findUnique({
where: { tokenHash: hash }, include: { user: true } });
if (!row || row.revokedAt || row.expiresAt < new Date())
throw new AppError(401, 'Log in again');
const next = randomToken();
await prisma.$transaction([
prisma.refreshToken.update({ where: { id: row.id },
data: { revokedAt: new Date() } }),
prisma.refreshToken.create({ data: { userId: row.userId,
tokenHash: sha256(next), expiresAt: days(7) } }),
]);
return { accessToken: signAccess(row.user), refreshToken: next };
}
Six TypeScript classes. Each one owns a slice of the domain and is the only code that touches its tables.
| Service | Owns | Key rules | Uses a transaction for |
|---|---|---|---|
| AuthService | user, refresh_token | email unique; role only PATIENT / PHARMACIST at register | refresh rotation |
| SearchService | reads availability (Mongo), view + stock (PG) | only VERIFIED + open now; final quantity check in PG; badge by last_updated | — (read only) |
| HoldService | hold, stock_item.quantity | one ACTIVE hold per patient; 30 min; lazy expiry returns units | place, cancel, collect |
| StockService | stock_item | owner + VERIFIED only; quantity ≥ 0; flags mongo_synced on failed copy | — (single row) |
| PharmacyService | pharmacy, credential, operating_hours | new pharmacy starts PENDING; expiry date in future; hours split past midnight | register (pharmacy + credential), set hours |
| AdminService | status changes, audit_log, compliance SQL | every decision writes audit_log; reject and suspend need a reason | verify, reject, suspend, reinstate |
Week 6 is setup and CRUD; week 7 is migrations, seeding, relations between models and transactions. All four are load-bearing in Velora.
// prisma/schema.prisma (excerpt)
enum Role { PATIENT PHARMACIST ADMIN }
enum PharmacyStatus { PENDING VERIFIED REJECTED SUSPENDED }
enum HoldStatus { ACTIVE COLLECTED CANCELLED EXPIRED }
model Pharmacy {
id Int @id @default(autoincrement())
owner User @relation(fields: [ownerId], references: [id])
ownerId Int
name String
address String
area String
lat Decimal @db.Decimal(9, 6)
lng Decimal @db.Decimal(9, 6)
phone String
is24x7 Boolean @default(false)
status PharmacyStatus @default(PENDING)
credentials Credential[]
hours OperatingHours[]
stock StockItem[]
@@index([status, lat, lng])
}
model StockItem {
id Int @id @default(autoincrement())
pharmacy Pharmacy @relation(fields: [pharmacyId], references: [id])
pharmacyId Int
medicine Medicine @relation(fields: [medicineId], references: [id])
medicineId Int
quantity Int
price Decimal @db.Decimal(8, 2)
lastUpdated DateTime @default(now())
mongoSynced Boolean @default(true)
holds Hold[]
@@unique([pharmacyId, medicineId])
@@index([medicineId, quantity])
}
CHECK (quantity >= 0) and the verified_open_pharmacy viewNine tables. Constraints do as much of the checking as possible, so a bug in code can't store bad data.
| Table | Keys | Constraints | Why it's separate |
|---|---|---|---|
| user | PK id | email UNIQUE NOT NULL, role enum | one row per login |
| refresh_token | PK id, FK user_id | token_hash UNIQUE | a user has many devices / sessions |
| pharmacy | PK id, FK owner_id | status enum, lat/lng NOT NULL | the business itself |
| credential | PK id, FK pharmacy_id | expiry_date > issue_date | licences renew: history without editing pharmacy (3NF) |
| operating_hours | PK id, FK pharmacy_id | day_of_week 0–6, close > open | one value per column: one row per day (1NF) |
| medicine | PK id | name + form UNIQUE | composition and form depend only on the medicine (BCNF) |
| stock_item | PK id, FK pharmacy_id, FK medicine_id | UNIQUE (pharmacy_id, medicine_id), CHECK quantity ≥ 0 | resolves pharmacy ↔ medicine many-to-many; quantity needs both (2NF) |
| hold | PK id, FK stock_item_id, FK patient_id | status enum, expires_at NOT NULL | each reservation has its own lifecycle |
| audit_log | PK id, FK actor_id, FK pharmacy_id | append only: no update or delete route | every admin decision, for compliance queries |
Every query technique from weeks 3 to 7 is used by an actual screen, run through prisma.$queryRaw.
| Question | Technique | DE wk | Used by |
|---|---|---|---|
| Is this stock fresh or stale? | CASE on last_updated | 3 | search badge |
| Medicines matching what was typed | ILIKE wildcard, LIMIT | 3 | suggestions |
| Pharmacies with more than 5 expired holds this week | GROUP BY + HAVING + DATE_TRUNC | 4 | compliance |
| Stock with pharmacy and medicine names | INNER JOIN x 2 | 5 | pharmacist stock list |
| Every pharmacy, even with no stock yet | LEFT JOIN + COALESCE(count, 0) | 4–5 | admin list |
| Other medicines with the same salt | SELF JOIN on composition | 6 | search alternatives |
| Open right now | NON-EQUI JOIN on time range, in a VIEW | 6–7 | search |
| Days with no hours set | CROSS JOIN days 0–6 + LEFT JOIN, keep NULLs | 6 | compliance |
| Pharmacies stocking nothing | non-correlated subquery, NOT IN | 6 | compliance |
| Licences expiring with no newer one on file | CTE + correlated NOT EXISTS | 7 | compliance |
| Was this pharmacy suspended before? | correlated EXISTS on audit_log | 7 | approval screen |
| Nearest first | CTE + Haversine + ORDER BY + LIMIT | 3, 7 | search |
-- DE wk 6-7: the view every search reads
CREATE VIEW verified_open_pharmacy AS
SELECT p.id, p.name, p.phone, p.lat, p.lng, p.is_24x7
FROM pharmacy p
JOIN operating_hours oh
ON oh.pharmacy_id = p.id
AND oh.day_of_week = EXTRACT(DOW FROM NOW())
AND CURRENT_TIME BETWEEN oh.open_time AND oh.close_time
WHERE p.status = 'VERIFIED';
-- DE wk 6: missing days
SELECT p.name, d.day
FROM pharmacy p
CROSS JOIN generate_series(0, 6) AS d(day)
LEFT JOIN operating_hours oh
ON oh.pharmacy_id = p.id AND oh.day_of_week = d.day
WHERE p.status = 'VERIFIED' AND oh.id IS NULL;
-- DE wk 7: CTE + correlated NOT EXISTS
WITH expiring AS (
SELECT c.pharmacy_id, c.license_number, c.expiry_date
FROM credential c
WHERE c.expiry_date BETWEEN CURRENT_DATE
AND CURRENT_DATE + INTERVAL '30 days'
)
SELECT p.name, e.license_number, e.expiry_date
FROM expiring e
JOIN pharmacy p ON p.id = e.pharmacy_id
WHERE NOT EXISTS (
SELECT 1 FROM credential c2
WHERE c2.pharmacy_id = e.pharmacy_id
AND c2.expiry_date > e.expiry_date);
-- DE wk 4: GROUP BY + HAVING
SELECT s.pharmacy_id, COUNT(*) AS expired_holds
FROM hold h JOIN stock_item s ON s.id = h.stock_item_id
WHERE h.status = 'EXPIRED'
AND h.expires_at >= DATE_TRUNC('week', NOW())
GROUP BY s.pharmacy_id
HAVING COUNT(*) > 5;
Two patients tap Hold on the last unit at once. The decrement only happens while quantity is at least 1, so PostgreSQL itself decides the one winner.
// services/HoldService.ts
async placeHold(patientId: number, stockItemId: number) {
if (await prisma.hold.count({ where: { patientId, status: 'ACTIVE',
expiresAt: { gt: new Date() } } }))
throw new AppError(409, 'You already have an active hold');
const hold = await prisma.$transaction(async (tx) => {
// 1. lazy expiry: old holds on this item give their units back
const expired = await tx.hold.updateMany({
where: { stockItemId, status: 'ACTIVE', expiresAt: { lt: new Date() } },
data: { status: 'EXPIRED' } });
if (expired.count) await tx.stockItem.update({ where: { id: stockItemId },
data: { quantity: { increment: expired.count } } });
// 2. take one unit only if one is left
const took = await tx.stockItem.updateMany({
where: { id: stockItemId, quantity: { gte: 1 } },
data: { quantity: { decrement: 1 } } });
if (took.count === 0) throw new AppError(409, 'Just missed it'); // ROLLBACK
// 3. record it
return tx.hold.create({ data: { stockItemId, patientId, status: 'ACTIVE',
expiresAt: new Date(Date.now() + 30 * 60_000) } });
}); // COMMIT
await this.copy.adjust(stockItemId, -1); // Mongo $inc, after commit
return hold;
}
Seed enough rows that a full scan hurts, run EXPLAIN ANALYZE, add B-Tree indexes, run it again. The before and after table goes in the report.
| Index | Query it serves | Expect before → after |
|---|---|---|
| stock_item (medicine_id, quantity) | who has medicine X in stock | Seq Scan → Index Scan |
| pharmacy (status, lat, lng) | verified pharmacies inside the search box | Seq Scan → Index / Bitmap Scan |
| operating_hours (pharmacy_id, day_of_week) | the open-now view join | Hash Join on full table → Index Scan |
| credential (pharmacy_id, expiry_date) | expiring-licence CTE | Seq Scan → Index Scan |
| hold (stock_item_id, status, expires_at) | lazy expiry inside the hold transaction | keeps the lock window short |
| hold (patient_id, status) | "one active hold" check, My holds | Seq Scan → Index Scan |
Two collections, written only by services after a PostgreSQL commit, and fully rebuildable from PostgreSQL by the admin rebuild endpoint.
// availability: one document per stock item (DE wk 11: embed)
{
_id: 42, // = stock_item.id
pharmacyId: 3, // reference, not embedded
medicine: { name: "Dolo 650", composition: "Paracetamol 650mg",
form: "TABLET" }, // embedded copy
quantity: 4, price: 32, available: true,
lastUpdated: ISODate("2026-09-28T08:10:00Z")
}
// pharmacies: referenced by availability.pharmacyId
{ _id: 3, name: "City Care Chemist", area: "Sonipat Sector 14",
lat: 28.9931, lng: 77.0151, is24x7: true, verified: true }
// DE wk 12: search pipeline
db.availability.aggregate([
{ $match: { "medicine.name": { $regex: q, $options: "i" }, available: true } },
{ $lookup: { from: "pharmacies", localField: "pharmacyId",
foreignField: "_id", as: "pharmacy" } },
{ $match: { "pharmacy.verified": true } },
{ $sort: { lastUpdated: -1 } },
{ $limit: 50 }
])
// DE wk 12: admin report
db.availability.aggregate([
{ $match: { available: false } },
{ $group: { _id: "$medicine.name", pharmacies: { $sum: 1 } } },
{ $sort: { pharmacies: -1 } }, { $limit: 10 }
])
updateOne({_id}, {$inc: {quantity: -1}})$inc: {quantity: +n}$set: {quantity, price, available, lastUpdated}pharmacies.updateOne({_id}, {$set: {verified}})mongo_synced = false on the PG row; the request still succeedsreplaceOne(..., {upsert: true})Week 10: sync and async errors, and a custom error class. Every failure, expected or not, leaves through one middleware in one JSON shape.
// utils/AppError.ts
export class AppError extends Error {
constructor(public status: number, message: string, public details?: unknown) {
super(message);
this.name = 'AppError';
}
}
// utils/asyncHandler.ts: async errors reach next()
export const asyncHandler = (fn: AsyncHandler): RequestHandler =>
(req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
// middlewares/errorHandler.ts
export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
if (err?.code === 'P2002') err = new AppError(409, 'Already exists');
if (err?.code === 'P2025') err = new AppError(404, 'Not found');
if (err instanceof AppError)
return res.status(err.status).json({ error: { status: err.status,
message: err.message, details: err.details } });
console.error(err); // stack stays on the server
res.status(500).json({ error: { status: 500, message: 'Something went wrong' } });
};
| Status | When |
|---|---|
| 400 | validate middleware: missing or wrong fields, with details per field |
| 401 | no token, bad token, expired token, wrong password |
| 403 | wrong role, or not the owner of that pharmacy / hold |
| 404 | unknown path, or Prisma P2025 record not found |
| 409 | Prisma P2002 duplicate, last unit taken, already has a hold, wrong status |
| 500 | anything unexpected; generic message only |
Node runs your JavaScript on one main thread. While it waits on PostgreSQL, MongoDB or bcrypt, it serves other requests. Anything synchronous and slow makes every user wait.
await bcrypt.hash() runs in the libuv thread pool; hashSync blocks everyonefs.promises in the seed script; no readFileSync in any handlertake / skip, default 20, max 50Promise.all for the three compliance reports, not three awaits in a row$transaction; the Mongo copy happens after commitWeek 11 converts JS to TS and represents entities as classes. Velora uses classes where they carry behaviour and types where they only describe shape.
AppError extends Error adds status and detailsreleaseExpired(tx, id); controllers only see public methodsreq.user typed in express.d.ts// types/express.d.ts
declare global { namespace Express { interface Request { user?: { id: number; role: Role } } } }
// services/index.ts: wire once, reuse everywhere
export const searchCopy = new SearchCopy(mongoDb); // wraps availability + pharmacies
export const authService = new AuthService(prisma);
export const holdService = new HoldService(prisma, searchCopy);
export const stockService = new StockService(prisma, searchCopy);
export const adminService = new AdminService(prisma, searchCopy);
Week 12: deploying Node projects, dev vs production, .env files. Heroku runs the API; Vercel serves the React app and forwards /api to Heroku.
| Variable | Used for |
|---|---|
| DATABASE_URL | Prisma → managed PostgreSQL |
| MONGO_URL | MongoDB Atlas |
| JWT_ACCESS_SECRET | signs access tokens |
| JWT_REFRESH_SECRET | kept separate so one leak doesn't expose both |
| ADMIN_EMAIL, ADMIN_PASSWORD | seed script only |
| NODE_ENV | secure cookies and log format in production |
| PORT | set by Heroku |
// package.json
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"db:migrate": "prisma migrate dev",
"db:seed": "prisma db seed"
}
# Procfile (Heroku)
release: npx prisma migrate deploy
web: npm start
# .env.example is committed; .env never is
Six things to show live with Postman or the frontend. Each one proves a syllabus topic instead of just claiming it.
/admin/pharmacies as a PATIENT → 403; as ADMIN → 200/holds/me → 401 → refresh → 200