Skip to content

GET /users/completeness — Design Spec

Status: Superseded (2026-05-05) by 2026-05-05-command-center-design.md Owner: Fabio Depends on: 2026-04-27-missing-fields-design.md, 2026-04-27-active-profile-design.md

Superseded. Tab 1 of the Command Center carries this design forward with three deltas: path moves to /api/v1/command-center/completeness; admit set changes from admin/teacher/staff to admin/referent (referents get cross-entity self-service for own + linked students); response shape collapses to flat { items, meta } with the me block dropped. All other decisions (status taxonomy, scope-restricted missingFields, sort, pagination, query plan) carry forward unchanged. See the new spec §3.1 for the full delta table.


1. Goal

Provide a single tenant-scoped endpoint that returns "people in this school whose profiles need attention" — across the four person-entity types (Student, Teacher, Staff, Referent) — so admins can triage missing data and individual users can see their own status. The endpoint backs a "data collection" view in the frontend.

Each row reports entityType, identity (id, firstName, lastName), a status (COMPLETED | PENDING | OVERDUE), and a flat missingFields list. The response also embeds a me block — the caller's own row — so the FE can render "your status" without a second request.

2. Non-goals

  • No write actions. This endpoint is strictly read-only; it neither remediates missing fields nor triggers reminders.
  • No cross-tenant aggregation. Tenant-scoped like every other endpoint.
  • No history / audit trail. Status is computed at read-time; no "was OVERDUE last week" record.
  • No grade/department drill-downs. Filtering is by entityType, status, firstName, lastName only. Sub-aggregations (e.g. "OVERDUE per grade") are out of scope.
  • No deferred-fields semantics. A field is missing iff COMPLETION_REQUIRED_REGISTRY says it should be present and the row's value is empty. No "snoozed", "waived", or "exempted" states.
  • No grace-period editing here. gracePeriodEnding is mutated through the academic-years endpoints; this endpoint is a read-only consumer.

3. Decisions

# Decision Rationale
D1 URL: GET /api/v1/users/completeness Lives in the existing users/ module; "completeness" is the chosen domain term (vendor-neutral, matches FE prototype).
D2 Response wrapper: { data: { items, meta }, me } Keeps me as a sibling of data so it isn't paginated/filtered. data.meta mirrors PaginationMetaDto.
D3 me is a single object matching one item shape, or null Single object → derived from request.user.activeProfile. null only when activeProfile = 'platform' (vendor support).
D4 Three statuses: COMPLETED, PENDING, OVERDUE OVERDUE = has missing fields and past the active year's gracePeriodEnding.
D5 All four entity types use the active year's gracePeriodEnding Referents are global (no academicYearId) but the deadline is tenant-wide; one date applies to everyone.
D6 Permission gating: @RequireRoles('admin', 'teacher', 'staff') + platform-admin bypass + per-entity scope intersection Students and Referents cannot call this endpoint at all (excluded at the role layer). Among admitted callers, rows are filtered to entity types each caller has any read scope on.
D7 missingFields is a flat string list, restricted to fields in scopes the caller can read Field names from scopes the caller can't see are not leaked. Custom-field names are included.
D8 Sort: default status (OVERDUE→PENDING→COMPLETED, then lastName,firstName); ?sort=name\|entityType configurable Single param, hardcoded sort directions per option.
D9 Pagination: standard ?page + ?limit (default 25, max 100), meta = { page, limit, total, totalPages } Mirrors PaginationQueryDto / PaginationMetaDto.
D10 Implementation: four parallel Prisma queries per readable entity, merged in service, sorted, then paginated post-merge Acceptable while tenants stay <1k people total. SQL UNION view deferred until evidence of scale.

4. Architecture

4.1 Module placement

src/users/
├── users.module.ts                          (existing — wire new controller)
├── users.controller.ts                      (existing — untouched)
├── users-completeness.controller.ts         (new)
├── users-completeness.service.ts            (new)
├── users-completeness.queries.ts            (new)
├── users-completeness.swagger.ts            (new — @Api decorators)
└── dto/
    ├── completeness-item.dto.ts             (new)
    ├── completeness-response.dto.ts         (new — wrapper + me)
    └── completeness-query.dto.ts            (new — query params + validation)

A new controller (not an additional method on UsersController) keeps the cross-entity aggregator out of the lean User read endpoints.

4.2 Permission flow

