Profile & dashboard
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) for one viewer, rather than to mutate it.
Profile
/**
* 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 and 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).
{
"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.
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:
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.
{ "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
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 — 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).
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
GET /api/dashboard/summary
The only route in DashboardModule. Same guard stack as Workspace — AuthGuard + OrganizationGuard — and it backs the Home page’s counts, “due today” list, project cards, and recent-activity feed in one call.
{
"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”.
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:
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”.
{ "statusCode": 400, "message": "dayStart must be on or before dayEnd" }
{ "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 |
