Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/decisions/0002-learner-profile-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# 0002 — Learner Public and Private Profile Schema

- **Status:** Accepted
- **Date:** 2026-07-30
- **Roadmap item:** Phase 1 / "Add Learner Public and Private Profile Schema" (issue #83)

## Context

Learner-facing account data was split across `User` (identity, credentials, account status/verification) and `LearnerPreference` (locale, accessibility, a coarse `profileVisibility` flag used only for search/preference purposes). There was no structured, consent-controlled profile — no bio, avatar, country, languages, interests, or goals — and no defined boundary for what an employer or the public may see versus what only the account owner may see.

## Decision

Add a `LearnerProfile` model (`prisma/schema.prisma`), one-to-one with `User`, holding only consent-controlled profile data:

- `displayName`, `bio`, `avatarUrl`, `country`, `timezone` — scalar fields.
- `languages`, `interests`, `goals` — `String[]` arrays, modeled consistently with the existing `LearnerPreference.preferredCategories` convention.
- `level` — bounded string (`beginner | intermediate | advanced | expert`).
- `visibility` — bounded string (`private | employer | public`), the single disclosure control for the whole profile. `country`, `level`, and `visibility` are indexed as the consented, searchable fields.

Account status/verification fields (`status`, `isVerified`, `phoneVerifiedAt`) already live on `User` from prior work and are deliberately **not** duplicated onto `LearnerProfile`. This decision's job is to guarantee they never leak through a profile response, not to re-model them.

### Visibility model

`visibility` is ranked `private (0) < employer (1) < public (2)` (`VISIBILITY_RANK` in `src/types/profile.types.ts`). A viewer at a given tier sees the full field set only if the profile's visibility rank is at or above that tier; otherwise they get a redacted stub (`{ id, visible: false }`). This keeps the rule in one place instead of scattered `if` checks in controllers.

### Serializers

Four pure functions in `src/services/profile-serializer.ts`, each taking the raw DB record and returning a fixed shape:

- `toOwnerProfile` — every profile field, always, plus a computed `completion` summary. Ignores `visibility` because the rule doesn't apply to the owner's own view.
- `toEmployerProfile` — full field set if `visibility >= employer`, else redacted stub.
- `toPublicProfile` — a narrower public-safe subset (excludes `timezone`, `languages`, `goals`) if `visibility === public`, else redacted stub.
- `toPrivateProfile` — internal/admin use only; joins the profile with `AccountPrivateFields` (`status`, `isVerified`, `phoneVerifiedAt`) from `User`. Never wired to a public or employer-facing route.

Being pure functions with no I/O, leakage is testable directly: `tests/profile-serializer.test.ts` asserts the employer and public views never carry `status`/`isVerified`/`phoneVerifiedAt`, and that redaction kicks in below each tier's visibility threshold.

### Completion

`computeProfileCompletion` (same file) is a pure function over eight tracked fields (`PROFILE_COMPLETION_FIELDS`) — every profile field except `level`, which always has a default and can never read as "empty". Completion is computed on read rather than stored, so it can never drift from the underlying data; the same input always produces the same percentage.

### Routes

- `GET/PATCH /users/me/profile` — owner view and partial update, mirroring the existing `LearnerPreference` endpoints' shape and auth.
- `GET /users/:id/profile` — optionally authenticated; returns the owner view if the caller is viewing themselves, the employer view if `req.user.role === 'employer'`, otherwise the public view.

## Out of scope

- Wiring `LearnerProfile` into the existing mock-backed `UserController` / `employer.controller.ts` candidate search — tracked separately by "Replace Mock User Helpers with Profile API".
- Change auditing on profile edits — tracked separately by "Add Auditable Data Lifecycle and Archive Policy"; `LearnerProfile` intentionally has no audit log of its own yet, unlike `LearnerPreference`'s privacy-impacting-field audit.
63 changes: 63 additions & 0 deletions docs/decisions/0003-onboarding-consent-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# 0003 — Onboarding and Consent Persistence

- **Status:** Accepted
- **Date:** 2026-07-30
- **Roadmap item:** Phase 1 / "Add Onboarding and Consent Persistence" (issue #84)

## Context

There was no persisted onboarding state and no consent model. `LearnerPreference` already carries two booleans (`analyticsConsent`, `dataSharingConsent`) but they are overwritten in place on every update — there is no history, no policy version, and no distinction between consent that's required to use the service versus consent that's optional. Onboarding progress did not exist at all, so there was no way to resume a partially completed flow or to gate onboarding completion on required steps.

This issue is blocked by "Introduce Typed Status Enums and Transition Guards," which hasn't been built yet. Rather than wait, this change ships a minimal, scoped transition guard (`src/utils/transitions.ts`) sized to what onboarding and consent actually need. It's intentionally generic (`canTransition<S>(map, from, to)`) so the future typed-enum work can adopt or replace it without reshaping the models built here.

## Decision

### Onboarding (`OnboardingProgress`, 1:1 with `User`)

- `version` — the onboarding flow version a user started under, so a later flow redesign doesn't silently remap in-progress users.
- `currentStep`, `completedSteps[]` — a `String[]` set of finished steps, deduplicated on write.
- `status` — `in_progress | completed`.
- `startedAt`, `completedAt`, `createdAt`, `updatedAt`.

**Transition matrix** (`ONBOARDING_TRANSITIONS`):

| From | Allowed to |
|---|---|
| `in_progress` | `completed` |
| `completed` | *(none — terminal)* |

`saveStep` refuses to write once `status === 'completed'` (`already-completed` result), which is the transition guard in effect: once complete, the record cannot be reopened by any client action.

**Idempotent save/resume**: `saveStep` upserts by the user's single `OnboardingProgress` row and adds the step into `completedSteps` via `Set` dedup, so re-submitting the same step is a no-op beyond updating `currentStep`. `resume` (`GET /onboarding`) always returns the current row, creating a fresh `in_progress` record on first access — same upsert-on-read pattern as `LearnerPreference` — so resuming is deterministic regardless of how many times it's called.

**Completion requirements**: `REQUIRED_ONBOARDING_STEPS = [profile_basics, consent]` (`preferences` is optional). `complete()` checks, in order: all required steps present in `completedSteps`, then all required consent purposes currently granted (via `ConsentService.hasAllRequiredGranted`). Either failure blocks the state transition and reports which requirement was unmet.

### Consent (`ConsentRecord`, many-to-one with `User`)

Append-only: every grant or withdrawal action inserts a **new** row rather than mutating an existing one. The "current" state of a purpose is just its most recent row (`findFirst` ordered by `createdAt desc`, or `findMany` with `distinct: ['purpose']` for all purposes at once). This makes the history model the audit trail — there is no separate audit table to keep in sync, and it can't drift from what's queryable.

- `purpose` — `terms_of_service | privacy_policy | marketing_emails | analytics | data_sharing`.
- `required` — computed from `REQUIRED_CONSENT_PURPOSES` at write time and stored on the row, so historical rows keep the classification that was in effect when they were written even if the required set changes later.
- `policyVersion`, `status` (`granted | withdrawn`), `source` (`onboarding | settings | api`), `grantedAt`, `withdrawnAt`, `createdAt`.

**Transition matrix** (`CONSENT_TRANSITIONS`):

| From | Allowed to |
|---|---|
| `granted` | `withdrawn` |
| `withdrawn` | `granted` |

**Required vs. optional** is enforced in `ConsentService.withdraw`, on top of the raw transition check: withdrawal requires the latest record to be `granted` (`canTransition(CONSENT_TRANSITIONS, 'granted', 'withdrawn')`), and additionally rejects the withdrawal outright if `required` is true. Granting has no such restriction — re-granting (e.g., accepting a new `policyVersion`) is always allowed and simply appends a new row, which is also how "duplicate" grants are handled: calling `grant` twice with the same purpose is not an error, it's two audit entries.

## Out of scope

- The generic typed-status-enum framework this issue is nominally blocked on — `src/utils/transitions.ts` is a minimal stand-in, not that system.
- Migrating `LearnerPreference.analyticsConsent` / `dataSharingConsent` onto `ConsentRecord` — left as-is to avoid touching an unrelated feature; a future cleanup can retire those booleans once consumers move to the consent API.
- Wiring onboarding/consent into `UserController`'s mock helpers — tracked by "Replace Mock User Helpers with Profile API".

## Verification evidence

- **Migration**: `prisma/migrations/20260730130000_add_onboarding_and_consent/migration.sql`.
- **Transition matrix tests**: `tests/transitions.test.ts` (generic guard), exercised concretely in `tests/onboarding.service.test.ts` (`already-completed` refusal) and `tests/consent.service.test.ts` (`required-cannot-withdraw`, `not-granted`).
- **Consent-history tests**: `tests/consent.service.test.ts` — grant/withdraw sequencing, policy-version carry-forward on withdrawal, `hasAllRequiredGranted` across granted/withdrawn/never-addressed states.
- **Controller tests**: `tests/onboarding.controller.test.ts`, `tests/consent.controller.test.ts` — auth gating, validation, status-code mapping for each result kind.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "learner_profiles" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"displayName" TEXT,
"bio" TEXT,
"avatarUrl" TEXT,
"country" TEXT,
"timezone" TEXT,
"languages" TEXT[] DEFAULT ARRAY[]::TEXT[],
"level" TEXT NOT NULL DEFAULT 'beginner',
"interests" TEXT[] DEFAULT ARRAY[]::TEXT[],
"goals" TEXT[] DEFAULT ARRAY[]::TEXT[],
"visibility" TEXT NOT NULL DEFAULT 'private',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "learner_profiles_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "learner_profiles_userId_key" ON "learner_profiles"("userId");

-- CreateIndex
CREATE INDEX "learner_profiles_country_idx" ON "learner_profiles"("country");

-- CreateIndex
CREATE INDEX "learner_profiles_level_idx" ON "learner_profiles"("level");

-- CreateIndex
CREATE INDEX "learner_profiles_visibility_idx" ON "learner_profiles"("visibility");

-- AddForeignKey
ALTER TABLE "learner_profiles" ADD CONSTRAINT "learner_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
-- CreateTable
CREATE TABLE "onboarding_progress" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"version" TEXT NOT NULL DEFAULT 'v1',
"currentStep" TEXT NOT NULL,
"completedSteps" TEXT[] DEFAULT ARRAY[]::TEXT[],
"status" TEXT NOT NULL DEFAULT 'in_progress',
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "onboarding_progress_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "consent_records" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"purpose" TEXT NOT NULL,
"required" BOOLEAN NOT NULL DEFAULT false,
"policyVersion" TEXT NOT NULL,
"status" TEXT NOT NULL,
"source" TEXT NOT NULL,
"grantedAt" TIMESTAMP(3),
"withdrawnAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "consent_records_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "onboarding_progress_userId_key" ON "onboarding_progress"("userId");

-- CreateIndex
CREATE INDEX "onboarding_progress_status_idx" ON "onboarding_progress"("status");

-- CreateIndex
CREATE INDEX "consent_records_userId_purpose_createdAt_idx" ON "consent_records"("userId", "purpose", "createdAt");

-- AddForeignKey
ALTER TABLE "onboarding_progress" ADD CONSTRAINT "onboarding_progress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "consent_records" ADD CONSTRAINT "consent_records_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
67 changes: 67 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,14 @@ model User {
emailDeliveries EmailDelivery[]
learnerPreference LearnerPreference?
preferenceAuditLogs PreferenceAuditLog[]
learnerProfile LearnerProfile?
sessions Session[]
audits AuditLog[]
dataExports DataExportRequest[]
deletionRequests AccountDeletionRequest[]
otpChallenges OtpChallenge[]
onboarding OnboardingProgress?
consentRecords ConsentRecord[]

@@map("users")
}
Expand Down Expand Up @@ -77,6 +80,70 @@ model LearnerPreference {
@@map("learner_preferences")
}

model LearnerProfile {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

displayName String?
bio String?
avatarUrl String?
country String?
timezone String?
languages String[] @default([])
level String @default("beginner") // beginner, intermediate, advanced, expert
interests String[] @default([])
goals String[] @default([])

visibility String @default("private") // private, employer, public

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([country])
@@index([level])
@@index([visibility])
@@map("learner_profiles")
}

model OnboardingProgress {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

version String @default("v1")
currentStep String
completedSteps String[] @default([])
status String @default("in_progress") // in_progress, completed

startedAt DateTime @default(now())
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([status])
@@map("onboarding_progress")
}

model ConsentRecord {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

purpose String // terms_of_service, privacy_policy, marketing_emails, analytics, data_sharing
required Boolean @default(false)
policyVersion String
status String // granted, withdrawn
source String // onboarding, settings, api
grantedAt DateTime?
withdrawnAt DateTime?

createdAt DateTime @default(now())

@@index([userId, purpose, createdAt])
@@map("consent_records")
}

model PreferenceAuditLog {
id String @id @default(uuid())
userId String
Expand Down
Loading
Loading