Skip to main content

chdev-backend

Backend architecture conventions for the chdev Node.js project (Express + TypeORM + Zod). Activate when working on backend code, creating new resources, controllers, routes, repositories, entities, or schemas.

Source facts

Repository
bioleyl/chdev
Last source activity
June 23, 2026 at 14:44
Detected SKILL.md language
English
Stars
0
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
chdev-backend
description
Backend architecture conventions for the chdev Node.js project (Express + TypeORM + Zod). Activate when working on backend code, creating new resources, controllers, routes, repositories, entities, or schemas.
# chdev Backend Architecture Monorepo with npm workspaces: `common/`, `backend/`, `frontend/`. This skill covers the **backend** and **common** packages. ## Build **Whenever a file in the `common/` folder is changed**, rebuild the common package from the project root: ```bash npm run build -w common ``` This must be done before the backend (or frontend) can pick up the updated schemas and types. ## Tech Stack - **Runtime**: Node.js, ESM (`"type": "module"`) - **Framework**: Express.js - **ORM**: TypeORM with `better-sqlite3` - **Validation**: Zod (schemas live in `common/src/schemas/`) - **Database**: SQLite (dev), migrations in `backend/src/migrations/` - **Auth**: JWT (jsonwebtoken + bcryptjs), role-based access control ## Project Layout ``` backend/src/ index.ts # Entry point: bootstrap DB + start Express app.ts # Express app setup, middleware, route mounting routes/ index.ts # Root Router, mounts all resource routers under /api <resource>.routes.ts # Router per resource controllers/ <resource>.controller.ts # Resource controller (class-based, singleton export) repositories/ <resource>.repository.ts # Resource repository (extends BaseRepository) entities/ <resource>.entity.ts # TypeORM entity middlewares/ validate.middleware.ts # Zod validation → TypedRequest pagination.middleware.ts # Vuetify-compatible pagination auth.middleware.ts # JWT verification + role-based access helpers/ build-search-condition.helper.ts # LIKE search builder build-order.helper.ts # Nested order builder for relations env.helper.ts with-transaction.helper.ts # DB transaction wrapper pdf-table.helper.ts # PDF generation helpers db/ connection.ts # AppDataSource setup migrate.ts seed*.ts services/ invoice-pdf.service.ts # Invoice PDF generation service common/ base-repository.ts # Abstract BaseRepository class common/src/ index.ts # Re-exports all schemas and types helpers/ cleanObject.ts # Removes undefined properties from objects schemas/ <resource>.schema.ts # Zod schemas + inferred types id-param.schema.ts # Shared { id: number } param schema pagination.schema.ts # Vuetify pagination schema (with sortBy/sortDesc) role.schema.ts # Role enum (ADMIN, EDITOR, VIEWER) auth.schema.ts # Login schema + JWT payload ``` --- ## Core Principle: Main Context (Resource Ownership) **A child resource has no meaningful existence without its parent.** This dictates routing and controller patterns. ### Example: Invoice → InvoiceLine - `Invoice` is the **main context** for `InvoiceLine` - Invoice lines are **never** accessible at `/api/invoice-lines` - All invoice line routes are **nested** under `/:invoiceId/lines` within the invoices router - The `invoice-line.controller.ts` exists as a separate file but is **only mounted** inside `invoices.routes.ts` ### Rules 1. **No standalone routes for child resources.** A child resource's routes must always be prefixed by the parent's ID parameter. 2. **Child routes live in the parent's route file.** The parent router file defines both parent and child routes. 3. **Child param schemas include the parent ID.** e.g., `invoiceLineParamSchema` includes both `invoiceId` and `id`. 4. **Child controllers receive parent context from params.** The controller method reads `invoiceId` from `req.params`, not from the body. ### Route Pattern ``` GET /api/invoices → invoiceController.getAll GET /api/invoices/paginated → invoiceController.getAllPaginated GET /api/invoices/:id → invoiceController.getById POST /api/invoices → invoiceController.create PUT /api/invoices/:id → invoiceController.update DELETE /api/invoices/:id → invoiceController.remove GET /api/invoices/:id/download-pdf → invoiceController.downloadPdf GET /api/invoices/:invoiceId/lines → invoiceLineController.getByInvoice GET /api/invoices/:invoiceId/lines/paginated → invoiceLineController.getByInvoicePaginated POST /api/invoices/:invoiceId/lines → invoiceLineController.create GET /api/invoices/:invoiceId/lines/:id → invoiceLineController.getById PUT /api/invoices/:invoiceId/lines/:id → invoiceLineController.update DELETE /api/invoices/:invoiceId/lines/:id → invoiceLineController.remove ``` --- ## Routing Conventions ### Root Router (`routes/index.ts`) - Creates a single `Router()` instance, exported as `routes` - Each resource router is mounted via `router.use('/<resource>', <resource>Router)` - Routes are mounted under `/api` in `app.ts` via `app.use('/api', routes)` - Child resources do NOT get their own mount — they are defined inside the parent's router file ```ts import { Router } from 'express'; import { authRouter } from './auth.routes.js'; import { clientsRouter } from './clients.routes.js'; import { invoicesRouter } from './invoices.routes.js'; import { prestationsRouter } from './prestations.routes.js'; const router = Router(); router.use('/auth', authRouter); router.use('/clients', clientsRouter); router.use('/prestations', prestationsRouter); router.use('/invoices', invoicesRouter); export const routes = router; ``` ### Resource Router File (`routes/<resource>.routes.ts`) - Creates a local `Router()` instance - Defines all CRUD routes + custom actions for the resource - If the resource has children, defines nested child routes in the same file - Exports as named export: `export { router as <resource>Router }` - Uses `validateMiddleware` and `paginationMiddleware` for type-safe handlers ### Route Order 1. Collection routes (`/`, `/paginated`) 2. Instance routes (`/:id`) 3. Custom actions (`/:id/download-pdf`, etc.) 4. Child collection routes (`/:parentId/child`) 5. Child instance routes (`/:parentId/child/:id`) ### Access Control Pattern All routes (except auth) use middleware for authentication and authorization: - **Read routes** (GET): `authMiddleware, requireRole('ADMIN', 'EDITOR', 'VIEWER')` - **Write routes** (POST/PUT/DELETE): `authMiddleware, requireRole('ADMIN', 'EDITOR')` - **Auth routes** (login): no auth middleware - **Protected info** (me): `authMiddleware` only ```ts // Read routes — all authenticated users router.get('/', authMiddleware, requireRole('ADMIN', 'EDITOR', 'VIEWER'), controller.getAll); // Write routes — EDITOR and ADMIN only router.post('/', authMiddleware, requireRole('ADMIN', 'EDITOR'), validateMiddleware({ body: createSchema }, controller.create)); ``` --- ## Controller Conventions ### Structure - Controllers are **classes** with async methods - Each method is an async Express handler - Exported as a **singleton instance**: `export const <resource>Controller = new <Resource>Controller()` - All repository and service access is wrapped in `withTransaction()` ### Standard CRUD Methods | Method | Route | Description | |--------|-------|-------------| | `getAll` | `GET /` | Return all records (non-paginated, for backward compat) | | `getAllPaginated` | `GET /paginated` | Paginated list with search | | `getById` | `GET /:id` | Single record by ID, 404 if not found | | `create` | `POST /` | Create new record, return 201 | | `update` | `PUT /:id` | Update record, 404 if not found | | `remove` | `DELETE /:id` | Delete record, 204 on success, 404 if not found | ### Response Patterns - **Success list**: `res.json(data)` - **Success single**: `res.json(data)` - **Created**: `res.status(201).json(data)` - **Deleted**: `res.status(204).send()` - **Not found**: `res.status(404).json({ error: 'Not found' })` - **Validation error**: Handled by `validateMiddleware` → `422` - **Authentication error**: `401` via `authMiddleware` - **Authorization error**: `403` via `requireRole()` ### Typed Requests - Use `TypedRequest<TBody, TParams>` from `validate.middleware.ts` when schemas are applied - Use `PaginatedRequest<TBody, TParams>` (extends TypedRequest with PaginationQuery) for paginated handlers - Use plain `Request` only for untyped handlers (e.g., `getAll`) ### Controller Template ```ts import { withTransaction } from '../helpers/with-transaction.helper.js'; import { ExampleRepository } from '../repositories/example.repository.js'; import type { CreateExampleInput, IdParamInput, UpdateExampleInput } from '@chdev/common'; import type { Request, Response } from 'express'; import type { PaginatedRequest } from '../middlewares/pagination.middleware.js'; import type { TypedRequest } from '../middlewares/validate.middleware.js'; class ExampleController { async getAll(_req: Request, res: Response) { const items = await withTransaction(async (transaction) => { const repository = ExampleRepository.create(transaction); return repository.findAll(); }); res.json(items); } async getAllPaginated({ query }: PaginatedRequest, res: Response) { const [items, total] = await withTransaction(async (transaction) => { const repository = ExampleRepository.create(transaction); return repository.findAllPaginated(query); }); res.json({ data: items, total }); } async getById(req: TypedRequest<null, IdParamInput>, res: Response) { const { id } = req.params; const item = await withTransaction(async (transaction) => { const repository = ExampleRepository.create(transaction); return repository.findById(id); }); if (!item) { return res.status(404).json({ error: 'Not found' }); } res.json(item); } async create({ body }: TypedRequest<CreateExampleInput>, res: Response) { const saved = await withTransaction(async (transaction) => { const repository = ExampleRepository.create(transaction); return repository.create(body); }); res.status(201).json(saved); } async update({ body, params }: TypedRequest<UpdateExampleInput, IdParamInput>, res: Response) { const { id } = params; const updated = await withTransaction(async (transaction) => { const repository = ExampleRepository.create(transaction); return repository.update(id, body); }); if (!updated) { return res.status(404).json({ error: 'Not found' }); } res.json(updated); } async remove(req: TypedRequest<null, IdParamInput>, res: Response) { const { id } = req.params; const result = await withTransaction(async (transaction) => { const repository = ExampleRepository.create(transaction); return repository.delete(id); }); if (result.affected === 0) { return res.status(404).json({ error: 'Not found' }); } res.status(204).send(); } } export const exampleController = new ExampleController(); ``` --- ## Repository Conventions ### Structure - All repositories **extend `BaseRepository`** (`backend/src/common/base-repository.ts`) - `BaseRepository` takes an `EntityManager` in the constructor and provides it as `this.transaction` - Repositories are instantiated via the static factory pattern: `ExampleRepository.create(transaction)` - Each repository method runs within a database transaction ```ts import { BaseRepository } from '../common/base-repository.js'; import { ExampleEntity } from '../entities/example.entity.js'; export class ExampleRepository extends BaseRepository { async findAll() { return this.transaction.find(ExampleEntity); } async findById(id: number) { return this.transaction.findOne(ExampleEntity, { where: { id } }); } async create(data: CreateExampleInput) { const entity = this.transaction.create(ExampleEntity, data); return this.transaction.save(entity); } async update(id: number, data: Partial<ExampleEntity>) { await this.transaction.update(ExampleEntity, id, data); return this.findById(id); } async delete(id: number) { return this.transaction.delete(ExampleEntity, id); } } ``` ### Standard Methods ```ts export class ExampleRepository extends BaseRepository { async findAll() { ... } async findAllPaginated({ skip, take, search, order }: PaginationQuery) { ... } async findById(id: number) { ... } async create(data: CreateExampleInput) { ... } async update(id: number, data: Partial<ExampleEntity>) { ... } async delete(id: number): Promise<DeleteResult> { ... } } ``` ### Child Repository Methods ```ts export class InvoiceLineRepository extends BaseRepository { async findByInvoiceId(invoiceId: number) { ... }
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub