Tech Stack & Architecture
Technical overview for developers and recruiters.
| Layer | Choice |
| Runtime | Node.js 22+ |
| Framework | Express 5, TypeScript |
| Database | MySQL 8 (mysql2 — no ORM, hand-written parameterized SQL) |
| Views | EJS + express-ejs-layouts |
| Validation | Zod |
| Styling | Sass → Bootstrap 5 grid + hand-written components |
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ Client (Browser) │
│ HTML + Bootstrap grid + vanilla JS │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Node.js / Express (TypeScript) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Middleware chain │ │
│ │ fake headers → trust proxy → rate limit → cookies → │ │
│ │ MySQL-backed session → current user → language → static → │ │
│ │ view engine → layouts │ │
│ └───────────────────────────┬───────────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Routes → Controllers (business logic) │ │
│ └───────────────────────────┬───────────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Models — raw parameterized SQL, no ORM │ │
│ └───────────────────────────┬───────────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Error handler (winston) — last middleware, catches every │ │
│ │ next(err) from every controller │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ MySQL 8 (mysql2 connection pool) │
└─────────────────────────────────────────────────────────────────┘
Security
- bcrypt — password hashing on every account (login + self-registration)
- Sessions —
express-session backed by MySQL (express-mysql-session), so sessions survive a restart
- Rate limiting —
express-rate-limit: a sitewide default, plus a stricter 5/min on login and 5/10min on registration
- Parameterized SQL everywhere — every query goes through
mysql2's placeholders, no string-built SQL
- Request validation — a Zod schema per form, parsed through one shared
parseAndValidate() helper
- XSS — EJS auto-escapes all interpolated output by default
- Gated file access — uploaded article images are served through a dedicated route that validates the requested filename against a strict UUID pattern, matches it against the article's own stored paths, and rejects anything that resolves outside the uploads root
- RBAC — every admin/manager/journalist route is gated by
requireAuth + requireRole middleware, not left to the view layer
Design Patterns
- MVC, layer-folder organization (
controllers/, models/, routes/, middleware/)
- Singletons instead of a DI container — every controller/model exports one instance directly; the app's dependency graph turned out too shallow to need a container managing it
- Schema-as-type — each Zod schema doubles as its own TypeScript type via
z.infer, replacing a DTO-class-plus-decorators split with one file
- Middleware chain — auth, role, rate limiting, and i18n as composable Express middleware rather than framework-specific guards/interceptors
Role-Based Access
| Role | Access |
| Admin | Every article across every organization (filter, block/unblock), organization management (create org + manager account atomically, block/unblock), category management, user list |
| Manager | Their own organization's articles only (filter by author), approve/flag — enforced as a single atomic query against the org's own non-blocked status |
| Journalist | Their own articles only — create, list/filter; self-approve/flag only when not attached to an organization |
Project Structure
src/
├── app.ts, index.ts # App class: middleware + route wiring, entry point
├── common/ # Constants, Translation, MySQL duplicate-key helper
├── config/ # env loading, winston logger, session store
├── controllers/ # admin, manager, journalist, my-articles, articles,
│ # articles-api, articles-rss, article-images, auth, pages
├── db/ # mysql2 connection pool
├── middleware/ # requireAuth, requireRole, currentUser, language,
│ # rateLimiter, multer, fakeHeaders, errorHandler
├── models/ # articles, organizations, categories, users, languages
├── routes/
├── translations/ # langEn, langRo
├── types/
└── validation/ # Zod schemas + the parseAndValidate() helper
views/ # EJS templates: admin/, manager/, journalist/, articles/, public/
scss/ # Sass source → public/css/main.css
uploads/articles/ # uploaded images, gitignored
Sample Endpoints
| Method | Endpoint | Description | Auth |
| GET | /health | Health check (DB connectivity) | Public |
| GET | /v1/feed | Paginated public JSON feed | Public |
| GET | /v1/feed.rss | RSS 2.0, 50 most recent, 15-min cache | Public |
| GET | /news/:id | Article detail | Public |
| GET | /articles/:id/images/:filename | Gated image serving | Public, per-article |
| POST | /login | Session login | Public, rate-limited |
| POST | /register | Journalist self-registration | Public, rate-limited |
| GET | /articles/new | New article form (images, reference links) | Authenticated |
| GET | /admin/dashboard | All articles, filterable by organization | Admin |
| GET | /manager/dashboard | Own organization's articles, filterable by author | Manager |
Database Schema (Key Tables)
Session store and Prisma migration-bookkeeping tables omitted — this is the domain schema only.
┌───────────────┐ ┌──────────────────────┐ ┌────────────────────┐
│ organizations │ │ users │ │ articles │
├───────────────┤ ├──────────────────────┤ ├────────────────────┤
│ id (PK) │ │ organization_id (FK) │ │ id (PK) │
│ name (unique) │ │ role_id (FK) │ │ author_id (FK) │
│ blocked │ │ email (unique) │ │ author_org_id (FK) │
└───────────────┘ │ phone (unique) │ │ source_org_id (FK) │
│ password_hash │ │ category_id (FK) │
│ api_key (unique) │ │ language_id (FK) │
└──────────────────────┘ │ status_id (FK) │
│ admin_blocked │
│ images (json) │
│ reference_links │
└────────────────────┘
┌───────────────────┐ ┌──────────────────────┐
│ roles │ │ article_statuses │
├───────────────────┤ ├──────────────────────┤
│ admin / manager / │ │ pending / approved / │
│ journalist │ │ flagged │
└───────────────────┘ └──────────────────────┘
┌──────────────────┐ ┌───────────────┐
│ categories │ │ languages │
├──────────────────┤ ├───────────────┤
│ id (PK) │ │ id (PK) │
│ slug (unique) │ │ code (unique) │
│ parent_id (self) │ │ name │
└──────────────────┘ └───────────────┘
- articles →
category_id/subcategory_id → categories, language_id → languages, status_id → article_statuses (all ON UPDATE CASCADE)
- articles →
author_id → users, author_organization_id/source_organization_id → organizations (all ON DELETE SET NULL — removing a user/org never cascades into deleting their articles)
- users →
organization_id → organizations (ON DELETE SET NULL), role_id → roles
- categories →
parent_id → categories (self-referencing, for subcategories)
source_organization_id on articles is a second, independent FK to organizations — it tracks a syndicated article's originating outlet separately from its publishing author's organization
Resources