Initial commit
This commit is contained in:
Generated
+2999
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "clima-laboral-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"dev": "node src/server.js",
|
||||
"start": "node src/server.js",
|
||||
"seed:superadmin": "node src/scripts/seed_superadmin.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dayjs": "^1.11.11",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.10.2",
|
||||
"pdfkit": "^0.15.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const superadminRoutes = require('./routes/superadmin');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
const surveyRoutes = require('./routes/surveys');
|
||||
const reportRoutes = require('./routes/reports');
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
|
||||
app.get('/health', (req, res) => res.json({ ok: true }));
|
||||
|
||||
app.use('/auth', authRoutes);
|
||||
app.use('/superadmin', superadminRoutes);
|
||||
app.use('/admin', adminRoutes);
|
||||
app.use('/surveys', surveyRoutes);
|
||||
app.use('/reports', reportRoutes);
|
||||
|
||||
const { getCentralPool } = require('./db/central');
|
||||
|
||||
app.use(async (err, req, res, next) => {
|
||||
console.error(err);
|
||||
const role = req.user?.role || null;
|
||||
const userId = req.user?.id || null;
|
||||
const companyCode = req.user?.company_code || req.headers['x-company-code'] || null;
|
||||
const menu = req.headers['x-menu'] || req.headers['x-route'] || req.path;
|
||||
const errorMessage = err?.message || 'Error interno';
|
||||
|
||||
if (role !== 'superadmin') {
|
||||
try {
|
||||
const central = getCentralPool();
|
||||
await central.query(
|
||||
'INSERT INTO error_logs (user_role, user_id, company_code, menu, error_message) VALUES (?, ?, ?, ?, ?)',
|
||||
[role, userId, companyCode, menu, errorMessage]
|
||||
);
|
||||
} catch (logErr) {
|
||||
console.error('Error log fail:', logErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'superadmin') {
|
||||
return res.status(500).json({ error: 'Error interno', detail: errorMessage });
|
||||
}
|
||||
return res.status(500).json({ error: 'Error interno' });
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
port: process.env.PORT || 4020,
|
||||
jwtSecret: process.env.JWT_SECRET || 'CHANGE_ME',
|
||||
centralDb: {
|
||||
host: process.env.CENTRAL_DB_HOST || '192.168.222.20',
|
||||
port: process.env.CENTRAL_DB_PORT || 3306,
|
||||
user: process.env.CENTRAL_DB_USER || 'user_ia',
|
||||
password: process.env.CENTRAL_DB_PASS || 'UserIA2026',
|
||||
database: process.env.CENTRAL_DB_NAME || 'clima_platform'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const { centralDb } = require('../config/env');
|
||||
|
||||
let pool;
|
||||
|
||||
function getCentralPool() {
|
||||
if (!pool) {
|
||||
pool = mysql.createPool({
|
||||
host: centralDb.host,
|
||||
port: centralDb.port,
|
||||
user: centralDb.user,
|
||||
password: centralDb.password,
|
||||
database: centralDb.database,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
module.exports = { getCentralPool };
|
||||
@@ -0,0 +1,24 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const pools = new Map();
|
||||
|
||||
function getTenantPool(company) {
|
||||
if (!company || !company.db_name) {
|
||||
throw new Error('Empresa sin datos de conexión');
|
||||
}
|
||||
if (!pools.has(company.db_name)) {
|
||||
const pool = mysql.createPool({
|
||||
host: company.db_host,
|
||||
port: company.db_port || 3306,
|
||||
user: company.db_user,
|
||||
password: company.db_pass,
|
||||
database: company.db_name,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
pools.set(company.db_name, pool);
|
||||
}
|
||||
return pools.get(company.db_name);
|
||||
}
|
||||
|
||||
module.exports = { getTenantPool };
|
||||
@@ -0,0 +1,15 @@
|
||||
const { verifyToken } = require('../utils/jwt');
|
||||
|
||||
function authRequired(req, res, next) {
|
||||
const header = req.headers.authorization || '';
|
||||
const token = header.replace('Bearer ', '');
|
||||
if (!token) return res.status(401).json({ error: 'Token requerido' });
|
||||
try {
|
||||
req.user = verifyToken(token);
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Token inválido' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { authRequired };
|
||||
@@ -0,0 +1,10 @@
|
||||
function requireRole(...roles) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user || !roles.includes(req.user.role)) {
|
||||
return res.status(403).json({ error: 'No autorizado' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { requireRole };
|
||||
@@ -0,0 +1,24 @@
|
||||
const { getCentralPool } = require('../db/central');
|
||||
const { getTenantPool } = require('../db/tenant');
|
||||
|
||||
async function resolveTenant(req, res, next) {
|
||||
try {
|
||||
const code = req.headers['x-company-code'] || req.query.company_code || req.body.company_code;
|
||||
if (!code) return res.status(400).json({ error: 'company_code requerido' });
|
||||
|
||||
const central = getCentralPool();
|
||||
const [rows] = await central.query(
|
||||
'SELECT * FROM companies WHERE code = ? AND status = "active" AND start_date <= CURDATE() AND end_date >= CURDATE()',
|
||||
[code]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Empresa no encontrada o inactiva' });
|
||||
|
||||
req.company = rows[0];
|
||||
req.tenantPool = getTenantPool(rows[0]);
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { resolveTenant };
|
||||
@@ -0,0 +1,217 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const { authRequired } = require('../middleware/auth');
|
||||
const { requireRole } = require('../middleware/rbac');
|
||||
const { resolveTenant } = require('../middleware/tenant');
|
||||
const { createSurveyPeriod, activateSurvey, closeSurvey } = require('../services/surveyService');
|
||||
const { importUsersFromExcel } = require('../services/importService');
|
||||
const { getParticipationSummary } = require('../services/participationService');
|
||||
const {
|
||||
listQuestions,
|
||||
exportQuestionsExcel,
|
||||
exportQuestionsTemplate,
|
||||
importQuestionsExcel,
|
||||
listQuestionSets,
|
||||
listDimensions,
|
||||
createDimension,
|
||||
createQuestionSetFromCurrent,
|
||||
createQuestionSetEmpty,
|
||||
addQuestionToSet
|
||||
} = require('../services/questionService');
|
||||
|
||||
const upload = multer();
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authRequired, requireRole('admin', 'superadmin'), resolveTenant);
|
||||
|
||||
router.get('/survey-periods', async (req, res, next) => {
|
||||
try {
|
||||
const [rows] = await req.tenantPool.query('SELECT * FROM survey_periods ORDER BY id DESC');
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/survey-periods', async (req, res, next) => {
|
||||
try {
|
||||
const { year, month, fecha_inicio, fecha_fin } = req.body;
|
||||
if (!year || !month || !fecha_inicio || !fecha_fin) return res.status(400).json({ error: 'Campos requeridos' });
|
||||
const period = await createSurveyPeriod(req.tenantPool, { year, month, fecha_inicio, fecha_fin });
|
||||
res.json(period);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/survey-periods/:id/activate', async (req, res, next) => {
|
||||
try {
|
||||
await activateSurvey(req.tenantPool, req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/survey-periods/:id/close', async (req, res, next) => {
|
||||
try {
|
||||
const result = await closeSurvey(req.tenantPool, req.params.id);
|
||||
res.json({ ok: true, result });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/dashboard', async (req, res, next) => {
|
||||
try {
|
||||
const [[current]] = await req.tenantPool.query(
|
||||
'SELECT score, classification, participation_rate FROM survey_results WHERE scope = "global" ORDER BY survey_period_id DESC LIMIT 1'
|
||||
);
|
||||
const [dimensions] = await req.tenantPool.query(
|
||||
'SELECT d.name, r.score, r.classification FROM survey_results r JOIN dimensions d ON d.id = r.dimension_id WHERE r.scope = "dimension" AND r.survey_period_id = (SELECT MAX(id) FROM survey_periods WHERE estado = "cerrada")'
|
||||
);
|
||||
const [history] = await req.tenantPool.query(
|
||||
'SELECT sp.codigo_medicion, r.score FROM survey_results r JOIN survey_periods sp ON sp.id = r.survey_period_id WHERE r.scope = "global" ORDER BY sp.id DESC'
|
||||
);
|
||||
const participation = await getParticipationSummary(req.tenantPool);
|
||||
res.json({ current, dimensions, history, participation });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/users/import', upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'Archivo requerido' });
|
||||
const result = await importUsersFromExcel(req.file.buffer, req.tenantPool);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/questions', async (req, res, next) => {
|
||||
try {
|
||||
const rows = await listQuestions(req.tenantPool);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/questions/export', async (req, res, next) => {
|
||||
try {
|
||||
const buffer = await exportQuestionsExcel(req.tenantPool);
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=\"preguntas.xlsx\"');
|
||||
res.send(buffer);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/questions/template', async (req, res, next) => {
|
||||
try {
|
||||
const buffer = await exportQuestionsTemplate();
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=\"preguntas_template.xlsx\"');
|
||||
res.send(buffer);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/questions/import', upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'Archivo requerido' });
|
||||
const setId = req.body?.set_id ? Number(req.body.set_id) : null;
|
||||
const result = await importQuestionsExcel(req.file.buffer, req.tenantPool, setId);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/question-sets', async (req, res, next) => {
|
||||
try {
|
||||
const rows = await listQuestionSets(req.tenantPool);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/question-sets', async (req, res, next) => {
|
||||
try {
|
||||
const { name, empty } = req.body;
|
||||
const result = empty ? await createQuestionSetEmpty(req.tenantPool, name) : await createQuestionSetFromCurrent(req.tenantPool, name);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/question-sets/:id', async (req, res, next) => {
|
||||
try {
|
||||
const [rows] = await req.tenantPool.query('SELECT id, code, is_active FROM question_sets WHERE id = ?', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Set no encontrado' });
|
||||
const setRow = rows[0];
|
||||
if (setRow.is_active) {
|
||||
return res.status(400).json({ error: 'No se puede eliminar un set activo' });
|
||||
}
|
||||
const [[{ used }]] = await req.tenantPool.query('SELECT COUNT(*) AS used FROM survey_periods WHERE question_set_code = ?', [setRow.code]);
|
||||
if (used > 0) {
|
||||
return res.status(400).json({ error: 'No se puede eliminar: ya tiene mediciones' });
|
||||
}
|
||||
await req.tenantPool.query('DELETE FROM question_set_questions WHERE question_set_id = ?', [setRow.id]);
|
||||
await req.tenantPool.query('DELETE FROM question_sets WHERE id = ?', [setRow.id]);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/question-sets/:id/questions', async (req, res, next) => {
|
||||
try {
|
||||
const [rows] = await req.tenantPool.query(
|
||||
'SELECT qs.question_code, qs.text_snapshot, d.name AS dimension FROM question_set_questions qs JOIN dimensions d ON d.id = qs.dimension_id WHERE qs.question_set_id = ? ORDER BY qs.sort_order',
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/dimensions', async (req, res, next) => {
|
||||
try {
|
||||
const rows = await listDimensions(req.tenantPool);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/dimensions', async (req, res, next) => {
|
||||
try {
|
||||
const { name, code, description } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Nombre requerido' });
|
||||
const result = await createDimension(req.tenantPool, { name, code, description });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/question-sets/:id/questions', async (req, res, next) => {
|
||||
try {
|
||||
const { dimension_id, text } = req.body;
|
||||
if (!dimension_id || !text) return res.status(400).json({ error: 'dimension_id y text requeridos' });
|
||||
const result = await addQuestionToSet(req.tenantPool, { questionSetId: req.params.id, dimensionId: dimension_id, text });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,47 @@
|
||||
const express = require('express');
|
||||
const { getCentralPool } = require('../db/central');
|
||||
const { resolveTenant } = require('../middleware/tenant');
|
||||
const { comparePassword } = require('../utils/password');
|
||||
const { signToken } = require('../utils/jwt');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password, role, company_code } = req.body;
|
||||
if (!email || !password || !role) {
|
||||
return res.status(400).json({ error: 'email, password y role requeridos' });
|
||||
}
|
||||
|
||||
if (role === 'superadmin') {
|
||||
const central = getCentralPool();
|
||||
const [rows] = await central.query('SELECT * FROM superadmins WHERE email = ? AND status = "active"', [email]);
|
||||
if (!rows.length) return res.status(401).json({ error: 'Credenciales inválidas' });
|
||||
const ok = await comparePassword(password, rows[0].password_hash);
|
||||
if (!ok) return res.status(401).json({ error: 'Credenciales inválidas' });
|
||||
|
||||
const token = signToken({ id: rows[0].id, role: 'superadmin' });
|
||||
return res.json({ token, role: 'superadmin' });
|
||||
}
|
||||
|
||||
if (!company_code) return res.status(400).json({ error: 'company_code requerido' });
|
||||
|
||||
req.body.company_code = company_code;
|
||||
await resolveTenant(req, res, async (err) => {
|
||||
if (err) return next(err);
|
||||
const pool = req.tenantPool;
|
||||
const table = role === 'admin' ? 'admins' : 'users';
|
||||
const [rows] = await pool.query(`SELECT * FROM ${table} WHERE email = ? AND status = 'active'`, [email]);
|
||||
if (!rows.length) return res.status(401).json({ error: 'Credenciales inválidas' });
|
||||
const ok = await comparePassword(password, rows[0].password_hash);
|
||||
if (!ok) return res.status(401).json({ error: 'Credenciales inválidas' });
|
||||
|
||||
const token = signToken({ id: rows[0].id, role, company_code });
|
||||
return res.json({ token, role, company_code });
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,31 @@
|
||||
const express = require('express');
|
||||
const { authRequired } = require('../middleware/auth');
|
||||
const { requireRole } = require('../middleware/rbac');
|
||||
const { resolveTenant } = require('../middleware/tenant');
|
||||
const { generatePdfReport, generateExcelReport } = require('../services/reportService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/pdf/:surveyPeriodId', authRequired, requireRole('admin'), resolveTenant, async (req, res, next) => {
|
||||
try {
|
||||
const buffer = await generatePdfReport(req.tenantPool, req.params.surveyPeriodId, req.company.name);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="reporte.pdf"');
|
||||
res.send(buffer);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/excel/:surveyPeriodId', authRequired, requireRole('admin'), resolveTenant, async (req, res, next) => {
|
||||
try {
|
||||
const buffer = await generateExcelReport(req.tenantPool, req.params.surveyPeriodId);
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="reporte.xlsx"');
|
||||
res.send(buffer);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,180 @@
|
||||
const express = require('express');
|
||||
const { authRequired } = require('../middleware/auth');
|
||||
const { requireRole } = require('../middleware/rbac');
|
||||
const { getCentralPool } = require('../db/central');
|
||||
const { provisionTenant } = require('../services/tenantProvision');
|
||||
const { getTenantPool } = require('../db/tenant');
|
||||
const { hashPassword } = require('../utils/password');
|
||||
const { getParticipationSummary } = require('../services/participationService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/companies', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||
try {
|
||||
const central = getCentralPool();
|
||||
const [rows] = await central.query('SELECT id, name, code, status, start_date, end_date, db_host, db_port, db_name, created_at FROM companies ORDER BY id DESC');
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/companies', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||
try {
|
||||
const { name, code, start_date, end_date, db_name, db_host, db_port, db_user, db_pass, admin_name, admin_email, admin_password } = req.body;
|
||||
if (!name || !code || !db_name || !db_host || !db_user || !db_pass || !admin_name || !admin_email || !admin_password) {
|
||||
return res.status(400).json({ error: 'Faltan campos requeridos' });
|
||||
}
|
||||
if (!start_date || !end_date) {
|
||||
return res.status(400).json({ error: 'Fecha inicio y fin son obligatorias' });
|
||||
}
|
||||
if (start_date > end_date) {
|
||||
return res.status(400).json({ error: 'Fecha inicio no puede ser mayor que fecha fin' });
|
||||
}
|
||||
|
||||
await provisionTenant({
|
||||
name,
|
||||
code,
|
||||
dbName: db_name,
|
||||
dbHost: db_host,
|
||||
dbPort: db_port || 3306,
|
||||
dbUser: db_user,
|
||||
dbPass: db_pass,
|
||||
adminName: admin_name,
|
||||
adminEmail: admin_email,
|
||||
adminPassword: admin_password,
|
||||
startDate: start_date,
|
||||
endDate: end_date
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/companies/:id/status', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||
try {
|
||||
const { status } = req.body;
|
||||
if (!['active', 'suspended'].includes(status)) {
|
||||
return res.status(400).json({ error: 'Estado inválido' });
|
||||
}
|
||||
const central = getCentralPool();
|
||||
await central.query('UPDATE companies SET status = ? WHERE id = ?', [status, req.params.id]);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/companies/:id/dates', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||
try {
|
||||
const { start_date, end_date } = req.body;
|
||||
if (!start_date || !end_date) {
|
||||
return res.status(400).json({ error: 'Fecha inicio y fin son obligatorias' });
|
||||
}
|
||||
if (start_date > end_date) {
|
||||
return res.status(400).json({ error: 'Fecha inicio no puede ser mayor que fecha fin' });
|
||||
}
|
||||
const central = getCentralPool();
|
||||
await central.query('UPDATE companies SET start_date = ?, end_date = ? WHERE id = ?', [
|
||||
start_date || null,
|
||||
end_date || null,
|
||||
req.params.id
|
||||
]);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/companies/:id/participation', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||
try {
|
||||
const central = getCentralPool();
|
||||
const [[company]] = await central.query('SELECT * FROM companies WHERE id = ?', [req.params.id]);
|
||||
if (!company) return res.status(404).json({ error: 'Empresa no encontrada' });
|
||||
const tenantPool = getTenantPool(company);
|
||||
const participation = await getParticipationSummary(tenantPool);
|
||||
res.json({ company_id: company.id, participation });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/companies/:id', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||
try {
|
||||
const central = getCentralPool();
|
||||
const [[company]] = await central.query('SELECT * FROM companies WHERE id = ?', [req.params.id]);
|
||||
if (!company) return res.status(404).json({ error: 'Empresa no encontrada' });
|
||||
|
||||
const tenantPool = getTenantPool(company);
|
||||
const [[{ periods }]] = await tenantPool.query('SELECT COUNT(*) AS periods FROM survey_periods');
|
||||
const [[{ responses }]] = await tenantPool.query('SELECT COUNT(*) AS responses FROM responses');
|
||||
|
||||
if (periods > 0 || responses > 0) {
|
||||
return res.status(400).json({ error: 'No se puede eliminar: existen encuestas registradas' });
|
||||
}
|
||||
|
||||
await central.query('DROP DATABASE IF EXISTS `' + company.db_name + '`');
|
||||
await central.query('DELETE FROM companies WHERE id = ?', [company.id]);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/companies/:id/admins', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||
try {
|
||||
const { name, email, password } = req.body;
|
||||
if (!name || !email || !password) {
|
||||
return res.status(400).json({ error: 'Nombre, email y password requeridos' });
|
||||
}
|
||||
const central = getCentralPool();
|
||||
const [[company]] = await central.query('SELECT * FROM companies WHERE id = ?', [req.params.id]);
|
||||
if (!company) return res.status(404).json({ error: 'Empresa no encontrada' });
|
||||
|
||||
const tenantPool = getTenantPool(company);
|
||||
const passwordHash = await hashPassword(password);
|
||||
await tenantPool.query(
|
||||
'INSERT INTO admins (name, email, password_hash, role, status) VALUES (?, ?, ?, "admin", "active")',
|
||||
[name, email, passwordHash]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/companies/:id/admins', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||
try {
|
||||
const central = getCentralPool();
|
||||
const [[company]] = await central.query('SELECT * FROM companies WHERE id = ?', [req.params.id]);
|
||||
if (!company) return res.status(404).json({ error: 'Empresa no encontrada' });
|
||||
|
||||
const tenantPool = getTenantPool(company);
|
||||
const [rows] = await tenantPool.query('SELECT id, name, email, status, created_at FROM admins ORDER BY id DESC');
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/companies/:id/admins/:adminId/status', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||
try {
|
||||
const { status } = req.body;
|
||||
if (!['active', 'inactive'].includes(status)) {
|
||||
return res.status(400).json({ error: 'Estado inválido' });
|
||||
}
|
||||
const central = getCentralPool();
|
||||
const [[company]] = await central.query('SELECT * FROM companies WHERE id = ?', [req.params.id]);
|
||||
if (!company) return res.status(404).json({ error: 'Empresa no encontrada' });
|
||||
|
||||
const tenantPool = getTenantPool(company);
|
||||
await tenantPool.query('UPDATE admins SET status = ? WHERE id = ?', [status, req.params.adminId]);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,58 @@
|
||||
const express = require('express');
|
||||
const { authRequired } = require('../middleware/auth');
|
||||
const { resolveTenant } = require('../middleware/tenant');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/active', resolveTenant, async (req, res, next) => {
|
||||
try {
|
||||
const [[period]] = await req.tenantPool.query('SELECT * FROM survey_periods WHERE estado = "activa" ORDER BY id DESC LIMIT 1');
|
||||
if (!period) return res.json({ period: null, questions: [] });
|
||||
|
||||
const [questions] = await req.tenantPool.query(
|
||||
'SELECT sq.question_id, sq.question_code, sq.text_snapshot AS text, d.name AS dimension FROM survey_questions sq JOIN dimensions d ON d.id = sq.dimension_id WHERE sq.survey_period_id = ? ORDER BY sq.sort_order ASC',
|
||||
[period.id]
|
||||
);
|
||||
|
||||
res.json({ period, questions });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/responses', authRequired, resolveTenant, async (req, res, next) => {
|
||||
try {
|
||||
const { survey_period_id, answers } = req.body;
|
||||
if (!survey_period_id || !Array.isArray(answers)) {
|
||||
return res.status(400).json({ error: 'survey_period_id y answers requeridos' });
|
||||
}
|
||||
const [[{ total_questions }]] = await req.tenantPool.query(
|
||||
'SELECT COUNT(*) AS total_questions FROM survey_questions WHERE survey_period_id = ?',
|
||||
[survey_period_id]
|
||||
);
|
||||
const uniqueIds = new Set(answers.map((a) => a.question_id));
|
||||
if (uniqueIds.size !== total_questions) {
|
||||
return res.status(400).json({ error: 'Resultados incompletos' });
|
||||
}
|
||||
|
||||
for (const ans of answers) {
|
||||
if (![1,2,3,4,5].includes(Number(ans.value))) {
|
||||
return res.status(400).json({ error: 'Valor inválido' });
|
||||
}
|
||||
const [[qrow]] = await req.tenantPool.query(
|
||||
'SELECT question_code FROM survey_questions WHERE survey_period_id = ? AND question_id = ? LIMIT 1',
|
||||
[survey_period_id, ans.question_id]
|
||||
);
|
||||
await req.tenantPool.query(
|
||||
'INSERT INTO responses (survey_period_id, user_id, question_id, question_code, value) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE value = VALUES(value)',
|
||||
[survey_period_id, req.user.id, ans.question_id, qrow?.question_code || '', ans.value]
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,21 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const ExcelJS = require('exceljs');
|
||||
|
||||
async function run() {
|
||||
const outDir = path.resolve(__dirname, '../../../templates');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('Usuarios');
|
||||
sheet.addRow(['Nombre', 'Email', 'Departamento', 'Cargo']);
|
||||
|
||||
const outPath = path.join(outDir, 'plantilla_usuarios.xlsx');
|
||||
await workbook.xlsx.writeFile(outPath);
|
||||
console.log('Plantilla XLSX creada:', outPath);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
const { getCentralPool } = require('../db/central');
|
||||
const { hashPassword } = require('../utils/password');
|
||||
|
||||
async function run() {
|
||||
const name = process.argv[2];
|
||||
const email = process.argv[3];
|
||||
const password = process.argv[4];
|
||||
|
||||
if (!name || !email || !password) {
|
||||
console.error('Uso: node seed_superadmin.js "Nombre" correo@example.com clave');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const pool = getCentralPool();
|
||||
await pool.query(
|
||||
'INSERT INTO superadmins (name, email, password_hash, status) VALUES (?, ?, ?, "active")',
|
||||
[name, email, passwordHash]
|
||||
);
|
||||
console.log('Superadmin creado');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
const app = require('./app');
|
||||
const { port } = require('./config/env');
|
||||
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`API escuchando en puerto ${port}`);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
const ExcelJS = require('exceljs');
|
||||
|
||||
async function importUsersFromExcel(buffer, pool) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer);
|
||||
const sheet = workbook.worksheets[0];
|
||||
const errors = [];
|
||||
const inserted = [];
|
||||
|
||||
if (!sheet) return { errors: ['No hay hoja en el archivo'], inserted: 0 };
|
||||
|
||||
sheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber === 1) return;
|
||||
const name = String(row.getCell(1).value || '').trim();
|
||||
const email = String(row.getCell(2).value || '').trim();
|
||||
const department = String(row.getCell(3).value || '').trim();
|
||||
const position = String(row.getCell(4).value || '').trim();
|
||||
|
||||
if (!name || !email) {
|
||||
errors.push(`Fila ${rowNumber}: nombre y email son obligatorios`);
|
||||
return;
|
||||
}
|
||||
|
||||
inserted.push({ name, email, department, position });
|
||||
});
|
||||
|
||||
for (const user of inserted) {
|
||||
try {
|
||||
await pool.query(
|
||||
'INSERT INTO users (name, email, department, position, status) VALUES (?, ?, ?, ?, "active")',
|
||||
[user.name, user.email, user.department, user.position]
|
||||
);
|
||||
} catch (err) {
|
||||
errors.push(`Usuario ${user.email}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { inserted: inserted.length - errors.length, errors };
|
||||
}
|
||||
|
||||
module.exports = { importUsersFromExcel };
|
||||
@@ -0,0 +1,69 @@
|
||||
async function getLatestSurveyPeriodId(pool) {
|
||||
const [[active]] = await pool.query(
|
||||
'SELECT id FROM survey_periods WHERE estado = "activa" ORDER BY id DESC LIMIT 1'
|
||||
);
|
||||
if (active && active.id) return active.id;
|
||||
const [[closed]] = await pool.query(
|
||||
'SELECT id FROM survey_periods WHERE estado = "cerrada" ORDER BY id DESC LIMIT 1'
|
||||
);
|
||||
return closed ? closed.id : null;
|
||||
}
|
||||
|
||||
async function getParticipationSummary(pool) {
|
||||
const [[{ total_users }]] = await pool.query(
|
||||
'SELECT COUNT(*) AS total_users FROM users WHERE status = "active"'
|
||||
);
|
||||
const surveyPeriodId = await getLatestSurveyPeriodId(pool);
|
||||
if (!surveyPeriodId) {
|
||||
return {
|
||||
survey_period_id: null,
|
||||
total_users,
|
||||
total_questions: 0,
|
||||
completed_users: 0,
|
||||
partial_users: 0,
|
||||
started_users: 0
|
||||
};
|
||||
}
|
||||
|
||||
const [[{ total_questions }]] = await pool.query(
|
||||
'SELECT COUNT(*) AS total_questions FROM survey_questions WHERE survey_period_id = ?',
|
||||
[surveyPeriodId]
|
||||
);
|
||||
|
||||
if (!total_questions) {
|
||||
return {
|
||||
survey_period_id: surveyPeriodId,
|
||||
total_users,
|
||||
total_questions: 0,
|
||||
completed_users: 0,
|
||||
partial_users: 0,
|
||||
started_users: 0
|
||||
};
|
||||
}
|
||||
|
||||
const [[{ started_users }]] = await pool.query(
|
||||
'SELECT COUNT(DISTINCT user_id) AS started_users FROM responses WHERE survey_period_id = ?',
|
||||
[surveyPeriodId]
|
||||
);
|
||||
|
||||
const [[{ completed_users }]] = await pool.query(
|
||||
'SELECT COUNT(*) AS completed_users FROM (SELECT user_id, COUNT(*) c FROM responses WHERE survey_period_id = ? GROUP BY user_id HAVING c = ?) t',
|
||||
[surveyPeriodId, total_questions]
|
||||
);
|
||||
|
||||
const [[{ partial_users }]] = await pool.query(
|
||||
'SELECT COUNT(*) AS partial_users FROM (SELECT user_id, COUNT(*) c FROM responses WHERE survey_period_id = ? GROUP BY user_id HAVING c > 0 AND c < ?) t',
|
||||
[surveyPeriodId, total_questions]
|
||||
);
|
||||
|
||||
return {
|
||||
survey_period_id: surveyPeriodId,
|
||||
total_users,
|
||||
total_questions,
|
||||
completed_users,
|
||||
partial_users,
|
||||
started_users
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getParticipationSummary };
|
||||
@@ -0,0 +1,176 @@
|
||||
const ExcelJS = require('exceljs');
|
||||
const { createQuestionSet, createEmptyQuestionSet, getActiveQuestionSet } = require('./questionSetService');
|
||||
|
||||
async function listQuestions(pool) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT q.id, q.code, q.text, q.is_active, d.name AS dimension, d.code AS dimension_code FROM questions q JOIN dimensions d ON d.id = q.dimension_id ORDER BY d.sort_order, q.id'
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function listQuestionSets(pool) {
|
||||
const [rows] = await pool.query('SELECT id, code, name, version, is_active, created_at FROM question_sets ORDER BY id DESC');
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function listDimensions(pool) {
|
||||
const [rows] = await pool.query('SELECT id, name, code, description, sort_order FROM dimensions ORDER BY sort_order, id');
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function createDimension(pool, { name, code, description }) {
|
||||
const finalCode = code || `DIM-${Date.now()}`;
|
||||
const [res] = await pool.query(
|
||||
'INSERT INTO dimensions (name, code, description, sort_order) VALUES (?, ?, ?, ?)',
|
||||
[name, finalCode, description || null, 0]
|
||||
);
|
||||
return { id: res.insertId, code: finalCode, name };
|
||||
}
|
||||
|
||||
async function exportQuestionsExcel(pool) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('Preguntas');
|
||||
sheet.addRow(['question_code', 'dimension_code', 'dimension_name', 'text', 'is_active']);
|
||||
|
||||
const rows = await listQuestions(pool);
|
||||
rows.forEach((r) => {
|
||||
sheet.addRow([r.code, r.dimension_code, r.dimension, r.text, r.is_active]);
|
||||
});
|
||||
|
||||
return workbook.xlsx.writeBuffer();
|
||||
}
|
||||
|
||||
async function exportQuestionsTemplate() {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('Preguntas');
|
||||
sheet.addRow(['question_code', 'dimension_code', 'dimension_name', 'text', 'is_active']);
|
||||
sheet.addRow(['Q-001', 'DIM-LID', 'Liderazgo', 'Ejemplo de pregunta', 1]);
|
||||
return workbook.xlsx.writeBuffer();
|
||||
}
|
||||
|
||||
async function importQuestionsExcel(buffer, pool, setId) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer);
|
||||
const sheet = workbook.worksheets[0];
|
||||
const errors = [];
|
||||
let processed = 0;
|
||||
const setSortOrder = new Map();
|
||||
|
||||
if (!sheet) return { processed, errors: ['No hay hoja en el archivo'] };
|
||||
|
||||
const dimMap = new Map();
|
||||
const [dims] = await pool.query('SELECT id, code FROM dimensions');
|
||||
dims.forEach((d) => dimMap.set(String(d.code).trim(), d.id));
|
||||
|
||||
for (let i = 2; i <= sheet.rowCount; i++) {
|
||||
const row = sheet.getRow(i);
|
||||
const code = String(row.getCell(1).value || '').trim();
|
||||
const dimCode = String(row.getCell(2).value || '').trim();
|
||||
const text = String(row.getCell(4).value || '').trim();
|
||||
const isActiveRaw = row.getCell(5).value;
|
||||
const isActive = String(isActiveRaw || '1').trim() === '0' ? 0 : 1;
|
||||
|
||||
if (!code || !dimCode || !text) {
|
||||
errors.push(`Fila ${i}: code, dimension_code y text son obligatorios`);
|
||||
continue;
|
||||
}
|
||||
const dimId = dimMap.get(dimCode);
|
||||
if (!dimId) {
|
||||
errors.push(`Fila ${i}: dimension_code inválido (${dimCode})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [[existing]] = await pool.query('SELECT id, text FROM questions WHERE code = ?', [code]);
|
||||
let questionId = existing?.id;
|
||||
if (!existing) {
|
||||
const [res] = await pool.query(
|
||||
'INSERT INTO questions (dimension_id, code, text, is_active) VALUES (?, ?, ?, ?)',
|
||||
[dimId, code, text, isActive]
|
||||
);
|
||||
questionId = res.insertId;
|
||||
await pool.query(
|
||||
'INSERT INTO question_versions (question_id, version, text, is_active) VALUES (?, 1, ?, 1)',
|
||||
[questionId, text]
|
||||
);
|
||||
} else {
|
||||
if (existing.text !== text) {
|
||||
const [[ver]] = await pool.query('SELECT MAX(version) AS v FROM question_versions WHERE question_id = ?', [existing.id]);
|
||||
const nextVer = (ver?.v || 0) + 1;
|
||||
await pool.query(
|
||||
'INSERT INTO question_versions (question_id, version, text, is_active) VALUES (?, ?, ?, 1)',
|
||||
[existing.id, nextVer, text]
|
||||
);
|
||||
await pool.query('UPDATE questions SET text = ?, is_active = ? WHERE id = ?', [text, isActive, existing.id]);
|
||||
}
|
||||
}
|
||||
if (setId && questionId) {
|
||||
const [[{ existsInSet }]] = await pool.query(
|
||||
'SELECT COUNT(*) AS existsInSet FROM question_set_questions WHERE question_set_id = ? AND question_id = ?',
|
||||
[setId, questionId]
|
||||
);
|
||||
if (!existsInSet) {
|
||||
let currentOrder = setSortOrder.get(setId);
|
||||
if (!currentOrder) {
|
||||
const [[{ maxOrder }]] = await pool.query(
|
||||
'SELECT COALESCE(MAX(sort_order), 0) AS maxOrder FROM question_set_questions WHERE question_set_id = ?',
|
||||
[setId]
|
||||
);
|
||||
currentOrder = maxOrder || 0;
|
||||
}
|
||||
currentOrder += 1;
|
||||
setSortOrder.set(setId, currentOrder);
|
||||
await pool.query(
|
||||
'INSERT INTO question_set_questions (question_set_id, question_id, question_code, dimension_id, text_snapshot, version, sort_order) VALUES (?, ?, ?, ?, ?, 1, ?)',
|
||||
[setId, questionId, code, dimId, text, currentOrder]
|
||||
);
|
||||
}
|
||||
}
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
return { processed, errors };
|
||||
}
|
||||
|
||||
async function createQuestionSetFromCurrent(pool, name) {
|
||||
await getActiveQuestionSet(pool);
|
||||
return createQuestionSet(pool, name);
|
||||
}
|
||||
|
||||
async function createQuestionSetEmpty(pool, name) {
|
||||
return createEmptyQuestionSet(pool, name);
|
||||
}
|
||||
|
||||
async function addQuestionToSet(pool, { questionSetId, dimensionId, text }) {
|
||||
const code = `Q-${Date.now()}`;
|
||||
const [qRes] = await pool.query(
|
||||
'INSERT INTO questions (dimension_id, code, text, is_active) VALUES (?, ?, ?, 1)',
|
||||
[dimensionId, code, text]
|
||||
);
|
||||
await pool.query(
|
||||
'INSERT INTO question_versions (question_id, version, text, is_active) VALUES (?, 1, ?, 1)',
|
||||
[qRes.insertId, text]
|
||||
);
|
||||
const [[{ maxOrder }]] = await pool.query(
|
||||
'SELECT COALESCE(MAX(sort_order), 0) AS maxOrder FROM question_set_questions WHERE question_set_id = ?',
|
||||
[questionSetId]
|
||||
);
|
||||
const sortOrder = (maxOrder || 0) + 1;
|
||||
await pool.query(
|
||||
'INSERT INTO question_set_questions (question_set_id, question_id, question_code, dimension_id, text_snapshot, version, sort_order) VALUES (?, ?, ?, ?, ?, 1, ?)',
|
||||
[questionSetId, qRes.insertId, code, dimensionId, text, sortOrder]
|
||||
);
|
||||
return { question_id: qRes.insertId, code, sort_order: sortOrder };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listQuestions,
|
||||
listQuestionSets,
|
||||
listDimensions,
|
||||
createDimension,
|
||||
exportQuestionsExcel,
|
||||
exportQuestionsTemplate,
|
||||
importQuestionsExcel,
|
||||
createQuestionSetFromCurrent,
|
||||
createQuestionSetEmpty,
|
||||
addQuestionToSet
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
const dayjs = require('dayjs');
|
||||
|
||||
async function ensureDefaultQuestionSet(pool) {
|
||||
const [[exists]] = await pool.query('SELECT id, code FROM question_sets WHERE is_active = 1 ORDER BY id DESC LIMIT 1');
|
||||
if (exists && exists.id) return exists;
|
||||
|
||||
const code = `QSET-${dayjs().format('YYYYMMDD')}-01`;
|
||||
const name = `Set ${dayjs().format('YYYY-MM-DD')}`;
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO question_sets (code, name, version, is_active) VALUES (?, ?, 1, 1)',
|
||||
[code, name]
|
||||
);
|
||||
const questionSetId = result.insertId;
|
||||
const [questions] = await pool.query('SELECT id, code, dimension_id, text FROM questions WHERE is_active = 1 ORDER BY id ASC');
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
await pool.query(
|
||||
'INSERT INTO question_set_questions (question_set_id, question_id, question_code, dimension_id, text_snapshot, version, sort_order) VALUES (?, ?, ?, ?, ?, 1, ?)',
|
||||
[questionSetId, q.id, q.code, q.dimension_id, q.text, i + 1]
|
||||
);
|
||||
}
|
||||
return { id: questionSetId, code };
|
||||
}
|
||||
|
||||
async function createQuestionSet(pool, name) {
|
||||
const [[latest]] = await pool.query('SELECT version FROM question_sets ORDER BY id DESC LIMIT 1');
|
||||
const version = (latest?.version || 0) + 1;
|
||||
const code = `QSET-${dayjs().format('YYYYMMDD')}-${String(version).padStart(2, '0')}`;
|
||||
|
||||
await pool.query('UPDATE question_sets SET is_active = 0');
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO question_sets (code, name, version, is_active) VALUES (?, ?, ?, 1)',
|
||||
[code, name || `Set ${dayjs().format('YYYY-MM-DD')}`, version]
|
||||
);
|
||||
const questionSetId = result.insertId;
|
||||
|
||||
const [questions] = await pool.query('SELECT id, code, dimension_id, text FROM questions WHERE is_active = 1 ORDER BY id ASC');
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
await pool.query(
|
||||
'INSERT INTO question_set_questions (question_set_id, question_id, question_code, dimension_id, text_snapshot, version, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[questionSetId, q.id, q.code, q.dimension_id, q.text, version, i + 1]
|
||||
);
|
||||
}
|
||||
|
||||
return { id: questionSetId, code, version };
|
||||
}
|
||||
|
||||
async function createEmptyQuestionSet(pool, name) {
|
||||
const [[latest]] = await pool.query('SELECT version FROM question_sets ORDER BY id DESC LIMIT 1');
|
||||
const version = (latest?.version || 0) + 1;
|
||||
const code = `QSET-${dayjs().format('YYYYMMDD')}-${String(version).padStart(2, '0')}`;
|
||||
|
||||
await pool.query('UPDATE question_sets SET is_active = 0');
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO question_sets (code, name, version, is_active) VALUES (?, ?, ?, 1)',
|
||||
[code, name || `Set ${dayjs().format('YYYY-MM-DD')}`, version]
|
||||
);
|
||||
return { id: result.insertId, code, version };
|
||||
}
|
||||
|
||||
async function getActiveQuestionSet(pool) {
|
||||
const [[active]] = await pool.query('SELECT id, code FROM question_sets WHERE is_active = 1 ORDER BY id DESC LIMIT 1');
|
||||
if (active && active.id) return active;
|
||||
return ensureDefaultQuestionSet(pool);
|
||||
}
|
||||
|
||||
module.exports = { ensureDefaultQuestionSet, createQuestionSet, createEmptyQuestionSet, getActiveQuestionSet };
|
||||
@@ -0,0 +1,69 @@
|
||||
const PDFDocument = require('pdfkit');
|
||||
const ExcelJS = require('exceljs');
|
||||
|
||||
async function generatePdfReport(pool, surveyPeriodId, companyName, logoPath) {
|
||||
const [[period]] = await pool.query('SELECT * FROM survey_periods WHERE id = ?', [surveyPeriodId]);
|
||||
const [dimensions] = await pool.query(
|
||||
'SELECT d.name, r.score, r.classification FROM survey_results r JOIN dimensions d ON d.id = r.dimension_id WHERE r.survey_period_id = ? AND r.scope = "dimension"',
|
||||
[surveyPeriodId]
|
||||
);
|
||||
const [[global]] = await pool.query(
|
||||
'SELECT score, classification, participation_rate FROM survey_results WHERE survey_period_id = ? AND scope = "global"',
|
||||
[surveyPeriodId]
|
||||
);
|
||||
|
||||
const doc = new PDFDocument({ margin: 50 });
|
||||
const chunks = [];
|
||||
doc.on('data', (c) => chunks.push(c));
|
||||
|
||||
if (logoPath) {
|
||||
try { doc.image(logoPath, 50, 40, { width: 80 }); } catch (_) {}
|
||||
}
|
||||
|
||||
doc.fontSize(18).text('Reporte de Clima Laboral', 150, 50);
|
||||
doc.moveDown();
|
||||
doc.fontSize(12).text(`Empresa: ${companyName || '-'}`);
|
||||
doc.text(`Código medición: ${period.codigo_medicion}`);
|
||||
doc.text(`Periodo: ${period.fecha_inicio} a ${period.fecha_fin}`);
|
||||
doc.text(`Participación: ${global ? global.participation_rate : 0}%`);
|
||||
|
||||
doc.moveDown();
|
||||
doc.fontSize(14).text('Resumen Ejecutivo');
|
||||
doc.fontSize(11).text('El índice global refleja el nivel general de clima laboral. Se recomienda revisar dimensiones con puntajes bajos para generar planes de acción.');
|
||||
|
||||
doc.moveDown();
|
||||
doc.fontSize(14).text('Resultados por Dimensión');
|
||||
dimensions.forEach((d) => {
|
||||
doc.fontSize(11).text(`${d.name}: ${d.score} (${d.classification})`);
|
||||
});
|
||||
|
||||
doc.end();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
|
||||
async function generateExcelReport(pool, surveyPeriodId) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const rawSheet = workbook.addWorksheet('Respuestas');
|
||||
const dimSheet = workbook.addWorksheet('Dimensiones');
|
||||
|
||||
rawSheet.addRow(['Usuario', 'Pregunta', 'Valor']);
|
||||
const [rawRows] = await pool.query(
|
||||
'SELECT u.email, q.text, r.value FROM responses r JOIN users u ON u.id = r.user_id JOIN questions q ON q.id = r.question_id WHERE r.survey_period_id = ?',
|
||||
[surveyPeriodId]
|
||||
);
|
||||
rawRows.forEach((r) => rawSheet.addRow([r.email, r.text, r.value]));
|
||||
|
||||
dimSheet.addRow(['Dimensión', 'Puntaje', 'Clasificación']);
|
||||
const [dimRows] = await pool.query(
|
||||
'SELECT d.name, r.score, r.classification FROM survey_results r JOIN dimensions d ON d.id = r.dimension_id WHERE r.survey_period_id = ? AND r.scope = "dimension"',
|
||||
[surveyPeriodId]
|
||||
);
|
||||
dimRows.forEach((r) => dimSheet.addRow([r.name, r.score, r.classification]));
|
||||
|
||||
return workbook.xlsx.writeBuffer();
|
||||
}
|
||||
|
||||
module.exports = { generatePdfReport, generateExcelReport };
|
||||
@@ -0,0 +1,61 @@
|
||||
function scoreFromAvg(avg) {
|
||||
if (avg === null || avg === undefined) return 0;
|
||||
return ((avg - 1) / 4) * 100;
|
||||
}
|
||||
|
||||
function classify(score) {
|
||||
if (score < 60) return 'Bajo';
|
||||
if (score < 80) return 'Medio';
|
||||
return 'Alto';
|
||||
}
|
||||
|
||||
async function calculateResults(pool, surveyPeriodId) {
|
||||
const [[{ total_users }]] = await pool.query('SELECT COUNT(*) AS total_users FROM users WHERE status = "active"');
|
||||
const [[{ participants }]] = await pool.query(
|
||||
'SELECT COUNT(DISTINCT user_id) AS participants FROM responses WHERE survey_period_id = ?',
|
||||
[surveyPeriodId]
|
||||
);
|
||||
const participationRate = total_users ? (participants / total_users) * 100 : 0;
|
||||
|
||||
await pool.query('DELETE FROM survey_results WHERE survey_period_id = ?', [surveyPeriodId]);
|
||||
|
||||
const [questionRows] = await pool.query(
|
||||
'SELECT question_id, AVG(value) AS avg_val, COUNT(*) AS responses FROM responses WHERE survey_period_id = ? GROUP BY question_id',
|
||||
[surveyPeriodId]
|
||||
);
|
||||
|
||||
for (const row of questionRows) {
|
||||
const score = scoreFromAvg(row.avg_val);
|
||||
await pool.query(
|
||||
'INSERT INTO survey_results (survey_period_id, scope, question_id, score, classification, response_count, participation_rate) VALUES (?, "question", ?, ?, ?, ?, ?)',
|
||||
[surveyPeriodId, row.question_id, score.toFixed(2), classify(score), row.responses, participationRate.toFixed(2)]
|
||||
);
|
||||
}
|
||||
|
||||
const [dimensionRows] = await pool.query(
|
||||
'SELECT q.dimension_id, AVG(r.value) AS avg_val, COUNT(*) AS responses FROM responses r JOIN questions q ON q.id = r.question_id WHERE r.survey_period_id = ? GROUP BY q.dimension_id',
|
||||
[surveyPeriodId]
|
||||
);
|
||||
|
||||
for (const row of dimensionRows) {
|
||||
const score = scoreFromAvg(row.avg_val);
|
||||
await pool.query(
|
||||
'INSERT INTO survey_results (survey_period_id, scope, dimension_id, score, classification, response_count, participation_rate) VALUES (?, "dimension", ?, ?, ?, ?, ?)',
|
||||
[surveyPeriodId, row.dimension_id, score.toFixed(2), classify(score), row.responses, participationRate.toFixed(2)]
|
||||
);
|
||||
}
|
||||
|
||||
const [[globalRow]] = await pool.query(
|
||||
'SELECT AVG(value) AS avg_val, COUNT(*) AS responses FROM responses WHERE survey_period_id = ?',
|
||||
[surveyPeriodId]
|
||||
);
|
||||
const globalScore = scoreFromAvg(globalRow.avg_val || 0);
|
||||
await pool.query(
|
||||
'INSERT INTO survey_results (survey_period_id, scope, score, classification, response_count, participation_rate) VALUES (?, "global", ?, ?, ?, ?)',
|
||||
[surveyPeriodId, globalScore.toFixed(2), classify(globalScore), globalRow.responses || 0, participationRate.toFixed(2)]
|
||||
);
|
||||
|
||||
return { participationRate, globalScore };
|
||||
}
|
||||
|
||||
module.exports = { calculateResults, classify };
|
||||
@@ -0,0 +1,37 @@
|
||||
const dayjs = require('dayjs');
|
||||
const { calculateResults } = require('./resultService');
|
||||
const { getActiveQuestionSet } = require('./questionSetService');
|
||||
|
||||
async function createSurveyPeriod(pool, { year, month, fecha_inicio, fecha_fin }) {
|
||||
const code = `CLIMA-${year}-${String(month).padStart(2, '0')}`;
|
||||
const activeSet = await getActiveQuestionSet(pool);
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO survey_periods (codigo_medicion, question_set_code, year, month, fecha_inicio, fecha_fin, estado) VALUES (?, ?, ?, ?, ?, ?, "borrador")',
|
||||
[code, activeSet.code, year, month, fecha_inicio, fecha_fin]
|
||||
);
|
||||
|
||||
const surveyPeriodId = result.insertId;
|
||||
const [setQuestions] = await pool.query(
|
||||
'SELECT question_id, question_code, dimension_id, text_snapshot, sort_order FROM question_set_questions WHERE question_set_id = ? ORDER BY sort_order ASC',
|
||||
[activeSet.id]
|
||||
);
|
||||
for (const sq of setQuestions) {
|
||||
await pool.query(
|
||||
'INSERT INTO survey_questions (survey_period_id, question_id, question_code, dimension_id, text_snapshot, sort_order, question_set_code) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[surveyPeriodId, sq.question_id, sq.question_code, sq.dimension_id, sq.text_snapshot, sq.sort_order, activeSet.code]
|
||||
);
|
||||
}
|
||||
|
||||
return { id: surveyPeriodId, code };
|
||||
}
|
||||
|
||||
async function activateSurvey(pool, surveyPeriodId) {
|
||||
await pool.query('UPDATE survey_periods SET estado = "activa" WHERE id = ?', [surveyPeriodId]);
|
||||
}
|
||||
|
||||
async function closeSurvey(pool, surveyPeriodId) {
|
||||
await pool.query('UPDATE survey_periods SET estado = "cerrada", fecha_cierre = ? WHERE id = ?', [dayjs().format('YYYY-MM-DD HH:mm:ss'), surveyPeriodId]);
|
||||
return calculateResults(pool, surveyPeriodId);
|
||||
}
|
||||
|
||||
module.exports = { createSurveyPeriod, activateSurvey, closeSurvey };
|
||||
@@ -0,0 +1,57 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getCentralPool } = require('../db/central');
|
||||
const { hashPassword } = require('../utils/password');
|
||||
|
||||
function splitSql(sql) {
|
||||
return sql
|
||||
.split(/;\s*\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length);
|
||||
}
|
||||
|
||||
async function runSqlStatements(pool, sqlText) {
|
||||
const statements = splitSql(sqlText);
|
||||
for (const stmt of statements) {
|
||||
await pool.query(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
async function provisionTenant({ name, code, dbName, dbHost, dbPort, dbUser, dbPass, adminName, adminEmail, adminPassword, startDate, endDate }) {
|
||||
const central = getCentralPool();
|
||||
|
||||
await central.query(
|
||||
'INSERT INTO companies (name, code, start_date, end_date, db_host, db_port, db_name, db_user, db_pass) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[name, code, startDate || null, endDate || null, dbHost, dbPort, dbName, dbUser, dbPass]
|
||||
);
|
||||
|
||||
await central.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||
|
||||
const mysql = require('mysql2/promise');
|
||||
const tenantPool = mysql.createPool({
|
||||
host: dbHost,
|
||||
port: dbPort,
|
||||
user: dbUser,
|
||||
password: dbPass,
|
||||
database: dbName,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5
|
||||
});
|
||||
|
||||
const schemaSql = fs.readFileSync(path.join(__dirname, '../sql/tenant_schema.sql'), 'utf8');
|
||||
const seedSql = fs.readFileSync(path.join(__dirname, '../sql/seed_questions.sql'), 'utf8');
|
||||
|
||||
await runSqlStatements(tenantPool, schemaSql);
|
||||
await runSqlStatements(tenantPool, seedSql);
|
||||
|
||||
const passwordHash = await hashPassword(adminPassword);
|
||||
await tenantPool.query(
|
||||
'INSERT INTO admins (name, email, password_hash, role, status) VALUES (?, ?, ?, "admin", "active")',
|
||||
[adminName, adminEmail, passwordHash]
|
||||
);
|
||||
|
||||
await tenantPool.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { provisionTenant };
|
||||
@@ -0,0 +1,36 @@
|
||||
CREATE DATABASE IF NOT EXISTS clima_platform CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE clima_platform;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS superadmins (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
email VARCHAR(160) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
code VARCHAR(40) NOT NULL UNIQUE,
|
||||
status ENUM('active','suspended') NOT NULL DEFAULT 'active',
|
||||
start_date DATE NULL,
|
||||
end_date DATE NULL,
|
||||
db_host VARCHAR(120) NOT NULL,
|
||||
db_port INT NOT NULL DEFAULT 3306,
|
||||
db_name VARCHAR(120) NOT NULL,
|
||||
db_user VARCHAR(120) NOT NULL,
|
||||
db_pass VARCHAR(120) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS error_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_role VARCHAR(40) NULL,
|
||||
user_id INT NULL,
|
||||
company_code VARCHAR(40) NULL,
|
||||
menu VARCHAR(120) NULL,
|
||||
error_message TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
INSERT INTO dimensions (name, code, description, sort_order) VALUES
|
||||
('Liderazgo', 'LID', 'Estilo de dirección y apoyo del liderazgo', 1),
|
||||
('Comunicación', 'COM', 'Flujo, claridad y oportunidad de la información', 2),
|
||||
('Trabajo en equipo', 'TEA', 'Colaboración y apoyo entre áreas', 3),
|
||||
('Reconocimiento', 'REC', 'Valoración del desempeño y esfuerzos', 4),
|
||||
('Desarrollo profesional', 'DES', 'Oportunidades de crecimiento y aprendizaje', 5),
|
||||
('Cultura organizacional', 'CUL', 'Valores compartidos y ambiente laboral', 6);
|
||||
|
||||
INSERT INTO scales (name, min_value, max_value, labels_json) VALUES
|
||||
('Likert 1-5', 1, 5, '{"1":"Totalmente en desacuerdo","2":"En desacuerdo","3":"Ni de acuerdo ni en desacuerdo","4":"De acuerdo","5":"Totalmente de acuerdo"}');
|
||||
|
||||
INSERT INTO questions (dimension_id, code, text) VALUES
|
||||
(1, 'LID-01', 'Mi líder comunica expectativas claras sobre mi trabajo.'),
|
||||
(1, 'LID-02', 'Recibo apoyo oportuno de mi líder cuando lo necesito.'),
|
||||
(1, 'LID-03', 'Las decisiones de liderazgo se perciben justas y consistentes.'),
|
||||
(1, 'LID-04', 'Mi líder promueve un ambiente de confianza y respeto.'),
|
||||
|
||||
(2, 'COM-01', 'La información importante llega a tiempo para hacer bien mi trabajo.'),
|
||||
(2, 'COM-02', 'La comunicación entre áreas es clara y efectiva.'),
|
||||
(2, 'COM-03', 'Puedo expresar mis ideas y ser escuchado/a.'),
|
||||
(2, 'COM-04', 'Los cambios relevantes se comunican de manera adecuada.'),
|
||||
|
||||
(3, 'TEA-01', 'En mi equipo se fomenta la colaboración para lograr objetivos.'),
|
||||
(3, 'TEA-02', 'Mis compañeros/as están dispuestos a ayudar cuando se requiere.'),
|
||||
(3, 'TEA-03', 'Existe coordinación efectiva entre roles dentro del equipo.'),
|
||||
(3, 'TEA-04', 'Se resuelven los conflictos de forma constructiva.'),
|
||||
|
||||
(4, 'REC-01', 'Mi trabajo es reconocido de manera oportuna.'),
|
||||
(4, 'REC-02', 'El reconocimiento recibido es coherente con mi desempeño.'),
|
||||
(4, 'REC-03', 'La organización valora los esfuerzos adicionales.'),
|
||||
(4, 'REC-04', 'Se celebran los logros del equipo.'),
|
||||
|
||||
(5, 'DES-01', 'Tengo oportunidades reales de crecimiento profesional.'),
|
||||
(5, 'DES-02', 'Recibo retroalimentación que me ayuda a mejorar.'),
|
||||
(5, 'DES-03', 'La organización promueve el aprendizaje continuo.'),
|
||||
(5, 'DES-04', 'Dispongo de recursos para desarrollar nuevas habilidades.'),
|
||||
|
||||
(6, 'CUL-01', 'Me siento orgulloso/a de pertenecer a esta organización.'),
|
||||
(6, 'CUL-02', 'Los valores de la empresa se reflejan en las decisiones diarias.'),
|
||||
(6, 'CUL-03', 'El ambiente laboral es respetuoso e inclusivo.'),
|
||||
(6, 'CUL-04', 'Se promueve el bienestar de las personas.');
|
||||
@@ -0,0 +1,4 @@
|
||||
SET FOREIGN_KEY_CHECKS=0;
|
||||
SOURCE tenant_schema.sql;
|
||||
SOURCE seed_questions.sql;
|
||||
SET FOREIGN_KEY_CHECKS=1;
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Add columns
|
||||
ALTER TABLE dimensions ADD COLUMN IF NOT EXISTS code VARCHAR(40) NULL;
|
||||
ALTER TABLE questions ADD COLUMN IF NOT EXISTS code VARCHAR(60) NULL;
|
||||
ALTER TABLE survey_periods ADD COLUMN IF NOT EXISTS question_set_code VARCHAR(60) NULL;
|
||||
ALTER TABLE survey_questions ADD COLUMN IF NOT EXISTS question_code VARCHAR(60) NULL;
|
||||
ALTER TABLE survey_questions ADD COLUMN IF NOT EXISTS text_snapshot VARCHAR(500) NULL;
|
||||
ALTER TABLE survey_questions ADD COLUMN IF NOT EXISTS question_set_code VARCHAR(60) NULL;
|
||||
ALTER TABLE responses ADD COLUMN IF NOT EXISTS question_code VARCHAR(60) NULL;
|
||||
ALTER TABLE question_versions ADD COLUMN IF NOT EXISTS changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- Create new tables
|
||||
CREATE TABLE IF NOT EXISTS question_sets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
code VARCHAR(60) NOT NULL UNIQUE,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS question_set_questions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
question_set_id INT NOT NULL,
|
||||
question_id INT NOT NULL,
|
||||
question_code VARCHAR(60) NOT NULL,
|
||||
dimension_id INT NOT NULL,
|
||||
text_snapshot VARCHAR(500) NOT NULL,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (question_set_id) REFERENCES question_sets(id),
|
||||
FOREIGN KEY (question_id) REFERENCES questions(id),
|
||||
FOREIGN KEY (dimension_id) REFERENCES dimensions(id)
|
||||
);
|
||||
|
||||
-- Populate dimension codes if empty
|
||||
UPDATE dimensions SET code = 'LID' WHERE name='Liderazgo' AND (code IS NULL OR code='');
|
||||
UPDATE dimensions SET code = 'COM' WHERE name='Comunicación' AND (code IS NULL OR code='');
|
||||
UPDATE dimensions SET code = 'TEA' WHERE name='Trabajo en equipo' AND (code IS NULL OR code='');
|
||||
UPDATE dimensions SET code = 'REC' WHERE name='Reconocimiento' AND (code IS NULL OR code='');
|
||||
UPDATE dimensions SET code = 'DES' WHERE name='Desarrollo profesional' AND (code IS NULL OR code='');
|
||||
UPDATE dimensions SET code = 'CUL' WHERE name='Cultura organizacional' AND (code IS NULL OR code='');
|
||||
|
||||
-- Populate question codes
|
||||
SET @n:=0;
|
||||
UPDATE questions q JOIN dimensions d ON d.id=q.dimension_id SET q.code = CONCAT(d.code, '-', LPAD((@n:=@n+1),2,'0')) WHERE d.code='LID' AND (q.code IS NULL OR q.code='') ORDER BY q.id;
|
||||
SET @n:=0;
|
||||
UPDATE questions q JOIN dimensions d ON d.id=q.dimension_id SET q.code = CONCAT(d.code, '-', LPAD((@n:=@n+1),2,'0')) WHERE d.code='COM' AND (q.code IS NULL OR q.code='') ORDER BY q.id;
|
||||
SET @n:=0;
|
||||
UPDATE questions q JOIN dimensions d ON d.id=q.dimension_id SET q.code = CONCAT(d.code, '-', LPAD((@n:=@n+1),2,'0')) WHERE d.code='TEA' AND (q.code IS NULL OR q.code='') ORDER BY q.id;
|
||||
SET @n:=0;
|
||||
UPDATE questions q JOIN dimensions d ON d.id=q.dimension_id SET q.code = CONCAT(d.code, '-', LPAD((@n:=@n+1),2,'0')) WHERE d.code='REC' AND (q.code IS NULL OR q.code='') ORDER BY q.id;
|
||||
SET @n:=0;
|
||||
UPDATE questions q JOIN dimensions d ON d.id=q.dimension_id SET q.code = CONCAT(d.code, '-', LPAD((@n:=@n+1),2,'0')) WHERE d.code='DES' AND (q.code IS NULL OR q.code='') ORDER BY q.id;
|
||||
SET @n:=0;
|
||||
UPDATE questions q JOIN dimensions d ON d.id=q.dimension_id SET q.code = CONCAT(d.code, '-', LPAD((@n:=@n+1),2,'0')) WHERE d.code='CUL' AND (q.code IS NULL OR q.code='') ORDER BY q.id;
|
||||
|
||||
-- Default question set
|
||||
INSERT INTO question_sets (code, name, version, is_active)
|
||||
SELECT 'QSET-DEFAULT', 'Set inicial', 1, 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM question_sets);
|
||||
|
||||
SET @setId := (SELECT id FROM question_sets WHERE is_active=1 ORDER BY id DESC LIMIT 1);
|
||||
|
||||
INSERT INTO question_set_questions (question_set_id, question_id, question_code, dimension_id, text_snapshot, version, sort_order)
|
||||
SELECT @setId, q.id, q.code, q.dimension_id, q.text, 1, q.id
|
||||
FROM questions q
|
||||
WHERE NOT EXISTS (SELECT 1 FROM question_set_questions WHERE question_set_id=@setId);
|
||||
|
||||
-- Backfill survey_periods and survey_questions
|
||||
UPDATE survey_periods SET question_set_code = 'QSET-DEFAULT' WHERE question_set_code IS NULL OR question_set_code='';
|
||||
UPDATE survey_questions sq JOIN questions q ON q.id = sq.question_id SET sq.question_code = q.code, sq.text_snapshot = q.text, sq.question_set_code = 'QSET-DEFAULT' WHERE sq.question_code IS NULL OR sq.text_snapshot IS NULL OR sq.question_set_code IS NULL;
|
||||
|
||||
-- Backfill responses
|
||||
UPDATE responses r JOIN questions q ON q.id=r.question_id SET r.question_code = q.code WHERE r.question_code IS NULL OR r.question_code='';
|
||||
|
||||
-- Add unique constraints if not exists (run separately if needed)
|
||||
@@ -0,0 +1,159 @@
|
||||
CREATE TABLE IF NOT EXISTS admins (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
email VARCHAR(160) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role ENUM('admin') NOT NULL DEFAULT 'admin',
|
||||
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
email VARCHAR(160) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NULL,
|
||||
department VARCHAR(120),
|
||||
position VARCHAR(120),
|
||||
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS survey_periods (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
codigo_medicion VARCHAR(40) NOT NULL UNIQUE,
|
||||
question_set_code VARCHAR(60) NOT NULL,
|
||||
year INT NOT NULL,
|
||||
month INT NOT NULL,
|
||||
fecha_inicio DATE NOT NULL,
|
||||
fecha_fin DATE NOT NULL,
|
||||
estado ENUM('borrador','activa','cerrada') NOT NULL DEFAULT 'borrador',
|
||||
fecha_cierre DATETIME NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dimensions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
code VARCHAR(40) NOT NULL UNIQUE,
|
||||
description VARCHAR(255),
|
||||
sort_order INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS questions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
dimension_id INT NOT NULL,
|
||||
code VARCHAR(60) NOT NULL UNIQUE,
|
||||
text VARCHAR(500) NOT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (dimension_id) REFERENCES dimensions(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS survey_questions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
survey_period_id INT NOT NULL,
|
||||
question_id INT NOT NULL,
|
||||
question_code VARCHAR(60) NOT NULL,
|
||||
dimension_id INT NOT NULL,
|
||||
text_snapshot VARCHAR(500) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
question_set_code VARCHAR(60) NOT NULL,
|
||||
FOREIGN KEY (survey_period_id) REFERENCES survey_periods(id),
|
||||
FOREIGN KEY (question_id) REFERENCES questions(id),
|
||||
FOREIGN KEY (dimension_id) REFERENCES dimensions(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS responses (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
survey_period_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
question_id INT NOT NULL,
|
||||
question_code VARCHAR(60) NOT NULL,
|
||||
value INT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_response (survey_period_id, user_id, question_id),
|
||||
FOREIGN KEY (survey_period_id) REFERENCES survey_periods(id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (question_id) REFERENCES questions(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS survey_results (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
survey_period_id INT NOT NULL,
|
||||
scope ENUM('global','dimension','question') NOT NULL,
|
||||
dimension_id INT NULL,
|
||||
question_id INT NULL,
|
||||
score DECIMAL(5,2) NOT NULL,
|
||||
classification ENUM('Bajo','Medio','Alto') NOT NULL,
|
||||
response_count INT NOT NULL DEFAULT 0,
|
||||
participation_rate DECIMAL(5,2) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (survey_period_id) REFERENCES survey_periods(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scales (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
min_value INT NOT NULL,
|
||||
max_value INT NOT NULL,
|
||||
labels_json JSON NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS question_versions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
question_id INT NOT NULL,
|
||||
version INT NOT NULL,
|
||||
text VARCHAR(500) NOT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (question_id) REFERENCES questions(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS question_sets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
code VARCHAR(60) NOT NULL UNIQUE,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS question_set_questions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
question_set_id INT NOT NULL,
|
||||
question_id INT NOT NULL,
|
||||
question_code VARCHAR(60) NOT NULL,
|
||||
dimension_id INT NOT NULL,
|
||||
text_snapshot VARCHAR(500) NOT NULL,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (question_set_id) REFERENCES question_sets(id),
|
||||
FOREIGN KEY (question_id) REFERENCES questions(id),
|
||||
FOREIGN KEY (dimension_id) REFERENCES dimensions(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS question_weights (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
question_id INT NOT NULL,
|
||||
weight DECIMAL(5,2) NOT NULL DEFAULT 1,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (question_id) REFERENCES questions(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metric_definitions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
formula TEXT NOT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS survey_rules (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
rule_json JSON NOT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { jwtSecret } = require('../config/env');
|
||||
|
||||
function signToken(payload) {
|
||||
return jwt.sign(payload, jwtSecret, { expiresIn: '8h' });
|
||||
}
|
||||
|
||||
function verifyToken(token) {
|
||||
return jwt.verify(token, jwtSecret);
|
||||
}
|
||||
|
||||
module.exports = { signToken, verifyToken };
|
||||
@@ -0,0 +1,12 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
async function hashPassword(password) {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
return bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
async function comparePassword(password, hash) {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
module.exports = { hashPassword, comparePassword };
|
||||
Reference in New Issue
Block a user