Application code you write: routes, controllers, services
Data: Prisma, PostgreSQL, MongoDB
Entry and security boundary: middleware, auth
Correctness-critical: gets it wrong once, it's bad
VELORA
BACKEND ARCHITECTURE
CSA213 + CSA214 · SEM 3

Backend

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

1 · Stack: one tool per syllabus topic AP 1–12 · DE 1–12

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.

Node.js + ExpressHTTP server, routing, middlewareAP wk 1–4
TypeScripttyped requests, services as classesAP wk 11
Prisma ORMmodels, relations, migrations, seed, transactionsAP wk 5–7
jsonwebtoken + bcryptaccess + refresh tokens, password hashing, RBACAP wk 8–9
PostgreSQLsource of truth: SQL, joins, views, CTEs, ACID, indexesDE wk 1–9
MongoDBread copy for search: CRUD, $set/$inc, aggregationDE wk 10–12
The one rule: PostgreSQL decides, MongoDB copies. Every write and every final "is it really in stock" check goes to PostgreSQL. MongoDB is only read by search, and it can always be rebuilt from PostgreSQL.
2 · Folder structure: MVC-style layers AP wk 5

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
routes/path + middleware list only; no logic
controllers/read req, call one service method, send res; never import Prisma
services/business rules; every DB call and every transaction lives here
db/one PrismaClient and one Mongo connection for the whole app
middlewares/cross-cutting checks that run before controllers
utils/AppErrorthe only way a layer reports a known failure
3 · Request path: middleware in the order it runs AP wk 3–4

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.

1 · express.json()built-in: parses JSON bodies, limit 100 kb
2 · cookieParser()third-party: reads the refresh cookie
3 · morgan('dev')third-party: logs method, path, status, time
4 · /api/v1 routersversioned paths (wk 4)
5 · per-route chainauthenticate → authorize → validate → controller
6 · notFound + errorHandleruser-created, registered 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));
4 · Route map: all 30 endpoints under /api/v1 AP wk 1, 4

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 + pathWhoService methodOKErrors
POST /auth/registerpublicAuthService.register201400, 409
POST /auth/loginpublicAuthService.login200400, 401
POST /auth/refreshcookieAuthService.refresh200401
POST /auth/logoutcookieAuthService.logout204—
GET /auth/meany loginAuthService.me200401
GET /searchpublicSearchService.search200400
GET /search/medicines?q=publicSearchService.suggest200—
GET /search/areaspublicSearchService.areas200—
GET /pharmacies/:id/publicpublicPharmacyService.publicDetail200404
POST /holdsPATIENTHoldService.placeHold201401, 409
GET /holds/mePATIENTHoldService.listMine200401
PATCH /holds/:id/cancelPATIENTHoldService.cancel200403, 409
POST /pharmaciesPHARMACISTPharmacyService.register201400, 409
GET /pharmacies/minePHARMACISTPharmacyService.mine200404
POST /pharmacies/:id/credentialsPHARMACISTPharmacyService.renewLicence201403
GET /pharmacies/:id/stockPHARMACISTStockService.list200403
POST /pharmacies/:id/stockPHARMACISTStockService.add201403, 409
PATCH /pharmacies/:id/stock/:itemIdPHARMACISTStockService.update200403, 404
PUT /pharmacies/:id/hoursPHARMACISTPharmacyService.setHours200400, 403
GET /pharmacies/:id/holdsPHARMACISTHoldService.incoming200403
PATCH /pharmacies/:id/holds/:holdId/collectPHARMACISTHoldService.collect200403, 409
GET /admin/pharmacies?status=ADMINAdminService.list200403
PATCH /admin/pharmacies/:id/verifyADMINAdminService.verify200404, 409
PATCH /admin/pharmacies/:id/rejectADMINAdminService.reject200400, 404
PATCH /admin/pharmacies/:id/suspendADMINAdminService.suspend200400, 404
PATCH /admin/pharmacies/:id/reinstateADMINAdminService.reinstate200404, 409
GET /admin/compliance/expiringADMINAdminService.expiringLicences200403
GET /admin/compliance/missing-hoursADMINAdminService.missingHours200403
GET /admin/compliance/expired-holdsADMINAdminService.expiredHolds200403
POST /admin/search-index/rebuildADMINAdminService.rebuildSearch200403
Success body: { "data": ... } (lists add "meta": { page, limit, total }). Error body: { "error": { "status": 409, "message": "Just missed it", "details": ... } }. The frontend relies on this shape everywhere.
5 · Authentication and RBAC AP wk 8–9

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.

Access tokenJWT, 15 minutes, signed with JWT_ACCESS_SECRET; payload { sub, role }; sent as Authorization: Bearer
Refresh tokenrandom, 7 days, httpOnly cookie on path /api/v1/auth; only its SHA-256 hash is stored
RefreshToken tableuser_id, token_hash UNIQUE, expires_at, revoked_at; rotated on every refresh
Passwordsbcrypt, 10 rounds, async API only; the hash never leaves AuthService
// 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 };
}
RBAC = role check in middleware: "can this role call this route at all"
ownership = check in the service: "does this pharmacist own pharmacy :id"
ADMIN can't be picked at register; the one admin comes from the seed script
logout sets revoked_at and clears the cookie; that token can never refresh again
6 · Services: where the business rules live AP wk 5–7, 11

Six TypeScript classes. Each one owns a slice of the domain and is the only code that touches its tables.

ServiceOwnsKey rulesUses a transaction for
AuthServiceuser, refresh_tokenemail unique; role only PATIENT / PHARMACIST at registerrefresh rotation
SearchServicereads availability (Mongo), view + stock (PG)only VERIFIED + open now; final quantity check in PG; badge by last_updated— (read only)
HoldServicehold, stock_item.quantityone ACTIVE hold per patient; 30 min; lazy expiry returns unitsplace, cancel, collect
StockServicestock_itemowner + VERIFIED only; quantity ≥ 0; flags mongo_synced on failed copy— (single row)
PharmacyServicepharmacy, credential, operating_hoursnew pharmacy starts PENDING; expiry date in future; hours split past midnightregister (pharmacy + credential), set hours
AdminServicestatus changes, audit_log, compliance SQLevery decision writes audit_log; reject and suspend need a reasonverify, reject, suspend, reinstate
7 · Prisma: models, migrations, seed, transactions AP wk 5–7

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])
}
prisma migrate devcreates a migration per schema change; committed to git
Raw SQL migrationadds what Prisma can't express: CHECK (quantity >= 0) and the verified_open_pharmacy view
prisma db seedreads JSON with fs.promises, createMany into PG, then builds and insertMany into Mongo
Relationsinclude / select to load pharmacy + medicine with a stock item in one query
$transaction(fn)interactive: the hold, where later steps depend on earlier results
$transaction([...])batch: refresh rotation, admin status + audit row
$queryRawtagged template for the search SQL and compliance reports; parameters are never string-joined
One clientdb/prisma.ts exports a single PrismaClient so the connection pool is shared
8 · PostgreSQL schema and normalisation DE wk 1–2, 5, 8

Nine tables. Constraints do as much of the checking as possible, so a bug in code can't store bad data.

TableKeysConstraintsWhy it's separate
userPK idemail UNIQUE NOT NULL, role enumone row per login
refresh_tokenPK id, FK user_idtoken_hash UNIQUEa user has many devices / sessions
pharmacyPK id, FK owner_idstatus enum, lat/lng NOT NULLthe business itself
credentialPK id, FK pharmacy_idexpiry_date > issue_datelicences renew: history without editing pharmacy (3NF)
operating_hoursPK id, FK pharmacy_idday_of_week 0–6, close > openone value per column: one row per day (1NF)
medicinePK idname + form UNIQUEcomposition and form depend only on the medicine (BCNF)
stock_itemPK id, FK pharmacy_id, FK medicine_idUNIQUE (pharmacy_id, medicine_id), CHECK quantity ≥ 0resolves pharmacy ↔ medicine many-to-many; quantity needs both (2NF)
holdPK id, FK stock_item_id, FK patient_idstatus enum, expires_at NOT NULLeach reservation has its own lifecycle
audit_logPK id, FK actor_id, FK pharmacy_idappend only: no update or delete routeevery admin decision, for compliance queries
anomaly avoided: renewing a licence never overwrites the old one (update anomaly)
anomaly avoided: deleting the last stock row never deletes the medicine (delete anomaly)
anomaly avoided: a medicine can exist before any pharmacy stocks it (insert anomaly)
9 · Advanced SQL: each technique answers a real question DE wk 3–7

Every query technique from weeks 3 to 7 is used by an actual screen, run through prisma.$queryRaw.

QuestionTechniqueDE wkUsed by
Is this stock fresh or stale?CASE on last_updated3search badge
Medicines matching what was typedILIKE wildcard, LIMIT3suggestions
Pharmacies with more than 5 expired holds this weekGROUP BY + HAVING + DATE_TRUNC4compliance
Stock with pharmacy and medicine namesINNER JOIN x 25pharmacist stock list
Every pharmacy, even with no stock yetLEFT JOIN + COALESCE(count, 0)4–5admin list
Other medicines with the same saltSELF JOIN on composition6search alternatives
Open right nowNON-EQUI JOIN on time range, in a VIEW6–7search
Days with no hours setCROSS JOIN days 0–6 + LEFT JOIN, keep NULLs6compliance
Pharmacies stocking nothingnon-correlated subquery, NOT IN6compliance
Licences expiring with no newer one on fileCTE + correlated NOT EXISTS7compliance
Was this pharmacy suspended before?correlated EXISTS on audit_log7approval screen
Nearest firstCTE + Haversine + ORDER BY + LIMIT3, 7search
-- 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;
10 · The hold: one ACID transaction AP wk 7 · DE wk 8

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;
}
Atomicitydecrement and hold insert commit together or not at all; a thrown AppError rolls both back
ConsistencyCHECK quantity ≥ 0 means the database refuses a negative count even if code is wrong
Isolationthe UPDATE row-locks the stock row; the second transaction waits, re-reads, finds 0, updates 0 rows
Durabilityonce COMMIT returns, the hold survives a crash; only then is the patient told "reserved"
Canceltransaction: ACTIVE → CANCELLED and quantity + 1
CollectACTIVE → COLLECTED; quantity unchanged, the unit left the shelf
11 · Indexing and query optimisation DE wk 9

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.