@RequireRoles('admin','teacher','staff')   // platform admins admitted via isPlatformAdmin bypass
   ↓ RolesGuard rejects students & referents at the controller boundary
   ↓ EnsurePermissionsGuard populates request.permissions (CompiledPermissions)
   ↓ AccessContextDecorator builds RecordAccessContext
UsersCompletenessController.findCompleteness(ctx, permissions, query)
UsersCompletenessService.findCompleteness(ctx, permissions, query)
   ├─ AcademicYearsService.getActiveYear(ctx.tenantId) → throws if none
   ├─ resolveVisibleEntities(permissions)
   │     uses permissions.scopes (Record<entityKey, Record<scopeKey, ScopeAccess>>)
   │     to compute which of [STUDENTS, TEACHERS, STAFF, REFERENTS] the caller
   │     can read at all (entity has at least one scope with access > NONE)
   ├─ entityFilter = (query.entityType ?? all) ∩ visibleEntities
   ├─ for each entity in entityFilter, in parallel:
   │     queries.<entity>FilteredByCtx(prisma, ctx, nameFilters)
   │       uses the same access-context filter as the entity's existing list endpoint
   │       applies SQL-level firstName/lastName ILIKE filters
   ├─ flat items = (for each row) buildItem(entity, row, permissions, gracePeriodEnding, customFieldDefs)
   ├─ statusFilter applied (post-build, since status is computed)
   ├─ sort per query.sort
   ├─ paginate → slice [offset, offset+limit)
   ├─ buildMe(activeProfile, ctx, gracePeriodEnding, customFieldDefs)
   └─ return { data: { items, meta }, me }

RecordAccessContext carries identity (tenantId, userId, roles, isPlatformAdmin); compiled permissions live on request.permissions and are loaded once per request via ensurePermissionsLoaded. The controller passes both into the service explicitly — keeping the service free of request-mutation side effects.

4.3 Status derivation

function deriveStatus(
  missingFields: string[],
  gracePeriodEnding: Date | null,
  now: Date,
): 'COMPLETED' | 'PENDING' | 'OVERDUE' {
  if (missingFields.length === 0) return 'COMPLETED';
  if (gracePeriodEnding === null) return 'PENDING'; // null = no deadline → never overdue
  return now > gracePeriodEnding ? 'OVERDUE' : 'PENDING';
}

4.4 missingFields computation (scope-restricted)

For each row, the flat missingFields list is the union of computeMissingFields(...) calls across only the scopes the caller can read on that entity:

function buildMissingFields(
  row: PersonRow,
  entityKey: EntityKeyValue,
  permissions: CompiledPermissions,
  customFieldDefs: Record<string /*scopeKey*/, CustomFieldDef[]>,
): string[] {
  const result: string[] = [];
  const callerScopes = permissions.scopes[entityKey] ?? {};
  for (const [scopeKey, access] of Object.entries(callerScopes)) {
    if (access === ScopeAccess.NONE) continue;
    const missing = computeMissingFields(
      row,
      row.customFields ?? null,
      entityKey,
      scopeKey,
      customFieldDefs,
    );
    result.push(...missing);
  }
  return result;
}

Platform admins (permissions.scopes is empty for them by current convention — they bypass scope checks at the guard layer) get all scopes treated as readable; the service short-circuits on isPlatformAdmin === true and iterates every scope in ENTITY_SCOPE_REGISTRY[entityKey].

Custom-field defs are loaded once per (tenantId, entityType) per request (cached in the service call). Referents are excluded from CUSTOM_FIELD_ENTITY_KEYS — for that entity, the helper passes null for customFieldsRaw and skips the def-load entirely. Native rules from REFERENT_COMPLETION_REQUIRED still apply.

4.5 me resolution

function resolveMeRow(
  activeProfile: ActiveProfileKey,
  userId: string,
  tenantId: string,
  activeYearId: string,
): PersonRow | null {
  switch (activeProfile) {
    case 'student':  return prisma.student.findFirst({ where: { userId, tenantId, academicYearId: activeYearId } });
    case 'teacher':  return prisma.teacher.findFirst({ where: { userId, tenantId, academicYearId: activeYearId } });
    case 'staff':    return prisma.staff.findFirst({ where: { userId, tenantId, academicYearId: activeYearId } });
    case 'referent': return prisma.referent.findFirst({ where: { userId, tenantId } }); // global
    case 'platform': return null;
  }
}

If the row is null for a non-platform profile, throw 500 with a logged me-row-not-found event — this is a data-integrity violation (User claims activeProfile = 'teacher' but no Teacher row exists for them).

