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

Replaces the device-events demo with the actual product:

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

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

View File

@@ -0,0 +1,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);
}
}
}
}