Appearance
Crest — API Module Standard
| Product | Crest — Personal Finance App |
| Document version | 0.3 (Draft) — adds naming of bodies, queries, and responses (§1.4); repository rules (§1.3), filters and arrays (§3), relation summaries (§4), repository unit tests (§7) |
| Date | 2026-10-02 |
| Based on | TechnicalDesign.md v0.12, Conventions.md v0.6 (code style §5 applies to all API code) |
| Owner | Reizkian Y. Radityatama |
| Status | Active — mandatory for code in Apps/Api |
This document says how a module in Apps/Api is written: which file does what, how requests are checked, how the database is reached, and what responses and errors look like. The Technical Design says what the API does; this document says how each piece of it is shaped, so that every module looks like the one before it.
Apps/Api/Src/AccessRequests together with Apps/Api/Src/Admin/AccessRequests is the reference implementation. When in doubt, copy it.
1. Layers
Every feature module has the same four layers. A request flows down through them; nothing skips a layer.
| Layer | Owns | Never does |
|---|---|---|
| Controller | Route, HTTP status, @Public() / guards, @Throttle, OpenAPI decorators, ParseUUIDPipe on path ids | Business rules, SQL, try/catch for flow |
| Service | The steps of the use case; deciding which error to throw; building repository input field by field | SQL, pg types, HTTP objects (Request, Response) |
| Repository | SQL; running it with the right pool and user (WithUser.run); turning rows (snake_case) into DTOs (camelCase); turning database errors it expects into results the service can act on | Deciding what is allowed (that is the database's and service's job); throwing HTTP errors |
| Database | Who may see and change which row; rules across rows | — |
1.1 Files
Src/<Feature>/
<Feature>Module.ts
<Feature>Controller.ts
<Feature>Service.ts
<Feature>Repository.ts
Dto/ every class, interface, type, and enum of the module
<Action><Thing>Body.ts a request body (validated)
<Action><Thing>Query.ts a query string (validated)
<Action><Thing>Response.ts what a route sends back
<Thing>PageResponse.ts a page of a list (§4)
New<Thing>.ts repository input (internal, not part of the API)- One main export per file, named after the file (0.5 §1).
- Bodies, queries, and responses are always named types (§1.4).
- Types live in
Dto/. A controller, service, repository, guard, filter, or module file declares nointerface,type, orenum; ESLint fails if it does. (Framework-free code such asSrc/MoneyandSrc/Configkeeps its types next to its class.) - Public DTOs and repository input are separate types. The API's names may suit the client (
acceptedTerms); repository input says what it holds (consentAt).
1.2 Admin modules
Super Admin endpoints live under Src/Admin/<Feature>/, are served under /v1/admin/..., and are the only code that may import ADMIN_POOL (the crest_admin database role, which has no grants on financial tables). ESLint enforces the import rule. Each admin controller carries @UseGuards(SuperAdminGuard) at class level.
1.3 Repositories
No shared base repository and no generic database helper layer (no
BaseRepository, nofindAll(table, filter)). Each repository writes its own SQL, so every query is visible where it runs and none can skipWithUser.run. Small pure helpers with no database access (such asSrc/Common/Cursor.ts) are fine.One private method builds a list's filter SQL, named
whereClause(filter). It takes the module's own filter type and returns thewhereconditions and their parameters. Each supported filter adds exactly one condition, written with the real table alias and column name, with a comment saying which UI field or rule it serves. Free-text search is one bracketedorgroup; the other filters areandconditions after it.tsprivate whereClause(filter: AccessRequestListFilter): SqlConditions { const conditions = ["r.deleted_at is null"]; const params: unknown[] = []; // Status tabs in the admin list (FR-ADM-4). if (filter.status !== null) { params.push(filter.status); conditions.push(`r.status = $${params.length}`); } … return { sql: conditions.join(" and "), params }; }Every value goes in as a
$nparameter; only fixed text written in the repository (table, column, operator) is put into the SQL string.
1.4 Naming what goes in and out
Every route says exactly what it takes and what it gives back, with a type whose name tells which it is. The OpenAPI contract, and the Dart and TypeScript clients generated from it, then have a named type for every route.
| What | Name ends in | Rule |
|---|---|---|
A request body (@Body()) | Body | SubmitAccessRequestBody. A route that takes a body takes a class, never an inline object or a string |
A query string (@Query()) | Query | ListAccessRequestsQuery |
| What a route returns | Response | AccessRequestResponse, AccessRequestPageResponse. The return type of a route is a …Response or a Promise<…Response> |
| Nested parts of a response | Response | FieldErrorResponse inside ApiErrorResponse |
| Repository input and rows, filters, internal types | anything else | NewAccessRequest, AccessRequestRow, AccessRequestListFilter. They never appear in a controller signature |
The old suffix Dto is not used for new types.
An empty answer is declared, never accidental. A route that has nothing to return says so with @HttpCode(HttpStatus.NO_CONTENT) (204, the work is done) or @HttpCode(HttpStatus.ACCEPTED) (202, accepted, finishing later, or answering the same whatever happened) and returns Promise<void>. It never takes the default 200/201 with no body. A route does not invent a field to avoid being empty: an always-true { "ok": true } tells a client nothing that the status code does not.
Enforced in two places. Both fail pnpm lint or pnpm test, so a violation never reaches master:
- ESLint, on every
*Controller.ts(eslint.config.mjs): the type of@Body()must end inBody, the type of@Query()inQuery, and every route (@Get,@Post,@Put,@Patch,@Delete) must return a…ResponseorPromise<…Response>, or bePromise<void>withNO_CONTENTorACCEPTEDdeclared. Avoidroute on the default status, an inline object type, and a missing return type are errors. Test/Integration/OpenApiContractSpec.ts, on the generated contract: every request body schema ends inBody, and every2xxanswer has a JSON body whose schema ends inResponse, except a204or202that declares none.
2. Access control
Four layers, each with one job. Only the last one is the real barrier; the others make failures early and clear.
| # | Layer | Where | Job |
|---|---|---|---|
| 1 | Authentication | JwtAuthGuard, global | Every route requires a valid access token and a live session, unless marked @Public() |
| 2 | Portal role | SuperAdminGuard, on admin controllers | Super Admin, admin-portal session, two-factor passed |
| 3 | Space role | SpaceRoleGuard + @RequiresSpaceRole() (added with the first space endpoint, M2) | Owner or Member of the space in the path; clear 403/404 |
| 4 | Database | crest_app + row-level security; crest_admin + admin functions | Filters every row, whatever the code above does |
2.1 Rules
- Closed by default.
JwtAuthGuardruns on every route. A route is open only with@Public(), and every public route is listed inTest/Integration/RouteAccessSpec.ts, so opening one is a reviewed change. - Roles come from the database, never from the token. The access token carries only
sub(user id) andsid(session id). On every requestJwtAuthGuardreads the session, the user's status, andis_super_adminthroughauth_check_session(). A deactivated user, a revoked session, or a removed Super Admin role takes effect on the next request (FR-AUTH-9). - Every user query runs inside
WithUser.run, which setsapp.user_idfor the transaction so that row-level security applies. Repositories do not callAPP_POOL.querydirectly. The two exceptions have no user yet, and call aSECURITY DEFINERfunction instead of touching tables: the session check that finds the user (auth_check_session()), and public endpoints such assubmit_access_request(). - Space roles are fixed, not data. The role table in the requirements (BRD §8.4) is mirrored in one place in code (
Src/Spaces/SpacePermissions.ts, M2) and in the row-level security policies. A new role changes the enum, the policies, that file, and the requirements in one pull request. There are no role or permission tables. - Rules that depend on the row (e.g. "Members edit only transactions they added", BR-36) are left to row-level security. The service turns "0 rows changed" into 404 (not visible) or 403 (visible, not editable).
- Rate limits (
@nestjs/throttler) apply to every route with a generous default; login, refresh, password reset, activation, and access requests set tighter limits with@Throttle.
3. Requests
- Validation. Every body and query string is a DTO class with
class-validatordecorators. The globalValidationPiperejects unknown fields (whitelist,forbidNonWhitelisted) and converts types (transform). Every DTO property also has@ApiPropertyso that the OpenAPI contract is complete. - Path ids use
ParseUUIDPipe, so a bad id is a 400 with a clear code, not a database error. - Creates are idempotent. The client may send the new row's
id(a UUID). Repeating the same request does not create a second row. - Filters match what the screen needs, and no more: small, flat query fields such as
search,categoryIds,from,to. No generic filter language (filter[field][op]=…), no dotted paths ("category.name"), no filter a screen does not use yet. - Lists in a query string are repeated keys:
?categoryIds=a&categoryIds=b. This is what OpenAPI's default (style: form, explode: true) and the generated clients send. Express turns repeated keys into an array but a single key into a plain string, so array fields use the one shared helper@Transform(QueryArray.transform)(Src/Common/QueryArray.ts) before@IsArray(); it is the only normalization allowed. Any other spelling (categoryIds[]=a,categoryIds=a,b) is rejected as an unknown or invalid field. - Partial updates (
PATCH). An omitted field is left unchanged; a field sent asnullis cleared (where the column allows it). The service builds the repository input field by field; it never spreads the request body into SQL.
4. Responses
JSON fields are camelCase; repositories map from snake_case explicitly.
Lists are cursor-paginated, newest first, and return:
json{ "items": [ ... ], "nextCursor": "eyJjcmVhdGVkQXQiOi..." }nextCursorisnullon the last page. Query parameters arelimit(1–100, default 20) andcursor. The cursor is opaque to clients (Src/Common/Cursor.ts); it encodes the last row's(created_at, id)and the repository continues withwhere (created_at, id) < (...). There are no page numbers and no totals: they break when rows are added while paging, and offline sync needs stable cursors.Related records are returned as summaries, never as bare ids. A transaction returns
financialAccount: { id, name },category: { id, name }, andaddedBy: { id, name }(FR-SPC-7), notfinancialAccountId, so the client shows it without more calls. The repository joins what the response shows. Keep an id field alone only where a client needs it to send a change and nothing is displayed from it.Search covers what is shown. If a list displays
category.nameandaddedBy.name, itssearchmatches them too.Read, create, and update of the same resource return the same shape, so a client can show the result without fetching again.
5. Errors
Every error has one shape (Technical Design §2.4), produced by the global ApiExceptionFilter:
json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "The request is not valid.",
"code": "ValidationFailed",
"requestId": "0190f0e2-6c6e-7c2a-9f3b-2a1f4e6d9a10",
"errors": [{ "field": "email", "code": "isEmail" }]
}codeis stable and listed inSrc/Errors/Dto/ErrorCode.ts. Clients translatecode, neverdetailortitle, which are for developers.requestIdis the id of this request: also theX-Request-Idresponse header and a field of every log line of the request. A client shows it when it cannot explain an error, so the owner can find what happened without any request data in the logs.- A service throws
new ApiException(HttpStatus.Forbidden, ErrorCode.SuperAdminRequired). Nest's built-in exceptions (unknown route, too many requests, body too large) are mapped to codes by the filter. - Unexpected errors are logged with their class, database error code, request id, and stack frames, never the message (a database or library message can contain row values or email addresses), and returned as
500 InternalErrorwith no detail. No financial data, request bodies, tokens, or SQL ever appear in a response or a log line.
6. Logging
nestjs-pino writes one structured JSON line per request: request id, method, path (without the query string, which may contain search text), status, duration, and the user id once authenticated. Headers, bodies, and query strings are never logged. Tests run with logging off.
7. Tests
Every test lives under Test/, never next to the code, and is named <Name>Spec.ts (not .spec.ts). Test/ has three parts, so what a test needs is visible from its folder:
| Folder | What | Needs Docker? | Run with |
|---|---|---|---|
Test/Unit/ | One class at a time, with its collaborators mocked. It mirrors Src/: the test of Src/Auth/AuthService.ts is Test/Unit/Auth/AuthServiceSpec.ts. | No | pnpm test:unit |
Test/Integration/ | The real app and a real PostgreSQL (Testcontainers), and checks that cover the whole app. | Yes | pnpm test:integration |
Test/Support/ | Helpers the tests share: PgMock, TestApp, TestDatabase, ReadDatabaseSchema. Never a test. | — | — |
pnpm test runs both. The reason for the split is cost: a unit test runs in milliseconds, so it is run all the time; an integration test starts a database container and is run before a commit.
| What | Where | How |
|---|---|---|
| Service rules | Test/Unit/<Feature>/<Feature>ServiceSpec.ts | Jest, repository mocked |
| Repositories | Test/Unit/<Feature>/<Feature>RepositorySpec.ts | Jest, pg pool mocked (Test/Support/PgMock.ts): the SQL text, the parameters in order, WithUser.run with the right user, and the mapping from rows to DTOs |
| Guards, filters, and helper classes | Test/Unit/<Folder>/<Name>Spec.ts | Jest, pools mocked |
| Endpoints | Test/Integration/<Feature>Spec.ts | Supertest against the real app and a real PostgreSQL: createTestApp(database) in Test/Support/TestApp.ts. These, not the repository unit tests, prove the SQL works |
| Database rules | Test/Integration/Database/*Spec.ts | Act as each database role and user; a new table or function gets tests here in the same pull request |
| Route access | Test/Integration/RouteAccessSpec.ts | Every route is public (and listed), or authenticated; every /admin route has SuperAdminGuard and no other route does |
| Contract | Test/Integration/OpenApiContractSpec.ts | Packages/ApiContract/OpenApi.json matches the code; every body is a …Body and every success answer a …Response unless it is a declared 204/202 (§1.4) |
Apps/Admin follows the same layout for the tests it has: Apps/Admin/Test/Unit/, mirroring Src/, with include in angular.json pointing there.
8. Checklist for a new module
- Folder and files as in §1.1; types in
Dto/; a…Body,…Query, and…Responsefor every route (§1.4); code style as in 0.5 §5 (doc comment on every service and repository method; helpers are static classes, §5.9). - Every route: authenticated by default, or
@Public()and added toTest/Integration/RouteAccessSpec.ts; admin routes underSrc/AdminwithSuperAdminGuard. - Every query through
WithUser.run(or aSECURITY DEFINERfunction for public routes). - New tables or functions: migration with row-level security and grants, plus database tests.
- Errors as
ApiExceptionwith a code fromErrorCode. pnpm --filter @crest/api openapiand commit the updated contract.- Unit tests for the service and the repository; endpoint tests against a real database.
pnpm lint && pnpm typecheck && pnpm testpass.