IndexQuery it servesExpect before → after
stock_item (medicine_id, quantity)who has medicine X in stockSeq Scan → Index Scan
pharmacy (status, lat, lng)verified pharmacies inside the search boxSeq Scan → Index / Bitmap Scan
operating_hours (pharmacy_id, day_of_week)the open-now view joinHash Join on full table → Index Scan
credential (pharmacy_id, expiry_date)expiring-licence CTESeq Scan → Index Scan
hold (stock_item_id, status, expires_at)lazy expiry inside the hold transactionkeeps the lock window short
hold (patient_id, status)"one active hold" check, My holdsSeq Scan → Index Scan
seed about 500 pharmacies and 20,000 stock rows first, or every plan says Seq Scan is fine
distance: bounding box on lat/lng uses the B-Tree; Haversine runs only on what's left
record Execution Time from 5 runs and use the median
12 · MongoDB: the search copy DE wk 10–12

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 }
])
After a holdupdateOne({_id}, {$inc: {quantity: -1}})
After a cancel / expiry$inc: {quantity: +n}
After a stock edit$set: {quantity, price, available, lastUpdated}
After verify / suspendpharmacies.updateOne({_id}, {$set: {verified}})
Copy failslog it, set mongo_synced = false on the PG row; the request still succeeds
Rebuildadmin endpoint re-copies flagged rows with replaceOne(..., {upsert: true})
Why embed medicinealways shown with the stock, and rarely changes
Why reference pharmacyone pharmacy, hundreds of docs, and its verified flag changes
13 · Validation and error handling AP wk 4, 10

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' } });
};
StatusWhen
400validate middleware: missing or wrong fields, with details per field
401no token, bad token, expired token, wrong password
403wrong role, or not the owner of that pharmacy / hold
404unknown path, or Prisma P2025 record not found
409Prisma P2002 duplicate, last unit taken, already has a hold, wrong status
500anything unexpected; generic message only
14 · The event loop: rules that keep one thread fast AP wk 10

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.

bcrypt async onlyawait bcrypt.hash() runs in the libuv thread pool; hashSync blocks everyone
No sync fs in requestsfs.promises in the seed script; no readFileSync in any handler
Paginate everythingPrisma take / skip, default 20, max 50
Let SQL do the workfilter, join and sort in the query; never load a table and loop in JS
Parallel when independentPromise.all for the three compliance reports, not three awaits in a row
Short transactionsno network calls inside $transaction; the Mongo copy happens after commit
15 · TypeScript and OOP AP wk 11

Week 11 converts JS to TS and represents entities as classes. Velora uses classes where they carry behaviour and types where they only describe shape.

Classes with behaviourAuthService, HoldService, ...; dependencies (prisma, the Mongo copy) passed in the constructor
InheritanceAppError extends Error adds status and details
Encapsulationprivate helpers like releaseExpired(tx, id); controllers only see public methods
Types for shapePrisma generates model types; DTO types for request bodies; req.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);
16 · Deployment and environment AP wk 12

Week 12: deploying Node projects, dev vs production, .env files. Heroku runs the API; Vercel serves the React app and forwards /api to Heroku.

VariableUsed for
DATABASE_URLPrisma → managed PostgreSQL
MONGO_URLMongoDB Atlas
JWT_ACCESS_SECRETsigns access tokens
JWT_REFRESH_SECRETkept separate so one leak doesn't expose both
ADMIN_EMAIL, ADMIN_PASSWORDseed script only
NODE_ENVsecure cookies and log format in production
PORTset 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
17 · Demo checks: proving it works for the viva

Six things to show live with Postman or the frontend. Each one proves a syllabus topic instead of just claiming it.

RBACcall /admin/pharmacies as a PATIENT → 403; as ADMIN → 200
Refreshwait 15 min (or shorten in dev), call /holds/me → 401 → refresh → 200
The raceset quantity to 1, fire two POST /holds together → one 201, one 409, quantity 0
Rollbackshow the 409 case left no hold row and quantity unchanged
EXPLAINsame query before and after the index, Execution Time side by side
Copy repairstop Mongo, edit stock → row flagged; start Mongo, rebuild → search shows the new number