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