Skip to main content

Departments V2 DTO Reuse Refactor Report

Summary

This report documents the current v2 department DTO duplication in services/amm/agent-web-api and proposes a refactor so that department shape changes propagate consistently across endpoints that are intended to share the same contract.

Goal: if we change the canonical shape of Department 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/departments/departments-dtos.ts#L102 (DepartmentDto)
  • services/amm/agent-web-api/src/api/v2/departments/departments-dtos.ts#L143 (DepartmentDetailDto)
  • services/amm/agent-web-api/src/api/v2/departments/departments.controller.ts#L58 (toDepartmentDto)
  • services/amm/agent-web-api/src/api/v2/departments/departments.controller.ts#L68 (toDepartmentDetailDto)
  • services/amm/agent-web-api/src/api/v2/users/users-dtos.ts#L158 (UserDepartmentRefDto)
  • services/amm/agent-web-api/src/api/v2/users/users.controller.ts#L55 (toDepartmentRefDto)
  • services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/utilization-dimension-dtos.ts#L106 (DepartmentRefDto)
  • services/amm/agent-web-api/src/api/v2/analytics-apps/app-utilization/app-utilization.controller.ts#L862 (inline department mapping)

Findings

1. The docs define one Department DTO, but the backend splits it into two inconsistent classes

The docs define a single Department shape used by list, detail, create, and update responses:

The backend implements this as two separate classes that do not share any inheritance or composition:

  • DepartmentDto in services/amm/agent-web-api/src/api/v2/departments/departments-dtos.ts#L102
  • DepartmentDetailDto in services/amm/agent-web-api/src/api/v2/departments/departments-dtos.ts#L143

DepartmentDetailDto is missing members_count, so detail, create, and update responses do not include the member count that the docs require.

The controller reinforces this split with two separate mapper functions:

// departments.controller.ts#L58 — list mapper (has members_count)
const toDepartmentDto = (dept: any, membersCount: number): DepartmentDto => ({
id: dept.id,
client_id: dept.clientId,
name: dept.name,
description: dept.description ?? null,
members_count: membersCount,
created_at: dept.createdAt instanceof Date ? dept.createdAt.toISOString() : dept.createdAt,
updated_at: dept.updatedAt instanceof Date ? dept.updatedAt.toISOString() : dept.updatedAt,
});

// departments.controller.ts#L68 — detail mapper (no members_count)
const toDepartmentDetailDto = (dept: any): DepartmentDetailDto => ({
id: dept.id,
client_id: dept.clientId,
name: dept.name,
description: dept.description ?? null,
created_at: dept.createdAt instanceof Date ? dept.createdAt.toISOString() : dept.createdAt,
updated_at: dept.updatedAt instanceof Date ? dept.updatedAt.toISOString() : dept.updatedAt,
});

Adding a field to the documented Department shape requires updating both classes and both mappers, and the compiler will not flag a missed one.

2. DepartmentRef is duplicated across two modules

The docs define DepartmentRef as a minimal reference shape (id, name, description) for embedding in other domains:

  • /departments — DTO definition
  • Embedded as User.departments[] — see Users domain
  • Embedded in analytics utilization responses

The backend has two independent classes for this:

  • 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

These already have a bug divergence. UserDepartmentRefDto is missing type: String in its @ApiProperty decorator for description:

// users-dtos.ts#L168 — INCORRECT: missing type: String
@ApiProperty({ nullable: true, description: 'Optional department description' })
description!: string | null;

// utilization-dimension-dtos.ts#L113 — CORRECT: has type: String
@ApiProperty({ nullable: true, type: String, description: 'Department description' })
description!: string | null;

This causes the OpenAPI spec to type UserDepartmentRefDto.description as nullable object instead of nullable string. The analytics DepartmentRefDto is correct. Fixing one does not fix the other.

3. The analytics controller maps department refs inline instead of using a shared mapper

The analytics controller builds DepartmentRefDto objects with inline object literals in two places:

// app-utilization.controller.ts#L862 — user utilization
department: item.department
? { id: item.department.id, name: item.department.name, description: item.department.description }
: null,

// app-utilization.controller.ts#L945 — department utilization
department: { id: item.department.id, name: item.department.name, description: item.department.description },

The users controller has its own mapper function:

// users.controller.ts#L55
const toDepartmentRefDto = (dept: UserDepartment): UserDepartmentRefDto => ({
id: dept.id,
name: dept.name,
description: dept.description ?? null,
});

There is no shared mapper. Each consumer independently maps the same three fields.

Current Duplication Map

DepartmentRef (id, name, description)

PurposeClassLocationHas bug?
Embedded in UserDto.departmentsUserDepartmentRefDtoapi/v2/users/users-dtos.ts#L158Yes — description typed as object in OpenAPI
Embedded in analytics utilizationDepartmentRefDtoapi/v2/analytics-apps/app-utilization/utilization-dimension-dtos.ts#L106No

Department (id, name, description, member_count, created_at, updated_at)

PurposeClassLocationHas member_count?
List response itemsDepartmentDtoapi/v2/departments/departments-dtos.ts#L102Yes (as members_count)
Detail / Create / Update responseDepartmentDetailDtoapi/v2/departments/departments-dtos.ts#L143No — missing

Mapper functions

