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:
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();
|
||||
});
|
||||
Reference in New Issue
Block a user