Build core domain: orgs, users, jobs, locate points, auth, live map

Replaces the device-events demo with the actual product:

- Prisma + PostGIS data layer (postgis/postgis:17-3.5). Lat/lng decimals
  are the source of truth; a generated geometry(Point,4326) column with
  a GIST index backs bbox queries. Migrations apply on container boot.
- JWT auth (bcryptjs + httpOnly cookie) with public registration that
  creates an org; per-org roles (ORG_ADMIN/MEMBER/VIEWER) enforced by
  guards on all /orgs/:orgId routes.
- Scoped API keys (X-API-Key, sha256-hashed, shown once) for
  programmatic access, manageable by org admins.
- REST API: jobs/tickets CRUD with filters, points query (time range,
  recordedAt cursor, bbox), members, devices, api-keys.
- MQTT ingest: devices publish to devices/{username}/points and /jobs;
  unknown tickets auto-create stub jobs (source=DEVICE); every message
  is raw-logged to device_events; acks on devices/{username}/jobs/ack.
  Broker gets a dedicated backend user; testuser is now a plain device.
- Realtime: plain-WS gateway at /api/ws (socket.io removed) with
  cookie auth and per-job channels feeding the map live.
- Next.js frontend: login/register, jobs list with filters, job detail
  with live Google map (APWA utility colors, polylines per run) behind
  a provider-neutral JobMap abstraction for a future Esri swap, and
  settings pages for members/devices/api-keys.
- Seed: Umagul org, admin user, testuser device, demo job with RTK
  points. Sample publisher updated to the new topic contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ulhub
2026-07-14 12:43:25 +00:00
parent cbe0cc6da2
commit eae4075265
79 changed files with 10215 additions and 494 deletions

View File

