Modules
The 15 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 236 TypeScript files across 15 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 |
4 | Profile overview + stats, field updates, 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 |
11 | Organizations, members, invitations, and both organization guards |
ProjectModule |
/api/projects |
6 | Project CRUD and board ordering |
ProjectCommentModule |
/api/projects/:projectId/comments |
4 | Flat comment threads on a project, no attachments |
TaskModule |
/api/tasks + /api/projects/:projectId/tasks |
10 | Tasks, board moves, assignees, task-asset attachment |
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 |
6 | 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”.
Soft deletes do not cascade through joins
TypeORM’s soft-delete filter applies to the root entity of a query only. A leftJoinAndSelect/innerJoin to a related table carries no deleted_at IS NULL unless it is added by hand — and ProjectService.remove() soft-removes a project without cascading that delete to its tasks, so a task under a soft-deleted project stays a live row.
Four call sites independently discovered this and each added its own project.deleted_at IS NULL predicate: TaskService.list, TaskService.findOwnedWithManager, DashboardService.orgTasks, and ProfileService.stats. The same shape recurs one relation lower for loadRelationCountAndMap in ProjectService.countRelations (task and asset counts) and for the task.assets join in TaskService.findOwnedWithManager. See Workspace for the concrete routes this affects.
