Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions backend/config/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
// ============================================================================
Expand Down
19 changes: 14 additions & 5 deletions backend/controllers/auth/auth-controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
*/
export const demoLogin = async (_req: Request, res: Response): Promise<void> => {
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
}

Expand Down Expand Up @@ -198,15 +198,18 @@ export const register = async (req: Request, res: Response): Promise<void> => {
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
}

if (process.env.NODE_ENV !== 'production') {
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 })
}
}

Expand Down Expand Up @@ -411,7 +414,10 @@ export const updateProfile = async (req: Request, res: Response): Promise<void>
}

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
}

Expand Down Expand Up @@ -480,7 +486,10 @@ export const updatePassword = async (req: Request, res: Response): Promise<void>
}

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
}

Expand Down
60 changes: 46 additions & 14 deletions backend/controllers/auth/user-controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,7 +25,9 @@ export const getAllUsers = async (_req: Request, res: Response): Promise<void> =
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 })
}
}

Expand All @@ -37,14 +40,18 @@ export const getUserById = async (req: Request, res: Response): Promise<void> =>
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 })
}
}

Expand All @@ -58,7 +65,10 @@ export const getUsersByRole = async (req: Request, res: Response): Promise<void>
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,
})
}
}

Expand All @@ -78,27 +88,38 @@ export const updateUser = async (req: Request, res: Response): Promise<void> =>
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
}

res.status(200).json(updatedUser)
} 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 })
}
}

Expand All @@ -111,14 +132,18 @@ export const deleteUser = async (req: Request, res: Response): Promise<void> =>
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 })
}
}

Expand All @@ -138,17 +163,24 @@ export const resetUserPassword = async (req: Request, res: Response): Promise<vo
const success = await UserRepository.resetPassword(req.params.id, newPassword)

if (!success) {
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: 'Contraseña actualizada correctamente' })
} catch (error) {
logger.error({ err: error }, 'Error resetUserPassword')
if ((error as Error).message === 'Usuario no encontrado') {
res.status(404).json({ error: (error as Error).message })
res
.status(404)
.json({ error: ERROR_CODES.AUTH_USER_NOT_FOUND, code: ERROR_CODES.AUTH_USER_NOT_FOUND })
return
}
res.status(500).json({ error: 'Error al resetear contraseña' })
res.status(500).json({
error: ERROR_CODES.AUTH_RESET_PASSWORD_ERROR,
code: ERROR_CODES.AUTH_RESET_PASSWORD_ERROR,
})
}
}
5 changes: 4 additions & 1 deletion backend/controllers/group/group-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,14 @@ export class GroupController {
await PaymentCalculatorService.recalculatePayments(groupId, updateData.total_amount)
}

const changedFields = Object.keys(parsed.data)

await GroupHistoryService.logGroupUpdated(
groupId,
userId,
oldGroup,
updatedGroup || updateData
updatedGroup || updateData,
changedFields
)