@@ -0,0 +1,201 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- PostGIS (required for locate_points.geom)
CREATE EXTENSION IF NOT EXISTS postgis;
-- CreateEnum
CREATE TYPE "OrgRole" AS ENUM ('ORG_ADMIN', 'MEMBER', 'VIEWER');
-- CreateEnum
CREATE TYPE "JobStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "JobSource" AS ENUM ('WEB', 'DEVICE');
-- CreateEnum
CREATE TYPE "UtilityType" AS ENUM ('ELECTRIC', 'GAS', 'WATER', 'SEWER', 'TELECOM', 'CATV', 'FIBER', 'STEAM', 'UNKNOWN');
-- CreateEnum
CREATE TYPE "GpsFixType" AS ENUM ('NONE', 'AUTONOMOUS', 'DGPS', 'FLOAT_RTK', 'FIXED_RTK');
-- CreateTable
CREATE TABLE "organizations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "org_memberships" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "OrgRole" NOT NULL DEFAULT 'MEMBER',
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "org_memberships_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "jobs" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"ticketNumber" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"address" TEXT,
"status" "JobStatus" NOT NULL DEFAULT 'OPEN',
"source" "JobSource" NOT NULL DEFAULT 'WEB',
"assignedToId" TEXT,
"createdById" TEXT,
"createdByDeviceId" TEXT,
"dueAt" TIMESTAMPTZ(6),
"startedAt" TIMESTAMPTZ(6),
"completedAt" TIMESTAMPTZ(6),
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "jobs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "devices" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"serialNumber" TEXT,
"mqttUsername" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastSeenAt" TIMESTAMPTZ(6),
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "devices_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "locate_points" (
"id" BIGSERIAL NOT NULL,
"jobId" TEXT NOT NULL,
"deviceId" TEXT,
"lat" DECIMAL(10,8) NOT NULL,
"lng" DECIMAL(11,8) NOT NULL,
"altitude" DECIMAL(8,3),
"fixType" "GpsFixType" NOT NULL DEFAULT 'NONE',
"hAccuracy" DECIMAL(7,3),
"depth" DECIMAL(6,3),
"utilityType" "UtilityType" NOT NULL DEFAULT 'UNKNOWN',
"sequence" INTEGER,
"recordedAt" TIMESTAMPTZ(6) NOT NULL,
"receivedAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"raw" JSONB,
-- geom is derived from lat/lng and can never drift; Prisma never writes it (Unsupported type)
"geom" geometry(Point, 4326) GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint("lng"::float8, "lat"::float8), 4326)) STORED,
CONSTRAINT "locate_points_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "api_keys" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"keyPrefix" TEXT NOT NULL,
"keyHash" TEXT NOT NULL,
"scopes" TEXT[],
"createdById" TEXT,
"expiresAt" TIMESTAMPTZ(6),
"lastUsedAt" TIMESTAMPTZ(6),
"revokedAt" TIMESTAMPTZ(6),
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "device_events" (
"id" SERIAL NOT NULL,
"topic" TEXT NOT NULL,
"payload" TEXT NOT NULL,
"received_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "device_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "organizations_slug_key" ON "organizations"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "org_memberships_orgId_userId_key" ON "org_memberships"("orgId", "userId");
-- CreateIndex
CREATE INDEX "jobs_orgId_status_idx" ON "jobs"("orgId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "jobs_orgId_ticketNumber_key" ON "jobs"("orgId", "ticketNumber");
-- CreateIndex
CREATE UNIQUE INDEX "devices_mqttUsername_key" ON "devices"("mqttUsername");
-- CreateIndex
CREATE INDEX "locate_points_jobId_recordedAt_idx" ON "locate_points"("jobId", "recordedAt");
-- CreateIndex (spatial)
CREATE INDEX "locate_points_geom_idx" ON "locate_points" USING GIST ("geom");
-- CreateIndex
CREATE UNIQUE INDEX "api_keys_keyHash_key" ON "api_keys"("keyHash");
-- CreateIndex
CREATE INDEX "api_keys_keyPrefix_idx" ON "api_keys"("keyPrefix");
-- AddForeignKey
ALTER TABLE "org_memberships" ADD CONSTRAINT "org_memberships_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "org_memberships" ADD CONSTRAINT "org_memberships_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "jobs" ADD CONSTRAINT "jobs_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "jobs" ADD CONSTRAINT "jobs_assignedToId_fkey" FOREIGN KEY ("assignedToId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "jobs" ADD CONSTRAINT "jobs_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "devices" ADD CONSTRAINT "devices_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "locate_points" ADD CONSTRAINT "locate_points_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "locate_points" ADD CONSTRAINT "locate_points_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "devices"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL 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"

View File

@@ -0,0 +1,188 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum OrgRole {
ORG_ADMIN
MEMBER
VIEWER
}
enum JobStatus {
OPEN
IN_PROGRESS
COMPLETED
CANCELLED
}
enum JobSource {
WEB
DEVICE
}
enum UtilityType {
ELECTRIC
GAS
WATER
SEWER
TELECOM
CATV
FIBER
STEAM
UNKNOWN
}
enum GpsFixType {
NONE
AUTONOMOUS
DGPS
FLOAT_RTK
FIXED_RTK
}
model Organization {
id String @id @default(cuid())
name String
slug String @unique
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
memberships OrgMembership[]
jobs Job[]
devices Device[]
apiKeys ApiKey[]
@@map("organizations")
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
memberships OrgMembership[]
assignedJobs Job[] @relation("JobAssignee")
createdJobs Job[] @relation("JobCreator")
apiKeys ApiKey[]
@@map("users")
}
model OrgMembership {
id String @id @default(cuid())
orgId String
userId String
role OrgRole @default(MEMBER)
createdAt DateTime @default(now()) @db.Timestamptz(6)
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([orgId, userId])
@@map("org_memberships")
}
model Job {
id String @id @default(cuid())
orgId String
ticketNumber String
title String
description String?
address String?
status JobStatus @default(OPEN)
source JobSource @default(WEB)
assignedToId String?
createdById String?
createdByDeviceId String?
dueAt DateTime? @db.Timestamptz(6)
startedAt DateTime? @db.Timestamptz(6)
completedAt DateTime? @db.Timestamptz(6)
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
assignedTo User? @relation("JobAssignee", fields: [assignedToId], references: [id])
createdBy User? @relation("JobCreator", fields: [createdById], references: [id])
points LocatePoint[]
@@unique([orgId, ticketNumber])
@@index([orgId, status])
@@map("jobs")
}
model Device {
id String @id @default(cuid())
orgId String
name String
serialNumber String?
mqttUsername String @unique
isActive Boolean @default(true)
lastSeenAt DateTime? @db.Timestamptz(6)
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
points LocatePoint[]
@@map("devices")
}
model LocatePoint {
id BigInt @id @default(autoincrement())
jobId String
deviceId String?
lat Decimal @db.Decimal(10, 8)
lng Decimal @db.Decimal(11, 8)
altitude Decimal? @db.Decimal(8, 3)
fixType GpsFixType @default(NONE)
hAccuracy Decimal? @db.Decimal(7, 3)
depth Decimal? @db.Decimal(6, 3)
utilityType UtilityType @default(UNKNOWN)
sequence Int?
recordedAt DateTime @db.Timestamptz(6)
receivedAt DateTime @default(now()) @db.Timestamptz(6)
raw Json?
geom Unsupported("geometry(Point, 4326)")?
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
device Device? @relation(fields: [deviceId], references: [id], onDelete: SetNull)
@@index([jobId, recordedAt])
@@map("locate_points")
}
model ApiKey {
id String @id @default(cuid())
orgId String
name String
keyPrefix String
keyHash String @unique
scopes String[]
createdById String?
expiresAt DateTime? @db.Timestamptz(6)
lastUsedAt DateTime? @db.Timestamptz(6)
revokedAt DateTime? @db.Timestamptz(6)
createdAt DateTime @default(now()) @db.Timestamptz(6)
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id])
@@index([keyPrefix])
@@map("api_keys")
}
// Raw MQTT ingest log (every message on devices/#, matched or not)
model DeviceEvent {
id Int @id @default(autoincrement())
topic String
payload String
receivedAt DateTime @default(now()) @map("received_at") @db.Timestamptz(6)
@@map("device_events")
}

