dev #23

Merged
Seazn merged 16 commits from dev into main 2026-05-21 17:34:48 +00:00
10 changed files with 357 additions and 0 deletions
Showing only changes of commit 918deb2b69 - Show all commits

View File

@ -0,0 +1,106 @@
const I18nPreferencesRepository = require('../../repositories/settings/I18nPreferencesRepository');
const { logger } = require('../../middleware/logger');
const repo = new I18nPreferencesRepository();
class I18nPreferencesController {
static _normalizeStringArray(values) {
if (!Array.isArray(values)) return [];
const normalized = values
.map((v) => (v == null ? '' : String(v).trim()))
.filter(Boolean);
return [...new Set(normalized)];
}
static _normalizeCategories(categories) {
if (!Array.isArray(categories)) return [];
return categories
.map((item, idx) => {
const id = String(item?.id ?? '').trim() || `category_${idx + 1}`;
const label = String(item?.label ?? id).trim() || id;
const namespaces = I18nPreferencesController._normalizeStringArray(item?.namespaces);
const isCustom = Boolean(item?.isCustom);
return { id, label, namespaces, isCustom };
});
}
static _buildResponse(preferences) {
return {
ok: true,
preferences,
categories: preferences.categories,
globalKeys: preferences.globalKeys,
};
}
static async get(req, res) {
try {
const preferences = await repo.get();
return res.status(200).json(I18nPreferencesController._buildResponse(preferences));
} catch (error) {
logger.error('i18nPreferences:get:failed', { error: error?.message });
return res.status(500).json({ ok: false, message: 'Failed to load i18n preferences' });
}
}
static async post(req, res) {
try {
const categories = I18nPreferencesController._normalizeCategories(req.body?.categories);
const globalKeys = I18nPreferencesController._normalizeStringArray(req.body?.globalKeys);
const preferences = await repo.upsert({
categories,
globalKeys,
updatedByUserId: req.user?.userId ?? req.user?.id ?? null,
});
return res.status(200).json(I18nPreferencesController._buildResponse(preferences));
} catch (error) {
logger.error('i18nPreferences:post:failed', { error: error?.message });
return res.status(500).json({ ok: false, message: 'Failed to save i18n preferences' });
}
}
static async put(req, res) {
try {
const categories = I18nPreferencesController._normalizeCategories(req.body?.categories);
const globalKeys = I18nPreferencesController._normalizeStringArray(req.body?.globalKeys);
const preferences = await repo.upsert({
categories,
globalKeys,
updatedByUserId: req.user?.userId ?? req.user?.id ?? null,
});
return res.status(200).json(I18nPreferencesController._buildResponse(preferences));
} catch (error) {
logger.error('i18nPreferences:put:failed', { error: error?.message });
return res.status(500).json({ ok: false, message: 'Failed to update i18n preferences' });
}
}
static async delete(req, res) {
try {
const hasBodyReplacement = req.body && (
Object.prototype.hasOwnProperty.call(req.body, 'categories') ||
Object.prototype.hasOwnProperty.call(req.body, 'globalKeys')
);
const preferences = hasBodyReplacement
? await repo.upsert({
categories: I18nPreferencesController._normalizeCategories(req.body?.categories),
globalKeys: I18nPreferencesController._normalizeStringArray(req.body?.globalKeys),
updatedByUserId: req.user?.userId ?? req.user?.id ?? null,
})
: await repo.clear(req.user?.userId ?? req.user?.id ?? null);
return res.status(200).json(I18nPreferencesController._buildResponse(preferences));
} catch (error) {
logger.error('i18nPreferences:delete:failed', { error: error?.message });
return res.status(500).json({ ok: false, message: 'Failed to delete i18n preferences' });
}
}
}
module.exports = I18nPreferencesController;

View File

