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

Replaces the device-events demo with the actual product:

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

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

View File

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