88
backend/prisma/seed.ts Normal file
View File

@@ -0,0 +1,88 @@
import { PrismaClient, GpsFixType, JobStatus, UtilityType } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
const SEED_ADMIN_EMAIL = 'brent.perteet@gmail.com';
const SEED_ADMIN_PASSWORD = 'changeme123';
async function main() {
const org = await prisma.organization.upsert({
where: { slug: 'umagul' },
update: {},
create: { name: 'Umagul', slug: 'umagul' },
});
const admin = await prisma.user.upsert({
where: { email: SEED_ADMIN_EMAIL },
update: {},
create: {
email: SEED_ADMIN_EMAIL,
name: 'Brent Perteet',
passwordHash: await bcrypt.hash(SEED_ADMIN_PASSWORD, 12),
},
});
await prisma.orgMembership.upsert({
where: { orgId_userId: { orgId: org.id, userId: admin.id } },
update: { role: 'ORG_ADMIN' },
create: { orgId: org.id, userId: admin.id, role: 'ORG_ADMIN' },
});
const device = await prisma.device.upsert({
where: { mqttUsername: 'testuser' },
update: {},
create: {
orgId: org.id,
name: 'Demo locator',
mqttUsername: 'testuser',
serialNumber: 'DEMO-0001',
},
});
const job = await prisma.job.upsert({
where: { orgId_ticketNumber: { orgId: org.id, ticketNumber: 'TKT-2026-0001' } },
update: {},
create: {
orgId: org.id,
ticketNumber: 'TKT-2026-0001',
title: 'Gas line locate - Main St demo',
description: 'Seeded demo job with sample RTK points along a gas line.',
address: '100 Main St',
status: JobStatus.IN_PROGRESS,
createdById: admin.id,
assignedToId: admin.id,
},
});
const existing = await prisma.locatePoint.count({ where: { jobId: job.id } });
if (existing === 0) {
// A short run of points along a line (roughly northeast, ~1.5m spacing)
const startLat = 33.15012345;
const startLng = -96.83512345;
const points = Array.from({ length: 12 }, (_, i) => ({
jobId: job.id,
deviceId: device.id,
lat: startLat + i * 0.0000135,
lng: startLng + i * 0.0000042,
altitude: 187.4 + i * 0.02,
fixType: GpsFixType.FIXED_RTK,
hAccuracy: 0.014,
depth: 1.2,
utilityType: UtilityType.GAS,
sequence: i + 1,
recordedAt: new Date(Date.now() - (12 - i) * 5000),
}));
await prisma.locatePoint.createMany({ data: points });
}
console.log(`Seeded org=${org.slug} admin=${admin.email} device=${device.mqttUsername} job=${job.ticketNumber}`);
console.log(`Admin password: ${SEED_ADMIN_PASSWORD}`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());