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

View File

@@ -119,14 +119,18 @@
<div id="main-footer"></div> <div id="main-footer"></div>
<script src="mock-data.js"></script> <script src="api.js"></script>
<script src="mock-data-extended.js"></script>
<script src="nav.js"></script> <script src="nav.js"></script>
<script> <script>
renderNav('alerts'); renderNav('alerts');
renderBanner(); renderBanner();
renderFooter(); renderFooter();
(async () => {
const alertData = await PilotEdge.getAlerts();
const MOCK_ROUTE_CONDITIONS = alertData.routeConditions;
const MOCK_WEATHER_ALERTS = alertData.weatherAlerts;
// ── State ── // ── State ──
let activeTab = 'all'; let activeTab = 'all';
let map, markersLayer; let map, markersLayer;
@@ -413,6 +417,7 @@
document.getElementById('filter-type').addEventListener('change', renderAll); document.getElementById('filter-type').addEventListener('change', renderAll);
document.getElementById('filter-severity').addEventListener('change', renderAll); document.getElementById('filter-severity').addEventListener('change', renderAll);
document.getElementById('filter-oversize').addEventListener('change', renderAll); document.getElementById('filter-oversize').addEventListener('change', renderAll);
})();
</script> </script>
</body> </body>
</html> </html>

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]}`;
}

View File

@@ -112,14 +112,16 @@
<div id="main-footer"></div> <div id="main-footer"></div>
<script src="mock-data.js"></script> <script src="api.js"></script>
<script src="mock-data-extended.js"></script>
<script src="nav.js"></script> <script src="nav.js"></script>
<script> <script>
renderNav('bridges'); renderNav('bridges');
renderBanner(); renderBanner();
renderFooter(); renderFooter();
(async () => {
const MOCK_BRIDGE_CLEARANCES = await PilotEdge.getBridges();
// --- Helpers --- // --- Helpers ---
// Parse a clearance string like '13\'6"' or '14\'0"' into decimal feet. // Parse a clearance string like '13\'6"' or '14\'0"' into decimal feet.
@@ -380,6 +382,7 @@
// --- Initial render --- // --- Initial render ---
applyFilters(); applyFilters();
})();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -85,14 +85,16 @@
<div id="main-footer"></div> <div id="main-footer"></div>
<script src="mock-data.js"></script> <script src="api.js"></script>
<script src="mock-data-extended.js"></script>
<script src="nav.js"></script> <script src="nav.js"></script>
<script> <script>
renderNav('calendar'); renderNav('calendar');
renderBanner(); renderBanner();
renderFooter(); renderFooter();
(async () => {
const MOCK_SEASONAL_RESTRICTIONS = await PilotEdge.getSeasonalRestrictions();
// ---- Constants ---- // ---- Constants ----
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
@@ -286,6 +288,7 @@
// ---- Initial render ---- // ---- Initial render ----
applyFilters(); applyFilters();
})();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -42,14 +42,16 @@
<div id="main-footer"></div> <div id="main-footer"></div>
<script src="mock-data.js"></script> <script src="api.js"></script>
<script src="mock-data-extended.js"></script>
<script src="nav.js"></script> <script src="nav.js"></script>
<script> <script>
renderNav('contacts'); renderNav('contacts');
renderBanner(); renderBanner();
renderFooter(); renderFooter();
(async () => {
const MOCK_STATE_CONTACTS = await PilotEdge.getContacts();
const grid = document.getElementById('contacts-grid'); const grid = document.getElementById('contacts-grid');
const noResults = document.getElementById('no-results'); const noResults = document.getElementById('no-results');
const searchInput = document.getElementById('state-search'); const searchInput = document.getElementById('state-search');
@@ -103,6 +105,7 @@
searchInput.addEventListener('input', () => renderCards(searchInput.value)); searchInput.addEventListener('input', () => renderCards(searchInput.value));
renderCards(); renderCards();
})();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -105,14 +105,16 @@
<div id="main-footer"></div> <div id="main-footer"></div>
<script src="mock-data.js"></script> <script src="api.js"></script>
<script src="mock-data-extended.js"></script>
<script src="nav.js"></script> <script src="nav.js"></script>
<script> <script>
renderNav('documents'); renderNav('documents');
renderBanner(); renderBanner();
renderFooter(); renderFooter();
(async () => {
const MOCK_DOCUMENTS = await PilotEdge.getDocuments();
// ── Helpers ────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────
const today = new Date(); const today = new Date();
const MS_PER_DAY = 86400000; const MS_PER_DAY = 86400000;
@@ -254,6 +256,7 @@
renderStats(MOCK_DOCUMENTS); renderStats(MOCK_DOCUMENTS);
renderExpiryWarnings(MOCK_DOCUMENTS); renderExpiryWarnings(MOCK_DOCUMENTS);
renderDocuments(MOCK_DOCUMENTS); renderDocuments(MOCK_DOCUMENTS);
})();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -141,13 +141,16 @@
<div id="main-footer"></div> <div id="main-footer"></div>
<script src="mock-data.js"></script> <script src="api.js"></script>
<script src="nav.js"></script> <script src="nav.js"></script>
<script> <script>
renderNav('loadboard'); renderNav('loadboard');
renderBanner(); renderBanner();
renderFooter(); renderFooter();
(async () => {
const MOCK_LOAD_BOARD = await PilotEdge.getLoads();
function getStatusBadge(status) { function getStatusBadge(status) {
if (status === 'posted') return '<span class="bg-green-100 text-green-800 text-xs font-bold px-3 py-1 rounded-full">AVAILABLE</span>'; if (status === 'posted') return '<span class="bg-green-100 text-green-800 text-xs font-bold px-3 py-1 rounded-full">AVAILABLE</span>';
if (status === 'in_transit') return '<span class="bg-blue-100 text-blue-800 text-xs font-bold px-3 py-1 rounded-full">IN TRANSIT</span>'; if (status === 'in_transit') return '<span class="bg-blue-100 text-blue-800 text-xs font-bold px-3 py-1 rounded-full">IN TRANSIT</span>';
@@ -293,6 +296,7 @@
document.getElementById('post-modal').addEventListener('click', function(e) { document.getElementById('post-modal').addEventListener('click', function(e) {
if (e.target === this) closePostModal(); if (e.target === this) closePostModal();
}); });
})();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -115,13 +115,16 @@
<div id="main-footer"></div> <div id="main-footer"></div>
<script src="mock-data.js"></script> <script src="api.js"></script>
<script src="nav.js"></script> <script src="nav.js"></script>
<script> <script>
renderNav('locator'); renderNav('locator');
renderBanner(); renderBanner();
renderFooter(); renderFooter();
(async () => {
const MOCK_ESCORT_OPERATORS = await PilotEdge.getEscortOperators();
// Populate certification filter // Populate certification filter
const allCerts = new Set(); const allCerts = new Set();
MOCK_ESCORT_OPERATORS.forEach(op => op.certifications.forEach(c => allCerts.add(c))); MOCK_ESCORT_OPERATORS.forEach(op => op.certifications.forEach(c => allCerts.add(c)));
@@ -296,6 +299,7 @@
// Initial render // Initial render
filterOperators(); filterOperators();
})();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -139,14 +139,17 @@
<div id="main-footer"></div> <div id="main-footer"></div>
<script src="mock-data.js"></script> <script src="api.js"></script>
<script src="mock-data-extended.js"></script>
<script src="nav.js"></script> <script src="nav.js"></script>
<script> <script>
renderNav('regulations'); renderNav('regulations');
renderBanner(); renderBanner();
renderFooter(); renderFooter();
(async () => {
const MOCK_STATE_REGULATIONS = await PilotEdge.getRegulations();
const MOCK_STATE_EQUIPMENT = await PilotEdge.getEquipment();
// Initialize map centered on continental US // Initialize map centered on continental US
const map = L.map('map').setView([39.5, -98.5], 4); const map = L.map('map').setView([39.5, -98.5], 4);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
@@ -435,6 +438,7 @@
</div> </div>
`; `;
} }
})();
</script> </script>
</body> </body>
</html> </html>

3
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.env
uploads/

2146
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
server/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "pilotedge-server",
"version": "1.0.0",
"description": "PilotEdge backend API — Oversize Load Resource Platform",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"db:migrate": "npx prisma migrate dev",
"db:seed": "node src/seeds/seed.js",
"db:reset": "npx prisma migrate reset --force",
"db:studio": "npx prisma studio"
},
"dependencies": {
"@prisma/client": "^6.6.0",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.2"
},
"devDependencies": {
"prisma": "^6.6.0"
}
}

View File

@@ -0,0 +1,308 @@
-- CreateTable
CREATE TABLE "states" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"abbr" VARCHAR(2) NOT NULL,
"lat" DOUBLE PRECISION NOT NULL,
"lng" DOUBLE PRECISION NOT NULL,
CONSTRAINT "states_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "regulations" (
"id" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
"permitWidth" TEXT NOT NULL,
"permitHeight" TEXT NOT NULL,
"permitLength" TEXT NOT NULL,
"permitWeight" TEXT NOT NULL,
"escortWidth" TEXT NOT NULL,
"escortHeight" TEXT NOT NULL,
"escortLength" TEXT NOT NULL,
"escortWeight" TEXT NOT NULL,
"travelRestrictions" TEXT NOT NULL,
"holidays" TEXT NOT NULL,
"agency" TEXT NOT NULL,
"url" TEXT NOT NULL,
"notes" TEXT NOT NULL,
CONSTRAINT "regulations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "equipment_requirements" (
"id" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"certification" TEXT NOT NULL DEFAULT '',
"vehicle" TEXT NOT NULL DEFAULT '',
"signs" TEXT NOT NULL DEFAULT '',
"lights" TEXT NOT NULL DEFAULT '',
"heightPole" TEXT NOT NULL DEFAULT '',
"flags" TEXT NOT NULL DEFAULT '',
"safetyGear" TEXT NOT NULL DEFAULT '',
"communication" TEXT NOT NULL DEFAULT '',
CONSTRAINT "equipment_requirements_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "contacts" (
"id" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
"permitPhone" TEXT NOT NULL,
"policePhone" TEXT NOT NULL,
"email" TEXT NOT NULL,
"hours" TEXT NOT NULL,
"portalUrl" TEXT NOT NULL,
CONSTRAINT "contacts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "seasonal_restrictions" (
"id" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"startMonth" INTEGER NOT NULL,
"endMonth" INTEGER NOT NULL,
"description" TEXT NOT NULL,
CONSTRAINT "seasonal_restrictions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "truck_stops" (
"id" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"lat" DOUBLE PRECISION NOT NULL,
"lng" DOUBLE PRECISION NOT NULL,
"address" TEXT NOT NULL,
"hasOversizeParking" BOOLEAN NOT NULL DEFAULT false,
"entranceHeight" TEXT NOT NULL DEFAULT '',
"entranceWidth" TEXT NOT NULL DEFAULT '',
"lotSqFt" INTEGER,
"facilities" JSONB NOT NULL DEFAULT '[]',
"satelliteUrl" TEXT NOT NULL DEFAULT '',
"phone" TEXT NOT NULL DEFAULT '',
CONSTRAINT "truck_stops_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "bridges" (
"id" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"lat" DOUBLE PRECISION NOT NULL,
"lng" DOUBLE PRECISION NOT NULL,
"route" TEXT NOT NULL,
"heightClearance" DOUBLE PRECISION NOT NULL,
"widthClearance" DOUBLE PRECISION,
"weightLimit" DOUBLE PRECISION,
"lastVerified" TIMESTAMP(3),
CONSTRAINT "bridges_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "weigh_stations" (
"id" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"lat" DOUBLE PRECISION NOT NULL,
"lng" DOUBLE PRECISION NOT NULL,
"direction" TEXT NOT NULL,
"route" TEXT NOT NULL,
"prePass" BOOLEAN NOT NULL DEFAULT false,
"hours" TEXT NOT NULL DEFAULT '',
"currentStatus" TEXT NOT NULL DEFAULT 'unknown',
"lastStatusUpdate" TIMESTAMP(3),
"lastStatusUserId" TEXT,
CONSTRAINT "weigh_stations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'driver',
"tier" TEXT NOT NULL DEFAULT 'free',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "escort_profiles" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"lat" DOUBLE PRECISION NOT NULL,
"lng" DOUBLE PRECISION NOT NULL,
"radiusMiles" INTEGER NOT NULL DEFAULT 100,
"certifications" JSONB NOT NULL DEFAULT '[]',
"vehicleType" TEXT NOT NULL DEFAULT '',
"availability" TEXT NOT NULL DEFAULT 'available',
"rating" DOUBLE PRECISION NOT NULL DEFAULT 0,
"ratingCount" INTEGER NOT NULL DEFAULT 0,
"phone" TEXT NOT NULL DEFAULT '',
"bio" TEXT NOT NULL DEFAULT '',
CONSTRAINT "escort_profiles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "loads" (
"id" TEXT NOT NULL,
"posterId" TEXT NOT NULL,
"origin" TEXT NOT NULL,
"destination" TEXT NOT NULL,
"pickupDate" TIMESTAMP(3) NOT NULL,
"width" TEXT NOT NULL,
"height" TEXT NOT NULL,
"length" TEXT NOT NULL,
"weight" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"escortsNeeded" INTEGER NOT NULL DEFAULT 1,
"status" TEXT NOT NULL DEFAULT 'open',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "loads_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "orders" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"origin" TEXT NOT NULL,
"destination" TEXT NOT NULL,
"pickupDate" TIMESTAMP(3) NOT NULL,
"width" TEXT NOT NULL DEFAULT '',
"height" TEXT NOT NULL DEFAULT '',
"length" TEXT NOT NULL DEFAULT '',
"weight" TEXT NOT NULL DEFAULT '',
"loadType" TEXT NOT NULL DEFAULT '',
"escortsNeeded" INTEGER NOT NULL DEFAULT 1,
"status" TEXT NOT NULL DEFAULT 'pending',
"notes" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "orders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "documents" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"filepath" TEXT NOT NULL,
"mimeType" TEXT NOT NULL DEFAULT '',
"sizeBytes" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "documents_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "contributions" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"truckStopId" TEXT,
"weighStationId" TEXT,
CONSTRAINT "contributions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "alerts" (
"id" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"route" TEXT NOT NULL,
"description" TEXT NOT NULL,
"severity" TEXT NOT NULL DEFAULT 'info',
"startsAt" TIMESTAMP(3) NOT NULL,
"endsAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "alerts_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "states_name_key" ON "states"("name");
-- CreateIndex
CREATE UNIQUE INDEX "states_abbr_key" ON "states"("abbr");
-- CreateIndex
CREATE UNIQUE INDEX "regulations_stateId_key" ON "regulations"("stateId");
-- CreateIndex
CREATE UNIQUE INDEX "contacts_stateId_key" ON "contacts"("stateId");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "escort_profiles_userId_key" ON "escort_profiles"("userId");
-- AddForeignKey
ALTER TABLE "regulations" ADD CONSTRAINT "regulations_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "states"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "equipment_requirements" ADD CONSTRAINT "equipment_requirements_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "states"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "contacts" ADD CONSTRAINT "contacts_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "states"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "seasonal_restrictions" ADD CONSTRAINT "seasonal_restrictions_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "states"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "truck_stops" ADD CONSTRAINT "truck_stops_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "states"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bridges" ADD CONSTRAINT "bridges_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "states"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "weigh_stations" ADD CONSTRAINT "weigh_stations_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "states"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "escort_profiles" ADD CONSTRAINT "escort_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "loads" ADD CONSTRAINT "loads_posterId_fkey" FOREIGN KEY ("posterId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "orders" ADD CONSTRAINT "orders_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "documents" ADD CONSTRAINT "documents_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "contributions" ADD CONSTRAINT "contributions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "contributions" ADD CONSTRAINT "contributions_truckStopId_fkey" FOREIGN KEY ("truckStopId") REFERENCES "truck_stops"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "contributions" ADD CONSTRAINT "contributions_weighStationId_fkey" FOREIGN KEY ("weighStationId") REFERENCES "weigh_stations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "alerts" ADD CONSTRAINT "alerts_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "states"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

312
server/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,312 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// =====================================================================
// Core Reference Data
// =====================================================================
model State {
id String @id @default(cuid())
name String @unique
abbr String @unique @db.VarChar(2)
lat Float
lng Float
regulation Regulation?
equipmentRequirements EquipmentRequirement[]
contact Contact?
seasonalRestrictions SeasonalRestriction[]
truckStops TruckStop[]
bridges Bridge[]
weighStations WeighStation[]
alerts Alert[]
@@map("states")
}
model Regulation {
id String @id @default(cuid())
stateId String @unique
state State @relation(fields: [stateId], references: [id], onDelete: Cascade)
permitWidth String
permitHeight String
permitLength String
permitWeight String
escortWidth String
escortHeight String
escortLength String
escortWeight String
travelRestrictions String
holidays String
agency String
url String
notes String @db.Text
@@map("regulations")
}
model EquipmentRequirement {
id String @id @default(cuid())
stateId String
state State @relation(fields: [stateId], references: [id], onDelete: Cascade)
type String // "escort" or "carrier"
certification String @default("")
vehicle String @default("")
signs String @default("")
lights String @default("")
heightPole String @default("")
flags String @default("")
safetyGear String @default("") @db.Text
communication String @default("")
@@map("equipment_requirements")
}
model Contact {
id String @id @default(cuid())
stateId String @unique
state State @relation(fields: [stateId], references: [id], onDelete: Cascade)
permitPhone String
policePhone String
email String
hours String
portalUrl String
@@map("contacts")
}
model SeasonalRestriction {
id String @id @default(cuid())
stateId String
state State @relation(fields: [stateId], references: [id], onDelete: Cascade)
name String
type String // "spring_weight", "winter_closure", "harvest", "holiday_blackout"
startMonth Int
endMonth Int
description String @db.Text
@@map("seasonal_restrictions")
}
// =====================================================================
// Geospatial Entities
// =====================================================================
model TruckStop {
id String @id @default(cuid())
stateId String
state State @relation(fields: [stateId], references: [id], onDelete: Cascade)
name String
lat Float
lng Float
address String
hasOversizeParking Boolean @default(false)
entranceHeight String @default("")
entranceWidth String @default("")
lotSqFt Int?
facilities Json @default("[]") // ["fuel","food","showers","restrooms"]
satelliteUrl String @default("")
phone String @default("")
contributions Contribution[]
@@map("truck_stops")
}
model Bridge {
id String @id @default(cuid())
stateId String
state State @relation(fields: [stateId], references: [id], onDelete: Cascade)
name String
lat Float
lng Float
route String
heightClearance Float // in feet
widthClearance Float? // in feet, null = unrestricted
weightLimit Float? // in lbs, null = unrestricted
lastVerified DateTime?
@@map("bridges")
}
model WeighStation {
id String @id @default(cuid())
stateId String
state State @relation(fields: [stateId], references: [id], onDelete: Cascade)
name String
lat Float
lng Float
direction String // "NB", "SB", "EB", "WB"
route String
prePass Boolean @default(false)
hours String @default("")
currentStatus String @default("unknown") // "open", "closed", "unknown"
lastStatusUpdate DateTime?
lastStatusUserId String?
contributions Contribution[]
@@map("weigh_stations")
}
// =====================================================================
// Users & Auth
// =====================================================================
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
role String @default("driver") // "driver", "carrier", "escort", "admin"
tier String @default("free") // "free", "subscriber", "premium"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
escortProfile EscortProfile?
loads Load[]
orders Order[]
documents Document[]
contributions Contribution[]
@@map("users")
}
model EscortProfile {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lat Float
lng Float
radiusMiles Int @default(100)
certifications Json @default("[]") // ["TX","OK","CA"]
vehicleType String @default("")
availability String @default("available") // "available", "on_job", "unavailable"
rating Float @default(0)
ratingCount Int @default(0)
phone String @default("")
bio String @default("") @db.Text
@@map("escort_profiles")
}
// =====================================================================
// Transactional Data
// =====================================================================
model Load {
id String @id @default(cuid())
posterId String
poster User @relation(fields: [posterId], references: [id], onDelete: Cascade)
origin String
destination String
pickupDate DateTime
width String
height String
length String
weight String
description String @default("") @db.Text
escortsNeeded Int @default(1)
status String @default("open") // "open", "assigned", "in_transit", "delivered", "cancelled"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("loads")
}
model Order {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Load details (embedded, since orders may not reference a load board listing)
origin String
destination String
pickupDate DateTime
width String @default("")
height String @default("")
length String @default("")
weight String @default("")
loadType String @default("")
escortsNeeded Int @default(1)
status String @default("pending") // "pending", "confirmed", "in_progress", "completed", "cancelled"
notes String @default("") @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("orders")
}
model Document {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // "permit", "insurance", "certification", "license", "other"
filename String
filepath String
mimeType String @default("")
sizeBytes Int @default(0)
expiresAt DateTime?
createdAt DateTime @default(now())
@@map("documents")
}
model Contribution {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
entityType String // "truck_stop", "weigh_station", "bridge", "regulation"
entityId String
type String // "info", "flag", "confirm"
content String @db.Text
createdAt DateTime @default(now())
// Optional relations (polymorphic — only one will be set)
truckStop TruckStop? @relation(fields: [truckStopId], references: [id])
truckStopId String?
weighStation WeighStation? @relation(fields: [weighStationId], references: [id])
weighStationId String?
@@map("contributions")
}
// =====================================================================
// Alerts
// =====================================================================
model Alert {
id String @id @default(cuid())
stateId String
state State @relation(fields: [stateId], references: [id], onDelete: Cascade)
type String // "construction", "closure", "weather", "wind"
route String
description String @db.Text
severity String @default("info") // "info", "warning", "critical"
startsAt DateTime
endsAt DateTime?
createdAt DateTime @default(now())
@@map("alerts")
}

7
server/src/config/db.js Normal file
View File

@@ -0,0 +1,7 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});
module.exports = prisma;

46
server/src/index.js Normal file
View File

@@ -0,0 +1,46 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const errorHandler = require('./middleware/errorHandler');
const app = express();
const PORT = process.env.PORT || 3000;
// --------------- Middleware ---------------
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Serve the frontend (static HTML/JS/CSS from project root)
app.use(express.static(path.join(__dirname, '..', '..')));
// --------------- API Routes ---------------
app.use('/api/auth', require('./routes/auth'));
app.use('/api/regulations', require('./routes/regulations'));
app.use('/api/contacts', require('./routes/contacts'));
app.use('/api/calendar', require('./routes/calendar'));
app.use('/api/truckstops', require('./routes/truckstops'));
app.use('/api/bridges', require('./routes/bridges'));
app.use('/api/weighstations', require('./routes/weighstations'));
app.use('/api/alerts', require('./routes/alerts'));
app.use('/api/loads', require('./routes/loadboard'));
app.use('/api/escorts', require('./routes/locator'));
app.use('/api/orders', require('./routes/orders'));
app.use('/api/documents', require('./routes/documents'));
app.use('/api/contributions', require('./routes/contributions'));
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// --------------- Error Handler ---------------
app.use(errorHandler);
// --------------- Start Server ---------------
app.listen(PORT, () => {
console.log(`\n🚛 PilotEdge API running at http://localhost:${PORT}`);
console.log(`📡 API endpoints at http://localhost:${PORT}/api`);
console.log(`🌐 Frontend at http://localhost:${PORT}/index.html\n`);
});

