first commit

This commit is contained in:
2026-08-26 22:39:33 +03:00
commit 2ade668e3e
10 changed files with 1640 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
const express = require('express');
const path = require('node:path');
const { Pool } = require('pg');
const app = express();
const port = Number(process.env.PORT || 3000);
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
app.get('/api/summary', async (_request, response) => {
try {
const result = await pool.query(`SELECT (SELECT COUNT(*)::int FROM customers) AS customers, (SELECT COUNT(*)::int FROM vehicles) AS vehicles, (SELECT COUNT(*)::int FROM service_orders WHERE status NOT IN ('closed', 'cancelled')) AS active_orders, (SELECT COUNT(*)::int FROM appointments WHERE scheduled_at >= CURRENT_DATE AND scheduled_at < CURRENT_DATE + INTERVAL '1 day' AND status NOT IN ('cancelled', 'no_show')) AS today_appointments`);
response.json(result.rows[0]);
} catch (error) { response.status(500).json({ error: 'Unable to load dashboard summary.' }); }
});
app.get('/api/appointments', async (_request, response) => {
try {
const result = await pool.query(`SELECT a.appointment_id, a.scheduled_at, a.reason, a.status, c.first_name || ' ' || c.last_name AS customer_name, v.registration_number, v.make, v.model FROM appointments a JOIN vehicles v ON v.vehicle_id = a.vehicle_id JOIN customers c ON c.customer_id = v.customer_id WHERE a.scheduled_at >= CURRENT_DATE AND a.status NOT IN ('cancelled', 'no_show') ORDER BY a.scheduled_at LIMIT 12`);
response.json(result.rows);
} catch (error) { response.status(500).json({ error: 'Unable to load appointments.' }); }
});
app.get('/api/orders', async (_request, response) => {
try {
const result = await pool.query(`SELECT so.service_order_id, so.status, so.complaint, so.opened_at, c.first_name || ' ' || c.last_name AS customer_name, v.registration_number, v.make, v.model, COALESCE(e.first_name || ' ' || e.last_name, 'Unassigned') AS technician FROM service_orders so JOIN vehicles v ON v.vehicle_id = so.vehicle_id JOIN customers c ON c.customer_id = v.customer_id LEFT JOIN employees e ON e.employee_id = so.assigned_employee_id WHERE so.status NOT IN ('closed', 'cancelled') ORDER BY so.opened_at DESC LIMIT 12`);
response.json(result.rows);
} catch (error) { response.status(500).json({ error: 'Unable to load service orders.' }); }
});
app.get('/api/service-records', async (_request, response) => {
try {
const result = await pool.query(`
SELECT so.service_order_id, so.status, so.complaint, so.opened_at, so.service_date,
btrim(v.vin) AS vin, v.registration_number AS license_plate, v.mileage,
c.first_name || ' ' || c.last_name AS customer_name, c.email AS customer_email,
COALESCE(e.first_name || ' ' || e.last_name, 'Unassigned') AS technician
FROM service_orders so
JOIN vehicles v ON v.vehicle_id = so.vehicle_id
JOIN customers c ON c.customer_id = v.customer_id
LEFT JOIN employees e ON e.employee_id = so.assigned_employee_id
ORDER BY so.opened_at DESC
LIMIT 100
`);
response.json(result.rows);
} catch (error) { response.status(500).json({ error: 'Unable to load service records.' }); }
});
app.post('/api/service-records', async (request, response) => {
const { customerName, customerEmail, vin, licensePlate, mileage, serviceDate, technicianName, complaint } = request.body;
const parsedMileage = Number(mileage);
if (!customerName || !vin || !licensePlate || !technicianName || !complaint || !serviceDate || !Number.isInteger(parsedMileage) || parsedMileage < 0) {
return response.status(400).json({ error: 'Customer, VIN, license plate, non-negative mileage, service date, technician, and complaint are required.' });
}
const customerParts = customerName.trim().split(/\s+/);
const firstName = customerParts.shift();
const lastName = customerParts.join(' ') || firstName;
const technicianParts = technicianName.trim().split(/\s+/);
const technicianFirstName = technicianParts.shift();
const technicianLastName = technicianParts.join(' ') || technicianFirstName;
const client = await pool.connect();
try {
await client.query('BEGIN');
const customer = await client.query(
`INSERT INTO customers (first_name, last_name, email) VALUES ($1, $2, NULLIF($3, '')) RETURNING customer_id`,
[firstName, lastName, customerEmail?.trim() || '']
);
const technician = await client.query(
`INSERT INTO employees (first_name, last_name, role) VALUES ($1, $2, 'technician') RETURNING employee_id`,
[technicianFirstName, technicianLastName]
);
const vehicle = await client.query(
`INSERT INTO vehicles (customer_id, registration_number, make, model, vin, mileage) VALUES ($1, $2, 'Not specified', 'Not specified', $3, $4) RETURNING vehicle_id`,
[customer.rows[0].customer_id, licensePlate.trim(), vin.trim(), parsedMileage]
);
const record = await client.query(
`INSERT INTO service_orders (vehicle_id, assigned_employee_id, complaint, service_date) VALUES ($1, $2, $3, $4) RETURNING service_order_id`,
[vehicle.rows[0].vehicle_id, technician.rows[0].employee_id, complaint.trim(), serviceDate]
);
await client.query('COMMIT');
response.status(201).json({ service_order_id: record.rows[0].service_order_id });
} catch (error) {
await client.query('ROLLBACK');
const status = error.code === '23505' ? 409 : 500;
response.status(status).json({ error: status === 409 ? 'That VIN or license plate is already registered.' : 'Unable to create service record.' });
} finally { client.release(); }
});
app.patch('/api/service-records/:id', async (request, response) => {
const { customerName, customerEmail, vin, licensePlate, mileage, serviceDate, technicianName, complaint } = request.body;
const parsedMileage = Number(mileage);
if (!customerName || !vin || !licensePlate || !technicianName || !complaint || !serviceDate || !Number.isInteger(parsedMileage) || parsedMileage < 0) return response.status(400).json({ error: 'Customer, VIN, license plate, non-negative mileage, service date, technician, and complaint are required.' });
const customerParts = customerName.trim().split(/\s+/);
const technicianParts = technicianName.trim().split(/\s+/);
const client = await pool.connect();
try {
await client.query('BEGIN');
const current = await client.query(`SELECT c.customer_id, v.vehicle_id, so.assigned_employee_id FROM service_orders so JOIN vehicles v ON v.vehicle_id = so.vehicle_id JOIN customers c ON c.customer_id = v.customer_id WHERE so.service_order_id = $1`, [request.params.id]);
if (!current.rowCount) { await client.query('ROLLBACK'); return response.status(404).json({ error: 'Service record not found.' }); }
const customerFirst = customerParts.shift();
const technicianFirst = technicianParts.shift();
await client.query('UPDATE customers SET first_name = $1, last_name = $2, email = NULLIF($3, \'\') WHERE customer_id = $4', [customerFirst, customerParts.join(' ') || customerFirst, customerEmail?.trim() || '', current.rows[0].customer_id]);
await client.query('UPDATE employees SET first_name = $1, last_name = $2 WHERE employee_id = $3', [technicianFirst, technicianParts.join(' ') || technicianFirst, current.rows[0].assigned_employee_id]);
await client.query('UPDATE vehicles SET vin = $1, registration_number = $2, mileage = $3 WHERE vehicle_id = $4', [vin.trim(), licensePlate.trim(), parsedMileage, current.rows[0].vehicle_id]);
await client.query('UPDATE service_orders SET complaint = $1, service_date = $2 WHERE service_order_id = $3', [complaint.trim(), serviceDate, request.params.id]);
await client.query('COMMIT');
response.json({ service_order_id: request.params.id });
} catch (error) {
await client.query('ROLLBACK');
response.status(error.code === '23505' ? 409 : 500).json({ error: error.code === '23505' ? 'That VIN or license plate is already registered.' : 'Unable to update service record.' });
} finally { client.release(); }
});
app.get('/api/customers', async (_request, response) => {
try {
const result = await pool.query(`SELECT c.customer_id, c.first_name, c.last_name, c.email, c.phone, c.created_at, COUNT(v.vehicle_id)::int AS vehicle_count FROM customers c LEFT JOIN vehicles v ON v.customer_id = c.customer_id GROUP BY c.customer_id ORDER BY c.created_at DESC LIMIT 50`);
response.json(result.rows);
} catch (error) { response.status(500).json({ error: 'Unable to load customers.' }); }
});
app.post('/api/customers', async (request, response) => {
const { firstName, lastName, email, phone } = request.body;
if (!firstName || !lastName) return response.status(400).json({ error: 'First name and last name are required.' });
try {
const result = await pool.query(`INSERT INTO customers (first_name, last_name, email, phone) VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, '')) RETURNING customer_id, first_name, last_name, email, phone`, [firstName.trim(), lastName.trim(), email?.trim() || '', phone?.trim() || '']);
response.status(201).json(result.rows[0]);
} catch (error) { response.status(error.code === '23505' ? 409 : 500).json({ error: error.code === '23505' ? 'That email is already registered.' : 'Unable to create customer.' }); }
});
app.patch('/api/orders/:id/status', async (request, response) => {
const allowedStatuses = ['open', 'in_progress', 'waiting_for_parts', 'ready', 'closed', 'cancelled'];
if (!allowedStatuses.includes(request.body.status)) return response.status(400).json({ error: 'Invalid service order status.' });
try {
const result = await pool.query(`UPDATE service_orders SET status = $1, closed_at = CASE WHEN $1 = 'closed' THEN NOW() ELSE NULL END WHERE service_order_id = $2 RETURNING service_order_id, status`, [request.body.status, request.params.id]);
if (!result.rowCount) return response.status(404).json({ error: 'Service order not found.' });
response.json(result.rows[0]);
} catch (error) { response.status(500).json({ error: 'Unable to update service order.' }); }
});
app.get('/api/users', async (_request, response) => {
try {
const result = await pool.query('SELECT user_id, username, display_name, email, role, can_read, can_add, can_delete, created_at FROM app_users ORDER BY created_at DESC');
response.json(result.rows);
} catch (error) { response.status(500).json({ error: 'Unable to load user accounts.' }); }
});
app.post('/api/users', async (request, response) => {
const { username, displayName, email, role, canRead, canAdd, canDelete } = request.body;
const allowedRoles = ['admin', 'service_advisor', 'technician', 'viewer'];
if (!username || !displayName || !allowedRoles.includes(role)) return response.status(400).json({ error: 'Username, display name, and a valid role are required.' });
try {
const result = await pool.query(`INSERT INTO app_users (username, display_name, email, role, can_read, can_add, can_delete) VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7) RETURNING user_id, username, display_name, email, role, can_read, can_add, can_delete, created_at`, [username.trim(), displayName.trim(), email?.trim() || '', role, canRead !== false, canAdd === true, canDelete === true]);
response.status(201).json(result.rows[0]);
} catch (error) { response.status(error.code === '23505' ? 409 : 500).json({ error: error.code === '23505' ? 'That username or email is already in use.' : 'Unable to create user account.' }); }
});
app.patch('/api/users/:id', async (request, response) => {
const { displayName, email, role, canRead, canAdd, canDelete } = request.body;
const allowedRoles = ['admin', 'service_advisor', 'technician', 'viewer'];
if (!displayName || !allowedRoles.includes(role)) return response.status(400).json({ error: 'Display name and a valid role are required.' });
try {
const result = await pool.query(`UPDATE app_users SET display_name = $1, email = NULLIF($2, ''), role = $3, can_read = $4, can_add = $5, can_delete = $6 WHERE user_id = $7 RETURNING user_id`, [displayName.trim(), email?.trim() || '', role, canRead === true, canAdd === true, canDelete === true, request.params.id]);
if (!result.rowCount) return response.status(404).json({ error: 'User account not found.' });
response.json(result.rows[0]);
} catch (error) { response.status(error.code === '23505' ? 409 : 500).json({ error: error.code === '23505' ? 'That email is already in use.' : 'Unable to update user account.' }); }
});
app.delete('/api/users/:id', async (request, response) => {
try {
const user = await pool.query('SELECT role FROM app_users WHERE user_id = $1', [request.params.id]);
if (!user.rowCount) return response.status(404).json({ error: 'User account not found.' });
if (user.rows[0].role === 'admin') {
const admins = await pool.query("SELECT COUNT(*)::int AS count FROM app_users WHERE role = 'admin'");
if (admins.rows[0].count <= 1) return response.status(409).json({ error: 'The last administrator cannot be deleted.' });
}
await pool.query('DELETE FROM app_users WHERE user_id = $1', [request.params.id]);
response.status(204).end();
} catch (error) { response.status(500).json({ error: 'Unable to delete user account.' }); }
});
app.use((_request, response) => response.sendFile(path.join(__dirname, 'public', 'index.html')));
async function start() {
await pool.query(`ALTER TABLE service_orders ADD COLUMN IF NOT EXISTS service_date DATE`);
await pool.query(`UPDATE service_orders SET service_date = opened_at::date WHERE service_date IS NULL`);
await pool.query(`ALTER TABLE service_orders ALTER COLUMN service_date SET DEFAULT CURRENT_DATE`);
await pool.query(`ALTER TABLE service_orders ALTER COLUMN service_date SET NOT NULL`);
await pool.query(`CREATE TABLE IF NOT EXISTS app_users (user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, email TEXT UNIQUE, role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'service_advisor', 'technician', 'viewer')), can_read BOOLEAN NOT NULL DEFAULT TRUE, can_add BOOLEAN NOT NULL DEFAULT FALSE, can_delete BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`);
await pool.query("INSERT INTO app_users (username, display_name, role, can_read, can_add, can_delete) VALUES ('admin', 'Workshop administrator', 'admin', TRUE, TRUE, TRUE) ON CONFLICT (username) DO NOTHING");
app.listen(port, () => console.log(`Car service web app listening on port ${port}`));
}
start().catch((error) => { console.error('Unable to initialize user accounts.', error); process.exit(1); });