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