return res.status(200).json({
Expand Down
1 change: 1 addition & 0 deletions backend/db-mysql/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ db-mysql/
| 2026-10-05 | `20261005_drop_demo_admin_role.sql` | Borra el rol `demo-admin` si ningún usuario lo tiene (ADR-038): la cuenta demo es un admin con `is_demo` y el código ya no conoce ese rol. En local no existía. | ✅ local (sin cambios) · ✅ Aiven |
| 2026-10-11 | `20261011_keep_group_history_on_delete.sql` | Quita la clave foránea `group_history_ibfk_1` (`ON DELETE CASCADE` a `hotel_groups`): borrar un grupo ya no borra su historial, y el borrado deja una fila `deleted` con la foto del grupo. **Aplicar en Aiven antes de desplegar el código**: con la clave dentro, borrar un grupo falla. | ✅ local · ✅ Aiven |
| 2026-10-12 | `20261012_unique_invoice_supplier_number.sql` | Añade `UNIQUE KEY uk_invoice_supplier_number (supplier_id, invoice_number)` a `bo_invoices`: la misma factura de un proveedor se podía registrar dos veces y se pagaban las dos en el lote mensual. El backend responde 409 `BACKOFFICE_INVOICE_DUPLICATE`. Antes del `ALTER` lista las duplicadas; si hay alguna, el `ALTER` falla y hay que corregirlas. Idempotente. El código funciona sin ella (no rechaza nada hasta que exista). | ✅ local · ✅ Aiven |
| 2026-10-14 | `20261014_cron_catch_up.sql` | Crea el usuario inactivo `system-cron` (el pago automático del día 10 firmaba con él y no existía: fallaba la clave foránea de `updated_by`) y la tabla `cron_job_runs`, con el último periodo de cada tarea programada para ponerlas al día al arrancar sin repetirlas (ADR-046). Idempotente. **Aplicar en Aiven antes de desplegar el código**: sin la tabla las tareas no se ejecutan. | ✅ local · ✅ Aiven |

### Verificación (99)

Expand Down
42 changes: 42 additions & 0 deletions backend/db-mysql/scripts/20261014_cron_catch_up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
-- =============================================================================
-- 20261014_cron_catch_up.sql
-- =============================================================================
-- Two things the scheduled jobs need (services/cron/):
--
-- 1. The `system-cron` user. The monthly batch payment writes
-- bo_invoices.updated_by and bo_invoice_history.changed_by, both foreign keys
-- to users, with 'system-cron'; that user never existed, so the batch failed
-- and no invoice was ever paid by it. It is inactive (login refuses it and
-- every user list filters is_active = 1) and its password is not a bcrypt
-- hash, so nothing can match it.
--
-- 2. Table `cron_job_runs`: one row per job with the last period it ran for
-- (day, week or month). Render's free plan puts the backend to sleep, so
-- the node-cron times are missed; on every start the backend runs the jobs
-- whose period is due and not yet done, and the row keeps a job from running
-- twice in the same period.
--
-- Idempotent: INSERT IGNORE and CREATE TABLE IF NOT EXISTS.
-- Safe to run multiple times.
--
-- Applies to: LOCAL + AIVEN. Apply it before deploying the code: without the
-- table, every job logs an error and skips.
-- Rollback:
-- DROP TABLE cron_job_runs;
-- DELETE FROM users WHERE id = 'system-cron'; -- only if nothing references it
-- =============================================================================

INSERT IGNORE INTO users (id, username, email, password, role_id, is_active)
VALUES ('system-cron', 'system-cron', 'system-cron@localhost', '!', 1, 0);

SELECT id, username, role_id, is_active FROM users WHERE id = 'system-cron';

CREATE TABLE IF NOT EXISTS cron_job_runs (
job VARCHAR(64) NOT NULL PRIMARY KEY,
last_period VARCHAR(16) NOT NULL COMMENT 'YYYY-MM-DD (the day, or the Monday of the week) or YYYY-MM',
last_status ENUM('running', 'ok', 'error') NOT NULL,
last_run_at DATETIME NOT NULL,
last_error VARCHAR(500) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

SELECT COUNT(*) AS cron_job_runs_rows FROM cron_job_runs;
21 changes: 11 additions & 10 deletions backend/middlewares/authenticateToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { verifyToken } from '../services/auth/tokenService.js'
import { demoRestriction } from './demoRestriction.js'
import { demoWriteLimiter } from './rateLimiter.js'
import { logger } from '../config/logger.js'
import { ERROR_CODES } from '../config/error-codes.js'

/**
* Middleware para verificar el token de acceso JWT.
Expand All @@ -30,10 +31,7 @@ export function authenticateToken(req: Request, res: Response, next: NextFunctio
}

if (!token) {
res.status(401).json({
error: 'No autorizado, falta token',
message: 'Debes enviar el token en cookies o en el header Authorization',
})
res.status(401).json({ error: ERROR_CODES.UNAUTHORIZED, code: ERROR_CODES.UNAUTHORIZED })
return
}

Expand All @@ -42,12 +40,16 @@ export function authenticateToken(req: Request, res: Response, next: NextFunctio

// Verifica que el payload contenga el id y sea un access token
if (!decoded?.id) {
res.status(401).json({ error: 'Token inválido: no contiene id de usuario' })
res
.status(401)
.json({ error: ERROR_CODES.AUTH_TOKEN_INVALID, code: ERROR_CODES.AUTH_TOKEN_INVALID })
return
}

if (decoded.type !== 'access') {
res.status(401).json({ error: 'Token inválido: tipo incorrecto' })
res
.status(401)
.json({ error: ERROR_CODES.AUTH_TOKEN_INVALID, code: ERROR_CODES.AUTH_TOKEN_INVALID })
return
}

Expand All @@ -60,18 +62,17 @@ export function authenticateToken(req: Request, res: Response, next: NextFunctio
isDemo: decoded.demo === true,
}
} catch (error) {
// Diferencia entre token expirado y token inválido
let message = 'Token inválido'
let code: string = ERROR_CODES.AUTH_TOKEN_INVALID

if (error instanceof Error) {
if (error.message === 'Token expirado' || error.name === 'TokenExpiredError') {
message = 'Token expirado'
code = ERROR_CODES.AUTH_TOKEN_EXPIRED
} else {
logger.debug({ err: error.message, path: req.path }, '[authenticateToken] invalid token')
}
}

res.status(401).json({ error: message })
res.status(401).json({ error: code, code })
return
}

Expand Down
Loading
Loading