Profile Personalization Internals
Data model, cache architecture, tile fetchers, and extension points for the personalization subsystem
Overview
Engineering-level reference for the profile-scoped personalization feature. For admin configuration, see the Profile Personalization admin guide. This doc is for engineers extending or integrating with the subsystem.
Three independently-useful features on a shared foundation:
┌───────────────────────────────┬──────────────────────┬────────────────────┐
│ Feature │ DB model │ Lives at │
├───────────────────────────────┼──────────────────────┼────────────────────┤
│ Profile home pages │ ProfileHomePage │ /home → dashboard / │
│ │ │ report / entity │
├───────────────────────────────┼──────────────────────┼────────────────────┤
│ Object default views │ ProfileObjectDefault │ /o/[apiName] picks │
│ │ │ initial list view │
├───────────────────────────────┼──────────────────────┼────────────────────┤
│ Insight strips │ InsightStrip │ Strip above grid │
│ │ │ on /o/[apiName] │
└───────────────────────────────┴──────────────────────┴────────────────────┘Subject polymorphism
Every feature targets a subject stored as a (subjectType, subjectValue) pair:
enum AssignmentSubjectType {
USER // subjectValue = userId
ROLE_DEF // subjectValue = custom RoleDefinition id
ROLE // subjectValue = enum role (TEACHER, PRINCIPAL, ...)
PROFILE_TYPE // subjectValue = profile type (PROFILE_STAFF, ...)
}Resolvers use a priority chain to pick the most specific match for a user:
URL query param > per-user override > custom role definition >
profile type > built-in role > tenant / global defaultEach feature implements the chain in its own resolver:
src/lib/profile-home-verify.ts— home page resolversrc/lib/list-view-resolver.ts— default list view resolversrc/app/(dashboard)/o/[apiName]/page.tsx— strip lookup viaProfileObjectDefault.insightStripId
Insight strips don't have their own resolver — they share ProfileObjectDefault with default list views (one row per subject × entity, two fields).
Tenant-scope invariant
Every cache key includes institutionId. Every server action calls requireInstitutionId() before reading or writing. Every prisma.findFirst for cross-tenant-danger data includes {institutionId} in the where. A cache key without tenant scope is a multi-tenancy isolation failure.
Data model
ProfileHomePage
model ProfileHomePage {
id String @id @default(cuid())
institutionId String
subjectType AssignmentSubjectType
subjectValue String
pageType HomePageType // DASHBOARD | REPORT | LIST_VIEW
pageTargetId String // id of the dashboard / report / list view
createdAt DateTime
updatedAt DateTime
@@unique([institutionId, subjectType, subjectValue])
@@index([institutionId, subjectType])
}ProfileObjectDefault
model ProfileObjectDefault {
id String @id @default(cuid())
institutionId String
subjectType AssignmentSubjectType
subjectValue String
entityApiName String
defaultListViewId String? // null = no default for this entity
insightStripId String? // null = no strip for this entity
createdAt DateTime
updatedAt DateTime
listView ListView? @relation(fields: [defaultListViewId], references: [id])
insightStrip InsightStrip? @relation(fields: [insightStripId], references: [id])
@@unique([institutionId, subjectType, subjectValue, entityApiName])
}One row per (subject × entity). Carries both default list view and strip binding. If both fields end null, the row is auto-deleted.
InsightStrip
model InsightStrip {
id String @id @default(cuid())
institutionId String
entityApiName String
name String
tiles Json // validated at read time via parseTilesJson
isActive Boolean @default(true)
createdAt DateTime
updatedAt DateTime
@@index([institutionId, entityApiName])
}Tiles are stored as a JSON blob. Validated runtime via Zod (src/types/insight-strip.ts). Corrupt tiles are skipped with a WARN log — the rest of the strip renders (graceful degradation).
Tile contract
Every tile has a discriminated type:
interface Tile {
id: string;
type: "METRIC" | "CHART" | "LIST_PREVIEW";
title: string;
sourceType: "REPORT" | "QUERY"; // QUERY is schema-supported, UI-deferred
sourceId: string; // references CustomReport.id (or future QueryDefinition)
aggregation?: "COUNT" | "SUM" | "AVG" | "PERCENT"; // METRIC only
fieldApiName?: string; // METRIC (non-COUNT) + CHART
filter?: Record<string, unknown>; // equality-only at launch
width: "sm" | "md" | "lg";
colorHint?: "default" | "success" | "warn" | "danger";
}Tile fetchers return discriminated unions:
type MetricTileData =
| { status: "ok"; value: number; aggregation: TileAggregation; label?: string }
| { status: "error"; message: string };
type ChartTileData =
| { status: "ok"; chartKind: "donut" | "sparkline" | "bars"; data: ChartPoint[] }
| { status: "error"; message: string };
type ListPreviewTileData =
| { status: "ok"; rows: ListPreviewRow[]; totalCount?: number }
| { status: "error"; message: string };No fetcher ever throws to the caller. Errors become { status: "error", message } entries cached for the same TTL as success results — prevents thundering herd against a failing data source.
Cache architecture
Every key includes institutionId. TTLs declared at the read site.
| Key pattern | Writer | Reader | TTL | Notes |
|---|---|---|---|---|
profile-home:{inst}:{subjectType}:{subjectValue} | src/actions/profile-home-page.ts | src/lib/profile-home-verify.ts | 3600 s | Fixed TTL, admin writes invalidate explicitly |
profile-object-default:{inst}:{subjectType}:{subjectValue}:{entityApiName} | src/actions/profile-object-default.ts | src/lib/list-view-resolver.ts | 3600 s | Fixed TTL |
list-view-references:{inst}:{viewId} | same | src/app/api/list-views/[id]/route.ts DELETE | no TTL | Redis set. Reverse index for cascade invalidation on list-view delete |
insight-strip:{inst}:{stripId} | src/actions/insight-strip.ts | src/lib/insights/load-strip.ts | 3600 s | Strip definition, not tile data |
insight-tile:{stripId}:{tileId}:{paramHash} | src/lib/insights/fetch-tile-data.ts | same | 60 s METRIC / 300 s CHART / 30 s LIST_PREVIEW | SWR cache — see below. paramHash is a 16-char SHA-256 prefix over data-affecting tile fields |
The insight-tile key doesn't include {inst} because stripId is already tenant-scoped at write time.
Stale-while-revalidate (SWR)
Tile data uses cacheGetOrSetSWR from src/lib/cache.ts. Soft TTL = half of full TTL.
┌──────────────────────────────────────────────────────────────┐
│ cacheGetOrSetSWR(key, ttl, loader) │
├──────────────────────────────────────────────────────────────┤
│ 1. GET key → parse envelope {value, softExpiresAt} │
│ 2. if no envelope: │
│ call loader synchronously, write envelope, return value │
│ 3. if envelope present AND now < softExpiresAt: │
│ return cached value, no network │
│ 4. if envelope present AND now >= softExpiresAt: │
│ return cached value (stale ok), fire loader async, │
│ write updated envelope when it resolves. │
└──────────────────────────────────────────────────────────────┘For a 60s METRIC: returns fresh for 30s, serves stale for another 30s while kicking off a background refresh, then full expiry. Prevents thundering herd and gives near-instant perceived response.
Strip DEFINITIONS (not tile data) use fixed-TTL cacheGetOrSet because admin writes explicitly invalidate — there's no freshness concern.
Reverse-index pattern (for cascade invalidation)
Problem: ProfileObjectDefault rows cache a specific defaultListViewId. When a list view is deleted, every PoD cache key that referenced it is stale. Iterating every PoD row to invalidate is O(n).
Solution: per-view reverse index stored as a Redis set:
list-view-references:{inst}:{viewId} → {
profile-object-default:{inst}:ASSIGN_PROFILE_TYPE:PROFILE_STAFF:Student,
profile-object-default:{inst}:ROLE:TEACHER:Student,
...
}setProfileObjectDefaultwrites a PoD row with a list view →SADDs the PoD cache key into the view's set- On list view DELETE, handler does
SMEMBERS→DEL-many →DELthe set itself
One round-trip on the read side. Idempotent writes via SADD.
Server-action template (five-bucket)
Every personalization mutation follows this shape:
┌─────────────────┐
│ 1. Auth gate │ requireAdmin() → SUPER_ADMIN | INSTITUTION_ADMIN
├─────────────────┤
│ 2. Zod │ parse input; reject on any shape mismatch
├─────────────────┤
│ 3. Tenant verify│ for every referenced id (list view, strip, report)
│ │ confirm it exists in caller's institutionId AND
│ │ matches the targetEntity of the row being written
├─────────────────┤
│ 4. Mutation │ single upsert / delete / bulk op; return DB row
├─────────────────┤
│ 5. Audit + cache│ write audit entry, invalidate cache, record reverse
│ │ index where applicable — OUTSIDE the transaction
└─────────────────┘Order matters: auth before Zod (don't leak schema errors to unauthenticated callers), Zod before DB reads (reject malformed input before any query cost), tenant verify before mutation (cross-tenant reference must fail before writing), audit + cache after mutation (audit records the observed row, not the input).
Why audit + cache are OUTSIDE the transaction: a failed audit write or Redis invalidation must not roll back a successful DB mutation. If audit logging fails, log a warning but the user-visible write succeeded. If Redis invalidation fails, the cache expires on TTL.
Source API — server actions
For extending the system, these are the public server actions:
Home pages (src/actions/profile-home-page.ts)
setProfileHomePage(input)— upsert one (subject, pageType, pageTargetId) bindingunsetProfileHomePage(input)— delete by (subject)copyHomePagesFromProfile(input)— bulk copy from one profile type to another with per-item diff
Object defaults (src/actions/profile-object-default.ts)
setProfileObjectDefault(input)— upsert (subject × entity) row; accepts bothdefaultListViewIdandinsightStripId; auto-deletes row if both end nullunsetProfileObjectDefault(input)— delete by (subject × entity)copyObjectDefaultsFromProfile(input)— bulk copy with per-field diff (list view + strip)
Insight strips (src/actions/insight-strip.ts)
createInsightStrip(input)— create with initial tile list (1-6 tiles enforced)updateInsightStrip(input)— update name / tiles / isActivearchiveInsightStrip(id)— setsisActive=false, idempotentduplicateInsightStrip(input)— clones with new tile IDs; defaults new name to{original} (Copy)listInsightStrips(input)— admin list for builderlistReportsForEntity(input)— populates the strip builder's source report combobox
Every action returns Result<T> (never throws). The admin UI displays the error string directly.
Tile fetchers
src/lib/insights/fetch-tile-data.ts exports three pure functions:
fetchMetricTile(tile, ctx): Promise<MetricTileData>fetchChartTile(tile, ctx): Promise<ChartTileData>fetchListPreviewTile(tile, ctx): Promise<ListPreviewTileData>
Each wraps its DB query in cacheGetOrSetSWR with the appropriate TTL tier. Each converts thrown errors (e.g., "source report not found") into { status: "error", message } results so the UI renders "Data unavailable" gracefully.
Tile context
interface TileContext {
institutionId: string;
userId: string;
entityApiName: string;
stripId: string;
}The page render at /o/[apiName] builds the context from the session + strip lookup, then fires all tile fetchers in parallel inside React Suspense boundaries.
Adding a new tile type
If you extend beyond METRIC / CHART / LIST_PREVIEW:
- Add the enum value to
tileTypeValuesinsrc/types/insight-strip.ts - Add a new
TileDatadiscriminated union variant with itsokshape - Write a new
fetchXTile(tile, ctx)function infetch-tile-data.tsthat:- Uses
cacheGetOrSetSWRwith appropriate TTL - Calls
resolveTileSource(tile, ctx)to load report rows - Shapes those rows into the new tile type's
okshape - Never throws — catches errors into
{ status: "error", message }
- Uses
- Add a renderer component in
src/components/insights/following theMetricTile.tsxpattern - Wire it into the
TileRenderer.tsxswitch statement - Add the option to the strip builder's properties panel
Corresponding unit tests in src/__tests__/fetch-tile-data.test.ts and integration test in src/__tests__/insight-strip-integration.test.ts for the new tile's end-to-end flow.
Performance budget
From docs/personalization/04-phase-3-insight-strips.md §Step 9:
| Surface | Cold | Warm |
|---|---|---|
| Full strip (definition + all tiles) | < 2000 ms | < 500 ms |
| Per-tile p95 | < 1000 ms | < 300 ms |
Current baseline (2026-04-20, 101-row demo tenant):
- Full strip p95: 34 ms cold / 1.3 ms warm
- METRIC p95: 28 ms cold / 0.4 ms warm
- CHART p95: 11 ms cold / 0.4 ms warm
- LIST_PREVIEW p95: 5 ms cold / 0.3 ms warm
All budgets clear with 30×-1000× headroom at this data scale. Re-run scripts/benchmark-insight-strip.ts for any tenant > ~1000 source-entity rows before deploying. If p95 exceeds 50% of budget, investigate the source report's query plan before enabling.
Feature flags
Three flags, all default ON (GA as of 2026-04-20):
profilePersonalization.homePageprofilePersonalization.objectDefaultsprofilePersonalization.insightStrips
Env var FEATURE_X=0 acts as an emergency kill-switch. No env config required for normal operation.
Implementation: src/lib/feature-flags.ts has a DEFAULT_ON_FLAGS set — flags in that set default to enabled. Future non-GA flags stay out of the set and default to off.
Testing patterns
Unit — five-bucket action tests
src/__tests__/profile-object-default-actions.test.ts (and siblings) cover each action across the five buckets. 28 cases for object-default actions alone. Mocks prisma / auth / institution-context at the module boundary.
Unit — tile fetcher tests
src/__tests__/fetch-tile-data.test.ts pins tile-type-specific behavior (aggregation, filter sanitization, graceful error on missing report). 19 cases.
Integration — stateful Map-backed mocks
src/__tests__/insight-strip-integration.test.ts and src/__tests__/object-defaults-integration.test.ts use Map-backed stores (kvStore: Map<string, string>, stripRows: Map<string, StripRow>) instead of static vi.fn returns. The cache is observable — assertions do expect(kvStore.has(cacheKey)).toBe(false) after invalidation, not just expect(invalidate).toHaveBeenCalled(). This catches "invalidate called on the wrong key" regressions that unit tests miss.
Seven scenarios per integration suite, covering the full admin-write → cache-invalidate → user-read cycle. The critical one (strip integration case 6): admin deletes a source report, sibling tiles pointing at a different report still return status:"ok" — pins the graceful-degradation invariant.
Performance benchmark
scripts/benchmark-insight-strip.ts seeds a scratch report + 4-tile strip, runs 15 cold + 15 warm iterations per tile type against real Postgres + Redis, reports p50/p95. Cold iteration purges all insight-tile:{stripId}:* keys first for genuine cache-miss measurement.
File map
Models — prisma/schema.prisma
ProfileHomePage,ProfileObjectDefault,InsightStrip
Actions — src/actions/
profile-home-page.ts,profile-object-default.ts,insight-strip.ts
Library — src/lib/
profile-home-verify.ts— Phase 1 resolverlist-view-resolver.ts— Phase 2 resolverprofile-object-default.ts— Phase 2 cache + reverse indexinsights/load-strip.ts— Phase 3 strip loaderinsights/fetch-tile-data.ts— Phase 3 tile fetchers with SWRcache.ts— shared cache helper (cacheGetOrSet+cacheGetOrSetSWR)feature-flags.ts— env-var-driven flag reader withDEFAULT_ON_FLAGS
UI — src/app/(dashboard)/settings/
profiles/home-pages/— home page adminprofiles/object-defaults/ProfileObjectDefaultsAdmin.tsx— object defaults + strip bindinginsight-strips/— strip builder
Components — src/components/insights/
InsightStrip.tsx,TileRenderer.tsx,MetricTile.tsx,ChartTile.tsx,ListPreviewTile.tsx,TileSkeleton.tsx,CollapsibleStripShell.tsx
Tests — src/__tests__/
Personalization cluster: 11 test files, 155 tests. See the testing-patterns section above for structure.
Scripts — scripts/
benchmark-insight-strip.ts— perf benchmarkseed-phase3-insight-strips.ts— starter-strip seedingverify-phase3-strips-seed.ts— seed verification