@ -261,6 +261,56 @@ class LoginController {
return res.status(500).json({ success: false, message: 'Internal server error' }); return res.status(500).json({ success: false, message: 'Internal server error' });
} }
} }
static async validate(req, res) {
try {
const refreshToken = req.cookies?.refreshToken;
if (!refreshToken) {
return res.status(401).json({ ok: false, message: 'No refresh token provided' });
}
const result = await LoginService.validateByRefreshToken(refreshToken);
const user = result?.user || {};
const role = String(user.role || '').toLowerCase();
const userRoles = Array.isArray(user.roles)
? user.roles.map((r) => String(r).toLowerCase())
: [];
const mergedRoles = [...new Set([role, ...userRoles].filter(Boolean))];
const isAdmin = Boolean(
user.isAdmin ||
role === 'admin' ||
role === 'super_admin' ||
role === 'superadmin' ||
mergedRoles.includes('admin') ||
mergedRoles.includes('super_admin') ||
mergedRoles.includes('superadmin')
);
return res.status(200).json({
ok: true,
user: {
id: String(user.id ?? ''),
role: user.role || null,
isAdmin,
roles: mergedRoles,
},
isAdmin,
role: user.role || null,
roles: mergedRoles,
});
} catch (error) {
if (error.status) {
return res.status(error.status).json({ ok: false, message: error.message });
}
logger.error('authValidate:error', {
message: error?.message,
stack: error?.stack,
});
return res.status(500).json({ ok: false, message: 'Internal server error' });
}
}
} }
module.exports = LoginController; module.exports = LoginController;

View File

