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:
35
backend/src/api-keys/api-keys.controller.ts
Normal file
35
backend/src/api-keys/api-keys.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { CurrentPrincipal } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/auth.guard';
|
||||
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
|
||||
import { UserPrincipal } from '../auth/principal';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { CreateApiKeyDto } from './dto/api-keys.dto';
|
||||
|
||||
// JWT-only by design: an API key must not be able to mint or revoke API keys
|
||||
@Controller('orgs/:orgId/api-keys')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('ORG_ADMIN')
|
||||
export class ApiKeysController {
|
||||
constructor(private readonly apiKeysService: ApiKeysService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('orgId') orgId: string) {
|
||||
return this.apiKeysService.list(orgId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@Param('orgId') orgId: string,
|
||||
@Body() dto: CreateApiKeyDto,
|
||||
@CurrentPrincipal() principal: UserPrincipal,
|
||||
) {
|
||||
return this.apiKeysService.create(orgId, dto, principal.userId);
|
||||
}
|
||||
|
||||
@Delete(':keyId')
|
||||
revoke(@Param('orgId') orgId: string, @Param('keyId') keyId: string) {
|
||||
return this.apiKeysService.revoke(orgId, keyId);
|
||||
}
|
||||
}
|
||||
9
backend/src/api-keys/api-keys.module.ts
Normal file
9
backend/src/api-keys/api-keys.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ApiKeysController],
|
||||
providers: [ApiKeysService],
|
||||
})
|
||||
export class ApiKeysModule {}
|
||||
71
backend/src/api-keys/api-keys.service.ts
Normal file
71
backend/src/api-keys/api-keys.service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { API_KEY_PREFIX, hashApiKey } from '../auth/strategies/api-key.strategy';
|
||||
import { CreateApiKeyDto } from './dto/api-keys.dto';
|
||||
|
||||
const KEY_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
keyPrefix: true,
|
||||
scopes: true,
|
||||
expiresAt: true,
|
||||
lastUsedAt: true,
|
||||
revokedAt: true,
|
||||
createdAt: true,
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
};
|
||||
|
||||
function generateKey(): string {
|
||||
// ulh_ + 40 chars base62
|
||||
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
const bytes = randomBytes(40);
|
||||
let suffix = '';
|
||||
for (let i = 0; i < 40; i++) {
|
||||
suffix += alphabet[bytes[i] % alphabet.length];
|
||||
}
|
||||
return API_KEY_PREFIX + suffix;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeysService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list(orgId: string) {
|
||||
return this.prisma.apiKey.findMany({
|
||||
where: { orgId },
|
||||
select: KEY_SELECT,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(orgId: string, dto: CreateApiKeyDto, createdById: string) {
|
||||
const key = generateKey();
|
||||
const record = await this.prisma.apiKey.create({
|
||||
data: {
|
||||
orgId,
|
||||
name: dto.name,
|
||||
keyPrefix: key.slice(0, 12),
|
||||
keyHash: hashApiKey(key),
|
||||
scopes: dto.scopes,
|
||||
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined,
|
||||
createdById,
|
||||
},
|
||||
select: KEY_SELECT,
|
||||
});
|
||||
// The plaintext key is returned exactly once; only the hash is stored.
|
||||
return { ...record, key };
|
||||
}
|
||||
|
||||
async revoke(orgId: string, keyId: string) {
|
||||
const record = await this.prisma.apiKey.findFirst({ where: { id: keyId, orgId } });
|
||||
if (!record) {
|
||||
throw new NotFoundException('API key not found');
|
||||
}
|
||||
await this.prisma.apiKey.update({
|
||||
where: { id: keyId },
|
||||
data: { revokedAt: record.revokedAt ?? new Date() },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
18
backend/src/api-keys/dto/api-keys.dto.ts
Normal file
18
backend/src/api-keys/dto/api-keys.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ArrayMinSize, IsArray, IsDateString, IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { API_KEY_SCOPES, ApiKeyScope } from '../../auth/principal';
|
||||
|
||||
export class CreateApiKeyDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
name: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsIn(API_KEY_SCOPES, { each: true })
|
||||
scopes: ApiKeyScope[];
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiresAt?: string;
|
||||
}
|
||||
@@ -2,12 +2,29 @@ import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { StatusController } from './status.controller';
|
||||
import { DeviceEventsController } from './device-events.controller';
|
||||
import { DeviceDataService } from './device-data.service';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { OrgsModule } from './orgs/orgs.module';
|
||||
import { JobsModule } from './jobs/jobs.module';
|
||||
import { PointsModule } from './points/points.module';
|
||||
import { DevicesModule } from './devices/devices.module';
|
||||
import { IngestModule } from './ingest/ingest.module';
|
||||
import { RealtimeModule } from './realtime/realtime.module';
|
||||
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AppController, StatusController, DeviceEventsController],
|
||||
providers: [AppService, DeviceDataService],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
OrgsModule,
|
||||
JobsModule,
|
||||
PointsModule,
|
||||
DevicesModule,
|
||||
IngestModule,
|
||||
RealtimeModule,
|
||||
ApiKeysModule,
|
||||
],
|
||||
controllers: [AppController, StatusController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
55
backend/src/auth/auth.controller.ts
Normal file
55
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Body, Controller, Get, HttpCode, Post, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentPrincipal } from './decorators/current-user.decorator';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { JwtAuthGuard } from './guards/auth.guard';
|
||||
import { UserPrincipal } from './principal';
|
||||
import { AUTH_COOKIE } from './strategies/jwt.strategy';
|
||||
|
||||
const COOKIE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // matches JWT expiry
|
||||
|
||||
function setAuthCookie(res: Response, token: string) {
|
||||
res.cookie(AUTH_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: COOKIE_MAX_AGE_MS,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
||||
const { token, user, memberships } = await this.authService.register(dto);
|
||||
setAuthCookie(res, token);
|
||||
return { user, memberships };
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
||||
const { token, user, memberships } = await this.authService.login(dto);
|
||||
setAuthCookie(res, token);
|
||||
return { user, memberships };
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(200)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
logout(@Res({ passthrough: true }) res: Response) {
|
||||
res.clearCookie(AUTH_COOKIE, { path: '/' });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentPrincipal() principal: UserPrincipal) {
|
||||
return this.authService.me(principal.userId);
|
||||
}
|
||||
}
|
||||
21
backend/src/auth/auth.module.ts
Normal file
21
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ApiKeyStrategy } from './strategies/api-key.strategy';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'dev-only-insecure-secret',
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, ApiKeyStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
92
backend/src/auth/auth.service.ts
Normal file
92
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ConflictException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { JwtPayload } from './strategies/jwt.strategy';
|
||||
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48) || 'org';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
const existing = await this.prisma.user.findUnique({ where: { email: dto.email } });
|
||||
if (existing) {
|
||||
throw new ConflictException('An account with this email already exists');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
|
||||
const baseSlug = slugify(dto.orgName);
|
||||
|
||||
const { user } = await this.prisma.$transaction(async (tx) => {
|
||||
let slug = baseSlug;
|
||||
if (await tx.organization.findUnique({ where: { slug } })) {
|
||||
slug = `${baseSlug}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
const org = await tx.organization.create({ data: { name: dto.orgName, slug } });
|
||||
const user = await tx.user.create({
|
||||
data: { email: dto.email, name: dto.name, passwordHash },
|
||||
});
|
||||
await tx.orgMembership.create({
|
||||
data: { orgId: org.id, userId: user.id, role: 'ORG_ADMIN' },
|
||||
});
|
||||
return { user, org };
|
||||
});
|
||||
|
||||
return this.sessionFor(user.id);
|
||||
}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
|
||||
if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) {
|
||||
throw new UnauthorizedException('Invalid email or password');
|
||||
}
|
||||
return this.sessionFor(user.id);
|
||||
}
|
||||
|
||||
async me(userId: string) {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true, name: true, createdAt: true },
|
||||
});
|
||||
const memberships = await this.membershipsOf(userId);
|
||||
return { user, memberships };
|
||||
}
|
||||
|
||||
private async sessionFor(userId: string) {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true, name: true },
|
||||
});
|
||||
const payload: JwtPayload = { sub: user.id, email: user.email };
|
||||
const token = this.jwtService.sign(payload);
|
||||
const memberships = await this.membershipsOf(userId);
|
||||
return { token, user, memberships };
|
||||
}
|
||||
|
||||
private membershipsOf(userId: string) {
|
||||
return this.prisma.orgMembership.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
role: true,
|
||||
org: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
6
backend/src/auth/decorators/current-user.decorator.ts
Normal file
6
backend/src/auth/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { Principal } from '../principal';
|
||||
|
||||
export const CurrentPrincipal = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): Principal => ctx.switchToHttp().getRequest().user,
|
||||
);
|
||||
7
backend/src/auth/decorators/roles.decorator.ts
Normal file
7
backend/src/auth/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { OrgRole } from '@prisma/client';
|
||||
|
||||
export const ROLES_KEY = 'org_role';
|
||||
|
||||
// Requires the JWT user to hold AT LEAST this role in the org from :orgId.
|
||||
export const Roles = (role: OrgRole) => SetMetadata(ROLES_KEY, role);
|
||||
8
backend/src/auth/decorators/scopes.decorator.ts
Normal file
8
backend/src/auth/decorators/scopes.decorator.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { ApiKeyScope } from '../principal';
|
||||
|
||||
export const SCOPES_KEY = 'api_key_scopes';
|
||||
|
||||
// Requires API-key principals to hold ALL listed scopes. JWT users are unaffected
|
||||
// (their access is governed by org role instead).
|
||||
export const RequireScopes = (...scopes: ApiKeyScope[]) => SetMetadata(SCOPES_KEY, scopes);
|
||||
10
backend/src/auth/dto/login.dto.ts
Normal file
10
backend/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
password: string;
|
||||
}
|
||||
21
backend/src/auth/dto/register.dto.ts
Normal file
21
backend/src/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(72)
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
orgName: string;
|
||||
}
|
||||
10
backend/src/auth/guards/auth.guard.ts
Normal file
10
backend/src/auth/guards/auth.guard.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
// JWT-only (auth/me, logout — endpoints that make no sense for API keys)
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
|
||||
// Accepts either a logged-in user (cookie/Bearer JWT) or an X-API-Key.
|
||||
@Injectable()
|
||||
export class UserOrApiKeyGuard extends AuthGuard(['jwt', 'api-key']) {}
|
||||
51
backend/src/auth/guards/org-roles.guard.ts
Normal file
51
backend/src/auth/guards/org-roles.guard.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { OrgRole } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
import { Principal, ROLE_RANK } from '../principal';
|
||||
|
||||
// Runs after UserOrApiKeyGuard on org-scoped routes (/orgs/:orgId/...).
|
||||
// Users: must be a member of the org, with at least the @Roles() role if present.
|
||||
// API keys: must belong to the org (scopes are checked by ScopesGuard).
|
||||
@Injectable()
|
||||
export class OrgRolesGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const principal: Principal | undefined = req.user;
|
||||
const orgId: string | undefined = req.params?.orgId;
|
||||
if (!principal || !orgId) {
|
||||
throw new ForbiddenException('Organization scope required');
|
||||
}
|
||||
|
||||
if (principal.type === 'apiKey') {
|
||||
if (principal.orgId !== orgId) {
|
||||
throw new ForbiddenException('API key does not belong to this organization');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const membership = await this.prisma.orgMembership.findUnique({
|
||||
where: { orgId_userId: { orgId, userId: principal.userId } },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('Not a member of this organization');
|
||||
}
|
||||
|
||||
const required = this.reflector.getAllAndOverride<OrgRole | undefined>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (required && ROLE_RANK[membership.role] < ROLE_RANK[required]) {
|
||||
throw new ForbiddenException(`Requires ${required} role`);
|
||||
}
|
||||
|
||||
req.membership = membership;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
26
backend/src/auth/guards/scopes.guard.ts
Normal file
26
backend/src/auth/guards/scopes.guard.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { SCOPES_KEY } from '../decorators/scopes.decorator';
|
||||
import { ApiKeyScope, Principal } from '../principal';
|
||||
|
||||
@Injectable()
|
||||
export class ScopesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const principal: Principal | undefined = context.switchToHttp().getRequest().user;
|
||||
if (!principal || principal.type !== 'apiKey') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const required = this.reflector.getAllAndOverride<ApiKeyScope[] | undefined>(SCOPES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
const missing = (required ?? []).filter((s) => !principal.scopes.includes(s));
|
||||
if (missing.length > 0) {
|
||||
throw new ForbiddenException(`API key missing scope(s): ${missing.join(', ')}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
32
backend/src/auth/principal.ts
Normal file
32
backend/src/auth/principal.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { OrgRole } from '@prisma/client';
|
||||
|
||||
export interface UserPrincipal {
|
||||
type: 'user';
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyPrincipal {
|
||||
type: 'apiKey';
|
||||
apiKeyId: string;
|
||||
orgId: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export type Principal = UserPrincipal | ApiKeyPrincipal;
|
||||
|
||||
// Rank order used by OrgRolesGuard: a required role means "at least this role".
|
||||
export const ROLE_RANK: Record<OrgRole, number> = {
|
||||
VIEWER: 0,
|
||||
MEMBER: 1,
|
||||
ORG_ADMIN: 2,
|
||||
};
|
||||
|
||||
export const API_KEY_SCOPES = [
|
||||
'jobs:read',
|
||||
'jobs:write',
|
||||
'points:read',
|
||||
'points:write',
|
||||
'devices:read',
|
||||
] as const;
|
||||
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
||||
40
backend/src/auth/strategies/api-key.strategy.ts
Normal file
40
backend/src/auth/strategies/api-key.strategy.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-custom';
|
||||
import type { Request } from 'express';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { ApiKeyPrincipal } from '../principal';
|
||||
|
||||
export const API_KEY_HEADER = 'x-api-key';
|
||||
export const API_KEY_PREFIX = 'ulh_';
|
||||
|
||||
export function hashApiKey(key: string): string {
|
||||
return createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyStrategy extends PassportStrategy(Strategy, 'api-key') {
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async validate(req: Request): Promise<ApiKeyPrincipal> {
|
||||
const key = req.headers[API_KEY_HEADER];
|
||||
if (typeof key !== 'string' || !key.startsWith(API_KEY_PREFIX)) {
|
||||
throw new UnauthorizedException('Missing or malformed API key');
|
||||
}
|
||||
|
||||
const record = await this.prisma.apiKey.findUnique({ where: { keyHash: hashApiKey(key) } });
|
||||
if (!record || record.revokedAt || (record.expiresAt && record.expiresAt < new Date())) {
|
||||
throw new UnauthorizedException('Invalid API key');
|
||||
}
|
||||
|
||||
// fire-and-forget usage stamp; never block the request on it
|
||||
this.prisma.apiKey
|
||||
.update({ where: { id: record.id }, data: { lastUsedAt: new Date() } })
|
||||
.catch(() => undefined);
|
||||
|
||||
return { type: 'apiKey', apiKeyId: record.id, orgId: record.orgId, scopes: record.scopes };
|
||||
}
|
||||
}
|
||||
29
backend/src/auth/strategies/jwt.strategy.ts
Normal file
29
backend/src/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { UserPrincipal } from '../principal';
|
||||
|
||||
export const AUTH_COOKIE = 'ulhub_token';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
(req) => req?.cookies?.[AUTH_COOKIE] ?? null,
|
||||
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
]),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || 'dev-only-insecure-secret',
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload): UserPrincipal {
|
||||
return { type: 'user', userId: payload.sub, email: payload.email };
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { connect as connectMqtt } from 'mqtt';
|
||||
import { Client as PgClient } from 'pg';
|
||||
|
||||
export interface DeviceRecord {
|
||||
id: number;
|
||||
topic: string;
|
||||
payload: string;
|
||||
receivedAt: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DeviceDataService implements OnModuleInit {
|
||||
private readonly logger = new Logger(DeviceDataService.name);
|
||||
private readonly listeners = new Set<(record: DeviceRecord) => void>();
|
||||
private pgClient: PgClient | null = null;
|
||||
private mqttClient: any = null;
|
||||
private initialized = false;
|
||||
|
||||
async onModuleInit() {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.connectDatabase();
|
||||
await this.connectMqtt();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async getRecords(limit = 20): Promise<DeviceRecord[]> {
|
||||
if (!this.pgClient) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
const result = await this.pgClient!.query(
|
||||
`SELECT id, topic, payload, received_at as "receivedAt"
|
||||
FROM device_events
|
||||
ORDER BY received_at DESC, id DESC
|
||||
LIMIT $1`,
|
||||
[limit],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
topic: row.topic,
|
||||
payload: row.payload,
|
||||
receivedAt: row.receivedAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
subscribe(listener: (record: DeviceRecord) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private async connectDatabase() {
|
||||
try {
|
||||
const client = new PgClient({
|
||||
host: process.env.DATABASE_HOST || 'postgres',
|
||||
port: Number(process.env.DATABASE_PORT || 5432),
|
||||
user: process.env.DATABASE_USER || 'ulhub',
|
||||
password: process.env.DATABASE_PASSWORD || 'development',
|
||||
database: process.env.DATABASE_NAME || 'ulhub',
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS device_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
topic TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
this.pgClient = client;
|
||||
this.logger.log('Connected to PostgreSQL and ensured device_events table exists');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to connect to PostgreSQL', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async connectMqtt() {
|
||||
try {
|
||||
const host = process.env.MQTT_HOST || 'mosquitto';
|
||||
const port = Number(process.env.MQTT_PORT || 1883);
|
||||
const username = process.env.MQTT_USERNAME || 'testuser';
|
||||
const password = process.env.MQTT_PASSWORD || 'testpass';
|
||||
this.mqttClient = connectMqtt(`mqtt://${host}:${port}`, {
|
||||
username,
|
||||
password,
|
||||
clientId: `ulhub-backend-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
|
||||
this.mqttClient.on('connect', () => {
|
||||
this.logger.log(`Connected to MQTT broker at ${host}:${port}`);
|
||||
this.mqttClient!.subscribe('devices/#', (err) => {
|
||||
if (err) {
|
||||
this.logger.error('Failed to subscribe to devices/#', err);
|
||||
} else {
|
||||
this.logger.log('Subscribed to devices/#');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.mqttClient.on('message', async (topic, payload) => {
|
||||
await this.persistRecord(topic, payload.toString());
|
||||
});
|
||||
|
||||
this.mqttClient.on('error', (error) => {
|
||||
this.logger.error('MQTT client error', error);
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to connect to MQTT broker', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistRecord(topic: string, payload: string): Promise<DeviceRecord> {
|
||||
if (!this.pgClient) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
const result = await this.pgClient!.query(
|
||||
`INSERT INTO device_events (topic, payload)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, topic, payload, received_at as "receivedAt"`,
|
||||
[topic, payload],
|
||||
);
|
||||
|
||||
const record: DeviceRecord = {
|
||||
id: result.rows[0].id,
|
||||
topic: result.rows[0].topic,
|
||||
payload: result.rows[0].payload,
|
||||
receivedAt: result.rows[0].receivedAt.toISOString(),
|
||||
};
|
||||
|
||||
this.notifyListeners(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
private notifyListeners(record: DeviceRecord) {
|
||||
for (const listener of this.listeners) {
|
||||
listener(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { DeviceDataService, DeviceRecord } from './device-data.service';
|
||||
|
||||
@WebSocketGateway({ path: '/api/device-events/ws' })
|
||||
@Controller('device-events')
|
||||
export class DeviceEventsController implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
constructor(private readonly deviceDataService: DeviceDataService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.deviceDataService.getRecords();
|
||||
}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
client.emit('message', JSON.stringify({ type: 'connected' }));
|
||||
this.deviceDataService.subscribe((record: DeviceRecord) => {
|
||||
client.emit('message', JSON.stringify(record));
|
||||
});
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
// no-op; subscriptions are handled by the service subscription set
|
||||
void client;
|
||||
}
|
||||
}
|
||||
43
backend/src/devices/devices.controller.ts
Normal file
43
backend/src/devices/devices.controller.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RequireScopes } from '../auth/decorators/scopes.decorator';
|
||||
import { UserOrApiKeyGuard } from '../auth/guards/auth.guard';
|
||||
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
|
||||
import { ScopesGuard } from '../auth/guards/scopes.guard';
|
||||
import { DevicesService } from './devices.service';
|
||||
import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto';
|
||||
|
||||
@Controller('orgs/:orgId/devices')
|
||||
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
||||
export class DevicesController {
|
||||
constructor(private readonly devicesService: DevicesService) {}
|
||||
|
||||
@Get()
|
||||
@RequireScopes('devices:read')
|
||||
list(@Param('orgId') orgId: string) {
|
||||
return this.devicesService.list(orgId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('ORG_ADMIN')
|
||||
@RequireScopes('devices:read') // API keys cannot create devices; role gate handles users
|
||||
create(@Param('orgId') orgId: string, @Body() dto: CreateDeviceDto) {
|
||||
return this.devicesService.create(orgId, dto);
|
||||
}
|
||||
|
||||
@Patch(':deviceId')
|
||||
@Roles('ORG_ADMIN')
|
||||
update(
|
||||
@Param('orgId') orgId: string,
|
||||
@Param('deviceId') deviceId: string,
|
||||
@Body() dto: UpdateDeviceDto,
|
||||
) {
|
||||
return this.devicesService.update(orgId, deviceId, dto);
|
||||
}
|
||||
|
||||
@Delete(':deviceId')
|
||||
@Roles('ORG_ADMIN')
|
||||
remove(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) {
|
||||
return this.devicesService.remove(orgId, deviceId);
|
||||
}
|
||||
}
|
||||
10
backend/src/devices/devices.module.ts
Normal file
10
backend/src/devices/devices.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DevicesController } from './devices.controller';
|
||||
import { DevicesService } from './devices.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DevicesController],
|
||||
providers: [DevicesService],
|
||||
exports: [DevicesService],
|
||||
})
|
||||
export class DevicesModule {}
|
||||
55
backend/src/devices/devices.service.ts
Normal file
55
backend/src/devices/devices.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DevicesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list(orgId: string) {
|
||||
return this.prisma.device.findMany({
|
||||
where: { orgId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(orgId: string, dto: CreateDeviceDto) {
|
||||
const existing = await this.prisma.device.findUnique({
|
||||
where: { mqttUsername: dto.mqttUsername },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('A device with this MQTT username already exists');
|
||||
}
|
||||
const device = await this.prisma.device.create({
|
||||
data: { orgId, name: dto.name, mqttUsername: dto.mqttUsername, serialNumber: dto.serialNumber },
|
||||
});
|
||||
return {
|
||||
...device,
|
||||
provisioning: {
|
||||
mqttUsername: device.mqttUsername,
|
||||
pointsTopic: `devices/${device.mqttUsername}/points`,
|
||||
jobsTopic: `devices/${device.mqttUsername}/jobs`,
|
||||
note: 'Broker credentials must be created separately (mosquitto_passwd) until dynamic broker auth lands.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async update(orgId: string, deviceId: string, dto: UpdateDeviceDto) {
|
||||
await this.get(orgId, deviceId);
|
||||
return this.prisma.device.update({ where: { id: deviceId }, data: dto });
|
||||
}
|
||||
|
||||
async remove(orgId: string, deviceId: string) {
|
||||
await this.get(orgId, deviceId);
|
||||
await this.prisma.device.delete({ where: { id: deviceId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async get(orgId: string, deviceId: string) {
|
||||
const device = await this.prisma.device.findFirst({ where: { id: deviceId, orgId } });
|
||||
if (!device) {
|
||||
throw new NotFoundException('Device not found');
|
||||
}
|
||||
return device;
|
||||
}
|
||||
}
|
||||
37
backend/src/devices/dto/devices.dto.ts
Normal file
37
backend/src/devices/dto/devices.dto.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateDeviceDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
name: string;
|
||||
|
||||
// Must match the broker username (or future TLS cert CN) the device connects with
|
||||
@IsString()
|
||||
@Matches(/^[a-zA-Z0-9._-]{3,64}$/, {
|
||||
message: 'mqttUsername must be 3-64 chars of letters, digits, dot, dash, underscore',
|
||||
})
|
||||
mqttUsername: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
serialNumber?: string;
|
||||
}
|
||||
|
||||
export class UpdateDeviceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
serialNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
100
backend/src/ingest/dto/mqtt-messages.dto.ts
Normal file
100
backend/src/ingest/dto/mqtt-messages.dto.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { GpsFixType, UtilityType } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class MqttPointDto {
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
lat: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
lng: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
alt?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(GpsFixType)
|
||||
fix?: GpsFixType;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
hAcc?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
depth?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(UtilityType)
|
||||
utility?: UtilityType;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
seq?: number;
|
||||
|
||||
@IsDateString()
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export class MqttPointsMessageDto {
|
||||
// Job is resolved by jobId when present, else by (device org, ticket)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
jobId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
ticket?: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(500)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => MqttPointDto)
|
||||
points: MqttPointDto[];
|
||||
}
|
||||
|
||||
export class MqttJobMessageDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
ticket: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
address?: string;
|
||||
}
|
||||
63
backend/src/ingest/ingest-router.service.ts
Normal file
63
backend/src/ingest/ingest-router.service.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobsIngestService } from './jobs-ingest.service';
|
||||
import { MqttClientService } from './mqtt-client.service';
|
||||
import { PointsIngestService } from './points-ingest.service';
|
||||
|
||||
@Injectable()
|
||||
export class IngestRouterService implements OnModuleInit {
|
||||
private readonly logger = new Logger(IngestRouterService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mqttClient: MqttClientService,
|
||||
private readonly pointsIngest: PointsIngestService,
|
||||
private readonly jobsIngest: JobsIngestService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.mqttClient.onMessage((topic, payload) => {
|
||||
this.route(topic, payload.toString()).catch((err) =>
|
||||
this.logger.error(`Failed to process ${topic}: ${err.message}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async route(topic: string, payload: string) {
|
||||
// Raw log captures every message on devices/#, matched or not (audit/debug trail)
|
||||
await this.prisma.deviceEvent.create({ data: { topic, payload } });
|
||||
|
||||
const [root, username, ...rest] = topic.split('/');
|
||||
const subtopic = rest.join('/');
|
||||
if (root !== 'devices' || !username || subtopic === 'jobs/ack') {
|
||||
return; // not device traffic, or our own ack echoed back
|
||||
}
|
||||
|
||||
const device = await this.prisma.device.findUnique({ where: { mqttUsername: username } });
|
||||
if (!device) {
|
||||
this.logger.warn(`Message from unregistered device username "${username}" (raw-logged only)`);
|
||||
return;
|
||||
}
|
||||
if (!device.isActive) {
|
||||
this.logger.warn(`Message from deactivated device "${username}" ignored`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.prisma.device
|
||||
.update({ where: { id: device.id }, data: { lastSeenAt: new Date() } })
|
||||
.catch(() => undefined);
|
||||
|
||||
switch (subtopic) {
|
||||
case 'points':
|
||||
await this.pointsIngest.handle(device, payload);
|
||||
break;
|
||||
case 'jobs':
|
||||
await this.jobsIngest.handle(device, payload);
|
||||
break;
|
||||
case 'status':
|
||||
break; // lastSeenAt already stamped above
|
||||
default:
|
||||
this.logger.debug(`Unhandled subtopic "${subtopic}" from ${username}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
backend/src/ingest/ingest.module.ts
Normal file
12
backend/src/ingest/ingest.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RealtimeModule } from '../realtime/realtime.module';
|
||||
import { IngestRouterService } from './ingest-router.service';
|
||||
import { JobsIngestService } from './jobs-ingest.service';
|
||||
import { MqttClientService } from './mqtt-client.service';
|
||||
import { PointsIngestService } from './points-ingest.service';
|
||||
|
||||
@Module({
|
||||
imports: [RealtimeModule],
|
||||
providers: [MqttClientService, IngestRouterService, PointsIngestService, JobsIngestService],
|
||||
})
|
||||
export class IngestModule {}
|
||||
60
backend/src/ingest/jobs-ingest.service.ts
Normal file
60
backend/src/ingest/jobs-ingest.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Device } from '@prisma/client';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { MqttJobMessageDto } from './dto/mqtt-messages.dto';
|
||||
import { MqttClientService } from './mqtt-client.service';
|
||||
|
||||
@Injectable()
|
||||
export class JobsIngestService {
|
||||
private readonly logger = new Logger(JobsIngestService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mqttClient: MqttClientService,
|
||||
) {}
|
||||
|
||||
async handle(device: Device, rawPayload: string) {
|
||||
let msg: MqttJobMessageDto;
|
||||
try {
|
||||
msg = plainToInstance(MqttJobMessageDto, JSON.parse(rawPayload) as object);
|
||||
} catch {
|
||||
this.ack(device, { ticket: null, status: 'error', reason: 'invalid JSON' });
|
||||
return;
|
||||
}
|
||||
|
||||
const errors = await validate(msg, { whitelist: true });
|
||||
if (errors.length > 0) {
|
||||
this.ack(device, { ticket: msg.ticket ?? null, status: 'error', reason: 'validation failed' });
|
||||
this.logger.warn(`Invalid job message from ${device.mqttUsername}: ${errors}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await this.prisma.job.findUnique({
|
||||
where: { orgId_ticketNumber: { orgId: device.orgId, ticketNumber: msg.ticket } },
|
||||
});
|
||||
if (existing) {
|
||||
this.ack(device, { ticket: msg.ticket, jobId: existing.id, status: 'exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
const job = await this.prisma.job.create({
|
||||
data: {
|
||||
orgId: device.orgId,
|
||||
ticketNumber: msg.ticket,
|
||||
title: msg.title || `Ticket ${msg.ticket}`,
|
||||
description: msg.description,
|
||||
address: msg.address,
|
||||
source: 'DEVICE',
|
||||
createdByDeviceId: device.id,
|
||||
},
|
||||
});
|
||||
this.ack(device, { ticket: msg.ticket, jobId: job.id, status: 'created' });
|
||||
this.logger.log(`Device ${device.mqttUsername} created job ${job.ticketNumber}`);
|
||||
}
|
||||
|
||||
private ack(device: Device, payload: object) {
|
||||
this.mqttClient.publish(`devices/${device.mqttUsername}/jobs/ack`, payload);
|
||||
}
|
||||
}
|
||||
59
backend/src/ingest/mqtt-client.service.ts
Normal file
59
backend/src/ingest/mqtt-client.service.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { connect, MqttClient } from 'mqtt';
|
||||
|
||||
export type MqttMessageListener = (topic: string, payload: Buffer) => void;
|
||||
|
||||
@Injectable()
|
||||
export class MqttClientService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(MqttClientService.name);
|
||||
private readonly listeners = new Set<MqttMessageListener>();
|
||||
private client: MqttClient | null = null;
|
||||
|
||||
onModuleInit() {
|
||||
const host = process.env.MQTT_HOST || 'mosquitto';
|
||||
const port = Number(process.env.MQTT_PORT || 1883);
|
||||
this.client = connect(`mqtt://${host}:${port}`, {
|
||||
username: process.env.MQTT_USERNAME || 'backend',
|
||||
password: process.env.MQTT_PASSWORD || 'backendpass',
|
||||
clientId: `ulhub-backend-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
|
||||
this.client.on('connect', () => {
|
||||
this.logger.log(`Connected to MQTT broker at ${host}:${port}`);
|
||||
this.client!.subscribe('devices/#', (err) => {
|
||||
if (err) {
|
||||
this.logger.error('Failed to subscribe to devices/#', err);
|
||||
} else {
|
||||
this.logger.log('Subscribed to devices/#');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.client.on('message', (topic, payload) => {
|
||||
for (const listener of this.listeners) {
|
||||
listener(topic, payload);
|
||||
}
|
||||
});
|
||||
|
||||
this.client.on('error', (error) => {
|
||||
this.logger.error(`MQTT client error: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.client?.endAsync();
|
||||
}
|
||||
|
||||
onMessage(listener: MqttMessageListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
publish(topic: string, payload: object) {
|
||||
this.client?.publish(topic, JSON.stringify(payload), { qos: 1 }, (err) => {
|
||||
if (err) {
|
||||
this.logger.error(`Failed to publish to ${topic}: ${err.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
95
backend/src/ingest/points-ingest.service.ts
Normal file
95
backend/src/ingest/points-ingest.service.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Device, Job } from '@prisma/client';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { toPointDto } from '../points/points.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { MqttPointsMessageDto } from './dto/mqtt-messages.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PointsIngestService {
|
||||
private readonly logger = new Logger(PointsIngestService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly realtime: RealtimeService,
|
||||
) {}
|
||||
|
||||
async handle(device: Device, rawPayload: string) {
|
||||
const msg = plainToInstance(MqttPointsMessageDto, JSON.parse(rawPayload) as object);
|
||||
const errors = await validate(msg, { whitelist: true });
|
||||
if (errors.length > 0) {
|
||||
this.logger.warn(`Invalid points message from ${device.mqttUsername}: ${errors}`);
|
||||
return;
|
||||
}
|
||||
if (!msg.jobId && !msg.ticket) {
|
||||
this.logger.warn(`Points message from ${device.mqttUsername} has neither jobId nor ticket`);
|
||||
return;
|
||||
}
|
||||
|
||||
const job = await this.resolveJob(device, msg);
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
|
||||
const points = await this.prisma.locatePoint.createManyAndReturn({
|
||||
data: msg.points.map((p) => ({
|
||||
jobId: job.id,
|
||||
deviceId: device.id,
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
altitude: p.alt,
|
||||
fixType: p.fix,
|
||||
hAccuracy: p.hAcc,
|
||||
depth: p.depth,
|
||||
utilityType: p.utility,
|
||||
sequence: p.seq,
|
||||
recordedAt: new Date(p.ts),
|
||||
raw: p as object,
|
||||
})),
|
||||
});
|
||||
|
||||
this.realtime.publish(`job:${job.id}`, {
|
||||
type: 'points',
|
||||
jobId: job.id,
|
||||
points: points.map(toPointDto),
|
||||
});
|
||||
this.logger.debug(`Stored ${points.length} points for job ${job.ticketNumber}`);
|
||||
}
|
||||
|
||||
private async resolveJob(device: Device, msg: MqttPointsMessageDto): Promise<Job | null> {
|
||||
if (msg.jobId) {
|
||||
const job = await this.prisma.job.findFirst({
|
||||
where: { id: msg.jobId, orgId: device.orgId },
|
||||
});
|
||||
if (!job) {
|
||||
this.logger.warn(`Unknown jobId ${msg.jobId} from device ${device.mqttUsername}`);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
const ticket = msg.ticket!;
|
||||
const existing = await this.prisma.job.findUnique({
|
||||
where: { orgId_ticketNumber: { orgId: device.orgId, ticketNumber: ticket } },
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Field-first workflow: points arrive before anyone opened the ticket in the web
|
||||
// app. Create a stub so the data is never lost; metadata gets filled in later.
|
||||
this.logger.log(`Auto-creating stub job for unknown ticket ${ticket} (device ${device.mqttUsername})`);
|
||||
return this.prisma.job.create({
|
||||
data: {
|
||||
orgId: device.orgId,
|
||||
ticketNumber: ticket,
|
||||
title: `Ticket ${ticket} (device-created)`,
|
||||
status: 'IN_PROGRESS',
|
||||
source: 'DEVICE',
|
||||
createdByDeviceId: device.id,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
104
backend/src/jobs/dto/jobs.dto.ts
Normal file
104
backend/src/jobs/dto/jobs.dto.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { JobStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateJobDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
ticketNumber: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
title: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(JobStatus)
|
||||
status?: JobStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assignedToId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueAt?: string;
|
||||
}
|
||||
|
||||
export class UpdateJobDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(JobStatus)
|
||||
status?: JobStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assignedToId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueAt?: string | null;
|
||||
}
|
||||
|
||||
export class QueryJobsDto {
|
||||
@IsOptional()
|
||||
@IsEnum(JobStatus)
|
||||
status?: JobStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assignedToId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
offset?: number;
|
||||
}
|
||||
53
backend/src/jobs/jobs.controller.ts
Normal file
53
backend/src/jobs/jobs.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { CurrentPrincipal } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RequireScopes } from '../auth/decorators/scopes.decorator';
|
||||
import { UserOrApiKeyGuard } from '../auth/guards/auth.guard';
|
||||
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
|
||||
import { ScopesGuard } from '../auth/guards/scopes.guard';
|
||||
import { Principal } from '../auth/principal';
|
||||
import { CreateJobDto, QueryJobsDto, UpdateJobDto } from './dto/jobs.dto';
|
||||
import { JobsService } from './jobs.service';
|
||||
|
||||
@Controller('orgs/:orgId/jobs')
|
||||
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
||||
export class JobsController {
|
||||
constructor(private readonly jobsService: JobsService) {}
|
||||
|
||||
@Get()
|
||||
@RequireScopes('jobs:read')
|
||||
list(@Param('orgId') orgId: string, @Query() query: QueryJobsDto) {
|
||||
return this.jobsService.list(orgId, query);
|
||||
}
|
||||
|
||||
@Get(':jobId')
|
||||
@RequireScopes('jobs:read')
|
||||
get(@Param('orgId') orgId: string, @Param('jobId') jobId: string) {
|
||||
return this.jobsService.get(orgId, jobId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('MEMBER')
|
||||
@RequireScopes('jobs:write')
|
||||
create(
|
||||
@Param('orgId') orgId: string,
|
||||
@Body() dto: CreateJobDto,
|
||||
@CurrentPrincipal() principal: Principal,
|
||||
) {
|
||||
return this.jobsService.create(orgId, dto, principal.type === 'user' ? principal.userId : null);
|
||||
}
|
||||
|
||||
@Patch(':jobId')
|
||||
@Roles('MEMBER')
|
||||
@RequireScopes('jobs:write')
|
||||
update(@Param('orgId') orgId: string, @Param('jobId') jobId: string, @Body() dto: UpdateJobDto) {
|
||||
return this.jobsService.update(orgId, jobId, dto);
|
||||
}
|
||||
|
||||
@Delete(':jobId')
|
||||
@Roles('ORG_ADMIN')
|
||||
@RequireScopes('jobs:write')
|
||||
remove(@Param('orgId') orgId: string, @Param('jobId') jobId: string) {
|
||||
return this.jobsService.remove(orgId, jobId);
|
||||
}
|
||||
}
|
||||
10
backend/src/jobs/jobs.module.ts
Normal file
10
backend/src/jobs/jobs.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JobsController } from './jobs.controller';
|
||||
import { JobsService } from './jobs.service';
|
||||
|
||||
@Module({
|
||||
controllers: [JobsController],
|
||||
providers: [JobsService],
|
||||
exports: [JobsService],
|
||||
})
|
||||
export class JobsModule {}
|
||||
117
backend/src/jobs/jobs.service.ts
Normal file
117
backend/src/jobs/jobs.service.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateJobDto, QueryJobsDto, UpdateJobDto } from './dto/jobs.dto';
|
||||
|
||||
const JOB_SELECT = {
|
||||
id: true,
|
||||
ticketNumber: true,
|
||||
title: true,
|
||||
description: true,
|
||||
address: true,
|
||||
status: true,
|
||||
source: true,
|
||||
dueAt: true,
|
||||
startedAt: true,
|
||||
completedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
assignedTo: { select: { id: true, name: true, email: true } },
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
createdByDeviceId: true,
|
||||
_count: { select: { points: true } },
|
||||
} satisfies Prisma.JobSelect;
|
||||
|
||||
@Injectable()
|
||||
export class JobsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(orgId: string, query: QueryJobsDto) {
|
||||
const where: Prisma.JobWhereInput = {
|
||||
orgId,
|
||||
...(query.status && { status: query.status }),
|
||||
...(query.assignedToId && { assignedToId: query.assignedToId }),
|
||||
...(query.q && {
|
||||
OR: [
|
||||
{ ticketNumber: { contains: query.q, mode: 'insensitive' } },
|
||||
{ title: { contains: query.q, mode: 'insensitive' } },
|
||||
{ address: { contains: query.q, mode: 'insensitive' } },
|
||||
],
|
||||
}),
|
||||
};
|
||||
const [jobs, total] = await this.prisma.$transaction([
|
||||
this.prisma.job.findMany({
|
||||
where,
|
||||
select: JOB_SELECT,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: query.limit ?? 50,
|
||||
skip: query.offset ?? 0,
|
||||
}),
|
||||
this.prisma.job.count({ where }),
|
||||
]);
|
||||
return { jobs, total };
|
||||
}
|
||||
|
||||
async get(orgId: string, jobId: string) {
|
||||
const job = await this.prisma.job.findFirst({
|
||||
where: { id: jobId, orgId },
|
||||
select: JOB_SELECT,
|
||||
});
|
||||
if (!job) {
|
||||
throw new NotFoundException('Job not found');
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
async create(orgId: string, dto: CreateJobDto, createdById: string | null) {
|
||||
const existing = await this.prisma.job.findUnique({
|
||||
where: { orgId_ticketNumber: { orgId, ticketNumber: dto.ticketNumber } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(`Ticket ${dto.ticketNumber} already exists in this organization`);
|
||||
}
|
||||
return this.prisma.job.create({
|
||||
data: {
|
||||
orgId,
|
||||
ticketNumber: dto.ticketNumber,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
address: dto.address,
|
||||
status: dto.status,
|
||||
assignedToId: dto.assignedToId,
|
||||
dueAt: dto.dueAt ? new Date(dto.dueAt) : undefined,
|
||||
createdById,
|
||||
},
|
||||
select: JOB_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
async update(orgId: string, jobId: string, dto: UpdateJobDto) {
|
||||
await this.get(orgId, jobId);
|
||||
const statusTimestamps: Prisma.JobUncheckedUpdateInput =
|
||||
dto.status === 'IN_PROGRESS'
|
||||
? { startedAt: new Date() }
|
||||
: dto.status === 'COMPLETED'
|
||||
? { completedAt: new Date() }
|
||||
: {};
|
||||
return this.prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
...(dto.title !== undefined && { title: dto.title }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.address !== undefined && { address: dto.address }),
|
||||
...(dto.status !== undefined && { status: dto.status }),
|
||||
...(dto.assignedToId !== undefined && { assignedToId: dto.assignedToId }),
|
||||
...(dto.dueAt !== undefined && { dueAt: dto.dueAt ? new Date(dto.dueAt) : null }),
|
||||
...statusTimestamps,
|
||||
},
|
||||
select: JOB_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(orgId: string, jobId: string) {
|
||||
await this.get(orgId, jobId);
|
||||
await this.prisma.job.delete({ where: { id: jobId } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { WsAdapter } from '@nestjs/platform-ws';
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api');
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
app.useWebSocketAdapter(new WsAdapter(app));
|
||||
await app.listen(3001, '0.0.0.0');
|
||||
}
|
||||
|
||||
|
||||
22
backend/src/orgs/dto/orgs.dto.ts
Normal file
22
backend/src/orgs/dto/orgs.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { OrgRole } from '@prisma/client';
|
||||
import { IsEmail, IsEnum, IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateOrgDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class AddMemberDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsEnum(OrgRole)
|
||||
role: OrgRole;
|
||||
}
|
||||
|
||||
export class UpdateMemberDto {
|
||||
@IsEnum(OrgRole)
|
||||
role: OrgRole;
|
||||
}
|
||||
58
backend/src/orgs/orgs.controller.ts
Normal file
58
backend/src/orgs/orgs.controller.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { CurrentPrincipal } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard, UserOrApiKeyGuard } from '../auth/guards/auth.guard';
|
||||
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
|
||||
import { ScopesGuard } from '../auth/guards/scopes.guard';
|
||||
import { UserPrincipal } from '../auth/principal';
|
||||
import { AddMemberDto, UpdateMemberDto, UpdateOrgDto } from './dto/orgs.dto';
|
||||
import { OrgsService } from './orgs.service';
|
||||
|
||||
@Controller('orgs')
|
||||
export class OrgsController {
|
||||
constructor(private readonly orgsService: OrgsService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
listMine(@CurrentPrincipal() principal: UserPrincipal) {
|
||||
return this.orgsService.listForUser(principal.userId);
|
||||
}
|
||||
|
||||
@Patch(':orgId')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('ORG_ADMIN')
|
||||
rename(@Param('orgId') orgId: string, @Body() dto: UpdateOrgDto) {
|
||||
return this.orgsService.rename(orgId, dto.name);
|
||||
}
|
||||
|
||||
@Get(':orgId/members')
|
||||
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
||||
listMembers(@Param('orgId') orgId: string) {
|
||||
return this.orgsService.listMembers(orgId);
|
||||
}
|
||||
|
||||
@Post(':orgId/members')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('ORG_ADMIN')
|
||||
addMember(@Param('orgId') orgId: string, @Body() dto: AddMemberDto) {
|
||||
return this.orgsService.addMember(orgId, dto.email, dto.role);
|
||||
}
|
||||
|
||||
@Patch(':orgId/members/:userId')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('ORG_ADMIN')
|
||||
updateMember(
|
||||
@Param('orgId') orgId: string,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: UpdateMemberDto,
|
||||
) {
|
||||
return this.orgsService.updateMember(orgId, userId, dto.role);
|
||||
}
|
||||
|
||||
@Delete(':orgId/members/:userId')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('ORG_ADMIN')
|
||||
removeMember(@Param('orgId') orgId: string, @Param('userId') userId: string) {
|
||||
return this.orgsService.removeMember(orgId, userId);
|
||||
}
|
||||
}
|
||||
9
backend/src/orgs/orgs.module.ts
Normal file
9
backend/src/orgs/orgs.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OrgsController } from './orgs.controller';
|
||||
import { OrgsService } from './orgs.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OrgsController],
|
||||
providers: [OrgsService],
|
||||
})
|
||||
export class OrgsModule {}
|
||||
92
backend/src/orgs/orgs.service.ts
Normal file
92
backend/src/orgs/orgs.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { OrgRole } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class OrgsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listForUser(userId: string) {
|
||||
return this.prisma.organization.findMany({
|
||||
where: { memberships: { some: { userId } } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
createdAt: true,
|
||||
memberships: { where: { userId }, select: { role: true } },
|
||||
_count: { select: { jobs: true, devices: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
rename(orgId: string, name: string) {
|
||||
return this.prisma.organization.update({ where: { id: orgId }, data: { name } });
|
||||
}
|
||||
|
||||
listMembers(orgId: string) {
|
||||
return this.prisma.orgMembership.findMany({
|
||||
where: { orgId },
|
||||
select: {
|
||||
role: true,
|
||||
createdAt: true,
|
||||
user: { select: { id: true, email: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async addMember(orgId: string, email: string, role: OrgRole) {
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('No account exists with that email (they must register first)');
|
||||
}
|
||||
const existing = await this.prisma.orgMembership.findUnique({
|
||||
where: { orgId_userId: { orgId, userId: user.id } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BadRequestException('Already a member of this organization');
|
||||
}
|
||||
return this.prisma.orgMembership.create({
|
||||
data: { orgId, userId: user.id, role },
|
||||
select: { role: true, user: { select: { id: true, email: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async updateMember(orgId: string, userId: string, role: OrgRole) {
|
||||
await this.assertNotLastAdmin(orgId, userId);
|
||||
return this.prisma.orgMembership.update({
|
||||
where: { orgId_userId: { orgId, userId } },
|
||||
data: { role },
|
||||
select: { role: true, user: { select: { id: true, email: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async removeMember(orgId: string, userId: string) {
|
||||
await this.assertNotLastAdmin(orgId, userId);
|
||||
await this.prisma.orgMembership.delete({
|
||||
where: { orgId_userId: { orgId, userId } },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Refuse to demote/remove the only remaining admin so the org can't be orphaned.
|
||||
private async assertNotLastAdmin(orgId: string, userId: string) {
|
||||
const target = await this.prisma.orgMembership.findUnique({
|
||||
where: { orgId_userId: { orgId, userId } },
|
||||
});
|
||||
if (!target) {
|
||||
throw new NotFoundException('Membership not found');
|
||||
}
|
||||
if (target.role !== 'ORG_ADMIN') {
|
||||
return;
|
||||
}
|
||||
const admins = await this.prisma.orgMembership.count({
|
||||
where: { orgId, role: 'ORG_ADMIN' },
|
||||
});
|
||||
if (admins <= 1) {
|
||||
throw new BadRequestException('Cannot demote or remove the last organization admin');
|
||||
}
|
||||
}
|
||||
}
|
||||
84
backend/src/points/dto/points.dto.ts
Normal file
84
backend/src/points/dto/points.dto.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { GpsFixType, UtilityType } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreatePointDto {
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
lat: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
lng: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
altitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(GpsFixType)
|
||||
fixType?: GpsFixType;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
hAccuracy?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
depth?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(UtilityType)
|
||||
utilityType?: UtilityType;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sequence?: number;
|
||||
|
||||
@IsDateString()
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export class QueryPointsDto {
|
||||
// recordedAt cursor: return points recorded strictly after this instant
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
after?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
from?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
to?: string;
|
||||
|
||||
// minLng,minLat,maxLng,maxLat
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?$/, {
|
||||
message: 'bbox must be "minLng,minLat,maxLng,maxLat"',
|
||||
})
|
||||
bbox?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10000)
|
||||
limit?: number;
|
||||
}
|
||||
41
backend/src/points/points.controller.ts
Normal file
41
backend/src/points/points.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RequireScopes } from '../auth/decorators/scopes.decorator';
|
||||
import { UserOrApiKeyGuard } from '../auth/guards/auth.guard';
|
||||
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
|
||||
import { ScopesGuard } from '../auth/guards/scopes.guard';
|
||||
import { CreatePointDto, QueryPointsDto } from './dto/points.dto';
|
||||
import { PointsService } from './points.service';
|
||||
|
||||
@Controller('orgs/:orgId')
|
||||
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
||||
export class PointsController {
|
||||
constructor(private readonly pointsService: PointsService) {}
|
||||
|
||||
@Get('jobs/:jobId/points')
|
||||
@RequireScopes('points:read')
|
||||
listForJob(
|
||||
@Param('orgId') orgId: string,
|
||||
@Param('jobId') jobId: string,
|
||||
@Query() query: QueryPointsDto,
|
||||
) {
|
||||
return this.pointsService.listForJob(orgId, jobId, query);
|
||||
}
|
||||
|
||||
@Post('jobs/:jobId/points')
|
||||
@Roles('MEMBER')
|
||||
@RequireScopes('points:write')
|
||||
create(
|
||||
@Param('orgId') orgId: string,
|
||||
@Param('jobId') jobId: string,
|
||||
@Body() dto: CreatePointDto,
|
||||
) {
|
||||
return this.pointsService.createForJob(orgId, jobId, dto);
|
||||
}
|
||||
|
||||
@Get('points')
|
||||
@RequireScopes('points:read')
|
||||
listForOrg(@Param('orgId') orgId: string, @Query() query: QueryPointsDto) {
|
||||
return this.pointsService.listForOrg(orgId, query);
|
||||
}
|
||||
}
|
||||
10
backend/src/points/points.module.ts
Normal file
10
backend/src/points/points.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PointsController } from './points.controller';
|
||||
import { PointsService } from './points.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PointsController],
|
||||
providers: [PointsService],
|
||||
exports: [PointsService],
|
||||
})
|
||||
export class PointsModule {}
|
||||
116
backend/src/points/points.service.ts
Normal file
116
backend/src/points/points.service.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { LocatePoint, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreatePointDto, QueryPointsDto } from './dto/points.dto';
|
||||
|
||||
// JSON-safe shape: BigInt id -> string, Decimal -> number
|
||||
export interface PointDto {
|
||||
id: string;
|
||||
jobId: string;
|
||||
deviceId: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
altitude: number | null;
|
||||
fixType: string;
|
||||
hAccuracy: number | null;
|
||||
depth: number | null;
|
||||
utilityType: string;
|
||||
sequence: number | null;
|
||||
recordedAt: Date;
|
||||
receivedAt: Date;
|
||||
}
|
||||
|
||||
export function toPointDto(p: LocatePoint): PointDto {
|
||||
return {
|
||||
id: p.id.toString(),
|
||||
jobId: p.jobId,
|
||||
deviceId: p.deviceId,
|
||||
lat: Number(p.lat),
|
||||
lng: Number(p.lng),
|
||||
altitude: p.altitude === null ? null : Number(p.altitude),
|
||||
fixType: p.fixType,
|
||||
hAccuracy: p.hAccuracy === null ? null : Number(p.hAccuracy),
|
||||
depth: p.depth === null ? null : Number(p.depth),
|
||||
utilityType: p.utilityType,
|
||||
sequence: p.sequence,
|
||||
recordedAt: p.recordedAt,
|
||||
receivedAt: p.receivedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PointsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listForJob(orgId: string, jobId: string, query: QueryPointsDto): Promise<{ points: PointDto[] }> {
|
||||
const job = await this.prisma.job.findFirst({ where: { id: jobId, orgId }, select: { id: true } });
|
||||
if (!job) {
|
||||
throw new NotFoundException('Job not found');
|
||||
}
|
||||
return { points: await this.query({ jobId }, query) };
|
||||
}
|
||||
|
||||
async listForOrg(orgId: string, query: QueryPointsDto): Promise<{ points: PointDto[] }> {
|
||||
return { points: await this.query({ job: { orgId } }, query) };
|
||||
}
|
||||
|
||||
async createForJob(
|
||||
orgId: string,
|
||||
jobId: string,
|
||||
dto: CreatePointDto,
|
||||
deviceId: string | null = null,
|
||||
): Promise<PointDto> {
|
||||
const job = await this.prisma.job.findFirst({ where: { id: jobId, orgId }, select: { id: true } });
|
||||
if (!job) {
|
||||
throw new NotFoundException('Job not found');
|
||||
}
|
||||
const point = await this.prisma.locatePoint.create({
|
||||
data: {
|
||||
jobId,
|
||||
deviceId,
|
||||
lat: dto.lat,
|
||||
lng: dto.lng,
|
||||
altitude: dto.altitude,
|
||||
fixType: dto.fixType,
|
||||
hAccuracy: dto.hAccuracy,
|
||||
depth: dto.depth,
|
||||
utilityType: dto.utilityType,
|
||||
sequence: dto.sequence,
|
||||
recordedAt: new Date(dto.recordedAt),
|
||||
},
|
||||
});
|
||||
return toPointDto(point);
|
||||
}
|
||||
|
||||
private async query(scope: Prisma.LocatePointWhereInput, query: QueryPointsDto): Promise<PointDto[]> {
|
||||
const where: Prisma.LocatePointWhereInput = {
|
||||
...scope,
|
||||
...(query.after && { recordedAt: { gt: new Date(query.after) } }),
|
||||
...((query.from || query.to) && {
|
||||
recordedAt: {
|
||||
...(query.after && { gt: new Date(query.after) }),
|
||||
...(query.from && { gte: new Date(query.from) }),
|
||||
...(query.to && { lte: new Date(query.to) }),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
if (query.bbox) {
|
||||
const [minLng, minLat, maxLng, maxLat] = query.bbox.split(',').map(Number);
|
||||
// Spatial filter runs on the generated geom column (GIST-indexed); the id list
|
||||
// is then fed back through Prisma so scope/time filters and typing stay uniform.
|
||||
const rows = await this.prisma.$queryRaw<{ id: bigint }[]>`
|
||||
SELECT id FROM locate_points
|
||||
WHERE geom && ST_MakeEnvelope(${minLng}, ${minLat}, ${maxLng}, ${maxLat}, 4326)
|
||||
`;
|
||||
where.id = { in: rows.map((r) => r.id) };
|
||||
}
|
||||
|
||||
const points = await this.prisma.locatePoint.findMany({
|
||||
where,
|
||||
orderBy: [{ recordedAt: 'asc' }, { id: 'asc' }],
|
||||
take: query.limit ?? 5000,
|
||||
});
|
||||
return points.map(toPointDto);
|
||||
}
|
||||
}
|
||||
9
backend/src/prisma/prisma.module.ts
Normal file
9
backend/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
13
backend/src/prisma/prisma.service.ts
Normal file
13
backend/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
108
backend/src/realtime/realtime.gateway.ts
Normal file
108
backend/src/realtime/realtime.gateway.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway } from '@nestjs/websockets';
|
||||
import type { IncomingMessage } from 'http';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { JwtPayload, AUTH_COOKIE } from '../auth/strategies/jwt.strategy';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RealtimeService } from './realtime.service';
|
||||
|
||||
interface ClientMessage {
|
||||
type: 'subscribe' | 'unsubscribe';
|
||||
channel: string;
|
||||
}
|
||||
|
||||
@WebSocketGateway({ path: '/api/ws' })
|
||||
export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
private readonly logger = new Logger(RealtimeGateway.name);
|
||||
private readonly users = new WeakMap<WebSocket, string>();
|
||||
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly realtime: RealtimeService,
|
||||
) {}
|
||||
|
||||
handleConnection(client: WebSocket, req: IncomingMessage) {
|
||||
const userId = this.authenticate(req);
|
||||
if (!userId) {
|
||||
client.close(4401, 'unauthorized');
|
||||
return;
|
||||
}
|
||||
this.users.set(client, userId);
|
||||
client.send(JSON.stringify({ type: 'connected' }));
|
||||
|
||||
client.on('message', (data) => {
|
||||
this.onClientMessage(client, data.toString()).catch((err) =>
|
||||
this.logger.error(`WS message error: ${err.message}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
handleDisconnect(client: WebSocket) {
|
||||
this.realtime.removeSocket(client);
|
||||
}
|
||||
|
||||
private authenticate(req: IncomingMessage): string | null {
|
||||
// Same-origin httpOnly cookie rides along on the WS upgrade request
|
||||
const cookies = req.headers.cookie ?? '';
|
||||
const token = cookies
|
||||
.split(';')
|
||||
.map((c) => c.trim())
|
||||
.find((c) => c.startsWith(`${AUTH_COOKIE}=`))
|
||||
?.slice(AUTH_COOKIE.length + 1);
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const payload = this.jwtService.verify<JwtPayload>(token);
|
||||
return payload.sub;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async onClientMessage(client: WebSocket, raw: string) {
|
||||
let msg: ClientMessage;
|
||||
try {
|
||||
msg = JSON.parse(raw);
|
||||
} catch {
|
||||
client.send(JSON.stringify({ type: 'error', reason: 'invalid JSON' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'unsubscribe' && typeof msg.channel === 'string') {
|
||||
this.realtime.unsubscribe(msg.channel, client);
|
||||
client.send(JSON.stringify({ type: 'unsubscribed', channel: msg.channel }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type !== 'subscribe' || typeof msg.channel !== 'string') {
|
||||
client.send(JSON.stringify({ type: 'error', reason: 'expected {type: subscribe|unsubscribe, channel}' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = msg.channel.startsWith('job:') ? msg.channel.slice(4) : null;
|
||||
if (!jobId || !(await this.canAccessJob(this.users.get(client), jobId))) {
|
||||
client.send(JSON.stringify({ type: 'error', reason: `cannot subscribe to ${msg.channel}` }));
|
||||
return;
|
||||
}
|
||||
|
||||
this.realtime.subscribe(msg.channel, client);
|
||||
client.send(JSON.stringify({ type: 'subscribed', channel: msg.channel }));
|
||||
}
|
||||
|
||||
private async canAccessJob(userId: string | undefined, jobId: string): Promise<boolean> {
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
const job = await this.prisma.job.findUnique({ where: { id: jobId }, select: { orgId: true } });
|
||||
if (!job) {
|
||||
return false;
|
||||
}
|
||||
const membership = await this.prisma.orgMembership.findUnique({
|
||||
where: { orgId_userId: { orgId: job.orgId, userId } },
|
||||
});
|
||||
return membership !== null;
|
||||
}
|
||||
}
|
||||
16
backend/src/realtime/realtime.module.ts
Normal file
16
backend/src/realtime/realtime.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { RealtimeGateway } from './realtime.gateway';
|
||||
import { RealtimeService } from './realtime.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'dev-only-insecure-secret',
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
],
|
||||
providers: [RealtimeGateway, RealtimeService],
|
||||
exports: [RealtimeService],
|
||||
})
|
||||
export class RealtimeModule {}
|
||||
46
backend/src/realtime/realtime.service.ts
Normal file
46
backend/src/realtime/realtime.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
@Injectable()
|
||||
export class RealtimeService {
|
||||
private readonly channels = new Map<string, Set<WebSocket>>();
|
||||
|
||||
subscribe(channel: string, socket: WebSocket) {
|
||||
let sockets = this.channels.get(channel);
|
||||
if (!sockets) {
|
||||
sockets = new Set();
|
||||
this.channels.set(channel, sockets);
|
||||
}
|
||||
sockets.add(socket);
|
||||
}
|
||||
|
||||
unsubscribe(channel: string, socket: WebSocket) {
|
||||
const sockets = this.channels.get(channel);
|
||||
sockets?.delete(socket);
|
||||
if (sockets?.size === 0) {
|
||||
this.channels.delete(channel);
|
||||
}
|
||||
}
|
||||
|
||||
removeSocket(socket: WebSocket) {
|
||||
for (const [channel, sockets] of this.channels) {
|
||||
sockets.delete(socket);
|
||||
if (sockets.size === 0) {
|
||||
this.channels.delete(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publish(channel: string, message: object) {
|
||||
const sockets = this.channels.get(channel);
|
||||
if (!sockets) {
|
||||
return;
|
||||
}
|
||||
const data = JSON.stringify(message);
|
||||
for (const socket of sockets) {
|
||||
if (socket.readyState === socket.OPEN) {
|
||||
socket.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user