diff --git a/.prettierrc b/.prettierrc index 09d5c700..cf5eb531 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,6 @@ { "tabWidth": 2, "useTabs": true, - "printWidth": 80, + "printWidth": 100, "singleQuote": false } diff --git a/apps/dispatch-server/.d.ts b/apps/dispatch-server/.d.ts index 8b3f9cfb..93743eea 100644 --- a/apps/dispatch-server/.d.ts +++ b/apps/dispatch-server/.d.ts @@ -6,3 +6,14 @@ declare module "next-auth/jwt" { email: string; } } +declare module "cookie-parser"; + +import type { User } from "@repo/db"; + +declare global { + namespace Express { + interface Request { + user?: User | null; + } + } +} diff --git a/apps/dispatch-server/index.ts b/apps/dispatch-server/index.ts index bb8751f5..bac7492e 100644 --- a/apps/dispatch-server/index.ts +++ b/apps/dispatch-server/index.ts @@ -11,6 +11,18 @@ import cors from "cors"; import { handleSendMessage } from "socket-events/send-message"; import { handleConnectPilot } from "socket-events/connect-pilot"; import { handleConnectDesktop } from "socket-events/connect-desktop"; +import cookieParser from "cookie-parser"; +import { authMiddleware } from "modules/expressMiddleware"; +import { prisma, User } from "@repo/db"; +import { Request, Response, NextFunction } from "express"; + +declare global { + namespace Express { + interface Request { + user?: User | null; + } + } +} const app = express(); const server = createServer(app); @@ -31,6 +43,8 @@ io.on("connection", (socket) => { app.use(cors()); app.use(express.json()); +app.use(cookieParser()); +app.use(authMiddleware as any); app.use(router); server.listen(process.env.PORT, () => { diff --git a/apps/dispatch-server/modules/expressMiddleware.ts b/apps/dispatch-server/modules/expressMiddleware.ts new file mode 100644 index 00000000..b873f131 --- /dev/null +++ b/apps/dispatch-server/modules/expressMiddleware.ts @@ -0,0 +1,24 @@ +import { prisma, User } from "@repo/db"; +import { NextFunction } from "express"; + +interface AttachUserRequest extends Request { + user?: User | null; +} + +interface AttachUserMiddleware { + (req: AttachUserRequest, res: Response, next: NextFunction): Promise; +} + +export const authMiddleware: AttachUserMiddleware = async (req, res, next) => { + const authHeader = (req.headers as any).authorization; + if (authHeader && authHeader.startsWith("User ")) { + const userId = authHeader.split(" ")[1]; + const user = await prisma.user.findFirst({ + where: { + id: userId, + }, + }); + req.user = user; + } + next(); +}; diff --git a/apps/dispatch-server/modules/mission.ts b/apps/dispatch-server/modules/mission.ts new file mode 100644 index 00000000..d74ba882 --- /dev/null +++ b/apps/dispatch-server/modules/mission.ts @@ -0,0 +1,101 @@ +import { ConnectedAircraft, Mission, prisma } from "@repo/db"; +import { io } from "index"; +import { sendNtfyMission } from "modules/ntfy"; + +export const sendAlert = async ( + id: number, + { + stationId, + }: { + stationId?: number; + }, +): Promise<{ + connectedAircrafts: ConnectedAircraft[]; + mission: Mission; +}> => { + const mission = await prisma.mission.findUnique({ + where: { id: id }, + }); + const Stations = await prisma.station.findMany({ + where: { + id: { + in: mission?.missionStationIds, + }, + }, + }); + + if (!mission) { + throw new Error("Mission not found"); + } + + // connectedAircrafts the alert is sent to + const connectedAircrafts = await prisma.connectedAircraft.findMany({ + where: { + stationId: stationId + ? stationId + : { + in: mission.missionStationIds, + }, + logoutTime: null, + }, + include: { + Station: true, + }, + }); + + for (const aircraft of connectedAircrafts) { + console.log(`Sending mission to: station:${aircraft.stationId}`); + io.to(`station:${aircraft.stationId}`).emit("mission-alert", { + ...mission, + Stations, + }); + const user = await prisma.user.findUnique({ + where: { id: aircraft.userId }, + }); + if (!user) continue; + if (user.settingsNtfyRoom) { + await sendNtfyMission( + mission, + Stations, + aircraft.Station, + user.settingsNtfyRoom, + ); + } + const existingMissionOnStationUser = + await prisma.missionOnStationUsers.findFirst({ + where: { + missionId: mission.id, + userId: aircraft.userId, + stationId: aircraft.stationId, + }, + }); + if (!existingMissionOnStationUser) + await prisma.missionOnStationUsers.create({ + data: { + missionId: mission.id, + userId: aircraft.userId, + stationId: aircraft.stationId, + }, + }); + } + + // for statistics only + await prisma.missionsOnStations + .createMany({ + data: mission.missionStationIds.map((stationId) => ({ + missionId: mission.id, + stationId, + })), + }) + .catch(() => { + // Ignore if the entry already exists + }); + + await prisma.mission.update({ + where: { id: Number(id) }, + data: { + state: "running", + }, + }); + return { connectedAircrafts, mission }; +}; diff --git a/apps/dispatch-server/package.json b/apps/dispatch-server/package.json index 159f742c..b5c753aa 100644 --- a/apps/dispatch-server/package.json +++ b/apps/dispatch-server/package.json @@ -10,6 +10,7 @@ "devDependencies": { "@repo/db": "*", "@repo/typescript-config": "*", + "@types/cookie-parser": "^1.4.8", "@types/express": "^5.0.0", "@types/node": "^22.13.5", "@types/nodemailer": "^6.4.17", @@ -21,6 +22,7 @@ "@redis/json": "^1.0.7", "@socket.io/redis-adapter": "^8.3.0", "axios": "^1.7.9", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "cron": "^4.1.0", "dotenv": "^16.4.7", diff --git a/apps/dispatch-server/routes/mission.ts b/apps/dispatch-server/routes/mission.ts index cede9fa8..94208122 100644 --- a/apps/dispatch-server/routes/mission.ts +++ b/apps/dispatch-server/routes/mission.ts @@ -1,7 +1,16 @@ -import { HpgValidationState, Prisma, prisma } from "@repo/db"; +import { + HpgValidationState, + MissionSdsLog, + MissionStationLog, + NotificationPayload, + Prisma, + prisma, + User, +} from "@repo/db"; import { Router } from "express"; import { io } from "../index"; import { sendNtfyMission } from "modules/ntfy"; +import { sendAlert } from "modules/mission"; const router = Router(); @@ -107,90 +116,8 @@ router.post("/:id/send-alert", async (req, res) => { const { id } = req.params; const { stationId } = req.body as { stationId?: number }; try { - const mission = await prisma.mission.findUnique({ - where: { id: Number(id) }, - }); - const Stations = await prisma.station.findMany({ - where: { - id: { - in: mission?.missionStationIds, - }, - }, - }); - - if (!mission) { - res.status(404).json({ error: "Mission not found" }); - return; - } - - // connectedAircrafts the alert is sent to - const connectedAircrafts = await prisma.connectedAircraft.findMany({ - where: { - stationId: stationId - ? stationId - : { - in: mission.missionStationIds, - }, - logoutTime: null, - }, - include: { - Station: true, - }, - }); - - for (const aircraft of connectedAircrafts) { - console.log(`Sending mission to: station:${aircraft.stationId}`); - io.to(`station:${aircraft.stationId}`).emit("mission-alert", { - ...mission, - Stations, - }); - const user = await prisma.user.findUnique({ - where: { id: aircraft.userId }, - }); - if (!user) continue; - if (user.settingsNtfyRoom) { - await sendNtfyMission( - mission, - Stations, - aircraft.Station, - user.settingsNtfyRoom, - ); - } - const existingMissionOnStationUser = - await prisma.missionOnStationUsers.findFirst({ - where: { - missionId: mission.id, - userId: aircraft.userId, - stationId: aircraft.stationId, - }, - }); - if (!existingMissionOnStationUser) - await prisma.missionOnStationUsers.create({ - data: { - missionId: mission.id, - userId: aircraft.userId, - stationId: aircraft.stationId, - }, - }); - } - - // for statistics only - await prisma.missionsOnStations - .createMany({ - data: mission.missionStationIds.map((stationId) => ({ - missionId: mission.id, - stationId, - })), - }) - .catch(() => { - // Ignore if the entry already exists - }); - - await prisma.mission.update({ - where: { id: Number(id) }, - data: { - state: "running", - }, + const { connectedAircrafts, mission } = await sendAlert(Number(id), { + stationId, }); res.status(200).json({ @@ -203,9 +130,36 @@ router.post("/:id/send-alert", async (req, res) => { } }); +router.post("/:id/send-sds", async (req, res) => { + const sdsMessage = req.body as MissionSdsLog; + const newMission = await prisma.mission.update({ + where: { + id: Number(req.params.id), + }, + data: { + missionLog: { + push: sdsMessage as any, + }, + }, + }); + + io.to(`station:${sdsMessage.data.stationId}`).emit("sds-message", sdsMessage); + res.json({ + message: "SDS message sent", + mission: newMission, + }); + io.to("dispatchers").emit("update-mission", newMission); +}); + router.post("/:id/validate-hpg", async (req, res) => { try { + console.log(req.user); const { id } = req.params; + const config = req.body as + | { + alertWhenValid?: boolean; + } + | undefined; const mission = await prisma.mission.findFirstOrThrow({ where: { id: Number(id), @@ -225,16 +179,11 @@ router.post("/:id/validate-hpg", async (req, res) => { }, }); - /* if (activeAircraftinMission.length === 0) { - res.status(400).json({ error: "No active aircraft in mission" }); - return; - } */ - res.json({ message: "HPG validation started", }); - io.to(`desktop:${activeAircraftinMission}`).emit( + /* io.to(`desktop:${activeAircraftinMission}`).emit( "hpg-validation", { hpgMissionType: mission?.hpgMissionString, @@ -266,8 +215,41 @@ router.post("/:id/validate-hpg", async (req, res) => { }, }); io.to("dispatchers").emit("update-mission", newMission); + + const noActionRequired = result.state === "VALID"; + if (noActionRequired) { + io.to(`user:${req.user?.id}`).emit("notification", { + type: "hpg-validation", + status: "success", + message: `HPG Validierung erfolgreich`, + } as NotificationPayload); + if (config?.alertWhenValid) { + sendAlert(Number(id), {}); + } + } else { + io.to(`user:${req.user?.id}`).emit("notification", { + type: "hpg-validation", + status: "failed", + message: `HPG Validation fehlgeschlagen`, + } as NotificationPayload); + } }, - ); + ); */ + setTimeout(() => { + io.to(`user:${req.user?.id}`).emit("notification", { + type: "hpg-validation", + status: "success", + message: "HPG_BUSY", + data: { + mission, + }, + } as NotificationPayload); + io.to(`user:${req.user?.id}`).emit("notification", { + type: "hpg-validation", + status: "failed", + message: `HPG Validation fehlgeschlagen`, + } as NotificationPayload); + }, 5000); } catch (error) { console.error(error); res.json({ error: (error as Error).message || "Failed to validate HPG" }); diff --git a/apps/dispatch-server/tsconfig.json b/apps/dispatch-server/tsconfig.json index ad90dfe0..f3c1bbd3 100644 --- a/apps/dispatch-server/tsconfig.json +++ b/apps/dispatch-server/tsconfig.json @@ -6,6 +6,6 @@ "baseUrl": ".", "jsx": "react" }, - "include": ["**/*.ts", "./index.ts"], + "include": ["**/*.ts", "./index.ts", "**/*.d.ts"], "exclude": ["node_modules", "dist"] } diff --git a/apps/dispatch/app/_components/QueryProvider.tsx b/apps/dispatch/app/_components/QueryProvider.tsx index 0ffd0ca6..d0ec5293 100644 --- a/apps/dispatch/app/_components/QueryProvider.tsx +++ b/apps/dispatch/app/_components/QueryProvider.tsx @@ -5,9 +5,12 @@ import { toast } from "react-hot-toast"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactNode, useEffect, useState } from "react"; import { dispatchSocket } from "dispatch/socket"; -import { Mission } from "@repo/db"; +import { Mission, NotificationPayload } from "@repo/db"; +import { HPGnotificationToast } from "_components/customToasts/HPGnotification"; +import { useMapStore } from "_store/mapStore"; export function QueryProvider({ children }: { children: ReactNode }) { + const mapStore = useMapStore((s) => s); const [queryClient] = useState( () => new QueryClient({ @@ -48,15 +51,42 @@ export function QueryProvider({ children }: { children: ReactNode }) { }); }; + const handleNotification = (notification: NotificationPayload) => { + console.log("notification", notification); + switch (notification.type) { + case "hpg-validation": + toast.custom( + (t) => , + { + duration: 9999, + }, + ); + + break; + default: + toast(notification.message); + break; + } + }; + dispatchSocket.on("update-mission", invalidateMission); dispatchSocket.on("delete-mission", invalidateMission); dispatchSocket.on("new-mission", invalidateMission); dispatchSocket.on("dispatchers-update", invalidateConnectedUsers); dispatchSocket.on("pilots-update", invalidateConnectedUsers); dispatchSocket.on("update-connectedAircraft", invalidateConenctedAircrafts); - }, [queryClient]); + dispatchSocket.on("notification", handleNotification); - return ( - {children} - ); + return () => { + dispatchSocket.off("update-mission", invalidateMission); + dispatchSocket.off("delete-mission", invalidateMission); + dispatchSocket.off("new-mission", invalidateMission); + dispatchSocket.off("dispatchers-update", invalidateConnectedUsers); + dispatchSocket.off("pilots-update", invalidateConnectedUsers); + dispatchSocket.off("update-connectedAircraft", invalidateConenctedAircrafts); + dispatchSocket.off("notification", handleNotification); + }; + }, [queryClient, mapStore]); + + return {children}; } diff --git a/apps/dispatch/app/_components/customToasts/BaseNotification.tsx b/apps/dispatch/app/_components/customToasts/BaseNotification.tsx new file mode 100644 index 00000000..d51475fa --- /dev/null +++ b/apps/dispatch/app/_components/customToasts/BaseNotification.tsx @@ -0,0 +1,19 @@ +import { cn } from "helpers/cn"; + +export const BaseNotification = ({ + children, + className, + icon, +}: { + children: React.ReactNode; + className?: string; + icon?: React.ReactNode; +}) => { + return ( +
+ {icon} + +
{children}
+
+ ); +}; diff --git a/apps/dispatch/app/_components/customToasts/HPGnotification.tsx b/apps/dispatch/app/_components/customToasts/HPGnotification.tsx new file mode 100644 index 00000000..b566c0ca --- /dev/null +++ b/apps/dispatch/app/_components/customToasts/HPGnotification.tsx @@ -0,0 +1,57 @@ +import { NotificationPayload } from "@repo/db"; +import { BaseNotification } from "_components/customToasts/BaseNotification"; +import { MapStore, useMapStore } from "_store/mapStore"; +import { Check, Cross } from "lucide-react"; +import toast, { Toast } from "react-hot-toast"; + +export const HPGnotificationToast = ({ + event, + t, + mapStore, +}: { + event: NotificationPayload; + t: Toast; + mapStore: MapStore; +}) => { + const handleClick = () => { + toast.dismiss(t.id); + mapStore.setOpenMissionMarker({ + open: [{ id: event.data.mission.id, tab: "home" }], + close: [], + }); + mapStore.setMap({ + center: [event.data.mission.addressLat, event.data.mission.addressLng], + zoom: 14, + }); + }; + + if (event.status === "failed") { + return ( + } className="flex flex-row"> +
+

HPG validierung fehlgeschlagen

+

{event.message}

+
+
+ +
+
+ ); + } else { + return ( + } className="flex flex-row"> +
+

HPG validierung erfolgreich

+

{event.message}

+
+
+ +
+
+ ); + } +}; diff --git a/apps/dispatch/app/dispatch/_components/map/AircraftMarker.tsx b/apps/dispatch/app/_components/map/AircraftMarker.tsx similarity index 100% rename from apps/dispatch/app/dispatch/_components/map/AircraftMarker.tsx rename to apps/dispatch/app/_components/map/AircraftMarker.tsx diff --git a/apps/dispatch/app/dispatch/_components/map/BaseMaps.tsx b/apps/dispatch/app/_components/map/BaseMaps.tsx similarity index 100% rename from apps/dispatch/app/dispatch/_components/map/BaseMaps.tsx rename to apps/dispatch/app/_components/map/BaseMaps.tsx diff --git a/apps/dispatch/app/dispatch/_components/map/ContextMenu.tsx b/apps/dispatch/app/_components/map/ContextMenu.tsx similarity index 100% rename from apps/dispatch/app/dispatch/_components/map/ContextMenu.tsx rename to apps/dispatch/app/_components/map/ContextMenu.tsx diff --git a/apps/dispatch/app/_components/map/Map.tsx b/apps/dispatch/app/_components/map/Map.tsx new file mode 100644 index 00000000..c26558eb --- /dev/null +++ b/apps/dispatch/app/_components/map/Map.tsx @@ -0,0 +1,78 @@ +"use client"; +import "leaflet/dist/leaflet.css"; +import { useMapStore } from "_store/mapStore"; +import { MapContainer } from "react-leaflet"; +import { BaseMaps } from "_components/map/BaseMaps"; +import { ContextMenu } from "_components/map/ContextMenu"; +import { MissionLayer } from "_components/map/MissionMarkers"; +import { SearchElements } from "_components/map/SearchElements"; +import { AircraftLayer } from "_components/map/AircraftMarker"; +import { MarkerCluster } from "_components/map/_components/MarkerCluster"; +import { useEffect, useRef } from "react"; +import { Map as TMap } from "leaflet"; + +const Map = () => { + const ref = useRef(null); + const { map, setMap } = useMapStore(); + + useEffect(() => { + // Sync map zoom and center with the map store + if (ref.current) { + ref.current.setView(map.center, map.zoom); + /* ref.current.on("moveend", () => { + const center = ref.current?.getCenter(); + const zoom = ref.current?.getZoom(); + if (center && zoom) { + setMap({ + center: [center.lat, center.lng], + zoom, + }); + } + }); + ref.current.on("zoomend", () => { + const zoom = ref.current?.getZoom(); + const center = ref.current?.getCenter(); + + if (zoom && center) { + setMap({ + center, + zoom, + }); + } + }); */ + } + }, [map, setMap]); + + useEffect(() => { + console.log("Map center or zoom changed"); + + if (ref.current) { + const center = ref.current?.getCenter(); + const zoom = ref.current?.getZoom(); + console.log("Map center or zoom changed", center.equals(map.center), zoom === map.zoom); + if (!center.equals(map.center) || zoom !== map.zoom) { + console.log("Updating map center and zoom"); + ref.current.setView(map.center, map.zoom); + } + } + }, [map.center, map.zoom]); + + return ( + + + + + + + + + ); +}; + +export default Map; diff --git a/apps/dispatch/app/dispatch/_components/map/MissionMarkers.tsx b/apps/dispatch/app/_components/map/MissionMarkers.tsx similarity index 100% rename from apps/dispatch/app/dispatch/_components/map/MissionMarkers.tsx rename to apps/dispatch/app/_components/map/MissionMarkers.tsx diff --git a/apps/dispatch/app/dispatch/_components/map/SearchElements.tsx b/apps/dispatch/app/_components/map/SearchElements.tsx similarity index 100% rename from apps/dispatch/app/dispatch/_components/map/SearchElements.tsx rename to apps/dispatch/app/_components/map/SearchElements.tsx diff --git a/apps/dispatch/app/dispatch/_components/map/_components/AircraftMarkerTabs.tsx b/apps/dispatch/app/_components/map/_components/AircraftMarkerTabs.tsx similarity index 92% rename from apps/dispatch/app/dispatch/_components/map/_components/AircraftMarkerTabs.tsx rename to apps/dispatch/app/_components/map/_components/AircraftMarkerTabs.tsx index ac76eaac..feb2725b 100644 --- a/apps/dispatch/app/dispatch/_components/map/_components/AircraftMarkerTabs.tsx +++ b/apps/dispatch/app/_components/map/_components/AircraftMarkerTabs.tsx @@ -37,7 +37,7 @@ import { TextSearch, } from "lucide-react"; import { useSession } from "next-auth/react"; -import { editMissionAPI } from "querys/missions"; +import { editMissionAPI, sendSdsMessageAPI } from "querys/missions"; const FMSStatusHistory = ({ aircraft, @@ -298,17 +298,18 @@ const SDSTab = ({ const [note, setNote] = useState(""); const queryClient = useQueryClient(); - const editMissionMutation = useMutation({ - mutationFn: ({ + const sendSdsMutation = useMutation({ + mutationFn: async ({ id, - mission, + message, }: { id: number; - mission: Partial; - }) => editMissionAPI(id, mission), - mutationKey: ["missions"], - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["missions"] }); + message: MissionSdsLog; + }) => { + await sendSdsMessageAPI(id, message); + queryClient.invalidateQueries({ + queryKey: ["missions"], + }); }, }); @@ -347,26 +348,19 @@ const SDSTab = ({ className="btn btn-sm btn-primary btn-outline" onClick={() => { if (!mission) return; - const newMissionLog = [ - ...mission.missionLog, - { - type: "sds-log", - auto: false, - timeStamp: new Date().toISOString(), - data: { - stationId: aircraft.Station.id, - station: aircraft.Station, - message: note, - user: getPublicUser(session.data!.user), - }, - } as MissionSdsLog, - ]; - editMissionMutation + sendSdsMutation .mutateAsync({ id: mission.id, - mission: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - missionLog: newMissionLog as any, + message: { + type: "sds-log", + auto: false, + timeStamp: new Date().toISOString(), + data: { + stationId: aircraft.Station.id, + station: aircraft.Station, + message: note, + user: getPublicUser(session.data!.user), + }, }, }) .then(() => { diff --git a/apps/dispatch/app/dispatch/_components/map/_components/MarkerCluster.tsx b/apps/dispatch/app/_components/map/_components/MarkerCluster.tsx similarity index 98% rename from apps/dispatch/app/dispatch/_components/map/_components/MarkerCluster.tsx rename to apps/dispatch/app/_components/map/_components/MarkerCluster.tsx index 89ff6cd7..8b016c30 100644 --- a/apps/dispatch/app/dispatch/_components/map/_components/MarkerCluster.tsx +++ b/apps/dispatch/app/_components/map/_components/MarkerCluster.tsx @@ -11,11 +11,11 @@ import { useMapStore } from "_store/mapStore"; import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS, -} from "dispatch/_components/map/AircraftMarker"; +} from "_components/map/AircraftMarker"; import { MISSION_STATUS_COLORS, MISSION_STATUS_TEXT_COLORS, -} from "dispatch/_components/map/MissionMarkers"; +} from "_components/map/MissionMarkers"; import { cn } from "helpers/cn"; import { checkSimulatorConnected } from "helpers/simulatorConnected"; import { getConnectedAircraftsAPI } from "querys/aircrafts"; diff --git a/apps/dispatch/app/dispatch/_components/map/_components/MissionMarkerTabs.tsx b/apps/dispatch/app/_components/map/_components/MissionMarkerTabs.tsx similarity index 87% rename from apps/dispatch/app/dispatch/_components/map/_components/MissionMarkerTabs.tsx rename to apps/dispatch/app/_components/map/_components/MissionMarkerTabs.tsx index 080484ea..08ca01ac 100644 --- a/apps/dispatch/app/dispatch/_components/map/_components/MissionMarkerTabs.tsx +++ b/apps/dispatch/app/_components/map/_components/MissionMarkerTabs.tsx @@ -367,32 +367,25 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => { }, }); - const dispatcherConnected = - useDispatchConnectionStore((s) => s.status) === "connected"; - return (
-
-

+
+

Rettungsmittel

- {dispatcherConnected && ( -
-
- -
-
- )} +
+ +
    {missionStations?.map((station, index) => { @@ -427,24 +420,74 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => { ); })}
- {dispatcherConnected && ( -
-
-
- {/* TODO: make it a small multiselect */} - - -
-
- )} +
+
+ {/* TODO: make it a small multiselect */} + + +
); }; diff --git a/apps/dispatch/app/_store/audioStore.ts b/apps/dispatch/app/_store/audioStore.ts index 580eead5..ff524c40 100644 --- a/apps/dispatch/app/_store/audioStore.ts +++ b/apps/dispatch/app/_store/audioStore.ts @@ -47,6 +47,7 @@ export const useAudioStore = create((set, get) => ({ const { room, isTalking } = get(); if (!room) return; room.localParticipant.setMicrophoneEnabled(!isTalking); + if (!isTalking) { // If old status was not talking, we need to emit the PTT event if (pilotSocket.connected) { diff --git a/apps/dispatch/app/_store/dispatch/connectionStore.ts b/apps/dispatch/app/_store/dispatch/connectionStore.ts index 9c4eef98..2219971c 100644 --- a/apps/dispatch/app/_store/dispatch/connectionStore.ts +++ b/apps/dispatch/app/_store/dispatch/connectionStore.ts @@ -1,5 +1,8 @@ import { create } from "zustand"; import { dispatchSocket } from "../../dispatch/socket"; +import toast from "react-hot-toast"; +import { HPGnotificationToast } from "_components/customToasts/HPGnotification"; +import { NotificationPayload } from "@repo/db"; interface ConnectionStore { status: "connected" | "disconnected" | "connecting" | "error"; diff --git a/apps/dispatch/app/_store/mapStore.ts b/apps/dispatch/app/_store/mapStore.ts index a93c730b..06a5ea71 100644 --- a/apps/dispatch/app/_store/mapStore.ts +++ b/apps/dispatch/app/_store/mapStore.ts @@ -1,7 +1,7 @@ import { OSMWay } from "@repo/db"; import { create } from "zustand"; -interface MapStore { +export interface MapStore { contextMenu: { lat: number; lng: number; @@ -10,6 +10,7 @@ interface MapStore { center: L.LatLngExpression; zoom: number; }; + setMap: (map: MapStore["map"]) => void; openMissionMarker: { id: number; tab: "home" | "details" | "patient" | "log"; @@ -67,19 +68,24 @@ export const useMapStore = create((set, get) => ({ center: [51.5, 10.5], zoom: 6, }, + setMap: (map) => { + set(() => ({ + map, + })); + }, searchPopup: null, searchElements: [], setSearchPopup: (popup) => - set((state) => ({ + set(() => ({ searchPopup: popup, })), contextMenu: null, setContextMenu: (contextMenu) => - set((state) => ({ + set(() => ({ contextMenu, })), setSearchElements: (elements) => - set((state) => ({ + set(() => ({ searchElements: elements, })), aircraftTabs: {}, diff --git a/apps/dispatch/app/_store/pilot/MrtStore.ts b/apps/dispatch/app/_store/pilot/MrtStore.ts index 55aac686..7a2c5cd5 100644 --- a/apps/dispatch/app/_store/pilot/MrtStore.ts +++ b/apps/dispatch/app/_store/pilot/MrtStore.ts @@ -1,9 +1,15 @@ -import { Station } from "@repo/db"; +import { MissionSdsLog, Station } from "@repo/db"; import { fmsStatusDescription } from "_data/fmsStatusDescription"; import { DisplayLineProps } from "pilot/_components/mrt/Mrt"; import { create } from "zustand"; import { syncTabs } from "zustand-sync-tabs"; +interface SetSdsPageParams { + page: "sds"; + station: Station; + sdsMessage: MissionSdsLog; +} + interface SetHomePageParams { page: "home"; station: Station; @@ -23,6 +29,7 @@ interface SetNewStatusPageParams { type SetPageParams = | SetHomePageParams | SetSendingStatusPageParams + | SetSdsPageParams | SetNewStatusPageParams; interface MrtStore { @@ -123,6 +130,25 @@ export const useMrtStore = create( }); break; } + case "sds": { + const { sdsMessage } = pageData as SetSdsPageParams; + set({ + page: "sds", + lines: [ + { + textLeft: `neue SDS-Nachricht`, + style: { fontWeight: "bold" }, + textSize: "2", + }, + { + textLeft: sdsMessage.data.message, + style: {}, + textSize: "1", + }, + ], + }); + break; + } default: set({ page: "home" }); break; diff --git a/apps/dispatch/app/_store/pilot/connectionStore.ts b/apps/dispatch/app/_store/pilot/connectionStore.ts index 7c5cfca8..71ff874e 100644 --- a/apps/dispatch/app/_store/pilot/connectionStore.ts +++ b/apps/dispatch/app/_store/pilot/connectionStore.ts @@ -1,8 +1,17 @@ import { create } from "zustand"; import { dispatchSocket } from "../../dispatch/socket"; -import { ConnectedAircraft, Mission, Station, User } from "@repo/db"; +import { + ConnectedAircraft, + Mission, + MissionSdsLog, + NotificationPayload, + Station, + User, +} from "@repo/db"; import { pilotSocket } from "pilot/socket"; import { useDmeStore } from "_store/pilot/dmeStore"; +import { useMrtStore } from "_store/pilot/MrtStore"; +import toast from "react-hot-toast"; interface ConnectionStore { status: "connected" | "disconnected" | "connecting" | "error"; @@ -103,3 +112,13 @@ pilotSocket.on("mission-alert", (data: Mission & { Stations: Station[] }) => { page: "new-mission", }); }); + +pilotSocket.on("sds-message", (sdsMessage: MissionSdsLog) => { + const station = usePilotConnectionStore.getState().selectedStation; + if (!station) return; + useMrtStore.getState().setPage({ + page: "sds", + station, + sdsMessage, + }); +}); diff --git a/apps/dispatch/app/dispatch/_components/map/Map.tsx b/apps/dispatch/app/dispatch/_components/map/Map.tsx deleted file mode 100644 index 1b001ec4..00000000 --- a/apps/dispatch/app/dispatch/_components/map/Map.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; -import "leaflet/dist/leaflet.css"; -import { useMapStore } from "_store/mapStore"; -import { MapContainer } from "react-leaflet"; -import { BaseMaps } from "dispatch/_components/map/BaseMaps"; -import { ContextMenu } from "dispatch/_components/map/ContextMenu"; -import { MissionLayer } from "dispatch/_components/map/MissionMarkers"; -import { SearchElements } from "dispatch/_components/map/SearchElements"; -import { AircraftLayer } from "dispatch/_components/map/AircraftMarker"; -import { MarkerCluster } from "dispatch/_components/map/_components/MarkerCluster"; - -const Map = () => { - const { map } = useMapStore(); - - return ( - - - - - - - - - ); -}; - -export default Map; diff --git a/apps/dispatch/app/dispatch/_components/pannel/MissionForm.tsx b/apps/dispatch/app/dispatch/_components/pannel/MissionForm.tsx index 96255f7e..6ebce008 100644 --- a/apps/dispatch/app/dispatch/_components/pannel/MissionForm.tsx +++ b/apps/dispatch/app/dispatch/_components/pannel/MissionForm.tsx @@ -17,10 +17,12 @@ import { createMissionAPI, editMissionAPI, sendMissionAPI, + startHpgValidation, } from "querys/missions"; import { getKeywordsAPI } from "querys/keywords"; import { getStationsAPI } from "querys/stations"; import { useMapStore } from "_store/mapStore"; +import { getConnectedAircraftsAPI } from "querys/aircrafts"; export const MissionForm = () => { const { isEditingMission, editingMissionId, setEditingMission } = @@ -33,6 +35,12 @@ export const MissionForm = () => { queryFn: () => getKeywordsAPI(), }); + const { data: aircrafts } = useQuery({ + queryKey: ["aircrafts"], + queryFn: getConnectedAircraftsAPI, + refetchInterval: 10000, + }); + const { data: stations } = useQuery({ queryKey: ["stations"], queryFn: () => getStationsAPI(), @@ -105,8 +113,11 @@ export const MissionForm = () => { }); const { missionFormValues, setOpen } = usePannelStore((state) => state); - const missionInfoText = form.watch("missionAdditionalInfo"); - const hpgMissionString = form.watch("hpgMissionString"); + const validationRequired = /* form.watch("missionStationIds")?.some((id) => { + const aircraft = aircrafts?.find((a) => a.stationId === id); + + return aircraft?.posH145active; + }) && form.watch("hpgMissionString")?.length !== 0; */ true; useEffect(() => { if (session.data?.user.id) { @@ -296,14 +307,11 @@ export const MissionForm = () => { ); })} - {form.watch("hpgMissionString") && - form.watch("hpgMissionString") !== "" && ( -

- Szenario wird vor Alarmierung HPG-Validiert.
- Achte nach dem Vorbereiten / Alarmieren auf den Status der - Mission. -

- )} + {validationRequired && ( +

+ Szenario wird vor Alarmierung HPG-Validiert. +

+ )}
)} @@ -356,6 +364,9 @@ export const MissionForm = () => { : mission.missionAdditionalInfo, }, }); + if (validationRequired) { + await startHpgValidation(newMission.id); + } toast.success( `Einsatz ${newMission.id} erfolgreich aktualisiert`, ); @@ -391,7 +402,13 @@ export const MissionForm = () => { ? `HPG-Szenario: ${hpgSzenario}` : mission.missionAdditionalInfo, }); - await sendAlertMutation.mutateAsync(newMission.id); + if (validationRequired) { + await startHpgValidation(newMission.id, { + alertWhenValid: true, + }); + } else { + await sendAlertMutation.mutateAsync(newMission.id); + } setSeachOSMElements([]); // Reset search elements setOpen(false); } catch (error) { @@ -421,7 +438,7 @@ export const MissionForm = () => { : mission.missionAdditionalInfo, }); setSeachOSMElements([]); // Reset search elements - + await startHpgValidation(newMission.id); toast.success(`Einsatz ${newMission.publicId} erstellt`); form.reset(); setOpen(false); diff --git a/apps/dispatch/app/dispatch/_components/toast/ToastCard.tsx b/apps/dispatch/app/dispatch/_components/toast/ToastCard.tsx deleted file mode 100644 index f60d725c..00000000 --- a/apps/dispatch/app/dispatch/_components/toast/ToastCard.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useState } from "react"; -import { toast } from "react-hot-toast"; - -interface ToastCard { - id: number; - title: string; - content: string; -} - -const MapToastCard2 = () => { - const [cards, setCards] = useState([]); - const [openCardId, setOpenCardId] = useState(null); - - const addCard = () => { - const newCard: ToastCard = { - id: Date.now(), - title: `Einsatz #${cards.length + 1}`, - content: `Inhalt von Einsatz #${cards.length + 1}.`, - }; - setCards([...cards, newCard]); - // DEBUG - /* toast("😖 Christoph 31 sendet Status 4", { - duration: 10000, - }); */ - // DEBUG - const toastId = toast.custom( -
-
- 😖 Christoph 31 sendet Status 5{" "} - -
-
, - { - duration: 999999999, - }, - ); - // DEBUG - }; - - const removeCard = (id: number) => { - setCards(cards.filter((card) => card.id !== id)); - }; - - const toggleCard = (id: number) => { - setOpenCardId(openCardId === id ? null : id); - }; - - return ( -
- {/* DEBUG */} - - {/* DEBUG */} - {cards.map((card) => ( -
- toggleCard(card.id)} - /> -
- {card.title} - -
-
{card.content}
-
- ))} -
- ); -}; - -export default MapToastCard2; diff --git a/apps/dispatch/app/dispatch/page.tsx b/apps/dispatch/app/dispatch/page.tsx index 33be9f30..c705cb3c 100644 --- a/apps/dispatch/app/dispatch/page.tsx +++ b/apps/dispatch/app/dispatch/page.tsx @@ -6,7 +6,7 @@ import { cn } from "helpers/cn"; import dynamic from "next/dynamic"; import { Chat } from "../_components/left/Chat"; import { Report } from "../_components/left/Report"; -const Map = dynamic(() => import("./_components/map/Map"), { ssr: false }); +const Map = dynamic(() => import("../_components/map/Map"), { ssr: false }); const DispatchPage = () => { const { isOpen } = usePannelStore(); diff --git a/apps/dispatch/app/helpers/axios.ts b/apps/dispatch/app/helpers/axios.ts index 58506170..2316a18c 100644 --- a/apps/dispatch/app/helpers/axios.ts +++ b/apps/dispatch/app/helpers/axios.ts @@ -1,5 +1,6 @@ import { ConnectedAircraft, ConnectedDispatcher } from "@repo/db"; import axios from "axios"; +import { getSession } from "next-auth/react"; export const serverApi = axios.create({ baseURL: process.env.NEXT_PUBLIC_DISPATCH_SERVER_URL, @@ -9,6 +10,22 @@ export const serverApi = axios.create({ }, }); +serverApi.interceptors.request.use( + async (config) => { + const session = await getSession(); + const token = session?.user.id; /* session?.accessToken */ // abhängig von deinem NextAuth setup + + if (token) { + config.headers.Authorization = `User ${token}`; + } + + return config; + }, + (error) => { + return Promise.reject(error); + }, +); + export const getConenctedUsers = async (): Promise< (ConnectedDispatcher | ConnectedAircraft)[] > => { diff --git a/apps/dispatch/app/pilot/_components/mrt/Mrt.tsx b/apps/dispatch/app/pilot/_components/mrt/Mrt.tsx index e2d37c3b..52fc1a4a 100644 --- a/apps/dispatch/app/pilot/_components/mrt/Mrt.tsx +++ b/apps/dispatch/app/pilot/_components/mrt/Mrt.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, useEffect } from "react"; +import { CSSProperties } from "react"; import MrtImage from "./MRT.png"; import { useButtons } from "./useButtons"; import { useSounds } from "./useSounds"; diff --git a/apps/dispatch/app/pilot/page.tsx b/apps/dispatch/app/pilot/page.tsx index d5ac4c7f..20d8d35c 100644 --- a/apps/dispatch/app/pilot/page.tsx +++ b/apps/dispatch/app/pilot/page.tsx @@ -5,7 +5,7 @@ import { Chat } from "../_components/left/Chat"; import { Report } from "../_components/left/Report"; import { Dme } from "pilot/_components/dme/Dme"; import dynamic from "next/dynamic"; -const Map = dynamic(() => import("../dispatch/_components/map/Map"), { +const Map = dynamic(() => import("../_components/map/Map"), { ssr: false, }); diff --git a/apps/dispatch/app/querys/missions.ts b/apps/dispatch/app/querys/missions.ts index 80797455..a4cff3e1 100644 --- a/apps/dispatch/app/querys/missions.ts +++ b/apps/dispatch/app/querys/missions.ts @@ -1,4 +1,4 @@ -import { Mission, Prisma } from "@repo/db"; +import { Mission, MissionSdsLog, Prisma } from "@repo/db"; import axios from "axios"; import { serverApi } from "helpers/axios"; @@ -26,6 +26,29 @@ export const editMissionAPI = async ( const respone = await serverApi.patch(`/mission/${id}`, mission); return respone.data; }; +export const sendSdsMessageAPI = async ( + id: number, + sdsMessage: MissionSdsLog, +) => { + const respone = await serverApi.post( + `/mission/${id}/send-sds`, + sdsMessage, + ); + return respone.data; +}; + +export const startHpgValidation = async ( + id: number, + config?: { + alertWhenValid?: boolean; + }, +) => { + const respone = await serverApi.post( + `/mission/${id}/validate-hpg`, + config, + ); + return respone.data; +}; export const sendMissionAPI = async ( id: number, diff --git a/apps/dispatch/package.json b/apps/dispatch/package.json index 4b63ed20..aef088a6 100644 --- a/apps/dispatch/package.json +++ b/apps/dispatch/package.json @@ -30,6 +30,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", + "react-hot-toast": "^2.5.2", "react-leaflet": "^5.0.0-rc.2", "socket.io-client": "^4.8.1", "tailwindcss": "^4.0.14", diff --git a/grafana/grafana.db b/grafana/grafana.db index a5f36215..1905c35b 100644 Binary files a/grafana/grafana.db and b/grafana/grafana.db differ diff --git a/package-lock.json b/package-lock.json index 77946f43..d0f37310 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", + "react-hot-toast": "^2.5.2", "react-leaflet": "^5.0.0-rc.2", "socket.io-client": "^4.8.1", "tailwindcss": "^4.0.14", @@ -63,6 +64,7 @@ "@redis/json": "^1.0.7", "@socket.io/redis-adapter": "^8.3.0", "axios": "^1.7.9", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "cron": "^4.1.0", "dotenv": "^16.4.7", @@ -77,6 +79,7 @@ "devDependencies": { "@repo/db": "*", "@repo/typescript-config": "*", + "@types/cookie-parser": "^1.4.8", "@types/express": "^5.0.0", "@types/node": "^22.13.5", "@types/nodemailer": "^6.4.17", @@ -3122,6 +3125,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.8.tgz", + "integrity": "sha512-l37JqFrOJ9yQfRQkljb41l0xVphc7kg5JTjjr+pLRZ0IyZ49V4BQ8vbF4Ut2C2e+WH4al3xD3ZwYwIUfnbT4NQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.17", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", @@ -5330,6 +5343,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", diff --git a/packages/database/prisma/json/SocketEvents.ts b/packages/database/prisma/json/SocketEvents.ts new file mode 100644 index 00000000..a2eb854f --- /dev/null +++ b/packages/database/prisma/json/SocketEvents.ts @@ -0,0 +1,21 @@ +import { Mission } from "../../generated/client"; + +interface ValidationFailed { + type: "hpg-validation"; + status: "failed"; + message: string; + data: { + mission: Mission; + }; +} + +interface ValidationSuccess { + type: "hpg-validation"; + status: "success"; + message: string; + data: { + mission: Mission; + }; +} + +export type NotificationPayload = ValidationFailed | ValidationSuccess; diff --git a/packages/database/prisma/json/index.ts b/packages/database/prisma/json/index.ts index b407e3c3..4bd03599 100644 --- a/packages/database/prisma/json/index.ts +++ b/packages/database/prisma/json/index.ts @@ -2,3 +2,4 @@ export * from "./ParticipantLog"; export * from "./MissionVehicleLog"; export * from "./User"; export * from "./OSMway"; +export * from "./SocketEvents";