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 fromadmin/teacher/stafftoadmin/referent(referents get cross-entity self-service for own + linked students); response shape collapses to flat{ items, meta }with themeblock dropped. All other decisions (status taxonomy, scope-restrictedmissingFields, 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,lastNameonly. Sub-aggregations (e.g. "OVERDUE per grade") are out of scope. - No deferred-fields semantics. A field is missing iff
COMPLETION_REQUIRED_REGISTRYsays it should be present and the row's value is empty. No "snoozed", "waived", or "exempted" states. - No grace-period editing here.
gracePeriodEndingis 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, referent — rejected 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.
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 = 0findCompleteness— caller with onlystudentsscope, requestedentityType=TEACHER→ 200,items = []findCompleteness— admin with 3 students (1 OVERDUE, 1 PENDING, 1 COMPLETED), filterstatus=OVERDUE→ 1 itemfindCompleteness— referent with 2 linked students → only those 2 appear initems;meis the referent's own rowfindCompleteness— multi-profile teacher+referent caller →itemscontains both Teacher and Referent rows for them (subject to filters);meis whichever matchesactiveProfilefindCompleteness—gracePeriodEndingis null → no row is OVERDUE (all PENDING)deriveStatus— full truth table for the three statusesbuildMissingFields— caller has onlystudents.anagraphic→ missingstudents.sensitivefields are NOT in resultresolveVisibleEntities— caller with scopes on STUDENTS only →{STUDENTS}- Sort —
sort=namereturns 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/completenessas admin in seeded E2E tenant → 200 with non-empty items + me- as teacher → sees students per their scopes;
meis 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=OVERDUEafter rolling tenantgracePeriodEndingto 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.totalcorrect
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),
gracePeriodEndingwill need per-entity expansion. Out of scope. - Caching.
missingFieldsis 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
mefor platform admins. Currently 403. If vendor support needs to inspect a tenant's completeness dashboard, we may relax to allowplatformwithme: null. Defer until use case appears. - Sub-aggregations. "Count of OVERDUE per grade" / "per role" — needs a separate
/users/completeness/summaryendpoint 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.activeProfileprovides the discriminator forme. Shipped. AcademicYear.gracePeriodEnding: column exists; default = first TERMstartDate − 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
getActiveYearcall returns null → 400NO_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_REGISTRYrules satisfied for a row. This endpoint exposes the gap (missingFields), not the share. - Active year: tenant's currently-
ACTIVEAcademicYear. Bootstrap (DRAFT) years are excluded. - Grace period: window between the active year's start and
gracePeriodEndingduring which incomplete profiles are tolerated (PENDING). After the deadline, they flip toOVERDUE. merow: the caller's own person-entity row, selected byactiveProfile.