PurposeFunctionLocationShared?
Department list itemtoDepartmentDtodepartments.controller.ts#L58No — controller-local
Department detailtoDepartmentDetailDtodepartments.controller.ts#L68No — controller-local
User department reftoDepartmentRefDtousers.controller.ts#L55No — controller-local
Analytics department ref(inline)app-utilization.controller.ts#L862,945No — inline literals

Refactor Goal

Introduce canonical shared DTO classes for department 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 Department, use the same shared DepartmentDto
  • if the docs say an endpoint embeds DepartmentRef, use the same shared DepartmentRefDto
  • if an endpoint intentionally returns a different projection, give it a distinct name and do not pretend it is the canonical resource

Proposed Target Design

1. Create shared canonical DTOs in one module

Create one shared DTO file for reusable department resource contracts:

src/api/v2/departments/shared/department-resource-dtos.ts

Recommended canonical classes:

  • DepartmentRefDto — the minimal reference (id, name, description) for embedding
  • DepartmentDto — the full department shape with member_count and timestamps

2. Collapse DepartmentDto and DepartmentDetailDto into one class

The docs define one Department shape. The backend should have one class.

The current DepartmentDetailDto is a strict subset of DepartmentDto — it has the same fields minus members_count. Since the docs require member_count on all department-primary endpoints, DepartmentDetailDto should be eliminated and all endpoints should use the unified DepartmentDto.

This means the detail, create, and update endpoints need to fetch or compute the member count to populate the field. For create, the count is always 0.

3. Reuse DepartmentRefDto across modules

Replace module-local copies with the shared class:

  • UserDepartmentRefDto in users → import shared DepartmentRefDto
  • DepartmentRefDto in analytics → import shared DepartmentRefDto

This automatically fixes the description typing bug in UserDepartmentRefDto since both modules will use the same class with the correct @ApiProperty({ nullable: true, type: String }) decorator.

4. Move mapping into shared department mappers

Create shared mapper functions:

src/api/v2/departments/shared/department-mappers.ts

Recommended functions:

  • toDepartmentDto(dept, memberCount) — maps entity + count to full DepartmentDto
  • toDepartmentRefDto(dept) — maps entity to minimal DepartmentRefDto

Then reuse those mappers from:

  • departments controller (list, detail, create, update)
  • users controller (embedded departments[] in UserDto)
  • analytics controller (embedded department in utilization items)

Phase 1. Establish canonical resource DTOs

  • Create src/api/v2/departments/shared/department-resource-dtos.ts with DepartmentRefDto and DepartmentDto
  • Create src/api/v2/departments/shared/department-mappers.ts with toDepartmentDto and toDepartmentRefDto
  • Keep existing DTOs temporarily while migrating imports

Phase 2. Collapse DepartmentDetailDto into DepartmentDto

  • Remove DepartmentDetailDto class
  • Update detail, create, and update endpoints to return DepartmentDto (with member_count)
  • Update controller to use a single toDepartmentDto mapper
  • For create: pass 0 as member count
  • For detail and update: fetch the member count from the repository

Phase 3. Migrate cross-module references

  • Replace UserDepartmentRefDto in users-dtos.ts with an import of shared DepartmentRefDto
  • Replace DepartmentRefDto in utilization-dimension-dtos.ts with an import of shared DepartmentRefDto
  • Replace inline department mapping in analytics controller with shared toDepartmentRefDto
  • Replace toDepartmentRefDto in users controller with shared mapper

Phase 4. Regenerate and verify the contract

  • Regenerate services/amm/agent-web-api/openapi.json
  • Confirm the OpenAPI no longer emits duplicate schemas for the same department shape
  • Confirm UserDepartmentRefDto/DepartmentRefDto schema consolidation in OpenAPI output
  • Rerun any contract tests

Acceptance Criteria

The refactor is complete when all of the following are true:

  • Changing the shared DepartmentDto updates every endpoint that is documented to return Department (list, detail, create, update)
  • Changing the shared DepartmentRefDto updates every endpoint that embeds DepartmentRef (users, analytics)
  • DepartmentDetailDto no longer exists — all department-primary endpoints use the single DepartmentDto
  • The users module does not define UserDepartmentRefDto — it imports the shared DepartmentRefDto
  • The analytics module does not define its own DepartmentRefDto — it imports the shared one
  • Shared mapper functions are used instead of controller-local or inline mapping blocks
  • The description field is correctly typed as nullable string (not object) in all OpenAPI schemas that reference department ref

Risks

Detail/create/update endpoints need to fetch member count

Currently DepartmentDetailDto avoids the member count query. Collapsing into DepartmentDto means the detail, create, and update endpoints must either:

  • Run an additional count query, or
  • Use a repository method that returns the department with its count (like listByClientV2 already does for list items)

The list endpoint already computes this count efficiently. The same pattern can be reused for single-department fetches.

Breaking generated clients

Removing DepartmentDetailDto will rename the schema used by detail/create/update responses in OpenAPI. Generated SDKs that reference the old schema name will need to be regenerated.

OpenAPI schema consolidation

When UserDepartmentRefDto and DepartmentRefDto are replaced by a single shared class, the OpenAPI generator will emit one schema instead of two. Consumers referencing either old name will see it disappear.

Requested Action

Implement a shared department DTO layer for v2, collapse DepartmentDetailDto into DepartmentDto, migrate users and analytics endpoints to reuse the shared DepartmentRefDto, and extract shared mapper functions to eliminate inline and controller-local mapping duplication.