View File

@@ -0,0 +1,44 @@
const jwt = require('jsonwebtoken');
// Required auth — rejects if no valid token
function requireAuth(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required.' });
}
try {
const token = header.split(' ')[1];
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload; // { id, email, role, tier }
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token.' });
}
}
// Optional auth — attaches user if token present, continues either way
function optionalAuth(req, res, next) {
const header = req.headers.authorization;
if (header && header.startsWith('Bearer ')) {
try {
const token = header.split(' ')[1];
req.user = jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
// Invalid token — continue without user
}
}
next();
}
// Role check — use after requireAuth
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions.' });
}
next();
};
}
module.exports = { requireAuth, optionalAuth, requireRole };

View File

@@ -0,0 +1,24 @@
function errorHandler(err, req, res, next) {
console.error(`[ERROR] ${err.message}`);
if (process.env.NODE_ENV === 'development') {
console.error(err.stack);
}
if (err.name === 'ValidationError') {
return res.status(400).json({ error: err.message });
}
if (err.code === 'P2002') {
return res.status(409).json({ error: 'A record with that value already exists.' });
}
if (err.code === 'P2025') {
return res.status(404).json({ error: 'Record not found.' });
}
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error',
});
}
module.exports = errorHandler;

View File

@@ -0,0 +1,54 @@
const express = require('express');
const prisma = require('../config/db');
const router = express.Router();
// GET /api/alerts?state=...&type=...&severity=...
router.get('/', async (req, res, next) => {
try {
const { state, type, severity } = req.query;
const where = {};
if (state) {
where.state = { abbr: state.toUpperCase() };
}
if (type) {
where.type = type;
}
if (severity) {
where.severity = severity;
}
// Only show active alerts (endsAt is null or in the future)
where.OR = [
{ endsAt: null },
{ endsAt: { gte: new Date() } },
];
const alerts = await prisma.alert.findMany({
where,
include: { state: { select: { name: true, abbr: true } } },
orderBy: [{ severity: 'desc' }, { startsAt: 'desc' }],
});
res.json(alerts);
} catch (err) {
next(err);
}
});
// GET /api/alerts/:id
router.get('/:id', async (req, res, next) => {
try {
const alert = await prisma.alert.findUnique({
where: { id: req.params.id },
include: { state: { select: { name: true, abbr: true } } },
});
if (!alert) return res.status(404).json({ error: 'Alert not found.' });
res.json(alert);
} catch (err) {
next(err);
}
});
module.exports = router;

