---
title: Profile & dashboard
description: The signed-in user's profile and avatar upload, plus the one-route dashboard summary that powers the Home page.
---

`ProfileController` lives inside `AuthModule` — not its own module — and mounts at `/api/auth/user`, a segment deeper than `AuthController`. `DashboardModule` is its own module at `/api/dashboard`, folded into this page because it is a single route. Both are covered here because both exist to summarize the workspace domain ([Workspace](/api/workspace)) for one viewer, rather than to mutate it.

## Profile

```ts title="backend/src/auth/profile.controller.ts"
/**
 * User-scoped, so no `OrganizationGuard` and no `x-organization-id` header —
 * a profile is not a tenant resource.
 */
@UseGuards(AuthGuard)
@Controller('auth/user')
export class ProfileController {}
```

Unlike every route in [Workspace](/api/workspace) and [Comments](/api/comments), profile routes carry **only** `AuthGuard` — no `OrganizationGuard`, because a profile belongs to a user, not to an organization.

| Method | Path | Summary |
| --- | --- | --- |
| `GET` | `/api/auth/user/profile` | Get the signed-in user's profile and activity totals |
| `PATCH` | `/api/auth/user/profile` | Update the signed-in user's profile |
| `POST` | `/api/auth/user/avatar-upload` | Start an avatar upload |
| `POST` | `/api/auth/user/avatar-upload/complete` | Finish an avatar upload |

### Why this exists alongside `GET /api/auth/user`

`GET /api/auth/user/profile` re-reads the user with the `plan` relation joined — `AuthGuard` loads `req.user` through a lookup that joins nothing, so `req.user.plan` is always `undefined` on every other route. It also carries `memberSince`, which `GET /api/auth/user` structurally cannot: `User.createdAt` carries `@Exclude()`, so it never survives serialization through a route that returns the entity itself. `ProfileService.overview` reads `user.createdAt` in plain TypeScript and returns a **plain object literal** instead of a `User` instance — `@Exclude()` is a class-transformer decorator that only fires when the global `ClassSerializerInterceptor` serializes an actual `User` instance, so a mapped plain object passes through untouched. Do not "fix" this by removing the decorator: it still guards every route that returns a raw `User` relation (organization members, task assignees, activity logs).

```json title="GET /api/auth/user/profile"
{
  "message": "Profile retrieved successfully",
  "data": {
    "id": "USa1B2c3D4",
    "email": "amoako@example.com",
    "fullName": "Amoako Owusu",
    "username": "amoako",
    "bio": null,
    "jobTitle": null,
    "location": null,
    "imageUrl": null,
    "role": "CONTENT_CREATOR",
    "accountStatus": "VERIFIED",
    "provider": "LOCAL",
    "showHeatmap": true,
    "planName": "Free",
    "memberSince": "2026-01-15T10:00:00.000Z",
    "stats": {
      "projectsCreated": 3,
      "tasksCompleted": 12,
      "tasksOpen": 5,
      "organizationCount": 2,
      "completionRate": 71
    }
  }
}
```

`stats` runs four counts concurrently. `projectsCreated` counts only projects this user **created** — there is no project-membership table, so "projects I worked on" is not a question the schema can answer. `completionRate` is `round(tasksCompleted / (tasksCompleted + tasksOpen) * 100)`, `0` with no tasks; it replaces a mocked "Efficiency Score" the frontend used to render, because nothing records *when* a task entered `COMPLETED` — `updated_at` is "last touched" — so "finished before the due date" has no source and never had one.

