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:
@@ -119,14 +119,18 @@
|
||||
|
||||
<div id="main-footer"></div>
|
||||
|
||||
<script src="mock-data.js"></script>
|
||||
<script src="mock-data-extended.js"></script>
|
||||
<script src="api.js"></script>
|
||||
<script src="nav.js"></script>
|
||||
<script>
|
||||
renderNav('alerts');
|
||||
renderBanner();
|
||||
renderFooter();
|
||||
|
||||
(async () => {
|
||||
const alertData = await PilotEdge.getAlerts();
|
||||
const MOCK_ROUTE_CONDITIONS = alertData.routeConditions;
|
||||
const MOCK_WEATHER_ALERTS = alertData.weatherAlerts;
|
||||
|
||||
// ── State ──
|
||||
let activeTab = 'all';
|
||||
let map, markersLayer;
|
||||
@@ -413,6 +417,7 @@
|
||||
document.getElementById('filter-type').addEventListener('change', renderAll);
|
||||
document.getElementById('filter-severity').addEventListener('change', renderAll);
|
||||
document.getElementById('filter-oversize').addEventListener('change', renderAll);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
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]}`;
|
||||
}
|
||||
@@ -112,14 +112,16 @@
|
||||
|
||||
<div id="main-footer"></div>
|
||||
|
||||
<script src="mock-data.js"></script>
|
||||
<script src="mock-data-extended.js"></script>
|
||||
<script src="api.js"></script>
|
||||
<script src="nav.js"></script>
|
||||
<script>
|
||||
renderNav('bridges');
|
||||
renderBanner();
|
||||
renderFooter();
|
||||
|
||||
(async () => {
|
||||
const MOCK_BRIDGE_CLEARANCES = await PilotEdge.getBridges();
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// Parse a clearance string like '13\'6"' or '14\'0"' into decimal feet.
|
||||
@@ -380,6 +382,7 @@
|
||||
|
||||
// --- Initial render ---
|
||||
applyFilters();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -85,14 +85,16 @@
|
||||
|
||||
<div id="main-footer"></div>
|
||||
|
||||
<script src="mock-data.js"></script>
|
||||
<script src="mock-data-extended.js"></script>
|
||||
<script src="api.js"></script>
|
||||
<script src="nav.js"></script>
|
||||
<script>
|
||||
renderNav('calendar');
|
||||
renderBanner();
|
||||
renderFooter();
|
||||
|
||||
(async () => {
|
||||
const MOCK_SEASONAL_RESTRICTIONS = await PilotEdge.getSeasonalRestrictions();
|
||||
|
||||
// ---- Constants ----
|
||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
|
||||
@@ -286,6 +288,7 @@
|
||||
|
||||
// ---- Initial render ----
|
||||
applyFilters();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
101
contacts.html
101
contacts.html
@@ -42,67 +42,70 @@
|
||||
|
||||
<div id="main-footer"></div>
|
||||
|
||||
<script src="mock-data.js"></script>
|
||||
<script src="mock-data-extended.js"></script>
|
||||
<script src="api.js"></script>
|
||||
<script src="nav.js"></script>
|
||||
<script>
|
||||
renderNav('contacts');
|
||||
renderBanner();
|
||||
renderFooter();
|
||||
|
||||
const grid = document.getElementById('contacts-grid');
|
||||
const noResults = document.getElementById('no-results');
|
||||
const searchInput = document.getElementById('state-search');
|
||||
(async () => {
|
||||
const MOCK_STATE_CONTACTS = await PilotEdge.getContacts();
|
||||
|
||||
function buildCard(abbr, c) {
|
||||
return `
|
||||
<div class="bg-white rounded-2xl shadow-lg p-6 flex flex-col" data-state="${c.name.toLowerCase()}">
|
||||
<h3 class="text-lg font-bold text-slate-900 mb-1">${c.name} <span class="text-slate-400 font-medium text-sm">(${abbr})</span></h3>
|
||||
<div class="mt-3 space-y-2 text-sm text-slate-700 flex-1">
|
||||
<p class="flex items-start gap-2">
|
||||
<span class="shrink-0">📞</span>
|
||||
<span><span class="font-medium">Permit Office:</span> <a href="tel:${c.permit.replace(/[^+\d]/g, '')}" class="text-amber-600 hover:text-amber-700 font-semibold">${c.permit}</a></span>
|
||||
</p>
|
||||
<p class="flex items-start gap-2">
|
||||
<span class="shrink-0">🚔</span>
|
||||
<span><span class="font-medium">State Police:</span> <a href="tel:${c.police.replace(/[^+\d]/g, '')}" class="text-amber-600 hover:text-amber-700 font-semibold">${c.police}</a></span>
|
||||
</p>
|
||||
<p class="flex items-start gap-2">
|
||||
<span class="shrink-0">✉️</span>
|
||||
<span><span class="font-medium">Email:</span> <a href="mailto:${c.email}" class="text-amber-600 hover:text-amber-700 font-semibold break-all">${c.email}</a></span>
|
||||
</p>
|
||||
<p class="flex items-start gap-2">
|
||||
<span class="shrink-0">🕐</span>
|
||||
<span><span class="font-medium">Hours:</span> ${c.hours}</span>
|
||||
</p>
|
||||
</div>
|
||||
<a href="${c.portal}" target="_blank" rel="noopener noreferrer"
|
||||
class="mt-4 inline-flex items-center justify-center gap-1 bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold text-sm px-4 py-2 rounded-lg transition-colors shadow-md">
|
||||
Visit Permit Portal <span class="text-xs">↗</span>
|
||||
</a>
|
||||
</div>`;
|
||||
}
|
||||
const grid = document.getElementById('contacts-grid');
|
||||
const noResults = document.getElementById('no-results');
|
||||
const searchInput = document.getElementById('state-search');
|
||||
|
||||
function renderCards(filter) {
|
||||
const term = (filter || '').toLowerCase();
|
||||
let html = '';
|
||||
let count = 0;
|
||||
function buildCard(abbr, c) {
|
||||
return `
|
||||
<div class="bg-white rounded-2xl shadow-lg p-6 flex flex-col" data-state="${c.name.toLowerCase()}">
|
||||
<h3 class="text-lg font-bold text-slate-900 mb-1">${c.name} <span class="text-slate-400 font-medium text-sm">(${abbr})</span></h3>
|
||||
<div class="mt-3 space-y-2 text-sm text-slate-700 flex-1">
|
||||
<p class="flex items-start gap-2">
|
||||
<span class="shrink-0">📞</span>
|
||||
<span><span class="font-medium">Permit Office:</span> <a href="tel:${c.permit.replace(/[^+\d]/g, '')}" class="text-amber-600 hover:text-amber-700 font-semibold">${c.permit}</a></span>
|
||||
</p>
|
||||
<p class="flex items-start gap-2">
|
||||
<span class="shrink-0">🚔</span>
|
||||
<span><span class="font-medium">State Police:</span> <a href="tel:${c.police.replace(/[^+\d]/g, '')}" class="text-amber-600 hover:text-amber-700 font-semibold">${c.police}</a></span>
|
||||
</p>
|
||||
<p class="flex items-start gap-2">
|
||||
<span class="shrink-0">✉️</span>
|
||||
<span><span class="font-medium">Email:</span> <a href="mailto:${c.email}" class="text-amber-600 hover:text-amber-700 font-semibold break-all">${c.email}</a></span>
|
||||
</p>
|
||||
<p class="flex items-start gap-2">
|
||||
<span class="shrink-0">🕐</span>
|
||||
<span><span class="font-medium">Hours:</span> ${c.hours}</span>
|
||||
</p>
|
||||
</div>
|
||||
<a href="${c.portal}" target="_blank" rel="noopener noreferrer"
|
||||
class="mt-4 inline-flex items-center justify-center gap-1 bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold text-sm px-4 py-2 rounded-lg transition-colors shadow-md">
|
||||
Visit Permit Portal <span class="text-xs">↗</span>
|
||||
</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
Object.keys(MOCK_STATE_CONTACTS).forEach(abbr => {
|
||||
const c = MOCK_STATE_CONTACTS[abbr];
|
||||
if (!term || c.name.toLowerCase().includes(term) || abbr.toLowerCase().includes(term)) {
|
||||
html += buildCard(abbr, c);
|
||||
count++;
|
||||
}
|
||||
});
|
||||
function renderCards(filter) {
|
||||
const term = (filter || '').toLowerCase();
|
||||
let html = '';
|
||||
let count = 0;
|
||||
|
||||
grid.innerHTML = html;
|
||||
noResults.classList.toggle('hidden', count > 0);
|
||||
}
|
||||
Object.keys(MOCK_STATE_CONTACTS).forEach(abbr => {
|
||||
const c = MOCK_STATE_CONTACTS[abbr];
|
||||
if (!term || c.name.toLowerCase().includes(term) || abbr.toLowerCase().includes(term)) {
|
||||
html += buildCard(abbr, c);
|
||||
count++;
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', () => renderCards(searchInput.value));
|
||||
grid.innerHTML = html;
|
||||
noResults.classList.toggle('hidden', count > 0);
|
||||
}
|
||||
|
||||
renderCards();
|
||||
searchInput.addEventListener('input', () => renderCards(searchInput.value));
|
||||
|
||||
renderCards();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
263
documents.html
263
documents.html
@@ -105,155 +105,158 @@
|
||||
|
||||
<div id="main-footer"></div>
|
||||
|
||||
<script src="mock-data.js"></script>
|
||||
<script src="mock-data-extended.js"></script>
|
||||
<script src="api.js"></script>
|
||||
<script src="nav.js"></script>
|
||||
<script>
|
||||
renderNav('documents');
|
||||
renderBanner();
|
||||
renderFooter();
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────
|
||||
const today = new Date();
|
||||
const MS_PER_DAY = 86400000;
|
||||
(async () => {
|
||||
const MOCK_DOCUMENTS = await PilotEdge.getDocuments();
|
||||
|
||||
function daysUntil(dateStr) {
|
||||
return Math.ceil((new Date(dateStr) - today) / MS_PER_DAY);
|
||||
}
|
||||
// ── Helpers ──────────────────────────────────────────
|
||||
const today = new Date();
|
||||
const MS_PER_DAY = 86400000;
|
||||
|
||||
function fmtDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' });
|
||||
}
|
||||
|
||||
const typeBadge = {
|
||||
permit: 'bg-blue-100 text-blue-700',
|
||||
insurance: 'bg-green-100 text-green-700',
|
||||
certification: 'bg-purple-100 text-purple-700',
|
||||
registration: 'bg-slate-200 text-slate-700'
|
||||
};
|
||||
|
||||
function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
||||
|
||||
// ── Stats ───────────────────────────────────────────
|
||||
function renderStats(docs) {
|
||||
const total = docs.length;
|
||||
const active = docs.filter(d => d.status === 'active').length;
|
||||
const expired = docs.filter(d => d.status === 'expired').length;
|
||||
const expiring = docs.filter(d => d.status === 'active' && daysUntil(d.expiryDate) <= 30 && daysUntil(d.expiryDate) > 0).length;
|
||||
|
||||
const items = [
|
||||
{ label:'Total Documents', value:total, color:'bg-slate-900 text-white' },
|
||||
{ label:'Active Permits', value:active, color:'bg-green-600 text-white' },
|
||||
{ label:'Expiring Soon', value:expiring, color:'bg-amber-500 text-white' },
|
||||
{ label:'Expired', value:expired, color:'bg-red-600 text-white' }
|
||||
];
|
||||
|
||||
document.getElementById('stats-bar').innerHTML = items.map(s => `
|
||||
<div class="${s.color} rounded-2xl shadow-lg p-5 text-center">
|
||||
<div class="text-3xl font-bold">${s.value}</div>
|
||||
<div class="text-sm mt-1 opacity-90">${s.label}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// ── Expiry Warnings ─────────────────────────────────
|
||||
function renderExpiryWarnings(docs) {
|
||||
const soon = docs.filter(d => d.status === 'active' && daysUntil(d.expiryDate) <= 30 && daysUntil(d.expiryDate) > 0);
|
||||
const el = document.getElementById('expiry-warnings');
|
||||
if (!soon.length) { el.innerHTML = ''; return; }
|
||||
el.innerHTML = `
|
||||
<div class="bg-amber-50 border border-amber-300 rounded-2xl p-5">
|
||||
<h3 class="font-bold text-amber-800 text-lg mb-3">⚠️ Expiring Within 30 Days</h3>
|
||||
<div class="space-y-2">
|
||||
${soon.map(d => `
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between bg-white rounded-xl px-4 py-3 shadow-sm border border-amber-200">
|
||||
<div>
|
||||
<span class="font-semibold text-slate-900">${d.name}</span>
|
||||
<span class="inline-block ml-2 text-xs font-medium px-2 py-0.5 rounded-full ${typeBadge[d.type]}">${capitalize(d.type)}</span>
|
||||
</div>
|
||||
<span class="text-amber-700 font-medium text-sm mt-1 sm:mt-0">Expires ${fmtDate(d.expiryDate)} (${daysUntil(d.expiryDate)} day${daysUntil(d.expiryDate) === 1 ? '' : 's'})</span>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Document Cards ──────────────────────────────────
|
||||
function renderDocuments(docs) {
|
||||
const list = document.getElementById('document-list');
|
||||
const empty = document.getElementById('empty-state');
|
||||
|
||||
if (!docs.length) {
|
||||
list.innerHTML = '';
|
||||
empty.classList.remove('hidden');
|
||||
return;
|
||||
function daysUntil(dateStr) {
|
||||
return Math.ceil((new Date(dateStr) - today) / MS_PER_DAY);
|
||||
}
|
||||
empty.classList.add('hidden');
|
||||
|
||||
list.innerHTML = docs.map(d => {
|
||||
const statusBadge = d.status === 'active'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700';
|
||||
return `
|
||||
<div class="bg-white rounded-2xl shadow-lg p-5 sm:p-6 hover:shadow-xl transition-shadow border border-slate-100">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<h3 class="font-bold text-slate-900 text-base truncate">${d.name}</h3>
|
||||
<span class="inline-block text-xs font-medium px-2.5 py-0.5 rounded-full ${typeBadge[d.type]}">${capitalize(d.type)}</span>
|
||||
<span class="inline-block text-xs font-medium px-2.5 py-0.5 rounded-full ${statusBadge}">${capitalize(d.status)}</span>
|
||||
function fmtDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' });
|
||||
}
|
||||
|
||||
const typeBadge = {
|
||||
permit: 'bg-blue-100 text-blue-700',
|
||||
insurance: 'bg-green-100 text-green-700',
|
||||
certification: 'bg-purple-100 text-purple-700',
|
||||
registration: 'bg-slate-200 text-slate-700'
|
||||
};
|
||||
|
||||
function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
||||
|
||||
// ── Stats ───────────────────────────────────────────
|
||||
function renderStats(docs) {
|
||||
const total = docs.length;
|
||||
const active = docs.filter(d => d.status === 'active').length;
|
||||
const expired = docs.filter(d => d.status === 'expired').length;
|
||||
const expiring = docs.filter(d => d.status === 'active' && daysUntil(d.expiryDate) <= 30 && daysUntil(d.expiryDate) > 0).length;
|
||||
|
||||
const items = [
|
||||
{ label:'Total Documents', value:total, color:'bg-slate-900 text-white' },
|
||||
{ label:'Active Permits', value:active, color:'bg-green-600 text-white' },
|
||||
{ label:'Expiring Soon', value:expiring, color:'bg-amber-500 text-white' },
|
||||
{ label:'Expired', value:expired, color:'bg-red-600 text-white' }
|
||||
];
|
||||
|
||||
document.getElementById('stats-bar').innerHTML = items.map(s => `
|
||||
<div class="${s.color} rounded-2xl shadow-lg p-5 text-center">
|
||||
<div class="text-3xl font-bold">${s.value}</div>
|
||||
<div class="text-sm mt-1 opacity-90">${s.label}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// ── Expiry Warnings ─────────────────────────────────
|
||||
function renderExpiryWarnings(docs) {
|
||||
const soon = docs.filter(d => d.status === 'active' && daysUntil(d.expiryDate) <= 30 && daysUntil(d.expiryDate) > 0);
|
||||
const el = document.getElementById('expiry-warnings');
|
||||
if (!soon.length) { el.innerHTML = ''; return; }
|
||||
el.innerHTML = `
|
||||
<div class="bg-amber-50 border border-amber-300 rounded-2xl p-5">
|
||||
<h3 class="font-bold text-amber-800 text-lg mb-3">⚠️ Expiring Within 30 Days</h3>
|
||||
<div class="space-y-2">
|
||||
${soon.map(d => `
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between bg-white rounded-xl px-4 py-3 shadow-sm border border-amber-200">
|
||||
<div>
|
||||
<span class="font-semibold text-slate-900">${d.name}</span>
|
||||
<span class="inline-block ml-2 text-xs font-medium px-2 py-0.5 rounded-full ${typeBadge[d.type]}">${capitalize(d.type)}</span>
|
||||
</div>
|
||||
<span class="text-amber-700 font-medium text-sm mt-1 sm:mt-0">Expires ${fmtDate(d.expiryDate)} (${daysUntil(d.expiryDate)} day${daysUntil(d.expiryDate) === 1 ? '' : 's'})</span>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Document Cards ──────────────────────────────────
|
||||
function renderDocuments(docs) {
|
||||
const list = document.getElementById('document-list');
|
||||
const empty = document.getElementById('empty-state');
|
||||
|
||||
if (!docs.length) {
|
||||
list.innerHTML = '';
|
||||
empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
empty.classList.add('hidden');
|
||||
|
||||
list.innerHTML = docs.map(d => {
|
||||
const statusBadge = d.status === 'active'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700';
|
||||
return `
|
||||
<div class="bg-white rounded-2xl shadow-lg p-5 sm:p-6 hover:shadow-xl transition-shadow border border-slate-100">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<h3 class="font-bold text-slate-900 text-base truncate">${d.name}</h3>
|
||||
<span class="inline-block text-xs font-medium px-2.5 py-0.5 rounded-full ${typeBadge[d.type]}">${capitalize(d.type)}</span>
|
||||
<span class="inline-block text-xs font-medium px-2.5 py-0.5 rounded-full ${statusBadge}">${capitalize(d.status)}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-1 text-sm text-slate-500">
|
||||
${d.state ? `<span>📍 ${d.state}</span>` : ''}
|
||||
<span>📤 Uploaded ${fmtDate(d.uploadDate)}</span>
|
||||
<span>📅 Expires ${fmtDate(d.expiryDate)}</span>
|
||||
<span>💾 ${d.fileSize}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-1 text-sm text-slate-500">
|
||||
${d.state ? `<span>📍 ${d.state}</span>` : ''}
|
||||
<span>📤 Uploaded ${fmtDate(d.uploadDate)}</span>
|
||||
<span>📅 Expires ${fmtDate(d.expiryDate)}</span>
|
||||
<span>💾 ${d.fileSize}</span>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<button onclick="alert('Viewing: ${d.name}')" class="px-3 py-1.5 text-sm font-medium rounded-lg border border-slate-300 text-slate-700 hover:bg-slate-50 transition-colors">View</button>
|
||||
<button onclick="alert('Downloading: ${d.name}')" class="px-3 py-1.5 text-sm font-medium rounded-lg border border-amber-400 text-amber-700 hover:bg-amber-50 transition-colors">Download</button>
|
||||
<button onclick="alert('Delete requested for: ${d.name}')" class="px-3 py-1.5 text-sm font-medium rounded-lg border border-red-300 text-red-600 hover:bg-red-50 transition-colors">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<button onclick="alert('Viewing: ${d.name}')" class="px-3 py-1.5 text-sm font-medium rounded-lg border border-slate-300 text-slate-700 hover:bg-slate-50 transition-colors">View</button>
|
||||
<button onclick="alert('Downloading: ${d.name}')" class="px-3 py-1.5 text-sm font-medium rounded-lg border border-amber-400 text-amber-700 hover:bg-amber-50 transition-colors">Download</button>
|
||||
<button onclick="alert('Delete requested for: ${d.name}')" class="px-3 py-1.5 text-sm font-medium rounded-lg border border-red-300 text-red-600 hover:bg-red-50 transition-colors">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Filtering ────────────────────────────────────────
|
||||
function applyFilters() {
|
||||
const query = document.getElementById('search-input').value.toLowerCase();
|
||||
const type = document.getElementById('type-filter').value;
|
||||
const status = document.getElementById('status-filter').value;
|
||||
// ── Filtering ────────────────────────────────────────
|
||||
function applyFilters() {
|
||||
const query = document.getElementById('search-input').value.toLowerCase();
|
||||
const type = document.getElementById('type-filter').value;
|
||||
const status = document.getElementById('status-filter').value;
|
||||
|
||||
const filtered = MOCK_DOCUMENTS.filter(d => {
|
||||
if (query && !d.name.toLowerCase().includes(query) && !d.id.toLowerCase().includes(query)) return false;
|
||||
if (type !== 'all' && d.type !== type) return false;
|
||||
if (status !== 'all' && d.status !== status) return false;
|
||||
return true;
|
||||
});
|
||||
renderDocuments(filtered);
|
||||
}
|
||||
const filtered = MOCK_DOCUMENTS.filter(d => {
|
||||
if (query && !d.name.toLowerCase().includes(query) && !d.id.toLowerCase().includes(query)) return false;
|
||||
if (type !== 'all' && d.type !== type) return false;
|
||||
if (status !== 'all' && d.status !== status) return false;
|
||||
return true;
|
||||
});
|
||||
renderDocuments(filtered);
|
||||
}
|
||||
|
||||
document.getElementById('search-input').addEventListener('input', applyFilters);
|
||||
document.getElementById('type-filter').addEventListener('change', applyFilters);
|
||||
document.getElementById('status-filter').addEventListener('change', applyFilters);
|
||||
document.getElementById('search-input').addEventListener('input', applyFilters);
|
||||
document.getElementById('type-filter').addEventListener('change', applyFilters);
|
||||
document.getElementById('status-filter').addEventListener('change', applyFilters);
|
||||
|
||||
// ── Upload Modal ─────────────────────────────────────
|
||||
function openUploadModal() {
|
||||
const modal = document.getElementById('upload-modal');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
}
|
||||
// ── Upload Modal ─────────────────────────────────────
|
||||
function openUploadModal() {
|
||||
const modal = document.getElementById('upload-modal');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
}
|
||||
|
||||
function closeUploadModal() {
|
||||
const modal = document.getElementById('upload-modal');
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
function closeUploadModal() {
|
||||
const modal = document.getElementById('upload-modal');
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
|
||||
// ── Initial Render ───────────────────────────────────
|
||||
renderStats(MOCK_DOCUMENTS);
|
||||
renderExpiryWarnings(MOCK_DOCUMENTS);
|
||||
renderDocuments(MOCK_DOCUMENTS);
|
||||
// ── Initial Render ───────────────────────────────────
|
||||
renderStats(MOCK_DOCUMENTS);
|
||||
renderExpiryWarnings(MOCK_DOCUMENTS);
|
||||
renderDocuments(MOCK_DOCUMENTS);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -141,13 +141,16 @@
|
||||
|
||||
<div id="main-footer"></div>
|
||||
|
||||
<script src="mock-data.js"></script>
|
||||
<script src="api.js"></script>
|
||||
<script src="nav.js"></script>
|
||||
<script>
|
||||
renderNav('loadboard');
|
||||
renderBanner();
|
||||
renderFooter();
|
||||
|
||||
(async () => {
|
||||
const MOCK_LOAD_BOARD = await PilotEdge.getLoads();
|
||||
|
||||
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 === '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) {
|
||||
if (e.target === this) closePostModal();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -115,13 +115,16 @@
|
||||
|
||||
<div id="main-footer"></div>
|
||||
|
||||
<script src="mock-data.js"></script>
|
||||
<script src="api.js"></script>
|
||||
<script src="nav.js"></script>
|
||||
<script>
|
||||
renderNav('locator');
|
||||
renderBanner();
|
||||
renderFooter();
|
||||
|
||||
(async () => {
|
||||
const MOCK_ESCORT_OPERATORS = await PilotEdge.getEscortOperators();
|
||||
|
||||
// Populate certification filter
|
||||
const allCerts = new Set();
|
||||
MOCK_ESCORT_OPERATORS.forEach(op => op.certifications.forEach(c => allCerts.add(c)));
|
||||
@@ -296,6 +299,7 @@
|
||||
|
||||
// Initial render
|
||||
filterOperators();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -139,14 +139,17 @@
|
||||
|
||||
<div id="main-footer"></div>
|
||||
|
||||
<script src="mock-data.js"></script>
|
||||
<script src="mock-data-extended.js"></script>
|
||||
<script src="api.js"></script>
|
||||
<script src="nav.js"></script>
|
||||
<script>
|
||||
renderNav('regulations');
|
||||
renderBanner();
|
||||
renderFooter();
|
||||
|
||||
(async () => {
|
||||
const MOCK_STATE_REGULATIONS = await PilotEdge.getRegulations();
|
||||
const MOCK_STATE_EQUIPMENT = await PilotEdge.getEquipment();
|
||||
|
||||
// Initialize map centered on continental US
|
||||
const map = L.map('map').setView([39.5, -98.5], 4);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
@@ -435,6 +438,7 @@
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
3
server/.gitignore
vendored
Normal file
3
server/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
uploads/
|
||||
2146
server/package-lock.json
generated
Normal file
2146
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
server/package.json
Normal file
26
server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
308
server/prisma/migrations/20260330193158_init/migration.sql
Normal file
308
server/prisma/migrations/20260330193158_init/migration.sql
Normal 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;
|
||||
3
server/prisma/migrations/migration_lock.toml
Normal file
3
server/prisma/migrations/migration_lock.toml
Normal 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
312
server/prisma/schema.prisma
Normal 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
7
server/src/config/db.js
Normal 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
46
server/src/index.js
Normal 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`);
|
||||
});
|
||||
44
server/src/middleware/auth.js
Normal file
44
server/src/middleware/auth.js
Normal 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 };
|
||||
24
server/src/middleware/errorHandler.js
Normal file
24
server/src/middleware/errorHandler.js
Normal 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;
|
||||
54
server/src/routes/alerts.js
Normal file
54
server/src/routes/alerts.js
Normal 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
89
server/src/routes/auth.js
Normal 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;
|
||||
87
server/src/routes/bridges.js
Normal file
87
server/src/routes/bridges.js
Normal 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;
|
||||
34
server/src/routes/calendar.js
Normal file
34
server/src/routes/calendar.js
Normal 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;
|
||||
34
server/src/routes/contacts.js
Normal file
34
server/src/routes/contacts.js
Normal 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;
|
||||
74
server/src/routes/contributions.js
Normal file
74
server/src/routes/contributions.js
Normal 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;
|
||||
119
server/src/routes/documents.js
Normal file
119
server/src/routes/documents.js
Normal 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;
|
||||
119
server/src/routes/loadboard.js
Normal file
119
server/src/routes/loadboard.js
Normal 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;
|
||||
111
server/src/routes/locator.js
Normal file
111
server/src/routes/locator.js
Normal 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;
|
||||
92
server/src/routes/orders.js
Normal file
92
server/src/routes/orders.js
Normal 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;
|
||||
38
server/src/routes/regulations.js
Normal file
38
server/src/routes/regulations.js
Normal 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;
|
||||
86
server/src/routes/truckstops.js
Normal file
86
server/src/routes/truckstops.js
Normal 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;
|
||||
86
server/src/routes/weighstations.js
Normal file
86
server/src/routes/weighstations.js
Normal 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
346
server/src/seeds/seed.js
Normal 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();
|
||||
});
|
||||
@@ -65,14 +65,16 @@
|
||||
|
||||
<div id="main-footer"></div>
|
||||
|
||||
<script src="mock-data.js"></script>
|
||||
<script src="mock-data-extended.js"></script>
|
||||
<script src="api.js"></script>
|
||||
<script src="nav.js"></script>
|
||||
<script>
|
||||
renderNav('truckstops');
|
||||
renderBanner();
|
||||
renderFooter();
|
||||
|
||||
(async () => {
|
||||
const MOCK_TRUCK_STOPS = await PilotEdge.getTruckStops();
|
||||
|
||||
// Facility emoji mapping
|
||||
const facilityIcons = {
|
||||
fuel: '⛽', food: '🍔', restrooms: '🚻', showers: '🚿',
|
||||
@@ -235,6 +237,7 @@
|
||||
// Initialize
|
||||
initMap();
|
||||
renderStops(MOCK_TRUCK_STOPS);
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -90,14 +90,16 @@
|
||||
|
||||
<div id="main-footer"></div>
|
||||
|
||||
<script src="mock-data.js"></script>
|
||||
<script src="mock-data-extended.js"></script>
|
||||
<script src="api.js"></script>
|
||||
<script src="nav.js"></script>
|
||||
<script>
|
||||
renderNav('weighstations');
|
||||
renderBanner();
|
||||
renderFooter();
|
||||
|
||||
(async () => {
|
||||
const MOCK_WEIGH_STATIONS = await PilotEdge.getWeighStations();
|
||||
|
||||
// ── Local mutable copy of station data ──
|
||||
const stations = JSON.parse(JSON.stringify(MOCK_WEIGH_STATIONS));
|
||||
|
||||
@@ -261,6 +263,7 @@
|
||||
|
||||
// Initial render
|
||||
applyFilters();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user