Skip to content

Command Center — Design Spec

Status: Draft (2026-05-05) Owner: Fabio Supersedes: 2026-04-28-users-completeness-design.md — Tab 1 carries forward with three deltas (path, admit set, no me block); see §3. Depends on: 2026-04-27-missing-fields-design.md, 2026-04-27-active-profile-design.md Implements (partial): Epic 1 — Tenant Setup & Onboarding (US-12 admin to-do intent), Epic 2 — Identity/Access (US-31 monitoring intent), Epic 8 — Grace Period (US-23 admin completion view)


1. Goal

Provide the backend surface for the admin Command Center — a single dashboard with two tabs covering the operational state of people in the school against the active academic year's grace period:

  • Tab 1 — User Completeness: people whose profile data is incomplete or overdue against the grace-period deadline. Admin-facing operator view; also reused as the referent-side "fill your data" view (own + linked students).
  • Tab 2 — Onboarding: invitation/access lifecycle status for Teacher / Staff / Referent. Admin-only.

Each tab is one tenant-scoped, paginated read endpoint. Both anchor on the active academic year's gracePeriodEnding.

A separate frontend "System Setup" tile reuses the existing GET /configure/setup/overview and is out of scope for this spec — no new endpoint.

2. Non-goals

  • No write actions. Both tabs are read-only.
  • No me block. Teachers and staff use GET /auth/profile for self-status (per-scope missingFields already ships there via FieldFilterInterceptor). Referents are admitted to Tab 1 directly because their self-status spans linked entities (own row + linked students).
  • No Students in either tab. Tab 1 admits Students-as-rows under admin/referent visibility (per-scope filters), but Students are not admitted as callers — they have no profile-completion stage (per STUS-1) and no operator surface. Tab 2 omits Students entirely (US-14.1 not built — Students are not invitation recipients yet).
  • No per-Referent-student-pair onboarding status. Account-level only — see project memory. The corresponding spec clauses in US-14 S4, US-31 S2, and US-23 must be deleted from ClickUp; do not implement.
  • No Guardians. GUARDIAN_COMPLETION_REQUIRED is not in the registry yet (US-23 S3 deferred). Guardian rows do not appear in Tab 1 for this iteration; they join when the registry adds the entry.
  • No curriculum-selection ("Tab 3"). Deferred entirely until US-32 ships its data model and selection-round flow. The frontend will show no Tab 3 in v1.
  • No reminder dispatch / scheduler. US-23 S2/S4/S5/S6/S9 (rolling deadlines, weekly mailer, manual triggers) are out of scope; this endpoint is the first read consumer of gracePeriodEnding only.
  • No summary aggregations. Counts per stage / per status (US-31 S5) are deferred. Frontend computes display rollups from meta.total and per-row data.
  • No suspended-user surfacing semantics. US-31 S9 (frozen status, distinct UI marker, exclusion from counts) deferred.
  • No bulk actions. Send / resend already exist via /invitations/send and /invitations/:id/resend; no new bulk endpoint here. Reminder-only bulk (US-31 S8) is out of scope.
  • No setup-mode lock. US-1 Scenario 2 (operational endpoints reject when setupStep !== COMPLETE) is its own spec.
  • No System Setup module envelope. The frontend tile reads GET /configure/setup/overview directly; no new /system-setup/modules endpoint.

3. Decisions