:::note[Same soft-delete join gotcha, a third time]
Every task query inside `stats()` explicitly joins `project` and filters `project.deleted_at IS NULL`, for the same reason `TaskService` and `DashboardService` do — see [Workspace](/api/workspace#board-position-ordering). Without it, a user's totals would keep counting work under projects that were soft-deleted.
:::

### Updating

`PATCH /api/auth/user/profile` takes any subset of `fullName`, `username`, `bio`, `jobTitle`, `location`, `showHeatmap`. Sending a body with no recognized fields is a **400 `Supply at least one field to update`** — `{}` passes the validation pipe (every field is optional so a save can send only what changed), but the service treats a no-op update as a caller error.

| Field | Rule |
| --- | --- |
| `fullName` | 2–100 characters |
| `username` | 3–30 chars, `^[a-z0-9](?:[a-z0-9_-]{1,28}[a-z0-9])?$`, unique case-insensitively, **cannot be cleared once set** |
| `bio` | ≤ 280 chars, empty string clears it |
| `jobTitle` | ≤ 100 chars, empty string clears it |
| `location` | ≤ 100 chars, empty string clears it |
| `showHeatmap` | boolean |

`bio`/`jobTitle`/`location` store an empty submission as `null`, not `''`, so "not set" has exactly one representation. `username` has no such escape hatch — an empty string fails the pattern — because a handle that has already been announced should not silently become nothing; omit the key to leave it alone instead.

`username` is also checked against a reserved list before the unique index gets a chance to reject it:

```ts title="backend/src/auth/dto/profile.dto.ts"
export const RESERVED_USERNAMES = [
  'admin', 'api', 'auth', 'billing', 'help', 'login', 'logout', 'me', 'null',
  'profile', 'register', 'root', 'settings', 'signup', 'support', 'system',
  'tala', 'undefined', 'user', 'users',
];
```

`profile` and `settings` matter concretely: the frontend routes `/profile/:username`, so a user holding one of those literal names would sit on top of a real path.

```json title="409 — the username is taken"
{ "statusCode": 409, "message": "That username is already taken" }
```

That check is pre-validated in the service — TOCTOU-racy by construction — and backstopped by the actual unique index, which is what the global exception filter's generic `23505 → 409` mapping exists for. The pre-check exists because that generic mapping's message ("please retry") can never be true advice for a taken handle, and names no field.

### Avatar upload

```http
POST /api/auth/user/avatar-upload
Content-Type: application/json

{ "contentType": "image/png" }
```

`contentType` must be one of `image/jpeg`, `image/png`, `image/webp`. This is **not** the [multipart upload pipeline](/api/uploads) — it returns a single-shot presigned `PUT` (`S3Service.generateUploadUrl`, 900-second expiry) rather than an `uploadId` and parts. An avatar is one small object, and running it through the multipart pipeline would create an `Asset` row — putting the avatar in the Library, which is not where it belongs.

The key is minted server-side as `avatars/<userId>/<uuid>.<ext>`; the extension is derived from `contentType` (`png`, `webp`, or `jpg` for anything else, i.e. `image/jpeg`).

```http
POST /api/auth/user/avatar-upload/complete
Content-Type: application/json

{ "key": "avatars/USa1B2c3D4/8f1c6f2e-….png" }
```

Two checks run before the profile is updated: the key must start with `avatars/<callingUser'sId>/` (**403 `Invalid avatar key`** otherwise — the same no-existence-disclosure posture as elsewhere, applied to a key namespace instead of a resource id), and the object must actually exist in S3 via a `HeadObject` call (**400 `Avatar upload has not completed`** otherwise), so a failed `PUT` can never leave the profile pointing at a broken image URL. On success, `imageUrl` is set to the CloudFront short URL and the updated profile is returned in the same shape as `PATCH /api/auth/user/profile`.

## Dashboard

```http
GET /api/dashboard/summary
```

The only route in `DashboardModule`. Same guard stack as [Workspace](/api/workspace) — `AuthGuard` + `OrganizationGuard` — and it backs the Home page's counts, "due today" list, project cards, and recent-activity feed in one call.

```json title="GET /api/dashboard/summary"
{
  "message": "Dashboard summary retrieved successfully",
  "data": {
    "projectCount": 6,
    "activeProjectCount": 4,
    "dueTodayTaskCount": 2,
    "overdueTaskCount": 1,
    "memberCount": 5,
    "tasksDueToday": [
      { "id": "TAa7Bk9x2Q", "title": "Redraw the wordmark", "status": "IN_PROGRESS", "dueAt": "2026-09-15T17:00:00.000Z" }
    ],
    "projects": [
      { "id": "PRa7Bk9x2Q", "name": "Q3 rebrand", "organizationName": "Acme Studio", "taskCount": 8, "completedTaskCount": 3, "progress": 38 }
    ],
    "recentActivity": [
      {
        "id": "TAa7Bk9x2Q",
        "type": "task",
        "title": "Redraw the wordmark",
        "projectId": "PRa7Bk9x2Q",
        "projectName": "Q3 rebrand",
        "status": "IN_PROGRESS",
        "occurredAt": "2026-09-14T16:20:00.000Z"
      }
    ]
  }
}
```

`projectCount` is every project in the organization, `completedTaskCount`-style included; `activeProjectCount` is the same minus `COMPLETED`. `dueTodayTaskCount`/`overdueTaskCount` and the `tasksDueToday` list only count **non-completed** tasks. `memberCount` counts `ACTIVE` memberships only. `projects` reuses `ProjectService.countRelations`/`withProgress` — the same functions `GET /api/projects` uses — so the Home project cards and the Workspace board can never disagree about a project's task counts or progress.

Every list here is a **hard cap with no pagination**: `tasksDueToday` is at most 5, `projects` at most 3, `recentActivity` at most 5. The counts beside them are the honest totals regardless of the cap — a truncated list next to a real count reads as "5 of 12"; a truncated list next to a truncated count would read as "12".

:::note[`recentActivity` has no actor]
Each row is a `Task` ordered by `updated_at`, and a task records who **created** it, not who last touched it — naming a "changed by" here would attribute an edit to someone who may not have made it. Real attribution needs `activity_logs`, which today has no organization column and cannot be org-scoped without resolving every row's resource first.
:::

### The day window

`dayStart`/`dayEnd` are optional ISO instants representing the viewer's **local** day, expressed in UTC — the server cannot know "today" for a viewer outside its own offset, so the client computes and sends both boundaries:

```http
GET /api/dashboard/summary?dayStart=2026-09-15T00:00:00.000Z&dayEnd=2026-09-16T00:00:00.000Z
```

Omit both and the service measures against the UTC day. Supply one and the other is derived 24 hours away rather than paired with the UTC default. The window is capped at **48 hours**, not 24 — a local day expressed as UTC instants can be up to 14 hours off in either direction depending on the viewer's offset, and DST transitions push a legitimate "day" slightly past 24 hours; 48 is loose enough to never reject a real day and tight enough that "give me the last five years" cannot be smuggled through as a single "day".

```json title="400 — dayStart after dayEnd"
{ "statusCode": 400, "message": "dayStart must be on or before dayEnd" }
```

```json title="400 — window wider than 48 hours"
{ "statusCode": 400, "message": "dayStart and dayEnd must span at most 48 hours" }
```

## Errors

| Situation | Status | Message |
| --- | --- | --- |
| Profile update body has no recognized fields | 400 | `Supply at least one field to update` |
| Username already taken (case-insensitive) | 409 | `That username is already taken` |
| Avatar completion key does not belong to the caller | 403 | `Invalid avatar key` |
| Avatar object does not yet exist in S3 | 400 | `Avatar upload has not completed` |
| `dayStart` after `dayEnd` | 400 | `dayStart must be on or before dayEnd` |
| Dashboard window wider than 48 hours | 400 | `dayStart and dayEnd must span at most 48 hours` |
| No active membership in the resolved organization (dashboard only) | 403 | `You do not belong to an active organization` |
