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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,11 @@ index.ts → routes → controllers → services → repositories → models
outside production, the production and Vercel domains — plus `FRONTEND_URL`.
Credentials enabled. It is also the CSRF defence: any other origin gets a 403
before reaching a route.
- **Dates (ADR-047)**: the process and every MySQL session run in UTC;
timestamps are UTC instants. The hotel's "today" and "now" come from
`config/date-utils.ts` and `repositories/shared/hotel-clock.ts`
(`sqlHotelToday`, `sqlHotelNow`, `sqlMadridDay`), never from `CURDATE()`,
`NOW()`, `DATE(col)` or `toISOString()`.
- **Cron jobs**: `services/cron/cron-service.ts`, started from `index.ts`. The
times are in `cron-schedule.ts` (Europe/Madrid). Render's free plan sleeps, so
on every start the jobs whose period is due and not done run, and
Expand Down
21 changes: 21 additions & 0 deletions backend/config/date-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,27 @@ export const getLastDayOfMonth = (year: number, month: number): string => {
.format('YYYY-MM-DD')
}

// ── Hotel wall-clock times (ADR-047) ─────────────────────────────────────────
// Parking check-in and check-out times are the hotel's clock: "12:00" is noon in
// Madrid, stored as 12:00:00. mysql2 reads such a DATETIME as a Date whose UTC
// fields are the stored value, so these helpers never go through the local zone.

/** The hotel's current wall-clock time, as a Date to store in such a column */
export const getHotelNowDate = (): Date =>
new Date(`${getNowMadrid().format('YYYY-MM-DDTHH:mm:ss')}Z`)

/** A wall-clock time typed by the user ('YYYY-MM-DD HH:mm' or 'YYYY-MM-DDTHH:mm'), as stored */
export const parseHotelTime = (value: string): Date => dayjs.utc(value).toDate()

/** A stored wall-clock time for the API: no Z, so the browser reads it as local time */
export function toHotelTime(value: Date | string): string
export function toHotelTime(value: Date | string | null | undefined): string | null
export function toHotelTime(value: Date | string | null | undefined): string | null {
if (value === null || value === undefined) return null
const date = value instanceof Date ? value : parseHotelTime(value)
return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 19)
}

export default {
getTodayMadrid,
getNowMadrid,
Expand Down
4 changes: 3 additions & 1 deletion backend/config/db-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,6 @@ if (environment === 'aiven' && (!config.user || !Number.isInteger(config.port)))
throw new Error('❌ Faltan AIVEN_DB_USER o AIVEN_DB_PORT en .env')
}

