Modules
The 14 backend feature modules — where each one mounts, what it owns, and the route-ordering and scoping rules that are easy to get wrong.
The backend is 167 TypeScript files across 14 feature modules plus the app root. The global prefix is api, with /health explicitly excluded.
app.setGlobalPrefix("api", {
exclude: [{ path: "health", method: RequestMethod.GET }],
});
The map
| Module or controller | Mount | Routes | Owns |
|---|---|---|---|
AuthModule |
/api/auth |
14 | Registration, verification, password reset, login, OAuth, refresh rotation, logout |
ProfileController |
/api/auth/user |
3 | Display name, avatar presign and commit — lives inside AuthModule |
CollectionModule |
/api/collections |
6 | CRUD, pagination, per-user favourites |
AssetsModule |
/api/assets |
12 | Read, update, delete, publish, download, versions, restore, asset logs |
UploadModule |
/api/upload |
3 | S3 multipart orchestration |
OrganizationModule |
/api/organizations |
10 | Organizations, members, invitations, and both organization guards |
ProjectModule |
/api/projects |
6 | Project CRUD and board ordering |
TaskModule |
/api/tasks + /api/projects/:projectId/tasks |
8 | Tasks, board moves, assignees |
TaskCommentModule |
/api/tasks/:taskId/comments |
4 | Threaded comments with asset attachments |
DashboardModule |
/api/dashboard |
1 | Org-scoped counts and recent task activity |
ActivityLogsModule |
/api/timeline |
4 | HTTP audit log, the global interceptor, a retention cron |
PlanModule |
/api/plan |
1 | Read-only subscription plans |
SeederModule |
/api/seeder |
1 | Plan seeding, ADMIN only |
AssetVersionModule |
— | 0 | Entity and repository only; surfaced through AssetsModule |
SharedModule |
— | 0 | S3, CloudFront, paginator, token generator, query runner — @Global |
AppController |
/health |
1 | Liveness, outside the api prefix |
Two mounts that are not where you would look
TaskController is root-mounted. Its @Controller() decorator takes no prefix, so it declares both /api/tasks/* and /api/projects/:projectId/tasks from the same class. If you go looking for project tasks under src/project/, you will not find them there.
ProfileController is not its own module. It lives in src/auth/profile.controller.ts and mounts at /api/auth/user, so its routes sit a segment deeper than AuthController’s: /api/auth/user/profile, /api/auth/user/avatar-upload. Nothing collides with GET /api/auth/user — the paths are simply different.
Route declaration order matters
AssetController mounts GET logs above GET :id with a comment explaining why:
// NOTE: this MUST stay above @Get(':id'). Nest matches routes in declaration
// order, so a later literal path loses to an earlier parameterized one.
@Get("logs")
async getMyLogs(/* … */) {}
@Get(":id")
async getAssetById(/* … */) {}
Reorder those two and /api/assets/logs starts resolving as an asset whose ID is the literal string logs.
Transactional writes
Multi-step database writes go through QueryRunnerExec in src/shared/services/query-runner-exec.service.ts. Its commit and rollback always release the runner and are idempotent, so a double-rollback in an error path is safe.
Upload completion is the canonical example: asset, version, and log rows are persisted in a single transaction, so a failure never leaves a version row pointing at an asset that does not exist.
Custom exceptions
Throw ApplicationException from src/lib/exception/app.exception.ts for expected 4xx conditions. It is a bare Error subclass — services catch it and translate to the right Nest exception:
if (error instanceof ApplicationException)
throw new NotFoundException(error.message);
this._logger.error((error as Error).message);
throw new InternalServerErrorException("Something went wrong");
Everything else reaches the global filter.
Scoping is not enforced by the compiler
Asset and collection reads and writes verify Asset → Collection → User == req.user and return 404 on mismatch, so a probe cannot distinguish “not yours” from “does not exist”.
