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:
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# Copy to .env and fill in real values (.env is gitignored)
|
||||
|
||||
# Secret used to sign JWT auth tokens
|
||||
JWT_SECRET=change-me-to-a-long-random-string
|
||||
|
||||
# MQTT credentials the backend uses to connect to mosquitto
|
||||
MQTT_BACKEND_USERNAME=backend
|
||||
MQTT_BACKEND_PASSWORD=backendpass
|
||||
|
||||
# Google Maps JavaScript API key (exposed to the browser; restrict by HTTP referrer)
|
||||
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=
|
||||
@@ -3,6 +3,7 @@ FROM node:20-alpine
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
1003
backend/package-lock.json
generated
1003
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,28 +6,46 @@
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\""
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"postinstall": "prisma generate",
|
||||
"db:seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/platform-socket.io": "^10.0.0",
|
||||
"@nestjs/platform-ws": "^10.0.0",
|
||||
"@nestjs/websockets": "^10.0.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@prisma/client": "^6.10.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"mqtt": "^5.15.2",
|
||||
"pg": "^8.22.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-custom": "^1.1.1",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.0.0",
|
||||
"socket.io": "^4.7.0"
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/ws": "^8.5.10",
|
||||
"prettier": "^3.0.0",
|
||||
"prisma": "^6.10.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
|
||||
201
backend/prisma/migrations/20260714000000_init/migration.sql
Normal file
201
backend/prisma/migrations/20260714000000_init/migration.sql
Normal 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;
|
||||
|
||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/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"
|
||||
188
backend/prisma/schema.prisma
Normal file
188
backend/prisma/schema.prisma
Normal 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
88
backend/prisma/seed.ts
Normal 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());
|
||||
35
backend/src/api-keys/api-keys.controller.ts
Normal file
35
backend/src/api-keys/api-keys.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { CurrentPrincipal } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/auth.guard';
|
||||
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
|
||||
import { UserPrincipal } from '../auth/principal';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { CreateApiKeyDto } from './dto/api-keys.dto';
|
||||
|
||||
// JWT-only by design: an API key must not be able to mint or revoke API keys
|
||||
@Controller('orgs/:orgId/api-keys')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('ORG_ADMIN')
|
||||
export class ApiKeysController {
|
||||
constructor(private readonly apiKeysService: ApiKeysService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('orgId') orgId: string) {
|
||||
return this.apiKeysService.list(orgId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@Param('orgId') orgId: string,
|
||||
@Body() dto: CreateApiKeyDto,
|
||||
@CurrentPrincipal() principal: UserPrincipal,
|
||||
) {
|
||||
return this.apiKeysService.create(orgId, dto, principal.userId);
|
||||
}
|
||||
|
||||
@Delete(':keyId')
|
||||
revoke(@Param('orgId') orgId: string, @Param('keyId') keyId: string) {
|
||||
return this.apiKeysService.revoke(orgId, keyId);
|
||||
}
|
||||
}
|
||||
9
backend/src/api-keys/api-keys.module.ts
Normal file
9
backend/src/api-keys/api-keys.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ApiKeysController],
|
||||
providers: [ApiKeysService],
|
||||
})
|
||||
export class ApiKeysModule {}
|
||||
71
backend/src/api-keys/api-keys.service.ts
Normal file
71
backend/src/api-keys/api-keys.service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { API_KEY_PREFIX, hashApiKey } from '../auth/strategies/api-key.strategy';
|
||||
import { CreateApiKeyDto } from './dto/api-keys.dto';
|
||||
|
||||
const KEY_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
keyPrefix: true,
|
||||
scopes: true,
|
||||
expiresAt: true,
|
||||
lastUsedAt: true,
|
||||
revokedAt: true,
|
||||
createdAt: true,
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
};
|
||||
|
||||
function generateKey(): string {
|
||||
// ulh_ + 40 chars base62
|
||||
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
const bytes = randomBytes(40);
|
||||
let suffix = '';
|
||||
for (let i = 0; i < 40; i++) {
|
||||
suffix += alphabet[bytes[i] % alphabet.length];
|
||||
}
|
||||
return API_KEY_PREFIX + suffix;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeysService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list(orgId: string) {
|
||||
return this.prisma.apiKey.findMany({
|
||||
where: { orgId },
|
||||
select: KEY_SELECT,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(orgId: string, dto: CreateApiKeyDto, createdById: string) {
|
||||
const key = generateKey();
|
||||
const record = await this.prisma.apiKey.create({
|
||||
data: {
|
||||
orgId,
|
||||
name: dto.name,
|
||||
keyPrefix: key.slice(0, 12),
|
||||
keyHash: hashApiKey(key),
|
||||
scopes: dto.scopes,
|
||||
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined,
|
||||
createdById,
|
||||
},
|
||||
select: KEY_SELECT,
|
||||
});
|
||||
// The plaintext key is returned exactly once; only the hash is stored.
|
||||
return { ...record, key };
|
||||
}
|
||||
|
||||
async revoke(orgId: string, keyId: string) {
|
||||
const record = await this.prisma.apiKey.findFirst({ where: { id: keyId, orgId } });
|
||||
if (!record) {
|
||||
throw new NotFoundException('API key not found');
|
||||
}
|
||||
await this.prisma.apiKey.update({
|
||||
where: { id: keyId },
|
||||
data: { revokedAt: record.revokedAt ?? new Date() },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
18
backend/src/api-keys/dto/api-keys.dto.ts
Normal file
18
backend/src/api-keys/dto/api-keys.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ArrayMinSize, IsArray, IsDateString, IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { API_KEY_SCOPES, ApiKeyScope } from '../../auth/principal';
|
||||
|
||||
export class CreateApiKeyDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
name: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsIn(API_KEY_SCOPES, { each: true })
|
||||
scopes: ApiKeyScope[];
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiresAt?: string;
|
||||
}
|
||||
@@ -2,12 +2,29 @@ import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { StatusController } from './status.controller';
|
||||
import { DeviceEventsController } from './device-events.controller';
|
||||
import { DeviceDataService } from './device-data.service';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { OrgsModule } from './orgs/orgs.module';
|
||||
import { JobsModule } from './jobs/jobs.module';
|
||||
import { PointsModule } from './points/points.module';
|
||||
import { DevicesModule } from './devices/devices.module';
|
||||
import { IngestModule } from './ingest/ingest.module';
|
||||
import { RealtimeModule } from './realtime/realtime.module';
|
||||
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AppController, StatusController, DeviceEventsController],
|
||||
providers: [AppService, DeviceDataService],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
OrgsModule,
|
||||
JobsModule,
|
||||
PointsModule,
|
||||
DevicesModule,
|
||||
IngestModule,
|
||||
RealtimeModule,
|
||||
ApiKeysModule,
|
||||
],
|
||||
controllers: [AppController, StatusController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
55
backend/src/auth/auth.controller.ts
Normal file
55
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Body, Controller, Get, HttpCode, Post, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentPrincipal } from './decorators/current-user.decorator';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { JwtAuthGuard } from './guards/auth.guard';
|
||||
import { UserPrincipal } from './principal';
|
||||
import { AUTH_COOKIE } from './strategies/jwt.strategy';
|
||||
|
||||
const COOKIE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // matches JWT expiry
|
||||
|
||||
function setAuthCookie(res: Response, token: string) {
|
||||
res.cookie(AUTH_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: COOKIE_MAX_AGE_MS,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
||||
const { token, user, memberships } = await this.authService.register(dto);
|
||||
setAuthCookie(res, token);
|
||||
return { user, memberships };
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
||||
const { token, user, memberships } = await this.authService.login(dto);
|
||||
setAuthCookie(res, token);
|
||||
return { user, memberships };
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(200)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
logout(@Res({ passthrough: true }) res: Response) {
|
||||
res.clearCookie(AUTH_COOKIE, { path: '/' });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentPrincipal() principal: UserPrincipal) {
|
||||
return this.authService.me(principal.userId);
|
||||
}
|
||||
}
|
||||
21
backend/src/auth/auth.module.ts
Normal file
21
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ApiKeyStrategy } from './strategies/api-key.strategy';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'dev-only-insecure-secret',
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, ApiKeyStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
92
backend/src/auth/auth.service.ts
Normal file
92
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ConflictException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { JwtPayload } from './strategies/jwt.strategy';
|
||||
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48) || 'org';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
const existing = await this.prisma.user.findUnique({ where: { email: dto.email } });
|
||||
if (existing) {
|
||||
throw new ConflictException('An account with this email already exists');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
|
||||
const baseSlug = slugify(dto.orgName);
|
||||
|
||||
const { user } = await this.prisma.$transaction(async (tx) => {
|
||||
let slug = baseSlug;
|
||||
if (await tx.organization.findUnique({ where: { slug } })) {
|
||||
slug = `${baseSlug}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
const org = await tx.organization.create({ data: { name: dto.orgName, slug } });
|
||||
const user = await tx.user.create({
|
||||
data: { email: dto.email, name: dto.name, passwordHash },
|
||||
});
|
||||
await tx.orgMembership.create({
|
||||
data: { orgId: org.id, userId: user.id, role: 'ORG_ADMIN' },
|
||||
});
|
||||
return { user, org };
|
||||
});
|
||||
|
||||
return this.sessionFor(user.id);
|
||||
}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
|
||||
if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) {
|
||||
throw new UnauthorizedException('Invalid email or password');
|
||||
}
|
||||
return this.sessionFor(user.id);
|
||||
}
|
||||
|
||||
async me(userId: string) {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true, name: true, createdAt: true },
|
||||
});
|
||||
const memberships = await this.membershipsOf(userId);
|
||||
return { user, memberships };
|
||||
}
|
||||
|
||||
private async sessionFor(userId: string) {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true, name: true },
|
||||
});
|
||||
const payload: JwtPayload = { sub: user.id, email: user.email };
|
||||
const token = this.jwtService.sign(payload);
|
||||
const memberships = await this.membershipsOf(userId);
|
||||
return { token, user, memberships };
|
||||
}
|
||||
|
||||
private membershipsOf(userId: string) {
|
||||
return this.prisma.orgMembership.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
role: true,
|
||||
org: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
6
backend/src/auth/decorators/current-user.decorator.ts
Normal file
6
backend/src/auth/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { Principal } from '../principal';
|
||||
|
||||
export const CurrentPrincipal = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): Principal => ctx.switchToHttp().getRequest().user,
|
||||
);
|
||||
7
backend/src/auth/decorators/roles.decorator.ts
Normal file
7
backend/src/auth/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { OrgRole } from '@prisma/client';
|
||||
|
||||
export const ROLES_KEY = 'org_role';
|
||||
|
||||
// Requires the JWT user to hold AT LEAST this role in the org from :orgId.
|
||||
export const Roles = (role: OrgRole) => SetMetadata(ROLES_KEY, role);
|
||||
8
backend/src/auth/decorators/scopes.decorator.ts
Normal file
8
backend/src/auth/decorators/scopes.decorator.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { ApiKeyScope } from '../principal';
|
||||
|
||||
export const SCOPES_KEY = 'api_key_scopes';
|
||||
|
||||
// Requires API-key principals to hold ALL listed scopes. JWT users are unaffected
|
||||
// (their access is governed by org role instead).
|
||||
export const RequireScopes = (...scopes: ApiKeyScope[]) => SetMetadata(SCOPES_KEY, scopes);
|
||||
10
backend/src/auth/dto/login.dto.ts
Normal file
10
backend/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
password: string;
|
||||
}
|
||||
21
backend/src/auth/dto/register.dto.ts
Normal file
21
backend/src/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(72)
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
orgName: string;
|
||||
}
|
||||
10
backend/src/auth/guards/auth.guard.ts
Normal file
10
backend/src/auth/guards/auth.guard.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
// JWT-only (auth/me, logout — endpoints that make no sense for API keys)
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
|
||||
// Accepts either a logged-in user (cookie/Bearer JWT) or an X-API-Key.
|
||||
@Injectable()
|
||||
export class UserOrApiKeyGuard extends AuthGuard(['jwt', 'api-key']) {}
|
||||
51
backend/src/auth/guards/org-roles.guard.ts
Normal file
51
backend/src/auth/guards/org-roles.guard.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { OrgRole } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
import { Principal, ROLE_RANK } from '../principal';
|
||||
|
||||
// Runs after UserOrApiKeyGuard on org-scoped routes (/orgs/:orgId/...).
|
||||
// Users: must be a member of the org, with at least the @Roles() role if present.
|
||||
// API keys: must belong to the org (scopes are checked by ScopesGuard).
|
||||
@Injectable()
|
||||
export class OrgRolesGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const principal: Principal | undefined = req.user;
|
||||
const orgId: string | undefined = req.params?.orgId;
|
||||
if (!principal || !orgId) {
|
||||
throw new ForbiddenException('Organization scope required');
|
||||
}
|
||||
|
||||
if (principal.type === 'apiKey') {
|
||||
if (principal.orgId !== orgId) {
|
||||
throw new ForbiddenException('API key does not belong to this organization');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const membership = await this.prisma.orgMembership.findUnique({
|
||||
where: { orgId_userId: { orgId, userId: principal.userId } },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('Not a member of this organization');
|
||||
}
|
||||
|
||||
const required = this.reflector.getAllAndOverride<OrgRole | undefined>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (required && ROLE_RANK[membership.role] < ROLE_RANK[required]) {
|
||||
throw new ForbiddenException(`Requires ${required} role`);
|
||||
}
|
||||
|
||||
req.membership = membership;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
26
backend/src/auth/guards/scopes.guard.ts
Normal file
26
backend/src/auth/guards/scopes.guard.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { SCOPES_KEY } from '../decorators/scopes.decorator';
|
||||
import { ApiKeyScope, Principal } from '../principal';
|
||||
|
||||
@Injectable()
|
||||
export class ScopesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const principal: Principal | undefined = context.switchToHttp().getRequest().user;
|
||||
if (!principal || principal.type !== 'apiKey') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const required = this.reflector.getAllAndOverride<ApiKeyScope[] | undefined>(SCOPES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
const missing = (required ?? []).filter((s) => !principal.scopes.includes(s));
|
||||
if (missing.length > 0) {
|
||||
throw new ForbiddenException(`API key missing scope(s): ${missing.join(', ')}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
32
backend/src/auth/principal.ts
Normal file
32
backend/src/auth/principal.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { OrgRole } from '@prisma/client';
|
||||
|
||||
export interface UserPrincipal {
|
||||
type: 'user';
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyPrincipal {
|
||||
type: 'apiKey';
|
||||
apiKeyId: string;
|
||||
orgId: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export type Principal = UserPrincipal | ApiKeyPrincipal;
|
||||
|
||||
// Rank order used by OrgRolesGuard: a required role means "at least this role".
|
||||
export const ROLE_RANK: Record<OrgRole, number> = {
|
||||
VIEWER: 0,
|
||||
MEMBER: 1,
|
||||
ORG_ADMIN: 2,
|
||||
};
|
||||
|
||||
export const API_KEY_SCOPES = [
|
||||
'jobs:read',
|
||||
'jobs:write',
|
||||
'points:read',
|
||||
'points:write',
|
||||
'devices:read',
|
||||
] as const;
|
||||
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
||||
40
backend/src/auth/strategies/api-key.strategy.ts
Normal file
40
backend/src/auth/strategies/api-key.strategy.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-custom';
|
||||
import type { Request } from 'express';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { ApiKeyPrincipal } from '../principal';
|
||||
|
||||
export const API_KEY_HEADER = 'x-api-key';
|
||||
export const API_KEY_PREFIX = 'ulh_';
|
||||
|
||||
export function hashApiKey(key: string): string {
|
||||
return createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyStrategy extends PassportStrategy(Strategy, 'api-key') {
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async validate(req: Request): Promise<ApiKeyPrincipal> {
|
||||
const key = req.headers[API_KEY_HEADER];
|
||||
if (typeof key !== 'string' || !key.startsWith(API_KEY_PREFIX)) {
|
||||
throw new UnauthorizedException('Missing or malformed API key');
|
||||
}
|
||||
|
||||
const record = await this.prisma.apiKey.findUnique({ where: { keyHash: hashApiKey(key) } });
|
||||
if (!record || record.revokedAt || (record.expiresAt && record.expiresAt < new Date())) {
|
||||
throw new UnauthorizedException('Invalid API key');
|
||||
}
|
||||
|
||||
// fire-and-forget usage stamp; never block the request on it
|
||||
this.prisma.apiKey
|
||||
.update({ where: { id: record.id }, data: { lastUsedAt: new Date() } })
|
||||
.catch(() => undefined);
|
||||
|
||||
return { type: 'apiKey', apiKeyId: record.id, orgId: record.orgId, scopes: record.scopes };
|
||||
}
|
||||
}
|
||||
29
backend/src/auth/strategies/jwt.strategy.ts
Normal file
29
backend/src/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { UserPrincipal } from '../principal';
|
||||
|
||||
export const AUTH_COOKIE = 'ulhub_token';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
(req) => req?.cookies?.[AUTH_COOKIE] ?? null,
|
||||
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
]),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || 'dev-only-insecure-secret',
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload): UserPrincipal {
|
||||
return { type: 'user', userId: payload.sub, email: payload.email };
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { connect as connectMqtt } from 'mqtt';
|
||||
import { Client as PgClient } from 'pg';
|
||||
|
||||
export interface DeviceRecord {
|
||||
id: number;
|
||||
topic: string;
|
||||
payload: string;
|
||||
receivedAt: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DeviceDataService implements OnModuleInit {
|
||||
private readonly logger = new Logger(DeviceDataService.name);
|
||||
private readonly listeners = new Set<(record: DeviceRecord) => void>();
|
||||
private pgClient: PgClient | null = null;
|
||||
private mqttClient: any = null;
|
||||
private initialized = false;
|
||||
|
||||
async onModuleInit() {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.connectDatabase();
|
||||
await this.connectMqtt();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async getRecords(limit = 20): Promise<DeviceRecord[]> {
|
||||
if (!this.pgClient) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
const result = await this.pgClient!.query(
|
||||
`SELECT id, topic, payload, received_at as "receivedAt"
|
||||
FROM device_events
|
||||
ORDER BY received_at DESC, id DESC
|
||||
LIMIT $1`,
|
||||
[limit],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
topic: row.topic,
|
||||
payload: row.payload,
|
||||
receivedAt: row.receivedAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
subscribe(listener: (record: DeviceRecord) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private async connectDatabase() {
|
||||
try {
|
||||
const client = new PgClient({
|
||||
host: process.env.DATABASE_HOST || 'postgres',
|
||||
port: Number(process.env.DATABASE_PORT || 5432),
|
||||
user: process.env.DATABASE_USER || 'ulhub',
|
||||
password: process.env.DATABASE_PASSWORD || 'development',
|
||||
database: process.env.DATABASE_NAME || 'ulhub',
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS device_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
topic TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
this.pgClient = client;
|
||||
this.logger.log('Connected to PostgreSQL and ensured device_events table exists');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to connect to PostgreSQL', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async connectMqtt() {
|
||||
try {
|
||||
const host = process.env.MQTT_HOST || 'mosquitto';
|
||||
const port = Number(process.env.MQTT_PORT || 1883);
|
||||
const username = process.env.MQTT_USERNAME || 'testuser';
|
||||
const password = process.env.MQTT_PASSWORD || 'testpass';
|
||||
this.mqttClient = connectMqtt(`mqtt://${host}:${port}`, {
|
||||
username,
|
||||
password,
|
||||
clientId: `ulhub-backend-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
|
||||
this.mqttClient.on('connect', () => {
|
||||
this.logger.log(`Connected to MQTT broker at ${host}:${port}`);
|
||||
this.mqttClient!.subscribe('devices/#', (err) => {
|
||||
if (err) {
|
||||
this.logger.error('Failed to subscribe to devices/#', err);
|
||||
} else {
|
||||
this.logger.log('Subscribed to devices/#');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.mqttClient.on('message', async (topic, payload) => {
|
||||
await this.persistRecord(topic, payload.toString());
|
||||
});
|
||||
|
||||
this.mqttClient.on('error', (error) => {
|
||||
this.logger.error('MQTT client error', error);
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to connect to MQTT broker', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistRecord(topic: string, payload: string): Promise<DeviceRecord> {
|
||||
if (!this.pgClient) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
const result = await this.pgClient!.query(
|
||||
`INSERT INTO device_events (topic, payload)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, topic, payload, received_at as "receivedAt"`,
|
||||
[topic, payload],
|
||||
);
|
||||
|
||||
const record: DeviceRecord = {
|
||||
id: result.rows[0].id,
|
||||
topic: result.rows[0].topic,
|
||||
payload: result.rows[0].payload,
|
||||
receivedAt: result.rows[0].receivedAt.toISOString(),
|
||||
};
|
||||
|
||||
this.notifyListeners(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
private notifyListeners(record: DeviceRecord) {
|
||||
for (const listener of this.listeners) {
|
||||
listener(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { DeviceDataService, DeviceRecord } from './device-data.service';
|
||||
|
||||
@WebSocketGateway({ path: '/api/device-events/ws' })
|
||||
@Controller('device-events')
|
||||
export class DeviceEventsController implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
constructor(private readonly deviceDataService: DeviceDataService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.deviceDataService.getRecords();
|
||||
}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
client.emit('message', JSON.stringify({ type: 'connected' }));
|
||||
this.deviceDataService.subscribe((record: DeviceRecord) => {
|
||||
client.emit('message', JSON.stringify(record));
|
||||
});
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
// no-op; subscriptions are handled by the service subscription set
|
||||
void client;
|
||||
}
|
||||
}
|
||||
43
backend/src/devices/devices.controller.ts
Normal file
43
backend/src/devices/devices.controller.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RequireScopes } from '../auth/decorators/scopes.decorator';
|
||||
import { UserOrApiKeyGuard } from '../auth/guards/auth.guard';
|
||||
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
|
||||
import { ScopesGuard } from '../auth/guards/scopes.guard';
|
||||
import { DevicesService } from './devices.service';
|
||||
import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto';
|
||||
|
||||
@Controller('orgs/:orgId/devices')
|
||||
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
||||
export class DevicesController {
|
||||
constructor(private readonly devicesService: DevicesService) {}
|
||||
|
||||
@Get()
|
||||
@RequireScopes('devices:read')
|
||||
list(@Param('orgId') orgId: string) {
|
||||
return this.devicesService.list(orgId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('ORG_ADMIN')
|
||||
@RequireScopes('devices:read') // API keys cannot create devices; role gate handles users
|
||||
create(@Param('orgId') orgId: string, @Body() dto: CreateDeviceDto) {
|
||||
return this.devicesService.create(orgId, dto);
|
||||
}
|
||||
|
||||
@Patch(':deviceId')
|
||||
@Roles('ORG_ADMIN')
|
||||
update(
|
||||
@Param('orgId') orgId: string,
|
||||
@Param('deviceId') deviceId: string,
|
||||
@Body() dto: UpdateDeviceDto,
|
||||
) {
|
||||
return this.devicesService.update(orgId, deviceId, dto);
|
||||
}
|
||||
|
||||
@Delete(':deviceId')
|
||||
@Roles('ORG_ADMIN')
|
||||
remove(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) {
|
||||
return this.devicesService.remove(orgId, deviceId);
|
||||
}
|
||||
}
|
||||
10
backend/src/devices/devices.module.ts
Normal file
10
backend/src/devices/devices.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DevicesController } from './devices.controller';
|
||||
import { DevicesService } from './devices.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DevicesController],
|
||||
providers: [DevicesService],
|
||||
exports: [DevicesService],
|
||||
})
|
||||
export class DevicesModule {}
|
||||
55
backend/src/devices/devices.service.ts
Normal file
55
backend/src/devices/devices.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DevicesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list(orgId: string) {
|
||||
return this.prisma.device.findMany({
|
||||
where: { orgId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(orgId: string, dto: CreateDeviceDto) {
|
||||
const existing = await this.prisma.device.findUnique({
|
||||
where: { mqttUsername: dto.mqttUsername },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('A device with this MQTT username already exists');
|
||||
}
|
||||
const device = await this.prisma.device.create({
|
||||
data: { orgId, name: dto.name, mqttUsername: dto.mqttUsername, serialNumber: dto.serialNumber },
|
||||
});
|
||||
return {
|
||||
...device,
|
||||
provisioning: {
|
||||
mqttUsername: device.mqttUsername,
|
||||
pointsTopic: `devices/${device.mqttUsername}/points`,
|
||||
jobsTopic: `devices/${device.mqttUsername}/jobs`,
|
||||
note: 'Broker credentials must be created separately (mosquitto_passwd) until dynamic broker auth lands.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async update(orgId: string, deviceId: string, dto: UpdateDeviceDto) {
|
||||
await this.get(orgId, deviceId);
|
||||
return this.prisma.device.update({ where: { id: deviceId }, data: dto });
|
||||
}
|
||||
|
||||
async remove(orgId: string, deviceId: string) {
|
||||
await this.get(orgId, deviceId);
|
||||
await this.prisma.device.delete({ where: { id: deviceId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async get(orgId: string, deviceId: string) {
|
||||
const device = await this.prisma.device.findFirst({ where: { id: deviceId, orgId } });
|
||||
if (!device) {
|
||||
throw new NotFoundException('Device not found');
|
||||
}
|
||||
return device;
|
||||
}
|
||||
}
|
||||
37
backend/src/devices/dto/devices.dto.ts
Normal file
37
backend/src/devices/dto/devices.dto.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateDeviceDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
name: string;
|
||||
|
||||
// Must match the broker username (or future TLS cert CN) the device connects with
|
||||
@IsString()
|
||||
@Matches(/^[a-zA-Z0-9._-]{3,64}$/, {
|
||||
message: 'mqttUsername must be 3-64 chars of letters, digits, dot, dash, underscore',
|
||||
})
|
||||
mqttUsername: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
serialNumber?: string;
|
||||
}
|
||||
|
||||
export class UpdateDeviceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
serialNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
100
backend/src/ingest/dto/mqtt-messages.dto.ts
Normal file
100
backend/src/ingest/dto/mqtt-messages.dto.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { GpsFixType, UtilityType } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class MqttPointDto {
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
lat: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
lng: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
alt?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(GpsFixType)
|
||||
fix?: GpsFixType;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
hAcc?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
depth?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(UtilityType)
|
||||
utility?: UtilityType;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
seq?: number;
|
||||
|
||||
@IsDateString()
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export class MqttPointsMessageDto {
|
||||
// Job is resolved by jobId when present, else by (device org, ticket)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
jobId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
ticket?: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(500)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => MqttPointDto)
|
||||
points: MqttPointDto[];
|
||||
}
|
||||
|
||||
export class MqttJobMessageDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
ticket: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
address?: string;
|
||||
}
|
||||
63
backend/src/ingest/ingest-router.service.ts
Normal file
63
backend/src/ingest/ingest-router.service.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobsIngestService } from './jobs-ingest.service';
|
||||
import { MqttClientService } from './mqtt-client.service';
|
||||
import { PointsIngestService } from './points-ingest.service';
|
||||
|
||||
@Injectable()
|
||||
export class IngestRouterService implements OnModuleInit {
|
||||
private readonly logger = new Logger(IngestRouterService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mqttClient: MqttClientService,
|
||||
private readonly pointsIngest: PointsIngestService,
|
||||
private readonly jobsIngest: JobsIngestService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.mqttClient.onMessage((topic, payload) => {
|
||||
this.route(topic, payload.toString()).catch((err) =>
|
||||
this.logger.error(`Failed to process ${topic}: ${err.message}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async route(topic: string, payload: string) {
|
||||
// Raw log captures every message on devices/#, matched or not (audit/debug trail)
|
||||
await this.prisma.deviceEvent.create({ data: { topic, payload } });
|
||||
|
||||
const [root, username, ...rest] = topic.split('/');
|
||||
const subtopic = rest.join('/');
|
||||
if (root !== 'devices' || !username || subtopic === 'jobs/ack') {
|
||||
return; // not device traffic, or our own ack echoed back
|
||||
}
|
||||
|
||||
const device = await this.prisma.device.findUnique({ where: { mqttUsername: username } });
|
||||
if (!device) {
|
||||
this.logger.warn(`Message from unregistered device username "${username}" (raw-logged only)`);
|
||||
return;
|
||||
}
|
||||
if (!device.isActive) {
|
||||
this.logger.warn(`Message from deactivated device "${username}" ignored`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.prisma.device
|
||||
.update({ where: { id: device.id }, data: { lastSeenAt: new Date() } })
|
||||
.catch(() => undefined);
|
||||
|
||||
switch (subtopic) {
|
||||
case 'points':
|
||||
await this.pointsIngest.handle(device, payload);
|
||||
break;
|
||||
case 'jobs':
|
||||
await this.jobsIngest.handle(device, payload);
|
||||
break;
|
||||
case 'status':
|
||||
break; // lastSeenAt already stamped above
|
||||
default:
|
||||
this.logger.debug(`Unhandled subtopic "${subtopic}" from ${username}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
backend/src/ingest/ingest.module.ts
Normal file
12
backend/src/ingest/ingest.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RealtimeModule } from '../realtime/realtime.module';
|
||||
import { IngestRouterService } from './ingest-router.service';
|
||||
import { JobsIngestService } from './jobs-ingest.service';
|
||||
import { MqttClientService } from './mqtt-client.service';
|
||||
import { PointsIngestService } from './points-ingest.service';
|
||||
|
||||
@Module({
|
||||
imports: [RealtimeModule],
|
||||
providers: [MqttClientService, IngestRouterService, PointsIngestService, JobsIngestService],
|
||||
})
|
||||
export class IngestModule {}
|
||||
60
backend/src/ingest/jobs-ingest.service.ts
Normal file
60
backend/src/ingest/jobs-ingest.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Device } from '@prisma/client';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { MqttJobMessageDto } from './dto/mqtt-messages.dto';
|
||||
import { MqttClientService } from './mqtt-client.service';
|
||||
|
||||
@Injectable()
|
||||
export class JobsIngestService {
|
||||
private readonly logger = new Logger(JobsIngestService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mqttClient: MqttClientService,
|
||||
) {}
|
||||
|
||||
async handle(device: Device, rawPayload: string) {
|
||||
let msg: MqttJobMessageDto;
|
||||
try {
|
||||
msg = plainToInstance(MqttJobMessageDto, JSON.parse(rawPayload) as object);
|
||||
} catch {
|
||||
this.ack(device, { ticket: null, status: 'error', reason: 'invalid JSON' });
|
||||
return;
|
||||
}
|
||||
|
||||
const errors = await validate(msg, { whitelist: true });
|
||||
if (errors.length > 0) {
|
||||
this.ack(device, { ticket: msg.ticket ?? null, status: 'error', reason: 'validation failed' });
|
||||
this.logger.warn(`Invalid job message from ${device.mqttUsername}: ${errors}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await this.prisma.job.findUnique({
|
||||
where: { orgId_ticketNumber: { orgId: device.orgId, ticketNumber: msg.ticket } },
|
||||
});
|
||||
if (existing) {
|
||||
this.ack(device, { ticket: msg.ticket, jobId: existing.id, status: 'exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
const job = await this.prisma.job.create({
|
||||
data: {
|
||||
orgId: device.orgId,
|
||||
ticketNumber: msg.ticket,
|
||||
title: msg.title || `Ticket ${msg.ticket}`,
|
||||
description: msg.description,
|
||||
address: msg.address,
|
||||
source: 'DEVICE',
|
||||
createdByDeviceId: device.id,
|
||||
},
|
||||
});
|
||||
this.ack(device, { ticket: msg.ticket, jobId: job.id, status: 'created' });
|
||||
this.logger.log(`Device ${device.mqttUsername} created job ${job.ticketNumber}`);
|
||||
}
|
||||
|
||||
private ack(device: Device, payload: object) {
|
||||
this.mqttClient.publish(`devices/${device.mqttUsername}/jobs/ack`, payload);
|
||||
}
|
||||
}
|
||||
59
backend/src/ingest/mqtt-client.service.ts
Normal file
59
backend/src/ingest/mqtt-client.service.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { connect, MqttClient } from 'mqtt';
|
||||
|
||||
export type MqttMessageListener = (topic: string, payload: Buffer) => void;
|
||||
|
||||
@Injectable()
|
||||
export class MqttClientService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(MqttClientService.name);
|
||||
private readonly listeners = new Set<MqttMessageListener>();
|
||||
private client: MqttClient | null = null;
|
||||
|
||||
onModuleInit() {
|
||||
const host = process.env.MQTT_HOST || 'mosquitto';
|
||||
const port = Number(process.env.MQTT_PORT || 1883);
|
||||
this.client = connect(`mqtt://${host}:${port}`, {
|
||||
username: process.env.MQTT_USERNAME || 'backend',
|
||||
password: process.env.MQTT_PASSWORD || 'backendpass',
|
||||
clientId: `ulhub-backend-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
|
||||
this.client.on('connect', () => {
|
||||
this.logger.log(`Connected to MQTT broker at ${host}:${port}`);
|
||||
this.client!.subscribe('devices/#', (err) => {
|
||||
if (err) {
|
||||
this.logger.error('Failed to subscribe to devices/#', err);
|
||||
} else {
|
||||
this.logger.log('Subscribed to devices/#');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.client.on('message', (topic, payload) => {
|
||||
for (const listener of this.listeners) {
|
||||
listener(topic, payload);
|
||||
}
|
||||
});
|
||||
|
||||
this.client.on('error', (error) => {
|
||||
this.logger.error(`MQTT client error: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.client?.endAsync();
|
||||
}
|
||||
|
||||
onMessage(listener: MqttMessageListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
publish(topic: string, payload: object) {
|
||||
this.client?.publish(topic, JSON.stringify(payload), { qos: 1 }, (err) => {
|
||||
if (err) {
|
||||
this.logger.error(`Failed to publish to ${topic}: ${err.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
95
backend/src/ingest/points-ingest.service.ts
Normal file
95
backend/src/ingest/points-ingest.service.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Device, Job } from '@prisma/client';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { toPointDto } from '../points/points.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { MqttPointsMessageDto } from './dto/mqtt-messages.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PointsIngestService {
|
||||
private readonly logger = new Logger(PointsIngestService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly realtime: RealtimeService,
|
||||
) {}
|
||||
|
||||
async handle(device: Device, rawPayload: string) {
|
||||
const msg = plainToInstance(MqttPointsMessageDto, JSON.parse(rawPayload) as object);
|
||||
const errors = await validate(msg, { whitelist: true });
|
||||
if (errors.length > 0) {
|
||||
this.logger.warn(`Invalid points message from ${device.mqttUsername}: ${errors}`);
|
||||
return;
|
||||
}
|
||||
if (!msg.jobId && !msg.ticket) {
|
||||
this.logger.warn(`Points message from ${device.mqttUsername} has neither jobId nor ticket`);
|
||||
return;
|
||||
}
|
||||
|
||||
const job = await this.resolveJob(device, msg);
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
|
||||
const points = await this.prisma.locatePoint.createManyAndReturn({
|
||||
data: msg.points.map((p) => ({
|
||||
jobId: job.id,
|
||||
deviceId: device.id,
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
altitude: p.alt,
|
||||
fixType: p.fix,
|
||||
hAccuracy: p.hAcc,
|
||||
depth: p.depth,
|
||||
utilityType: p.utility,
|
||||
sequence: p.seq,
|
||||
recordedAt: new Date(p.ts),
|
||||
raw: p as object,
|
||||
})),
|
||||
});
|
||||
|
||||
this.realtime.publish(`job:${job.id}`, {
|
||||
type: 'points',
|
||||
jobId: job.id,
|
||||
points: points.map(toPointDto),
|
||||
});
|
||||
this.logger.debug(`Stored ${points.length} points for job ${job.ticketNumber}`);
|
||||
}
|
||||
|
||||
private async resolveJob(device: Device, msg: MqttPointsMessageDto): Promise<Job | null> {
|
||||
if (msg.jobId) {
|
||||
const job = await this.prisma.job.findFirst({
|
||||
where: { id: msg.jobId, orgId: device.orgId },
|
||||
});
|
||||
if (!job) {
|
||||
this.logger.warn(`Unknown jobId ${msg.jobId} from device ${device.mqttUsername}`);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
const ticket = msg.ticket!;
|
||||
const existing = await this.prisma.job.findUnique({
|
||||
where: { orgId_ticketNumber: { orgId: device.orgId, ticketNumber: ticket } },
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Field-first workflow: points arrive before anyone opened the ticket in the web
|
||||
// app. Create a stub so the data is never lost; metadata gets filled in later.
|
||||
this.logger.log(`Auto-creating stub job for unknown ticket ${ticket} (device ${device.mqttUsername})`);
|
||||
return this.prisma.job.create({
|
||||
data: {
|
||||
orgId: device.orgId,
|
||||
ticketNumber: ticket,
|
||||
title: `Ticket ${ticket} (device-created)`,
|
||||
status: 'IN_PROGRESS',
|
||||
source: 'DEVICE',
|
||||
createdByDeviceId: device.id,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
104
backend/src/jobs/dto/jobs.dto.ts
Normal file
104
backend/src/jobs/dto/jobs.dto.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { JobStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateJobDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
ticketNumber: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
title: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(JobStatus)
|
||||
status?: JobStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assignedToId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueAt?: string;
|
||||
}
|
||||
|
||||
export class UpdateJobDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(JobStatus)
|
||||
status?: JobStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assignedToId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueAt?: string | null;
|
||||
}
|
||||
|
||||
export class QueryJobsDto {
|
||||
@IsOptional()
|
||||
@IsEnum(JobStatus)
|
||||
status?: JobStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assignedToId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
offset?: number;
|
||||
}
|
||||
53
backend/src/jobs/jobs.controller.ts
Normal file
53
backend/src/jobs/jobs.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { CurrentPrincipal } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RequireScopes } from '../auth/decorators/scopes.decorator';
|
||||
import { UserOrApiKeyGuard } from '../auth/guards/auth.guard';
|
||||
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
|
||||
import { ScopesGuard } from '../auth/guards/scopes.guard';
|
||||
import { Principal } from '../auth/principal';
|
||||
import { CreateJobDto, QueryJobsDto, UpdateJobDto } from './dto/jobs.dto';
|
||||
import { JobsService } from './jobs.service';
|
||||
|
||||
@Controller('orgs/:orgId/jobs')
|
||||
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
||||
export class JobsController {
|
||||
constructor(private readonly jobsService: JobsService) {}
|
||||
|
||||
@Get()
|
||||
@RequireScopes('jobs:read')
|
||||
list(@Param('orgId') orgId: string, @Query() query: QueryJobsDto) {
|
||||
return this.jobsService.list(orgId, query);
|
||||
}
|
||||
|
||||
@Get(':jobId')
|
||||
@RequireScopes('jobs:read')
|
||||
get(@Param('orgId') orgId: string, @Param('jobId') jobId: string) {
|
||||
return this.jobsService.get(orgId, jobId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('MEMBER')
|
||||
@RequireScopes('jobs:write')
|
||||
create(
|
||||
@Param('orgId') orgId: string,
|
||||
@Body() dto: CreateJobDto,
|
||||
@CurrentPrincipal() principal: Principal,
|
||||
) {
|
||||
return this.jobsService.create(orgId, dto, principal.type === 'user' ? principal.userId : null);
|
||||
}
|
||||
|
||||
@Patch(':jobId')
|
||||
@Roles('MEMBER')
|
||||
@RequireScopes('jobs:write')
|
||||
update(@Param('orgId') orgId: string, @Param('jobId') jobId: string, @Body() dto: UpdateJobDto) {
|
||||
return this.jobsService.update(orgId, jobId, dto);
|
||||
}
|
||||
|
||||
@Delete(':jobId')
|
||||
@Roles('ORG_ADMIN')
|
||||
@RequireScopes('jobs:write')
|
||||
remove(@Param('orgId') orgId: string, @Param('jobId') jobId: string) {
|
||||
return this.jobsService.remove(orgId, jobId);
|
||||
}
|
||||
}
|
||||
10
backend/src/jobs/jobs.module.ts
Normal file
10
backend/src/jobs/jobs.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JobsController } from './jobs.controller';
|
||||
import { JobsService } from './jobs.service';
|
||||
|
||||
@Module({
|
||||
controllers: [JobsController],
|
||||
providers: [JobsService],
|
||||
exports: [JobsService],
|
||||
})
|
||||
export class JobsModule {}
|
||||
117
backend/src/jobs/jobs.service.ts
Normal file
117
backend/src/jobs/jobs.service.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateJobDto, QueryJobsDto, UpdateJobDto } from './dto/jobs.dto';
|
||||
|
||||
const JOB_SELECT = {
|
||||
id: true,
|
||||
ticketNumber: true,
|
||||
title: true,
|
||||
description: true,
|
||||
address: true,
|
||||
status: true,
|
||||
source: true,
|
||||
dueAt: true,
|
||||
startedAt: true,
|
||||
completedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
assignedTo: { select: { id: true, name: true, email: true } },
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
createdByDeviceId: true,
|
||||
_count: { select: { points: true } },
|
||||
} satisfies Prisma.JobSelect;
|
||||
|
||||
@Injectable()
|
||||
export class JobsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(orgId: string, query: QueryJobsDto) {
|
||||
const where: Prisma.JobWhereInput = {
|
||||
orgId,
|
||||
...(query.status && { status: query.status }),
|
||||
...(query.assignedToId && { assignedToId: query.assignedToId }),
|
||||
...(query.q && {
|
||||
OR: [
|
||||
{ ticketNumber: { contains: query.q, mode: 'insensitive' } },
|
||||
{ title: { contains: query.q, mode: 'insensitive' } },
|
||||
{ address: { contains: query.q, mode: 'insensitive' } },
|
||||
],
|
||||
}),
|
||||
};
|
||||
const [jobs, total] = await this.prisma.$transaction([
|
||||
this.prisma.job.findMany({
|
||||
where,
|
||||
select: JOB_SELECT,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: query.limit ?? 50,
|
||||
skip: query.offset ?? 0,
|
||||
}),
|
||||
this.prisma.job.count({ where }),
|
||||
]);
|
||||
return { jobs, total };
|
||||
}
|
||||
|
||||
async get(orgId: string, jobId: string) {
|
||||
const job = await this.prisma.job.findFirst({
|
||||
where: { id: jobId, orgId },
|
||||
select: JOB_SELECT,
|
||||
});
|
||||
if (!job) {
|
||||
throw new NotFoundException('Job not found');
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
async create(orgId: string, dto: CreateJobDto, createdById: string | null) {
|
||||
const existing = await this.prisma.job.findUnique({
|
||||
where: { orgId_ticketNumber: { orgId, ticketNumber: dto.ticketNumber } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(`Ticket ${dto.ticketNumber} already exists in this organization`);
|
||||
}
|
||||
return this.prisma.job.create({
|
||||
data: {
|
||||
orgId,
|
||||
ticketNumber: dto.ticketNumber,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
address: dto.address,
|
||||
status: dto.status,
|
||||
assignedToId: dto.assignedToId,
|
||||
dueAt: dto.dueAt ? new Date(dto.dueAt) : undefined,
|
||||
createdById,
|
||||
},
|
||||
select: JOB_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
async update(orgId: string, jobId: string, dto: UpdateJobDto) {
|
||||
await this.get(orgId, jobId);
|
||||
const statusTimestamps: Prisma.JobUncheckedUpdateInput =
|
||||
dto.status === 'IN_PROGRESS'
|
||||
? { startedAt: new Date() }
|
||||
: dto.status === 'COMPLETED'
|
||||
? { completedAt: new Date() }
|
||||
: {};
|
||||
return this.prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
...(dto.title !== undefined && { title: dto.title }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.address !== undefined && { address: dto.address }),
|
||||
...(dto.status !== undefined && { status: dto.status }),
|
||||
...(dto.assignedToId !== undefined && { assignedToId: dto.assignedToId }),
|
||||
...(dto.dueAt !== undefined && { dueAt: dto.dueAt ? new Date(dto.dueAt) : null }),
|
||||
...statusTimestamps,
|
||||
},
|
||||
select: JOB_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(orgId: string, jobId: string) {
|
||||
await this.get(orgId, jobId);
|
||||
await this.prisma.job.delete({ where: { id: jobId } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { WsAdapter } from '@nestjs/platform-ws';
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api');
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
app.useWebSocketAdapter(new WsAdapter(app));
|
||||
await app.listen(3001, '0.0.0.0');
|
||||
}
|
||||
|
||||
|
||||
22
backend/src/orgs/dto/orgs.dto.ts
Normal file
22
backend/src/orgs/dto/orgs.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { OrgRole } from '@prisma/client';
|
||||
import { IsEmail, IsEnum, IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateOrgDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class AddMemberDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsEnum(OrgRole)
|
||||
role: OrgRole;
|
||||
}
|
||||
|
||||
export class UpdateMemberDto {
|
||||
@IsEnum(OrgRole)
|
||||
role: OrgRole;
|
||||
}
|
||||
58
backend/src/orgs/orgs.controller.ts
Normal file
58
backend/src/orgs/orgs.controller.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { CurrentPrincipal } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard, UserOrApiKeyGuard } from '../auth/guards/auth.guard';
|
||||
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
|
||||
import { ScopesGuard } from '../auth/guards/scopes.guard';
|
||||
import { UserPrincipal } from '../auth/principal';
|
||||
import { AddMemberDto, UpdateMemberDto, UpdateOrgDto } from './dto/orgs.dto';
|
||||
import { OrgsService } from './orgs.service';
|
||||
|
||||
@Controller('orgs')
|
||||
export class OrgsController {
|
||||
constructor(private readonly orgsService: OrgsService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
listMine(@CurrentPrincipal() principal: UserPrincipal) {
|
||||
return this.orgsService.listForUser(principal.userId);
|
||||
}
|
||||
|
||||
@Patch(':orgId')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('ORG_ADMIN')
|
||||
rename(@Param('orgId') orgId: string, @Body() dto: UpdateOrgDto) {
|
||||
return this.orgsService.rename(orgId, dto.name);
|
||||
}
|
||||
|
||||
@Get(':orgId/members')
|
||||
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
||||
listMembers(@Param('orgId') orgId: string) {
|
||||
return this.orgsService.listMembers(orgId);
|
||||
}
|
||||
|
||||
@Post(':orgId/members')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('ORG_ADMIN')
|
||||
addMember(@Param('orgId') orgId: string, @Body() dto: AddMemberDto) {
|
||||
return this.orgsService.addMember(orgId, dto.email, dto.role);
|
||||
}
|
||||
|
||||
@Patch(':orgId/members/:userId')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('ORG_ADMIN')
|
||||
updateMember(
|
||||
@Param('orgId') orgId: string,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: UpdateMemberDto,
|
||||
) {
|
||||
return this.orgsService.updateMember(orgId, userId, dto.role);
|
||||
}
|
||||
|
||||
@Delete(':orgId/members/:userId')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('ORG_ADMIN')
|
||||
removeMember(@Param('orgId') orgId: string, @Param('userId') userId: string) {
|
||||
return this.orgsService.removeMember(orgId, userId);
|
||||
}
|
||||
}
|
||||
9
backend/src/orgs/orgs.module.ts
Normal file
9
backend/src/orgs/orgs.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OrgsController } from './orgs.controller';
|
||||
import { OrgsService } from './orgs.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OrgsController],
|
||||
providers: [OrgsService],
|
||||
})
|
||||
export class OrgsModule {}
|
||||
92
backend/src/orgs/orgs.service.ts
Normal file
92
backend/src/orgs/orgs.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { OrgRole } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class OrgsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listForUser(userId: string) {
|
||||
return this.prisma.organization.findMany({
|
||||
where: { memberships: { some: { userId } } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
createdAt: true,
|
||||
memberships: { where: { userId }, select: { role: true } },
|
||||
_count: { select: { jobs: true, devices: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
rename(orgId: string, name: string) {
|
||||
return this.prisma.organization.update({ where: { id: orgId }, data: { name } });
|
||||
}
|
||||
|
||||
listMembers(orgId: string) {
|
||||
return this.prisma.orgMembership.findMany({
|
||||
where: { orgId },
|
||||
select: {
|
||||
role: true,
|
||||
createdAt: true,
|
||||
user: { select: { id: true, email: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async addMember(orgId: string, email: string, role: OrgRole) {
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('No account exists with that email (they must register first)');
|
||||
}
|
||||
const existing = await this.prisma.orgMembership.findUnique({
|
||||
where: { orgId_userId: { orgId, userId: user.id } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BadRequestException('Already a member of this organization');
|
||||
}
|
||||
return this.prisma.orgMembership.create({
|
||||
data: { orgId, userId: user.id, role },
|
||||
select: { role: true, user: { select: { id: true, email: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async updateMember(orgId: string, userId: string, role: OrgRole) {
|
||||
await this.assertNotLastAdmin(orgId, userId);
|
||||
return this.prisma.orgMembership.update({
|
||||
where: { orgId_userId: { orgId, userId } },
|
||||
data: { role },
|
||||
select: { role: true, user: { select: { id: true, email: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async removeMember(orgId: string, userId: string) {
|
||||
await this.assertNotLastAdmin(orgId, userId);
|
||||
await this.prisma.orgMembership.delete({
|
||||
where: { orgId_userId: { orgId, userId } },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Refuse to demote/remove the only remaining admin so the org can't be orphaned.
|
||||
private async assertNotLastAdmin(orgId: string, userId: string) {
|
||||
const target = await this.prisma.orgMembership.findUnique({
|
||||
where: { orgId_userId: { orgId, userId } },
|
||||
});
|
||||
if (!target) {
|
||||
throw new NotFoundException('Membership not found');
|
||||
}
|
||||
if (target.role !== 'ORG_ADMIN') {
|
||||
return;
|
||||
}
|
||||
const admins = await this.prisma.orgMembership.count({
|
||||
where: { orgId, role: 'ORG_ADMIN' },
|
||||
});
|
||||
if (admins <= 1) {
|
||||
throw new BadRequestException('Cannot demote or remove the last organization admin');
|
||||
}
|
||||
}
|
||||
}
|
||||
84
backend/src/points/dto/points.dto.ts
Normal file
84
backend/src/points/dto/points.dto.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { GpsFixType, UtilityType } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreatePointDto {
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
lat: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
lng: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
altitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(GpsFixType)
|
||||
fixType?: GpsFixType;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
hAccuracy?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
depth?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(UtilityType)
|
||||
utilityType?: UtilityType;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sequence?: number;
|
||||
|
||||
@IsDateString()
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export class QueryPointsDto {
|
||||
// recordedAt cursor: return points recorded strictly after this instant
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
after?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
from?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
to?: string;
|
||||
|
||||
// minLng,minLat,maxLng,maxLat
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?$/, {
|
||||
message: 'bbox must be "minLng,minLat,maxLng,maxLat"',
|
||||
})
|
||||
bbox?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10000)
|
||||
limit?: number;
|
||||
}
|
||||
41
backend/src/points/points.controller.ts
Normal file
41
backend/src/points/points.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RequireScopes } from '../auth/decorators/scopes.decorator';
|
||||
import { UserOrApiKeyGuard } from '../auth/guards/auth.guard';
|
||||
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
|
||||
import { ScopesGuard } from '../auth/guards/scopes.guard';
|
||||
import { CreatePointDto, QueryPointsDto } from './dto/points.dto';
|
||||
import { PointsService } from './points.service';
|
||||
|
||||
@Controller('orgs/:orgId')
|
||||
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
||||
export class PointsController {
|
||||
constructor(private readonly pointsService: PointsService) {}
|
||||
|
||||
@Get('jobs/:jobId/points')
|
||||
@RequireScopes('points:read')
|
||||
listForJob(
|
||||
@Param('orgId') orgId: string,
|
||||
@Param('jobId') jobId: string,
|
||||
@Query() query: QueryPointsDto,
|
||||
) {
|
||||
return this.pointsService.listForJob(orgId, jobId, query);
|
||||
}
|
||||
|
||||
@Post('jobs/:jobId/points')
|
||||
@Roles('MEMBER')
|
||||
@RequireScopes('points:write')
|
||||
create(
|
||||
@Param('orgId') orgId: string,
|
||||
@Param('jobId') jobId: string,
|
||||
@Body() dto: CreatePointDto,
|
||||
) {
|
||||
return this.pointsService.createForJob(orgId, jobId, dto);
|
||||
}
|
||||
|
||||
@Get('points')
|
||||
@RequireScopes('points:read')
|
||||
listForOrg(@Param('orgId') orgId: string, @Query() query: QueryPointsDto) {
|
||||
return this.pointsService.listForOrg(orgId, query);
|
||||
}
|
||||
}
|
||||
10
backend/src/points/points.module.ts
Normal file
10
backend/src/points/points.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PointsController } from './points.controller';
|
||||
import { PointsService } from './points.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PointsController],
|
||||
providers: [PointsService],
|
||||
exports: [PointsService],
|
||||
})
|
||||
export class PointsModule {}
|
||||
116
backend/src/points/points.service.ts
Normal file
116
backend/src/points/points.service.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { LocatePoint, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreatePointDto, QueryPointsDto } from './dto/points.dto';
|
||||
|
||||
// JSON-safe shape: BigInt id -> string, Decimal -> number
|
||||
export interface PointDto {
|
||||
id: string;
|
||||
jobId: string;
|
||||
deviceId: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
altitude: number | null;
|
||||
fixType: string;
|
||||
hAccuracy: number | null;
|
||||
depth: number | null;
|
||||
utilityType: string;
|
||||
sequence: number | null;
|
||||
recordedAt: Date;
|
||||
receivedAt: Date;
|
||||
}
|
||||
|
||||
export function toPointDto(p: LocatePoint): PointDto {
|
||||
return {
|
||||
id: p.id.toString(),
|
||||
jobId: p.jobId,
|
||||
deviceId: p.deviceId,
|
||||
lat: Number(p.lat),
|
||||
lng: Number(p.lng),
|
||||
altitude: p.altitude === null ? null : Number(p.altitude),
|
||||
fixType: p.fixType,
|
||||
hAccuracy: p.hAccuracy === null ? null : Number(p.hAccuracy),
|
||||
depth: p.depth === null ? null : Number(p.depth),
|
||||
utilityType: p.utilityType,
|
||||
sequence: p.sequence,
|
||||
recordedAt: p.recordedAt,
|
||||
receivedAt: p.receivedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PointsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listForJob(orgId: string, jobId: string, query: QueryPointsDto): Promise<{ points: PointDto[] }> {
|
||||
const job = await this.prisma.job.findFirst({ where: { id: jobId, orgId }, select: { id: true } });
|
||||
if (!job) {
|
||||
throw new NotFoundException('Job not found');
|
||||
}
|
||||
return { points: await this.query({ jobId }, query) };
|
||||
}
|
||||
|
||||
async listForOrg(orgId: string, query: QueryPointsDto): Promise<{ points: PointDto[] }> {
|
||||
return { points: await this.query({ job: { orgId } }, query) };
|
||||
}
|
||||
|
||||
async createForJob(
|
||||
orgId: string,
|
||||
jobId: string,
|
||||
dto: CreatePointDto,
|
||||
deviceId: string | null = null,
|
||||
): Promise<PointDto> {
|
||||
const job = await this.prisma.job.findFirst({ where: { id: jobId, orgId }, select: { id: true } });
|
||||
if (!job) {
|
||||
throw new NotFoundException('Job not found');
|
||||
}
|
||||
const point = await this.prisma.locatePoint.create({
|
||||
data: {
|
||||
jobId,
|
||||
deviceId,
|
||||
lat: dto.lat,
|
||||
lng: dto.lng,
|
||||
altitude: dto.altitude,
|
||||
fixType: dto.fixType,
|
||||
hAccuracy: dto.hAccuracy,
|
||||
depth: dto.depth,
|
||||
utilityType: dto.utilityType,
|
||||
sequence: dto.sequence,
|
||||
recordedAt: new Date(dto.recordedAt),
|
||||
},
|
||||
});
|
||||
return toPointDto(point);
|
||||
}
|
||||
|
||||
private async query(scope: Prisma.LocatePointWhereInput, query: QueryPointsDto): Promise<PointDto[]> {
|
||||
const where: Prisma.LocatePointWhereInput = {
|
||||
...scope,
|
||||
...(query.after && { recordedAt: { gt: new Date(query.after) } }),
|
||||
...((query.from || query.to) && {
|
||||
recordedAt: {
|
||||
...(query.after && { gt: new Date(query.after) }),
|
||||
...(query.from && { gte: new Date(query.from) }),
|
||||
...(query.to && { lte: new Date(query.to) }),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
if (query.bbox) {
|
||||
const [minLng, minLat, maxLng, maxLat] = query.bbox.split(',').map(Number);
|
||||
// Spatial filter runs on the generated geom column (GIST-indexed); the id list
|
||||
// is then fed back through Prisma so scope/time filters and typing stay uniform.
|
||||
const rows = await this.prisma.$queryRaw<{ id: bigint }[]>`
|
||||
SELECT id FROM locate_points
|
||||
WHERE geom && ST_MakeEnvelope(${minLng}, ${minLat}, ${maxLng}, ${maxLat}, 4326)
|
||||
`;
|
||||
where.id = { in: rows.map((r) => r.id) };
|
||||
}
|
||||
|
||||
const points = await this.prisma.locatePoint.findMany({
|
||||
where,
|
||||
orderBy: [{ recordedAt: 'asc' }, { id: 'asc' }],
|
||||
take: query.limit ?? 5000,
|
||||
});
|
||||
return points.map(toPointDto);
|
||||
}
|
||||
}
|
||||
9
backend/src/prisma/prisma.module.ts
Normal file
9
backend/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
13
backend/src/prisma/prisma.service.ts
Normal file
13
backend/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
108
backend/src/realtime/realtime.gateway.ts
Normal file
108
backend/src/realtime/realtime.gateway.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway } from '@nestjs/websockets';
|
||||
import type { IncomingMessage } from 'http';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { JwtPayload, AUTH_COOKIE } from '../auth/strategies/jwt.strategy';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RealtimeService } from './realtime.service';
|
||||
|
||||
interface ClientMessage {
|
||||
type: 'subscribe' | 'unsubscribe';
|
||||
channel: string;
|
||||
}
|
||||
|
||||
@WebSocketGateway({ path: '/api/ws' })
|
||||
export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
private readonly logger = new Logger(RealtimeGateway.name);
|
||||
private readonly users = new WeakMap<WebSocket, string>();
|
||||
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly realtime: RealtimeService,
|
||||
) {}
|
||||
|
||||
handleConnection(client: WebSocket, req: IncomingMessage) {
|
||||
const userId = this.authenticate(req);
|
||||
if (!userId) {
|
||||
client.close(4401, 'unauthorized');
|
||||
return;
|
||||
}
|
||||
this.users.set(client, userId);
|
||||
client.send(JSON.stringify({ type: 'connected' }));
|
||||
|
||||
client.on('message', (data) => {
|
||||
this.onClientMessage(client, data.toString()).catch((err) =>
|
||||
this.logger.error(`WS message error: ${err.message}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
handleDisconnect(client: WebSocket) {
|
||||
this.realtime.removeSocket(client);
|
||||
}
|
||||
|
||||
private authenticate(req: IncomingMessage): string | null {
|
||||
// Same-origin httpOnly cookie rides along on the WS upgrade request
|
||||
const cookies = req.headers.cookie ?? '';
|
||||
const token = cookies
|
||||
.split(';')
|
||||
.map((c) => c.trim())
|
||||
.find((c) => c.startsWith(`${AUTH_COOKIE}=`))
|
||||
?.slice(AUTH_COOKIE.length + 1);
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const payload = this.jwtService.verify<JwtPayload>(token);
|
||||
return payload.sub;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async onClientMessage(client: WebSocket, raw: string) {
|
||||
let msg: ClientMessage;
|
||||
try {
|
||||
msg = JSON.parse(raw);
|
||||
} catch {
|
||||
client.send(JSON.stringify({ type: 'error', reason: 'invalid JSON' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'unsubscribe' && typeof msg.channel === 'string') {
|
||||
this.realtime.unsubscribe(msg.channel, client);
|
||||
client.send(JSON.stringify({ type: 'unsubscribed', channel: msg.channel }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type !== 'subscribe' || typeof msg.channel !== 'string') {
|
||||
client.send(JSON.stringify({ type: 'error', reason: 'expected {type: subscribe|unsubscribe, channel}' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = msg.channel.startsWith('job:') ? msg.channel.slice(4) : null;
|
||||
if (!jobId || !(await this.canAccessJob(this.users.get(client), jobId))) {
|
||||
client.send(JSON.stringify({ type: 'error', reason: `cannot subscribe to ${msg.channel}` }));
|
||||
return;
|
||||
}
|
||||
|
||||
this.realtime.subscribe(msg.channel, client);
|
||||
client.send(JSON.stringify({ type: 'subscribed', channel: msg.channel }));
|
||||
}
|
||||
|
||||
private async canAccessJob(userId: string | undefined, jobId: string): Promise<boolean> {
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
const job = await this.prisma.job.findUnique({ where: { id: jobId }, select: { orgId: true } });
|
||||
if (!job) {
|
||||
return false;
|
||||
}
|
||||
const membership = await this.prisma.orgMembership.findUnique({
|
||||
where: { orgId_userId: { orgId: job.orgId, userId } },
|
||||
});
|
||||
return membership !== null;
|
||||
}
|
||||
}
|
||||
16
backend/src/realtime/realtime.module.ts
Normal file
16
backend/src/realtime/realtime.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { RealtimeGateway } from './realtime.gateway';
|
||||
import { RealtimeService } from './realtime.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'dev-only-insecure-secret',
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
],
|
||||
providers: [RealtimeGateway, RealtimeService],
|
||||
exports: [RealtimeService],
|
||||
})
|
||||
export class RealtimeModule {}
|
||||
46
backend/src/realtime/realtime.service.ts
Normal file
46
backend/src/realtime/realtime.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
@Injectable()
|
||||
export class RealtimeService {
|
||||
private readonly channels = new Map<string, Set<WebSocket>>();
|
||||
|
||||
subscribe(channel: string, socket: WebSocket) {
|
||||
let sockets = this.channels.get(channel);
|
||||
if (!sockets) {
|
||||
sockets = new Set();
|
||||
this.channels.set(channel, sockets);
|
||||
}
|
||||
sockets.add(socket);
|
||||
}
|
||||
|
||||
unsubscribe(channel: string, socket: WebSocket) {
|
||||
const sockets = this.channels.get(channel);
|
||||
sockets?.delete(socket);
|
||||
if (sockets?.size === 0) {
|
||||
this.channels.delete(channel);
|
||||
}
|
||||
}
|
||||
|
||||
removeSocket(socket: WebSocket) {
|
||||
for (const [channel, sockets] of this.channels) {
|
||||
sockets.delete(socket);
|
||||
if (sockets.size === 0) {
|
||||
this.channels.delete(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publish(channel: string, message: object) {
|
||||
const sockets = this.channels.get(channel);
|
||||
if (!sockets) {
|
||||
return;
|
||||
}
|
||||
const data = JSON.stringify(message);
|
||||
for (const socket of sockets) {
|
||||
if (socket.readyState === socket.OPEN) {
|
||||
socket.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
image: postgis/postgis:17-3.5
|
||||
environment:
|
||||
POSTGRES_DB: ulhub
|
||||
POSTGRES_USER: ulhub
|
||||
@@ -25,18 +25,20 @@ services:
|
||||
- "3001:3001"
|
||||
depends_on:
|
||||
- postgres
|
||||
- mosquitto
|
||||
# Development mounts: mount source for hot-reload and keep container node_modules
|
||||
volumes:
|
||||
- ./backend:/usr/src/app:delegated
|
||||
- /usr/src/app/node_modules
|
||||
environment:
|
||||
DATABASE_HOST: postgres
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_USER: ulhub
|
||||
DATABASE_PASSWORD: development
|
||||
DATABASE_NAME: ulhub
|
||||
DATABASE_URL: postgresql://ulhub:development@postgres:5432/ulhub
|
||||
JWT_SECRET: ${JWT_SECRET:-dev-only-insecure-secret}
|
||||
MQTT_HOST: mosquitto
|
||||
MQTT_PORT: 1883
|
||||
MQTT_USERNAME: ${MQTT_BACKEND_USERNAME:-backend}
|
||||
MQTT_PASSWORD: ${MQTT_BACKEND_PASSWORD:-backendpass}
|
||||
NODE_ENV: development
|
||||
command: npm run start:dev
|
||||
command: sh -c "npx prisma migrate deploy && npm run start:dev"
|
||||
|
||||
web:
|
||||
build:
|
||||
@@ -52,6 +54,7 @@ services:
|
||||
- /usr/src/app/node_modules
|
||||
environment:
|
||||
BACKEND_HOST: http://backend:3001
|
||||
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY: ${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY:-}
|
||||
NODE_ENV: development
|
||||
command: npm run dev
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ user admin
|
||||
topic readwrite #
|
||||
topic readwrite $SYS/#
|
||||
|
||||
user testuser
|
||||
topic readwrite #
|
||||
topic readwrite $SYS/#
|
||||
# Backend service: reads all device traffic, writes job acks back to devices
|
||||
user backend
|
||||
topic read devices/#
|
||||
topic write devices/+/jobs/ack
|
||||
|
||||
# testuser is a demo *device*: only the per-device pattern rule above applies
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
brent:$7$101$PPXeFRMHZwxYKE8F$/+qnOjXhFdxQYuiPxFrtVKdDME2ddCogKcOyV/Q/BYdw8UB8LJcfX23ghcVpr8cifK0z6/DZcgo0TORpungMOA==
|
||||
admin:$7$101$3g0kY+V60o3BKzNi$fphdbnq1TwE7nFTuZHPu86K6619owoIUNZK/w+6iE77utXhgCj/zTRmWoCzCbbvNZRnGLac2PZqdcMGGDXHAWQ==
|
||||
|
||||
# Test MQTT user
|
||||
testuser:$7$101$bKvyrR98MaR7giOp$RcwsxHrBENyVBWSCkzQo0hGA8nLh3CMaTi1GtTpvUzky7D2cReWc68dujesEf+PMh6EWIufl0D4YnPmiAwDSWw==
|
||||
backend:$7$1000$oUW5cxuZZxX50zTybgkJFsr4dwTAGgoPNwmmlw1Q6zTTtqFfnVX9akCJZcFGb3b/anFpeBcO4AfrnVJEiXClyg==$/JsO5qrshpNsHqfrKy1B4a8NJOaaCrWqHYPfCZd95l4mq1zntjCiM/lZo4TE3YklOaouQidMjxmgXKchQPrNbg==
|
||||
testuser:$7$1000$D6b1NWJ2AqoYF1zCBfJetGS13J1SksxPouVO4CPCCwijMkpS0bZDU+GIA13kvvGoikcfxAI9h2JE/BQDQR9EFw==$MCLvYJh6QHlrcE75cyaYwkHkFuEW4Z0gROrLKjeFiAhnTGR2S2cLQsk22URZDxG3LnnL6E8rZ1IcWRv1mwhIRg==
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
"""Publish sample locate points to the UlHub backend via MQTT.
|
||||
|
||||
The backend subscribes to devices/# and expects points at
|
||||
devices/{mqttUsername}/points with a JSON body:
|
||||
|
||||
{"ticket": "TKT-...", "points": [{"lat": .., "lng": .., "ts": "ISO8601", ...}]}
|
||||
|
||||
Points for an unknown ticket auto-create a stub job (source=DEVICE).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
@@ -9,27 +19,45 @@ BROKER_HOST = os.getenv("MQTT_HOST", "127.0.0.1")
|
||||
BROKER_PORT = int(os.getenv("MQTT_PORT", "1883"))
|
||||
USERNAME = os.getenv("MQTT_USERNAME", "testuser")
|
||||
PASSWORD = os.getenv("MQTT_PASSWORD", "testpass")
|
||||
TOPIC = os.getenv("MQTT_TOPIC", "devices/demo")
|
||||
TICKET = os.getenv("TICKET", "TKT-2026-0001")
|
||||
INTERVAL = float(os.getenv("MQTT_INTERVAL", "2"))
|
||||
|
||||
client = mqtt.Client()
|
||||
if USERNAME:
|
||||
client.username_pw_set(USERNAME, PASSWORD)
|
||||
# Walk northeast from this location, one point per interval
|
||||
START_LAT = float(os.getenv("START_LAT", "33.15012345"))
|
||||
START_LNG = float(os.getenv("START_LNG", "-96.83512345"))
|
||||
|
||||
TOPIC = f"devices/{USERNAME}/points"
|
||||
|
||||
client = mqtt.Client()
|
||||
client.username_pw_set(USERNAME, PASSWORD)
|
||||
client.connect(BROKER_HOST, BROKER_PORT, 60)
|
||||
client.loop_start()
|
||||
|
||||
try:
|
||||
counter = 0
|
||||
seq = 0
|
||||
while True:
|
||||
payload = {
|
||||
"message": f"hello from host {counter}",
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"ticket": TICKET,
|
||||
"points": [
|
||||
{
|
||||
"lat": START_LAT + seq * 0.0000135,
|
||||
"lng": START_LNG + seq * 0.0000042,
|
||||
"alt": 187.4 + seq * 0.02,
|
||||
"fix": "FIXED_RTK",
|
||||
"hAcc": 0.014,
|
||||
"depth": 1.2,
|
||||
"utility": "GAS",
|
||||
"seq": seq + 1,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
],
|
||||
}
|
||||
client.publish(TOPIC, json.dumps(payload), qos=0)
|
||||
print(f"published to {TOPIC}: {payload}")
|
||||
counter += 1
|
||||
client.publish(TOPIC, json.dumps(payload), qos=1)
|
||||
print(f"published point {seq + 1} to {TOPIC} (ticket {TICKET})")
|
||||
seq += 1
|
||||
time.sleep(INTERVAL)
|
||||
except KeyboardInterrupt:
|
||||
print("stopped")
|
||||
finally:
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
|
||||
65
web/components/Layout.tsx
Normal file
65
web/components/Layout.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ReactNode } from 'react';
|
||||
import { useAuth } from '../lib/auth-context';
|
||||
|
||||
export default function Layout({ children, title }: { children: ReactNode; title?: string }) {
|
||||
const { user, memberships, activeOrg, setActiveOrgId, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{title ? `${title} · UlHub` : 'UlHub'}</title>
|
||||
</Head>
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1.5rem',
|
||||
padding: '0.75rem 1.5rem',
|
||||
borderBottom: '1px solid #ddd',
|
||||
fontFamily: 'sans-serif',
|
||||
}}
|
||||
>
|
||||
<Link href="/" style={{ fontWeight: 700, fontSize: '1.1rem', textDecoration: 'none', color: '#111' }}>
|
||||
UlHub
|
||||
</Link>
|
||||
{user && (
|
||||
<>
|
||||
<nav style={{ display: 'flex', gap: '1rem' }}>
|
||||
<Link href="/">Jobs</Link>
|
||||
<Link href="/settings/devices">Devices</Link>
|
||||
<Link href="/settings/members">Members</Link>
|
||||
<Link href="/settings/api-keys">API Keys</Link>
|
||||
</nav>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
{memberships.length > 1 ? (
|
||||
<select value={activeOrg?.org.id ?? ''} onChange={(e) => setActiveOrgId(e.target.value)}>
|
||||
{memberships.map((m) => (
|
||||
<option key={m.org.id} value={m.org.id}>
|
||||
{m.org.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span style={{ color: '#555' }}>{activeOrg?.org.name}</span>
|
||||
)}
|
||||
<span style={{ color: '#888', fontSize: '0.9rem' }}>{user.email}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
router.push('/login');
|
||||
}}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
<main style={{ fontFamily: 'sans-serif', padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
web/components/map/JobMap.tsx
Normal file
10
web/components/map/JobMap.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { JobMapProps } from './types';
|
||||
|
||||
// Provider dispatcher. Google is the only implementation for now; an Esri
|
||||
// implementation would be selected here (e.g. via NEXT_PUBLIC_MAP_PROVIDER).
|
||||
const GoogleJobMap = dynamic(() => import('./google/GoogleJobMap'), { ssr: false });
|
||||
|
||||
export default function JobMap(props: JobMapProps) {
|
||||
return <GoogleJobMap {...props} />;
|
||||
}
|
||||
120
web/components/map/google/GoogleJobMap.tsx
Normal file
120
web/components/map/google/GoogleJobMap.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/// <reference types="google.maps" />
|
||||
import { APIProvider, Map as GoogleMap, useMap } from '@vis.gl/react-google-maps';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { JobMapProps, MapPoint, UTILITY_COLORS } from '../types';
|
||||
|
||||
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || '';
|
||||
|
||||
const DEFAULT_CENTER = { lat: 39.5, lng: -98.35 }; // continental US
|
||||
const DEFAULT_ZOOM = 4;
|
||||
|
||||
function colorFor(utilityType: string): string {
|
||||
return UTILITY_COLORS[utilityType] ?? UTILITY_COLORS.UNKNOWN;
|
||||
}
|
||||
|
||||
function orderKey(p: MapPoint): number {
|
||||
return p.sequence ?? new Date(p.recordedAt).getTime();
|
||||
}
|
||||
|
||||
// Draws points as colored circles plus one polyline per utility run.
|
||||
// Imperative overlays (google.maps.Marker/Polyline) are used because
|
||||
// @vis.gl/react-google-maps has no polyline component.
|
||||
function PointsLayer({ points, fitBounds }: { points: MapPoint[]; fitBounds: boolean }) {
|
||||
const map = useMap();
|
||||
const overlaysRef = useRef<{ setMap: (m: google.maps.Map | null) => void }[]>([]);
|
||||
const fittedCountRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
overlaysRef.current.forEach((o) => o.setMap(null));
|
||||
overlaysRef.current = [];
|
||||
if (points.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const byUtility = new Map<string, MapPoint[]>();
|
||||
for (const p of points) {
|
||||
const group = byUtility.get(p.utilityType) ?? [];
|
||||
group.push(p);
|
||||
byUtility.set(p.utilityType, group);
|
||||
}
|
||||
|
||||
for (const [utility, group] of byUtility) {
|
||||
const sorted = [...group].sort((a, b) => orderKey(a) - orderKey(b));
|
||||
const color = colorFor(utility);
|
||||
|
||||
const line = new google.maps.Polyline({
|
||||
path: sorted.map((p) => ({ lat: p.lat, lng: p.lng })),
|
||||
strokeColor: color,
|
||||
strokeOpacity: 0.8,
|
||||
strokeWeight: 3,
|
||||
map,
|
||||
});
|
||||
overlaysRef.current.push(line);
|
||||
|
||||
for (const p of sorted) {
|
||||
const marker = new google.maps.Marker({
|
||||
position: { lat: p.lat, lng: p.lng },
|
||||
map,
|
||||
icon: {
|
||||
path: google.maps.SymbolPath.CIRCLE,
|
||||
scale: 5,
|
||||
fillColor: color,
|
||||
fillOpacity: 1,
|
||||
strokeColor: '#ffffff',
|
||||
strokeWeight: 1.5,
|
||||
},
|
||||
title: `${p.utilityType} · ${p.fixType}${p.depth != null ? ` · depth ${p.depth}m` : ''}\n${new Date(p.recordedAt).toLocaleString()}`,
|
||||
});
|
||||
overlaysRef.current.push(marker);
|
||||
}
|
||||
}
|
||||
|
||||
if (fitBounds && points.length !== fittedCountRef.current) {
|
||||
fittedCountRef.current = points.length;
|
||||
const bounds = new google.maps.LatLngBounds();
|
||||
points.forEach((p) => bounds.extend({ lat: p.lat, lng: p.lng }));
|
||||
map.fitBounds(bounds, 48);
|
||||
}
|
||||
}, [map, points, fitBounds]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function GoogleJobMap({ points, fitBounds = true, heightPx = 480 }: JobMapProps) {
|
||||
if (!API_KEY) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: heightPx,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px dashed #999',
|
||||
borderRadius: 8,
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Set NEXT_PUBLIC_GOOGLE_MAPS_API_KEY in .env to enable the map.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: heightPx, borderRadius: 8, overflow: 'hidden' }}>
|
||||
<APIProvider apiKey={API_KEY}>
|
||||
<GoogleMap
|
||||
defaultCenter={points[0] ? { lat: points[0].lat, lng: points[0].lng } : DEFAULT_CENTER}
|
||||
defaultZoom={points[0] ? 18 : DEFAULT_ZOOM}
|
||||
mapTypeId="hybrid"
|
||||
gestureHandling="greedy"
|
||||
>
|
||||
<PointsLayer points={points} fitBounds={fitBounds} />
|
||||
</GoogleMap>
|
||||
</APIProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
web/components/map/types.ts
Normal file
34
web/components/map/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Provider-neutral mapping types. Pages import only these and <JobMap>;
|
||||
// swapping Google for Esri later means adding a new provider implementation
|
||||
// of JobMapProps under components/map/esri/ and switching the dispatcher.
|
||||
|
||||
export interface MapPoint {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
utilityType: string;
|
||||
fixType: string;
|
||||
depth: number | null;
|
||||
sequence: number | null;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export interface JobMapProps {
|
||||
points: MapPoint[];
|
||||
// Pan/zoom to fit all points whenever their count changes
|
||||
fitBounds?: boolean;
|
||||
heightPx?: number;
|
||||
}
|
||||
|
||||
// APWA uniform color code for marking underground utilities
|
||||
export const UTILITY_COLORS: Record<string, string> = {
|
||||
ELECTRIC: '#d32f2f', // red
|
||||
GAS: '#fbc02d', // yellow
|
||||
WATER: '#1976d2', // blue
|
||||
SEWER: '#388e3c', // green
|
||||
TELECOM: '#f57c00', // orange
|
||||
CATV: '#f57c00', // orange
|
||||
FIBER: '#f57c00', // orange
|
||||
STEAM: '#fbc02d', // yellow
|
||||
UNKNOWN: '#e91e8c', // pink (unknown/proposed)
|
||||
};
|
||||
35
web/lib/api.ts
Normal file
35
web/lib/api.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchJson<T = unknown>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const message = Array.isArray(body?.message)
|
||||
? body.message.join('; ')
|
||||
: body?.message || `Request failed (${res.status})`;
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T = unknown>(path: string) => fetchJson<T>(path),
|
||||
post: <T = unknown>(path: string, data: unknown) =>
|
||||
fetchJson<T>(path, { method: 'POST', body: JSON.stringify(data) }),
|
||||
patch: <T = unknown>(path: string, data: unknown) =>
|
||||
fetchJson<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
delete: <T = unknown>(path: string) => fetchJson<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
124
web/lib/auth-context.tsx
Normal file
124
web/lib/auth-context.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { api, ApiError } from './api';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Membership {
|
||||
role: 'ORG_ADMIN' | 'MEMBER' | 'VIEWER';
|
||||
org: { id: string; name: string; slug: string };
|
||||
}
|
||||
|
||||
interface MeResponse {
|
||||
user: AuthUser;
|
||||
memberships: Membership[];
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
memberships: Membership[];
|
||||
activeOrg: Membership | null;
|
||||
loading: boolean;
|
||||
setActiveOrgId: (orgId: string) => void;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (data: { email: string; password: string; name: string; orgName: string }) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
const ACTIVE_ORG_KEY = 'ulhub_active_org';
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [memberships, setMemberships] = useState<Membership[]>([]);
|
||||
const [activeOrgId, setActiveOrgIdState] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const applySession = useCallback((session: MeResponse) => {
|
||||
setUser(session.user);
|
||||
setMemberships(session.memberships);
|
||||
setActiveOrgIdState((current) => {
|
||||
const stored = current ?? localStorage.getItem(ACTIVE_ORG_KEY);
|
||||
const valid = session.memberships.some((m) => m.org.id === stored);
|
||||
return valid ? stored : (session.memberships[0]?.org.id ?? null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<MeResponse>('/api/auth/me')
|
||||
.then(applySession)
|
||||
.catch(() => setUser(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [applySession]);
|
||||
|
||||
const setActiveOrgId = useCallback((orgId: string) => {
|
||||
localStorage.setItem(ACTIVE_ORG_KEY, orgId);
|
||||
setActiveOrgIdState(orgId);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
applySession(await api.post<MeResponse>('/api/auth/login', { email, password }));
|
||||
},
|
||||
[applySession],
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
async (data: { email: string; password: string; name: string; orgName: string }) => {
|
||||
applySession(await api.post<MeResponse>('/api/auth/register', data));
|
||||
},
|
||||
[applySession],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await api.post('/api/auth/logout', {}).catch(() => undefined);
|
||||
localStorage.removeItem(ACTIVE_ORG_KEY);
|
||||
setUser(null);
|
||||
setMemberships([]);
|
||||
setActiveOrgIdState(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
memberships,
|
||||
activeOrg: memberships.find((m) => m.org.id === activeOrgId) ?? null,
|
||||
loading,
|
||||
setActiveOrgId,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
}),
|
||||
[user, memberships, activeOrgId, loading, setActiveOrgId, login, register, logout],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAuth must be used inside AuthProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Client-side page guard: redirects to /login when unauthenticated.
|
||||
export function useRequireAuth() {
|
||||
const auth = useAuth();
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
if (!auth.loading && !auth.user) {
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [auth.loading, auth.user, router]);
|
||||
return auth;
|
||||
}
|
||||
|
||||
export { ApiError };
|
||||
60
web/lib/use-job-stream.ts
Normal file
60
web/lib/use-job-stream.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { MapPoint } from '../components/map/types';
|
||||
|
||||
// Subscribes to live points for a job over the backend WebSocket.
|
||||
// Reconnects with capped exponential backoff; resubscribes on reconnect.
|
||||
export function useJobStream(jobId: string | null, onPoints: (points: MapPoint[]) => void) {
|
||||
const handlerRef = useRef(onPoints);
|
||||
handlerRef.current = onPoints;
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let closed = false;
|
||||
let attempt = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const connect = () => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
socket = new WebSocket(`${proto}://${window.location.host}/api/ws`);
|
||||
|
||||
socket.onopen = () => {
|
||||
attempt = 0;
|
||||
socket?.send(JSON.stringify({ type: 'subscribe', channel: `job:${jobId}` }));
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'points' && msg.jobId === jobId) {
|
||||
handlerRef.current(msg.points);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
attempt += 1;
|
||||
const delay = Math.min(1000 * 2 ** attempt, 15000);
|
||||
timer = setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
socket?.close();
|
||||
};
|
||||
}, [jobId]);
|
||||
}
|
||||
5470
web/package-lock.json
generated
Normal file
5470
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,13 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vis.gl/react-google-maps": "^1.4.0",
|
||||
"next": "14.2.5",
|
||||
"react": "18.3.0",
|
||||
"react-dom": "18.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google.maps": "^3.58.1",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
|
||||
10
web/pages/_app.tsx
Normal file
10
web/pages/_app.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { AppProps } from 'next/app';
|
||||
import { AuthProvider } from '../lib/auth-context';
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Component {...pageProps} />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +1,118 @@
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Layout from '../components/Layout';
|
||||
import { api } from '../lib/api';
|
||||
import { useRequireAuth } from '../lib/auth-context';
|
||||
|
||||
const WS_PATH = '/api/device-events/ws';
|
||||
|
||||
interface ApiStatus {
|
||||
message: string;
|
||||
docs: string;
|
||||
interface JobRow {
|
||||
id: string;
|
||||
ticketNumber: string;
|
||||
title: string;
|
||||
address: string | null;
|
||||
status: string;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
assignedTo: { id: string; name: string } | null;
|
||||
_count: { points: number };
|
||||
}
|
||||
|
||||
interface DeviceRecord {
|
||||
id: number;
|
||||
topic: string;
|
||||
payload: string;
|
||||
receivedAt: string;
|
||||
}
|
||||
const STATUSES = ['', 'OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'];
|
||||
|
||||
export default function Home() {
|
||||
const [status, setStatus] = useState<ApiStatus | null>(null);
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
OPEN: '#1976d2',
|
||||
IN_PROGRESS: '#f57c00',
|
||||
COMPLETED: '#388e3c',
|
||||
CANCELLED: '#9e9e9e',
|
||||
};
|
||||
|
||||
export default function JobsPage() {
|
||||
const { user, activeOrg, loading } = useRequireAuth();
|
||||
const [jobs, setJobs] = useState<JobRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [status, setStatus] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<DeviceRecord[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/status')
|
||||
.then((res) => res.json())
|
||||
.then(setStatus)
|
||||
.catch((err) => setError(err.message));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/device-events')
|
||||
.then((res) => res.json())
|
||||
.then((payload) => {
|
||||
const records = Array.isArray(payload) ? payload : payload?.records ?? [];
|
||||
setMessages(Array.isArray(records) ? records : []);
|
||||
setConnected(true);
|
||||
if (!activeOrg) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set('status', status);
|
||||
if (q) params.set('q', q);
|
||||
api
|
||||
.get<{ jobs: JobRow[]; total: number }>(`/api/orgs/${activeOrg.org.id}/jobs?${params}`)
|
||||
.then((res) => {
|
||||
setJobs(res.jobs);
|
||||
setTotal(res.total);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message || 'Unable to load device events');
|
||||
});
|
||||
.catch((err) => setError(err.message));
|
||||
}, [activeOrg, status, q]);
|
||||
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const socket = new WebSocket(`${wsProtocol}://${window.location.host}${WS_PATH}`);
|
||||
|
||||
socket.onopen = () => {
|
||||
setConnected(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (payload.type === 'connected') {
|
||||
setConnected(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) => [payload, ...prev].slice(0, 20));
|
||||
} catch (err) {
|
||||
console.error('Failed to parse device-event payload', err);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
setConnected(false);
|
||||
setError('Realtime stream disconnected');
|
||||
};
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, []);
|
||||
if (loading || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>UlHub</title>
|
||||
<meta name="description" content="UlHub monolithic app" />
|
||||
</Head>
|
||||
<main style={{ padding: '3rem', fontFamily: 'system-ui, sans-serif', maxWidth: '900px' }}>
|
||||
<h1>UlHub</h1>
|
||||
<p>React + Next.js frontend with NestJS backend and live MQTT WebSocket data.</p>
|
||||
|
||||
<section style={{ marginTop: '1.5rem' }}>
|
||||
<h2>API Status</h2>
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
{status ? (
|
||||
<div>
|
||||
<p>{status.message}</p>
|
||||
<p>{status.docs}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>Loading status...</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ marginTop: '1.5rem' }}>
|
||||
<h2>Device Events</h2>
|
||||
<p>Status: {connected ? 'Connected' : 'Connecting...'}</p>
|
||||
<ul>
|
||||
{(Array.isArray(messages) ? messages : []).map((msg, index) => (
|
||||
<li key={`${msg.topic}-${index}`} style={{ marginBottom: '0.75rem' }}>
|
||||
<strong>{msg.topic}</strong> <span style={{ color: '#666' }}>{msg.receivedAt}</span>
|
||||
<br />
|
||||
{msg.payload}
|
||||
</li>
|
||||
<Layout title="Jobs">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
|
||||
<h1 style={{ margin: 0 }}>Jobs</h1>
|
||||
<span style={{ color: '#888' }}>{total} total</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
||||
<input placeholder="Search ticket, title, address…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s || 'All statuses'}
|
||||
</option>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
</select>
|
||||
<Link href="/jobs/new">
|
||||
<button>+ New job</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '0.5rem' }}>Ticket</th>
|
||||
<th>Title</th>
|
||||
<th>Address</th>
|
||||
<th>Status</th>
|
||||
<th>Source</th>
|
||||
<th>Assignee</th>
|
||||
<th style={{ textAlign: 'right' }}>Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job) => (
|
||||
<tr key={job.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '0.5rem' }}>
|
||||
<Link href={`/jobs/${job.id}`}>{job.ticketNumber}</Link>
|
||||
</td>
|
||||
<td>{job.title}</td>
|
||||
<td>{job.address ?? '—'}</td>
|
||||
<td>
|
||||
<span style={{ color: STATUS_COLORS[job.status] ?? '#333', fontWeight: 600 }}>{job.status}</span>
|
||||
</td>
|
||||
<td>{job.source}</td>
|
||||
<td>{job.assignedTo?.name ?? '—'}</td>
|
||||
<td style={{ textAlign: 'right' }}>{job._count.points}</td>
|
||||
</tr>
|
||||
))}
|
||||
{jobs.length === 0 && !error && (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ padding: '2rem', textAlign: 'center', color: '#888' }}>
|
||||
No jobs yet. Create one, or let a device post points to auto-create its ticket.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
115
web/pages/jobs/[jobId].tsx
Normal file
115
web/pages/jobs/[jobId].tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import JobMap from '../../components/map/JobMap';
|
||||
import type { MapPoint } from '../../components/map/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { useRequireAuth } from '../../lib/auth-context';
|
||||
import { useJobStream } from '../../lib/use-job-stream';
|
||||
|
||||
interface JobDetail {
|
||||
id: string;
|
||||
ticketNumber: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
address: string | null;
|
||||
status: string;
|
||||
source: string;
|
||||
dueAt: string | null;
|
||||
createdAt: string;
|
||||
assignedTo: { id: string; name: string; email: string } | null;
|
||||
_count: { points: number };
|
||||
}
|
||||
|
||||
const STATUSES = ['OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'];
|
||||
|
||||
export default function JobDetailPage() {
|
||||
const { user, activeOrg, loading } = useRequireAuth();
|
||||
const router = useRouter();
|
||||
const jobId = typeof router.query.jobId === 'string' ? router.query.jobId : null;
|
||||
const orgId = activeOrg?.org.id ?? null;
|
||||
|
||||
const [job, setJob] = useState<JobDetail | null>(null);
|
||||
const [points, setPoints] = useState<MapPoint[]>([]);
|
||||
const [live, setLive] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orgId || !jobId) {
|
||||
return;
|
||||
}
|
||||
api
|
||||
.get<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`)
|
||||
.then(setJob)
|
||||
.catch((err) => setError(err.message));
|
||||
api
|
||||
.get<{ points: MapPoint[] }>(`/api/orgs/${orgId}/jobs/${jobId}/points`)
|
||||
.then((res) => setPoints(res.points))
|
||||
.catch((err) => setError(err.message));
|
||||
}, [orgId, jobId]);
|
||||
|
||||
const onLivePoints = useCallback((incoming: MapPoint[]) => {
|
||||
setPoints((prev) => {
|
||||
const seen = new Set(prev.map((p) => p.id));
|
||||
const fresh = incoming.filter((p) => !seen.has(p.id));
|
||||
return fresh.length > 0 ? [...prev, ...fresh] : prev;
|
||||
});
|
||||
setLive((n) => n + incoming.length);
|
||||
}, []);
|
||||
|
||||
useJobStream(jobId, onLivePoints);
|
||||
|
||||
const updateStatus = async (status: string) => {
|
||||
if (!orgId || !jobId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setJob(await api.patch<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`, { status }));
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title={job ? job.ticketNumber : 'Job'}>
|
||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||
{job && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<h1 style={{ margin: 0 }}>{job.ticketNumber}</h1>
|
||||
<span style={{ fontSize: '1.1rem' }}>{job.title}</span>
|
||||
<select value={job.status} onChange={(e) => updateStatus(e.target.value)} style={{ marginLeft: 'auto' }}>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p style={{ color: '#666' }}>
|
||||
{job.address && <>{job.address} · </>}
|
||||
source {job.source} · created {new Date(job.createdAt).toLocaleString()}
|
||||
{job.assignedTo && <> · assigned to {job.assignedTo.name}</>}
|
||||
{job.dueAt && <> · locate by {new Date(job.dueAt).toLocaleString()}</>}
|
||||
</p>
|
||||
{job.description && <p>{job.description}</p>}
|
||||
|
||||
<div style={{ margin: '1rem 0', display: 'flex', gap: '1rem', color: '#555' }}>
|
||||
<span>
|
||||
<strong>{points.length}</strong> points
|
||||
</span>
|
||||
<span style={{ color: live > 0 ? '#388e3c' : '#999' }}>
|
||||
● live{live > 0 ? ` (+${live} this session)` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<JobMap points={points} heightPx={520} />
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
74
web/pages/jobs/new.tsx
Normal file
74
web/pages/jobs/new.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { FormEvent, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { api } from '../../lib/api';
|
||||
import { useRequireAuth } from '../../lib/auth-context';
|
||||
|
||||
export default function NewJobPage() {
|
||||
const { user, activeOrg, loading } = useRequireAuth();
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState({ ticketNumber: '', title: '', address: '', description: '', dueAt: '' });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const set = (key: keyof typeof form) => (e: { target: { value: string } }) =>
|
||||
setForm((f) => ({ ...f, [key]: e.target.value }));
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!activeOrg) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const job = await api.post<{ id: string }>(`/api/orgs/${activeOrg.org.id}/jobs`, {
|
||||
ticketNumber: form.ticketNumber,
|
||||
title: form.title,
|
||||
address: form.address || undefined,
|
||||
description: form.description || undefined,
|
||||
dueAt: form.dueAt ? new Date(form.dueAt).toISOString() : undefined,
|
||||
});
|
||||
router.push(`/jobs/${job.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create job');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title="New job">
|
||||
<h1>New job</h1>
|
||||
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxWidth: 480 }}>
|
||||
<label>
|
||||
Ticket number *
|
||||
<input value={form.ticketNumber} onChange={set('ticketNumber')} required style={{ width: '100%', padding: '0.5rem' }} />
|
||||
</label>
|
||||
<label>
|
||||
Title *
|
||||
<input value={form.title} onChange={set('title')} required style={{ width: '100%', padding: '0.5rem' }} />
|
||||
</label>
|
||||
<label>
|
||||
Address / dig site
|
||||
<input value={form.address} onChange={set('address')} style={{ width: '100%', padding: '0.5rem' }} />
|
||||
</label>
|
||||
<label>
|
||||
Description
|
||||
<textarea value={form.description} onChange={set('description')} rows={4} style={{ width: '100%', padding: '0.5rem' }} />
|
||||
</label>
|
||||
<label>
|
||||
Locate-by date
|
||||
<input type="datetime-local" value={form.dueAt} onChange={set('dueAt')} style={{ width: '100%', padding: '0.5rem' }} />
|
||||
</label>
|
||||
{error && <p style={{ color: '#c62828', margin: 0 }}>{error}</p>}
|
||||
<button type="submit" disabled={submitting} style={{ padding: '0.6rem' }}>
|
||||
{submitting ? 'Creating…' : 'Create job'}
|
||||
</button>
|
||||
</form>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
72
web/pages/login.tsx
Normal file
72
web/pages/login.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { useAuth } from '../lib/auth-context';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { user, loading, login } = useAuth();
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && user) {
|
||||
router.replace('/');
|
||||
}
|
||||
}, [loading, user, router]);
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
router.push('/');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Login failed');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: 'sans-serif', maxWidth: 360, margin: '10vh auto', padding: '0 1rem' }}>
|
||||
<Head>
|
||||
<title>Log in · UlHub</title>
|
||||
</Head>
|
||||
<h1>UlHub</h1>
|
||||
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<label>
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
style={{ width: '100%', padding: '0.5rem' }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
style={{ width: '100%', padding: '0.5rem' }}
|
||||
/>
|
||||
</label>
|
||||
{error && <p style={{ color: '#c62828', margin: 0 }}>{error}</p>}
|
||||
<button type="submit" disabled={submitting} style={{ padding: '0.6rem' }}>
|
||||
{submitting ? 'Logging in…' : 'Log in'}
|
||||
</button>
|
||||
</form>
|
||||
<p>
|
||||
No account? <Link href="/register">Register</Link>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
78
web/pages/register.tsx
Normal file
78
web/pages/register.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useAuth } from '../lib/auth-context';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { register } = useAuth();
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState({ name: '', email: '', password: '', orgName: '' });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const set = (key: keyof typeof form) => (e: { target: { value: string } }) =>
|
||||
setForm((f) => ({ ...f, [key]: e.target.value }));
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await register(form);
|
||||
router.push('/');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registration failed');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: 'sans-serif', maxWidth: 360, margin: '10vh auto', padding: '0 1rem' }}>
|
||||
<Head>
|
||||
<title>Register · UlHub</title>
|
||||
</Head>
|
||||
<h1>Create your account</h1>
|
||||
<p style={{ color: '#666' }}>Registering creates a new organization with you as its admin.</p>
|
||||
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<label>
|
||||
Your name
|
||||
<input value={form.name} onChange={set('name')} required style={{ width: '100%', padding: '0.5rem' }} />
|
||||
</label>
|
||||
<label>
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={set('email')}
|
||||
required
|
||||
style={{ width: '100%', padding: '0.5rem' }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password (min 8 chars)
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={set('password')}
|
||||
required
|
||||
minLength={8}
|
||||
style={{ width: '100%', padding: '0.5rem' }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Organization name
|
||||
<input value={form.orgName} onChange={set('orgName')} required style={{ width: '100%', padding: '0.5rem' }} />
|
||||
</label>
|
||||
{error && <p style={{ color: '#c62828', margin: 0 }}>{error}</p>}
|
||||
<button type="submit" disabled={submitting} style={{ padding: '0.6rem' }}>
|
||||
{submitting ? 'Creating…' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
<p>
|
||||
Already registered? <Link href="/login">Log in</Link>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
149
web/pages/settings/api-keys.tsx
Normal file
149
web/pages/settings/api-keys.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { api } from '../../lib/api';
|
||||
import { useRequireAuth } from '../../lib/auth-context';
|
||||
|
||||
interface ApiKeyRow {
|
||||
id: string;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
scopes: string[];
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const ALL_SCOPES = ['jobs:read', 'jobs:write', 'points:read', 'points:write', 'devices:read'];
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const { user, activeOrg, loading } = useRequireAuth();
|
||||
const orgId = activeOrg?.org.id ?? null;
|
||||
const isAdmin = activeOrg?.role === 'ORG_ADMIN';
|
||||
|
||||
const [keys, setKeys] = useState<ApiKeyRow[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [scopes, setScopes] = useState<string[]>(['jobs:read', 'points:read']);
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!orgId || !isAdmin) {
|
||||
return;
|
||||
}
|
||||
api
|
||||
.get<ApiKeyRow[]>(`/api/orgs/${orgId}/api-keys`)
|
||||
.then((rows) => {
|
||||
setKeys(rows);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
}, [orgId, isAdmin]);
|
||||
|
||||
useEffect(reload, [reload]);
|
||||
|
||||
const toggleScope = (scope: string) =>
|
||||
setScopes((s) => (s.includes(scope) ? s.filter((x) => x !== scope) : [...s, scope]));
|
||||
|
||||
const createKey = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const created = await api.post<ApiKeyRow & { key: string }>(`/api/orgs/${orgId}/api-keys`, { name, scopes });
|
||||
setCreatedKey(created.key);
|
||||
setName('');
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (keyId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/orgs/${orgId}/api-keys/${keyId}`);
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Layout title="API Keys">
|
||||
<h1>API Keys</h1>
|
||||
<p>Only organization admins can manage API keys.</p>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title="API Keys">
|
||||
<h1>API Keys</h1>
|
||||
<p style={{ color: '#666' }}>
|
||||
Send keys in the <code>X-API-Key</code> header. Access is limited to this organization and the selected scopes.
|
||||
</p>
|
||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||
|
||||
{createdKey && (
|
||||
<div style={{ background: '#e8f5e9', border: '1px solid #388e3c', borderRadius: 6, padding: '1rem', margin: '1rem 0' }}>
|
||||
<strong>Copy this key now — it will not be shown again:</strong>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginTop: '0.5rem' }}>
|
||||
<code style={{ wordBreak: 'break-all' }}>{createdKey}</code>
|
||||
<button onClick={() => navigator.clipboard.writeText(createdKey)}>Copy</button>
|
||||
<button onClick={() => setCreatedKey(null)}>Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={createKey} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap', marginBottom: '1.5rem' }}>
|
||||
<input
|
||||
placeholder="key name (e.g. GIS export)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
style={{ padding: '0.4rem', width: 220 }}
|
||||
/>
|
||||
{ALL_SCOPES.map((scope) => (
|
||||
<label key={scope} style={{ whiteSpace: 'nowrap' }}>
|
||||
<input type="checkbox" checked={scopes.includes(scope)} onChange={() => toggleScope(scope)} /> {scope}
|
||||
</label>
|
||||
))}
|
||||
<button type="submit" disabled={scopes.length === 0}>
|
||||
Create key
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '0.5rem' }}>Name</th>
|
||||
<th>Prefix</th>
|
||||
<th>Scopes</th>
|
||||
<th>Last used</th>
|
||||
<th>Status</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((k) => (
|
||||
<tr key={k.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '0.5rem' }}>{k.name}</td>
|
||||
<td>
|
||||
<code>{k.keyPrefix}…</code>
|
||||
</td>
|
||||
<td>{k.scopes.join(', ')}</td>
|
||||
<td>{k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleString() : 'never'}</td>
|
||||
<td style={{ color: k.revokedAt ? '#c62828' : '#388e3c' }}>{k.revokedAt ? 'revoked' : 'active'}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
{!k.revokedAt && <button onClick={() => revoke(k.id)}>Revoke</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
150
web/pages/settings/devices.tsx
Normal file
150
web/pages/settings/devices.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { api } from '../../lib/api';
|
||||
import { useRequireAuth } from '../../lib/auth-context';
|
||||
|
||||
interface DeviceRow {
|
||||
id: string;
|
||||
name: string;
|
||||
serialNumber: string | null;
|
||||
mqttUsername: string;
|
||||
isActive: boolean;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
export default function DevicesPage() {
|
||||
const { user, activeOrg, loading } = useRequireAuth();
|
||||
const orgId = activeOrg?.org.id ?? null;
|
||||
const isAdmin = activeOrg?.role === 'ORG_ADMIN';
|
||||
|
||||
const [devices, setDevices] = useState<DeviceRow[]>([]);
|
||||
const [form, setForm] = useState({ name: '', mqttUsername: '', serialNumber: '' });
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
api
|
||||
.get<DeviceRow[]>(`/api/orgs/${orgId}/devices`)
|
||||
.then((rows) => {
|
||||
setDevices(rows);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
}, [orgId]);
|
||||
|
||||
useEffect(reload, [reload]);
|
||||
|
||||
const addDevice = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const created = await api.post<DeviceRow & { provisioning: { pointsTopic: string } }>(
|
||||
`/api/orgs/${orgId}/devices`,
|
||||
{
|
||||
name: form.name,
|
||||
mqttUsername: form.mqttUsername,
|
||||
serialNumber: form.serialNumber || undefined,
|
||||
},
|
||||
);
|
||||
setNotice(`Device created. It should publish points to ${created.provisioning.pointsTopic}`);
|
||||
setForm({ name: '', mqttUsername: '', serialNumber: '' });
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (device: DeviceRow) => {
|
||||
try {
|
||||
await api.patch(`/api/orgs/${orgId}/devices/${device.id}`, { isActive: !device.isActive });
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (deviceId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/orgs/${orgId}/devices/${deviceId}`);
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title="Devices">
|
||||
<h1>Devices</h1>
|
||||
<p style={{ color: '#666' }}>
|
||||
Field devices publish to <code>devices/<mqtt username>/points</code>. Broker credentials are provisioned
|
||||
separately for now.
|
||||
</p>
|
||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||
{notice && <p style={{ color: '#388e3c' }}>{notice}</p>}
|
||||
|
||||
{isAdmin && (
|
||||
<form onSubmit={addDevice} style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
placeholder="name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
required
|
||||
style={{ padding: '0.4rem' }}
|
||||
/>
|
||||
<input
|
||||
placeholder="mqtt username"
|
||||
value={form.mqttUsername}
|
||||
onChange={(e) => setForm((f) => ({ ...f, mqttUsername: e.target.value }))}
|
||||
required
|
||||
style={{ padding: '0.4rem' }}
|
||||
/>
|
||||
<input
|
||||
placeholder="serial number (optional)"
|
||||
value={form.serialNumber}
|
||||
onChange={(e) => setForm((f) => ({ ...f, serialNumber: e.target.value }))}
|
||||
style={{ padding: '0.4rem' }}
|
||||
/>
|
||||
<button type="submit">Add device</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '0.5rem' }}>Name</th>
|
||||
<th>MQTT username</th>
|
||||
<th>Serial</th>
|
||||
<th>Status</th>
|
||||
<th>Last seen</th>
|
||||
{isAdmin && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((d) => (
|
||||
<tr key={d.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '0.5rem' }}>{d.name}</td>
|
||||
<td>
|
||||
<code>{d.mqttUsername}</code>
|
||||
</td>
|
||||
<td>{d.serialNumber ?? '—'}</td>
|
||||
<td style={{ color: d.isActive ? '#388e3c' : '#9e9e9e' }}>{d.isActive ? 'active' : 'disabled'}</td>
|
||||
<td>{d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'never'}</td>
|
||||
{isAdmin && (
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button onClick={() => toggleActive(d)}>{d.isActive ? 'Disable' : 'Enable'}</button>{' '}
|
||||
<button onClick={() => remove(d.id)}>Delete</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
138
web/pages/settings/members.tsx
Normal file
138
web/pages/settings/members.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { api } from '../../lib/api';
|
||||
import { useRequireAuth } from '../../lib/auth-context';
|
||||
|
||||
interface MemberRow {
|
||||
role: string;
|
||||
createdAt: string;
|
||||
user: { id: string; email: string; name: string };
|
||||
}
|
||||
|
||||
const ROLES = ['ORG_ADMIN', 'MEMBER', 'VIEWER'];
|
||||
|
||||
export default function MembersPage() {
|
||||
const { user, activeOrg, loading } = useRequireAuth();
|
||||
const orgId = activeOrg?.org.id ?? null;
|
||||
const isAdmin = activeOrg?.role === 'ORG_ADMIN';
|
||||
|
||||
const [members, setMembers] = useState<MemberRow[]>([]);
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState('MEMBER');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
api
|
||||
.get<MemberRow[]>(`/api/orgs/${orgId}/members`)
|
||||
.then((rows) => {
|
||||
setMembers(rows);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
}, [orgId]);
|
||||
|
||||
useEffect(reload, [reload]);
|
||||
|
||||
const addMember = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.post(`/api/orgs/${orgId}/members`, { email, role });
|
||||
setEmail('');
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const changeRole = async (userId: string, newRole: string) => {
|
||||
try {
|
||||
await api.patch(`/api/orgs/${orgId}/members/${userId}`, { role: newRole });
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (userId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/orgs/${orgId}/members/${userId}`);
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title="Members">
|
||||
<h1>Members</h1>
|
||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||
|
||||
{isAdmin && (
|
||||
<form onSubmit={addMember} style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="registered user's email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
style={{ padding: '0.4rem', width: 280 }}
|
||||
/>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit">Add member</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '0.5rem' }}>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Since</th>
|
||||
{isAdmin && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map((m) => (
|
||||
<tr key={m.user.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '0.5rem' }}>{m.user.name}</td>
|
||||
<td>{m.user.email}</td>
|
||||
<td>
|
||||
{isAdmin && m.user.id !== user.id ? (
|
||||
<select value={m.role} onChange={(e) => changeRole(m.user.id, e.target.value)}>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
m.role
|
||||
)}
|
||||
</td>
|
||||
<td>{new Date(m.createdAt).toLocaleDateString()}</td>
|
||||
{isAdmin && (
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
{m.user.id !== user.id && <button onClick={() => remove(m.user.id)}>Remove</button>}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user