User V2 DTO Reuse Refactor Report
Summary
This report documents the current v2 user DTO duplication in services/amm/agent-web-api and proposes a refactor so that user shape changes propagate consistently across endpoints that are intended to share the same contract.
Goal: if we change the canonical shape of UserRef, UserLastActivity, or DepartmentRef, those changes should automatically be reflected everywhere those same DTOs are used.
For this report, the docs are the source of truth for which endpoints are supposed to share the same DTO contract.
Scope Reviewed
services/amm/agent-web-api/src/api/v2/users/users-dtos.ts#L141services/amm/agent-web-api/src/api/v2/users/users-dtos.ts#L158services/amm/agent-web-api/src/api/v2/users/users-dtos.ts#L174services/amm/agent-web-api/src/api/v2/users/users.controller.ts#L61services/amm/agent-web-api/src/api/v2/devices/devices-dtos.ts#L440services/amm/agent-web-api/src/api/v2/devices/devices-dtos.ts#L457services/amm/agent-web-api/src/api/v2/devices/devices.controller.ts#L106services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/utilization-dimension-dtos.ts#L81services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/utilization-dimension-dtos.ts#L92services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/utilization-dimension-dtos.ts#L106services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/app-utilization-dtos.ts#L178services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/app-utilization-dtos.ts#L189services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/app-utilization.controller.ts#L471services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/app-utilization.controller.ts#L851
Findings
1. There is no canonical shared UserRef DTO class in the backend
The docs define UserRef as a shared contract used across domains. It is described in docs/api/users/index.md as:
User reference for embedding inside other domain responses. Think of it as a business card -- enough to identify the user, display their name, and show when they were last active.
The docs explicitly state UserRef is "defined here but consumed by other domains (e.g. DeviceUserRef)."
The backend does not implement that as one reusable class. Instead it has separate DTOs with overlapping responsibility:
UserDtoinservices/amm/agent-web-api/src/api/v2/users/users-dtos.ts#L174— includesUserReffields plusdepartments,status,created_at,updated_atDeviceUserRefDtoinservices/amm/agent-web-api/src/api/v2/devices/devices-dtos.ts#L457— redefinesUserReffields inline plus junction fieldsUserRefDtoinservices/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/utilization-dimension-dtos.ts#L92— independent redefinitionAppUserUtilizationUserRefDtoinservices/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/app-utilization-dtos.ts#L189— yet another independent redefinition
As a result, changing the UserRef shape in one place does not update the others.
2. UserLastActivity is duplicated four times
The last_activity sub-object appears in four places with different class names:
UserLastActivityDtoinservices/amm/agent-web-api/src/api/v2/users/users-dtos.ts#L141LastActivityDtoinservices/amm/agent-web-api/src/api/v2/devices/devices-dtos.ts#L440UserLastActivityDtoinservices/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/utilization-dimension-dtos.ts#L81(same name, separate class)AppUserUtilizationLastActivityDtoinservices/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/app-utilization-dtos.ts#L178
These are all variants of:
{
"at": "2026-02-24T21:23:53.082Z",
"desktop_at": "2026-02-24T21:22:46.119Z",
"web_at": "2026-02-24T21:23:53.082Z"
}
Any change to last-activity fields currently has to be repeated manually in four files.
3. DepartmentRef is duplicated across modules
Department identity is modeled in three places:
UserDepartmentRefDtoinservices/amm/agent-web-api/src/api/v2/users/users-dtos.ts#L158DepartmentRefDtoinservices/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/utilization-dimension-dtos.ts#L106DepartmentDtoinservices/amm/agent-web-api/src/api/v2/departments/departments-dtos.ts#L102(the full department, not a ref)
The docs say DepartmentRef is "defined fully in the Departments domain." The users module should import a shared ref, not define its own UserDepartmentRefDto.
4. The devices domain does not reuse user DTO classes
DeviceUserRefDto is documented as wrapping UserRef with junction-level fields:
Wraps
UserRefwith the junction-levellast_seen_atfromuser_devices.
But the backend defines all UserRef fields (id, username, user_key, last_activity) inline in DeviceUserRefDto at services/amm/agent-web-api/src/api/v2/devices/devices-dtos.ts#L457 instead of composing from a shared UserRef class. It also defines its own LastActivityDto at line 440 instead of importing UserLastActivityDto.
The devices controller builds the embed manually:
services/amm/agent-web-api/src/api/v2/devices/devices.controller.ts#L106
const toDeviceUserRefDto = (row: any): DeviceUserRefDto => ({
id: row.id,
username: row.username,
user_key: row.userKey,
last_activity: {
at: toIsoOrNull(row.lastActivityUtc),
desktop_at: toIsoOrNull(row.lastDesktopActivityUtc),
web_at: toIsoOrNull(row.lastWebActivityUtc),
},
enrolled_at: toIsoOrNull(row.enrolledAt) ?? new Date(0).toISOString(),
last_seen_at: toIsoOrNull(row.lastSeenAt),
});
That means devices can drift from users even when both are supposed to describe the same UserRef resource.
5. Analytics controllers each own their own manual mapping logic
The analytics controller maps user references inline in two separate places:
- Per-app user utilization mapping at
services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/app-utilization.controller.ts#L471 - Utilization dimension by-user mapping at
services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/app-utilization.controller.ts#L851
Both produce identical inline user objects:
user: {
id: item.user.id,
username: item.user.username,
user_key: item.user.userKey,
last_activity: {
at: item.user.lastActivity.at,
desktop_at: item.user.lastActivity.desktopAt,
web_at: item.user.lastActivity.webAt,
},
},
There is no shared mapper layer for canonical user DTOs.
Current Duplication Map
UserLastActivity
| Purpose | Class | File |
|---|---|---|
| Core users endpoints | UserLastActivityDto | users/users-dtos.ts#L141 |
| Device users endpoint | LastActivityDto | devices/devices-dtos.ts#L440 |
| Analytics utilization by-user dimension | UserLastActivityDto | utilization-dimension-dtos.ts#L81 |
| Analytics per-app user utilization | AppUserUtilizationLastActivityDto | app-utilization-dtos.ts#L178 |
UserRef
| Purpose | Class | File |
|---|---|---|
| Core users endpoints | UserDto (superset — no standalone UserRef) | users/users-dtos.ts#L174 |
| Device users endpoint | DeviceUserRefDto (inline UserRef fields) | devices/devices-dtos.ts#L457 |
| Analytics utilization by-user dimension | UserRefDto | utilization-dimension-dtos.ts#L92 |
| Analytics per-app user utilization | AppUserUtilizationUserRefDto | app-utilization-dtos.ts#L189 |
DepartmentRef
| Purpose | Class | File |
|---|---|---|
| Core users endpoints | UserDepartmentRefDto | users/users-dtos.ts#L158 |
| Analytics utilization by-user dimension | DepartmentRefDto | utilization-dimension-dtos.ts#L106 |
Refactor Goal
Introduce canonical shared DTO classes for user-domain resources and use them anywhere the docs say the same resource shape should be returned or embedded.
The rule should be:
- if the docs say an endpoint returns or embeds
UserRef, use the same sharedUserRefDto - if the docs say an endpoint returns or embeds
UserLastActivity, use the same sharedUserLastActivityDto - if the docs say an endpoint returns or embeds
DepartmentRef, use the same sharedDepartmentRefDto User(full) should composeUserReffields plus additional fields — changes toUserRefautomatically propagate toUserDeviceUserRefshould composeUserRefplus junction fields — changes toUserRefautomatically propagate toDeviceUserRef
Proposed Target Design
1. Create shared canonical DTOs in one module
Create one shared DTO file for reusable user resource contracts, for example:
src/api/v2/users/shared/user-resource-dtos.ts
Recommended canonical classes:
UserLastActivityDtoUserRefDtoDepartmentRefDto
2. Reuse canonical DTOs in endpoint-specific DTOs
Examples:
UserDtoinusers/users-dtos.tsshould composeUserRefDtofields (either via class extension or nesting) plusdepartments,status,created_at,updated_atDeviceUserRefDtoindevices/devices-dtos.tsshould composeUserRefDtoplus junction fields (enrolled_at,last_seen_at), and should importUserLastActivityDtoinstead of definingLastActivityDtoUserRefDtoandDepartmentRefDtoin analytics utilization dimension DTOs should import from the shared moduleAppUserUtilizationUserRefDtoandAppUserUtilizationLastActivityDtoin analytics app utilization DTOs should be replaced with the sharedUserRefDtoandUserLastActivityDto
3. Move mapping into shared user mappers
Create shared mapper functions, for example:
toUserLastActivityDto(...)
toUserRefDto(...)
toDepartmentRefDto(...)
Then reuse those mappers from:
- users controller
- devices controller
- app-utilization controller (both per-app user utilization and dimension by-user)
4. Normalize class names
The refactor should remove near-duplicate DTO names for the same contract:
- replace
LastActivityDto(devices) with sharedUserLastActivityDto - replace
AppUserUtilizationLastActivityDtowith sharedUserLastActivityDto - replace
AppUserUtilizationUserRefDtowith sharedUserRefDto - replace
UserDepartmentRefDtowith sharedDepartmentRefDto
Recommended Rollout
Phase 1. Establish canonical resource DTOs
- add the shared user DTO module with
UserLastActivityDto,UserRefDto,DepartmentRefDto - add shared user mapper helpers (
toUserLastActivityDto,toUserRefDto,toDepartmentRefDto) - keep existing endpoint-specific DTOs temporarily for compatibility while migrating imports
Phase 2. Migrate endpoints that should share canonical contracts
- refactor
UserDtoto compose from sharedUserRefDtofields - switch
DeviceUserRefDtoto compose sharedUserRefDtoplus junction fields, import sharedUserLastActivityDto - remove
LastActivityDtofromdevices/devices-dtos.ts
Phase 3. Migrate analytics endpoints
- switch utilization dimension DTOs to import shared
UserRefDto,UserLastActivityDto,DepartmentRefDto - switch app utilization DTOs to import shared
UserRefDtoandUserLastActivityDto - remove
AppUserUtilizationUserRefDto,AppUserUtilizationLastActivityDto, and the duplicateUserLastActivityDtofrom analytics modules - replace inline user mapping blocks in
app-utilization.controller.tswith shared mapper calls
Phase 4. Regenerate and verify the contract
- regenerate
services/amm/agent-web-api/openapi.json - rerun the contract validation script
- rerun user and device contract tests
- confirm that duplicate OpenAPI schemas for the same conceptual resource have been eliminated where intended
Acceptance Criteria
The refactor is complete when all of the following are true:
- changing the shared
UserRefDtoupdates every endpoint that is documented to return or embedUserRef - changing the shared
UserLastActivityDtoupdates every endpoint that embedslast_activity - changing the shared
DepartmentRefDtoupdates every endpoint that embedsDepartmentRef - devices no longer define
LastActivityDtoor inlineUserReffields for canonical shapes - analytics no longer define
AppUserUtilizationUserRefDto,AppUserUtilizationLastActivityDto, or duplicateUserLastActivityDto/DepartmentRefDtoclasses - shared mapper functions are used instead of repeated controller-local user mapping blocks
- OpenAPI no longer emits multiple schema names for the same intended user contract unless the shapes are intentionally different
Risks
Over-sharing partial DTOs
Some endpoints may intentionally need a lighter user projection in the future. If so, give it a distinct Ref or Lite DTO name and do not pretend it is the canonical UserRef.
NestJS Swagger class identity
NestJS Swagger derives OpenAPI schema names from DTO class names. If DeviceUserRefDto starts nesting or extending UserRefDto, the generated schema may change. Verify the OpenAPI output does not introduce unintended renames.
Breaking generated clients
Consolidating DTOs may rename schemas in OpenAPI. That can affect generated SDKs even when the wire shape stays the same.
Requested Action
Implement a shared user DTO layer for v2, migrate users, devices, and analytics endpoints to reuse those canonical DTOs where the docs require shared contracts, and keep separate endpoint-specific DTOs only where the response is intentionally not the canonical user resource.