Add Node.js/Express backend with PostgreSQL and wire frontend to API

- Server: Express.js with 13 API route files (auth, regulations, contacts,
  calendar, truck stops, bridges, weigh stations, alerts, load board,
  escort locator, orders, documents, contributions)
- Database: PostgreSQL with Prisma ORM, 15 models covering all modules
- Auth: JWT + bcrypt with role-based access control (driver/carrier/escort/admin)
- Geospatial: Haversine distance filtering on truck stops, bridges, escorts
- Seed script: Imports all existing mock data (51 states, contacts, equipment,
  truck stops, bridges, weigh stations, alerts, seasonal restrictions)
- Frontend: All 10 data-driven pages now fetch from /api instead of mock-data.js
- API client (api.js): Compatibility layer that transforms API responses to
  match existing frontend rendering code, minimizing page-level changes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Kovalevich
2026-03-30 15:43:27 -04:00
parent 260f7c4928
commit f917fb8014
35 changed files with 4964 additions and 193 deletions

448
api.js Normal file
View File

@@ -0,0 +1,448 @@
// =====================================================================
// PilotEdge API Client
// Fetches data from the backend API and transforms responses to match
// the shapes expected by the existing frontend rendering code.
// Replace mock-data.js and mock-data-extended.js with this file.
// =====================================================================
const API_BASE = '/api';
const PilotEdge = {
// Auth token management
_token: localStorage.getItem('pilotedge_token'),
setToken(token) {
this._token = token;
if (token) localStorage.setItem('pilotedge_token', token);
else localStorage.removeItem('pilotedge_token');
},
getToken() {
return this._token;
},
getUser() {
const raw = localStorage.getItem('pilotedge_user');
return raw ? JSON.parse(raw) : null;
},
setUser(user) {
if (user) localStorage.setItem('pilotedge_user', JSON.stringify(user));
else localStorage.removeItem('pilotedge_user');
},
// Core fetch wrapper
async request(path, options = {}) {
const headers = { 'Content-Type': 'application/json', ...options.headers };
if (this._token) headers['Authorization'] = `Bearer ${this._token}`;
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const err = new Error(body.error || `API error ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
},
async get(path) { return this.request(path); },
async post(path, data) { return this.request(path, { method: 'POST', body: JSON.stringify(data) }); },
async put(path, data) { return this.request(path, { method: 'PUT', body: JSON.stringify(data) }); },
async del(path) { return this.request(path, { method: 'DELETE' }); },
// -----------------------------------------------------------------
// Auth
// -----------------------------------------------------------------
async register(email, password, name, role) {
const res = await this.post('/auth/register', { email, password, name, role });
this.setToken(res.token);
this.setUser(res.user);
return res;
},
async login(email, password) {
const res = await this.post('/auth/login', { email, password });
this.setToken(res.token);
this.setUser(res.user);
return res;
},
logout() {
this.setToken(null);
this.setUser(null);
},
async me() { return this.get('/auth/me'); },
// -----------------------------------------------------------------
// Regulations — returns MOCK_STATE_REGULATIONS-compatible shape
// -----------------------------------------------------------------
async getRegulations() {
const states = await this.get('/regulations');
return states.map(s => ({
name: s.name,
abbr: s.abbr,
lat: s.lat,
lng: s.lng,
permitWidth: s.regulation?.permitWidth || '',
permitHeight: s.regulation?.permitHeight || '',
permitLength: s.regulation?.permitLength || '',
permitWeight: s.regulation?.permitWeight || '',
escortWidth: s.regulation?.escortWidth || '',
escortHeight: s.regulation?.escortHeight || '',
escortLength: s.regulation?.escortLength || '',
escortWeight: s.regulation?.escortWeight || '',
travel: s.regulation?.travelRestrictions || '',
holidays: s.regulation?.holidays || '',
agency: s.regulation?.agency || '',
url: s.regulation?.url || '',
notes: s.regulation?.notes || '',
}));
},
// -----------------------------------------------------------------
// Equipment — returns MOCK_STATE_EQUIPMENT-compatible shape
// Object keyed by state abbr: { TX: { escort: {...}, carrier: {...} } }
// -----------------------------------------------------------------
async getEquipment() {
const states = await this.get('/regulations');
const equipment = {};
for (const s of states) {
if (!s.equipmentRequirements) {
const full = await this.get(`/regulations/${s.abbr}`);
s.equipmentRequirements = full.equipmentRequirements || [];
}
if (s.equipmentRequirements.length > 0) {
equipment[s.abbr] = {};
for (const eq of s.equipmentRequirements) {
const obj = {
certification: eq.certification || '',
vehicle: eq.vehicle || '',
signs: eq.signs || '',
lights: eq.lights || '',
heightPole: eq.heightPole || '',
flags: eq.flags || '',
communication: eq.communication || '',
safety: eq.safetyGear || '',
};
if (eq.type === 'escort') equipment[s.abbr].escort = obj;
else if (eq.type === 'carrier') {
// Parse carrier safetyGear back to individual fields
const gear = eq.safetyGear || '';
equipment[s.abbr].carrier = {
signs: eq.signs || '',
flags: eq.flags || '',
lights: eq.lights || '',
cones: extractGearField(gear, 'Cones'),
fireExtinguisher: extractGearField(gear, 'Fire ext'),
triangles: extractGearField(gear, 'Triangles'),
flares: extractGearField(gear, 'Flares'),
firstAid: extractGearField(gear, 'First aid'),
};
}
}
}
}
return equipment;
},
// -----------------------------------------------------------------
// Contacts — returns MOCK_STATE_CONTACTS-compatible shape
// Object keyed by state abbr: { AL: { name, permit, police, email, hours, portal } }
// -----------------------------------------------------------------
async getContacts() {
const contacts = await this.get('/contacts');
const result = {};
for (const c of contacts) {
result[c.state.abbr] = {
name: c.state.name,
permit: c.permitPhone,
police: c.policePhone,
email: c.email,
hours: c.hours,
portal: c.portalUrl,
};
}
return result;
},
// -----------------------------------------------------------------
// Calendar — returns MOCK_SEASONAL_RESTRICTIONS-compatible shape
// -----------------------------------------------------------------
async getSeasonalRestrictions() {
const restrictions = await this.get('/calendar');
return restrictions.map(r => ({
id: r.id,
state: r.state?.abbr || '',
stateName: r.state?.name || '',
type: r.type,
title: r.name,
startMonth: r.startMonth,
startDay: 1,
endMonth: r.endMonth,
endDay: 28,
description: r.description,
color: getRestrictionColor(r.type),
routes: '',
impact: '',
}));
},
// -----------------------------------------------------------------
// Truck Stops — returns MOCK_TRUCK_STOPS-compatible shape
// -----------------------------------------------------------------
async getTruckStops() {
const stops = await this.get('/truckstops');
return stops.map(ts => ({
id: ts.id,
name: ts.name,
type: 'truck_stop',
location: {
city: ts.address?.split(',')[0]?.trim() || '',
state: ts.state?.abbr || '',
lat: ts.lat,
lng: ts.lng,
},
oversizeFriendly: ts.hasOversizeParking,
entranceWidth: ts.entranceWidth || '',
entranceHeight: ts.entranceHeight || '',
lotSize: ts.lotSqFt ? `${ts.lotSqFt} sq ft` : '',
oversizeCapacity: '',
facilities: ts.facilities || [],
description: '',
comments: (ts.contributions || []).map(c => ({
user: c.user?.name || 'Anonymous',
date: c.createdAt,
text: c.content,
})),
}));
},
// -----------------------------------------------------------------
// Bridges — returns MOCK_BRIDGE_CLEARANCES-compatible shape
// -----------------------------------------------------------------
async getBridges() {
const bridges = await this.get('/bridges');
return bridges.map(b => ({
id: b.id,
route: b.route,
mileMarker: '',
type: b.name.split(' at ')[0] || 'Bridge',
location: {
desc: b.name.split(' at ')[1] || b.name,
city: '',
state: b.state?.abbr || '',
lat: b.lat,
lng: b.lng,
},
clearanceHeight: `${b.heightClearance}'`,
clearanceWidth: b.widthClearance ? `${b.widthClearance}'` : 'Unrestricted',
weightLimit: b.weightLimit ? `${b.weightLimit} lbs` : 'No posted limit',
notes: '',
}));
},
// -----------------------------------------------------------------
// Weigh Stations — returns MOCK_WEIGH_STATIONS-compatible shape
// -----------------------------------------------------------------
async getWeighStations() {
const stations = await this.get('/weighstations');
return stations.map(ws => ({
id: ws.id,
name: ws.name,
route: ws.route,
location: {
city: '',
state: ws.state?.abbr || '',
lat: ws.lat,
lng: ws.lng,
},
hours: ws.hours,
prePass: ws.prePass,
currentStatus: ws.currentStatus,
direction: ws.direction,
lastFlagged: ws.lastStatusUpdate || null,
flaggedBy: '',
notes: '',
}));
},
// -----------------------------------------------------------------
// Alerts — returns MOCK_ROUTE_CONDITIONS + MOCK_WEATHER_ALERTS shapes
// -----------------------------------------------------------------
async getAlerts() {
const alerts = await this.get('/alerts');
const routeConditions = [];
const weatherAlerts = [];
for (const a of alerts) {
if (a.type === 'weather' || a.type === 'wind') {
weatherAlerts.push({
id: a.id,
type: a.type,
severity: a.severity,
region: a.state?.name || '',
routes: a.route ? a.route.split(', ') : [],
description: a.description,
validFrom: a.startsAt,
validTo: a.endsAt,
source: 'NWS',
lat: 0,
lng: 0,
});
} else {
routeConditions.push({
id: a.id,
type: a.type,
severity: a.severity,
route: a.route,
location: {
desc: a.description.substring(0, 60),
state: a.state?.abbr || '',
lat: 0,
lng: 0,
},
description: a.description,
startDate: a.startsAt,
endDate: a.endsAt,
source: 'State DOT',
affectsOversize: true,
});
}
}
return { routeConditions, weatherAlerts };
},
// -----------------------------------------------------------------
// Load Board — returns MOCK_LOAD_BOARD-compatible shape
// -----------------------------------------------------------------
async getLoads() {
const data = await this.get('/loads?limit=100');
return (data.loads || []).map(l => ({
id: l.id,
carrier: l.poster?.name || 'Unknown',
origin: parseLocation(l.origin),
destination: parseLocation(l.destination),
departureDate: l.pickupDate,
dimensions: {
width: l.width,
height: l.height,
length: l.length,
weight: l.weight,
},
description: l.description,
escortsNeeded: l.escortsNeeded,
status: l.status,
postedDate: l.createdAt,
contact: l.poster?.name || '',
}));
},
// -----------------------------------------------------------------
// Escort Operators — returns MOCK_ESCORT_OPERATORS-compatible shape
// -----------------------------------------------------------------
async getEscortOperators() {
const escorts = await this.get('/escorts');
return escorts.map(e => ({
id: e.id,
name: e.user?.name || 'Unknown',
location: {
city: '',
state: '',
lat: e.lat,
lng: e.lng,
},
status: e.availability,
certifications: e.certifications || [],
vehicleType: e.vehicleType,
rating: e.rating,
totalJobs: e.ratingCount || 0,
experience: '',
contact: e.user?.email || '',
phone: e.phone,
bio: e.bio,
}));
},
// -----------------------------------------------------------------
// Documents — returns MOCK_DOCUMENTS-compatible shape
// -----------------------------------------------------------------
async getDocuments() {
try {
const docs = await this.get('/documents');
return docs.map(d => ({
id: d.id,
name: d.filename,
type: d.type,
state: '',
uploadDate: d.createdAt,
expiryDate: d.expiresAt,
fileSize: formatFileSize(d.sizeBytes),
status: d.expiresAt && new Date(d.expiresAt) < new Date() ? 'expired' : 'active',
}));
} catch (err) {
// If not authenticated, return empty array
if (err.status === 401) return [];
throw err;
}
},
// -----------------------------------------------------------------
// Orders
// -----------------------------------------------------------------
async submitOrder(orderData) {
return this.post('/orders', orderData);
},
async getOrders() {
return this.get('/orders');
},
// -----------------------------------------------------------------
// Contributions
// -----------------------------------------------------------------
async submitContribution(entityType, entityId, type, content) {
return this.post('/contributions', { entityType, entityId, type, content });
},
};
// -----------------------------------------------------------------
// Helper functions
// -----------------------------------------------------------------
function extractGearField(gear, label) {
const match = gear.match(new RegExp(`${label}:\\s*([^;]+)`));
return match ? match[1].trim() : '';
}
function getRestrictionColor(type) {
const colors = {
spring_weight: '#3b82f6',
winter_closure: '#8b5cf6',
harvest: '#f59e0b',
holiday_blackout: '#ef4444',
};
return colors[type] || '#6b7280';
}
function parseLocation(str) {
// Parse "City, ST" into { city, state, lat: 0, lng: 0 }
if (!str) return { city: '', state: '', lat: 0, lng: 0 };
const parts = str.split(',').map(s => s.trim());
return {
city: parts[0] || '',
state: parts[1] || '',
lat: 0,
lng: 0,
};
}
function formatFileSize(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let size = bytes;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return `${Math.round(size * 10) / 10} ${units[i]}`;
}