REST API for a doctor-appointment platform: patients book consultations, doctors run them, admins manage the platform. This repo is the backend only.
Stack: Node.js · Express 5 · TypeScript · Prisma 7 · PostgreSQL · JWT auth
This is an early build, not the finished product. Right now the only working feature is authentication — a patient can register, log in, and fetch their own profile. Appointments, doctor schedules, payments, and everything else in Project Requirements.md is planned but not built yet.
Treat this README as a description of what the code actually does today, including its rough edges. A few are called out directly in Known limitations further down — read that section before assuming something is broken on your end.
| Tool | Version | Check with |
|---|---|---|
| Node.js | 20+ | node -v |
| PostgreSQL | 14+ | psql -V |
Any package manager works (npm, pnpm, yarn, bun). The examples below use npm.
1. Install dependencies
npm install2. Set up your environment file
cp .env.example .envOpen .env and point DATABASE_URL at a Postgres database you can connect to:
DATABASE_URL="postgresql://YOUR_USERNAME:YOUR_PASSWORD@localhost:5432/ph_healthcare?schema=public"
The database doesn't need to exist beforehand — prisma migrate dev creates it. The other variables in .env.example are fine to leave as-is for local development; see Environment variables for what each one does.
3. Generate the Prisma client
npx prisma generatePrisma writes a typed client into src/generated/prisma. That folder is git-ignored, so a fresh clone never has it, and almost every file under src/ imports from it — skip this step and nothing compiles. Re-run it any time you change a file in prisma/schema/.
4. Run the migrations
npx prisma migrate devThis creates the user and patient tables using the SQL already committed under prisma/migrations/.
5. Start the server
npm run devYou should see:
Connected to the database successfully.
Server is running on port 5000
Confirm it's up:
curl http://localhost:5000/
# {"success":true,"message":"Welcome to PH Healthcare System Backend"}src/app/config/index.ts is the only place process.env is read — application code should import config from there rather than reaching for process.env directly.
| Variable | What it's for |
|---|---|
NODE_ENV |
development includes the raw error and stack trace in API error responses |
PORT |
Port the HTTP server listens on |
DATABASE_URL |
Postgres connection string, used by both Prisma and the app |
JWT_ACCESS_SECRET |
Signing key for access tokens |
JWT_REFRESH_SECRET |
Signing key for refresh tokens |
JWT_ACCESS_EXPIRES_IN |
Access token lifetime (e.g. 15m, 1d) |
JWT_REFRESH_EXPIRES_IN |
Refresh token lifetime |
BCRYPT_SALT_ROUNDS |
Read into config but not wired up yet — password hashing currently uses a hardcoded value (see below) |
BACKEND_URL |
Read into config but not used anywhere yet |
FRONTEND_URL |
Added to the CORS allowlist |
There's no validation on startup: if a variable is missing, config simply holds undefined for it, and the app boots anyway. The first sign of trouble is usually a runtime error the moment that value is actually used — for JWT_ACCESS_SECRET, that means the very first login or registration.
Before deploying anywhere, replace the JWT secrets — the ones in .env.example are placeholders anyone can guess:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"src/
├── server.ts # connects to the DB, then starts listening
├── app.ts # express app: cors, body parsing, routes, error handling
├── generated/prisma/ # Prisma client — git-ignored, run `npx prisma generate`
└── app/
├── config/index.ts # reads and exposes every environment variable
├── lib/prisma.ts # shared PrismaClient instance — always import this, don't `new` your own
├── middleware/
│ ├── checkAuth.ts # exports `auth(...roles)`, the JWT + role guard
│ ├── globalErrorHandler.ts # turns thrown errors into JSON responses
│ └── notFound.ts # catch-all for unmatched routes
├── utils/
│ ├── catchAsync.ts # wraps async route handlers so thrown errors reach the error handler
│ ├── jwt.ts # sign / verify helpers
│ └── sendResponse.ts # the standard `{ success, statusCode, message, data }` envelope
└── module/
└── auth/ # the one feature module that exists so far
├── auth.route.ts
├── auth.controller.ts
├── auth.service.ts
└── auth.interface.ts
prisma/
├── schema/
│ ├── schema.prisma # generator + datasource only
│ ├── user.prisma
│ ├── patient.prisma
│ └── enums.prisma # Role, UserStatus, Gender
└── migrations/ # generated SQL, committed to git
Prisma's schema is split across multiple files, wired together by prisma.config.ts at the repo root. That file also loads .env so the Prisma CLI can see DATABASE_URL.
The data model: a User has at most one Patient (1-to-1). Registering writes both rows in a single nested Prisma call. Deletes are meant to be soft — there's an isDeleted flag and a deletedAt timestamp on both models — but nothing in the codebase sets them yet; there's no delete endpoint at all right now.
Base URL: http://localhost:5000
| Method | Path | Auth required | Body |
|---|---|---|---|
GET |
/ |
– | health check |
POST |
/api/v1/auth/register |
– | name, email, password |
POST |
/api/v1/auth/login |
– | email, password |
GET |
/api/v1/auth/me |
yes | – |
POST |
/api/v1/auth/refresh-token |
– | reads the refreshToken cookie |
Every response from sendResponse (i.e. everything except the root route) has this shape:
{ "success": true, "statusCode": 200, "message": "...", "data": {} }register and login return accessToken and refreshToken two ways: in the JSON body, and as cookies. Use the JSON body. The cookies are set with sameSite: "none" but secure: false — that combination is invalid under the cookie spec, and modern browsers silently drop the cookie rather than send it. Grab data.accessToken from the response and send it yourself:
curl -X POST http://localhost:5000/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"name":"Test Patient","email":"patient@example.com","password":"password123"}'
curl http://localhost:5000/api/v1/auth/me \
-H "Authorization: Bearer <accessToken from the response above>"Authorization accepts either Bearer <token> or the raw token with no prefix.
Four roles exist in the schema — SUPER_ADMIN, ADMIN, DOCTOR, PATIENT — but registration always creates a PATIENT. registerPatient hardcodes Role.PATIENT and only reads name, email, and password out of the request body, so sending "role": "ADMIN" does nothing. There's no admin module and no seed script, so the other three roles aren't reachable through the API yet. To test them, register a normal user and change their role directly in the database with npx prisma studio (opens at http://localhost:5555) — then log in again, since the role is baked into the token at login time and an old token keeps the old role.
auth(...roles), exported from checkAuth.ts, is the route guard:
router.get('/me', auth(Role.ADMIN, Role.DOCTOR, Role.PATIENT, Role.SUPER_ADMIN), AuthController.getMe)What it actually does, in order:
- Reads the token from the
accessTokencookie, falling back to theAuthorizationheader. - Verifies the JWT signature.
- Checks the role from the token payload against the roles the route allows.
- Looks the user up in the database by matching
id,email,name, androleall at once — if any of those four have changed since the token was issued, the lookup fails and the request is rejected, even though the account still exists. - Rejects the request only if the user's
statusis exactlyBLOCKED. It does not checkisDeletedor aDELETEDstatus, so a soft-deleted account can still authenticate as long asstatuswasn't also set toBLOCKED.
Worth knowing before you spend time debugging what looks like your own mistake:
- Every error comes back as HTTP 500.
globalErrorHandlerworks out the "correct" status code internally but always sends the response with500, regardless. Read themessagefield, not the status code, to see what actually went wrong. - No request validation. Nothing checks that
emaillooks like an email or thatpasswordmeets any length requirement — Postgres and Prisma are the only things that will reject bad input, and usually not with a helpful message. BCRYPT_SALT_ROUNDSisn't used. Password hashing inauth.service.tscallsbcrypt.hash(password, 8)with a hardcoded cost factor; the environment variable is read intoconfigbut nothing references it yet.- No tests.
npm testis a placeholder.
New features go under src/app/module/<name>/ as four files with strict responsibilities:
| File | Responsibility |
|---|---|
<name>.route.ts |
Wires auth(...roles) to controller functions, exports <Name>Routes |
<name>.controller.ts |
Reads req.body / req.user, calls the service, calls sendResponse |
<name>.service.ts |
All business logic and every Prisma call for the module |
<name>.interface.ts |
The TypeScript types for the module's payloads |
Then mount it in app.ts next to the existing line:
app.use('/api/v1/doctor', DoctorRoutes)Two rules keep the module boundaries useful rather than decorative:
- Controllers never call Prisma directly, and services never touch
reqorres. If a service needs to know who's calling it, pass it the small{ userId, email, name, role }shape, not the whole request. - Never spread
req.bodystraight into a Prismacreate/update. Destructure the exact fields you expect. With no validation layer in front of the API, that destructuring is the only thing stopping someone from sending"role": "ADMIN"in a request body and having it stick.
npm run dev # start the server with auto-reload (tsx watch) — use this while developing
npm run build # typecheck with tsc and emit to dist/
npm run start # run the server once, no watchingThere's no npm run generate / migrate / studio wrapper — call Prisma's CLI directly:
npx prisma generate # regenerate the client after editing prisma/schema/
npx prisma migrate dev # create + apply a migration
npx prisma studio # browser GUI for your data, at http://localhost:5555npm run build is useful for catching type errors, but its output isn't directly runnable with node. The codebase uses extensionless relative imports (from './app'), which tsx resolves fine but Node's native ESM loader doesn't — running node dist/src/server.js fails with ERR_UNSUPPORTED_DIR_IMPORT. That's why npm run start runs the TypeScript source through tsx rather than executing dist/.
Cannot find module '.../src/generated/prisma/client'
Run npx prisma generate — see step 3 of Getting started.
Can't reach database server / ECONNREFUSED
Postgres isn't running, or DATABASE_URL points somewhere it can't reach. Confirm with pg_isready -h localhost -p 5432.
P1010: User was denied access on the database
The username or password in DATABASE_URL doesn't match a real role on your Postgres server. psql -c '\du' lists the roles that actually exist; whoami gives you your OS username, which is usually your local superuser with no password.
Login or register throws instead of returning a token
Check that JWT_ACCESS_SECRET and JWT_REFRESH_SECRET are actually set in your .env — jsonwebtoken throws if the signing secret is undefined, and this project doesn't validate environment variables on startup.