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
meblock. Teachers and staff useGET /auth/profilefor self-status (per-scopemissingFieldsalready ships there viaFieldFilterInterceptor). 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_REQUIREDis 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
gracePeriodEndingonly. - No summary aggregations. Counts per stage / per status (US-31 S5) are deferred. Frontend computes display rollups from
meta.totaland 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/sendand/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/overviewdirectly; no new/system-setup/modulesendpoint.
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
gracePeriodEndingin the past, missing fields on one student → that student is OVERDUE. gracePeriodEnding = null→ no OVERDUE rows; incomplete rows are PENDING.- Caller with only
students.anagraphicscope →missingFieldsexcludes any field belonging to other scopes. - Referent caller → their own Referent row has
isMe: true; all linked-student rows haveisMe: false. - Admin caller → no row has
isMe: true(admins are not person-entity rows). deriveCompletenessStatustruth table.- Sort
namereturns rows bylastName,firstNameASC 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 → FAILEDprojection asserted on the final item. isOverduetruth table: NOT_SENT past deadline →true; SENT past deadline →true; ACCESSED past deadline →false; deadline null →falsefor any status; pre-deadline →false.- Filter
status=FAILEDreturns FAILED + projected COMPLAINED rows. - Email fallback — Teacher with no Invitation:
email = institutionalEmail; Referent with no Invitation:email = email. - Sort
statusorders FAILED → NOT_SENT → SENT → ACCESSED. - Pagination as Tab 1.
8.3 E2E — test/command-center.e2e-spec.ts¶
GET /command-center/completenessas 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=studentas admin → only student items.?firstName=ali(case-insensitive contains) → only matching rows; null firstNames excluded.?status=OVERDUEafter rolling tenantgracePeriodEndingto a past date → all incomplete rows now OVERDUE.GET /command-center/onboardingas admin → 200 with non-empty items includingstatusandisOverdue.- as referent → 403.
- as teacher / staff → 403.
- After flipping a Referent's
Invitation.status = 'COMPLAINED'→ that row reportsstatus: 'FAILED'from the endpoint. - Custom field def with
isRequired=trueonstudents.anagraphicand a row with emptycustomFields[fieldKey]→ field key appears inmissingFieldsfor 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
OnboardingQueryDtowhen product re-asks. - Tab 2
missingFieldsjoin. A future iteration could addmissingFields[]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_REQUIREDis added to the registry,resolveVisibleEntitieswill surfaceGUARDIANSfor referents; query helperguardiansForCompletenesslands then. - Tab 3 — Study plan collection. Deferred entirely; product is still discussing US-32 design. When unblocked, a third controller
selection-status.controller.tslands insrc/command-center/. - Caching. Both tabs compute per request. If list size grows past ~1k people, cache
missingFieldskeyed 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;SUSPENDEDwas renamed toON_LEAVEandARCHIVEDadded on 2026-06-22) becomes operationally relevant, both tabs add apeopleStatusfield and exclusion semantics. Note ARCHIVED people already have their app access revoked (seedocs/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/summaryreturning 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
RecordAccessContextfor record-level filtering. Shipped. AcademicYear.gracePeriodEnding: column exists; default = first TERMstartDate − 14d. Set during academic-year creation.- Invitations primitives:
Invitationmodel,UiInvitationStatus,listRoleRowsquery 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.
requireActiveYearreturns null → 400NO_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 consumesGET /configure/setup/overviewdirectly — no backend addition. - Profile completeness: the share of
COMPLETION_REQUIRED_REGISTRYrules satisfied for a row. Tab 1 exposes the gap (missingFields), not the share. - Onboarding lifecycle: the credential-issuance flow (
NOT_SENT → SENT → ACCESSED, withFAILEDas a terminal-ish error state recoverable via resend). - Active year: tenant's currently-
ACTIVEAcademicYear. Bootstrap (DRAFT) years are excluded. - Grace period: window before the active year's
gracePeriodEndingduring which incomplete profiles or non-accessed accounts are tolerated. After the deadline, Tab 1 flips them toOVERDUE(3-state status) and Tab 2 setsisOverdue = true(boolean overlay).