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 default

Each feature implements the chain in its own resolver:

  • src/lib/profile-home-verify.ts — home page resolver
  • src/lib/list-view-resolver.ts — default list view resolver
  • src/app/(dashboard)/o/[apiName]/page.tsx — strip lookup via ProfileObjectDefault.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 patternWriterReaderTTLNotes
profile-home:{inst}:{subjectType}:{subjectValue}src/actions/profile-home-page.tssrc/lib/profile-home-verify.ts3600 sFixed TTL, admin writes invalidate explicitly
profile-object-default:{inst}:{subjectType}:{subjectValue}:{entityApiName}src/actions/profile-object-default.tssrc/lib/list-view-resolver.ts3600 sFixed TTL
list-view-references:{inst}:{viewId}samesrc/app/api/list-views/[id]/route.ts DELETEno TTLRedis set. Reverse index for cascade invalidation on list-view delete
insight-strip:{inst}:{stripId}src/actions/insight-strip.tssrc/lib/insights/load-strip.ts3600 sStrip definition, not tile data
insight-tile:{stripId}:{tileId}:{paramHash}src/lib/insights/fetch-tile-data.tssame60 s METRIC / 300 s CHART / 30 s LIST_PREVIEWSWR 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,
  ...
}
  • setProfileObjectDefault writes 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 → DEL the 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) binding
  • unsetProfileHomePage(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 both defaultListViewId and insightStripId; auto-deletes row if both end null
  • unsetProfileObjectDefault(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 / isActive
  • archiveInsightStrip(id) — sets isActive=false, idempotent
  • duplicateInsightStrip(input) — clones with new tile IDs; defaults new name to {original} (Copy)
  • listInsightStrips(input) — admin list for builder
  • listReportsForEntity(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:

  1. Add the enum value to tileTypeValues in src/types/insight-strip.ts
  2. Add a new TileData discriminated union variant with its ok shape
  3. Write a new fetchXTile(tile, ctx) function in fetch-tile-data.ts that:
    • Uses cacheGetOrSetSWR with appropriate TTL
    • Calls resolveTileSource(tile, ctx) to load report rows
    • Shapes those rows into the new tile type's ok shape
    • Never throws — catches errors into { status: "error", message }
  4. Add a renderer component in src/components/insights/ following the MetricTile.tsx pattern
  5. Wire it into the TileRenderer.tsx switch statement
  6. 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:

SurfaceColdWarm
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.homePage
  • profilePersonalization.objectDefaults
  • profilePersonalization.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 resolver
  • list-view-resolver.ts — Phase 2 resolver
  • profile-object-default.ts — Phase 2 cache + reverse index
  • insights/load-strip.ts — Phase 3 strip loader
  • insights/fetch-tile-data.ts — Phase 3 tile fetchers with SWR
  • cache.ts — shared cache helper (cacheGetOrSet + cacheGetOrSetSWR)
  • feature-flags.ts — env-var-driven flag reader with DEFAULT_ON_FLAGS

UI — src/app/(dashboard)/settings/

  • profiles/home-pages/ — home page admin
  • profiles/object-defaults/ProfileObjectDefaultsAdmin.tsx — object defaults + strip binding
  • insight-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 benchmark
  • seed-phase3-insight-strips.ts — starter-strip seeding
  • verify-phase3-strips-seed.ts — seed verification