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.
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;
27 changes: 27 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ model User {
emailDeliveries EmailDelivery[]
learnerPreference LearnerPreference?
preferenceAuditLogs PreferenceAuditLog[]
learnerProfile LearnerProfile?
sessions Session[]
audits AuditLog[]
dataExports DataExportRequest[]
Expand Down Expand Up @@ -77,6 +78,32 @@ 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 PreferenceAuditLog {
id String @id @default(uuid())
userId String
Expand Down
156 changes: 156 additions & 0 deletions src/controllers/profile.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { Request, Response } from 'express'
import { z } from 'zod'
import { ProfileService } from '../services/profile.service'
import { LEARNER_LEVELS, PROFILE_VISIBILITIES } from '../types/profile.types'

const profileService = new ProfileService()

const updateProfileSchema = z
.object({
displayName: z.string().min(1).max(80).nullable().optional(),
bio: z.string().max(1000).nullable().optional(),
avatarUrl: z.string().url().nullable().optional(),
country: z.string().min(2).max(60).nullable().optional(),
timezone: z.string().nullable().optional(),
languages: z.array(z.string().min(1)).max(20).optional(),
level: z.enum(LEARNER_LEVELS, {
errorMap: () => ({ message: `Level must be one of: ${LEARNER_LEVELS.join(', ')}` }),
}).optional(),
interests: z.array(z.string().min(1)).max(50).optional(),
goals: z.array(z.string().min(1)).max(20).optional(),
visibility: z.enum(PROFILE_VISIBILITIES, {
errorMap: () => ({ message: `Visibility must be one of: ${PROFILE_VISIBILITIES.join(', ')}` }),
}).optional(),
})
.strict()
.refine(data => Object.keys(data).length > 0, { message: 'At least one profile field is required' })

export class ProfileController {
/**
* @openapi
* /users/me/profile:
* get:
* summary: Get the authenticated user's full learner profile
* tags: [Profiles]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Profile retrieved successfully
* 401:
* description: Unauthorized
*/
async getMyProfile(req: Request, res: Response): Promise<void> {
try {
const userId = req.user?.id
if (!userId) {
res.status(401).json({ error: 'Unauthorized' })

return
}

const profile = await profileService.getOwnerView(userId)

res.status(200).json({ data: profile })
} catch (error) {
console.error('Get profile error:', error)
res.status(500).json({ error: 'Internal server error' })
}
}

/**
* @openapi
* /users/me/profile:
* patch:
* summary: Partially update the authenticated user's learner profile
* tags: [Profiles]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Profile updated successfully
* 400:
* description: Validation failed
* 401:
* description: Unauthorized
*/
async updateMyProfile(req: Request, res: Response): Promise<void> {
try {
const userId = req.user?.id
if (!userId) {
res.status(401).json({ error: 'Unauthorized' })

return
}

const validation = updateProfileSchema.safeParse(req.body)
if (!validation.success) {
res.status(400).json({
error: 'Validation failed',
details: validation.error.format(),
})

return
}

await profileService.updateProfile(userId, validation.data)
const profile = await profileService.getOwnerView(userId)

res.status(200).json({
message: 'Profile updated successfully',
data: profile,
})
} catch (error) {
console.error('Update profile error:', error)
res.status(500).json({ error: 'Internal server error' })
}
}

/**
* @openapi
* /users/{id}/profile:
* get:
* summary: Get a learner's profile, scoped to the caller's visibility level
* tags: [Profiles]
* security: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* format: uuid
* responses:
* 200:
* description: Profile retrieved successfully, subject to the owner's visibility setting
* 404:
* description: Profile not found
*/
async getProfileById(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params

if (req.user?.id === id) {
const profile = await profileService.getOwnerView(id)
res.status(200).json({ data: profile })

return
}

const profile = req.user?.role === 'employer'
? await profileService.getEmployerView(id)
: await profileService.getPublicView(id)

if (!profile) {
res.status(404).json({ error: 'Profile not found' })

return
}

res.status(200).json({ data: profile })
} catch (error) {
console.error('Get profile by id error:', error)
res.status(500).json({ error: 'Internal server error' })
}
}
}
10 changes: 9 additions & 1 deletion src/routes/v1/users.routes.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import express, { Router } from 'express'
import { UserController } from '../../controllers/user.controller'
import { PreferenceController } from '../../controllers/preference.controller'
import { authenticate } from '../../middleware/auth.middleware'
import { ProfileController } from '../../controllers/profile.controller'
import { authenticate, optionalAuthenticate } from '../../middleware/auth.middleware'
import { validateProfileUpdate, validatePasswordChange, validateWalletAddress } from '../../middleware/validation.middleware'

const router: express.Router = Router()
const userController = new UserController()
const preferenceController = new PreferenceController()
const profileController = new ProfileController()

router.get('/me', authenticate, userController.getCurrentUser.bind(userController))

Expand All @@ -16,6 +18,12 @@ router.get('/me/preferences', authenticate, preferenceController.getPreferences.

router.patch('/me/preferences', authenticate, preferenceController.updatePreferences.bind(preferenceController))

router.get('/me/profile', authenticate, profileController.getMyProfile.bind(profileController))

router.patch('/me/profile', authenticate, profileController.updateMyProfile.bind(profileController))

router.get('/:id/profile', optionalAuthenticate, profileController.getProfileById.bind(profileController))

router.get('/:id', userController.getUserById.bind(userController))

router.patch('/password', authenticate, validatePasswordChange, userController.changePassword.bind(userController))
Expand Down
83 changes: 83 additions & 0 deletions src/services/profile-serializer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {
AccountPrivateFields,
EmployerProfileView,
LearnerProfileRecord,
OwnerProfileView,
PrivateProfileView,
PROFILE_COMPLETION_FIELDS,
ProfileCompletion,
PublicProfileView,
VISIBILITY_RANK,
} from '../types/profile.types'

function isFilled(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (Array.isArray(value)) {
return value.length > 0
}
if (typeof value === 'string') {
return value.trim().length > 0
}

return true
}

export function computeProfileCompletion(profile: LearnerProfileRecord): ProfileCompletion {
const missingFields = PROFILE_COMPLETION_FIELDS.filter(field => !isFilled(profile[field]))
const filledCount = PROFILE_COMPLETION_FIELDS.length - missingFields.length
const percent = Math.round((filledCount / PROFILE_COMPLETION_FIELDS.length) * 100)

return { percent, missingFields: [...missingFields] }
}

export function toOwnerProfile(profile: LearnerProfileRecord): OwnerProfileView {
return { ...profile, completion: computeProfileCompletion(profile) }
}

export function toEmployerProfile(profile: LearnerProfileRecord): EmployerProfileView {
if (VISIBILITY_RANK[profile.visibility] < VISIBILITY_RANK.employer) {
return { id: profile.id, visible: false }
}

return {
id: profile.id,
displayName: profile.displayName,
bio: profile.bio,
avatarUrl: profile.avatarUrl,
country: profile.country,
timezone: profile.timezone,
languages: profile.languages,
level: profile.level,
interests: profile.interests,
goals: profile.goals,
visible: true,
}
}

export function toPublicProfile(profile: LearnerProfileRecord): PublicProfileView {
if (VISIBILITY_RANK[profile.visibility] < VISIBILITY_RANK.public) {
return { id: profile.id, visible: false }
}

return {
id: profile.id,
displayName: profile.displayName,
bio: profile.bio,
avatarUrl: profile.avatarUrl,
country: profile.country,
level: profile.level,
interests: profile.interests,
visible: true,
}
}

// Internal/administrative use only (e.g. audit tooling). Never route this
// view through a public or employer-facing endpoint.
export function toPrivateProfile(
profile: LearnerProfileRecord,
account: AccountPrivateFields
): PrivateProfileView {
return { ...profile, ...account }
}
Loading
Loading