Skip to content

Architecture

_david edited this page Sep 26, 2026 · 2 revisions

Architecture

Project structure

src/
├── server.ts                  # Entry: Express setup, MongoDB connect, Redis init
├── alias.ts                   # module-alias: @ → src/ (dev) or dist/ (prod)
├── config/
│   ├── process.config.ts      # Env var validation + export
│   ├── cors.config.ts         # CORS: CORS_ORIGIN allow-list, credentials: true
│   ├── session.config.ts      # Express session config
│   ├── joi.config.ts          # Shared Joi schemas (email, password, phone, description, etc.)
│   ├── regex.config.ts        # Password + phone regex
│   └── swagger.config.ts      # OpenAPI spec (swagger-jsdoc), served at /api-docs
├── database/
│   └── mongo.db.ts            # Singleton MongoDB connection manager
├── locales/                   # i18n message dictionaries (vi.ts default, en.ts) — 100+ keys,
│                               # covers auth, candidate, CV sections, uploads/images
├── middlewares/
│   ├── verifyToken.middleware.ts       # JWT extraction (Bearer/cookie/body/query) + blacklist
│   │                                   # + session-revocation + CSRF check; attaches req.user._id,
│   │                                   # forces req.body.candidateId
│   ├── csrf.middleware.ts              # Standalone CSRF check for the 2 auth routes that
│   │                                   # bypass verifyToken (/auth/refresh, /auth/logout)
│   ├── language.middleware.ts          # Resolves Accept-Language → req.lang / req.t(key)
│   ├── rateLimit.middleware.ts         # Redis-backed rate limit; mem fallback
│   ├── errors.middleware.ts            # Global error handler; AppError-aware
│   ├── requestLogger.middleware.ts     # Logs method, URL, status, duration via Winston
│   ├── uploadCV.middleware.ts          # Multer: candidate's own PDF résumé (max 5MB)
│   ├── uploadImages.middleware.ts      # Multer: CV-section image attachments
│   └── uploadLinkedInExport.middleware.ts  # Multer: LinkedIn export ZIP (max 20MB)
├── models/
│   ├── candidate.model.ts, generalInformation.model.ts, experience.model.ts,
│   │   education.model.ts, project.model.ts, certificate.model.ts,
│   │   award.model.ts, reference.modal.ts, application.model.ts, profile.model.ts,
│   │   visit.model.ts
│   └── part/index.ts          # Reusable sub-schemas (skills, languages, socialMedia, localizedTextSchema)
├── routers/
│   ├── api/v1/                # All active routes
│   └── api/v2/                # Auth v2 — thin re-export of the same v1 auth controller (see below)
├── auth/                      # Auth controller/service — the one real implementation, used by both v1 and v2
├── candidate/                 # Self-profile controller/service, CV upload/download, LinkedIn export parsing
├── candidate_profile/         # One controller+service+validate per CV section (+ application, profile)
│   └── BaseController.ts / BaseService.ts  # Shared CRUD factory: getAll/create/update/delete/
│                                            # restore/upload-images, used by every section
├── candidate_me/               # Public profile aggregation (slug/email, i18n, ?profile= filter),
│                               # visit recording, PDF/JSON/DOCX export
├── services/
│   ├── index.ts                 # Core DB ops: baseFindDocument, baseCreateDocument,
│   │                             # baseUpdateDocument, basePatchDocument, baseDeleteDocument
│   │                             # (soft-delete), baseRestoreDocument
│   ├── redis.ts                  # Redis client singleton
│   ├── createPDF.ts              # Puppeteer PDF generation
│   └── createDocx.ts             # docx-based Word export (same aggregated data as PDF)
├── scripts/
│   └── migrate-localize-text-fields.ts  # One-off data migration (see Data Models)
├── utils/                       # jwt, bcrypt, tokenBlacklist, sessionRevocation, authCookies, csrf,
│                                 # slug, querySafe, i18n, timeout, helper, emailVerification,
│                                 # passwordReset, ...
├── errors/                      # AppError hierarchy
└── types/                       # base.type.ts, candidate.type.ts, express.d.ts

