const { Op, ValidationError, UniqueConstraintError, ForeignKeyConstraintError } = require('sequelize'); const USER_FIELDS = ['login', 'email', 'password', 'first_name', 'last_name', 'role', 'active']; const PROFILE_FIELDS = ['email', 'password', 'first_name', 'last_name']; const CUSTOMER_FIELDS = [ 'type', 'first_name', 'last_name', 'email', 'phone', 'company_name', 'tax_id', 'street', 'city', 'postal_code', 'country', 'birth_date', 'notes', ]; const CAR_FIELDS = [ 'customer_id', 'make', 'model', 'production_year', 'vin', 'registration_number', 'fuel_type', 'color', 'engine_capacity', 'power_hp', 'transmission', 'mileage', 'notes', ]; function pick(source, fields) { source ||= {}; return Object.fromEntries(fields.filter((field) => source[field] !== undefined) .map((field) => [field, source[field]])); } function cleanUser(user) { const data = user?.toJSON ? user.toJSON() : { ...user }; delete data.password; return data; } function response(res, data, status = 200, extra = {}) { if (status === 204) return res.status(status).end(); return res.status(status).json({ success: status < 400, data, ...extra }); } function validationResponse(res, fields) { return res.status(422).json({ success: false, error: { code: 422, message: 'Validation failed.', fields }, }); } function validationFields(error) { if (error instanceof ValidationError || error instanceof UniqueConstraintError) { return Object.fromEntries(error.errors.map((item) => [item.path, item.message])); } if (error instanceof ForeignKeyConstraintError) { return { customer_id: 'The referenced record does not exist.' }; } return null; } function fail(res, error) { const fields = validationFields(error); if (fields) return validationResponse(res, fields); throw error; } function requireFields(data, names) { const errors = {}; for (const name of names) { if (data[name] === undefined || data[name] === null || data[name] === '') { errors[name] = 'This field is required.'; } } return errors; } function validateUser(data, creating) { const errors = requireFields(data, creating ? ['login', 'email', 'password', 'first_name', 'last_name'] : []); if (data.login !== undefined && (typeof data.login !== 'string' || !data.login.trim() || data.login.length > 255)) errors.login = 'Login is required and must be at most 255 characters.'; if (data.email !== undefined && (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email) || data.email.length > 255)) errors.email = 'Enter a valid email address.'; if (data.password !== undefined && (String(data.password).length < 8 || String(data.password).length > 72)) errors.password = 'Password must be between 8 and 72 characters.'; for (const field of ['first_name', 'last_name']) { if (data[field] !== undefined && (typeof data[field] !== 'string' || !data[field].trim() || data[field].length > 100)) errors[field] = 'This field is required and must be at most 100 characters.'; } if (data.role !== undefined && !['admin', 'user'].includes(data.role)) errors.role = 'Role must be admin or user.'; if (data.active !== undefined && typeof data.active !== 'boolean') errors.active = 'Active must be a boolean.'; return errors; } function validateCustomer(data, creating) { const errors = requireFields(data, creating ? ['first_name', 'last_name', 'email', 'phone'] : []); if (data.type !== undefined && !['individual', 'company'].includes(data.type)) errors.type = 'Type must be individual or company.'; if (data.email !== undefined && (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email) || data.email.length > 255)) errors.email = 'Enter a valid email address.'; for (const [field, max] of Object.entries({ first_name: 100, last_name: 100, phone: 30, company_name: 255, street: 255, city: 100, notes: 5000 })) { const required = ['first_name', 'last_name', 'phone'].includes(field); if (data[field] !== undefined && ((data[field] === null && required) || (data[field] !== null && (typeof data[field] !== 'string' || data[field].length > max)))) { errors[field] = `Must be a string of at most ${max} characters.`; } } if (data.phone !== undefined && !/^\+?[0-9 ()-]{6,30}$/.test(data.phone)) errors.phone = 'Invalid phone number.'; if (data.type === 'company' && (creating || data.company_name !== undefined) && (typeof data.company_name !== 'string' || !data.company_name.trim())) { errors.company_name = 'Company name is required for company customers.'; } if (data.tax_id != null && (typeof data.tax_id !== 'string' || !/^[0-9]{10}$/.test(data.tax_id))) errors.tax_id = 'Tax ID (NIP) must consist of 10 digits.'; if (data.postal_code != null && !/^[0-9]{2}-[0-9]{3}$/.test(data.postal_code)) errors.postal_code = 'Postal code must be in format 00-000.'; if (data.country !== undefined && !/^[A-Z]{2}$/.test(data.country)) errors.country = 'Country must be a two-letter uppercase code.'; if (data.birth_date != null) { const date = typeof data.birth_date === 'string' && /^(\d{4})-(\d{2})-(\d{2})$/.exec(data.birth_date); const parsedDate = date ? new Date(Date.UTC(Number(date[1]), Number(date[2]) - 1, Number(date[3]))) : null; if (!parsedDate || parsedDate.toISOString().slice(0, 10) !== data.birth_date) { errors.birth_date = 'Birth date must be a valid date in YYYY-MM-DD format.'; } } return errors; } function validateCar(data, creating) { const errors = requireFields(data, creating ? ['customer_id', 'make', 'model', 'production_year', 'vin', 'registration_number', 'fuel_type'] : []); for (const field of ['make', 'model']) { if (data[field] !== undefined && (typeof data[field] !== 'string' || !data[field].trim() || data[field].length > 50)) errors[field] = 'This field is required and must be at most 50 characters.'; } for (const field of ['customer_id', 'production_year', 'engine_capacity', 'power_hp', 'mileage']) { if (data[field] !== undefined && data[field] !== null && !Number.isInteger(Number(data[field]))) errors[field] = 'Must be an integer.'; } if (data.production_year !== undefined && (Number(data.production_year) < 1900 || Number(data.production_year) > new Date().getFullYear() + 1)) errors.production_year = 'Production year is out of range.'; if (data.customer_id !== undefined && Number(data.customer_id) < 1) errors.customer_id = 'Customer ID must be positive.'; if (data.vin !== undefined && !/^[A-HJ-NPR-Z0-9]{17}$/i.test(data.vin)) errors.vin = 'VIN must have 17 characters (letters I, O, Q are not allowed).'; if (data.registration_number !== undefined && !/^[A-Z0-9 ]{4,10}$/i.test(data.registration_number)) errors.registration_number = 'Registration number must have 4-10 letters/digits.'; if (data.fuel_type !== undefined && !['petrol', 'diesel', 'lpg', 'hybrid', 'electric'].includes(data.fuel_type)) errors.fuel_type = 'Invalid fuel type.'; if (data.transmission != null && !['manual', 'automatic'].includes(data.transmission)) errors.transmission = 'Transmission must be manual or automatic.'; if (data.engine_capacity != null && (Number(data.engine_capacity) < 0 || Number(data.engine_capacity) > 10000)) errors.engine_capacity = 'Engine capacity must be between 0 and 10000.'; if (data.power_hp != null && (Number(data.power_hp) < 1 || Number(data.power_hp) > 2000)) errors.power_hp = 'Power must be between 1 and 2000.'; if (data.mileage != null && Number(data.mileage) < 0) errors.mileage = 'Mileage must not be negative.'; if (data.color != null && (typeof data.color !== 'string' || data.color.length > 30)) errors.color = 'Color must be at most 30 characters.'; if (data.notes != null && (typeof data.notes !== 'string' || data.notes.length > 5000)) errors.notes = 'Notes must be at most 5000 characters.'; if (typeof data.vin === 'string') data.vin = data.vin.toUpperCase().trim(); if (typeof data.registration_number === 'string') data.registration_number = data.registration_number.toUpperCase().replaceAll(' ', ''); return errors; } function pageOptions(query, sortable, defaultSort = 'id') { const page = Math.max(1, Number.parseInt(query.page, 10) || 1); const limit = Math.min(100, Math.max(1, Number.parseInt(query.limit, 10) || 20)); const sort = sortable.includes(query.sort) ? query.sort : defaultSort; const direction = String(query.direction).toLowerCase() === 'desc' ? 'DESC' : 'ASC'; return { page, limit, sort, direction, offset: (page - 1) * limit }; } async function list(res, model, options, include) { const { page, limit, sort, direction, offset, ...query } = options; const result = await model.findAndCountAll({ ...query, include, distinct: true, limit, offset, order: [[sort, direction]], }); const total = result.count; res.set('X-Total-Count', String(total)); return response(res, result.rows, 200, { meta: { page, limit, total, pages: Math.ceil(total / limit), has_next_page: page * limit < total, has_prev_page: page > 1, sort, direction: direction.toLowerCase(), }, }); } function notFound(res, resource) { return res.status(404).json({ success: false, error: { code: 404, message: `${resource} not found.` }, }); } function asyncRoute(handler) { return (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next); } module.exports = { USER_FIELDS, PROFILE_FIELDS, CUSTOMER_FIELDS, CAR_FIELDS, pick, cleanUser, response, validationResponse, validationFields, fail, requireFields, validateUser, validateCustomer, validateCar, pageOptions, list, notFound, asyncRoute, Op, };