diff --git a/docs/decisions/0002-learner-profile-visibility.md b/docs/decisions/0002-learner-profile-visibility.md new file mode 100644 index 0000000..823b312 --- /dev/null +++ b/docs/decisions/0002-learner-profile-visibility.md @@ -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. diff --git a/docs/decisions/0003-onboarding-consent-persistence.md b/docs/decisions/0003-onboarding-consent-persistence.md new file mode 100644 index 0000000..33a7b57 --- /dev/null +++ b/docs/decisions/0003-onboarding-consent-persistence.md @@ -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(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. diff --git a/prisma/migrations/20260730120000_add_learner_profiles/migration.sql b/prisma/migrations/20260730120000_add_learner_profiles/migration.sql new file mode 100644 index 0000000..a2f59c0 --- /dev/null +++ b/prisma/migrations/20260730120000_add_learner_profiles/migration.sql @@ -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; diff --git a/prisma/migrations/20260730130000_add_onboarding_and_consent/migration.sql b/prisma/migrations/20260730130000_add_onboarding_and_consent/migration.sql new file mode 100644 index 0000000..36240dd --- /dev/null +++ b/prisma/migrations/20260730130000_add_onboarding_and_consent/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e831d87..bb8d5cb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } @@ -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 diff --git a/src/controllers/consent.controller.ts b/src/controllers/consent.controller.ts new file mode 100644 index 0000000..4360841 --- /dev/null +++ b/src/controllers/consent.controller.ts @@ -0,0 +1,207 @@ +import { Request, Response } from 'express' +import { z } from 'zod' +import { consentService } from '../services/consent.service' +import { CONSENT_PURPOSES, CONSENT_SOURCES } from '../types/consent.types' + +const grantConsentSchema = z.object({ + purpose: z.enum(CONSENT_PURPOSES, { + errorMap: () => ({ message: `Purpose must be one of: ${CONSENT_PURPOSES.join(', ')}` }), + }), + policyVersion: z.string().min(1), + source: z.enum(CONSENT_SOURCES, { + errorMap: () => ({ message: `Source must be one of: ${CONSENT_SOURCES.join(', ')}` }), + }), +}) + +const withdrawConsentSchema = z.object({ + purpose: z.enum(CONSENT_PURPOSES, { + errorMap: () => ({ message: `Purpose must be one of: ${CONSENT_PURPOSES.join(', ')}` }), + }), + source: z.enum(CONSENT_SOURCES, { + errorMap: () => ({ message: `Source must be one of: ${CONSENT_SOURCES.join(', ')}` }), + }), +}) + +const historyQuerySchema = z.object({ + purpose: z.enum(CONSENT_PURPOSES).optional(), +}) + +export class ConsentController { + /** + * @openapi + * /consents: + * get: + * summary: Get the authenticated user's current consent per purpose + * tags: [Consents] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Current consents retrieved successfully + * 401: + * description: Unauthorized + */ + async getCurrent(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const consents = await consentService.getCurrent(userId) + + res.status(200).json({ data: consents }) + } catch (error) { + console.error('Get current consents error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /consents/history: + * get: + * summary: Get the authenticated user's full consent history, optionally filtered by purpose + * tags: [Consents] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Consent history retrieved successfully + * 400: + * description: Validation failed + * 401: + * description: Unauthorized + */ + async getHistory(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const validation = historyQuerySchema.safeParse(req.query) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const history = await consentService.getHistory(userId, validation.data.purpose) + + res.status(200).json({ data: history }) + } catch (error) { + console.error('Get consent history error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /consents/grant: + * post: + * summary: Grant consent for a purpose + * tags: [Consents] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Consent granted successfully + * 400: + * description: Validation failed + * 401: + * description: Unauthorized + */ + async grant(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const validation = grantConsentSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const record = await consentService.grant(userId, validation.data) + + res.status(200).json({ message: 'Consent granted successfully', data: record }) + } catch (error) { + console.error('Grant consent error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /consents/withdraw: + * post: + * summary: Withdraw a previously granted, optional consent + * tags: [Consents] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Consent withdrawn successfully + * 400: + * description: Validation failed + * 401: + * description: Unauthorized + * 409: + * description: Consent is required and cannot be withdrawn, or was never granted + */ + async withdraw(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const validation = withdrawConsentSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const result = await consentService.withdraw(userId, validation.data) + + if (result.kind === 'not-granted') { + res.status(409).json({ error: 'Consent was never granted' }) + + return + } + + if (result.kind === 'required-cannot-withdraw') { + res.status(409).json({ error: 'Required consent cannot be withdrawn' }) + + return + } + + res.status(200).json({ message: 'Consent withdrawn successfully', data: result.record }) + } catch (error) { + console.error('Withdraw consent error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } +} diff --git a/src/controllers/onboarding.controller.ts b/src/controllers/onboarding.controller.ts new file mode 100644 index 0000000..d42a04f --- /dev/null +++ b/src/controllers/onboarding.controller.ts @@ -0,0 +1,142 @@ +import { Request, Response } from 'express' +import { z } from 'zod' +import { onboardingService } from '../services/onboarding.service' +import { ONBOARDING_STEPS } from '../types/onboarding.types' + +const saveStepSchema = z.object({ + step: z.enum(ONBOARDING_STEPS, { + errorMap: () => ({ message: `Step must be one of: ${ONBOARDING_STEPS.join(', ')}` }), + }), +}) + +export class OnboardingController { + /** + * @openapi + * /onboarding: + * get: + * summary: Resume the authenticated user's onboarding progress + * tags: [Onboarding] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Onboarding progress retrieved successfully + * 401: + * description: Unauthorized + */ + async getProgress(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const progress = await onboardingService.resume(userId) + + res.status(200).json({ data: progress }) + } catch (error) { + console.error('Get onboarding progress error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /onboarding/steps: + * post: + * summary: Save (or idempotently re-save) an onboarding step + * tags: [Onboarding] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Step saved successfully + * 400: + * description: Validation failed + * 401: + * description: Unauthorized + * 409: + * description: Onboarding is already completed + */ + async saveStep(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const validation = saveStepSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const result = await onboardingService.saveStep(userId, validation.data.step) + + if (result.kind === 'already-completed') { + res.status(409).json({ error: 'Onboarding is already completed', data: result.progress }) + + return + } + + res.status(200).json({ message: 'Step saved successfully', data: result.progress }) + } catch (error) { + console.error('Save onboarding step error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /onboarding/complete: + * post: + * summary: Complete onboarding once all required steps and consents are satisfied + * tags: [Onboarding] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Onboarding completed successfully + * 401: + * description: Unauthorized + * 409: + * description: Required steps or consents are missing + */ + async complete(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const result = await onboardingService.complete(userId) + + if (result.kind === 'incomplete-steps') { + res.status(409).json({ error: 'Required onboarding steps are missing', missingSteps: result.missingSteps }) + + return + } + + if (result.kind === 'missing-required-consent') { + res.status(409).json({ error: 'Required consent has not been granted' }) + + return + } + + res.status(200).json({ message: 'Onboarding completed successfully', data: result.progress }) + } catch (error) { + console.error('Complete onboarding error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } +} diff --git a/src/controllers/profile.controller.ts b/src/controllers/profile.controller.ts new file mode 100644 index 0000000..5a00998 --- /dev/null +++ b/src/controllers/profile.controller.ts @@ -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 { + 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 { + 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 { + 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' }) + } + } +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 617d0e3..045b519 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -9,6 +9,8 @@ import notificationRoutes from './v1/notifications.routes' import syncRoutes from './v1/sync.routes' import referralRoutes from './v1/referrals.routes' import accountRoutes from './v1/account.routes' +import onboardingRoutes from './v1/onboarding.routes' +import consentRoutes from './v1/consent.routes' const router: Router = Router() @@ -26,5 +28,7 @@ router.use('/v1/notifications', notificationRoutes) router.use('/v1/sync', syncRoutes) router.use('/v1/referrals', referralRoutes) router.use('/v1/account', accountRoutes) +router.use('/v1/onboarding', onboardingRoutes) +router.use('/v1/consents', consentRoutes) export default router diff --git a/src/routes/v1/consent.routes.ts b/src/routes/v1/consent.routes.ts new file mode 100644 index 0000000..ee333b6 --- /dev/null +++ b/src/routes/v1/consent.routes.ts @@ -0,0 +1,16 @@ +import { Router } from 'express' +import { ConsentController } from '../../controllers/consent.controller' +import { authenticate } from '../../middleware/auth.middleware' + +const router: Router = Router() +const consentController = new ConsentController() + +router.get('/', authenticate, consentController.getCurrent.bind(consentController)) + +router.get('/history', authenticate, consentController.getHistory.bind(consentController)) + +router.post('/grant', authenticate, consentController.grant.bind(consentController)) + +router.post('/withdraw', authenticate, consentController.withdraw.bind(consentController)) + +export default router diff --git a/src/routes/v1/onboarding.routes.ts b/src/routes/v1/onboarding.routes.ts new file mode 100644 index 0000000..6cd57fb --- /dev/null +++ b/src/routes/v1/onboarding.routes.ts @@ -0,0 +1,14 @@ +import { Router } from 'express' +import { OnboardingController } from '../../controllers/onboarding.controller' +import { authenticate } from '../../middleware/auth.middleware' + +const router: Router = Router() +const onboardingController = new OnboardingController() + +router.get('/', authenticate, onboardingController.getProgress.bind(onboardingController)) + +router.post('/steps', authenticate, onboardingController.saveStep.bind(onboardingController)) + +router.post('/complete', authenticate, onboardingController.complete.bind(onboardingController)) + +export default router diff --git a/src/routes/v1/users.routes.ts b/src/routes/v1/users.routes.ts index 87b9633..7ddb93f 100644 --- a/src/routes/v1/users.routes.ts +++ b/src/routes/v1/users.routes.ts @@ -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)) @@ -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)) diff --git a/src/services/consent.service.ts b/src/services/consent.service.ts new file mode 100644 index 0000000..0dfe367 --- /dev/null +++ b/src/services/consent.service.ts @@ -0,0 +1,89 @@ +import prisma from '../config/database' +import { canTransition } from '../utils/transitions' +import { + CONSENT_TRANSITIONS, + ConsentRecordEntry, + GrantConsentData, + REQUIRED_CONSENT_PURPOSES, + WithdrawConsentData, +} from '../types/consent.types' + +export type WithdrawConsentResult = + | { kind: 'withdrawn'; record: ConsentRecordEntry } + | { kind: 'not-granted' } + | { kind: 'required-cannot-withdraw' } + +export class ConsentService { + async grant(userId: string, data: GrantConsentData): Promise { + const required = (REQUIRED_CONSENT_PURPOSES as readonly string[]).includes(data.purpose) + + return prisma.consentRecord.create({ + data: { + userId, + purpose: data.purpose, + required, + policyVersion: data.policyVersion, + status: 'granted', + source: data.source, + grantedAt: new Date(), + }, + }) as unknown as ConsentRecordEntry + } + + async withdraw(userId: string, data: WithdrawConsentData): Promise { + const latest = await this.getCurrentForPurpose(userId, data.purpose) + + if (!latest || !canTransition(CONSENT_TRANSITIONS, latest.status, 'withdrawn')) { + return { kind: 'not-granted' } + } + + if (latest.required) { + return { kind: 'required-cannot-withdraw' } + } + + const record = await prisma.consentRecord.create({ + data: { + userId, + purpose: data.purpose, + required: latest.required, + policyVersion: latest.policyVersion, + status: 'withdrawn', + source: data.source, + withdrawnAt: new Date(), + }, + }) + + return { kind: 'withdrawn', record: record as unknown as ConsentRecordEntry } + } + + async getCurrentForPurpose(userId: string, purpose: string): Promise { + return prisma.consentRecord.findFirst({ + where: { userId, purpose }, + orderBy: { createdAt: 'desc' }, + }) as unknown as ConsentRecordEntry | null + } + + async getCurrent(userId: string): Promise { + return prisma.consentRecord.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + distinct: ['purpose'], + }) as unknown as ConsentRecordEntry[] + } + + async getHistory(userId: string, purpose?: string): Promise { + return prisma.consentRecord.findMany({ + where: { userId, ...(purpose ? { purpose } : {}) }, + orderBy: { createdAt: 'asc' }, + }) as unknown as ConsentRecordEntry[] + } + + async hasAllRequiredGranted(userId: string): Promise { + const current = await this.getCurrent(userId) + const byPurpose = new Map(current.map(entry => [entry.purpose, entry])) + + return REQUIRED_CONSENT_PURPOSES.every(purpose => byPurpose.get(purpose)?.status === 'granted') + } +} + +export const consentService = new ConsentService() diff --git a/src/services/onboarding.service.ts b/src/services/onboarding.service.ts new file mode 100644 index 0000000..c2dbd08 --- /dev/null +++ b/src/services/onboarding.service.ts @@ -0,0 +1,90 @@ +import prisma from '../config/database' +import { consentService } from './consent.service' +import { + CURRENT_ONBOARDING_VERSION, + ONBOARDING_STEPS, + OnboardingProgressRecord, + OnboardingStep, + REQUIRED_ONBOARDING_STEPS, +} from '../types/onboarding.types' + +export type SaveStepResult = + | { kind: 'saved'; progress: OnboardingProgressRecord } + | { kind: 'already-completed'; progress: OnboardingProgressRecord } + +export type CompleteOnboardingResult = + | { kind: 'completed'; progress: OnboardingProgressRecord } + | { kind: 'already-completed'; progress: OnboardingProgressRecord } + | { kind: 'incomplete-steps'; missingSteps: OnboardingStep[] } + | { kind: 'missing-required-consent' } + +export class OnboardingService { + async getOrCreate(userId: string): Promise { + return prisma.onboardingProgress.upsert({ + where: { userId }, + update: {}, + create: { + userId, + version: CURRENT_ONBOARDING_VERSION, + currentStep: ONBOARDING_STEPS[0], + completedSteps: [], + }, + }) as unknown as OnboardingProgressRecord + } + + async saveStep(userId: string, step: OnboardingStep): Promise { + const existing = await prisma.onboardingProgress.findUnique({ where: { userId } }) + + if (existing && existing.status === 'completed') { + return { kind: 'already-completed', progress: existing as unknown as OnboardingProgressRecord } + } + + const completedSteps = existing + ? Array.from(new Set([...(existing.completedSteps as string[]), step])) + : [step] + + const progress = await prisma.onboardingProgress.upsert({ + where: { userId }, + update: { currentStep: step, completedSteps }, + create: { + userId, + version: CURRENT_ONBOARDING_VERSION, + currentStep: step, + completedSteps, + }, + }) + + return { kind: 'saved', progress: progress as unknown as OnboardingProgressRecord } + } + + async resume(userId: string): Promise { + return this.getOrCreate(userId) + } + + async complete(userId: string): Promise { + const progress = await this.getOrCreate(userId) + + if (progress.status === 'completed') { + return { kind: 'already-completed', progress } + } + + const missingSteps = REQUIRED_ONBOARDING_STEPS.filter(step => !progress.completedSteps.includes(step)) + if (missingSteps.length > 0) { + return { kind: 'incomplete-steps', missingSteps: [...missingSteps] } + } + + const requiredConsentsGranted = await consentService.hasAllRequiredGranted(userId) + if (!requiredConsentsGranted) { + return { kind: 'missing-required-consent' } + } + + const updated = await prisma.onboardingProgress.update({ + where: { userId }, + data: { status: 'completed', completedAt: new Date() }, + }) + + return { kind: 'completed', progress: updated as unknown as OnboardingProgressRecord } + } +} + +export const onboardingService = new OnboardingService() diff --git a/src/services/profile-serializer.ts b/src/services/profile-serializer.ts new file mode 100644 index 0000000..33d7a82 --- /dev/null +++ b/src/services/profile-serializer.ts @@ -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 } +} diff --git a/src/services/profile.service.ts b/src/services/profile.service.ts new file mode 100644 index 0000000..901ab2c --- /dev/null +++ b/src/services/profile.service.ts @@ -0,0 +1,66 @@ +import prisma from '../config/database' +import { LearnerProfileRecord, UpdateLearnerProfileData } from '../types/profile.types' +import { + toEmployerProfile, + toOwnerProfile, + toPrivateProfile, + toPublicProfile, +} from './profile-serializer' + +export class ProfileService { + async getOrCreateProfile(userId: string): Promise { + return prisma.learnerProfile.upsert({ + where: { userId }, + update: {}, + create: { userId }, + }) as unknown as LearnerProfileRecord + } + + async updateProfile(userId: string, data: UpdateLearnerProfileData): Promise { + return prisma.learnerProfile.upsert({ + where: { userId }, + update: data, + create: { userId, ...data }, + }) as unknown as LearnerProfileRecord + } + + async getOwnerView(userId: string) { + const profile = await this.getOrCreateProfile(userId) + + return toOwnerProfile(profile) + } + + async getEmployerView(userId: string) { + const profile = await prisma.learnerProfile.findUnique({ where: { userId } }) + if (!profile) { + return null + } + + return toEmployerProfile(profile as unknown as LearnerProfileRecord) + } + + async getPublicView(userId: string) { + const profile = await prisma.learnerProfile.findUnique({ where: { userId } }) + if (!profile) { + return null + } + + return toPublicProfile(profile as unknown as LearnerProfileRecord) + } + + async getPrivateView(userId: string) { + const [profile, user] = await Promise.all([ + this.getOrCreateProfile(userId), + prisma.user.findUnique({ + where: { id: userId }, + select: { status: true, isVerified: true, phoneVerifiedAt: true }, + }), + ]) + + if (!user) { + return null + } + + return toPrivateProfile(profile, user) + } +} diff --git a/src/types/consent.types.ts b/src/types/consent.types.ts new file mode 100644 index 0000000..9261703 --- /dev/null +++ b/src/types/consent.types.ts @@ -0,0 +1,50 @@ +import { TransitionMap } from '../utils/transitions' + +export const CONSENT_PURPOSES = [ + 'terms_of_service', + 'privacy_policy', + 'marketing_emails', + 'analytics', + 'data_sharing', +] as const +export type ConsentPurpose = typeof CONSENT_PURPOSES[number] + +export const REQUIRED_CONSENT_PURPOSES: readonly ConsentPurpose[] = [ + 'terms_of_service', + 'privacy_policy', +] + +export const CONSENT_STATUSES = ['granted', 'withdrawn'] as const +export type ConsentStatus = typeof CONSENT_STATUSES[number] + +export const CONSENT_TRANSITIONS: TransitionMap = { + granted: ['withdrawn'], + withdrawn: ['granted'], +} + +export const CONSENT_SOURCES = ['onboarding', 'settings', 'api'] as const +export type ConsentSource = typeof CONSENT_SOURCES[number] + +export interface ConsentRecordEntry { + id: string + userId: string + purpose: ConsentPurpose + required: boolean + policyVersion: string + status: ConsentStatus + source: ConsentSource + grantedAt: Date | null + withdrawnAt: Date | null + createdAt: Date +} + +export interface GrantConsentData { + purpose: ConsentPurpose + policyVersion: string + source: ConsentSource +} + +export interface WithdrawConsentData { + purpose: ConsentPurpose + source: ConsentSource +} diff --git a/src/types/onboarding.types.ts b/src/types/onboarding.types.ts new file mode 100644 index 0000000..fd69239 --- /dev/null +++ b/src/types/onboarding.types.ts @@ -0,0 +1,33 @@ +import { TransitionMap } from '../utils/transitions' + +export const ONBOARDING_STEPS = ['profile_basics', 'consent', 'preferences'] as const +export type OnboardingStep = typeof ONBOARDING_STEPS[number] + +export const REQUIRED_ONBOARDING_STEPS: readonly OnboardingStep[] = ['profile_basics', 'consent'] + +export const ONBOARDING_STATUSES = ['in_progress', 'completed'] as const +export type OnboardingStatus = typeof ONBOARDING_STATUSES[number] + +export const ONBOARDING_TRANSITIONS: TransitionMap = { + in_progress: ['completed'], + completed: [], +} + +export const CURRENT_ONBOARDING_VERSION = 'v1' + +export interface OnboardingProgressRecord { + id: string + userId: string + version: string + currentStep: OnboardingStep + completedSteps: OnboardingStep[] + status: OnboardingStatus + startedAt: Date + completedAt: Date | null + createdAt: Date + updatedAt: Date +} + +export interface SaveOnboardingStepData { + step: OnboardingStep +} diff --git a/src/types/profile.types.ts b/src/types/profile.types.ts new file mode 100644 index 0000000..4f49c4e --- /dev/null +++ b/src/types/profile.types.ts @@ -0,0 +1,84 @@ +export const LEARNER_LEVELS = ['beginner', 'intermediate', 'advanced', 'expert'] as const +export type LearnerLevel = typeof LEARNER_LEVELS[number] + +export const PROFILE_VISIBILITIES = ['private', 'employer', 'public'] as const +export type ProfileVisibility = typeof PROFILE_VISIBILITIES[number] + +// Ordered from most to least restrictive. A profile's `visibility` is the +// widest audience allowed to see its non-private fields; the owner can +// always see the full record regardless of this setting. +export const VISIBILITY_RANK: Record = { + private: 0, + employer: 1, + public: 2, +} + +export interface LearnerProfileRecord { + id: string + userId: string + displayName: string | null + bio: string | null + avatarUrl: string | null + country: string | null + timezone: string | null + languages: string[] + level: LearnerLevel + interests: string[] + goals: string[] + visibility: ProfileVisibility + createdAt: Date + updatedAt: Date +} + +export interface UpdateLearnerProfileData { + displayName?: string | null + bio?: string | null + avatarUrl?: string | null + country?: string | null + timezone?: string | null + languages?: string[] + level?: LearnerLevel + interests?: string[] + goals?: string[] + visibility?: ProfileVisibility +} + +// Private account fields, sourced from `User`, that must never appear in +// any profile serializer other than the internal/private one. +export interface AccountPrivateFields { + status: string + isVerified: boolean + phoneVerifiedAt: Date | null +} + +export interface ProfileCompletion { + percent: number + missingFields: string[] +} + +export interface OwnerProfileView extends LearnerProfileRecord { + completion: ProfileCompletion +} + +export type EmployerProfileView = + | (Pick & { visible: true }) + | { id: string; visible: false } + +export type PublicProfileView = + | (Pick & { visible: true }) + | { id: string; visible: false } + +export interface PrivateProfileView extends LearnerProfileRecord, AccountPrivateFields {} + +// Fields counted toward profile-completion percentage. `level` is excluded +// because it always has a default value and can never read as "empty". +export const PROFILE_COMPLETION_FIELDS = [ + 'displayName', + 'bio', + 'avatarUrl', + 'country', + 'timezone', + 'languages', + 'interests', + 'goals', +] as const diff --git a/src/utils/transitions.ts b/src/utils/transitions.ts new file mode 100644 index 0000000..68fb91a --- /dev/null +++ b/src/utils/transitions.ts @@ -0,0 +1,5 @@ +export type TransitionMap = Readonly> + +export function canTransition(map: TransitionMap, from: S, to: S): boolean { + return map[from]?.includes(to) ?? false +} diff --git a/tests/consent.controller.test.ts b/tests/consent.controller.test.ts new file mode 100644 index 0000000..2d2d36d --- /dev/null +++ b/tests/consent.controller.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ConsentController } from '../src/controllers/consent.controller' + +const { mockGetCurrent, mockGetHistory, mockGrant, mockWithdraw } = vi.hoisted(() => ({ + mockGetCurrent: vi.fn(), + mockGetHistory: vi.fn(), + mockGrant: vi.fn(), + mockWithdraw: vi.fn(), +})) + +vi.mock('../src/services/consent.service', () => ({ + consentService: { + getCurrent: mockGetCurrent, + getHistory: mockGetHistory, + grant: mockGrant, + withdraw: mockWithdraw, + }, +})) + +describe('ConsentController', () => { + let controller: ConsentController + let req: any + let res: any + + beforeEach(() => { + vi.clearAllMocks() + controller = new ConsentController() + req = { user: { id: 'user1' }, body: {}, query: {} } + res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } + }) + + describe('getCurrent', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + + await controller.getCurrent(req, res) + + expect(res.status).toHaveBeenCalledWith(401) + }) + + it('returns current consents on success', async () => { + mockGetCurrent.mockResolvedValue([{ purpose: 'terms_of_service', status: 'granted' }]) + + await controller.getCurrent(req, res) + + expect(res.status).toHaveBeenCalledWith(200) + }) + }) + + describe('getHistory', () => { + it('returns 400 on an invalid purpose filter', async () => { + req.query = { purpose: 'not_a_purpose' } + + await controller.getHistory(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns history filtered by purpose', async () => { + req.query = { purpose: 'analytics' } + mockGetHistory.mockResolvedValue([]) + + await controller.getHistory(req, res) + + expect(mockGetHistory).toHaveBeenCalledWith('user1', 'analytics') + expect(res.status).toHaveBeenCalledWith(200) + }) + }) + + describe('grant', () => { + it('returns 400 on an invalid purpose', async () => { + req.body = { purpose: 'not_a_purpose', policyVersion: 'v1', source: 'onboarding' } + + await controller.grant(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 400 when policyVersion is missing', async () => { + req.body = { purpose: 'analytics', source: 'onboarding' } + + await controller.grant(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('grants consent with a valid payload', async () => { + req.body = { purpose: 'analytics', policyVersion: 'v1', source: 'onboarding' } + mockGrant.mockResolvedValue({ purpose: 'analytics', status: 'granted' }) + + await controller.grant(req, res) + + expect(mockGrant).toHaveBeenCalledWith('user1', { purpose: 'analytics', policyVersion: 'v1', source: 'onboarding' }) + expect(res.status).toHaveBeenCalledWith(200) + }) + }) + + describe('withdraw', () => { + it('returns 400 on an invalid source', async () => { + req.body = { purpose: 'analytics', source: 'not_a_source' } + + await controller.withdraw(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 409 when consent was never granted', async () => { + req.body = { purpose: 'analytics', source: 'settings' } + mockWithdraw.mockResolvedValue({ kind: 'not-granted' }) + + await controller.withdraw(req, res) + + expect(res.status).toHaveBeenCalledWith(409) + }) + + it('returns 409 when consent is required', async () => { + req.body = { purpose: 'terms_of_service', source: 'settings' } + mockWithdraw.mockResolvedValue({ kind: 'required-cannot-withdraw' }) + + await controller.withdraw(req, res) + + expect(res.status).toHaveBeenCalledWith(409) + }) + + it('withdraws optional consent', async () => { + req.body = { purpose: 'analytics', source: 'settings' } + mockWithdraw.mockResolvedValue({ kind: 'withdrawn', record: { status: 'withdrawn' } }) + + await controller.withdraw(req, res) + + expect(res.status).toHaveBeenCalledWith(200) + }) + + it('returns 500 on unexpected error', async () => { + req.body = { purpose: 'analytics', source: 'settings' } + mockWithdraw.mockRejectedValue(new Error('db down')) + + await controller.withdraw(req, res) + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) +}) diff --git a/tests/consent.service.test.ts b/tests/consent.service.test.ts new file mode 100644 index 0000000..797cdfd --- /dev/null +++ b/tests/consent.service.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ConsentService } from '../src/services/consent.service' + +const { mockCreate, mockFindFirst, mockFindMany } = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockFindFirst: vi.fn(), + mockFindMany: vi.fn(), +})) + +vi.mock('../src/config/database', () => ({ + default: { + consentRecord: { + create: mockCreate, + findFirst: mockFindFirst, + findMany: mockFindMany, + }, + }, +})) + +describe('ConsentService', () => { + let service: ConsentService + + beforeEach(() => { + vi.clearAllMocks() + service = new ConsentService() + }) + + describe('grant', () => { + it('marks required purposes as required', async () => { + mockCreate.mockResolvedValue({ id: 'c1', purpose: 'terms_of_service', required: true, status: 'granted' }) + + await service.grant('user1', { purpose: 'terms_of_service', policyVersion: 'v1', source: 'onboarding' }) + + expect(mockCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ purpose: 'terms_of_service', required: true, status: 'granted' }), + }) + }) + + it('marks optional purposes as not required', async () => { + mockCreate.mockResolvedValue({ id: 'c2', purpose: 'marketing_emails', required: false, status: 'granted' }) + + await service.grant('user1', { purpose: 'marketing_emails', policyVersion: 'v1', source: 'settings' }) + + expect(mockCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ purpose: 'marketing_emails', required: false }), + }) + }) + + it('records a fresh row even when a grant for the purpose already exists (versioned re-consent)', async () => { + mockCreate.mockResolvedValue({ id: 'c3', purpose: 'analytics', required: false, status: 'granted' }) + + await service.grant('user1', { purpose: 'analytics', policyVersion: 'v2', source: 'settings' }) + await service.grant('user1', { purpose: 'analytics', policyVersion: 'v2', source: 'settings' }) + + expect(mockCreate).toHaveBeenCalledTimes(2) + }) + }) + + describe('withdraw', () => { + it('returns not-granted when there is no prior consent', async () => { + mockFindFirst.mockResolvedValue(null) + + const result = await service.withdraw('user1', { purpose: 'analytics', source: 'settings' }) + + expect(result).toEqual({ kind: 'not-granted' }) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it('returns not-granted when the latest record is already withdrawn', async () => { + mockFindFirst.mockResolvedValue({ purpose: 'analytics', required: false, status: 'withdrawn' }) + + const result = await service.withdraw('user1', { purpose: 'analytics', source: 'settings' }) + + expect(result).toEqual({ kind: 'not-granted' }) + }) + + it('blocks withdrawal of required consent', async () => { + mockFindFirst.mockResolvedValue({ purpose: 'terms_of_service', required: true, status: 'granted', policyVersion: 'v1' }) + + const result = await service.withdraw('user1', { purpose: 'terms_of_service', source: 'settings' }) + + expect(result).toEqual({ kind: 'required-cannot-withdraw' }) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it('withdraws granted optional consent, carrying forward its policy version', async () => { + mockFindFirst.mockResolvedValue({ purpose: 'marketing_emails', required: false, status: 'granted', policyVersion: 'v3' }) + mockCreate.mockResolvedValue({ id: 'c4', purpose: 'marketing_emails', status: 'withdrawn', policyVersion: 'v3' }) + + const result = await service.withdraw('user1', { purpose: 'marketing_emails', source: 'settings' }) + + expect(mockCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ purpose: 'marketing_emails', status: 'withdrawn', policyVersion: 'v3' }), + }) + expect(result.kind).toBe('withdrawn') + }) + }) + + describe('hasAllRequiredGranted', () => { + it('is true when every required purpose is currently granted', async () => { + mockFindMany.mockResolvedValue([ + { purpose: 'terms_of_service', status: 'granted' }, + { purpose: 'privacy_policy', status: 'granted' }, + ]) + + expect(await service.hasAllRequiredGranted('user1')).toBe(true) + }) + + it('is false when a required purpose was withdrawn', async () => { + mockFindMany.mockResolvedValue([ + { purpose: 'terms_of_service', status: 'granted' }, + { purpose: 'privacy_policy', status: 'withdrawn' }, + ]) + + expect(await service.hasAllRequiredGranted('user1')).toBe(false) + }) + + it('is false when a required purpose has never been addressed', async () => { + mockFindMany.mockResolvedValue([{ purpose: 'terms_of_service', status: 'granted' }]) + + expect(await service.hasAllRequiredGranted('user1')).toBe(false) + }) + }) +}) diff --git a/tests/onboarding.controller.test.ts b/tests/onboarding.controller.test.ts new file mode 100644 index 0000000..4d63bfe --- /dev/null +++ b/tests/onboarding.controller.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { OnboardingController } from '../src/controllers/onboarding.controller' + +const { mockResume, mockSaveStep, mockComplete } = vi.hoisted(() => ({ + mockResume: vi.fn(), + mockSaveStep: vi.fn(), + mockComplete: vi.fn(), +})) + +vi.mock('../src/services/onboarding.service', () => ({ + onboardingService: { + resume: mockResume, + saveStep: mockSaveStep, + complete: mockComplete, + }, +})) + +describe('OnboardingController', () => { + let controller: OnboardingController + let req: any + let res: any + + beforeEach(() => { + vi.clearAllMocks() + controller = new OnboardingController() + req = { user: { id: 'user1' }, body: {} } + res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } + }) + + describe('getProgress', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + + await controller.getProgress(req, res) + + expect(res.status).toHaveBeenCalledWith(401) + }) + + it('returns progress on success', async () => { + mockResume.mockResolvedValue({ userId: 'user1', status: 'in_progress' }) + + await controller.getProgress(req, res) + + expect(res.status).toHaveBeenCalledWith(200) + }) + + it('returns 500 on unexpected error', async () => { + mockResume.mockRejectedValue(new Error('db down')) + + await controller.getProgress(req, res) + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) + + describe('saveStep', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + req.body = { step: 'profile_basics' } + + await controller.saveStep(req, res) + + expect(res.status).toHaveBeenCalledWith(401) + }) + + it('returns 400 on an unknown step', async () => { + req.body = { step: 'not_a_step' } + + await controller.saveStep(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 400 when step is missing', async () => { + req.body = {} + + await controller.saveStep(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('saves a valid step', async () => { + req.body = { step: 'profile_basics' } + mockSaveStep.mockResolvedValue({ kind: 'saved', progress: { currentStep: 'profile_basics' } }) + + await controller.saveStep(req, res) + + expect(mockSaveStep).toHaveBeenCalledWith('user1', 'profile_basics') + expect(res.status).toHaveBeenCalledWith(200) + }) + + it('returns 409 when onboarding is already completed', async () => { + req.body = { step: 'preferences' } + mockSaveStep.mockResolvedValue({ kind: 'already-completed', progress: { status: 'completed' } }) + + await controller.saveStep(req, res) + + expect(res.status).toHaveBeenCalledWith(409) + }) + + it('returns 500 on unexpected error', async () => { + req.body = { step: 'profile_basics' } + mockSaveStep.mockRejectedValue(new Error('db down')) + + await controller.saveStep(req, res) + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) + + describe('complete', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + + await controller.complete(req, res) + + expect(res.status).toHaveBeenCalledWith(401) + }) + + it('returns 409 when required steps are missing', async () => { + mockComplete.mockResolvedValue({ kind: 'incomplete-steps', missingSteps: ['consent'] }) + + await controller.complete(req, res) + + expect(res.status).toHaveBeenCalledWith(409) + }) + + it('returns 409 when required consent is missing', async () => { + mockComplete.mockResolvedValue({ kind: 'missing-required-consent' }) + + await controller.complete(req, res) + + expect(res.status).toHaveBeenCalledWith(409) + }) + + it('returns 200 on successful completion', async () => { + mockComplete.mockResolvedValue({ kind: 'completed', progress: { status: 'completed' } }) + + await controller.complete(req, res) + + expect(res.status).toHaveBeenCalledWith(200) + }) + + it('returns 500 on unexpected error', async () => { + mockComplete.mockRejectedValue(new Error('db down')) + + await controller.complete(req, res) + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) +}) diff --git a/tests/onboarding.service.test.ts b/tests/onboarding.service.test.ts new file mode 100644 index 0000000..b347c9a --- /dev/null +++ b/tests/onboarding.service.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { OnboardingService } from '../src/services/onboarding.service' + +const { mockUpsert, mockFindUnique, mockUpdate, mockHasAllRequiredGranted } = vi.hoisted(() => ({ + mockUpsert: vi.fn(), + mockFindUnique: vi.fn(), + mockUpdate: vi.fn(), + mockHasAllRequiredGranted: vi.fn(), +})) + +vi.mock('../src/config/database', () => ({ + default: { + onboardingProgress: { + upsert: mockUpsert, + findUnique: mockFindUnique, + update: mockUpdate, + }, + }, +})) + +vi.mock('../src/services/consent.service', () => ({ + consentService: { + hasAllRequiredGranted: mockHasAllRequiredGranted, + }, +})) + +const baseProgress = { + id: 'onb1', + userId: 'user1', + version: 'v1', + currentStep: 'profile_basics', + completedSteps: ['profile_basics'], + status: 'in_progress', + startedAt: new Date(), + completedAt: null, + createdAt: new Date(), + updatedAt: new Date(), +} + +describe('OnboardingService', () => { + let service: OnboardingService + + beforeEach(() => { + vi.clearAllMocks() + service = new OnboardingService() + }) + + describe('getOrCreate / resume', () => { + it('upserts a default in-progress row so resume is deterministic', async () => { + mockUpsert.mockResolvedValue({ ...baseProgress, completedSteps: [] }) + + const result = await service.resume('user1') + + expect(mockUpsert).toHaveBeenCalledWith({ + where: { userId: 'user1' }, + update: {}, + create: { userId: 'user1', version: 'v1', currentStep: 'profile_basics', completedSteps: [] }, + }) + expect(result.status).toBe('in_progress') + }) + }) + + describe('saveStep', () => { + it('creates a new row on first save', async () => { + mockFindUnique.mockResolvedValue(null) + mockUpsert.mockResolvedValue({ ...baseProgress, completedSteps: ['profile_basics'] }) + + const result = await service.saveStep('user1', 'profile_basics') + + expect(mockUpsert).toHaveBeenCalledWith({ + where: { userId: 'user1' }, + update: { currentStep: 'profile_basics', completedSteps: ['profile_basics'] }, + create: { userId: 'user1', version: 'v1', currentStep: 'profile_basics', completedSteps: ['profile_basics'] }, + }) + expect(result.kind).toBe('saved') + }) + + it('does not duplicate a step that is saved twice (idempotent)', async () => { + mockFindUnique.mockResolvedValue({ ...baseProgress, completedSteps: ['profile_basics'] }) + mockUpsert.mockResolvedValue(baseProgress) + + await service.saveStep('user1', 'profile_basics') + + expect(mockUpsert).toHaveBeenCalledWith(expect.objectContaining({ + update: { currentStep: 'profile_basics', completedSteps: ['profile_basics'] }, + })) + }) + + it('appends a new step onto existing progress', async () => { + mockFindUnique.mockResolvedValue({ ...baseProgress, completedSteps: ['profile_basics'] }) + mockUpsert.mockResolvedValue({ ...baseProgress, completedSteps: ['profile_basics', 'consent'] }) + + await service.saveStep('user1', 'consent') + + expect(mockUpsert).toHaveBeenCalledWith(expect.objectContaining({ + update: { currentStep: 'consent', completedSteps: ['profile_basics', 'consent'] }, + })) + }) + + it('refuses to modify a completed onboarding record', async () => { + mockFindUnique.mockResolvedValue({ ...baseProgress, status: 'completed' }) + + const result = await service.saveStep('user1', 'preferences') + + expect(result.kind).toBe('already-completed') + expect(mockUpsert).not.toHaveBeenCalled() + }) + }) + + describe('complete', () => { + it('returns already-completed when already completed', async () => { + mockUpsert.mockResolvedValue({ ...baseProgress, status: 'completed' }) + + const result = await service.complete('user1') + + expect(result.kind).toBe('already-completed') + expect(mockUpdate).not.toHaveBeenCalled() + }) + + it('blocks completion when required steps are missing', async () => { + mockUpsert.mockResolvedValue({ ...baseProgress, completedSteps: ['profile_basics'] }) + + const result = await service.complete('user1') + + expect(result).toEqual({ kind: 'incomplete-steps', missingSteps: ['consent'] }) + expect(mockHasAllRequiredGranted).not.toHaveBeenCalled() + }) + + it('blocks completion when required consent is missing', async () => { + mockUpsert.mockResolvedValue({ ...baseProgress, completedSteps: ['profile_basics', 'consent'] }) + mockHasAllRequiredGranted.mockResolvedValue(false) + + const result = await service.complete('user1') + + expect(result).toEqual({ kind: 'missing-required-consent' }) + expect(mockUpdate).not.toHaveBeenCalled() + }) + + it('completes when all required steps and consents are satisfied', async () => { + mockUpsert.mockResolvedValue({ ...baseProgress, completedSteps: ['profile_basics', 'consent'] }) + mockHasAllRequiredGranted.mockResolvedValue(true) + mockUpdate.mockResolvedValue({ ...baseProgress, status: 'completed', completedAt: new Date() }) + + const result = await service.complete('user1') + + expect(mockUpdate).toHaveBeenCalledWith({ + where: { userId: 'user1' }, + data: { status: 'completed', completedAt: expect.any(Date) }, + }) + expect(result.kind).toBe('completed') + }) + }) +}) diff --git a/tests/profile-serializer.test.ts b/tests/profile-serializer.test.ts new file mode 100644 index 0000000..4bb68fd --- /dev/null +++ b/tests/profile-serializer.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from 'vitest' +import { + computeProfileCompletion, + toEmployerProfile, + toOwnerProfile, + toPrivateProfile, + toPublicProfile, +} from '../src/services/profile-serializer' +import { LearnerProfileRecord } from '../src/types/profile.types' + +const baseProfile: LearnerProfileRecord = { + id: 'profile1', + userId: 'user1', + displayName: 'Ada Learner', + bio: 'Building on Stellar', + avatarUrl: 'https://cdn.example.com/a.png', + country: 'NG', + timezone: 'Africa/Lagos', + languages: ['en', 'ig'], + level: 'intermediate', + interests: ['blockchain'], + goals: ['finish course'], + visibility: 'public', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-02'), +} + +describe('computeProfileCompletion', () => { + it('is 100% when every tracked field is filled', () => { + expect(computeProfileCompletion(baseProfile)).toEqual({ percent: 100, missingFields: [] }) + }) + + it('is deterministic for the same input', () => { + const first = computeProfileCompletion(baseProfile) + const second = computeProfileCompletion(baseProfile) + + expect(first).toEqual(second) + }) + + it('lists missing fields for an empty profile', () => { + const empty: LearnerProfileRecord = { + ...baseProfile, + displayName: null, + bio: null, + avatarUrl: null, + country: null, + timezone: null, + languages: [], + interests: [], + goals: [], + } + + const result = computeProfileCompletion(empty) + + expect(result.percent).toBe(0) + expect(result.missingFields).toEqual([ + 'displayName', 'bio', 'avatarUrl', 'country', 'timezone', 'languages', 'interests', 'goals', + ]) + }) + + it('does not count level, since it always has a default', () => { + const result = computeProfileCompletion(baseProfile) + + expect(result.missingFields).not.toContain('level') + }) +}) + +describe('toOwnerProfile', () => { + it('always returns every field regardless of visibility', () => { + const privateProfile: LearnerProfileRecord = { ...baseProfile, visibility: 'private' } + + const view = toOwnerProfile(privateProfile) + + expect(view.displayName).toBe('Ada Learner') + expect(view.bio).toBe('Building on Stellar') + expect(view.completion.percent).toBe(100) + }) +}) + +describe('toEmployerProfile', () => { + it('redacts fields when visibility is private', () => { + const profile: LearnerProfileRecord = { ...baseProfile, visibility: 'private' } + + const view = toEmployerProfile(profile) + + expect(view).toEqual({ id: profile.id, visible: false }) + }) + + it('exposes fields when visibility is employer', () => { + const profile: LearnerProfileRecord = { ...baseProfile, visibility: 'employer' } + + const view = toEmployerProfile(profile) + + expect(view.visible).toBe(true) + if (view.visible) { + expect(view.displayName).toBe('Ada Learner') + } + }) + + it('exposes fields when visibility is public', () => { + const view = toEmployerProfile(baseProfile) + + expect(view.visible).toBe(true) + }) + + it('never includes account-private fields', () => { + const view = toEmployerProfile({ ...baseProfile, visibility: 'employer' }) as Record + + expect(view).not.toHaveProperty('status') + expect(view).not.toHaveProperty('isVerified') + expect(view).not.toHaveProperty('phoneVerifiedAt') + }) +}) + +describe('toPublicProfile', () => { + it('redacts fields unless visibility is public', () => { + const employerOnly: LearnerProfileRecord = { ...baseProfile, visibility: 'employer' } + + expect(toPublicProfile(employerOnly)).toEqual({ id: employerOnly.id, visible: false }) + }) + + it('exposes only the public-safe subset when visibility is public', () => { + const view = toPublicProfile(baseProfile) + + expect(view.visible).toBe(true) + if (view.visible) { + expect(view).not.toHaveProperty('goals') + expect(view).not.toHaveProperty('timezone') + expect(view).not.toHaveProperty('languages') + } + }) + + it('never includes account-private fields', () => { + const view = toPublicProfile(baseProfile) as Record + + expect(view).not.toHaveProperty('status') + expect(view).not.toHaveProperty('isVerified') + expect(view).not.toHaveProperty('phoneVerifiedAt') + }) +}) + +describe('toPrivateProfile', () => { + it('is the only serializer that carries account-private fields', () => { + const view = toPrivateProfile(baseProfile, { + status: 'ACTIVE', + isVerified: true, + phoneVerifiedAt: null, + }) + + expect(view.status).toBe('ACTIVE') + expect(view.isVerified).toBe(true) + }) +}) diff --git a/tests/profile.controller.test.ts b/tests/profile.controller.test.ts new file mode 100644 index 0000000..fd6ca38 --- /dev/null +++ b/tests/profile.controller.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ProfileController } from '../src/controllers/profile.controller' + +const { mockGetOwnerView, mockUpdateProfile, mockGetEmployerView, mockGetPublicView } = vi.hoisted(() => ({ + mockGetOwnerView: vi.fn(), + mockUpdateProfile: vi.fn(), + mockGetEmployerView: vi.fn(), + mockGetPublicView: vi.fn(), +})) + +vi.mock('../src/services/profile.service', () => ({ + ProfileService: class { + getOwnerView = mockGetOwnerView + updateProfile = mockUpdateProfile + getEmployerView = mockGetEmployerView + getPublicView = mockGetPublicView + }, +})) + +describe('ProfileController', () => { + let controller: ProfileController + let req: any + let res: any + + beforeEach(() => { + vi.clearAllMocks() + controller = new ProfileController() + req = { user: { id: 'user1' }, body: {}, params: {} } + res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } + }) + + describe('getMyProfile', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + + await controller.getMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(401) + }) + + it('returns the owner view on success', async () => { + mockGetOwnerView.mockResolvedValue({ id: 'profile1', displayName: 'Ada' }) + + await controller.getMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ data: { id: 'profile1', displayName: 'Ada' } }) + }) + + it('returns 500 on unexpected error', async () => { + mockGetOwnerView.mockRejectedValue(new Error('db down')) + + await controller.getMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) + + describe('updateMyProfile', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + req.body = { displayName: 'Ada' } + + await controller.updateMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(401) + }) + + it('returns 400 on empty body', async () => { + req.body = {} + + await controller.updateMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 400 on unknown fields (stable, closed schema)', async () => { + req.body = { notARealField: true } + + await controller.updateMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 400 on invalid level', async () => { + req.body = { level: 'not-a-level' } + + await controller.updateMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 400 on invalid visibility', async () => { + req.body = { visibility: 'friends' } + + await controller.updateMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('accepts a partial update', async () => { + req.body = { displayName: 'Ada' } + mockUpdateProfile.mockResolvedValue({ id: 'profile1', displayName: 'Ada' }) + mockGetOwnerView.mockResolvedValue({ id: 'profile1', displayName: 'Ada' }) + + await controller.updateMyProfile(req, res) + + expect(mockUpdateProfile).toHaveBeenCalledWith('user1', { displayName: 'Ada' }) + expect(res.status).toHaveBeenCalledWith(200) + }) + + it('returns 500 on unexpected error', async () => { + req.body = { displayName: 'Ada' } + mockUpdateProfile.mockRejectedValue(new Error('db down')) + + await controller.updateMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) + + describe('getProfileById', () => { + it('returns the owner view when the caller requests their own profile', async () => { + req.params = { id: 'user1' } + mockGetOwnerView.mockResolvedValue({ id: 'profile1', displayName: 'Ada' }) + + await controller.getProfileById(req, res) + + expect(mockGetOwnerView).toHaveBeenCalledWith('user1') + expect(res.status).toHaveBeenCalledWith(200) + }) + + it('returns the employer view for an employer caller viewing someone else', async () => { + req.user = { id: 'employer1', role: 'employer' } + req.params = { id: 'user2' } + mockGetEmployerView.mockResolvedValue({ id: 'profile2', visible: true }) + + await controller.getProfileById(req, res) + + expect(mockGetEmployerView).toHaveBeenCalledWith('user2') + expect(mockGetPublicView).not.toHaveBeenCalled() + }) + + it('returns the public view for an anonymous caller', async () => { + req.user = undefined + req.params = { id: 'user2' } + mockGetPublicView.mockResolvedValue({ id: 'profile2', visible: true }) + + await controller.getProfileById(req, res) + + expect(mockGetPublicView).toHaveBeenCalledWith('user2') + }) + + it('returns 404 when the profile is not found', async () => { + req.user = undefined + req.params = { id: 'missing' } + mockGetPublicView.mockResolvedValue(null) + + await controller.getProfileById(req, res) + + expect(res.status).toHaveBeenCalledWith(404) + }) + + it('returns 500 on unexpected error', async () => { + req.user = undefined + req.params = { id: 'user2' } + mockGetPublicView.mockRejectedValue(new Error('db down')) + + await controller.getProfileById(req, res) + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) +}) diff --git a/tests/profile.service.test.ts b/tests/profile.service.test.ts new file mode 100644 index 0000000..4f2bdfc --- /dev/null +++ b/tests/profile.service.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ProfileService } from '../src/services/profile.service' + +const { mockUpsert, mockFindUnique, mockUserFindUnique } = vi.hoisted(() => ({ + mockUpsert: vi.fn(), + mockFindUnique: vi.fn(), + mockUserFindUnique: vi.fn(), +})) + +vi.mock('../src/config/database', () => ({ + default: { + learnerProfile: { + upsert: mockUpsert, + findUnique: mockFindUnique, + }, + user: { + findUnique: mockUserFindUnique, + }, + }, +})) + +const baseProfile = { + id: 'profile1', + userId: 'user1', + displayName: 'Ada', + bio: null, + avatarUrl: null, + country: null, + timezone: null, + languages: [], + level: 'beginner', + interests: [], + goals: [], + visibility: 'private', + createdAt: new Date(), + updatedAt: new Date(), +} + +describe('ProfileService', () => { + let service: ProfileService + + beforeEach(() => { + vi.clearAllMocks() + service = new ProfileService() + }) + + describe('getOrCreateProfile', () => { + it('upserts a default row so every learner gets a deterministic starting profile', async () => { + mockUpsert.mockResolvedValue(baseProfile) + + const result = await service.getOrCreateProfile('user1') + + expect(mockUpsert).toHaveBeenCalledWith({ + where: { userId: 'user1' }, + update: {}, + create: { userId: 'user1' }, + }) + expect(result).toEqual(baseProfile) + }) + }) + + describe('updateProfile', () => { + it('preserves omitted fields via a partial upsert', async () => { + mockUpsert.mockResolvedValue({ ...baseProfile, displayName: 'New Name' }) + + const result = await service.updateProfile('user1', { displayName: 'New Name' }) + + expect(mockUpsert).toHaveBeenCalledWith({ + where: { userId: 'user1' }, + update: { displayName: 'New Name' }, + create: { userId: 'user1', displayName: 'New Name' }, + }) + expect(result.displayName).toBe('New Name') + }) + }) + + describe('getOwnerView', () => { + it('returns the full profile with a completion summary', async () => { + mockUpsert.mockResolvedValue(baseProfile) + + const result = await service.getOwnerView('user1') + + expect(result.displayName).toBe('Ada') + expect(result.completion).toBeDefined() + }) + }) + + describe('getEmployerView', () => { + it('returns null when the profile does not exist', async () => { + mockFindUnique.mockResolvedValue(null) + + const result = await service.getEmployerView('missing') + + expect(result).toBeNull() + }) + + it('redacts fields when visibility is below employer', async () => { + mockFindUnique.mockResolvedValue(baseProfile) + + const result = await service.getEmployerView('user1') + + expect(result).toEqual({ id: 'profile1', visible: false }) + }) + }) + + describe('getPublicView', () => { + it('redacts fields when visibility is below public', async () => { + mockFindUnique.mockResolvedValue(baseProfile) + + const result = await service.getPublicView('user1') + + expect(result).toEqual({ id: 'profile1', visible: false }) + }) + + it('exposes the public subset when visibility is public', async () => { + mockFindUnique.mockResolvedValue({ ...baseProfile, visibility: 'public', displayName: 'Ada' }) + + const result = await service.getPublicView('user1') + + expect(result).toMatchObject({ visible: true, displayName: 'Ada' }) + expect(result).not.toHaveProperty('goals') + }) + }) + + describe('getPrivateView', () => { + it('returns null when the account does not exist', async () => { + mockUpsert.mockResolvedValue(baseProfile) + mockUserFindUnique.mockResolvedValue(null) + + const result = await service.getPrivateView('missing') + + expect(result).toBeNull() + }) + + it('joins account-private fields onto the full profile', async () => { + mockUpsert.mockResolvedValue(baseProfile) + mockUserFindUnique.mockResolvedValue({ status: 'ACTIVE', isVerified: true, phoneVerifiedAt: null }) + + const result = await service.getPrivateView('user1') + + expect(result).toMatchObject({ displayName: 'Ada', status: 'ACTIVE', isVerified: true }) + }) + }) +}) diff --git a/tests/transitions.test.ts b/tests/transitions.test.ts new file mode 100644 index 0000000..785f579 --- /dev/null +++ b/tests/transitions.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { canTransition } from '../src/utils/transitions' + +type Status = 'a' | 'b' | 'c' + +const map = { + a: ['b'], + b: ['c'], + c: [], +} as const + +describe('canTransition', () => { + it('allows a transition present in the map', () => { + expect(canTransition(map, 'a', 'b')).toBe(true) + }) + + it('rejects a transition not present in the map', () => { + expect(canTransition(map, 'a', 'c')).toBe(false) + }) + + it('rejects any transition out of a terminal state', () => { + expect(canTransition(map, 'c', 'a' as Status)).toBe(false) + }) + + it('rejects a no-op transition unless explicitly listed', () => { + expect(canTransition(map, 'a', 'a')).toBe(false) + }) +})