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/config/error-codes.ts b/backend/config/error-codes.ts index 39d90afe..5c2bf850 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', @@ -253,6 +256,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', @@ -387,6 +392,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', @@ -417,6 +423,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/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/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 { @@ -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) { @@ -1097,6 +1158,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 +1168,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 +1323,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 +1333,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/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/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/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/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 }) } } 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/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/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/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/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/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/controllers/parking/bookings.controller.ts b/backend/controllers/parking/bookings.controller.ts index 57bbd82e..f9b04251 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, }) @@ -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 } @@ -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/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/controllers/scheduling/scheduling-controller.ts b/backend/controllers/scheduling/scheduling-controller.ts index 7a733520..67c0cfa4 100644 --- a/backend/controllers/scheduling/scheduling-controller.ts +++ b/backend/controllers/scheduling/scheduling-controller.ts @@ -165,6 +165,25 @@ 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 +} + +// 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() @@ -809,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) { @@ -867,6 +884,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 +956,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 +984,15 @@ export async function bulkUpdateDays(req: Request, res: Response): Promise res.status(404).json({ error: 'Mes no encontrado' }) 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) @@ -996,6 +1028,7 @@ export async function updateAssignment(req: Request, res: Response): Promise 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)) @@ -1203,6 +1232,7 @@ export async function createConstraint(req: Request, res: Response): Promise 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/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', 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/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/middlewares/roleCheck.ts b/backend/middlewares/roleCheck.ts index 96230a2c..ccb90096 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 @@ -95,6 +96,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. @@ -159,24 +182,22 @@ 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'] - 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 (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', }) - } else { - next() } } diff --git a/backend/repositories/auth/user-repository.ts b/backend/repositories/auth/user-repository.ts index 1ca7ad7d..137ae4ef 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, @@ -86,10 +86,12 @@ export class UserRepository { created_at: new Date(createdAt), is_active: 1, } - } catch (error: any) { + } catch (error) { logger.error({ err: error }, 'Error creating user') - if (error.code === 'ER_DUP_ENTRY') { - const err = new Error('El nombre de usuario o email ya existe') as any + if ((error as { code?: string }).code === 'ER_DUP_ENTRY') { + const err = new Error('El nombre de usuario o email ya existe') as Error & { + code?: string + } err.code = 'ER_DUP_ENTRY' throw err } @@ -130,8 +132,8 @@ export class UserRepository { try { Validation.username(username) Validation.password(password) - } catch (err: any) { - throw new Error(err.message, { cause: err }) + } catch (err) { + throw new Error((err as Error).message, { cause: err }) } const [rows] = await db.query( @@ -226,6 +228,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 */ @@ -397,19 +409,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 +566,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 +628,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 +661,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 } 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/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/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/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/repositories/maintenance/maintenance-repository.ts b/backend/repositories/maintenance/maintenance-repository.ts index 5758ab8a..af3b1a9b 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, @@ -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. @@ -104,7 +110,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 +340,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 +365,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 +388,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 +410,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 @@ -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, @@ -474,17 +476,36 @@ export class MaintenanceRepository { userId, ] - await db.query(query, params) - - // Crear entrada de historial - 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, + // The report and its history rows commit or roll back together + 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) + + // 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) @@ -559,36 +580,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 +662,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 +703,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 +742,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 +775,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 +808,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,18 +832,20 @@ 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) } /** - * 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 @@ -828,20 +863,30 @@ export class MaintenanceRepository { userId, ] - const [result] = await db.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 db.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]) + }) } /** * 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 +900,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 +916,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 +943,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 +978,7 @@ export class MaintenanceRepository { userId, ] - await db.query(query, params) + await dbx().query(query, params) } // ======================================== @@ -979,11 +1024,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/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/repositories/scheduling/scheduling-repository.ts b/backend/repositories/scheduling/scheduling-repository.ts index efb90374..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 // ============================================ @@ -1361,13 +1427,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/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/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/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/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/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..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 (SCHEDULING-SOLVER-PLAN.md): +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 5429537b..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 (SCHEDULING-SOLVER-PLAN.md Fase 1): +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/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/auth/module-access.ts b/backend/services/auth/module-access.ts index 13fff2fa..d6883491 100644 --- a/backend/services/auth/module-access.ts +++ b/backend/services/auth/module-access.ts @@ -7,14 +7,17 @@ export type AppModule = 'parking' | 'maintenance' | 'groups' | 'blacklist' | 'cashier' | 'logbook' -const allButMaintenanceRole = (role: string) => role !== 'mantenimiento' +// 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> = { - 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/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/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 }> { 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/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/services/scheduling/AGENTS.md b/backend/services/scheduling/AGENTS.md index b3a1515e..6c4065f4 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 @@ -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 @@ -471,8 +476,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 +501,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. 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/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/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/role-access.test.ts b/backend/tests/auth/role-access.test.ts new file mode 100644 index 00000000..b9fbd9cc --- /dev/null +++ b/backend/tests/auth/role-access.test.ts @@ -0,0 +1,161 @@ +// 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 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' +import { resolve } from 'path' + +// Helper to create mock req/res/next +function createMockReqRes(role?: string) { + const req = { + user: role ? { role } : undefined, + } as unknown as Request + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as unknown as Response + 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() + }) + + 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', 'auditor'] as const + const modules: AppModule[] = [ + 'parking', + 'maintenance', + 'groups', + 'blacklist', + 'cashier', + 'logbook', + ] + + // 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> = { + 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, + }, + auditor: { + parking: false, + maintenance: false, + groups: false, + 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/) + }) +}) 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/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/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/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,`) + ) + } + }) +}) 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/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') + }) +}) 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/frontend/error-code-message.test.ts b/backend/tests/frontend/error-code-message.test.ts index 1ac38380..e97c2224 100644 --- a/backend/tests/frontend/error-code-message.test.ts +++ b/backend/tests/frontend/error-code-message.test.ts @@ -2,9 +2,44 @@ // 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), +// 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']) + 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]) + for (const m of source.matchAll(/\bcode:\s*['"`]([A-Z][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/ @@ -14,6 +49,60 @@ 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('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') @@ -21,8 +110,496 @@ 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) + }) + }) + + 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', () => { + // 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() + // 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() + 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() + 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() + }) + }) + + 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) + }) + }) + + 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) + }) + }) + + 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) + }) + }) + + 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) + }) + }) + + 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) + }) + }) + + 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) + }) + }) + + 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) + }) + }) + + 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/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/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/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/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/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') + }) +}) 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/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([]) + } + ) +}) 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/backend/tests/notifications/manual-recipients.test.ts b/backend/tests/notifications/manual-recipients.test.ts new file mode 100644 index 00000000..021e0fd8 --- /dev/null +++ b/backend/tests/notifications/manual-recipients.test.ts @@ -0,0 +1,88 @@ +// 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' + +// 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() }, +})) +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/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/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() + }) +}) 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 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]) + }) +}) 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() + }) +}) diff --git a/backend/tests/scheduling/published-month.test.ts b/backend/tests/scheduling/published-month.test.ts new file mode 100644 index 00000000..d43979b3 --- /dev/null +++ b/backend/tests/scheduling/published-month.test.ts @@ -0,0 +1,120 @@ +// tests/scheduling/published-month.test.ts +// Regression tests: a published month could still be changed. Editing cells +// and days and creating, editing, approving or deleting constraints did not +// look at the month status, and PUT /months/:id went back to draft without +// the unpublish endpoint. A published month is now read-only: 409 until it +// is unpublished. + +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(), + 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() + }) +}) 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() + }) +}) diff --git a/backend/tests/scheduling/solver-parity.test.ts b/backend/tests/scheduling/solver-parity.test.ts index 28e93777..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. // -// SCHEDULING-SOLVER-PLAN.md Fase 1 criterion: -// "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. 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/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/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/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/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`. diff --git a/docs/TODO.md b/docs/TODO.md index 473ba85c..9a669b9f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -34,7 +34,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 @@ -105,11 +109,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 @@ -132,11 +131,34 @@ 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, + `CURDATE()`/`NOW()` en Aiven (19 consultas con `CURDATE()`: filtros de + 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). + + 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 @@ -145,23 +167,9 @@ 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í._ -- [ ] **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. @@ -208,75 +216,28 @@ 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í._ -- [ ] **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._ +- [ ] **Groups: las mutaciones no van en transacción con su historial** — el + historial se escribe en el controlador, fuera de las transacciones del + repositorio: un fallo entre ambos deja cambios sin registrar. El borrado + del grupo ya va junto a su fila de historial (2026-10-11). _Comprobado + el 2026-10-11._ +- [ ] **Groups: el historial registra campos que no cambiaron** — editar solo + el importe de un grupo dejó 7 filas `updated` (`arrival_date`, + `created_at`, `updated_by`…). La comparación de `logGroupUpdated` trata + como distintos valores iguales (fechas, nulos). _Visto el 2026-10-11._ - [ ] **Checklist: el reinicio manual no funciona** — `resetRun` cierra el run y crea otro del mismo día, pero la clave única `uk_run (hotel_id, checklist_id, hotel_date)` lo impide: 500. Después `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í._ -- [ ] **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í._ + 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._ ### 🟢 Baja @@ -286,25 +247,25 @@ 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 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 - 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` 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 @@ -316,87 +277,18 @@ 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: `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); - 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 - 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í._ +- [ ] **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._ - [ ] **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 - 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._ @@ -405,7 +297,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/backoffice/README.md b/docs/backoffice/README.md index 5deba492..13e0a836 100644 --- a/docs/backoffice/README.md +++ b/docs/backoffice/README.md @@ -62,10 +62,23 @@ 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. +- **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. 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 c94d5368..b7043115 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,8 +65,12 @@ 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`.** +- **`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 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/` diff --git a/docs/general/database/README.md b/docs/general/database/README.md index e566e8c5..43d44687 100644 --- a/docs/general/database/README.md +++ b/docs/general/database/README.md @@ -70,10 +70,15 @@ 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/`. - -**Migration status**: the 15 recorded in `INDEX.md` are applied on Aiven -(verified on 2026-09-28). + `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 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 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/groups/README.md b/docs/groups/README.md index 945db347..adf5799e 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. @@ -82,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. diff --git a/docs/logbook/README.md b/docs/logbook/README.md index 53f06f09..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`): @@ -58,6 +63,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/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 diff --git a/docs/parking/README.md b/docs/parking/README.md index 51c505ed..4caa6986 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** @@ -126,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. diff --git a/docs/scheduling/decisions.md b/docs/scheduling/decisions.md index 982d97e6..5abb1862 100644 --- a/docs/scheduling/decisions.md +++ b/docs/scheduling/decisions.md @@ -1304,3 +1304,69 @@ 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. + +## 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. + +## 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". + 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/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/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/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/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/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/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/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, }) } diff --git a/frontend/app/lib/helpers/errorCodeMessage.ts b/frontend/app/lib/helpers/errorCodeMessage.ts index edbce236..f5287f1e 100644 --- a/frontend/app/lib/helpers/errorCodeMessage.ts +++ b/frontend/app/lib/helpers/errorCodeMessage.ts @@ -7,7 +7,356 @@ 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([ + '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', + '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_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_INVOICES_ERROR', + 'BACKOFFICE_FETCH_INVOICE_ERROR', + 'BACKOFFICE_FETCH_MONTHLY_SUMMARY_ERROR', + 'BACKOFFICE_FETCH_STATS_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_MARK_PAID_ERROR', + 'BACKOFFICE_MAX_INVOICES_EXCEEDED', + 'BACKOFFICE_NAME_REQUIRED', + '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', + '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', + 'BLACKLIST_CREATE_RECORD_ERROR', + 'BLACKLIST_DELETE_IMAGE_ERROR', + 'BLACKLIST_DELETE_RECORD_ERROR', + 'BLACKLIST_DOCUMENT_EXISTS', + 'BLACKLIST_FETCH_RECORDS_ERROR', + 'BLACKLIST_FETCH_RECORD_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', + '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', + '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', + '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', + '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_OVERPAID', + 'GROUP_PAYMENT_PERCENTAGE_OVER_100', + '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', + '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', + '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_CONVERSATIONS_ERROR', + 'MESSAGES_FETCH_CONVERSATION_ERROR', + 'MESSAGES_FETCH_MESSAGES_ERROR', + 'MESSAGES_FETCH_UNREAD_ERROR', + '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', + 'MESSAGES_MARK_READ_ERROR', + 'MESSAGES_MESSAGE_NOT_FOUND', + 'MESSAGES_MESSAGE_TOO_LONG', + 'MESSAGES_MISSING_REQUIRED_FIELDS', + 'MESSAGES_NOT_PARTICIPANT', + 'MESSAGES_NO_ACCESS', + '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', + '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_RECIPIENTS', + '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_CHECKIN_INVALID_STATUS', + '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_SPOT_OCCUPIED', + 'PARKING_START_BEFORE_END', + 'PARKING_UPDATE_BOOKING_ERROR', + 'STORAGE_NOT_CONFIGURED', + 'UNAUTHORIZED', +]) const byLocale: Record> = { es: es.codes, en: en.codes } @@ -15,3 +364,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/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] +} 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}`) 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/errors.json b/frontend/messages/en/errors.json index ff1d971e..881d6472 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,10 +105,15 @@ "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", "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", @@ -133,6 +139,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", @@ -284,6 +293,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", @@ -398,6 +409,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", @@ -425,6 +437,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/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/errors.json b/frontend/messages/es/errors.json index 09f2fbc6..3f683c05 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,10 +105,15 @@ "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", "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", @@ -133,6 +139,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", @@ -284,6 +293,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", @@ -398,6 +409,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", @@ -425,6 +437,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", 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",