Skip to main content

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#L141
  • services/amm/agent-web-api/src/api/v2/users/users-dtos.ts#L158
  • services/amm/agent-web-api/src/api/v2/users/users-dtos.ts#L174
  • services/amm/agent-web-api/src/api/v2/users/users.controller.ts#L61
  • services/amm/agent-web-api/src/api/v2/devices/devices-dtos.ts#L440
  • services/amm/agent-web-api/src/api/v2/devices/devices-dtos.ts#L457
  • services/amm/agent-web-api/src/api/v2/devices/devices.controller.ts#L106
  • services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/utilization-dimension-dtos.ts#L81
  • services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/utilization-dimension-dtos.ts#L92
  • services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/utilization-dimension-dtos.ts#L106
  • services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/app-utilization-dtos.ts#L178
  • services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/app-utilization-dtos.ts#L189
  • services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/app-utilization.controller.ts#L471
  • services/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:

  • UserDto in services/amm/agent-web-api/src/api/v2/users/users-dtos.ts#L174 — includes UserRef fields plus departments, status, created_at, updated_at
  • DeviceUserRefDto in services/amm/agent-web-api/src/api/v2/devices/devices-dtos.ts#L457 — redefines UserRef fields inline plus junction fields
  • UserRefDto in services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/utilization-dimension-dtos.ts#L92 — independent redefinition
  • AppUserUtilizationUserRefDto in services/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:

  • UserLastActivityDto in services/amm/agent-web-api/src/api/v2/users/users-dtos.ts#L141
  • LastActivityDto in services/amm/agent-web-api/src/api/v2/devices/devices-dtos.ts#L440
  • UserLastActivityDto in services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/utilization-dimension-dtos.ts#L81 (same name, separate class)
  • AppUserUtilizationLastActivityDto in services/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:

  • UserDepartmentRefDto in services/amm/agent-web-api/src/api/v2/users/users-dtos.ts#L158
  • DepartmentRefDto in services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/utilization-dimension-dtos.ts#L106
  • DepartmentDto in services/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 UserRef with the junction-level last_seen_at from user_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

PurposeClassFile
Core users endpointsUserLastActivityDtousers/users-dtos.ts#L141
Device users endpointLastActivityDtodevices/devices-dtos.ts#L440
Analytics utilization by-user dimensionUserLastActivityDtoutilization-dimension-dtos.ts#L81
Analytics per-app user utilizationAppUserUtilizationLastActivityDtoapp-utilization-dtos.ts#L178

UserRef

PurposeClassFile
Core users endpointsUserDto (superset — no standalone UserRef)users/users-dtos.ts#L174
Device users endpointDeviceUserRefDto (inline UserRef fields)devices/devices-dtos.ts#L457
Analytics utilization by-user dimensionUserRefDtoutilization-dimension-dtos.ts#L92
Analytics per-app user utilizationAppUserUtilizationUserRefDtoapp-utilization-dtos.ts#L189

DepartmentRef

PurposeClassFile
Core users endpointsUserDepartmentRefDtousers/users-dtos.ts#L158
Analytics utilization by-user dimensionDepartmentRefDtoutilization-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 shared UserRefDto
  • if the docs say an endpoint returns or embeds UserLastActivity, use the same shared UserLastActivityDto
  • if the docs say an endpoint returns or embeds DepartmentRef, use the same shared DepartmentRefDto
  • User (full) should compose UserRef fields plus additional fields — changes to UserRef automatically propagate to User
  • DeviceUserRef should compose UserRef plus junction fields — changes to UserRef automatically propagate to DeviceUserRef

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:

  • UserLastActivityDto
  • UserRefDto
  • DepartmentRefDto

2. Reuse canonical DTOs in endpoint-specific DTOs

Examples:

  • UserDto in users/users-dtos.ts should compose UserRefDto fields (either via class extension or nesting) plus departments, status, created_at, updated_at
  • DeviceUserRefDto in devices/devices-dtos.ts should compose UserRefDto plus junction fields (enrolled_at, last_seen_at), and should import UserLastActivityDto instead of defining LastActivityDto
  • UserRefDto and DepartmentRefDto in analytics utilization dimension DTOs should import from the shared module
  • AppUserUtilizationUserRefDto and AppUserUtilizationLastActivityDto in analytics app utilization DTOs should be replaced with the shared UserRefDto and UserLastActivityDto

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 shared UserLastActivityDto
  • replace AppUserUtilizationLastActivityDto with shared UserLastActivityDto
  • replace AppUserUtilizationUserRefDto with shared UserRefDto
  • replace UserDepartmentRefDto with shared DepartmentRefDto

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 UserDto to compose from shared UserRefDto fields
  • switch DeviceUserRefDto to compose shared UserRefDto plus junction fields, import shared UserLastActivityDto
  • remove LastActivityDto from devices/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 UserRefDto and UserLastActivityDto
  • remove AppUserUtilizationUserRefDto, AppUserUtilizationLastActivityDto, and the duplicate UserLastActivityDto from analytics modules
  • replace inline user mapping blocks in app-utilization.controller.ts with 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 UserRefDto updates every endpoint that is documented to return or embed UserRef
  • changing the shared UserLastActivityDto updates every endpoint that embeds last_activity
  • changing the shared DepartmentRefDto updates every endpoint that embeds DepartmentRef
  • devices no longer define LastActivityDto or inline UserRef fields for canonical shapes
  • analytics no longer define AppUserUtilizationUserRefDto, AppUserUtilizationLastActivityDto, or duplicate UserLastActivityDto / DepartmentRefDto classes
  • 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.