---
title: Modules
description: 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.

```ts title="src/main.ts"
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:

```ts title="src/assets/asset.controller.ts"
// 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:

```ts
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](/architecture/request-pipeline#exception-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".

:::danger[When you add an endpoint that touches tenant data, scope it explicitly]
A missing ownership check is a silent IDOR, not a compile error. Nothing in the type system will tell you it is absent.
:::

## 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](/api/workspace) for the concrete routes this affects.

:::note[There is no general fix]
Each of the five sites above patches itself. A new query joined to `tasks`, `projects`, or `assets` needs the same explicit predicate added by hand — TypeORM will not add it for you, and nothing will fail loudly if you forget.
:::
