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

Replaces the device-events demo with the actual product:

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

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

11
.env.example Normal file
View 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=

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}

View File

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

View File

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

View File

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

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

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

View 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);
}
}

View 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 {}

View 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 };
}
}

View 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;
}

View File

@@ -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 {}

View 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);
}
}

View 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 {}

View 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' },
});
}
}

View 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,
);

View 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);

View 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);

View File

@@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
@MinLength(1)
password: string;
}

View 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;
}

View 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']) {}

View 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;
}
}

View 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;
}
}

View 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];

View 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 };
}
}

View 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 };
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View 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);
}
}

View 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 {}

View 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;
}
}

View 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;
}

View 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;
}

View 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}`);
}
}
}

View 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 {}

View 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);
}
}

View 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}`);
}
});
}
}

View 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(),
},
});
}
}

View 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;
}

View 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);
}
}

View 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 {}

View 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 };
}
}

View File

@@ -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');
}

View 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;
}

View 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);
}
}

View 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 {}

View 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');
}
}
}

View 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;
}

View 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);
}
}

View 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 {}

View 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);
}
}

View 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 {}

View 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();
}
}

View 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;
}
}

View 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 {}

View 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);
}
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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==

View File

@@ -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
View 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>
</>
);
}

View 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} />;
}

View 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>
);
}

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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>
);
}

View File

@@ -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);
})
.catch((err) => {
setError(err.message || 'Unable to load device events');
});
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);
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));
}, [activeOrg, status, q]);
setMessages((prev) => [payload, ...prev].slice(0, 20));
} catch (err) {
console.error('Failed to parse device-event payload', err);
if (loading || !user) {
return null;
}
};
socket.onerror = () => {
setConnected(false);
setError('Realtime stream disconnected');
};
return () => {
socket.close();
};
}, []);
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
View 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
View 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
View 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
View 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>
);
}

View 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>
);
}

View 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/&lt;mqtt username&gt;/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>
);
}

View 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>
);
}