From e023793c6bc8e5d849574aa3330982bd847fb7c6 Mon Sep 17 00:00:00 2001 From: razyd Date: Thu, 8 Oct 2026 09:34:46 +0200 Subject: [PATCH 01/71] docs(scheduling): point cross references to docs/scheduling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove SCHEDULING-SOLVER-PLAN.md references (archived) - Add note about 2026-04-22 entry in decisions.md - Fix TODO.md path (root → docs/TODO.md) - Fix frontend AGENTS.md path (CLAUDE.md → AGENTS.md) --- backend/scheduling-solver/AGENTS.md | 9 ++++----- backend/services/scheduling/AGENTS.md | 15 +++++++-------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/backend/scheduling-solver/AGENTS.md b/backend/scheduling-solver/AGENTS.md index 5f1b5893..2f336d25 100644 --- a/backend/scheduling-solver/AGENTS.md +++ b/backend/scheduling-solver/AGENTS.md @@ -199,8 +199,8 @@ matches, generic fallback. When an INFEASIBLE appears in production that the analyzer can't diagnose, **don't blindly patch here**: rebuild the real `SolverInput` for that month, -identify the pattern, and then extend the analyzer with a -new branch. One correct, specific message beats a thousand generic ones. +identify the pattern, and then extend the analyzer with a new branch. One +correct, specific message beats a thousand generic ones. ## Local setup @@ -280,9 +280,8 @@ that drifts. - `docs/scheduling/constraints.md` — textual specification of the rules. Source of truth when code and constraints disagree. - `docs/scheduling/decisions.md` — decision log (why each constraint is - hard/soft, why the weights, what was tried and discarded). -- `SCHEDULING-SOLVER-PLAN.md` (repo root) — original solver plan. Historical, - not updated; the code is the current truth. + hard/soft, why the weights, what was tried and discarded); the 2026-04-22 + entry keeps the original phases. - `backend/services/scheduling/build-solver-input.ts` — the TS side that assembles `SolverInput`. Any new field here needs its counterpart there. - `backend/services/scheduling/solver-client.ts` — daemon process management, diff --git a/backend/services/scheduling/AGENTS.md b/backend/services/scheduling/AGENTS.md index b3a1515e..abaec3e8 100644 --- a/backend/services/scheduling/AGENTS.md +++ b/backend/services/scheduling/AGENTS.md @@ -12,8 +12,8 @@ Manages monthly staff schedules through **manual cell-by-cell editing with real-time validation**, plus optional **auto-generation** via a CP-SAT solver (Python). -**Month states:** `draft` (editable, validation surfaces warnings) ↔ -`published` (read-only for staff). +**Month states:** `draft` (editable, validation surfaces warnings) ↔ `published` +(read-only for staff). **Cell locking.** Assignments with `source_constraint_id` are **locked**: they come from approvals (vacations, IT, etc.) and can't be overwritten from the UI @@ -471,8 +471,8 @@ git log. ## Historical Excel importer The one-off importer that loaded `PLANNING 2026.xlsx` (January-May) into -`scheduling_*` in draft state was removed on 2026-10-04: it held the real -names of the staff and had already run. The months stay in the database. +`scheduling_*` in draft state was removed on 2026-10-04: it held the real names +of the staff and had already run. The months stay in the database. ## Cross-month gotcha (H4/H5) — outstanding debt @@ -496,8 +496,7 @@ logic in `consecutive-rest.constraint.ts` and - `docs/scheduling/constraints.md` — full textual specification. Source of truth. - `docs/scheduling/decisions.md` — decision log (why hard/soft, why the weights, - what was tried). -- `SCHEDULING-SOLVER-PLAN.md` (root) — original plan. Historical. -- `TODO.md` (root) — known module bugs (setSchedulableEmployees, H5 cross-month + what was tried); the 2026-04-22 entry keeps the original phases. +- `docs/TODO.md` — known module bugs (setSchedulableEmployees, H5 cross-month debt, etc.). -- `frontend/app/components/scheduling/CLAUDE.md` — the UI side of the module. +- `frontend/app/components/scheduling/AGENTS.md` — the UI side of the module. From 2a1ff7231e8e4ef309b3340ab12658ca29a78bcd Mon Sep 17 00:00:00 2001 From: razyd Date: Thu, 8 Oct 2026 09:38:13 +0200 Subject: [PATCH 02/71] docs(scheduling): replace archived plan references in solver comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.py: SCHEDULING-SOLVER-PLAN.md §3.4 → docs/scheduling/solver.md «Where it runs» - schemas.py: §3.1-§3.2 → docs/scheduling/solver.md «Input» y «Output» - test_benchmark.py, test_corpus.py, solver-parity.test.ts: Fase 1/2 → docs/scheduling/decisions.md entrada 2026-04-22 --- backend/scheduling-solver/main.py | 2 +- backend/scheduling-solver/schemas.py | 2 +- backend/scheduling-solver/tests/test_benchmark.py | 2 +- backend/scheduling-solver/tests/test_corpus.py | 2 +- backend/tests/scheduling/solver-parity.test.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/scheduling-solver/main.py b/backend/scheduling-solver/main.py index 14906cb7..27bd1008 100644 --- a/backend/scheduling-solver/main.py +++ b/backend/scheduling-solver/main.py @@ -1,7 +1,7 @@ """ main.py — CLI entry point del solver. -Protocolo (SCHEDULING-SOLVER-PLAN.md §3.4): +Protocolo (docs/scheduling/solver.md, «Where it runs»): stdin → JSON con SolverInput stdout → JSON con SolverOutput stderr → logs de debug diff --git a/backend/scheduling-solver/schemas.py b/backend/scheduling-solver/schemas.py index 5750e6cd..9200d2aa 100644 --- a/backend/scheduling-solver/schemas.py +++ b/backend/scheduling-solver/schemas.py @@ -1,6 +1,6 @@ """ schemas.py — Contratos de input/output entre Node y el solver Python. -Corresponde a SCHEDULING-SOLVER-PLAN.md §3.1-§3.2. +Corresponde a docs/scheduling/solver.md, «Input» y «Output». """ from __future__ import annotations diff --git a/backend/scheduling-solver/tests/test_benchmark.py b/backend/scheduling-solver/tests/test_benchmark.py index 27379722..701a3cae 100644 --- a/backend/scheduling-solver/tests/test_benchmark.py +++ b/backend/scheduling-solver/tests/test_benchmark.py @@ -1,7 +1,7 @@ """ test_benchmark.py — Benchmark sintético: 30 empleados × 31 días. -Criterio de cierre Fase 2 (SCHEDULING-SOLVER-PLAN.md): +Criterio de cierre Fase 2 (docs/scheduling/decisions.md, entrada 2026-04-22): - 30 empleados × 31 días resueltos en < 30 segundos con horario factible. - Validación: no hard errors en validator (delegada a tests/scheduling/ TS, aquí sólo medimos status/tiempo del solver). diff --git a/backend/scheduling-solver/tests/test_corpus.py b/backend/scheduling-solver/tests/test_corpus.py index 5429537b..ebc9cd62 100644 --- a/backend/scheduling-solver/tests/test_corpus.py +++ b/backend/scheduling-solver/tests/test_corpus.py @@ -1,7 +1,7 @@ """ test_corpus.py — Corpus de fixtures del scheduler contra el solver Python. -Objetivo (SCHEDULING-SOLVER-PLAN.md Fase 1): +Objetivo (docs/scheduling/decisions.md, entrada 2026-04-22): - Ningún fixture produce status='error' (sin crashes internos). - Fixtures con parámetros claramente resolubles → status='ok'. diff --git a/backend/tests/scheduling/solver-parity.test.ts b/backend/tests/scheduling/solver-parity.test.ts index 28e93777..c97d015a 100644 --- a/backend/tests/scheduling/solver-parity.test.ts +++ b/backend/tests/scheduling/solver-parity.test.ts @@ -1,7 +1,7 @@ // tests/scheduling/solver-parity.test.ts // Parity test: solver output must pass ScheduleValidator with 0 hard errors. // -// SCHEDULING-SOLVER-PLAN.md Fase 1 criterion: +// docs/scheduling/decisions.md, entrada 2026-04-22: // "Output del solver pasa ScheduleValidator.validate() con 0 hard errors." // // Each test case: build SolverInput from a corpus fixture → run solver → validate matrix. From 0cad6b35563e80a275cfc78c77428957022cb232 Mon Sep 17 00:00:00 2001 From: razyd Date: Thu, 8 Oct 2026 09:42:15 +0200 Subject: [PATCH 03/71] fix(settings): fall back to the first available tab - Add resolveSettingsTab helper to validate tab parameter - Use helper in SettingsPanel to handle invalid ?tab= values - Add 5 test cases for the helper --- backend/tests/frontend/settings-tab.test.ts | 32 +++++++++++++++++++ .../app/components/profile/SettingsPanel.tsx | 10 ++++-- frontend/app/lib/helpers/settingsTab.ts | 12 +++++++ 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 backend/tests/frontend/settings-tab.test.ts create mode 100644 frontend/app/lib/helpers/settingsTab.ts diff --git a/backend/tests/frontend/settings-tab.test.ts b/backend/tests/frontend/settings-tab.test.ts new file mode 100644 index 00000000..a2992092 --- /dev/null +++ b/backend/tests/frontend/settings-tab.test.ts @@ -0,0 +1,32 @@ +// tests/frontend/settings-tab.test.ts +// resolveSettingsTab returns the requested tab if available, otherwise the +// first available tab. Runs the real helper (the frontend has no test runner). + +import { describe, it, expect } from 'vitest' +import { resolveSettingsTab } from '../../../frontend/app/lib/helpers/settingsTab.js' + +describe('resolveSettingsTab', () => { + const available = ['users', 'notifications', 'security'] as const + + it('returns the requested tab when it is available', () => { + expect(resolveSettingsTab('notifications', available)).toBe('notifications') + expect(resolveSettingsTab('security', available)).toBe('security') + expect(resolveSettingsTab('users', available)).toBe('users') + }) + + it('returns available[0] when requested tab is not in available (no permission)', () => { + expect(resolveSettingsTab('users', ['notifications', 'security'])).toBe('notifications') + }) + + it('returns available[0] when requested tab does not exist', () => { + expect(resolveSettingsTab('nonexistent', available)).toBe('users') + }) + + it('returns available[0] when requested is null', () => { + expect(resolveSettingsTab(null, available)).toBe('users') + }) + + it('returns available[0] when requested is empty string', () => { + expect(resolveSettingsTab('', available)).toBe('users') + }) +}) diff --git a/frontend/app/components/profile/SettingsPanel.tsx b/frontend/app/components/profile/SettingsPanel.tsx index 1a46134e..2445fbb0 100644 --- a/frontend/app/components/profile/SettingsPanel.tsx +++ b/frontend/app/components/profile/SettingsPanel.tsx @@ -15,6 +15,7 @@ import { departmentsApi } from '@/app/lib/departments' import { formatDepartmentName } from '@/app/lib/logbooks/hooks/useDepartments' import { cn } from '@/app/lib/helpers/utils' import { canRunNotificationCheckRole, isAdminRole } from '@/app/lib/helpers/utils' +import { resolveSettingsTab } from '@/app/lib/helpers/settingsTab' import { toast } from 'react-hot-toast' import { FiUsers, @@ -138,8 +139,12 @@ export function SettingsPanel() { [t, showDemoTab] ) - const activeTab = - (searchParams.get('tab') as SettingsTab) || (isUserAdmin ? 'users' : 'notifications') + const availableTabs = tabs.filter((tab) => !tab.adminOnly || isUserAdmin) + + const activeTab = resolveSettingsTab( + searchParams.get('tab'), + availableTabs.map((t) => t.id) + ) as SettingsTab const [users, setUsers] = useState([]) const [loading, setLoading] = useState(false) @@ -150,7 +155,6 @@ export function SettingsPanel() { const [isDropdownOpen, setIsDropdownOpen] = useState(false) const dropdownRef = useRef(null) - const availableTabs = tabs.filter((tab) => !tab.adminOnly || isUserAdmin) const activeTabConfig = availableTabs.find((t) => t.id === activeTab) || availableTabs[0] useEffect(() => { diff --git a/frontend/app/lib/helpers/settingsTab.ts b/frontend/app/lib/helpers/settingsTab.ts new file mode 100644 index 00000000..c79abbbc --- /dev/null +++ b/frontend/app/lib/helpers/settingsTab.ts @@ -0,0 +1,12 @@ +// frontend/app/lib/helpers/settingsTab.ts + +/** + * Resolves the active settings tab from URL parameter. + * Returns the requested tab if it's available, otherwise the first available tab. + */ +export function resolveSettingsTab(requested: string | null, available: readonly string[]): string { + if (requested && available.includes(requested)) { + return requested + } + return available[0] +} From c855777cf97930791acaa52ae3f44db43a403664 Mon Sep 17 00:00:00 2001 From: razyd Date: Thu, 8 Oct 2026 09:45:41 +0200 Subject: [PATCH 04/71] test(auth): pin current role access for excludeMantenimiento - Test excludeMantenimiento with 4 roles + missing user + uppercase - Test canOpenModule with 4 roles x 6 modules matrix - Verify 9 routers use excludeMantenimiento middleware --- backend/tests/auth/role-access.test.ts | 145 +++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 backend/tests/auth/role-access.test.ts diff --git a/backend/tests/auth/role-access.test.ts b/backend/tests/auth/role-access.test.ts new file mode 100644 index 00000000..7197f0b9 --- /dev/null +++ b/backend/tests/auth/role-access.test.ts @@ -0,0 +1,145 @@ +// tests/auth/role-access.test.ts +// Pin the current role access behaviour for excludeMantenimiento and +// canOpenModule. This test documents what the code does today; B4 will +// change the implementation but keep these tests green. + +import { describe, it, expect, vi } from 'vitest' +import { excludeMantenimiento } from '../../middlewares/roleCheck.js' +import { canOpenModule, type AppModule } from '../../services/auth/module-access.js' +import { readFileSync } from 'fs' +import { resolve } from 'path' + +// Helper to create mock req/res/next +function createMockReqRes(role?: string) { + const req = { + user: role ? { role } : undefined, + } as any + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as any + const next = vi.fn() + return { req, res, next } +} + +describe('excludeMantenimiento', () => { + it('allows admin', () => { + const { req, res, next } = createMockReqRes('admin') + excludeMantenimiento(req, res, next) + expect(next).toHaveBeenCalled() + expect(res.status).not.toHaveBeenCalled() + }) + + it('allows recepcionista', () => { + const { req, res, next } = createMockReqRes('recepcionista') + excludeMantenimiento(req, res, next) + expect(next).toHaveBeenCalled() + expect(res.status).not.toHaveBeenCalled() + }) + + it('allows group-admin', () => { + const { req, res, next } = createMockReqRes('group-admin') + excludeMantenimiento(req, res, next) + expect(next).toHaveBeenCalled() + expect(res.status).not.toHaveBeenCalled() + }) + + it('blocks mantenimiento with 403', () => { + const { req, res, next } = createMockReqRes('mantenimiento') + excludeMantenimiento(req, res, next) + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(403) + }) + + it('blocks missing req.user with 403', () => { + const { req, res, next } = createMockReqRes(undefined) + excludeMantenimiento(req, res, next) + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(403) + }) + + it('allows ADMIN in uppercase (toLowerCase)', () => { + const { req, res, next } = createMockReqRes('ADMIN') + excludeMantenimiento(req, res, next) + expect(next).toHaveBeenCalled() + expect(res.status).not.toHaveBeenCalled() + }) +}) + +describe('canOpenModule', () => { + const roles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento'] as const + const modules: AppModule[] = [ + 'parking', + 'maintenance', + 'groups', + 'blacklist', + 'cashier', + 'logbook', + ] + + // Expected results based on current implementation: + // - parking, blacklist, cashier, logbook: allButMaintenanceRole (all except mantenimiento) + // - maintenance: staffRoles (admin, recepcionista, group-admin, mantenimiento) + // - groups: staffRoles (admin, recepcionista, group-admin, mantenimiento) + const expected: Record> = { + admin: { + parking: true, + maintenance: true, + groups: true, + blacklist: true, + cashier: true, + logbook: true, + }, + recepcionista: { + parking: true, + maintenance: true, + groups: true, + blacklist: true, + cashier: true, + logbook: true, + }, + 'group-admin': { + parking: true, + maintenance: true, + groups: true, + blacklist: true, + cashier: true, + logbook: true, + }, + mantenimiento: { + parking: false, + maintenance: true, + groups: true, + blacklist: false, + cashier: false, + logbook: false, + }, + } + + it.each(roles)('role %s', (role) => { + for (const mod of modules) { + expect(canOpenModule(role, mod)).toBe(expected[role][mod]) + } + }) +}) + +describe('router wiring: excludeMantenimiento', () => { + const routersWithExcludeMantenimiento = [ + 'routes/blacklist/blacklist-routes.ts', + 'routes/cashier/cashier-routes.ts', + 'routes/checklist/checklist-routes.ts', + 'routes/conciliation/conciliation.routes.ts', + 'routes/departments/departments-routes.ts', + 'routes/logbook/logbook-routes.ts', + 'routes/parking/bookings.routes.ts', + 'routes/parking/parking.routes.ts', + 'routes/parking/stats.routes.ts', + ] + + it.each(routersWithExcludeMantenimiento)('%s uses excludeMantenimiento', (routerPath) => { + const fullPath = resolve(process.cwd(), routerPath) + const content = readFileSync(fullPath, 'utf-8') + expect(content).toContain('excludeMantenimiento') + expect(content).toMatch(/router\.use\(.*excludeMantenimiento/) + }) +}) From 843485ad9ba82609a22fb62564a700c08fff736f Mon Sep 17 00:00:00 2001 From: razyd Date: Fri, 9 Oct 2026 15:45:45 +0200 Subject: [PATCH 05/71] refactor(auth): replace the maintenance exclusion with an allowlist - excludeMantenimiento now uses allowedRoles = ['admin', 'recepcionista', 'group-admin'] instead of blockedRoles = ['mantenimiento'], so unknown roles are denied by default - module-access.ts: rename allButMaintenanceRole to frontDeskRoles with an explicit allowlist - role-access.test.ts: add 'auditor' role case (403 in middleware, false in all 6 modules) --- backend/middlewares/roleCheck.ts | 12 ++++++------ backend/services/auth/module-access.ts | 10 +++++----- backend/tests/auth/role-access.test.ts | 21 ++++++++++++++++++--- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/backend/middlewares/roleCheck.ts b/backend/middlewares/roleCheck.ts index 96230a2c..d7237864 100644 --- a/backend/middlewares/roleCheck.ts +++ b/backend/middlewares/roleCheck.ts @@ -159,24 +159,24 @@ export const canAccessMaintenance: RoleCheckMiddleware = (req, res, next) => { } /** - * Excluye al rol mantenimiento de acceder a módulos generales + * Verifica que el usuario tenga uno de los roles de mostrador * Uso: Para rutas de logbook, parking, blacklist, etc. - * El rol mantenimiento SOLO puede acceder a /api/maintenance + * Solo admin, recepcionista y group-admin pueden acceder; el resto (mantenimiento, etc.) no. */ export const excludeMantenimiento: RoleCheckMiddleware = (req, res, next) => { - const blockedRoles = ['mantenimiento'] + const allowedRoles = ['admin', 'recepcionista', 'group-admin'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) return } - if (blockedRoles.includes(req.user.role.toLowerCase())) { + if (allowedRoles.includes(req.user.role.toLowerCase())) { + next() + } else { res.status(403).json({ error: 'Acceso denegado. Tu rol solo tiene acceso al módulo de mantenimiento', }) - } else { - next() } } diff --git a/backend/services/auth/module-access.ts b/backend/services/auth/module-access.ts index 13fff2fa..70e63b81 100644 --- a/backend/services/auth/module-access.ts +++ b/backend/services/auth/module-access.ts @@ -7,14 +7,14 @@ export type AppModule = 'parking' | 'maintenance' | 'groups' | 'blacklist' | 'cashier' | 'logbook' -const allButMaintenanceRole = (role: string) => role !== 'mantenimiento' +const frontDeskRoles = (role: string) => ['admin', 'recepcionista', 'group-admin'].includes(role) const staffRoles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento'] const MODULE_ACCESS: Record boolean> = { - parking: allButMaintenanceRole, // excludeMantenimiento - blacklist: allButMaintenanceRole, // excludeMantenimiento - cashier: allButMaintenanceRole, // excludeMantenimiento - logbook: allButMaintenanceRole, // excludeMantenimiento + parking: frontDeskRoles, // excludeMantenimiento + blacklist: frontDeskRoles, // excludeMantenimiento + cashier: frontDeskRoles, // excludeMantenimiento + logbook: frontDeskRoles, // excludeMantenimiento maintenance: (role) => staffRoles.includes(role), // canAccessMaintenance groups: (role) => staffRoles.includes(role), // canViewGroups } diff --git a/backend/tests/auth/role-access.test.ts b/backend/tests/auth/role-access.test.ts index 7197f0b9..566ff062 100644 --- a/backend/tests/auth/role-access.test.ts +++ b/backend/tests/auth/role-access.test.ts @@ -64,10 +64,17 @@ describe('excludeMantenimiento', () => { expect(next).toHaveBeenCalled() expect(res.status).not.toHaveBeenCalled() }) + + it('blocks unknown role "auditor" with 403', () => { + const { req, res, next } = createMockReqRes('auditor') + excludeMantenimiento(req, res, next) + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(403) + }) }) describe('canOpenModule', () => { - const roles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento'] as const + const roles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento', 'auditor'] as const const modules: AppModule[] = [ 'parking', 'maintenance', @@ -77,8 +84,8 @@ describe('canOpenModule', () => { 'logbook', ] - // Expected results based on current implementation: - // - parking, blacklist, cashier, logbook: allButMaintenanceRole (all except mantenimiento) + // Expected results based on implementation: + // - parking, blacklist, cashier, logbook: frontDeskRoles (admin, recepcionista, group-admin) // - maintenance: staffRoles (admin, recepcionista, group-admin, mantenimiento) // - groups: staffRoles (admin, recepcionista, group-admin, mantenimiento) const expected: Record> = { @@ -114,6 +121,14 @@ describe('canOpenModule', () => { cashier: false, logbook: false, }, + auditor: { + parking: false, + maintenance: false, + groups: false, + blacklist: false, + cashier: false, + logbook: false, + }, } it.each(roles)('role %s', (role) => { From bb7180113b34c68d17f2a530b3e38fb0fdc9557e Mon Sep 17 00:00:00 2001 From: razyd Date: Fri, 9 Oct 2026 15:55:13 +0200 Subject: [PATCH 06/71] feat(i18n): translate BACKOFFICE error codes - Add 57 BACKOFFICE codes to TRANSLATED set in errorCodeMessage.ts (alphabetical order) - All codes have matching text in es/en errors.json and are sent with fixed text from backend - Exclude 5 BACKOFFICE codes without translation: SUPPLIER_INACTIVE_EXISTS, SUPPLIER_HAS_INVOICES, INACTIVATE_SUPPLIER_ERROR, ACTIVATE_SUPPLIER_ERROR, DELETE_SUPPLIER_ERROR - Add it.each test for all 57 codes (non-empty text in es and en) - Add test for 5 excluded codes (return undefined) --- .../tests/frontend/error-code-message.test.ts | 82 ++++++++++++++++++- frontend/app/lib/helpers/errorCodeMessage.ts | 61 +++++++++++++- 2 files changed, 141 insertions(+), 2 deletions(-) diff --git a/backend/tests/frontend/error-code-message.test.ts b/backend/tests/frontend/error-code-message.test.ts index 1ac38380..f369d0f4 100644 --- a/backend/tests/frontend/error-code-message.test.ts +++ b/backend/tests/frontend/error-code-message.test.ts @@ -21,8 +21,88 @@ describe('errorCodeMessage', () => { }) it('leaves other codes and missing codes to the backend text', () => { - expect(errorCodeMessage('AUTH_INVALID_CREDENTIALS', 'en')).toBeUndefined() expect(errorCodeMessage(undefined, 'es')).toBeUndefined() expect(errorCodeMessage(42, 'es')).toBeUndefined() }) + + describe('BACKOFFICE codes', () => { + const backofficeCodes = [ + 'BACKOFFICE_ASSET_NOT_FOUND', + 'BACKOFFICE_BATCH_PAYMENT_ERROR', + 'BACKOFFICE_BATCH_PAYMENT_PREVIEW_ERROR', + 'BACKOFFICE_CATEGORY_NOT_FOUND', + 'BACKOFFICE_COST_CENTER_DEPARTMENT_REQUIRED', + 'BACKOFFICE_CREATE_ASSET_ERROR', + 'BACKOFFICE_CREATE_CATEGORY_ERROR', + 'BACKOFFICE_CREATE_INVOICE_ERROR', + 'BACKOFFICE_CREATE_SUPPLIER_ERROR', + 'BACKOFFICE_CREATE_ZIP_ERROR', + 'BACKOFFICE_DEACTIVATE_SUPPLIER_ERROR', + 'BACKOFFICE_DELETE_ASSET_ERROR', + 'BACKOFFICE_DELETE_INVOICE_ERROR', + 'BACKOFFICE_DOWNLOAD_PDF_ERROR', + 'BACKOFFICE_FETCH_ASSETS_ERROR', + 'BACKOFFICE_FETCH_CATEGORIES_ERROR', + 'BACKOFFICE_FETCH_CATEGORY_ERROR', + 'BACKOFFICE_FETCH_HISTORY_ERROR', + 'BACKOFFICE_FETCH_INVOICE_ERROR', + 'BACKOFFICE_FETCH_INVOICES_ERROR', + 'BACKOFFICE_FETCH_MONTHLY_SUMMARY_ERROR', + 'BACKOFFICE_FETCH_STATS_ERROR', + 'BACKOFFICE_FETCH_SUPPLIER_ERROR', + 'BACKOFFICE_FETCH_SUPPLIERS_ERROR', + 'BACKOFFICE_FILE_TOO_LARGE', + 'BACKOFFICE_GET_PDF_URL_ERROR', + 'BACKOFFICE_INVALID_ASSET_TYPE', + 'BACKOFFICE_INVALID_IMAGE_TYPE', + 'BACKOFFICE_INVALID_MONTH', + 'BACKOFFICE_INVALID_STATUS', + 'BACKOFFICE_INVOICE_FIELDS_REQUIRED', + 'BACKOFFICE_INVOICE_IDS_REQUIRED', + 'BACKOFFICE_INVOICE_NOT_FOUND', + 'BACKOFFICE_INVOICE_NOT_VALIDATED', + 'BACKOFFICE_INVOICES_NOT_FOUND', + 'BACKOFFICE_MARK_PAID_ERROR', + 'BACKOFFICE_MAX_INVOICES_EXCEEDED', + 'BACKOFFICE_NAME_REQUIRED', + 'BACKOFFICE_NO_FILE_SENT', + 'BACKOFFICE_NO_IMAGE_SENT', + 'BACKOFFICE_PDF_ONLY', + 'BACKOFFICE_PDF_TYPE_REQUIRED', + 'BACKOFFICE_PAYMENT_DATE_REQUIRED', + 'BACKOFFICE_REJECT_INVOICE_ERROR', + 'BACKOFFICE_REJECTION_REASON_REQUIRED', + 'BACKOFFICE_REVERT_BATCH_ERROR', + 'BACKOFFICE_REVERT_BATCH_PREVIEW_ERROR', + 'BACKOFFICE_REVERT_PAYMENT_ERROR', + 'BACKOFFICE_REVERT_VALIDATION_ERROR', + 'BACKOFFICE_SET_DEFAULT_ERROR', + 'BACKOFFICE_SUPPLIER_EXISTS', + 'BACKOFFICE_SUPPLIER_NOT_FOUND', + 'BACKOFFICE_UPDATE_INVOICE_ERROR', + 'BACKOFFICE_UPDATE_SUPPLIER_ERROR', + 'BACKOFFICE_UPLOAD_PDF_ERROR', + 'BACKOFFICE_VALIDATE_INVOICE_ERROR', + 'BACKOFFICE_YEAR_MONTH_REQUIRED', + ] + + it.each(backofficeCodes)('%s returns non-empty text in es and en', (code) => { + const esText = errorCodeMessage(code, 'es') + const enText = errorCodeMessage(code, 'en') + expect(esText).toBeTruthy() + expect(typeof esText).toBe('string') + expect(esText!.length).toBeGreaterThan(0) + expect(enText).toBeTruthy() + expect(typeof enText).toBe('string') + expect(enText!.length).toBeGreaterThan(0) + }) + + it('excludes BACKOFFICE codes without translation', () => { + expect(errorCodeMessage('BACKOFFICE_SUPPLIER_INACTIVE_EXISTS', 'es')).toBeUndefined() + expect(errorCodeMessage('BACKOFFICE_SUPPLIER_HAS_INVOICES', 'es')).toBeUndefined() + expect(errorCodeMessage('BACKOFFICE_INACTIVATE_SUPPLIER_ERROR', 'es')).toBeUndefined() + expect(errorCodeMessage('BACKOFFICE_ACTIVATE_SUPPLIER_ERROR', 'es')).toBeUndefined() + expect(errorCodeMessage('BACKOFFICE_DELETE_SUPPLIER_ERROR', 'es')).toBeUndefined() + }) + }) }) diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index edbce236..a1e1d7cb 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -7,7 +7,66 @@ import es from '../../../messages/es/errors.json' import en from '../../../messages/en/errors.json' // Codes whose backend text is not meant for the user -const TRANSLATED = new Set(['STORAGE_NOT_CONFIGURED']) +const TRANSLATED = new Set([ + 'STORAGE_NOT_CONFIGURED', + 'BACKOFFICE_ASSET_NOT_FOUND', + 'BACKOFFICE_BATCH_PAYMENT_ERROR', + 'BACKOFFICE_BATCH_PAYMENT_PREVIEW_ERROR', + 'BACKOFFICE_CATEGORY_NOT_FOUND', + 'BACKOFFICE_COST_CENTER_DEPARTMENT_REQUIRED', + 'BACKOFFICE_CREATE_ASSET_ERROR', + 'BACKOFFICE_CREATE_CATEGORY_ERROR', + 'BACKOFFICE_CREATE_INVOICE_ERROR', + 'BACKOFFICE_CREATE_SUPPLIER_ERROR', + 'BACKOFFICE_CREATE_ZIP_ERROR', + 'BACKOFFICE_DEACTIVATE_SUPPLIER_ERROR', + 'BACKOFFICE_DELETE_ASSET_ERROR', + 'BACKOFFICE_DELETE_INVOICE_ERROR', + 'BACKOFFICE_DOWNLOAD_PDF_ERROR', + 'BACKOFFICE_FETCH_ASSETS_ERROR', + 'BACKOFFICE_FETCH_CATEGORIES_ERROR', + 'BACKOFFICE_FETCH_CATEGORY_ERROR', + 'BACKOFFICE_FETCH_HISTORY_ERROR', + 'BACKOFFICE_FETCH_INVOICE_ERROR', + 'BACKOFFICE_FETCH_INVOICES_ERROR', + 'BACKOFFICE_FETCH_MONTHLY_SUMMARY_ERROR', + 'BACKOFFICE_FETCH_STATS_ERROR', + 'BACKOFFICE_FETCH_SUPPLIER_ERROR', + 'BACKOFFICE_FETCH_SUPPLIERS_ERROR', + 'BACKOFFICE_FILE_TOO_LARGE', + 'BACKOFFICE_GET_PDF_URL_ERROR', + 'BACKOFFICE_INVALID_ASSET_TYPE', + 'BACKOFFICE_INVALID_IMAGE_TYPE', + 'BACKOFFICE_INVALID_MONTH', + 'BACKOFFICE_INVALID_STATUS', + 'BACKOFFICE_INVOICE_FIELDS_REQUIRED', + 'BACKOFFICE_INVOICE_IDS_REQUIRED', + 'BACKOFFICE_INVOICE_NOT_FOUND', + 'BACKOFFICE_INVOICE_NOT_VALIDATED', + 'BACKOFFICE_INVOICES_NOT_FOUND', + 'BACKOFFICE_MARK_PAID_ERROR', + 'BACKOFFICE_MAX_INVOICES_EXCEEDED', + 'BACKOFFICE_NAME_REQUIRED', + 'BACKOFFICE_NO_FILE_SENT', + 'BACKOFFICE_NO_IMAGE_SENT', + 'BACKOFFICE_PDF_ONLY', + 'BACKOFFICE_PDF_TYPE_REQUIRED', + 'BACKOFFICE_PAYMENT_DATE_REQUIRED', + 'BACKOFFICE_REJECT_INVOICE_ERROR', + 'BACKOFFICE_REJECTION_REASON_REQUIRED', + 'BACKOFFICE_REVERT_BATCH_ERROR', + 'BACKOFFICE_REVERT_BATCH_PREVIEW_ERROR', + 'BACKOFFICE_REVERT_PAYMENT_ERROR', + 'BACKOFFICE_REVERT_VALIDATION_ERROR', + 'BACKOFFICE_SET_DEFAULT_ERROR', + 'BACKOFFICE_SUPPLIER_EXISTS', + 'BACKOFFICE_SUPPLIER_NOT_FOUND', + 'BACKOFFICE_UPDATE_INVOICE_ERROR', + 'BACKOFFICE_UPDATE_SUPPLIER_ERROR', + 'BACKOFFICE_UPLOAD_PDF_ERROR', + 'BACKOFFICE_VALIDATE_INVOICE_ERROR', + 'BACKOFFICE_YEAR_MONTH_REQUIRED', +]) const byLocale: Record> = { es: es.codes, en: en.codes } From 5878e37b781594594463ace17bad75d76c2e6b1a Mon Sep 17 00:00:00 2001 From: razyd Date: Fri, 9 Oct 2026 15:59:10 +0200 Subject: [PATCH 07/71] refactor(auth): type the remaining any - user-repository.ts: replace 6 'catch (error: any)' with 'catch (error)' and type assertions for .code and .message access - user-repository.ts: replace 'as any' with explicit type 'Error & { code?: string }' for the duplicate entry error - user-controllers.ts: replace 'catch (error: any)' with 'catch (error)' and type assertion for .message access - Pattern used: (error as { code?: string }).code and (error as Error).message --- backend/controllers/auth/user-controllers.ts | 6 ++-- backend/repositories/auth/user-repository.ts | 31 ++++++++++++-------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/backend/controllers/auth/user-controllers.ts b/backend/controllers/auth/user-controllers.ts index d790ab28..4ee441b6 100644 --- a/backend/controllers/auth/user-controllers.ts +++ b/backend/controllers/auth/user-controllers.ts @@ -143,10 +143,10 @@ export const resetUserPassword = async (req: Request, res: Response): Promise( @@ -397,19 +399,22 @@ export class UserRepository { // Devolver el usuario completo actualizado const updatedUser = await this.getById(id) return updatedUser - } catch (error: any) { + } catch (error) { await dbConnection.rollback() logger.error({ err: error }, 'Error en update') + const message = (error as Error).message + const code = (error as { code?: string }).code + if ( - error.message.includes('no encontrado') || - error.message.includes('no válido') || - error.message.includes('No hay campos') + message.includes('no encontrado') || + message.includes('no válido') || + message.includes('No hay campos') ) { throw error } - if (error.code === 'ER_DUP_ENTRY') { + if (code === 'ER_DUP_ENTRY') { throw new Error('El nombre de usuario o email ya existe', { cause: error }) } @@ -551,7 +556,7 @@ export class UserRepository { } return updatedUser - } catch (error: any) { + } catch (error) { await dbConnection.rollback() logger.error({ err: error }, 'Error en updateProfile') throw error @@ -613,7 +618,7 @@ export class UserRepository { logger.info({ event: 'password_changed', userId }, '[AUTH] password changed') return true - } catch (error: any) { + } catch (error) { await dbConnection.rollback() logger.error({ err: error }, 'Error en updatePassword') throw error @@ -646,7 +651,7 @@ export class UserRepository { logger.info({ event: 'password_reset_by_admin', userId }, '[AUTH] password reset by admin') return true - } catch (error: any) { + } catch (error) { logger.error({ err: error }, 'Error en resetPassword') throw error } From 40af16a6b159a3266d7bc1b18df8f369cf4154ea Mon Sep 17 00:00:00 2001 From: razyd Date: Fri, 9 Oct 2026 16:01:21 +0200 Subject: [PATCH 08/71] refactor(backend): type the remaining any in cron, config and scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - db-config.ts: ssl?: any → ssl?: object - cron-service.ts: results?: any → results?: unknown - checklist-report.ts: replace 9 any with proper types: - Import RowDataPacket from mysql2 - Define interfaces RunRow, StepRow, CommentRow extending RowDataPacket - Use db.query instead of (db as any).query - Replace (d: any) with (d: unknown) in fmt function - Remove all (s: any) and (c: any) from callbacks --- backend/config/db-config.ts | 2 +- backend/scripts/checklist-report.ts | 42 ++++++++++++++++++++------- backend/services/cron/cron-service.ts | 2 +- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/backend/config/db-config.ts b/backend/config/db-config.ts index 51e4fd91..71ca80bb 100644 --- a/backend/config/db-config.ts +++ b/backend/config/db-config.ts @@ -26,7 +26,7 @@ interface DBConfig { user: string password: string | undefined database: string - ssl?: any + ssl?: object } // ======================================== diff --git a/backend/scripts/checklist-report.ts b/backend/scripts/checklist-report.ts index a2a2bc0f..871927e9 100644 --- a/backend/scripts/checklist-report.ts +++ b/backend/scripts/checklist-report.ts @@ -16,16 +16,38 @@ import db from '../config/db.js' import { getTodayMadrid } from '../config/date-utils.js' import { printable } from '../services/text/control-chars.js' +import type { RowDataPacket } from 'mysql2' const CHECKLIST_ID = process.argv[2] ?? 'cl-night-audit' const HOTEL_DATE = process.argv[3] ?? getTodayMadrid() -const fmt = (d: any) => +const fmt = (d: unknown) => d instanceof Date ? d.toISOString().slice(0, 19).replace('T', ' ') : String(d).slice(0, 19) +interface RunRow extends RowDataPacket { + id: number + checklist_id: string + hotel_date: string | Date + started_at: string | Date +} + +interface StepRow extends RowDataPacket { + step_id: string + done: number + done_at: string | Date | null + done_by: string | null +} + +interface CommentRow extends RowDataPacket { + step_id: string + comentario: string + created_at: string | Date + autor: string | null +} + async function main() { // 1. Encontrar el run - const [runs] = await (db as any).query( + const [runs] = await db.query( `SELECT id, checklist_id, hotel_date, started_at FROM checklist_runs WHERE checklist_id = ? AND DATE(hotel_date) = ? AND reset_at IS NULL @@ -43,7 +65,7 @@ async function main() { console.log(`\n=== ${run.checklist_id} | ${HOTEL_DATE} (run_id=${run.id}) ===\n`) // 2. Tareas completadas - const [steps] = await (db as any).query( + const [steps] = await db.query( `SELECT css.step_id, css.done, @@ -56,12 +78,12 @@ async function main() { [run.id] ) - const done = steps.filter((s: any) => s.done === 1) - const pending = steps.filter((s: any) => s.done !== 1) + const done = steps.filter((s) => s.done === 1) + const pending = steps.filter((s) => s.done !== 1) console.log(`Completadas (${done.length}):`) if (done.length) { - done.forEach((s: any) => + done.forEach((s) => console.log(` ✓ ${s.step_id.padEnd(12)} ${fmt(s.done_at)} por: ${s.done_by ?? '—'}`) ) } else { @@ -70,13 +92,13 @@ async function main() { console.log(`\nPendientes (${pending.length}):`) if (pending.length) { - pending.forEach((s: any) => console.log(` · ${s.step_id}`)) + pending.forEach((s) => console.log(` · ${s.step_id}`)) } else { console.log(' (ninguna)') } // 3. Comentarios - const [comments] = await (db as any).query( + const [comments] = await db.query( `SELECT csc.step_id, csc.body AS comentario, @@ -91,7 +113,7 @@ async function main() { console.log(`\nComentarios (${comments.length}):`) if (comments.length) { - comments.forEach((c: any) => + comments.forEach((c) => console.log( ` [${fmt(c.created_at)}] ${c.step_id} | ${printable(c.autor ?? '—')}: ${printable(c.comentario)}` ) @@ -104,6 +126,6 @@ async function main() { main() .then(() => process.exit(0)) .catch((e) => { - console.error(e.message) + console.error((e as Error).message) process.exit(1) }) diff --git a/backend/services/cron/cron-service.ts b/backend/services/cron/cron-service.ts index c4023c18..fd1f19a9 100644 --- a/backend/services/cron/cron-service.ts +++ b/backend/services/cron/cron-service.ts @@ -157,7 +157,7 @@ export class CronService { */ static async runNotificationsNow(): Promise<{ success: boolean - results?: any + results?: unknown error?: string duration?: number }> { From 5102cc9b63ec0aa01ab678f4db66d7cb708e6e28 Mon Sep 17 00:00:00 2001 From: razyd Date: Fri, 9 Oct 2026 16:04:58 +0200 Subject: [PATCH 09/71] test: type the remaining any - demo-restriction.test.ts: replace 9 'as any' with 'as unknown as Request[...]' or 'as unknown' for socket, user, headers, and mock access - user-repository-login.test.ts: import RowDataPacket, replace db.query with db.query, replace (user as any) with (user as Record) - checklist.test.ts: import RowDataPacket, replace 7 'const [rows]: any' with 'const [rows] = await db.query', remove (r: any) from callback - employee-requests-repository.test.ts: import RowDataPacket, replace 'const [users]: any' with 'const [users] = await db.query' --- backend/tests/auth/demo-restriction.test.ts | 30 ++++++++++++------- .../tests/auth/user-repository-login.test.ts | 5 ++-- backend/tests/checklist/checklist.test.ts | 17 ++++++----- .../employee-requests-repository.test.ts | 3 +- 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/backend/tests/auth/demo-restriction.test.ts b/backend/tests/auth/demo-restriction.test.ts index f440ea6d..a65a259f 100644 --- a/backend/tests/auth/demo-restriction.test.ts +++ b/backend/tests/auth/demo-restriction.test.ts @@ -25,7 +25,7 @@ function buildReq(overrides: Partial = {}): Request { headers: {}, body: {}, ip: '127.0.0.1', - socket: { remoteAddress: '127.0.0.1' } as any, + socket: { remoteAddress: '127.0.0.1' } as unknown, get: () => undefined, user: undefined, ...overrides, @@ -47,8 +47,13 @@ beforeEach(() => { // The public demo account (users.is_demo) is a real admin: only what is // declared here or on the route is blocked -const DEMO_USER = { id: 'd1', username: 'demo', role: 'admin', isDemo: true } as any -const OWNER = { id: 'a1', username: 'admin', role: 'admin' } as any +const DEMO_USER = { + id: 'd1', + username: 'demo', + role: 'admin', + isDemo: true, +} as unknown as Request['user'] +const OWNER = { id: 'a1', username: 'admin', role: 'admin' } as unknown as Request['user'] describe('demoRestriction — pass-through cases', () => { it('lets through requests without an authenticated user (handled upstream)', () => { @@ -61,7 +66,7 @@ describe('demoRestriction — pass-through cases', () => { it('lets through any non-demo role (admin, recepcionista, etc.)', () => { const req = buildReq({ - user: { id: 'u1', role: 'admin' } as any, + user: { id: 'u1', role: 'admin' } as unknown as Request['user'], method: 'DELETE', originalUrl: '/api/parking/bookings/123', }) @@ -85,8 +90,9 @@ describe('demoRestriction — blocked-attempt log', () => { }, }) denyDemo(req, buildRes(), next) - const logged = (DemoActivityRepository.logActivity as any).mock.calls[0][0] - .body_preview as string + const logged = ( + DemoActivityRepository.logActivity as unknown as { mock: { calls: unknown[][] } } + ).mock.calls[0][0].body_preview as string expect(logged).not.toMatch(/secret/) expect(JSON.parse(logged)).toEqual({ currentPassword: '[REDACTED]', @@ -105,7 +111,7 @@ describe('demoRestriction — demo account (is_demo)', () => { user: DEMO_USER, method: 'POST', originalUrl: '/api/maintenance/1/images', - headers: { 'content-type': contentType } as any, + headers: { 'content-type': contentType } as unknown as Request['headers'], }) const res = buildRes() demoRestriction(req, res, next) @@ -120,7 +126,7 @@ describe('demoRestriction — demo account (is_demo)', () => { user: DEMO_USER, method: 'DELETE', originalUrl: '/api/parking/bookings/ABC', - headers: { 'content-type': 'application/json' } as any, + headers: { 'content-type': 'application/json' } as unknown as Request['headers'], }) const res = buildRes() demoRestriction(req, res, next) @@ -131,7 +137,9 @@ describe('demoRestriction — demo account (is_demo)', () => { const req = buildReq({ user: OWNER, method: 'POST', - headers: { 'content-type': 'multipart/form-data; boundary=x' } as any, + headers: { + 'content-type': 'multipart/form-data; boundary=x', + } as unknown as Request['headers'], }) const res = buildRes() demoRestriction(req, res, next) @@ -190,7 +198,9 @@ describe('blocking a request without a JSON body', () => { user: DEMO_USER, method: 'POST', body: undefined, - headers: { 'content-type': 'multipart/form-data; boundary=x' } as any, + headers: { + 'content-type': 'multipart/form-data; boundary=x', + } as unknown as Request['headers'], }) const res = buildRes() demoRestriction(req, res, next) diff --git a/backend/tests/auth/user-repository-login.test.ts b/backend/tests/auth/user-repository-login.test.ts index a3c14ce1..4c14dd2d 100644 --- a/backend/tests/auth/user-repository-login.test.ts +++ b/backend/tests/auth/user-repository-login.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import bcrypt from 'bcrypt' import db from '../../config/db.js' import { UserRepository } from '../../repositories/auth/user-repository.js' +import type { RowDataPacket } from 'mysql2' const TEST_USERNAME = 'authtest_user_h115' const TEST_PASSWORD = 'CorrectHorseBattery42!' @@ -35,7 +36,7 @@ beforeAll(async () => { ) // Usuario inactivo: reusa el demo si existe y is_active=0 (caso real tras H1-12). - const [rows] = await db.query( + const [rows] = await db.query( `SELECT id FROM users WHERE username = 'demo' AND is_active = 0 LIMIT 1` ) inactiveUserId = rows[0]?.id ?? null @@ -57,7 +58,7 @@ describe('UserRepository.login — integration', () => { expect(user.username).toBe(TEST_USERNAME) expect(user.email).toBe(TEST_EMAIL) expect(user.role).toBe('admin') - expect((user as any).password).toBeUndefined() + expect((user as Record).password).toBeUndefined() }) it('rejects with "Credenciales inválidas" when password is wrong', async () => { diff --git a/backend/tests/checklist/checklist.test.ts b/backend/tests/checklist/checklist.test.ts index 563a3a78..cca48613 100644 --- a/backend/tests/checklist/checklist.test.ts +++ b/backend/tests/checklist/checklist.test.ts @@ -9,6 +9,7 @@ import * as repo from '../../repositories/checklist/checklist-repository.js' import * as service from '../../services/checklist/checklist.service.js' import { getTodayMadrid } from '../../config/date-utils.js' import { createCommentSchema } from '../../validations/checklist/checklist-schemas.js' +import type { RowDataPacket } from 'mysql2' const TEST_CHECKLIST_ID = 'vitest-integration-checklist' const TEST_STEP_ID = 'vitest-step-1' @@ -55,7 +56,7 @@ describe('closeStaleRuns', () => { await repo.closeStaleRuns() - const [rows]: any = await db.query( + const [rows] = await db.query( `SELECT reset_at FROM checklist_runs WHERE checklist_id = ? AND hotel_date = ?`, [TEST_CHECKLIST_ID, today] ) @@ -72,7 +73,7 @@ describe('closeStaleRuns', () => { const affected = await repo.closeStaleRuns() expect(affected).toBeGreaterThanOrEqual(1) - const [rows]: any = await db.query( + const [rows] = await db.query( `SELECT reset_at, reset_reason, reset_by_user_id FROM checklist_runs WHERE checklist_id = ? AND hotel_date = ?`, [TEST_CHECKLIST_ID, STALE_DATE] @@ -123,7 +124,7 @@ describe('getRunState', () => { await service.getRunState(TEST_CHECKLIST_ID) - const [rows]: any = await db.query( + const [rows] = await db.query( `SELECT reset_at FROM checklist_runs WHERE checklist_id = ? AND hotel_date = ?`, [TEST_CHECKLIST_ID, STALE_DATE] ) @@ -141,7 +142,7 @@ describe('toggleStep', () => { await service.toggleStep(TEST_CHECKLIST_ID, TEST_STEP_ID, true, TEST_USER_ID) - const [rows]: any = await db.query( + const [rows] = await db.query( `SELECT css.done, css.done_by_user_id, css.done_at FROM checklist_step_state css JOIN checklist_runs cr ON cr.id = css.run_id @@ -160,7 +161,7 @@ describe('toggleStep', () => { await service.toggleStep(TEST_CHECKLIST_ID, TEST_STEP_ID, true, TEST_USER_ID) await service.toggleStep(TEST_CHECKLIST_ID, TEST_STEP_ID, false, TEST_USER_ID) - const [rows]: any = await db.query( + const [rows] = await db.query( `SELECT css.done, css.done_by_user_id, css.done_at FROM checklist_step_state css JOIN checklist_runs cr ON cr.id = css.run_id @@ -177,7 +178,7 @@ describe('toggleStep', () => { await service.toggleStep(TEST_CHECKLIST_ID, TEST_STEP_ID, true, TEST_USER_ID) - const [rows]: any = await db.query( + const [rows] = await db.query( `SELECT cel.action FROM checklist_event_log cel JOIN checklist_runs cr ON cr.id = cel.run_id WHERE cr.checklist_id = ? AND cel.step_id = ? @@ -193,14 +194,14 @@ describe('toggleStep', () => { await service.toggleStep(TEST_CHECKLIST_ID, TEST_STEP_ID, true, TEST_USER_ID) await service.toggleStep(TEST_CHECKLIST_ID, TEST_STEP_ID, false, TEST_USER_ID) - const [rows]: any = await db.query( + const [rows] = await db.query( `SELECT cel.action FROM checklist_event_log cel JOIN checklist_runs cr ON cr.id = cel.run_id WHERE cr.checklist_id = ? AND cel.step_id = ? ORDER BY cel.at ASC`, [TEST_CHECKLIST_ID, TEST_STEP_ID] ) - const actions = rows.map((r: any) => r.action) + const actions = rows.map((r) => r.action) expect(actions).toEqual(['check', 'uncheck']) }) }) diff --git a/backend/tests/scheduling/employee-requests-repository.test.ts b/backend/tests/scheduling/employee-requests-repository.test.ts index 23aa96c2..348a2bb4 100644 --- a/backend/tests/scheduling/employee-requests-repository.test.ts +++ b/backend/tests/scheduling/employee-requests-repository.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import db from '../../config/db.js' +import type { RowDataPacket } from 'mysql2' // Only run integration tests when DB is available let dbAvailable = false @@ -43,7 +44,7 @@ describe('scheduling_employee_requests — integration', () => { } // Insert a test row and verify findByMonth retrieves it - const [users]: any = await db.query(`SELECT id FROM users LIMIT 1`) + const [users] = await db.query(`SELECT id FROM users LIMIT 1`) if (!users.length) { console.warn('Skipping: no users found in local DB') return From c569a07fce060352c17ca98a88dd2dc0cf4b0092 Mon Sep 17 00:00:00 2001 From: razyd Date: Fri, 9 Oct 2026 16:06:11 +0200 Subject: [PATCH 10/71] build(lint): make no-explicit-any an error - Change '@typescript-eslint/no-explicit-any' from 'warn' to 'error' in eslint.config.js - Remove outdated comment about 341 existing uses (now 0) - All code is now free of explicit any types --- backend/eslint.config.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/eslint.config.js b/backend/eslint.config.js index a83d5191..2e79ce2d 100644 --- a/backend/eslint.config.js +++ b/backend/eslint.config.js @@ -32,8 +32,7 @@ export default defineConfig( globals: globals.node, }, rules: { - // 341 existing uses: warn until they are typed, so new code is still flagged - '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-explicit-any': 'error', 'prefer-const': ['error', { destructuring: 'all' }], '@typescript-eslint/no-unused-vars': [ 'error', From dfe64f99664ce4ea0f96455ea858badfce9ef76b Mon Sep 17 00:00:00 2001 From: razyd Date: Fri, 9 Oct 2026 22:03:13 +0200 Subject: [PATCH 11/71] feat(i18n): translate ACTIVITY error codes --- .../tests/frontend/error-code-message.test.ts | 21 +++++++++++++++++++ frontend/app/lib/helpers/errorCodeMessage.ts | 5 ++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/backend/tests/frontend/error-code-message.test.ts b/backend/tests/frontend/error-code-message.test.ts index f369d0f4..4589f4a2 100644 --- a/backend/tests/frontend/error-code-message.test.ts +++ b/backend/tests/frontend/error-code-message.test.ts @@ -14,6 +14,27 @@ describe('errorCodeMessage', () => { ) }) + it('gives activity messages in Spanish and in English', () => { + expect(errorCodeMessage('ACTIVITY_INVALID_SOURCE', 'es')).toMatch( + /^Fuente inválida/ + ) + expect(errorCodeMessage('ACTIVITY_INVALID_SOURCE', 'en')).toMatch( + /^Invalid source/ + ) + expect(errorCodeMessage('ACTIVITY_INVALID_DATE_FORMAT', 'es')).toMatch( + /^Formato de fecha inválido/ + ) + expect(errorCodeMessage('ACTIVITY_INVALID_DATE_FORMAT', 'en')).toMatch( + /^Invalid date format/ + ) + expect(errorCodeMessage('ACTIVITY_FETCH_ERROR', 'es')).toMatch( + /^Error al obtener actividad/ + ) + expect(errorCodeMessage('ACTIVITY_FETCH_ERROR', 'en')).toMatch( + /^Error fetching recent activity/ + ) + }) + it('falls back to Spanish for a language without messages', () => { expect(errorCodeMessage('STORAGE_NOT_CONFIGURED', 'fr')).toBe( errorCodeMessage('STORAGE_NOT_CONFIGURED', 'es') diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index a1e1d7cb..f3c74ee9 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -8,7 +8,9 @@ import en from '../../../messages/en/errors.json' // Codes whose backend text is not meant for the user const TRANSLATED = new Set([ - 'STORAGE_NOT_CONFIGURED', + 'ACTIVITY_FETCH_ERROR', + 'ACTIVITY_INVALID_DATE_FORMAT', + 'ACTIVITY_INVALID_SOURCE', 'BACKOFFICE_ASSET_NOT_FOUND', 'BACKOFFICE_BATCH_PAYMENT_ERROR', 'BACKOFFICE_BATCH_PAYMENT_PREVIEW_ERROR', @@ -66,6 +68,7 @@ const TRANSLATED = new Set([ 'BACKOFFICE_UPLOAD_PDF_ERROR', 'BACKOFFICE_VALIDATE_INVOICE_ERROR', 'BACKOFFICE_YEAR_MONTH_REQUIRED', + 'STORAGE_NOT_CONFIGURED', ]) const byLocale: Record> = { es: es.codes, en: en.codes } From 2c8e6aeb0d6dfbdbb8a27bc6811113f18a2b4b82 Mon Sep 17 00:00:00 2001 From: razyd Date: Fri, 9 Oct 2026 22:04:06 +0200 Subject: [PATCH 12/71] feat(i18n): translate DEPARTMENTS error codes --- .../tests/frontend/error-code-message.test.ts | 57 +++++++++++++++++++ frontend/app/lib/helpers/errorCodeMessage.ts | 9 +++ 2 files changed, 66 insertions(+) diff --git a/backend/tests/frontend/error-code-message.test.ts b/backend/tests/frontend/error-code-message.test.ts index 4589f4a2..56c9862d 100644 --- a/backend/tests/frontend/error-code-message.test.ts +++ b/backend/tests/frontend/error-code-message.test.ts @@ -35,6 +35,63 @@ describe('errorCodeMessage', () => { ) }) + it('gives departments messages in Spanish and in English', () => { + expect(errorCodeMessage('DEPARTMENTS_NAME_REQUIRED', 'es')).toMatch( + /^El nombre del departamento es requerido/ + ) + expect(errorCodeMessage('DEPARTMENTS_NAME_REQUIRED', 'en')).toMatch( + /^Department name is required/ + ) + expect(errorCodeMessage('DEPARTMENTS_DUPLICATE', 'es')).toMatch( + /^Ya existe un departamento/ + ) + expect(errorCodeMessage('DEPARTMENTS_DUPLICATE', 'en')).toMatch( + /^A department with that name already exists/ + ) + expect(errorCodeMessage('DEPARTMENTS_HAS_RECORDS', 'es')).toMatch( + /^No se puede eliminar un departamento/ + ) + expect(errorCodeMessage('DEPARTMENTS_HAS_RECORDS', 'en')).toMatch( + /^Cannot delete department/ + ) + expect(errorCodeMessage('DEPARTMENTS_NOT_FOUND', 'es')).toMatch( + /^Departamento no encontrado/ + ) + expect(errorCodeMessage('DEPARTMENTS_NOT_FOUND', 'en')).toMatch( + /^Department not found/ + ) + expect(errorCodeMessage('DEPARTMENTS_CREATE_ERROR', 'es')).toMatch( + /^Error al crear departamento/ + ) + expect(errorCodeMessage('DEPARTMENTS_CREATE_ERROR', 'en')).toMatch( + /^Error creating department/ + ) + expect(errorCodeMessage('DEPARTMENTS_FETCH_ERROR', 'es')).toMatch( + /^Error al recuperar departamentos/ + ) + expect(errorCodeMessage('DEPARTMENTS_FETCH_ERROR', 'en')).toMatch( + /^Error fetching departments/ + ) + expect(errorCodeMessage('DEPARTMENTS_FETCH_ONE_ERROR', 'es')).toMatch( + /^Error al obtener departamento/ + ) + expect(errorCodeMessage('DEPARTMENTS_FETCH_ONE_ERROR', 'en')).toMatch( + /^Error fetching department/ + ) + expect(errorCodeMessage('DEPARTMENTS_UPDATE_ERROR', 'es')).toMatch( + /^Error al actualizar departamento/ + ) + expect(errorCodeMessage('DEPARTMENTS_UPDATE_ERROR', 'en')).toMatch( + /^Error updating department/ + ) + expect(errorCodeMessage('DEPARTMENTS_DELETE_ERROR', 'es')).toMatch( + /^Error al eliminar departamento/ + ) + expect(errorCodeMessage('DEPARTMENTS_DELETE_ERROR', 'en')).toMatch( + /^Error deleting department/ + ) + }) + it('falls back to Spanish for a language without messages', () => { expect(errorCodeMessage('STORAGE_NOT_CONFIGURED', 'fr')).toBe( errorCodeMessage('STORAGE_NOT_CONFIGURED', 'es') diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index f3c74ee9..6869677b 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -68,6 +68,15 @@ const TRANSLATED = new Set([ 'BACKOFFICE_UPLOAD_PDF_ERROR', 'BACKOFFICE_VALIDATE_INVOICE_ERROR', 'BACKOFFICE_YEAR_MONTH_REQUIRED', + 'DEPARTMENTS_CREATE_ERROR', + 'DEPARTMENTS_DELETE_ERROR', + 'DEPARTMENTS_DUPLICATE', + 'DEPARTMENTS_FETCH_ERROR', + 'DEPARTMENTS_FETCH_ONE_ERROR', + 'DEPARTMENTS_HAS_RECORDS', + 'DEPARTMENTS_NAME_REQUIRED', + 'DEPARTMENTS_NOT_FOUND', + 'DEPARTMENTS_UPDATE_ERROR', 'STORAGE_NOT_CONFIGURED', ]) From efd85398f441441158aac4bfedc1484d66bb86ec Mon Sep 17 00:00:00 2001 From: razyd Date: Sat, 10 Oct 2026 15:47:29 +0200 Subject: [PATCH 13/71] feat(i18n): translate NOTIFICATIONS error codes --- .../tests/frontend/error-code-message.test.ts | 40 +++++++++++++++++++ frontend/app/lib/helpers/errorCodeMessage.ts | 16 ++++++++ 2 files changed, 56 insertions(+) diff --git a/backend/tests/frontend/error-code-message.test.ts b/backend/tests/frontend/error-code-message.test.ts index 56c9862d..8dbd0a40 100644 --- a/backend/tests/frontend/error-code-message.test.ts +++ b/backend/tests/frontend/error-code-message.test.ts @@ -183,4 +183,44 @@ describe('errorCodeMessage', () => { expect(errorCodeMessage('BACKOFFICE_DELETE_SUPPLIER_ERROR', 'es')).toBeUndefined() }) }) + + describe('NOTIFICATIONS codes', () => { + const notificationsCodes = [ + 'NOTIFICATIONS_CREATE_ERROR', + 'NOTIFICATIONS_DELETE_ERROR', + 'NOTIFICATIONS_FETCH_COUNT_ERROR', + 'NOTIFICATIONS_FETCH_ERROR', + 'NOTIFICATIONS_FETCH_GROUP_ERROR', + 'NOTIFICATIONS_FETCH_UNREAD_ERROR', + 'NOTIFICATIONS_INVALID_GROUP_ID', + 'NOTIFICATIONS_INVALID_ID', + 'NOTIFICATIONS_INVALID_MODULE', + 'NOTIFICATIONS_INVALID_SCHEDULED_DATE', + 'NOTIFICATIONS_MARK_ALL_ERROR', + 'NOTIFICATIONS_MARK_ERROR', + 'NOTIFICATIONS_MARK_READ_ERROR', + 'NOTIFICATIONS_MISSING_REQUIRED_FIELDS', + 'NOTIFICATIONS_NOT_FOUND', + 'NOTIFICATIONS_PROCESS_ERROR', + ] + + it.each(notificationsCodes)('%s returns non-empty text in es and en', (code) => { + const esText = errorCodeMessage(code, 'es') + const enText = errorCodeMessage(code, 'en') + expect(esText).toBeTruthy() + expect(typeof esText).toBe('string') + expect(esText!.length).toBeGreaterThan(0) + expect(enText).toBeTruthy() + expect(typeof enText).toBe('string') + expect(enText!.length).toBeGreaterThan(0) + }) + + it('excludes codes without translation', () => { + expect(errorCodeMessage('AUTH_DEMO_UNAVAILABLE', 'es')).toBeUndefined() + expect(errorCodeMessage('AUTH_DEMO_UNAVAILABLE', 'en')).toBeUndefined() + // Success messages are in "success" section, not "codes" + expect(errorCodeMessage('NOTIFICATIONS_PROCESSED', 'es')).toBeUndefined() + expect(errorCodeMessage('NOTIFICATIONS_CREATED', 'es')).toBeUndefined() + }) + }) }) diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index 6869677b..3cdf63ff 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -77,6 +77,22 @@ const TRANSLATED = new Set([ 'DEPARTMENTS_NAME_REQUIRED', 'DEPARTMENTS_NOT_FOUND', 'DEPARTMENTS_UPDATE_ERROR', + 'NOTIFICATIONS_CREATE_ERROR', + 'NOTIFICATIONS_DELETE_ERROR', + 'NOTIFICATIONS_FETCH_COUNT_ERROR', + 'NOTIFICATIONS_FETCH_ERROR', + 'NOTIFICATIONS_FETCH_GROUP_ERROR', + 'NOTIFICATIONS_FETCH_UNREAD_ERROR', + 'NOTIFICATIONS_INVALID_GROUP_ID', + 'NOTIFICATIONS_INVALID_ID', + 'NOTIFICATIONS_INVALID_MODULE', + 'NOTIFICATIONS_INVALID_SCHEDULED_DATE', + 'NOTIFICATIONS_MARK_ALL_ERROR', + 'NOTIFICATIONS_MARK_ERROR', + 'NOTIFICATIONS_MARK_READ_ERROR', + 'NOTIFICATIONS_MISSING_REQUIRED_FIELDS', + 'NOTIFICATIONS_NOT_FOUND', + 'NOTIFICATIONS_PROCESS_ERROR', 'STORAGE_NOT_CONFIGURED', ]) From db40fb8efa8209e5f67145f35db2f9791c01bc0e Mon Sep 17 00:00:00 2001 From: razyd Date: Sat, 10 Oct 2026 15:53:07 +0200 Subject: [PATCH 14/71] feat(i18n): translate BLACKLIST error codes --- .../tests/frontend/error-code-message.test.ts | 35 +++++++++++++++++++ frontend/app/lib/helpers/errorCodeMessage.ts | 16 +++++++++ 2 files changed, 51 insertions(+) diff --git a/backend/tests/frontend/error-code-message.test.ts b/backend/tests/frontend/error-code-message.test.ts index 8dbd0a40..7e0ef5f1 100644 --- a/backend/tests/frontend/error-code-message.test.ts +++ b/backend/tests/frontend/error-code-message.test.ts @@ -221,6 +221,41 @@ describe('errorCodeMessage', () => { // Success messages are in "success" section, not "codes" expect(errorCodeMessage('NOTIFICATIONS_PROCESSED', 'es')).toBeUndefined() expect(errorCodeMessage('NOTIFICATIONS_CREATED', 'es')).toBeUndefined() + expect(errorCodeMessage('BLACKLIST_RECORD_CREATED', 'es')).toBeUndefined() + // Orphan code (in ES/EN but not in ERROR_CODES) + expect(errorCodeMessage('BLACKLIST_PUBLIC_ID_MISSING', 'es')).toBeUndefined() + }) + }) + + describe('BLACKLIST codes', () => { + const blacklistCodes = [ + 'BLACKLIST_CREATE_RECORD_ERROR', + 'BLACKLIST_DELETE_IMAGE_ERROR', + 'BLACKLIST_DELETE_RECORD_ERROR', + 'BLACKLIST_DOCUMENT_EXISTS', + 'BLACKLIST_FETCH_RECORD_ERROR', + 'BLACKLIST_FETCH_RECORDS_ERROR', + 'BLACKLIST_FETCH_STATS_ERROR', + 'BLACKLIST_FILE_TOO_LARGE', + 'BLACKLIST_IMAGE_NOT_FOUND', + 'BLACKLIST_INVALID_FILE_TYPE', + 'BLACKLIST_INVALID_SEARCH_PARAMS', + 'BLACKLIST_NO_IMAGE_SENT', + 'BLACKLIST_RECORD_NOT_FOUND', + 'BLACKLIST_RESTORE_RECORD_ERROR', + 'BLACKLIST_UPDATE_RECORD_ERROR', + 'BLACKLIST_UPLOAD_IMAGE_ERROR', + ] + + it.each(blacklistCodes)('%s returns non-empty text in es and en', (code) => { + const esText = errorCodeMessage(code, 'es') + const enText = errorCodeMessage(code, 'en') + expect(esText).toBeTruthy() + expect(typeof esText).toBe('string') + expect(esText!.length).toBeGreaterThan(0) + expect(enText).toBeTruthy() + expect(typeof enText).toBe('string') + expect(enText!.length).toBeGreaterThan(0) }) }) }) diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index 3cdf63ff..21d093ae 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -77,6 +77,22 @@ const TRANSLATED = new Set([ 'DEPARTMENTS_NAME_REQUIRED', 'DEPARTMENTS_NOT_FOUND', 'DEPARTMENTS_UPDATE_ERROR', + 'BLACKLIST_CREATE_RECORD_ERROR', + 'BLACKLIST_DELETE_IMAGE_ERROR', + 'BLACKLIST_DELETE_RECORD_ERROR', + 'BLACKLIST_DOCUMENT_EXISTS', + 'BLACKLIST_FETCH_RECORD_ERROR', + 'BLACKLIST_FETCH_RECORDS_ERROR', + 'BLACKLIST_FETCH_STATS_ERROR', + 'BLACKLIST_FILE_TOO_LARGE', + 'BLACKLIST_IMAGE_NOT_FOUND', + 'BLACKLIST_INVALID_FILE_TYPE', + 'BLACKLIST_INVALID_SEARCH_PARAMS', + 'BLACKLIST_NO_IMAGE_SENT', + 'BLACKLIST_RECORD_NOT_FOUND', + 'BLACKLIST_RESTORE_RECORD_ERROR', + 'BLACKLIST_UPDATE_RECORD_ERROR', + 'BLACKLIST_UPLOAD_IMAGE_ERROR', 'NOTIFICATIONS_CREATE_ERROR', 'NOTIFICATIONS_DELETE_ERROR', 'NOTIFICATIONS_FETCH_COUNT_ERROR', From c443ad8970b8b517c0e724e6dab46cbd988a787c Mon Sep 17 00:00:00 2001 From: razyd Date: Sat, 10 Oct 2026 16:02:58 +0200 Subject: [PATCH 15/71] feat(i18n): translate LOGBOOK error codes --- .../tests/frontend/error-code-message.test.ts | 43 +++++++++++++++++++ frontend/app/lib/helpers/errorCodeMessage.ts | 24 +++++++++++ 2 files changed, 67 insertions(+) diff --git a/backend/tests/frontend/error-code-message.test.ts b/backend/tests/frontend/error-code-message.test.ts index 7e0ef5f1..47abb6ff 100644 --- a/backend/tests/frontend/error-code-message.test.ts +++ b/backend/tests/frontend/error-code-message.test.ts @@ -224,6 +224,9 @@ describe('errorCodeMessage', () => { expect(errorCodeMessage('BLACKLIST_RECORD_CREATED', 'es')).toBeUndefined() // Orphan code (in ES/EN but not in ERROR_CODES) expect(errorCodeMessage('BLACKLIST_PUBLIC_ID_MISSING', 'es')).toBeUndefined() + // Success messages are in "success" section, not "codes" + expect(errorCodeMessage('LOGBOOK_UPDATED', 'es')).toBeUndefined() + expect(errorCodeMessage('LOGBOOK_COMMENT_DELETED', 'es')).toBeUndefined() }) }) @@ -258,4 +261,44 @@ describe('errorCodeMessage', () => { expect(enText!.length).toBeGreaterThan(0) }) }) + + describe('LOGBOOK codes', () => { + const logbookCodes = [ + 'LOGBOOK_COMMENT_CREATE_ERROR', + 'LOGBOOK_COMMENT_DELETE_ERROR', + 'LOGBOOK_COMMENT_NOT_FOUND', + 'LOGBOOK_COMMENT_NOT_IN_LOGBOOK', + 'LOGBOOK_COMMENT_NO_PERMISSION', + 'LOGBOOK_COMMENT_UPDATE_ERROR', + 'LOGBOOK_CREATE_ERROR', + 'LOGBOOK_DELETE_ERROR', + 'LOGBOOK_FETCH_BY_AUTHOR_ERROR', + 'LOGBOOK_FETCH_BY_DAY_ERROR', + 'LOGBOOK_FETCH_BY_DEPARTMENT_ERROR', + 'LOGBOOK_FETCH_BY_IMPORTANCE_ERROR', + 'LOGBOOK_FETCH_ERROR', + 'LOGBOOK_FETCH_READERS_ERROR', + 'LOGBOOK_FETCH_SOLVER_ERROR', + 'LOGBOOK_INVALID_IMPORTANCE', + 'LOGBOOK_NOT_FOUND', + 'LOGBOOK_NOT_SOLVED', + 'LOGBOOK_ONLY_AUTHOR_DELETE', + 'LOGBOOK_ONLY_AUTHOR_UPDATE', + 'LOGBOOK_READ_ERROR', + 'LOGBOOK_REOPEN_ERROR', + 'LOGBOOK_SOLVE_ERROR', + 'LOGBOOK_UNREAD_ERROR', + ] + + it.each(logbookCodes)('%s returns non-empty text in es and en', (code) => { + const esText = errorCodeMessage(code, 'es') + const enText = errorCodeMessage(code, 'en') + expect(esText).toBeTruthy() + expect(typeof esText).toBe('string') + expect(esText!.length).toBeGreaterThan(0) + expect(enText).toBeTruthy() + expect(typeof enText).toBe('string') + expect(enText!.length).toBeGreaterThan(0) + }) + }) }) diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index 21d093ae..0b418f5b 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -93,6 +93,30 @@ const TRANSLATED = new Set([ 'BLACKLIST_RESTORE_RECORD_ERROR', 'BLACKLIST_UPDATE_RECORD_ERROR', 'BLACKLIST_UPLOAD_IMAGE_ERROR', + 'LOGBOOK_COMMENT_CREATE_ERROR', + 'LOGBOOK_COMMENT_DELETE_ERROR', + 'LOGBOOK_COMMENT_NOT_FOUND', + 'LOGBOOK_COMMENT_NOT_IN_LOGBOOK', + 'LOGBOOK_COMMENT_NO_PERMISSION', + 'LOGBOOK_COMMENT_UPDATE_ERROR', + 'LOGBOOK_CREATE_ERROR', + 'LOGBOOK_DELETE_ERROR', + 'LOGBOOK_FETCH_BY_AUTHOR_ERROR', + 'LOGBOOK_FETCH_BY_DAY_ERROR', + 'LOGBOOK_FETCH_BY_DEPARTMENT_ERROR', + 'LOGBOOK_FETCH_BY_IMPORTANCE_ERROR', + 'LOGBOOK_FETCH_ERROR', + 'LOGBOOK_FETCH_READERS_ERROR', + 'LOGBOOK_FETCH_SOLVER_ERROR', + 'LOGBOOK_INVALID_IMPORTANCE', + 'LOGBOOK_NOT_FOUND', + 'LOGBOOK_NOT_SOLVED', + 'LOGBOOK_ONLY_AUTHOR_DELETE', + 'LOGBOOK_ONLY_AUTHOR_UPDATE', + 'LOGBOOK_READ_ERROR', + 'LOGBOOK_REOPEN_ERROR', + 'LOGBOOK_SOLVE_ERROR', + 'LOGBOOK_UNREAD_ERROR', 'NOTIFICATIONS_CREATE_ERROR', 'NOTIFICATIONS_DELETE_ERROR', 'NOTIFICATIONS_FETCH_COUNT_ERROR', From cea17d7c69a96075d126ecadefd297ca375ce5ab Mon Sep 17 00:00:00 2001 From: razyd Date: Sat, 10 Oct 2026 16:11:00 +0200 Subject: [PATCH 16/71] feat(i18n): translate CONCILIATION error codes --- .../tests/frontend/error-code-message.test.ts | 41 +++++++++++++++++++ frontend/app/lib/helpers/errorCodeMessage.ts | 23 +++++++++++ 2 files changed, 64 insertions(+) diff --git a/backend/tests/frontend/error-code-message.test.ts b/backend/tests/frontend/error-code-message.test.ts index 47abb6ff..dc607b94 100644 --- a/backend/tests/frontend/error-code-message.test.ts +++ b/backend/tests/frontend/error-code-message.test.ts @@ -227,6 +227,8 @@ describe('errorCodeMessage', () => { // Success messages are in "success" section, not "codes" expect(errorCodeMessage('LOGBOOK_UPDATED', 'es')).toBeUndefined() expect(errorCodeMessage('LOGBOOK_COMMENT_DELETED', 'es')).toBeUndefined() + expect(errorCodeMessage('CONCILIATION_CREATED', 'es')).toBeUndefined() + expect(errorCodeMessage('CONCILIATION_DELETED', 'es')).toBeUndefined() }) }) @@ -301,4 +303,43 @@ describe('errorCodeMessage', () => { expect(enText!.length).toBeGreaterThan(0) }) }) + + describe('CONCILIATION codes', () => { + const conciliationCodes = [ + 'CONCILIATION_ADMIN_ONLY_CLOSE', + 'CONCILIATION_ALREADY_EXISTS', + 'CONCILIATION_CLOSED_CANNOT_MODIFY', + 'CONCILIATION_CREATE_ERROR', + 'CONCILIATION_DATE_REQUIRED', + 'CONCILIATION_DELETE_ERROR', + 'CONCILIATION_FETCH_ERROR', + 'CONCILIATION_FETCH_ONE_ERROR', + 'CONCILIATION_INCOMPLETE_DATA', + 'CONCILIATION_INVALID_HOUSEKEEPING_ENTRIES', + 'CONCILIATION_INVALID_MONTH', + 'CONCILIATION_INVALID_RECEPTION_ENTRIES', + 'CONCILIATION_INVALID_STATUS', + 'CONCILIATION_INVALID_YEAR', + 'CONCILIATION_INVALID_YEAR_MONTH', + 'CONCILIATION_MONTHLY_FETCH_ERROR', + 'CONCILIATION_MONTHLY_MISSING_DAYS_ERROR', + 'CONCILIATION_MONTHLY_UPDATE_STATUS_ERROR', + 'CONCILIATION_MONTHLY_VALIDATE_ERROR', + 'CONCILIATION_NOT_FOUND', + 'CONCILIATION_RECALCULATE_ERROR', + 'CONCILIATION_UPDATE_FORM_ERROR', + 'CONCILIATION_UPDATE_STATUS_ERROR', + ] + + it.each(conciliationCodes)('%s returns non-empty text in es and en', (code) => { + const esText = errorCodeMessage(code, 'es') + const enText = errorCodeMessage(code, 'en') + expect(esText).toBeTruthy() + expect(typeof esText).toBe('string') + expect(esText!.length).toBeGreaterThan(0) + expect(enText).toBeTruthy() + expect(typeof enText).toBe('string') + expect(enText!.length).toBeGreaterThan(0) + }) + }) }) diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index 0b418f5b..87ec5bae 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -93,6 +93,29 @@ const TRANSLATED = new Set([ 'BLACKLIST_RESTORE_RECORD_ERROR', 'BLACKLIST_UPDATE_RECORD_ERROR', 'BLACKLIST_UPLOAD_IMAGE_ERROR', + 'CONCILIATION_ADMIN_ONLY_CLOSE', + 'CONCILIATION_ALREADY_EXISTS', + 'CONCILIATION_CLOSED_CANNOT_MODIFY', + 'CONCILIATION_CREATE_ERROR', + 'CONCILIATION_DATE_REQUIRED', + 'CONCILIATION_DELETE_ERROR', + 'CONCILIATION_FETCH_ERROR', + 'CONCILIATION_FETCH_ONE_ERROR', + 'CONCILIATION_INCOMPLETE_DATA', + 'CONCILIATION_INVALID_HOUSEKEEPING_ENTRIES', + 'CONCILIATION_INVALID_MONTH', + 'CONCILIATION_INVALID_RECEPTION_ENTRIES', + 'CONCILIATION_INVALID_STATUS', + 'CONCILIATION_INVALID_YEAR', + 'CONCILIATION_INVALID_YEAR_MONTH', + 'CONCILIATION_MONTHLY_FETCH_ERROR', + 'CONCILIATION_MONTHLY_MISSING_DAYS_ERROR', + 'CONCILIATION_MONTHLY_UPDATE_STATUS_ERROR', + 'CONCILIATION_MONTHLY_VALIDATE_ERROR', + 'CONCILIATION_NOT_FOUND', + 'CONCILIATION_RECALCULATE_ERROR', + 'CONCILIATION_UPDATE_FORM_ERROR', + 'CONCILIATION_UPDATE_STATUS_ERROR', 'LOGBOOK_COMMENT_CREATE_ERROR', 'LOGBOOK_COMMENT_DELETE_ERROR', 'LOGBOOK_COMMENT_NOT_FOUND', From b52c92fc0c851c0a166526246cbb324bf445bb2d Mon Sep 17 00:00:00 2001 From: razyd Date: Sat, 10 Oct 2026 16:18:17 +0200 Subject: [PATCH 17/71] feat(i18n): translate PARKING error codes --- .../tests/frontend/error-code-message.test.ts | 56 +++++++++++++++++++ frontend/app/lib/helpers/errorCodeMessage.ts | 38 +++++++++++++ 2 files changed, 94 insertions(+) diff --git a/backend/tests/frontend/error-code-message.test.ts b/backend/tests/frontend/error-code-message.test.ts index dc607b94..e37d861c 100644 --- a/backend/tests/frontend/error-code-message.test.ts +++ b/backend/tests/frontend/error-code-message.test.ts @@ -229,6 +229,8 @@ describe('errorCodeMessage', () => { expect(errorCodeMessage('LOGBOOK_COMMENT_DELETED', 'es')).toBeUndefined() expect(errorCodeMessage('CONCILIATION_CREATED', 'es')).toBeUndefined() expect(errorCodeMessage('CONCILIATION_DELETED', 'es')).toBeUndefined() + expect(errorCodeMessage('PARKING_BOOKING_CREATED', 'es')).toBeUndefined() + expect(errorCodeMessage('PARKING_BOOKING_DELETED', 'es')).toBeUndefined() }) }) @@ -342,4 +344,58 @@ describe('errorCodeMessage', () => { expect(enText!.length).toBeGreaterThan(0) }) }) + + describe('PARKING codes', () => { + const parkingCodes = [ + 'PARKING_ANALYZE_BOOKINGS_ERROR', + 'PARKING_BOOKING_NOT_FOUND', + 'PARKING_CANCEL_ERROR', + 'PARKING_CANNOT_DELETE_STARTED', + 'PARKING_CANNOT_DELETE_STATUS', + 'PARKING_CANNOT_UPDATE_STATUS', + 'PARKING_CHECKIN_ERROR', + 'PARKING_CHECKOUT_ERROR', + 'PARKING_COMPARE_PERIODS_ERROR', + 'PARKING_COMPARISON_DATES_REQUIRED', + 'PARKING_CREATE_BOOKING_ERROR', + 'PARKING_DATE_RANGE_REQUIRED', + 'PARKING_DELETE_BOOKING_ERROR', + 'PARKING_END_BEFORE_START', + 'PARKING_FETCH_AVAILABILITY_ERROR', + 'PARKING_FETCH_BOOKING_ERROR', + 'PARKING_FETCH_BOOKINGS_ERROR', + 'PARKING_FETCH_DASHBOARD_ERROR', + 'PARKING_FETCH_DELAYED_ERROR', + 'PARKING_FETCH_OCCUPANCY_ERROR', + 'PARKING_FETCH_PENDING_CHECKINS_ERROR', + 'PARKING_FETCH_PENDING_CHECKOUTS_ERROR', + 'PARKING_FETCH_PERFORMANCE_ERROR', + 'PARKING_FETCH_RANGE_OCCUPANCY_ERROR', + 'PARKING_FETCH_RANGE_STATS_ERROR', + 'PARKING_FETCH_STATS_ERROR', + 'PARKING_FETCH_TRENDS_ERROR', + 'PARKING_INVALID_AMOUNT', + 'PARKING_INVALID_CODE_FORMAT', + 'PARKING_INVALID_DATE', + 'PARKING_INVALID_DATE_FORMAT', + 'PARKING_INVALID_DATES', + 'PARKING_INVALID_END_DATE', + 'PARKING_INVALID_START_DATE', + 'PARKING_NO_FIELDS_TO_UPDATE', + 'PARKING_NOSHOW_ERROR', + 'PARKING_START_BEFORE_END', + 'PARKING_UPDATE_BOOKING_ERROR', + ] + + it.each(parkingCodes)('%s returns non-empty text in es and en', (code) => { + const esText = errorCodeMessage(code, 'es') + const enText = errorCodeMessage(code, 'en') + expect(esText).toBeTruthy() + expect(typeof esText).toBe('string') + expect(esText!.length).toBeGreaterThan(0) + expect(enText).toBeTruthy() + expect(typeof enText).toBe('string') + expect(enText!.length).toBeGreaterThan(0) + }) + }) }) diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index 87ec5bae..9c528323 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -156,6 +156,44 @@ const TRANSLATED = new Set([ 'NOTIFICATIONS_MISSING_REQUIRED_FIELDS', 'NOTIFICATIONS_NOT_FOUND', 'NOTIFICATIONS_PROCESS_ERROR', + 'PARKING_ANALYZE_BOOKINGS_ERROR', + 'PARKING_BOOKING_NOT_FOUND', + 'PARKING_CANCEL_ERROR', + 'PARKING_CANNOT_DELETE_STARTED', + 'PARKING_CANNOT_DELETE_STATUS', + 'PARKING_CANNOT_UPDATE_STATUS', + 'PARKING_CHECKIN_ERROR', + 'PARKING_CHECKOUT_ERROR', + 'PARKING_COMPARE_PERIODS_ERROR', + 'PARKING_COMPARISON_DATES_REQUIRED', + 'PARKING_CREATE_BOOKING_ERROR', + 'PARKING_DATE_RANGE_REQUIRED', + 'PARKING_DELETE_BOOKING_ERROR', + 'PARKING_END_BEFORE_START', + 'PARKING_FETCH_AVAILABILITY_ERROR', + 'PARKING_FETCH_BOOKING_ERROR', + 'PARKING_FETCH_BOOKINGS_ERROR', + 'PARKING_FETCH_DASHBOARD_ERROR', + 'PARKING_FETCH_DELAYED_ERROR', + 'PARKING_FETCH_OCCUPANCY_ERROR', + 'PARKING_FETCH_PENDING_CHECKINS_ERROR', + 'PARKING_FETCH_PENDING_CHECKOUTS_ERROR', + 'PARKING_FETCH_PERFORMANCE_ERROR', + 'PARKING_FETCH_RANGE_OCCUPANCY_ERROR', + 'PARKING_FETCH_RANGE_STATS_ERROR', + 'PARKING_FETCH_STATS_ERROR', + 'PARKING_FETCH_TRENDS_ERROR', + 'PARKING_INVALID_AMOUNT', + 'PARKING_INVALID_CODE_FORMAT', + 'PARKING_INVALID_DATE', + 'PARKING_INVALID_DATE_FORMAT', + 'PARKING_INVALID_DATES', + 'PARKING_INVALID_END_DATE', + 'PARKING_INVALID_START_DATE', + 'PARKING_NO_FIELDS_TO_UPDATE', + 'PARKING_NOSHOW_ERROR', + 'PARKING_START_BEFORE_END', + 'PARKING_UPDATE_BOOKING_ERROR', 'STORAGE_NOT_CONFIGURED', ]) From c99198fa216902ef4b47d95ba160098b9949dd9c Mon Sep 17 00:00:00 2001 From: razyd Date: Sat, 10 Oct 2026 16:20:18 +0200 Subject: [PATCH 18/71] feat(i18n): translate GROUP error codes --- .../tests/frontend/error-code-message.test.ts | 60 +++++++++++++++++++ frontend/app/lib/helpers/errorCodeMessage.ts | 42 +++++++++++++ 2 files changed, 102 insertions(+) diff --git a/backend/tests/frontend/error-code-message.test.ts b/backend/tests/frontend/error-code-message.test.ts index e37d861c..7ac17521 100644 --- a/backend/tests/frontend/error-code-message.test.ts +++ b/backend/tests/frontend/error-code-message.test.ts @@ -231,6 +231,8 @@ describe('errorCodeMessage', () => { expect(errorCodeMessage('CONCILIATION_DELETED', 'es')).toBeUndefined() expect(errorCodeMessage('PARKING_BOOKING_CREATED', 'es')).toBeUndefined() expect(errorCodeMessage('PARKING_BOOKING_DELETED', 'es')).toBeUndefined() + expect(errorCodeMessage('GROUP_CREATED', 'es')).toBeUndefined() + expect(errorCodeMessage('GROUP_UPDATED', 'es')).toBeUndefined() }) }) @@ -398,4 +400,62 @@ describe('errorCodeMessage', () => { expect(enText!.length).toBeGreaterThan(0) }) }) + + describe('GROUP codes', () => { + const groupCodes = [ + 'GROUP_CONTACT_CREATE_ERROR', + 'GROUP_CONTACT_DELETE_ERROR', + 'GROUP_CONTACT_FETCH_ERROR', + 'GROUP_CONTACT_FETCH_PRIMARY_ERROR', + 'GROUP_CONTACT_NOT_FOUND', + 'GROUP_CONTACT_NOT_IN_GROUP', + 'GROUP_CONTACT_PRIMARY_NOT_FOUND', + 'GROUP_CONTACT_UPDATE_ERROR', + 'GROUP_CREATE_ERROR', + 'GROUP_DELETE_ERROR', + 'GROUP_FETCH_DASHBOARD_ERROR', + 'GROUP_FETCH_ERROR', + 'GROUP_FETCH_ONE_ERROR', + 'GROUP_FETCH_TIMELINE_ERROR', + 'GROUP_HISTORY_FETCH_ERROR', + 'GROUP_INVALID_ID', + 'GROUP_MISSING_REQUIRED_FIELDS', + 'GROUP_NOT_FOUND', + 'GROUP_PAYMENT_CREATE_ERROR', + 'GROUP_PAYMENT_DELETE_ERROR', + 'GROUP_PAYMENT_FETCH_ERROR', + 'GROUP_PAYMENT_INVALID_AMOUNT', + 'GROUP_PAYMENT_MISSING_FIELDS', + 'GROUP_PAYMENT_NOT_FOUND', + 'GROUP_PAYMENT_NOT_IN_GROUP', + 'GROUP_PAYMENT_UPDATE_ERROR', + 'GROUP_ROOM_DELETE_ERROR', + 'GROUP_ROOM_FETCH_ERROR', + 'GROUP_ROOM_MISSING_FIELDS', + 'GROUP_ROOM_NOT_FOUND', + 'GROUP_ROOM_NOT_IN_GROUP', + 'GROUP_ROOM_SAVE_ERROR', + 'GROUP_ROOM_UPDATE_ERROR', + 'GROUP_STATUS_CONFIRMED_REQUIRED', + 'GROUP_STATUS_FETCH_ERROR', + 'GROUP_STATUS_NOT_FOUND', + 'GROUP_STATUS_SIGNED_REQUIRED', + 'GROUP_STATUS_UPDATE_BALANCE_ERROR', + 'GROUP_STATUS_UPDATE_BOOKING_ERROR', + 'GROUP_STATUS_UPDATE_CONTRACT_ERROR', + 'GROUP_STATUS_UPDATE_ROOMING_ERROR', + 'GROUP_UPDATE_ERROR', + ] + + it.each(groupCodes)('%s returns non-empty text in es and en', (code) => { + const esText = errorCodeMessage(code, 'es') + const enText = errorCodeMessage(code, 'en') + expect(esText).toBeTruthy() + expect(typeof esText).toBe('string') + expect(esText!.length).toBeGreaterThan(0) + expect(enText).toBeTruthy() + expect(typeof enText).toBe('string') + expect(enText!.length).toBeGreaterThan(0) + }) + }) }) diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index 9c528323..d4575004 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -194,6 +194,48 @@ const TRANSLATED = new Set([ 'PARKING_NOSHOW_ERROR', 'PARKING_START_BEFORE_END', 'PARKING_UPDATE_BOOKING_ERROR', + 'GROUP_CONTACT_CREATE_ERROR', + 'GROUP_CONTACT_DELETE_ERROR', + 'GROUP_CONTACT_FETCH_ERROR', + 'GROUP_CONTACT_FETCH_PRIMARY_ERROR', + 'GROUP_CONTACT_NOT_FOUND', + 'GROUP_CONTACT_NOT_IN_GROUP', + 'GROUP_CONTACT_PRIMARY_NOT_FOUND', + 'GROUP_CONTACT_UPDATE_ERROR', + 'GROUP_CREATE_ERROR', + 'GROUP_DELETE_ERROR', + 'GROUP_FETCH_DASHBOARD_ERROR', + 'GROUP_FETCH_ERROR', + 'GROUP_FETCH_ONE_ERROR', + 'GROUP_FETCH_TIMELINE_ERROR', + 'GROUP_HISTORY_FETCH_ERROR', + 'GROUP_INVALID_ID', + 'GROUP_MISSING_REQUIRED_FIELDS', + 'GROUP_NOT_FOUND', + 'GROUP_PAYMENT_CREATE_ERROR', + 'GROUP_PAYMENT_DELETE_ERROR', + 'GROUP_PAYMENT_FETCH_ERROR', + 'GROUP_PAYMENT_INVALID_AMOUNT', + 'GROUP_PAYMENT_MISSING_FIELDS', + 'GROUP_PAYMENT_NOT_FOUND', + 'GROUP_PAYMENT_NOT_IN_GROUP', + 'GROUP_PAYMENT_UPDATE_ERROR', + 'GROUP_ROOM_DELETE_ERROR', + 'GROUP_ROOM_FETCH_ERROR', + 'GROUP_ROOM_MISSING_FIELDS', + 'GROUP_ROOM_NOT_FOUND', + 'GROUP_ROOM_NOT_IN_GROUP', + 'GROUP_ROOM_SAVE_ERROR', + 'GROUP_ROOM_UPDATE_ERROR', + 'GROUP_STATUS_CONFIRMED_REQUIRED', + 'GROUP_STATUS_FETCH_ERROR', + 'GROUP_STATUS_NOT_FOUND', + 'GROUP_STATUS_SIGNED_REQUIRED', + 'GROUP_STATUS_UPDATE_BALANCE_ERROR', + 'GROUP_STATUS_UPDATE_BOOKING_ERROR', + 'GROUP_STATUS_UPDATE_CONTRACT_ERROR', + 'GROUP_STATUS_UPDATE_ROOMING_ERROR', + 'GROUP_UPDATE_ERROR', 'STORAGE_NOT_CONFIGURED', ]) From fb55531854d837e1367b27ac19513911d470d263 Mon Sep 17 00:00:00 2001 From: razyd Date: Sat, 10 Oct 2026 16:24:56 +0200 Subject: [PATCH 19/71] feat(i18n): translate MESSAGES error codes --- .../tests/frontend/error-code-message.test.ts | 60 +++++++++++++++++++ frontend/app/lib/helpers/errorCodeMessage.ts | 42 +++++++++++++ 2 files changed, 102 insertions(+) diff --git a/backend/tests/frontend/error-code-message.test.ts b/backend/tests/frontend/error-code-message.test.ts index 7ac17521..0df49cc3 100644 --- a/backend/tests/frontend/error-code-message.test.ts +++ b/backend/tests/frontend/error-code-message.test.ts @@ -233,6 +233,8 @@ describe('errorCodeMessage', () => { expect(errorCodeMessage('PARKING_BOOKING_DELETED', 'es')).toBeUndefined() expect(errorCodeMessage('GROUP_CREATED', 'es')).toBeUndefined() expect(errorCodeMessage('GROUP_UPDATED', 'es')).toBeUndefined() + expect(errorCodeMessage('MESSAGES_SENT', 'es')).toBeUndefined() + expect(errorCodeMessage('MESSAGES_EDITED', 'es')).toBeUndefined() }) }) @@ -458,4 +460,62 @@ describe('errorCodeMessage', () => { expect(enText!.length).toBeGreaterThan(0) }) }) + + describe('MESSAGES codes', () => { + const messagesCodes = [ + 'MESSAGES_ADD_PARTICIPANTS_ERROR', + 'MESSAGES_ADMIN_ONLY_ADD', + 'MESSAGES_ADMIN_ONLY_EDIT', + 'MESSAGES_ADMIN_ONLY_REMOVE', + 'MESSAGES_ADMIN_ONLY_VIEW_ALL', + 'MESSAGES_ADMIN_OR_SYSTEM_DELETE', + 'MESSAGES_CONVERSATION_NOT_FOUND', + 'MESSAGES_CREATE_CONVERSATION_ERROR', + 'MESSAGES_DELETE_CONVERSATION_ERROR', + 'MESSAGES_DELETE_MESSAGE_ERROR', + 'MESSAGES_DM_ONE_PARTICIPANT', + 'MESSAGES_EDIT_MESSAGE_ERROR', + 'MESSAGES_EMPTY_MESSAGE', + 'MESSAGES_FETCH_CONVERSATION_ERROR', + 'MESSAGES_FETCH_CONVERSATIONS_ERROR', + 'MESSAGES_FETCH_MESSAGES_ERROR', + 'MESSAGES_FETCH_UNREAD_ERROR', + 'MESSAGES_GROUP_MAX_PARTICIPANTS', + 'MESSAGES_GROUP_NAME_REQUIRED', + 'MESSAGES_GROUP_NAME_TOO_LONG', + 'MESSAGES_GROUPS_ONLY_ADD', + 'MESSAGES_GROUPS_ONLY_EDIT', + 'MESSAGES_GROUPS_ONLY_REMOVE', + 'MESSAGES_INVALID_CONVERSATION_ID', + 'MESSAGES_INVALID_MESSAGE_ID', + 'MESSAGES_LEAVE_CONVERSATION_ERROR', + 'MESSAGES_MARK_READ_ERROR', + 'MESSAGES_MESSAGE_NOT_FOUND', + 'MESSAGES_MESSAGE_TOO_LONG', + 'MESSAGES_MISSING_REQUIRED_FIELDS', + 'MESSAGES_NO_ACCESS', + 'MESSAGES_NOT_PARTICIPANT', + 'MESSAGES_ONLY_OWN_DELETE', + 'MESSAGES_ONLY_OWN_EDIT', + 'MESSAGES_REMOVE_PARTICIPANT_ERROR', + 'MESSAGES_SEARCH_MESSAGES_ERROR', + 'MESSAGES_SEARCH_MIN_LENGTH', + 'MESSAGES_SEARCH_USERS_ERROR', + 'MESSAGES_SEND_MESSAGE_ERROR', + 'MESSAGES_SPECIFY_USER', + 'MESSAGES_UPDATE_CONVERSATION_ERROR', + 'MESSAGES_USE_LEAVE_OPTION', + ] + + it.each(messagesCodes)('%s returns non-empty text in es and en', (code) => { + const esText = errorCodeMessage(code, 'es') + const enText = errorCodeMessage(code, 'en') + expect(esText).toBeTruthy() + expect(typeof esText).toBe('string') + expect(esText!.length).toBeGreaterThan(0) + expect(enText).toBeTruthy() + expect(typeof enText).toBe('string') + expect(enText!.length).toBeGreaterThan(0) + }) + }) }) diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index d4575004..4b7c04c5 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -236,6 +236,48 @@ const TRANSLATED = new Set([ 'GROUP_STATUS_UPDATE_CONTRACT_ERROR', 'GROUP_STATUS_UPDATE_ROOMING_ERROR', 'GROUP_UPDATE_ERROR', + 'MESSAGES_ADD_PARTICIPANTS_ERROR', + 'MESSAGES_ADMIN_ONLY_ADD', + 'MESSAGES_ADMIN_ONLY_EDIT', + 'MESSAGES_ADMIN_ONLY_REMOVE', + 'MESSAGES_ADMIN_ONLY_VIEW_ALL', + 'MESSAGES_ADMIN_OR_SYSTEM_DELETE', + 'MESSAGES_CONVERSATION_NOT_FOUND', + 'MESSAGES_CREATE_CONVERSATION_ERROR', + 'MESSAGES_DELETE_CONVERSATION_ERROR', + 'MESSAGES_DELETE_MESSAGE_ERROR', + 'MESSAGES_DM_ONE_PARTICIPANT', + 'MESSAGES_EDIT_MESSAGE_ERROR', + 'MESSAGES_EMPTY_MESSAGE', + 'MESSAGES_FETCH_CONVERSATION_ERROR', + 'MESSAGES_FETCH_CONVERSATIONS_ERROR', + 'MESSAGES_FETCH_MESSAGES_ERROR', + 'MESSAGES_FETCH_UNREAD_ERROR', + 'MESSAGES_GROUP_MAX_PARTICIPANTS', + 'MESSAGES_GROUP_NAME_REQUIRED', + 'MESSAGES_GROUP_NAME_TOO_LONG', + 'MESSAGES_GROUPS_ONLY_ADD', + 'MESSAGES_GROUPS_ONLY_EDIT', + 'MESSAGES_GROUPS_ONLY_REMOVE', + 'MESSAGES_INVALID_CONVERSATION_ID', + 'MESSAGES_INVALID_MESSAGE_ID', + 'MESSAGES_LEAVE_CONVERSATION_ERROR', + 'MESSAGES_MARK_READ_ERROR', + 'MESSAGES_MESSAGE_NOT_FOUND', + 'MESSAGES_MESSAGE_TOO_LONG', + 'MESSAGES_MISSING_REQUIRED_FIELDS', + 'MESSAGES_NO_ACCESS', + 'MESSAGES_NOT_PARTICIPANT', + 'MESSAGES_ONLY_OWN_DELETE', + 'MESSAGES_ONLY_OWN_EDIT', + 'MESSAGES_REMOVE_PARTICIPANT_ERROR', + 'MESSAGES_SEARCH_MESSAGES_ERROR', + 'MESSAGES_SEARCH_MIN_LENGTH', + 'MESSAGES_SEARCH_USERS_ERROR', + 'MESSAGES_SEND_MESSAGE_ERROR', + 'MESSAGES_SPECIFY_USER', + 'MESSAGES_UPDATE_CONVERSATION_ERROR', + 'MESSAGES_USE_LEAVE_OPTION', 'STORAGE_NOT_CONFIGURED', ]) From 0f467000d438f8416153237526c3c33373ae209a Mon Sep 17 00:00:00 2001 From: razyd Date: Sat, 10 Oct 2026 16:29:03 +0200 Subject: [PATCH 20/71] feat(i18n): translate CASHIER error codes --- .../tests/frontend/error-code-message.test.ts | 63 +++++++++++++++++++ frontend/app/lib/helpers/errorCodeMessage.ts | 45 +++++++++++++ 2 files changed, 108 insertions(+) diff --git a/backend/tests/frontend/error-code-message.test.ts b/backend/tests/frontend/error-code-message.test.ts index 0df49cc3..2a572d3d 100644 --- a/backend/tests/frontend/error-code-message.test.ts +++ b/backend/tests/frontend/error-code-message.test.ts @@ -235,6 +235,8 @@ describe('errorCodeMessage', () => { expect(errorCodeMessage('GROUP_UPDATED', 'es')).toBeUndefined() expect(errorCodeMessage('MESSAGES_SENT', 'es')).toBeUndefined() expect(errorCodeMessage('MESSAGES_EDITED', 'es')).toBeUndefined() + expect(errorCodeMessage('CASHIER_DAY_INITIALIZED', 'es')).toBeUndefined() + expect(errorCodeMessage('CASHIER_VOUCHER_CREATED', 'es')).toBeUndefined() }) }) @@ -518,4 +520,65 @@ describe('errorCodeMessage', () => { expect(enText!.length).toBeGreaterThan(0) }) }) + + describe('CASHIER codes', () => { + const cashierCodes = [ + 'CASHIER_CLOSE_DAY_ERROR', + 'CASHIER_CREATE_DENOMINATION_ERROR', + 'CASHIER_CREATE_PAYMENT_ERROR', + 'CASHIER_CREATE_VOUCHER_ERROR', + 'CASHIER_DATE_RANGE_REQUIRED', + 'CASHIER_DAY_ALREADY_INITIALIZED', + 'CASHIER_DAY_CANNOT_CLOSE', + 'CASHIER_DAY_NOT_FOUND', + 'CASHIER_DELETE_DENOMINATION_ERROR', + 'CASHIER_DELETE_PAYMENT_ERROR', + 'CASHIER_FETCH_DAILY_REPORT_ERROR', + 'CASHIER_FETCH_DAY_ERROR', + 'CASHIER_FETCH_DAYS_ERROR', + 'CASHIER_FETCH_DASHBOARD_ERROR', + 'CASHIER_FETCH_DENOMINATIONS_ERROR', + 'CASHIER_FETCH_HISTORY_ERROR', + 'CASHIER_FETCH_HISTORY_STATS_ERROR', + 'CASHIER_FETCH_MONTHLY_SUMMARY_ERROR', + 'CASHIER_FETCH_PAYMENT_SUMMARY_ERROR', + 'CASHIER_FETCH_PAYMENTS_ERROR', + 'CASHIER_FETCH_PERIOD_REPORT_ERROR', + 'CASHIER_FETCH_RECENT_ACTIVITY_ERROR', + 'CASHIER_FETCH_SHIFT_ERROR', + 'CASHIER_FETCH_SHIFT_HISTORY_ERROR', + 'CASHIER_FETCH_SHIFT_SUMMARY_ERROR', + 'CASHIER_FETCH_SHIFTS_ERROR', + 'CASHIER_FETCH_SUMMARY_ERROR', + 'CASHIER_FETCH_VOUCHER_ERROR', + 'CASHIER_FETCH_VOUCHER_HISTORY_ERROR', + 'CASHIER_FETCH_VOUCHER_STATS_ERROR', + 'CASHIER_FETCH_VOUCHERS_ERROR', + 'CASHIER_FETCH_ACTIVE_VOUCHERS_ERROR', + 'CASHIER_INIT_DAY_ERROR', + 'CASHIER_INVALID_DATE_FORMAT', + 'CASHIER_INVALID_DATE_RANGE', + 'CASHIER_INVALID_SHIFT_ID', + 'CASHIER_INVALID_YEAR_MONTH', + 'CASHIER_REOPEN_DAY_ERROR', + 'CASHIER_SHIFT_NOT_FOUND', + 'CASHIER_SHIFTS_ALREADY_EXIST', + 'CASHIER_UPDATE_DENOMINATIONS_ERROR', + 'CASHIER_UPDATE_PAYMENTS_ERROR', + 'CASHIER_UPDATE_USERS_ERROR', + 'CASHIER_VOUCHER_MAX_REACHED', + 'CASHIER_VOUCHER_NOT_FOUND', + ] + + it.each(cashierCodes)('%s returns non-empty text in es and en', (code) => { + const esText = errorCodeMessage(code, 'es') + const enText = errorCodeMessage(code, 'en') + expect(esText).toBeTruthy() + expect(typeof esText).toBe('string') + expect(esText!.length).toBeGreaterThan(0) + expect(enText).toBeTruthy() + expect(typeof enText).toBe('string') + expect(enText!.length).toBeGreaterThan(0) + }) + }) }) diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index 4b7c04c5..0b822b43 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -278,6 +278,51 @@ const TRANSLATED = new Set([ 'MESSAGES_SPECIFY_USER', 'MESSAGES_UPDATE_CONVERSATION_ERROR', 'MESSAGES_USE_LEAVE_OPTION', + 'CASHIER_CLOSE_DAY_ERROR', + 'CASHIER_CREATE_DENOMINATION_ERROR', + 'CASHIER_CREATE_PAYMENT_ERROR', + 'CASHIER_CREATE_VOUCHER_ERROR', + 'CASHIER_DATE_RANGE_REQUIRED', + 'CASHIER_DAY_ALREADY_INITIALIZED', + 'CASHIER_DAY_CANNOT_CLOSE', + 'CASHIER_DAY_NOT_FOUND', + 'CASHIER_DELETE_DENOMINATION_ERROR', + 'CASHIER_DELETE_PAYMENT_ERROR', + 'CASHIER_FETCH_DAILY_REPORT_ERROR', + 'CASHIER_FETCH_DAY_ERROR', + 'CASHIER_FETCH_DAYS_ERROR', + 'CASHIER_FETCH_DASHBOARD_ERROR', + 'CASHIER_FETCH_DENOMINATIONS_ERROR', + 'CASHIER_FETCH_HISTORY_ERROR', + 'CASHIER_FETCH_HISTORY_STATS_ERROR', + 'CASHIER_FETCH_MONTHLY_SUMMARY_ERROR', + 'CASHIER_FETCH_PAYMENT_SUMMARY_ERROR', + 'CASHIER_FETCH_PAYMENTS_ERROR', + 'CASHIER_FETCH_PERIOD_REPORT_ERROR', + 'CASHIER_FETCH_RECENT_ACTIVITY_ERROR', + 'CASHIER_FETCH_SHIFT_ERROR', + 'CASHIER_FETCH_SHIFT_HISTORY_ERROR', + 'CASHIER_FETCH_SHIFT_SUMMARY_ERROR', + 'CASHIER_FETCH_SHIFTS_ERROR', + 'CASHIER_FETCH_SUMMARY_ERROR', + 'CASHIER_FETCH_VOUCHER_ERROR', + 'CASHIER_FETCH_VOUCHER_HISTORY_ERROR', + 'CASHIER_FETCH_VOUCHER_STATS_ERROR', + 'CASHIER_FETCH_VOUCHERS_ERROR', + 'CASHIER_FETCH_ACTIVE_VOUCHERS_ERROR', + 'CASHIER_INIT_DAY_ERROR', + 'CASHIER_INVALID_DATE_FORMAT', + 'CASHIER_INVALID_DATE_RANGE', + 'CASHIER_INVALID_SHIFT_ID', + 'CASHIER_INVALID_YEAR_MONTH', + 'CASHIER_REOPEN_DAY_ERROR', + 'CASHIER_SHIFT_NOT_FOUND', + 'CASHIER_SHIFTS_ALREADY_EXIST', + 'CASHIER_UPDATE_DENOMINATIONS_ERROR', + 'CASHIER_UPDATE_PAYMENTS_ERROR', + 'CASHIER_UPDATE_USERS_ERROR', + 'CASHIER_VOUCHER_MAX_REACHED', + 'CASHIER_VOUCHER_NOT_FOUND', 'STORAGE_NOT_CONFIGURED', ]) From 4a5e3e2d390038855ff0c7fde166d0a3a3c947c4 Mon Sep 17 00:00:00 2001 From: razyd Date: Sat, 10 Oct 2026 18:19:54 +0200 Subject: [PATCH 21/71] style(test): format error code message tests --- .../tests/frontend/error-code-message.test.ts | 48 +++++-------------- 1 file changed, 12 insertions(+), 36 deletions(-) diff --git a/backend/tests/frontend/error-code-message.test.ts b/backend/tests/frontend/error-code-message.test.ts index 2a572d3d..d500f736 100644 --- a/backend/tests/frontend/error-code-message.test.ts +++ b/backend/tests/frontend/error-code-message.test.ts @@ -15,21 +15,13 @@ describe('errorCodeMessage', () => { }) it('gives activity messages in Spanish and in English', () => { - expect(errorCodeMessage('ACTIVITY_INVALID_SOURCE', 'es')).toMatch( - /^Fuente inválida/ - ) - expect(errorCodeMessage('ACTIVITY_INVALID_SOURCE', 'en')).toMatch( - /^Invalid source/ - ) + expect(errorCodeMessage('ACTIVITY_INVALID_SOURCE', 'es')).toMatch(/^Fuente inválida/) + expect(errorCodeMessage('ACTIVITY_INVALID_SOURCE', 'en')).toMatch(/^Invalid source/) expect(errorCodeMessage('ACTIVITY_INVALID_DATE_FORMAT', 'es')).toMatch( /^Formato de fecha inválido/ ) - expect(errorCodeMessage('ACTIVITY_INVALID_DATE_FORMAT', 'en')).toMatch( - /^Invalid date format/ - ) - expect(errorCodeMessage('ACTIVITY_FETCH_ERROR', 'es')).toMatch( - /^Error al obtener actividad/ - ) + expect(errorCodeMessage('ACTIVITY_INVALID_DATE_FORMAT', 'en')).toMatch(/^Invalid date format/) + expect(errorCodeMessage('ACTIVITY_FETCH_ERROR', 'es')).toMatch(/^Error al obtener actividad/) expect(errorCodeMessage('ACTIVITY_FETCH_ERROR', 'en')).toMatch( /^Error fetching recent activity/ ) @@ -42,36 +34,24 @@ describe('errorCodeMessage', () => { expect(errorCodeMessage('DEPARTMENTS_NAME_REQUIRED', 'en')).toMatch( /^Department name is required/ ) - expect(errorCodeMessage('DEPARTMENTS_DUPLICATE', 'es')).toMatch( - /^Ya existe un departamento/ - ) + expect(errorCodeMessage('DEPARTMENTS_DUPLICATE', 'es')).toMatch(/^Ya existe un departamento/) expect(errorCodeMessage('DEPARTMENTS_DUPLICATE', 'en')).toMatch( /^A department with that name already exists/ ) expect(errorCodeMessage('DEPARTMENTS_HAS_RECORDS', 'es')).toMatch( /^No se puede eliminar un departamento/ ) - expect(errorCodeMessage('DEPARTMENTS_HAS_RECORDS', 'en')).toMatch( - /^Cannot delete department/ - ) - expect(errorCodeMessage('DEPARTMENTS_NOT_FOUND', 'es')).toMatch( - /^Departamento no encontrado/ - ) - expect(errorCodeMessage('DEPARTMENTS_NOT_FOUND', 'en')).toMatch( - /^Department not found/ - ) + expect(errorCodeMessage('DEPARTMENTS_HAS_RECORDS', 'en')).toMatch(/^Cannot delete department/) + expect(errorCodeMessage('DEPARTMENTS_NOT_FOUND', 'es')).toMatch(/^Departamento no encontrado/) + expect(errorCodeMessage('DEPARTMENTS_NOT_FOUND', 'en')).toMatch(/^Department not found/) expect(errorCodeMessage('DEPARTMENTS_CREATE_ERROR', 'es')).toMatch( /^Error al crear departamento/ ) - expect(errorCodeMessage('DEPARTMENTS_CREATE_ERROR', 'en')).toMatch( - /^Error creating department/ - ) + expect(errorCodeMessage('DEPARTMENTS_CREATE_ERROR', 'en')).toMatch(/^Error creating department/) expect(errorCodeMessage('DEPARTMENTS_FETCH_ERROR', 'es')).toMatch( /^Error al recuperar departamentos/ ) - expect(errorCodeMessage('DEPARTMENTS_FETCH_ERROR', 'en')).toMatch( - /^Error fetching departments/ - ) + expect(errorCodeMessage('DEPARTMENTS_FETCH_ERROR', 'en')).toMatch(/^Error fetching departments/) expect(errorCodeMessage('DEPARTMENTS_FETCH_ONE_ERROR', 'es')).toMatch( /^Error al obtener departamento/ ) @@ -81,15 +61,11 @@ describe('errorCodeMessage', () => { expect(errorCodeMessage('DEPARTMENTS_UPDATE_ERROR', 'es')).toMatch( /^Error al actualizar departamento/ ) - expect(errorCodeMessage('DEPARTMENTS_UPDATE_ERROR', 'en')).toMatch( - /^Error updating department/ - ) + expect(errorCodeMessage('DEPARTMENTS_UPDATE_ERROR', 'en')).toMatch(/^Error updating department/) expect(errorCodeMessage('DEPARTMENTS_DELETE_ERROR', 'es')).toMatch( /^Error al eliminar departamento/ ) - expect(errorCodeMessage('DEPARTMENTS_DELETE_ERROR', 'en')).toMatch( - /^Error deleting department/ - ) + expect(errorCodeMessage('DEPARTMENTS_DELETE_ERROR', 'en')).toMatch(/^Error deleting department/) }) it('falls back to Spanish for a language without messages', () => { From 0c0cdb5aa85e18a1e29555fdd9e85e6003ddb75d Mon Sep 17 00:00:00 2001 From: razyd Date: Sat, 10 Oct 2026 18:21:45 +0200 Subject: [PATCH 22/71] test(auth): type the role access mocks --- backend/tests/auth/role-access.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/tests/auth/role-access.test.ts b/backend/tests/auth/role-access.test.ts index 566ff062..b9fbd9cc 100644 --- a/backend/tests/auth/role-access.test.ts +++ b/backend/tests/auth/role-access.test.ts @@ -4,6 +4,7 @@ // change the implementation but keep these tests green. import { describe, it, expect, vi } from 'vitest' +import type { Request, Response } from 'express' import { excludeMantenimiento } from '../../middlewares/roleCheck.js' import { canOpenModule, type AppModule } from '../../services/auth/module-access.js' import { readFileSync } from 'fs' @@ -13,11 +14,11 @@ import { resolve } from 'path' function createMockReqRes(role?: string) { const req = { user: role ? { role } : undefined, - } as any + } as unknown as Request const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), - } as any + } as unknown as Response const next = vi.fn() return { req, res, next } } From 23d4887e2e85e03e4aff14a433d1a1fd4e4d0f61 Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 10:48:04 +0200 Subject: [PATCH 23/71] docs(scheduling): point test headers to the sections that hold their criteria MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_benchmark.py: the 30x31 exit criterion lives in the 2026-05-09 entry of decisions.md, not in 2026-04-22. - test_corpus.py, solver-parity.test.ts: point to «How it is tested» in solver.md and drop a quote that no document contains. Why: the previous commit replaced the archived plan with references that did not hold the cited text. --- backend/scheduling-solver/tests/test_benchmark.py | 2 +- backend/scheduling-solver/tests/test_corpus.py | 2 +- backend/tests/scheduling/solver-parity.test.ts | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/scheduling-solver/tests/test_benchmark.py b/backend/scheduling-solver/tests/test_benchmark.py index 701a3cae..be64e448 100644 --- a/backend/scheduling-solver/tests/test_benchmark.py +++ b/backend/scheduling-solver/tests/test_benchmark.py @@ -1,7 +1,7 @@ """ test_benchmark.py — Benchmark sintético: 30 empleados × 31 días. -Criterio de cierre Fase 2 (docs/scheduling/decisions.md, entrada 2026-04-22): +Criterio de cierre Fase 2 (docs/scheduling/decisions.md, entrada 2026-05-09): - 30 empleados × 31 días resueltos en < 30 segundos con horario factible. - Validación: no hard errors en validator (delegada a tests/scheduling/ TS, aquí sólo medimos status/tiempo del solver). diff --git a/backend/scheduling-solver/tests/test_corpus.py b/backend/scheduling-solver/tests/test_corpus.py index ebc9cd62..0454d1a8 100644 --- a/backend/scheduling-solver/tests/test_corpus.py +++ b/backend/scheduling-solver/tests/test_corpus.py @@ -1,7 +1,7 @@ """ test_corpus.py — Corpus de fixtures del scheduler contra el solver Python. -Objetivo (docs/scheduling/decisions.md, entrada 2026-04-22): +Objetivo (docs/scheduling/solver.md, «How it is tested»): - Ningún fixture produce status='error' (sin crashes internos). - Fixtures con parámetros claramente resolubles → status='ok'. diff --git a/backend/tests/scheduling/solver-parity.test.ts b/backend/tests/scheduling/solver-parity.test.ts index c97d015a..1b9e297d 100644 --- a/backend/tests/scheduling/solver-parity.test.ts +++ b/backend/tests/scheduling/solver-parity.test.ts @@ -1,8 +1,7 @@ // tests/scheduling/solver-parity.test.ts // Parity test: solver output must pass ScheduleValidator with 0 hard errors. // -// docs/scheduling/decisions.md, entrada 2026-04-22: -// "Output del solver pasa ScheduleValidator.validate() con 0 hard errors." +// See docs/scheduling/solver.md, «How it is tested». // // Each test case: build SolverInput from a corpus fixture → run solver → validate matrix. From 286cb5c940fea211deb0f24b5ab62f49d2a00bcc Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 10:56:50 +0200 Subject: [PATCH 24/71] docs(todo): drop the settings tab fallback, now fixed - Remove the "?tab= of a tab without permission" entry. Why: resolveSettingsTab falls back to the first available tab. Checked in the browser: admin with ?tab=xxx and ?tab=demo opens Users, a receptionist with ?tab=users opens Notifications. --- docs/TODO.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 473ba85c..ccacfe42 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -297,9 +297,6 @@ preview. volviera a ejecutar (`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._ -- [ ] **Configuración: `?tab=` de una pestaña sin permiso deja el contenido en - blanco** — p. ej. recepción con `?tab=users`; debería caer en la primera - pestaña disponible. _Visto el 2026-10-05._ - [ ] **Errores del backend sin traducir** — `messages/{es,en}/errors.json` tiene ~200 textos en `codes` que nadie usa: la UI muestra el `error` del backend, en español. `app/lib/helpers/errorCodeMessage.ts` ya los traduce From 4e827495ef280e9d66accbd30bee02035ad24359 Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 11:00:42 +0200 Subject: [PATCH 25/71] refactor(auth): share the front-desk allowlist and word the 403 for any role - module-access.ts: export FRONT_DESK_ROLES and build frontDeskRoles on it. - roleCheck.ts: excludeMantenimiento reads FRONT_DESK_ROLES instead of its own copy, and its 403 no longer says the role only reaches maintenance. Why: the allowlist lived in two files that had to be kept in step, and after the switch to an allowlist the old message was wrong for any role other than mantenimiento. --- backend/middlewares/roleCheck.ts | 7 +++---- backend/services/auth/module-access.ts | 5 ++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/middlewares/roleCheck.ts b/backend/middlewares/roleCheck.ts index d7237864..cb402bb2 100644 --- a/backend/middlewares/roleCheck.ts +++ b/backend/middlewares/roleCheck.ts @@ -1,6 +1,7 @@ // middlewares/roleCheck.ts import { Request, Response, NextFunction } from 'express' +import { FRONT_DESK_ROLES } from '../services/auth/module-access.js' type RoleCheckMiddleware = (req: Request, res: Response, next: NextFunction) => void @@ -164,18 +165,16 @@ export const canAccessMaintenance: RoleCheckMiddleware = (req, res, next) => { * Solo admin, recepcionista y group-admin pueden acceder; el resto (mantenimiento, etc.) no. */ export const excludeMantenimiento: 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' }) return } - if (allowedRoles.includes(req.user.role.toLowerCase())) { + if (FRONT_DESK_ROLES.includes(req.user.role.toLowerCase())) { next() } else { res.status(403).json({ - error: 'Acceso denegado. Tu rol solo tiene acceso al módulo de mantenimiento', + error: 'Acceso denegado. Tu rol no tiene acceso a este módulo', }) } } diff --git a/backend/services/auth/module-access.ts b/backend/services/auth/module-access.ts index 70e63b81..d6883491 100644 --- a/backend/services/auth/module-access.ts +++ b/backend/services/auth/module-access.ts @@ -7,7 +7,10 @@ export type AppModule = 'parking' | 'maintenance' | 'groups' | 'blacklist' | 'cashier' | 'logbook' -const frontDeskRoles = (role: string) => ['admin', 'recepcionista', 'group-admin'].includes(role) +// Front-desk modules: an allowlist, so a new role gets no access until someone decides it +export const FRONT_DESK_ROLES: readonly string[] = ['admin', 'recepcionista', 'group-admin'] + +const frontDeskRoles = (role: string) => FRONT_DESK_ROLES.includes(role) const staffRoles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento'] const MODULE_ACCESS: Record boolean> = { From 4c1023a5caf243b3265c0be3f4fc7ff9c2a55fbd Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 11:00:43 +0200 Subject: [PATCH 26/71] docs(todo): drop the excludeMantenimiento allowlist, now done - Remove the "excludeMantenimiento is an exclusion list" entry. Why: the guard and module-access.ts share FRONT_DESK_ROLES, an allowlist; role-access.test.ts pins the four roles and denies an unknown one. --- docs/TODO.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 473ba85c..539dd3f2 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -286,13 +286,6 @@ preview. de Python. Enlazar la demo y el repo. Es el gancho para promocionar el proyecto. Se escribe en el repo `portfolio-bp`, no aquí. _Decidido el 2026-10-05._ -- [ ] **`excludeMantenimiento` es una lista de exclusión** — parking, lista - negra, caja, partes, conciliación y departamentos dejan pasar cualquier - rol salvo `mantenimiento`, y `services/auth/module-access.ts` lo replica. - Hoy solo hay roles conocidos (el rol va en el token firmado y sale de - `roles`), pero un rol nuevo tendría acceso sin que nadie lo decida. Pasar - a lista de permitidos. _Comprobado el 2026-10-05 al retirar - `demo-admin`._ - [ ] **`20260512_disable_demo_user.sql` desactivaría la cuenta demo** si se volviera a ejecutar (`UPDATE users SET is_active = 0 WHERE username = 'demo'`). `setup:local` no la ejecuta (es anterior al baseline); no From fef59425ff630bf66b70ccc1166c6b11b16d4321 Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 11:02:51 +0200 Subject: [PATCH 27/71] docs(todo): drop the remaining backend any, now typed - Remove the "55 uses of any left in the backend" entry. Why: auth, config, cron, scripts and tests are typed and no-explicit-any is an error again; the backend lint reports 0 problems. --- docs/TODO.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 473ba85c..4e0c8052 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -105,11 +105,6 @@ preview. - [ ] **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._ -- [ ] **Backend: quedan 55 usos de `any`** — `no-explicit-any` está como aviso - en `backend/eslint.config.js` para que el lint pase. El 2026-10-04 se - tiparon controllers, services y repositories de los 15 módulos (de 316 - avisos a 55); quedan auth, `config/db.ts`, cron, scripts y tests. Tiparlos - y volver a poner la regla como error. - [ ] **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 From 259d97c4e8aaf7660bee713338ee329a0d2720cf Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 11:12:32 +0200 Subject: [PATCH 28/71] fix(api): send the code with the blacklist image and demo reset errors - blacklist-controller.ts: getImage 404s carry code NOT_FOUND. - demo-reset-controller.ts: failed reset and status 500s carry code INTERNAL_ERROR. Why: the frontend translates errors by `code`; without it the user saw the raw value of `error`, which is the code itself. --- backend/controllers/blacklist/blacklist-controller.ts | 8 ++++++-- backend/controllers/demo/demo-reset-controller.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/backend/controllers/blacklist/blacklist-controller.ts b/backend/controllers/blacklist/blacklist-controller.ts index 7b06d2cd..9e183b3a 100644 --- a/backend/controllers/blacklist/blacklist-controller.ts +++ b/backend/controllers/blacklist/blacklist-controller.ts @@ -420,7 +420,9 @@ export class BlacklistController { static async getImage(req: Request, res: Response): Promise { const file = String(req.params.file) if (!isBlacklistImageFile(file)) { - res.status(404).json({ success: false, error: ERROR_CODES.NOT_FOUND }) + res + .status(404) + .json({ success: false, error: ERROR_CODES.NOT_FOUND, code: ERROR_CODES.NOT_FOUND }) return } const { publicId, format } = parseBlacklistImageFile(file) @@ -435,7 +437,9 @@ export class BlacklistController { sendPrivateFile(res, fetched) } catch (error) { logger.warn({ err: error, file }, '[BlacklistController.getImage] Not available') - res.status(404).json({ success: false, error: ERROR_CODES.NOT_FOUND }) + res + .status(404) + .json({ success: false, error: ERROR_CODES.NOT_FOUND, code: ERROR_CODES.NOT_FOUND }) } } diff --git a/backend/controllers/demo/demo-reset-controller.ts b/backend/controllers/demo/demo-reset-controller.ts index feeed28d..a8f3b2f9 100644 --- a/backend/controllers/demo/demo-reset-controller.ts +++ b/backend/controllers/demo/demo-reset-controller.ts @@ -21,7 +21,9 @@ function answer(res: Response, result: DemoResetResult): void { return } if (result.status === 'failed') { - res.status(500).json({ success: false, error: ERROR_CODES.INTERNAL_ERROR }) + res + .status(500) + .json({ success: false, error: ERROR_CODES.INTERNAL_ERROR, code: ERROR_CODES.INTERNAL_ERROR }) return } res.status(200).json({ success: true, data: result }) @@ -32,7 +34,9 @@ export async function getStatus(_req: Request, res: Response): Promise { res.status(200).json({ success: true, data: await getDemoStatus() }) } catch (error) { logger.error({ err: error }, '[DEMO] status error') - res.status(500).json({ success: false, error: ERROR_CODES.INTERNAL_ERROR }) + res + .status(500) + .json({ success: false, error: ERROR_CODES.INTERNAL_ERROR, code: ERROR_CODES.INTERNAL_ERROR }) } } From 7dd71a4c909b2a0baf5f05386227cb34f23fa848 Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 11:12:32 +0200 Subject: [PATCH 29/71] feat(i18n): translate the remaining error codes and plain-fetch errors - errorCodeMessage.ts: add the 24 codes the backend sends that were still untranslated (AUTH, generic ones such as FORBIDDEN or INVALID_ID, and five supplier codes); keep the set in alphabetical order. - errors.json (es, en): add texts for AUTH_DEMO_UNAVAILABLE and the five supplier codes that had none. - errorBodyMessage: shared helper for errors read with a plain fetch. Used by the login, the invoice PDF download and SupplierInvoicesModal, which showed `error` as is. - error-code-message.test.ts: new guard that every ERROR_CODES.X in the backend has a text in es and en; drop the assertions that required those codes to stay untranslated; cover errorBodyMessage. Why: a failed login showed AUTH_INVALID_CREDENTIALS on screen. Checked in the browser in es and en: wrong password and duplicate supplier name. --- .../tests/frontend/error-code-message.test.ts | 65 +++- .../bo/modals/SupplierInvoicesModal.tsx | 3 +- frontend/app/lib/auth/authService.ts | 3 +- frontend/app/lib/backoffice/backofficeApi.ts | 3 +- frontend/app/lib/helpers/errorCodeMessage.ts | 320 ++++++++++-------- frontend/messages/en/errors.json | 6 + frontend/messages/es/errors.json | 6 + 7 files changed, 249 insertions(+), 157 deletions(-) diff --git a/backend/tests/frontend/error-code-message.test.ts b/backend/tests/frontend/error-code-message.test.ts index d500f736..b35fa180 100644 --- a/backend/tests/frontend/error-code-message.test.ts +++ b/backend/tests/frontend/error-code-message.test.ts @@ -2,9 +2,42 @@ // The text the user sees for a backend error code, in each page language. import { describe, it, expect } from 'vitest' -import { errorCodeMessage } from '../../../frontend/app/lib/helpers/errorCodeMessage.js' +import { readdirSync, readFileSync } from 'fs' +import { join, resolve } from 'path' +import { + errorBodyMessage, + errorCodeMessage, +} from '../../../frontend/app/lib/helpers/errorCodeMessage.js' + +// Every ERROR_CODES.X the backend source mentions (tests and the catalogue aside) +function backendErrorCodes(): string[] { + const root = resolve(__dirname, '../..') + const skip = new Set(['node_modules', 'tests', 'scheduling-solver', 'db-mysql']) + const codes = new Set() + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!skip.has(entry.name) && !entry.name.startsWith('.')) walk(join(dir, entry.name)) + } else if (entry.name.endsWith('.ts') && entry.name !== 'error-codes.ts') { + const source = readFileSync(join(dir, entry.name), 'utf-8') + for (const m of source.matchAll(/ERROR_CODES\.([A-Z0-9_]+)/g)) codes.add(m[1]) + } + } + } + walk(root) + return [...codes].sort() +} describe('errorCodeMessage', () => { + it('has a text in es and en for every error code the backend sends', () => { + const codes = backendErrorCodes() + expect(codes.length).toBeGreaterThan(250) + const missing = codes.filter( + (code) => !errorCodeMessage(code, 'es') || !errorCodeMessage(code, 'en') + ) + expect(missing).toEqual([]) + }) + it('gives the storage message in Spanish and in English', () => { expect(errorCodeMessage('STORAGE_NOT_CONFIGURED', 'es')).toMatch( /^El almacenamiento de archivos no está configurado/ @@ -150,14 +183,6 @@ describe('errorCodeMessage', () => { expect(typeof enText).toBe('string') expect(enText!.length).toBeGreaterThan(0) }) - - it('excludes BACKOFFICE codes without translation', () => { - expect(errorCodeMessage('BACKOFFICE_SUPPLIER_INACTIVE_EXISTS', 'es')).toBeUndefined() - expect(errorCodeMessage('BACKOFFICE_SUPPLIER_HAS_INVOICES', 'es')).toBeUndefined() - expect(errorCodeMessage('BACKOFFICE_INACTIVATE_SUPPLIER_ERROR', 'es')).toBeUndefined() - expect(errorCodeMessage('BACKOFFICE_ACTIVATE_SUPPLIER_ERROR', 'es')).toBeUndefined() - expect(errorCodeMessage('BACKOFFICE_DELETE_SUPPLIER_ERROR', 'es')).toBeUndefined() - }) }) describe('NOTIFICATIONS codes', () => { @@ -192,8 +217,6 @@ describe('errorCodeMessage', () => { }) it('excludes codes without translation', () => { - expect(errorCodeMessage('AUTH_DEMO_UNAVAILABLE', 'es')).toBeUndefined() - expect(errorCodeMessage('AUTH_DEMO_UNAVAILABLE', 'en')).toBeUndefined() // Success messages are in "success" section, not "codes" expect(errorCodeMessage('NOTIFICATIONS_PROCESSED', 'es')).toBeUndefined() expect(errorCodeMessage('NOTIFICATIONS_CREATED', 'es')).toBeUndefined() @@ -557,4 +580,24 @@ describe('errorCodeMessage', () => { expect(enText!.length).toBeGreaterThan(0) }) }) + + describe('errorBodyMessage', () => { + it('translates a known code (no document here: Spanish)', () => { + expect( + errorBodyMessage({ error: 'AUTH_INVALID_CREDENTIALS', code: 'AUTH_INVALID_CREDENTIALS' }) + ).toBe(errorCodeMessage('AUTH_INVALID_CREDENTIALS', 'es')) + }) + + it('keeps the backend text when the code is unknown or missing', () => { + expect(errorBodyMessage({ error: 'Texto del backend', code: 'NO_SUCH_CODE' })).toBe( + 'Texto del backend' + ) + expect(errorBodyMessage({ error: 'Texto del backend' })).toBe('Texto del backend') + }) + + it('gives undefined when there is nothing to show', () => { + expect(errorBodyMessage(null)).toBeUndefined() + expect(errorBodyMessage({ error: 42 })).toBeUndefined() + }) + }) }) diff --git a/frontend/app/components/bo/modals/SupplierInvoicesModal.tsx b/frontend/app/components/bo/modals/SupplierInvoicesModal.tsx index 55133dca..d6137218 100644 --- a/frontend/app/components/bo/modals/SupplierInvoicesModal.tsx +++ b/frontend/app/components/bo/modals/SupplierInvoicesModal.tsx @@ -28,6 +28,7 @@ import type { } from '@/app/lib/backoffice/types' import { formatCurrency, INVOICE_STATUS_COLORS } from '@/app/lib/backoffice/types' import { backofficeApi } from '@/app/lib/backoffice/backofficeApi' +import { errorBodyMessage } from '@/app/lib/helpers/errorCodeMessage' import toast from 'react-hot-toast' interface SupplierInvoicesModalProps { @@ -114,7 +115,7 @@ export function SupplierInvoicesModal({ if (!response.ok) { const errorData = await response.json().catch(() => ({ error: t('toast.unknownError') })) - throw new Error(errorData.error || `Error ${response.status}`) + throw new Error(errorBodyMessage(errorData) || `Error ${response.status}`) } // Create blob and trigger download diff --git a/frontend/app/lib/auth/authService.ts b/frontend/app/lib/auth/authService.ts index 8eda4a1f..6288d8cb 100644 --- a/frontend/app/lib/auth/authService.ts +++ b/frontend/app/lib/auth/authService.ts @@ -8,6 +8,7 @@ import { apiClient } from '@/app/lib/apiClient' import { API_BASE_URL } from '@/app/lib/env' +import { errorBodyMessage } from '@/app/lib/helpers/errorCodeMessage' const isDev = process.env.NODE_ENV === 'development' @@ -27,7 +28,7 @@ export const authLogin = { if (!res.ok) { const data = await res.json().catch(() => null) - const message = data?.error || data?.message || 'Login failed' + const message = errorBodyMessage(data) || data?.message || 'Login failed' throw new Error(message) } diff --git a/frontend/app/lib/backoffice/backofficeApi.ts b/frontend/app/lib/backoffice/backofficeApi.ts index defda6cd..a8e53a4c 100644 --- a/frontend/app/lib/backoffice/backofficeApi.ts +++ b/frontend/app/lib/backoffice/backofficeApi.ts @@ -5,6 +5,7 @@ */ import { apiClient } from '@/app/lib/apiClient' +import { errorBodyMessage } from '@/app/lib/helpers/errorCodeMessage' import type { Category, SupplierWithStats, @@ -357,7 +358,7 @@ export const backofficeApi = { if (!response.ok) { const errorData = await response.json().catch(() => ({ error: 'Error desconocido' })) - throw new Error(errorData.error || `Error ${response.status}`) + throw new Error(errorBodyMessage(errorData) || `Error ${response.status}`) } return response.blob() diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index 0b822b43..09360dee 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -11,6 +11,19 @@ const TRANSLATED = new Set([ 'ACTIVITY_FETCH_ERROR', 'ACTIVITY_INVALID_DATE_FORMAT', 'ACTIVITY_INVALID_SOURCE', + 'AUTH_CREDENTIALS_REQUIRED', + 'AUTH_CURRENT_PASSWORD_INCORRECT', + 'AUTH_DELETE_AVATAR_ERROR', + 'AUTH_DEMO_UNAVAILABLE', + 'AUTH_INVALID_CREDENTIALS', + 'AUTH_INVALID_FILE_TYPE', + 'AUTH_NO_IMAGE_PROVIDED', + 'AUTH_REFRESH_TOKEN_INVALID', + 'AUTH_REFRESH_TOKEN_MISSING', + 'AUTH_UPLOAD_AVATAR_ERROR', + 'AUTH_USER_NOT_AUTHENTICATED', + 'AUTH_USER_NOT_FOUND', + 'BACKOFFICE_ACTIVATE_SUPPLIER_ERROR', 'BACKOFFICE_ASSET_NOT_FOUND', 'BACKOFFICE_BATCH_PAYMENT_ERROR', 'BACKOFFICE_BATCH_PAYMENT_PREVIEW_ERROR', @@ -24,65 +37,60 @@ const TRANSLATED = new Set([ 'BACKOFFICE_DEACTIVATE_SUPPLIER_ERROR', 'BACKOFFICE_DELETE_ASSET_ERROR', 'BACKOFFICE_DELETE_INVOICE_ERROR', + 'BACKOFFICE_DELETE_SUPPLIER_ERROR', 'BACKOFFICE_DOWNLOAD_PDF_ERROR', 'BACKOFFICE_FETCH_ASSETS_ERROR', 'BACKOFFICE_FETCH_CATEGORIES_ERROR', 'BACKOFFICE_FETCH_CATEGORY_ERROR', 'BACKOFFICE_FETCH_HISTORY_ERROR', - 'BACKOFFICE_FETCH_INVOICE_ERROR', 'BACKOFFICE_FETCH_INVOICES_ERROR', + 'BACKOFFICE_FETCH_INVOICE_ERROR', 'BACKOFFICE_FETCH_MONTHLY_SUMMARY_ERROR', 'BACKOFFICE_FETCH_STATS_ERROR', - 'BACKOFFICE_FETCH_SUPPLIER_ERROR', 'BACKOFFICE_FETCH_SUPPLIERS_ERROR', + 'BACKOFFICE_FETCH_SUPPLIER_ERROR', 'BACKOFFICE_FILE_TOO_LARGE', 'BACKOFFICE_GET_PDF_URL_ERROR', + 'BACKOFFICE_INACTIVATE_SUPPLIER_ERROR', 'BACKOFFICE_INVALID_ASSET_TYPE', 'BACKOFFICE_INVALID_IMAGE_TYPE', 'BACKOFFICE_INVALID_MONTH', 'BACKOFFICE_INVALID_STATUS', + 'BACKOFFICE_INVOICES_NOT_FOUND', 'BACKOFFICE_INVOICE_FIELDS_REQUIRED', 'BACKOFFICE_INVOICE_IDS_REQUIRED', 'BACKOFFICE_INVOICE_NOT_FOUND', 'BACKOFFICE_INVOICE_NOT_VALIDATED', - 'BACKOFFICE_INVOICES_NOT_FOUND', 'BACKOFFICE_MARK_PAID_ERROR', 'BACKOFFICE_MAX_INVOICES_EXCEEDED', 'BACKOFFICE_NAME_REQUIRED', 'BACKOFFICE_NO_FILE_SENT', 'BACKOFFICE_NO_IMAGE_SENT', + 'BACKOFFICE_PAYMENT_DATE_REQUIRED', 'BACKOFFICE_PDF_ONLY', 'BACKOFFICE_PDF_TYPE_REQUIRED', - 'BACKOFFICE_PAYMENT_DATE_REQUIRED', - 'BACKOFFICE_REJECT_INVOICE_ERROR', 'BACKOFFICE_REJECTION_REASON_REQUIRED', + 'BACKOFFICE_REJECT_INVOICE_ERROR', 'BACKOFFICE_REVERT_BATCH_ERROR', 'BACKOFFICE_REVERT_BATCH_PREVIEW_ERROR', 'BACKOFFICE_REVERT_PAYMENT_ERROR', 'BACKOFFICE_REVERT_VALIDATION_ERROR', 'BACKOFFICE_SET_DEFAULT_ERROR', 'BACKOFFICE_SUPPLIER_EXISTS', + 'BACKOFFICE_SUPPLIER_HAS_INVOICES', + 'BACKOFFICE_SUPPLIER_INACTIVE_EXISTS', 'BACKOFFICE_SUPPLIER_NOT_FOUND', 'BACKOFFICE_UPDATE_INVOICE_ERROR', 'BACKOFFICE_UPDATE_SUPPLIER_ERROR', 'BACKOFFICE_UPLOAD_PDF_ERROR', 'BACKOFFICE_VALIDATE_INVOICE_ERROR', 'BACKOFFICE_YEAR_MONTH_REQUIRED', - 'DEPARTMENTS_CREATE_ERROR', - 'DEPARTMENTS_DELETE_ERROR', - 'DEPARTMENTS_DUPLICATE', - 'DEPARTMENTS_FETCH_ERROR', - 'DEPARTMENTS_FETCH_ONE_ERROR', - 'DEPARTMENTS_HAS_RECORDS', - 'DEPARTMENTS_NAME_REQUIRED', - 'DEPARTMENTS_NOT_FOUND', - 'DEPARTMENTS_UPDATE_ERROR', 'BLACKLIST_CREATE_RECORD_ERROR', 'BLACKLIST_DELETE_IMAGE_ERROR', 'BLACKLIST_DELETE_RECORD_ERROR', 'BLACKLIST_DOCUMENT_EXISTS', - 'BLACKLIST_FETCH_RECORD_ERROR', 'BLACKLIST_FETCH_RECORDS_ERROR', + 'BLACKLIST_FETCH_RECORD_ERROR', 'BLACKLIST_FETCH_STATS_ERROR', 'BLACKLIST_FILE_TOO_LARGE', 'BLACKLIST_IMAGE_NOT_FOUND', @@ -93,6 +101,51 @@ const TRANSLATED = new Set([ 'BLACKLIST_RESTORE_RECORD_ERROR', 'BLACKLIST_UPDATE_RECORD_ERROR', 'BLACKLIST_UPLOAD_IMAGE_ERROR', + 'CASHIER_CLOSE_DAY_ERROR', + 'CASHIER_CREATE_DENOMINATION_ERROR', + 'CASHIER_CREATE_PAYMENT_ERROR', + 'CASHIER_CREATE_VOUCHER_ERROR', + 'CASHIER_DATE_RANGE_REQUIRED', + 'CASHIER_DAY_ALREADY_INITIALIZED', + 'CASHIER_DAY_CANNOT_CLOSE', + 'CASHIER_DAY_NOT_FOUND', + 'CASHIER_DELETE_DENOMINATION_ERROR', + 'CASHIER_DELETE_PAYMENT_ERROR', + 'CASHIER_FETCH_ACTIVE_VOUCHERS_ERROR', + 'CASHIER_FETCH_DAILY_REPORT_ERROR', + 'CASHIER_FETCH_DASHBOARD_ERROR', + 'CASHIER_FETCH_DAYS_ERROR', + 'CASHIER_FETCH_DAY_ERROR', + 'CASHIER_FETCH_DENOMINATIONS_ERROR', + 'CASHIER_FETCH_HISTORY_ERROR', + 'CASHIER_FETCH_HISTORY_STATS_ERROR', + 'CASHIER_FETCH_MONTHLY_SUMMARY_ERROR', + 'CASHIER_FETCH_PAYMENTS_ERROR', + 'CASHIER_FETCH_PAYMENT_SUMMARY_ERROR', + 'CASHIER_FETCH_PERIOD_REPORT_ERROR', + 'CASHIER_FETCH_RECENT_ACTIVITY_ERROR', + 'CASHIER_FETCH_SHIFTS_ERROR', + 'CASHIER_FETCH_SHIFT_ERROR', + 'CASHIER_FETCH_SHIFT_HISTORY_ERROR', + 'CASHIER_FETCH_SHIFT_SUMMARY_ERROR', + 'CASHIER_FETCH_SUMMARY_ERROR', + 'CASHIER_FETCH_VOUCHERS_ERROR', + 'CASHIER_FETCH_VOUCHER_ERROR', + 'CASHIER_FETCH_VOUCHER_HISTORY_ERROR', + 'CASHIER_FETCH_VOUCHER_STATS_ERROR', + 'CASHIER_INIT_DAY_ERROR', + 'CASHIER_INVALID_DATE_FORMAT', + 'CASHIER_INVALID_DATE_RANGE', + 'CASHIER_INVALID_SHIFT_ID', + 'CASHIER_INVALID_YEAR_MONTH', + 'CASHIER_REOPEN_DAY_ERROR', + 'CASHIER_SHIFTS_ALREADY_EXIST', + 'CASHIER_SHIFT_NOT_FOUND', + 'CASHIER_UPDATE_DENOMINATIONS_ERROR', + 'CASHIER_UPDATE_PAYMENTS_ERROR', + 'CASHIER_UPDATE_USERS_ERROR', + 'CASHIER_VOUCHER_MAX_REACHED', + 'CASHIER_VOUCHER_NOT_FOUND', 'CONCILIATION_ADMIN_ONLY_CLOSE', 'CONCILIATION_ALREADY_EXISTS', 'CONCILIATION_CLOSED_CANNOT_MODIFY', @@ -116,84 +169,16 @@ const TRANSLATED = new Set([ 'CONCILIATION_RECALCULATE_ERROR', 'CONCILIATION_UPDATE_FORM_ERROR', 'CONCILIATION_UPDATE_STATUS_ERROR', - 'LOGBOOK_COMMENT_CREATE_ERROR', - 'LOGBOOK_COMMENT_DELETE_ERROR', - 'LOGBOOK_COMMENT_NOT_FOUND', - 'LOGBOOK_COMMENT_NOT_IN_LOGBOOK', - 'LOGBOOK_COMMENT_NO_PERMISSION', - 'LOGBOOK_COMMENT_UPDATE_ERROR', - 'LOGBOOK_CREATE_ERROR', - 'LOGBOOK_DELETE_ERROR', - 'LOGBOOK_FETCH_BY_AUTHOR_ERROR', - 'LOGBOOK_FETCH_BY_DAY_ERROR', - 'LOGBOOK_FETCH_BY_DEPARTMENT_ERROR', - 'LOGBOOK_FETCH_BY_IMPORTANCE_ERROR', - 'LOGBOOK_FETCH_ERROR', - 'LOGBOOK_FETCH_READERS_ERROR', - 'LOGBOOK_FETCH_SOLVER_ERROR', - 'LOGBOOK_INVALID_IMPORTANCE', - 'LOGBOOK_NOT_FOUND', - 'LOGBOOK_NOT_SOLVED', - 'LOGBOOK_ONLY_AUTHOR_DELETE', - 'LOGBOOK_ONLY_AUTHOR_UPDATE', - 'LOGBOOK_READ_ERROR', - 'LOGBOOK_REOPEN_ERROR', - 'LOGBOOK_SOLVE_ERROR', - 'LOGBOOK_UNREAD_ERROR', - 'NOTIFICATIONS_CREATE_ERROR', - 'NOTIFICATIONS_DELETE_ERROR', - 'NOTIFICATIONS_FETCH_COUNT_ERROR', - 'NOTIFICATIONS_FETCH_ERROR', - 'NOTIFICATIONS_FETCH_GROUP_ERROR', - 'NOTIFICATIONS_FETCH_UNREAD_ERROR', - 'NOTIFICATIONS_INVALID_GROUP_ID', - 'NOTIFICATIONS_INVALID_ID', - 'NOTIFICATIONS_INVALID_MODULE', - 'NOTIFICATIONS_INVALID_SCHEDULED_DATE', - 'NOTIFICATIONS_MARK_ALL_ERROR', - 'NOTIFICATIONS_MARK_ERROR', - 'NOTIFICATIONS_MARK_READ_ERROR', - 'NOTIFICATIONS_MISSING_REQUIRED_FIELDS', - 'NOTIFICATIONS_NOT_FOUND', - 'NOTIFICATIONS_PROCESS_ERROR', - 'PARKING_ANALYZE_BOOKINGS_ERROR', - 'PARKING_BOOKING_NOT_FOUND', - 'PARKING_CANCEL_ERROR', - 'PARKING_CANNOT_DELETE_STARTED', - 'PARKING_CANNOT_DELETE_STATUS', - 'PARKING_CANNOT_UPDATE_STATUS', - 'PARKING_CHECKIN_ERROR', - 'PARKING_CHECKOUT_ERROR', - 'PARKING_COMPARE_PERIODS_ERROR', - 'PARKING_COMPARISON_DATES_REQUIRED', - 'PARKING_CREATE_BOOKING_ERROR', - 'PARKING_DATE_RANGE_REQUIRED', - 'PARKING_DELETE_BOOKING_ERROR', - 'PARKING_END_BEFORE_START', - 'PARKING_FETCH_AVAILABILITY_ERROR', - 'PARKING_FETCH_BOOKING_ERROR', - 'PARKING_FETCH_BOOKINGS_ERROR', - 'PARKING_FETCH_DASHBOARD_ERROR', - 'PARKING_FETCH_DELAYED_ERROR', - 'PARKING_FETCH_OCCUPANCY_ERROR', - 'PARKING_FETCH_PENDING_CHECKINS_ERROR', - 'PARKING_FETCH_PENDING_CHECKOUTS_ERROR', - 'PARKING_FETCH_PERFORMANCE_ERROR', - 'PARKING_FETCH_RANGE_OCCUPANCY_ERROR', - 'PARKING_FETCH_RANGE_STATS_ERROR', - 'PARKING_FETCH_STATS_ERROR', - 'PARKING_FETCH_TRENDS_ERROR', - 'PARKING_INVALID_AMOUNT', - 'PARKING_INVALID_CODE_FORMAT', - 'PARKING_INVALID_DATE', - 'PARKING_INVALID_DATE_FORMAT', - 'PARKING_INVALID_DATES', - 'PARKING_INVALID_END_DATE', - 'PARKING_INVALID_START_DATE', - 'PARKING_NO_FIELDS_TO_UPDATE', - 'PARKING_NOSHOW_ERROR', - 'PARKING_START_BEFORE_END', - 'PARKING_UPDATE_BOOKING_ERROR', + 'DEPARTMENTS_CREATE_ERROR', + 'DEPARTMENTS_DELETE_ERROR', + 'DEPARTMENTS_DUPLICATE', + 'DEPARTMENTS_FETCH_ERROR', + 'DEPARTMENTS_FETCH_ONE_ERROR', + 'DEPARTMENTS_HAS_RECORDS', + 'DEPARTMENTS_NAME_REQUIRED', + 'DEPARTMENTS_NOT_FOUND', + 'DEPARTMENTS_UPDATE_ERROR', + 'FORBIDDEN', 'GROUP_CONTACT_CREATE_ERROR', 'GROUP_CONTACT_DELETE_ERROR', 'GROUP_CONTACT_FETCH_ERROR', @@ -236,6 +221,34 @@ const TRANSLATED = new Set([ 'GROUP_STATUS_UPDATE_CONTRACT_ERROR', 'GROUP_STATUS_UPDATE_ROOMING_ERROR', 'GROUP_UPDATE_ERROR', + 'INTERNAL_ERROR', + 'INVALID_DATA', + 'INVALID_DATE_FORMAT', + 'INVALID_ID', + 'LOGBOOK_COMMENT_CREATE_ERROR', + 'LOGBOOK_COMMENT_DELETE_ERROR', + 'LOGBOOK_COMMENT_NOT_FOUND', + 'LOGBOOK_COMMENT_NOT_IN_LOGBOOK', + 'LOGBOOK_COMMENT_NO_PERMISSION', + 'LOGBOOK_COMMENT_UPDATE_ERROR', + 'LOGBOOK_CREATE_ERROR', + 'LOGBOOK_DELETE_ERROR', + 'LOGBOOK_FETCH_BY_AUTHOR_ERROR', + 'LOGBOOK_FETCH_BY_DAY_ERROR', + 'LOGBOOK_FETCH_BY_DEPARTMENT_ERROR', + 'LOGBOOK_FETCH_BY_IMPORTANCE_ERROR', + 'LOGBOOK_FETCH_ERROR', + 'LOGBOOK_FETCH_READERS_ERROR', + 'LOGBOOK_FETCH_SOLVER_ERROR', + 'LOGBOOK_INVALID_IMPORTANCE', + 'LOGBOOK_NOT_FOUND', + 'LOGBOOK_NOT_SOLVED', + 'LOGBOOK_ONLY_AUTHOR_DELETE', + 'LOGBOOK_ONLY_AUTHOR_UPDATE', + 'LOGBOOK_READ_ERROR', + 'LOGBOOK_REOPEN_ERROR', + 'LOGBOOK_SOLVE_ERROR', + 'LOGBOOK_UNREAD_ERROR', 'MESSAGES_ADD_PARTICIPANTS_ERROR', 'MESSAGES_ADMIN_ONLY_ADD', 'MESSAGES_ADMIN_ONLY_EDIT', @@ -249,16 +262,16 @@ const TRANSLATED = new Set([ 'MESSAGES_DM_ONE_PARTICIPANT', 'MESSAGES_EDIT_MESSAGE_ERROR', 'MESSAGES_EMPTY_MESSAGE', - 'MESSAGES_FETCH_CONVERSATION_ERROR', 'MESSAGES_FETCH_CONVERSATIONS_ERROR', + 'MESSAGES_FETCH_CONVERSATION_ERROR', 'MESSAGES_FETCH_MESSAGES_ERROR', 'MESSAGES_FETCH_UNREAD_ERROR', - 'MESSAGES_GROUP_MAX_PARTICIPANTS', - 'MESSAGES_GROUP_NAME_REQUIRED', - 'MESSAGES_GROUP_NAME_TOO_LONG', 'MESSAGES_GROUPS_ONLY_ADD', 'MESSAGES_GROUPS_ONLY_EDIT', 'MESSAGES_GROUPS_ONLY_REMOVE', + 'MESSAGES_GROUP_MAX_PARTICIPANTS', + 'MESSAGES_GROUP_NAME_REQUIRED', + 'MESSAGES_GROUP_NAME_TOO_LONG', 'MESSAGES_INVALID_CONVERSATION_ID', 'MESSAGES_INVALID_MESSAGE_ID', 'MESSAGES_LEAVE_CONVERSATION_ERROR', @@ -266,8 +279,8 @@ const TRANSLATED = new Set([ 'MESSAGES_MESSAGE_NOT_FOUND', 'MESSAGES_MESSAGE_TOO_LONG', 'MESSAGES_MISSING_REQUIRED_FIELDS', - 'MESSAGES_NO_ACCESS', 'MESSAGES_NOT_PARTICIPANT', + 'MESSAGES_NO_ACCESS', 'MESSAGES_ONLY_OWN_DELETE', 'MESSAGES_ONLY_OWN_EDIT', 'MESSAGES_REMOVE_PARTICIPANT_ERROR', @@ -278,52 +291,63 @@ const TRANSLATED = new Set([ 'MESSAGES_SPECIFY_USER', 'MESSAGES_UPDATE_CONVERSATION_ERROR', 'MESSAGES_USE_LEAVE_OPTION', - 'CASHIER_CLOSE_DAY_ERROR', - 'CASHIER_CREATE_DENOMINATION_ERROR', - 'CASHIER_CREATE_PAYMENT_ERROR', - 'CASHIER_CREATE_VOUCHER_ERROR', - 'CASHIER_DATE_RANGE_REQUIRED', - 'CASHIER_DAY_ALREADY_INITIALIZED', - 'CASHIER_DAY_CANNOT_CLOSE', - 'CASHIER_DAY_NOT_FOUND', - 'CASHIER_DELETE_DENOMINATION_ERROR', - 'CASHIER_DELETE_PAYMENT_ERROR', - 'CASHIER_FETCH_DAILY_REPORT_ERROR', - 'CASHIER_FETCH_DAY_ERROR', - 'CASHIER_FETCH_DAYS_ERROR', - 'CASHIER_FETCH_DASHBOARD_ERROR', - 'CASHIER_FETCH_DENOMINATIONS_ERROR', - 'CASHIER_FETCH_HISTORY_ERROR', - 'CASHIER_FETCH_HISTORY_STATS_ERROR', - 'CASHIER_FETCH_MONTHLY_SUMMARY_ERROR', - 'CASHIER_FETCH_PAYMENT_SUMMARY_ERROR', - 'CASHIER_FETCH_PAYMENTS_ERROR', - 'CASHIER_FETCH_PERIOD_REPORT_ERROR', - 'CASHIER_FETCH_RECENT_ACTIVITY_ERROR', - 'CASHIER_FETCH_SHIFT_ERROR', - 'CASHIER_FETCH_SHIFT_HISTORY_ERROR', - 'CASHIER_FETCH_SHIFT_SUMMARY_ERROR', - 'CASHIER_FETCH_SHIFTS_ERROR', - 'CASHIER_FETCH_SUMMARY_ERROR', - 'CASHIER_FETCH_VOUCHER_ERROR', - 'CASHIER_FETCH_VOUCHER_HISTORY_ERROR', - 'CASHIER_FETCH_VOUCHER_STATS_ERROR', - 'CASHIER_FETCH_VOUCHERS_ERROR', - 'CASHIER_FETCH_ACTIVE_VOUCHERS_ERROR', - 'CASHIER_INIT_DAY_ERROR', - 'CASHIER_INVALID_DATE_FORMAT', - 'CASHIER_INVALID_DATE_RANGE', - 'CASHIER_INVALID_SHIFT_ID', - 'CASHIER_INVALID_YEAR_MONTH', - 'CASHIER_REOPEN_DAY_ERROR', - 'CASHIER_SHIFT_NOT_FOUND', - 'CASHIER_SHIFTS_ALREADY_EXIST', - 'CASHIER_UPDATE_DENOMINATIONS_ERROR', - 'CASHIER_UPDATE_PAYMENTS_ERROR', - 'CASHIER_UPDATE_USERS_ERROR', - 'CASHIER_VOUCHER_MAX_REACHED', - 'CASHIER_VOUCHER_NOT_FOUND', + 'NOTIFICATIONS_CREATE_ERROR', + 'NOTIFICATIONS_DELETE_ERROR', + 'NOTIFICATIONS_FETCH_COUNT_ERROR', + 'NOTIFICATIONS_FETCH_ERROR', + 'NOTIFICATIONS_FETCH_GROUP_ERROR', + 'NOTIFICATIONS_FETCH_UNREAD_ERROR', + 'NOTIFICATIONS_INVALID_GROUP_ID', + 'NOTIFICATIONS_INVALID_ID', + 'NOTIFICATIONS_INVALID_MODULE', + 'NOTIFICATIONS_INVALID_SCHEDULED_DATE', + 'NOTIFICATIONS_MARK_ALL_ERROR', + 'NOTIFICATIONS_MARK_ERROR', + 'NOTIFICATIONS_MARK_READ_ERROR', + 'NOTIFICATIONS_MISSING_REQUIRED_FIELDS', + 'NOTIFICATIONS_NOT_FOUND', + 'NOTIFICATIONS_PROCESS_ERROR', + 'NOT_FOUND', + 'PARKING_ANALYZE_BOOKINGS_ERROR', + 'PARKING_BOOKING_NOT_FOUND', + 'PARKING_CANCEL_ERROR', + 'PARKING_CANNOT_DELETE_STARTED', + 'PARKING_CANNOT_DELETE_STATUS', + 'PARKING_CANNOT_UPDATE_STATUS', + 'PARKING_CHECKIN_ERROR', + 'PARKING_CHECKOUT_ERROR', + 'PARKING_COMPARE_PERIODS_ERROR', + 'PARKING_COMPARISON_DATES_REQUIRED', + 'PARKING_CREATE_BOOKING_ERROR', + 'PARKING_DATE_RANGE_REQUIRED', + 'PARKING_DELETE_BOOKING_ERROR', + 'PARKING_END_BEFORE_START', + 'PARKING_FETCH_AVAILABILITY_ERROR', + 'PARKING_FETCH_BOOKINGS_ERROR', + 'PARKING_FETCH_BOOKING_ERROR', + 'PARKING_FETCH_DASHBOARD_ERROR', + 'PARKING_FETCH_DELAYED_ERROR', + 'PARKING_FETCH_OCCUPANCY_ERROR', + 'PARKING_FETCH_PENDING_CHECKINS_ERROR', + 'PARKING_FETCH_PENDING_CHECKOUTS_ERROR', + 'PARKING_FETCH_PERFORMANCE_ERROR', + 'PARKING_FETCH_RANGE_OCCUPANCY_ERROR', + 'PARKING_FETCH_RANGE_STATS_ERROR', + 'PARKING_FETCH_STATS_ERROR', + 'PARKING_FETCH_TRENDS_ERROR', + 'PARKING_INVALID_AMOUNT', + 'PARKING_INVALID_CODE_FORMAT', + 'PARKING_INVALID_DATE', + 'PARKING_INVALID_DATES', + 'PARKING_INVALID_DATE_FORMAT', + 'PARKING_INVALID_END_DATE', + 'PARKING_INVALID_START_DATE', + 'PARKING_NOSHOW_ERROR', + 'PARKING_NO_FIELDS_TO_UPDATE', + 'PARKING_START_BEFORE_END', + 'PARKING_UPDATE_BOOKING_ERROR', 'STORAGE_NOT_CONFIGURED', + 'UNAUTHORIZED', ]) const byLocale: Record> = { es: es.codes, en: en.codes } @@ -332,3 +356,13 @@ export function errorCodeMessage(code: unknown, lang: string): string | undefine if (typeof code !== 'string' || !TRANSLATED.has(code)) return undefined return (byLocale[lang] ?? byLocale.es)[code] } + +/** Text for an error body read with a plain fetch (outside apiClient), in the page language. */ +export function errorBodyMessage( + data: { code?: unknown; error?: unknown } | null | undefined +): string | undefined { + const lang = typeof document === 'undefined' ? 'es' : document.documentElement.lang + const translated = errorCodeMessage(data?.code, lang) + if (translated) return translated + return typeof data?.error === 'string' ? data.error : undefined +} diff --git a/frontend/messages/en/errors.json b/frontend/messages/en/errors.json index ff1d971e..409b8e3a 100644 --- a/frontend/messages/en/errors.json +++ b/frontend/messages/en/errors.json @@ -85,6 +85,7 @@ "NOT_FOUND": "Resource not found", "AUTH_CREDENTIALS_REQUIRED": "Username and password are required", + "AUTH_DEMO_UNAVAILABLE": "The demo is not available right now. Please try again later.", "AUTH_INVALID_CREDENTIALS": "Invalid credentials", "AUTH_REFRESH_TOKEN_MISSING": "Refresh token not provided", "AUTH_REFRESH_TOKEN_INVALID": "Refresh token invalid or expired", @@ -104,6 +105,8 @@ "BACKOFFICE_COST_CENTER_DEPARTMENT_REQUIRED": "Cost center and department are required", "BACKOFFICE_NAME_REQUIRED": "Name is required", "BACKOFFICE_SUPPLIER_EXISTS": "A supplier with that name already exists", + "BACKOFFICE_SUPPLIER_INACTIVE_EXISTS": "An inactive supplier with that name already exists", + "BACKOFFICE_SUPPLIER_HAS_INVOICES": "Cannot delete: the supplier has invoices", "BACKOFFICE_INVOICE_FIELDS_REQUIRED": "Required fields: invoice number, supplier, amount without VAT, amount with VAT, invoice date", "BACKOFFICE_SUPPLIER_NOT_FOUND": "Supplier not found", "BACKOFFICE_REJECTION_REASON_REQUIRED": "Rejection reason is required", @@ -133,6 +136,9 @@ "BACKOFFICE_CREATE_SUPPLIER_ERROR": "Error creating supplier", "BACKOFFICE_UPDATE_SUPPLIER_ERROR": "Error updating supplier", "BACKOFFICE_DEACTIVATE_SUPPLIER_ERROR": "Error deactivating supplier", + "BACKOFFICE_INACTIVATE_SUPPLIER_ERROR": "Error inactivating supplier", + "BACKOFFICE_ACTIVATE_SUPPLIER_ERROR": "Error activating supplier", + "BACKOFFICE_DELETE_SUPPLIER_ERROR": "Error deleting supplier", "BACKOFFICE_FETCH_INVOICES_ERROR": "Error fetching invoices", "BACKOFFICE_FETCH_INVOICE_ERROR": "Error fetching invoice", "BACKOFFICE_CREATE_INVOICE_ERROR": "Error creating invoice", diff --git a/frontend/messages/es/errors.json b/frontend/messages/es/errors.json index 09f2fbc6..c36f2733 100644 --- a/frontend/messages/es/errors.json +++ b/frontend/messages/es/errors.json @@ -85,6 +85,7 @@ "NOT_FOUND": "Recurso no encontrado", "AUTH_CREDENTIALS_REQUIRED": "Usuario y contraseña son requeridos", + "AUTH_DEMO_UNAVAILABLE": "La demo no está disponible ahora mismo. Inténtalo más tarde.", "AUTH_INVALID_CREDENTIALS": "Credenciales inválidas", "AUTH_REFRESH_TOKEN_MISSING": "Refresh token no proporcionado", "AUTH_REFRESH_TOKEN_INVALID": "Refresh token inválido o expirado", @@ -104,6 +105,8 @@ "BACKOFFICE_COST_CENTER_DEPARTMENT_REQUIRED": "Centro de costo y departamento son requeridos", "BACKOFFICE_NAME_REQUIRED": "El nombre es requerido", "BACKOFFICE_SUPPLIER_EXISTS": "Ya existe un proveedor con ese nombre", + "BACKOFFICE_SUPPLIER_INACTIVE_EXISTS": "Ya existe un proveedor inactivo con ese nombre", + "BACKOFFICE_SUPPLIER_HAS_INVOICES": "No se puede eliminar: el proveedor tiene facturas", "BACKOFFICE_INVOICE_FIELDS_REQUIRED": "Campos requeridos: número de factura, proveedor, monto sin IVA, monto con IVA, fecha de factura", "BACKOFFICE_SUPPLIER_NOT_FOUND": "Proveedor no encontrado", "BACKOFFICE_REJECTION_REASON_REQUIRED": "Se requiere un motivo de rechazo", @@ -133,6 +136,9 @@ "BACKOFFICE_CREATE_SUPPLIER_ERROR": "Error al crear el proveedor", "BACKOFFICE_UPDATE_SUPPLIER_ERROR": "Error al actualizar el proveedor", "BACKOFFICE_DEACTIVATE_SUPPLIER_ERROR": "Error al desactivar el proveedor", + "BACKOFFICE_INACTIVATE_SUPPLIER_ERROR": "Error al inactivar el proveedor", + "BACKOFFICE_ACTIVATE_SUPPLIER_ERROR": "Error al activar el proveedor", + "BACKOFFICE_DELETE_SUPPLIER_ERROR": "Error al eliminar el proveedor", "BACKOFFICE_FETCH_INVOICES_ERROR": "Error al obtener las facturas", "BACKOFFICE_FETCH_INVOICE_ERROR": "Error al obtener la factura", "BACKOFFICE_CREATE_INVOICE_ERROR": "Error al crear la factura", From 1809203ded01a21d5b42338dde7c3321e749e578 Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 11:12:44 +0200 Subject: [PATCH 30/71] docs(todo): narrow the untranslated errors entry to what is left - Rewrite the entry: backend codes are translated on the client; what remains is the server actions, maintenance, the demo reset 409 and the login subtitle. Why: the old entry described the whole job, most of which is now done. --- docs/TODO.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 473ba85c..6a7ece8d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -300,11 +300,15 @@ preview. - [ ] **Configuración: `?tab=` de una pestaña sin permiso deja el contenido en blanco** — p. ej. recepción con `?tab=users`; debería caer en la primera pestaña disponible. _Visto el 2026-10-05._ -- [ ] **Errores del backend sin traducir** — `messages/{es,en}/errors.json` - tiene ~200 textos en `codes` que nadie usa: la UI muestra el `error` del - backend, en español. `app/lib/helpers/errorCodeMessage.ts` ya los traduce - para `STORAGE_NOT_CONFIGURED`; extenderlo a los demás códigos revisando - que no se pierda detalle del mensaje. _Visto 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`). 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; + maintenance responde con textos fijos en español sin `code`; el 409 del + reinicio de la demo y el subtítulo del login (`login/page.tsx`) también + van fijos en español. _Comprobado el 2026-10-11._ - [ ] **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 From aaa3fca433d5d9d493ebf9509df2ec1e25166d8e Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 11:33:44 +0200 Subject: [PATCH 31/71] fix(maintenance): write each change and its history in one transaction - maintenance-repository.ts: create, update, updateStatus, updatePriority, addResolutionNotes, delete and restore run their write and history rows inside withTransaction; every query goes through dbx(). - atomic-history.test.ts: each of the seven rolls back when the history insert fails, and writes both rows on one connection when it succeeds. Why: the two writes were separate queries, so a failed history insert left the change saved without a trace and answered 500, inviting a retry that applied it twice. Checked against the local database: the seven operations answer 200 and leave their eight history rows. --- .../maintenance/maintenance-repository.ts | 182 ++++++++++-------- .../tests/maintenance/atomic-history.test.ts | 130 +++++++++++++ 2 files changed, 228 insertions(+), 84 deletions(-) create mode 100644 backend/tests/maintenance/atomic-history.test.ts diff --git a/backend/repositories/maintenance/maintenance-repository.ts b/backend/repositories/maintenance/maintenance-repository.ts index 5758ab8a..be8036a4 100644 --- a/backend/repositories/maintenance/maintenance-repository.ts +++ b/backend/repositories/maintenance/maintenance-repository.ts @@ -4,7 +4,7 @@ * Acceso a datos con MySQL - 3 tablas relacionadas */ -import db from '../../config/db.js' +import { dbx, withTransaction } from '../../config/transaction.js' import type { ResultSetHeader, RowDataPacket } from 'mysql2' import type { MaintenanceReport, @@ -104,7 +104,7 @@ async function generateReportId(): Promise { const datePrefix = `${day}${month}${year}` // Buscar el último número del día - const [rows] = await db.query( + const [rows] = await dbx().query( `SELECT id FROM maintenance_reports WHERE id LIKE ? ORDER BY id DESC @@ -334,7 +334,7 @@ export class MaintenanceRepository { // Count query (reutiliza los mismos params) const countQuery = `SELECT COUNT(*) as total FROM maintenance_reports r ${whereClause}` - const [countResult] = await db.query(countQuery, whereParams) + const [countResult] = await dbx().query(countQuery, whereParams) const total = countResult[0]?.total || 0 // Data query @@ -359,7 +359,7 @@ export class MaintenanceRepository { r.report_date DESC LIMIT ? OFFSET ? ` - const [rows] = await db.query(dataQuery, [...whereParams, limit, offset]) + const [rows] = await dbx().query(dataQuery, [...whereParams, limit, offset]) // Calcular paginación const totalPages = Math.ceil(total / limit) @@ -382,7 +382,7 @@ export class MaintenanceRepository { * Obtener reporte por ID con imágenes e historial */ static async isDeleted(id: string): Promise { - const [rows] = await db.query( + const [rows] = await dbx().query( 'SELECT is_deleted FROM maintenance_reports WHERE id = ?', [id] ) @@ -404,7 +404,7 @@ export class MaintenanceRepository { WHERE r.id = ? ` - const [reportRows] = await db.query(reportQuery, [id]) + const [reportRows] = await dbx().query(reportQuery, [id]) if (reportRows.length === 0) { return null @@ -474,18 +474,20 @@ export class MaintenanceRepository { userId, ] - await db.query(query, params) + // The report and its history rows commit or roll back together + await withTransaction(async () => { + await dbx().query(query, params) - // Crear entrada de historial - await this.addHistoryEntry(id, 'created', userId, username) + await this.addHistoryEntry(id, 'created', userId, username) - // Si se asignó, agregar entrada de asignación - if (data.assigned_to || data.external_company_name) { - await this.addHistoryEntry(id, 'assigned', userId, username, { - field_changed: 'assigned_to', - new_value: data.assigned_to || data.external_company_name || null, - }) - } + // Si se asignó, agregar entrada de asignación + if (data.assigned_to || data.external_company_name) { + await this.addHistoryEntry(id, 'assigned', userId, username, { + field_changed: 'assigned_to', + new_value: data.assigned_to || data.external_company_name || null, + }) + } + }) const created = await this.getById(id) if (!created) { @@ -559,36 +561,38 @@ export class MaintenanceRepository { // Ejecutar update values.push(id) const query = `UPDATE maintenance_reports SET ${fields.join(', ')} WHERE id = ?` - await db.query(query, values) - - // Agregar entradas de historial para cada cambio significativo - for (const change of changes) { - if (change.field === 'status') { - await this.addHistoryEntry(id, 'status_changed', userId, username, { - field_changed: 'status', - old_value: change.old, - new_value: change.new, - }) - } else if (change.field === 'priority') { - await this.addHistoryEntry(id, 'priority_changed', userId, username, { - field_changed: 'priority', - old_value: change.old, - new_value: change.new, - }) - } else if (change.field === 'assigned_to' || change.field === 'assigned_type') { - await this.addHistoryEntry(id, 'assigned', userId, username, { - field_changed: change.field, - old_value: change.old, - new_value: change.new, - }) - } else { - await this.addHistoryEntry(id, 'updated', userId, username, { - field_changed: change.field, - old_value: String(change.old), - new_value: String(change.new), - }) + await withTransaction(async () => { + await dbx().query(query, values) + + // Agregar entradas de historial para cada cambio significativo + for (const change of changes) { + if (change.field === 'status') { + await this.addHistoryEntry(id, 'status_changed', userId, username, { + field_changed: 'status', + old_value: change.old, + new_value: change.new, + }) + } else if (change.field === 'priority') { + await this.addHistoryEntry(id, 'priority_changed', userId, username, { + field_changed: 'priority', + old_value: change.old, + new_value: change.new, + }) + } else if (change.field === 'assigned_to' || change.field === 'assigned_type') { + await this.addHistoryEntry(id, 'assigned', userId, username, { + field_changed: change.field, + old_value: change.old, + new_value: change.new, + }) + } else { + await this.addHistoryEntry(id, 'updated', userId, username, { + field_changed: change.field, + old_value: String(change.old), + new_value: String(change.new), + }) + } } - } + }) return this.getById(id) } @@ -639,14 +643,16 @@ export class MaintenanceRepository { WHERE id = ? ` - await db.query(query, [status, userId, id]) + await withTransaction(async () => { + await dbx().query(query, [status, userId, id]) - // Agregar entrada de historial - await this.addHistoryEntry(id, historyAction, userId, username, { - field_changed: 'status', - old_value: oldStatus, - new_value: status, - notes, + // Agregar entrada de historial + await this.addHistoryEntry(id, historyAction, userId, username, { + field_changed: 'status', + old_value: oldStatus, + new_value: status, + notes, + }) }) return this.getById(id) @@ -678,13 +684,15 @@ export class MaintenanceRepository { WHERE id = ? ` - await db.query(query, [priority, userId, id]) + await withTransaction(async () => { + await dbx().query(query, [priority, userId, id]) - // Agregar entrada de historial - await this.addHistoryEntry(id, 'priority_changed', userId, username, { - field_changed: 'priority', - old_value: oldPriority, - new_value: priority, + // Agregar entrada de historial + await this.addHistoryEntry(id, 'priority_changed', userId, username, { + field_changed: 'priority', + old_value: oldPriority, + new_value: priority, + }) }) return this.getById(id) @@ -715,13 +723,15 @@ export class MaintenanceRepository { WHERE id = ? ` - await db.query(query, [username, notes, userId, id]) + await withTransaction(async () => { + await dbx().query(query, [username, notes, userId, id]) - // Agregar entrada de historial - await this.addHistoryEntry(id, 'updated', userId, username, { - field_changed: 'resolution_notes', - new_value: notes, - notes: 'Notas de resolución agregadas', + // Agregar entrada de historial + await this.addHistoryEntry(id, 'updated', userId, username, { + field_changed: 'resolution_notes', + new_value: notes, + notes: 'Notas de resolución agregadas', + }) }) return this.getById(id) @@ -746,12 +756,14 @@ export class MaintenanceRepository { WHERE id = ? ` - const [result] = await db.query(query, [userId, userId, id]) + return withTransaction(async () => { + const [result] = await dbx().query(query, [userId, userId, id]) - // Agregar entrada de historial - await this.addHistoryEntry(id, 'deleted', userId, username) + // Agregar entrada de historial + await this.addHistoryEntry(id, 'deleted', userId, username) - return result.affectedRows > 0 + return result.affectedRows > 0 + }) } /** @@ -777,10 +789,12 @@ export class MaintenanceRepository { WHERE id = ? ` - await db.query(query, [userId, id]) + await withTransaction(async () => { + await dbx().query(query, [userId, id]) - // Agregar entrada de historial - await this.addHistoryEntry(id, 'restored', userId, username) + // Agregar entrada de historial + await this.addHistoryEntry(id, 'restored', userId, username) + }) return this.getById(id) } @@ -799,7 +813,7 @@ export class MaintenanceRepository { ORDER BY uploaded_at DESC ` - const [rows] = await db.query(query, [reportId]) + const [rows] = await dbx().query(query, [reportId]) return rows.map(parseImageRow) } @@ -828,9 +842,9 @@ export class MaintenanceRepository { userId, ] - const [result] = await db.query(query, params) + const [result] = await dbx().query(query, params) - const [rows] = await db.query('SELECT * FROM maintenance_images WHERE id = ?', [ + const [rows] = await dbx().query('SELECT * FROM maintenance_images WHERE id = ?', [ result.insertId, ]) @@ -841,7 +855,7 @@ export class MaintenanceRepository { * Eliminar imagen */ static async deleteImage(imageId: number): Promise { - const [result] = await db.query( + const [result] = await dbx().query( 'DELETE FROM maintenance_images WHERE id = ?', [imageId] ) @@ -855,7 +869,7 @@ export class MaintenanceRepository { static async getImageStorage( imageId: number ): Promise<{ report_id: string; file_path: string; public_id: string | null } | null> { - const [rows] = await db.query( + const [rows] = await dbx().query( 'SELECT report_id, file_path, public_id FROM maintenance_images WHERE id = ?', [imageId] ) @@ -871,7 +885,7 @@ export class MaintenanceRepository { * Obtener imagen por ID */ static async getImageById(imageId: number): Promise { - const [rows] = await db.query('SELECT * FROM maintenance_images WHERE id = ?', [ + const [rows] = await dbx().query('SELECT * FROM maintenance_images WHERE id = ?', [ imageId, ]) @@ -898,7 +912,7 @@ export class MaintenanceRepository { ORDER BY h.changed_at DESC ` - const [rows] = await db.query(query, [reportId]) + const [rows] = await dbx().query(query, [reportId]) return rows.map(parseHistoryRow) } @@ -933,7 +947,7 @@ export class MaintenanceRepository { userId, ] - await db.query(query, params) + await dbx().query(query, params) } // ======================================== @@ -979,11 +993,11 @@ export class MaintenanceRepository { `, } - const [totalResult] = await db.query(queries.total) - const [statusResult] = await db.query(queries.byStatus) - const [priorityResult] = await db.query(queries.byPriority) - const [locationResult] = await db.query(queries.byLocation) - const [roomsResult] = await db.query(queries.roomsOut) + const [totalResult] = await dbx().query(queries.total) + const [statusResult] = await dbx().query(queries.byStatus) + const [priorityResult] = await dbx().query(queries.byPriority) + const [locationResult] = await dbx().query(queries.byLocation) + const [roomsResult] = await dbx().query(queries.roomsOut) const byStatus: Record = {} for (const row of statusResult) { diff --git a/backend/tests/maintenance/atomic-history.test.ts b/backend/tests/maintenance/atomic-history.test.ts new file mode 100644 index 00000000..3d753209 --- /dev/null +++ b/backend/tests/maintenance/atomic-history.test.ts @@ -0,0 +1,130 @@ +// tests/maintenance/atomic-history.test.ts +// Regression tests: maintenance wrote the report and its history row with two +// separate queries, so a failure in between left a change without history and +// the user got a 500 for a change that was already saved. Each change and its +// history now commit or roll back together through withTransaction. + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { ReportWithDetails } from '../../models/maintenance/index.js' + +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(async () => [[]]), + execute: vi.fn(), + }, +})) + +const { MaintenanceRepository } = + await import('../../repositories/maintenance/maintenance-repository.js') +const { default: db } = await import('../../config/db.js') + +const report = (overrides: Partial = {}) => + ({ + id: '111026-001', + title: 'Grifo', + status: 'reported', + priority: 'medium', + is_deleted: false, + ...overrides, + }) as ReportWithDetails + +const ok = [{ affectedRows: 1 }] +const historyFails = () => { + connection.query.mockResolvedValueOnce(ok) // the change + connection.query.mockRejectedValueOnce(new Error('history insert failed')) +} + +// Each mutation, with the state getById must return for it to reach the write +const mutations: Array<[string, () => Promise, Partial]> = [ + [ + 'create', + () => + MaintenanceRepository.create( + { + title: 'Grifo', + description: 'Gotea', + location_type: 'room', + location_description: 'Baño', + } as Parameters[0], + 'u1', + 'admin' + ), + {}, + ], + [ + 'update', + () => MaintenanceRepository.update('111026-001', { title: 'Otro' }, 'u1', 'admin'), + {}, + ], + [ + 'updateStatus', + () => MaintenanceRepository.updateStatus('111026-001', 'in_progress', 'u1', 'admin'), + {}, + ], + [ + 'updatePriority', + () => MaintenanceRepository.updatePriority('111026-001', 'high', 'u1', 'admin'), + {}, + ], + [ + 'addResolutionNotes', + () => MaintenanceRepository.addResolutionNotes('111026-001', 'Cambiado', 'u1', 'admin'), + {}, + ], + ['delete', () => MaintenanceRepository.delete('111026-001', 'u1', 'admin'), {}], + [ + 'restore', + () => MaintenanceRepository.restore('111026-001', 'u1', 'admin'), + { is_deleted: true }, + ], +] + +describe('maintenance changes and their history are atomic', () => { + beforeEach(() => { + vi.clearAllMocks() + connection.query.mockReset() + vi.restoreAllMocks() + }) + + it.each(mutations)('%s rolls back when the history insert fails', async (_name, run, state) => { + vi.spyOn(MaintenanceRepository, 'getById').mockResolvedValue(report(state)) + historyFails() + + await expect(run()).rejects.toThrow('history insert failed') + expect(connection.beginTransaction).toHaveBeenCalledOnce() + expect(connection.rollback).toHaveBeenCalledOnce() + expect(connection.commit).not.toHaveBeenCalled() + expect(connection.release).toHaveBeenCalledOnce() + }) + + it.each(mutations)( + '%s writes the change and its history on one connection', + async (_name, run, state) => { + vi.spyOn(MaintenanceRepository, 'getById').mockResolvedValue(report(state)) + connection.query.mockResolvedValue(ok) + + await run() + expect(connection.commit).toHaveBeenCalledOnce() + expect(connection.rollback).not.toHaveBeenCalled() + const sql = connection.query.mock.calls.map(([q]) => String(q)) + expect(sql.some((q) => /maintenance_reports/.test(q))).toBe(true) + expect(sql.some((q) => /INSERT INTO maintenance_history/.test(q))).toBe(true) + // No write reaches the pool directly + const poolWrites = vi + .mocked(db.query) + .mock.calls.map(([q]) => String(q)) + .filter((q) => /INSERT|UPDATE/.test(q)) + expect(poolWrites).toEqual([]) + } + ) +}) From d0659c86d186cc2601f0651d6e9a14c381cf165e Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 11:36:57 +0200 Subject: [PATCH 32/71] fix(parking): refuse a check-out earlier than its check-in - bookings.repository.ts: checkOut compares the real check-out with the stored check-in, inside its transaction, and throws PARKING_INVALID_DATES when it is earlier. - bookings.controller.ts: that error answers 400 with its code. - checkout-dates.test.ts: an earlier check-out is refused and writes nothing; an equal or later one commits. Why: the handler only rejected an unreadable date, so a check-out before the check-in was stored. Checked in the browser: the check-out modal with the day before is refused, with a later time it completes. Any year is still accepted: the owner chose to only enforce the order. --- .../parking/bookings.controller.ts | 9 ++++ .../parking/bookings.repository.ts | 7 +++ backend/tests/parking/checkout-dates.test.ts | 53 +++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 backend/tests/parking/checkout-dates.test.ts diff --git a/backend/controllers/parking/bookings.controller.ts b/backend/controllers/parking/bookings.controller.ts index 57bbd82e..9cb52ec5 100644 --- a/backend/controllers/parking/bookings.controller.ts +++ b/backend/controllers/parking/bookings.controller.ts @@ -440,6 +440,15 @@ class ParkingBookingsController { return } + if (errorMessage === ERROR_CODES.PARKING_INVALID_DATES) { + res.status(400).json({ + success: false, + error: ERROR_CODES.PARKING_INVALID_DATES, + code: ERROR_CODES.PARKING_INVALID_DATES, + }) + return + } + if (errorMessage.includes('No se puede hacer check-out')) { res.status(400).json({ success: false, diff --git a/backend/repositories/parking/bookings.repository.ts b/backend/repositories/parking/bookings.repository.ts index b7b134e7..d2d56325 100644 --- a/backend/repositories/parking/bookings.repository.ts +++ b/backend/repositories/parking/bookings.repository.ts @@ -3,6 +3,7 @@ // Versión profesional con booking_code // ============================================ import pool from '../../config/db.js' +import { ERROR_CODES } from '../../config/error-codes.js' import { targetSpot, type SpotRef } from '../../services/parking/target-spot.js' import { ResultSetHeader, RowDataPacket, type PoolConnection } from 'mysql2/promise' import type { @@ -467,6 +468,12 @@ class ParkingBookingsRepository { throw new Error(`No se puede hacer check-out: estado actual es '${booking[0].status}'`) } + // The check-out cannot be earlier than the check-in it closes + const checkin = booking[0].actual_checkin + if (checkin && actual_checkout.getTime() < new Date(checkin).getTime()) { + throw new Error(ERROR_CODES.PARKING_INVALID_DATES) + } + const payment_date = payment_amount ? new Date() : null await connection.query( diff --git a/backend/tests/parking/checkout-dates.test.ts b/backend/tests/parking/checkout-dates.test.ts new file mode 100644 index 00000000..70257a2e --- /dev/null +++ b/backend/tests/parking/checkout-dates.test.ts @@ -0,0 +1,53 @@ +// tests/parking/checkout-dates.test.ts +// Regression tests for PUT /api/parking/bookings/:code/checkout: the real +// check-out date was only checked for being readable, so a check-out earlier +// than its check-in was stored. It is now refused with PARKING_INVALID_DATES. + +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(), +} + +vi.mock('../../config/db.js', () => ({ + default: { getConnection: vi.fn(async () => connection), query: vi.fn() }, +})) + +const { default: repository } = await import('../../repositories/parking/bookings.repository.js') +const { ERROR_CODES } = await import('../../config/error-codes.js') + +const checkin = new Date('2026-10-10T12:00:00Z') +const booked = [[{ id: 7, status: 'checked_in', actual_checkin: checkin }]] + +describe('checkOut dates', () => { + beforeEach(() => { + vi.clearAllMocks() + connection.query.mockReset() + vi.restoreAllMocks() + }) + + it('refuses a check-out earlier than the check-in and writes nothing', async () => { + connection.query.mockResolvedValueOnce(booked) + + await expect( + repository.checkOut(7, { actual_checkout: new Date('2026-10-10T11:59:00Z') }) + ).rejects.toThrow(ERROR_CODES.PARKING_INVALID_DATES) + expect(connection.query).toHaveBeenCalledOnce() // only the SELECT + expect(connection.rollback).toHaveBeenCalledOnce() + expect(connection.commit).not.toHaveBeenCalled() + }) + + it('accepts a check-out equal to or later than the check-in', async () => { + vi.spyOn(repository, 'findById').mockResolvedValue(null) + for (const at of ['2026-10-10T12:00:00Z', '2026-10-12T09:00:00Z']) { + connection.query.mockResolvedValueOnce(booked).mockResolvedValueOnce([{ affectedRows: 1 }]) + await repository.checkOut(7, { actual_checkout: new Date(at) }) + } + expect(connection.commit).toHaveBeenCalledTimes(2) + expect(connection.rollback).not.toHaveBeenCalled() + }) +}) From abe35ce551c073e614f0f8927e90bc5fd12067ed Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 11:36:58 +0200 Subject: [PATCH 33/71] docs(todo): drop the parking real dates entry, now settled - Remove the "Parking: check-in and check-out accept arbitrary real dates" entry. Why: a check-out earlier than its check-in is now refused; the owner decided not to bound the year, so nothing else is left. --- docs/TODO.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 473ba85c..a7da927e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -145,10 +145,6 @@ preview. - [ ] **Logbook: la fecha de una entrada admite cualquier día** — pasado o futuro, sin límite. Decidir qué rango tiene sentido. _Comprobado por mí el 2026-09-28._ -- [ ] **Parking: entrada y salida aceptan fechas reales arbitrarias** — el - cuerpo del `PUT` ya se valida con Zod (2026-10-02), pero las fechas de - entrada y salida reales no. _Según la revisión `security` L3 del - 2026-09-28 (fichero y línea en el informe); no repasado por mí._ - [ ] **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í._ From 0cd74f3f90d141dc8d3f94758c7e86cfa6cce6f8 Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 11:39:32 +0200 Subject: [PATCH 34/71] fix(notifications): send manual notifications only to active users - user-repository.ts: getActiveIds returns which of the given ids exist and are active. - notification-controller.ts: the group and general manual notifications answer 400 NOTIFICATIONS_INVALID_RECIPIENTS when any recipient is inactive or unknown, before anything is written. - error-codes.ts, errors.json (es, en), errorCodeMessage.ts: the new code and its texts. - manual-recipients.test.ts: both endpoints refuse a bad recipient, send to active ones and skip the lookup without userIds. Why: an inactive user got the notice, and an unknown id broke the recipients insert after the notification was created. Checked with curl against the local database: unknown or mixed ids answer 400, an active one 201. --- backend/config/error-codes.ts | 1 + .../notifications/notification-controller.ts | 27 ++++++ backend/repositories/auth/user-repository.ts | 12 ++- .../notifications/manual-recipients.test.ts | 85 +++++++++++++++++++ frontend/app/lib/helpers/errorCodeMessage.ts | 1 + frontend/messages/en/errors.json | 1 + frontend/messages/es/errors.json | 1 + 7 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 backend/tests/notifications/manual-recipients.test.ts diff --git a/backend/config/error-codes.ts b/backend/config/error-codes.ts index 39d90afe..c74d2454 100644 --- a/backend/config/error-codes.ts +++ b/backend/config/error-codes.ts @@ -387,6 +387,7 @@ export const ERROR_CODES = { NOTIFICATIONS_MISSING_REQUIRED_FIELDS: 'NOTIFICATIONS_MISSING_REQUIRED_FIELDS', NOTIFICATIONS_INVALID_SCHEDULED_DATE: 'NOTIFICATIONS_INVALID_SCHEDULED_DATE', NOTIFICATIONS_INVALID_MODULE: 'NOTIFICATIONS_INVALID_MODULE', + NOTIFICATIONS_INVALID_RECIPIENTS: 'NOTIFICATIONS_INVALID_RECIPIENTS', NOTIFICATIONS_NOT_FOUND: 'NOTIFICATIONS_NOT_FOUND', NOTIFICATIONS_FETCH_ERROR: 'NOTIFICATIONS_FETCH_ERROR', NOTIFICATIONS_FETCH_UNREAD_ERROR: 'NOTIFICATIONS_FETCH_UNREAD_ERROR', diff --git a/backend/controllers/notifications/notification-controller.ts b/backend/controllers/notifications/notification-controller.ts index fbb74b21..7e5e9637 100644 --- a/backend/controllers/notifications/notification-controller.ts +++ b/backend/controllers/notifications/notification-controller.ts @@ -9,6 +9,7 @@ import { NotificationModule, } from '../../models/notifications/index' import { ERROR_CODES, SUCCESS_CODES } from '../../config/error-codes.js' +import { UserRepository } from '../../repositories/auth/user-repository.js' import { logger } from '../../config/logger.js' import { generalNotificationSchema, @@ -351,6 +352,19 @@ export class NotificationController { } } + // Only users that exist and are active can receive it (checked before + // creating the notification, so a bad id leaves nothing behind) + if ( + userIds?.length && + (await UserRepository.getActiveIds(userIds)).length !== userIds.length + ) { + return res.status(400).json({ + success: false, + error: ERROR_CODES.NOTIFICATIONS_INVALID_RECIPIENTS, + code: ERROR_CODES.NOTIFICATIONS_INVALID_RECIPIENTS, + }) + } + await NotificationGeneratorService.generateManualNotification( groupId, title, @@ -421,6 +435,19 @@ export class NotificationController { } } + // Only users that exist and are active can receive it (checked before + // creating the notification, so a bad id leaves nothing behind) + if ( + userIds?.length && + (await UserRepository.getActiveIds(userIds)).length !== userIds.length + ) { + return res.status(400).json({ + success: false, + error: ERROR_CODES.NOTIFICATIONS_INVALID_RECIPIENTS, + code: ERROR_CODES.NOTIFICATIONS_INVALID_RECIPIENTS, + }) + } + await NotificationGeneratorService.generateGeneralNotification({ title, message, diff --git a/backend/repositories/auth/user-repository.ts b/backend/repositories/auth/user-repository.ts index 1ca7ad7d..7de783fb 100644 --- a/backend/repositories/auth/user-repository.ts +++ b/backend/repositories/auth/user-repository.ts @@ -5,7 +5,7 @@ import bcrypt from 'bcrypt' import crypto from 'crypto' import { SALT_ROUNDS } from '../../config/config.js' import db from '../../config/db.js' -import { ResultSetHeader } from 'mysql2' +import { ResultSetHeader, type RowDataPacket } from 'mysql2' import { logger } from '../../config/logger.js' import type { User, @@ -226,6 +226,16 @@ export class UserRepository { } } + /** Of the given ids, the ones that exist and are active */ + static async getActiveIds(ids: readonly string[]): Promise { + if (ids.length === 0) return [] + const [rows] = await db.query<(RowDataPacket & { id: string })[]>( + 'SELECT id FROM users WHERE is_active = 1 AND id IN (?)', + [ids] + ) + return rows.map((r) => r.id) + } + /** * Obtener por ID con JOIN directo */ diff --git a/backend/tests/notifications/manual-recipients.test.ts b/backend/tests/notifications/manual-recipients.test.ts new file mode 100644 index 00000000..27ae10cb --- /dev/null +++ b/backend/tests/notifications/manual-recipients.test.ts @@ -0,0 +1,85 @@ +// tests/notifications/manual-recipients.test.ts +// Regression tests: manual notifications accepted any user id. An inactive +// user got the notice, and an unknown one broke the recipients insert after +// the notification was created, leaving it without recipients. Both are now +// refused with NOTIFICATIONS_INVALID_RECIPIENTS before anything is written. + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Request, Response } from 'express' + +vi.mock('../../repositories/auth/user-repository.js', () => ({ + UserRepository: { getActiveIds: vi.fn() }, +})) +vi.mock('../../services/notifications/notification-generator-service.js', () => ({ + NotificationGeneratorService: { + generateManualNotification: vi.fn(), + generateGeneralNotification: vi.fn(), + }, +})) + +const { NotificationController } = + await import('../../controllers/notifications/notification-controller.js') +const { UserRepository } = await import('../../repositories/auth/user-repository.js') +const { NotificationGeneratorService } = + await import('../../services/notifications/notification-generator-service.js') +const { ERROR_CODES } = await import('../../config/error-codes.js') + +const ACTIVE = '11111111-1111-4111-8111-111111111111' +const INACTIVE = '22222222-2222-4222-8222-222222222222' + +function call( + handler: (req: Request, res: Response) => Promise, + body: object, + params = {} +) { + const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() } + const req = { user: { id: 'u1' }, params, body } as unknown as Request + return handler(req, res as unknown as Response).then(() => res) +} + +const endpoints = [ + ['general', NotificationController.createGeneralNotification, {}], + ['group', NotificationController.createManualNotification, { id: '5' }], +] as const + +describe('manual notification recipients', () => { + beforeEach(() => vi.clearAllMocks()) + + it.each(endpoints)( + '%s: refuses a recipient that is inactive or unknown', + async (_, handler, params) => { + vi.mocked(UserRepository.getActiveIds).mockResolvedValue([ACTIVE]) + + const res = await call( + handler, + { title: 'T', message: 'M', userIds: [ACTIVE, INACTIVE] }, + params + ) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ code: ERROR_CODES.NOTIFICATIONS_INVALID_RECIPIENTS }) + ) + expect(NotificationGeneratorService.generateGeneralNotification).not.toHaveBeenCalled() + expect(NotificationGeneratorService.generateManualNotification).not.toHaveBeenCalled() + } + ) + + it.each(endpoints)('%s: sends when every recipient is active', async (_, handler, params) => { + vi.mocked(UserRepository.getActiveIds).mockResolvedValue([ACTIVE]) + + const res = await call(handler, { title: 'T', message: 'M', userIds: [ACTIVE] }, params) + + expect(res.status).toHaveBeenCalledWith(201) + }) + + it.each(endpoints)( + '%s: without userIds it does not look users up', + async (_, handler, params) => { + const res = await call(handler, { title: 'T', message: 'M' }, params) + + expect(res.status).toHaveBeenCalledWith(201) + expect(UserRepository.getActiveIds).not.toHaveBeenCalled() + } + ) +}) diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index 09360dee..dce040b1 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -300,6 +300,7 @@ const TRANSLATED = new Set([ 'NOTIFICATIONS_INVALID_GROUP_ID', 'NOTIFICATIONS_INVALID_ID', 'NOTIFICATIONS_INVALID_MODULE', + 'NOTIFICATIONS_INVALID_RECIPIENTS', 'NOTIFICATIONS_INVALID_SCHEDULED_DATE', 'NOTIFICATIONS_MARK_ALL_ERROR', 'NOTIFICATIONS_MARK_ERROR', diff --git a/frontend/messages/en/errors.json b/frontend/messages/en/errors.json index 409b8e3a..d89c323f 100644 --- a/frontend/messages/en/errors.json +++ b/frontend/messages/en/errors.json @@ -404,6 +404,7 @@ "NOTIFICATIONS_MISSING_REQUIRED_FIELDS": "Missing required fields: title, message", "NOTIFICATIONS_INVALID_SCHEDULED_DATE": "Invalid date format for scheduled_for", "NOTIFICATIONS_INVALID_MODULE": "Invalid module. Allowed values: groups, parking, logbooks, system", + "NOTIFICATIONS_INVALID_RECIPIENTS": "Some recipient does not exist or is not active", "NOTIFICATIONS_NOT_FOUND": "Notification not found", "NOTIFICATIONS_FETCH_ERROR": "Error fetching notifications", "NOTIFICATIONS_FETCH_UNREAD_ERROR": "Error fetching unread notifications", diff --git a/frontend/messages/es/errors.json b/frontend/messages/es/errors.json index c36f2733..88cdecfc 100644 --- a/frontend/messages/es/errors.json +++ b/frontend/messages/es/errors.json @@ -404,6 +404,7 @@ "NOTIFICATIONS_MISSING_REQUIRED_FIELDS": "Faltan campos obligatorios: título, mensaje", "NOTIFICATIONS_INVALID_SCHEDULED_DATE": "Formato de fecha inválido para scheduled_for", "NOTIFICATIONS_INVALID_MODULE": "Módulo inválido. Valores permitidos: groups, parking, logbooks, system", + "NOTIFICATIONS_INVALID_RECIPIENTS": "Algún destinatario no existe o no está activo", "NOTIFICATIONS_NOT_FOUND": "Notificación no encontrada", "NOTIFICATIONS_FETCH_ERROR": "Error al obtener notificaciones", "NOTIFICATIONS_FETCH_UNREAD_ERROR": "Error al obtener notificaciones no leídas", From 46f84d33b38be700fe0613cae8b50861916e31ae Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 11:53:01 +0200 Subject: [PATCH 35/71] fix(parking): keep the booking source and offer only the ENUM values - bookings.controller.ts: createBooking hands booking_source and external_booking_id to Zod under the schema's own names. - booking-validation.ts: BOOKING_SOURCES lists the ENUM values; create and update accept only those, and the create external id is capped at 64. - useBookingWizard.ts, types.ts: the wizard sends booking_source and external_booking_id, the names of the API and the table. - DateOnlyStep.tsx, EditBookingModal.tsx: offer direct, booking_com, expedia, airbnb and agency_other, like DateSpotStep already did. - booking.json, parking.json (es, en): drop the phone, email, walk-in and other labels; add "agency_other" to bookingSources. - booking-source.test.ts: create keeps source and reference, defaults to direct, refuses other values; the three forms offer the ENUM only. Why: every booking was stored as direct and lost its external reference, and phone, email or walk-in had no column value to land in. Decided on 2026-10-11: the forms follow the ENUM. Checked in the browser: a booking created with Airbnb and a reference keeps both, and the edit modal offers the five values. --- .../parking/bookings.controller.ts | 4 +- backend/tests/parking/booking-source.test.ts | 87 +++++++++++++++++++ .../validations/parking/booking-validation.ts | 19 ++-- .../BookingWizard/hooks/useBookingWizard.ts | 4 +- .../BookingWizard/steps/DateOnlyStep.tsx | 7 +- .../parking/bookings/EditBookingModal.tsx | 5 +- frontend/app/lib/parking/types.ts | 4 +- frontend/messages/en/booking.json | 5 +- frontend/messages/en/parking.json | 5 +- frontend/messages/es/booking.json | 5 +- frontend/messages/es/parking.json | 5 +- 11 files changed, 115 insertions(+), 35 deletions(-) create mode 100644 backend/tests/parking/booking-source.test.ts diff --git a/backend/controllers/parking/bookings.controller.ts b/backend/controllers/parking/bookings.controller.ts index 57bbd82e..f315596f 100644 --- a/backend/controllers/parking/bookings.controller.ts +++ b/backend/controllers/parking/bookings.controller.ts @@ -214,8 +214,8 @@ class ParkingBookingsController { expected_checkin, expected_checkout, total_amount: total_amount ? parseFloat(total_amount) : undefined, - source: booking_source, - external_id: external_booking_id, + booking_source, + external_booking_id, notes, }) diff --git a/backend/tests/parking/booking-source.test.ts b/backend/tests/parking/booking-source.test.ts new file mode 100644 index 00000000..9cd74a80 --- /dev/null +++ b/backend/tests/parking/booking-source.test.ts @@ -0,0 +1,87 @@ +// tests/parking/booking-source.test.ts +// Regression tests: creating a booking always stored booking_source 'direct' +// and dropped the external reference (the controller handed them to Zod under +// other names), and the forms offered phone, email and walk-in, which the +// parking_bookings.booking_source ENUM does not hold. Decided on 2026-10-11: +// the forms offer the ENUM values only. + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Request, Response } from 'express' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +vi.mock('../../repositories/parking/bookings.repository.js', () => ({ + default: { create: vi.fn(async () => ({ id: 1 })) }, +})) + +const { default: controller } = await import('../../controllers/parking/bookings.controller.js') +const { default: repository } = await import('../../repositories/parking/bookings.repository.js') +const { BOOKING_SOURCES, createBookingSchema, updateBookingBodySchema } = + await import('../../validations/parking/booking-validation.js') + +const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10) +const body = { + spot_number: 1, + level_code: '-3', + vehicle_id: 1, + expected_checkin: `${tomorrow}T10:00:00`, + expected_checkout: `${tomorrow}T18:00:00`, +} + +function create(extra: object) { + const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() } + const req = { user: { id: 'u1' }, body: { ...body, ...extra } } as unknown as Request + return controller.createBooking(req, res as unknown as Response).then(() => res) +} + +describe('booking source', () => { + beforeEach(() => vi.clearAllMocks()) + + it('create keeps the source and the external reference the form sends', async () => { + const res = await create({ booking_source: 'airbnb', external_booking_id: 'HM-123' }) + + expect(res.status).toHaveBeenCalledWith(201) + expect(vi.mocked(repository.create).mock.calls[0][0]).toMatchObject({ + booking_source: 'airbnb', + external_booking_id: 'HM-123', + }) + }) + + it('create defaults to direct and refuses a value outside the ENUM', async () => { + await create({}) + expect(vi.mocked(repository.create).mock.calls[0][0]).toMatchObject({ + booking_source: 'direct', + }) + + const res = await create({ booking_source: 'phone' }) + expect(res.status).toHaveBeenCalledWith(400) + expect(repository.create).toHaveBeenCalledOnce() + }) + + it('both schemas take exactly the ENUM values', () => { + for (const source of BOOKING_SOURCES) { + expect(createBookingSchema.safeParse({ ...body, booking_source: source }).success).toBe(true) + expect(updateBookingBodySchema.safeParse({ booking_source: source }).success).toBe(true) + } + for (const source of ['phone', 'email', 'walkin', 'booking', 'other']) { + expect(updateBookingBodySchema.safeParse({ booking_source: source }).success).toBe(false) + } + }) + + it('the forms offer the ENUM values only', () => { + const frontend = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'frontend') + for (const file of [ + ['booking', 'BookingWizard', 'steps', 'DateOnlyStep.tsx'], + ['booking', 'BookingWizard', 'steps', 'DateSpotStep.tsx'], + ['parking', 'bookings', 'EditBookingModal.tsx'], + ]) { + const src = readFileSync(join(frontend, 'app', 'components', ...file), 'utf8') + const name = file.at(-1) + for (const source of BOOKING_SOURCES) { + expect(src, `${name} offers ${source}`).toMatch(new RegExp(`value(=|: )["']${source}["']`)) + } + expect(src, name).not.toMatch(/value(=|: )["'](booking|phone|email|walkin|other)["']/) + } + }) +}) diff --git a/backend/validations/parking/booking-validation.ts b/backend/validations/parking/booking-validation.ts index d7b17b68..d6be5e1f 100644 --- a/backend/validations/parking/booking-validation.ts +++ b/backend/validations/parking/booking-validation.ts @@ -8,6 +8,16 @@ import timezone from 'dayjs/plugin/timezone.js' dayjs.extend(utc) dayjs.extend(timezone) +// The values of the parking_bookings.booking_source ENUM +export const BOOKING_SOURCES = [ + 'direct', + 'booking_com', + 'expedia', + 'airbnb', + 'agency_other', +] as const +const bookingSource = z.enum(BOOKING_SOURCES) + const dateTimeSchema = z .string() .min(1, 'La fecha es requerida') @@ -22,8 +32,8 @@ export const createBookingSchema = z expected_checkin: dateTimeSchema, expected_checkout: dateTimeSchema, total_amount: z.number().positive().optional(), - booking_source: z.string().optional().default('direct'), - external_booking_id: z.string().optional(), + booking_source: bookingSource.optional().default('direct'), + external_booking_id: z.string().max(64).optional(), notes: z.string().max(500).optional(), }) .refine( @@ -53,8 +63,7 @@ export const createBookingSchema = z // DECIMAL(10,2) and never negative, payment_method is an ENUM, references and // external ids are VARCHAR(100)/(64). Before, any value reached MySQL (a // negative payment was stored, a bad ENUM or text ended in a 500). -// booking_source is left as it is: its allowed values are still to be decided -// (docs/TODO.md) +// booking_source is one of the ENUM values const amount = z.union([z.null(), z.coerce.number().finite().min(0).max(99_999_999.99)]) export const updateBookingBodySchema = z.object({ @@ -64,7 +73,7 @@ export const updateBookingBodySchema = z.object({ level_code: z.enum(['-2', '-3']).optional(), vehicle_id: z.union([z.null(), z.coerce.number().int().positive()]).optional(), total_amount: amount.optional(), - booking_source: z.string().optional(), + booking_source: bookingSource.optional(), external_booking_id: z.string().max(64).nullable().optional(), notes: z.string().max(16000).nullable().optional(), payment_amount: amount.optional(), diff --git a/frontend/app/components/booking/BookingWizard/hooks/useBookingWizard.ts b/frontend/app/components/booking/BookingWizard/hooks/useBookingWizard.ts index 79fd03cb..04165774 100644 --- a/frontend/app/components/booking/BookingWizard/hooks/useBookingWizard.ts +++ b/frontend/app/components/booking/BookingWizard/hooks/useBookingWizard.ts @@ -376,9 +376,9 @@ export const useBookingWizard = ({ vehicle_id: state.vehicleId ?? undefined, expected_checkin: checkinISO, expected_checkout: checkoutISO, - source: state.reservationData.booking_source || 'direct', + booking_source: state.reservationData.booking_source || 'direct', total_amount: totalAmount, - external_id: state.reservationData.external_booking_id || undefined, + external_booking_id: state.reservationData.external_booking_id || undefined, notes: state.reservationData.notes || undefined, } diff --git a/frontend/app/components/booking/BookingWizard/steps/DateOnlyStep.tsx b/frontend/app/components/booking/BookingWizard/steps/DateOnlyStep.tsx index 91db08b3..e6eff228 100644 --- a/frontend/app/components/booking/BookingWizard/steps/DateOnlyStep.tsx +++ b/frontend/app/components/booking/BookingWizard/steps/DateOnlyStep.tsx @@ -165,11 +165,10 @@ export default function DateOnlyStep({ state, actions }: DateOnlyStepProps) { className={selectClassName} > - + - - - + + diff --git a/frontend/app/components/parking/bookings/EditBookingModal.tsx b/frontend/app/components/parking/bookings/EditBookingModal.tsx index bde6115a..47382db1 100644 --- a/frontend/app/components/parking/bookings/EditBookingModal.tsx +++ b/frontend/app/components/parking/bookings/EditBookingModal.tsx @@ -306,10 +306,7 @@ export function EditBookingModal({ { value: 'booking_com', label: t('bookingSources.booking_com') }, { value: 'expedia', label: t('bookingSources.expedia') }, { value: 'airbnb', label: t('bookingSources.airbnb') }, - { value: 'phone', label: t('bookingSources.phone') }, - { value: 'email', label: t('bookingSources.email') }, - { value: 'walkin', label: t('bookingSources.walkin') }, - { value: 'other', label: t('bookingSources.other') }, + { value: 'agency_other', label: t('bookingSources.agency_other') }, ]} label={t('editModal.bookingSource')} size="md" diff --git a/frontend/app/lib/parking/types.ts b/frontend/app/lib/parking/types.ts index aeec8d37..be8542e9 100644 --- a/frontend/app/lib/parking/types.ts +++ b/frontend/app/lib/parking/types.ts @@ -318,8 +318,8 @@ export interface CreateBookingDto { expected_checkin: string expected_checkout: string total_amount?: number - source?: string - external_id?: string + booking_source?: string + external_booking_id?: string notes?: string } diff --git a/frontend/messages/en/booking.json b/frontend/messages/en/booking.json index 0ae703c6..f9e69896 100644 --- a/frontend/messages/en/booking.json +++ b/frontend/messages/en/booking.json @@ -84,10 +84,7 @@ "booking_com": "Booking.com", "airbnb": "Airbnb", "expedia": "Expedia", - "agency_other": "Other Agency", - "phone": "Phone", - "email": "Email", - "walkin": "Walk-in" + "agency_other": "Other agency" }, "confirmation": { "title": "Confirm Booking", diff --git a/frontend/messages/en/parking.json b/frontend/messages/en/parking.json index d88e5dd9..a398a634 100644 --- a/frontend/messages/en/parking.json +++ b/frontend/messages/en/parking.json @@ -407,10 +407,7 @@ "booking_com": "Booking.com", "expedia": "Expedia", "airbnb": "Airbnb", - "phone": "Phone", - "email": "Email", - "walkin": "Walk-in", - "other": "Other" + "agency_other": "Other agency" }, "statusPageNav": { "title": "Parking Control", diff --git a/frontend/messages/es/booking.json b/frontend/messages/es/booking.json index 722cc50c..833f573c 100644 --- a/frontend/messages/es/booking.json +++ b/frontend/messages/es/booking.json @@ -84,10 +84,7 @@ "booking_com": "Booking.com", "airbnb": "Airbnb", "expedia": "Expedia", - "agency_other": "Otra Agencia", - "phone": "Teléfono", - "email": "Email", - "walkin": "Walk-in" + "agency_other": "Otra agencia" }, "confirmation": { "title": "Confirmar Reserva", diff --git a/frontend/messages/es/parking.json b/frontend/messages/es/parking.json index 8afbc368..c34e27be 100644 --- a/frontend/messages/es/parking.json +++ b/frontend/messages/es/parking.json @@ -407,10 +407,7 @@ "booking_com": "Booking.com", "expedia": "Expedia", "airbnb": "Airbnb", - "phone": "Teléfono", - "email": "Email", - "walkin": "Walk-in", - "other": "Otro" + "agency_other": "Otra agencia" }, "statusPageNav": { "title": "Control de Parking", From 25d46f7becf47ff13d7dad01b30c4ff75dfbf612 Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 11:53:02 +0200 Subject: [PATCH 36/71] docs(todo): drop the lost parking booking source, now fixed - Remove the "Parking: the source and the external reference are lost on create" entry. Why: create keeps both and the forms offer only the ENUM values, as decided on 2026-10-11. --- docs/TODO.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 473ba85c..1768debc 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -152,16 +152,6 @@ 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í._ -- [ ] **Parking: el origen y la referencia externa se pierden al crear** — el - frontend (`useBookingWizard.ts`) envía `source` - y `external_id`; el controlador lee `booking_source` y - `external_booking_id` y además los pasa a Zod con los nombres cambiados: - siempre queda `direct`. **Decidir antes de arreglar:** los formularios - ofrecen `phone`, `email`, `walkin` y `booking` (`DateOnlyStep.tsx`), que el - `ENUM` de `parking_bookings.booking_source` no admite (`direct`, - `booking_com`, `expedia`, `airbnb`, `agency_other`); conservar el origen - sin ampliar el `ENUM` o mapear esos valores haría fallar la reserva. - _Comprobado por mí el 2026-09-28; detalle del 2026-10-02._ - [ ] **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. From aadcc410cf1a47e793f19cc7a42c3f9796fb3c0d Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 11:57:54 +0200 Subject: [PATCH 37/71] fix(groups): keep payment amounts, status and percentages coherent - payment-rules.ts: statusFor, reconcilePayment and checkPercentages, the rules decided on 2026-10-11: what was paid never exceeds what is owed, marking a payment paid fills in what was paid, the status follows the amounts ("requested" stays until money arrives), and a group's percentages add up to 100 at most. - group-payment-controller.ts: create, update, status and amount-paid apply them; a broken rule answers 400 with GROUP_PAYMENT_OVERPAID or GROUP_PAYMENT_PERCENTAGE_OVER_100. - group-payment-repository.ts: the total recalculation also sets the status from the new amount; updateAmountPaid goes, it had no other user. - error-codes.ts, errors.json (es, en), errorCodeMessage.ts: the two codes. - payment-rules.test.ts: the rules and the four endpoints. Why: more paid than owed was accepted, a payment could be paid with nothing paid, a new group total left statuses behind, and percentages could exceed 100. Checked in the browser and with curl against the local database: an overpayment is refused with its text, marking paid fills in 5000, a 120 % total is refused, and lowering and restoring the group total moves the statuses and puts them back. --- backend/config/error-codes.ts | 2 + .../group/group-payment-controller.ts | 90 ++++++++++- .../group/group-payment-repository.ts | 37 +---- backend/services/group/payment-rules.ts | 59 +++++++ backend/tests/group/payment-rules.test.ts | 151 ++++++++++++++++++ frontend/app/lib/helpers/errorCodeMessage.ts | 2 + frontend/messages/en/errors.json | 2 + frontend/messages/es/errors.json | 2 + 8 files changed, 314 insertions(+), 31 deletions(-) create mode 100644 backend/services/group/payment-rules.ts create mode 100644 backend/tests/group/payment-rules.test.ts diff --git a/backend/config/error-codes.ts b/backend/config/error-codes.ts index 39d90afe..16b7344b 100644 --- a/backend/config/error-codes.ts +++ b/backend/config/error-codes.ts @@ -253,6 +253,8 @@ export const ERROR_CODES = { // Group Payment GROUP_PAYMENT_MISSING_FIELDS: 'GROUP_PAYMENT_MISSING_FIELDS', GROUP_PAYMENT_INVALID_AMOUNT: 'GROUP_PAYMENT_INVALID_AMOUNT', + GROUP_PAYMENT_OVERPAID: 'GROUP_PAYMENT_OVERPAID', + GROUP_PAYMENT_PERCENTAGE_OVER_100: 'GROUP_PAYMENT_PERCENTAGE_OVER_100', GROUP_PAYMENT_NOT_FOUND: 'GROUP_PAYMENT_NOT_FOUND', GROUP_PAYMENT_NOT_IN_GROUP: 'GROUP_PAYMENT_NOT_IN_GROUP', GROUP_PAYMENT_FETCH_ERROR: 'GROUP_PAYMENT_FETCH_ERROR', diff --git a/backend/controllers/group/group-payment-controller.ts b/backend/controllers/group/group-payment-controller.ts index 1941c554..c502caef 100644 --- a/backend/controllers/group/group-payment-controller.ts +++ b/backend/controllers/group/group-payment-controller.ts @@ -5,10 +5,16 @@ import { GroupPaymentRepository } from '../../repositories/group/group-payment-r import { GroupRepository } from '../../repositories/group/group-repository' import { PaymentCalculatorService } from '../../services/group/payment-calculator-service' import { GroupHistoryService } from '../../services/group/group-history-service' +import { + checkPercentages, + PaymentRuleError, + reconcilePayment, +} from '../../services/group/payment-rules.js' import { CreateGroupPaymentDTO, UpdateGroupPaymentDTO, HistoryAction, + PaymentStatus, } from '../../models/group/index' import { ERROR_CODES, SUCCESS_CODES } from '../../config/error-codes.js' import { logger } from '../../config/logger.js' @@ -21,6 +27,12 @@ import { validationError, } from '../../validations/group/group-schemas.js' +// A broken payment rule answers 400 with its code; anything else is rethrown +function ruleError(res: Response, error: unknown): Response { + if (!(error instanceof PaymentRuleError)) throw error + return res.status(400).json({ success: false, error: error.code, code: error.code }) +} + export class GroupPaymentController { /** * GET /api/groups/:id/payments @@ -129,6 +141,25 @@ export class GroupPaymentController { }) } + try { + if (paymentData.percentage != null) { + checkPercentages( + await GroupPaymentRepository.getByGroupId(groupId), + paymentData.percentage + ) + } + Object.assign( + paymentData, + reconcilePayment({ + amount: paymentData.amount, + amountPaid: paymentData.amount_paid ?? 0, + status: paymentData.status, + }) + ) + } catch (error) { + return ruleError(res, error) + } + const newPayment = await GroupPaymentRepository.create(paymentData) await GroupHistoryService.logChange( @@ -209,6 +240,32 @@ export class GroupPaymentController { } const updateData = parsed.data as UpdateGroupPaymentDTO + try { + if (updateData.percentage != null) { + const others = (await GroupPaymentRepository.getByGroupId(groupId)).filter( + (p) => p.id !== paymentId + ) + checkPercentages(others, updateData.percentage) + } + if ( + updateData.amount != null || + updateData.amount_paid !== undefined || + updateData.status !== undefined + ) { + Object.assign( + updateData, + reconcilePayment({ + amount: updateData.amount ?? oldPayment.amount, + amountPaid: updateData.amount_paid ?? oldPayment.amount_paid, + status: updateData.status, + current: oldPayment.status, + }) + ) + } + } catch (error) { + return ruleError(res, error) + } + const updated = await GroupPaymentRepository.update(paymentId, updateData) if (!updated) { @@ -295,7 +352,12 @@ export class GroupPaymentController { }) } - const updated = await GroupPaymentRepository.updateStatus(paymentId, status) + // Marking it paid by hand fills in what was paid + const filled = status === PaymentStatus.PAID ? Number(oldPayment.amount) : null + const updated = + filled === null + ? await GroupPaymentRepository.updateStatus(paymentId, status) + : await GroupPaymentRepository.update(paymentId, { status, amount_paid: filled }) if (!updated) { return res.status(500).json({ @@ -316,6 +378,19 @@ export class GroupPaymentController { status, 'Estado de pago actualizado' ) + if (filled !== null && Number(oldPayment.amount_paid) !== filled) { + await GroupHistoryService.logChange( + oldPayment.group_id, + userId, + HistoryAction.PAYMENT_UPDATED, + 'group_payments', + paymentId, + 'amount_paid', + oldPayment.amount_paid, + filled, + 'Cantidad pagada completada al marcar el pago como pagado' + ) + } return res.status(200).json({ success: true, @@ -382,7 +457,18 @@ export class GroupPaymentController { }) } - const updated = await GroupPaymentRepository.updateAmountPaid(paymentId, amount_paid) + let next: ReturnType + try { + next = reconcilePayment({ + amount: oldPayment.amount, + amountPaid: amount_paid, + current: oldPayment.status, + }) + } catch (error) { + return ruleError(res, error) + } + + const updated = await GroupPaymentRepository.update(paymentId, next) if (!updated) { return res.status(500).json({ diff --git a/backend/repositories/group/group-payment-repository.ts b/backend/repositories/group/group-payment-repository.ts index f02f2532..bf00891e 100644 --- a/backend/repositories/group/group-payment-repository.ts +++ b/backend/repositories/group/group-payment-repository.ts @@ -159,34 +159,6 @@ export class GroupPaymentRepository { return result.affectedRows > 0 } - /** - * Actualizar cantidad pagada (pagos parciales) - */ - static async updateAmountPaid(id: number, amountPaid: number): Promise { - const payment = await this.getById(id) - - if (!payment) { - throw new Error('Pago no encontrado') - } - - // Calcular nuevo estado automáticamente - let newStatus = PaymentStatus.PENDING - if (amountPaid >= payment.amount) { - newStatus = PaymentStatus.PAID - } else if (amountPaid > 0) { - newStatus = PaymentStatus.PARTIAL - } - - const query = ` - UPDATE group_payments - SET amount_paid = ?, status = ?, status_updated_at = NOW() - WHERE id = ? - ` - - const [result] = await db.query(query, [amountPaid, newStatus, id]) - return result.affectedRows > 0 - } - /** * Eliminar pago */ @@ -203,7 +175,14 @@ export class GroupPaymentRepository { static async recalculateAmounts(groupId: number, totalAmount: number): Promise { const query = ` UPDATE group_payments - SET amount = (? * percentage / 100) + SET amount = (? * percentage / 100), + -- The status follows the new amount (MySQL assigns left to right) + status = CASE + WHEN amount > 0 AND amount_paid >= amount THEN 'paid' + WHEN amount_paid > 0 THEN 'partial' + WHEN status IN ('paid', 'partial') THEN 'pending' + ELSE status + END WHERE group_id = ? AND percentage IS NOT NULL ` diff --git a/backend/services/group/payment-rules.ts b/backend/services/group/payment-rules.ts new file mode 100644 index 00000000..a43be6b8 --- /dev/null +++ b/backend/services/group/payment-rules.ts @@ -0,0 +1,59 @@ +// services/group/payment-rules.ts +// Coherence rules for a group payment, decided on 2026-10-11: what was paid +// never exceeds what is owed, marking a payment paid by hand fills in what was +// paid, the status follows the amounts, and the percentages of a group's +// payments add up to 100 at most. DECIMAL columns arrive as strings, hence +// the Number() calls. + +import { PaymentStatus } from '../../models/group/index.js' +import { ERROR_CODES } from '../../config/error-codes.js' + +/** A rule the payment breaks; `code` is the ERROR_CODES entry to answer with */ +export class PaymentRuleError extends Error { + constructor(readonly code: string) { + super(code) + } +} + +/** Status that follows from what is owed and what was paid; "requested" stays until money arrives */ +export function statusFor( + amount: number, + amountPaid: number, + current?: PaymentStatus +): PaymentStatus { + if (amount > 0 && amountPaid >= amount) return PaymentStatus.PAID + if (amountPaid > 0) return PaymentStatus.PARTIAL + return current === PaymentStatus.REQUESTED ? PaymentStatus.REQUESTED : PaymentStatus.PENDING +} + +/** + * amount_paid and status to store after a change. An explicit status is kept, + * except that "paid" fills amount_paid with the amount; without one, the + * status follows the amounts. + */ +export function reconcilePayment(input: { + amount: number | string + amountPaid: number | string + status?: PaymentStatus + current?: PaymentStatus +}): { amount_paid: number; status: PaymentStatus } { + const amount = Number(input.amount) + const given = Number(input.amountPaid) + if (given > amount) throw new PaymentRuleError(ERROR_CODES.GROUP_PAYMENT_OVERPAID) + + const amountPaid = input.status === PaymentStatus.PAID ? amount : given + return { + amount_paid: amountPaid, + status: input.status ?? statusFor(amount, amountPaid, input.current), + } +} + +/** Throws when the group's percentages would add up to more than 100 */ +export function checkPercentages( + others: ReadonlyArray<{ percentage: number | string | null }>, + next: number | null | undefined +): void { + const total = others.reduce((sum, p) => sum + Number(p.percentage ?? 0), Number(next ?? 0)) + // Two decimals in the column: allow the rounding of thirds and the like + if (total > 100.001) throw new PaymentRuleError(ERROR_CODES.GROUP_PAYMENT_PERCENTAGE_OVER_100) +} diff --git a/backend/tests/group/payment-rules.test.ts b/backend/tests/group/payment-rules.test.ts new file mode 100644 index 00000000..64144300 --- /dev/null +++ b/backend/tests/group/payment-rules.test.ts @@ -0,0 +1,151 @@ +// tests/group/payment-rules.test.ts +// Regression tests for the group payments logic: more paid than owed was +// accepted, a payment could be marked paid with nothing paid, recalculating +// the group total left the status behind, and percentages could add up to +// more than 100. Rules decided on 2026-10-11 (services/group/payment-rules.ts). + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Request, Response } from 'express' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { PaymentStatus } from '../../models/group/index.js' +import { + checkPercentages, + reconcilePayment, + statusFor, +} from '../../services/group/payment-rules.js' +import { ERROR_CODES } from '../../config/error-codes.js' + +vi.mock('../../repositories/group/group-payment-repository', () => ({ + GroupPaymentRepository: { + getById: vi.fn(), + getByGroupId: vi.fn(async () => []), + update: vi.fn(async () => true), + updateStatus: vi.fn(async () => true), + create: vi.fn(async () => ({ id: 9 })), + }, +})) +vi.mock('../../repositories/group/group-repository', () => ({ + GroupRepository: { getById: vi.fn(async () => ({ id: 1, total_amount: 1000 })) }, +})) +vi.mock('../../services/group/group-history-service', () => ({ + GroupHistoryService: { logChange: vi.fn(), logPaymentUpdated: vi.fn() }, +})) + +const { GroupPaymentController } = + await import('../../controllers/group/group-payment-controller.js') +const { GroupPaymentRepository } = await import('../../repositories/group/group-payment-repository') + +const OVERPAID = ERROR_CODES.GROUP_PAYMENT_OVERPAID +const OVER_100 = ERROR_CODES.GROUP_PAYMENT_PERCENTAGE_OVER_100 + +describe('payment rules', () => { + it('the status follows the amounts and keeps "requested" until money arrives', () => { + expect(statusFor(100, 0)).toBe(PaymentStatus.PENDING) + expect(statusFor(100, 0, PaymentStatus.REQUESTED)).toBe(PaymentStatus.REQUESTED) + expect(statusFor(100, 40, PaymentStatus.REQUESTED)).toBe(PaymentStatus.PARTIAL) + expect(statusFor(100, 100)).toBe(PaymentStatus.PAID) + }) + + it('refuses more paid than owed, also when DECIMALs arrive as strings', () => { + expect(() => reconcilePayment({ amount: 100, amountPaid: 100.01 })).toThrow(OVERPAID) + expect(() => reconcilePayment({ amount: '100.00', amountPaid: '250.00' })).toThrow(OVERPAID) + expect(reconcilePayment({ amount: '100.00', amountPaid: '100.00' })).toEqual({ + amount_paid: 100, + status: PaymentStatus.PAID, + }) + }) + + it('marking it paid fills in what was paid', () => { + expect(reconcilePayment({ amount: 300, amountPaid: 0, status: PaymentStatus.PAID })).toEqual({ + amount_paid: 300, + status: PaymentStatus.PAID, + }) + }) + + it('percentages add up to 100 at most', () => { + expect(() => checkPercentages([{ percentage: '60.00' }], 40)).not.toThrow() + expect(() => + checkPercentages([{ percentage: 33.33 }, { percentage: 33.33 }], 33.34) + ).not.toThrow() + expect(() => checkPercentages([{ percentage: '60.00' }, { percentage: null }], 41)).toThrow( + OVER_100 + ) + }) + + it('the total recalculation sets the status from the new amount', () => { + const repo = readFileSync( + join( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'repositories', + 'group', + 'group-payment-repository.ts' + ), + 'utf8' + ) + const recalc = repo.slice(repo.indexOf('static async recalculateAmounts')) + expect(recalc.slice(0, 900)).toMatch( + /SET amount = \(\? \* percentage \/ 100\),\s+[\s\S]*status = CASE/ + ) + }) +}) + +function call( + handler: (req: Request, res: Response) => Promise, + params: object, + body: object +) { + const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() } + const req = { user: { id: 'u1' }, params, body } as unknown as Request + return handler(req, res as unknown as Response).then(() => res) +} + +const payment = { id: 5, group_id: 1, amount: '300.00', amount_paid: '0.00', status: 'pending' } +const params = { id: '1', paymentId: '5' } + +describe('payment endpoints', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(GroupPaymentRepository.getById).mockResolvedValue(payment as never) + }) + + it('PATCH status paid stores amount_paid = amount', async () => { + const res = await call(GroupPaymentController.updatePaymentStatus, params, { status: 'paid' }) + expect(res.status).toHaveBeenCalledWith(200) + expect(GroupPaymentRepository.update).toHaveBeenCalledWith(5, { + status: 'paid', + amount_paid: 300, + }) + }) + + it('PATCH amount-paid over the amount answers 400 and writes nothing', async () => { + const res = await call(GroupPaymentController.updateAmountPaid, params, { amount_paid: 301 }) + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: OVERPAID })) + expect(GroupPaymentRepository.update).not.toHaveBeenCalled() + }) + + it('PUT with a percentage over the group total answers 400', async () => { + vi.mocked(GroupPaymentRepository.getByGroupId).mockResolvedValue([ + { id: 6, percentage: '70.00' }, + ] as never) + const res = await call(GroupPaymentController.updatePayment, params, { percentage: 40 }) + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: OVER_100 })) + }) + + it('POST paid by hand is created with amount_paid filled in', async () => { + const res = await call( + GroupPaymentController.createPayment, + { id: '1' }, + { payment_name: 'Anticipo', amount: 200, due_date: '2026-11-01', status: 'paid' } + ) + expect(res.status).toHaveBeenCalledWith(201) + expect(GroupPaymentRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ amount_paid: 200, status: 'paid' }) + ) + }) +}) diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index 09360dee..bce4898f 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -201,6 +201,8 @@ const TRANSLATED = new Set([ 'GROUP_PAYMENT_DELETE_ERROR', 'GROUP_PAYMENT_FETCH_ERROR', 'GROUP_PAYMENT_INVALID_AMOUNT', + 'GROUP_PAYMENT_OVERPAID', + 'GROUP_PAYMENT_PERCENTAGE_OVER_100', 'GROUP_PAYMENT_MISSING_FIELDS', 'GROUP_PAYMENT_NOT_FOUND', 'GROUP_PAYMENT_NOT_IN_GROUP', diff --git a/frontend/messages/en/errors.json b/frontend/messages/en/errors.json index 409b8e3a..da1de2d4 100644 --- a/frontend/messages/en/errors.json +++ b/frontend/messages/en/errors.json @@ -290,6 +290,8 @@ "GROUP_ROOM_DELETE_ERROR": "Error deleting room", "GROUP_PAYMENT_MISSING_FIELDS": "Missing required fields: payment name, due date", "GROUP_PAYMENT_INVALID_AMOUNT": "Payment must have an amount greater than 0", + "GROUP_PAYMENT_OVERPAID": "The amount paid cannot exceed the payment amount", + "GROUP_PAYMENT_PERCENTAGE_OVER_100": "The group's payment percentages add up to more than 100", "GROUP_PAYMENT_NOT_FOUND": "Payment not found", "GROUP_PAYMENT_NOT_IN_GROUP": "Payment does not belong to this group", "GROUP_PAYMENT_FETCH_ERROR": "Error fetching payments", diff --git a/frontend/messages/es/errors.json b/frontend/messages/es/errors.json index c36f2733..0a0f4260 100644 --- a/frontend/messages/es/errors.json +++ b/frontend/messages/es/errors.json @@ -290,6 +290,8 @@ "GROUP_ROOM_DELETE_ERROR": "Error al eliminar habitación", "GROUP_PAYMENT_MISSING_FIELDS": "Faltan campos obligatorios: nombre del pago, fecha de vencimiento", "GROUP_PAYMENT_INVALID_AMOUNT": "El pago debe tener un monto mayor a 0", + "GROUP_PAYMENT_OVERPAID": "Lo pagado no puede superar el importe del pago", + "GROUP_PAYMENT_PERCENTAGE_OVER_100": "Los porcentajes de los pagos del grupo suman más de 100", "GROUP_PAYMENT_NOT_FOUND": "Pago no encontrado", "GROUP_PAYMENT_NOT_IN_GROUP": "El pago no pertenece a este grupo", "GROUP_PAYMENT_FETCH_ERROR": "Error al obtener pagos", From 2ca4afd72931a298f921c981ce43e9c642af0b79 Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 12:02:40 +0200 Subject: [PATCH 38/71] fix(groups): keep a group's history when it is deleted - 20261011_keep_group_history_on_delete.sql: drops the ON DELETE CASCADE foreign key from group_history.group_id; idempotent. - group-repository.ts: delete removes the group and writes a "deleted" history row with a snapshot of the group, in one transaction. - group-controller.ts: passes the user and the group to delete. - INDEX.md: registers the migration, applied locally, pending in Aiven. - group-deletion.test.ts: the delete writes its row, rolls back when the row fails, writes nothing when there was no group; the migration exists. Why: deleting a group deleted its whole history and logged nothing. Decided on 2026-10-11: delete the group but keep a trace. Checked in the browser against the local database with the migration applied: the group is gone, its earlier history stays and a deleted row holds the snapshot. The migration must reach Aiven before this code is deployed. --- backend/controllers/group/group-controller.ts | 2 +- backend/db-mysql/INDEX.md | 1 + .../20261011_keep_group_history_on_delete.sql | 40 ++++++++++ .../repositories/group/group-repository.ts | 30 ++++++- backend/tests/group/group-deletion.test.ts | 78 +++++++++++++++++++ 5 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 backend/db-mysql/scripts/20261011_keep_group_history_on_delete.sql create mode 100644 backend/tests/group/group-deletion.test.ts diff --git a/backend/controllers/group/group-controller.ts b/backend/controllers/group/group-controller.ts index 5083b020..fd5f09e5 100644 --- a/backend/controllers/group/group-controller.ts +++ b/backend/controllers/group/group-controller.ts @@ -253,7 +253,7 @@ export class GroupController { }) } - const deleted = await GroupRepository.delete(groupId) + const deleted = await GroupRepository.delete(groupId, userId, group) if (!deleted) { return res.status(500).json({ diff --git a/backend/db-mysql/INDEX.md b/backend/db-mysql/INDEX.md index 30eef9bf..5fb4e757 100644 --- a/backend/db-mysql/INDEX.md +++ b/backend/db-mysql/INDEX.md @@ -108,6 +108,7 @@ db-mysql/ | 2026-10-05 | `20261005_complete_fresh_install.sql` | Completa una instalación desde cero: el baseline congelado no crea `cashier_history` (nombre de clave duplicado), `scheduling_assignments` (referencia a una tabla creada después) ni `demo_activity_log`, y deja `roles.name` en `varchar(50)`. Definiciones copiadas de Aiven. En Aiven no cambia nada; en local creó `demo_activity_log`, que faltaba. `pnpm setup:local` además corrige esos dos fallos del baseline en memoria. | ✅ local · ✅ Aiven (sin cambios) | | 2026-10-05 | `20261005_fix_bo_monthly_summary_view.sql` | Recrea `v_bo_monthly_summary` con `period` en el `GROUP BY`. Con `ONLY_FULL_GROUP_BY` (activo en Aiven y local) toda consulta a la vista fallaba con el error 1055 y `GET /api/backoffice/stats/monthly` respondía siempre 500. Mismas columnas y mismos grupos; comprobada contra un cálculo directo sobre `bo_invoices`. | ✅ local · ✅ Aiven | | 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 | ### Verificación (99) diff --git a/backend/db-mysql/scripts/20261011_keep_group_history_on_delete.sql b/backend/db-mysql/scripts/20261011_keep_group_history_on_delete.sql new file mode 100644 index 00000000..96f77455 --- /dev/null +++ b/backend/db-mysql/scripts/20261011_keep_group_history_on_delete.sql @@ -0,0 +1,40 @@ +-- ============================================================================= +-- 20261011_keep_group_history_on_delete.sql +-- ============================================================================= +-- Drops the foreign key from group_history.group_id to hotel_groups.id. It was +-- ON DELETE CASCADE, so deleting a group also deleted its whole history and +-- left no trace of the deletion. Decided on 2026-10-11: a group is still +-- deleted, but its history stays and gains a "deleted" row with a snapshot of +-- the group (GroupRepository.delete). group_id keeps its index +-- (idx_group_id); rows of a deleted group point to an id that no longer +-- exists. +-- +-- Idempotent: if the foreign key is already gone the ALTER is a no-op. +-- Safe to run multiple times. +-- +-- Applies to: LOCAL + AIVEN. Apply it before deploying the code that writes +-- the deletion row: with the foreign key in place that insert fails and the +-- delete rolls back. +-- Rollback (only while no history row points to a deleted group): +-- ALTER TABLE group_history ADD CONSTRAINT group_history_ibfk_1 +-- FOREIGN KEY (group_id) REFERENCES hotel_groups (id) ON DELETE CASCADE; +-- ============================================================================= + +SET @fk_exists := ( + SELECT COUNT(*) + FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'group_history' + AND CONSTRAINT_NAME = 'group_history_ibfk_1' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' +); + +SET @ddl := IF( + @fk_exists = 1, + 'ALTER TABLE group_history DROP FOREIGN KEY group_history_ibfk_1', + 'SELECT ''group_history_ibfk_1 already dropped, skipping'' AS info' +); + +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/repositories/group/group-repository.ts b/backend/repositories/group/group-repository.ts index 90b96155..13bfa0f2 100644 --- a/backend/repositories/group/group-repository.ts +++ b/backend/repositories/group/group-repository.ts @@ -269,9 +269,33 @@ export class GroupRepository { /** * Eliminar grupo */ - static async delete(id: number): Promise { - const [result] = await db.query('DELETE FROM hotel_groups WHERE id = ?', [id]) - return result.affectedRows > 0 + static async delete(id: number, deletedBy: string, snapshot: unknown): Promise { + // The group goes, its history stays: group_history has no foreign key to + // hotel_groups since 20261011_keep_group_history_on_delete.sql. The delete + // and its history row commit or roll back together + const connection = await db.getConnection() + try { + await connection.beginTransaction() + const [result] = await connection.query( + 'DELETE FROM hotel_groups WHERE id = ?', + [id] + ) + if (result.affectedRows > 0) { + await connection.query( + `INSERT INTO group_history + (group_id, action, table_affected, record_id, old_value, changed_by, notes) + VALUES (?, 'deleted', 'hotel_groups', ?, ?, ?, 'Grupo eliminado')`, + [id, id, JSON.stringify(snapshot), deletedBy] + ) + } + await connection.commit() + return result.affectedRows > 0 + } catch (error) { + await connection.rollback() + throw error + } finally { + connection.release() + } } /** diff --git a/backend/tests/group/group-deletion.test.ts b/backend/tests/group/group-deletion.test.ts new file mode 100644 index 00000000..0d287e74 --- /dev/null +++ b/backend/tests/group/group-deletion.test.ts @@ -0,0 +1,78 @@ +// tests/group/group-deletion.test.ts +// Regression tests: deleting a group deleted its whole history too +// (group_history had ON DELETE CASCADE) and logged nothing. Decided on +// 2026-10-11: the group is still deleted, its history stays, and the delete +// writes a "deleted" row with a snapshot of the group, in the same +// transaction. + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const connection = { + beginTransaction: vi.fn(), + commit: vi.fn(), + rollback: vi.fn(), + release: vi.fn(), + query: vi.fn(), +} + +vi.mock('../../config/db.js', () => ({ + default: { getConnection: vi.fn(async () => connection), query: vi.fn() }, +})) + +const { GroupRepository } = await import('../../repositories/group/group-repository.js') + +const group = { id: 4, name: 'Congreso', total_amount: '12500.00' } + +describe('deleting a group', () => { + beforeEach(() => { + vi.clearAllMocks() + connection.query.mockReset() + }) + + it('deletes the group and writes a deleted row with its snapshot', async () => { + connection.query.mockResolvedValueOnce([{ affectedRows: 1 }]).mockResolvedValueOnce([{}]) + + expect(await GroupRepository.delete(4, 'u1', group)).toBe(true) + + const [del, history] = connection.query.mock.calls + expect(del[0]).toMatch(/DELETE FROM hotel_groups/) + expect(history[0]).toMatch(/INSERT INTO group_history[\s\S]*'deleted', 'hotel_groups'/) + expect(history[1]).toEqual([4, 4, JSON.stringify(group), 'u1']) + expect(connection.commit).toHaveBeenCalledOnce() + }) + + it('rolls the delete back when the history row fails', async () => { + connection.query + .mockResolvedValueOnce([{ affectedRows: 1 }]) + .mockRejectedValueOnce(new Error('history insert failed')) + + await expect(GroupRepository.delete(4, 'u1', group)).rejects.toThrow('history insert failed') + expect(connection.rollback).toHaveBeenCalledOnce() + expect(connection.commit).not.toHaveBeenCalled() + }) + + it('writes no history when there was no group to delete', async () => { + connection.query.mockResolvedValueOnce([{ affectedRows: 0 }]) + + expect(await GroupRepository.delete(4, 'u1', group)).toBe(false) + expect(connection.query).toHaveBeenCalledOnce() + }) + + it('a migration drops the cascade from group_history', () => { + const script = readFileSync( + join( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'db-mysql', + 'scripts', + '20261011_keep_group_history_on_delete.sql' + ), + 'utf8' + ) + expect(script).toContain('ALTER TABLE group_history DROP FOREIGN KEY group_history_ibfk_1') + }) +}) From acdef6df70bbc5645d74ef426d414f46c0419cd1 Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 12:04:05 +0200 Subject: [PATCH 39/71] docs(todo): close the entries fixed on 2026-10-11 and add the Aiven step - Remove: maintenance change and history without a transaction, incoherent group payments, manual notifications to inactive users. - Replace "deleting a group deletes its history" with what is left: group mutations and their history outside one transaction. - Add, high priority: apply 20261011_keep_group_history_on_delete.sql in Aiven before deploying the group deletion. - Add: the groups history logs fields that did not change. Why: these four entries sit next to each other, so editing them from four branches made every pull request conflict. They are fixed in opencode/fix-maintenance-history, fix-group-payments, fix-notification-recipients and fix-group-deletion; merge this one last. --- docs/TODO.md | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 473ba85c..77c13d3d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -30,6 +30,13 @@ preview. ### 🔴 Alta +- [ ] **Aplicar en Aiven `20261011_keep_group_history_on_delete.sql` antes de + desplegar el borrado de grupos** — el código escribe una fila de + historial tras borrar el grupo; con la clave foránea todavía en Aiven + ese insert falla y el borrado se deshace (500). Preview y producción + usan Aiven: aplicarla antes de que la rama llegue a compassionate, con + `scripts/apply-migration.sh aiven --dry-run` primero, y + marcarla en `backend/db-mysql/INDEX.md`. Ya aplicada en local. - [ ] **Rotar las credenciales de producción** — contraseña de la BD y claves de Cloudinary, y revocar las claves de servicios que ningún código usa. El historial publicado no las contiene, pero siguen en copias privadas @@ -208,20 +215,15 @@ preview. 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í._ -- [ ] **Maintenance: cambio e historial sin transacción** — un fallo entre ambos - deja cambios sin registrar. _Comprobado por mí el 2026-09-28._ -- [ ] **Groups: lógica de pagos incoherente** — se acepta un `amount_paid` mayor - que el importe, `paid` sin haber pagado nada, recálculos que no tocan el - estado y porcentajes que suman más de 100. _Según la revisión `security` - L3 del 2026-09-28 (fichero y línea en el informe); no repasado por mí._ -- [ ] **Groups: borrar un grupo borra su historial** — no se registra el borrado - y `group_history` tiene `ON DELETE CASCADE`; ninguna mutación va en - transacción con su registro. _Comprobado por mí el 2026-09-28._ - (transacciones, según el revisor) -- [ ] **Notificaciones manuales: se pueden enviar a usuarios inactivos** — el - formato, los repetidos, la prioridad y los textos ya se validan; que el - usuario exista y esté activo, no. _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._ - [ ] **Scheduling: generar un mes deja vacías las celdas de días fijos y solicitudes aprobadas** — el solver las recibe como bloqueadas, la generación no las reinserta y el borrado previo se las lleva. En Aiven, en From 8b8cf4c999075b649cc8c87724082e582a27d6ec Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 12:10:47 +0200 Subject: [PATCH 40/71] fix(scheduling): keep fixed-day and approved-request cells when generating - generated-rows.ts: builds the rows to insert from the solver matrix and the locked cells, skipping only those stored with source_constraint_id. - schedule-generate.controller.ts: uses it; the locked cells that live only in lockedCells (fixed days, approved requests) are inserted with their locked value. - generated-rows.test.ts: covers both kinds of locked cell and the virtual days of the previous month. Why: generation skipped every locked cell when inserting, while the delete before it only spares the stored ones, so fixed-day and approved-request cells ended empty. Checked against the local database: the two fixed-days employees had 0 cells in the four solver-generated months; regenerating October 2026 gives each 31 (P on weekdays, L at weekends), also in the grid. --- .../schedule-generate.controller.ts | 33 +++---------- backend/services/scheduling/generated-rows.ts | 43 +++++++++++++++++ .../tests/scheduling/generated-rows.test.ts | 48 +++++++++++++++++++ 3 files changed, 98 insertions(+), 26 deletions(-) create mode 100644 backend/services/scheduling/generated-rows.ts create mode 100644 backend/tests/scheduling/generated-rows.test.ts diff --git a/backend/controllers/scheduling/schedule-generate.controller.ts b/backend/controllers/scheduling/schedule-generate.controller.ts index c3b3f213..ebd8e747 100644 --- a/backend/controllers/scheduling/schedule-generate.controller.ts +++ b/backend/controllers/scheduling/schedule-generate.controller.ts @@ -9,6 +9,7 @@ import { buildSolverInput } from '../../services/scheduling/build-solver-input.j import { runSolver } from '../../services/scheduling/solver-client.js' import type { SolverSuccess, SolverOutput } from '../../services/scheduling/types/solver.js' import { solverErrorMessage } from '../../services/scheduling/solver-errors.js' +import { generatedRows } from '../../services/scheduling/generated-rows.js' /** Persiste el registro del run; nunca lanza — error de log no debe romper la respuesta. */ async function recordSolverRun(data: SolverRunRecord): Promise { @@ -164,32 +165,12 @@ export async function generateSchedule(req: Request, res: Response): Promise [d.day_number, d.id])) - const lockedSet = new Set() - for (const [empId, dayDayMap] of Object.entries(solverInput.lockedCells)) { - for (const dayNum of Object.keys(dayDayMap)) { - lockedSet.add(`${empId}:${dayNum}`) - } - } - - const toInsert: { - day_id: number - employee_id: string - shift_code: string - source_constraint_id: null - }[] = [] - for (const [empId, dayShifts] of Object.entries(success.matrix)) { - for (const [dayNumStr, shiftCode] of Object.entries(dayShifts)) { - if (lockedSet.has(`${empId}:${dayNumStr}`)) continue - const dayId = dayMap.get(Number(dayNumStr)) - if (!dayId) continue - toInsert.push({ - day_id: dayId, - employee_id: empId, - shift_code: shiftCode, - source_constraint_id: null, - }) - } - } + const persisted = new Set( + (await repo.getAssignmentsByMonth(monthId)) + .filter((a) => a.source_constraint_id !== null) + .map((a) => `${a.employee_id}:${a.day_id}`) + ) + const toInsert = generatedRows(success.matrix, solverInput.lockedCells, dayMap, persisted) const { deleted, inserted } = await repo.applyGeneratedSchedule(monthId, toInsert) logger.info({ monthId, deleted, inserted }, '[generate] transacción completada') diff --git a/backend/services/scheduling/generated-rows.ts b/backend/services/scheduling/generated-rows.ts new file mode 100644 index 00000000..59deb43e --- /dev/null +++ b/backend/services/scheduling/generated-rows.ts @@ -0,0 +1,43 @@ +// services/scheduling/generated-rows.ts +// Rows to insert after a generation. applyGeneratedSchedule deletes every +// assignment of the month without source_constraint_id, so only the cells +// stored with one survive. The other locked cells (approved requests and +// fixed days) live only in lockedCells and are inserted with their locked +// value; skipping them, as before 2026-10-11, left those cells empty. + +export interface GeneratedRow { + day_id: number + employee_id: string + shift_code: string + source_constraint_id: null +} + +export function generatedRows( + matrix: Record>, + lockedCells: Record>, + dayIdByNumber: ReadonlyMap, + persisted: ReadonlySet // `${employee_id}:${day_id}` stored with source_constraint_id +): GeneratedRow[] { + const cells: Record> = {} + for (const source of [matrix, lockedCells]) { + for (const [empId, dayShifts] of Object.entries(source)) { + cells[empId] = { ...cells[empId], ...dayShifts } + } + } + + const rows: GeneratedRow[] = [] + for (const [empId, dayShifts] of Object.entries(cells)) { + for (const [dayNum, shiftCode] of Object.entries(dayShifts)) { + // Negative keys are the solver's virtual days of the previous month + const dayId = dayIdByNumber.get(Number(dayNum)) + if (!dayId || persisted.has(`${empId}:${dayId}`)) continue + rows.push({ + day_id: dayId, + employee_id: empId, + shift_code: shiftCode, + source_constraint_id: null, + }) + } + } + return rows +} diff --git a/backend/tests/scheduling/generated-rows.test.ts b/backend/tests/scheduling/generated-rows.test.ts new file mode 100644 index 00000000..26dcfb29 --- /dev/null +++ b/backend/tests/scheduling/generated-rows.test.ts @@ -0,0 +1,48 @@ +// tests/scheduling/generated-rows.test.ts +// Regression tests: generating a month skipped every locked cell when +// inserting, while the delete before it only spares cells stored with +// source_constraint_id. Fixed days and approved requests are locked but not +// stored that way, so their cells ended empty (seen in the solver-generated +// months: the fixed-days employees had 0 cells). + +import { describe, it, expect } from 'vitest' +import { generatedRows } from '../../services/scheduling/generated-rows.js' + +const dayIdByNumber = new Map([ + [1, 101], + [2, 102], + [3, 103], +]) + +describe('generatedRows', () => { + it('inserts the locked cells that are not stored, with their locked value', () => { + const rows = generatedRows( + { fixed: { '1': 'P', '2': 'P', '3': 'L' }, other: { '1': 'M', '2': 'T', '3': 'N' } }, + { fixed: { '1': 'P', '2': 'P', '3': 'L' }, other: { '2': 'V' } }, + dayIdByNumber, + new Set() + ) + const cell = (emp: string, day: number) => + rows.find((r) => r.employee_id === emp && r.day_id === day)?.shift_code + expect(rows).toHaveLength(6) + expect([cell('fixed', 101), cell('fixed', 102), cell('fixed', 103)]).toEqual(['P', 'P', 'L']) + expect(cell('other', 102)).toBe('V') // the approved request wins over the matrix + }) + + it('skips the cells stored with source_constraint_id, which the delete keeps', () => { + const rows = generatedRows( + { e: { '1': 'V', '2': 'M' } }, + { e: { '1': 'V' } }, + dayIdByNumber, + new Set(['e:101']) + ) + expect(rows).toEqual([ + { day_id: 102, employee_id: 'e', shift_code: 'M', source_constraint_id: null }, + ]) + }) + + it('ignores the virtual days of the previous month', () => { + const rows = generatedRows({ e: { '-1': 'N', '1': 'L' } }, {}, dayIdByNumber, new Set()) + expect(rows.map((r) => r.day_id)).toEqual([101]) + }) +}) From 1c4899ea8c2c66eeb9be317ddc7e1b18f19b431d Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 12:12:43 +0200 Subject: [PATCH 41/71] fix(scheduling): make a published month read-only - scheduling-controller.ts: refusePublished answers 409 when the month is published; editing a cell or a day (one or in bulk) and creating, editing, approving or deleting a constraint use it. PUT /months/:id no longer goes back to draft: that is what unpublish is for. Notes can still be edited. - published-month.test.ts: the nine paths answer 409 and write nothing. Why: none of these paths looked at the month status, so a published roster could change under the staff reading it. Checked with curl against the local database: on a published month a cell edit and PUT back to draft answer 409; after unpublish the same edit answers 200. --- .../scheduling/scheduling-controller.ts | 22 ++++ .../tests/scheduling/published-month.test.ts | 120 ++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 backend/tests/scheduling/published-month.test.ts diff --git a/backend/controllers/scheduling/scheduling-controller.ts b/backend/controllers/scheduling/scheduling-controller.ts index 7a733520..0addb5cf 100644 --- a/backend/controllers/scheduling/scheduling-controller.ts +++ b/backend/controllers/scheduling/scheduling-controller.ts @@ -165,6 +165,15 @@ function handleZodError(err: unknown, res: Response): boolean { // CONFIG // ============================================ +// A published month is read-only: its cells, days and constraints change only +// after POST /months/:id/unpublish. Answers 409 and returns true when it is +async function refusePublished(monthId: number, res: Response): Promise { + const month = await repo.getMonthById(monthId) + if (month?.status !== 'published') return false + res.status(409).json({ error: 'El mes está publicado: despublícalo para editarlo' }) + return true +} + export async function getAllConfig(_req: Request, res: Response): Promise { try { const config = await repo.getAllConfig() @@ -867,6 +876,11 @@ export async function updateMonth(req: Request, res: Response): Promise { return } + if (existing.status === 'published' && data.status === 'draft') { + res.status(409).json({ error: 'Para volver a borrador, despublica el mes' }) + return + } + // Handle status changes if (data.status === 'published' && existing.status !== 'published') { await repo.updateMonth(monthId, { @@ -934,6 +948,7 @@ export async function updateDay(req: Request, res: Response): Promise { res.status(404).json({ error: 'Día no encontrado' }) return } + if (await refusePublished(existing.month_id, res)) return await repo.updateDay(dayId, data) const day = await repo.getDayById(dayId) @@ -961,6 +976,7 @@ export async function bulkUpdateDays(req: Request, res: Response): Promise res.status(404).json({ error: 'Mes no encontrado' }) return } + if (await refusePublished(monthId, res)) return const updatedCount = await repo.bulkUpdateDays(data.days) @@ -996,6 +1012,7 @@ export async function updateAssignment(req: Request, res: Response): Promise ({ + getMonthById: vi.fn(), + getDayById: vi.fn(async () => ({ id: 7, month_id: 140 })), + getAssignmentById: vi.fn(async () => ({ id: 9, month_id: 140, shift_code: 'M' })), + getConstraintById: vi.fn(async () => ({ id: 3, month_id: 140, status: 'pending' })), + updateMonth: vi.fn(), + updateDay: vi.fn(), + bulkUpdateDays: vi.fn(), + updateAssignment: vi.fn(), + bulkUpsertAssignments: vi.fn(), + createConstraint: vi.fn(), + updateConstraint: vi.fn(), + deleteConstraint: vi.fn(), + createHistory: vi.fn(), +})) + +const controller = await import('../../controllers/scheduling/scheduling-controller.js') +const repo = await import('../../repositories/scheduling/scheduling-repository.js') + +function call( + handler: (req: Request, res: Response) => Promise, + params: object, + body: object +) { + const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() } + const req = { user: { id: 'u1' }, params, body } as unknown as Request + return handler(req, res as unknown as Response).then(() => res) +} + +const writes = [ + 'updateMonth', + 'updateDay', + 'bulkUpdateDays', + 'updateAssignment', + 'bulkUpsertAssignments', + 'createConstraint', + 'updateConstraint', + 'deleteConstraint', +] as const + +const cases: Array<[string, () => ReturnType]> = [ + ['updateDay', () => call(controller.updateDay, { dayId: '7' }, { is_holiday: true })], + [ + 'bulkUpdateDays', + () => + call(controller.bulkUpdateDays, { id: '140' }, { days: [{ day_id: 7, is_holiday: true }] }), + ], + [ + 'updateAssignment', + () => call(controller.updateAssignment, { assignmentId: '9' }, { shift_code: 'T' }), + ], + [ + 'bulkUpdateAssignments', + () => + call( + controller.bulkUpdateAssignments, + { id: '140' }, + { assignments: [{ day_id: 7, employee_id: EMPLOYEE, shift_code: 'T' }] } + ), + ], + [ + 'createConstraint', + () => + call( + controller.createConstraint, + {}, + { + month_id: 140, + employee_id: EMPLOYEE, + constraint_type: 'vacation', + start_date: '2026-10-05', + end_date: '2026-10-06', + } + ), + ], + [ + 'updateConstraint', + () => call(controller.updateConstraint, { constraintId: '3' }, { priority: 2 }), + ], + [ + 'approveConstraint', + () => call(controller.approveConstraint, { constraintId: '3' }, { status: 'approved' }), + ], + ['deleteConstraint', () => call(controller.deleteConstraint, { constraintId: '3' }, {})], + [ + 'updateMonth back to draft', + () => call(controller.updateMonth, { id: '140' }, { status: 'draft' }), + ], +] + +describe('a published month is read-only', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(repo.getMonthById).mockResolvedValue({ id: 140, status: 'published' } as never) + }) + + it.each(cases)('%s answers 409 and writes nothing', async (_name, run) => { + const res = await run() + expect(res.status).toHaveBeenCalledWith(409) + for (const write of writes) expect(repo[write], write).not.toHaveBeenCalled() + }) + + it('notes can still be edited on a published month', async () => { + const res = await call(controller.updateMonth, { id: '140' }, { notes: 'Cerrado' }) + expect(res.status).not.toHaveBeenCalledWith(409) + expect(repo.updateMonth).toHaveBeenCalledOnce() + }) +}) From 9d96e603579dde8db0f5ec71a9e52e47fec3812a Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 12:14:33 +0200 Subject: [PATCH 42/71] fix(scheduling): keep employee dates and order when saving the list - scheduling-repository.ts: setSchedulableEmployees removes only the employees left out and adds only the new ones, in one transaction; the rows that stay keep start_date, end_date, display_order and added_by. An unknown id still fails instead of being ignored. - schedulable-employees.test.ts: the queries it runs, the empty list and the rollback. Why: it cleared scheduling_employees and filled it again, so every save lost the start and end dates and the manual order. Checked with curl against the local database: a start date set on an employee survives saving the same list. --- .../scheduling/scheduling-repository.ts | 32 +++++++++--- .../scheduling/schedulable-employees.test.ts | 51 +++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 backend/tests/scheduling/schedulable-employees.test.ts diff --git a/backend/repositories/scheduling/scheduling-repository.ts b/backend/repositories/scheduling/scheduling-repository.ts index efb90374..fbc8e501 100644 --- a/backend/repositories/scheduling/scheduling-repository.ts +++ b/backend/repositories/scheduling/scheduling-repository.ts @@ -1361,13 +1361,31 @@ export async function setSchedulableEmployees( employeeIds: string[], addedBy?: string ): Promise { - // Clear existing - await db.query(`DELETE FROM scheduling_employees`) - - // Insert new ones - if (employeeIds.length > 0) { - const values = employeeIds.map((id) => [id, addedBy || null]) - await db.query(`INSERT INTO scheduling_employees (employee_id, added_by) VALUES ?`, [values]) + // Remove the ones left out and add the new ones; those that stay keep + // their row (start_date, end_date, display_order, added_by). Clearing the + // table and filling it again lost all of that + const connection = await db.getConnection() + try { + await connection.beginTransaction() + if (employeeIds.length === 0) { + await connection.query(`DELETE FROM scheduling_employees`) + } else { + await connection.query(`DELETE FROM scheduling_employees WHERE employee_id NOT IN (?)`, [ + employeeIds, + ]) + const values = employeeIds.map((id) => [id, addedBy || null]) + await connection.query( + `INSERT INTO scheduling_employees (employee_id, added_by) VALUES ? + ON DUPLICATE KEY UPDATE employee_id = employee_id`, + [values] + ) + } + await connection.commit() + } catch (error) { + await connection.rollback() + throw error + } finally { + connection.release() } } diff --git a/backend/tests/scheduling/schedulable-employees.test.ts b/backend/tests/scheduling/schedulable-employees.test.ts new file mode 100644 index 00000000..6cf7c435 --- /dev/null +++ b/backend/tests/scheduling/schedulable-employees.test.ts @@ -0,0 +1,51 @@ +// tests/scheduling/schedulable-employees.test.ts +// Regression tests: saving the list of schedulable employees cleared +// scheduling_employees and filled it again, so everyone lost start_date, +// end_date, display_order and added_by. Now only the ones left out are +// removed and only the new ones are added, in one transaction. + +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(async () => [{}]), +} + +vi.mock('../../config/db.js', () => ({ + default: { getConnection: vi.fn(async () => connection), query: vi.fn(), execute: vi.fn() }, +})) + +const { setSchedulableEmployees } = + await import('../../repositories/scheduling/scheduling-repository.js') + +const sql = () => connection.query.mock.calls.map((c) => String(c[0]).replace(/\s+/g, ' ')) + +describe('setSchedulableEmployees', () => { + beforeEach(() => vi.clearAllMocks()) + + it('keeps the rows of the employees that stay', async () => { + await setSchedulableEmployees(['a', 'b'], 'admin') + + expect(sql()).toEqual([ + 'DELETE FROM scheduling_employees WHERE employee_id NOT IN (?)', + 'INSERT INTO scheduling_employees (employee_id, added_by) VALUES ? ON DUPLICATE KEY UPDATE employee_id = employee_id', + ]) + expect(connection.query.mock.calls[0][1]).toEqual([['a', 'b']]) + expect(connection.commit).toHaveBeenCalledOnce() + }) + + it('an empty list clears the table', async () => { + await setSchedulableEmployees([]) + expect(sql()).toEqual(['DELETE FROM scheduling_employees']) + }) + + it('rolls back when the insert fails', async () => { + connection.query.mockResolvedValueOnce([{}]).mockRejectedValueOnce(new Error('fk')) + await expect(setSchedulableEmployees(['x'])).rejects.toThrow('fk') + expect(connection.rollback).toHaveBeenCalledOnce() + expect(connection.commit).not.toHaveBeenCalled() + }) +}) From 89e957ae14ee8bd2f0bf42d7d64ace30ce654cc6 Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 12:22:28 +0200 Subject: [PATCH 43/71] docs(scheduling): record that the solver stays non-deterministic - decisions.md: 2026-10-11 entry with the measurements (current, seed, one thread, interleave_search) on the local month and the 30x31 benchmark, the decision and how to revisit it. Why: the owner chose generation speed over reproducibility; the TODO entry asked to decide it. --- docs/scheduling/decisions.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/scheduling/decisions.md b/docs/scheduling/decisions.md index 982d97e6..0ec4e18e 100644 --- a/docs/scheduling/decisions.md +++ b/docs/scheduling/decisions.md @@ -1304,3 +1304,36 @@ an L,L pair). The validator (`consecutive-rest.constraint.ts`) matches the spec. The Python solver (`rest.py`) implements it as **sum(rest) ≥ 2** (it does not require them to be consecutive). A historical drift since the start of the Python work. Alignment pending. + +## 2026-10-11 — Solver determinism: left as it is + +**Context.** The solver gets no seed and CP-SAT searches with every core in +parallel, so generating the same month twice can give two different rosters. +Measured on the local October 2026 month (10 employees, 31 days) and on the +synthetic 30×31 benchmark, four runs per setting on 12 cores: + +| Setting | 10 employees | 30×31 | +| --------------------------------------- | --------------------------------------------- | ----------------------------------------- | +| Current (no seed, all threads) | 4 different rosters, all OPTIMAL, ~0.8 s | different rosters, FEASIBLE at 30 s | +| Seed 0, all threads | 3 different rosters out of 4 | — | +| Seed 0, 1 thread | always the same, but no solution in 30 s | — | +| Seed 0, `interleave_search`, 8 threads | always the same, OPTIMAL, ~3.5 s | the same roster, FEASIBLE at 30 s | + +Every roster in a row had the same soft penalty: they are equally good, only +different. With `interleave_search` the result also depends on `num_workers` +(8 and 12 give different rosters), and when the 30 s limit is hit the cut +point depends on the speed of the machine, so local and production could +still differ on large months. + +**Decision.** Keep the current behaviour: no seed, all threads, a wall-clock +limit. Two generations of the same month may differ; both satisfy every hard +rule and have the same penalty. + +**Why.** Reproducibility would cost about 4× the generation time for the +current hotel and would still not hold across machines for months that hit +the time limit. A roster is generated a few times a month and then edited by +hand; the owner chose speed over reproducibility. + +**If it is revisited.** Set `random_seed`, `interleave_search = True` and a +fixed `num_workers` in `model.py`, and replace `max_time_in_seconds` with +`max_deterministic_time` for months that reach the limit. From 8496d9fa8119cf1d071d28af3d3869b8846bff8b Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 12:22:30 +0200 Subject: [PATCH 44/71] docs(todo): close the four scheduling entries reviewed on 2026-10-11 - Remove: generation leaving fixed-day and approved-request cells empty, a published month that could be changed, setSchedulableEmployees losing dates, and the non-deterministic solver. Why: the first three are fixed in opencode/fix-scheduling; the fourth was decided (left as it is) and recorded in docs/scheduling/decisions.md. --- docs/TODO.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 77c13d3d..25d1e7ec 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -224,22 +224,6 @@ preview. 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._ -- [ ] **Scheduling: generar un mes deja vacías las celdas de días fijos y - solicitudes aprobadas** — el solver las recibe como bloqueadas, la - generación no las reinserta y el borrado previo se las lleva. En Aiven, en - los meses 126 y 130 (generados por el solver) el empleado con días fijos - tiene 0 celdas. _Comprobado por mí el 2026-09-28._ -- [ ] **Scheduling: un mes publicado se puede modificar** — editar celdas y - aprobar restricciones no miran el estado del mes, y `PUT /months/:id` - vuelve a borrador sin pasar por `unpublish`. _Según la revisión `security` - L3 del 2026-09-28 (fichero y línea en el informe); no repasado por mí._ -- [ ] **Scheduling: `setSchedulableEmployees` pierde las fechas de alta y baja** - — borra la tabla entera y la vuelve a llenar. _Comprobado por mí el - 2026-09-28._ -- [ ] **Scheduling: el solver no es determinista** — no recibe semilla y CP-SAT - busca en paralelo: dos ejecuciones del mismo mes pueden dar horarios - distintos. Decidir si se fija semilla y un solo hilo. _Comprobado por mí - el 2026-09-28._ - [ ] **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 From 355b844e42172dc3f34e340392dc974a8ca7090a Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 12:35:24 +0200 Subject: [PATCH 45/71] fix(cashier): final voucher states, atomic voucher creation, guarded totals - cashier-voucher-repository.ts: only a pending voucher can change its amount, reason or notes, be justified or be cancelled; the write itself checks the status. createForShift locks the shift, counts its vouchers and creates and links the new one in one transaction; create, canAddVoucherToShift and associateWithShift go. - cashier-voucher-controller.ts: create uses createForShift and answers domain errors with their status. - cashier-errors.ts: the new voucher errors. - cashier-routes.ts: GET /daily, /shifts/:id/history and /vouchers/stats require canViewReports, like the other history and report routes. - voucher-lifecycle.test.ts: transitions, the locked creation, the limit and the three guarded routes. Why: a cancelled voucher could be justified, a justified one cancelled and the amount of either changed; the 5-voucher limit could be passed in parallel and going over it left a voucher without a shift; reception saw history and totals meant for admin. Decided on 2026-10-11: justified and cancelled are final. Checked with curl against the local database: reception gets 403 on the three routes and admin 200; a justified voucher refuses a new amount, cancel and justify; a voucher on a missing shift answers 404 and creates nothing. --- backend/controllers/cashier/cashier-errors.ts | 4 + .../cashier/cashier-voucher-controller.ts | 17 +-- .../cashier/cashier-voucher-repository.ts | 115 ++++++++-------- backend/routes/cashier/cashier-routes.ts | 11 +- .../tests/cashier/voucher-lifecycle.test.ts | 124 ++++++++++++++++++ 5 files changed, 199 insertions(+), 72 deletions(-) create mode 100644 backend/tests/cashier/voucher-lifecycle.test.ts diff --git a/backend/controllers/cashier/cashier-errors.ts b/backend/controllers/cashier/cashier-errors.ts index 6631cc78..e848e0cc 100644 --- a/backend/controllers/cashier/cashier-errors.ts +++ b/backend/controllers/cashier/cashier-errors.ts @@ -17,6 +17,10 @@ export const CASHIER_DOMAIN_ERRORS: Record = { 'El vale ya está justificado': 409, 'El vale ya está cancelado': 409, 'Solo se pueden eliminar vales pendientes': 409, + 'Solo se pueden modificar vales pendientes': 409, + 'Solo se pueden justificar vales pendientes': 409, + 'Solo se pueden cancelar vales pendientes': 409, + 'El turno ya tiene el máximo de 5 vales': 400, } /** Known domain errors keep their message; anything else is a generic 500 (no SQL details). */ diff --git a/backend/controllers/cashier/cashier-voucher-controller.ts b/backend/controllers/cashier/cashier-voucher-controller.ts index 3ea6576e..865e6f7b 100644 --- a/backend/controllers/cashier/cashier-voucher-controller.ts +++ b/backend/controllers/cashier/cashier-voucher-controller.ts @@ -133,18 +133,8 @@ export class CashierVoucherController { } const voucherData = { ...parsed.data, created_by: userId } - // Verificar límite de 5 vales por turno - const canAdd = await CashierVoucherRepository.canAddVoucherToShift(parseInt(shiftId)) - if (!canAdd) { - res.status(400).json({ error: 'El turno ya tiene el máximo de 5 vales' }) - return - } - - // Crear vale - const voucher = await CashierVoucherRepository.create(voucherData) - - // Asociar con turno - await CashierVoucherRepository.associateWithShift(voucher.id, parseInt(shiftId)) + // Crear el vale y asociarlo al turno (límite de 5, en una transacción) + const voucher = await CashierVoucherRepository.createForShift(voucherData, parseInt(shiftId)) // Registrar en historial await CashierHistoryRepository.create({ @@ -158,8 +148,7 @@ export class CashierVoucherController { res.status(201).json(voucher) } catch (error) { - logger.error({ err: error }, 'Error al crear vale') - res.status(500).json({ error: 'Error al crear vale' }) + sendCashierError(res, error, 'Error al crear vale') } } diff --git a/backend/repositories/cashier/cashier-voucher-repository.ts b/backend/repositories/cashier/cashier-voucher-repository.ts index 43848cb4..4dd944c1 100644 --- a/backend/repositories/cashier/cashier-voucher-repository.ts +++ b/backend/repositories/cashier/cashier-voucher-repository.ts @@ -11,32 +11,6 @@ import { } from '../../models/cashier/index.js' export class CashierVoucherRepository { - /** - * Crear un vale - */ - static async create(data: CreateVoucherDTO): Promise { - const query = ` - INSERT INTO cashier_vouchers ( - amount, - reason, - created_by, - notes - ) VALUES (?, ?, ?, ?) - ` - - const [result] = await db.query(query, [ - data.amount, - data.reason, - data.created_by, - data.notes || null, - ]) - - const created = await this.getById(result.insertId) - if (!created) throw new Error('Error al recuperar vale creado') - - return created - } - /** * Obtener vale por ID */ @@ -199,14 +173,19 @@ export class CashierVoucherRepository { throw new Error('No hay campos para actualizar') } + // Justified and cancelled are final (2026-10-11): only a pending voucher changes + const current = await this.getById(id) + if (!current) throw new Error('Vale no encontrado') + const query = ` - UPDATE cashier_vouchers + UPDATE cashier_vouchers SET ${updates.join(', ')} - WHERE id = ? + WHERE id = ? AND status = 'pending' ` params.push(id) - await db.query(query, params) + const [result] = await db.query(query, params) + if (result.affectedRows === 0) throw new Error('Solo se pueden modificar vales pendientes') const updated = await this.getById(id) if (!updated) throw new Error('Error al recuperar vale actualizado') @@ -221,19 +200,23 @@ export class CashierVoucherRepository { const voucher = await this.getById(id) if (!voucher) throw new Error('Vale no encontrado') if (voucher.status === 'justified') throw new Error('El vale ya está justificado') + if (voucher.status !== 'pending') throw new Error('Solo se pueden justificar vales pendientes') const connection = await db.getConnection() try { await connection.beginTransaction() - // Actualizar estado del vale - await connection.query( - `UPDATE cashier_vouchers - SET status = 'justified', justified_at = NOW() - WHERE id = ?`, + // Actualizar estado del vale (solo si sigue pendiente) + const [updated] = await connection.query( + `UPDATE cashier_vouchers + SET status = 'justified', justified_at = NOW() + WHERE id = ? AND status = 'pending'`, [id] ) + if (updated.affectedRows === 0) { + throw new Error('Solo se pueden justificar vales pendientes') + } // Asociar vale con turno await connection.query( @@ -262,14 +245,16 @@ export class CashierVoucherRepository { const voucher = await this.getById(id) if (!voucher) throw new Error('Vale no encontrado') if (voucher.status === 'cancelled') throw new Error('El vale ya está cancelado') + if (voucher.status !== 'pending') throw new Error('Solo se pueden cancelar vales pendientes') const query = ` UPDATE cashier_vouchers SET status = 'cancelled', cancelled_at = NOW() - WHERE id = ? + WHERE id = ? AND status = 'pending' ` - await db.query(query, [id]) + const [result] = await db.query(query, [id]) + if (result.affectedRows === 0) throw new Error('Solo se pueden cancelar vales pendientes') // Eliminar asociación con turnos si existe await db.query('DELETE FROM cashier_shift_vouchers WHERE voucher_id = ?', [id]) @@ -356,29 +341,49 @@ export class CashierVoucherRepository { } /** - * Verificar si se puede añadir más vales a un turno (límite: 5) + * Crear un vale en un turno: comprueba el límite de 5 y lo asocia en una sola + * transacción, con el turno bloqueado, para que dos altas a la vez no lo salten + * ni dejen un vale sin turno */ - static async canAddVoucherToShift(shiftId: number): Promise { - const count = await this.countByShift(shiftId) - return count < 5 - } + static async createForShift(data: CreateVoucherDTO, shiftId: number): Promise { + const connection = await db.getConnection() + let voucherId: number + try { + await connection.beginTransaction() - /** - * Asociar vale existente con un turno - */ - static async associateWithShift(voucherId: number, shiftId: number): Promise { - // Verificar límite de 5 vales por turno - const canAdd = await this.canAddVoucherToShift(shiftId) - if (!canAdd) { - throw new Error('El turno ya tiene el máximo de 5 vales') - } + const [shift] = await connection.query( + 'SELECT id FROM cashier_shifts WHERE id = ? FOR UPDATE', + [shiftId] + ) + if (!shift[0]) throw new Error('Turno no encontrado') - const query = ` - INSERT INTO cashier_shift_vouchers (shift_id, voucher_id) - VALUES (?, ?) - ` + const [count] = await connection.query( + 'SELECT COUNT(*) AS total FROM cashier_shift_vouchers WHERE shift_id = ?', + [shiftId] + ) + if (Number(count[0].total) >= 5) throw new Error('El turno ya tiene el máximo de 5 vales') + + const [result] = await connection.query( + 'INSERT INTO cashier_vouchers (amount, reason, created_by, notes) VALUES (?, ?, ?, ?)', + [data.amount, data.reason, data.created_by, data.notes || null] + ) + voucherId = result.insertId + await connection.query( + 'INSERT INTO cashier_shift_vouchers (shift_id, voucher_id) VALUES (?, ?)', + [shiftId, voucherId] + ) - await db.query(query, [shiftId, voucherId]) + await connection.commit() + } catch (error) { + await connection.rollback() + throw error + } finally { + connection.release() + } + + const created = await this.getById(voucherId) + if (!created) throw new Error('Error al recuperar vale creado') + return created } /** diff --git a/backend/routes/cashier/cashier-routes.ts b/backend/routes/cashier/cashier-routes.ts index 882e4718..5de1de08 100644 --- a/backend/routes/cashier/cashier-routes.ts +++ b/backend/routes/cashier/cashier-routes.ts @@ -100,7 +100,7 @@ router.get('/daily/:date/summary', authenticateToken, CashierDailyController.get * GET /api/cashier/daily * Listar días con paginación */ -router.get('/daily', authenticateToken, CashierDailyController.getAll) +router.get('/daily', authenticateToken, canViewReports, CashierDailyController.getAll) /** * GET /api/cashier/reports/monthly/:year/:month @@ -163,7 +163,12 @@ router.delete('/shifts/:id', authenticateToken, isAdmin, CashierShiftController. * GET /api/cashier/shifts/:id/history * Obtener historial de un turno */ -router.get('/shifts/:id/history', authenticateToken, CashierShiftController.getHistory) +router.get( + '/shifts/:id/history', + authenticateToken, + canViewReports, + CashierShiftController.getHistory +) // ============================================ // 🎫 VOUCHER ROUTES (Vales) @@ -185,7 +190,7 @@ router.get('/vouchers/active', authenticateToken, CashierVoucherController.getAc * GET /api/cashier/vouchers/stats * Obtener estadísticas de vales */ -router.get('/vouchers/stats', authenticateToken, CashierVoucherController.getStats) +router.get('/vouchers/stats', authenticateToken, canViewReports, CashierVoucherController.getStats) /** * GET /api/cashier/vouchers/:id diff --git a/backend/tests/cashier/voucher-lifecycle.test.ts b/backend/tests/cashier/voucher-lifecycle.test.ts new file mode 100644 index 00000000..c36f20d3 --- /dev/null +++ b/backend/tests/cashier/voucher-lifecycle.test.ts @@ -0,0 +1,124 @@ +// tests/cashier/voucher-lifecycle.test.ts +// Regression tests for the cashier vouchers and reports: +// - a cancelled voucher could be justified, a justified one cancelled and the +// amount of either changed. Decided on 2026-10-11: justified and cancelled +// are final, only a pending voucher changes. +// - the 5-voucher limit was checked before writing, without a lock, and the +// voucher was created before linking it, so going over the limit left it +// without a shift. +// - GET /daily, /shifts/:id/history and /vouchers/stats only needed a session. + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const connection = { + beginTransaction: vi.fn(), + commit: vi.fn(), + rollback: vi.fn(), + release: vi.fn(), + query: vi.fn(), +} + +vi.mock('../../config/db.js', () => ({ + default: { getConnection: vi.fn(async () => connection), query: vi.fn() }, +})) + +const { CashierVoucherRepository: repo } = + await import('../../repositories/cashier/cashier-voucher-repository.js') +const { default: db } = await import('../../config/db.js') + +const voucher = (status: string) => ({ id: 4, status, amount: 20, shift_id: 1 }) as never + +describe('voucher lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() + connection.query.mockReset() + }) + + it.each(['justified', 'cancelled'])('a %s voucher cannot change its amount', async (status) => { + vi.spyOn(repo, 'getById').mockResolvedValue(voucher(status)) + vi.mocked(db.query).mockResolvedValue([{ affectedRows: 0 }] as never) + await expect(repo.update(4, { amount: 99 })).rejects.toThrow( + 'Solo se pueden modificar vales pendientes' + ) + expect(String(vi.mocked(db.query).mock.calls[0][0])).toMatch(/status = 'pending'/) + }) + + it('a cancelled voucher cannot be justified', async () => { + vi.spyOn(repo, 'getById').mockResolvedValue(voucher('cancelled')) + await expect(repo.justify(4, 1)).rejects.toThrow('Solo se pueden justificar vales pendientes') + expect(connection.query).not.toHaveBeenCalled() + }) + + it('a justified voucher cannot be cancelled', async () => { + vi.spyOn(repo, 'getById').mockResolvedValue(voucher('justified')) + await expect(repo.cancel(4)).rejects.toThrow('Solo se pueden cancelar vales pendientes') + expect(db.query).not.toHaveBeenCalled() + }) + + it('a pending voucher is justified only if it is still pending when written', async () => { + vi.spyOn(repo, 'getById').mockResolvedValue(voucher('pending')) + connection.query.mockResolvedValueOnce([{ affectedRows: 0 }]) // changed meanwhile + await expect(repo.justify(4, 1)).rejects.toThrow('Solo se pueden justificar vales pendientes') + expect(connection.rollback).toHaveBeenCalledOnce() + }) +}) + +describe('creating a voucher in a shift', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() + connection.query.mockReset() + }) + + it('locks the shift, counts inside the transaction and links the voucher', async () => { + vi.spyOn(repo, 'getById').mockResolvedValue(voucher('pending')) + connection.query + .mockResolvedValueOnce([[{ id: 1 }]]) + .mockResolvedValueOnce([[{ total: 4 }]]) + .mockResolvedValueOnce([{ insertId: 4 }]) + .mockResolvedValueOnce([{}]) + + await repo.createForShift({ amount: 20, reason: 'Taxi', created_by: 'u1' } as never, 1) + + const sql = connection.query.mock.calls.map((c) => String(c[0])) + expect(sql[0]).toMatch(/FROM cashier_shifts WHERE id = \? FOR UPDATE/) + expect(sql[2]).toMatch(/INSERT INTO cashier_vouchers/) + expect(sql[3]).toMatch(/INSERT INTO cashier_shift_vouchers/) + expect(connection.commit).toHaveBeenCalledOnce() + }) + + it('refuses the sixth voucher and creates nothing', async () => { + connection.query.mockResolvedValueOnce([[{ id: 1 }]]).mockResolvedValueOnce([[{ total: 5 }]]) + + await expect( + repo.createForShift({ amount: 20, reason: 'Taxi', created_by: 'u1' } as never, 1) + ).rejects.toThrow('El turno ya tiene el máximo de 5 vales') + expect(connection.query).toHaveBeenCalledTimes(2) + expect(connection.rollback).toHaveBeenCalledOnce() + }) +}) + +describe('cashier history and totals', () => { + it('need canViewReports', () => { + const routes = readFileSync( + join( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'routes', + 'cashier', + 'cashier-routes.ts' + ), + 'utf8' + ).replace(/\s+/g, ' ') + for (const path of ["'/daily',", "'/shifts/:id/history',", "'/vouchers/stats',"]) { + expect(routes, path).toMatch( + new RegExp(`router\\.get\\( ?${path} authenticateToken, canViewReports,`) + ) + } + }) +}) From 2c0101fdeaf8f0b49198c1c0530184bb7d0b87c5 Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 12:37:40 +0200 Subject: [PATCH 46/71] fix(checklist): keep closed days' comments and photos to admin - checklist-comments.repository.ts: findComment returns the author, the run and whether the run is closed; findAttachment also says if its run is closed; deleteComment only deletes. - checklist-comments.service.ts: removeComment and removeAttachment answer deleted, not_found, forbidden or closed. On a closed day only admin deletes; the event is logged in the item's own run, not in today's. - checklist-comments-controllers.ts: 404, 403 or 409 for each refusal. - closed-day-deletes.test.ts: closed day, admin, author on an open day, unknown id. Why: the author could delete comments and photos of a closed day, rewriting its history, and the deletion was logged in today's run. Decided on 2026-10-11: only admin deletes on a closed day. Checked with curl against the local database: after closing the day, reception gets 409 and admin deletes the comment. --- .../checklist-comments-controllers.ts | 34 ++++++---- .../checklist-comments.repository.ts | 39 +++++++---- .../checklist/checklist-comments.service.ts | 47 +++++++++---- .../checklist/closed-day-deletes.test.ts | 67 +++++++++++++++++++ 4 files changed, 146 insertions(+), 41 deletions(-) create mode 100644 backend/tests/checklist/closed-day-deletes.test.ts diff --git a/backend/controllers/checklist/checklist-comments-controllers.ts b/backend/controllers/checklist/checklist-comments-controllers.ts index f32c4ff6..29e3c29c 100644 --- a/backend/controllers/checklist/checklist-comments-controllers.ts +++ b/backend/controllers/checklist/checklist-comments-controllers.ts @@ -6,6 +6,20 @@ import { createCommentSchema } from '../../validations/checklist/checklist-schem import { logger } from '../../config/logger.js' import { fetchStoredFile, sendPrivateFile } from '../../services/uploads/private-files.js' +// Answers a refused delete; returns true when it did +function refuseRemove( + res: Response, + result: commentsService.RemoveResult, + what: 'comentario' | 'adjunto' +): boolean { + if (result === 'deleted') return false + if (result === 'not_found') res.status(404).json({ error: `No existe ese ${what}` }) + else if (result === 'closed') + res.status(409).json({ error: 'El día está cerrado: solo un administrador puede borrar' }) + else res.status(403).json({ error: `No tienes permiso para eliminar este ${what}` }) + return true +} + function isAdmin(req: Request): boolean { return req.user?.role?.toLowerCase() === 'admin' } @@ -45,17 +59,13 @@ export async function addCommentController(req: Request, res: Response): Promise // DELETE /api/checklists/:id/steps/:stepId/comments/:commentId export async function deleteCommentController(req: Request, res: Response): Promise { try { - const { id, commentId } = req.params - const deleted = await commentsService.removeComment( - id, + const { commentId } = req.params + const result = await commentsService.removeComment( Number(commentId), req.user!.id, isAdmin(req) ) - if (!deleted) { - res.status(403).json({ error: 'No tienes permiso para eliminar este comentario' }) - return - } + if (refuseRemove(res, result, 'comentario')) return res.json({ success: true }) } catch (err) { logger.error({ err }, '[checklist] deleteComment') @@ -113,17 +123,13 @@ export async function addAttachmentController(req: Request, res: Response): Prom // DELETE /api/checklists/:id/steps/:stepId/attachments/:attachmentId export async function deleteAttachmentController(req: Request, res: Response): Promise { try { - const { id, attachmentId } = req.params - const deleted = await commentsService.removeAttachment( - id, + const { attachmentId } = req.params + const result = await commentsService.removeAttachment( Number(attachmentId), req.user!.id, isAdmin(req) ) - if (!deleted) { - res.status(403).json({ error: 'No tienes permiso para eliminar este adjunto' }) - return - } + if (refuseRemove(res, result, 'adjunto')) return res.json({ success: true }) } catch (err) { logger.error({ err }, '[checklist] deleteAttachment') diff --git a/backend/repositories/checklist/checklist-comments.repository.ts b/backend/repositories/checklist/checklist-comments.repository.ts index 3a715c32..d42b7f8d 100644 --- a/backend/repositories/checklist/checklist-comments.repository.ts +++ b/backend/repositories/checklist/checklist-comments.repository.ts @@ -60,19 +60,27 @@ export async function createComment( return rows[0] } -export async function deleteComment( - commentId: number, - userId: string, - isAdmin: boolean -): Promise { +// The comment with its run, to check who may delete it and whether its day is closed +export async function findComment( + commentId: number +): Promise<{ user_id: string; run_id: number; run_closed: boolean } | null> { const [rows] = await db.execute( - `SELECT user_id FROM checklist_step_comments WHERE id = ?`, + `SELECT c.user_id, c.run_id, r.reset_at IS NOT NULL AS run_closed + FROM checklist_step_comments c + JOIN checklist_runs r ON r.id = c.run_id + WHERE c.id = ?`, [commentId] ) - if (!rows[0]) return false - if (!isAdmin && rows[0].user_id !== userId) return false + if (!rows[0]) return null + return { + user_id: rows[0].user_id, + run_id: rows[0].run_id, + run_closed: Boolean(rows[0].run_closed), + } +} + +export async function deleteComment(commentId: number): Promise { await db.execute(`DELETE FROM checklist_step_comments WHERE id = ?`, [commentId]) - return true } // ── Attachments ─────────────────────────────────────────── @@ -113,10 +121,15 @@ export async function createAttachment( return rows[0] } -export async function findAttachment(attachmentId: number): Promise { - const [rows] = await db.execute( - `SELECT a.*, u.username FROM checklist_step_attachments a - JOIN users u ON u.id = a.user_id WHERE a.id = ?`, +export async function findAttachment( + attachmentId: number +): Promise<(Attachment & { run_closed: number }) | null> { + const [rows] = await db.execute<(Attachment & { run_closed: number })[]>( + `SELECT a.*, u.username, r.reset_at IS NOT NULL AS run_closed + FROM checklist_step_attachments a + JOIN users u ON u.id = a.user_id + JOIN checklist_runs r ON r.id = a.run_id + WHERE a.id = ?`, [attachmentId] ) return rows[0] ?? null diff --git a/backend/services/checklist/checklist-comments.service.ts b/backend/services/checklist/checklist-comments.service.ts index dec4d479..d728eb4b 100644 --- a/backend/services/checklist/checklist-comments.service.ts +++ b/backend/services/checklist/checklist-comments.service.ts @@ -11,6 +11,21 @@ async function resolveRun(checklistId: string): Promise<{ id: number }> { return run } +// Result of deleting a comment or an attachment. A closed day (its run has +// reset_at) is history: since 2026-10-11 only admin may delete there +export type RemoveResult = 'deleted' | 'not_found' | 'forbidden' | 'closed' + +function canRemove( + item: { user_id: string; run_closed: boolean | number }, + userId: string, + isAdmin: boolean +): RemoveResult | null { + if (isAdmin) return null + if (item.user_id !== userId) return 'forbidden' + if (item.run_closed) return 'closed' + return null +} + // ── Comments ────────────────────────────────────────────── export async function getComments(checklistId: string, stepId: string): Promise { @@ -31,16 +46,20 @@ export async function addComment( } export async function removeComment( - checklistId: string, commentId: number, userId: string, isAdmin: boolean -): Promise { - const run = await resolveRun(checklistId) - const deleted = await commentsRepo.deleteComment(commentId, userId, isAdmin) - if (deleted) - await checklistRepo.logEvent(run.id, null, userId, 'comment', { deleted_comment_id: commentId }) - return deleted +): Promise { + const comment = await commentsRepo.findComment(commentId) + if (!comment) return 'not_found' + const refused = canRemove(comment, userId, isAdmin) + if (refused) return refused + await commentsRepo.deleteComment(commentId) + // Logged in the comment's own run, not in today's + await checklistRepo.logEvent(comment.run_id, null, userId, 'comment', { + deleted_comment_id: commentId, + }) + return 'deleted' } // ── Attachments ─────────────────────────────────────────── @@ -78,21 +97,21 @@ export async function addAttachment( } export async function removeAttachment( - checklistId: string, attachmentId: number, userId: string, isAdmin: boolean -): Promise { +): Promise { const attachment = await commentsRepo.findAttachment(attachmentId) - if (!attachment) return false - if (!isAdmin && attachment.user_id !== userId) return false + if (!attachment) return 'not_found' + const refused = canRemove(attachment, userId, isAdmin) + if (refused) return refused await CloudinaryService.deleteImage(attachment.public_id) await commentsRepo.deleteAttachment(attachmentId) - const run = await resolveRun(checklistId) - await checklistRepo.logEvent(run.id, null, userId, 'attach', { + // Logged in the attachment's own run, not in today's + await checklistRepo.logEvent(attachment.run_id, null, userId, 'attach', { deleted_attachment_id: attachmentId, }) - return true + return 'deleted' } // Stored Cloudinary URL of an attachment, for the server to serve it diff --git a/backend/tests/checklist/closed-day-deletes.test.ts b/backend/tests/checklist/closed-day-deletes.test.ts new file mode 100644 index 00000000..e6295280 --- /dev/null +++ b/backend/tests/checklist/closed-day-deletes.test.ts @@ -0,0 +1,67 @@ +// tests/checklist/closed-day-deletes.test.ts +// Regression tests: the author of a comment or a photo could delete it from a +// closed day (rewriting its history), and the deletion was logged in today's +// run as if it were a new one. Decided on 2026-10-11: on a closed day only +// admin deletes; the event goes to the item's own run. + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../../repositories/checklist/checklist-comments.repository.js', () => ({ + findComment: vi.fn(), + deleteComment: vi.fn(), + findAttachment: vi.fn(), + deleteAttachment: vi.fn(), +})) +vi.mock('../../repositories/checklist/checklist-repository.js', () => ({ + logEvent: vi.fn(), + getOrCreateRun: vi.fn(async () => ({ id: 999 })), // today's run: must not be used +})) +vi.mock('../../services/blacklist/cloudinary-service.js', () => ({ + CloudinaryService: { deleteImage: vi.fn() }, +})) + +const service = await import('../../services/checklist/checklist-comments.service.js') +const repo = await import('../../repositories/checklist/checklist-comments.repository.js') +const checklistRepo = await import('../../repositories/checklist/checklist-repository.js') + +const closed = { user_id: 'author', run_id: 7, run_closed: true } +const open = { user_id: 'author', run_id: 8, run_closed: false } + +describe('deleting comments and photos', () => { + beforeEach(() => vi.clearAllMocks()) + + it('the author cannot delete from a closed day', async () => { + vi.mocked(repo.findComment).mockResolvedValue(closed) + vi.mocked(repo.findAttachment).mockResolvedValue({ ...closed, public_id: 'p' } as never) + + expect(await service.removeComment(1, 'author', false)).toBe('closed') + expect(await service.removeAttachment(1, 'author', false)).toBe('closed') + expect(repo.deleteComment).not.toHaveBeenCalled() + expect(repo.deleteAttachment).not.toHaveBeenCalled() + }) + + it('admin can, and the event goes to the closed run', async () => { + vi.mocked(repo.findComment).mockResolvedValue(closed) + + expect(await service.removeComment(1, 'admin-id', true)).toBe('deleted') + expect(checklistRepo.logEvent).toHaveBeenCalledWith(7, null, 'admin-id', 'comment', { + deleted_comment_id: 1, + }) + expect(checklistRepo.getOrCreateRun).not.toHaveBeenCalled() + }) + + it('on an open day the author deletes, someone else cannot', async () => { + vi.mocked(repo.findComment).mockResolvedValue(open) + + expect(await service.removeComment(1, 'someone-else', false)).toBe('forbidden') + expect(await service.removeComment(1, 'author', false)).toBe('deleted') + expect(checklistRepo.logEvent).toHaveBeenCalledWith(8, null, 'author', 'comment', { + deleted_comment_id: 1, + }) + }) + + it('an unknown id is not found', async () => { + vi.mocked(repo.findComment).mockResolvedValue(null) + expect(await service.removeComment(1, 'author', false)).toBe('not_found') + }) +}) From 22a8ec756c6979a96f714cd6459036ece4e3d5c7 Mon Sep 17 00:00:00 2001 From: razyd Date: Sun, 11 Oct 2026 12:38:17 +0200 Subject: [PATCH 47/71] docs(todo): close the checklist and cashier entries reviewed on 2026-10-11 - Remove: deleting comments and photos of closed checklist days; cashier history and totals without canViewReports. - Narrow "voucher lifecycle and closures without a lock" to what is left: shift and day closures without a lock. - Checklist manual reset: note the decision to leave the unused endpoint as it is and what to fix before using it. Why: fixed in opencode/fix-checklist and opencode/fix-cashier; the reset was decided on 2026-10-11. --- docs/TODO.md | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 25d1e7ec..a7a9fd19 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -229,20 +229,14 @@ preview. `uk_run (hotel_id, checklist_id, hotel_date)` lo impide: 500. Después `getOrCreateRun` devuelve el run cerrado y los cambios del día se escriben en él. Sin transacción ni test. En Aiven nadie lo ha usado (0 cierres - manuales). _Comprobado por mí el 2026-09-28._ -- [ ] **Checklist: borrar comentarios e imágenes de días cerrados** — el autor - puede reescribir el histórico, y el evento se registra en el run de hoy y - como si fuera una alta. _Según la revisión `security` L3 del 2026-09-28 - (fichero y línea en el informe); no repasado por mí._ -- [ ] **Cashier: ciclo de vida de los vales y cierres sin bloqueo** — se puede - justificar un vale cancelado y cambiar el importe de uno justificado; el - límite de 5 vales y los cierres de turno y día se comprueban antes de - escribir, sin bloqueo. _Según la revisión `security` L3 del 2026-09-28 - (fichero y línea en el informe); no repasado por mí._ -- [ ] **Cashier: el historial y los totales se ven sin `canViewReports`** — - `GET /shifts/:id/history`, `/daily` y `/vouchers/stats`. _Según la - revisión `security` L3 del 2026-09-28 (fichero y línea en el informe); no - repasado por mí._ + 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._ - [ ] **Backoffice: borrar una factura borra también su historial** — borrado definitivo, con las columnas de borrado lógico sin usar. _Comprobado por mí el 2026-09-28._ From bb77dd6f24ca4a07aa156c6463c3f4878fa25094 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 00:00:41 +0200 Subject: [PATCH 48/71] fix(backoffice): check uploads by their bytes and cap stored-file downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Invoice PDFs and stamp or signature images go through middlewares/backofficeUpload.ts: the declared type and the first bytes must match (PDF; PNG or WebP), and a file over 10 MB answers 413 with BACKOFFICE_FILE_TOO_LARGE instead of 500. - The controller drops its own type and size checks, now unreachable. - fetchStoredFile stops at 20 MB (maxContentLength); redirects were already refused. - isPdfBuffer moves from the F&B parser to services/uploads/pdf-signature.ts so both modules share it. - Tests: real multipart requests for both uploads, and the axios options of fetchStoredFile. Why: any file declared as application/pdf reached Cloudinary, the multer size error reached the global handler as a 500, and the ZIP of up to 100 validated invoices downloaded each one without a size limit (TODO "subidas y descargas sin comprobar contenido ni tamaño"). --- .../backoffice/backoffice-controller.ts | 34 +--- backend/middlewares/backofficeUpload.ts | 69 ++++++++ .../routes/backoffice/backoffice-routes.ts | 14 +- backend/services/fnb/pdf-parser.service.ts | 7 +- backend/services/uploads/pdf-signature.ts | 11 ++ backend/services/uploads/private-files.ts | 8 +- backend/tests/backoffice/upload-guard.test.ts | 151 ++++++++++++++++++ backend/tests/fnb/pdf-upload-guard.test.ts | 8 +- backend/tests/uploads/private-files.test.ts | 28 +++- docs/backoffice/README.md | 3 + 10 files changed, 275 insertions(+), 58 deletions(-) create mode 100644 backend/middlewares/backofficeUpload.ts create mode 100644 backend/services/uploads/pdf-signature.ts create mode 100644 backend/tests/backoffice/upload-guard.test.ts diff --git a/backend/controllers/backoffice/backoffice-controller.ts b/backend/controllers/backoffice/backoffice-controller.ts index 6f4e128e..6a34e67d 100644 --- a/backend/controllers/backoffice/backoffice-controller.ts +++ b/backend/controllers/backoffice/backoffice-controller.ts @@ -1097,6 +1097,7 @@ export class BackofficeController { const previousPublicId = type === 'original' ? pdfInfo?.original_pdf_public_id : pdfInfo?.validated_pdf_public_id + // Type, bytes and size were checked by singleInvoicePdf (backofficeUpload.ts) if (!req.file) { res.status(400).json({ success: false, @@ -1106,27 +1107,6 @@ export class BackofficeController { return } - // Validar tipo de archivo - if (req.file.mimetype !== 'application/pdf') { - res.status(400).json({ - success: false, - error: ERROR_CODES.BACKOFFICE_PDF_ONLY, - code: ERROR_CODES.BACKOFFICE_PDF_ONLY, - }) - return - } - - // Validar tamaño (máx 10MB) - const maxSize = 10 * 1024 * 1024 - if (req.file.size > maxSize) { - res.status(400).json({ - success: false, - error: ERROR_CODES.BACKOFFICE_FILE_TOO_LARGE, - code: ERROR_CODES.BACKOFFICE_FILE_TOO_LARGE, - }) - return - } - logger.debug('[BackofficeController.uploadInvoicePdf] Uploading to Cloudinary...') // Subir a Cloudinary (usar uploadPdf para archivos PDF) @@ -1282,6 +1262,7 @@ export class BackofficeController { return } + // Type, bytes and size were checked by singleAssetImage (backofficeUpload.ts) if (!req.file) { res.status(400).json({ success: false, @@ -1291,17 +1272,6 @@ export class BackofficeController { return } - // Validar tipo de archivo - const allowedTypes = ['image/png', 'image/webp'] - if (!allowedTypes.includes(req.file.mimetype)) { - res.status(400).json({ - success: false, - error: ERROR_CODES.BACKOFFICE_INVALID_IMAGE_TYPE, - code: ERROR_CODES.BACKOFFICE_INVALID_IMAGE_TYPE, - }) - return - } - // Subir a Cloudinary const cloudinaryResult = await CloudinaryService.uploadImage( req.file.buffer, diff --git a/backend/middlewares/backofficeUpload.ts b/backend/middlewares/backofficeUpload.ts new file mode 100644 index 00000000..2e381f6f --- /dev/null +++ b/backend/middlewares/backofficeUpload.ts @@ -0,0 +1,69 @@ +// middlewares/backofficeUpload.ts + +import type { Request, Response, NextFunction, RequestHandler } from 'express' +import multer from 'multer' +import { ERROR_CODES } from '../config/error-codes.js' +import { isPdfBuffer } from '../services/uploads/pdf-signature.js' +import { detectImageType } from '../services/uploads/image-signature.js' + +export const MAX_BACKOFFICE_FILE_BYTES = 10 * 1024 * 1024 + +// Stamps and signatures are laid over the invoice PDF: only formats with +// transparency +const ASSET_IMAGE_TYPES = ['image/png', 'image/webp'] + +/** + * One file in `field`, at most 10 MB: an oversized file answers 413 and one + * whose declared type or bytes `accepts` refuses answers 400 with `wrongType`. + * A request without a file goes on to the controller, which answers it. + */ +function singleFile( + field: string, + accepts: (file: Express.Multer.File) => boolean, + wrongType: string +): RequestHandler { + // Multer stops reading at the limit and rejects a file whose size reaches + // fileSize, hence the +1: exactly 10 MB stays valid + const handler = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: MAX_BACKOFFICE_FILE_BYTES + 1, files: 1 }, + }).single(field) + + return (req: Request, res: Response, next: NextFunction) => { + handler(req, res, (err?: unknown) => { + const fail = (status: number, code: string) => { + res.status(status).json({ success: false, error: code, code }) + } + if (err instanceof multer.MulterError) { + if (err.code === 'LIMIT_FILE_SIZE') fail(413, ERROR_CODES.BACKOFFICE_FILE_TOO_LARGE) + else fail(400, wrongType) + return + } + if (!err && req.file && !accepts(req.file)) { + fail(400, wrongType) + return + } + next(err) + }) + } +} + +/** The invoice PDF: declared as PDF and starting like one. */ +export function singleInvoicePdf(field: string): RequestHandler { + return singleFile( + field, + (file) => file.mimetype === 'application/pdf' && isPdfBuffer(file.buffer), + ERROR_CODES.BACKOFFICE_PDF_ONLY + ) +} + +/** A stamp or signature: a PNG or WebP by its declared type and its bytes. */ +export function singleAssetImage(field: string): RequestHandler { + return singleFile( + field, + (file) => + ASSET_IMAGE_TYPES.includes(file.mimetype) && + ASSET_IMAGE_TYPES.includes(detectImageType(file.buffer) ?? ''), + ERROR_CODES.BACKOFFICE_INVALID_IMAGE_TYPE + ) +} diff --git a/backend/routes/backoffice/backoffice-routes.ts b/backend/routes/backoffice/backoffice-routes.ts index 1f466ce1..e7570278 100644 --- a/backend/routes/backoffice/backoffice-routes.ts +++ b/backend/routes/backoffice/backoffice-routes.ts @@ -9,23 +9,15 @@ */ import { Router } from 'express' -import multer from 'multer' import { BackofficeController } from '../../controllers/backoffice/backoffice-controller.js' import { authenticateToken } from '../../middlewares/authenticateToken.js' import { canAccessBackoffice, isAdmin } from '../../middlewares/roleCheck.js' import { denyDemo } from '../../middlewares/demoRestriction.js' import { requireStorage } from '../../middlewares/requireStorage.js' +import { singleAssetImage, singleInvoicePdf } from '../../middlewares/backofficeUpload.js' const router = Router() -// Configurar multer para uploads en memoria -const upload = multer({ - storage: multer.memoryStorage(), - limits: { - fileSize: 10 * 1024 * 1024, // 10MB para PDFs - }, -}) - // Aplicar autenticación y verificación de acceso a backoffice router.use(authenticateToken) router.use(canAccessBackoffice) @@ -267,7 +259,7 @@ router.post( '/invoices/:id/pdf', isAdmin, requireStorage, - upload.single('pdf'), + singleInvoicePdf('pdf'), BackofficeController.uploadInvoicePdf ) @@ -310,7 +302,7 @@ router.post( '/assets', isAdmin, requireStorage, - upload.single('image'), + singleAssetImage('image'), BackofficeController.createAsset ) diff --git a/backend/services/fnb/pdf-parser.service.ts b/backend/services/fnb/pdf-parser.service.ts index b97044ec..b673abff 100644 --- a/backend/services/fnb/pdf-parser.service.ts +++ b/backend/services/fnb/pdf-parser.service.ts @@ -1,5 +1,6 @@ import pdfParse from 'pdf-parse' import { trackedCodesSet } from './fnb-categories.cache.js' +import { isPdfBuffer } from '../uploads/pdf-signature.js' export interface ParsedPdfData { date: string | null // YYYY-MM-DD @@ -13,16 +14,10 @@ export class FnbPdfError extends Error { status = 422 } -const PDF_MAGIC = Buffer.from('%PDF-') // Opera's F&B report is 3 pages; anything far longer is not that report const MAX_PAGES = 20 export const PARSE_TIMEOUT_MS = 15_000 -// PDF readers accept the header anywhere in the first 1024 bytes -export function isPdfBuffer(buffer: Buffer): boolean { - return buffer.subarray(0, 1024).includes(PDF_MAGIC) -} - // Stops waiting for the parser; pdf.js cannot be aborted, so the page limit is // what bounds the work left running in the background export function withTimeout(promise: Promise, ms: number): Promise { diff --git a/backend/services/uploads/pdf-signature.ts b/backend/services/uploads/pdf-signature.ts new file mode 100644 index 00000000..dccc9273 --- /dev/null +++ b/backend/services/uploads/pdf-signature.ts @@ -0,0 +1,11 @@ +// services/uploads/pdf-signature.ts + +const PDF_MAGIC = Buffer.from('%PDF-') + +/** + * True when the bytes are a PDF. PDF readers accept the header anywhere in the + * first 1024 bytes; the declared mimetype is whatever the client says. + */ +export function isPdfBuffer(buffer: Buffer): boolean { + return buffer.subarray(0, 1024).includes(PDF_MAGIC) +} diff --git a/backend/services/uploads/private-files.ts b/backend/services/uploads/private-files.ts index 32e94b6f..08b21837 100644 --- a/backend/services/uploads/private-files.ts +++ b/backend/services/uploads/private-files.ts @@ -14,9 +14,14 @@ export interface FetchedFile { contentType: string } +// No upload is over 10 MB (backoffice PDFs; images are 5 MB): the margin covers +// the stored format, and anything bigger is not one of our files +export const MAX_STORED_FILE_BYTES = 20 * 1024 * 1024 + /** * Downloads a stored Cloudinary URL. Throws if the URL is not in our cloud - * (SSRF) or Cloudinary does not answer 2xx. No redirects are followed. + * (SSRF), Cloudinary does not answer 2xx or the file is over + * MAX_STORED_FILE_BYTES. No redirects are followed. */ export async function fetchStoredFile(storedUrl: string): Promise { if (!isOwnCloudinaryUrl(storedUrl)) { @@ -26,6 +31,7 @@ export async function fetchStoredFile(storedUrl: string): Promise { responseType: 'arraybuffer', timeout: 30000, maxRedirects: 0, + maxContentLength: MAX_STORED_FILE_BYTES, }) const contentType = String(response.headers['content-type'] || 'application/octet-stream') return { buffer: Buffer.from(response.data), contentType } diff --git a/backend/tests/backoffice/upload-guard.test.ts b/backend/tests/backoffice/upload-guard.test.ts new file mode 100644 index 00000000..9a78a1ce --- /dev/null +++ b/backend/tests/backoffice/upload-guard.test.ts @@ -0,0 +1,151 @@ +// tests/backoffice/upload-guard.test.ts +// Regression tests for the backoffice uploads: the invoice PDF and the stamp or +// signature image were accepted by their declared type alone (any file sent +// as application/pdf went to Cloudinary), and a PDF over 10 MB answered 500 +// because the multer error reached the global handler. +// No database: real multipart requests to a real Express app with multer. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import express from 'express' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { + MAX_BACKOFFICE_FILE_BYTES, + singleAssetImage, + singleInvoicePdf, +} from '../../middlewares/backofficeUpload.js' + +let server: Server +let base: string + +beforeAll(async () => { + const app = express() + // Same fields as routes/backoffice/backoffice-routes.ts + app.post('/pdf', singleInvoicePdf('pdf'), (req, res) => { + res.status(201).json({ size: req.file?.size }) + }) + app.post('/assets', singleAssetImage('image'), (req, res) => { + res.status(201).json({ size: req.file?.size }) + }) + server = app.listen(0) + await new Promise((resolve) => server.once('listening', resolve)) + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}` +}) + +afterAll(() => { + server.close() +}) + +const PDF_START = Buffer.from('%PDF-1.7\n') +const PNG_START = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) +const WEBP_START = Buffer.from('RIFF\0\0\0\0WEBP') +const JPEG_START = Buffer.from([0xff, 0xd8, 0xff, 0xe0]) + +// A file of `bytes` bytes that starts with `start` +function file(bytes: number, start: Buffer = Buffer.alloc(0)): Uint8Array { + const data = new Uint8Array(bytes) + data.set(start) + return data +} + +async function upload(path: string, field: string, data: Uint8Array, type: string) { + const form = new FormData() + form.append(field, new Blob([data], { type }), 'fichero') + const res = await fetch(`${base}${path}`, { method: 'POST', body: form }) + return { status: res.status, body: await res.json() } +} + +describe('singleInvoicePdf', () => { + it('accepts a PDF up to 10 MB', async () => { + const res = await upload( + '/pdf', + 'pdf', + file(MAX_BACKOFFICE_FILE_BYTES, PDF_START), + 'application/pdf' + ) + expect(res).toEqual({ status: 201, body: { size: MAX_BACKOFFICE_FILE_BYTES } }) + }) + + it('refuses a file declared as PDF whose bytes are not a PDF', async () => { + const res = await upload('/pdf', 'pdf', file(1024, Buffer.from('MZ')), 'application/pdf') + expect(res.status).toBe(400) + expect(res.body.code).toBe('BACKOFFICE_PDF_ONLY') + }) + + it('refuses a real PDF declared with another type', async () => { + const res = await upload('/pdf', 'pdf', file(1024, PDF_START), 'text/html') + expect(res.status).toBe(400) + expect(res.body.code).toBe('BACKOFFICE_PDF_ONLY') + }) + + it('answers 413, not 500, over 10 MB', async () => { + const res = await upload( + '/pdf', + 'pdf', + file(MAX_BACKOFFICE_FILE_BYTES + 1, PDF_START), + 'application/pdf' + ) + expect(res.status).toBe(413) + expect(res.body.code).toBe('BACKOFFICE_FILE_TOO_LARGE') + }) + + it('leaves a request without a file to the controller', async () => { + const res = await fetch(`${base}/pdf`, { method: 'POST', body: new FormData() }) + expect(res.status).toBe(201) + }) +}) + +describe('singleAssetImage', () => { + it.each([ + ['PNG', PNG_START, 'image/png'], + ['WebP', WEBP_START, 'image/webp'], + ])('accepts a %s', async (_name, start, type) => { + expect((await upload('/assets', 'image', file(2048, start), type)).status).toBe(201) + }) + + it('refuses a JPEG renamed as PNG: the stamp needs transparency', async () => { + const res = await upload('/assets', 'image', file(2048, JPEG_START), 'image/png') + expect(res.status).toBe(400) + expect(res.body.code).toBe('BACKOFFICE_INVALID_IMAGE_TYPE') + }) + + it('refuses anything else declared as PNG', async () => { + const res = await upload('/assets', 'image', file(2048, PDF_START), 'image/png') + expect(res.status).toBe(400) + expect(res.body.code).toBe('BACKOFFICE_INVALID_IMAGE_TYPE') + }) + + it('answers 413 over 10 MB', async () => { + const res = await upload( + '/assets', + 'image', + file(MAX_BACKOFFICE_FILE_BYTES + 1, PNG_START), + 'image/png' + ) + expect(res.status).toBe(413) + expect(res.body.code).toBe('BACKOFFICE_FILE_TOO_LARGE') + }) +}) + +describe('routes', () => { + const routes = readFileSync( + join( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'routes', + 'backoffice', + 'backoffice-routes.ts' + ), + 'utf8' + ) + + it('use the guarded uploads, not a bare multer', () => { + expect(routes).toContain("singleInvoicePdf('pdf')") + expect(routes).toContain("singleAssetImage('image')") + expect(routes).not.toContain('multer') + }) +}) diff --git a/backend/tests/fnb/pdf-upload-guard.test.ts b/backend/tests/fnb/pdf-upload-guard.test.ts index 9adeb59f..42c72cf2 100644 --- a/backend/tests/fnb/pdf-upload-guard.test.ts +++ b/backend/tests/fnb/pdf-upload-guard.test.ts @@ -6,12 +6,8 @@ import { describe, it, expect, vi } from 'vitest' import { readFileSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' -import { - FnbPdfError, - isPdfBuffer, - parseOperaPdf, - withTimeout, -} from '../../services/fnb/pdf-parser.service.js' +import { FnbPdfError, parseOperaPdf, withTimeout } from '../../services/fnb/pdf-parser.service.js' +import { isPdfBuffer } from '../../services/uploads/pdf-signature.js' // The categories cache imports the DB pool; none of these cases reach it vi.mock('../../services/fnb/fnb-categories.cache.js', () => ({ diff --git a/backend/tests/uploads/private-files.test.ts b/backend/tests/uploads/private-files.test.ts index 13e85dfe..7fca2e9a 100644 --- a/backend/tests/uploads/private-files.test.ts +++ b/backend/tests/uploads/private-files.test.ts @@ -3,12 +3,17 @@ // secure_url is signed and works for anyone forever, so no API response may // carry it: responses carry the API path that serves the file. -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, afterEach } from 'vitest' +import axios from 'axios' import { readFileSync } from 'node:fs' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import type { Response } from 'express' -import { fetchStoredFile, sendPrivateFile } from '../../services/uploads/private-files.js' +import { + fetchStoredFile, + MAX_STORED_FILE_BYTES, + sendPrivateFile, +} from '../../services/uploads/private-files.js' import { presentAsset, presentInvoice, @@ -80,9 +85,28 @@ describe('sendPrivateFile', () => { }) describe('fetchStoredFile', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + it('refuses a URL outside our Cloudinary cloud before any request', async () => { await expect(fetchStoredFile('https://evil.example/x.pdf')).rejects.toThrow( 'outside our Cloudinary cloud' ) }) + + // The ZIP of validated invoices downloads up to 100 of them into memory + it('follows no redirect and stops at 20 MB', async () => { + vi.stubEnv('CLOUDINARY_CLOUD_NAME', 'fourpoints') + const get = vi + .spyOn(axios, 'get') + .mockResolvedValue({ data: new ArrayBuffer(1), headers: {} }) + await fetchStoredFile(SIGNED) + expect(get).toHaveBeenCalledWith( + SIGNED, + expect.objectContaining({ maxRedirects: 0, maxContentLength: MAX_STORED_FILE_BYTES }) + ) + expect(MAX_STORED_FILE_BYTES).toBe(20 * 1024 * 1024) + }) }) diff --git a/docs/backoffice/README.md b/docs/backoffice/README.md index 5deba492..df486e1b 100644 --- a/docs/backoffice/README.md +++ b/docs/backoffice/README.md @@ -66,6 +66,9 @@ PDFs and images are stored in **Cloudinary**; the database keeps their URLs. - **Deleting an invoice is permanent** and also deletes its history. - **The automatic payment on the 10th does not work today**: it records the change as a system user that does not exist, and the database rejects it. +- **Uploads are checked by their bytes**, not only the type the browser + declares: a PDF for invoices, a PNG or WebP for stamps and signatures, up to + 10 MB (413 above it). - **PDFs are downloaded through the backend**, which fetches them from Cloudinary. The files themselves are uploaded with public delivery, so their Cloudinary URLs also work without logging in. From f5734210489e73515ece61219f8e4e105c83b8f4 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 00:08:28 +0200 Subject: [PATCH 49/71] fix(backoffice): refuse negative amounts, duplicate invoices and impossible payment dates - validations/backoffice/invoice.ts: the rules decided on 2026-10-11. An amount is a number or the DECIMAL string the API returns, never negative; a payment date is a real day between the invoice date and today. - Create and edit answer 400 BACKOFFICE_INVALID_AMOUNT for a negative or non-numeric amount, and 409 BACKOFFICE_INVOICE_DUPLICATE when the supplier already has an invoice with that number. - Pay answers 400 BACKOFFICE_INVALID_PAYMENT_DATE for a date before the invoice, in the future or that does not exist. - Migration 20261012_unique_invoice_supplier_number.sql adds the unique key (applied locally; pending on Aiven) and its row in INDEX.md. - error-codes.ts, errors.json (es, en), errorCodeMessage.ts: the three codes. - docs/backoffice: the new rules, and the two decisions to keep deleting permanent and validation without a stamped PDF. - invoice-rules.test.ts: the rules, the controller and the migration. Why: negative amounts, the same invoice twice (both paid in the monthly batch) and any payment date were saved. Checked with curl against the local database: all three were accepted before and are refused now, and an edit that sends the amounts back as strings still saves. --- backend/config/error-codes.ts | 3 + .../backoffice/backoffice-controller.ts | 61 ++++++++++ backend/db-mysql/INDEX.md | 1 + ...0261012_unique_invoice_supplier_number.sql | 45 ++++++++ .../tests/backoffice/invoice-rules.test.ts | 109 ++++++++++++++++++ backend/validations/backoffice/invoice.ts | 40 +++++++ docs/backoffice/README.md | 12 +- frontend/app/lib/helpers/errorCodeMessage.ts | 3 + frontend/messages/en/errors.json | 3 + frontend/messages/es/errors.json | 3 + 10 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 backend/db-mysql/scripts/20261012_unique_invoice_supplier_number.sql create mode 100644 backend/tests/backoffice/invoice-rules.test.ts create mode 100644 backend/validations/backoffice/invoice.ts diff --git a/backend/config/error-codes.ts b/backend/config/error-codes.ts index 39d90afe..4a8714bc 100644 --- a/backend/config/error-codes.ts +++ b/backend/config/error-codes.ts @@ -54,6 +54,9 @@ export const ERROR_CODES = { BACKOFFICE_SUPPLIER_NOT_FOUND: 'BACKOFFICE_SUPPLIER_NOT_FOUND', BACKOFFICE_REJECTION_REASON_REQUIRED: 'BACKOFFICE_REJECTION_REASON_REQUIRED', BACKOFFICE_PAYMENT_DATE_REQUIRED: 'BACKOFFICE_PAYMENT_DATE_REQUIRED', + BACKOFFICE_INVALID_PAYMENT_DATE: 'BACKOFFICE_INVALID_PAYMENT_DATE', + BACKOFFICE_INVALID_AMOUNT: 'BACKOFFICE_INVALID_AMOUNT', + BACKOFFICE_INVOICE_DUPLICATE: 'BACKOFFICE_INVOICE_DUPLICATE', BACKOFFICE_NO_FILE_SENT: 'BACKOFFICE_NO_FILE_SENT', BACKOFFICE_PDF_ONLY: 'BACKOFFICE_PDF_ONLY', BACKOFFICE_FILE_TOO_LARGE: 'BACKOFFICE_FILE_TOO_LARGE', diff --git a/backend/controllers/backoffice/backoffice-controller.ts b/backend/controllers/backoffice/backoffice-controller.ts index 6f4e128e..4dd2c151 100644 --- a/backend/controllers/backoffice/backoffice-controller.ts +++ b/backend/controllers/backoffice/backoffice-controller.ts @@ -18,6 +18,12 @@ import { executeBatchPaymentSchema, revertBatchPaymentSchema, } from '../../validations/backoffice/batch-payment.js' +import { + hasValidAmounts, + isDuplicateEntry, + isValidPaidDate, +} from '../../validations/backoffice/invoice.js' +import { formatDateMadrid, getTodayMadrid } from '../../config/date-utils.js' import { isOwnCloudinaryUrl, isPublicIdInFolder, @@ -26,6 +32,23 @@ import { import { fetchStoredFile, sendPrivateFile } from '../../services/uploads/private-files.js' import { presentAsset, presentInvoice, presentInvoiceOrNull } from './backoffice-files.js' +function respondInvalidAmount(res: Response): void { + res.status(400).json({ + success: false, + error: ERROR_CODES.BACKOFFICE_INVALID_AMOUNT, + code: ERROR_CODES.BACKOFFICE_INVALID_AMOUNT, + }) +} + +// uk_invoice_supplier_number: the supplier already has an invoice with that number +function respondDuplicateInvoice(res: Response): void { + res.status(409).json({ + success: false, + error: ERROR_CODES.BACKOFFICE_INVOICE_DUPLICATE, + code: ERROR_CODES.BACKOFFICE_INVOICE_DUPLICATE, + }) +} + // An invoice action changed no row: the invoice does not exist (404) or its // status does not allow the action (409) async function respondActionNotApplied(res: Response, id: number): Promise { @@ -570,6 +593,11 @@ export class BackofficeController { return } + if (!hasValidAmounts(req.body)) { + respondInvalidAmount(res) + return + } + // The PDF must be one we uploaded: the server later downloads this URL if ( (req.body.original_pdf_url && !isOwnCloudinaryUrl(req.body.original_pdf_url)) || @@ -611,6 +639,10 @@ export class BackofficeController { invoice: presentInvoiceOrNull(invoice), }) } catch (error) { + if (isDuplicateEntry(error)) { + respondDuplicateInvoice(res) + return + } logger.error({ err: error }, '[BackofficeController.createInvoice] Error') res.status(500).json({ success: false, @@ -637,6 +669,11 @@ export class BackofficeController { const { id } = req.params + if (!hasValidAmounts(req.body)) { + respondInvalidAmount(res) + return + } + const updated = await BackofficeRepository.updateInvoice(Number(id), req.body, req.user.id) if (!updated) { @@ -653,6 +690,10 @@ export class BackofficeController { invoice: presentInvoiceOrNull(invoice), }) } catch (error) { + if (isDuplicateEntry(error)) { + respondDuplicateInvoice(res) + return + } logger.error({ err: error }, '[BackofficeController.updateInvoice] Error') res.status(500).json({ success: false, @@ -872,6 +913,26 @@ export class BackofficeController { return } + const current = await BackofficeRepository.getInvoiceById(Number(id)) + if (!current) { + res.status(404).json({ + success: false, + error: ERROR_CODES.BACKOFFICE_INVOICE_NOT_FOUND, + code: ERROR_CODES.BACKOFFICE_INVOICE_NOT_FOUND, + }) + return + } + + // A real day, not before the invoice nor in the future + if (!isValidPaidDate(paid_date, formatDateMadrid(current.invoice_date), getTodayMadrid())) { + res.status(400).json({ + success: false, + error: ERROR_CODES.BACKOFFICE_INVALID_PAYMENT_DATE, + code: ERROR_CODES.BACKOFFICE_INVALID_PAYMENT_DATE, + }) + return + } + const paid = await BackofficeRepository.markAsPaid(Number(id), paid_date, req.user.id) if (!paid) { diff --git a/backend/db-mysql/INDEX.md b/backend/db-mysql/INDEX.md index 30eef9bf..d788d602 100644 --- a/backend/db-mysql/INDEX.md +++ b/backend/db-mysql/INDEX.md @@ -108,6 +108,7 @@ db-mysql/ | 2026-10-05 | `20261005_complete_fresh_install.sql` | Completa una instalación desde cero: el baseline congelado no crea `cashier_history` (nombre de clave duplicado), `scheduling_assignments` (referencia a una tabla creada después) ni `demo_activity_log`, y deja `roles.name` en `varchar(50)`. Definiciones copiadas de Aiven. En Aiven no cambia nada; en local creó `demo_activity_log`, que faltaba. `pnpm setup:local` además corrige esos dos fallos del baseline en memoria. | ✅ local · ✅ Aiven (sin cambios) | | 2026-10-05 | `20261005_fix_bo_monthly_summary_view.sql` | Recrea `v_bo_monthly_summary` con `period` en el `GROUP BY`. Con `ONLY_FULL_GROUP_BY` (activo en Aiven y local) toda consulta a la vista fallaba con el error 1055 y `GET /api/backoffice/stats/monthly` respondía siempre 500. Mismas columnas y mismos grupos; comprobada contra un cálculo directo sobre `bo_invoices`. | ✅ local · ✅ Aiven | | 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-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 | ### Verificación (99) diff --git a/backend/db-mysql/scripts/20261012_unique_invoice_supplier_number.sql b/backend/db-mysql/scripts/20261012_unique_invoice_supplier_number.sql new file mode 100644 index 00000000..2fdc12ca --- /dev/null +++ b/backend/db-mysql/scripts/20261012_unique_invoice_supplier_number.sql @@ -0,0 +1,45 @@ +-- ============================================================================= +-- 20261012_unique_invoice_supplier_number.sql +-- ============================================================================= +-- Adds UNIQUE KEY uk_invoice_supplier_number (supplier_id, invoice_number) to +-- bo_invoices. The same invoice of the same supplier could be entered twice +-- and both copies were paid in the monthly batch. Decided on 2026-10-11: a +-- second invoice with the same supplier and number is refused (409 +-- BACKOFFICE_INVOICE_DUPLICATE, see validations/backoffice/invoice.ts). +-- Deleting an invoice stays permanent, so a deleted number can be reused. +-- The column collation is case-insensitive: "F-1" and "f-1" count as the same. +-- +-- Idempotent: if the key already exists the ALTER is skipped. +-- Safe to run multiple times. +-- +-- If the table already holds duplicates, the first SELECT lists them and the +-- ALTER fails with "Duplicate entry": fix those rows and run it again. +-- +-- Applies to: LOCAL + AIVEN. The code works without it (nothing is refused +-- until the key exists), so it can go before or after the deploy. +-- Rollback: +-- ALTER TABLE bo_invoices DROP INDEX uk_invoice_supplier_number; +-- ============================================================================= + +SELECT supplier_id, invoice_number, COUNT(*) AS copies +FROM bo_invoices +GROUP BY supplier_id, invoice_number +HAVING COUNT(*) > 1; + +SET @key_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'bo_invoices' + AND INDEX_NAME = 'uk_invoice_supplier_number' +); + +SET @ddl := IF( + @key_exists = 0, + 'ALTER TABLE bo_invoices ADD UNIQUE KEY uk_invoice_supplier_number (supplier_id, invoice_number)', + 'SELECT ''uk_invoice_supplier_number already exists, skipping'' AS info' +); + +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/tests/backoffice/invoice-rules.test.ts b/backend/tests/backoffice/invoice-rules.test.ts new file mode 100644 index 00000000..546d91f9 --- /dev/null +++ b/backend/tests/backoffice/invoice-rules.test.ts @@ -0,0 +1,109 @@ +// tests/backoffice/invoice-rules.test.ts +// Regression tests for the invoice data rules decided on 2026-10-11: a +// negative amount, a second invoice with the same supplier and number, and a +// payment date before the invoice, in the future or that does not exist were +// all saved. Pure rules plus the controller and migration sources; no database. + +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + hasValidAmounts, + isDuplicateEntry, + isValidAmount, + isValidPaidDate, +} from '../../validations/backoffice/invoice.js' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const read = (...p: string[]) => readFileSync(join(root, ...p), 'utf8') +const controller = read('controllers', 'backoffice', 'backoffice-controller.ts') + +// Body of `static async name(` up to the next static method +function method(name: string): string { + const start = controller.indexOf(`static async ${name}(`) + expect(start, name).toBeGreaterThan(-1) + const next = controller.indexOf('static async ', start + 1) + return controller.slice(start, next === -1 ? undefined : next) +} + +describe('isValidAmount', () => { + it('accepts zero and positive numbers, and the DECIMAL strings the API returns', () => { + for (const v of [0, 12.5, 2680, '2680.00', '0', ' 15 ']) { + expect(isValidAmount(v), String(v)).toBe(true) + } + }) + + it('refuses negatives, non-numbers and empty values', () => { + for (const v of [-0.01, -100, '-5', '1e3', 'abc', '', NaN, Infinity, null, {}, [1]]) { + expect(isValidAmount(v), String(v)).toBe(false) + } + }) +}) + +describe('hasValidAmounts', () => { + it('checks only the amounts present, as a partial edit sends', () => { + expect(hasValidAmounts({ notes: 'x' })).toBe(true) + expect(hasValidAmounts({ amount_with_vat: '121.00' })).toBe(true) + expect(hasValidAmounts({ amount_without_vat: 100, amount_with_vat: -121 })).toBe(false) + }) +}) + +describe('isValidPaidDate', () => { + const invoice = '2026-09-30' + const today = '2026-10-12' + + it('accepts the invoice day, today and any day between', () => { + for (const d of [invoice, '2026-10-01', today]) { + expect(isValidPaidDate(d, invoice, today)).toBe(true) + } + }) + + it('refuses a day before the invoice, a future day and days that do not exist', () => { + for (const d of ['2026-09-29', '2026-10-13', '2026-02-31', '30/09/2026', '', 20261001]) { + expect(isValidPaidDate(d, invoice, today), String(d)).toBe(false) + } + }) +}) + +describe('isDuplicateEntry', () => { + it('recognises only MySQL duplicate-key errors', () => { + expect(isDuplicateEntry({ code: 'ER_DUP_ENTRY' })).toBe(true) + expect(isDuplicateEntry(new Error('x'))).toBe(false) + expect(isDuplicateEntry(null)).toBe(false) + }) +}) + +describe('controller', () => { + it.each(['createInvoice', 'updateInvoice'])( + '%s refuses negative amounts (400) and duplicates (409)', + (name) => { + const body = method(name) + expect(body).toMatch(/if \(!hasValidAmounts\(req\.body\)\) \{\s*respondInvalidAmount\(res\)/) + expect(body).toMatch(/if \(isDuplicateEntry\(error\)\) \{\s*respondDuplicateInvoice\(res\)/) + } + ) + + it('answers 400 BACKOFFICE_INVALID_AMOUNT and 409 BACKOFFICE_INVOICE_DUPLICATE', () => { + expect(controller).toMatch( + /function respondInvalidAmount[\s\S]*?res\.status\(400\)[\s\S]*?ERROR_CODES\.BACKOFFICE_INVALID_AMOUNT/ + ) + expect(controller).toMatch( + /function respondDuplicateInvoice[\s\S]*?res\.status\(409\)[\s\S]*?ERROR_CODES\.BACKOFFICE_INVOICE_DUPLICATE/ + ) + }) + + it('markAsPaid checks the date against the invoice and today', () => { + const body = method('markAsPaid') + expect(body).toContain('isValidPaidDate(') + expect(body).toContain('getTodayMadrid()') + expect(body).toContain('ERROR_CODES.BACKOFFICE_INVALID_PAYMENT_DATE') + }) +}) + +describe('migration', () => { + it('adds the unique key on supplier and number', () => { + const sql = read('db-mysql', 'scripts', '20261012_unique_invoice_supplier_number.sql') + expect(sql).toContain('ADD UNIQUE KEY uk_invoice_supplier_number (supplier_id, invoice_number)') + }) +}) diff --git a/backend/validations/backoffice/invoice.ts b/backend/validations/backoffice/invoice.ts new file mode 100644 index 00000000..07e85750 --- /dev/null +++ b/backend/validations/backoffice/invoice.ts @@ -0,0 +1,40 @@ +// validations/backoffice/invoice.ts +// Rules decided on 2026-10-11: no negative amounts, one invoice per supplier +// and number (UNIQUE uk_invoice_supplier_number, scripts/20261012_*), and a +// payment date that is a real day, not before the invoice nor in the future. + +import { isCalendarDate } from '../common/calendar-date.js' + +export const INVOICE_AMOUNT_FIELDS = ['amount_without_vat', 'amount_with_vat'] as const + +/** + * A number, or the numeric string the API itself returns for DECIMAL columns + * (the edit form sends them back as they came), and not negative. + */ +export function isValidAmount(value: unknown): boolean { + if (typeof value === 'number') return Number.isFinite(value) && value >= 0 + return typeof value === 'string' && /^\d+(\.\d+)?$/.test(value.trim()) +} + +/** True when every amount present in `body` is valid; absent ones are not checked. */ +export function hasValidAmounts(body: Record): boolean { + return INVOICE_AMOUNT_FIELDS.every((f) => body[f] === undefined || isValidAmount(body[f])) +} + +/** + * `paidDate` (YYYY-MM-DD) is a real day between the invoice date and today, + * both included. + */ +export function isValidPaidDate(paidDate: unknown, invoiceDate: string, today: string): boolean { + return ( + typeof paidDate === 'string' && + isCalendarDate(paidDate) && + paidDate >= invoiceDate && + paidDate <= today + ) +} + +/** MySQL's duplicate-key error, raised by uk_invoice_supplier_number. */ +export function isDuplicateEntry(error: unknown): boolean { + return (error as { code?: string } | null)?.code === 'ER_DUP_ENTRY' +} diff --git a/docs/backoffice/README.md b/docs/backoffice/README.md index 5deba492..72a41900 100644 --- a/docs/backoffice/README.md +++ b/docs/backoffice/README.md @@ -62,8 +62,18 @@ PDFs and images are stored in **Cloudinary**; the database keeps their URLs. or payment. - **The batch payment only takes validated invoices**, and only validated or paid invoices with a stamped PDF can go into a ZIP. +- **An invoice can be validated without a stamped PDF**, on purpose (decided on + 2026-10-11): the screen validates without a PDF and in bulk. The stamp is + placed in the browser, so the server cannot tell whether the stamped copy + comes from the original. +- **Amounts cannot be negative**, a supplier cannot have two invoices with the + same number (`uk_invoice_supplier_number`, case-insensitive), and a payment + date must be a real day between the invoice date and today (decided on + 2026-10-11). - **Changes are logged** in `bo_invoice_history`. -- **Deleting an invoice is permanent** and also deletes its history. +- **Deleting an invoice is permanent** and also deletes its history and its + PDFs. Kept on purpose (decided on 2026-10-11): deleting is how a wrong entry + is corrected, since rejecting has no screen. - **The automatic payment on the 10th does not work today**: it records the change as a system user that does not exist, and the database rejects it. - **PDFs are downloaded through the backend**, which fetches them from diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index 09360dee..da3cd102 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -67,6 +67,9 @@ const TRANSLATED = new Set([ 'BACKOFFICE_NO_FILE_SENT', 'BACKOFFICE_NO_IMAGE_SENT', 'BACKOFFICE_PAYMENT_DATE_REQUIRED', + 'BACKOFFICE_INVALID_PAYMENT_DATE', + 'BACKOFFICE_INVALID_AMOUNT', + 'BACKOFFICE_INVOICE_DUPLICATE', 'BACKOFFICE_PDF_ONLY', 'BACKOFFICE_PDF_TYPE_REQUIRED', 'BACKOFFICE_REJECTION_REASON_REQUIRED', diff --git a/frontend/messages/en/errors.json b/frontend/messages/en/errors.json index 409b8e3a..af2f3804 100644 --- a/frontend/messages/en/errors.json +++ b/frontend/messages/en/errors.json @@ -111,6 +111,9 @@ "BACKOFFICE_SUPPLIER_NOT_FOUND": "Supplier not found", "BACKOFFICE_REJECTION_REASON_REQUIRED": "Rejection reason is required", "BACKOFFICE_PAYMENT_DATE_REQUIRED": "Payment date is required", + "BACKOFFICE_INVALID_PAYMENT_DATE": "The payment date must be a real day, not before the invoice nor in the future", + "BACKOFFICE_INVALID_AMOUNT": "Amounts must be numbers and cannot be negative", + "BACKOFFICE_INVOICE_DUPLICATE": "This supplier already has an invoice with that number", "BACKOFFICE_NO_FILE_SENT": "No file was sent", "BACKOFFICE_PDF_ONLY": "Only PDF files are allowed", "BACKOFFICE_FILE_TOO_LARGE": "File is too large. Maximum 10MB", diff --git a/frontend/messages/es/errors.json b/frontend/messages/es/errors.json index c36f2733..0c0f17af 100644 --- a/frontend/messages/es/errors.json +++ b/frontend/messages/es/errors.json @@ -111,6 +111,9 @@ "BACKOFFICE_SUPPLIER_NOT_FOUND": "Proveedor no encontrado", "BACKOFFICE_REJECTION_REASON_REQUIRED": "Se requiere un motivo de rechazo", "BACKOFFICE_PAYMENT_DATE_REQUIRED": "Se requiere la fecha de pago", + "BACKOFFICE_INVALID_PAYMENT_DATE": "La fecha de pago debe ser un día real, no anterior a la factura ni futura", + "BACKOFFICE_INVALID_AMOUNT": "Los importes deben ser números y no pueden ser negativos", + "BACKOFFICE_INVOICE_DUPLICATE": "Ya existe una factura con ese número para este proveedor", "BACKOFFICE_NO_FILE_SENT": "No se envió ningún archivo", "BACKOFFICE_PDF_ONLY": "Solo se permiten archivos PDF", "BACKOFFICE_FILE_TOO_LARGE": "El archivo es demasiado grande. Máximo 10MB", From 200cae2c38f78e3c95b722435ebbb8fb513e9010 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 00:09:06 +0200 Subject: [PATCH 50/71] docs(todo): close the backoffice and conciliation entries reviewed on 2026-10-11 - Remove: deleting an invoice also deletes its history (kept permanent by decision); integrity of the validated PDF (kept by decision); no checks on amounts and dates; uploads and downloads without content or size checks. - Move the monthly close of Conciliation to low priority and rewrite it: it has no screen and a closed month does not lock its days; left as is. - docs/conciliation: say the monthly close exists only in the API and does not lock the days. Why: fixed in opencode/fix-backoffice-files and opencode/fix-backoffice-invoices; the rest was decided on 2026-10-11, and both decisions are recorded in docs/backoffice/README.md. --- docs/TODO.md | 28 ++++++++-------------------- docs/conciliation/README.md | 6 +++++- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index a7a9fd19..3ec922be 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -237,26 +237,6 @@ preview. 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._ -- [ ] **Backoffice: borrar una factura borra también su historial** — borrado - definitivo, con las columnas de borrado lógico sin usar. _Comprobado por - mí el 2026-09-28._ -- [ ] **Backoffice: integridad del PDF validado** — se puede validar sin PDF o - con cualquier PDF, sin enlace entre el original y la copia sellada. _Según - la revisión `security` L3 del 2026-09-28 (fichero y línea en el informe); - no repasado por mí._ -- [ ] **Backoffice: sin validación de importes ni fechas** — importes negativos, - facturas duplicadas (sin UNIQUE por proveedor y número) y `paid_date` - libre. _Según la revisión `security` L3 del 2026-09-28 (fichero y línea en - el informe); no repasado por mí._ -- [ ] **Backoffice: subidas y descargas sin comprobar contenido ni tamaño** — - solo el tipo declarado por el navegador; las descargas del ZIP no tienen - límite de tamaño y siguen redirecciones. _Según la revisión `security` L3 - del 2026-09-28 (fichero y línea en el informe); no repasado por mí._ -- [ ] **Conciliation: el cierre mensual tiene una carrera** — la validación - (lectura) y el cambio de estado (escritura) no comparten transacción ni - bloqueo; dos administradores cerrando días distintos del mismo mes a la - vez pueden dejarlo inconsistente. _Según la revisión `security` L3 del - 2026-09-28 (fichero y línea en el informe); no repasado por mí._ ### 🟢 Baja @@ -296,6 +276,14 @@ preview. 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 mes cerrado un admin puede reabrir o borrar cualquiera de sus días. La + comprobación de que todos los días están cerrados va sin bloqueo. El + dueño 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._ - [ ] **Conciliation: `GET /api/conciliations` sin paginar ni filtrar** — devuelve todo el histórico a cualquier rol con acceso. _Según la revisión `security` L3 del 2026-09-28 (fichero y línea en el informe); no repasado diff --git a/docs/conciliation/README.md b/docs/conciliation/README.md index c94d5368..83a651e0 100644 --- a/docs/conciliation/README.md +++ b/docs/conciliation/README.md @@ -38,6 +38,8 @@ reconciliation or closes a month. - **Monthly summary**: totals for the month, which days are missing, and whether the month can be closed. - **Close the month**, only once every day of the month exists and is closed. + It exists only in the API (`PATCH /monthly-summary/:year/:month/status`): no + screen calls it. ## What data it handles @@ -63,7 +65,9 @@ Four tables (`backend/db-mysql/aiven/09_conciliation.sql`): - **A closed day cannot be edited** — not its adjustments, not its totals — except by an `admin`. `confirmed` is not a locking state. - **A month closes only when every one of its days exists and is closed.** - Missing or still-open days block it and are listed by day number. + Missing or still-open days block it and are listed by day number. A closed + month does not lock its days: an `admin` can still reopen or delete them + (left as is on 2026-10-11, see `docs/TODO.md`). - **Deleting a day's reconciliation is only for `admin`.** ## How information flows From 2ceb14752d6119e6578278bbdfaeebba9145319f Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 00:24:35 +0200 Subject: [PATCH 51/71] docs(todo): widen the Aiven UTC entry to the timezone handling of dates - Rewrite "MySQL de Aiven en UTC" as the whole problem: DATE columns reach the client as a timestamp at Node's midnight, DATETIMEs shift when Node and MySQL run in different zones, and "today" is computed in UTC in the browser, the backend and Aiven. - List where it shows up and the plan: DATE as YYYY-MM-DD strings, today in Europe/Madrid, a fixed zone between Node and MySQL. Why: editing an invoice in local moved its dates back one day; measured on 2026-10-12 against the local database with Node in Madrid and in UTC. The owner decided to handle it as its own task. --- docs/TODO.md | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 3ec922be..d65f24e8 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -139,11 +139,33 @@ preview. los `CLAUDE.md` de módulo se borraron en vez de quedar como punteros; si Claude Code solo lee `CLAUDE.md` por directorio, ese contexto se pierde. _Sin comprobar._ -- [ ] **MySQL de Aiven en UTC** — `time_zone=SYSTEM` con el sistema en UTC. - `CURDATE()` y `NOW()` de la BD (filtros rápidos de parking, código - `PK-AAAAMMDD` del trigger, `CURRENT_TIMESTAMP` de las tablas) van 1–2 h - por detrás de Madrid: entre las 00:00 y las 02:00 «hoy» sigue siendo ayer. - _Comprobado el 2026-09-28._ +- [ ] **Fechas y «hoy» dependen de la zona horaria de cada pieza** — navegador + en Madrid, Node en la del proceso (Madrid en local; Render sin comprobar, + UTC si no tiene `TZ`) y MySQL en la suya (Madrid en local; Aiven en UTC, + `time_zone=SYSTEM`, comprobado el 2026-09-28). No son relojes + desincronizados: + - El pool (`config/db.ts`) no fija `timezone` ni `dateStrings`: una + columna `DATE` llega como instante a medianoche de Node. Con Node en + Madrid, el 30/09 viaja como `2026-09-29T22:00:00Z`, y quien lo corta por + la `T` se queda con el 29. Así, en local, cada edición de una factura le + resta un día a sus fechas (`InvoiceFormModal.tsx:83-87`). Con Node en + UTC sale bien. + - Si Node y MySQL no están en la misma zona, los `DATETIME` se desplazan: + con Node en UTC contra la MySQL local, `NOW()` sale 2 h movido (p. ej. + `pnpm dev:aiven` desde un equipo en Madrid). + - «Hoy» en UTC está mal entre las 00:00 y las 02:00 de Madrid: + `toISOString()` en 26 sitios del frontend y 6 del backend, y + `CURDATE()`/`NOW()` en Aiven (19 consultas con `CURDATE()`: filtros de + parking, código `PK-AAAAMMDD` del trigger, `CURRENT_TIMESTAMP`). + - 15 sitios del frontend cortan por la `T` fechas del servidor (grupos, + lista negra, parking, horarios, checklist, caja, backoffice). + + Plan: `DATE` como texto `AAAA-MM-DD` (`dateStrings`), «hoy» siempre en + Madrid (un helper en el frontend, `getTodayMadrid` en el backend) y zona + fija entre Node y MySQL. Antes: mirar si Render tiene `TZ` y si alguna + fecha de Aiven se ha movido ya por ediciones. Tarea propia, decidida el + 2026-10-12. _Medido el 2026-10-12 contra la BD local con Node en Madrid y + en UTC._ - [ ] **Logbook: papelera e historial solo ocultos en la pantalla** — el backend da `/trashed`, `include_trashed`, el historial y los comentarios de entradas borradas a cualquier rol con acceso. _Según la revisión From 1896adbee9048396d53fe5f5e974c646a17d737f Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 00:38:14 +0200 Subject: [PATCH 52/71] fix(scheduling): keep bulk edits inside their month and reset a month in one transaction - Bulk edits of cells and days answer 400 when a day id is not a day of the month in the URL (countDaysInMonth). - bulkUpsertAssignments reads the locked cells FOR UPDATE and writes the edit in one transaction; a locked cell still answers 409 and now nothing is written. - resetMonthAssignments corrects the dates, deletes the cells and inserts the new grid in one transaction. - fixMonthDates, deleteAllAssignmentsByMonth and upsertAssignment go: they had no other user. - services/scheduling/AGENTS.md: both rules and the real bulk endpoint. - month-scope.test.ts: foreign days, the locked cell and the reset. Why: PUT /months/141/assignments with a day of month 140 rewrote that cell through uk_day_employee, so a published month could be edited from any draft month; and a reset that failed after the delete left the month empty. Checked against the local database: the cross-month edit changed October from November before and answers 400 now, a locked cell answers 409 with nothing written, a reset whose insert fails keeps its 280 cells, and editing a cell from the grid in the browser still saves. --- .../scheduling/scheduling-controller.ts | 83 ++++++---- .../scheduling/scheduling-repository.ts | 154 +++++++++++++----- backend/services/scheduling/AGENTS.md | 11 +- backend/tests/scheduling/month-scope.test.ts | 120 ++++++++++++++ 4 files changed, 285 insertions(+), 83 deletions(-) create mode 100644 backend/tests/scheduling/month-scope.test.ts diff --git a/backend/controllers/scheduling/scheduling-controller.ts b/backend/controllers/scheduling/scheduling-controller.ts index 0addb5cf..67c0cfa4 100644 --- a/backend/controllers/scheduling/scheduling-controller.ts +++ b/backend/controllers/scheduling/scheduling-controller.ts @@ -174,6 +174,16 @@ async function refusePublished(monthId: number, res: Response): Promise return true } +// A bulk edit carries day ids: all must be days of the month in the URL, or a +// day of another month (published or not) would be written through this one. +// Answers 400 and returns true when one is not +async function refuseForeignDays(monthId: number, dayIds: number[], res: Response) { + const unique = [...new Set(dayIds)] + if ((await repo.countDaysInMonth(monthId, unique)) === unique.length) return false + res.status(400).json({ error: 'Hay días que no pertenecen a este mes' }) + return true +} + export async function getAllConfig(_req: Request, res: Response): Promise { try { const config = await repo.getAllConfig() @@ -818,25 +828,23 @@ export async function resetMonth(req: Request, res: Response): Promise { return } - // Fix dates in scheduling_days (timezone issue fix) - const fixedCount = await repo.fixMonthDates(monthId, month.year, month.month) - logger.info({ monthId, fixedCount }, '[resetMonth] Fixed day dates') - - // Delete all assignments - await repo.deleteAllAssignmentsByMonth(monthId) - logger.info({ monthId }, '[resetMonth] Deleted all assignments') - - // Re-pre-load approved constraints as assignments + // The new grid: an L per day and employee, or the code of an approved request const assignments = await initializeMonthGrid(monthId) - // Save assignments if any - if (assignments.length > 0) { - await repo.createAssignmentsBulk(monthId, assignments) - logger.info( - { monthId, assignmentsCount: assignments.length }, - '[resetMonth] Initialized assignments' - ) + // Dates fixed, cells deleted and the grid inserted in one transaction: a + // failure leaves the month as it was, not empty + const fixedCount = await repo.resetMonthAssignments( + monthId, + month.year, + month.month, + assignments + ) + logger.info( + { monthId, fixedCount, assignmentsCount: assignments.length }, + '[resetMonth] Month reset' + ) + if (assignments.length > 0) { // Number libre pairs immediately after seeding const uniqueEmployees = new Set(assignments.map((a) => a.employee_id)) for (const empId of uniqueEmployees) { @@ -977,6 +985,14 @@ export async function bulkUpdateDays(req: Request, res: Response): Promise return } if (await refusePublished(monthId, res)) return + if ( + await refuseForeignDays( + monthId, + data.days.map((d) => d.day_id), + res + ) + ) + return const updatedCount = await repo.bulkUpdateDays(data.days) @@ -1085,29 +1101,24 @@ export async function bulkUpdateAssignments(req: Request, res: Response): Promis } } - // Prevent editing locked cells (preloaded from approved constraints) - for (const assignment of data.assignments) { - const existing = await repo.getAssignmentByDayEmployee( - assignment.day_id, - assignment.employee_id + if ( + await refuseForeignDays( + monthId, + data.assignments.map((a) => a.day_id), + res ) - if (existing?.source_constraint_id) { - res.status(409).json({ - error: - 'Hay celdas bloqueadas por peticiones aprobadas. No se pueden editar desde bulk update.', - }) - return - } - } + ) + return - // Upsert each assignment - for (const assignment of data.assignments) { - await repo.upsertAssignment({ - month_id: monthId, - day_id: assignment.day_id, - employee_id: assignment.employee_id, - shift_code: assignment.shift_code, + // Cells locked by approved requests are checked and the rest written in + // one transaction + const { locked } = await repo.bulkUpsertAssignments(monthId, data.assignments) + if (locked) { + res.status(409).json({ + error: + 'Hay celdas bloqueadas por peticiones aprobadas. No se pueden editar desde bulk update.', }) + return } const affectedEmployees = new Set(data.assignments.map((a) => a.employee_id)) diff --git a/backend/repositories/scheduling/scheduling-repository.ts b/backend/repositories/scheduling/scheduling-repository.ts index fbc8e501..954edc8c 100644 --- a/backend/repositories/scheduling/scheduling-repository.ts +++ b/backend/repositories/scheduling/scheduling-repository.ts @@ -22,7 +22,6 @@ import type { UpdateMonthDTO, CreateDayDTO, UpdateDayDTO, - CreateAssignmentDTO, UpdateAssignmentDTO, BulkAssignmentDTO, CreateConstraintDTO, @@ -572,6 +571,20 @@ export async function updateDay(id: number, data: UpdateDayDTO): Promise 0 } +/** + * How many of `dayIds` (distinct) are days of `monthId`. The bulk edits use it + * to refuse days of another month: uk_day_employee would otherwise rewrite + * that month's cell, published or not. + */ +export async function countDaysInMonth(monthId: number, dayIds: number[]): Promise { + if (dayIds.length === 0) return 0 + const [rows] = await db.query( + 'SELECT COUNT(*) AS n FROM scheduling_days WHERE month_id = ? AND id IN (?)', + [monthId, dayIds] + ) + return Number(rows[0].n) +} + export async function bulkUpdateDays( days: Array<{ day_id: number; is_holiday?: boolean; holiday_name?: string | null }> ): Promise { @@ -721,33 +734,49 @@ export async function updateAssignment(id: number, data: UpdateAssignmentDTO): P return result.affectedRows > 0 } -export async function upsertAssignment(data: CreateAssignmentDTO): Promise { - // Use INSERT ... ON DUPLICATE KEY UPDATE for better performance - // Avoids extra SELECT query before INSERT/UPDATE - const [result] = await db.execute( - `INSERT INTO scheduling_assignments - (month_id, day_id, employee_id, shift_code, source_constraint_id, notes, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW()) - ON DUPLICATE KEY UPDATE - shift_code = VALUES(shift_code), - notes = VALUES(notes), - updated_at = NOW()`, - [ - data.month_id, - data.day_id, - data.employee_id, - data.shift_code, - data.source_constraint_id ?? null, - data.notes ?? null, - ] - ) - // insertId is the new ID on insert, or 0 on update - // For updates, we need to fetch the existing ID - if (result.insertId === 0) { - const existing = await getAssignmentByDayEmployee(data.day_id, data.employee_id) - return existing?.id ?? 0 +/** + * Writes the cells of a bulk edit in one transaction. The cells locked by an + * approved request are read FOR UPDATE inside it: if any is in the edit, + * nothing is written and `locked` is true. + */ +export async function bulkUpsertAssignments( + monthId: number, + assignments: { day_id: number; employee_id: string; shift_code: string }[] +): Promise<{ locked: boolean }> { + const connection = await db.getConnection() + try { + await connection.beginTransaction() + + const pairs = assignments.map((a) => [a.day_id, a.employee_id]) + const [locked] = await connection.query( + `SELECT id FROM scheduling_assignments + WHERE (day_id, employee_id) IN (?) AND source_constraint_id IS NOT NULL + FOR UPDATE`, + [pairs] + ) + if (locked.length > 0) { + await connection.rollback() + return { locked: true } + } + + for (const a of assignments) { + await connection.execute( + `INSERT INTO scheduling_assignments + (month_id, day_id, employee_id, shift_code, source_constraint_id, notes, created_at, updated_at) + VALUES (?, ?, ?, ?, NULL, NULL, NOW(), NOW()) + ON DUPLICATE KEY UPDATE shift_code = VALUES(shift_code), updated_at = NOW()`, + [monthId, a.day_id, a.employee_id, a.shift_code] + ) + } + + await connection.commit() + return { locked: false } + } catch (err) { + await connection.rollback() + throw err + } finally { + connection.release() } - return result.insertId } export async function recalculateLibreNumbers(employeeId: string, year: number): Promise { @@ -784,8 +813,60 @@ export async function recalculateLibreNumbers(employeeId: string, year: number): ) } -export async function deleteAllAssignmentsByMonth(monthId: number): Promise { - await db.execute('DELETE FROM scheduling_assignments WHERE month_id = ?', [monthId]) +/** + * Resets a month in one transaction: corrects the day dates, deletes every + * cell and inserts `seed` (an L per day and employee, or the code of an + * approved request). A failure leaves the month as it was. Returns the days + * whose date was corrected. + */ +export async function resetMonthAssignments( + monthId: number, + year: number, + month: number, + seed: BulkAssignmentDTO[] +): Promise { + const connection = await db.getConnection() + try { + await connection.beginTransaction() + + const daysInMonth = new Date(year, month, 0).getDate() + let fixedCount = 0 + for (let day = 1; day <= daysInMonth; day++) { + const correctDate = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` + const [result] = await connection.execute( + 'UPDATE scheduling_days SET date = ? WHERE month_id = ? AND day_number = ?', + [correctDate, monthId, day] + ) + fixedCount += result.affectedRows + } + + await connection.execute('DELETE FROM scheduling_assignments WHERE month_id = ?', [monthId]) + + if (seed.length > 0) { + const values = seed.map((a) => [ + monthId, + a.day_id, + a.employee_id, + a.shift_code, + a.source_constraint_id ?? null, + ]) + const placeholders = values.map(() => '(?, ?, ?, ?, ?, NULL, NOW(), NOW())').join(', ') + await connection.query( + `INSERT INTO scheduling_assignments + (month_id, day_id, employee_id, shift_code, source_constraint_id, notes, created_at, updated_at) + VALUES ${placeholders}`, + values.flat() + ) + } + + await connection.commit() + return fixedCount + } catch (err) { + await connection.rollback() + throw err + } finally { + connection.release() + } } /** Borra las asignaciones no bloqueadas e inserta las nuevas en una sola transacción. */ @@ -883,21 +964,6 @@ export async function getAnnualLCountByEmployee( return rows as { employee_id: string; employee_name: string; libre_count: number }[] } -export async function fixMonthDates(monthId: number, year: number, month: number): Promise { - const daysInMonth = new Date(year, month, 0).getDate() - - let fixedCount = 0 - for (let day = 1; day <= daysInMonth; day++) { - const correctDate = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` - const [result] = await db.execute( - 'UPDATE scheduling_days SET date = ? WHERE month_id = ? AND day_number = ?', - [correctDate, monthId, day] - ) - fixedCount += (result as ResultSetHeader).affectedRows - } - return fixedCount -} - // ============================================ // CONSTRAINTS // ============================================ diff --git a/backend/services/scheduling/AGENTS.md b/backend/services/scheduling/AGENTS.md index b3a1515e..04672277 100644 --- a/backend/services/scheduling/AGENTS.md +++ b/backend/services/scheduling/AGENTS.md @@ -279,7 +279,11 @@ When you touch this file: - Almost every function takes `monthId` or `year/month` — they're per-month, not global. - Assignments with `source_constraint_id !== null` are locked: respect that - invariant in any bulk update query. + invariant in any bulk update query. `bulkUpsertAssignments` reads them + `FOR UPDATE` inside its transaction. +- A bulk edit must only carry days of its own month: check with + `countDaysInMonth` first. `uk_day_employee` would otherwise rewrite the cell + of another month, published or not. - `getSchedulableEmployeesForMonth(year, month)` applies `start_date`/`end_date` filtering. For raw employee lists (Totals tab, etc.) use `getAllEmployeesWithStatus`. @@ -299,10 +303,11 @@ setSchedulableEmployees". panel - `POST /months/:id/generate` — Run CP-SAT solver, apply matrix to assignments - `POST /months/:id/reset` — Wipe all assignments, re-seed from approved - constraints + constraints, in one transaction (`resetMonthAssignments`) - `POST /months/:id/unpublish` — Back to draft (controlled transition) - `PATCH /assignments/:id` — Update single cell (409 if locked) -- `POST /assignments/bulk` — Bulk update; respects locks +- `PUT /months/:id/assignments` — Bulk update; respects locks, 400 for a day + of another month - `POST /constraints/:id/approve` — Approve/reject + auto-sync assignments - `GET /employee-rules` — Rules list in camelCase for the panel - `PUT /scheduling/employees` — Set schedulable employees list (⚠️ see bug diff --git a/backend/tests/scheduling/month-scope.test.ts b/backend/tests/scheduling/month-scope.test.ts new file mode 100644 index 00000000..53e0bf67 --- /dev/null +++ b/backend/tests/scheduling/month-scope.test.ts @@ -0,0 +1,120 @@ +// tests/scheduling/month-scope.test.ts +// Regression tests: the bulk edits of a month accepted day ids of any other +// month. PUT /months/141/assignments with a day of month 140 rewrote that +// cell (uk_day_employee), so a published month could be edited through any +// draft month. Resetting a month deleted its cells and seeded them again in +// separate statements: a failure in between left the month without its +// locked cells. Both now check the days and write in one transaction. + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Request, Response } from 'express' + +const EMPLOYEE = '11111111-1111-4111-8111-111111111111' + +vi.mock('../../repositories/scheduling/scheduling-repository.js', () => ({ + getMonthById: vi.fn(), + countDaysInMonth: vi.fn(), + bulkUpdateDays: vi.fn(async () => 1), + bulkUpsertAssignments: vi.fn(async () => ({ locked: false })), + getAllShifts: vi.fn(async () => [{ code: 'M' }, { code: 'L' }]), + recalculateLibreNumbers: vi.fn(), + resetMonthAssignments: vi.fn(async () => 31), + createAssignmentsBulk: vi.fn(), + createHistory: vi.fn(), + // initializeMonthGrid (in the controller) seeds an L for every day and employee + getDaysByMonth: vi.fn(async () => [{ id: 7, date: '2026-11-01' }]), + getConstraintsByMonth: vi.fn(async () => []), + getSchedulableEmployees: vi.fn(async () => [{ id: EMPLOYEE }]), +})) + +const controller = await import('../../controllers/scheduling/scheduling-controller.js') +const repo = await import('../../repositories/scheduling/scheduling-repository.js') + +function call( + handler: (req: Request, res: Response) => Promise, + params: object, + body: object +) { + const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() } + const req = { user: { id: 'u1' }, params, body } as unknown as Request + return handler(req, res as unknown as Response).then(() => res) +} + +const assignments = { + assignments: [ + { day_id: 7, employee_id: EMPLOYEE, shift_code: 'M' }, + { day_id: 8, employee_id: EMPLOYEE, shift_code: 'L' }, + ], +} +const days = { + days: [ + { day_id: 7, is_holiday: true }, + { day_id: 8, is_holiday: false }, + ], +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(repo.getMonthById).mockResolvedValue({ + id: 141, + year: 2026, + month: 11, + status: 'draft', + } as never) +}) + +describe('bulk edits only touch days of their month', () => { + it('assignments with a day of another month answer 400 and write nothing', async () => { + vi.mocked(repo.countDaysInMonth).mockResolvedValue(1) + const res = await call(controller.bulkUpdateAssignments, { id: '141' }, assignments) + expect(repo.countDaysInMonth).toHaveBeenCalledWith(141, [7, 8]) + expect(res.status).toHaveBeenCalledWith(400) + expect(repo.bulkUpsertAssignments).not.toHaveBeenCalled() + }) + + it('days of another month answer 400 and write nothing', async () => { + vi.mocked(repo.countDaysInMonth).mockResolvedValue(1) + const res = await call(controller.bulkUpdateDays, { id: '141' }, days) + expect(res.status).toHaveBeenCalledWith(400) + expect(repo.bulkUpdateDays).not.toHaveBeenCalled() + }) + + it('assignments of the month are written in one transactional call', async () => { + vi.mocked(repo.countDaysInMonth).mockResolvedValue(2) + const res = await call(controller.bulkUpdateAssignments, { id: '141' }, assignments) + expect(res.status).not.toHaveBeenCalled() + expect(repo.bulkUpsertAssignments).toHaveBeenCalledWith(141, assignments.assignments) + }) + + it('a locked cell, checked inside the transaction, answers 409', async () => { + vi.mocked(repo.countDaysInMonth).mockResolvedValue(2) + vi.mocked(repo.bulkUpsertAssignments).mockResolvedValueOnce({ locked: true }) + const res = await call(controller.bulkUpdateAssignments, { id: '141' }, assignments) + expect(res.status).toHaveBeenCalledWith(409) + expect(repo.createHistory).not.toHaveBeenCalled() + }) + + it('the same day twice counts once', async () => { + vi.mocked(repo.countDaysInMonth).mockResolvedValue(1) + const twice = { + assignments: [ + { day_id: 7, employee_id: EMPLOYEE, shift_code: 'M' }, + { day_id: 7, employee_id: '22222222-2222-4222-8222-222222222222', shift_code: 'L' }, + ], + } + const res = await call(controller.bulkUpdateAssignments, { id: '141' }, twice) + expect(repo.countDaysInMonth).toHaveBeenCalledWith(141, [7]) + expect(res.status).not.toHaveBeenCalled() + }) +}) + +describe('resetting a month', () => { + it('deletes and seeds the cells in one transactional call', async () => { + const res = await call(controller.resetMonth, { id: '141' }, {}) + expect(res.status).not.toHaveBeenCalled() + expect(repo.resetMonthAssignments).toHaveBeenCalledWith(141, 2026, 11, [ + { day_id: 7, employee_id: EMPLOYEE, shift_code: 'L', source_constraint_id: null }, + ]) + expect(repo.createAssignmentsBulk).not.toHaveBeenCalled() + }) +}) From cbec8c85852f3407a4d61ff3404e699c3bdf1743 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 00:38:14 +0200 Subject: [PATCH 53/71] docs(scheduling): record that LI stays outside the rest rules of the solver - decisions.md: LI does not count towards the weekly free days nor the monthly minimum of L, and it stays that way. Why: decided by the owner on 2026-10-12; it matches the catalogue, which describes LI as an extra free day, and keeps every hard rule unchanged. --- docs/scheduling/decisions.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/scheduling/decisions.md b/docs/scheduling/decisions.md index 0ec4e18e..83cc37dc 100644 --- a/docs/scheduling/decisions.md +++ b/docs/scheduling/decisions.md @@ -1337,3 +1337,18 @@ hand; the owner chose speed over reproducibility. **If it is revisited.** Set `random_seed`, `interleave_search = True` and a fixed `num_workers` in `model.py`, and replace `max_time_in_seconds` with `max_deterministic_time` for months that reach the limit. + +## 2026-10-12 — `LI` does not count as rest in the solver + +**Context.** `LI` (Libre Disposición) is not in `REST_SHIFTS` of +`constraints/rest.py` nor in `_SPECIAL_REST` of `constraints/libres.py`: a +locked `LI` neither counts towards the two free days of any 7-day window nor +lowers the monthly minimum of `L`. Whoever has an `LI` also gets every +regular free day. + +**Decision.** Keep it. It matches the catalogue (`README.md`: "extra free +day"; `services/scheduling/AGENTS.md`: "outside the weekly rotation"). + +**Why.** The owner's priority is a solver that keeps working: counting `LI` +as rest would change two hard rules and the rosters of every month with an +`LI`, with no reported problem behind it. From 8093de3d5dd1b2c01ea39159f4cb49206705f82a Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 00:38:44 +0200 Subject: [PATCH 54/71] docs(todo): close the low-priority entries reviewed in A5.1 - Remove: conciliation list without paging and checklist_config unused (both left as is by decision, now noted in their module docs); month reset without a transaction and day ids of another month in bulk edits (fixed in opencode/fix-scheduling); LI as rest in the solver (kept as is, recorded in docs/scheduling/decisions.md). - Keep: rejecting invoices has no screen (decided on 2026-10-04). Why: reviewed against the code on 2026-10-12; the owner decided the three entries that needed a decision. --- docs/TODO.md | 15 --------------- docs/checklist/README.md | 3 ++- docs/conciliation/README.md | 2 ++ 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index d65f24e8..16159b10 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -306,21 +306,6 @@ preview. dueño 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._ -- [ ] **Conciliation: `GET /api/conciliations` sin paginar ni filtrar** — - devuelve todo el histórico a cualquier rol con acceso. _Según la revisión - `security` L3 del 2026-09-28 (fichero y línea en el informe); no repasado - por mí._ -- [ ] **Checklist: `checklist_config` existe pero no se usa.** _Comprobado por - mí el 2026-09-28._ -- [ ] **Scheduling: reiniciar un mes no usa transacción** — si falla a mitad, el - mes queda vacío. _Comprobado por mí el 2026-09-28._ -- [ ] **Scheduling: comprobar si `LI` debe contar como descanso en el solver** — - falta en `REST_SHIFTS` de `rest.py`. _Sin comprobar su efecto._ -- [ ] **Scheduling: `day_id` de otro mes aceptado** y comprobación de bloqueo - fuera de transacción en las ediciones masivas. El tamaño de las listas y - los ids de empleado ya se validan (2026-10-02). _Según la revisión - `security` L3 del 2026-09-28 (fichero y línea en el informe); no repasado - por mí._ - [ ] **Scheduling: el daemon del solver puede quedar bloqueado** — el semáforo se retiene durante el arranque (hasta 30 min) con cola sin límite. _Según la revisión `security` L3 del 2026-09-28 (fichero y línea en el informe); diff --git a/docs/checklist/README.md b/docs/checklist/README.md index 6300c9f3..139b8552 100644 --- a/docs/checklist/README.md +++ b/docs/checklist/README.md @@ -61,7 +61,8 @@ from `backend/`. - **`checklist_step_comments`** and **`checklist_step_attachments`**: comments and images per step. - **`checklist_event_log`**: every tick, untick and restart. -- **`checklist_config`**: exists but is not used. +- **`checklist_config`**: exists but is not used: the 06:30 reset and the + `Europe/Madrid` zone are written in the code. Left as is on 2026-10-12. ## What rules it follows diff --git a/docs/conciliation/README.md b/docs/conciliation/README.md index 83a651e0..b7043115 100644 --- a/docs/conciliation/README.md +++ b/docs/conciliation/README.md @@ -69,6 +69,8 @@ Four tables (`backend/db-mysql/aiven/09_conciliation.sql`): month does not lock its days: an `admin` can still reopen or delete them (left as is on 2026-10-11, see `docs/TODO.md`). - **Deleting a day's reconciliation is only for `admin`.** +- **`GET /api/conciliations` returns every day, without paging or filters**, + to any role with access. No screen uses it; left as is on 2026-10-12. ## How information flows From e7a5443aa3265c848e08c19c17ab26468f562d21 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 00:56:13 +0200 Subject: [PATCH 55/71] fix(groups): refresh the group page after an edit and keep contacts from maintenance - GroupDetailClient: the page paints useGroupStore, filled once from the server; it now copies the fresh useGroup data into the store and, after an edit, invalidates the group key (payments, status and rooms share the prefix). useGroup reads initialGroup.id, not the store, which may still hold the previous page's group. - canViewGroupContacts (admin, recepcionista, group-admin) guards GET /contacts and /contacts/primary; the history leaves out the group_contacts entries, which keep the email and phone as JSON, for the other roles. - Frontend: the contacts tab and its query are left out for those roles, ?tab=contacts opens the summary, and ContactPanel drops a contacts query it never read. - docs/groups: who sees the contacts. - Tests: the middleware, the routes and the history filter; the page code (no frontend test runner). Why: after "Grupo actualizado correctamente" the page kept the old total and balance until another action reloaded it; and every role, maintenance included, could read the contacts. Decided on 2026-10-12: maintenance keeps groups for its notifications, not the contacts. Checked in the browser: before, a 3200 -> 4000 edit kept showing 3200; now it shows 4000 at once; as maintenance there is no contacts tab, no /contacts request and no contact entry in the history; admin still sees both. --- .../group/group-history-controller.ts | 8 +- backend/middlewares/roleCheck.ts | 22 +++++ backend/routes/group/group-routes.ts | 16 +++- backend/tests/frontend/group-detail.test.ts | 59 +++++++++++++ .../tests/group/group-contacts-access.test.ts | 85 +++++++++++++++++++ docs/groups/README.md | 2 + .../components/groups/GroupDetailClient.tsx | 29 ++++++- .../groups/layout/TabNavigation.tsx | 7 +- .../components/groups/panels/ContactPanel.tsx | 8 +- frontend/app/lib/groups/contact-access.ts | 9 ++ frontend/app/lib/groups/index.ts | 1 + frontend/app/lib/groups/queries.ts | 5 +- 12 files changed, 233 insertions(+), 18 deletions(-) create mode 100644 backend/tests/frontend/group-detail.test.ts create mode 100644 backend/tests/group/group-contacts-access.test.ts create mode 100644 frontend/app/lib/groups/contact-access.ts diff --git a/backend/controllers/group/group-history-controller.ts b/backend/controllers/group/group-history-controller.ts index ed0da35b..b63ce671 100644 --- a/backend/controllers/group/group-history-controller.ts +++ b/backend/controllers/group/group-history-controller.ts @@ -5,6 +5,7 @@ import { GroupHistoryService } from '../../services/group/group-history-service' import { GroupRepository } from '../../repositories/group/group-repository' import { ERROR_CODES } from '../../config/error-codes.js' import { logger } from '../../config/logger.js' +import { canSeeGroupContacts } from '../../middlewares/roleCheck.js' import { groupHistoryQuerySchema, validationError } from '../../validations/group/group-schemas.js' export class GroupHistoryController { @@ -39,7 +40,12 @@ export class GroupHistoryController { }) } - const history = await GroupHistoryService.getGroupHistory(groupId, limit) + // Contact entries keep the email and phone as JSON: only for the roles + // that may see the contacts themselves + const all = await GroupHistoryService.getGroupHistory(groupId, limit) + const history = canSeeGroupContacts(req.user?.role) + ? all + : all.filter((h) => h.table_affected !== 'group_contacts') return res.status(200).json({ success: true, diff --git a/backend/middlewares/roleCheck.ts b/backend/middlewares/roleCheck.ts index 96230a2c..da406813 100644 --- a/backend/middlewares/roleCheck.ts +++ b/backend/middlewares/roleCheck.ts @@ -95,6 +95,28 @@ export const canViewGroups: RoleCheckMiddleware = (req, res, next) => { } } +// Email and phone of a group's contacts: not mantenimiento, which views groups +// only to open its notifications (decided on 2026-10-12) +const GROUP_CONTACT_ROLES = ['admin', 'recepcionista', 'group-admin'] + +export function canSeeGroupContacts(role: string | undefined): boolean { + return !!role && GROUP_CONTACT_ROLES.includes(role.toLowerCase()) +} + +/** + * Verifica que el usuario pueda ver los contactos de un grupo + * Uso: GET /api/groups/:id/contacts y /contacts/primary + */ +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', + }) + } +} + /** * Verifica que el usuario pueda gestionar caja * Uso: Para crear, editar, cerrar turnos, vales, etc. diff --git a/backend/routes/group/group-routes.ts b/backend/routes/group/group-routes.ts index cb507c95..12021e90 100644 --- a/backend/routes/group/group-routes.ts +++ b/backend/routes/group/group-routes.ts @@ -2,7 +2,12 @@ import { Router } from 'express' import { authenticateToken as verifyToken } from '../../middlewares/authenticateToken.js' -import { canManageGroups, canViewGroups, isAdmin } from '../../middlewares/roleCheck.js' +import { + canManageGroups, + canViewGroupContacts, + canViewGroups, + isAdmin, +} from '../../middlewares/roleCheck.js' // Controllers import { GroupController } from '../../controllers/group/group-controller.js' @@ -122,12 +127,17 @@ router.delete('/:id/rooms/:roomId', verifyToken, canManageGroups, GroupRoomContr // RUTAS DE CONTACTOS // ═══════════════════════════════════════════════════════ -router.get('/:id/contacts', verifyToken, canViewGroups, GroupContactController.getContactsByGroup) +router.get( + '/:id/contacts', + verifyToken, + canViewGroupContacts, + GroupContactController.getContactsByGroup +) router.get( '/:id/contacts/primary', verifyToken, - canViewGroups, + canViewGroupContacts, GroupContactController.getPrimaryContact ) diff --git a/backend/tests/frontend/group-detail.test.ts b/backend/tests/frontend/group-detail.test.ts new file mode 100644 index 00000000..a86f5a63 --- /dev/null +++ b/backend/tests/frontend/group-detail.test.ts @@ -0,0 +1,59 @@ +// tests/frontend/group-detail.test.ts +// Regression tests for the group page (the frontend has no test runner). +// After "Grupo actualizado correctamente" the page kept the old amount: it +// paints the group from useGroupStore, filled once from the server, and the +// useGroup query was never read nor invalidated. And mantenimiento, which +// may not see contacts since 2026-10-12, would get a 403 from the contacts +// tab: the tab and its query are left out for it. + +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { canViewGroupContacts } from '../../../frontend/app/lib/groups/contact-access.js' + +const groups = join( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + '..', + 'frontend', + 'app', + 'components', + 'groups' +) +const read = (...p: string[]) => readFileSync(join(groups, ...p), 'utf8') +const detail = read('GroupDetailClient.tsx') + +describe('the group page after an edit', () => { + it('copies the fresh useGroup data into the store', () => { + expect(detail).toMatch(/const \{ data: groupData \} = useGroup\(/) + expect(detail).toMatch(/if \(groupData\) setCurrentGroup\(groupData\)/) + }) + + it('invalidates the group, its payments and status on success', () => { + const handler = detail.slice(detail.indexOf('const handleEditSuccess')) + expect(handler.slice(0, 300)).toContain( + 'queryClient.invalidateQueries({ queryKey: groupsKeys.detail(currentGroup.id) })' + ) + }) +}) + +describe('contacts by role', () => { + it('mirrors canViewGroupContacts of the backend', () => { + for (const role of ['admin', 'recepcionista', 'group-admin', 'Admin']) { + expect(canViewGroupContacts(role), role).toBe(true) + } + for (const role of ['mantenimiento', '', undefined]) { + expect(canViewGroupContacts(role), String(role)).toBe(false) + } + }) + + it('the contacts tab and its query depend on it', () => { + expect(read('layout', 'TabNavigation.tsx')).toContain('canViewGroupContacts(') + expect(detail).toContain( + "const activeTab = requestedTab === 'contacts' && !showContacts ? 'overview' : requestedTab" + ) + expect(detail).toMatch(/useGroupContacts\(currentGroup\?\.id, showContacts\)/) + }) +}) diff --git a/backend/tests/group/group-contacts-access.test.ts b/backend/tests/group/group-contacts-access.test.ts new file mode 100644 index 00000000..a4aee07e --- /dev/null +++ b/backend/tests/group/group-contacts-access.test.ts @@ -0,0 +1,85 @@ +// tests/group/group-contacts-access.test.ts +// Regression tests: every role that views groups, mantenimiento included, +// could read the email and phone of a group's contacts, both from +// /contacts and from the history, which keeps each contact as JSON when it +// is created, edited or deleted. Decided on 2026-10-12: mantenimiento keeps +// groups and their notifications, but not the contacts. + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Request, Response, NextFunction } from 'express' + +vi.mock('../../repositories/group/group-repository', () => ({ + GroupRepository: { getById: vi.fn(async () => ({ id: 4 })) }, +})) +vi.mock('../../services/group/group-history-service', () => ({ + GroupHistoryService: { + getGroupHistory: vi.fn(async () => [ + { id: 1, table_affected: 'hotel_groups', new_value: '{"name":"Boda"}' }, + { id: 2, table_affected: 'group_contacts', new_value: '{"contact_email":"a@b.es"}' }, + { id: 3, table_affected: 'group_payments', new_value: '{"amount":100}' }, + ]), + }, +})) + +const { canViewGroupContacts } = await import('../../middlewares/roleCheck.js') +const { GroupHistoryController } = + await import('../../controllers/group/group-history-controller.js') + +function runMiddleware(role: string) { + const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() } + const next = vi.fn() as NextFunction + canViewGroupContacts({ user: { role } } as unknown as Request, res as unknown as Response, next) + return { res, next } +} + +async function history(role: string) { + const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() } + const req = { params: { id: '4' }, query: {}, user: { id: 'u1', role } } as unknown as Request + await GroupHistoryController.getGroupHistory(req, res as unknown as Response) + return res.json.mock.calls[0][0] as { data: { table_affected: string }[]; count: number } +} + +beforeEach(() => vi.clearAllMocks()) + +describe('canViewGroupContacts', () => { + it.each(['admin', 'recepcionista', 'group-admin', 'Admin'])('lets %s through', (role) => { + expect(runMiddleware(role).next).toHaveBeenCalledOnce() + }) + + it('answers 403 to mantenimiento', () => { + const { res, next } = runMiddleware('mantenimiento') + expect(res.status).toHaveBeenCalledWith(403) + expect(next).not.toHaveBeenCalled() + }) +}) + +describe('group history', () => { + it('leaves out the contact entries for mantenimiento', async () => { + const body = await history('mantenimiento') + expect(body.data.map((h) => h.table_affected)).toEqual(['hotel_groups', 'group_payments']) + expect(body.count).toBe(2) + expect(JSON.stringify(body)).not.toContain('a@b.es') + }) + + it('keeps every entry for the roles that see contacts', async () => { + expect((await history('recepcionista')).count).toBe(3) + }) +}) + +describe('routes', () => { + const routes = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'routes', 'group', 'group-routes.ts'), + 'utf8' + ) + + it.each(['/:id/contacts', '/:id/contacts/primary'])( + 'GET %s needs canViewGroupContacts', + (path) => { + const call = new RegExp(`router\\.get\\(\\s*'${path}',\\s*verifyToken,\\s*(\\w+)`) + expect(routes.match(call)?.[1]).toBe('canViewGroupContacts') + } + ) +}) diff --git a/docs/groups/README.md b/docs/groups/README.md index 945db347..d0c73a01 100644 --- a/docs/groups/README.md +++ b/docs/groups/README.md @@ -16,6 +16,8 @@ lines in parallel, and sends in-app reminders before the important dates. - **Every role can view groups**, including `recepcionista` and `mantenimiento` (the latter needs it to open group notifications from the profile). + `mantenimiento` does not see the contacts (email and phone), neither in their + tab nor in the history (decided on 2026-10-12). - **`admin` and `group-admin`** create and edit groups, payments, contacts, rooms and statuses, and send manual notifications. - **Only `admin`** deletes a group. diff --git a/frontend/app/components/groups/GroupDetailClient.tsx b/frontend/app/components/groups/GroupDetailClient.tsx index 207b4139..bbfaf47d 100644 --- a/frontend/app/components/groups/GroupDetailClient.tsx +++ b/frontend/app/components/groups/GroupDetailClient.tsx @@ -13,6 +13,7 @@ // IMPORTANTE: Este archivo depende directamente del diseño actual duplicado. import { useEffect } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { useRouter, useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' import { useGroupStore } from '@/app/stores/useGroupStore' @@ -24,7 +25,10 @@ import { useGroupPayments, useGroupContacts, useGroupRooms, + groupsKeys, + canViewGroupContacts, } from '@/app/lib/groups' +import { useAuthContext } from '@/app/lib/auth/useAuth' import { GroupHeader } from './layout/GroupHeader' import { TabNavigation } from './layout/TabNavigation' import { GroupDetailSummaryPanel } from './layout/GroupDetailSummaryPanel' @@ -47,17 +51,26 @@ interface GroupDetailClientProps { export function GroupDetailClient({ initialGroup }: GroupDetailClientProps) { const router = useRouter() const searchParams = useSearchParams() - const activeTab = searchParams.get('tab') || 'overview' + const requestedTab = searchParams.get('tab') || 'overview' const panel = searchParams.get('panel') const highlightId = searchParams.get('highlight') const t = useTranslations('groups') const { currentGroup, setCurrentGroup, setActiveTab, setHighlight } = useGroupStore() - const { data: _groupData } = useGroup(currentGroup?.id) + const queryClient = useQueryClient() + const { user } = useAuthContext() + const showContacts = canViewGroupContacts(user?.role) + // ?tab=contacts for a role without contacts opens the summary + const activeTab = requestedTab === 'contacts' && !showContacts ? 'overview' : requestedTab + + // The page paints the store; the query keeps it fresh after an edit. + // initialGroup.id, not currentGroup: the store may still hold the group of + // the previous page during the first render + const { data: groupData } = useGroup(initialGroup.id) const { data: paymentsData } = useGroupPayments(currentGroup?.id) const payments = paymentsData?.payments ?? [] - const { data: contacts = [] } = useGroupContacts(currentGroup?.id) + const { data: contacts = [] } = useGroupContacts(currentGroup?.id, showContacts) const { data: rooms = [] } = useGroupRooms(currentGroup?.id) // Inicializar grupo en el store @@ -65,6 +78,10 @@ export function GroupDetailClient({ initialGroup }: GroupDetailClientProps) { setCurrentGroup(initialGroup) }, [initialGroup, setCurrentGroup]) + useEffect(() => { + if (groupData) setCurrentGroup(groupData) + }, [groupData, setCurrentGroup]) + // Sincronizar activeTab con query params useEffect(() => { setActiveTab(activeTab) @@ -85,8 +102,12 @@ export function GroupDetailClient({ initialGroup }: GroupDetailClientProps) { router.push(`?${params.toString()}`, { scroll: false }) } + // EditGroupPanel calls the API directly: refetch the group and, under the + // same key prefix, its payments, status and rooms (the balance depends on + // the total) const handleEditSuccess = () => { - // Las mutaciones ya invalidan los queries necesarios vía React Query + if (!currentGroup) return + void queryClient.invalidateQueries({ queryKey: groupsKeys.detail(currentGroup.id) }) } // Payment Panel Logic diff --git a/frontend/app/components/groups/layout/TabNavigation.tsx b/frontend/app/components/groups/layout/TabNavigation.tsx index 9309c7cd..90fe9846 100644 --- a/frontend/app/components/groups/layout/TabNavigation.tsx +++ b/frontend/app/components/groups/layout/TabNavigation.tsx @@ -7,6 +7,8 @@ import { useRouter, usePathname, useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' import { cn } from '@/app/lib/helpers/utils' import { FiChevronDown, FiCheck } from 'react-icons/fi' +import { useAuthContext } from '@/app/lib/auth/useAuth' +import { canViewGroupContacts } from '@/app/lib/groups/contact-access' type Tab = 'overview' | 'payments' | 'contacts' | 'rooms' | 'status' | 'history' @@ -23,8 +25,9 @@ export function TabNavigation(_props: TabNavigationProps) { const [isOpen, setIsOpen] = useState(false) const dropdownRef = useRef(null) const t = useTranslations('groups') + const { user } = useAuthContext() - const tabs: { id: Tab; label: string }[] = [ + const allTabs: { id: Tab; label: string }[] = [ { id: 'overview', label: t('tabs.overview') }, { id: 'payments', label: t('tabs.payments') }, { id: 'contacts', label: t('tabs.contacts') }, @@ -32,6 +35,8 @@ export function TabNavigation(_props: TabNavigationProps) { { id: 'status', label: t('tabs.status') }, { id: 'history', label: t('tabs.history') }, ] + // mantenimiento opens groups for its notifications, not for the contacts + const tabs = allTabs.filter((tab) => tab.id !== 'contacts' || canViewGroupContacts(user?.role)) const activeTabConfig = tabs.find((tab) => tab.id === activeTab) || tabs[0] diff --git a/frontend/app/components/groups/panels/ContactPanel.tsx b/frontend/app/components/groups/panels/ContactPanel.tsx index 5816cd48..46a40426 100644 --- a/frontend/app/components/groups/panels/ContactPanel.tsx +++ b/frontend/app/components/groups/panels/ContactPanel.tsx @@ -10,12 +10,7 @@ import toast from 'react-hot-toast' import { useTranslations } from 'next-intl' import { contactSchema, type ContactFormData } from '@/app/lib/schemas/group-schemas' -import { - useGroupContacts, - useCreateContact, - useUpdateContact, - type GroupContact, -} from '@/app/lib/groups' +import { useCreateContact, useUpdateContact, type GroupContact } from '@/app/lib/groups' import { useGroupStore } from '@/app/stores/useGroupStore' import { SlidePanel, @@ -41,7 +36,6 @@ export function ContactPanel({ isOpen, onClose, contact, groupId }: ContactPanel const groupIdFromStore = currentGroup?.id const effectiveGroupId = groupIdFromStore ?? groupId - const { data: _contactsData } = useGroupContacts(effectiveGroupId) const createContactMutation = useCreateContact(effectiveGroupId) const updateContactMutation = useUpdateContact(effectiveGroupId, contact?.id) diff --git a/frontend/app/lib/groups/contact-access.ts b/frontend/app/lib/groups/contact-access.ts new file mode 100644 index 00000000..ea23b4d6 --- /dev/null +++ b/frontend/app/lib/groups/contact-access.ts @@ -0,0 +1,9 @@ +// app/lib/groups/contact-access.ts + +// Same roles as canViewGroupContacts in the backend (middlewares/roleCheck.ts): +// mantenimiento views groups to open its notifications, not their contacts +const GROUP_CONTACT_ROLES = ['admin', 'recepcionista', 'group-admin'] + +export function canViewGroupContacts(role: string | undefined | null): boolean { + return !!role && GROUP_CONTACT_ROLES.includes(role.toLowerCase()) +} diff --git a/frontend/app/lib/groups/index.ts b/frontend/app/lib/groups/index.ts index 6afe2009..7fde9447 100644 --- a/frontend/app/lib/groups/index.ts +++ b/frontend/app/lib/groups/index.ts @@ -2,4 +2,5 @@ export * from './types' export * from './queries' +export * from './contact-access' export { RoomType } from './types' diff --git a/frontend/app/lib/groups/queries.ts b/frontend/app/lib/groups/queries.ts index fe2c75fb..5d002d4b 100644 --- a/frontend/app/lib/groups/queries.ts +++ b/frontend/app/lib/groups/queries.ts @@ -438,12 +438,13 @@ export const useGroupPayments = (id?: number) => { }) } -export const useGroupContacts = (id?: number) => { +// `allowed`: false for the roles that may not see contacts (canViewGroupContacts) +export const useGroupContacts = (id?: number, allowed = true) => { return useQuery({ queryKey: id ? groupsKeys.contacts(id) : ['groups', 'contacts', 'none'], queryFn: () => groupsApi.getContacts(id as number), select: (res) => res.data, - enabled: typeof id === 'number' && !Number.isNaN(id), + enabled: allowed && typeof id === 'number' && !Number.isNaN(id), ...defaultQueryOptions, }) } From 6b60a4c6e98c04143de962c0d53df76ad8d973cf Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 00:56:33 +0200 Subject: [PATCH 56/71] docs(groups): describe how the automatic reminders really repeat - The 48-hour duplicate check: a payment due soon gets the 15- and 7-day reminders every two days, overdue payments and rooming lists repeat too. Why: the doc said a reminder is never created twice; the owner decided on 2026-10-12 to keep the current behaviour. --- docs/groups/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/groups/README.md b/docs/groups/README.md index d0c73a01..adf5799e 100644 --- a/docs/groups/README.md +++ b/docs/groups/README.md @@ -84,7 +84,10 @@ Six tables (`backend/db-mysql/aiven/10_group-tracking.sql`): - arrivals, 3 days before. - The configuration also lists reminders for unsigned contracts and pending balances, but **they are never generated**. - - A reminder is not created twice for the same payment or group and date. + - Duplicates are only checked within 48 hours: a payment due in 2 days gets + both the "15 days" and the "7 days" reminder every two days, and an + overdue payment is reminded every two days until it is paid; the rooming + list behaves the same. Left as is on 2026-10-12. - **No emails are sent**: the module includes an email service, but nothing calls it. All notifications are in-app. From 9f1344ef53d88d47ad4943c7384d3b568a9ca3ea Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 00:56:43 +0200 Subject: [PATCH 57/71] docs(scheduling): record that the solver daemon queue stays as it is - decisions.md: the 30-minute startup limit and the unbounded queue are kept, with the reasons and what to change if it is revisited. Why: decided by the owner on 2026-10-12; only admins generate, the demo is rate limited and the daemon starts in seconds. --- docs/scheduling/decisions.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/scheduling/decisions.md b/docs/scheduling/decisions.md index 83cc37dc..5abb1862 100644 --- a/docs/scheduling/decisions.md +++ b/docs/scheduling/decisions.md @@ -1352,3 +1352,21 @@ day"; `services/scheduling/AGENTS.md`: "outside the weekly rotation"). **Why.** The owner's priority is a solver that keeps working: counting `LI` as rest would change two hard rules and the rosters of every month with an `LI`, with no reported problem behind it. + +## 2026-10-12 — Solver daemon: startup limit and queue left as they are + +**Context.** `services/scheduling/solver-client.ts` takes the semaphore before +waiting for the daemon, waits up to 30 minutes for it to start on every +platform (the limit was set for Windows with Defender) and queues requests +without a limit. + +**Decision.** Keep it. + +**Why.** The risk is low: only `admin` generates, the demo account is limited +to 5 generations per IP and 30 in total per hour, the daemon starts in 1–3 s +when it is warm, and a request cancelled while it waits returns before +calling the solver. + +**If it is revisited.** A shorter startup limit outside Windows and a cap on +the queue that answers "solver busy". + From 8f5ad0f30ebdfaa57fb7968ab5119bd12e585165 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 00:56:59 +0200 Subject: [PATCH 58/71] docs(todo): close the low-priority entries reviewed in A5.2 - Remove: the Excel importer (deleted on 2026-10-04); the group page not refreshing and contacts visible to every role (fixed in opencode/fix-group-detail); the solver daemon queue and the repeated group reminders (kept as they are by decision, recorded in docs/scheduling/decisions.md and docs/groups/README.md). Why: reviewed against the code on 2026-10-12; the owner decided the two entries that needed a decision. --- docs/TODO.md | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 16159b10..3ad0db18 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -306,28 +306,6 @@ preview. dueño 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._ -- [ ] **Scheduling: el daemon del solver puede quedar bloqueado** — el semáforo - se retiene durante el arranque (hasta 30 min) con cola sin límite. _Según - la revisión `security` L3 del 2026-09-28 (fichero y línea en el informe); - no repasado por mí._ -- [ ] **Importador del Excel** — borra también las celdas bloqueadas, sin - transacción ni comprobar si el mes está publicado. _Según la revisión - `security` L3 del 2026-09-28 (fichero y línea en el informe); no repasado - por mí._ -- [ ] **Groups: recordatorios repetidos cada 48 h** — los de pago se buscan en - un rango de días y se deduplican por título en una ventana de 48 h: el - mismo pago se vuelve a avisar con «vence en 15 días» aunque falten 2, y - los vencidos se repiten sin fin. _Según la revisión `security` L3 del - 2026-09-28 (fichero y línea en el informe); no repasado por mí._ -- [ ] **Groups: la ficha no se refresca tras editar el grupo** — después de - «Grupo actualizado correctamente» el balance sigue mostrando el importe - anterior (y el panel de pagos calcula los porcentajes sobre él) hasta que - otra acción recarga los datos; el backend sí guarda el valor nuevo. - _Comprobado en producción el 2026-09-29._ -- [ ] **Groups: contactos (email y teléfono) visibles para todos los roles**, - también `mantenimiento` (ASVS L3: solo los datos mínimos). _Según la - revisión `security` L3 del 2026-09-28 (fichero y línea en el informe); no - repasado por mí._ - [ ] **Maintenance: identificador `DDMMAA-NNN` frágil** — se calcula leyendo el último del día (dos altas a la vez chocan y dan 500) con la hora del servidor; a partir del parte 1000 del día el id no cumple el formato y el From c237ce3229aaa8dea4b195bcf001cc16f2f41391 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 01:19:05 +0200 Subject: [PATCH 59/71] fix(maintenance): survive reports and photos created at the same time - Retry a new report with the next number of the day when another one took its id (up to 5 attempts, each in its own transaction). - Count a report's photos again under a lock on the report when saving one; an upload that loses the race is removed from Cloudinary and answers 400. - Document both rules, the 999 reports a day left as is, and that changes and their history now share a transaction. Two reports created at once read the same last id and the second one died with a 500 on the duplicate key. Photos were counted before the upload to Cloudinary, so uploads in parallel went past the limit of 5. --- .../maintenance/maintenance-controller.ts | 20 +- .../maintenance/maintenance-repository.ts | 73 +++++--- .../maintenance/concurrent-writes.test.ts | 177 ++++++++++++++++++ docs/maintenance/README.md | 12 +- 4 files changed, 255 insertions(+), 27 deletions(-) create mode 100644 backend/tests/maintenance/concurrent-writes.test.ts diff --git a/backend/controllers/maintenance/maintenance-controller.ts b/backend/controllers/maintenance/maintenance-controller.ts index ab871075..39c8c619 100644 --- a/backend/controllers/maintenance/maintenance-controller.ts +++ b/backend/controllers/maintenance/maintenance-controller.ts @@ -7,7 +7,10 @@ */ import type { Request, Response } from 'express' -import { MaintenanceRepository } from '../../repositories/maintenance/maintenance-repository.js' +import { + MaintenanceRepository, + MAX_REPORT_IMAGES, +} from '../../repositories/maintenance/maintenance-repository.js' import { CloudinaryService } from '../../services/blacklist/cloudinary-service.js' import { createReportSchema, @@ -27,7 +30,9 @@ import { fetchStoredFile, sendPrivateFile } from '../../services/uploads/private // CONTROLLER // ======================================== -// Exists, and is not deleted unless the user is an admin +const FULL_REPORT_MESSAGE = `El reporte ya tiene el máximo de ${MAX_REPORT_IMAGES} imágenes` + +// Exists,and is not deleted unless the user is an admin async function visibleReport(id: string, role: string | undefined): Promise { const deleted = await MaintenanceRepository.isDeleted(id) return deleted === false || (deleted === true && isAdminRole(role)) @@ -567,8 +572,8 @@ export class MaintenanceController { } // Verificar límite de imágenes - if (report.images.length >= 5) { - res.status(400).json({ error: 'El reporte ya tiene el máximo de 5 imágenes' }) + if (report.images.length >= MAX_REPORT_IMAGES) { + res.status(400).json({ error: FULL_REPORT_MESSAGE }) return } @@ -613,6 +618,13 @@ export class MaintenanceController { req.user.id ) + // Another upload filled the report while this one went to Cloudinary + if (!image) { + await CloudinaryService.deleteImage(cloudinaryResult.public_id) + res.status(400).json({ error: FULL_REPORT_MESSAGE }) + return + } + res.status(201).json({ message: 'Imagen subida correctamente', image, diff --git a/backend/repositories/maintenance/maintenance-repository.ts b/backend/repositories/maintenance/maintenance-repository.ts index be8036a4..af3b1a9b 100644 --- a/backend/repositories/maintenance/maintenance-repository.ts +++ b/backend/repositories/maintenance/maintenance-repository.ts @@ -92,6 +92,12 @@ interface CountRow extends RowDataPacket { // HELPERS: Parsear filas de BD a objetos // ======================================== +// Times create tries a new id when another report took it +const ID_ATTEMPTS = 5 + +// Photos a report can hold +export const MAX_REPORT_IMAGES = 5 + /** * Genera un ID único con formato DDMMYY-XXX * Ejemplo: 120625-001, 120625-002, etc. @@ -436,9 +442,6 @@ export class MaintenanceRepository { userId: string, username: string ): Promise { - // Generar ID con formato DDMMYY-XXX - const id = await generateReportId() - const query = ` INSERT INTO maintenance_reports ( id, @@ -459,7 +462,6 @@ export class MaintenanceRepository { ` const params = [ - id, data.title, data.description, data.location_type, @@ -475,19 +477,36 @@ export class MaintenanceRepository { ] // The report and its history rows commit or roll back together - await withTransaction(async () => { - await dbx().query(query, params) + const insert = () => + withTransaction(async () => { + // Generar ID con formato DDMMYY-XXX + const id = await generateReportId() + await dbx().query(query, [id, ...params]) - await this.addHistoryEntry(id, 'created', userId, username) + await this.addHistoryEntry(id, 'created', userId, username) - // Si se asignó, agregar entrada de asignación - if (data.assigned_to || data.external_company_name) { - await this.addHistoryEntry(id, 'assigned', userId, username, { - field_changed: 'assigned_to', - new_value: data.assigned_to || data.external_company_name || null, - }) + // Si se asignó, agregar entrada de asignación + if (data.assigned_to || data.external_company_name) { + await this.addHistoryEntry(id, 'assigned', userId, username, { + field_changed: 'assigned_to', + new_value: data.assigned_to || data.external_company_name || null, + }) + } + return id + }) + + // Two reports created at once read the same last id: the one that loses + // retries with the next number, in a new transaction that sees the winner + let id: string + for (let attempt = 1; ; attempt++) { + try { + id = await insert() + break + } catch (error) { + const duplicate = (error as { code?: string }).code === 'ER_DUP_ENTRY' + if (!duplicate || attempt >= ID_ATTEMPTS) throw error } - }) + } const created = await this.getById(id) if (!created) { @@ -818,13 +837,15 @@ export class MaintenanceRepository { } /** - * Agregar imagen a un reporte + * Agregar imagen a un reporte. Null when the report already holds + * MAX_REPORT_IMAGES: the count runs under a lock on the report, so uploads + * in parallel cannot go past the limit */ static async addImage( reportId: string, data: AddImageInput, userId: string - ): Promise { + ): Promise { const query = ` INSERT INTO maintenance_images ( report_id, file_name, file_path, file_size, mime_type, public_id, auto_delete_on_close, uploaded_by @@ -842,13 +863,23 @@ export class MaintenanceRepository { userId, ] - const [result] = await dbx().query(query, params) + return withTransaction(async () => { + await dbx().query('SELECT id FROM maintenance_reports WHERE id = ? FOR UPDATE', [reportId]) + const [count] = await dbx().query( + 'SELECT COUNT(*) as total FROM maintenance_images WHERE report_id = ?', + [reportId] + ) + if ((count[0]?.total ?? 0) >= MAX_REPORT_IMAGES) return null - const [rows] = await dbx().query('SELECT * FROM maintenance_images WHERE id = ?', [ - result.insertId, - ]) + const [result] = await dbx().query(query, params) - return parseImageRow(rows[0]) + const [rows] = await dbx().query( + 'SELECT * FROM maintenance_images WHERE id = ?', + [result.insertId] + ) + + return parseImageRow(rows[0]) + }) } /** diff --git a/backend/tests/maintenance/concurrent-writes.test.ts b/backend/tests/maintenance/concurrent-writes.test.ts new file mode 100644 index 00000000..234d3430 --- /dev/null +++ b/backend/tests/maintenance/concurrent-writes.test.ts @@ -0,0 +1,177 @@ +// tests/maintenance/concurrent-writes.test.ts +// Regression tests for two races. Two reports created at once read the same +// last id of the day and the second one died with a 500 on the duplicate key; +// it now retries with the next number. Photos were counted before the upload +// to Cloudinary, so uploads in parallel went past the limit of 5; the count is +// now repeated under a lock on the report when the photo is saved. + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Request, Response } from 'express' +import type { ReportWithDetails } from '../../models/maintenance/index.js' + +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(async () => [[]]), + execute: vi.fn(), + }, +})) + +vi.mock('../../services/blacklist/cloudinary-service.js', () => ({ + CloudinaryService: { + uploadImage: vi.fn(async () => ({ secure_url: 'https://x/y.jpg', public_id: 'maint/y' })), + deleteImage: vi.fn(async () => undefined), + }, +})) + +const { MaintenanceRepository, MAX_REPORT_IMAGES } = + await import('../../repositories/maintenance/maintenance-repository.js') +const { MaintenanceController } = + await import('../../controllers/maintenance/maintenance-controller.js') +const { CloudinaryService } = await import('../../services/blacklist/cloudinary-service.js') + +const duplicate = () => Object.assign(new Error('Duplicate entry'), { code: 'ER_DUP_ENTRY' }) + +const newReport = { + title: 'Grifo', + description: 'Gotea', + location_type: 'room', + location_description: 'Baño', +} as Parameters[0] + +const insertedIds = () => + connection.query.mock.calls + .filter(([q]) => /INSERT INTO maintenance_reports/.test(String(q))) + .map(([, params]) => (params as unknown[])[0]) + +describe('creating maintenance reports at once', () => { + beforeEach(() => { + vi.clearAllMocks() + connection.query.mockReset() + vi.restoreAllMocks() + vi.spyOn(MaintenanceRepository, 'getById').mockResolvedValue({ + id: 'x', + } as ReportWithDetails) + }) + + it('retries with the next number when another report took the id', async () => { + connection.query.mockImplementation(async (q: string) => { + const sql = String(q) + if (/SELECT id FROM maintenance_reports/.test(sql)) { + // The second read already sees the report that won the race + return insertedIds().length === 0 ? [[]] : [[{ id: `${'0'.repeat(6)}-001` }]] + } + if (/INSERT INTO maintenance_reports/.test(sql) && insertedIds().length === 1) { + throw duplicate() + } + return [{ affectedRows: 1 }] + }) + + await MaintenanceRepository.create(newReport, 'u1', 'admin') + + const ids = insertedIds().map((id) => String(id).split('-')[1]) + expect(ids).toEqual(['001', '002']) + // Each attempt is its own transaction, so the retry reads the new last id + expect(connection.beginTransaction).toHaveBeenCalledTimes(2) + expect(connection.rollback).toHaveBeenCalledOnce() + expect(connection.commit).toHaveBeenCalledOnce() + }) + + it('gives up after a few attempts', async () => { + connection.query.mockImplementation(async (q: string) => { + if (/INSERT INTO maintenance_reports/.test(String(q))) throw duplicate() + return [[]] + }) + + await expect(MaintenanceRepository.create(newReport, 'u1', 'admin')).rejects.toThrow( + 'Duplicate entry' + ) + expect(connection.beginTransaction).toHaveBeenCalledTimes(5) + }) + + it('does not retry other errors', async () => { + connection.query.mockImplementation(async (q: string) => { + if (/INSERT INTO maintenance_reports/.test(String(q))) throw new Error('boom') + return [[]] + }) + + await expect(MaintenanceRepository.create(newReport, 'u1', 'admin')).rejects.toThrow('boom') + expect(connection.beginTransaction).toHaveBeenCalledOnce() + }) +}) + +describe('the photo limit holds for uploads in parallel', () => { + beforeEach(() => { + vi.clearAllMocks() + connection.query.mockReset() + vi.restoreAllMocks() + }) + + const image = { + file_name: 'a.jpg', + file_path: 'https://x/a.jpg', + file_size: 10, + mime_type: 'image/jpeg', + public_id: 'maint/a', + } + + it('counts again under a lock on the report and saves nothing when full', async () => { + connection.query.mockImplementation(async (q: string) => { + if (/COUNT\(\*\)/.test(String(q))) return [[{ total: MAX_REPORT_IMAGES }]] + return [[{ id: '121026-001' }]] + }) + + expect(await MaintenanceRepository.addImage('121026-001', image, 'u1')).toBeNull() + + const sql = connection.query.mock.calls.map(([q]) => String(q)) + expect(sql[0]).toMatch(/FROM maintenance_reports WHERE id = \? FOR UPDATE/) + expect(sql.some((q) => /INSERT INTO maintenance_images/.test(q))).toBe(false) + expect(connection.commit).toHaveBeenCalledOnce() + }) + + it('saves the photo while there is room', async () => { + connection.query.mockImplementation(async (q: string) => { + const sql = String(q) + if (/COUNT\(\*\)/.test(sql)) return [[{ total: MAX_REPORT_IMAGES - 1 }]] + if (/INSERT INTO maintenance_images/.test(sql)) return [{ insertId: 9 }] + if (/FROM maintenance_images WHERE id/.test(sql)) { + return [[{ id: 9, report_id: '121026-001', uploaded_at: '2026-10-12', ...image }]] + } + return [[{ id: '121026-001' }]] + }) + + const saved = await MaintenanceRepository.addImage('121026-001', image, 'u1') + expect(saved?.id).toBe(9) + }) + + it('the upload that loses the race answers 400 and removes its file from Cloudinary', async () => { + vi.spyOn(MaintenanceRepository, 'getById').mockResolvedValue({ + id: '121026-001', + is_deleted: false, + images: [], + } as unknown as ReportWithDetails) + vi.spyOn(MaintenanceRepository, 'addImage').mockResolvedValue(null) + + const res = { status: vi.fn(), json: vi.fn() } + res.status.mockReturnValue(res) + const req = { + user: { id: 'u1' }, + params: { id: '121026-001' }, + file: { buffer: Buffer.from('x'), originalname: 'a.jpg', mimetype: 'image/jpeg', size: 10 }, + } + + await MaintenanceController.uploadImage(req as unknown as Request, res as unknown as Response) + + expect(res.status).toHaveBeenCalledWith(400) + expect(CloudinaryService.deleteImage).toHaveBeenCalledWith('maint/y') + }) +}) diff --git a/docs/maintenance/README.md b/docs/maintenance/README.md index f588e441..dbb4d508 100644 --- a/docs/maintenance/README.md +++ b/docs/maintenance/README.md @@ -78,16 +78,24 @@ reported ─► assigned ─► in_progress ─► completed ─► closed - Creating, editing, changing state, priority or assignment, adding notes, deleting and restoring are recorded in `maintenance_history`. **Uploading or deleting photos, not.** -- The change and its history entry do not go in a transaction. +- Each change and its history entries commit or roll back together, in one + transaction. **Others** - Photos are accepted according to the type declared by the browser, with a maximum of 5 MB, and Cloudinary processes them as an image. +- A report holds at most 5 photos. The count is repeated under a lock on the + report when the photo is saved: an upload that loses the race against another + is removed from Cloudinary and answers 400. - Each photo saves a "delete on close" (`auto_delete_on_close`) that **nobody uses**: when closing a report, its photos stay. - The number of the day of the identifier is calculated by looking for the last - report of that date, with the server's time. + report of that date, with the server's time. Two reports created at once read + the same number: the second one retries with the next (up to 5 attempts). +- **Up to 999 reports a day** (decided on 2026-10-12, left as is): the 1000th + gets an identifier the API rejects, and from then on every report of that day + fails. A hotel does not get near that volume. ## How information flows From 18d71fe443f481c0badc93d79e04672eb2ce779e Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 01:23:19 +0200 Subject: [PATCH 60/71] fix(security): log authorization decisions and keep SQL values out of the log - Log every 403 the API answers (user, role, method and path without the query string) from one middleware before the routes. - Log each time an admin reads, lists or deletes conversations or messages they are not in. - Hide err.sql, and passwords and tokens at the first three levels of each entry instead of only the second. - Document what the log keeps, and that failed logins keep the typed username (owner's decision). No 403 left a trace, nor did an admin reading conversations of others (ASVS 7.2). The SQL of a failed db.query carries its values interpolated, and `*.password` missed req.body.password and a top-level password. --- backend/config/logger.ts | 28 ++-- .../messages/conversation-controller.ts | 4 + .../messages/message-controller.ts | 3 + backend/index.ts | 3 + backend/middlewares/accessLog.ts | 38 +++++ backend/tests/security/access-log.test.ts | 133 ++++++++++++++++++ docs/general/README.md | 11 ++ 7 files changed, 209 insertions(+), 11 deletions(-) create mode 100644 backend/middlewares/accessLog.ts create mode 100644 backend/tests/security/access-log.test.ts diff --git a/backend/config/logger.ts b/backend/config/logger.ts index 6eea5f1d..52f0c33e 100644 --- a/backend/config/logger.ts +++ b/backend/config/logger.ts @@ -17,6 +17,22 @@ import pino from 'pino' const isDev = process.env.NODE_ENV !== 'production' const level = process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'info') +const SECRET_KEYS = ['password', 'currentPassword', 'newPassword', 'token', 'refreshToken'] + +// Redactar campos sensibles si aparecen accidentalmente. pino has no +// recursive wildcard: secrets are hidden at the first three levels. The SQL +// of a failed db.query carries its values interpolated (texts, plates, +// hashes); err.code and err.message stay +export const redact = { + paths: [ + ...SECRET_KEYS.flatMap((key) => [key, `*.${key}`, `*.*.${key}`]), + 'req.headers.authorization', + 'req.headers.cookie', + '*.sql', + ], + censor: '[REDACTED]', +} + export const logger = pino({ level, ...(isDev @@ -31,15 +47,5 @@ export const logger = pino({ }, } : {}), - // Redactar campos sensibles si aparecen accidentalmente - redact: { - paths: [ - '*.password', - '*.token', - '*.refreshToken', - 'req.headers.authorization', - 'req.headers.cookie', - ], - censor: '[REDACTED]', - }, + redact, }) diff --git a/backend/controllers/messages/conversation-controller.ts b/backend/controllers/messages/conversation-controller.ts index 656e9959..6c88cfec 100644 --- a/backend/controllers/messages/conversation-controller.ts +++ b/backend/controllers/messages/conversation-controller.ts @@ -9,6 +9,7 @@ import { } from '../../models/messages/index.js' import { ERROR_CODES, SUCCESS_CODES } from '../../config/error-codes.js' import { logger } from '../../config/logger.js' +import { logAdminOverride } from '../../middlewares/accessLog.js' import { createConversationSchema, addParticipantsSchema, @@ -86,6 +87,7 @@ export class ConversationController { code: ERROR_CODES.MESSAGES_NO_ACCESS, }) } + if (!isParticipant) logAdminOverride(req, 'read_conversation', { conversationId }) const conversation = await ConversationRepository.getByIdWithDetails(conversationId, userId) @@ -435,6 +437,7 @@ export class ConversationController { code: ERROR_CODES.MESSAGES_ADMIN_OR_SYSTEM_DELETE, }) } + if (!isConversationAdmin) logAdminOverride(req, 'delete_conversation', { conversationId }) // Eliminar conversacion completa (mensajes se eliminan por CASCADE) await ConversationRepository.deleteComplete(conversationId) @@ -760,6 +763,7 @@ export class ConversationController { } const { limit } = query.data const conversations = await ConversationRepository.getAll(limit) + logAdminOverride(req, 'list_all_conversations', { count: conversations.length }) return res.status(200).json({ success: true, diff --git a/backend/controllers/messages/message-controller.ts b/backend/controllers/messages/message-controller.ts index df51832d..8ee5bbeb 100644 --- a/backend/controllers/messages/message-controller.ts +++ b/backend/controllers/messages/message-controller.ts @@ -7,6 +7,7 @@ import { NotificationRepository } from '../../repositories/notifications/notific import { MESSAGE_CONSTANTS } from '../../models/messages/index.js' import { ERROR_CODES, SUCCESS_CODES } from '../../config/error-codes.js' import { logger } from '../../config/logger.js' +import { logAdminOverride } from '../../middlewares/accessLog.js' import { NotificationModule, NotificationRelatedTo, @@ -55,6 +56,7 @@ export class MessageController { code: ERROR_CODES.MESSAGES_NO_ACCESS, }) } + if (!isParticipant) logAdminOverride(req, 'read_messages', { conversationId }) // Parametros de paginacion const page = messagePageQuerySchema.safeParse(req.query) @@ -302,6 +304,7 @@ export class MessageController { code: ERROR_CODES.MESSAGES_ONLY_OWN_DELETE, }) } + if (!isSender) logAdminOverride(req, 'delete_message', { messageId }) const deleted = await MessageRepository.delete(messageId) if (!deleted) { diff --git a/backend/index.ts b/backend/index.ts index 0dafd4e9..ee8b27db 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -8,6 +8,7 @@ import { logServerInfo } from './config/startup-logger.js' import { logger } from './config/logger.js' import { apiLimiter } from './middlewares/rateLimiter.js' import { noStore } from './middlewares/noStore.js' +import { logAccessDenied } from './middlewares/accessLog.js' import { corsMiddleware } from './config/cors.js' import { CronService } from './services/cron/cron-service.js' @@ -66,6 +67,8 @@ app.use(cookieParser()) app.use('/api', apiLimiter) // Personal and financial data must not stay in browser or proxy caches app.use('/api', noStore) +// Every 403 leaves a trace in the log +app.use('/api', logAccessDenied) // ======================================== // RUTAS diff --git a/backend/middlewares/accessLog.ts b/backend/middlewares/accessLog.ts new file mode 100644 index 00000000..566c1e00 --- /dev/null +++ b/backend/middlewares/accessLog.ts @@ -0,0 +1,38 @@ +// middlewares/accessLog.ts +// Authorization decisions leave a trace in the log (ASVS 7.2): every 403 the +// API answers, and every time an admin reads or deletes messages of a +// conversation they are not in. + +import type { Request, Response, NextFunction } from 'express' +import { logger } from '../config/logger.js' + +// Goes before the routes: logs the 403 whichever middleware or handler sends it +export function logAccessDenied(req: Request, res: Response, next: NextFunction): void { + res.on('finish', () => { + if (res.statusCode !== 403) return + logger.warn( + { + event: 'access_denied', + userId: req.user?.id ?? null, + role: req.user?.role ?? null, + method: req.method, + // Without the query string, which may carry search terms + path: req.originalUrl.split('?')[0], + }, + '[SECURITY] access denied' + ) + }) + next() +} + +// An admin acting where only participants or owners can +export function logAdminOverride( + req: Request, + action: string, + target: Record +): void { + logger.info( + { event: 'admin_override', userId: req.user?.id ?? null, action, ...target }, + '[SECURITY] admin access to conversations of others' + ) +} diff --git a/backend/tests/security/access-log.test.ts b/backend/tests/security/access-log.test.ts new file mode 100644 index 00000000..8a5531b2 --- /dev/null +++ b/backend/tests/security/access-log.test.ts @@ -0,0 +1,133 @@ +// tests/security/access-log.test.ts +// Regression tests for the log (ASVS 7.1 and 7.2). The SQL of a failed +// db.query went to the log with its values interpolated (texts, plates, +// hashes), and `*.password` only hid the field one level down. No 403 left a +// trace, nor did an admin reading or deleting messages of a conversation they +// are not in. + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { EventEmitter } from 'node:events' +import { Writable } from 'node:stream' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import pino from 'pino' +import type { Request, Response } from 'express' + +vi.mock('../../config/logger.js', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() } } +}) + +const { logger, redact } = await import('../../config/logger.js') +const { logAccessDenied } = await import('../../middlewares/accessLog.js') + +const backend = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const read = (...p: string[]) => readFileSync(join(backend, ...p), 'utf8') + +// One line written by a pino logger with the app's redaction +function logged(obj: Record): Record { + let line = '' + const out = new Writable({ + write(chunk, _enc, done) { + line += chunk.toString() + done() + }, + }) + pino({ redact }, out).error(obj, 'test') + return JSON.parse(line) +} + +describe('log redaction', () => { + it('hides the SQL of a mysql2 error, which carries the values', () => { + const err = Object.assign(new Error("Duplicate entry '1234-ABC'"), { + code: 'ER_DUP_ENTRY', + sql: "INSERT INTO t VALUES ('1234-ABC', 'nota privada')", + }) + const line = JSON.stringify(logged({ err })) + expect(line).not.toContain('nota privada') + expect(line).toContain('ER_DUP_ENTRY') + }) + + it('hides passwords and tokens at any of the first three levels', () => { + const line = JSON.stringify( + logged({ + password: 'p0', + body: { newPassword: 'p1', token: 't1' }, + req: { body: { password: 'p2', currentPassword: 'p3', refreshToken: 't2' } }, + }) + ) + for (const secret of ['p0', 'p1', 'p2', 'p3', 't1', 't2']) { + expect(line, secret).not.toContain(`"${secret}"`) + } + }) +}) + +describe('403 responses are logged', () => { + beforeEach(() => vi.clearAllMocks()) + + function respond(status: number, user?: { id: string; role: string }) { + const res = Object.assign(new EventEmitter(), { statusCode: status }) + const req = { method: 'DELETE', originalUrl: '/api/cashier/daily/3?x=secret', user } + const next = vi.fn() + logAccessDenied(req as unknown as Request, res as unknown as Response, next) + expect(next).toHaveBeenCalledOnce() + res.emit('finish') + } + + it('logs who was refused what, without the query string', () => { + respond(403, { id: 'u1', role: 'recepcionista' }) + expect(logger.warn).toHaveBeenCalledWith( + { + event: 'access_denied', + userId: 'u1', + role: 'recepcionista', + method: 'DELETE', + path: '/api/cashier/daily/3', + }, + '[SECURITY] access denied' + ) + }) + + it('ignores every other status', () => { + for (const status of [200, 201, 400, 401, 404, 500]) respond(status) + expect(logger.warn).not.toHaveBeenCalled() + }) + + it('runs for the whole API, before the routes', () => { + const index = read('index.ts') + const at = index.indexOf("app.use('/api', logAccessDenied)") + expect(at).toBeGreaterThan(-1) + expect(at).toBeLessThan(index.indexOf("app.use('/api/auth', authRoutes)")) + }) +}) + +describe('admins acting on conversations they are not in are logged', () => { + const conversations = read('controllers', 'messages', 'conversation-controller.ts') + const messages = read('controllers', 'messages', 'message-controller.ts') + + // Body of `static async name(` up to the next static method + function method(source: string, name: string): string { + const start = source.indexOf(`static async ${name}(`) + expect(start, name).toBeGreaterThan(-1) + const next = source.indexOf('static async ', start + 1) + return source.slice(start, next === -1 ? undefined : next) + } + + it.each([ + [conversations, 'getConversation', 'isParticipant', 'read_conversation'], + [conversations, 'deleteConversation', 'isConversationAdmin', 'delete_conversation'], + [messages, 'getMessages', 'isParticipant', 'read_messages'], + [messages, 'deleteMessage', 'isSender', 'delete_message'], + ])('%#: %s logs the override', (source, name, own, action) => { + expect(method(source, name)).toMatch( + new RegExp(`if \\(!${own}\\) logAdminOverride\\(req, '${action}', \\{`) + ) + }) + + it('the moderation list of every conversation is logged', () => { + expect(method(conversations, 'getAllConversations')).toContain( + "logAdminOverride(req, 'list_all_conversations'" + ) + }) +}) diff --git a/docs/general/README.md b/docs/general/README.md index 1b2c9bc6..73bf4ead 100644 --- a/docs/general/README.md +++ b/docs/general/README.md @@ -93,6 +93,17 @@ if it does not match. server to server). - In production, unhandled errors return a generic message. +**Log** + +- Every `403` is logged (`access_denied`: user, role, method and path, without + the query string), and so is every time an `admin` reads, lists or deletes + conversations or messages they are not in (`admin_override`). +- Passwords and tokens are hidden at the first three levels of each entry, and + so is the SQL of a failed query (`err.sql`), which carries its values. +- Failed logins keep the username that was typed (decided on 2026-10-12, left + as is): it is what reveals a brute-force attack, although someone may type + their password in that field. + **Database** - Every schema change is a new, idempotent script in `backend/db-mysql/scripts/` From 39762abed12e96dfc152f78386e77cb6512b9183 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 01:24:02 +0200 Subject: [PATCH 61/71] docs(todo): close the low-priority entries reviewed in A5.3 - Delete the two Maintenance races, sensitive data in the logs and the authorization log, fixed in fix-maintenance-history and fix-logging. - Delete the unused parking procedures and the Logbook history entries, left as they are, and record both decisions in the module docs. - Add the Maintenance identifier to the timezone entry: it takes the date of Node. The A5.3 review checked each entry against the code and the owner decided the ones that were not bugs. --- docs/TODO.md | 30 +++--------------------------- docs/logbook/README.md | 4 ++++ docs/parking/README.md | 7 +++++++ 3 files changed, 14 insertions(+), 27 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 3ad0db18..39dcc244 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -154,9 +154,10 @@ preview. con Node en UTC contra la MySQL local, `NOW()` sale 2 h movido (p. ej. `pnpm dev:aiven` desde un equipo en Madrid). - «Hoy» en UTC está mal entre las 00:00 y las 02:00 de Madrid: - `toISOString()` en 26 sitios del frontend y 6 del backend, y + `toISOString()` en 26 sitios del frontend y 6 del backend, `CURDATE()`/`NOW()` en Aiven (19 consultas con `CURDATE()`: filtros de - parking, código `PK-AAAAMMDD` del trigger, `CURRENT_TIMESTAMP`). + parking, código `PK-AAAAMMDD` del trigger, `CURRENT_TIMESTAMP`) y el + identificador `DDMMAA-NNN` de Maintenance (fecha de Node). - 15 sitios del frontend cortan por la `T` fechas del servidor (grupos, lista negra, parking, horarios, checklist, caja, backoffice). @@ -306,33 +307,8 @@ preview. dueño 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: identificador `DDMMAA-NNN` frágil** — se calcula leyendo el - último del día (dos altas a la vez chocan y dan 500) con la hora del - servidor; a partir del parte 1000 del día el id no cumple el formato y el - parte queda inaccesible. _Comprobado por mí el 2026-09-28._ (el límite de - 1000, según el revisor) -- [ ] **Maintenance: el límite de 5 fotos se salta subiendo en paralelo** — - se cuenta antes de subir, sin bloqueo. _Según la revisión `security` L3 - del 2026-09-28 (fichero y línea en el informe); no repasado por mí._ - [ ] **Maintenance: comprobar si Cloudinary quita los metadatos EXIF/GPS** de las fotos. _Sin comprobar._ -- [ ] **Procedimientos de BD sin uso** — de las 2 funciones y 5 procedimientos - de parking, el código solo llama a `check_availability`; el resto - (incluido `generate_availability`, que amplía el calendario) no los usa - nadie. _Comprobado por mí el 2026-09-28._ -- [ ] **Datos sensibles en los logs** — usernames de logins fallidos, `redact` - de un solo nivel y, probablemente, `err.sql` de mysql2 con los valores - (textos, matrículas, hashes). _Según la revisión `security` L3 del - 2026-09-28 (fichero y línea en el informe); no repasado por mí._ No - verificado en ejecución. -- [ ] **Logbook: el historial registra cada comentario dos veces y cada - lectura sin límite.** (Editar o volver a borrar un comentario borrado ya - no es posible desde el 2026-10-02.) _Según la revisión `security` L3 del - 2026-09-28; no repasado por mí._ -- [ ] **Registrar decisiones de autorización** — ni los 403 ni los accesos de un - `admin` a conversaciones ajenas quedan en el log (ASVS L3). _Según la - revisión `security` L3 del 2026-09-28 (fichero y línea en el informe); no - repasado por mí._ - [ ] **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 €). - [ ] **Parking: el mapa de estado no se refresca solo** — los cambios de otro diff --git a/docs/logbook/README.md b/docs/logbook/README.md index 53f06f09..9339245d 100644 --- a/docs/logbook/README.md +++ b/docs/logbook/README.md @@ -58,6 +58,10 @@ Departments are the application's general ones (table `departments`). entries and comments, and also reading, unmarking, resolving and reopening. Each change and its history rows are written in one transaction: either both are saved or neither is. +- **A comment leaves two history rows** (one of the comment, one of the entry), + and **each read or unmark adds one**, with no limit; all of them show in the + entry's history in Reports and in the recent activity. Left as is (decided + on 2026-10-12). - **Nothing is truly deleted**: entries and comments are only marked with `deleted_at`. There is no way to restore them from the interface. - **Only the author edits or deletes** their entry or comment. diff --git a/docs/parking/README.md b/docs/parking/README.md index 51c505ed..5a5b8674 100644 --- a/docs/parking/README.md +++ b/docs/parking/README.md @@ -58,6 +58,13 @@ Part of the logic lives **in the database**: the triggers in `05_parking_functions_triggers.sql` generate the booking code and keep the calendar up to date. +The database also has 2 functions (`check_availability`, +`get_total_availability`) and 5 procedures (`06_parking_procedures.sql`) that +**nothing calls**, neither the code nor the triggers; only the local seeds run +`generate_availability`, which the backend replaced with `extendAvailability`. +`daily_parking_maintenance` would mark expired bookings as no-shows if someone +ran it by hand. They stay in the database (decided on 2026-10-12). + ## What rules it follows **Booking states** From 3c187411a83082f381ab7b2895cc767429352c25 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 01:36:20 +0200 Subject: [PATCH 62/71] perf(home): load the important logbook entries with one request per period - Add logbooksApi.getLogbooksInRange, which reads /api/logbooks/all for a date range, 500 entries per page. - Use it in the home panel instead of one /day request per date, sorting the dates first (the week comes newest first). With the month period the panel sent up to 31 requests in parallel; /all filters by the same entry day, so the result is the same with one. --- backend/tests/frontend/home-logbooks.test.ts | 39 ++++++++++++++++++++ frontend/app/dashboard/page.tsx | 10 ++--- frontend/app/lib/logbooks/queries.ts | 16 ++++++++ 3 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 backend/tests/frontend/home-logbooks.test.ts diff --git a/backend/tests/frontend/home-logbooks.test.ts b/backend/tests/frontend/home-logbooks.test.ts new file mode 100644 index 00000000..d4fedec4 --- /dev/null +++ b/backend/tests/frontend/home-logbooks.test.ts @@ -0,0 +1,39 @@ +// tests/frontend/home-logbooks.test.ts +// The home panel asked for the important logbook entries one day at a time: +// up to 31 requests to /api/logbooks/day/ for the month. It now asks /all for +// the whole range, page by page. Reads the frontend sources; no browser. + +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const frontend = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'frontend') +const read = (...p: string[]) => readFileSync(join(frontend, ...p), 'utf8') +const page = read('app', 'dashboard', 'page.tsx') +const queries = read('app', 'lib', 'logbooks', 'queries.ts') + +describe('home panel logbook entries', () => { + it('asks for the whole period at once, not one request per day', () => { + expect(page).not.toContain('getLogbooksByDay') + expect(page).not.toMatch(/dates\.map\(/) + expect(page).toMatch( + /logbooksApi\s*\.getLogbooksInRange\(range\[0\], range\[range\.length - 1\]\)/ + ) + }) + + it('sorts the dates first: the week comes newest first', () => { + expect(page).toContain('const range = [...dates].sort()') + }) + + it('reads every page of /all for that range', () => { + const start = queries.indexOf('getLogbooksInRange: async (') + expect(start).toBeGreaterThan(-1) + const body = queries.slice(start, queries.indexOf('\n },', start)) + expect(body).toContain('/api/logbooks/all?date_from=${dateFrom}&date_to=${dateTo}') + expect(body).toContain('&limit=${RANGE_PAGE}&offset=${offset}') + expect(body).toMatch(/if \(page\.length < RANGE_PAGE\) return entries/) + // The backend accepts up to 500 per page + expect(queries).toContain('const RANGE_PAGE = 500') + }) +}) diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 32e1339c..8e8b5c26 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -109,12 +109,12 @@ export default function DashboardHome() { } } - const responses = await Promise.all( - dates.map((date) => logbooksApi.getLogbooksByDay(date).catch(() => [])) - ) - + // One request for the whole period (one per page), not one per day + const range = [...dates].sort() // eslint-disable-next-line @typescript-eslint/no-explicit-any - const allLogbooks: any[] = responses.flat().filter(Boolean) + const allLogbooks: any[] = await logbooksApi + .getLogbooksInRange(range[0], range[range.length - 1]) + .catch(() => []) // eslint-disable-next-line @typescript-eslint/no-explicit-any const logbooksInPeriod = allLogbooks.filter((entry: any) => { diff --git a/frontend/app/lib/logbooks/queries.ts b/frontend/app/lib/logbooks/queries.ts index ac00c917..bd640023 100644 --- a/frontend/app/lib/logbooks/queries.ts +++ b/frontend/app/lib/logbooks/queries.ts @@ -3,10 +3,14 @@ import apiClient from '@/app/lib/apiClient' import { API_BASE_URL } from '@/app/lib/env' +import type { LogbookEntry } from './types' // Types available in ./types if needed: LogbookEntry, LogbookComment const API_URL = API_BASE_URL +// Largest page /api/logbooks/all accepts +const RANGE_PAGE = 500 + // =============== LOGBOOKS API =============== export const logbooksApi = { @@ -63,6 +67,18 @@ export const logbooksApi = { } }, + // Entries whose day falls between two dates (YYYY-MM-DD), every page + getLogbooksInRange: async (dateFrom: string, dateTo: string): Promise => { + const entries: LogbookEntry[] = [] + for (let offset = 0; ; offset += RANGE_PAGE) { + const page = await apiClient.get( + `${API_URL}/api/logbooks/all?date_from=${dateFrom}&date_to=${dateTo}&limit=${RANGE_PAGE}&offset=${offset}` + ) + entries.push(...page) + if (page.length < RANGE_PAGE) return entries + } + }, + // Eliminar un logbook (soft-delete) deleteLogbook: async (id: number) => { return apiClient.delete(`${API_URL}/api/logbooks/${id}`) From e7436cd8312c8211599f57448adea7bc8e0abd1e Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 08:45:29 +0200 Subject: [PATCH 63/71] docs(todo): close the low-priority entries reviewed in A5.4 - Delete the home panel entry, fixed in fix-home-logbooks. - Delete the parking status map, the Logbook trash and filters, and the messaging features without a screen, left as they are, and record the decisions in the parking, Logbook and messaging docs. - Rewrite the frontend tests entry: the tests exist in backend/tests; what is missing is a runner of its own. The A5.4 review checked each entry against the code and the owner decided the ones that were not bugs. --- docs/TODO.md | 19 +++---------------- docs/general/messages/README.md | 3 ++- docs/logbook/README.md | 5 +++++ docs/parking/README.md | 1 + 4 files changed, 11 insertions(+), 17 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 39dcc244..1553381b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -311,21 +311,6 @@ preview. 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 €). -- [ ] **Parking: el mapa de estado no se refresca solo** — los cambios de otro - usuario no aparecen hasta recargar. -- [ ] **Logbook: papelera y filtros sin interfaz** — `/trashed`, `/author`, - `/department` y `/priority` existen en el backend y en `queries.ts`, pero - la pantalla del módulo no los usa (Informes usa `/all` con filtros). - Tampoco hay forma de restaurar lo borrado. -- [ ] **Panel de inicio: una petición por día** — con el periodo _mes_ lanza - hasta 31 peticiones a `/api/logbooks/day/` - (`frontend/app/dashboard/page.tsx`); `/all?date_from=&date_to=` lo haría - en una. -- [ ] **Mensajería: exponer en la interfaz lo que el backend ya ofrece** — - renombrar grupo, añadir y quitar participantes, buscar en mensajes, - contador global de no leídos y vista de todas las conversaciones para - `admin`. Las funciones existen en `frontend/app/lib/messaging/queries.ts` - pero nadie las llama. _Comprobado el 2026-09-28 con búsqueda de usos._ - [ ] **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._ @@ -334,7 +319,9 @@ preview. - [ ] **Presencias a mano** — la pestaña de presencias de `scheduling` pide pegar el texto en un `textarea`; podría leerse de `scheduling_assignments`. -- [ ] **Tests en el frontend** — hoy no hay ninguno. +- [ ] **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) diff --git a/docs/general/messages/README.md b/docs/general/messages/README.md index 323d2cf8..33d9fa33 100644 --- a/docs/general/messages/README.md +++ b/docs/general/messages/README.md @@ -41,7 +41,8 @@ conversation, and list them all. **The backend offers, but the interface does not use yet**: renaming a group, adding or removing participants after creation, searching in message text, the -global unread counter and the view of all conversations for `admin`. +global unread counter and the view of all conversations for `admin`. They are +not planned (decided on 2026-10-12). ## What data it handles diff --git a/docs/logbook/README.md b/docs/logbook/README.md index 9339245d..5b05f959 100644 --- a/docs/logbook/README.md +++ b/docs/logbook/README.md @@ -37,6 +37,11 @@ All roles **except `mantenimiento`**, which does not have access to the module. - **Edit and delete** own entries. Deleting does not remove: the entry goes to the trash. +**The backend offers, but the module screen does not use**: the trash +(`/trashed`) and the lists by author, department and priority (Reports uses +`/all` with its own filters, deleted ones included). Nothing restores a deleted +entry, not even the backend. Not planned (decided on 2026-10-12). + ## What data it handles Four tables (`backend/db-mysql/aiven/03_logbook_tables.sql`): diff --git a/docs/parking/README.md b/docs/parking/README.md index 5a5b8674..4caa6986 100644 --- a/docs/parking/README.md +++ b/docs/parking/README.md @@ -133,6 +133,7 @@ BookingsListClient / BookingDetailClient ───────────── 2. The status map requests spaces, bookings, expired ones and statistics with React Query. **It does not refresh on its own**: each action requests the data again, but changes made by another person do not appear until reloading. + Left as is (decided on 2026-10-12). 3. Creating, moving, closing or cancelling a booking writes to `parking_bookings` within a transaction; the triggers update the calendar in the same operation. From bbdbd3db075c9d19416994e9ed8dc167f112c8c5 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 09:08:24 +0200 Subject: [PATCH 64/71] docs(todo): add the leaked user password to the rotation and keep the Aiven CA - TODO: the credential rotation now covers the plaintext password of an application user found in the published history (test-login.js, 8595ec0). - database README: record that the Aiven CA certificate stays in the repository. Why: the privacy review of the full history (A7.1-A8.2) found that password, which the 2026-10-05 history rewrite missed; the owner chose to rotate it instead of rewriting history again, and to keep the public CA certificate the TLS connection needs. --- docs/TODO.md | 6 +++++- docs/general/database/README.md | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 1553381b..99244ecd 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -41,7 +41,11 @@ preview. de Cloudinary, y revocar las claves de servicios que ningún código usa. El historial publicado no las contiene, pero siguen en copias privadas (ADR-007). El procedimiento está fuera del repositorio. - `SECRET_JWT_KEY` ya se cambió el 2026-09-29. + `SECRET_JWT_KEY` ya se cambió el 2026-09-29. Además, el historial + publicado sí trae la contraseña en claro de un usuario de la aplicación + (`test-login.js`, 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 diff --git a/docs/general/database/README.md b/docs/general/database/README.md index e566e8c5..03ade29e 100644 --- a/docs/general/database/README.md +++ b/docs/general/database/README.md @@ -70,7 +70,10 @@ Tables by module: `NOW()` or `CURDATE()` runs 1–2 hours behind Madrid; the backend calculates "today" in Madrid time on its own. - **Encrypted connection**: TLS with the Aiven certificate in - `backend/config/certs/`. + `backend/config/certs/`. It is the project's public CA certificate (no + private key) and names the Aiven project by its id; it stays in the + repository because the backend and `apply-migration.sh` need it (decided on + 2026-10-12). **Migration status**: the 15 recorded in `INDEX.md` are applied on Aiven (verified on 2026-09-28). From 15081946ab1db7e9ce9114dfbea27f8ca97c7438 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 09:18:17 +0200 Subject: [PATCH 65/71] fix(parking): declare the check-in status and occupied-spot error codes - Add PARKING_CHECKIN_INVALID_STATUS and PARKING_SPOT_OCCUPIED to ERROR_CODES and send them from the check-in controller instead of bare strings. - Give both codes a text in es and en and list them as translated. - The error-code guard test now also collects codes written as bare strings (`code: 'X'`), so a code that skips ERROR_CODES cannot go untranslated. Why: the two codes were sent as literals, which the guard test did not see, so they had no text; with the page in English, a check-in on an occupied spot showed the backend's Spanish message. --- backend/config/error-codes.ts | 2 ++ backend/controllers/parking/bookings.controller.ts | 4 ++-- backend/tests/frontend/error-code-message.test.ts | 4 +++- frontend/app/lib/helpers/errorCodeMessage.ts | 2 ++ frontend/messages/en/errors.json | 2 ++ frontend/messages/es/errors.json | 2 ++ 6 files changed, 13 insertions(+), 3 deletions(-) diff --git a/backend/config/error-codes.ts b/backend/config/error-codes.ts index 39d90afe..88b5a44c 100644 --- a/backend/config/error-codes.ts +++ b/backend/config/error-codes.ts @@ -417,6 +417,8 @@ export const ERROR_CODES = { PARKING_FETCH_BOOKING_ERROR: 'PARKING_FETCH_BOOKING_ERROR', PARKING_CREATE_BOOKING_ERROR: 'PARKING_CREATE_BOOKING_ERROR', PARKING_CHECKIN_ERROR: 'PARKING_CHECKIN_ERROR', + PARKING_CHECKIN_INVALID_STATUS: 'PARKING_CHECKIN_INVALID_STATUS', + PARKING_SPOT_OCCUPIED: 'PARKING_SPOT_OCCUPIED', PARKING_CHECKOUT_ERROR: 'PARKING_CHECKOUT_ERROR', PARKING_CANCEL_ERROR: 'PARKING_CANCEL_ERROR', PARKING_NOSHOW_ERROR: 'PARKING_NOSHOW_ERROR', diff --git a/backend/controllers/parking/bookings.controller.ts b/backend/controllers/parking/bookings.controller.ts index 57bbd82e..1392a337 100644 --- a/backend/controllers/parking/bookings.controller.ts +++ b/backend/controllers/parking/bookings.controller.ts @@ -347,7 +347,7 @@ class ParkingBookingsController { res.status(400).json({ success: false, error: errorMessage, - code: 'PARKING_CHECKIN_INVALID_STATUS', + code: ERROR_CODES.PARKING_CHECKIN_INVALID_STATUS, }) return } @@ -356,7 +356,7 @@ class ParkingBookingsController { res.status(409).json({ success: false, error: errorMessage, - code: 'PARKING_SPOT_OCCUPIED', + code: ERROR_CODES.PARKING_SPOT_OCCUPIED, }) return } diff --git a/backend/tests/frontend/error-code-message.test.ts b/backend/tests/frontend/error-code-message.test.ts index b35fa180..e97c2224 100644 --- a/backend/tests/frontend/error-code-message.test.ts +++ b/backend/tests/frontend/error-code-message.test.ts @@ -9,7 +9,8 @@ import { errorCodeMessage, } from '../../../frontend/app/lib/helpers/errorCodeMessage.js' -// Every ERROR_CODES.X the backend source mentions (tests and the catalogue aside) +// Every ERROR_CODES.X the backend source mentions (tests and the catalogue aside), +// plus any code written as a bare string (`code: 'X'`) function backendErrorCodes(): string[] { const root = resolve(__dirname, '../..') const skip = new Set(['node_modules', 'tests', 'scheduling-solver', 'db-mysql']) @@ -21,6 +22,7 @@ function backendErrorCodes(): string[] { } else if (entry.name.endsWith('.ts') && entry.name !== 'error-codes.ts') { const source = readFileSync(join(dir, entry.name), 'utf-8') for (const m of source.matchAll(/ERROR_CODES\.([A-Z0-9_]+)/g)) codes.add(m[1]) + for (const m of source.matchAll(/\bcode:\s*['"`]([A-Z][A-Z0-9_]+)['"`]/g)) codes.add(m[1]) } } } diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index 09360dee..e8bd9c5a 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -315,6 +315,7 @@ const TRANSLATED = new Set([ 'PARKING_CANNOT_DELETE_STATUS', 'PARKING_CANNOT_UPDATE_STATUS', 'PARKING_CHECKIN_ERROR', + 'PARKING_CHECKIN_INVALID_STATUS', 'PARKING_CHECKOUT_ERROR', 'PARKING_COMPARE_PERIODS_ERROR', 'PARKING_COMPARISON_DATES_REQUIRED', @@ -344,6 +345,7 @@ const TRANSLATED = new Set([ 'PARKING_INVALID_START_DATE', 'PARKING_NOSHOW_ERROR', 'PARKING_NO_FIELDS_TO_UPDATE', + 'PARKING_SPOT_OCCUPIED', 'PARKING_START_BEFORE_END', 'PARKING_UPDATE_BOOKING_ERROR', 'STORAGE_NOT_CONFIGURED', diff --git a/frontend/messages/en/errors.json b/frontend/messages/en/errors.json index 409b8e3a..b573b173 100644 --- a/frontend/messages/en/errors.json +++ b/frontend/messages/en/errors.json @@ -431,6 +431,8 @@ "PARKING_FETCH_BOOKING_ERROR": "Error fetching booking", "PARKING_CREATE_BOOKING_ERROR": "Error creating booking", "PARKING_CHECKIN_ERROR": "Error performing check-in", + "PARKING_CHECKIN_INVALID_STATUS": "Only a booking in the Reserved status can be checked in", + "PARKING_SPOT_OCCUPIED": "The spot is already taken by another booking", "PARKING_CHECKOUT_ERROR": "Error performing check-out", "PARKING_CANCEL_ERROR": "Error cancelling booking", "PARKING_NOSHOW_ERROR": "Error marking as no-show", diff --git a/frontend/messages/es/errors.json b/frontend/messages/es/errors.json index c36f2733..6d5dd772 100644 --- a/frontend/messages/es/errors.json +++ b/frontend/messages/es/errors.json @@ -431,6 +431,8 @@ "PARKING_FETCH_BOOKING_ERROR": "Error al obtener reserva", "PARKING_CREATE_BOOKING_ERROR": "Error al crear reserva", "PARKING_CHECKIN_ERROR": "Error al realizar check-in", + "PARKING_CHECKIN_INVALID_STATUS": "Solo se puede hacer check-in de una reserva en estado Reservado", + "PARKING_SPOT_OCCUPIED": "La plaza ya está ocupada por otra reserva", "PARKING_CHECKOUT_ERROR": "Error al realizar check-out", "PARKING_CANCEL_ERROR": "Error al cancelar reserva", "PARKING_NOSHOW_ERROR": "Error al marcar como no-show", From b97086e54f6ab3c15765b4348b3fc0ecc6496823 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 09:19:04 +0200 Subject: [PATCH 66/71] docs(todo): measure the backend errors that still arrive without a code - Rewrite the untranslated-errors entry: the guard now also covers bare `code: 'X'` strings, and about 380 error responses carry a fixed Spanish text with no code, counted per module. Why: the entry only named maintenance, while the A9 review found the same gap in scheduling, parking, middlewares, cashier, checklist and auth. --- docs/TODO.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 6a7ece8d..b2c2ed4f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -302,13 +302,19 @@ preview. pestaña disponible. _Visto 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`). 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; - maintenance responde con textos fijos en español sin `code`; el 409 del - reinicio de la demo y el subtítulo del login (`login/page.tsx`) también - van fijos en español. _Comprobado el 2026-10-11._ + 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 From ae0308ce97be6125e151d40d5f02d21a6d253325 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 09:33:49 +0200 Subject: [PATCH 67/71] docs(db): mark 20261012_unique_invoice_supplier_number as applied on Aiven - INDEX.md: the migration row now reads applied on local and Aiven. Why: it was applied on Aiven on 2026-10-12 after a dry run and read-only checks of its preconditions, and verified afterwards. --- backend/db-mysql/INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/db-mysql/INDEX.md b/backend/db-mysql/INDEX.md index d788d602..a0751705 100644 --- a/backend/db-mysql/INDEX.md +++ b/backend/db-mysql/INDEX.md @@ -108,7 +108,7 @@ db-mysql/ | 2026-10-05 | `20261005_complete_fresh_install.sql` | Completa una instalación desde cero: el baseline congelado no crea `cashier_history` (nombre de clave duplicado), `scheduling_assignments` (referencia a una tabla creada después) ni `demo_activity_log`, y deja `roles.name` en `varchar(50)`. Definiciones copiadas de Aiven. En Aiven no cambia nada; en local creó `demo_activity_log`, que faltaba. `pnpm setup:local` además corrige esos dos fallos del baseline en memoria. | ✅ local · ✅ Aiven (sin cambios) | | 2026-10-05 | `20261005_fix_bo_monthly_summary_view.sql` | Recrea `v_bo_monthly_summary` con `period` en el `GROUP BY`. Con `ONLY_FULL_GROUP_BY` (activo en Aiven y local) toda consulta a la vista fallaba con el error 1055 y `GET /api/backoffice/stats/monthly` respondía siempre 500. Mismas columnas y mismos grupos; comprobada contra un cálculo directo sobre `bo_invoices`. | ✅ local · ✅ Aiven | | 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-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-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 | ### Verificación (99) From 8f3de7bfb7e5f66024e990bfc50f398634e62748 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 09:33:50 +0200 Subject: [PATCH 68/71] docs(db): mark 20261011_keep_group_history_on_delete as applied on Aiven - INDEX.md: the migration row now reads applied on local and Aiven. Why: it was applied on Aiven on 2026-10-12 after a dry run and read-only checks of its preconditions, and verified afterwards. --- backend/db-mysql/INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/db-mysql/INDEX.md b/backend/db-mysql/INDEX.md index 5fb4e757..f4eb998b 100644 --- a/backend/db-mysql/INDEX.md +++ b/backend/db-mysql/INDEX.md @@ -108,7 +108,7 @@ db-mysql/ | 2026-10-05 | `20261005_complete_fresh_install.sql` | Completa una instalación desde cero: el baseline congelado no crea `cashier_history` (nombre de clave duplicado), `scheduling_assignments` (referencia a una tabla creada después) ni `demo_activity_log`, y deja `roles.name` en `varchar(50)`. Definiciones copiadas de Aiven. En Aiven no cambia nada; en local creó `demo_activity_log`, que faltaba. `pnpm setup:local` además corrige esos dos fallos del baseline en memoria. | ✅ local · ✅ Aiven (sin cambios) | | 2026-10-05 | `20261005_fix_bo_monthly_summary_view.sql` | Recrea `v_bo_monthly_summary` con `period` en el `GROUP BY`. Con `ONLY_FULL_GROUP_BY` (activo en Aiven y local) toda consulta a la vista fallaba con el error 1055 y `GET /api/backoffice/stats/monthly` respondía siempre 500. Mismas columnas y mismos grupos; comprobada contra un cálculo directo sobre `bo_invoices`. | ✅ local · ✅ Aiven | | 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-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 | ### Verificación (99) From 880bc2176f09536a5900ebaf593dc99e2e0594a5 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 09:34:25 +0200 Subject: [PATCH 69/71] docs(todo): close the Aiven migration entry and update the migration count - TODO: remove the high-priority entry to apply 20261011_keep_group_history_on_delete.sql on Aiven. - database README: 26 migrations recorded and applied on Aiven, with the two applied on 2026-10-12. Why: both migrations were applied on Aiven on 2026-10-12 after a dry run and read-only checks, and verified afterwards; the README still said 15. --- docs/TODO.md | 7 ------- docs/general/database/README.md | 6 ++++-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 99244ecd..a4a0e7ce 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -30,13 +30,6 @@ preview. ### 🔴 Alta -- [ ] **Aplicar en Aiven `20261011_keep_group_history_on_delete.sql` antes de - desplegar el borrado de grupos** — el código escribe una fila de - historial tras borrar el grupo; con la clave foránea todavía en Aiven - ese insert falla y el borrado se deshace (500). Preview y producción - usan Aiven: aplicarla antes de que la rama llegue a compassionate, con - `scripts/apply-migration.sh aiven --dry-run` primero, y - marcarla en `backend/db-mysql/INDEX.md`. Ya aplicada en local. - [ ] **Rotar las credenciales de producción** — contraseña de la BD y claves de Cloudinary, y revocar las claves de servicios que ningún código usa. El historial publicado no las contiene, pero siguen en copias privadas diff --git a/docs/general/database/README.md b/docs/general/database/README.md index 03ade29e..43d44687 100644 --- a/docs/general/database/README.md +++ b/docs/general/database/README.md @@ -75,8 +75,10 @@ Tables by module: repository because the backend and `apply-migration.sh` need it (decided on 2026-10-12). -**Migration status**: the 15 recorded in `INDEX.md` are applied on Aiven -(verified on 2026-09-28). +**Migration status**: the 26 recorded in `INDEX.md` are applied on Aiven +(the last two, `20261011_keep_group_history_on_delete.sql` and +`20261012_unique_invoice_supplier_number.sql`, applied and verified on +2026-10-12). ## How information flows From 53df25891cdfa69ad95f4a2e84e391db22cb6dcd Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 19:44:32 +0200 Subject: [PATCH 70/71] test(notifications): mock the database pool in the manual recipients test - manual-recipients.test.ts: mock config/db.js, which the controller loads through NotificationRepository. Why: CI has no backend/.env, so loading the pool config threw "Falta LOCAL_DB_PASSWORD" and the file failed before any test ran; locally the .env hid it. Reproduced with LOCAL_DB_PASSWORD empty; now the whole suite passes that way. --- backend/tests/notifications/manual-recipients.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/tests/notifications/manual-recipients.test.ts b/backend/tests/notifications/manual-recipients.test.ts index 27ae10cb..021e0fd8 100644 --- a/backend/tests/notifications/manual-recipients.test.ts +++ b/backend/tests/notifications/manual-recipients.test.ts @@ -7,6 +7,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { Request, Response } from 'express' +// The controller also loads NotificationRepository: without this the pool's +// config needs a real database password (CI has none) +vi.mock('../../config/db.js', () => ({ default: { query: vi.fn(), getConnection: vi.fn() } })) vi.mock('../../repositories/auth/user-repository.js', () => ({ UserRepository: { getActiveIds: vi.fn() }, })) From 9c5bdcad6160b6cca4f1c08402385a6631c72ef0 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 12 Oct 2026 20:31:42 +0200 Subject: [PATCH 71/71] docs(session): close the OpenCode review and point to the PR to main - Estado: the 20 opencode/* branches are merged into the working branch (PR #7-#26) and tested on preview; both migrations applied on Aiven. - Siguiente: the PR to main comes first; the credential rotation now names the plaintext application password found in the history; the time zone task is listed. - Cuidado: only 3 SQL files still cite the archived plans. Why: the session still described the state of 2026-10-05, said no credential was in the history, and counted 10 broken references. --- docs/SESSION.md | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/docs/SESSION.md b/docs/SESSION.md index 4e4ce987..1b921f8b 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -7,7 +7,7 @@ Lo que no encaje aquí es una decisión (a `DECISIONS.md`), un hallazgo (a su propio fichero) o ruido (se borra). -**Última actualización:** 2026-10-05 +**Última actualización:** 2026-10-12 --- @@ -29,6 +29,11 @@ y auditado, publicación y demo. Sus documentos (`ROADMAP.md`, `GITCLEAN.md`, ficticios y la base de horarios de la demo (`scheduling-seed.sql`). - **Repositorio original:** `four-points-archive`, archivado y privado. - **Decisiones:** `DECISIONS.md`, hasta ADR-041. +- **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`. ## ⚠️ Empieza por aquí @@ -48,14 +53,22 @@ y auditado, publicación y demo. Sus documentos (`ROADMAP.md`, `GITCLEAN.md`, ## Siguiente -«Después de publicar» de `TODO.md`, por prioridad: +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`). -1. **Rotar las credenciales de producción** (BD, clave JWT, Cloudinary, claves - de IA). Ninguna está en el historial; se hace en los paneles y se comprueba - que producción y preview siguen funcionando. -2. **Rastro de cambios en dinero:** ingresos de F&B, pagos y recuentos de caja, +Luego, «Después de publicar» de `TODO.md`, por prioridad: + +2. **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. -3. Las de prioridad media, empezando por las comprobaciones cortas (versión de +4. **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. @@ -81,8 +94,10 @@ Nada ahora mismo. se contrastan siempre con el código y la BD. - **Nada de `docs/_archive/` se publica** (git lo ignora). Si algo de ahí debe ser público, se copia fuera. -- **Enlaces rotos conocidos:** 10 ficheros fuera de `docs/` citan +- **Enlaces rotos conocidos:** 3 ficheros fuera de `docs/` citan todavía `Global-Plan.md`, `SCHEDULING-SOLVER-PLAN.md` o `SCHEDULING-CONSTRAINTS.md`, - ya archivados. Uno es `backend/db-mysql/aiven/19_scheduling.sql`, que no se - toca (ADR-021); la sección equivalente es `docs/scheduling/constraints.md - §7.5`. Los demás se arreglan al escribir cada módulo (ADR-010). + ya archivados: `backend/db-mysql/MASTER_INSTALL.sql`, + `backend/db-mysql/aiven/19_scheduling.sql` y + `backend/db-mysql/scripts/20260512_add_scheduling_solver_runs_and_requests.sql`. + Son SQL ya aplicados y no se tocan (ADR-021); la sección equivalente es + `docs/scheduling/constraints.md §7.5`.