173 lines
9.4 KiB
JavaScript
173 lines
9.4 KiB
JavaScript
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,
|
|
};
|