Request flow (server.ts middleware order)

  1. Request logger (Winston)
  2. Cookie parser (httpOnly JWT cookies → req.cookies)
  3. Language resolution (Accept-Language → req.lang)
  4. Session middleware
  5. CORS (CORS_ORIGIN allow-list, credentials: true)
  6. Body parser (JSON + URL-encoded)
  7. GET /health — exempt from rate limit
  8. Swagger UI (/api-docs, /api-docs.json) — exempt from rate limit
  9. Rate limiter (Redis or in-memory)
  10. Static files (public/)
  11. API router
  12. Global error handler

Dev runs on port 3001, prod on port 3008 (both respect LOCAL_PORT when it's actually set).

Auth flow

  1. POST /auth/register → validate Joi → check duplicate email → bcrypt(password, 12) → create Candidate doc
  2. POST /auth/login → find by email → bcrypt compare → sign accessToken (TOKEN_SECRET, expiry TOKEN_EXP_IN) + refreshToken (TOKEN_REFRESH, expiry TOKEN_REFRESH_EXP_IN, default 7d) with payload { _id: candidateId } → set as httpOnly cookies and returned in the response body (dual, transitional)
  3. Every protected request → verifyToken middleware → extract the token from Bearer header, cookie, body, or query → verify signature → check blacklist → check per-candidate session-revocation timestamp (logout-all) → if the request authenticated purely off the cookie (no Bearer header), require a valid double-submit CSRF token → attach req.user._id and force req.body.candidateId to that same id, overwriting anything the client sent (the fix for the IDOR incident — see Security)
  4. POST /auth/refresh → CSRF-checked → verify refresh token → blacklist old refresh token → issue new pair
  5. POST /auth/logout → CSRF-checked → blacklist current token (Redis TTL = token remaining exp; in-memory fallback)
  6. POST /auth/logout-all → bumps the candidate's session-invalidated-at timestamp, so every token issued before now is rejected on next use even if not individually blacklisted or expired

The v1 vs v2 auth "duplication" — resolved

Earlier versions of this wiki (through v1.1.0) flagged routers/api/v2/auth.route.ts as pointing at a separate, untested, buggy implementation. That's no longer the case: api/v2/auth.route.ts now imports authRegister/authLogin directly from @/auth/auth.controller — the exact same controller /api/v1/auth uses. src/api/ is now empty (just a .gitkeep). /api/v2 is still thinner than v1 (only register/login, none of v1's logout/refresh/logout-all/forgot-password/reset-password/verify-email), but it's no longer a second, drifting code path.

Shared CRUD pattern for CV sections

The 7 CV sections (education, experience, award, certificate, project, reference, generalInformation) plus the newer application and profile collections all go through candidate_profile/BaseController.ts + BaseService.ts + services/index.ts's base DB ops. Ownership is enforced via req.user._id (see Security for the incident that made this actually true). Delete is soft (deletedAt timestamp, POST .../restore/:id to undo) rather than a real deleteOne, except account-level self-delete (DELETE /api/v1/candidate), which still hard-deletes and cascades across every section.

CV Profiles and the public-profile filter

profile.model.ts stores a named subset of a candidate's own Education/Experience/Project/Certificate/Award/Reference ids. On first GET /api/v1/profile, ensureDefaultProfile() lazily synthesizes a "Tổng hợp" (All) profile containing every existing item, so no candidate ever starts with zero profiles. candidate_me/index.ts's public-profile handler accepts ?profile=<id> and, when it resolves to a profile owned by the requested candidate, filters each of the 6 selectable sections down to that profile's id lists — an invalid/foreign profile id falls back to the old unfiltered behavior (fail-closed, not fail-open), so every pre-existing share link keeps working unchanged.

Clone this wiki locally