89
server/src/routes/auth.js Normal file
View File

@@ -0,0 +1,89 @@
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
// POST /api/auth/register
router.post('/register', async (req, res, next) => {
try {
const { email, password, name, role } = req.body;
if (!email || !password || !name) {
return res.status(400).json({ error: 'Email, password, and name are required.' });
}
const validRoles = ['driver', 'carrier', 'escort'];
const userRole = validRoles.includes(role) ? role : 'driver';
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { email, passwordHash, name, role: userRole },
});
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role, tier: user.tier },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
res.status(201).json({
token,
user: { id: user.id, email: user.email, name: user.name, role: user.role, tier: user.tier },
});
} catch (err) {
next(err);
}
});
// POST /api/auth/login
router.post('/login', async (req, res, next) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required.' });
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return res.status(401).json({ error: 'Invalid email or password.' });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: 'Invalid email or password.' });
}
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role, tier: user.tier },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({
token,
user: { id: user.id, email: user.email, name: user.name, role: user.role, tier: user.tier },
});
} catch (err) {
next(err);
}
});
// GET /api/auth/me
router.get('/me', requireAuth, async (req, res, next) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: { id: true, email: true, name: true, role: true, tier: true, createdAt: true },
});
if (!user) return res.status(404).json({ error: 'User not found.' });
res.json(user);
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,87 @@
const express = require('express');
const prisma = require('../config/db');
const router = express.Router();
// GET /api/bridges?lat=...&lng=...&radius=...&maxHeight=...&maxWidth=...
router.get('/', async (req, res, next) => {
try {
const { lat, lng, radius, maxHeight, maxWidth, state } = req.query;
const where = {};
if (state) {
where.state = { abbr: state.toUpperCase() };
}
let bridges = await prisma.bridge.findMany({
where,
include: { state: { select: { name: true, abbr: true } } },
orderBy: { route: 'asc' },
});
// Distance filter
if (lat && lng && radius) {
const centerLat = parseFloat(lat);
const centerLng = parseFloat(lng);
const maxMiles = parseFloat(radius);
bridges = bridges.filter((b) => {
const dist = haversine(centerLat, centerLng, b.lat, b.lng);
b.distanceMiles = Math.round(dist * 10) / 10;
return dist <= maxMiles;
});
bridges.sort((a, b) => a.distanceMiles - b.distanceMiles);
}
// Dimension conflict detection
if (maxHeight) {
const loadHeight = parseFloat(maxHeight);
bridges = bridges.map((b) => ({
...b,
heightConflict: b.heightClearance < loadHeight,
}));
}
if (maxWidth) {
const loadWidth = parseFloat(maxWidth);
bridges = bridges.map((b) => ({
...b,
widthConflict: b.widthClearance != null && b.widthClearance < loadWidth,
}));
}
res.json(bridges);
} catch (err) {
next(err);
}
});
// GET /api/bridges/:id
router.get('/:id', async (req, res, next) => {
try {
const bridge = await prisma.bridge.findUnique({
where: { id: req.params.id },
include: { state: { select: { name: true, abbr: true } } },
});
if (!bridge) return res.status(404).json({ error: 'Bridge not found.' });
res.json(bridge);
} catch (err) {
next(err);
}
});
function haversine(lat1, lng1, lat2, lng2) {
const R = 3959;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function toRad(deg) {
return (deg * Math.PI) / 180;
}
module.exports = router;

View File

@@ -0,0 +1,34 @@
const express = require('express');
const prisma = require('../config/db');
const router = express.Router();
// GET /api/calendar — all seasonal restrictions
router.get('/', async (req, res, next) => {
try {
const restrictions = await prisma.seasonalRestriction.findMany({
include: { state: { select: { name: true, abbr: true } } },
orderBy: [{ startMonth: 'asc' }, { state: { name: 'asc' } }],
});
res.json(restrictions);
} catch (err) {
next(err);
}
});
// GET /api/calendar/:stateAbbr
router.get('/:stateAbbr', async (req, res, next) => {
try {
const abbr = req.params.stateAbbr.toUpperCase();
const state = await prisma.state.findUnique({
where: { abbr },
include: { seasonalRestrictions: true },
});
if (!state) return res.status(404).json({ error: `State '${abbr}' not found.` });
res.json(state.seasonalRestrictions);
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,34 @@
const express = require('express');
const prisma = require('../config/db');
const router = express.Router();
// GET /api/contacts — all state contacts
router.get('/', async (req, res, next) => {
try {
const contacts = await prisma.contact.findMany({
include: { state: { select: { name: true, abbr: true } } },
orderBy: { state: { name: 'asc' } },
});
res.json(contacts);
} catch (err) {
next(err);
}
});
// GET /api/contacts/:stateAbbr
router.get('/:stateAbbr', async (req, res, next) => {
try {
const abbr = req.params.stateAbbr.toUpperCase();
const state = await prisma.state.findUnique({
where: { abbr },
include: { contact: true },
});
if (!state) return res.status(404).json({ error: `State '${abbr}' not found.` });
res.json(state.contact || { error: 'No contact data for this state.' });
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,74 @@
const express = require('express');
const prisma = require('../config/db');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
// GET /api/contributions?entityType=...&entityId=...
router.get('/', async (req, res, next) => {
try {
const { entityType, entityId } = req.query;
const where = {};
if (entityType) where.entityType = entityType;
if (entityId) where.entityId = entityId;
const contributions = await prisma.contribution.findMany({
where,
include: { user: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
take: 50,
});
res.json(contributions);
} catch (err) {
next(err);
}
});
// POST /api/contributions — submit a contribution (auth required)
router.post('/', requireAuth, async (req, res, next) => {
try {
const { entityType, entityId, type, content } = req.body;
const validEntityTypes = ['truck_stop', 'weigh_station', 'bridge', 'regulation'];
const validTypes = ['info', 'flag', 'confirm'];
if (!validEntityTypes.includes(entityType)) {
return res.status(400).json({ error: `entityType must be one of: ${validEntityTypes.join(', ')}` });
}
if (!validTypes.includes(type)) {
return res.status(400).json({ error: `type must be one of: ${validTypes.join(', ')}` });
}
if (!entityId || !content) {
return res.status(400).json({ error: 'entityId and content are required.' });
}
const data = {
userId: req.user.id,
entityType,
entityId,
type,
content,
};
// Link to the specific entity if it exists
if (entityType === 'truck_stop') {
const exists = await prisma.truckStop.findUnique({ where: { id: entityId } });
if (exists) data.truckStopId = entityId;
} else if (entityType === 'weigh_station') {
const exists = await prisma.weighStation.findUnique({ where: { id: entityId } });
if (exists) data.weighStationId = entityId;
}
const contribution = await prisma.contribution.create({
data,
include: { user: { select: { name: true } } },
});
res.status(201).json(contribution);
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,119 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const prisma = require('../config/db');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(__dirname, '..', '..', 'uploads', req.user.id);
fs.mkdirSync(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueName = `${Date.now()}-${file.originalname}`;
cb(null, uniqueName);
},
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter: (req, file, cb) => {
const allowed = ['.pdf', '.jpg', '.jpeg', '.png', '.doc', '.docx'];
const ext = path.extname(file.originalname).toLowerCase();
if (allowed.includes(ext)) {
cb(null, true);
} else {
cb(new Error(`File type ${ext} not allowed. Accepted: ${allowed.join(', ')}`));
}
},
});
// GET /api/documents — list own documents
router.get('/', requireAuth, async (req, res, next) => {
try {
const documents = await prisma.document.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
});
res.json(documents);
} catch (err) {
next(err);
}
});
// POST /api/documents — upload a document
router.post('/', requireAuth, upload.single('file'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded.' });
}
const { type, expiresAt } = req.body;
const validTypes = ['permit', 'insurance', 'certification', 'license', 'other'];
const docType = validTypes.includes(type) ? type : 'other';
const document = await prisma.document.create({
data: {
userId: req.user.id,
type: docType,
filename: req.file.originalname,
filepath: req.file.path,
mimeType: req.file.mimetype,
sizeBytes: req.file.size,
expiresAt: expiresAt ? new Date(expiresAt) : null,
},
});
res.status(201).json(document);
} catch (err) {
next(err);
}
});
// GET /api/documents/:id/download — download a document
router.get('/:id/download', requireAuth, async (req, res, next) => {
try {
const document = await prisma.document.findUnique({
where: { id: req.params.id },
});
if (!document) return res.status(404).json({ error: 'Document not found.' });
if (document.userId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied.' });
}
res.download(document.filepath, document.filename);
} catch (err) {
next(err);
}
});
// DELETE /api/documents/:id
router.delete('/:id', requireAuth, async (req, res, next) => {
try {
const document = await prisma.document.findUnique({
where: { id: req.params.id },
});
if (!document) return res.status(404).json({ error: 'Document not found.' });
if (document.userId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied.' });
}
// Delete file from disk
if (fs.existsSync(document.filepath)) {
fs.unlinkSync(document.filepath);
}
await prisma.document.delete({ where: { id: req.params.id } });
res.json({ message: 'Document deleted.' });
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,119 @@
const express = require('express');
const prisma = require('../config/db');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
// GET /api/loads — list loads with search/filter
router.get('/', async (req, res, next) => {
try {
const { origin, destination, minDate, maxDate, status, page = 1, limit = 20 } = req.query;
const where = {};
if (origin) where.origin = { contains: origin, mode: 'insensitive' };
if (destination) where.destination = { contains: destination, mode: 'insensitive' };
if (status) where.status = status;
if (minDate || maxDate) {
where.pickupDate = {};
if (minDate) where.pickupDate.gte = new Date(minDate);
if (maxDate) where.pickupDate.lte = new Date(maxDate);
}
const skip = (parseInt(page) - 1) * parseInt(limit);
const [loads, total] = await Promise.all([
prisma.load.findMany({
where,
include: { poster: { select: { name: true, role: true } } },
orderBy: { createdAt: 'desc' },
skip,
take: parseInt(limit),
}),
prisma.load.count({ where }),
]);
res.json({ loads, total, page: parseInt(page), totalPages: Math.ceil(total / parseInt(limit)) });
} catch (err) {
next(err);
}
});
// GET /api/loads/:id
router.get('/:id', async (req, res, next) => {
try {
const load = await prisma.load.findUnique({
where: { id: req.params.id },
include: { poster: { select: { name: true, role: true } } },
});
if (!load) return res.status(404).json({ error: 'Load not found.' });
res.json(load);
} catch (err) {
next(err);
}
});
// POST /api/loads — create a load posting (auth required)
router.post('/', requireAuth, async (req, res, next) => {
try {
const { origin, destination, pickupDate, width, height, length, weight, description, escortsNeeded } = req.body;
if (!origin || !destination || !pickupDate) {
return res.status(400).json({ error: 'Origin, destination, and pickup date are required.' });
}
const load = await prisma.load.create({
data: {
posterId: req.user.id,
origin,
destination,
pickupDate: new Date(pickupDate),
width: width || '',
height: height || '',
length: length || '',
weight: weight || '',
description: description || '',
escortsNeeded: parseInt(escortsNeeded) || 1,
},
});
res.status(201).json(load);
} catch (err) {
next(err);
}
});
// PUT /api/loads/:id — update own load
router.put('/:id', requireAuth, async (req, res, next) => {
try {
const existing = await prisma.load.findUnique({ where: { id: req.params.id } });
if (!existing) return res.status(404).json({ error: 'Load not found.' });
if (existing.posterId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'You can only edit your own loads.' });
}
const load = await prisma.load.update({
where: { id: req.params.id },
data: req.body,
});
res.json(load);
} catch (err) {
next(err);
}
});
// DELETE /api/loads/:id
router.delete('/:id', requireAuth, async (req, res, next) => {
try {
const existing = await prisma.load.findUnique({ where: { id: req.params.id } });
if (!existing) return res.status(404).json({ error: 'Load not found.' });
if (existing.posterId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'You can only delete your own loads.' });
}
await prisma.load.delete({ where: { id: req.params.id } });
res.json({ message: 'Load deleted.' });
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,111 @@
const express = require('express');
const prisma = require('../config/db');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
// GET /api/escorts?lat=...&lng=...&radius=...&availability=...
router.get('/', async (req, res, next) => {
try {
const { lat, lng, radius, availability } = req.query;
const where = {};
if (availability) {
where.availability = availability;
}
let escorts = await prisma.escortProfile.findMany({
where,
include: { user: { select: { name: true, email: true } } },
orderBy: { rating: 'desc' },
});
// Distance filter
if (lat && lng && radius) {
const centerLat = parseFloat(lat);
const centerLng = parseFloat(lng);
const maxMiles = parseFloat(radius);
escorts = escorts.filter((e) => {
const dist = haversine(centerLat, centerLng, e.lat, e.lng);
e.distanceMiles = Math.round(dist * 10) / 10;
return dist <= maxMiles;
});
escorts.sort((a, b) => a.distanceMiles - b.distanceMiles);
}
res.json(escorts);
} catch (err) {
next(err);
}
});
// GET /api/escorts/:id
router.get('/:id', async (req, res, next) => {
try {
const profile = await prisma.escortProfile.findUnique({
where: { id: req.params.id },
include: { user: { select: { name: true, email: true } } },
});
if (!profile) return res.status(404).json({ error: 'Escort profile not found.' });
res.json(profile);
} catch (err) {
next(err);
}
});
// POST /api/escorts/profile — create or update own profile (auth required)
router.post('/profile', requireAuth, async (req, res, next) => {
try {
const { lat, lng, radiusMiles, certifications, vehicleType, availability, phone, bio } = req.body;
if (!lat || !lng) {
return res.status(400).json({ error: 'Location (lat, lng) is required.' });
}
const profile = await prisma.escortProfile.upsert({
where: { userId: req.user.id },
update: {
lat: parseFloat(lat),
lng: parseFloat(lng),
radiusMiles: parseInt(radiusMiles) || 100,
certifications: certifications || [],
vehicleType: vehicleType || '',
availability: availability || 'available',
phone: phone || '',
bio: bio || '',
},
create: {
userId: req.user.id,
lat: parseFloat(lat),
lng: parseFloat(lng),
radiusMiles: parseInt(radiusMiles) || 100,
certifications: certifications || [],
vehicleType: vehicleType || '',
availability: availability || 'available',
phone: phone || '',
bio: bio || '',
},
});
res.json(profile);
} catch (err) {
next(err);
}
});
function haversine(lat1, lng1, lat2, lng2) {
const R = 3959;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function toRad(deg) {
return (deg * Math.PI) / 180;
}
module.exports = router;

View File

@@ -0,0 +1,92 @@
const express = require('express');
const prisma = require('../config/db');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
// GET /api/orders — list own orders
router.get('/', requireAuth, async (req, res, next) => {
try {
const orders = await prisma.order.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
});
res.json(orders);
} catch (err) {
next(err);
}
});
// GET /api/orders/:id
router.get('/:id', requireAuth, async (req, res, next) => {
try {
const order = await prisma.order.findUnique({
where: { id: req.params.id },
});
if (!order) return res.status(404).json({ error: 'Order not found.' });
if (order.userId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied.' });
}
res.json(order);
} catch (err) {
next(err);
}
});
// POST /api/orders — submit escort service request
router.post('/', requireAuth, async (req, res, next) => {
try {
const { origin, destination, pickupDate, width, height, length, weight, loadType, escortsNeeded, notes } = req.body;
if (!origin || !destination || !pickupDate) {
return res.status(400).json({ error: 'Origin, destination, and pickup date are required.' });
}
const order = await prisma.order.create({
data: {
userId: req.user.id,
origin,
destination,
pickupDate: new Date(pickupDate),
width: width || '',
height: height || '',
length: length || '',
weight: weight || '',
loadType: loadType || '',
escortsNeeded: parseInt(escortsNeeded) || 1,
notes: notes || '',
},
});
res.status(201).json(order);
} catch (err) {
next(err);
}
});
// PUT /api/orders/:id/status — update order status (admin or owner)
router.put('/:id/status', requireAuth, async (req, res, next) => {
try {
const { status } = req.body;
const validStatuses = ['pending', 'confirmed', 'in_progress', 'completed', 'cancelled'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Status must be one of: ${validStatuses.join(', ')}` });
}
const order = await prisma.order.findUnique({ where: { id: req.params.id } });
if (!order) return res.status(404).json({ error: 'Order not found.' });
if (order.userId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied.' });
}
const updated = await prisma.order.update({
where: { id: req.params.id },
data: { status },
});
res.json(updated);
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,38 @@
const express = require('express');
const prisma = require('../config/db');
const router = express.Router();
// GET /api/regulations — list all states with regulation data
router.get('/', async (req, res, next) => {
try {
const states = await prisma.state.findMany({
include: { regulation: true },
orderBy: { name: 'asc' },
});
res.json(states);
} catch (err) {
next(err);
}
});
// GET /api/regulations/:stateAbbr — single state with regulation + equipment
router.get('/:stateAbbr', async (req, res, next) => {
try {
const abbr = req.params.stateAbbr.toUpperCase();
const state = await prisma.state.findUnique({
where: { abbr },
include: {
regulation: true,
equipmentRequirements: true,
},
});
if (!state) return res.status(404).json({ error: `State '${abbr}' not found.` });
res.json(state);
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,86 @@
const express = require('express');
const prisma = require('../config/db');
const router = express.Router();
// GET /api/truckstops?lat=...&lng=...&radius=...&state=...
router.get('/', async (req, res, next) => {
try {
const { lat, lng, radius, state } = req.query;
const where = {};
if (state) {
where.state = { abbr: state.toUpperCase() };
}
let truckStops = await prisma.truckStop.findMany({
where,
include: {
state: { select: { name: true, abbr: true } },
contributions: {
orderBy: { createdAt: 'desc' },
take: 5,
select: { id: true, type: true, content: true, createdAt: true },
},
},
orderBy: { name: 'asc' },
});
// Client-side distance filter (Haversine) when lat/lng/radius provided
// For production, replace with PostGIS ST_DWithin
if (lat && lng && radius) {
const centerLat = parseFloat(lat);
const centerLng = parseFloat(lng);
const maxMiles = parseFloat(radius);
truckStops = truckStops.filter((ts) => {
const dist = haversine(centerLat, centerLng, ts.lat, ts.lng);
ts.distanceMiles = Math.round(dist * 10) / 10;
return dist <= maxMiles;
});
truckStops.sort((a, b) => a.distanceMiles - b.distanceMiles);
}
res.json(truckStops);
} catch (err) {
next(err);
}
});
// GET /api/truckstops/:id
router.get('/:id', async (req, res, next) => {
try {
const truckStop = await prisma.truckStop.findUnique({
where: { id: req.params.id },
include: {
state: { select: { name: true, abbr: true } },
contributions: {
orderBy: { createdAt: 'desc' },
include: { user: { select: { name: true } } },
},
},
});
if (!truckStop) return res.status(404).json({ error: 'Truck stop not found.' });
res.json(truckStop);
} catch (err) {
next(err);
}
});
// Haversine distance in miles
function haversine(lat1, lng1, lat2, lng2) {
const R = 3959; // Earth radius in miles
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function toRad(deg) {
return (deg * Math.PI) / 180;
}
module.exports = router;

View File

@@ -0,0 +1,86 @@
const express = require('express');
const prisma = require('../config/db');
const router = express.Router();
// GET /api/weighstations?lat=...&lng=...&radius=...&state=...&status=...
router.get('/', async (req, res, next) => {
try {
const { lat, lng, radius, state, status } = req.query;
const where = {};
if (state) {
where.state = { abbr: state.toUpperCase() };
}
if (status) {
where.currentStatus = status;
}
let stations = await prisma.weighStation.findMany({
where,
include: {
state: { select: { name: true, abbr: true } },
contributions: {
orderBy: { createdAt: 'desc' },
take: 3,
select: { id: true, type: true, content: true, createdAt: true },
},
},
orderBy: { name: 'asc' },
});
// Distance filter
if (lat && lng && radius) {
const centerLat = parseFloat(lat);
const centerLng = parseFloat(lng);
const maxMiles = parseFloat(radius);
stations = stations.filter((ws) => {
const dist = haversine(centerLat, centerLng, ws.lat, ws.lng);
ws.distanceMiles = Math.round(dist * 10) / 10;
return dist <= maxMiles;
});
stations.sort((a, b) => a.distanceMiles - b.distanceMiles);
}
res.json(stations);
} catch (err) {
next(err);
}
});
// GET /api/weighstations/:id
router.get('/:id', async (req, res, next) => {
try {
const station = await prisma.weighStation.findUnique({
where: { id: req.params.id },
include: {
state: { select: { name: true, abbr: true } },
contributions: {
orderBy: { createdAt: 'desc' },
include: { user: { select: { name: true } } },
},
},
});
if (!station) return res.status(404).json({ error: 'Weigh station not found.' });
res.json(station);
} catch (err) {
next(err);
}
});
function haversine(lat1, lng1, lat2, lng2) {
const R = 3959;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function toRad(deg) {
return (deg * Math.PI) / 180;
}
module.exports = router;

346
server/src/seeds/seed.js Normal file
View File

@@ -0,0 +1,346 @@
// =====================================================================
// Database Seed Script
// Reads mock data from the existing frontend JS files and inserts
// into PostgreSQL via Prisma.
// Run with: npm run db:seed
// =====================================================================
const fs = require('fs');
const path = require('path');
const prisma = require('../config/db');
// Load the mock data files by evaluating them in a controlled context
function loadMockData(filename) {
const filepath = path.join(__dirname, '..', '..', '..', filename);
const code = fs.readFileSync(filepath, 'utf-8');
const context = {};
// Execute the JS to populate the const variables
const fn = new Function(code + '\nreturn { ' +
'MOCK_STATE_REGULATIONS: typeof MOCK_STATE_REGULATIONS !== "undefined" ? MOCK_STATE_REGULATIONS : undefined,' +
'MOCK_LOAD_BOARD: typeof MOCK_LOAD_BOARD !== "undefined" ? MOCK_LOAD_BOARD : undefined,' +
'MOCK_ESCORT_OPERATORS: typeof MOCK_ESCORT_OPERATORS !== "undefined" ? MOCK_ESCORT_OPERATORS : undefined,' +
'MOCK_STATE_CONTACTS: typeof MOCK_STATE_CONTACTS !== "undefined" ? MOCK_STATE_CONTACTS : undefined,' +
'MOCK_STATE_EQUIPMENT: typeof MOCK_STATE_EQUIPMENT !== "undefined" ? MOCK_STATE_EQUIPMENT : undefined,' +
'MOCK_TRUCK_STOPS: typeof MOCK_TRUCK_STOPS !== "undefined" ? MOCK_TRUCK_STOPS : undefined,' +
'MOCK_BRIDGE_CLEARANCES: typeof MOCK_BRIDGE_CLEARANCES !== "undefined" ? MOCK_BRIDGE_CLEARANCES : undefined,' +
'MOCK_WEIGH_STATIONS: typeof MOCK_WEIGH_STATIONS !== "undefined" ? MOCK_WEIGH_STATIONS : undefined,' +
'MOCK_ROUTE_CONDITIONS: typeof MOCK_ROUTE_CONDITIONS !== "undefined" ? MOCK_ROUTE_CONDITIONS : undefined,' +
'MOCK_WEATHER_ALERTS: typeof MOCK_WEATHER_ALERTS !== "undefined" ? MOCK_WEATHER_ALERTS : undefined,' +
'MOCK_SEASONAL_RESTRICTIONS: typeof MOCK_SEASONAL_RESTRICTIONS !== "undefined" ? MOCK_SEASONAL_RESTRICTIONS : undefined,' +
'MOCK_DOCUMENTS: typeof MOCK_DOCUMENTS !== "undefined" ? MOCK_DOCUMENTS : undefined' +
' };');
return fn();
}
async function seed() {
console.log('🌱 Seeding database...\n');
// Load mock data from both files
const data1 = loadMockData('mock-data.js');
const data2 = loadMockData('mock-data-extended.js');
// Clear existing data in reverse dependency order
console.log(' 🗑️ Clearing existing data...');
await prisma.contribution.deleteMany();
await prisma.document.deleteMany();
await prisma.order.deleteMany();
await prisma.load.deleteMany();
await prisma.escortProfile.deleteMany();
await prisma.user.deleteMany();
await prisma.alert.deleteMany();
await prisma.weighStation.deleteMany();
await prisma.bridge.deleteMany();
await prisma.truckStop.deleteMany();
await prisma.seasonalRestriction.deleteMany();
await prisma.contact.deleteMany();
await prisma.equipmentRequirement.deleteMany();
await prisma.regulation.deleteMany();
await prisma.state.deleteMany();
// ---------------------------------------------------------------
// 1. States + Regulations (from MOCK_STATE_REGULATIONS)
// ---------------------------------------------------------------
console.log(' 📍 Seeding states and regulations...');
const stateMap = {}; // abbr -> state.id
for (const reg of data1.MOCK_STATE_REGULATIONS) {
const state = await prisma.state.create({
data: {
name: reg.name,
abbr: reg.abbr,
lat: reg.lat,
lng: reg.lng,
regulation: {
create: {
permitWidth: reg.permitWidth,
permitHeight: reg.permitHeight,
permitLength: reg.permitLength,
permitWeight: reg.permitWeight,
escortWidth: reg.escortWidth,
escortHeight: reg.escortHeight,
escortLength: reg.escortLength,
escortWeight: reg.escortWeight,
travelRestrictions: reg.travel,
holidays: reg.holidays,
agency: reg.agency,
url: reg.url,
notes: reg.notes,
},
},
},
});
stateMap[reg.abbr] = state.id;
}
console.log(`${Object.keys(stateMap).length} states with regulations`);
// ---------------------------------------------------------------
// 2. Contacts (from MOCK_STATE_CONTACTS)
// ---------------------------------------------------------------
console.log(' 📞 Seeding contacts...');
let contactCount = 0;
if (data2.MOCK_STATE_CONTACTS) {
for (const [abbr, contact] of Object.entries(data2.MOCK_STATE_CONTACTS)) {
if (!stateMap[abbr]) continue;
await prisma.contact.create({
data: {
stateId: stateMap[abbr],
permitPhone: contact.permit,
policePhone: contact.police,
email: contact.email,
hours: contact.hours,
portalUrl: contact.portal,
},
});
contactCount++;
}
}
console.log(`${contactCount} state contacts`);
// ---------------------------------------------------------------
// 3. Equipment Requirements (from MOCK_STATE_EQUIPMENT)
// ---------------------------------------------------------------
console.log(' 🔧 Seeding equipment requirements...');
let equipCount = 0;
if (data2.MOCK_STATE_EQUIPMENT) {
for (const [abbr, equip] of Object.entries(data2.MOCK_STATE_EQUIPMENT)) {
if (!stateMap[abbr]) continue;
if (equip.escort) {
await prisma.equipmentRequirement.create({
data: {
stateId: stateMap[abbr],
type: 'escort',
certification: equip.escort.certification || '',
vehicle: equip.escort.vehicle || '',
signs: equip.escort.signs || '',
lights: equip.escort.lights || '',
heightPole: equip.escort.heightPole || '',
flags: equip.escort.flags || '',
safetyGear: equip.escort.safety || '',
communication: equip.escort.communication || '',
},
});
equipCount++;
}
if (equip.carrier) {
await prisma.equipmentRequirement.create({
data: {
stateId: stateMap[abbr],
type: 'carrier',
signs: equip.carrier.signs || '',
flags: equip.carrier.flags || '',
lights: equip.carrier.lights || '',
safetyGear: [
equip.carrier.cones ? `Cones: ${equip.carrier.cones}` : '',
equip.carrier.fireExtinguisher ? `Fire ext: ${equip.carrier.fireExtinguisher}` : '',
equip.carrier.triangles ? `Triangles: ${equip.carrier.triangles}` : '',
equip.carrier.flares ? `Flares: ${equip.carrier.flares}` : '',
equip.carrier.firstAid ? `First aid: ${equip.carrier.firstAid}` : '',
].filter(Boolean).join('; '),
},
});
equipCount++;
}
}
}
console.log(`${equipCount} equipment requirements`);
// ---------------------------------------------------------------
// 4. Truck Stops (from MOCK_TRUCK_STOPS)
// ---------------------------------------------------------------
console.log(' ⛽ Seeding truck stops...');
let tsCount = 0;
if (data2.MOCK_TRUCK_STOPS) {
for (const ts of data2.MOCK_TRUCK_STOPS) {
const stateAbbr = ts.location?.state;
const stateId = stateMap[stateAbbr];
if (!stateId) continue;
await prisma.truckStop.create({
data: {
stateId,
name: ts.name,
lat: ts.location.lat,
lng: ts.location.lng,
address: `${ts.location.city}, ${ts.location.state}`,
hasOversizeParking: ts.oversizeFriendly || false,
entranceHeight: ts.entranceHeight || '',
entranceWidth: ts.entranceWidth || '',
lotSqFt: ts.lotSize ? parseInt(String(ts.lotSize).replace(/[^0-9]/g, '')) || null : null,
facilities: ts.facilities || [],
phone: '',
},
});
tsCount++;
}
}
console.log(`${tsCount} truck stops`);
// ---------------------------------------------------------------
// 5. Bridge Clearances (from MOCK_BRIDGE_CLEARANCES)
// ---------------------------------------------------------------
console.log(' 🌉 Seeding bridges...');
let bridgeCount = 0;
if (data2.MOCK_BRIDGE_CLEARANCES) {
for (const b of data2.MOCK_BRIDGE_CLEARANCES) {
const stateAbbr = b.location?.state;
const stateId = stateMap[stateAbbr];
if (!stateId) continue;
await prisma.bridge.create({
data: {
stateId,
name: `${b.type || 'Bridge'} at ${b.location.desc || b.route}`,
lat: b.location.lat,
lng: b.location.lng,
route: b.route,
heightClearance: parseFloat(String(b.clearanceHeight).replace(/[^0-9.]/g, '')) || 0,
widthClearance: b.clearanceWidth ? parseFloat(String(b.clearanceWidth).replace(/[^0-9.]/g, '')) : null,
weightLimit: b.weightLimit ? parseFloat(String(b.weightLimit).replace(/[^0-9.]/g, '')) : null,
},
});
bridgeCount++;
}
}
console.log(`${bridgeCount} bridges`);
// ---------------------------------------------------------------
// 6. Weigh Stations (from MOCK_WEIGH_STATIONS)
// ---------------------------------------------------------------
console.log(' ⚖️ Seeding weigh stations...');
let wsCount = 0;
if (data2.MOCK_WEIGH_STATIONS) {
for (const ws of data2.MOCK_WEIGH_STATIONS) {
const stateAbbr = ws.location?.state;
const stateId = stateMap[stateAbbr];
if (!stateId) continue;
await prisma.weighStation.create({
data: {
stateId,
name: ws.name,
lat: ws.location.lat,
lng: ws.location.lng,
direction: ws.direction || '',
route: ws.route || '',
prePass: ws.prePass || false,
hours: ws.hours || '',
currentStatus: ws.currentStatus || 'unknown',
},
});
wsCount++;
}
}
console.log(`${wsCount} weigh stations`);
// ---------------------------------------------------------------
// 7. Alerts — Route Conditions + Weather (from MOCK_ROUTE_CONDITIONS + MOCK_WEATHER_ALERTS)
// ---------------------------------------------------------------
console.log(' 🚨 Seeding alerts...');
let alertCount = 0;
if (data2.MOCK_ROUTE_CONDITIONS) {
for (const rc of data2.MOCK_ROUTE_CONDITIONS) {
const stateAbbr = rc.location?.state;
const stateId = stateMap[stateAbbr];
if (!stateId) continue;
await prisma.alert.create({
data: {
stateId,
type: rc.type || 'construction',
route: rc.route || '',
description: rc.description || '',
severity: rc.severity || 'info',
startsAt: rc.startDate ? new Date(rc.startDate) : new Date(),
endsAt: rc.endDate ? new Date(rc.endDate) : null,
},
});
alertCount++;
}
}
if (data2.MOCK_WEATHER_ALERTS) {
for (const wa of data2.MOCK_WEATHER_ALERTS) {
// Weather alerts may not have a state directly, try to match from region
const stateAbbr = wa.state || (wa.routes?.[0] ? 'TX' : null); // fallback
const stateId = stateAbbr ? stateMap[stateAbbr] : Object.values(stateMap)[0];
if (!stateId) continue;
await prisma.alert.create({
data: {
stateId,
type: wa.type || 'weather',
route: (wa.routes || []).join(', '),
description: wa.description || '',
severity: wa.severity || 'info',
startsAt: wa.validFrom ? new Date(wa.validFrom) : new Date(),
endsAt: wa.validTo ? new Date(wa.validTo) : null,
},
});
alertCount++;
}
}
console.log(`${alertCount} alerts`);
// ---------------------------------------------------------------
// 8. Seasonal Restrictions (from MOCK_SEASONAL_RESTRICTIONS)
// ---------------------------------------------------------------
console.log(' 📅 Seeding seasonal restrictions...');
let seasonCount = 0;
if (data2.MOCK_SEASONAL_RESTRICTIONS) {
for (const sr of data2.MOCK_SEASONAL_RESTRICTIONS) {
const stateAbbr = sr.state;
const stateId = stateMap[stateAbbr];
if (!stateId) continue;
await prisma.seasonalRestriction.create({
data: {
stateId,
name: sr.title || sr.type,
type: sr.type || 'other',
startMonth: sr.startMonth || 1,
endMonth: sr.endMonth || 12,
description: sr.description || '',
},
});
seasonCount++;
}
}
console.log(`${seasonCount} seasonal restrictions`);
// ---------------------------------------------------------------
// Summary
// ---------------------------------------------------------------
console.log('\n🎉 Seed complete!\n');
}
seed()
.catch((err) => {
console.error('❌ Seed failed:', err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -65,14 +65,16 @@
<div id="main-footer"></div> <div id="main-footer"></div>
<script src="mock-data.js"></script> <script src="api.js"></script>
<script src="mock-data-extended.js"></script>
<script src="nav.js"></script> <script src="nav.js"></script>
<script> <script>
renderNav('truckstops'); renderNav('truckstops');
renderBanner(); renderBanner();
renderFooter(); renderFooter();
(async () => {
const MOCK_TRUCK_STOPS = await PilotEdge.getTruckStops();
// Facility emoji mapping // Facility emoji mapping
const facilityIcons = { const facilityIcons = {
fuel: '⛽', food: '🍔', restrooms: '🚻', showers: '🚿', fuel: '⛽', food: '🍔', restrooms: '🚻', showers: '🚿',
@@ -235,6 +237,7 @@
// Initialize // Initialize
initMap(); initMap();
renderStops(MOCK_TRUCK_STOPS); renderStops(MOCK_TRUCK_STOPS);
})();
</script> </script>
</body> </body>

View File

@@ -90,14 +90,16 @@
<div id="main-footer"></div> <div id="main-footer"></div>
<script src="mock-data.js"></script> <script src="api.js"></script>
<script src="mock-data-extended.js"></script>
<script src="nav.js"></script> <script src="nav.js"></script>
<script> <script>
renderNav('weighstations'); renderNav('weighstations');
renderBanner(); renderBanner();
renderFooter(); renderFooter();
(async () => {
const MOCK_WEIGH_STATIONS = await PilotEdge.getWeighStations();
// ── Local mutable copy of station data ── // ── Local mutable copy of station data ──
const stations = JSON.parse(JSON.stringify(MOCK_WEIGH_STATIONS)); const stations = JSON.parse(JSON.stringify(MOCK_WEIGH_STATIONS));
@@ -261,6 +263,7 @@
// Initial render // Initial render
applyFilters(); applyFilters();
})();
</script> </script>
</body> </body>
</html> </html>