The me row uses the same buildItem builder as items[], including the same status logic and scope-restricted missingFields. The caller's me row may also appear in items[] if it matches the filter — we do not deduplicate (clean separation: items is "the list", me is "your row").

5. Components

5.1 DTOs

// completeness-item.dto.ts
export class CompletenessItemDto {
  /** Aligns with `PersonProfileKey` from src/common/constants/person-profiles.ts */
  @ApiProperty({ enum: PERSON_PROFILE_KEYS })
  entityType: PersonProfileKey;  // 'student' | 'teacher' | 'staff' | 'referent'

  @ApiProperty({ format: 'uuid' })
  id: string;

  @ApiProperty({ nullable: true })
  firstName: string | null;

  @ApiProperty({ nullable: true })
  lastName: string | null;

  @ApiProperty({ enum: ['COMPLETED', 'PENDING', 'OVERDUE'] })
  status: 'COMPLETED' | 'PENDING' | 'OVERDUE';

  @ApiProperty({ type: [String] })
  missingFields: string[];
}

// completeness-response.dto.ts
export class CompletenessDataDto {
  @ApiProperty({ type: [CompletenessItemDto] })
  items: CompletenessItemDto[];

  @ApiProperty({ type: PaginationMetaDto })
  meta: PaginationMetaDto;
}

export class CompletenessResponseDto {
  @ApiProperty({ type: CompletenessDataDto })
  data: CompletenessDataDto;

  @ApiProperty({ type: CompletenessItemDto, nullable: true })
  me: CompletenessItemDto | null;
}

// completeness-query.dto.ts
export class CompletenessQueryDto extends PaginationQueryDto {
  @ApiProperty({ required: false, isArray: true, enum: ['COMPLETED','PENDING','OVERDUE'] })
  @IsOptional()
  @Transform(({ value }) => (typeof value === 'string' ? value.split(',') : value))
  @IsArray()
  @IsIn(['COMPLETED', 'PENDING', 'OVERDUE'], { each: true })
  status?: ('COMPLETED' | 'PENDING' | 'OVERDUE')[];

  @ApiProperty({ required: false, isArray: true, enum: PERSON_PROFILE_KEYS })
  @IsOptional()
  @Transform(({ value }) => (typeof value === 'string' ? value.split(',') : value))
  @IsArray()
  @IsIn([...PERSON_PROFILE_KEYS], { each: true })
  entityType?: PersonProfileKey[];

  @ApiProperty({ required: false })
  @IsOptional()
  @IsString()
  firstName?: string;

  @ApiProperty({ required: false })
  @IsOptional()
  @IsString()
  lastName?: string;

  @ApiProperty({ required: false, enum: ['status', 'name', 'entityType'] })
  @IsOptional()
  @IsIn(['status', 'name', 'entityType'])
  sort?: 'status' | 'name' | 'entityType' = 'status';
}

5.2 Controller

@ApiTags('users')
@Controller('users')
@UseGuards(ThrottlerGuard, JwtAuthGuard)
export class UsersCompletenessController {
  constructor(
    private readonly service: UsersCompletenessService,
    private readonly permissionsService: PermissionsService,
  ) {}

  @Get('completeness')
  @RequireRoles('admin', 'teacher', 'staff')
  @ApiCompleteness()
  async findCompleteness(
    @Req() req: AuthenticatedRequest,
    @AccessContext() ctx: RecordAccessContext,
    @Query() query: CompletenessQueryDto,
  ): Promise<CompletenessResponseDto> {
    const permissions = await ensurePermissionsLoaded(req, this.permissionsService);
    const activeProfile = req.user.activeProfile;
    return this.service.findCompleteness(ctx, permissions, activeProfile, query);
  }
}

Caller admit set: - admin, teacher, staff — admitted via @RequireRoles - Platform admins (isPlatformAdmin = true) — admitted via the standard RolesGuard bypass; their me resolves to null (no person-entity row in customer tenants) - student, referentrejected at the role layer (403 INSUFFICIENT_SCOPE). The completeness view is an admin/operator surface; students and parents have no use case here.

5.3 Service

Public method:

findCompleteness(
  ctx: RecordAccessContext,
  permissions: CompiledPermissions,
  activeProfile: ActiveProfileKey,
  query: CompletenessQueryDto,
): Promise<CompletenessResponseDto>

