diff --git a/src/features/settings/index.js b/src/features/settings/index.js index 14f5403..296acf9 100644 --- a/src/features/settings/index.js +++ b/src/features/settings/index.js @@ -1,8 +1,10 @@ import { Router } from 'express'; import settingsRouter from './settings/setting.routes.js'; +import themeRouter from './theme/routes/theme.routes.js'; const router = Router(); +router.use('/settings/theme', themeRouter); router.use('/settings', settingsRouter); export default router; diff --git a/src/features/settings/settings/setting.routes.js b/src/features/settings/settings/setting.routes.js index 6c60956..a7817af 100644 --- a/src/features/settings/settings/setting.routes.js +++ b/src/features/settings/settings/setting.routes.js @@ -3,7 +3,6 @@ import controller from './setting.controller.js'; import { authenticate } from '../../../shared/middleware/auth.middleware.js'; import { validate } from '../../../shared/middleware/validation.middleware.js'; import { authorize } from '../../../shared/middleware/permission.middleware.js'; -import { audit } from '../../../shared/middleware/audit.middleware.js'; import { createValidation, updateValidation, @@ -59,11 +58,20 @@ router.get( * @swagger * /api/v1/settings: * post: - * summary: Create a setting + * summary: Create a new setting * tags: [Settings] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string * responses: * 201: - * description: Success + * description: Created */ router.post( '/', @@ -71,7 +79,6 @@ router.post( authorize(['settings.users']), createValidation, validate, - audit('CREATE_SETTING'), controller.create ); @@ -79,12 +86,23 @@ router.post( * @swagger * /api/v1/settings/{id}: * put: - * summary: Update a setting + * summary: Update an existing setting * tags: [Settings] * parameters: * - in: path * name: id * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string * responses: * 200: * description: Success @@ -95,7 +113,6 @@ router.put( authorize(['settings.users']), updateValidation, validate, - audit('UPDATE_SETTING'), controller.update ); @@ -109,6 +126,8 @@ router.put( * - in: path * name: id * required: true + * schema: + * type: string * responses: * 200: * description: Success @@ -119,7 +138,6 @@ router.delete( authorize(['settings.users']), deleteValidation, validate, - audit('DELETE_SETTING'), controller.delete ); diff --git a/src/features/settings/theme/constants/theme.constants.js b/src/features/settings/theme/constants/theme.constants.js new file mode 100644 index 0000000..a6a3831 --- /dev/null +++ b/src/features/settings/theme/constants/theme.constants.js @@ -0,0 +1,11 @@ +export const DEFAULT_THEME = 'royal-purple'; + +export const ALLOWED_THEME_CODES = [ + 'royal-purple', + 'forest-green', + 'ocean-blue', + 'sunset-orange', + 'dark' +]; + +export const SYSTEM_THEMES = ['royal-purple', 'dark']; diff --git a/src/features/settings/theme/controllers/theme.controller.js b/src/features/settings/theme/controllers/theme.controller.js new file mode 100644 index 0000000..30cdf4e --- /dev/null +++ b/src/features/settings/theme/controllers/theme.controller.js @@ -0,0 +1,31 @@ +import service from '../services/theme.service.js'; + +export class ThemeController { + getCurrentTheme = async (req, res, next) => { + try { + const userId = req.user.user_id || req.user.id; + const data = await service.getCurrentTheme(userId); + return res.status(200).json({ success: true, data }); + } catch (error) { + next(error); + } + } + + updateCurrentTheme = async (req, res, next) => { + try { + const userId = req.user.user_id || req.user.id; + const { themeCode } = req.body; + const requestContext = { + method: req.method, + ip: req.ip, + userAgent: req.headers['user-agent'] + }; + await service.updateCurrentTheme(userId, themeCode, requestContext); + return res.status(200).json({ success: true, message: 'Theme updated successfully.' }); + } catch (error) { + next(error); + } + } +} + +export default new ThemeController(); diff --git a/src/features/settings/theme/index.js b/src/features/settings/theme/index.js new file mode 100644 index 0000000..0cade30 --- /dev/null +++ b/src/features/settings/theme/index.js @@ -0,0 +1,5 @@ +export { default as themeRoutes } from './routes/theme.routes.js'; +export { default as themeController } from './controllers/theme.controller.js'; +export { default as themeService } from './services/theme.service.js'; +export { default as themeRepository } from './repositories/theme.repository.js'; +export { default as userThemeModel } from './models/userTheme.model.js'; diff --git a/src/features/settings/theme/models/userTheme.model.js b/src/features/settings/theme/models/userTheme.model.js new file mode 100644 index 0000000..bd08ffb --- /dev/null +++ b/src/features/settings/theme/models/userTheme.model.js @@ -0,0 +1,39 @@ +import { Model, DataTypes } from 'sequelize'; + +export class UserTheme extends Model { + static associate(models) { + UserTheme.belongsTo(models.User, { + foreignKey: 'user_id', + as: 'user', + onDelete: 'CASCADE' + }); + } +} + +export default (sequelize) => { + UserTheme.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + unique: true + }, + theme_code: { + type: DataTypes.STRING(50), + allowNull: false + } + }, { + sequelize, + modelName: 'UserTheme', + tableName: 'user_themes', + timestamps: true, + underscored: true + }); + + return UserTheme; +}; diff --git a/src/features/settings/theme/repositories/theme.repository.js b/src/features/settings/theme/repositories/theme.repository.js new file mode 100644 index 0000000..356374e --- /dev/null +++ b/src/features/settings/theme/repositories/theme.repository.js @@ -0,0 +1,26 @@ +import { models } from '../../../../shared/database/models.js'; + +export class ThemeRepository { + async findByUserId(userId, options = {}) { + return await models.UserTheme.findOne({ + where: { user_id: userId }, + ...options + }); + } + + async create(data, options = {}) { + return await models.UserTheme.create(data, options); + } + + async update(userId, themeCode, options = {}) { + const record = await this.findByUserId(userId, options); + if (!record) return null; + return await record.update({ theme_code: themeCode }, options); + } + + async save(userThemeInstance, options = {}) { + return await userThemeInstance.save(options); + } +} + +export default new ThemeRepository(); diff --git a/src/features/settings/theme/routes/theme.routes.js b/src/features/settings/theme/routes/theme.routes.js new file mode 100644 index 0000000..165449e --- /dev/null +++ b/src/features/settings/theme/routes/theme.routes.js @@ -0,0 +1,43 @@ +import { Router } from 'express'; +import controller from '../controllers/theme.controller.js'; +import { authenticate } from '../../../../shared/middleware/auth.middleware.js'; +import { validate } from '../../../../shared/middleware/validation.middleware.js'; +import { themeValidation } from '../validators/theme.validation.js'; + +const router = Router(); + +/** + * @swagger + * /api/v1/settings/theme: + * get: + * summary: Retrieve user theme preference + * tags: [Theme] + * responses: + * 200: + * description: Success + */ +router.get( + '/', + authenticate, + controller.getCurrentTheme +); + +/** + * @swagger + * /api/v1/settings/theme: + * put: + * summary: Update user theme preference + * tags: [Theme] + * responses: + * 200: + * description: Success + */ +router.put( + '/', + authenticate, + themeValidation, + validate, + controller.updateCurrentTheme +); + +export default router; diff --git a/src/features/settings/theme/services/theme.service.js b/src/features/settings/theme/services/theme.service.js new file mode 100644 index 0000000..c2f1779 --- /dev/null +++ b/src/features/settings/theme/services/theme.service.js @@ -0,0 +1,72 @@ +import repository from '../repositories/theme.repository.js'; +import { ALLOWED_THEME_CODES, DEFAULT_THEME } from '../constants/theme.constants.js'; +import { sequelize } from '../../../../shared/database/models.js'; +import { AuditService } from '../../../../shared/services/audit.service.js'; +import { ApiError } from '../../../../utils/helpers/ApiError.utils.js'; + +export class ThemeService { + async getCurrentTheme(userId) { + if (!userId) { + return { themeCode: DEFAULT_THEME }; + } + const preference = await repository.findByUserId(userId); + return { + themeCode: preference ? preference.theme_code : DEFAULT_THEME + }; + } + + validateTheme(themeCode) { + if (!ALLOWED_THEME_CODES.includes(themeCode)) { + throw new ApiError(400, `Invalid theme code: "${themeCode}"`); + } + } + + async updateCurrentTheme(userId, themeCode, requestContext = {}) { + if (!userId) { + throw new ApiError(401, 'Unauthorized'); + } + + this.validateTheme(themeCode); + + const transaction = await sequelize.transaction(); + try { + const existing = await repository.findByUserId(userId, { transaction }); + const oldTheme = existing ? existing.theme_code : DEFAULT_THEME; + + let result; + if (existing) { + result = await existing.update({ theme_code: themeCode }, { transaction }); + } else { + result = await repository.create({ + user_id: userId, + theme_code: themeCode + }, { transaction }); + } + + await transaction.commit(); + + // Log the theme change via the existing Audit System. + // We pass a dummy 'method' in details to satisfy AuditService validation checks. + await AuditService.log({ + action: 'UPDATE_THEME_PREFERENCE', + resource: 'settings', + resourceId: result.id, + userId: userId, + old_value: { theme_code: oldTheme }, + new_value: { theme_code: themeCode }, + details: { + method: requestContext.method || 'PUT', + ip: requestContext.ip || '127.0.0.1', + userAgent: requestContext.userAgent || 'system' + } + }); + + return result; + } catch (error) { + await transaction.rollback(); + throw error; + } + } +} + +export default new ThemeService(); diff --git a/src/features/settings/theme/validators/theme.validation.js b/src/features/settings/theme/validators/theme.validation.js new file mode 100644 index 0000000..a12cab2 --- /dev/null +++ b/src/features/settings/theme/validators/theme.validation.js @@ -0,0 +1,12 @@ +import { body } from 'express-validator'; +import { ALLOWED_THEME_CODES } from '../constants/theme.constants.js'; + +export const themeValidation = [ + body('themeCode') + .notEmpty() + .withMessage('themeCode is required') + .isString() + .withMessage('themeCode must be a string') + .isIn(ALLOWED_THEME_CODES) + .withMessage(`themeCode must be one of: ${ALLOWED_THEME_CODES.join(', ')}`) +]; diff --git a/src/migrations/20260727163000-create-user-themes.cjs b/src/migrations/20260727163000-create-user-themes.cjs new file mode 100644 index 0000000..b76d74e --- /dev/null +++ b/src/migrations/20260727163000-create-user-themes.cjs @@ -0,0 +1,45 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('user_themes', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + user_id: { + type: Sequelize.INTEGER, + allowNull: false, + unique: true, + references: { + model: 'users', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + theme_code: { + type: Sequelize.STRING(50), + allowNull: false + }, + created_at: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') + }, + updated_at: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') + } + }); + + await queryInterface.addIndex('user_themes', ['user_id']); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('user_themes'); + } +}; diff --git a/src/shared/database/models.js b/src/shared/database/models.js index 4829c0f..2f3d47d 100644 --- a/src/shared/database/models.js +++ b/src/shared/database/models.js @@ -16,6 +16,10 @@ export const associateModels = () => { }); // Explicit RBAC Associations + if (models.User && models.UserTheme) { + models.User.hasOne(models.UserTheme, { foreignKey: 'user_id', as: 'theme' }); + } + if (models.Tenant && models.User) { models.User.belongsTo(models.Tenant, { foreignKey: 'tenant_id', as: 'tenant' }); models.Tenant.hasMany(models.User, { foreignKey: 'tenant_id', as: 'users' }); @@ -129,6 +133,7 @@ import brandModelInit from '../../features/brands/brands/brand.model.js'; import unitModelInit from '../../features/brands/units/unit.model.js'; import settingModelInit from '../../features/settings/settings/setting.model.js'; import auditLogModelInit from '../../features/auditLogs/auditLogs/auditLog.model.js'; +import userThemeModelInit from '../../features/settings/theme/models/userTheme.model.js'; // Attribute Management Models import attributeModelInit from '../../features/attributes/attributes/attribute.model.js'; @@ -191,6 +196,7 @@ export const initializeDatabaseModels = () => { registerModel('Unit', unitModelInit); registerModel('Setting', settingModelInit); registerModel('AuditLog', auditLogModelInit); + registerModel('UserTheme', userThemeModelInit); // Attribute Management registerModel('Attribute', attributeModelInit);