Files
testRestApi/node/lib/api.js
T

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,
};