System Features API Reference
Complete API contracts for system-features, including routes, auth, DTOs, responses, errors, examples, and integration notes.
System Features - API Reference
Audience: Admin panel engineers, mobile/portal engineers, QA, and any API consumer that needs to shape navigation around installation-level switches.
Scope: One route — GET /api/system/features — that tells any signed-in caller which installation-level features are switched on for this school, and reveals accounting-integration setup detail only to the roles allowed to see it.
1. Documentation Evidence
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Controller | apps/api/src/modules/system-features/system-features.controller.ts | Route, guards, decorators, Swagger metadata. |
| DTO | apps/api/src/modules/system-features/dto/system-features.dto.ts | Response shape, optional fields, descriptions. |
| Service | apps/api/src/modules/system-features/system-features.service.ts | Behavior, permission check, DB reads, response assembly. |
| Module | apps/api/src/modules/system-features/system-features.module.ts | Imports, controller/provider wiring. |
| Feature flag | apps/api/src/common/feature-flags/accounting-integration.ts | ACCOUNTING_INTEGRATION_ENABLED semantics, isAccountingIntegrationEnabled(). |
| Schema | packages/db/src/schema (integrationClients, integrationClientKeys, imported via @skoolsewa/db) | Columns read by clientConnected()/hasClients(): status, expiresAt, join on clientId. |
| Guard allowlist | apps/api/src/common/authorization/role.guard.ts:745-754 (NO_PERMISSION_ADMIN_HANDLERS) | Why the route has no @Permissions() yet still passes RoleGuard. |
| Structure mirror | apps/api/test/structure/route-permissions.spec.ts:127-131 | The CI mirror of the same allowlist entry. |
| Route ground truth | apps/api/test/structure/structure.baseline.json (controllerPath: "system/features", GET /api/system/features) | The route exists exactly once, under the admin document, not under /api/mobile. |
| Swagger registration | apps/api/src/config/swagger/swagger-documents.ts:70,1344 | SystemFeaturesModule is a leaf in ADMIN_MODULES, so the route is documented in /api/api-docs. |
| Error registry | apps/api/src/common/types/error-codes.ts:1966 | ACCOUNTING_INTEGRATION_DISABLED exists in the registry but is never thrown by this route — see Error Cases. |
| Permission catalog | packages/db/src/authorization/permission-catalog.ts:612,660,682 | FeeSync, SalarySync, AssetSync exist as permission modules (their _READ codes gate the setup fields, alongside FeeMappings_READ and FeeIntegrations_READ). |
| Tests | None — no *.spec.ts exists under apps/api/src/modules/system-features/. |
2. Module Summary
| Field | Value |
|---|---|
| Module name | system-features |
| Module slug | system-features |
| Primary actors | Any authenticated caller (guest/unauthenticated is refused by the global JwtAuthGuard); the response shape then further depends on whether the caller's active role holds an accounting permission |
| API surfaces | admin document (one route; not mobile-prefixed) |
| Base route prefix | /api/system/features |
| Auth model | JwtAuthGuard + RoleGuard, no @Permissions() — allowlisted in NO_PERMISSION_ADMIN_HANDLERS |
| Persistence | PostgreSQL only, read-only (integration_clients, integration_client_keys) |
| Runtime source of truth | ACCOUNTING_INTEGRATION_ENABLED env var for enabled; IntegrationKeyringService.configured for configured; integration_clients/integration_client_keys rows for clientConnected/hasClients |
| Sibling docs | Backend, Features and flows |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
ACCOUNTING_INTEGRATION_ENABLED | The installation-level switch for whether this school uses the separate accounting application. A product decision, not a secret. | common/feature-flags/accounting-integration.ts | enabled |
| Configured | Whether the server holds the integration master keys (INTEGRATION_SECRET_KEYS) — a technical state distinct from the switch. | SystemFeaturesService.get, IntegrationKeyringService | configured |
| Client connected | An active integration client exists holding at least one active, unexpired key. Always false while enabled is false. | SystemFeaturesService.clientConnected | clientConnected |
| Has clients | Any integration client that is not revoked exists. Computed even while enabled is false, so an existing connection stays findable for revocation. | SystemFeaturesService.hasClients | hasClients |
| Accounting permissions | The five permission codes whose holders may see the setup detail: FeeSync_READ, FeeMappings_READ, FeeIntegrations_READ, SalarySync_READ, AssetSync_READ. | ACCOUNTING_PERMISSIONS in system-features.service.ts | accountingAccess() (setup) |
4. API Surface Map
| Surface | Method | Path | Actor | Auth/Guard | Permission | Controller | Purpose |
|---|---|---|---|---|---|---|---|
| Admin (installation-level, all audiences) | GET | /api/system/features | Any signed-in user | JwtAuthGuard, RoleGuard | None — allowlisted, response shape gated internally | SystemFeaturesController | Report which installation features are switched on, for the panel to shape its navigation. |
This is the only route the module declares. SystemFeaturesModule is a leaf (its own entry in ADMIN_MODULES), not composed under any aggregate.
5. Auth, Identity, and Permissions
| Surface | Guard/Decorator | Identity Shape | Permission | Guest Allowed | Notes |
|---|---|---|---|---|---|
GET /api/system/features | JwtAuthGuard, RoleGuard, no @Permissions() | @CurrentUser() actor: AuthUser | undefined | None (allowlisted) | No — global JwtAuthGuard requires a valid session | A session that has not yet chosen an active role still reaches the handler and gets enabled only; there is no active-role-required refusal on this route. |
RoleGuard returns true for this handler before it reads request.user, because SystemFeaturesController.get is a key in NO_PERMISSION_ADMIN_HANDLERS (role.guard.ts:745). Every audience's navigation depends on this route, and the answer is booleans only — no row data, no ids — which is why it is deliberately left unpermissioned rather than gated behind any one module's read code.
The controller uses @CurrentUser, not @CurrentAdmin, specifically so a caller with no active role yet (mid-onboarding, or a multi-role account that has not selected one) still gets an answer instead of a hard failure; they just get no accounting setup detail, because accountingAccess() returns { setup: false, writeBackHint: false } when actor?.activeRole is absent.
6. DTO and Model Reference
6.1 SystemFeaturesDto (response)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
accountingIntegration | AccountingIntegrationFeatureDto | Yes | N/A | @ApiProperty({ type: AccountingIntegrationFeatureDto }) | see below | dto/system-features.dto.ts:29-32 |
6.2 AccountingIntegrationFeatureDto (nested)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
enabled | boolean | Yes | N/A | Always present, for every caller | true | dto/system-features.dto.ts:4-8 |
configured | boolean | No | omitted | @ApiPropertyOptional(); present only when the caller holds an accounting permission | true | dto/system-features.dto.ts:10-14 |
clientConnected | boolean | No | omitted | Same visibility rule; always false while enabled is false | false | dto/system-features.dto.ts:16-20 |
hasClients | boolean | No | omitted | Same visibility rule; computed regardless of enabled | true | dto/system-features.dto.ts:22-26 |
paymentWriteBack | boolean | No | omitted | Present for a role holding one of the accounting permissions or FeePayments_CREATE, and for a superadmin. false while enabled is false or while INTEGRATION_SECRET_KEYS is unset. Otherwise true when an active integration client holding an active, unexpired key has the payments:write scope — only the client named by ACCOUNTING_INTEGRATION_CLIENT_ID when that variable is set | true | dto/system-features.dto.ts |
paymentWriteBack answers one question: do payments recorded in the accounting application reach this school's fee records? It reads the payments:write scope itself, not merely the existence of a client, because an active client can hold read-only scopes. The office staff who record payments (FeePayments_CREATE) receive { enabled, paymentWriteBack } and none of the setup fields.
configured, clientConnected and hasClients are omitted from the response body, not sent as null or false — the service only assigns them onto accountingIntegration inside the accountingAccess().setup branch of SystemFeaturesService.get, and ResponseDto serializes whatever object it is given.
Response envelope: ResponseDto<SystemFeaturesDto> — message, data, with count/currentPage/totalPage/nextCursor/errorCode all present in the Swagger schema but unused by this route (common/dto/response-dto.ts).
7. Enum Reference
None. This module declares no enums; every field is a plain boolean.
8. Endpoint Reference
8.1 GET /api/system/features
Purpose
Call this once per session (or whenever navigation is rebuilt) to learn which installation-level features this school has switched on, so the panel can show or hide the accounting-integration screens and, for the roles allowed to configure it, its setup state. Safe to call before an active role is chosen — it degrades gracefully to enabled only.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/system-features/system-features.controller.ts |
| DTO | apps/api/src/modules/system-features/dto/system-features.dto.ts |
| Service | apps/api/src/modules/system-features/system-features.service.ts |
| Schema | packages/db — integrationClients, integrationClientKeys |
| Tests | N/A — no spec file exists for this module |
Auth and Permissions
- Auth: JWT required (global
JwtAuthGuard). - Guard chain:
JwtAuthGuard,RoleGuard. - Permission: none — allowlisted in
NO_PERMISSION_ADMIN_HANDLERS. - Guest support: none; an unauthenticated request is refused before the handler runs.
- Rate limit: none declared on this controller.
- Idempotency: N/A — pure read,
Cache-Control: no-storeon every response.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <token> |
| Params | No | None |
| Query | No | None |
| Body | No | None |
GET /api/system/features HTTP/1.1
Authorization: Bearer TOKENResponse
200 OK, always Cache-Control: no-store (@Header("Cache-Control", "no-store") on the handler — the answer must never be served stale from an intermediary, since it gates navigation and setup screens).
Caller without an accounting permission (or with no active role yet):
{
"message": "Features fetched.",
"data": {
"accountingIntegration": {
"enabled": true
}
}
}Caller whose active role holds one of the five accounting permissions:
{
"message": "Features fetched.",
"data": {
"accountingIntegration": {
"enabled": true,
"configured": true,
"clientConnected": false,
"hasClients": true
}
}
}Installation with the integration switched off entirely, for a caller who holds an accounting permission:
{
"message": "Features fetched.",
"data": {
"accountingIntegration": {
"enabled": false,
"configured": false,
"clientConnected": false,
"hasClients": true
}
}
}clientConnected is forced to false whenever enabled is false (SystemFeaturesService.get) — it is never computed against the database in that state. hasClients is still computed, because a client from before the switch was turned off must stay visible for revocation.
Side Effects
None. This route performs no writes, no cache writes, no queue enqueues, no realtime events, and no audit log entry — it is a plain GET with a read-only service, and it is not a mutating request, so ActivityAuditInterceptor does not record it.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
401 | N/A (global) | No valid session | Log in again. | JwtAuthGuard (global) |
No route-specific error code exists for this endpoint. ACCOUNTING_INTEGRATION_DISABLED is registered in error-codes.ts and thrown elsewhere in the codebase when a caller tries to use the accounting integration while it is switched off — this route never throws it, because reporting enabled: false is itself the successful response, not a refusal.
Edge Cases
- No active role selected yet:
actor?.activeRoleis undefined,accountingAccess()short-circuits to{ setup: false, writeBackHint: false }, response carriesenabledonly. - Superadmin caller:
accountingAccess()returns{ setup: true, writeBackHint: true }immediately forrole.isSuperadmin, without a permission lookup. - Non-superadmin caller holding none of the five accounting permissions: response carries
enabledonly, same shape as "no active role". - Integration switched off (
enabled: false):clientConnectedis hard-codedfalsewithout a database read;hasClientsstill queries the database. - No integration client rows exist at all: both
clientConnectedandhasClientsresolve tofalse(empty result from each query). - An integration client exists but every key on it is expired or inactive:
clientConnectedisfalse(the join requiresstatus: "active"on the key andexpiresAt > now());hasClientscan still betrueif the client itself is notrevoked. - Concurrent requests: no shared mutable state; every call independently re-reads config and the database, so there is no staleness beyond normal read-committed behavior.
Example Requests
GET /api/system/features HTTP/1.1
Authorization: Bearer TOKENcurl -X GET "$API_URL/api/system/features" \
-H "Authorization: Bearer TOKEN"9. Flow Diagrams
9.1 Route Ownership
9.2 Request Sequence
9.3 Error Branch
10. Pagination, Sorting, Filtering, and Search
Not applicable. This is a single-object read with no list, no pagination, no sort or filter parameters.
11. Caching, Jobs, and External Integrations
| Integration | Used? | Details | Source |
|---|---|---|---|
| Redis cache | No | Response is marked Cache-Control: no-store; nothing is cached server-side or client-side. | system-features.controller.ts:30 |
| BullMQ | No | — | — |
| External API | No | — | — |
12. Consumer Integration Notes
| Consumer | Required Knowledge | Failure Handling | Contract Stability |
|---|---|---|---|
| Admin panel | Call this to decide whether to render accounting-integration navigation and, for permitted roles, its setup state; treat a missing configured/clientConnected/hasClients as "not visible to this role", not as false. | A 401 means the session expired; re-authenticate and retry. | Stable |
| Mobile/portal surfaces | Same booleans apply to every audience; no audience-specific shape. | Same. | Stable |
| QA | Verify the omission behavior directly — the setup fields must be absent from the JSON body for a role with none of the five accounting permissions, not null. | — | Stable |
13. API Tradeoffs and Rationale
| Decision | Chosen Behavior | Alternatives Considered | Why This Tradeoff | Risk | Mitigation |
|---|---|---|---|---|---|
No @Permissions() on the route | Every signed-in caller gets enabled; setup detail gated inside the service instead of at the guard | A dedicated SystemFeatures_READ permission | Every audience's navigation needs enabled, including roles that will never hold an accounting permission; gating the whole route would 403 most callers for a boolean | A future field added to the DTO might leak more than intended if the service-level gate is forgotten | The gate lives in one place (accountingAccess(), returning { setup, writeBackHint }) and is reused for every optional field |
clientConnected forced false while disabled | No database read for that field when enabled is false | Always compute it | Avoids a meaningless read/answer when the integration cannot be connected anyway | None material | — |
hasClients computed regardless of enabled | Existing clients remain discoverable for revocation even after the switch is turned off | Omit it while disabled, matching clientConnected | An operator must still be able to see and revoke a stale client after disabling the integration | None material | — |
14. API Change Impact
| Change | Affected Consumers | Backend Impact | Data Impact | Migration Needed? | Compatibility Plan |
|---|---|---|---|---|---|
Adding a new feature key alongside accountingIntegration | Admin panel, portal navigation | New field on SystemFeaturesDto | None | No | Additive; existing consumers ignore unknown fields |
| Widening the accounting-permission list that unlocks setup detail | Admin panel (accounting screens) | ACCOUNTING_PERMISSIONS in the service | None | No | Additive |
15. Zero-Omission API Checklist
- The one controller route is documented.
- The route prefix and runtime URL are documented.
- Every DTO field, nested field, default, and visibility rule is documented.
- Every response field and its omission behavior is documented.
- Auth, guard chain, and the no-permission allowlist entry are documented.
- Success and auth-failure branches are documented; no route-specific error code exists.
- The module performs no DB writes, no cache, no queue, no realtime, no audit — stated explicitly.
- The route has minimal and full-detail response examples for both visibility states.
- Route, sequence, and error diagrams are included.
- Tradeoffs are documented.
- Links to backend and features/flows docs are included.
16. Integration Checklist
- The route from the controller is documented.
- Every DTO field is documented.
- No enums exist in this module.
- The response envelope is documented.
- No module-specific error code exists; this is stated rather than invented.
- Auth guard and the no-permission allowlist are documented.
- No cache key, queue job, realtime event, or external call exists; this is stated rather than invented.
- Diagrams match the current code.
- Links to backend and features/flows docs are included.
See Also
- Backend doc: /docs/developer/system-features/backend
- Features and flows doc: /docs/developer/system-features/feature