Internal helpers: - private resolveVisibleEntities(permissions, isPlatformAdmin): Set<EntityKeyValue> — examines permissions.scopes and returns the readable entity types (caller has any scope > NONE on each). Platform admins → all four. - private fetchRowsForEntity(entity, ctx, nameFilters) — delegates to per-entity query helpers in users-completeness.queries.ts. - private buildItem(entity, row, permissions, isPlatformAdmin, gracePeriodEnding, customFieldDefsByEntity) — produces a CompletenessItemDto. - private resolveMeItem(activeProfile, ctx, permissions, activeYear, customFieldDefsByEntity) — uses activeProfile to pick which row to fetch. - private loadCustomFieldDefs(tenantId, entities): Record<entity, Record<scope, CustomFieldDef[]>> — single batched load.

5.4 Queries

users-completeness.queries.ts exposes: - studentsForCompleteness(prisma, ctx, activeYearId, nameFilters) — wraps the existing studentsForAccessContext (record-level filtering for referents/teachers) and applies SQL ILIKE for firstName / lastName. - teachersForCompleteness(prisma, tenantId, activeYearId, nameFilters) — tenant-scoped + active-year filter + name filters. No record-level filter; visibility is gated upstream by scope presence on TEACHERS. - staffForCompleteness(prisma, tenantId, activeYearId, nameFilters) — same as teachers. - referentsForCompleteness(prisma, tenantId, nameFilters) — tenant-scoped + name filters. Not year-scoped (referents are global). No record-level filter; visibility gated by scope presence on REFERENTS. - findMeRow(prisma, activeProfile, userId, tenantId, activeYearId) — single row for me. Year-scoped for student/teacher/staff; global for referent.

Each query selects the minimum fields needed: id, firstName, lastName, userId, customFields, plus all native fields referenced by the entity's COMPLETION_REQUIRED_REGISTRY rules.

Note on record-level filtering asymmetry. Today only Students uses RecordAccessContext for record-level filtering (because students are the only entity where role-based row visibility differs — referents see their linked children, teachers see their own students). Teachers / Staff / Referents have scope-level visibility only: a caller without any scope on TEACHERS doesn't see them at all (filtered out by resolveVisibleEntities); a caller with any scope sees the whole tenant set (filtered by name/status).

This matches existing list endpoints (GET /teachers, GET /staff, GET /referents are admin-only today) and is acceptable for this iteration. If product later needs (e.g.) "teachers see other teachers in their department only", the corresponding teachersForAccessContext helper would be added in teachers.queries.ts and reused here — no spec change needed.

6. Data flow

6.1 Request

GET /api/v1/users/completeness?status=OVERDUE,PENDING&entityType=student,referent&lastName=smith&page=1&limit=25
Cookie: access_token=...

6.2 Successful response (200)

{
  "data": {
    "items": [
      {
        "entityType": "student",
        "id": "8c2d…",
        "firstName": "Alice",
        "lastName": "Smith",
        "status": "OVERDUE",
        "missingFields": ["dateOfBirth", "homeAddress"]
      },
      {
        "entityType": "referent",
        "id": "1f4e…",
        "firstName": null,
        "lastName": "Smith",
        "status": "PENDING",
        "missingFields": ["firstName", "homePhone"]
      }
    ],
    "meta": { "page": 1, "limit": 25, "total": 2, "totalPages": 1 }
  },
  "me": {
    "entityType": "teacher",
    "id": "9b1a…",
    "firstName": "Jane",
    "lastName": "Teacher",
    "status": "COMPLETED",
    "missingFields": []
  }
}

6.3 Empty results

