Spec 02 — API Contracts (NestJS, REST /api/v1)
Conventions: JSON; DTO validation (class-validator), strict — unknown properties are rejected, not stripped; request bodies are size-capped; errors { statusCode, code, message, messageAr }; pagination ?page=1&limit=20 → { items, total, page, limit }; auth via httpOnly cookies nutqi_at / nutqi_rt; guard decorators @Roles(...); Swagger annotated and served only when explicitly enabled in production.
Read paths return flat rows (D-26): names resolved to a single string, calendar dates as YYYY-MM-DD, avatars as ready-to-use URLs, counters computed server-side, relations dropped — the database layout never leaks to a client.
Rate limiting is per IP, with a much tighter budget on /auth/login and the OTP routes; it complements (does not replace) the account lockout of G15. JWT_ACCESS_SECRET and CORS_ORIGIN are required in production — the app refuses to boot rather than fall back to an insecure default.
Interactive documentation (Swagger / OpenAPI)#
Everything below this section is written by hand, and a hand-written list of routes drifts the moment somebody adds one. The interactive documentation does not: @nestjs/swagger builds it at boot from the same decorators that declare the routes — the controller and HTTP-verb decorators, plus the @ApiTags that give it the same per-module grouping used as headings here — so it always describes the build that is actually running, and it is a page you browse, with a Try it out control on every operation, rather than a document you read. The paths, verbs and tags are complete; parameter and body schemas are only ever as detailed as the decorators, and there are no @ApiProperty annotations yet, so the payload shapes sketched under each module below remain the reference for those.
Two routes, both mounted outside the /api/v1 prefix (SWAGGER_PATH in apps/api/src/app.setup.ts). The links resolve against whichever API host this documentation site is configured with:
/api/docs— the Swagger UI itself./api/docs-json— the raw OpenAPI document, which@nestjs/swaggerderives from the UI path so the two can never describe different schemas. This is the one to import into Postman or Insomnia, or to hand to a client generator.
In local development it is on without being asked for. apiDocsEnabled() honours ENABLE_API_DOCS when it is set and otherwise falls through to NODE_ENV !== "production", so a dev API serves both routes with nothing to configure. The explicit value wins in both directions: ENABLE_API_DOCS=false turns the docs off on a dev machine too.
On a deployed environment it is served only where ENABLE_API_DOCS=true is set, and never without a credential. The gate is deliberate (D-30) and apps/api/src/main.ts gives the reason in a line: the schema is a map of the attack surface. A complete, machine-readable inventory of every route, parameter and role check helps an attacker in a way it does not help an integrator, who can simply be sent the file.
So enabling it turns on the credential at the same time. apiDocsAuth in apps/api/src/app.setup.ts challenges for HTTP basic auth across the whole /api/docs prefix — the raw schema included, since guarding only the readable half would be theatre — and apiDocsCredentialProblem in apps/api/src/common/env.ts makes ENABLE_API_DOCS=true without API_DOCS_USER and API_DOCS_PASSWORD fatal at boot. A half-finished setup therefore fails as an API that will not start, never as a schema anybody can read. The check lives in the application rather than in the reverse proxy on purpose: a credential file under /etc/nginx is invisible to review, absent from every other environment, and lost the next time the vhost is re-rendered.
Production has it enabled, so both routes answer 401 to an anonymous request and 200 to a credentialed one. The credential is issued per audience rather than published here.
Authentication is by cookie, so there is no token to paste in. The API authenticates with the httpOnly cookies nutqi_at (access) and nutqi_rt (refresh), which is why the Swagger config declares addCookieAuth("nutqi_at") rather than a bearer scheme. That declaration names the cookie in the schema and marks the operations that need it; it is not somewhere to type a credential, because a page cannot set a Cookie header and an httpOnly cookie cannot be read by script in the first place. What makes Try it out work is the session the browser already holds: the UI is served from the API's own origin, so its requests are same-origin and the cookie rides along by itself. A browser without a session logs in from the page — run POST /auth/login there, which sets the cookies on that origin, and every operation after it is authenticated.
health module#
GET /health(public, unauthenticated) → liveness plus a real database check. Deliberately free of version numbers, connection strings and error text; consumed by the reverse proxy and the container healthcheck.
auth module#
POST /auth/register{ role: PERSONAL_PATIENT|GUARDIAN|SPECIALIST|CENTER, firstName, lastName, email, phone, password, gender?, birthDate?, center? {nameAr, licenseNumber?, governorate?, city?, branchName?} } → creates user (status PENDING_VERIFICATION) (+Center+first Branch when role CENTER per G03); sends email+phone OTP (console provider in dev). 409 on dup email/phone.POST /auth/verify-otp{ target, code, purpose: "verify" | "reset" } → on verify: marks verified, status → ACTIVE (patient/guardian) or PENDING_ACTIVATION (specialist/center); sets cookies. On reset: returns one-time resetToken.POST /auth/resend-otp{ target, channel? } — throttled 60s (matches UI countdown).POST /auth/login{ email, password } → cookies; wrong password increments counter; locked → 423 withlockedUntil(G15).POST /auth/forgot{ email, phone } → OTP (purpose reset).POST /auth/reset-password{ resetToken, password }.POST /auth/refresh,POST /auth/logout.GET /auth/me→ user + role-specific profile summary + activation completeness % (drives the gate ring).PATCH /auth/password{ current, next } (settings).
users module#
PATCH /users/me(names ar/en, nationalId, DOB, gender, avatar, locale, address fields).PUT /users/me/languages[{language, level}] (spoken languages, G13-b).GET/PUT /users/me/notification-channels[{channel, target, enabled}];POST /users/me/notification-channels/:channel/verify(OTP flow).
patients module (guardian portal)#
GET /patients(mine) /POST /patients(add child, G09) /GET|PATCH /patients/:id.POST /patients/:id/documents(multipart, kind DOC|VIDEO) /GET /patients/:id/documents/DELETE …/:docId.- Diagnosis:
GET /forms/diagnosis-template→ system template;POST /patients/:id/diagnosis-response(creates/updates response via answers API below).
specialists module#
GET /specialists— public directory (G12): filters q, governorate, specialty, sessionType, priceMin/Max, rating; only ACTIVE.GET /specialists/:id/profile→ overview payload (stats: bookings timeline, type distribution, top programs — computed), data (certificates, trainings, videos), clinics, reviews summary.GET /specialists/:id/schedule?weekStart=→ sessions grid + prices;GET /specialists/:id/slots?date=&clinicId?=→ available 30-min slots (work hours − bookings − days off) (D-09).- Me-scoped:
GET/PUT /specialists/me/profile,POST/PATCH/DELETE /specialists/me/work-info,…/certificates,…/videos,…/clinics,PUT /specialists/me/schedule(weekday blocks),POST /specialists/me/days-off. PUT /specialists/me/booking-settings{ availableForWork, acceptsOnline, acceptsOffline, acceptsConsultation }.GET/POST/DELETE /specialists/me/blocklist.GET /specialists/me/stats?period=all|year|month|week|day→ { dailyAvgCases, totalCases, earningsCents, pending: bool }.
bookings module#
POST /bookings{ patientId, specialistId, clinicId?|type ONLINE, sessionType, date, startTime } → PENDING; validates slot free, specialist active+available, not blocked; price resolved server-side. 409 slot taken.GET /bookingsrole-scoped list w/ filters (status, type, q, dateRange) +?export=csv.PATCH /bookings/:id/status{ status, meetingUrl?, newDate?/newTime? for POSTPONED } — allowed transitions per state machine (spec docs-content/02 §5); guardian may CANCEL own PENDING/WAITING.GET /bookings/upcoming→ next booking for banner (E10).
sessions module#
POST /sessions(from booking or special case) /PATCH /sessions/:id{ progressPercent, evaluation, notes }.GET /patients/:id/sessions,GET /special-cases/:id/sessions.
special-cases module (specialist)#
- CRUD
/special-cases; attachments/special-cases/:id/attachments(phase BEFORE|AFTER); notes CRUD (soft delete + undo window G18).
forms module (plans & assessments engine)#
- Templates:
GET/POST /forms/templates(mine),GET/PATCH/DELETE /forms/templates/:id(+pages/questions nested payload, versioned). - Assign:
POST /forms/assignments{ templateId, patientId|specialCaseId, dueDate? } → notification to guardian. GET /forms/assignments?role=guardian|specialist&status=→ cards data (Not answered / Answered).- Answer:
GET /forms/assignments/:id/response(or create),PUT /forms/responses/:id/answers/:questionId{ value } — autosave upsert (E01),POST /forms/responses/:id/submit→ validates required → assignment ANSWERED. - Results:
GET /forms/assignments/:id/result(Q + A read view).
reviews module#
POST /reviews{ specialistId, bookingId?, stars, text, kind } (author must have DONE booking with specialist for SESSION kind).GET /specialists/:id/reviews?kind=&period=;PATCH /reviews/:id/like,PATCH /reviews/:id/reply(specialist),POST /reviews/:id/report(G17).
payments module#
GET /payments/mine(guardian ledger, G01) → rows + totals.POST /bookings/:id/payment{ method, status } (secretary/specialist records; creates wallet EARNING txn on PAID).GET /wallet(specialist) → { balanceCents, withdrawnCents };GET /wallet/transactions.POST /wallet/withdrawals{ amountCents, method, target } (G19);GET /wallet/withdrawals.
centers module#
GET/PATCH /centers/me(owner/manager); branches CRUD/centers/me/branches.- Staff:
GET/POST /centers/me/staff(create staff user w/ role+branch+salary),PATCH/DELETE /centers/me/staff/:id. - Patients of center:
GET /centers/me/patients(+profile passthroughs).
hr module (center)#
POST /hr/attendance/clock-in|clock-out(self, CENTER_SPECIALIST/SECRETARY) — branchId optional (G20).GET /hr/attendance?staffId?&range(owner/manager see all; staff self).- CRUD-ish:
/hr/absences,/hr/overtime,/hr/penalties(with deductionCents), owner/manager only. - Requests:
POST /hr/requests(staff),GET /hr/requests(scoped),PATCH /hr/requests/:id/decision{ status: APPROVED|REJECTED } (owner/manager) → notification.
jobs module#
- Center:
GET/POST /jobs/postings,PATCH /jobs/postings/:id(draft→published→closed), applications:GET /jobs/postings/:id/applications,PATCH /jobs/applications/:id/decision→ on ACCEPTED offers CenterStaff creation payload. - Specialist:
GET /jobs/market(published, filters, G08),POST /jobs/postings/:id/apply,GET /jobs/applications/mine.
notifications module#
GET /notifications?unread=,PATCH /notifications/:id/read,PATCH /notifications/read-all.GET /notifications/stream— SSE (E13).- Emitter service used by other modules; dispatches IN_APP always + enabled verified channels. EMAIL is a real send through the injected
Mailer(notifications/mailer.ts, sameOTP_DRIVER/SMTP configuration as the OTP channel); it is started after the response path and its failures are logged, never rethrown, because there is no queue to defer to. WhatsApp/Telegram log and nothing more (D-17), andPOST /users/me/notification-channels/:channel/verifyanswers501 CHANNEL_UNSUPPORTEDfor them rather than handing a phone number to the mail transport.
files module#
POST /filesmultipart (auth) → StoredFile;GET /files/:id(authz by ownership/linkage); size/type limits (images 5MB, docs 10MB, video 100MB).
admin module (API-only v1)#
GET /admin/activations(pending specialists/centers),PATCH /admin/activations/:userId{ decision: approve|reject, reason? }.GET /admin/withdrawals,PATCH /admin/withdrawals/:id{ TRANSFERRED|REJECTED }.GET /admin/reported-reviews(flagged and not yet removed),PATCH /admin/reported-reviews/:id{ decision: dismiss|remove, reason? } —dismissclears the flag and leaves the review visible;removesoft-deletes it (removedAt), drops it fromGET /specialists/:id/reviews, fromratingAvgand fromreviewsCount(spec 13 W14).- Every decision above records its actor:
User.activationDecidedByUserId,WithdrawalRequest.processedByUserId,Review.moderatedByUserId, plus oneAuditLogrow per decision.AuditLogalso records ADMIN reads of a patient row, a patient document, an assessment response and a stored file. It has no read route in v1 — an operator queries it with SQL (spec 13 W5, RUNBOOK §3e). POST /reviewsreturns409 ALREADY_REVIEWEDfor a second review by the same author on the same specialist and kind.- There is no self-registration path to ADMIN. Provision one with
pnpm db:create-admin(password on stdin, never argv) — seedeploy/RUNBOOK.md§3e (spec 13 W15).
Events → notifications (minimum)#
booking.created (→specialist/secretary), booking.status_changed (→guardian), assignment.created (→guardian), assignment.answered (→specialist), application.decided (→specialist), hr.request.decided (→staff), withdrawal.processed (→specialist), activation.decided (→user).