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>
118 lines
3.7 KiB
TypeScript
118 lines
3.7 KiB
TypeScript
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 };
|
|
}
|
|
}
|