diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 70916b0f..49632e96 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -250,7 +250,10 @@ index.ts → routes → controllers → services → repositories → models outside production, the production and Vercel domains — plus `FRONTEND_URL`. Credentials enabled. It is also the CSRF defence: any other origin gets a 403 before reaching a route. -- **Cron jobs**: `services/cron/cron-service.ts`, started from `index.ts`. +- **Cron jobs**: `services/cron/cron-service.ts`, started from `index.ts`. The + times are in `cron-schedule.ts` (Europe/Madrid). Render's free plan sleeps, so + on every start the jobs whose period is due and not done run, and + `cron_job_runs` keeps each period to one run (ADR-046). - **Tests**: Vitest, in `tests/`, one folder per domain. ## Authentication diff --git a/backend/config/error-codes.ts b/backend/config/error-codes.ts index 5c2bf850..3f1d27ba 100644 --- a/backend/config/error-codes.ts +++ b/backend/config/error-codes.ts @@ -20,6 +20,19 @@ export const ERROR_CODES = { UNAUTHORIZED: 'UNAUTHORIZED', FORBIDDEN: 'FORBIDDEN', NOT_FOUND: 'NOT_FOUND', + DEMO_ACTION_BLOCKED: 'DEMO_ACTION_BLOCKED', + RATE_LIMIT_LOGIN: 'RATE_LIMIT_LOGIN', + RATE_LIMIT_DEMO_LOGIN: 'RATE_LIMIT_DEMO_LOGIN', + RATE_LIMIT_DEMO_WRITE: 'RATE_LIMIT_DEMO_WRITE', + RATE_LIMIT_DEMO_GENERATE: 'RATE_LIMIT_DEMO_GENERATE', + RATE_LIMIT_DEMO_GENERATE_TOTAL: 'RATE_LIMIT_DEMO_GENERATE_TOTAL', + RATE_LIMIT_PASSWORD_CHANGE: 'RATE_LIMIT_PASSWORD_CHANGE', + RATE_LIMIT_PROFILE_UPDATE: 'RATE_LIMIT_PROFILE_UPDATE', + RATE_LIMIT_REFRESH: 'RATE_LIMIT_REFRESH', + RATE_LIMIT_API: 'RATE_LIMIT_API', + IMAGE_INVALID_TYPE: 'IMAGE_INVALID_TYPE', + IMAGE_TOO_LARGE: 'IMAGE_TOO_LARGE', + IMAGE_SINGLE_FILE_ONLY: 'IMAGE_SINGLE_FILE_ONLY', // ============================================================================ // AUTH MODULE @@ -41,6 +54,12 @@ export const ERROR_CODES = { AUTH_DELETE_USER_ERROR: 'AUTH_DELETE_USER_ERROR', AUTH_UPLOAD_AVATAR_ERROR: 'AUTH_UPLOAD_AVATAR_ERROR', AUTH_DELETE_AVATAR_ERROR: 'AUTH_DELETE_AVATAR_ERROR', + AUTH_USER_ALREADY_EXISTS: 'AUTH_USER_ALREADY_EXISTS', + AUTH_NEW_PASSWORD_SAME_AS_CURRENT: 'AUTH_NEW_PASSWORD_SAME_AS_CURRENT', + AUTH_INVALID_ROLE: 'AUTH_INVALID_ROLE', + AUTH_RESET_PASSWORD_ERROR: 'AUTH_RESET_PASSWORD_ERROR', + AUTH_TOKEN_INVALID: 'AUTH_TOKEN_INVALID', + AUTH_TOKEN_EXPIRED: 'AUTH_TOKEN_EXPIRED', // ============================================================================ // BACKOFFICE MODULE @@ -180,6 +199,12 @@ export const ERROR_CODES = { CASHIER_FETCH_VOUCHER_HISTORY_ERROR: 'CASHIER_FETCH_VOUCHER_HISTORY_ERROR', CASHIER_FETCH_SHIFT_SUMMARY_ERROR: 'CASHIER_FETCH_SHIFT_SUMMARY_ERROR', + // ============================================================================ + // CHECKLIST MODULE + // ============================================================================ + CHECKLIST_NOT_FOUND: 'CHECKLIST_NOT_FOUND', + CHECKLIST_INVALID_STEP: 'CHECKLIST_INVALID_STEP', + // ============================================================================ // CONCILIATION MODULE // ============================================================================ diff --git a/backend/controllers/auth/auth-controllers.ts b/backend/controllers/auth/auth-controllers.ts index 7a18d208..fb97c702 100644 --- a/backend/controllers/auth/auth-controllers.ts +++ b/backend/controllers/auth/auth-controllers.ts @@ -129,7 +129,7 @@ export const login = async (req: Request, res: Response): Promise => { */ export const demoLogin = async (_req: Request, res: Response): Promise => { if (process.env.DEMO_MODE !== 'true') { - res.status(404).json({ error: 'Ruta no encontrada' }) + res.status(404).json({ error: ERROR_CODES.NOT_FOUND, code: ERROR_CODES.NOT_FOUND }) return } @@ -198,7 +198,10 @@ export const register = async (req: Request, res: Response): Promise => { const err = error as Error & { code?: string } if (err.code === 'ER_DUP_ENTRY') { - res.status(409).json({ error: err.message }) + res.status(409).json({ + error: ERROR_CODES.AUTH_USER_ALREADY_EXISTS, + code: ERROR_CODES.AUTH_USER_ALREADY_EXISTS, + }) return } @@ -206,7 +209,7 @@ export const register = async (req: Request, res: Response): Promise => { logger.error({ err: error }, 'Registration error') } - res.status(500).json({ error: 'Internal server error' }) + res.status(500).json({ error: ERROR_CODES.INTERNAL_ERROR, code: ERROR_CODES.INTERNAL_ERROR }) } } @@ -411,7 +414,10 @@ export const updateProfile = async (req: Request, res: Response): Promise } if (err.message === 'El nombre de usuario ya está en uso') { - res.status(409).json({ error: err.message }) + res.status(409).json({ + error: ERROR_CODES.AUTH_USER_ALREADY_EXISTS, + code: ERROR_CODES.AUTH_USER_ALREADY_EXISTS, + }) return } @@ -480,7 +486,10 @@ export const updatePassword = async (req: Request, res: Response): Promise } if (err.message === 'La nueva contraseña debe ser diferente a la actual') { - res.status(400).json({ error: err.message }) + res.status(400).json({ + error: ERROR_CODES.AUTH_NEW_PASSWORD_SAME_AS_CURRENT, + code: ERROR_CODES.AUTH_NEW_PASSWORD_SAME_AS_CURRENT, + }) return } diff --git a/backend/controllers/auth/user-controllers.ts b/backend/controllers/auth/user-controllers.ts index 4ee441b6..969a3a3c 100644 --- a/backend/controllers/auth/user-controllers.ts +++ b/backend/controllers/auth/user-controllers.ts @@ -4,6 +4,7 @@ import { Request, Response } from 'express' import { UserRepository } from '../../repositories/auth/user-repository.js' import type { UpdateUserDTO } from '../../models/auth/index.js' import { logger } from '../../config/logger.js' +import { ERROR_CODES } from '../../config/error-codes.js' import { validateUpdateUser, getValidationErrors, @@ -24,7 +25,9 @@ export const getAllUsers = async (_req: Request, res: Response): Promise = res.status(200).json(users) } catch (error) { logger.error({ err: error }, 'Error getAllUsers') - res.status(500).json({ error: 'Error al recuperar usuarios' }) + res + .status(500) + .json({ error: ERROR_CODES.AUTH_FETCH_USERS_ERROR, code: ERROR_CODES.AUTH_FETCH_USERS_ERROR }) } } @@ -37,14 +40,18 @@ export const getUserById = async (req: Request, res: Response): Promise => const user = await UserRepository.getById(req.params.id) if (!user) { - res.status(404).json({ error: 'Usuario no encontrado' }) + res + .status(404) + .json({ error: ERROR_CODES.AUTH_USER_NOT_FOUND, code: ERROR_CODES.AUTH_USER_NOT_FOUND }) return } res.status(200).json(user) } catch (error) { logger.error({ err: error }, 'Error getUserById') - res.status(500).json({ error: 'Error al obtener usuario' }) + res + .status(500) + .json({ error: ERROR_CODES.AUTH_FETCH_USER_ERROR, code: ERROR_CODES.AUTH_FETCH_USER_ERROR }) } } @@ -58,7 +65,10 @@ export const getUsersByRole = async (req: Request, res: Response): Promise res.status(200).json(users) } catch (error) { logger.error({ err: error }, 'Error getUsersByRole') - res.status(500).json({ error: 'Error al obtener usuarios por rol' }) + res.status(500).json({ + error: ERROR_CODES.AUTH_FETCH_USERS_BY_ROLE_ERROR, + code: ERROR_CODES.AUTH_FETCH_USERS_BY_ROLE_ERROR, + }) } } @@ -78,7 +88,9 @@ export const updateUser = async (req: Request, res: Response): Promise => const updatedUser = await UserRepository.update(req.params.id, { username, email, role }) if (!updatedUser) { - res.status(404).json({ error: 'Usuario no encontrado' }) + res + .status(404) + .json({ error: ERROR_CODES.AUTH_USER_NOT_FOUND, code: ERROR_CODES.AUTH_USER_NOT_FOUND }) return } @@ -86,19 +98,28 @@ export const updateUser = async (req: Request, res: Response): Promise => } catch (error) { const message = error instanceof Error ? error.message : '' if (message === 'Usuario no encontrado') { - res.status(404).json({ error: message }) + res + .status(404) + .json({ error: ERROR_CODES.AUTH_USER_NOT_FOUND, code: ERROR_CODES.AUTH_USER_NOT_FOUND }) return } if (message.startsWith('Rol no válido')) { - res.status(400).json({ error: 'Rol no válido' }) + res + .status(400) + .json({ error: ERROR_CODES.AUTH_INVALID_ROLE, code: ERROR_CODES.AUTH_INVALID_ROLE }) return } if (message === 'El nombre de usuario o email ya existe') { - res.status(409).json({ error: message }) + res.status(409).json({ + error: ERROR_CODES.AUTH_USER_ALREADY_EXISTS, + code: ERROR_CODES.AUTH_USER_ALREADY_EXISTS, + }) return } logger.error({ err: error }, 'Error updateUser') - res.status(500).json({ error: 'Error al actualizar usuario' }) + res + .status(500) + .json({ error: ERROR_CODES.AUTH_UPDATE_USER_ERROR, code: ERROR_CODES.AUTH_UPDATE_USER_ERROR }) } } @@ -111,14 +132,18 @@ export const deleteUser = async (req: Request, res: Response): Promise => const deleted = await UserRepository.delete(req.params.id) if (!deleted) { - res.status(404).json({ error: 'Usuario no encontrado' }) + res + .status(404) + .json({ error: ERROR_CODES.AUTH_USER_NOT_FOUND, code: ERROR_CODES.AUTH_USER_NOT_FOUND }) return } res.status(200).json({ message: 'Usuario eliminado correctamente' }) } catch (error) { logger.error({ err: error }, 'Error deleteUser') - res.status(500).json({ error: 'Error al eliminar usuario' }) + res + .status(500) + .json({ error: ERROR_CODES.AUTH_DELETE_USER_ERROR, code: ERROR_CODES.AUTH_DELETE_USER_ERROR }) } } @@ -138,7 +163,9 @@ export const resetUserPassword = async (req: Request, res: Response): Promise { if (!isKnownChecklist(id)) { - res.status(404).json({ error: 'Checklist no encontrado' }) + res + .status(404) + .json({ error: ERROR_CODES.CHECKLIST_NOT_FOUND, code: ERROR_CODES.CHECKLIST_NOT_FOUND }) return } next() @@ -21,7 +24,10 @@ export function validateChecklistParams(router: Router): void { router.param('stepId', (req, res, next, stepId: string) => { if (!getValidStepIds(req.params.id)?.has(stepId)) { - res.status(400).json({ error: 'Paso no válido' }) + res.status(400).json({ + error: ERROR_CODES.CHECKLIST_INVALID_STEP, + code: ERROR_CODES.CHECKLIST_INVALID_STEP, + }) return } next() @@ -30,7 +36,7 @@ export function validateChecklistParams(router: Router): void { for (const name of ['commentId', 'attachmentId']) { router.param(name, (_req, res, next, value: string) => { if (!positiveInt.test(value)) { - res.status(400).json({ error: 'ID inválido' }) + res.status(400).json({ error: ERROR_CODES.INVALID_ID, code: ERROR_CODES.INVALID_ID }) return } next() diff --git a/backend/middlewares/demoRestriction.ts b/backend/middlewares/demoRestriction.ts index b0e702ae..e025ab8b 100644 --- a/backend/middlewares/demoRestriction.ts +++ b/backend/middlewares/demoRestriction.ts @@ -11,6 +11,7 @@ import { Request, Response, NextFunction } from 'express' import { DemoActivityRepository } from '../repositories/demo/demo-activity-repository.js' import { logger } from '../config/logger.js' +import { ERROR_CODES } from '../config/error-codes.js' /** * Registra intentos bloqueados en la base de datos @@ -56,8 +57,8 @@ function block(req: Request, res: Response): void { res.status(403).json({ success: false, - error: - 'Acción no disponible en modo demo. Esta es una cuenta de demostración con funcionalidad limitada.', + error: ERROR_CODES.DEMO_ACTION_BLOCKED, + code: ERROR_CODES.DEMO_ACTION_BLOCKED, demo: true, }) } diff --git a/backend/middlewares/imageUpload.ts b/backend/middlewares/imageUpload.ts index 1b978426..ee75ec4e 100644 --- a/backend/middlewares/imageUpload.ts +++ b/backend/middlewares/imageUpload.ts @@ -3,6 +3,7 @@ import type { Request, Response, NextFunction, RequestHandler } from 'express' import multer from 'multer' import { isImageFile } from '../services/uploads/image-signature.js' +import { ERROR_CODES } from '../config/error-codes.js' const MB = 1024 * 1024 export const MAX_IMAGE_BYTES = 5 * MB @@ -29,24 +30,32 @@ export function singleImage(field: string, maxBytes: number = MAX_IMAGE_BYTES): }, }) const handler = upload.single(field) - const tooLarge = `Máximo ${Math.round((maxBytes / MB) * 10) / 10}MB por archivo` return (req: Request, res: Response, next: NextFunction) => { handler(req, res, (err?: unknown) => { if (err instanceof NotAnImageError) { - res.status(400).json({ error: 'Solo se permiten imágenes (JPG, PNG, WebP, GIF)' }) + res + .status(400) + .json({ error: ERROR_CODES.IMAGE_INVALID_TYPE, code: ERROR_CODES.IMAGE_INVALID_TYPE }) return } if (err instanceof multer.MulterError) { if (err.code === 'LIMIT_FILE_SIZE') { - res.status(413).json({ error: tooLarge }) + res + .status(413) + .json({ error: ERROR_CODES.IMAGE_TOO_LARGE, code: ERROR_CODES.IMAGE_TOO_LARGE }) } else { - res.status(400).json({ error: 'Solo se admite un archivo de imagen' }) + res.status(400).json({ + error: ERROR_CODES.IMAGE_SINGLE_FILE_ONLY, + code: ERROR_CODES.IMAGE_SINGLE_FILE_ONLY, + }) } return } if (!err && req.file && !isImageFile(req.file.buffer)) { - res.status(400).json({ error: 'Solo se permiten imágenes (JPG, PNG, WebP, GIF)' }) + res + .status(400) + .json({ error: ERROR_CODES.IMAGE_INVALID_TYPE, code: ERROR_CODES.IMAGE_INVALID_TYPE }) return } next(err) diff --git a/backend/middlewares/rateLimiter.ts b/backend/middlewares/rateLimiter.ts index 4c60a83c..2ee1588f 100644 --- a/backend/middlewares/rateLimiter.ts +++ b/backend/middlewares/rateLimiter.ts @@ -4,6 +4,7 @@ import rateLimit from 'express-rate-limit' import type { Request, Response } from 'express' import { logger } from '../config/logger.js' import { clientIpKey } from '../config/client-ip.js' +import { ERROR_CODES } from '../config/error-codes.js' /** * Helper to get a consistent IP key (handles IPv6). Fails closed if Render's @@ -22,9 +23,6 @@ function getIpKey(req: Request): string { export const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // 5 attempts per window - message: { - error: 'Demasiados intentos de inicio de sesión. Intenta de nuevo en 15 minutos.', - }, standardHeaders: true, legacyHeaders: false, // Use IP + username combination as key for more granular limiting @@ -48,7 +46,8 @@ export const loginLimiter = rateLimit({ '[SECURITY] login rate limit exceeded' ) res.status(429).json({ - error: 'Demasiados intentos de inicio de sesión. Intenta de nuevo en 15 minutos.', + error: ERROR_CODES.RATE_LIMIT_LOGIN, + code: ERROR_CODES.RATE_LIMIT_LOGIN, }) }, }) @@ -70,7 +69,8 @@ export const loginIpLimiter = rateLimit({ '[SECURITY] login IP rate limit exceeded' ) res.status(429).json({ - error: 'Demasiados intentos de inicio de sesión. Intenta de nuevo en 15 minutos.', + error: ERROR_CODES.RATE_LIMIT_LOGIN, + code: ERROR_CODES.RATE_LIMIT_LOGIN, }) }, }) @@ -90,7 +90,8 @@ export const demoLoginLimiter = rateLimit({ '[SECURITY] demo login rate limit exceeded' ) res.status(429).json({ - error: 'Demasiadas entradas a la demo. Intenta de nuevo en 15 minutos.', + error: ERROR_CODES.RATE_LIMIT_DEMO_LOGIN, + code: ERROR_CODES.RATE_LIMIT_DEMO_LOGIN, }) }, }) @@ -99,13 +100,13 @@ export const demoLoginLimiter = rateLimit({ // total cap where one Render instance is at stake (ADR-038) const READ_METHODS = ['GET', 'HEAD', 'OPTIONS'] -function demoLimitHandler(kind: string, error: string) { +function demoLimitHandler(kind: string, code: string) { return (req: Request, res: Response) => { logger.warn( { event: 'rate_limit_exceeded', kind, ip: getIpKey(req) }, '[SECURITY] demo rate limit exceeded' ) - res.status(429).json({ error }) + res.status(429).json({ error: code, code }) } } @@ -119,10 +120,7 @@ export const demoWriteLimiter = rateLimit({ legacyHeaders: false, skip: (req: Request) => !req.user?.isDemo || READ_METHODS.includes(req.method), keyGenerator: (req: Request) => `demo-write-${getIpKey(req)}`, - handler: demoLimitHandler( - 'demo_write', - 'Demasiados cambios en la demo. Intenta de nuevo en 15 minutos.' - ), + handler: demoLimitHandler('demo_write', ERROR_CODES.RATE_LIMIT_DEMO_WRITE), }) /** @@ -136,10 +134,7 @@ const demoGeneratePerIp = rateLimit({ legacyHeaders: false, skip: (req: Request) => !req.user?.isDemo, keyGenerator: (req: Request) => `demo-generate-${getIpKey(req)}`, - handler: demoLimitHandler( - 'demo_generate', - 'Has generado demasiados horarios en la demo. Intenta de nuevo en una hora.' - ), + handler: demoLimitHandler('demo_generate', ERROR_CODES.RATE_LIMIT_DEMO_GENERATE), }) const demoGenerateTotal = rateLimit({ @@ -149,10 +144,7 @@ const demoGenerateTotal = rateLimit({ legacyHeaders: false, skip: (req: Request) => !req.user?.isDemo, keyGenerator: () => 'demo-generate-total', - handler: demoLimitHandler( - 'demo_generate_total', - 'La demo ha generado demasiados horarios esta hora. Intenta de nuevo más tarde.' - ), + handler: demoLimitHandler('demo_generate_total', ERROR_CODES.RATE_LIMIT_DEMO_GENERATE_TOTAL), }) export const demoGenerateLimiter = [demoGeneratePerIp, demoGenerateTotal] @@ -164,9 +156,6 @@ export const demoGenerateLimiter = [demoGeneratePerIp, demoGenerateTotal] export const passwordChangeLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 3, // 3 attempts per hour - message: { - error: 'Demasiados intentos de cambio de contraseña. Intenta de nuevo en 1 hora.', - }, standardHeaders: true, legacyHeaders: false, keyGenerator: (req: Request) => { @@ -187,7 +176,8 @@ export const passwordChangeLimiter = rateLimit({ '[SECURITY] password change rate limit exceeded' ) res.status(429).json({ - error: 'Demasiados intentos de cambio de contraseña. Intenta de nuevo en 1 hora.', + error: ERROR_CODES.RATE_LIMIT_PASSWORD_CHANGE, + code: ERROR_CODES.RATE_LIMIT_PASSWORD_CHANGE, }) }, }) @@ -199,9 +189,6 @@ export const passwordChangeLimiter = rateLimit({ export const profileUpdateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // 5 attempts per window - message: { - error: 'Demasiados intentos de actualización. Intenta de nuevo en 15 minutos.', - }, standardHeaders: true, legacyHeaders: false, keyGenerator: (req: Request) => { @@ -222,7 +209,8 @@ export const profileUpdateLimiter = rateLimit({ '[SECURITY] profile update rate limit exceeded' ) res.status(429).json({ - error: 'Demasiados intentos de actualización. Intenta de nuevo en 15 minutos.', + error: ERROR_CODES.RATE_LIMIT_PROFILE_UPDATE, + code: ERROR_CODES.RATE_LIMIT_PROFILE_UPDATE, }) }, }) @@ -234,9 +222,6 @@ export const profileUpdateLimiter = rateLimit({ export const refreshLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 30, // 30 refreshes per 15 minutes per IP - message: { - error: 'Demasiadas solicitudes de refresco. Intenta de nuevo en 15 minutos.', - }, standardHeaders: true, legacyHeaders: false, keyGenerator: (req: Request) => `refresh-${getIpKey(req)}`, @@ -246,7 +231,8 @@ export const refreshLimiter = rateLimit({ '[SECURITY] refresh rate limit exceeded' ) res.status(429).json({ - error: 'Demasiadas solicitudes de refresco. Intenta de nuevo en 15 minutos.', + error: ERROR_CODES.RATE_LIMIT_REFRESH, + code: ERROR_CODES.RATE_LIMIT_REFRESH, }) }, }) @@ -259,7 +245,8 @@ export const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 300, // 300 requests per 15 minutes (budget alto para no romper UX) message: { - error: 'Demasiadas solicitudes. Intenta de nuevo más tarde.', + error: ERROR_CODES.RATE_LIMIT_API, + code: ERROR_CODES.RATE_LIMIT_API, }, standardHeaders: true, legacyHeaders: false, diff --git a/backend/middlewares/roleCheck.ts b/backend/middlewares/roleCheck.ts index ccb90096..43b294a6 100644 --- a/backend/middlewares/roleCheck.ts +++ b/backend/middlewares/roleCheck.ts @@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express' import { FRONT_DESK_ROLES } from '../services/auth/module-access.js' +import { ERROR_CODES } from '../config/error-codes.js' type RoleCheckMiddleware = (req: Request, res: Response, next: NextFunction) => void @@ -13,16 +14,14 @@ export const isAdmin: RoleCheckMiddleware = (req, res, next) => { const allowedRoles = ['admin'] if (!req.user?.role) { - res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) return } if (allowedRoles.includes(req.user.role.toLowerCase())) { next() } else { - res.status(403).json({ - error: 'Acceso denegado. Se requiere rol de administrador', - }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) } } @@ -48,9 +47,7 @@ export const isOwnerOrAdmin: RoleCheckMiddleware = (req, res, next) => { } // Si no es ni admin ni dueño, denegar acceso - res.status(403).json({ - error: 'No tienes permiso para acceder a este recurso', - }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) } /** @@ -61,16 +58,14 @@ export const canManageGroups: RoleCheckMiddleware = (req, res, next) => { const allowedRoles = ['admin', 'group-admin'] if (!req.user?.role) { - res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) return } if (allowedRoles.includes(req.user.role.toLowerCase())) { next() } else { - res.status(403).json({ - error: 'Acceso denegado. Se requiere rol de gestor de grupos', - }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) } } @@ -83,16 +78,14 @@ export const canViewGroups: RoleCheckMiddleware = (req, res, next) => { const allowedRoles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento'] if (!req.user?.role) { - res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) return } if (allowedRoles.includes(req.user.role.toLowerCase())) { next() } else { - res.status(403).json({ - error: 'Acceso denegado. No tienes permisos para ver grupos', - }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) } } @@ -112,9 +105,7 @@ export const canViewGroupContacts: RoleCheckMiddleware = (req, res, next) => { if (canSeeGroupContacts(req.user?.role)) { next() } else { - res.status(403).json({ - error: 'Acceso denegado. No tienes permisos para ver los contactos del grupo', - }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) } } @@ -126,16 +117,14 @@ export const canManageCashier: RoleCheckMiddleware = (req, res, next) => { const allowedRoles = ['admin', 'recepcionista', 'group-admin'] if (!req.user?.role) { - res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) return } if (allowedRoles.includes(req.user.role.toLowerCase())) { next() } else { - res.status(403).json({ - error: 'Acceso denegado. Se requiere rol de admin, recepcionista o group-admin', - }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) } } @@ -147,16 +136,14 @@ export const canViewReports: RoleCheckMiddleware = (req, res, next) => { const allowedRoles = ['admin'] if (!req.user?.role) { - res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) return } if (allowedRoles.includes(req.user.role.toLowerCase())) { next() } else { - res.status(403).json({ - error: 'Acceso denegado. Se requiere rol de administrador', - }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) } } @@ -168,16 +155,14 @@ export const canAccessMaintenance: RoleCheckMiddleware = (req, res, next) => { const allowedRoles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento'] if (!req.user?.role) { - res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) return } if (allowedRoles.includes(req.user.role.toLowerCase())) { next() } else { - res.status(403).json({ - error: 'Acceso denegado. No tienes permisos para acceder a mantenimiento', - }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) } } @@ -188,16 +173,14 @@ export const canAccessMaintenance: RoleCheckMiddleware = (req, res, next) => { */ export const excludeMantenimiento: RoleCheckMiddleware = (req, res, next) => { if (!req.user?.role) { - res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) return } if (FRONT_DESK_ROLES.includes(req.user.role.toLowerCase())) { next() } else { - res.status(403).json({ - error: 'Acceso denegado. Tu rol no tiene acceso a este módulo', - }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) } } @@ -210,16 +193,14 @@ export const canAccessBackoffice: RoleCheckMiddleware = (req, res, next) => { const allowedRoles = ['admin'] if (!req.user?.role) { - res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) return } if (allowedRoles.includes(req.user.role.toLowerCase())) { next() } else { - res.status(403).json({ - error: 'Acceso denegado. Se requiere rol de administrador para acceder al backoffice', - }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) } } @@ -232,16 +213,14 @@ export const canResetChecklist: RoleCheckMiddleware = (req, res, next) => { const allowedRoles = ['admin', 'recepcionista'] if (!req.user?.role) { - res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) return } if (allowedRoles.includes(req.user.role.toLowerCase())) { next() } else { - res.status(403).json({ - error: 'Acceso denegado. Se requiere rol de admin o recepcionista para resetear', - }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) } } @@ -253,16 +232,14 @@ export const canAccessFnb: RoleCheckMiddleware = (req, res, next) => { const allowedRoles = ['admin', 'recepcionista', 'group-admin'] if (!req.user?.role) { - res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) return } if (allowedRoles.includes(req.user.role.toLowerCase())) { next() } else { - res.status(403).json({ - error: 'Acceso denegado. Se requiere rol de admin o recepcionista', - }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) } } @@ -274,13 +251,13 @@ export const canRunNotificationCheck: RoleCheckMiddleware = (req, res, next) => const allowedRoles = ['admin', 'group-admin'] if (!req.user?.role) { - res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) return } if (allowedRoles.includes(req.user.role.toLowerCase())) { next() } else { - res.status(403).json({ error: 'Acceso denegado. Se requiere rol de admin o group-admin' }) + res.status(403).json({ error: ERROR_CODES.FORBIDDEN, code: ERROR_CODES.FORBIDDEN }) } } diff --git a/backend/models/parking/index.ts b/backend/models/parking/index.ts index ec8c4f65..efe8a9b8 100644 --- a/backend/models/parking/index.ts +++ b/backend/models/parking/index.ts @@ -6,7 +6,7 @@ import { RowDataPacket, ResultSetHeader } from 'mysql2' // ENUMS & CONSTANTS // ============================================ -export type SpotType = 'standard' | 'large' | 'handicapped' | 'electric' +export type SpotType = 'normal' | 'ancha' | 'mas_ancha' | 'esquina' | 'accesible' | 'estrecha_bicis' export type LevelCode = '-2' | '-3' export type BookingStatus = 'reserved' | 'checked_in' | 'completed' | 'canceled' | 'no_show' // Same values as the ENUM columns of parking_bookings (db-mysql/aiven/04_parking_tables.sql) diff --git a/backend/repositories/backoffice/backoffice-repository.ts b/backend/repositories/backoffice/backoffice-repository.ts index 5442779b..d62a29d6 100644 --- a/backend/repositories/backoffice/backoffice-repository.ts +++ b/backend/repositories/backoffice/backoffice-repository.ts @@ -5,6 +5,7 @@ */ import pool from '../../config/db.js' +import { dbx, withTransaction } from '../../config/transaction.js' import { RowDataPacket, ResultSetHeader } from 'mysql2' import { getNowMadrid } from '../../config/date-utils.js' import { logger } from '../../config/logger.js' @@ -608,56 +609,55 @@ export class BackofficeRepository { month: number, userId: string ): Promise<{ count: number; invoiceIds: number[] }> { - // First, get the IDs of invoices that will be updated - const [invoicesToUpdate] = await pool.query( - `SELECT id, invoice_date FROM bo_invoices - WHERE status = 'validated' - AND is_deleted = 0 - AND YEAR(invoice_date) = ? - AND MONTH(invoice_date) = ?`, - [year, month] - ) + // The status change and its history rows commit together; FOR UPDATE keeps + // the selected invoices from changing before the UPDATE pays exactly them + return withTransaction(async () => { + const [invoicesToUpdate] = await dbx().query( + `SELECT id FROM bo_invoices + WHERE status = 'validated' + AND is_deleted = 0 + AND YEAR(invoice_date) = ? + AND MONTH(invoice_date) = ? + FOR UPDATE`, + [year, month] + ) - if (invoicesToUpdate.length === 0) { - return { count: 0, invoiceIds: [] } - } + if (invoicesToUpdate.length === 0) { + return { count: 0, invoiceIds: [] } + } - const invoiceIds = invoicesToUpdate.map((inv) => inv.id) + const invoiceIds: number[] = invoicesToUpdate.map((inv) => inv.id) + + // paid_date is set to the invoice's own invoice_date + const [result] = await dbx().query( + `UPDATE bo_invoices SET + status = 'paid', + paid_date = invoice_date, + updated_by = ?, + updated_at = NOW() + WHERE id IN (?)`, + [userId, invoiceIds] + ) - // Update all validated invoices for that month - // paid_date is set to the invoice's own invoice_date - const [result] = await pool.query( - `UPDATE bo_invoices SET - status = 'paid', - paid_date = invoice_date, - updated_by = ?, - updated_at = NOW() - WHERE status = 'validated' - AND is_deleted = 0 - AND YEAR(invoice_date) = ? - AND MONTH(invoice_date) = ?`, - [userId, year, month] - ) + for (const invoiceId of invoiceIds) { + await this.addInvoiceHistory( + invoiceId, + 'paid', + 'status', + 'validated', + 'paid', + `Pago automático de facturas de ${month}/${year}`, + userId + ) + } - // Add history entry for each updated invoice - for (const invoice of invoicesToUpdate) { - await this.addInvoiceHistory( - invoice.id, - 'paid', - 'status', - 'validated', - 'paid', - `Pago automático de facturas de ${month}/${year}`, - userId + logger.info( + { affectedRows: result.affectedRows, month, year }, + '[BackofficeRepository.markValidatedInvoicesAsPaid] Marked invoices as paid' ) - } - logger.info( - { affectedRows: result.affectedRows, month, year }, - '[BackofficeRepository.markValidatedInvoicesAsPaid] Marked invoices as paid' - ) - - return { count: result.affectedRows, invoiceIds } + return { count: result.affectedRows, invoiceIds } + }) } /** @@ -842,7 +842,7 @@ export class BackofficeRepository { notes: string | null, changedBy: string ): Promise { - const [result] = await pool.query( + const [result] = await dbx().query( `INSERT INTO bo_invoice_history (invoice_id, action, field_changed, old_value, new_value, notes, changed_by) VALUES (?, ?, ?, ?, ?, ?, ?)`, diff --git a/backend/repositories/cron/cron-job-runs-repository.ts b/backend/repositories/cron/cron-job-runs-repository.ts new file mode 100644 index 00000000..825e7b17 --- /dev/null +++ b/backend/repositories/cron/cron-job-runs-repository.ts @@ -0,0 +1,44 @@ +// repositories/cron/cron-job-runs-repository.ts +// One row per scheduled job with the last period it ran for (cron_job_runs, +// 20261014_cron_catch_up.sql). claimRun is what keeps a job from running twice +// in the same period when the start-up catch-up and node-cron meet. + +import type { ResultSetHeader } from 'mysql2' +import db from '../../config/db.js' + +// A run left as 'running' this long ago died with its process: it can be retried +const STALE_RUNNING_MINUTES = 15 + +/** + * Takes the job for `period` if nobody has: true when this caller must run it. + * It can be taken when its last period is another one, when the last run + * failed, or when a run of this period was left unfinished. The UPDATE locks + * the row, so two concurrent callers cannot both get true. + */ +export async function claimRun(job: string, period: string): Promise { + await db.execute( + `INSERT IGNORE INTO cron_job_runs (job, last_period, last_status, last_run_at) + VALUES (?, '', 'ok', NOW())`, + [job] + ) + const [result] = await db.execute( + `UPDATE cron_job_runs + SET last_period = ?, last_status = 'running', last_run_at = NOW(), last_error = NULL + WHERE job = ? + AND (last_period <> ? + OR last_status = 'error' + OR (last_status = 'running' AND last_run_at < NOW() - INTERVAL ${STALE_RUNNING_MINUTES} MINUTE))`, + [period, job, period] + ) + return result.affectedRows === 1 +} + +export async function finishRun(job: string, error?: unknown): Promise { + const message = + error === undefined ? null : String(error instanceof Error ? error.message : error) + await db.execute(`UPDATE cron_job_runs SET last_status = ?, last_error = ? WHERE job = ?`, [ + message === null ? 'ok' : 'error', + message?.slice(0, 500) ?? null, + job, + ]) +} diff --git a/backend/services/backoffice/AGENTS.md b/backend/services/backoffice/AGENTS.md index a16eb434..d8147842 100644 --- a/backend/services/backoffice/AGENTS.md +++ b/backend/services/backoffice/AGENTS.md @@ -124,6 +124,13 @@ logging each one to `bo_invoice_history`; it needs `isAdmin` (the preview only the module access, which is admin anyway). A `revertBatchPayment` endpoint exists for undo (also with a preview). +The same payment runs on its own once a month (`services/cron/`): from day 10 at +23:59 (Madrid) it pays the validated invoices of the previous month, signed by +the inactive user `system-cron`. Render's free plan sleeps, so it runs on the +first start after that time, and `cron_job_runs` keeps it to once per month +(ADR-046). `markValidatedInvoicesAsPaid` locks the invoices it selects and +writes their history in the same transaction. + ## Role boundaries - **Read anything** (stats, categories, suppliers, invoices, assets) — `admin` diff --git a/backend/services/cron/cron-schedule.ts b/backend/services/cron/cron-schedule.ts new file mode 100644 index 00000000..1847d8a9 --- /dev/null +++ b/backend/services/cron/cron-schedule.ts @@ -0,0 +1,106 @@ +// services/cron/cron-schedule.ts +// When each scheduled job is due, in Madrid wall time. Render's free plan puts +// the backend to sleep, so node-cron misses its times: on start the backend +// asks every job for its due period and runs the ones not done yet +// (cron_job_runs). A job only catches up its current period, never older ones. + +const TIMEZONE = 'Europe/Madrid' + +export const CRON_JOBS = [ + 'notifications', + 'checklist-reset', + 'checklist-purge', + 'batch-payment', +] as const +export type CronJob = (typeof CRON_JOBS)[number] + +// node-cron expression of each job, in Europe/Madrid +export const CRON_EXPRESSIONS: Record = { + notifications: '0 7 * * *', + 'checklist-reset': '30 6 * * *', + 'checklist-purge': '0 4 * * 1', + 'batch-payment': '59 23 10 * *', +} + +interface MadridTime { + year: number + month: number // 1-12 + day: number + weekday: number // 0 = Monday … 6 = Sunday + minutes: number // since midnight +} + +const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + +const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: TIMEZONE, + year: 'numeric', + month: 'numeric', + day: 'numeric', + weekday: 'short', + hour: 'numeric', + minute: 'numeric', + hourCycle: 'h23', +}) + +export function madridTime(now: Date): MadridTime { + const parts = Object.fromEntries(formatter.formatToParts(now).map((p) => [p.type, p.value])) + return { + year: Number(parts.year), + month: Number(parts.month), + day: Number(parts.day), + weekday: WEEKDAYS.indexOf(parts.weekday), + minutes: Number(parts.hour) * 60 + Number(parts.minute), + } +} + +const pad = (n: number) => String(n).padStart(2, '0') + +// Calendar date `daysBack` days before y-m-d, as YYYY-MM-DD (UTC arithmetic: no DST) +function dateBefore(year: number, month: number, day: number, daysBack: number): string { + const d = new Date(Date.UTC(year, month - 1, day - daysBack)) + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}` +} + +const at = (hour: number, minute: number) => hour * 60 + minute + +/** + * The period a job is due for at `now`, or null when its time has not come yet + * in the current period. Daily jobs: YYYY-MM-DD. The weekly purge: the date of + * its Monday. A missed day or week is covered by the next run. + * + * The batch payment (YYYY-MM) is never null: before day 10 at 23:59 it is still + * due for the previous month's period, so a month the backend slept through + * from the 10th to its end is paid on the next start instead of skipped. + */ +export function duePeriod(job: CronJob, now: Date): string | null { + const t = madridTime(now) + const today = `${t.year}-${pad(t.month)}-${pad(t.day)}` + + switch (job) { + case 'notifications': + return t.minutes >= at(7, 0) ? today : null + case 'checklist-reset': + return t.minutes >= at(6, 30) ? today : null + case 'checklist-purge': + if (t.weekday === 0 && t.minutes < at(4, 0)) return null + return dateBefore(t.year, t.month, t.day, t.weekday) + case 'batch-payment': { + if (t.day > 10 || (t.day === 10 && t.minutes >= at(23, 59))) { + return `${t.year}-${pad(t.month)}` + } + const previous = previousMonth(t.year, t.month) + return `${previous.year}-${pad(previous.month)}` + } + } +} + +function previousMonth(year: number, month: number): { year: number; month: number } { + return month === 1 ? { year: year - 1, month: 12 } : { year, month: month - 1 } +} + +// The batch payment of period YYYY-MM pays the invoices of the month before it +export function batchPaymentTarget(period: string): { year: number; month: number } { + const [year, month] = period.split('-').map(Number) + return previousMonth(year, month) +} diff --git a/backend/services/cron/cron-service.ts b/backend/services/cron/cron-service.ts index fd1f19a9..28ed7f73 100644 --- a/backend/services/cron/cron-service.ts +++ b/backend/services/cron/cron-service.ts @@ -9,21 +9,33 @@ import { } from '../checklist/checklist.service.js' import { logger } from '../../config/logger.js' import { extendAvailability } from '../../repositories/parking/parking.repository.js' +import { claimRun, finishRun } from '../../repositories/cron/cron-job-runs-repository.js' +import { + CRON_EXPRESSIONS, + CRON_JOBS, + batchPaymentTarget, + duePeriod, + type CronJob, +} from './cron-schedule.js' /** * Servicio de tareas programadas (Cron Jobs) * - * Ejecuta tareas automáticas en intervalos definidos. + * Ejecuta tareas automáticas en intervalos definidos (horas en cron-schedule.ts). * - Notificaciones: 1x al día a las 7:00 AM * - Batch Payment: Día 10 de cada mes a las 23:59 (facturas validated del mes anterior -> paid) */ export class CronService { private static isRunning = false - // System user ID for automated actions + // Inactive user of 20261014_cron_catch_up.sql: updated_by and changed_by are foreign keys private static readonly SYSTEM_USER_ID = 'system-cron' /** * Iniciar todos los cron jobs + * + * Render free duerme el backend y node-cron pierde sus horas: al arrancar se + * ejecutan las tareas que tocaban y no se hicieron (catchUp), y cada hora de + * node-cron pasa por runIfDue, que no repite un periodo ya hecho. */ static start(): void { if (this.isRunning) { @@ -34,92 +46,12 @@ export class CronService { this.isRunning = true logger.info('Iniciando cron jobs') - // ═══════════════════════════════════════════════════════ - // NOTIFICACIONES AUTOMÁTICAS - Todos los días a las 7:00 AM - // ═══════════════════════════════════════════════════════ - // Formato cron: minuto hora día-mes mes día-semana - // '0 7 * * *' = A las 7:00 AM todos los días - - cron.schedule( - '0 7 * * *', - async () => { - logger.info('[CRON] Ejecutando verificación de notificaciones') - const startTime = Date.now() - - try { - await NotificationGeneratorService.processPendingNotifications() - const duration = Date.now() - startTime - logger.info({ duration }, '[CRON] Notificaciones procesadas') - } catch (error) { - logger.error({ err: error }, '[CRON] Error procesando notificaciones') - } - }, - { timezone: 'Europe/Madrid' } - ) - - // ═══════════════════════════════════════════════════════ - // BATCH PAYMENT - Día 10 de cada mes a las 23:59 - // ═══════════════════════════════════════════════════════ - // Marca todas las facturas 'validated' del mes anterior como 'paid' - // '59 23 10 * *' = A las 23:59 del día 10 de cada mes - - cron.schedule( - '59 23 10 * *', - async () => { - logger.info('[CRON] Ejecutando batch payment de facturas') - const startTime = Date.now() - - try { - const result = await this.runBatchPaymentNow() - const duration = Date.now() - startTime - - if (result.success) { - logger.info({ duration, count: result.count }, '[CRON] Batch payment completado') - } else { - logger.error({ err: result.error }, '[CRON] Batch payment falló') - } - } catch (error) { - logger.error({ err: error }, '[CRON] Error en batch payment') - } - }, - { timezone: 'Europe/Madrid' } - ) - - // ═══════════════════════════════════════════════════════ - // CHECKLIST RESET - Todos los días a las 06:30 (Europe/Madrid) - // ═══════════════════════════════════════════════════════ - cron.schedule( - '30 6 * * *', - async () => { - logger.info('[CRON] Ejecutando reset diario de checklists') - try { - const count = await checklistDailyReset() - logger.info({ count }, '[CRON] Checklist reset completado') - } catch (error) { - logger.error({ err: error }, '[CRON] Error en checklist reset') - } - }, - { timezone: 'Europe/Madrid' } - ) - - // ═══════════════════════════════════════════════════════ - // CHECKLIST EVENT LOG PURGE - Cada lunes a las 04:00 (Europe/Madrid) - // ═══════════════════════════════════════════════════════ - // Borra eventos de checklist_event_log con más de 7 días. - // Datos operativos diarios — no se necesita histórico más allá de una semana. - cron.schedule( - '0 4 * * 1', - async () => { - logger.info('[CRON] Purgando checklist_event_log (>7 días)') - try { - const deleted = await purgeOldEventLogs(7) - logger.info({ deleted }, '[CRON] Checklist event log purgado') - } catch (error) { - logger.error({ err: error }, '[CRON] Error purgando checklist event log') - } - }, - { timezone: 'Europe/Madrid' } - ) + for (const job of CRON_JOBS) { + cron.schedule(CRON_EXPRESSIONS[job], () => void this.runIfDue(job), { + timezone: 'Europe/Madrid', + }) + } + void this.catchUp() // ═══════════════════════════════════════════════════════ // PARKING CALENDAR - Al arrancar y todos los días a las 03:00 (Europe/Madrid) @@ -131,12 +63,79 @@ export class CronService { timezone: 'Europe/Madrid', }) - logger.info('Cron jobs iniciados') + logger.info('Cron jobs iniciados (Europe/Madrid; al arrancar se ponen al día)') logger.info(' - Notificaciones: Todos los días a las 7:00 AM') logger.info(' - Batch Payment: Día 10 de cada mes a las 23:59') - logger.info(' - Checklist Reset: Todos los días a las 6:30 AM (Europe/Madrid)') - logger.info(' - Checklist Event Log Purge: Cada lunes a las 4:00 AM (Europe/Madrid)') - logger.info(' - Parking Calendar: Al arrancar y todos los días a las 3:00 AM (Europe/Madrid)') + logger.info(' - Checklist Reset: Todos los días a las 6:30 AM') + logger.info(' - Checklist Event Log Purge: Cada lunes a las 4:00 AM') + logger.info(' - Parking Calendar: Al arrancar y todos los días a las 3:00 AM') + } + + /** + * Ejecuta las tareas cuyo periodo actual toca y no se ha hecho, una tras otra + */ + static async catchUp(now: Date = new Date()): Promise { + for (const job of CRON_JOBS) { + await this.runIfDue(job, now) + } + } + + /** + * Ejecuta `job` si su periodo actual ya toca y nadie lo ha hecho. Nunca lanza: + * un fallo queda en cron_job_runs y se reintenta en el siguiente arranque. + */ + static async runIfDue( + job: CronJob, + now: Date = new Date() + ): Promise<'not-due' | 'done' | 'ok' | 'error'> { + const period = duePeriod(job, now) + if (!period) return 'not-due' + + try { + if (!(await claimRun(job, period))) return 'done' + } catch (error) { + logger.error({ err: error, job, period }, '[CRON] No se pudo reservar la tarea') + return 'error' + } + + logger.info({ job, period }, '[CRON] Ejecutando') + const startTime = Date.now() + try { + await this.runJob(job, period) + await finishRun(job) + logger.info({ job, period, duration: Date.now() - startTime }, '[CRON] Completado') + return 'ok' + } catch (error) { + logger.error({ err: error, job, period }, '[CRON] Falló') + await finishRun(job, error).catch((err) => + logger.error({ err, job }, '[CRON] No se pudo anotar el fallo') + ) + return 'error' + } + } + + private static async runJob(job: CronJob, period: string): Promise { + switch (job) { + case 'notifications': + await NotificationGeneratorService.processPendingNotifications() + return + case 'checklist-reset': { + const count = await checklistDailyReset() + logger.info({ count }, '[CRON] Checklist reset completado') + return + } + case 'checklist-purge': { + const deleted = await purgeOldEventLogs(7) + logger.info({ deleted }, '[CRON] Checklist event log purgado') + return + } + case 'batch-payment': { + const { year, month } = batchPaymentTarget(period) + const result = await this.runBatchPaymentNow(year, month) + if (!result.success) throw new Error(result.error ?? 'Batch payment falló') + return + } + } } /** diff --git a/backend/services/group/group-history-service.ts b/backend/services/group/group-history-service.ts index dfe8e7ca..43ce372f 100644 --- a/backend/services/group/group-history-service.ts +++ b/backend/services/group/group-history-service.ts @@ -47,15 +47,15 @@ export class GroupHistoryService { const fieldsToLog = changedFields || Object.keys(nw) for (const field of fieldsToLog) { - if (old[field] !== nw[field]) { + if (!this.valuesEqual(old[field], nw[field])) { const historyData: CreateGroupHistoryDTO = { group_id: groupId, action: HistoryAction.UPDATED, table_affected: 'groups', record_id: groupId, field_changed: field, - old_value: this.serializeValue(old[field]) ?? undefined, // ✅ FIX - new_value: this.serializeValue(nw[field]) ?? undefined, // ✅ FIX + old_value: this.serializeValue(old[field]) ?? undefined, + new_value: this.serializeValue(nw[field]) ?? undefined, changed_by: updatedBy, notes: `Campo "${field}" actualizado`, } @@ -68,6 +68,53 @@ export class GroupHistoryService { return historyRecords } + /** + * Comparar dos valores normalizando tipos comunes: + * - null/undefined son iguales + * - Date vs Date o Date vs string: comparar por fecha local + * - número vs string numérico: comparar con Number() + * - resto: === + */ + private static valuesEqual(a: unknown, b: unknown): boolean { + if (a === b) return true + if (a == null && b == null) return true + if (a == null || b == null) return false + + const isDateA = a instanceof Date + const isDateB = b instanceof Date + + if (isDateA || isDateB) { + const toLocalDays = (v: unknown): string | null => { + if (v instanceof Date) { + return `${v.getFullYear()}-${String(v.getMonth() + 1).padStart(2, '0')}-${String(v.getDate()).padStart(2, '0')}` + } + // 'AAAA-MM-DD' is already a calendar day: new Date() would read it as UTC + if (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)) return v + if (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(v)) { + const d = new Date(v) + if (!isNaN(d.getTime())) { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + } + } + return null + } + const da = toLocalDays(a) + const db = toLocalDays(b) + if (da !== null && db !== null) return da === db + } + + if (typeof a === 'number' && typeof b === 'string') { + const nb = Number(b) + if (!isNaN(nb)) return a === nb + } + if (typeof a === 'string' && typeof b === 'number') { + const na = Number(a) + if (!isNaN(na)) return na === b + } + + return false + } + /** * Registrar cambio de estado */ diff --git a/backend/services/parking/AGENTS.md b/backend/services/parking/AGENTS.md index 42105d4a..9514d632 100644 --- a/backend/services/parking/AGENTS.md +++ b/backend/services/parking/AGENTS.md @@ -69,9 +69,9 @@ Booking status and payment method are duplicated in the backend (`models/parking/index.ts`) and the DB ENUMs. If you add a status or a method, **update both sides**. -⚠️ `SpotType` in `models/parking/index.ts` (`standard`, `large`, `handicapped`, -`electric`) does not match the `spot_type` ENUM of the DB, which is the list -below (checked 2026-10-13). +`SpotType` in `models/parking/index.ts` mirrors the `spot_type` ENUM of the DB +(`normal`, `ancha`, `mas_ancha`, `esquina`, `accesible`, `estrecha_bicis`). If +either side changes, update the other. ### Booking status diff --git a/backend/tests/backoffice/batch-payment-transaction.test.ts b/backend/tests/backoffice/batch-payment-transaction.test.ts new file mode 100644 index 00000000..f3f73b95 --- /dev/null +++ b/backend/tests/backoffice/batch-payment-transaction.test.ts @@ -0,0 +1,78 @@ +// tests/backoffice/batch-payment-transaction.test.ts +// Regression tests: the batch payment marked invoices paid and then wrote their +// history one by one outside any transaction, so a failure halfway left paid +// invoices without history. Both now commit or roll back together, and the +// UPDATE pays exactly the invoices the locked SELECT found. + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const connection = { + beginTransaction: vi.fn(), + commit: vi.fn(), + rollback: vi.fn(), + release: vi.fn(), + query: vi.fn(), + execute: vi.fn(), +} + +vi.mock('../../config/db.js', () => ({ + default: { + getConnection: vi.fn(async () => connection), + query: vi.fn(), + execute: vi.fn(), + }, +})) + +const { BackofficeRepository } = + await import('../../repositories/backoffice/backoffice-repository.js') +const { default: db } = await import('../../config/db.js') + +describe('markValidatedInvoicesAsPaid', () => { + beforeEach(() => { + vi.clearAllMocks() + connection.query.mockImplementation(async (sql: string) => { + if (sql.includes('SELECT id FROM bo_invoices')) return [[{ id: 7 }, { id: 9 }]] + if (sql.includes('UPDATE bo_invoices')) return [{ affectedRows: 2 }] + return [{ insertId: 1 }] + }) + }) + + it('pays the locked invoices and writes their history in one transaction', async () => { + const result = await BackofficeRepository.markValidatedInvoicesAsPaid(2026, 9, 'system-cron') + + expect(result).toEqual({ count: 2, invoiceIds: [7, 9] }) + expect(connection.beginTransaction).toHaveBeenCalledOnce() + expect(connection.commit).toHaveBeenCalledOnce() + expect(db.query).not.toHaveBeenCalled() + + const [select, update, ...history] = connection.query.mock.calls + expect(select[0]).toMatch(/FOR UPDATE/) + expect(select[1]).toEqual([2026, 9]) + expect(update[0]).toMatch(/WHERE id IN \(\?\)/) + expect(update[1]).toEqual(['system-cron', [7, 9]]) + expect(history).toHaveLength(2) + expect(history.map((call) => call[1][0])).toEqual([7, 9]) + }) + + it('rolls everything back when a history row fails', async () => { + connection.query.mockImplementation(async (sql: string) => { + if (sql.includes('SELECT id FROM bo_invoices')) return [[{ id: 7 }, { id: 9 }]] + if (sql.includes('UPDATE bo_invoices')) return [{ affectedRows: 2 }] + throw new Error('foreign key') + }) + + await expect( + BackofficeRepository.markValidatedInvoicesAsPaid(2026, 9, 'system-cron') + ).rejects.toThrow('foreign key') + expect(connection.rollback).toHaveBeenCalledOnce() + expect(connection.commit).not.toHaveBeenCalled() + }) + + it('opens no write when there is nothing to pay', async () => { + connection.query.mockResolvedValueOnce([[]]) + const result = await BackofficeRepository.markValidatedInvoicesAsPaid(2026, 9, 'system-cron') + expect(result).toEqual({ count: 0, invoiceIds: [] }) + expect(connection.query).toHaveBeenCalledOnce() + expect(connection.commit).toHaveBeenCalledOnce() + }) +}) diff --git a/backend/tests/checklist/checklist-params.test.ts b/backend/tests/checklist/checklist-params.test.ts index 13df7a9d..aabf0238 100644 --- a/backend/tests/checklist/checklist-params.test.ts +++ b/backend/tests/checklist/checklist-params.test.ts @@ -83,7 +83,7 @@ describe('checklist route params', () => { it('answers 404 for a checklist that does not exist', async () => { expect(await call('GET', '/cl-invented/run')).toEqual({ status: 404, - body: { error: 'Checklist no encontrado' }, + body: { error: 'CHECKLIST_NOT_FOUND', code: 'CHECKLIST_NOT_FOUND' }, }) expect((await call('GET', '/cl-..%2F..%2Fpackage/run')).status).toBe(404) }) @@ -92,7 +92,7 @@ describe('checklist route params', () => { // s1-10 exists in night-audit but not in morning-shift expect(await call('POST', '/cl-morning-shift/steps/s1-10/comments')).toEqual({ status: 400, - body: { error: 'Paso no válido' }, + body: { error: 'CHECKLIST_INVALID_STEP', code: 'CHECKLIST_INVALID_STEP' }, }) expect((await call('POST', '/cl-night-audit/steps/s99-1/attachments')).status).toBe(400) }) diff --git a/backend/tests/cron/cron-catch-up.test.ts b/backend/tests/cron/cron-catch-up.test.ts new file mode 100644 index 00000000..5abd0193 --- /dev/null +++ b/backend/tests/cron/cron-catch-up.test.ts @@ -0,0 +1,119 @@ +// tests/cron/cron-catch-up.test.ts +// Render's free plan sleeps, so node-cron never fired: no job ran in a week of +// logs (2026-10-14). The backend now runs on start whatever is due, and +// runIfDue keeps a period from running twice. The batch payment also failed +// because 'system-cron' was not a user (20261014_cron_catch_up.sql). + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const claimRun = vi.fn() +const finishRun = vi.fn() +vi.mock('../../repositories/cron/cron-job-runs-repository.js', () => ({ claimRun, finishRun })) + +const processPendingNotifications = vi.fn() +vi.mock('../../services/notifications/notification-generator-service', () => ({ + NotificationGeneratorService: { processPendingNotifications }, +})) + +const dailyReset = vi.fn(async () => 0) +const purgeOldEventLogs = vi.fn(async () => 0) +vi.mock('../../services/checklist/checklist.service.js', () => ({ dailyReset, purgeOldEventLogs })) + +const getValidatedInvoicesCountByMonth = vi.fn() +const markValidatedInvoicesAsPaid = vi.fn() +vi.mock('../../repositories/backoffice/backoffice-repository.js', () => ({ + BackofficeRepository: { getValidatedInvoicesCountByMonth, markValidatedInvoicesAsPaid }, +})) + +vi.mock('../../repositories/parking/parking.repository.js', () => ({ + extendAvailability: vi.fn(), +})) + +const { CronService } = await import('../../services/cron/cron-service.js') + +// Monday 2026-10-12 10:00 in Madrid: every job is due +const MONDAY = new Date('2026-10-12T08:00:00Z') + +describe('runIfDue', () => { + beforeEach(() => { + vi.clearAllMocks() + claimRun.mockResolvedValue(true) + finishRun.mockResolvedValue(undefined) + getValidatedInvoicesCountByMonth.mockResolvedValue({ count: 2, total_amount: 300 }) + markValidatedInvoicesAsPaid.mockResolvedValue({ count: 2, invoiceIds: [1, 2] }) + }) + + it('does nothing before the job is due', async () => { + const result = await CronService.runIfDue('notifications', new Date('2026-10-14T04:00:00Z')) + expect(result).toBe('not-due') + expect(claimRun).not.toHaveBeenCalled() + expect(processPendingNotifications).not.toHaveBeenCalled() + }) + + it('does not run a period someone already took', async () => { + claimRun.mockResolvedValue(false) + expect(await CronService.runIfDue('notifications', MONDAY)).toBe('done') + expect(claimRun).toHaveBeenCalledWith('notifications', '2026-10-12') + expect(processPendingNotifications).not.toHaveBeenCalled() + }) + + it('runs a due job and records it', async () => { + expect(await CronService.runIfDue('notifications', MONDAY)).toBe('ok') + expect(processPendingNotifications).toHaveBeenCalledOnce() + expect(finishRun).toHaveBeenCalledWith('notifications') + }) + + it('records a failure so the next start retries it, without throwing', async () => { + processPendingNotifications.mockRejectedValueOnce(new Error('db down')) + expect(await CronService.runIfDue('notifications', MONDAY)).toBe('error') + expect(finishRun).toHaveBeenCalledWith('notifications', expect.any(Error)) + }) + + it('pays the month before the period as the system user', async () => { + expect(await CronService.runIfDue('batch-payment', MONDAY)).toBe('ok') + expect(claimRun).toHaveBeenCalledWith('batch-payment', '2026-10') + expect(markValidatedInvoicesAsPaid).toHaveBeenCalledWith(2026, 9, 'system-cron') + }) + + it('still pays a month the backend slept through, early in the next one', async () => { + expect(await CronService.runIfDue('batch-payment', new Date('2026-11-02T08:00:00Z'))).toBe('ok') + expect(claimRun).toHaveBeenCalledWith('batch-payment', '2026-10') + expect(markValidatedInvoicesAsPaid).toHaveBeenCalledWith(2026, 9, 'system-cron') + }) + + it('marks the batch payment failed when the payment fails', async () => { + markValidatedInvoicesAsPaid.mockRejectedValueOnce(new Error('foreign key')) + expect(await CronService.runIfDue('batch-payment', MONDAY)).toBe('error') + expect(finishRun).toHaveBeenCalledWith('batch-payment', expect.any(Error)) + }) +}) + +describe('catchUp', () => { + beforeEach(() => { + vi.clearAllMocks() + claimRun.mockResolvedValue(true) + finishRun.mockResolvedValue(undefined) + getValidatedInvoicesCountByMonth.mockResolvedValue({ count: 0, total_amount: 0 }) + }) + + it('runs every due job once, in order', async () => { + await CronService.catchUp(MONDAY) + expect(claimRun.mock.calls).toEqual([ + ['notifications', '2026-10-12'], + ['checklist-reset', '2026-10-12'], + ['checklist-purge', '2026-10-12'], + ['batch-payment', '2026-10'], + ]) + expect(processPendingNotifications).toHaveBeenCalledOnce() + expect(dailyReset).toHaveBeenCalledOnce() + expect(purgeOldEventLogs).toHaveBeenCalledWith(7) + expect(getValidatedInvoicesCountByMonth).toHaveBeenCalledWith(2026, 9) + }) + + it('keeps going when one job fails', async () => { + dailyReset.mockRejectedValueOnce(new Error('boom')) + await CronService.catchUp(MONDAY) + expect(purgeOldEventLogs).toHaveBeenCalledOnce() + expect(getValidatedInvoicesCountByMonth).toHaveBeenCalledOnce() + }) +}) diff --git a/backend/tests/cron/cron-schedule.test.ts b/backend/tests/cron/cron-schedule.test.ts new file mode 100644 index 00000000..4a1e4cda --- /dev/null +++ b/backend/tests/cron/cron-schedule.test.ts @@ -0,0 +1,79 @@ +// tests/cron/cron-schedule.test.ts +// When each scheduled job is due, in Madrid wall time. Render's free plan +// sleeps, so the backend catches up on start; these periods decide what it +// runs. Madrid is UTC+2 until 2026-10-25 03:00 and UTC+1 after it. + +import { describe, it, expect } from 'vitest' +import { batchPaymentTarget, duePeriod, madridTime } from '../../services/cron/cron-schedule.js' + +const utc = (iso: string) => new Date(iso) + +describe('madridTime', () => { + it('reads the Madrid wall clock and weekday, not the UTC one', () => { + // Saturday 2026-10-31 23:30 UTC is Sunday 2026-11-01 00:30 in Madrid + expect(madridTime(utc('2026-10-31T23:30:00Z'))).toEqual({ + year: 2026, + month: 11, + day: 1, + weekday: 6, + minutes: 30, + }) + }) +}) + +describe('daily jobs', () => { + it('notifications are due from 07:00, before the change of hour', () => { + expect(duePeriod('notifications', utc('2026-10-14T04:59:00Z'))).toBeNull() + expect(duePeriod('notifications', utc('2026-10-14T05:00:00Z'))).toBe('2026-10-14') + }) + + it('the checklist reset is due from 06:30, before and after the change of hour', () => { + expect(duePeriod('checklist-reset', utc('2026-10-14T04:29:00Z'))).toBeNull() + expect(duePeriod('checklist-reset', utc('2026-10-14T04:30:00Z'))).toBe('2026-10-14') + expect(duePeriod('checklist-reset', utc('2026-10-26T05:29:00Z'))).toBeNull() + expect(duePeriod('checklist-reset', utc('2026-10-26T05:30:00Z'))).toBe('2026-10-26') + expect(duePeriod('checklist-reset', utc('2026-03-30T04:30:00Z'))).toBe('2026-03-30') + }) + + it('start a new day at midnight in Madrid, not in UTC', () => { + expect(duePeriod('notifications', utc('2026-10-14T21:59:00Z'))).toBe('2026-10-14') + expect(duePeriod('notifications', utc('2026-10-14T22:00:00Z'))).toBeNull() + }) +}) + +describe('the weekly checklist purge', () => { + it('is due from Monday 04:00 and keeps that Monday as its period all week', () => { + expect(duePeriod('checklist-purge', utc('2026-10-12T01:59:00Z'))).toBeNull() + expect(duePeriod('checklist-purge', utc('2026-10-12T02:00:00Z'))).toBe('2026-10-12') + expect(duePeriod('checklist-purge', utc('2026-10-18T20:00:00Z'))).toBe('2026-10-12') + }) + + it('counts Monday 04:00 in winter time too', () => { + expect(duePeriod('checklist-purge', utc('2026-10-26T02:59:00Z'))).toBeNull() + expect(duePeriod('checklist-purge', utc('2026-10-26T03:00:00Z'))).toBe('2026-10-26') + }) + + it('finds the Monday across a change of year', () => { + expect(duePeriod('checklist-purge', utc('2026-01-01T10:00:00Z'))).toBe('2025-12-29') + }) +}) + +describe('the monthly batch payment', () => { + it('takes the month as its period from day 10 at 23:59', () => { + expect(duePeriod('batch-payment', utc('2026-10-10T21:58:00Z'))).toBe('2026-09') + expect(duePeriod('batch-payment', utc('2026-10-10T21:59:00Z'))).toBe('2026-10') + expect(duePeriod('batch-payment', utc('2026-10-31T12:00:00Z'))).toBe('2026-10') + }) + + it('still owes the previous period on the first days of a month', () => { + expect(duePeriod('batch-payment', utc('2026-10-09T12:00:00Z'))).toBe('2026-09') + // 2026-10-31 23:30 UTC is already 1 November in Madrid + expect(duePeriod('batch-payment', utc('2026-10-31T23:30:00Z'))).toBe('2026-10') + expect(duePeriod('batch-payment', utc('2027-01-05T12:00:00Z'))).toBe('2026-12') + }) + + it('pays the month before its period, across a change of year', () => { + expect(batchPaymentTarget('2026-10')).toEqual({ year: 2026, month: 9 }) + expect(batchPaymentTarget('2027-01')).toEqual({ year: 2026, month: 12 }) + }) +}) diff --git a/backend/tests/frontend/error-code-message.test.ts b/backend/tests/frontend/error-code-message.test.ts index 95a58b74..5eaaafc7 100644 --- a/backend/tests/frontend/error-code-message.test.ts +++ b/backend/tests/frontend/error-code-message.test.ts @@ -62,6 +62,42 @@ describe('errorCodeMessage', () => { ) }) + it('gives middleware error messages in Spanish and in English', () => { + expect(errorCodeMessage('RATE_LIMIT_LOGIN', 'es')).toMatch(/^Demasiados intentos de inicio/) + expect(errorCodeMessage('RATE_LIMIT_LOGIN', 'en')).toMatch(/^Too many login attempts/) + expect(errorCodeMessage('DEMO_ACTION_BLOCKED', 'es')).toMatch( + /^Acción no disponible en modo demo/ + ) + expect(errorCodeMessage('DEMO_ACTION_BLOCKED', 'en')).toMatch( + /^Action not available in demo mode/ + ) + expect(errorCodeMessage('IMAGE_TOO_LARGE', 'es')).toMatch(/^La imagen supera el tamaño máximo/) + expect(errorCodeMessage('IMAGE_TOO_LARGE', 'en')).toMatch(/^Image exceeds the maximum/) + expect(errorCodeMessage('CHECKLIST_INVALID_STEP', 'es')).toMatch(/^Paso no válido/) + expect(errorCodeMessage('CHECKLIST_INVALID_STEP', 'en')).toMatch(/^Invalid step/) + }) + + it('gives new auth messages in Spanish and in English', () => { + expect(errorCodeMessage('AUTH_USER_ALREADY_EXISTS', 'es')).toMatch( + /^El nombre de usuario o email ya existe/ + ) + expect(errorCodeMessage('AUTH_USER_ALREADY_EXISTS', 'en')).toMatch( + /^The username or email is already in use/ + ) + expect(errorCodeMessage('AUTH_INVALID_ROLE', 'es')).toMatch(/^Rol no válido/) + expect(errorCodeMessage('AUTH_INVALID_ROLE', 'en')).toMatch(/^Invalid role/) + expect(errorCodeMessage('AUTH_NEW_PASSWORD_SAME_AS_CURRENT', 'es')).toMatch( + /^La nueva contraseña debe ser diferente/ + ) + expect(errorCodeMessage('AUTH_NEW_PASSWORD_SAME_AS_CURRENT', 'en')).toMatch( + /^The new password must be different/ + ) + expect(errorCodeMessage('AUTH_RESET_PASSWORD_ERROR', 'es')).toMatch( + /^Error al resetear contraseña/ + ) + expect(errorCodeMessage('AUTH_RESET_PASSWORD_ERROR', 'en')).toMatch(/^Error resetting password/) + }) + it('gives departments messages in Spanish and in English', () => { expect(errorCodeMessage('DEPARTMENTS_NAME_REQUIRED', 'es')).toMatch( /^El nombre del departamento es requerido/ diff --git a/backend/tests/group/group-history-update.test.ts b/backend/tests/group/group-history-update.test.ts new file mode 100644 index 00000000..eb67e689 --- /dev/null +++ b/backend/tests/group/group-history-update.test.ts @@ -0,0 +1,86 @@ +// tests/group/group-history-update.test.ts +// Regression tests for logGroupUpdated: editing only the amount logged 7 +// unchanged fields (dates as Date vs string, nulls, DECIMAL as string). + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { GroupHistoryService } from '../../services/group/group-history-service.js' + +vi.mock('../../repositories/group/group-history-repository', () => ({ + GroupHistoryRepository: { + create: vi.fn(async (data: unknown) => ({ id: Math.random(), ...data })), + }, +})) + +const { GroupHistoryRepository } = + await import('../../repositories/group/group-history-repository.js') + +describe('GroupHistoryService.logGroupUpdated', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not log when date is the same as Date object and string', async () => { + const date = new Date(2026, 9, 14) + const oldData = { arrival_date: date } + const newData = { arrival_date: '2026-10-14' } + + const result = await GroupHistoryService.logGroupUpdated(1, 'user1', oldData, newData) + + expect(result).toHaveLength(0) + expect(GroupHistoryRepository.create).not.toHaveBeenCalled() + }) + + it('does not log when null vs undefined', async () => { + const oldData = { notes: null } + const newData = { notes: undefined } + + const result = await GroupHistoryService.logGroupUpdated(1, 'user1', oldData, newData) + + expect(result).toHaveLength(0) + expect(GroupHistoryRepository.create).not.toHaveBeenCalled() + }) + + it('does not log when number vs numeric string (1500 vs "1500.00")', async () => { + const oldData = { total_amount: 1500 } + const newData = { total_amount: '1500.00' } + + const result = await GroupHistoryService.logGroupUpdated(1, 'user1', oldData, newData) + + expect(result).toHaveLength(0) + expect(GroupHistoryRepository.create).not.toHaveBeenCalled() + }) + + it('logs exactly 1 row when amount actually changes', async () => { + const oldData = { total_amount: 1500, name: 'Old Name' } + const newData = { total_amount: 2000, name: 'Old Name' } + + const result = await GroupHistoryService.logGroupUpdated(1, 'user1', oldData, newData) + + expect(result).toHaveLength(1) + expect(GroupHistoryRepository.create).toHaveBeenCalledTimes(1) + expect(GroupHistoryRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + field_changed: 'total_amount', + old_value: '1500', + new_value: '2000', + }) + ) + }) + + it('ignores fields not in changedFields even if they differ', async () => { + const oldData = { total_amount: 1500, name: 'Old Name', created_at: '2026-01-01' } + const newData = { total_amount: 2000, name: 'New Name', created_at: '2026-01-01' } + + const result = await GroupHistoryService.logGroupUpdated(1, 'user1', oldData, newData, [ + 'total_amount', + ]) + + expect(result).toHaveLength(1) + expect(GroupHistoryRepository.create).toHaveBeenCalledTimes(1) + expect(GroupHistoryRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + field_changed: 'total_amount', + }) + ) + }) +}) diff --git a/backend/tests/uploads/image-upload.test.ts b/backend/tests/uploads/image-upload.test.ts index d2a1af7e..b4de006d 100644 --- a/backend/tests/uploads/image-upload.test.ts +++ b/backend/tests/uploads/image-upload.test.ts @@ -68,7 +68,7 @@ describe('singleImage', () => { lastSize = undefined expect(await upload(MAX_IMAGE_BYTES + 1, 'image/jpeg')).toEqual({ status: 413, - body: { error: 'Máximo 5MB por archivo' }, + body: { error: 'IMAGE_TOO_LARGE', code: 'IMAGE_TOO_LARGE' }, }) expect(lastSize).toBeUndefined() }) @@ -76,14 +76,14 @@ describe('singleImage', () => { it('answers 400 for a file that is not an image', async () => { expect(await upload(100, 'application/pdf')).toEqual({ status: 400, - body: { error: 'Solo se permiten imágenes (JPG, PNG, WebP, GIF)' }, + body: { error: 'IMAGE_INVALID_TYPE', code: 'IMAGE_INVALID_TYPE' }, }) }) it('answers 400 for a file that only claims to be an image', async () => { expect(await upload(1024, 'image/jpeg', 'file', true)).toEqual({ status: 400, - body: { error: 'Solo se permiten imágenes (JPG, PNG, WebP, GIF)' }, + body: { error: 'IMAGE_INVALID_TYPE', code: 'IMAGE_INVALID_TYPE' }, }) }) @@ -96,7 +96,7 @@ describe('singleImage', () => { expect((await upload(MB2, 'image/jpeg', 'avatar', false, '/avatar')).status).toBe(201) expect(await upload(MB2 + 1, 'image/jpeg', 'avatar', false, '/avatar')).toEqual({ status: 413, - body: { error: 'Máximo 2MB por archivo' }, + body: { error: 'IMAGE_TOO_LARGE', code: 'IMAGE_TOO_LARGE' }, }) }) }) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c2a1d659..322951d6 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -992,3 +992,51 @@ - **Rechazado:** abrir una sesión por subproyecto. Las tareas que cruzan el contrato entre los dos lados (tipos, esquemas, JSON duplicado) piden verlos a la vez, y la excepción basta. + +## ADR-045 — Los errores conocidos que no se arreglan ahora salen de `TODO.md` + +- **Estado:** ✅ aceptada (2026-10-14) +- **Fecha:** 2026-10-14 +- **Decisión:** 25 puntos de `TODO.md` se dan por admitidos y no se arreglan + ahora: el rastro de cambios en dinero (F&B, caja, parking), carreras y + transacciones, el flujo de estados de Maintenance, las reglas duplicadas de + Scheduling, Sentry, Cloudflare Access, la subida a Node 24 y las ideas sin + plazo. Se mueven, con su texto y su prioridad, al apartado «Errores conocidos + pendientes» de `docs/_archive/TODO.md`; `TODO.md` deja una línea que apunta + allí. +- **Motivo:** decisión del propietario, para que `TODO.md` muestre solo lo que + se va a trabajar. Siguen anotados para supervisarlos y recuperarlos si alguno + vuelve a ser prioritario. +- **Consecuencias:** + - `docs/_archive/` lo ignora git y solo está en el PC principal: desde los + otros equipos esos puntos no se ven. + - Los puntos se quitaron del `TODO.md` publicado, pero siguen en su historial. + +## ADR-046 — Las tareas programadas se ponen al día al arrancar + +- **Estado:** ✅ aceptada (2026-10-14) +- **Fecha:** 2026-10-14 +- **Decisión:** cada tarea de `services/cron/` (avisos de las 7:00, reset del + checklist de las 6:30, purga del lunes y pago de facturas del día 10) se + ejecuta también al arrancar el backend si su periodo actual ya toca y no se ha + hecho. La tabla `cron_job_runs` guarda el último periodo de cada una y no deja + repetirlo; `node-cron` sigue disparando a su hora cuando el backend está + despierto. El pago firma con un usuario inactivo `system-cron` + (`20261014_cron_catch_up.sql`) y va en una transacción. +- **Motivo:** Render gratis duerme el backend a los ~15 minutos sin peticiones. + En los logs de los 7 días anteriores al 2026-10-14 arrancó unas 40 veces y no + hubo ni una ejecución de esas tareas; el día 10 a las 23:59 estaba dormido. + Además, el pago fallaba siempre: `system-cron` no existía y `updated_by` y + `changed_by` son claves foráneas a `users`. Se descartó despertar el backend + desde fuera (GitHub Actions programado): no garantiza la hora y añade una + pieza externa. Un plan de pago de Render tampoco entra: solo planes gratuitos. +- **Consecuencias:** + - Las tareas corren con la primera visita después de su hora, no a la hora + exacta. + - El pago solo recupera el último periodo: si el backend duerme del día 10 a + fin de mes, paga en el primer arranque del mes siguiente, pero nunca meses + más antiguos. + - El primer arranque tras desplegar paga las facturas validadas de septiembre + de 2026 (periodo `2026-10`). + - La migración tiene que estar en Aiven antes del despliegue; sin la tabla, + cada tarea anota el error en el log y no se ejecuta. diff --git a/docs/SESSION.md b/docs/SESSION.md index b327566a..575da891 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -37,8 +37,11 @@ y auditado, publicación y demo. Sus documentos (`ROADMAP.md`, `GITCLEAN.md`, - **Revisión de OpenCode, cerrada el 2026-10-12:** sus 20 ramas `opencode/*` están fusionadas en esta rama (PR #7–#26) y probadas en preview; las migraciones `20261011_keep_group_history_on_delete.sql` y - `20261012_unique_invoice_supplier_number.sql`, aplicadas en Aiven. Aún no - están en `main`. + `20261012_unique_invoice_supplier_number.sql`, aplicadas en Aiven. En `main` + desde el PR #28. +- **`main` al día con la rama** desde el PR #29 (2026-10-14, merge `47f77b9`): + harness en los dos subproyectos, documentos de módulo alineados con el código + y Prettier 3.9.9 en los dos lados. ## ⚠️ Empieza por aquí @@ -58,24 +61,20 @@ y auditado, publicación y demo. Sus documentos (`ROADMAP.md`, `GITCLEAN.md`, ## Siguiente -1. **PR de esta rama a `main`** con merge commit, y comprobar producción en el - navegador (Aiven ya tiene las migraciones). Después, adelantar esta rama a - `main` (`git merge --ff-only origin/main`). +«Después de publicar» de `TODO.md`, por prioridad: -Luego, «Después de publicar» de `TODO.md`, por prioridad: - -2. **Rotar las credenciales de producción** (BD, clave JWT, Cloudinary, claves +1. **Rotar las credenciales de producción** (BD, clave JWT, Cloudinary, claves de IA). Ninguna de ellas está en el historial, pero sí la contraseña en claro de un usuario de la aplicación (`test-login.js`, commit `8595ec0`): cambiarla también. Se hace en los paneles y se comprueba que producción y preview siguen funcionando. -3. **Rastro de cambios en dinero:** ingresos de F&B, pagos y recuentos de caja, - cobros de parking. -4. **Fechas y «hoy» según la zona horaria de cada pieza**, como tarea propia +2. **Fechas y «hoy» según la zona horaria de cada pieza**, como tarea propia (decidido el 2026-10-12). -5. Las de prioridad media, empezando por las comprobaciones cortas (versión de - Node y cron en Render) y el pago automático del día 10, que nunca ha - funcionado. +3. Las de prioridad media, empezando por las comprobaciones cortas (cron en + Render) y el pago automático del día 10, que nunca ha funcionado. + +Los errores conocidos que se aceptan sin arreglarlos ahora están en +`docs/_archive/TODO.md` (ADR-045). ## Esperando decisión diff --git a/docs/TODO.md b/docs/TODO.md index 38442da7..3341d9c3 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -18,7 +18,7 @@ hace que alguien reimplemente lo que ya existe. ## Rama `claude/compassionate-planck-gh6aof` -Última fusión en `main`: 2026-10-05 (PR #4, commit de merge `15a8531`). La rama +Última fusión en `main`: 2026-10-14 (PR #29, commit de merge `47f77b9`). La rama sigue siendo donde se arregla este fichero y se prueba en el entorno preview. ## Después de publicar @@ -27,6 +27,11 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. > carreras, operación, interfaz y calidad. Se trabajan en el repositorio > público, sin plazo (ADR-033). +📦 **Errores conocidos admitidos:** 26 puntos que se aceptan y no se arreglan +ahora están en `docs/_archive/TODO.md`, apartado «Errores conocidos pendientes» +(ADR-045). Se supervisan; si uno vuelve a ser prioritario, se devuelve aquí. +`docs/_archive/` solo está en el PC principal: git lo ignora. + ### 🔴 Alta - [ ] **Rotar las credenciales de producción** — contraseña de la BD y claves de @@ -38,35 +43,6 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. commit `8595ec0`, con su hash): cambiarla en toda cuenta que la use, en Aiven y fuera. Decisión del dueño (2026-10-12): se rota y el historial no se reescribe. -- [ ] **Fnb: ninguna mutación de ingresos deja rastro de quién la hizo** — - `upsertMany` sobrescribe el importe anterior (`ON DUPLICATE KEY UPDATE`) y - `deleteDay` borra filas sin dejar ningún registro de usuario, valor - anterior u origen del cambio, a diferencia de otros módulos del proyecto - (blacklist, logbook) que sí llevan una auditoría. Un dato financiero - alterado o borrado no se puede reconstruir ni revertir. - `backend/repositories/fnb/fnb.repository.ts`. _Comprobado por mí el - 2026-09-28._ -- [ ] **Cashier: cambios de dinero sin rastro** — pagos y recuentos no dejan - historial, las ediciones no guardan valores anteriores y borrar un turno - borra también su `cashier_history` (_visto en producción el 2026-09-29_). - _Según la revisión `security` L3 del 2026-09-28; no repasado por mí._ La - autoría ya no sale del cuerpo (`shift_id` del pago, `created_by` del vale, - `opened_by` del día; _comprobado en producción el 2026-09-29_). -- [ ] **Parking: cambios del cobro sin historial** — editar `payment_amount`, - método o referencia no deja rastro del valor anterior. Que se pueda editar - en cualquier estado es intencionado (ADR-032, 2026-10-04). El signo, el - `ENUM` del método y las longitudes ya se validan (2026-10-02). _Comprobado - por mí el 2026-09-28._ - -- [ ] **Grupos: el detalle del grupo da los contactos a `mantenimiento`** — - `GET /api/groups/:id` (`controllers/group/group-controller.ts`, detalle) - devuelve `contacts` con email y teléfono a cualquier rol de - `canViewGroups`. El commit `e7a5443` (decisión del 2026-10-12) protegió - `/contacts`, `/contacts/primary` y el historial con - `canViewGroupContacts`, pero este endpoint se quedó fuera; el frontend - oculta la pestaña, pero `useGroup` recibe los datos igual. Quitar los - contactos del detalle para esos roles y añadir el test. _Comprobado por mí - el 2026-10-13 leyendo el código._ ### 🟡 Media @@ -78,44 +54,18 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. (solo la API las sirve), pero el borrado lógico no las toca, `auto_delete_on_close` se guarda y nadie lo usa, y subir o borrar fotos no queda en el historial. _Comprobado por mí el 2026-09-28._ -- [ ] **Scheduling: datos de salud guardados sin plazo** — - `scheduling_solver_runs` guarda para siempre la entrada completa del - solver (con las bajas `IT`/`E` de cada usuario) y su matriz. Decidir el - plazo cuando haya datos reales (RGPD); el dueño lo dejó para después de - publicar el 2026-10-04. La lectura ya es solo de `admin` y el log de un - INFEASIBLE ya no las incluye (2026-10-04). - -- [ ] **Parking: dos reservas creadas a la vez chocan en `booking_code`** — el - trigger `trg_generate_booking_code` calcula el siguiente número con - `MAX()` sobre una lectura sin bloqueo; dos altas simultáneas (aunque sean - de plazas distintas) sacan el mismo código y una falla con 500. No hay - doble reserva, solo el error. _Visto en preview el 2026-10-04._ - [ ] **Probar una restauración del backup de Aiven** antes de meter datos reales — Aiven hace copias automáticas y existe `backend/db-mysql/backup/backup.ps1`, pero no consta que se haya probado a restaurar ninguna. Del plan archivado (`Global-Plan.md`, H3-8). -- [ ] **Sentry para los errores** — no está instalado (_comprobado el - 2026-09-28_). Esperaba un DSN. Cuando lo haya: `@sentry/node` en el - backend (`Sentry.init` al principio de `index.ts` y envolver el manejador - global de errores) y `@sentry/nextjs` en el frontend. El plan gratuito - (5000 errores al mes) basta. -- [ ] **Cloudflare Zero Trust Access delante del frontend** — login con Google - antes de llegar a la app, solo para el equipo (gratis hasta 50 usuarios). - Hoy `four-points.stackbp.es` apunta directo a Vercel (_comprobado con DNS - el 2026-09-28_). Pasos: el DNS de `stackbp.es` en Cloudflare; el CNAME del - frontend con proxy activado (nube naranja); activar Zero Trust; Google - como proveedor de identidad; una aplicación _self-hosted_ para - `four-points.stackbp.es` con la lista de correos del equipo; probar en - incógnito. ⚠️ **No poner `api.four-points.stackbp.es` detrás**: el - navegador no podría llamar al API. -- [ ] **Comprobar si los cron se ejecutan en Render** — corren dentro del - proceso del backend; si el plan gratuito lo duerme por inactividad, no se - disparan. _No comprobado._ -- [ ] **Avisar a los usuarios de que un `admin` puede leer sus mensajes** — - `getConversation` y `getMessages` (`backend/controllers/messages/`) dejan - leer cualquier conversación al rol `admin`, aunque no participe. Decidir - si se mantiene y, si es así, decirlo en la interfaz. _Comprobado el - 2026-09-28._ +- [ ] **Cron en producción: desplegar y comprobarlo en Render** — en Render no + se ejecutaba ninguna tarea programada (7 días de logs sin una, 2026-10-14: + el plan gratuito duerme el backend) y el pago del día 10 fallaba por + `system-cron`. Arreglado en la rama `opencode-tasks`: se ponen al día al + arrancar (ADR-046). `20261014_cron_catch_up.sql` ya está en Aiven + (2026-10-14). Falta desplegar y ver en los logs de Render el + `[CRON] Ejecutando` de cada tarea y la tabla `cron_job_runs` con sus + periodos. El primer arranque paga las facturas validadas de septiembre. - [ ] **La retención de mensajes borra las conversaciones vacías** — el evento MySQL `cleanup_old_messages` borra cada día, además de los mensajes de más de 90 días, toda conversación sin mensajes, incluidas las recién creadas y @@ -160,11 +110,6 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. - [ ] **Parking: `GET /vehicles` vuelca todos los vehículos** (matrícula y titular) sin paginar ni filtrar. _Según la revisión `security` L3 del 2026-09-28 (fichero y línea en el informe); no repasado por mí._ -- [ ] **Mensajería: crear una conversación no va en transacción** — el formato y - los repetidos de `participant_ids` y `user_ids` ya se validan, pero un - usuario inexistente o inactivo deja la conversación creada a medias. - _Según la revisión `security` L3 del 2026-09-28 (fichero y línea en el - informe); no repasado por mí._ - [ ] **Mensajería: avisos urgentes sin límite** — solo el límite global de 300 peticiones cada 15 min por IP. _Según la revisión `security` L3 del 2026-09-28 (fichero y línea en el informe); no repasado por mí._ @@ -184,10 +129,6 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. primero» (ADR-015). `MASTER_INSTALL.sql` solo llega al 2026-05-20. Se corrigen poco a poco, con `docs/general/database/` como resumen (ADR-016). _Comprobado por mí el 2026-09-28._ -- [ ] **El pago automático del día 10 no ha funcionado nunca** — el cron escribe - `updated_by = 'system-cron'`, que es clave foránea a `users`, y ese - usuario no existe en Aiven (0 facturas pagadas por el cron). Además el - pago en lote no va en una transacción. _Comprobado por mí el 2026-09-28._ - [ ] **La generación manual de notificaciones crea avisos duplicados** con varias peticiones a la vez: no hay clave única. (Desde el 2026-10-02 solo la lanzan admin y group-admin.) _Según la revisión `security` L3 del @@ -197,24 +138,6 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. historial) y `resolution_notes` (reescribe el campo entero, así que se pueden inventar o borrar notas con autor). _Comprobado por mí el 2026-09-28._ -- [ ] **Maintenance: sin flujo de estados en el backend** — cualquier estado - pasa a cualquier otro y un parte cerrado se reabre por la API. Cancelar se - registra en el historial como «cerrado». _Comprobado por mí el - 2026-09-28._ -- [ ] **Maintenance: la asignación no valida al destinatario** — vale un usuario - inactivo, de cualquier rol o inexistente, y la edición no comprueba que - tipo interno o externo encaje con el resto de campos. _Según la revisión - `security` L3 del 2026-09-28 (fichero y línea en el informe); no repasado - por mí._ -- [ ] **Groups: las mutaciones no van en transacción con su historial** — el - historial se escribe en el controlador, fuera de las transacciones del - repositorio: un fallo entre ambos deja cambios sin registrar. El borrado - del grupo ya va junto a su fila de historial (2026-10-11). _Comprobado el - 2026-10-11._ -- [ ] **Groups: el historial registra campos que no cambiaron** — editar solo el - importe de un grupo dejó 7 filas `updated` (`arrival_date`, `created_at`, - `updated_by`…). La comparación de `logGroupUpdated` trata como distintos - valores iguales (fechas, nulos). _Visto el 2026-10-11._ - [ ] **Checklist: el reinicio manual no funciona** — `resetRun` cierra el run y crea otro del mismo día, pero la clave única `uk_run (hotel_id, checklist_id, hotel_date)` lo impide: 500. Después @@ -223,28 +146,6 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. manuales) y la pantalla no lo llama: decidido el 2026-10-11 dejar el endpoint como está; antes de usarlo, cambiar la clave única. _Comprobado el 2026-10-11._ -- [ ] **Cashier: los cierres de turno y de día no bloquean** — se comprueban - antes de escribir, sin bloqueo: un pago o un vale puede entrar en un turno - que otro usuario está cerrando. El ciclo de los vales y su límite de 5 ya - van en transacción (2026-10-11). _Según la revisión `security` L3 del - 2026-09-28; no repasado._ - -- [ ] **Scheduling: las reglas H2, H4, H5 y libres están duplicadas y no - coinciden** — `validate()` solo registra `CoverageConstraint` y - `EmployeeRulesConstraint`; H2, H4, H5 y los libres se aplican en línea en - `runFinalValidation()`. Las clases `night-block`, `max-consecutive-work`, - `consecutive-rest` y `monthly-libre` solo las usa - `tests/scheduling/constraints.test.ts` y se comportan distinto (H4 suma la - racha del mes anterior; H5 es error, en línea es aviso). - `docs/scheduling/constraints.md` y los `@emitter` de `soft-weights.ts` - apuntan a esas clases. Decidir cuál es la buena, dejar una sola y alinear - la especificación. _Comprobado por mí el 2026-10-13._ -- [ ] **Scheduling: tras generar, las celdas de peticiones aprobadas y de - `fixedDays` quedan editables** — `applyGeneratedSchedule()` las inserta - desde `generatedRows()` sin `source_constraint_id`, así que no salen - bloqueadas en la parrilla ni devuelven 409 al editarlas. Decidir si es lo - querido o si deben bloquearse como las de restricciones aprobadas. - _Comprobado por mí el 2026-10-13 leyendo el código._ ### 🟢 Baja @@ -259,32 +160,6 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. (`UPDATE users SET is_active = 0 WHERE username = 'demo'`). `setup:local` no la ejecuta (es anterior al baseline); no aplicarla a mano. _Comprobado el 2026-10-05._ -- [ ] **Errores que aún llegan sin traducir** — los códigos del backend ya se - traducen en el cliente (`app/lib/helpers/errorCodeMessage.ts`; un test - exige texto en es y en para cada `ERROR_CODES.X` y cada `code: 'X'`). - Quedan: las server actions de blacklist, groups y parking - (`app/dashboard/*/actions/`, `app/lib/parking/actions.ts`) lanzan - `errorData.error`, que es el código en crudo, y en el servidor no hay - `document` para leer el idioma; unas 380 respuestas 4xx/5xx del backend - llevan un texto fijo (casi siempre en español) y ningún `code`, así que en - inglés se ven en español: scheduling ~135, maintenance ~56, parking ~59 - (`parking.controller.ts`, `stats.controller.ts` y los 400 de check-out, - cancelar y no-show), middlewares ~39 (`roleCheck.ts`, `rateLimiter.ts`…), - cashier ~39, checklist ~20, auth ~13 y algunas de fnb, demo y search; el - 409 del reinicio de la demo y el subtítulo del login (`login/page.tsx`) - también van fijos en español. Ir módulo a módulo (B5.n). _Medido el - 2026-10-12._ -- [ ] **Scheduling: vista de solo lectura para los demás roles** — hoy - `/dashboard/scheduling` es solo de `admin` y el resto no ve el cuadrante. - El dueño quiere que los demás (salvo `mantenimiento`) vean el cuadrante - publicado, sin edición ni configuración. Funcionalidad nueva, sin prisa - (2026-10-04). Necesita su propio endpoint sin bajas ni notas: la API de - scheduling es solo de admin por los datos de salud. -- [ ] **Backoffice: rechazar facturas no tiene UI** — el estado `rejected` y - `POST /invoices/:id/reject` existen, pero ningún botón los usa y ninguna - pestaña muestra las rechazadas. Una factura incorrecta hoy se borra. El - dueño decidió el 2026-10-04 no añadirlo porque casi nunca pasa; si se - añade, hace falta también reabrir (`rejected` → `pending`). - [ ] **Conciliation: el cierre mensual no tiene pantalla ni congela los días** — `PATCH /monthly-summary/:year/:month/status` solo existe en la API (`useUpdateMonthlySummaryStatus` no lo usa ningún componente), y con el @@ -293,51 +168,13 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. decidió el 2026-10-11 dejarlo así; si se le da pantalla, cerrar el mes debe bloquear sus días y comprobar con el mes bloqueado. _Comprobado el 2026-10-11._ -- [ ] **Maintenance: comprobar si Cloudinary quita los metadatos EXIF/GPS** de - las fotos. _Sin comprobar._ - [ ] **Parking: salto de precio a partir de 31 días** — las tarifas llegan a 30 días (250 € en Aiven); a partir de 31 se cobran 15 €/día (465 €). -- [ ] **Mensajería sin tiempo real** — los mensajes nuevos solo aparecen al - reabrir la conversación. Valorar un sondeo periódico como el de las - notificaciones. _Comprobado el 2026-09-28: no hay intervalo ni WebSocket._ - [ ] **Zod 3 en el frontend y Zod 4 en el backend** (`^3.25.17` frente a `4.0.5`) — unificar si se llegan a compartir esquemas. -- [ ] **Presencias a mano** — la pestaña de presencias de `scheduling` pide - pegar el texto en un `textarea`; podría leerse de - `scheduling_assignments`. - [ ] **Ejecutor de tests en el frontend** — no tiene: sus tests viven en `backend/tests/` (10 ficheros: 7 importan funciones del frontend y 3 leen su código) y no cubren componentes ni hooks. _Contado el 2026-10-12._ -- [ ] **Scheduling: cerrar la fase 3 del solver con uso real** — bloqueado hasta - que se generen meses de verdad. (1) Con ≥30 generaciones en - `scheduling_solver_runs`, buscar patrones (estado, penalización, desglose) - y anotarlos en `docs/scheduling/decisions.md`. (2) Probar el análisis de - `infeasible` con 3 casos imposibles a propósito (p. ej. 2 rotatorios y - cobertura mínima 3) y ver si las relajaciones que sugiere sirven. (3) - Ajustar los pesos: guardar la matriz publicada (falta la columna - `published_matrix`; hoy solo está `solver_matrix`), compararla con la del - solver y retocar pesos cada 2-3 meses; método en `decisions.md`, entrada - 2026-05-09. -- [ ] **Checklist: contenido que falta** (lo escribe el manager, no desarrollo) - — `tasks/housekeeping-daily.json` y las guías - `fidelizacion-postcheckin.md` y `balancing-opera.md` en - `frontend/content/checklist/`. -- [ ] **Seguridad cuando haya datos reales** (ideas del plan antiguo, no fallos) - — registro de auditoría de seguridad, 2FA opcional para `admin`, log - estructurado de los eventos de login y errores genéricos que no dejen - averiguar si un usuario existe. -- [ ] **Subir el backend a Node 24** — producción va en 22 (Render, 22.23.3) y - el frontend ya en 24 (Vercel). Desde f934b4d Render lee `backend/.nvmrc`; - el `.nvmrc` raíz ya no existe (ADR-044). Cambiar `backend/.nvmrc` y - `engines` a `24.x`, y probarlo antes en preview (pdf-parse v1, solver, - cron). _Comprobado en los logs de Render y Vercel el 2026-10-12, y en el - deploy preview de 72b5d3c el 2026-10-13._ -- [ ] **Parking: `SpotType` no coincide con la BD** — en - `backend/models/parking/index.ts` es - `standard | large | handicapped | electric`; el `ENUM` de - `parking_spots.spot_type` es - `normal | ancha | mas_ancha | esquina | accesible | estrecha_bicis`. El - tipo no protege nada hoy. _Comprobado por mí el 2026-10-13._ - [ ] **Solver: el venv local no coincide con producción** — en el equipo principal corre Python 3.13.5 y `.python-version` fija 3.11, y no tiene `pytest`, así que la suite de Python no se puede lanzar. Recrearlo con diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index f5287f1e..b70c89ea 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -14,13 +14,24 @@ const TRANSLATED = new Set([ 'AUTH_CREDENTIALS_REQUIRED', 'AUTH_CURRENT_PASSWORD_INCORRECT', 'AUTH_DELETE_AVATAR_ERROR', + 'AUTH_DELETE_USER_ERROR', 'AUTH_DEMO_UNAVAILABLE', + 'AUTH_FETCH_USER_ERROR', + 'AUTH_FETCH_USERS_BY_ROLE_ERROR', + 'AUTH_FETCH_USERS_ERROR', 'AUTH_INVALID_CREDENTIALS', 'AUTH_INVALID_FILE_TYPE', + 'AUTH_INVALID_ROLE', + 'AUTH_NEW_PASSWORD_SAME_AS_CURRENT', 'AUTH_NO_IMAGE_PROVIDED', 'AUTH_REFRESH_TOKEN_INVALID', 'AUTH_REFRESH_TOKEN_MISSING', + 'AUTH_RESET_PASSWORD_ERROR', + 'AUTH_TOKEN_EXPIRED', + 'AUTH_TOKEN_INVALID', + 'AUTH_UPDATE_USER_ERROR', 'AUTH_UPLOAD_AVATAR_ERROR', + 'AUTH_USER_ALREADY_EXISTS', 'AUTH_USER_NOT_AUTHENTICATED', 'AUTH_USER_NOT_FOUND', 'BACKOFFICE_ACTIVATE_SUPPLIER_ERROR', @@ -149,6 +160,8 @@ const TRANSLATED = new Set([ 'CASHIER_UPDATE_USERS_ERROR', 'CASHIER_VOUCHER_MAX_REACHED', 'CASHIER_VOUCHER_NOT_FOUND', + 'CHECKLIST_INVALID_STEP', + 'CHECKLIST_NOT_FOUND', 'CONCILIATION_ADMIN_ONLY_CLOSE', 'CONCILIATION_ALREADY_EXISTS', 'CONCILIATION_CLOSED_CANNOT_MODIFY', @@ -181,6 +194,7 @@ const TRANSLATED = new Set([ 'DEPARTMENTS_NAME_REQUIRED', 'DEPARTMENTS_NOT_FOUND', 'DEPARTMENTS_UPDATE_ERROR', + 'DEMO_ACTION_BLOCKED', 'FORBIDDEN', 'GROUP_CONTACT_CREATE_ERROR', 'GROUP_CONTACT_DELETE_ERROR', @@ -226,6 +240,9 @@ const TRANSLATED = new Set([ 'GROUP_STATUS_UPDATE_CONTRACT_ERROR', 'GROUP_STATUS_UPDATE_ROOMING_ERROR', 'GROUP_UPDATE_ERROR', + 'IMAGE_INVALID_TYPE', + 'IMAGE_SINGLE_FILE_ONLY', + 'IMAGE_TOO_LARGE', 'INTERNAL_ERROR', 'INVALID_DATA', 'INVALID_DATE_FORMAT', @@ -354,6 +371,15 @@ const TRANSLATED = new Set([ 'PARKING_SPOT_OCCUPIED', 'PARKING_START_BEFORE_END', 'PARKING_UPDATE_BOOKING_ERROR', + 'RATE_LIMIT_API', + 'RATE_LIMIT_DEMO_GENERATE', + 'RATE_LIMIT_DEMO_GENERATE_TOTAL', + 'RATE_LIMIT_DEMO_LOGIN', + 'RATE_LIMIT_DEMO_WRITE', + 'RATE_LIMIT_LOGIN', + 'RATE_LIMIT_PASSWORD_CHANGE', + 'RATE_LIMIT_PROFILE_UPDATE', + 'RATE_LIMIT_REFRESH', 'STORAGE_NOT_CONFIGURED', 'UNAUTHORIZED', ]) diff --git a/frontend/messages/en/errors.json b/frontend/messages/en/errors.json index 881d6472..74b86ab6 100644 --- a/frontend/messages/en/errors.json +++ b/frontend/messages/en/errors.json @@ -83,6 +83,19 @@ "UNAUTHORIZED": "Unauthorized", "FORBIDDEN": "You don't have permission for this action", "NOT_FOUND": "Resource not found", + "DEMO_ACTION_BLOCKED": "Action not available in demo mode. This is a demo account with limited functionality.", + "RATE_LIMIT_LOGIN": "Too many login attempts. Please try again in 15 minutes.", + "RATE_LIMIT_DEMO_LOGIN": "Too many demo login attempts. Please try again in 15 minutes.", + "RATE_LIMIT_DEMO_WRITE": "Too many demo changes. Please try again in 15 minutes.", + "RATE_LIMIT_DEMO_GENERATE": "You have generated too many schedules in the demo. Please try again in one hour.", + "RATE_LIMIT_DEMO_GENERATE_TOTAL": "The demo has generated too many schedules this hour. Please try again later.", + "RATE_LIMIT_PASSWORD_CHANGE": "Too many password change attempts. Please try again in 1 hour.", + "RATE_LIMIT_PROFILE_UPDATE": "Too many update attempts. Please try again in 15 minutes.", + "RATE_LIMIT_REFRESH": "Too many refresh requests. Please try again in 15 minutes.", + "RATE_LIMIT_API": "Too many requests. Please try again later.", + "IMAGE_INVALID_TYPE": "Only images are allowed (JPG, PNG, WebP, GIF)", + "IMAGE_TOO_LARGE": "Image exceeds the maximum allowed size", + "IMAGE_SINGLE_FILE_ONLY": "Only one image file is allowed", "AUTH_CREDENTIALS_REQUIRED": "Username and password are required", "AUTH_DEMO_UNAVAILABLE": "The demo is not available right now. Please try again later.", @@ -101,6 +114,12 @@ "AUTH_DELETE_USER_ERROR": "Error deleting user", "AUTH_UPLOAD_AVATAR_ERROR": "Error uploading avatar", "AUTH_DELETE_AVATAR_ERROR": "Error deleting avatar", + "AUTH_INVALID_ROLE": "Invalid role", + "AUTH_NEW_PASSWORD_SAME_AS_CURRENT": "The new password must be different from the current one", + "AUTH_RESET_PASSWORD_ERROR": "Error resetting password", + "AUTH_USER_ALREADY_EXISTS": "The username or email is already in use", + "AUTH_TOKEN_INVALID": "Invalid session. Please log in again", + "AUTH_TOKEN_EXPIRED": "Session has expired", "BACKOFFICE_COST_CENTER_DEPARTMENT_REQUIRED": "Cost center and department are required", "BACKOFFICE_NAME_REQUIRED": "Name is required", @@ -232,6 +251,9 @@ "CASHIER_FETCH_VOUCHER_HISTORY_ERROR": "Error fetching voucher history", "CASHIER_FETCH_SHIFT_SUMMARY_ERROR": "Error fetching shift summary", + "CHECKLIST_NOT_FOUND": "Checklist not found", + "CHECKLIST_INVALID_STEP": "Invalid step", + "CONCILIATION_DATE_REQUIRED": "Date is required", "CONCILIATION_INVALID_YEAR_MONTH": "Invalid year or month", "CONCILIATION_INVALID_YEAR": "Invalid year", diff --git a/frontend/messages/es/errors.json b/frontend/messages/es/errors.json index 3f683c05..fe976234 100644 --- a/frontend/messages/es/errors.json +++ b/frontend/messages/es/errors.json @@ -83,6 +83,19 @@ "UNAUTHORIZED": "No autorizado", "FORBIDDEN": "No tienes permiso para esta acción", "NOT_FOUND": "Recurso no encontrado", + "DEMO_ACTION_BLOCKED": "Acción no disponible en modo demo. Esta es una cuenta de demostración con funcionalidad limitada.", + "RATE_LIMIT_LOGIN": "Demasiados intentos de inicio de sesión. Intenta de nuevo en 15 minutos.", + "RATE_LIMIT_DEMO_LOGIN": "Demasiadas entradas a la demo. Intenta de nuevo en 15 minutos.", + "RATE_LIMIT_DEMO_WRITE": "Demasiados cambios en la demo. Intenta de nuevo en 15 minutos.", + "RATE_LIMIT_DEMO_GENERATE": "Has generado demasiados horarios en la demo. Intenta de nuevo en una hora.", + "RATE_LIMIT_DEMO_GENERATE_TOTAL": "La demo ha generado demasiados horarios esta hora. Intenta de nuevo más tarde.", + "RATE_LIMIT_PASSWORD_CHANGE": "Demasiados intentos de cambio de contraseña. Intenta de nuevo en 1 hora.", + "RATE_LIMIT_PROFILE_UPDATE": "Demasiados intentos de actualización. Intenta de nuevo en 15 minutos.", + "RATE_LIMIT_REFRESH": "Demasiadas solicitudes de refresco. Intenta de nuevo en 15 minutos.", + "RATE_LIMIT_API": "Demasiadas solicitudes. Intenta de nuevo más tarde.", + "IMAGE_INVALID_TYPE": "Solo se permiten imágenes (JPG, PNG, WebP, GIF)", + "IMAGE_TOO_LARGE": "La imagen supera el tamaño máximo permitido", + "IMAGE_SINGLE_FILE_ONLY": "Solo se admite un archivo de imagen", "AUTH_CREDENTIALS_REQUIRED": "Usuario y contraseña son requeridos", "AUTH_DEMO_UNAVAILABLE": "La demo no está disponible ahora mismo. Inténtalo más tarde.", @@ -101,6 +114,12 @@ "AUTH_DELETE_USER_ERROR": "Error al eliminar usuario", "AUTH_UPLOAD_AVATAR_ERROR": "Error al subir avatar", "AUTH_DELETE_AVATAR_ERROR": "Error al eliminar avatar", + "AUTH_INVALID_ROLE": "Rol no válido", + "AUTH_NEW_PASSWORD_SAME_AS_CURRENT": "La nueva contraseña debe ser diferente a la actual", + "AUTH_RESET_PASSWORD_ERROR": "Error al resetear contraseña", + "AUTH_USER_ALREADY_EXISTS": "El nombre de usuario o email ya existe", + "AUTH_TOKEN_INVALID": "Sesión no válida. Vuelve a iniciar sesión", + "AUTH_TOKEN_EXPIRED": "La sesión ha caducado", "BACKOFFICE_COST_CENTER_DEPARTMENT_REQUIRED": "Centro de costo y departamento son requeridos", "BACKOFFICE_NAME_REQUIRED": "El nombre es requerido", @@ -232,6 +251,9 @@ "CASHIER_FETCH_VOUCHER_HISTORY_ERROR": "Error al obtener historial de vales", "CASHIER_FETCH_SHIFT_SUMMARY_ERROR": "Error al obtener resumen de turnos", + "CHECKLIST_NOT_FOUND": "Checklist no encontrado", + "CHECKLIST_INVALID_STEP": "Paso no válido", + "CONCILIATION_DATE_REQUIRED": "La fecha es obligatoria", "CONCILIATION_INVALID_YEAR_MONTH": "Año o mes inválido", "CONCILIATION_INVALID_YEAR": "Año inválido",