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:
448
api.js
Normal file
448
api.js
Normal 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]}`;
|
||||
}
|
||||
Reference in New Issue
Block a user