From 99123370a40450ac59d22c787436edcfad628bf1 Mon Sep 17 00:00:00 2001 From: Casper <53900565+Dev-CasperTheGhost@users.noreply.github.com> Date: Sat, 9 Apr 2022 10:57:05 +0200 Subject: [PATCH] :tada: feat: ability to add officers/ems/fd to incidents (#593) --- .../migrations/20220409085241_/migration.sql | 24 +++ packages/api/prisma/schema.prisma | 144 ++++++++++-------- .../dispatch/911-calls/Calls911Controller.ts | 45 ++---- .../dispatch/DispatchController.ts | 5 +- .../leo/incidents/IncidentController.ts | 133 ++++++++++------ packages/api/src/lib/leo/activeOfficer.ts | 4 +- .../src/lib/leo/handleStartEndOfficerLog.ts | 4 +- .../api/src/lib/leo/officerOrDeputyToUnit.ts | 28 ++++ packages/api/src/lib/prisma.ts | 2 + .../src/migrations/officersToUnitsInvolved.ts | 31 ++++ packages/api/src/services/SocketService.ts | 2 +- packages/client/locales/en/leo.json | 1 + .../components/dispatch/ActiveIncidents.tsx | 37 +++-- .../components/dispatch/ActiveOfficers.tsx | 9 +- .../leo/incidents/ManageIncidentModal.tsx | 47 +++--- .../src/hooks/realtime/useActiveIncidents.ts | 6 +- packages/client/src/pages/dispatch/index.tsx | 5 +- .../client/src/pages/officer/incidents.tsx | 56 ++++--- packages/client/src/pages/officer/index.tsx | 5 +- packages/client/src/state/dispatchState.ts | 6 +- packages/schemas/src/leo.ts | 2 +- packages/types/src/index.ts | 17 +++ 22 files changed, 386 insertions(+), 227 deletions(-) create mode 100644 packages/api/prisma/migrations/20220409085241_/migration.sql create mode 100644 packages/api/src/lib/leo/officerOrDeputyToUnit.ts create mode 100644 packages/api/src/migrations/officersToUnitsInvolved.ts diff --git a/packages/api/prisma/migrations/20220409085241_/migration.sql b/packages/api/prisma/migrations/20220409085241_/migration.sql new file mode 100644 index 000000000..ab08bd39f --- /dev/null +++ b/packages/api/prisma/migrations/20220409085241_/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "IncidentInvolvedUnit" ( + "id" TEXT NOT NULL, + "officerId" TEXT, + "emsFdDeputyId" TEXT, + "combinedLeoId" TEXT, + "incidentId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "IncidentInvolvedUnit_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "IncidentInvolvedUnit" ADD CONSTRAINT "IncidentInvolvedUnit_officerId_fkey" FOREIGN KEY ("officerId") REFERENCES "Officer"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IncidentInvolvedUnit" ADD CONSTRAINT "IncidentInvolvedUnit_incidentId_fkey" FOREIGN KEY ("incidentId") REFERENCES "LeoIncident"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IncidentInvolvedUnit" ADD CONSTRAINT "IncidentInvolvedUnit_combinedLeoId_fkey" FOREIGN KEY ("combinedLeoId") REFERENCES "CombinedLeoUnit"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IncidentInvolvedUnit" ADD CONSTRAINT "IncidentInvolvedUnit_emsFdDeputyId_fkey" FOREIGN KEY ("emsFdDeputyId") REFERENCES "EmsFdDeputy"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index eb9a8481d..3e0e87780 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -633,42 +633,43 @@ model EmployeeValue { // leo model Officer { - id String @id @default(cuid()) - department DepartmentValue? @relation("officerDepartmentToDepartment", fields: [departmentId], references: [id]) + id String @id @default(cuid()) + department DepartmentValue? @relation("officerDepartmentToDepartment", fields: [departmentId], references: [id]) departmentId String? - callsign String @db.VarChar(255) - callsign2 String @db.VarChar(255) + callsign String @db.VarChar(255) + callsign2 String @db.VarChar(255) // `division` is deprecated. Use `divisions` instead. - division DivisionValue? @relation("officerDivisionToDivision", fields: [divisionId], references: [id]) + division DivisionValue? @relation("officerDivisionToDivision", fields: [divisionId], references: [id]) divisionId String? - divisions DivisionValue[] @relation("officerDivisionsToDivision") - rank Value? @relation("officerRankToValue", fields: [rankId], references: [id]) + divisions DivisionValue[] @relation("officerDivisionsToDivision") + rank Value? @relation("officerRankToValue", fields: [rankId], references: [id]) rankId String? - status StatusValue? @relation("officerStatusToValue", fields: [statusId], references: [id]) + status StatusValue? @relation("officerStatusToValue", fields: [statusId], references: [id]) statusId String? - suspended Boolean @default(false) + suspended Boolean @default(false) badgeNumber Int? - imageId String? @db.VarChar(255) - citizen Citizen @relation(fields: [citizenId], references: [id], onDelete: Cascade) + imageId String? @db.VarChar(255) + citizen Citizen @relation(fields: [citizenId], references: [id], onDelete: Cascade) citizenId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - whitelistStatus LeoWhitelistStatus? @relation(fields: [whitelistStatusId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + whitelistStatus LeoWhitelistStatus? @relation(fields: [whitelistStatusId], references: [id]) whitelistStatusId String? radioChannelId String? - bolos Bolo[] @relation("bolosToOfficer") + bolos Bolo[] @relation("bolosToOfficer") warrants Warrant[] logs OfficerLog[] Record Record[] assignedUnit AssignedUnit[] - activeIncident LeoIncident? @relation("activeIncident", fields: [activeIncidentId], references: [id]) + activeIncident LeoIncident? @relation("activeIncident", fields: [activeIncidentId], references: [id]) activeIncidentId String? LeoIncident LeoIncident[] - LeoIncidentInvolvedOfficers LeoIncident[] @relation("involvedOfficers") - combinedLeoUnit CombinedLeoUnit? @relation(fields: [combinedLeoUnitId], references: [id]) + LeoIncidentInvolvedOfficers LeoIncident[] @relation("involvedOfficers") + combinedLeoUnit CombinedLeoUnit? @relation(fields: [combinedLeoUnitId], references: [id]) combinedLeoUnitId String? + IncidentInvolvedUnit IncidentInvolvedUnit[] } model LeoWhitelistStatus { @@ -719,26 +720,27 @@ model ImpoundedVehicle { } model LeoIncident { - id String @id @default(uuid()) - caseNumber Int @default(autoincrement()) - description String? @db.Text + id String @id @default(uuid()) + caseNumber Int @default(autoincrement()) + description String? @db.Text descriptionData Json? postal String? // when null, it is the dispatcher - creator Officer? @relation(fields: [creatorId], references: [id]) + creator Officer? @relation(fields: [creatorId], references: [id]) creatorId String? - officersInvolved Officer[] @relation("involvedOfficers") - firearmsInvolved Boolean @default(false) - injuriesOrFatalities Boolean @default(false) - arrestsMade Boolean @default(false) - isActive Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - situationCode StatusValue? @relation(fields: [situationCodeId], references: [id]) + officersInvolved Officer[] @relation("involvedOfficers") + unitsInvolved IncidentInvolvedUnit[] @relation("unitsInvolved") + firearmsInvolved Boolean @default(false) + injuriesOrFatalities Boolean @default(false) + arrestsMade Boolean @default(false) + isActive Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + situationCode StatusValue? @relation(fields: [situationCodeId], references: [id]) situationCodeId String? events IncidentEvent[] calls Call911[] - Officer Officer[] @relation("activeIncident") + Officer Officer[] @relation("activeIncident") } model IncidentEvent { @@ -751,15 +753,16 @@ model IncidentEvent { } model CombinedLeoUnit { - id String @id @default(uuid()) - officers Officer[] - callsign String - callsign2 String? - incremental Int? - radioChannelId String? - status StatusValue? @relation("combinedUnitStatusToValue", fields: [statusId], references: [id]) - statusId String? - AssignedUnit AssignedUnit[] + id String @id @default(uuid()) + officers Officer[] + callsign String + callsign2 String? + incremental Int? + radioChannelId String? + status StatusValue? @relation("combinedUnitStatusToValue", fields: [statusId], references: [id]) + statusId String? + AssignedUnit AssignedUnit[] + IncidentInvolvedUnit IncidentInvolvedUnit[] } // dispatching @@ -817,6 +820,20 @@ model AssignedUnit { updatedAt DateTime @default(now()) @updatedAt } +model IncidentInvolvedUnit { + id String @id @default(uuid()) + officer Officer? @relation(fields: [officerId], references: [id]) + officerId String? + deputy EmsFdDeputy? @relation(fields: [emsFdDeputyId], references: [id]) + emsFdDeputyId String? + combinedUnit CombinedLeoUnit? @relation(fields: [combinedLeoId], references: [id]) + combinedLeoId String? + incident LeoIncident? @relation("unitsInvolved", fields: [incidentId], references: [id], onDelete: Cascade) + incidentId String? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt +} + model Call911Event { id String @id @default(uuid()) createdAt DateTime @default(now()) @@ -925,28 +942,29 @@ model NameChangeRequest { // ems-fd model EmsFdDeputy { - id String @id @default(cuid()) - department DepartmentValue @relation("emsFdDepartmentToDepartment", fields: [departmentId], references: [id]) - departmentId String - callsign String @db.VarChar(255) - callsign2 String @db.VarChar(255) - division DivisionValue @relation("emsFdDivisionToDivision", fields: [divisionId], references: [id]) - divisionId String - rank Value? @relation("emsFdRankToValue", fields: [rankId], references: [id]) - rankId String? - status StatusValue? @relation("emsFdStatusToValue", fields: [statusId], references: [id]) - statusId String? - suspended Boolean @default(false) - badgeNumber Int? - imageId String? @db.VarChar(255) - citizen Citizen @relation(fields: [citizenId], references: [id], onDelete: Cascade) - citizenId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId String - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - radioChannelId String? - AssignedUnit AssignedUnit[] + id String @id @default(cuid()) + department DepartmentValue @relation("emsFdDepartmentToDepartment", fields: [departmentId], references: [id]) + departmentId String + callsign String @db.VarChar(255) + callsign2 String @db.VarChar(255) + division DivisionValue @relation("emsFdDivisionToDivision", fields: [divisionId], references: [id]) + divisionId String + rank Value? @relation("emsFdRankToValue", fields: [rankId], references: [id]) + rankId String? + status StatusValue? @relation("emsFdStatusToValue", fields: [statusId], references: [id]) + statusId String? + suspended Boolean @default(false) + badgeNumber Int? + imageId String? @db.VarChar(255) + citizen Citizen @relation(fields: [citizenId], references: [id], onDelete: Cascade) + citizenId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + radioChannelId String? + AssignedUnit AssignedUnit[] + IncidentInvolvedUnit IncidentInvolvedUnit[] } // truck logs diff --git a/packages/api/src/controllers/dispatch/911-calls/Calls911Controller.ts b/packages/api/src/controllers/dispatch/911-calls/Calls911Controller.ts index c9d160cd3..2631e2fa5 100644 --- a/packages/api/src/controllers/dispatch/911-calls/Calls911Controller.ts +++ b/packages/api/src/controllers/dispatch/911-calls/Calls911Controller.ts @@ -7,7 +7,7 @@ import { prisma } from "lib/prisma"; import { Socket } from "services/SocketService"; import { UseBeforeEach } from "@tsed/platform-middlewares"; import { IsAuth } from "middlewares/IsAuth"; -import { unitProperties, leoProperties, combinedUnitProperties } from "lib/leo/activeOfficer"; +import { unitProperties, combinedUnitProperties, _leoProperties } from "lib/leo/activeOfficer"; import { validateSchema } from "lib/validateSchema"; import { ShouldDoType, @@ -19,22 +19,24 @@ import { User, StatusValue, MiscCadSettings, + Call911, } from "@prisma/client"; import { sendDiscordWebhook } from "lib/discord/webhooks"; -import type { cad, Call911 } from "@snailycad/types"; +import type { cad } from "@snailycad/types"; import type { APIEmbed } from "discord-api-types/v10"; import { manyToManyHelper } from "utils/manyToMany"; import { Permissions, UsePermissions } from "middlewares/UsePermissions"; +import { officerOrDeputyToUnit } from "lib/leo/officerOrDeputyToUnit"; -const assignedUnitsInclude = { +export const assignedUnitsInclude = { include: { - officer: { include: leoProperties }, + officer: { include: _leoProperties }, deputy: { include: unitProperties }, combinedUnit: { include: { status: { include: { value: true } }, officers: { - include: leoProperties, + include: _leoProperties, }, }, }, @@ -46,8 +48,8 @@ export const callInclude = { assignedUnits: assignedUnitsInclude, events: true, incidents: true, - departments: { include: leoProperties.department.include }, - divisions: { include: leoProperties.division.include }, + departments: { include: _leoProperties.department.include }, + divisions: { include: _leoProperties.division.include }, situationCode: { include: { value: true } }, }; @@ -70,7 +72,7 @@ export class Calls911Controller { where: includeEnded ? undefined : { ended: false }, }); - return calls.map(this.officerOrDeputyToUnit); + return calls.map(officerOrDeputyToUnit); } @Get("/:id") @@ -85,7 +87,7 @@ export class Calls911Controller { include: callInclude, }); - return this.officerOrDeputyToUnit(call); + return officerOrDeputyToUnit(call); } @Post("/") @@ -129,7 +131,7 @@ export class Calls911Controller { include: callInclude, }); - const returnData = this.officerOrDeputyToUnit(updated); + const returnData = officerOrDeputyToUnit(updated); try { const data = this.createWebhookData(returnData); @@ -238,9 +240,9 @@ export class Calls911Controller { include: callInclude, }); - this.socket.emitUpdate911Call(this.officerOrDeputyToUnit(updated)); + this.socket.emitUpdate911Call(officerOrDeputyToUnit(updated)); - return this.officerOrDeputyToUnit(updated); + return officerOrDeputyToUnit(updated); } @Delete("/purge") @@ -392,24 +394,9 @@ export class Calls911Controller { include: callInclude, }); - this.socket.emitUpdate911Call(this.officerOrDeputyToUnit(updated)); + this.socket.emitUpdate911Call(officerOrDeputyToUnit(updated)); - return this.officerOrDeputyToUnit(updated); - } - - protected officerOrDeputyToUnit(call: any & { assignedUnits: any[] }) { - return { - ...call, - assignedUnits: (call.assignedUnits ?? []) - ?.map((v: any) => ({ - ...v, - officer: undefined, - deputy: undefined, - - unit: v.officer ?? v.deputy ?? v.combinedUnit, - })) - .filter((v: any) => v.unit?.id), - }; + return officerOrDeputyToUnit(updated); } protected async linkOrUnlinkCallDepartmentsAndDivisions({ diff --git a/packages/api/src/controllers/dispatch/DispatchController.ts b/packages/api/src/controllers/dispatch/DispatchController.ts index 6f3e678fd..4df5d28ea 100644 --- a/packages/api/src/controllers/dispatch/DispatchController.ts +++ b/packages/api/src/controllers/dispatch/DispatchController.ts @@ -15,6 +15,7 @@ import { ExtendedNotFound } from "src/exceptions/ExtendedNotFound"; import { incidentInclude } from "controllers/leo/incidents/IncidentController"; import { UsePermissions, Permissions } from "middlewares/UsePermissions"; import { userProperties } from "lib/auth/user"; +import { officerOrDeputyToUnit } from "lib/leo/officerOrDeputyToUnit"; @Controller("/dispatch") @UseBeforeEach(IsAuth) @@ -66,7 +67,9 @@ export class DispatchController { include: incidentInclude, }); - return { deputies, officers, activeIncidents, activeDispatchers }; + const correctedIncidents = activeIncidents.map(officerOrDeputyToUnit); + + return { deputies, officers, activeIncidents: correctedIncidents, activeDispatchers }; } @Post("/aop") diff --git a/packages/api/src/controllers/leo/incidents/IncidentController.ts b/packages/api/src/controllers/leo/incidents/IncidentController.ts index 1b068aec2..c9e77ab8f 100644 --- a/packages/api/src/controllers/leo/incidents/IncidentController.ts +++ b/packages/api/src/controllers/leo/incidents/IncidentController.ts @@ -1,24 +1,26 @@ import { Controller, UseBefore, UseBeforeEach } from "@tsed/common"; import { Delete, Description, Get, Post, Put } from "@tsed/schema"; -import { NotFound, InternalServerError } from "@tsed/exceptions"; +import { NotFound, InternalServerError, BadRequest } from "@tsed/exceptions"; import { BodyParams, Context, PathParams } from "@tsed/platform-params"; import { prisma } from "lib/prisma"; import { IsAuth } from "middlewares/IsAuth"; import { leoProperties } from "lib/leo/activeOfficer"; import { LEO_INCIDENT_SCHEMA } from "@snailycad/schemas"; import { ActiveOfficer } from "middlewares/ActiveOfficer"; -import type { Officer } from "@prisma/client"; +import { Officer, ShouldDoType } from "@prisma/client"; import { validateSchema } from "lib/validateSchema"; import { Socket } from "services/SocketService"; import type { z } from "zod"; import { UsePermissions, Permissions } from "middlewares/UsePermissions"; import type { MiscCadSettings } from "@snailycad/types"; +import { assignedUnitsInclude, findUnit } from "controllers/dispatch/911-calls/Calls911Controller"; +import { officerOrDeputyToUnit } from "lib/leo/officerOrDeputyToUnit"; export const incidentInclude = { creator: { include: leoProperties }, - officersInvolved: { include: leoProperties }, events: true, situationCode: { include: { value: true } }, + unitsInvolved: assignedUnitsInclude, }; @Controller("/incidents") @@ -41,11 +43,7 @@ export class IncidentController { include: incidentInclude, }); - const officers = await prisma.officer.findMany({ - include: leoProperties, - }); - - return { incidents, officers }; + return { incidents: incidents.map(officerOrDeputyToUnit) }; } @Get("/:id") @@ -60,7 +58,7 @@ export class IncidentController { include: incidentInclude, }); - return incident; + return officerOrDeputyToUnit(incident); } @UseBefore(ActiveOfficer) @@ -92,7 +90,7 @@ export class IncidentController { }, }); - await this.connectOfficersInvolved(incident.id, data, maxAssignmentsToIncidents); + await this.connectUnitsInvolved(incident.id, data, maxAssignmentsToIncidents); const updated = await prisma.leoIncident.findUnique({ where: { id: incident.id }, @@ -103,12 +101,14 @@ export class IncidentController { throw new InternalServerError("Unable to find created incident"); } + const corrected = officerOrDeputyToUnit(updated); + if (updated.isActive) { - this.socket.emitCreateActiveIncident(updated); + this.socket.emitCreateActiveIncident(corrected); this.socket.emitUpdateOfficerStatus(); } - return updated; + return corrected; } @UseBefore(ActiveOfficer) @@ -127,26 +127,29 @@ export class IncidentController { const incident = await prisma.leoIncident.findUnique({ where: { id: incidentId }, - include: { officersInvolved: true }, + include: { unitsInvolved: true }, }); if (!incident) { throw new NotFound("notFound"); } - await Promise.all( - incident.officersInvolved.map(async (officer) => { - await prisma.officer.update({ - where: { id: officer.id }, - data: { activeIncidentId: null }, - }); + if (data.isActive) { + await prisma.$transaction( + incident.unitsInvolved.map(({ id }) => + prisma.incidentInvolvedUnit.delete({ where: { id } }), + ), + ); + } - await prisma.leoIncident.update({ - where: { id: incidentId }, - data: { - officersInvolved: { disconnect: { id: officer.id } }, - }, - }); + await Promise.all( + incident.unitsInvolved.map(async (unit) => { + if (unit.officerId) { + await prisma.officer.update({ + where: { id: unit.officerId }, + data: { activeIncidentId: null }, + }); + } }), ); @@ -164,7 +167,9 @@ export class IncidentController { }, }); - await this.connectOfficersInvolved(incident.id, data, maxAssignmentsToIncidents); + if (data.isActive) { + await this.connectUnitsInvolved(incident.id, data, maxAssignmentsToIncidents); + } const updated = await prisma.leoIncident.findUnique({ where: { id: incident.id }, @@ -175,10 +180,12 @@ export class IncidentController { throw new InternalServerError("Unable to find created incident"); } - this.socket.emitUpdateActiveIncident(updated); + const corrected = officerOrDeputyToUnit(updated); + + this.socket.emitUpdateActiveIncident(corrected); this.socket.emitUpdateOfficerStatus(); - return updated; + return corrected; } @Delete("/:id") @@ -203,36 +210,72 @@ export class IncidentController { return true; } - protected async connectOfficersInvolved( + protected async connectUnitsInvolved( incidentId: string, - data: Pick, "involvedOfficers" | "isActive">, + data: Pick, "unitsInvolved" | "isActive">, maxAssignmentsToIncidents: number, ) { await Promise.all( - (data.involvedOfficers ?? []).map(async (id: string) => { - const count = await prisma.leoIncident.count({ + (data.unitsInvolved ?? []).map(async (id: string) => { + const { unit, type } = await findUnit( + id, + { + NOT: { status: { shouldDo: ShouldDoType.SET_OFF_DUTY } }, + }, + true, + ); + + if (!unit) { + throw new BadRequest("unitOffDuty"); + } + + const types = { + combined: "combinedLeoId", + leo: "officerId", + "ems-fd": "emsFdDeputyId", + }; + + const assignmentCount = await prisma.incidentInvolvedUnit.count({ where: { - officersInvolved: { some: { id } }, - isActive: true, + [types[type]]: unit.id, + incident: { isActive: true }, }, }); - if (count >= maxAssignmentsToIncidents) { + if (assignmentCount >= maxAssignmentsToIncidents) { + // skip this officer return; } - return prisma.leoIncident.update({ + const existing = await prisma.incidentInvolvedUnit.count({ + where: { + [types[type]]: unit.id, + incidentId, + }, + }); + + if (existing >= 1) { + return; + } + + const involvedUnit = await prisma.incidentInvolvedUnit.create({ + data: { + incidentId, + [types[type]]: unit.id, + }, + }); + + if (type === "leo") { + await prisma.officer.update({ + where: { id: unit.id }, + data: { activeIncidentId: incidentId }, + }); + } + + await prisma.leoIncident.update({ where: { id: incidentId }, data: { - officersInvolved: { - connect: { id }, - update: data.isActive - ? { - where: { id }, - data: { activeIncidentId: incidentId }, - } - : undefined, - }, + unitsInvolved: { connect: { id: involvedUnit.id } }, }, }); }), diff --git a/packages/api/src/lib/leo/activeOfficer.ts b/packages/api/src/lib/leo/activeOfficer.ts index 9e80647da..3351f64ba 100644 --- a/packages/api/src/lib/leo/activeOfficer.ts +++ b/packages/api/src/lib/leo/activeOfficer.ts @@ -30,7 +30,9 @@ export const _leoProperties = { export const leoProperties = { ..._leoProperties, - activeIncident: { include: { officersInvolved: { include: _leoProperties }, events: true } }, + activeIncident: { + include: { unitsInvolved: { include: { officer: { include: _leoProperties } } }, events: true }, + }, }; export const combinedUnitProperties = { diff --git a/packages/api/src/lib/leo/handleStartEndOfficerLog.ts b/packages/api/src/lib/leo/handleStartEndOfficerLog.ts index 77f38b2d1..ac9aa58a9 100644 --- a/packages/api/src/lib/leo/handleStartEndOfficerLog.ts +++ b/packages/api/src/lib/leo/handleStartEndOfficerLog.ts @@ -99,6 +99,6 @@ async function handleUnassignFromActiveIncident(options: Pick v.id !== options.officer.id); - options.socket.emitUpdateActiveIncident({ ...incident, officersInvolved }); + const unitsInvolved = incident.unitsInvolved.filter((v) => v.id !== options.officer.id); + options.socket.emitUpdateActiveIncident({ ...incident, unitsInvolved }); } diff --git a/packages/api/src/lib/leo/officerOrDeputyToUnit.ts b/packages/api/src/lib/leo/officerOrDeputyToUnit.ts new file mode 100644 index 000000000..bdebdc118 --- /dev/null +++ b/packages/api/src/lib/leo/officerOrDeputyToUnit.ts @@ -0,0 +1,28 @@ +import type { Call911, LeoIncident } from "@prisma/client"; + +type _Call911 = Call911 & { assignedUnits?: any[] }; +type _Incident = LeoIncident & { unitsInvolved?: any[] }; + +export function officerOrDeputyToUnit(item: T | null): any { + if (!item) return item; + + const isCall = "assignedUnits" in item; + const isIncident = "unitsInvolved" in item; + + const arr = isCall ? item.assignedUnits : isIncident ? item.unitsInvolved : []; + const name = isCall ? "assignedUnits" : "unitsInvolved"; + + return { + ...item, + [name]: (arr ?? []) + .map((v: any) => ({ + ...v, + officer: undefined, + deputy: undefined, + combinedUnit: undefined, + + unit: v.officer ?? v.deputy ?? v.combinedUnit, + })) + .filter((v: any) => v.unit?.id), + }; +} diff --git a/packages/api/src/lib/prisma.ts b/packages/api/src/lib/prisma.ts index 0087eb21d..82c803537 100644 --- a/packages/api/src/lib/prisma.ts +++ b/packages/api/src/lib/prisma.ts @@ -3,6 +3,7 @@ import { divisionToDivisions } from "migrations/divisionToDivisions"; import { pairedSymbolToTemplate } from "migrations/pairedSymbolToTemplate"; import { xToXArrAll } from "migrations/xToXArr"; import { disabledFeatureToCadFeature } from "migrations/disabledFeatureToCadFeature"; +import { officersToUnitsInvolved } from "migrations/officersToUnitsInvolved"; export const prisma = new PrismaClient({ errorFormat: "colorless", @@ -15,6 +16,7 @@ async function handleMigrations() { pairedSymbolToTemplate(), xToXArrAll(), disabledFeatureToCadFeature(), + officersToUnitsInvolved(), ]); } diff --git a/packages/api/src/migrations/officersToUnitsInvolved.ts b/packages/api/src/migrations/officersToUnitsInvolved.ts new file mode 100644 index 000000000..83590501b --- /dev/null +++ b/packages/api/src/migrations/officersToUnitsInvolved.ts @@ -0,0 +1,31 @@ +import { prisma } from "lib/prisma"; + +export async function officersToUnitsInvolved() { + const leoIncidents = await prisma.leoIncident.findMany({ + include: { officersInvolved: true }, + }); + + for (const incident of leoIncidents) { + if (!incident.officersInvolved.length) { + continue; + } + + for (const officer of incident.officersInvolved) { + const { id } = await prisma.incidentInvolvedUnit.create({ + data: { officerId: officer.id }, + }); + + await prisma.leoIncident.update({ + where: { id: incident.id }, + data: { unitsInvolved: { connect: { id } } }, + }); + } + + await prisma.leoIncident.update({ + where: { id: incident.id }, + data: { + officersInvolved: { set: [] }, + }, + }); + } +} diff --git a/packages/api/src/services/SocketService.ts b/packages/api/src/services/SocketService.ts index d022c9abb..fc914d4cd 100644 --- a/packages/api/src/services/SocketService.ts +++ b/packages/api/src/services/SocketService.ts @@ -4,7 +4,7 @@ import { SocketEvents } from "@snailycad/config"; import type { LeoIncident, Call911, TowCall, Bolo, Call911Event, TaxiCall } from "@prisma/client"; import type { IncidentEvent } from "@snailycad/types"; -type FullIncident = LeoIncident & { officersInvolved: any[]; events?: IncidentEvent[] }; +type FullIncident = LeoIncident & { unitsInvolved: any[]; events?: IncidentEvent[] }; @SocketService("/") export class Socket { diff --git a/packages/client/locales/en/leo.json b/packages/client/locales/en/leo.json index a6c5709cd..89ed18e2d 100644 --- a/packages/client/locales/en/leo.json +++ b/packages/client/locales/en/leo.json @@ -168,6 +168,7 @@ "otherFields": "Other Fields", "manageCustomFields": "Manage other fields", "dmv": "Department of Motor Vehicles", + "unitsInvolved": "Units Involved", "noVehiclesPendingApprovalInDmv": "There are no vehicles pending approval in the dmv.", "alert_deleteDLExam": "Are you sure you want to delete this driver's license exam? This action cannot be undone.", "vehicleImpoundLocation": "The provided vehicle ({plate}) will be impounded at {impoundLocation}. You will be able to view this vehicle on the impound lot page.", diff --git a/packages/client/src/components/dispatch/ActiveIncidents.tsx b/packages/client/src/components/dispatch/ActiveIncidents.tsx index 51ead0fa1..8bcf291c9 100644 --- a/packages/client/src/components/dispatch/ActiveIncidents.tsx +++ b/packages/client/src/components/dispatch/ActiveIncidents.tsx @@ -4,7 +4,6 @@ import { Button } from "components/Button"; import compareDesc from "date-fns/compareDesc"; import { useActiveDispatchers } from "hooks/realtime/useActiveDispatchers"; import { Table } from "components/shared/Table"; -import type { FullIncident } from "src/pages/officer/incidents"; import { makeUnitName, yesOrNoText } from "lib/utils"; import { useGenerateCallsign } from "hooks/useGenerateCallsign"; import { FullDate } from "components/shared/FullDate"; @@ -15,14 +14,14 @@ import { ManageIncidentModal } from "components/leo/incidents/ManageIncidentModa import { useActiveIncidents } from "hooks/realtime/useActiveIncidents"; import { AlertModal } from "components/modal/AlertModal"; import useFetch from "lib/useFetch"; +import { isUnitCombined } from "@snailycad/utils"; +import type { LeoIncident } from "@snailycad/types"; export function ActiveIncidents() { /** * undefined = hide modal. It will otherwise open 2 modals, 1 with the incorrect data. */ - const [tempIncident, setTempIncident] = React.useState( - undefined, - ); + const [tempIncident, setTempIncident] = React.useState(undefined); const t = useTranslations("Leo"); const common = useTranslations("Common"); @@ -32,6 +31,12 @@ export function ActiveIncidents() { const { activeIncidents, setActiveIncidents } = useActiveIncidents(); const { state, execute } = useFetch(); + function makeAssignedUnit(unit: any) { + return isUnitCombined(unit.unit) + ? generateCallsign(unit.unit, "pairedUnitTemplate") + : `${generateCallsign(unit.unit)} ${makeUnitName(unit.unit)}`; + } + async function handleDismissIncident() { if (!tempIncident) return; @@ -39,7 +44,7 @@ export function ActiveIncidents() { method: "PUT", data: { ...tempIncident, - involvedOfficers: tempIncident.officersInvolved.map((v) => v.id), + unitsInvolved: tempIncident.unitsInvolved.map((v) => v.id), isActive: false, }, }); @@ -51,17 +56,17 @@ export function ActiveIncidents() { } } - function handleViewDescription(incident: FullIncident) { + function handleViewDescription(incident: LeoIncident) { setTempIncident(incident); openModal(ModalIds.Description); } - function onEditClick(incident: FullIncident) { + function onEditClick(incident: LeoIncident) { openModal(ModalIds.ManageIncident); setTempIncident(incident); } - function onEndClick(incident: FullIncident) { + function onEndClick(incident: LeoIncident) { openModal(ModalIds.AlertDeleteIncident); setTempIncident(incident); } @@ -71,14 +76,6 @@ export function ActiveIncidents() { setTempIncident(null); } - function involvedOfficers(incident: FullIncident) { - return (incident.officersInvolved?.length ?? 0) <= 0 ? ( - {common("none")} - ) : ( - incident.officersInvolved.map((o) => `${generateCallsign(o)} ${makeUnitName(o)}`).join(", ") - ); - } - return (
@@ -107,7 +104,9 @@ export function ActiveIncidents() { .map((incident) => { return { caseNumber: `#${incident.caseNumber}`, - involvedOfficers: involvedOfficers(incident), + unitsInvolved: + incident.unitsInvolved.map(makeAssignedUnit).join(", ") || common("none"), + createdAt: {incident.createdAt}, firearmsInvolved: common(yesOrNoText(incident.firearmsInvolved)), injuriesOrFatalities: common(yesOrNoText(incident.injuriesOrFatalities)), arrestsMade: common(yesOrNoText(incident.arrestsMade)), @@ -123,7 +122,7 @@ export function ActiveIncidents() { )} ), - createdAt: {incident.createdAt}, + actions: ( <>