@ -829,6 +829,38 @@ const createDatabase = async () => {
await addColumnIfMissing(connection, 'company_settings', 'qr_code_60_base64', 'LONGTEXT NULL'); await addColumnIfMissing(connection, 'company_settings', 'qr_code_60_base64', 'LONGTEXT NULL');
await addColumnIfMissing(connection, 'company_settings', 'qr_code_120_base64', 'LONGTEXT NULL'); await addColumnIfMissing(connection, 'company_settings', 'qr_code_120_base64', 'LONGTEXT NULL');
// --- I18n Preferences (single-row, admin language-management settings) ---
await connection.query(`
CREATE TABLE IF NOT EXISTS i18n_preferences (
id TINYINT PRIMARY KEY DEFAULT 1,
categories_json JSON NULL,
global_keys_json JSON NULL,
updated_by_user_id INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_i18n_preferences_singleton CHECK (id = 1),
FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
);
`);
await connection.query(`
INSERT IGNORE INTO i18n_preferences (id, categories_json, global_keys_json, updated_by_user_id)
VALUES (1, JSON_ARRAY(), JSON_ARRAY(), NULL);
`);
console.log('✅ i18n preferences table created/verified');
// Backward-compatible for older schemas
await addColumnIfMissing(connection, 'i18n_preferences', 'categories_json', 'JSON NULL');
await addColumnIfMissing(connection, 'i18n_preferences', 'global_keys_json', 'JSON NULL');
await addColumnIfMissing(connection, 'i18n_preferences', 'updated_by_user_id', 'INT NULL');
await addForeignKeyIfMissing(
connection,
'i18n_preferences',
'i18n_preferences_ibfk_1',
`ALTER TABLE \`i18n_preferences\`
ADD CONSTRAINT \`i18n_preferences_ibfk_1\` FOREIGN KEY (\`updated_by_user_id\`) REFERENCES \`users\`(\`id\`) ON DELETE SET NULL ON UPDATE CASCADE`
);
// --- Dashboard Platforms (admin managed dashboard cards) --- // --- Dashboard Platforms (admin managed dashboard cards) ---
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS dashboard_plattforms ( CREATE TABLE IF NOT EXISTS dashboard_plattforms (

View File

@ -0,0 +1,67 @@
const db = require('../../database/database');
class I18nPreferencesRepository {
_safeJsonArray(value) {
if (Array.isArray(value)) return value;
if (value == null) return [];
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
}
_normalizeRow(row) {
const categories = this._safeJsonArray(row?.categories_json);
const globalKeys = this._safeJsonArray(row?.global_keys_json);
return {
categories,
globalKeys,
};
}
async get() {
const [rows] = await db.query('SELECT * FROM i18n_preferences WHERE id = 1 LIMIT 1');
if (!rows.length) {
return { categories: [], globalKeys: [] };
}
return this._normalizeRow(rows[0]);
}
async upsert({ categories, globalKeys, updatedByUserId } = {}) {
const current = await this.get();
const nextCategories = categories !== undefined ? categories : current.categories;
const nextGlobalKeys = globalKeys !== undefined ? globalKeys : current.globalKeys;
await db.query(
`INSERT INTO i18n_preferences (id, categories_json, global_keys_json, updated_by_user_id)
VALUES (1, ?, ?, ?)
ON DUPLICATE KEY UPDATE
categories_json = VALUES(categories_json),
global_keys_json = VALUES(global_keys_json),
updated_by_user_id = VALUES(updated_by_user_id)`,
[JSON.stringify(nextCategories || []), JSON.stringify(nextGlobalKeys || []), updatedByUserId || null]
);
return this.get();
}
async clear(updatedByUserId) {
await db.query(
`INSERT INTO i18n_preferences (id, categories_json, global_keys_json, updated_by_user_id)
VALUES (1, ?, ?, ?)
ON DUPLICATE KEY UPDATE
categories_json = VALUES(categories_json),
global_keys_json = VALUES(global_keys_json),
updated_by_user_id = VALUES(updated_by_user_id)`,
[JSON.stringify([]), JSON.stringify([]), updatedByUserId || null]
);
return this.get();
}
}
module.exports = I18nPreferencesRepository;

View File

@ -10,6 +10,10 @@ const CoffeeController = require('../controller/admin/CoffeeController');
const AffiliateController = require('../controller/affiliate/AffiliateController'); const AffiliateController = require('../controller/affiliate/AffiliateController');
const NewsController = require('../controller/news/NewsController'); const NewsController = require('../controller/news/NewsController');
const PoolController = require('../controller/pool/PoolController'); const PoolController = require('../controller/pool/PoolController');
const I18nPreferencesController = require('../controller/admin/I18nPreferencesController');
const { getRouterPathFromApiEnv } = require('../utils/apiPath');
const I18N_PREFERENCES_ROUTE_PATH = getRouterPathFromApiEnv('BACKEND_I18N_PREFERENCES_PATH', '/api/admin/i18n/preferences');
// Helper middlewares for company-stamp // Helper middlewares for company-stamp
function forceCompanyForAdmin(req, res, next) { function forceCompanyForAdmin(req, res, next) {
@ -37,5 +41,6 @@ router.delete('/admin/news/:id', authMiddleware, adminOnly, NewsController.delet
// Admin: remove pool members // Admin: remove pool members
router.delete('/admin/pools/:id/members', authMiddleware, adminOnly, PoolController.removeMembers); router.delete('/admin/pools/:id/members', authMiddleware, adminOnly, PoolController.removeMembers);
router.delete(I18N_PREFERENCES_ROUTE_PATH, authMiddleware, adminOnly, I18nPreferencesController.delete);
module.exports = router; module.exports = router;

View File

@ -30,6 +30,12 @@ const DevManagementController = require('../controller/dev/DevManagementControll
const CompanySettingsController = require('../controller/admin/CompanySettingsController'); const CompanySettingsController = require('../controller/admin/CompanySettingsController');
const DashboardPlatformsController = require('../controller/admin/DashboardPlatformsController'); const DashboardPlatformsController = require('../controller/admin/DashboardPlatformsController');
const ShippingFeesController = require('../controller/admin/ShippingFeesController'); const ShippingFeesController = require('../controller/admin/ShippingFeesController');
const LoginController = require('../controller/login/LoginController');
const I18nPreferencesController = require('../controller/admin/I18nPreferencesController');
const { getRouterPathFromApiEnv } = require('../utils/apiPath');
const AUTH_VALIDATE_ROUTE_PATH = getRouterPathFromApiEnv('BACKEND_AUTH_VALIDATE_PATH', '/api/auth/validate');
const I18N_PREFERENCES_ROUTE_PATH = getRouterPathFromApiEnv('BACKEND_I18N_PREFERENCES_PATH', '/api/admin/i18n/preferences');
// small helpers copied from original files // small helpers copied from original files
@ -44,6 +50,7 @@ function forceCompanyForAdmin(req, res, next) {
// === GET routes moved from other files === // === GET routes moved from other files ===
// auth.js GETs // auth.js GETs
router.get(AUTH_VALIDATE_ROUTE_PATH, LoginController.validate);
router.get('/me', authMiddleware, UserController.getMe); router.get('/me', authMiddleware, UserController.getMe);
router.get('/user/status', authMiddleware, UserStatusController.getStatus); router.get('/user/status', authMiddleware, UserStatusController.getStatus);
router.get('/user/status-progress', authMiddleware, UserStatusController.getStatusProgress); router.get('/user/status-progress', authMiddleware, UserStatusController.getStatusProgress);
@ -53,6 +60,7 @@ router.get('/users/:id/permissions', authMiddleware, PermissionController.getUse
router.get('/admin/users/:id/full', authMiddleware, adminOnly, AdminUserController.getFullUserAccountDetails); router.get('/admin/users/:id/full', authMiddleware, adminOnly, AdminUserController.getFullUserAccountDetails);
router.get('/admin/users/:id/detailed', authMiddleware, adminOnly, AdminUserController.getDetailedUserInfo); router.get('/admin/users/:id/detailed', authMiddleware, adminOnly, AdminUserController.getDetailedUserInfo);
router.get('/admin/company-settings', authMiddleware, adminOnly, CompanySettingsController.get); router.get('/admin/company-settings', authMiddleware, adminOnly, CompanySettingsController.get);
router.get(I18N_PREFERENCES_ROUTE_PATH, authMiddleware, adminOnly, I18nPreferencesController.get);
router.get('/users/:id/documents', authMiddleware, UserController.getUserDocumentsAndContracts); router.get('/users/:id/documents', authMiddleware, UserController.getUserDocumentsAndContracts);
router.get('/verify-password-reset', (req, res) => { /* Note: was moved from PasswordResetController.verifyPasswordResetToken */ res.status(204).end(); }); // keep placeholder if controller already registered via other verb router.get('/verify-password-reset', (req, res) => { /* Note: was moved from PasswordResetController.verifyPasswordResetToken */ res.status(204).end(); }); // keep placeholder if controller already registered via other verb

View File

@ -32,6 +32,10 @@ const NewsController = require('../controller/news/NewsController');
const InvoiceController = require('../controller/invoice/InvoiceController'); // NEW const InvoiceController = require('../controller/invoice/InvoiceController'); // NEW
const DevManagementController = require('../controller/dev/DevManagementController'); const DevManagementController = require('../controller/dev/DevManagementController');
const DashboardPlatformsController = require('../controller/admin/DashboardPlatformsController'); const DashboardPlatformsController = require('../controller/admin/DashboardPlatformsController');
const I18nPreferencesController = require('../controller/admin/I18nPreferencesController');
const { getRouterPathFromApiEnv } = require('../utils/apiPath');
const I18N_PREFERENCES_ROUTE_PATH = getRouterPathFromApiEnv('BACKEND_I18N_PREFERENCES_PATH', '/api/admin/i18n/preferences');
const multer = require('multer'); const multer = require('multer');
const upload = multer({ storage: multer.memoryStorage() }); const upload = multer({ storage: multer.memoryStorage() });
@ -82,6 +86,7 @@ router.post('/profile/company/complete', authMiddleware, CompanyProfileControlle
// Admin POSTs (moved from routes/admin.js) // Admin POSTs (moved from routes/admin.js)
router.post('/admin/verify-user/:id', authMiddleware, adminOnly, AdminUserController.verifyUser); router.post('/admin/verify-user/:id', authMiddleware, adminOnly, AdminUserController.verifyUser);
router.post(I18N_PREFERENCES_ROUTE_PATH, authMiddleware, adminOnly, I18nPreferencesController.post);
router.post('/admin/send-password-reset/:userId', authMiddleware, adminOnly, async (req, res) => { router.post('/admin/send-password-reset/:userId', authMiddleware, adminOnly, async (req, res) => {
const userId = req.params.userId; const userId = req.params.userId;
// require here to avoid circular/top-level ordering issues // require here to avoid circular/top-level ordering issues

View File

@ -9,6 +9,10 @@ const CoffeeController = require('../controller/admin/CoffeeController');
const CompanySettingsController = require('../controller/admin/CompanySettingsController'); const CompanySettingsController = require('../controller/admin/CompanySettingsController');
const DashboardPlatformsController = require('../controller/admin/DashboardPlatformsController'); const DashboardPlatformsController = require('../controller/admin/DashboardPlatformsController');
const ShippingFeesController = require('../controller/admin/ShippingFeesController'); const ShippingFeesController = require('../controller/admin/ShippingFeesController');
const I18nPreferencesController = require('../controller/admin/I18nPreferencesController');
const { getRouterPathFromApiEnv } = require('../utils/apiPath');
const I18N_PREFERENCES_ROUTE_PATH = getRouterPathFromApiEnv('BACKEND_I18N_PREFERENCES_PATH', '/api/admin/i18n/preferences');
const multer = require('multer'); const multer = require('multer');
const upload = multer({ storage: multer.memoryStorage() }); const upload = multer({ storage: multer.memoryStorage() });
@ -28,5 +32,6 @@ router.put('/admin/dashboard-platforms/:id', authMiddleware, adminOnly, Dashboar
// Admin: update shipping fee for a piece count (60/120) // Admin: update shipping fee for a piece count (60/120)
router.put('/admin/shipping-fees/:pieceCount', authMiddleware, adminOnly, ShippingFeesController.updatePrice); router.put('/admin/shipping-fees/:pieceCount', authMiddleware, adminOnly, ShippingFeesController.updatePrice);
router.put(I18N_PREFERENCES_ROUTE_PATH, authMiddleware, adminOnly, I18nPreferencesController.put);
module.exports = router; module.exports = router;

View File

@ -221,6 +221,63 @@ class LoginService {
throw error; throw error;
} }
} }
static async validateByRefreshToken(refreshToken) {
logger.info('LoginService.validateByRefreshToken:start');
const unitOfWork = new UnitOfWork();
await unitOfWork.start();
unitOfWork.registerRepository('login', new LoginRepository(unitOfWork));
unitOfWork.registerRepository('user', new UserRepository(unitOfWork));
unitOfWork.registerRepository('status', new UserStatusRepository(unitOfWork));
try {
const loginRepo = unitOfWork.getRepository('login');
const tokenRecord = await loginRepo.findRefreshToken(refreshToken);
if (!tokenRecord) {
const error = new Error('Invalid or expired refresh token');
error.status = 401;
throw error;
}
if (new Date(tokenRecord.expires_at) < new Date()) {
const error = new Error('Refresh token expired');
error.status = 401;
throw error;
}
const userRepo = unitOfWork.getRepository('user');
const user = await userRepo.findUserByEmailOrId(tokenRecord.user_id);
if (!user) {
const error = new Error('User not found');
error.status = 401;
throw error;
}
const statusRepo = unitOfWork.getRepository('status');
const userStatus = await statusRepo.getStatusByUserId(user.id);
if (userStatus && userStatus.status === 'suspended') {
const error = new Error('Account suspended');
error.status = 403;
throw error;
}
const role = await loginRepo.getUserRole(user.id);
const permissions = await loginRepo.getUserPermissions(user.id);
await unitOfWork.commit();
return {
user: {
...user.getPublicData(),
role,
permissions,
},
};
} catch (error) {
await unitOfWork.rollback(error);
throw error;
}
}
} }
// Helper for finding user by id or email // Helper for finding user by id or email

22
utils/apiPath.js Normal file
View File

@ -0,0 +1,22 @@
function toRouterPath(configuredPath, defaultApiPath) {
const fallback = String(defaultApiPath || '/').trim() || '/';
const raw = String(configuredPath || fallback).trim() || fallback;
let path = raw.startsWith('/') ? raw : `/${raw}`;
// Routers are mounted under /api in server.js, so strip an optional /api prefix.
if (path === '/api') path = '/';
if (path.startsWith('/api/')) path = path.slice(4);
if (path.length > 1) path = path.replace(/\/+$/, '');
return path || '/';
}
function getRouterPathFromApiEnv(envKey, defaultApiPath) {
return toRouterPath(process.env[envKey], defaultApiPath);
}
module.exports = {
toRouterPath,
getRouterPathFromApiEnv,
};