diff --git a/.dockerignore b/.dockerignore index e742d34..3e54834 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,53 +1,54 @@ -# Dependencies -node_modules/ -.pnp -.pnp.js - -# Build outputs -dist/ -build/ -coverage/ -*.log - -# Git -.git/ -.gitignore - -# Environment files -.env -.env.local -.env.*.local -.env.example - -# IDE -.vscode/ -.idea/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# CI/CD -.github/ -.coderabbit.yaml - -# Docs (not needed in image) -docs/ -*.md - -# Test files -test/ -__tests__/ -*.test.js -*.spec.js -jest.config.js - -# Contracts (Rust/WASM builds not needed in Node image) -contracts/ -target/ - -# Misc -.tmp/ -tmp/ \ No newline at end of file +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build outputs +dist/ +build/ +coverage/ +*.log + +# Git +.git/ +.gitignore + +# Environment files +.env +.env.local +.env.*.local +.env.example + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# CI/CD +.github/ +.coderabbit.yaml + +# Docs (not needed in image) +docs/ +*.md + +# Test files +test/ +__tests__/ +*.test.js +*.spec.js +jest.config.js + +# Contracts (Rust/WASM builds not needed in Node image) +contracts/ +target/ + +# Misc +.tmp/ +tmp/ +docker-compose*.yml \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..89a0dee --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,67 @@ +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: dnb-backend + ports: + - "${PORT:-5000}:5000" + environment: + - NODE_ENV=development + - PORT=5000 + - MONGO_URI=mongodb://mongo:27017/dnb-backend + - JWT_SECRET=dev_jwt_secret_must_be_at_least_32_chars_long + - REDIS_HOST=redis + - REDIS_PORT=6379 + - JOBS_ENABLED=true + - QUEUE_DRIVER=mongo + depends_on: + mongo: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:5000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + restart: unless-stopped + + mongo: + image: mongo:7.0 + container_name: dnb-mongo + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: dnb-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + restart: unless-stopped + +volumes: + mongo_data: + driver: local + redis_data: + driver: local diff --git a/docs/USER_PREFERENCES.md b/docs/USER_PREFERENCES.md new file mode 100644 index 0000000..b68a34d --- /dev/null +++ b/docs/USER_PREFERENCES.md @@ -0,0 +1,167 @@ +# User Preferences API Guide + +This document details the schema, validation rules, API endpoints, and real-time update mechanisms for DeenBridge's User Preferences feature (Issue #267). + +--- + +## 1. Overview & Purpose + +User preferences empower users to personalize their app experience across devices. The feature supports: +- **Theme Customization**: Light, Dark, or System mode. +- **Language Selection**: Application language preference (synced to user profile). +- **Notification Settings**: Granular controls for Email, Push, In-App, Marketing, Course Updates, Prayer Reminders, and Security Alerts. +- **Privacy Controls**: Profile visibility, activity sharing, learning progress sharing, messaging permissions, and leaderboard participation. +- **Real-Time Preferences Sync**: Socket.IO room events and in-memory event emitter for instant multi-device state synchronization. + +--- + +## 2. Preference Schema & Defaults + +Stored in the `UserPreferences` collection linked to `User` via `user` ObjectId: + +| Field | Sub-field | Data Type | Allowed Values / Constraints | Default Value | +| :--- | :--- | :--- | :--- | :--- | +| `theme` | - | `String` | `"light"`, `"dark"`, `"system"` | `"light"` | +| `language` | - | `String` | Non-empty string code (e.g., `"en"`, `"ar"`) | `"en"` | +| `timezone` | - | `String` | Valid timezone string (e.g., `"UTC"`, `"EST"`) | `"UTC"` | +| `fontSize` | - | `String` | `"small"`, `"medium"`, `"large"` | `"medium"` | +| `notifications` | `email` | `Boolean` | `true` / `false` | `true` | +| | `push` | `Boolean` | `true` / `false` | `true` | +| | `inApp` | `Boolean` | `true` / `false` | `true` | +| | `marketing` | `Boolean` | `true` / `false` | `false` | +| | `courseUpdates` | `Boolean` | `true` / `false` | `true` | +| | `prayerReminders` | `Boolean` | `true` / `false` | `true` | +| | `securityAlerts` | `Boolean` | `true` / `false` | `true` | +| `privacy` | `profileVisibility` | `String` | `"public"`, `"private"`, `"followers"` | `"public"` | +| | `showActivity` | `Boolean` | `true` / `false` | `true` | +| | `showLearningProgress` | `Boolean` | `true` / `false` | `true` | +| | `allowMessagesFrom` | `String` | `"everyone"`, `"followers"`, `"none"` | `"everyone"` | +| | `showInLeaderboards` | `Boolean` | `true` / `false` | `true` | + +--- + +## 3. API Endpoints Reference + +### 3.1 Get User Preferences + +- **Endpoint**: `GET /api/users/me/preferences` (also supported: `GET /api/users/preferences`) +- **Authentication**: Required (`Bearer `) + +#### Success Response (`200 OK`) +```json +{ + "success": true, + "message": "User preferences retrieved successfully", + "data": { + "_id": "64c8f1e29b1d2c001f8a9e10", + "user": "64c8f1e29b1d2c001f8a9e01", + "theme": "light", + "language": "en", + "timezone": "UTC", + "fontSize": "medium", + "notifications": { + "email": true, + "push": true, + "inApp": true, + "marketing": false, + "courseUpdates": true, + "prayerReminders": true, + "securityAlerts": true + }, + "privacy": { + "profileVisibility": "public", + "showActivity": true, + "showLearningProgress": true, + "allowMessagesFrom": "everyone", + "showInLeaderboards": true + }, + "createdAt": "2026-08-30T14:30:00.000Z", + "updatedAt": "2026-08-30T14:30:00.000Z" + } +} +``` + +--- + +### 3.2 Update User Preferences + +- **Endpoint**: `PUT /api/users/me/preferences` (also supported: `PUT /api/users/preferences`) +- **Authentication**: Required (`Bearer `) + +#### Request Body Example +```json +{ + "theme": "dark", + "language": "ar", + "notifications": { + "marketing": true, + "prayerReminders": false + }, + "privacy": { + "profileVisibility": "followers", + "showInLeaderboards": false + } +} +``` + +#### Success Response (`200 OK`) +```json +{ + "success": true, + "message": "User preferences updated successfully", + "data": { + "_id": "64c8f1e29b1d2c001f8a9e10", + "user": "64c8f1e29b1d2c001f8a9e01", + "theme": "dark", + "language": "ar", + "notifications": { + "email": true, + "push": true, + "inApp": true, + "marketing": true, + "courseUpdates": true, + "prayerReminders": false, + "securityAlerts": true + }, + "privacy": { + "profileVisibility": "followers", + "showActivity": true, + "showLearningProgress": true, + "allowMessagesFrom": "everyone", + "showInLeaderboards": false + }, + "createdAt": "2026-08-30T14:30:00.000Z", + "updatedAt": "2026-08-30T14:30:05.000Z" + } +} +``` + +#### Validation Error Response (`400 Bad Request`) +```json +{ + "success": false, + "message": "Validation failed", + "errors": [ + "Invalid theme 'neon'. Allowed: light, dark, system" + ], + "data": null +} +``` + +--- + +## 4. Real-Time Preferences Updates + +When user preferences are updated via the API: +1. **Socket.IO Namespace**: `/preferences` +2. **Room**: `user_preferences_` +3. **Event**: `preference_updated` +4. **Payload**: +```json +{ + "userId": "64c8f1e29b1d2c001f8a9e01", + "preferences": { ... }, + "timestamp": "2026-08-30T14:30:05.000Z" +} +``` +5. **In-Process EventEmitter**: `preferenceEvents.on('updated', ({ userId, preferences }) => ...)` diff --git a/src/models/UserPreferences.ts b/src/models/UserPreferences.ts new file mode 100644 index 0000000..fcc888e --- /dev/null +++ b/src/models/UserPreferences.ts @@ -0,0 +1,90 @@ +import mongoose, { Schema, Document } from "mongoose"; +import { IUserPreferences } from "../types/preferences.js"; + +export interface UserPreferencesDocument extends Omit, Document {} + +const notificationSchema = new Schema( + { + email: { type: Boolean, default: true }, + push: { type: Boolean, default: true }, + inApp: { type: Boolean, default: true }, + marketing: { type: Boolean, default: false }, + courseUpdates: { type: Boolean, default: true }, + prayerReminders: { type: Boolean, default: true }, + securityAlerts: { type: Boolean, default: true }, + }, + { _id: false } +); + +const privacySchema = new Schema( + { + profileVisibility: { + type: String, + enum: ["public", "private", "followers"], + default: "public", + }, + showActivity: { type: Boolean, default: true }, + showLearningProgress: { type: Boolean, default: true }, + allowMessagesFrom: { + type: String, + enum: ["everyone", "followers", "none"], + default: "everyone", + }, + showInLeaderboards: { type: Boolean, default: true }, + }, + { _id: false } +); + +const userPreferencesSchema = new Schema( + { + user: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + unique: true, + index: true, + }, + theme: { + type: String, + enum: ["light", "dark", "system"], + default: "light", + }, + language: { + type: String, + default: "en", + trim: true, + }, + notifications: { + type: notificationSchema, + default: () => ({}), + }, + privacy: { + type: privacySchema, + default: () => ({}), + }, + timezone: { + type: String, + default: "UTC", + trim: true, + }, + fontSize: { + type: String, + enum: ["small", "medium", "large"], + default: "medium", + }, + }, + { timestamps: true } +); + +userPreferencesSchema.statics.getOrCreateForUser = async function (userId: string | mongoose.Types.ObjectId) { + let preferences = await this.findOne({ user: userId }); + if (!preferences) { + preferences = await this.create({ user: userId }); + } + return preferences; +}; + +export default mongoose.model( + "UserPreferences", + userPreferencesSchema +); diff --git a/src/routes/api/users/preferences.ts b/src/routes/api/users/preferences.ts new file mode 100644 index 0000000..c83d1cd --- /dev/null +++ b/src/routes/api/users/preferences.ts @@ -0,0 +1,240 @@ +import express, { Request, Response } from "express"; +import { protect } from "../../../middlewares/authMiddleware.js"; +import UserPreferences from "../../../models/UserPreferences.js"; +import User from "../../../models/User.js"; +import logger from "../../../config/logger.js"; +import { emitPreferenceUpdate } from "../../../sockets/preferences.socket.ts"; +import { EventEmitter } from "events"; + +export const preferenceEvents = new EventEmitter(); + +const router = express.Router(); + +const ALLOWED_THEMES = ["light", "dark", "system"]; +const ALLOWED_VISIBILITY = ["public", "private", "followers"]; +const ALLOWED_MESSAGES = ["everyone", "followers", "none"]; +const ALLOWED_FONT_SIZES = ["small", "medium", "large"]; + +export const validatePreferencesInput = (body: any) => { + const errors: string[] = []; + const updates: any = {}; + + if (body.theme !== undefined) { + if (!ALLOWED_THEMES.includes(body.theme)) { + errors.push(`Invalid theme '${body.theme}'. Allowed: ${ALLOWED_THEMES.join(", ")}`); + } else { + updates.theme = body.theme; + } + } + + if (body.language !== undefined) { + if (typeof body.language !== "string" || !body.language.trim()) { + errors.push("Language must be a non-empty string"); + } else { + updates.language = body.language.trim(); + } + } + + if (body.timezone !== undefined) { + if (typeof body.timezone !== "string" || !body.timezone.trim()) { + errors.push("Timezone must be a non-empty string"); + } else { + updates.timezone = body.timezone.trim(); + } + } + + if (body.fontSize !== undefined) { + if (!ALLOWED_FONT_SIZES.includes(body.fontSize)) { + errors.push(`Invalid fontSize '${body.fontSize}'. Allowed: ${ALLOWED_FONT_SIZES.join(", ")}`); + } else { + updates.fontSize = body.fontSize; + } + } + + if (body.notifications !== undefined) { + if (typeof body.notifications !== "object" || body.notifications === null) { + errors.push("Notifications must be an object"); + } else { + const notificationFields = [ + "email", + "push", + "inApp", + "marketing", + "courseUpdates", + "prayerReminders", + "securityAlerts", + ]; + updates.notifications = {}; + for (const field of notificationFields) { + if (body.notifications[field] !== undefined) { + if (typeof body.notifications[field] !== "boolean") { + errors.push(`notifications.${field} must be a boolean`); + } else { + updates.notifications[field] = body.notifications[field]; + } + } + } + } + } + + if (body.privacy !== undefined) { + if (typeof body.privacy !== "object" || body.privacy === null) { + errors.push("Privacy must be an object"); + } else { + updates.privacy = {}; + if (body.privacy.profileVisibility !== undefined) { + if (!ALLOWED_VISIBILITY.includes(body.privacy.profileVisibility)) { + errors.push( + `Invalid privacy.profileVisibility '${body.privacy.profileVisibility}'. Allowed: ${ALLOWED_VISIBILITY.join(", ")}` + ); + } else { + updates.privacy.profileVisibility = body.privacy.profileVisibility; + } + } + if (body.privacy.allowMessagesFrom !== undefined) { + if (!ALLOWED_MESSAGES.includes(body.privacy.allowMessagesFrom)) { + errors.push( + `Invalid privacy.allowMessagesFrom '${body.privacy.allowMessagesFrom}'. Allowed: ${ALLOWED_MESSAGES.join(", ")}` + ); + } else { + updates.privacy.allowMessagesFrom = body.privacy.allowMessagesFrom; + } + } + const booleanPrivacyFields = ["showActivity", "showLearningProgress", "showInLeaderboards"]; + for (const field of booleanPrivacyFields) { + if (body.privacy[field] !== undefined) { + if (typeof body.privacy[field] !== "boolean") { + errors.push(`privacy.${field} must be a boolean`); + } else { + updates.privacy[field] = body.privacy[field]; + } + } + } + } + } + + return { errors, updates }; +}; + +/** + * @route GET /api/users/me/preferences + * @route GET /api/users/preferences + * @desc Get current authenticated user's preferences + * @access Private + */ +export const getUserPreferences = async (req: Request, res: Response) => { + try { + const userId = (req as any).user?._id || (req as any).user?.id; + if (!userId) { + return res.status(401).json({ + success: false, + message: "Unauthorized. Please authenticate.", + data: null, + }); + } + + let preferences = await UserPreferences.findOne({ user: userId }); + if (!preferences) { + preferences = await UserPreferences.create({ user: userId }); + } + + return res.status(200).json({ + success: true, + message: "User preferences retrieved successfully", + data: preferences, + }); + } catch (error: any) { + logger.error("Get user preferences error:", error); + return res.status(500).json({ + success: false, + message: "Failed to fetch user preferences", + error: error.message, + }); + } +}; + +/** + * @route PUT /api/users/me/preferences + * @route PUT /api/users/preferences + * @desc Update current authenticated user's preferences + * @access Private + */ +export const updateUserPreferences = async (req: Request, res: Response) => { + try { + const userId = (req as any).user?._id || (req as any).user?.id; + if (!userId) { + return res.status(401).json({ + success: false, + message: "Unauthorized. Please authenticate.", + data: null, + }); + } + + const { errors, updates } = validatePreferencesInput(req.body); + if (errors.length > 0) { + return res.status(400).json({ + success: false, + message: "Validation failed", + errors, + data: null, + }); + } + + const updateQuery: any = {}; + if (updates.theme !== undefined) updateQuery.theme = updates.theme; + if (updates.language !== undefined) updateQuery.language = updates.language; + if (updates.timezone !== undefined) updateQuery.timezone = updates.timezone; + if (updates.fontSize !== undefined) updateQuery.fontSize = updates.fontSize; + + if (updates.notifications) { + for (const [k, v] of Object.entries(updates.notifications)) { + updateQuery[`notifications.${k}`] = v; + } + } + + if (updates.privacy) { + for (const [k, v] of Object.entries(updates.privacy)) { + updateQuery[`privacy.${k}`] = v; + } + } + + const preferences = await UserPreferences.findOneAndUpdate( + { user: userId }, + { $set: updateQuery }, + { new: true, upsert: true, runValidators: true } + ); + + if (updates.language) { + await User.findByIdAndUpdate(userId, { language: updates.language }).catch((err) => { + logger.warn("Failed to sync language to User profile:", err.message); + }); + } + + emitPreferenceUpdate(userId.toString(), preferences.toObject()); + preferenceEvents.emit("updated", { userId: userId.toString(), preferences }); + + return res.status(200).json({ + success: true, + message: "User preferences updated successfully", + data: preferences, + }); + } catch (error: any) { + logger.error("Update user preferences error:", error); + return res.status(500).json({ + success: false, + message: "Failed to update user preferences", + error: error.message, + }); + } +}; + +router.get("/me/preferences", protect, getUserPreferences); +router.put("/me/preferences", protect, updateUserPreferences); +router.get("/preferences", protect, getUserPreferences); +router.put("/preferences", protect, updateUserPreferences); +router.get("/me", protect, getUserPreferences); +router.put("/me", protect, updateUserPreferences); +router.get("/", protect, getUserPreferences); +router.put("/", protect, updateUserPreferences); + +export default router; diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index 8d6de8d..4c8f532 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -21,6 +21,7 @@ import { getUserBadgesController } from "../controllers/badge.controller.js"; import completionRoutes, { getUserCompletion, } from "./api/users/completion.js"; +import preferencesRoutes from "./api/users/preferences.js"; import { cacheMiddleware, invalidateCacheMiddleware, @@ -37,6 +38,10 @@ const followersCacheKey = (req) => const followingCacheKey = (req) => `${CACHE_KEYS.USER}${req.params.userId}:following`; +// User preferences routes (must be before /:id) +router.use("/me/preferences", preferencesRoutes); +router.use("/preferences", preferencesRoutes); + // Get personalized recommendations - cached for 10 minutes (must be before /:id) router.get( "/recommendations", diff --git a/src/sockets/preferences.socket.ts b/src/sockets/preferences.socket.ts new file mode 100644 index 0000000..e5acaff --- /dev/null +++ b/src/sockets/preferences.socket.ts @@ -0,0 +1,54 @@ +import { Server, Socket } from "socket.io"; +import logger from "../config/logger.js"; +import { IUserPreferences } from "../types/preferences.js"; + +let ioRef: Server | null = null; + +const roomForUser = (userId: string) => `user_preferences_${userId}`; + +export const initUserPreferencesSocket = (io: Server) => { + ioRef = io; + const preferencesNamespace = io.of("/preferences"); + + preferencesNamespace.on("connection", (socket: Socket) => { + logger.info(`Socket connected to /preferences: ${socket.id}`); + + socket.on("join_preferences_room", (userId: string) => { + if (!userId) return; + const room = roomForUser(userId); + socket.join(room); + logger.info(`Socket ${socket.id} joined preferences room ${room}`); + }); + + socket.on("leave_preferences_room", (userId: string) => { + if (!userId) return; + const room = roomForUser(userId); + socket.leave(room); + logger.info(`Socket ${socket.id} left preferences room ${room}`); + }); + + socket.on("disconnect", () => { + logger.info(`Socket disconnected from /preferences: ${socket.id}`); + }); + }); + + return preferencesNamespace; +}; + +export const emitPreferenceUpdate = (userId: string, preferences: Partial) => { + if (!ioRef || !userId) return false; + try { + const room = roomForUser(userId); + ioRef.of("/preferences").to(room).emit("preference_updated", { + userId, + preferences, + timestamp: new Date().toISOString(), + }); + return true; + } catch (error) { + logger.error("Failed to emit preference update event:", error); + return false; + } +}; + +export default initUserPreferencesSocket; diff --git a/src/types/preferences.ts b/src/types/preferences.ts new file mode 100644 index 0000000..8819049 --- /dev/null +++ b/src/types/preferences.ts @@ -0,0 +1,68 @@ +import { Types } from "mongoose"; + +export type ThemePreference = "light" | "dark" | "system"; + +export type LanguagePreference = string; + +export interface NotificationSettings { + email: boolean; + push: boolean; + inApp: boolean; + marketing: boolean; + courseUpdates: boolean; + prayerReminders: boolean; + securityAlerts: boolean; +} + +export interface PrivacyOptions { + profileVisibility: "public" | "private" | "followers"; + showActivity: boolean; + showLearningProgress: boolean; + allowMessagesFrom: "everyone" | "followers" | "none"; + showInLeaderboards: boolean; +} + +export interface IUserPreferences { + _id?: Types.ObjectId | string; + user: Types.ObjectId | string; + theme: ThemePreference; + language: LanguagePreference; + notifications: NotificationSettings; + privacy: PrivacyOptions; + timezone: string; + fontSize: "small" | "medium" | "large"; + createdAt?: Date; + updatedAt?: Date; +} + +export interface UpdateUserPreferencesInput { + theme?: ThemePreference; + language?: LanguagePreference; + notifications?: Partial; + privacy?: Partial; + timezone?: string; + fontSize?: "small" | "medium" | "large"; +} + +export const DEFAULT_PREFERENCES: Omit = { + theme: "light", + language: "en", + notifications: { + email: true, + push: true, + inApp: true, + marketing: false, + courseUpdates: true, + prayerReminders: true, + securityAlerts: true, + }, + privacy: { + profileVisibility: "public", + showActivity: true, + showLearningProgress: true, + allowMessagesFrom: "everyone", + showInLeaderboards: true, + }, + timezone: "UTC", + fontSize: "medium", +};