# Decision Rationale
D1 New module: src/command-center/, hosting both tabs and shared helpers. Coherent backend concept; shared grace-period and visibility logic; avoids cross-domain bloat in src/users/.
D2 Paths: GET /api/v1/command-center/completeness, GET /api/v1/command-center/onboarding. Replaces the Apr-28 spec's GET /users/completeness.
D3 Response shape: flat { data, meta } (matches the project's PaginatedResponseDto convention — the array key is data, not items). No me block ⇒ no envelope wrapper needed.
D4 Admit sets: Tab 1 = admin + referent (+ platform-admin bypass); Tab 2 = admin (+ platform-admin bypass). Tab 1 has cross-entity self-service value for referents (own + linked students). Tab 2 has no self-service value beyond confirming "you're logged in." Teachers/Staff use GET /auth/profile for self-status.
D5 Tab 1 status: COMPLETED \| PENDING \| OVERDUE (single 3-state field). Two axes (completion + deadline) collapse cleanly because completion is binary.
D6 Tab 2 row carries two fields: status: NOT_SENT \| SENT \| FAILED \| ACCESSED and isOverdue: boolean. Lifecycle has 4 non-terminal states; deadline overlay is orthogonal — not a redundant flag.
D7 COMPLAINED → FAILED projection at Tab 2 service layer; UiInvitationStatus source enum unchanged. Operationally identical to admin (both signal "delivery problem, resend or fix"); persistent enum keeps the granular event distinction for /invitations and Resend webhook reasoning.
D8 Both tabs use the active year's tenant-wide gracePeriodEnding. Per-record deadlines (US-23 S2) deferred. Single global deadline is current state; rolling per-record deadlines have no schema column today.
D9 missingFields is a flat string list, scope-restricted to the caller's readable scopes (Tab 1 only). Same convention as Apr-28 spec D7. Tab 2 carries no missingFields.
D9.1 isMe: boolean on Tab 1 items. true iff the row's userId matches the caller's userId (referent rows only — admin/platform admin always see false everywhere). Lets the FE distinguish "your row" from "your child's row" in the unified items[] without the FE needing to know its own person-entity id. Cheaper than re-introducing a top-level me block.
D10 Sort default status (Tab 1: OVERDUE→PENDING→COMPLETED; Tab 2: FAILED→NOT_SENT→SENT→ACCESSED, then isOverdue=true first within group), then lastName,firstName. ?sort=name\|entityType configurable. Hard-coded sort directions per option; the most operationally-urgent rows surface first.
D11 Pagination: standard ?page + ?limit (default 20, max 100, per the project's PaginationQueryDto). Server-side filters for status, entityType, firstName, lastName (+ Tab 2: email). No ?overdueOnly filter — FE filters by isOverdue field client-side within the page. Mirrors PaginationQueryDto / PaginationMetaDto.
D12 Implementation: per-entity Prisma queries in parallel, merged in service, sorted, paginated post-merge. Acceptable while tenants stay <1k people total. SQL UNION view deferred.

3.1 Deltas vs. the superseded Apr-28 spec

The Apr-28 spec covers Tab 1 only. This spec carries it forward with three changes and adds Tab 2:

Apr-28 decision New decision Why
URL GET /api/v1/users/completeness (D1) GET /api/v1/command-center/completeness (D2) Module relocation.
Admit admin/teacher/staff + platform bypass; reject student/referent (D6) Admit admin/referent + platform bypass; reject student/teacher/staff (D4) Referents have a real cross-entity self-service use case ("fill your son's data"). Teachers/Staff use /auth/profile for self-status; no operator-style cross-tenant browsing for v1.
Response wrapper { data: { items, meta }, me } (D2) Flat { data, meta } (D3) me block dropped — referent's own row appears naturally in data[] via scope-intersection.

All other Apr-28 decisions (D4 status taxonomy, D5 tenant-wide deadline, D7 scope-restricted missingFields, D8 sort, D9 pagination, D10 four-parallel-queries impl) carry forward unchanged.

4. Architecture

4.1 Module placement

src/command-center/
├── command-center.module.ts
├── completeness.controller.ts
├── completeness.service.ts
├── completeness.queries.ts
├── completeness.swagger.ts
├── onboarding.controller.ts
├── onboarding.service.ts
├── onboarding.queries.ts
├── onboarding.swagger.ts
├── shared/
│   ├── grace-period.helper.ts          # active-year lookup + status/overdue derivers
│   ├── visible-entities.ts             # resolveVisibleEntities(permissions, candidates)
│   └── pagination.helper.ts            # post-merge slice + meta build (if not already shared)
├── dto/
│   ├── completeness-item.dto.ts
│   ├── completeness-response.dto.ts
│   ├── completeness-query.dto.ts
│   ├── onboarding-item.dto.ts
│   ├── onboarding-response.dto.ts
│   └── onboarding-query.dto.ts
└── index.ts

CommandCenterModule imports PrismaModule, PermissionsModule, AcademicYearsModule, InvitationsModule, StudentsModule, TeachersModule, StaffModule, ReferentsModule (for query helpers and service contracts).

4.2 Permission and visibility flow

Both controllers use @RequireRoles(...) to gate admit; both load permissions via ensurePermissionsLoaded; both build a RecordAccessContext via the existing @AccessContext() decorator.

Tab 1: @RequireRoles('admin','referent')   // platform admin admitted via standard bypass
Tab 2: @RequireRoles('admin')              // platform admin admitted via standard bypass
   ↓ RolesGuard rejects unauthorized profiles
   ↓ EnsurePermissionsGuard populates request.permissions (CompiledPermissions)
   ↓ AccessContextDecorator builds RecordAccessContext
Controller(ctx, permissions, query)
Service.find(ctx, permissions, query)
   ├─ AcademicYearsService.requireActiveYear(ctx.tenantId) → { id, gracePeriodEnding }
   ├─ resolveVisibleEntities(permissions, isPlatformAdmin, candidates)
   ├─ for each visible entity, in parallel: queries.<entity>For<Tab>(...)
   ├─ build items via tab-specific builder
   ├─ apply post-build filters (Tab 1: status; Tab 2: isOverdue is read-only — no server filter)
   ├─ sort per query.sort
   ├─ paginate → slice [offset, offset+limit)
   └─ return { data, meta }

RecordAccessContext carries identity (tenantId, userId, roles, isPlatformAdmin); compiled permissions live on request.permissions.

4.3 Tab 1 — Completeness

Endpoint: GET /api/v1/command-center/completeness

Status derivation (identical to Apr-28 spec § 4.3):

function deriveCompletenessStatus(
  missingFields: string[],
  gracePeriodEnding: Date | null,
  now: Date,
): 'COMPLETED' | 'PENDING' | 'OVERDUE' {
  if (missingFields.length === 0) return 'COMPLETED';
  if (gracePeriodEnding === null) return 'PENDING';
  return now > gracePeriodEnding ? 'OVERDUE' : 'PENDING';
}

missingFields computation (identical to Apr-28 spec § 4.4): per-row, the union of computeMissingFields(...) across only the scopes the caller can read on that entity. Custom-field defs loaded once per request, scoped to non-Referent entities (Referent is excluded from CUSTOM_FIELD_ENTITY_KEYS).

Visibility per caller: - Admin: resolveVisibleEntities returns the entities they have any scope on (typically all four: STUDENTS, TEACHERS, STAFF, REFERENTS). - Referent: returns {REFERENTS, STUDENTS} (per current scope seed). The studentsForCompleteness query delegates to the existing studentsForAccessContext which filters Student rows by StudentReferentLink for referent callers — they see only linked children. The referentsForCompleteness query for a referent caller filters to userId = ctx.userId (self only) — no co-parent visibility for v1. - Platform admin: short-circuits to all four entities, all scopes treated as readable, no per-record filtering.

4.4 Tab 2 — Onboarding

Endpoint: GET /api/v1/command-center/onboarding

Status derivation:

function deriveOnboardingStatus(
  invitationStatus: UiInvitationStatus | null,  // null = no invitation row
): 'NOT_SENT' | 'SENT' | 'FAILED' | 'ACCESSED' {
  if (invitationStatus === null) return 'NOT_SENT';
  if (invitationStatus === 'COMPLAINED') return 'FAILED';  // D7 projection
  return invitationStatus;  // SENT | FAILED | ACCESSED pass through
}

Overdue derivation:

function deriveOnboardingOverdue(
  status: 'NOT_SENT' | 'SENT' | 'FAILED' | 'ACCESSED',
  gracePeriodEnding: Date | null,
  now: Date,
): boolean {
  if (status === 'ACCESSED') return false;
  if (gracePeriodEnding === null) return false;
  return now > gracePeriodEnding;
}

So a NOT_SENT Referent past the deadline reports {status: 'NOT_SENT', isOverdue: true} — strongest "needs admin attention" signal.

Email field source (per row): the entity's primary credential email — institutionalEmail for Teacher/Staff, email for Referent. (Invitation has no email column — credential delivery uses the role-row email at send time, see InvitationsService.listProjected and RECIPIENT_EMAIL_FIELD in src/invitations/invitations.queries.ts.)

Visibility: admin only ⇒ all three entity types unconditionally; platform-admin bypass equivalent. No resolveVisibleEntities call in the Tab 2 service (the helper is only useful when the caller's scopes vary — admin always has the operator scopes).

5. Components

5.1 Shared helpers (src/command-center/shared/)

grace-period.helper.ts

export async function getActiveYearGracePeriod(
  academicYearsService: AcademicYearsService,
  tenantId: string,
): Promise<{ activeYearId: string; gracePeriodEnding: Date | null }> {
  const year = await academicYearsService.requireActiveYear(tenantId);
  return { activeYearId: year.id, gracePeriodEnding: year.gracePeriodEnding };
}

export function deriveCompletenessStatus(...): 'COMPLETED'|'PENDING'|'OVERDUE' { ... }
export function deriveOnboardingOverdue(...): boolean { ... }

visible-entities.ts

export function resolveVisibleEntities(
  permissions: CompiledPermissions,
  isPlatformAdmin: boolean,
  candidates: readonly EntityKeyValue[],
): Set<EntityKeyValue> {
  if (isPlatformAdmin) return new Set(candidates);
  const result = new Set<EntityKeyValue>();
  for (const entity of candidates) {
    const scopes = permissions.scopes[entity] ?? {};
    const hasAnyScope = Object.values(scopes).some((a) => a !== ScopeAccess.NONE);
    if (hasAnyScope) result.add(entity);
  }
  return result;
}

computeMissingFieldsAcrossScopes lands in src/common/utils/compute-missing-fields.ts next to the existing per-scope function (used by both tabs in their builder; for Tab 2 only when the cross-scope rollup is needed for the FE-side correlation, which v1 doesn't need — kept here for the upcoming Tab-2-includes-completeness extension; for v1 only Tab 1 calls it).

5.2 DTOs

Tab 1

// completeness-item.dto.ts
export class CompletenessItemDto {
  @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[];

  @ApiProperty({ description: 'True iff this row belongs to the caller (referent rows only).' })
  isMe: boolean;
}

// completeness-response.dto.ts
export class CompletenessResponseDto extends AggregateResponseDto {
  @ApiProperty({ type: [CompletenessItemDto] })
  data: CompletenessItemDto[];

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

// completeness-query.dto.ts (extends PaginationQueryDto)
//   status?: ('COMPLETED'|'PENDING'|'OVERDUE')[]   (CSV ⇒ array)
//   entityType?: PersonProfileKey[]                 (CSV ⇒ array)
//   firstName?: string                              (ILIKE)
//   lastName?: string                               (ILIKE)
//   sort?: 'status' | 'name' | 'entityType' = 'status'

Tab 2

// onboarding-item.dto.ts
export class OnboardingItemDto {
  @ApiProperty({ enum: ['teacher', 'staff', 'referent'] })
  entityType: 'teacher' | 'staff' | 'referent';

  @ApiProperty({ format: 'uuid' }) id: string;
  @ApiProperty({ nullable: true }) firstName: string | null;
  @ApiProperty({ nullable: true }) lastName: string | null;
  @ApiProperty() email: string;

  @ApiProperty({ enum: ['NOT_SENT', 'SENT', 'FAILED', 'ACCESSED'] })
  status: 'NOT_SENT' | 'SENT' | 'FAILED' | 'ACCESSED';

  @ApiProperty() isOverdue: boolean;

  @ApiProperty({ nullable: true }) lastSendAt: Date | null;
  @ApiProperty({ nullable: true }) acceptedAt: Date | null;
}

// onboarding-response.dto.ts — extends AggregateResponseDto, exposes { data, meta }
// onboarding-query.dto.ts (extends PaginationQueryDto)
//   status?: ('NOT_SENT'|'SENT'|'FAILED'|'ACCESSED')[]
//   entityType?: ('teacher'|'staff'|'referent')[]
//   firstName?: string  lastName?: string  email?: string
//   sort?: 'status' | 'name' | 'entityType' = 'status'

5.3 Controllers

// completeness.controller.ts
@ApiTags('command-center')
@Controller('command-center')
@UseGuards(ThrottlerGuard, JwtAuthGuard)
export class CompletenessController {
  constructor(
    private readonly service: CompletenessService,
    private readonly permissionsService: PermissionsService,
  ) {}

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

// onboarding.controller.ts
@ApiTags('command-center')
@Controller('command-center')
@UseGuards(ThrottlerGuard, JwtAuthGuard)
export class OnboardingController {
  constructor(private readonly service: OnboardingService) {}

  @Get('onboarding')
  @RequireRoles('admin')
  @ApiOnboarding()
  async findOnboarding(
    @AccessContext() ctx: RecordAccessContext,
    @Query() query: OnboardingQueryDto,
  ): Promise<OnboardingResponseDto> {
    return this.service.findOnboarding(ctx, query);
  }
}

5.4 Services

CompletenessService - findCompleteness(ctx, permissions, query): Promise<CompletenessResponseDto> — orchestrates per-entity queries, builds items, status, sort, paginate. - Internal helpers: resolveVisibleEntities, fetchRowsForEntity, buildItem (uses computeMissingFields per scope, restricted to caller's readable scopes), loadCustomFieldDefs. - Mirrors Apr-28 spec § 5.3 exactly minus the me-row resolution path.

OnboardingService - findOnboarding(ctx, query): Promise<OnboardingResponseDto> — admin-only path; visibility is unconditional across the three entity types. - Internal helpers: fetchInvitationProjections(tenantId, activeYearId, nameFilters, emailFilter) — joins Teacher/Staff/Referent to latest Invitation per recipient; buildItem derives status (with COMPLAINED → FAILED projection), isOverdue, email (Invitation.email or fallback), and identity. - Delegates to InvitationsService where possible for projection logic; new query helpers in onboarding.queries.ts only where the existing listRoleRows doesn't satisfy the join/filter shape.

5.5 Queries

completeness.queries.ts — same shape as Apr-28 spec § 5.4: - studentsForCompleteness(prisma, ctx, activeYearId, nameFilters) — wraps studentsForAccessContext. - teachersForCompleteness(prisma, tenantId, activeYearId, nameFilters) - staffForCompleteness(prisma, tenantId, activeYearId, nameFilters) - referentsForCompleteness(prisma, ctx, nameFilters) — for referent callers, filters userId = ctx.userId.

onboarding.queries.ts - teachersWithInvitation(prisma, tenantId, activeYearId, filters) — selects Teacher rows joined to latest Invitation per recipientId. Filters on name/email applied at SQL. - staffWithInvitation(prisma, tenantId, activeYearId, filters) — same shape. - referentsWithInvitation(prisma, tenantId, filters) — not year-scoped (Referent is global per docs/01-architecture.md §6).

Each query selects: id, firstName, lastName, userId, the credential email field, plus the joined Invitation.{ status, email, lastSendAt, acceptedAt } (nullable when no row).

6. Data flow

6.1 Tab 1 — admin caller, all four entities visible

Request:

GET /api/v1/command-center/completeness?status=OVERDUE,PENDING&entityType=student,referent&page=1&limit=25
Cookie: access_token=...

Response (200):

{
  "data": [
    {
      "entityType": "student", "id": "8c2d…",
      "firstName": "Alice", "lastName": "Smith",
      "status": "OVERDUE",
      "missingFields": ["dateOfBirth", "homeAddress"],
      "isMe": false
    },
    {
      "entityType": "referent", "id": "1f4e…",
      "firstName": null, "lastName": "Smith",
      "status": "PENDING",
      "missingFields": ["firstName", "homePhone"],
      "isMe": false
    }
  ],
  "meta": { "page": 1, "limit": 25, "total": 2, "totalPages": 1 }
}

6.2 Tab 1 — referent caller, sees self + linked students

Request: GET /api/v1/command-center/completeness?page=1&limit=25 (no filters; referent expects to see all of "their" people)

Response: items contain the referent's own row (isMe: true) plus each linked student's row (isMe: false), all with missingFields populated per scope-restriction. The FE renders the isMe: true row with "your data" treatment and others with "your child's data" treatment.

6.3 Tab 2 — admin

Request:

GET /api/v1/command-center/onboarding?status=NOT_SENT,FAILED&entityType=teacher,referent&page=1&limit=50

Response:

{
  "data": [
    {
      "entityType": "teacher", "id": "9a1b…",
      "firstName": "Jane", "lastName": "Doe",
      "email": "jane.doe@school.example",
      "status": "FAILED", "isOverdue": true,
      "lastSendAt": "2026-04-15T08:30:00.000Z",
      "acceptedAt": null
    },
    {
      "entityType": "referent", "id": "2c3d…",
      "firstName": null, "lastName": null,
      "email": "parent@example.com",
      "status": "NOT_SENT", "isOverdue": true,
      "lastSendAt": null, "acceptedAt": null
    }
  ],
  "meta": { "page": 1, "limit": 50, "total": 2, "totalPages": 1 }
}

6.4 Empty results

If no rows match, data: [], meta.total: 0, meta.totalPages: 0. Endpoint always returns 200 (an empty admin tenant is a valid state).

6.5 Caller is platform admin (Tab 1)

Platform admin (isPlatformAdmin = true, activeProfile = 'platform') admitted via RolesGuard bypass. Sees all rows across all four entity types — no scope intersection, no per-record filtering. Still respects tenantId from RecordAccessContext (the tenant they're impersonating).

7. Error handling

Condition HTTP ErrorCode
No active academic year for tenant 409 NO_ACTIVE_ACADEMIC_YEAR (matches the curriculum module's existing convention)
Invalid status / entityType / sort value 400 VALIDATION_FAILED
page < 1 or limit > 100 400 VALIDATION_FAILED
Caller has no RequireRoles match 403 INSUFFICIENT_SCOPE
No auth cookie 401 (handled by JwtAuthGuard)
Caller has no readable entities (Tab 1) 200 data: []

The "no active year" path is the only domain-level error. Bootstrap-year tenants (placeholder DRAFT) raise this until the setup wizard creates a real ACTIVE year — the Command Center is therefore unusable until then, which matches product expectations.

8. Testing

8.1 Unit — completeness.service.spec.ts

  • Admin in tenant with 0 students → data=[], meta.total=0.
  • Admin with mixed-status rows + filter status=OVERDUE → only OVERDUE rows.
  • Referent with 2 linked students → 3 items (self + 2 students); no Teachers/Staff/other Referents.
  • Referent with gracePeriodEnding in the past, missing fields on one student → that student is OVERDUE.
  • gracePeriodEnding = null → no OVERDUE rows; incomplete rows are PENDING.
  • Caller with only students.anagraphic scope → missingFields excludes any field belonging to other scopes.
  • Referent caller → their own Referent row has isMe: true; all linked-student rows have isMe: false.
  • Admin caller → no row has isMe: true (admins are not person-entity rows).
  • deriveCompletenessStatus truth table.
  • Sort name returns rows by lastName,firstName ASC across entityTypes.
  • Pagination — page=2, limit=10 over 25 items returns items 11–20.

8.2 Unit — onboarding.service.spec.ts

  • Admin in tenant with 0 invitations + 0 entities → data=[].
  • Mixed lifecycle (1 NOT_SENT, 1 SENT, 1 ACCESSED, 1 FAILED, 1 COMPLAINED) → 5 items, COMPLAINED → FAILED projection asserted on the final item.
  • isOverdue truth table: NOT_SENT past deadline → true; SENT past deadline → true; ACCESSED past deadline → false; deadline null → false for any status; pre-deadline → false.
  • Filter status=FAILED returns FAILED + projected COMPLAINED rows.
  • Email fallback — Teacher with no Invitation: email = institutionalEmail; Referent with no Invitation: email = email.
  • Sort status orders FAILED → NOT_SENT → SENT → ACCESSED.
  • Pagination as Tab 1.

8.3 E2E — test/command-center.e2e-spec.ts

  • GET /command-center/completeness as admin → 200 with non-empty items.
  • as referent linked to 2 students → 200, items = self + 2 linked students.
  • as teacher → 403 INSUFFICIENT_SCOPE.
  • as staff → 403.
  • as student → 403.
  • without auth → 401.
  • ?entityType=student as admin → only student items.
  • ?firstName=ali (case-insensitive contains) → only matching rows; null firstNames excluded.
  • ?status=OVERDUE after rolling tenant gracePeriodEnding to a past date → all incomplete rows now OVERDUE.
  • GET /command-center/onboarding as admin → 200 with non-empty items including status and isOverdue.
  • as referent → 403.
  • as teacher / staff → 403.
  • After flipping a Referent's Invitation.status = 'COMPLAINED' → that row reports status: 'FAILED' from the endpoint.
  • Custom field def with isRequired=true on students.anagraphic and a row with empty customFields[fieldKey] → field key appears in missingFields for Tab 1.

9. Open questions / future work

  • Tab 2 cross-tenant or per-Department filters. US-31 S1 calls for Department / class / Grade filters; deferred. Add to OnboardingQueryDto when product re-asks.
  • Tab 2 missingFields join. A future iteration could add missingFields[] to Onboarding rows so the FE doesn't need to call Tab 1 to know "this ACCESSED Teacher still has incomplete profile." Out of scope for v1.
  • Per-entity grace-period deadlines. US-23 S2 (rolling individual deadlines for late-created rows) requires per-record schema columns; both tabs would consume them transparently when added.
  • Guardian rows in Tab 1. When GUARDIAN_COMPLETION_REQUIRED is added to the registry, resolveVisibleEntities will surface GUARDIANS for referents; query helper guardiansForCompleteness lands then.
  • Tab 3 — Study plan collection. Deferred entirely; product is still discussing US-32 design. When unblocked, a third controller selection-status.controller.ts lands in src/command-center/.
  • Caching. Both tabs compute per request. If list size grows past ~1k people, cache missingFields keyed by (tenantId, entityType, rowId, updatedAt). Not needed yet.
  • Reminder-only bulk action. US-31 S8's "Send reminder email" bulk action is a future endpoint outside Command Center.
  • Non-active-user surfacing. US-31 S9 (frozen status, distinct UI marker, exclusion from counts) — when PeopleStatus.ON_LEAVE / PeopleStatus.ARCHIVED (the enum was {ACTIVE, SUSPENDED} at the time of writing; SUSPENDED was renamed to ON_LEAVE and ARCHIVED added on 2026-06-22) becomes operationally relevant, both tabs add a peopleStatus field and exclusion semantics. Note ARCHIVED people already have their app access revoked (see docs/superpowers/specs/2026-06-22-people-status-archive-and-contract-validation-design.md).
  • Summary aggregations. US-31 S5 counts can land as a sibling endpoint GET /command-center/summary returning per-status / per-entity counts. Out of scope here.

10. Dependencies

  • Missing-fields scaffold (2026-04-27-missing-fields-design.md): COMPLETION_REQUIRED_REGISTRY, computeMissingFields. Shipped.
  • Active-profile flow (2026-04-27-active-profile-design.md): used by RecordAccessContext for record-level filtering. Shipped.
  • AcademicYear.gracePeriodEnding: column exists; default = first TERM startDate − 14d. Set during academic-year creation.
  • Invitations primitives: Invitation model, UiInvitationStatus, listRoleRows query helper, projection mechanics — all shipped.
  • StudentReferentLink + studentsForAccessContext: shipped; powers referent-side Tab 1 visibility.
  • Bootstrap year: tenants in fresh-setup state have a DRAFT placeholder year. requireActiveYear returns null → 400 NO_ACTIVE_ACADEMIC_YEAR. Both tabs unusable until the setup wizard creates a real ACTIVE year — matches product expectations.

11. Glossary

  • Command Center: the admin dashboard surface, two tabs (User Completeness, Onboarding), anchored on the active year's gracePeriodEnding. The frontend may surface a third "System Setup" tile that consumes GET /configure/setup/overview directly — no backend addition.
  • Profile completeness: the share of COMPLETION_REQUIRED_REGISTRY rules satisfied for a row. Tab 1 exposes the gap (missingFields), not the share.
  • Onboarding lifecycle: the credential-issuance flow (NOT_SENT → SENT → ACCESSED, with FAILED as a terminal-ish error state recoverable via resend).
  • Active year: tenant's currently-ACTIVE AcademicYear. Bootstrap (DRAFT) years are excluded.
  • Grace period: window before the active year's gracePeriodEnding during which incomplete profiles or non-accessed accounts are tolerated. After the deadline, Tab 1 flips them to OVERDUE (3-state status) and Tab 2 sets isOverdue = true (boolean overlay).