If items is empty (caller's filters match nothing, or caller has no readable entities), data.items = [], data.meta.total = 0. me is still computed and returned.

6.4 Caller is platform admin

Platform admins (isPlatformAdmin = true, activeProfile = 'platform') are admitted via RolesGuard's standard bypass. They see all rows across all four entity types (no scope intersection — platform admin bypasses scope filters). me resolves to null since they hold no person-entity row in the customer tenant.

{
  "data": { "items": [ ... ], "meta": { ... } },
  "me": null
}

7. Error handling

Condition HTTP ErrorCode
No active academic year for tenant 400 NO_ACTIVE_ACADEMIC_YEAR (existing)
Invalid status / entityType / sort value 400 VALIDATION_FAILED
page < 1 or limit > 100 400 VALIDATION_FAILED
me-row promised by activeProfile but DB returns null 500 INTERNAL_ERROR (defensive — invariant violation)
Caller has no RequireRoles match 403 INSUFFICIENT_SCOPE
Caller has no readable entities (200 with items=[]) 200 me still resolved

The "no active year" path is the only domain-level error here. The behavior matches existing conventions in students.service.ts etc. — bootstrap-year tenants (placeholder DRAFT) will raise this until the setup wizard creates a real year.

8. Testing

8.1 Unit (users-completeness.service.spec.ts)

  • findCompleteness — admin in tenant with 0 students → items = [], meta total = 0
  • findCompleteness — caller with only students scope, requested entityType=TEACHER → 200, items = []
  • findCompleteness — admin with 3 students (1 OVERDUE, 1 PENDING, 1 COMPLETED), filter status=OVERDUE → 1 item
  • findCompleteness — referent with 2 linked students → only those 2 appear in items; me is the referent's own row
  • findCompleteness — multi-profile teacher+referent caller → items contains both Teacher and Referent rows for them (subject to filters); me is whichever matches activeProfile
  • findCompletenessgracePeriodEnding is null → no row is OVERDUE (all PENDING)
  • deriveStatus — full truth table for the three statuses
  • buildMissingFields — caller has only students.anagraphic → missing students.sensitive fields are NOT in result
  • resolveVisibleEntities — caller with scopes on STUDENTS only → {STUDENTS}
  • Sort — sort=name returns rows by lastName/firstName ASC across entityTypes
  • Pagination — page=2 with limit=10 over 25 items → returns items 11-20

8.2 E2E (test/users-completeness.e2e-spec.ts)

  • GET /users/completeness as admin in seeded E2E tenant → 200 with non-empty items + me
  • as teacher → sees students per their scopes; me is their teacher row
  • as platform admin → 200, all rows across all entity types, me: null
  • as referent → 403 INSUFFICIENT_SCOPE (role rejected)
  • as student → 403 INSUFFICIENT_SCOPE (role rejected)
  • without auth → 401
  • with ?entityType=student → only student items
  • with ?firstName=ali (case-insensitive contains) → only matching rows; null firstNames excluded
  • with ?status=OVERDUE after rolling tenant gracePeriodEnding to a past date → all incomplete rows now OVERDUE
  • with ?sort=entityType → student first, then teacher, staff, referent
  • with ?page=2&limit=2 → second page of 2 items, meta.total correct

8.3 Coverage notes

Custom fields: at least one e2e test should seed an isRequired=true custom-field def on students.anagraphic, leave a row's customFields[fieldKey] empty, and assert the field key appears in missingFields.

9. Open questions / future work

  • OVERDUE granularity. Currently a single tenant-level deadline. If we ever want per-entity deadlines (e.g., students must complete by Sept, staff by Aug), gracePeriodEnding will need per-entity expansion. Out of scope.
  • Caching. missingFields is computed per request. If list size grows past ~1k people, a Redis cache keyed by (tenantId, entityType, rowId, updatedAt) would help. Not yet needed.
  • Deferred me for platform admins. Currently 403. If vendor support needs to inspect a tenant's completeness dashboard, we may relax to allow platform with me: null. Defer until use case appears.
  • Sub-aggregations. "Count of OVERDUE per grade" / "per role" — needs a separate /users/completeness/summary endpoint when product asks. Out of scope here.
  • Reminder integration. A future automated mailer that emails OVERDUE referents needs the same query plumbing. Reuse the queries module then.

10. Dependencies

  • Missing-fields scaffold (2026-04-27-missing-fields-design.md): provides COMPLETION_REQUIRED_REGISTRY, computeMissingFields. Both already shipped.
  • Active-profile flow (2026-04-27-active-profile-design.md): request.user.activeProfile provides the discriminator for me. Shipped.
  • AcademicYear.gracePeriodEnding: column exists; default = first TERM startDate − 14d. Set during academic-year creation. This endpoint is the first consumer of the value — earlier code reads it but doesn't gate on it.
  • Bootstrap year: tenants in fresh-setup state have a DRAFT placeholder year. The getActiveYear call returns null → 400 NO_ACTIVE_ACADEMIC_YEAR. The completeness page is therefore unusable until the setup wizard creates a real ACTIVE year, which matches product expectations.

11. Glossary

  • Profile completeness: the share of COMPLETION_REQUIRED_REGISTRY rules satisfied for a row. This endpoint exposes the gap (missingFields), not the share.
  • Active year: tenant's currently-ACTIVE AcademicYear. Bootstrap (DRAFT) years are excluded.
  • Grace period: window between the active year's start and gracePeriodEnding during which incomplete profiles are tolerated (PENDING). After the deadline, they flip to OVERDUE.
  • me row: the caller's own person-entity row, selected by activeProfile.