diff --git a/prisma/migrations/20260718000000_add_learner_preferences/migration.sql b/prisma/migrations/20260718000000_add_learner_preferences/migration.sql new file mode 100644 index 0000000..3c14607 --- /dev/null +++ b/prisma/migrations/20260718000000_add_learner_preferences/migration.sql @@ -0,0 +1,45 @@ +-- CreateTable +CREATE TABLE "learner_preferences" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "locale" TEXT NOT NULL DEFAULT 'en-US', + "timezone" TEXT NOT NULL DEFAULT 'UTC', + "lowDataMode" BOOLEAN NOT NULL DEFAULT false, + "highContrast" BOOLEAN NOT NULL DEFAULT false, + "reduceMotion" BOOLEAN NOT NULL DEFAULT false, + "screenReaderOptimized" BOOLEAN NOT NULL DEFAULT false, + "textSize" TEXT NOT NULL DEFAULT 'medium', + "preferredDifficulty" TEXT NOT NULL DEFAULT 'beginner', + "preferredCategories" TEXT[] DEFAULT ARRAY[]::TEXT[], + "profileVisibility" TEXT NOT NULL DEFAULT 'public', + "analyticsConsent" BOOLEAN NOT NULL DEFAULT false, + "dataSharingConsent" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "learner_preferences_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "preference_audit_logs" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "field" TEXT NOT NULL, + "oldValue" TEXT, + "newValue" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "preference_audit_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "learner_preferences_userId_key" ON "learner_preferences"("userId"); + +-- CreateIndex +CREATE INDEX "preference_audit_logs_userId_createdAt_idx" ON "preference_audit_logs"("userId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "learner_preferences" ADD CONSTRAINT "learner_preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "preference_audit_logs" ADD CONSTRAINT "preference_audit_logs_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 90ab33a..8bca838 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,12 +29,60 @@ model User { referredBy Referral? @relation("Referree") verificationTokens VerificationToken[] emailDeliveries EmailDelivery[] + learnerPreference LearnerPreference? + preferenceAuditLogs PreferenceAuditLog[] sessions Session[] audits AuditLog[] @@map("users") } +model LearnerPreference { + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // Locale & regional + locale String @default("en-US") + timezone String @default("UTC") + + // Connectivity + lowDataMode Boolean @default(false) + + // Accessibility + highContrast Boolean @default(false) + reduceMotion Boolean @default(false) + screenReaderOptimized Boolean @default(false) + textSize String @default("medium") // small, medium, large, extra_large + + // Content + preferredDifficulty String @default("beginner") // beginner, intermediate, advanced + preferredCategories String[] @default([]) + + // Privacy (authoritative source for privacy-impacting preferences) + profileVisibility String @default("public") // public, private, connections + analyticsConsent Boolean @default(false) + dataSharingConsent Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("learner_preferences") +} + +model PreferenceAuditLog { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + field String + oldValue String? + newValue String? + createdAt DateTime @default(now()) + + @@index([userId, createdAt]) + @@map("preference_audit_logs") +} + model Session { id String @id @default(uuid()) userId String diff --git a/src/controllers/preference.controller.ts b/src/controllers/preference.controller.ts new file mode 100644 index 0000000..df35f07 --- /dev/null +++ b/src/controllers/preference.controller.ts @@ -0,0 +1,161 @@ +import { Request, Response } from 'express' +import { z } from 'zod' +import { PreferenceService } from '../services/preference.service' +import { + DIFFICULTY_LEVELS, + PROFILE_VISIBILITIES, + SUPPORTED_LOCALES, + TEXT_SIZES, +} from '../types/preference.types' + +const preferenceService = new PreferenceService() + +const isValidTimezone = (value: string): boolean => { + try { + Intl.DateTimeFormat(undefined, { timeZone: value }) + + return true + } catch { + return false + } +} + +const updatePreferencesSchema = z + .object({ + locale: z.enum(SUPPORTED_LOCALES, { + errorMap: () => ({ message: `Locale must be one of: ${SUPPORTED_LOCALES.join(', ')}` }), + }).optional(), + timezone: z.string().refine(isValidTimezone, { message: 'Invalid IANA timezone' }).optional(), + lowDataMode: z.boolean().optional(), + highContrast: z.boolean().optional(), + reduceMotion: z.boolean().optional(), + screenReaderOptimized: z.boolean().optional(), + textSize: z.enum(TEXT_SIZES, { + errorMap: () => ({ message: `Text size must be one of: ${TEXT_SIZES.join(', ')}` }), + }).optional(), + preferredDifficulty: z.enum(DIFFICULTY_LEVELS, { + errorMap: () => ({ message: `Difficulty must be one of: ${DIFFICULTY_LEVELS.join(', ')}` }), + }).optional(), + preferredCategories: z.array(z.string().min(1)).max(50).optional(), + profileVisibility: z.enum(PROFILE_VISIBILITIES, { + errorMap: () => ({ message: `Profile visibility must be one of: ${PROFILE_VISIBILITIES.join(', ')}` }), + }).optional(), + analyticsConsent: z.boolean().optional(), + dataSharingConsent: z.boolean().optional(), + }) + .strict() + .refine(data => Object.keys(data).length > 0, { message: 'At least one preference field is required' }) + +export class PreferenceController { + /** + * @openapi + * /users/me/preferences: + * get: + * summary: Get current authenticated user's learner preferences + * tags: [Preferences] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Preferences retrieved successfully + * 401: + * description: Unauthorized + */ + async getPreferences(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const preferences = await preferenceService.getPreferences(userId) + + res.status(200).json({ data: preferences }) + } catch (error) { + console.error('Get preferences error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /users/me/preferences: + * patch: + * summary: Partially update current authenticated user's learner preferences + * tags: [Preferences] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * locale: + * type: string + * timezone: + * type: string + * lowDataMode: + * type: boolean + * highContrast: + * type: boolean + * reduceMotion: + * type: boolean + * screenReaderOptimized: + * type: boolean + * textSize: + * type: string + * preferredDifficulty: + * type: string + * preferredCategories: + * type: array + * items: + * type: string + * profileVisibility: + * type: string + * analyticsConsent: + * type: boolean + * dataSharingConsent: + * type: boolean + * responses: + * 200: + * description: Preferences updated successfully + * 400: + * description: Validation failed + * 401: + * description: Unauthorized + */ + async updatePreferences(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const validation = updatePreferencesSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const preferences = await preferenceService.updatePreferences(userId, validation.data) + + res.status(200).json({ + message: 'Preferences updated successfully', + data: preferences, + }) + } catch (error) { + console.error('Update preferences error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } +} diff --git a/src/routes/v1/users.routes.ts b/src/routes/v1/users.routes.ts index 690f289..87b9633 100644 --- a/src/routes/v1/users.routes.ts +++ b/src/routes/v1/users.routes.ts @@ -1,15 +1,21 @@ import express, { Router } from 'express' import { UserController } from '../../controllers/user.controller' +import { PreferenceController } from '../../controllers/preference.controller' import { authenticate } 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() router.get('/me', authenticate, userController.getCurrentUser.bind(userController)) router.patch('/me', authenticate, validateProfileUpdate, userController.updateProfile.bind(userController)) +router.get('/me/preferences', authenticate, preferenceController.getPreferences.bind(preferenceController)) + +router.patch('/me/preferences', authenticate, preferenceController.updatePreferences.bind(preferenceController)) + router.get('/:id', userController.getUserById.bind(userController)) router.patch('/password', authenticate, validatePasswordChange, userController.changePassword.bind(userController)) diff --git a/src/services/preference.service.ts b/src/services/preference.service.ts new file mode 100644 index 0000000..87a57e4 --- /dev/null +++ b/src/services/preference.service.ts @@ -0,0 +1,62 @@ +import prisma from '../config/database' +import { PRIVACY_IMPACTING_FIELDS, UpdateLearnerPreferencesData } from '../types/preference.types' + +export class PreferenceService { + /** + * Fetch a learner's preferences, creating the default row on first access. + * Defaults are deterministic and come from the Prisma schema, so every + * learner without an explicit override sees the same values. + */ + async getPreferences(userId: string) { + return prisma.learnerPreference.upsert({ + where: { userId }, + update: {}, + create: { userId }, + }) + } + + /** + * Partially update a learner's preferences. Omitted fields are left + * untouched, unknown fields are rejected by the controller-level schema + * before this is called, and privacy-impacting fields are audited. + * + * The upsert is atomic at the database level, so concurrent updates from + * the same user cannot create duplicate rows or silently drop a write. + */ + async updatePreferences(userId: string, data: UpdateLearnerPreferencesData) { + const before = await prisma.learnerPreference.findUnique({ where: { userId } }) + + const updated = await prisma.learnerPreference.upsert({ + where: { userId }, + update: data, + create: { userId, ...data }, + }) + + await this.auditPrivacyChanges(userId, before, updated, data) + + return updated + } + + private async auditPrivacyChanges( + userId: string, + before: { [key: string]: unknown } | null, + after: { [key: string]: unknown }, + changedFields: UpdateLearnerPreferencesData + ): Promise { + const entries = PRIVACY_IMPACTING_FIELDS + .filter(field => field in changedFields) + .map(field => ({ + userId, + field, + oldValue: before ? String(before[field]) : null, + newValue: String(after[field]), + })) + .filter(entry => entry.oldValue !== entry.newValue) + + if (entries.length === 0) { + return + } + + await prisma.preferenceAuditLog.createMany({ data: entries }) + } +} diff --git a/src/types/preference.types.ts b/src/types/preference.types.ts new file mode 100644 index 0000000..fb33ee5 --- /dev/null +++ b/src/types/preference.types.ts @@ -0,0 +1,55 @@ +export const SUPPORTED_LOCALES = [ + 'en-US', 'en-GB', 'fr-FR', 'es-ES', 'pt-BR', 'sw-KE', 'ar-SA', 'de-DE', +] as const + +export type SupportedLocale = typeof SUPPORTED_LOCALES[number] + +export const TEXT_SIZES = ['small', 'medium', 'large', 'extra_large'] as const +export type TextSize = typeof TEXT_SIZES[number] + +export const DIFFICULTY_LEVELS = ['beginner', 'intermediate', 'advanced'] as const +export type DifficultyLevel = typeof DIFFICULTY_LEVELS[number] + +export const PROFILE_VISIBILITIES = ['public', 'private', 'connections'] as const +export type ProfileVisibility = typeof PROFILE_VISIBILITIES[number] + +export interface LearnerPreferences { + id: string + userId: string + locale: SupportedLocale + timezone: string + lowDataMode: boolean + highContrast: boolean + reduceMotion: boolean + screenReaderOptimized: boolean + textSize: TextSize + preferredDifficulty: DifficultyLevel + preferredCategories: string[] + profileVisibility: ProfileVisibility + analyticsConsent: boolean + dataSharingConsent: boolean + createdAt: Date + updatedAt: Date +} + +export interface UpdateLearnerPreferencesData { + locale?: SupportedLocale + timezone?: string + lowDataMode?: boolean + highContrast?: boolean + reduceMotion?: boolean + screenReaderOptimized?: boolean + textSize?: TextSize + preferredDifficulty?: DifficultyLevel + preferredCategories?: string[] + profileVisibility?: ProfileVisibility + analyticsConsent?: boolean + dataSharingConsent?: boolean +} + +// Privacy-impacting fields are audited whenever they change. +export const PRIVACY_IMPACTING_FIELDS: readonly (keyof UpdateLearnerPreferencesData)[] = [ + 'profileVisibility', + 'analyticsConsent', + 'dataSharingConsent', +] diff --git a/tests/preference.controller.test.ts b/tests/preference.controller.test.ts new file mode 100644 index 0000000..64ce7c6 --- /dev/null +++ b/tests/preference.controller.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { PreferenceController } from '../src/controllers/preference.controller' + +const { mockGetPreferences, mockUpdatePreferences } = vi.hoisted(() => ({ + mockGetPreferences: vi.fn(), + mockUpdatePreferences: vi.fn(), +})) + +vi.mock('../src/services/preference.service', () => ({ + PreferenceService: class { + getPreferences = mockGetPreferences + updatePreferences = mockUpdatePreferences + }, +})) + +describe('PreferenceController', () => { + let controller: PreferenceController + let req: any + let res: any + + beforeEach(() => { + vi.clearAllMocks() + controller = new PreferenceController() + req = { user: { id: 'user1' }, body: {} } + res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } + }) + + describe('getPreferences', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + + await controller.getPreferences(req, res) + + expect(res.status).toHaveBeenCalledWith(401) + }) + + it('returns defaults on success', async () => { + mockGetPreferences.mockResolvedValue({ userId: 'user1', locale: 'en-US' }) + + await controller.getPreferences(req, res) + + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ data: { userId: 'user1', locale: 'en-US' } }) + }) + + it('returns 500 on unexpected error', async () => { + mockGetPreferences.mockRejectedValue(new Error('db down')) + + await controller.getPreferences(req, res) + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) + + describe('updatePreferences', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + req.body = { locale: 'fr-FR' } + + await controller.updatePreferences(req, res) + + expect(res.status).toHaveBeenCalledWith(401) + }) + + it('returns 400 on empty body', async () => { + req.body = {} + + await controller.updatePreferences(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 400 on invalid locale', async () => { + req.body = { locale: 'not-a-locale' } + + await controller.updatePreferences(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 400 on invalid timezone', async () => { + req.body = { timezone: 'Not/A_Timezone' } + + await controller.updatePreferences(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 400 on unknown fields (stable, closed schema)', async () => { + req.body = { notARealField: true } + + await controller.updatePreferences(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('accepts a partial update and preserves omitted fields', async () => { + req.body = { lowDataMode: true } + mockUpdatePreferences.mockResolvedValue({ userId: 'user1', lowDataMode: true, locale: 'en-US' }) + + await controller.updatePreferences(req, res) + + expect(mockUpdatePreferences).toHaveBeenCalledWith('user1', { lowDataMode: true }) + expect(res.status).toHaveBeenCalledWith(200) + }) + + it('returns 500 on unexpected error', async () => { + req.body = { lowDataMode: true } + mockUpdatePreferences.mockRejectedValue(new Error('db down')) + + await controller.updatePreferences(req, res) + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) +}) diff --git a/tests/preference.service.test.ts b/tests/preference.service.test.ts new file mode 100644 index 0000000..8b0705a --- /dev/null +++ b/tests/preference.service.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { PreferenceService } from '../src/services/preference.service' + +const { mockUpsert, mockFindUnique, mockCreateMany } = vi.hoisted(() => ({ + mockUpsert: vi.fn(), + mockFindUnique: vi.fn(), + mockCreateMany: vi.fn(), +})) + +vi.mock('../src/config/database', () => ({ + default: { + learnerPreference: { + upsert: mockUpsert, + findUnique: mockFindUnique, + }, + preferenceAuditLog: { + createMany: mockCreateMany, + }, + }, +})) + +describe('PreferenceService', () => { + let service: PreferenceService + + beforeEach(() => { + vi.clearAllMocks() + service = new PreferenceService() + }) + + describe('getPreferences', () => { + it('upserts a default row so every learner gets deterministic defaults', async () => { + const defaults = { userId: 'user1', locale: 'en-US', timezone: 'UTC' } + mockUpsert.mockResolvedValue(defaults) + + const result = await service.getPreferences('user1') + + expect(mockUpsert).toHaveBeenCalledWith({ + where: { userId: 'user1' }, + update: {}, + create: { userId: 'user1' }, + }) + expect(result).toEqual(defaults) + }) + }) + + describe('updatePreferences', () => { + it('preserves omitted fields via a partial upsert', async () => { + mockFindUnique.mockResolvedValue({ userId: 'user1', locale: 'en-US', profileVisibility: 'public' }) + mockUpsert.mockResolvedValue({ userId: 'user1', locale: 'fr-FR', profileVisibility: 'public' }) + + const result = await service.updatePreferences('user1', { locale: 'fr-FR' as any }) + + expect(mockUpsert).toHaveBeenCalledWith({ + where: { userId: 'user1' }, + update: { locale: 'fr-FR' }, + create: { userId: 'user1', locale: 'fr-FR' }, + }) + expect(result.locale).toBe('fr-FR') + }) + + it('audits privacy-impacting changes when the value actually changes', async () => { + mockFindUnique.mockResolvedValue({ userId: 'user1', profileVisibility: 'public' }) + mockUpsert.mockResolvedValue({ userId: 'user1', profileVisibility: 'private' }) + + await service.updatePreferences('user1', { profileVisibility: 'private' as any }) + + expect(mockCreateMany).toHaveBeenCalledWith({ + data: [{ userId: 'user1', field: 'profileVisibility', oldValue: 'public', newValue: 'private' }], + }) + }) + + it('does not audit when a non-privacy field changes', async () => { + mockFindUnique.mockResolvedValue({ userId: 'user1', locale: 'en-US' }) + mockUpsert.mockResolvedValue({ userId: 'user1', locale: 'fr-FR' }) + + await service.updatePreferences('user1', { locale: 'fr-FR' as any }) + + expect(mockCreateMany).not.toHaveBeenCalled() + }) + + it('does not audit when a privacy field is submitted but unchanged', async () => { + mockFindUnique.mockResolvedValue({ userId: 'user1', analyticsConsent: true }) + mockUpsert.mockResolvedValue({ userId: 'user1', analyticsConsent: true }) + + await service.updatePreferences('user1', { analyticsConsent: true }) + + expect(mockCreateMany).not.toHaveBeenCalled() + }) + + it('records null oldValue on first-ever write (no prior row)', async () => { + mockFindUnique.mockResolvedValue(null) + mockUpsert.mockResolvedValue({ userId: 'user1', dataSharingConsent: true }) + + await service.updatePreferences('user1', { dataSharingConsent: true }) + + expect(mockCreateMany).toHaveBeenCalledWith({ + data: [{ userId: 'user1', field: 'dataSharingConsent', oldValue: null, newValue: 'true' }], + }) + }) + }) +})