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:
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user