export const dbConfig = config
// mysql2 reads and writes DATETIME and DATE in UTC, whatever the machine's zone
// (ADR-047); config/db.ts sets the MySQL session to UTC too
export const dbConfig = { ...config, timezone: 'Z' }
11 changes: 11 additions & 0 deletions backend/config/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ logger.info(
// ========================================
const pool: Pool = mysql.createPool(config) // ← MODIFICADO (añadido tipo)

// NOW(), CURDATE() and DEFAULT CURRENT_TIMESTAMP in UTC on every connection, as on
// Aiven (ADR-047). The event hands over the callback connection, not the promise one
pool.on('connection', (connection) => {
const raw = connection as unknown as {
query: (sql: string, callback: (err: Error | null) => void) => void
}
raw.query("SET time_zone = '+00:00'", (err) => {
if (err) logger.error({ err }, 'No se pudo fijar la zona horaria UTC en la sesión MySQL')
})
})

// Verify connection at startup with retries (Aiven can timeout on cold start)
async function connectWithRetry(retries = 3, delayMs = 3000): Promise<void> {
for (let attempt = 1; attempt <= retries; attempt++) {
Expand Down
6 changes: 6 additions & 0 deletions backend/config/timezone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// config/timezone.ts
// The backend runs in UTC on every machine, as it does on Render (ADR-047).
// Imported first by index.ts: Node reads TZ when it next builds a Date. Business
// dates ("today", the hotel day) come from config/date-utils.ts, in Madrid.

process.env.TZ = 'UTC'
3 changes: 2 additions & 1 deletion backend/controllers/activity/activity-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
mergeByTimestamp,
} from '../../services/activity/activity-access.js'
import { logger } from '../../config/logger.js'
import { getTodayMadrid } from '../../config/date-utils.js'
import { isCalendarDate } from '../../validations/common/calendar-date.js'

export class ActivityController {
Expand Down Expand Up @@ -81,7 +82,7 @@ export class ActivityController {

const fetchActivity = (src?: ActivitySource) => {
if (dateFrom || dateTo) {
const to = dateTo || new Date().toISOString().slice(0, 10)
const to = dateTo || getTodayMadrid()
let from = dateFrom
if (!from) {
const d = new Date(to)
Expand Down
2 changes: 1 addition & 1 deletion backend/controllers/backoffice/backoffice-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1548,7 +1548,7 @@ export class BackofficeController {
const archive = archiver('zip', { zlib: { level: 5 } })

// Set response headers
const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '')
const timestamp = getTodayMadrid().replace(/-/g, '')
res.setHeader('Content-Type', 'application/zip')
res.setHeader(
'Content-Disposition',
Expand Down
9 changes: 5 additions & 4 deletions backend/controllers/cashier/cashier-history-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
recentHistoryQuerySchema,
validationError,
} from '../../validations/cashier/cashier-validation.js'
import { sqlMadridDay } from '../../repositories/shared/hotel-clock.js'

export class CashierHistoryController {
/**
Expand Down Expand Up @@ -94,8 +95,8 @@ export class CashierHistoryController {
COUNT(*) as count
FROM cashier_history
WHERE 1=1
${from_date ? 'AND DATE(changed_at) >= ?' : ''}
${to_date ? 'AND DATE(changed_at) <= ?' : ''}
${from_date ? `AND ${sqlMadridDay('changed_at')} >= ?` : ''}
${to_date ? `AND ${sqlMadridDay('changed_at')} <= ?` : ''}
GROUP BY action
ORDER BY count DESC
`
Expand All @@ -113,8 +114,8 @@ export class CashierHistoryController {
COUNT(*) as actions_count
FROM cashier_history
WHERE changed_by IS NOT NULL
${from_date ? 'AND DATE(changed_at) >= ?' : ''}
${to_date ? 'AND DATE(changed_at) <= ?' : ''}
${from_date ? `AND ${sqlMadridDay('changed_at')} >= ?` : ''}
${to_date ? `AND ${sqlMadridDay('changed_at')} <= ?` : ''}
GROUP BY changed_by
ORDER BY actions_count DESC
LIMIT 10
Expand Down
18 changes: 9 additions & 9 deletions backend/controllers/parking/bookings.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
createBookingSchema,
updateBookingBodySchema,
} from '../../validations/parking/booking-validation.js'
import { getNowMadrid } from '../../config/date-utils.js'
import { getHotelNowDate, parseHotelTime } from '../../config/date-utils.js'
import { ERROR_CODES, SUCCESS_CODES } from '../../config/error-codes.js'
import type { BookingFilters, BookingSource, UpdateBookingDTO } from '../../models/parking/index.js'
import { logger } from '../../config/logger.js'
Expand Down Expand Up @@ -298,7 +298,7 @@ class ParkingBookingsController {
// Convertir code → id
const id = await this._getBookingIdFromCode(code)

const checkinDate = actual_checkin ? new Date(actual_checkin) : getNowMadrid().toDate()
const checkinDate = actual_checkin ? parseHotelTime(actual_checkin) : getHotelNowDate()

if (actual_checkin && isNaN(checkinDate.getTime())) {
res.status(400).json({
Expand Down Expand Up @@ -396,7 +396,7 @@ class ParkingBookingsController {

const id = await this._getBookingIdFromCode(code)

const checkoutDate = actual_checkout ? new Date(actual_checkout) : getNowMadrid().toDate()
const checkoutDate = actual_checkout ? parseHotelTime(actual_checkout) : getHotelNowDate()

if (actual_checkout && isNaN(checkoutDate.getTime())) {
res.status(400).json({
Expand Down Expand Up @@ -679,11 +679,11 @@ class ParkingBookingsController {
// Validar fechas si se proporcionan
if (expected_checkin || expected_checkout) {
const checkin = expected_checkin
? new Date(expected_checkin)
: new Date(existingBooking.schedule.expected_checkin)
? parseHotelTime(expected_checkin)
: parseHotelTime(existingBooking.schedule.expected_checkin)
const checkout = expected_checkout
? new Date(expected_checkout)
: new Date(existingBooking.schedule.expected_checkout)
? parseHotelTime(expected_checkout)
: parseHotelTime(existingBooking.schedule.expected_checkout)

if (isNaN(checkin.getTime()) || isNaN(checkout.getTime())) {
res.status(400).json({
Expand Down Expand Up @@ -820,8 +820,8 @@ class ParkingBookingsController {
return
}

const now = getNowMadrid().toDate()
const checkin = new Date(booking.schedule.expected_checkin)
const now = getHotelNowDate()
const checkin = parseHotelTime(booking.schedule.expected_checkin)

if (now >= checkin) {
res.status(400).json({
Expand Down
1 change: 1 addition & 0 deletions backend/db-mysql/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ db-mysql/
| 2026-10-11 | `20261011_keep_group_history_on_delete.sql` | Quita la clave foránea `group_history_ibfk_1` (`ON DELETE CASCADE` a `hotel_groups`): borrar un grupo ya no borra su historial, y el borrado deja una fila `deleted` con la foto del grupo. **Aplicar en Aiven antes de desplegar el código**: con la clave dentro, borrar un grupo falla. | ✅ local · ✅ Aiven |
| 2026-10-12 | `20261012_unique_invoice_supplier_number.sql` | Añade `UNIQUE KEY uk_invoice_supplier_number (supplier_id, invoice_number)` a `bo_invoices`: la misma factura de un proveedor se podía registrar dos veces y se pagaban las dos en el lote mensual. El backend responde 409 `BACKOFFICE_INVOICE_DUPLICATE`. Antes del `ALTER` lista las duplicadas; si hay alguna, el `ALTER` falla y hay que corregirlas. Idempotente. El código funciona sin ella (no rechaza nada hasta que exista). | ✅ local · ✅ Aiven |
| 2026-10-14 | `20261014_cron_catch_up.sql` | Crea el usuario inactivo `system-cron` (el pago automático del día 10 firmaba con él y no existía: fallaba la clave foránea de `updated_by`) y la tabla `cron_job_runs`, con el último periodo de cada tarea programada para ponerlas al día al arrancar sin repetirlas (ADR-046). Idempotente. **Aplicar en Aiven antes de desplegar el código**: sin la tabla las tareas no se ejecutan. | ✅ local · ✅ Aiven |
| 2026-10-14 | `20261014_booking_code_hotel_date.sql` | `trg_generate_booking_code` toma la fecha de `PK-AAAAMMDD-NNNN` en hora de Madrid (`CONVERT_TZ` de `UTC_TIMESTAMP()`): con `NOW()` en UTC, una reserva entre las 00:00 y las 02:00 llevaba la fecha de ayer (ADR-047). Sin zonas con nombre (MySQL local) usa la fecha UTC, como antes. Idempotente. | ✅ local · ✅ Aiven |

### Verificación (99)

Expand Down
54 changes: 54 additions & 0 deletions backend/db-mysql/scripts/20261014_booking_code_hotel_date.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
-- =============================================================================
-- 20261014_booking_code_hotel_date.sql
-- =============================================================================
-- trg_generate_booking_code took the date of PK-YYYYMMDD-NNNN from NOW(), which
-- is UTC on Aiven and in every session (ADR-047): a booking made between 00:00
-- and 02:00 in Madrid got yesterday's code. It now converts UTC_TIMESTAMP()
-- (UTC whatever the session zone) to Madrid.
-- Where MySQL has no named time zones loaded (a local install), CONVERT_TZ
-- gives NULL and the code falls back to the UTC date, as before.
-- Only the date changes; the numbering is the same.
--
-- Idempotent: DROP TRIGGER IF EXISTS + CREATE.
--
-- Applies to: LOCAL + AIVEN. The code works with either version.
-- Rollback: recreate it with DATE_FORMAT(NOW(), '%Y%m%d')
-- (aiven/05_parking_functions_triggers.sql).
-- =============================================================================

DROP TRIGGER IF EXISTS trg_generate_booking_code;

DELIMITER $$

CREATE TRIGGER trg_generate_booking_code
BEFORE INSERT ON parking_bookings
FOR EACH ROW
BEGIN
DECLARE next_number INT;
DECLARE date_part VARCHAR(8);

-- Formato: PK-YYYYMMDD-0001, con la fecha de Madrid
SET date_part = DATE_FORMAT(
COALESCE(CONVERT_TZ(UTC_TIMESTAMP(), '+00:00', 'Europe/Madrid'), UTC_TIMESTAMP()),
'%Y%m%d'
);

SELECT COALESCE(MAX(
CAST(SUBSTRING(booking_code, -4) AS UNSIGNED)
), 0) + 1
INTO next_number
FROM parking_bookings
WHERE booking_code LIKE CONCAT('PK-', date_part, '-%');

SET NEW.booking_code = CONCAT(
'PK-',
date_part,
'-',
LPAD(next_number, 4, '0')
);
END$$

DELIMITER ;

SELECT DATE_FORMAT(COALESCE(CONVERT_TZ(UTC_TIMESTAMP(), '+00:00', 'Europe/Madrid'), UTC_TIMESTAMP()), '%Y%m%d')
AS booking_code_date_now;
1 change: 1 addition & 0 deletions backend/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// index.ts

import './config/timezone.js' // first: the process runs in UTC (ADR-047)
import express, { Request, Response, NextFunction } from 'express'
import cookieParser from 'cookie-parser'
import helmet from 'helmet'
Expand Down
3 changes: 3 additions & 0 deletions backend/models/maintenance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ export interface MaintenanceHistory {
changed_at: string
// Campos adicionales (JOINs)
user_name?: string
// Usernames when the change is an assignment (old_value/new_value are user ids)
old_value_name?: string
new_value_name?: string
}

// ========================================
Expand Down
23 changes: 12 additions & 11 deletions backend/models/parking/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,10 +300,11 @@ export interface FormattedOperator {
}

export interface FormattedSchedule {
expected_checkin: Date
expected_checkout: Date
actual_checkin: Date | null
actual_checkout: Date | null
// Hotel wall-clock times without Z: YYYY-MM-DDTHH:mm:ss (ADR-047)
expected_checkin: string
expected_checkout: string
actual_checkin: string | null
actual_checkout: string | null
planned_days: number
actual_days: number | null
}
Expand Down Expand Up @@ -356,8 +357,8 @@ export interface FormattedOverdueBooking {
owner: string | null
model: string | null
}
actual_checkin: Date
expected_checkout: Date
actual_checkin: string
expected_checkout: string
horas_retraso: number
status: BookingStatus
notes: string | null
Expand Down Expand Up @@ -413,8 +414,8 @@ export interface FormattedPendingCheckin {
model: string | null
} | null
schedule: {
expected_checkin: Date
expected_checkout: Date
expected_checkin: string
expected_checkout: string
days: number
}
booking_info: {
Expand All @@ -438,9 +439,9 @@ export interface FormattedPendingCheckout {
model: string | null
} | null
schedule: {
expected_checkin: Date
actual_checkin: Date | null
expected_checkout: Date
expected_checkin: string
actual_checkin: string | null
expected_checkout: string
planned_days: number
actual_days: number
is_overdue: boolean
Expand Down
9 changes: 5 additions & 4 deletions backend/repositories/activity/activity-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import db from '../../config/db.js'
import { RowDataPacket } from 'mysql2'
import { sqlMadridDay } from '../shared/hotel-clock.js'

// ========================================
// TYPES
Expand Down Expand Up @@ -235,7 +236,7 @@ export class ActivityRepository {
CAST(ch.shift_id AS CHAR) COLLATE utf8mb4_0900_ai_ci as record_id
FROM cashier_history ch
LEFT JOIN users u1 ON ch.changed_by = u1.id
WHERE DATE(ch.changed_at) = ?
WHERE ${sqlMadridDay('ch.changed_at')} = ?

UNION ALL

Expand All @@ -249,7 +250,7 @@ export class ActivityRepository {
CAST(gh.group_id AS CHAR) COLLATE utf8mb4_0900_ai_ci as record_id
FROM group_history gh
LEFT JOIN users u2 ON gh.changed_by = u2.id
WHERE DATE(gh.changed_at) = ?
WHERE ${sqlMadridDay('gh.changed_at')} = ?

UNION ALL

Expand All @@ -263,7 +264,7 @@ export class ActivityRepository {
CAST(lh.logbook_id AS CHAR) COLLATE utf8mb4_0900_ai_ci as record_id
FROM logbook_history lh
LEFT JOIN users u3 ON lh.editor_id = u3.id
WHERE DATE(lh.created_at) = ?
WHERE ${sqlMadridDay('lh.created_at')} = ?

UNION ALL

Expand All @@ -277,7 +278,7 @@ export class ActivityRepository {
mh.report_id COLLATE utf8mb4_0900_ai_ci as record_id
FROM maintenance_history mh
LEFT JOIN users u4 ON mh.changed_by = u4.id
WHERE DATE(mh.changed_at) = ?
WHERE ${sqlMadridDay('mh.changed_at')} = ?
) AS combined
ORDER BY timestamp DESC
LIMIT ?
Expand Down
3 changes: 2 additions & 1 deletion backend/repositories/backoffice/backoffice-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
Asset,
SummaryStats,
} from '../../models/backoffice/index.js'
import { sqlHotelToday } from '../shared/hotel-clock.js'
import { likeContains } from '../shared/like.js'

// Re-exportar tipos para mantener compatibilidad
Expand Down Expand Up @@ -960,7 +961,7 @@ export class BackofficeRepository {
`SELECT COUNT(*) as count
FROM bo_invoices
WHERE status IN ('pending', 'validated') AND is_deleted = 0
AND due_date < CURDATE()`
AND due_date < ${sqlHotelToday()}`
)

const [suppliers] = await pool.query<RowDataPacket[]>(
Expand Down
Loading
Loading