diff --git a/apps/backend/src/controllers/eventController.ts b/apps/backend/src/controllers/eventController.ts index d2ee090..8c1a282 100644 --- a/apps/backend/src/controllers/eventController.ts +++ b/apps/backend/src/controllers/eventController.ts @@ -1,6 +1,14 @@ import { Response } from "express"; import { EventServices } from "../services/eventServices"; import { AuthRequest } from "../types/auth"; +import { + CreateAccidentSchema, + CreateInspectionSchema, + CreateMaintenanceSchema, + CreateOtherEventSchema, + GetEventsFilterSchema, +} from "../validations/eventSchema"; +import { ZodError } from "zod"; /** * @swagger @@ -141,6 +149,10 @@ import { AuthRequest } from "../types/auth"; * documentationVerified: * type: boolean * example: true + * vehicleCondition: + * type: string + * enum: [ACCEPTABLE, NOT_ACCEPTABLE] + * example: "ACCEPTABLE" * lightsOk: * type: boolean * example: true @@ -163,7 +175,7 @@ import { AuthRequest } from "../types/auth"; * 201: * description: Inspection created successfully * 400: - * description: Failed to create inspection + * description: Validation failed * 401: * description: Not authorized * 500: @@ -176,20 +188,36 @@ export const createInspection = async (req: AuthRequest, res: Response) => { if (!companyId || !userId) { return res.status(401).json({ error: "Not authorized" }); } + + const validatedInspection = CreateInspectionSchema.parse(req.body); + const newInspection = await EventServices.createInspection( - req.body, + validatedInspection, companyId, userId, ); + if (!newInspection) { return res.status(400).json({ error: "Failed to create inspection" }); } + return res.status(201).json({ success: true, message: "Inspection created successfully", data: newInspection, }); } catch (error: any) { + // Manejo de errores de Zod + if (error instanceof ZodError) { + return res.status(400).json({ + error: "Validation failed", + issues: error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })), + }); + } + console.error("Create inspection error:", error); return res.status(500).json({ message: "Failed to create inspection", @@ -232,13 +260,16 @@ const getVehicleHistory = async (req: AuthRequest, res: Response) => { if (!companyId) { return res.status(401).json({ error: "Not authorized" }); } + const history = await EventServices.getVehicleHistory( vehicleId as string, companyId, ); + if (!history) { return res.status(404).json({ error: "Vehicle not found" }); } + res.json({ success: true, data: history, @@ -286,13 +317,16 @@ const getDriverHistory = async (req: AuthRequest, res: Response) => { if (!companyId) { return res.status(401).json({ error: "Not authorized" }); } + const history = await EventServices.getDriverHistory( driverId as string, companyId, ); + if (!history) { return res.status(404).json({ error: "Driver not found" }); } + res.json({ success: true, data: history, @@ -338,10 +372,12 @@ const getCurrentDriver = async (req: AuthRequest, res: Response) => { if (!companyId) { return res.status(401).json({ error: "Not authorized" }); } + const currentDriver = await EventServices.getCurrentDriver( vehicleId as string, companyId, ); + res.json({ success: true, data: currentDriver, @@ -398,9 +434,11 @@ const getCurrentDriver = async (req: AuthRequest, res: Response) => { * example: false * cost: * type: number + * minimum: 0 * example: 2500.00 * mileage: * type: integer + * minimum: 0 * example: 45000 * locationDetails: * type: string @@ -418,6 +456,8 @@ const getCurrentDriver = async (req: AuthRequest, res: Response) => { * responses: * 201: * description: Accident event created successfully + * 400: + * description: Validation failed * 401: * description: Not authorized * 500: @@ -432,8 +472,10 @@ const createAccident = async (req: AuthRequest, res: Response) => { return res.status(401).json({ error: "Not authorized" }); } + const validatedAccident = CreateAccidentSchema.parse(req.body); + const accident = await EventServices.createAccident( - req.body, + validatedAccident, companyId, userId, ); @@ -448,6 +490,17 @@ const createAccident = async (req: AuthRequest, res: Response) => { data: accident, }); } catch (error: any) { + // Manejo de errores de Zod + if (error instanceof ZodError) { + return res.status(400).json({ + error: "Validation failed", + issues: error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })), + }); + } + console.error("Create accident error:", error); return res.status(500).json({ message: "Failed to create accident event", @@ -492,9 +545,11 @@ const createAccident = async (req: AuthRequest, res: Response) => { * example: "MINOR" * cost: * type: number + * minimum: 0 * example: 350.50 * mileage: * type: integer + * minimum: 0 * example: 45000 * nextServiceDate: * type: string @@ -521,6 +576,8 @@ const createAccident = async (req: AuthRequest, res: Response) => { * responses: * 201: * description: Maintenance event created successfully + * 400: + * description: Validation failed * 401: * description: Not authorized * 500: @@ -535,8 +592,10 @@ const createMaintenance = async (req: AuthRequest, res: Response) => { return res.status(401).json({ error: "Not authorized" }); } + const validatedMaintenance = CreateMaintenanceSchema.parse(req.body); + const maintenance = await EventServices.createMaintenance( - req.body, + validatedMaintenance, companyId, userId, ); @@ -553,6 +612,17 @@ const createMaintenance = async (req: AuthRequest, res: Response) => { data: maintenance, }); } catch (error: any) { + // Manejo de errores de Zod + if (error instanceof ZodError) { + return res.status(400).json({ + error: "Validation failed", + issues: error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })), + }); + } + console.error("Create maintenance error:", error); return res.status(500).json({ message: "Failed to create maintenance event", @@ -578,6 +648,7 @@ const createMaintenance = async (req: AuthRequest, res: Response) => { * type: object * required: * - eventDatetime + * - eventTitle * properties: * vehicleId: * type: string @@ -597,7 +668,8 @@ const createMaintenance = async (req: AuthRequest, res: Response) => { * example: "GPS" * eventTitle: * type: string - * description: Stored in final_observations JSON + * minLength: 1 + * description: Stored in final_observations JSON (REQUIRED) * example: "Fuel purchase" * eventDescription: * type: string @@ -610,6 +682,8 @@ const createMaintenance = async (req: AuthRequest, res: Response) => { * responses: * 201: * description: Event created successfully + * 400: + * description: Validation failed * 401: * description: Not authorized * 500: @@ -624,8 +698,10 @@ const createOtherEvent = async (req: AuthRequest, res: Response) => { return res.status(401).json({ error: "Not authorized" }); } + const validatedOtherEvent = CreateOtherEventSchema.parse(req.body); + const event = await EventServices.createOtherEvent( - req.body, + validatedOtherEvent, companyId, userId, ); @@ -640,6 +716,17 @@ const createOtherEvent = async (req: AuthRequest, res: Response) => { data: event, }); } catch (error: any) { + // Manejo de errores de Zod + if (error instanceof ZodError) { + return res.status(400).json({ + error: "Validation failed", + issues: error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })), + }); + } + console.error("Create other event error:", error); return res.status(500).json({ message: "Failed to create event", @@ -688,27 +775,34 @@ const createOtherEvent = async (req: AuthRequest, res: Response) => { * type: string * format: date * description: Start date (YYYY-MM-DD) + * example: "2025-01-01" * - in: query * name: endDate * schema: * type: string * format: date * description: End date (YYYY-MM-DD) + * example: "2025-12-31" * - in: query * name: limit * schema: * type: integer + * minimum: 1 + * maximum: 100 * default: 50 * description: Number of results per page * - in: query * name: offset * schema: * type: integer + * minimum: 0 * default: 0 * description: Offset for pagination * responses: * 200: * description: List of events with pagination info + * 400: + * description: Validation failed * 401: * description: Not authorized * 500: @@ -721,24 +815,29 @@ const getAllEvents = async (req: AuthRequest, res: Response) => { return res.status(401).json({ error: "Not authorized" }); } - const filters = { - eventType: req.query.eventType as string, - severity: req.query.severity as string, - vehicleId: req.query.vehicleId as string, - driverId: req.query.driverId as string, - startDate: req.query.startDate as string, - endDate: req.query.endDate as string, - limit: parseInt(req.query.limit as string) || 50, - offset: parseInt(req.query.offset as string) || 0, - }; + const validatedFilters = GetEventsFilterSchema.parse(req.query); - const events = await EventServices.getAllEvents(companyId, filters); + const events = await EventServices.getAllEvents( + companyId, + validatedFilters, + ); res.json({ success: true, data: events, }); } catch (error: any) { + // Manejo de errores de Zod + if (error instanceof ZodError) { + return res.status(400).json({ + error: "Validation failed", + issues: error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })), + }); + } + console.error("Get all events error:", error); res.status(500).json({ message: "Failed to fetch events", diff --git a/apps/backend/src/controllers/userController.ts b/apps/backend/src/controllers/userController.ts index 97b177b..da1fdcd 100644 --- a/apps/backend/src/controllers/userController.ts +++ b/apps/backend/src/controllers/userController.ts @@ -3,6 +3,8 @@ import { UserService } from "../services/userServices"; import { UserRole } from "../../generated/prisma/enums"; import bcrypt from "bcrypt"; import { AuthRequest } from "../types/auth"; +import { CreateUserSchema, UpdateUserSchema } from "../validations/userSchema"; +import { ZodError } from "zod"; /** * @swagger @@ -191,32 +193,37 @@ const createUser = async (req: AuthRequest, res: Response) => { return res.status(403).json({ message: "Only Admins can create users" }); } - const { name, email, password, role } = req.body; + const validatedData = CreateUserSchema.parse(req.body); - if (!name || !email || !password || !role) { - return res.status(400).json({ message: "All fields are required" }); - } - - const existingUser = await UserService.findByEmail(email); + const existingUser = await UserService.findByEmail(validatedData.email); if (existingUser) { return res.status(409).json({ message: "Email already exists" }); } - const passwordHash = await bcrypt.hash(password, 10); + const passwordHash = await bcrypt.hash(validatedData.password, 10); const newUser = await UserService.create( { - name, - email, - passwordHash, - role: role as UserRole, // operador o compliance por ejemplo + name: validatedData.name, + email: validatedData.email, + passwordHash: passwordHash, + role: validatedData.role, }, adminCompanyId, - ); // esto deberia ser validado + ); res.status(201).json(newUser); } catch (error: any) { - console.error(error); + if (error instanceof ZodError) { + return res.status(400).json({ + message: "Validation failed", + errors: error.issues.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + })), + }); + } + console.error("User creation error", error); res.status(500).json({ message: "Error creating new user", error: error?.message, @@ -283,7 +290,7 @@ const updateUser = async (req: AuthRequest, res: Response) => { if (!companyId) return res.status(401).json({ error: "Not authorized" }); - const dataToUpdate = req.body; // esto deberia ser validado + const dataToUpdate = UpdateUserSchema.parse(req.body); const updatedUser = await UserService.update( id as string, diff --git a/apps/backend/src/services/eventServices.ts b/apps/backend/src/services/eventServices.ts index 2e2119b..ae56563 100644 --- a/apps/backend/src/services/eventServices.ts +++ b/apps/backend/src/services/eventServices.ts @@ -1,15 +1,22 @@ -import { Prisma } from "../../generated/prisma/client"; +import { + EventSeverity, + EventType, + LocationType, + Prisma, + TypeInspection as PrismaInspectionType, + InspectionStatus as PrismaVehicleCondition, +} from "../../generated/prisma/client"; import { prisma } from "../../prisma"; import { - CreateAccidentDto, - CreateInspectionDto, - CreateMaintenanceDto, - CreateOtherEventDto, - EventFilters, -} from "../types/events"; + CreateInspectionDTO, + CreateAccidentDTO, + CreateMaintenanceDTO, + CreateOtherEventDTO, + GetEventsFilterDTO, +} from "../validations/eventSchema"; const createInspection = ( - data: CreateInspectionDto, + data: CreateInspectionDTO, companyId: string, userId: string, ) => { @@ -26,10 +33,13 @@ const createInspection = ( inspection_details: { create: [ { - type_inspection: data.typeInspection, + type_inspection: data.typeInspection as PrismaInspectionType, documentation_verified: data.documentationVerified, lights_ok: data.lightsOk, + tires_ok: data.tiresOk, + brakes_ok: data.brakesOk, safety_elements_ok: data.safetyElementsOk, + vehicle_condition: data.vehicleCondition as PrismaVehicleCondition, }, ], }, @@ -38,7 +48,7 @@ const createInspection = ( }; const createAccident = async ( - data: CreateAccidentDto, + data: CreateAccidentDTO, companyId: string, userId: string, ) => { @@ -48,9 +58,9 @@ const createAccident = async ( vehicle_id: data.vehicleId, driver_id: data.driverId, event_type: "ACCIDENT", - event_datetime: new Date(data.eventDatetime), - location: data.location as any, - severity: data.severity as any, + event_datetime: data.eventDatetime, + location: data.location, + severity: data.severity, injuries_reported: data.injuriesReported, cost: data.cost ? new Prisma.Decimal(data.cost) : null, mileage: data.mileage, @@ -74,7 +84,7 @@ const createAccident = async ( }; const createMaintenance = async ( - data: CreateMaintenanceDto, + data: CreateMaintenanceDTO, companyId: string, userId: string, ) => { @@ -84,13 +94,11 @@ const createMaintenance = async ( vehicle_id: data.vehicleId, driver_id: data.driverId || null, event_type: "MAINTENANCE", - event_datetime: new Date(data.eventDatetime), - severity: data.severity as any, + event_datetime: data.eventDatetime, + severity: data.severity as EventSeverity, cost: data.cost ? new Prisma.Decimal(data.cost) : null, mileage: data.mileage, - next_service_date: data.nextServiceDate - ? new Date(data.nextServiceDate) - : null, + next_service_date: data.nextServiceDate ? data.nextServiceDate : null, final_observations: JSON.stringify({ maintenanceType: data.maintenanceType, serviceType: data.serviceType, @@ -112,7 +120,7 @@ const createMaintenance = async ( }; const createOtherEvent = async ( - data: CreateOtherEventDto, + data: CreateOtherEventDTO, companyId: string, userId: string, ) => { @@ -122,8 +130,8 @@ const createOtherEvent = async ( vehicle_id: data.vehicleId || null, driver_id: data.driverId || null, event_type: "OTHER", - event_datetime: new Date(data.eventDatetime), - location: data.location as any, + event_datetime: data.eventDatetime, + location: data.location, final_observations: JSON.stringify({ title: data.eventTitle, description: data.eventDescription, @@ -142,26 +150,15 @@ const createOtherEvent = async ( }); }; -const getAllEvents = async (companyId: string, filters: EventFilters) => { - const where: any = { +const getAllEvents = async (companyId: string, filters: GetEventsFilterDTO) => { + const where: Prisma.OperationalEventWhereInput = { company_id: companyId, }; - if (filters.eventType) { - where.event_type = filters.eventType; - } - - if (filters.severity) { - where.severity = filters.severity; - } - - if (filters.vehicleId) { - where.vehicle_id = filters.vehicleId; - } - - if (filters.driverId) { - where.driver_id = filters.driverId; - } + if (filters.eventType) where.event_type = filters.eventType as EventType; + if (filters.severity) where.severity = filters.severity as EventSeverity; + if (filters.vehicleId) where.vehicle_id = filters.vehicleId; + if (filters.driverId) where.driver_id = filters.driverId; if (filters.startDate || filters.endDate) { where.event_datetime = {}; @@ -173,6 +170,9 @@ const getAllEvents = async (companyId: string, filters: EventFilters) => { } } + const limit = filters.limit ?? 50; + const offset = filters.offset ?? 0; + const [events, total] = await Promise.all([ prisma.operationalEvent.findMany({ where, @@ -185,8 +185,8 @@ const getAllEvents = async (companyId: string, filters: EventFilters) => { inspection_details: true, }, orderBy: { event_datetime: "desc" }, - take: filters.limit, - skip: filters.offset, + take: limit, + skip: offset, }), prisma.operationalEvent.count({ where }), ]); @@ -194,8 +194,8 @@ const getAllEvents = async (companyId: string, filters: EventFilters) => { return { events, total, - limit: filters.limit, - offset: filters.offset, + limit, + offset, }; }; diff --git a/apps/backend/src/types/events.ts b/apps/backend/src/types/events.ts index de9f616..e2e2d0c 100644 --- a/apps/backend/src/types/events.ts +++ b/apps/backend/src/types/events.ts @@ -1,11 +1,19 @@ +import { + TypeInspection, + InspectionStatus, + EventSeverity, + LocationType, +} from "../../generated/prisma/client"; + export interface CreateInspectionDto { vehicleId: string; driverId: string; - typeInspection?: string; + typeInspection?: TypeInspection; documentationVerified?: boolean; lightsOk?: boolean; tiresOk?: boolean; brakesOk?: boolean; + vehicleCondition?: InspectionStatus; safetyElementsOk?: boolean; eSignature?: string; isConfirmed?: boolean; @@ -14,9 +22,9 @@ export interface CreateInspectionDto { export interface CreateAccidentDto { vehicleId: string; driverId: string; - eventDatetime: string; - location?: string; - severity?: string; // MINOR, MODERATE, SEVERE, CRITICAL + eventDatetime: Date; + location?: LocationType; + severity?: EventSeverity; injuriesReported?: boolean; cost?: number; mileage?: number; @@ -30,12 +38,12 @@ export interface CreateAccidentDto { export interface CreateMaintenanceDto { vehicleId: string; driverId?: string; - eventDatetime: string; - severity?: string; // MINOR, MODERATE, SEVERE, CRITICAL + eventDatetime: Date; + severity?: EventSeverity; cost?: number; mileage?: number; - nextServiceDate?: string; - maintenanceType?: string; // PREVENTIVE, CORRECTIVE, EMERGENCY + nextServiceDate?: Date; + maintenanceType?: TypeInspection; serviceType?: string; serviceProvider?: string; finalObservations?: string; @@ -45,8 +53,8 @@ export interface CreateMaintenanceDto { export interface CreateOtherEventDto { vehicleId?: string; driverId?: string; - eventDatetime: string; - location?: string; + eventDatetime: Date; + location?: LocationType; eventTitle?: string; eventDescription?: string; finalObservations?: string; @@ -55,7 +63,7 @@ export interface CreateOtherEventDto { export interface EventFilters { eventType?: string; - severity?: string; + severity?: EventSeverity; vehicleId?: string; driverId?: string; startDate?: string; diff --git a/apps/backend/src/validations/eventSchema.ts b/apps/backend/src/validations/eventSchema.ts new file mode 100644 index 0000000..7fe11a1 --- /dev/null +++ b/apps/backend/src/validations/eventSchema.ts @@ -0,0 +1,98 @@ +import { z } from "zod"; +import { + TypeInspection, + InspectionStatus, + LocationType, + EventSeverity, + EventType, +} from "../../generated/prisma/enums"; + +export const CreateInspectionSchema = z.object({ + vehicleId: z.uuid("Invalid vehicle ID format"), + driverId: z.uuid("Invalid driver ID format"), + typeInspection: z + .enum(TypeInspection, { + message: "Invalid inspection type. Must be ARRIVAL or DEPARTURE", + }) + .optional(), + documentationVerified: z.boolean().optional(), + vehicleCondition: z.enum(InspectionStatus).optional(), + lightsOk: z.boolean().optional(), + tiresOk: z.boolean().optional(), + brakesOk: z.boolean().optional(), + safetyElementsOk: z.boolean().optional(), + eSignature: z.string().optional(), + isConfirmed: z.boolean().optional(), + // Campos extra opcionales + location: z.enum(LocationType).optional(), + mileage: z.number().int().positive().optional(), +}); + +export const CreateAccidentSchema = z.object({ + vehicleId: z.uuid("Invalid vehicle ID format"), + driverId: z.uuid("Invalid driver ID format"), + // Mantener como Date - el servicio lo convierte + eventDatetime: z.coerce.date({ + message: "Invalid datetime format", + }), + location: z.enum(LocationType).optional(), + severity: z.enum(EventSeverity).optional(), + injuriesReported: z.boolean().optional(), + cost: z.number().positive("Cost must be positive").optional(), + mileage: z.number().int().positive("Mileage must be positive").optional(), + // Campos que van en final_observations JSON + locationDetails: z.string().optional(), + description: z.string().optional(), + finalObservations: z.string().optional(), + eSignature: z.string().optional(), +}); + +export const CreateMaintenanceSchema = z.object({ + vehicleId: z.uuid("Invalid vehicle ID format"), + driverId: z.uuid("Invalid driver ID format").optional(), + eventDatetime: z.coerce.date({ + message: "Invalid datetime format", + }), + severity: z.enum(EventSeverity).optional(), + cost: z.number().positive("Cost must be positive").optional(), + mileage: z.number().int().positive("Mileage must be positive").optional(), + nextServiceDate: z.coerce.date().optional(), + // Campos que van en final_observations JSON + maintenanceType: z.enum(["PREVENTIVE", "CORRECTIVE", "EMERGENCY"]).optional(), + serviceType: z.string().optional(), + serviceProvider: z.string().optional(), + finalObservations: z.string().optional(), + eSignature: z.string().optional(), +}); + +export const CreateOtherEventSchema = z.object({ + vehicleId: z.uuid().optional(), + driverId: z.uuid().optional(), + eventDatetime: z.coerce.date({ + message: "Invalid datetime format", + }), + location: z.enum(LocationType).optional(), + // Campos que van en final_observations JSON + eventTitle: z.string().min(1, "Event title is required"), + eventDescription: z.string().optional(), + finalObservations: z.string().optional(), + eSignature: z.string().optional(), +}); + +export const GetEventsFilterSchema = z.object({ + eventType: z.enum(EventType).optional(), + severity: z.enum(EventSeverity).optional(), + vehicleId: z.uuid().optional(), + driverId: z.uuid().optional(), + startDate: z.string().optional(), // Mantener como string - el servicio lo convierte + endDate: z.string().optional(), // Mantener como string - el servicio lo convierte + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + offset: z.coerce.number().int().min(0).optional().default(0), +}); + +// Exportar los tipos inferidos (estos reemplazan las interfaces manuales) +export type CreateInspectionDTO = z.infer; +export type CreateAccidentDTO = z.infer; +export type CreateMaintenanceDTO = z.infer; +export type CreateOtherEventDTO = z.infer; +export type GetEventsFilterDTO = z.infer; diff --git a/apps/backend/src/validations/userSchema.ts b/apps/backend/src/validations/userSchema.ts new file mode 100644 index 0000000..d717cb4 --- /dev/null +++ b/apps/backend/src/validations/userSchema.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { UserRole } from "../../generated/prisma/enums"; + +// 1. Validación para CREAR un usuario (POST) +export const CreateUserSchema = z.object({ + name: z.string().min(2, "Name must be at least 2 characters"), + email: z.email("Invalid email format"), + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[0-9]/, "Password must contain at least one number"), + role: z.enum(UserRole, { + message: "Invalid role. Must be ADMIN, OPERATOR, or COMPLIANCE", + }), +}); + +// 2. Validación para ACTUALIZAR un usuario (PUT) +// opcionales porque puede ser que no cambien todos los campos +export const UpdateUserSchema = z.object({ + name: z.string().min(2, "Name must be at least 2 characters").optional(), + email: z.email("Invalid email format").optional(), + role: z + .enum(UserRole, { + message: "Invalid role. Must be ADMIN, OPERATOR, or COMPLIANCE", + }) + .optional(), + isActive: z.boolean().optional(), +}); + +// Exportar los tipos inferidos +export type CreateUserDTO = z.infer; +export type UpdateUserDTO = z.infer;