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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
48 changes: 48 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
161 changes: 161 additions & 0 deletions src/controllers/preference.controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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' })
}
}
}
6 changes: 6 additions & 0 deletions src/routes/v1/users.routes.ts
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
62 changes: 62 additions & 0 deletions src/services/preference.service.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 })
}
}
Loading
Loading