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

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

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

3
server/.gitignore vendored Normal file
View File

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

2146
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
server/package.json Normal file
View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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