feat: nombre y logo de empresa configurables (solo dev, no desplegado a prod)
Nueva marca configurable desde Configuracion -> Empresa (solo superadmin): nombre y logo, guardados en system_settings (mismo patron que SMTP, sin tabla nueva). Nuevo endpoint publico GET /api/company (+ /logo) para que login/registro/recuperacion de clave puedan mostrar la marca antes de iniciar sesion. Sin configurar, se ve "LinkedCorp" y el logo estatico de siempre. Probado en dev (build + Playwright: panel de Configuracion, sidebar expandido/compacto, login publico), sin desplegar a produccion por pedido explicito del usuario -- este commit sirve de punto de control en git antes de decidir el despliegue. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "1.14.1",
|
||||
"version": "1.15.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -17,6 +17,7 @@ import notificationsRoutes from './modules/notifications/routes.js';
|
||||
import dashboardRoutes from './modules/dashboard/routes.js';
|
||||
import auditRoutes from './modules/audit/routes.js';
|
||||
import settingsRoutes from './modules/settings/routes.js';
|
||||
import companyRoutes from './modules/company/routes.js';
|
||||
import recurringTasksRoutes from './modules/recurring-tasks/routes.js';
|
||||
|
||||
export const app = express();
|
||||
@@ -39,6 +40,7 @@ app.use('/api/notifications', notificationsRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/audit', auditRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/company', companyRoutes);
|
||||
app.use('/api/recurring-tasks', recurringTasksRoutes);
|
||||
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
|
||||
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
|
||||
|
||||
@@ -57,4 +57,5 @@ export const ROLES = {
|
||||
export const CAN_MANAGE_CATALOGS = [ROLES.SUPERADMIN];
|
||||
export const CAN_MANAGE_USERS = [ROLES.SUPERADMIN, ROLES.ADMIN];
|
||||
export const CAN_MANAGE_SMTP = [ROLES.SUPERADMIN];
|
||||
export const CAN_MANAGE_SETTINGS = [ROLES.SUPERADMIN];
|
||||
export const CAN_WRITE = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD, ROLES.COLLABORATOR];
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import multer from 'multer';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']);
|
||||
|
||||
export const logoDir = path.join(env.storageRoot, 'branding');
|
||||
fs.mkdirSync(logoDir, { recursive: true });
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, logoDir),
|
||||
// Nombre generado por el servidor (evita colisiones/path traversal) y sirve de version para
|
||||
// el cache-busting del logo publico (cambia en cada subida).
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).slice(0, 10);
|
||||
cb(null, `logo-${Date.now()}-${crypto.randomBytes(6).toString('hex')}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const uploadLogo = multer({
|
||||
storage,
|
||||
limits: { fileSize: 3 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (!ALLOWED_MIME.has(file.mimetype)) {
|
||||
return cb(new Error('Tipo de archivo no permitido (usa PNG, JPG, WEBP o SVG)'));
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import path from 'node:path';
|
||||
import { ok, asyncHandler, HttpError } from '../../utils/http.js';
|
||||
import * as service from './service.js';
|
||||
|
||||
// Publico (sin auth): lo necesitan las paginas de login/registro/recuperacion antes de iniciar sesion.
|
||||
export const getCompany = asyncHandler(async (req, res) => ok(res, await service.getCompanyInfo()));
|
||||
|
||||
export const getCompanyLogo = asyncHandler(async (req, res) => {
|
||||
const file = await service.getCompanyLogoFile();
|
||||
if (!file) throw new HttpError(404, 'No hay logo configurado');
|
||||
// sendFile exige ruta absoluta (a diferencia de res.download, que la resuelve sola).
|
||||
res.type(file.mime).sendFile(path.resolve(file.filePath));
|
||||
});
|
||||
|
||||
export const putCompany = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.updateCompanyInfo({ name: req.body.name, file: req.file }, req.currentUser.id));
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth, requireRole, CAN_MANAGE_SETTINGS } from '../../middleware/auth.js';
|
||||
import { uploadLogo } from '../../middleware/uploadLogo.js';
|
||||
import { getCompany, getCompanyLogo, putCompany } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET publico (sin requireAuth): las paginas de login/registro/recuperacion lo necesitan antes
|
||||
// de tener sesion. Solo la escritura exige superadmin.
|
||||
router.get('/', getCompany);
|
||||
router.get('/logo', getCompanyLogo);
|
||||
router.put('/', requireAuth, requireRole(...CAN_MANAGE_SETTINGS), uploadLogo.single('logo'), putCompany);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,48 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as settingsRepo from '../settings/repository.js';
|
||||
import { logoDir } from '../../middleware/uploadLogo.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
|
||||
const COMPANY_KEYS = ['company_name', 'company_logo_filename', 'company_logo_mime'];
|
||||
const DEFAULT_NAME = 'LinkedCorp';
|
||||
|
||||
export async function getCompanyInfo() {
|
||||
const values = await settingsRepo.getAll(COMPANY_KEYS);
|
||||
return {
|
||||
name: values.company_name || DEFAULT_NAME,
|
||||
hasLogo: !!values.company_logo_filename,
|
||||
// El propio nombre de archivo (unico por subida) sirve de version para cache-busting del logo.
|
||||
logoVersion: values.company_logo_filename || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCompanyLogoFile() {
|
||||
const values = await settingsRepo.getAll(['company_logo_filename', 'company_logo_mime']);
|
||||
if (!values.company_logo_filename) return null;
|
||||
const filePath = path.join(logoDir, values.company_logo_filename);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return { filePath, mime: values.company_logo_mime || 'application/octet-stream' };
|
||||
}
|
||||
|
||||
export async function updateCompanyInfo({ name, file }, actorId) {
|
||||
const entries = {};
|
||||
if (name !== undefined) {
|
||||
const trimmed = (name || '').trim();
|
||||
if (!trimmed) throw new HttpError(400, 'El nombre de la empresa es obligatorio');
|
||||
entries.company_name = trimmed;
|
||||
}
|
||||
if (file) {
|
||||
const values = await settingsRepo.getAll(['company_logo_filename']);
|
||||
if (values.company_logo_filename) {
|
||||
const oldPath = path.join(logoDir, values.company_logo_filename);
|
||||
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||
}
|
||||
entries.company_logo_filename = file.filename;
|
||||
entries.company_logo_mime = file.mimetype;
|
||||
}
|
||||
if (Object.keys(entries).length) await settingsRepo.setMany(entries);
|
||||
await logAudit({ userId: actorId, action: 'update', entityType: 'settings:company', entityId: null });
|
||||
return getCompanyInfo();
|
||||
}
|
||||
Reference in New Issue
Block a user