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>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="api.js"></script>
|
||||||
<script src="mock-data-extended.js"></script>
|
|
||||||
<script src="nav.js"></script>
|
<script src="nav.js"></script>
|
||||||
<script>
|
<script>
|
||||||
renderNav('alerts');
|
renderNav('alerts');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const alertData = await PilotEdge.getAlerts();
|
||||||
|
const MOCK_ROUTE_CONDITIONS = alertData.routeConditions;
|
||||||
|
const MOCK_WEATHER_ALERTS = alertData.weatherAlerts;
|
||||||
|
|
||||||
// ── State ──
|
// ── State ──
|
||||||
let activeTab = 'all';
|
let activeTab = 'all';
|
||||||
let map, markersLayer;
|
let map, markersLayer;
|
||||||
@@ -413,6 +417,7 @@
|
|||||||
document.getElementById('filter-type').addEventListener('change', renderAll);
|
document.getElementById('filter-type').addEventListener('change', renderAll);
|
||||||
document.getElementById('filter-severity').addEventListener('change', renderAll);
|
document.getElementById('filter-severity').addEventListener('change', renderAll);
|
||||||
document.getElementById('filter-oversize').addEventListener('change', renderAll);
|
document.getElementById('filter-oversize').addEventListener('change', renderAll);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
448
api.js
Normal file
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>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="api.js"></script>
|
||||||
<script src="mock-data-extended.js"></script>
|
|
||||||
<script src="nav.js"></script>
|
<script src="nav.js"></script>
|
||||||
<script>
|
<script>
|
||||||
renderNav('bridges');
|
renderNav('bridges');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const MOCK_BRIDGE_CLEARANCES = await PilotEdge.getBridges();
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
// Parse a clearance string like '13\'6"' or '14\'0"' into decimal feet.
|
// Parse a clearance string like '13\'6"' or '14\'0"' into decimal feet.
|
||||||
@@ -380,6 +382,7 @@
|
|||||||
|
|
||||||
// --- Initial render ---
|
// --- Initial render ---
|
||||||
applyFilters();
|
applyFilters();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -85,14 +85,16 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="api.js"></script>
|
||||||
<script src="mock-data-extended.js"></script>
|
|
||||||
<script src="nav.js"></script>
|
<script src="nav.js"></script>
|
||||||
<script>
|
<script>
|
||||||
renderNav('calendar');
|
renderNav('calendar');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const MOCK_SEASONAL_RESTRICTIONS = await PilotEdge.getSeasonalRestrictions();
|
||||||
|
|
||||||
// ---- Constants ----
|
// ---- Constants ----
|
||||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
|
||||||
@@ -286,6 +288,7 @@
|
|||||||
|
|
||||||
// ---- Initial render ----
|
// ---- Initial render ----
|
||||||
applyFilters();
|
applyFilters();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
101
contacts.html
101
contacts.html
@@ -42,67 +42,70 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="api.js"></script>
|
||||||
<script src="mock-data-extended.js"></script>
|
|
||||||
<script src="nav.js"></script>
|
<script src="nav.js"></script>
|
||||||
<script>
|
<script>
|
||||||
renderNav('contacts');
|
renderNav('contacts');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
const grid = document.getElementById('contacts-grid');
|
(async () => {
|
||||||
const noResults = document.getElementById('no-results');
|
const MOCK_STATE_CONTACTS = await PilotEdge.getContacts();
|
||||||
const searchInput = document.getElementById('state-search');
|
|
||||||
|
|
||||||
function buildCard(abbr, c) {
|
const grid = document.getElementById('contacts-grid');
|
||||||
return `
|
const noResults = document.getElementById('no-results');
|
||||||
<div class="bg-white rounded-2xl shadow-lg p-6 flex flex-col" data-state="${c.name.toLowerCase()}">
|
const searchInput = document.getElementById('state-search');
|
||||||
<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>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCards(filter) {
|
function buildCard(abbr, c) {
|
||||||
const term = (filter || '').toLowerCase();
|
return `
|
||||||
let html = '';
|
<div class="bg-white rounded-2xl shadow-lg p-6 flex flex-col" data-state="${c.name.toLowerCase()}">
|
||||||
let count = 0;
|
<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 => {
|
function renderCards(filter) {
|
||||||
const c = MOCK_STATE_CONTACTS[abbr];
|
const term = (filter || '').toLowerCase();
|
||||||
if (!term || c.name.toLowerCase().includes(term) || abbr.toLowerCase().includes(term)) {
|
let html = '';
|
||||||
html += buildCard(abbr, c);
|
let count = 0;
|
||||||
count++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
grid.innerHTML = html;
|
Object.keys(MOCK_STATE_CONTACTS).forEach(abbr => {
|
||||||
noResults.classList.toggle('hidden', count > 0);
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
263
documents.html
263
documents.html
@@ -105,155 +105,158 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="api.js"></script>
|
||||||
<script src="mock-data-extended.js"></script>
|
|
||||||
<script src="nav.js"></script>
|
<script src="nav.js"></script>
|
||||||
<script>
|
<script>
|
||||||
renderNav('documents');
|
renderNav('documents');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────
|
(async () => {
|
||||||
const today = new Date();
|
const MOCK_DOCUMENTS = await PilotEdge.getDocuments();
|
||||||
const MS_PER_DAY = 86400000;
|
|
||||||
|
|
||||||
function daysUntil(dateStr) {
|
// ── Helpers ──────────────────────────────────────────
|
||||||
return Math.ceil((new Date(dateStr) - today) / MS_PER_DAY);
|
const today = new Date();
|
||||||
}
|
const MS_PER_DAY = 86400000;
|
||||||
|
|
||||||
function fmtDate(dateStr) {
|
function daysUntil(dateStr) {
|
||||||
return new Date(dateStr).toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' });
|
return Math.ceil((new Date(dateStr) - today) / MS_PER_DAY);
|
||||||
}
|
|
||||||
|
|
||||||
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 => {
|
function fmtDate(dateStr) {
|
||||||
const statusBadge = d.status === 'active'
|
return new Date(dateStr).toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' });
|
||||||
? 'bg-green-100 text-green-700'
|
}
|
||||||
: 'bg-red-100 text-red-700';
|
|
||||||
return `
|
const typeBadge = {
|
||||||
<div class="bg-white rounded-2xl shadow-lg p-5 sm:p-6 hover:shadow-xl transition-shadow border border-slate-100">
|
permit: 'bg-blue-100 text-blue-700',
|
||||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
insurance: 'bg-green-100 text-green-700',
|
||||||
<div class="flex-1 min-w-0">
|
certification: 'bg-purple-100 text-purple-700',
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
registration: 'bg-slate-200 text-slate-700'
|
||||||
<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 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>
|
||||||
<div class="flex flex-wrap gap-x-5 gap-y-1 text-sm text-slate-500">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
${d.state ? `<span>📍 ${d.state}</span>` : ''}
|
<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>
|
||||||
<span>📤 Uploaded ${fmtDate(d.uploadDate)}</span>
|
<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>
|
||||||
<span>📅 Expires ${fmtDate(d.expiryDate)}</span>
|
<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>
|
||||||
<span>💾 ${d.fileSize}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
</div>`;
|
||||||
<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>
|
}).join('');
|
||||||
<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('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Filtering ────────────────────────────────────────
|
// ── Filtering ────────────────────────────────────────
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
const query = document.getElementById('search-input').value.toLowerCase();
|
const query = document.getElementById('search-input').value.toLowerCase();
|
||||||
const type = document.getElementById('type-filter').value;
|
const type = document.getElementById('type-filter').value;
|
||||||
const status = document.getElementById('status-filter').value;
|
const status = document.getElementById('status-filter').value;
|
||||||
|
|
||||||
const filtered = MOCK_DOCUMENTS.filter(d => {
|
const filtered = MOCK_DOCUMENTS.filter(d => {
|
||||||
if (query && !d.name.toLowerCase().includes(query) && !d.id.toLowerCase().includes(query)) return false;
|
if (query && !d.name.toLowerCase().includes(query) && !d.id.toLowerCase().includes(query)) return false;
|
||||||
if (type !== 'all' && d.type !== type) return false;
|
if (type !== 'all' && d.type !== type) return false;
|
||||||
if (status !== 'all' && d.status !== status) return false;
|
if (status !== 'all' && d.status !== status) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
renderDocuments(filtered);
|
renderDocuments(filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('search-input').addEventListener('input', applyFilters);
|
document.getElementById('search-input').addEventListener('input', applyFilters);
|
||||||
document.getElementById('type-filter').addEventListener('change', applyFilters);
|
document.getElementById('type-filter').addEventListener('change', applyFilters);
|
||||||
document.getElementById('status-filter').addEventListener('change', applyFilters);
|
document.getElementById('status-filter').addEventListener('change', applyFilters);
|
||||||
|
|
||||||
// ── Upload Modal ─────────────────────────────────────
|
// ── Upload Modal ─────────────────────────────────────
|
||||||
function openUploadModal() {
|
function openUploadModal() {
|
||||||
const modal = document.getElementById('upload-modal');
|
const modal = document.getElementById('upload-modal');
|
||||||
modal.classList.remove('hidden');
|
modal.classList.remove('hidden');
|
||||||
modal.classList.add('flex');
|
modal.classList.add('flex');
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeUploadModal() {
|
function closeUploadModal() {
|
||||||
const modal = document.getElementById('upload-modal');
|
const modal = document.getElementById('upload-modal');
|
||||||
modal.classList.add('hidden');
|
modal.classList.add('hidden');
|
||||||
modal.classList.remove('flex');
|
modal.classList.remove('flex');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Initial Render ───────────────────────────────────
|
// ── Initial Render ───────────────────────────────────
|
||||||
renderStats(MOCK_DOCUMENTS);
|
renderStats(MOCK_DOCUMENTS);
|
||||||
renderExpiryWarnings(MOCK_DOCUMENTS);
|
renderExpiryWarnings(MOCK_DOCUMENTS);
|
||||||
renderDocuments(MOCK_DOCUMENTS);
|
renderDocuments(MOCK_DOCUMENTS);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -141,13 +141,16 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="api.js"></script>
|
||||||
<script src="nav.js"></script>
|
<script src="nav.js"></script>
|
||||||
<script>
|
<script>
|
||||||
renderNav('loadboard');
|
renderNav('loadboard');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const MOCK_LOAD_BOARD = await PilotEdge.getLoads();
|
||||||
|
|
||||||
function getStatusBadge(status) {
|
function getStatusBadge(status) {
|
||||||
if (status === 'posted') return '<span class="bg-green-100 text-green-800 text-xs font-bold px-3 py-1 rounded-full">AVAILABLE</span>';
|
if (status === 'posted') return '<span class="bg-green-100 text-green-800 text-xs font-bold px-3 py-1 rounded-full">AVAILABLE</span>';
|
||||||
if (status === 'in_transit') return '<span class="bg-blue-100 text-blue-800 text-xs font-bold px-3 py-1 rounded-full">IN TRANSIT</span>';
|
if (status === 'in_transit') return '<span class="bg-blue-100 text-blue-800 text-xs font-bold px-3 py-1 rounded-full">IN TRANSIT</span>';
|
||||||
@@ -293,6 +296,7 @@
|
|||||||
document.getElementById('post-modal').addEventListener('click', function(e) {
|
document.getElementById('post-modal').addEventListener('click', function(e) {
|
||||||
if (e.target === this) closePostModal();
|
if (e.target === this) closePostModal();
|
||||||
});
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -115,13 +115,16 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="api.js"></script>
|
||||||
<script src="nav.js"></script>
|
<script src="nav.js"></script>
|
||||||
<script>
|
<script>
|
||||||
renderNav('locator');
|
renderNav('locator');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const MOCK_ESCORT_OPERATORS = await PilotEdge.getEscortOperators();
|
||||||
|
|
||||||
// Populate certification filter
|
// Populate certification filter
|
||||||
const allCerts = new Set();
|
const allCerts = new Set();
|
||||||
MOCK_ESCORT_OPERATORS.forEach(op => op.certifications.forEach(c => allCerts.add(c)));
|
MOCK_ESCORT_OPERATORS.forEach(op => op.certifications.forEach(c => allCerts.add(c)));
|
||||||
@@ -296,6 +299,7 @@
|
|||||||
|
|
||||||
// Initial render
|
// Initial render
|
||||||
filterOperators();
|
filterOperators();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -139,14 +139,17 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="api.js"></script>
|
||||||
<script src="mock-data-extended.js"></script>
|
|
||||||
<script src="nav.js"></script>
|
<script src="nav.js"></script>
|
||||||
<script>
|
<script>
|
||||||
renderNav('regulations');
|
renderNav('regulations');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const MOCK_STATE_REGULATIONS = await PilotEdge.getRegulations();
|
||||||
|
const MOCK_STATE_EQUIPMENT = await PilotEdge.getEquipment();
|
||||||
|
|
||||||
// Initialize map centered on continental US
|
// Initialize map centered on continental US
|
||||||
const map = L.map('map').setView([39.5, -98.5], 4);
|
const map = L.map('map').setView([39.5, -98.5], 4);
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
@@ -435,6 +438,7 @@
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
3
server/.gitignore
vendored
Normal file
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>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="api.js"></script>
|
||||||
<script src="mock-data-extended.js"></script>
|
|
||||||
<script src="nav.js"></script>
|
<script src="nav.js"></script>
|
||||||
<script>
|
<script>
|
||||||
renderNav('truckstops');
|
renderNav('truckstops');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const MOCK_TRUCK_STOPS = await PilotEdge.getTruckStops();
|
||||||
|
|
||||||
// Facility emoji mapping
|
// Facility emoji mapping
|
||||||
const facilityIcons = {
|
const facilityIcons = {
|
||||||
fuel: '⛽', food: '🍔', restrooms: '🚻', showers: '🚿',
|
fuel: '⛽', food: '🍔', restrooms: '🚻', showers: '🚿',
|
||||||
@@ -235,6 +237,7 @@
|
|||||||
// Initialize
|
// Initialize
|
||||||
initMap();
|
initMap();
|
||||||
renderStops(MOCK_TRUCK_STOPS);
|
renderStops(MOCK_TRUCK_STOPS);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -90,14 +90,16 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="api.js"></script>
|
||||||
<script src="mock-data-extended.js"></script>
|
|
||||||
<script src="nav.js"></script>
|
<script src="nav.js"></script>
|
||||||
<script>
|
<script>
|
||||||
renderNav('weighstations');
|
renderNav('weighstations');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const MOCK_WEIGH_STATIONS = await PilotEdge.getWeighStations();
|
||||||
|
|
||||||
// ── Local mutable copy of station data ──
|
// ── Local mutable copy of station data ──
|
||||||
const stations = JSON.parse(JSON.stringify(MOCK_WEIGH_STATIONS));
|
const stations = JSON.parse(JSON.stringify(MOCK_WEIGH_STATIONS));
|
||||||
|
|
||||||
@@ -261,6 +263,7 @@
|
|||||||
|
|
||||||
// Initial render
|
// Initial render
|
||||||
applyFilters();
|
applyFilters();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user