Skip to content
NTOMA
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.

Was this page helpful?