diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..52aaa1f2 --- /dev/null +++ b/.env.example @@ -0,0 +1,82 @@ +# ─────────────────────────────────────────────── +# 🔐 Authentifizierung & Cookies +# ─────────────────────────────────────────────── +AUTH_DISPATCH_SECRET= +AUTH_HUB_SECRET= + +AUTH_DISPATCH_COOKIE_PREFIX= +AUTH_HUB_COOKIE_PREFIX= + +AUTH_DISPATCH_URL= +AUTH_HUB_URL= +NEXT_PUBLIC_DISPATCH_SERVICE_ID= + + +# ─────────────────────────────────────────────── +# 🌐 Öffentliche URLs +# ─────────────────────────────────────────────── +NEXT_PUBLIC_HUB_URL= +NEXT_PUBLIC_HUB_SERVER_URL= +NEXT_PUBLIC_DISPATCH_URL= +NEXT_PUBLIC_DISPATCH_SERVER_URL= +DISCORD_SERVER_URL= + + +NEXT_PUBLIC_ESRI_ACCESS_TOKEN= +NEXT_PUBLIC_OPENAIP_ACCESS= + +# ─────────────────────────────────────────────── +# 🗄️ Datenbank +# ─────────────────────────────────────────────── +DATABASE_URL= + +# ─────────────────────────────────────────────── +# 📡 LiveKit Konfiguration +# ─────────────────────────────────────────────── +NEXT_PUBLIC_LIVEKIT_URL= +LIVEKIT_API_KEY= +LIVEKIT_API_SECRET= + +# ─────────────────────────────────────────────── +# 🚦 Dispatch Server (Backend) +# ─────────────────────────────────────────────── +DISPATCH_SERVER_PORT= +DISPATCH_APP_TOKEN= + +REDIS_HOST= +REDIS_PORT= + +# ─────────────────────────────────────────────── +# 🧠 HUB Server (Backend) +# ─────────────────────────────────────────────── +HUB_SERVER_PORT= + +DISCORD_SERVER_PORT= +# ─────────────────────────────────────────────── +# 📚 Moodle +# ─────────────────────────────────────────────── +MOODLE_URL= +MOODLE_API_TOKEN= +MOODLE_USER_PASSWORD= +NEXT_PUBLIC_MOODLE_URL= + + +# ─────────────────────────────────────────────── +# 📧 E-Mail Einstellungen (nur HUB Server) +# ─────────────────────────────────────────────── +MAIL_SERVER= +MAIL_PORT= +MAIL_USER= +MAIL_PASSWORD= + +# ─────────────────────────────────────────────── +# 🕹️ Discord OAuth (optional) +# ─────────────────────────────────────────────── + +DISCORD_GUILD_ID= + +DISCORD_OAUTH_CLIENT_ID= +DISCORD_OAUTH_SECRET= +DISCORD_BOT_TOKEN= +DISCORD_REDIRECT_URL= +NEXT_PUBLIC_DISCORD_URL= \ No newline at end of file diff --git a/.env.prod b/.env.prod index 9360d8d0..52aaa1f2 100644 --- a/.env.prod +++ b/.env.prod @@ -1,77 +1,82 @@ # ─────────────────────────────────────────────── # 🔐 Authentifizierung & Cookies # ─────────────────────────────────────────────── -AUTH_DISPATCH_SECRET=dispatch -AUTH_HUB_SECRET=var +AUTH_DISPATCH_SECRET= +AUTH_HUB_SECRET= -AUTH_DISPATCH_COOKIE_PREFIX=DISPATCH -AUTH_HUB_COOKIE_PREFIX=HUB +AUTH_DISPATCH_COOKIE_PREFIX= +AUTH_HUB_COOKIE_PREFIX= -AUTH_DISPATCH_URL=https://dispatch.premiumag.de -AUTH_HUB_URL=https://hub.premiumag.de -NEXT_PUBLIC_DISPATCH_SERVICE_ID=1 +AUTH_DISPATCH_URL= +AUTH_HUB_URL= +NEXT_PUBLIC_DISPATCH_SERVICE_ID= # ─────────────────────────────────────────────── # 🌐 Öffentliche URLs # ─────────────────────────────────────────────── -NEXT_PUBLIC_HUB_URL=https://hub.premiumag.de -NEXT_PUBLIC_HUB_SERVER_URL=https://api.hub.premiumag.de -NEXT_PUBLIC_DISPATCH_URL=https://dispatch.premiumag.de -NEXT_PUBLIC_DISPATCH_SERVER_URL=https://api.dispatch.premiumag.de +NEXT_PUBLIC_HUB_URL= +NEXT_PUBLIC_HUB_SERVER_URL= +NEXT_PUBLIC_DISPATCH_URL= +NEXT_PUBLIC_DISPATCH_SERVER_URL= +DISCORD_SERVER_URL= NEXT_PUBLIC_ESRI_ACCESS_TOKEN= -NEXT_PUBLIC_OPENAIP_ACCESS=6e85069940543ef02f8615b737059d98 +NEXT_PUBLIC_OPENAIP_ACCESS= # ─────────────────────────────────────────────── # 🗄️ Datenbank # ─────────────────────────────────────────────── -DATABASE_URL=postgresql://persistant-data:persistant-data-pw@postgres:5432/var +DATABASE_URL= # ─────────────────────────────────────────────── # 📡 LiveKit Konfiguration # ─────────────────────────────────────────────── -NEXT_PUBLIC_LIVEKIT_URL=wss://livekit.premiumag.de -LIVEKIT_API_KEY=APIAnsGdtdYp2Ho -LIVEKIT_API_SECRET=tdPjVsYUx8ddC7K9NvdmVAeLRF9GeADD6Fedm1x63fWC +NEXT_PUBLIC_LIVEKIT_URL= +LIVEKIT_API_KEY= +LIVEKIT_API_SECRET= # ─────────────────────────────────────────────── # 🚦 Dispatch Server (Backend) # ─────────────────────────────────────────────── -DISPATCH_SERVER_PORT=3000 -DISPATCH_APP_TOKEN=dispatch +DISPATCH_SERVER_PORT= +DISPATCH_APP_TOKEN= -REDIS_HOST=redis -REDIS_PORT=6379 +REDIS_HOST= +REDIS_PORT= # ─────────────────────────────────────────────── # 🧠 HUB Server (Backend) # ─────────────────────────────────────────────── -HUB_SERVER_PORT=3000 +HUB_SERVER_PORT= +DISCORD_SERVER_PORT= # ─────────────────────────────────────────────── # 📚 Moodle # ─────────────────────────────────────────────── -MOODLE_URL=https://02.premiumag.de:8081 -MOODLE_API_TOKEN=ac346f0324647b68488d13fd52a9bbe8 -MOODLE_USER_PASSWORD=var-api-user-P1 -NEXT_PUBLIC_MOODLE_URL=https://02.premiumag.de:8081 +MOODLE_URL= +MOODLE_API_TOKEN= +MOODLE_USER_PASSWORD= +NEXT_PUBLIC_MOODLE_URL= + # ─────────────────────────────────────────────── # 📧 E-Mail Einstellungen (nur HUB Server) # ─────────────────────────────────────────────── -MAIL_SERVER=asmtp.mail.hostpoint.ch -MAIL_PORT=465 -MAIL_USER=noreply@virtualairrescue.com -MAIL_PASSWORD=b7316PB8aDPCC%-& +MAIL_SERVER= +MAIL_PORT= +MAIL_USER= +MAIL_PASSWORD= # ─────────────────────────────────────────────── # 🕹️ Discord OAuth (optional) # ─────────────────────────────────────────────── -DISCORD_OAUTH_CLIENT_ID=930384053344034846 -DISCORD_OAUTH_SECRET=96aSvmIePqFTbGc54mad0QsZfDnYwhl1 -DISCORD_BOT_TOKEN=OTMwMzg0MDUzMzQ0MDM0ODQ2.G7zIy-._hE3dTbtUv6sd7nIP2PUn3d8s-2MFk0x3nYMg8 -DISCORD_REDIRECT_URL=https://hub.premiumag.de/api/discord-redirect -NEXT_PUBLIC_DISCORD_URL=https://discord.com/oauth2/authorize?client_id=930384053344034846&response_type=code&redirect_uri=https%3A%2F%2Fhub.premiumag.de%2Fapi%2Fdiscord-redirect&scope=identify+guilds+email \ No newline at end of file +DISCORD_GUILD_ID= + +DISCORD_OAUTH_CLIENT_ID= +DISCORD_OAUTH_SECRET= +DISCORD_BOT_TOKEN= +DISCORD_REDIRECT_URL= +NEXT_PUBLIC_DISCORD_URL= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4163cefd..c47bc39c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +letsencrypt +moodle/* + # Grafana grafana @@ -15,9 +18,7 @@ mkcert # Local env files .env .env.local -.env.development.local -.env.test.local -.env.production.local +.env.prod # Testing coverage diff --git a/README.md b/README.md index ff540a6f..021e682f 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,7 @@ Um lokal Docker-Images zu bauen, gib die `.env`-Datei mit folgendem Befehl an `d ```sh docker compose --env-file .env.prod -f 'docker-compose.prod.yml' up -d ``` + +## Moodle-dev + +Damit die Moodle integration funktioniert muss ein Service token erstellt sein und ein Custom OAuth-Service für moodle angelegt werden diff --git a/apps/discord-server/.d.ts b/apps/discord-server/.d.ts new file mode 100644 index 00000000..93743eea --- /dev/null +++ b/apps/discord-server/.d.ts @@ -0,0 +1,19 @@ +declare module "next-auth/jwt" { + interface JWT { + uid: string; + firstname: string; + lastname: string; + 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/discord-server/.dockerignore b/apps/discord-server/.dockerignore new file mode 100644 index 00000000..7a533e48 --- /dev/null +++ b/apps/discord-server/.dockerignore @@ -0,0 +1,6 @@ +node_modules +Dockerfile +.dockerignore +nodemon.json +.env +.env.example \ No newline at end of file diff --git a/apps/discord-server/.env.example b/apps/discord-server/.env.example new file mode 100644 index 00000000..e53b08af --- /dev/null +++ b/apps/discord-server/.env.example @@ -0,0 +1,7 @@ +DISCORD_SERVER_PORT=3005 +DISCORD_GUILD_ID=1077269395019141140 +DISCORD_OAUTH_CLIENT_ID=930384053344034846 +DISCORD_OAUTH_SECRET=96aSvmIePqFTbGc54mad0QsZfDnYwhl1 +DISCORD_BOT_TOKEN=OTMwMzg0MDUzMzQ0MDM0ODQ2.G7zIy-._hE3dTbtUv6sd7nIP2PUn3d8s-2MFk0x3nYMg8 +DISCORD_REDIRECT_URL=https://hub.premiumag.de/api/discord-redirect +NEXT_PUBLIC_DISCORD_URL=https://discord.com/oauth2/authorize?client_id=930384053344034846&response_type=code&redirect_uri=https%3A%2F%2Fhub.premiumag.de%2Fapi%2Fdiscord-redirect&scope=identify+guilds+email \ No newline at end of file diff --git a/apps/discord-server/Dockerfile b/apps/discord-server/Dockerfile new file mode 100644 index 00000000..c8f26d91 --- /dev/null +++ b/apps/discord-server/Dockerfile @@ -0,0 +1,50 @@ +FROM node:22-alpine AS base + +ENV PNPM_HOME="/usr/local/pnpm" +ENV PATH="${PNPM_HOME}:${PATH}" +RUN corepack enable && corepack prepare pnpm@latest --activate + +RUN pnpm add -g turbo@^2.5 + +FROM base AS builder +RUN apk update +RUN apk add --no-cache libc6-compat + +WORKDIR /usr/app + +COPY . . + +RUN ls -lh + +RUN turbo prune discord-server --docker + +FROM base AS installer +RUN apk update +RUN apk add --no-cache libc6-compat + +WORKDIR /usr/app + +COPY --from=builder /usr/app/out/json/ . +RUN pnpm install + +# Build the project +COPY --from=builder /usr/app/out/full/ . + +RUN turbo run build + +FROM base AS runner +WORKDIR /usr/app + +# Don't run production as root +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +USER nextjs + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=installer --chown=nextjs:nodejs /usr/app/ ./ + +# Expose the application port +EXPOSE 3003 + +CMD ["pnpm", "--dir", "apps/discord-server", "run", "start"] \ No newline at end of file diff --git a/apps/discord-server/index.ts b/apps/discord-server/index.ts new file mode 100644 index 00000000..8c089588 --- /dev/null +++ b/apps/discord-server/index.ts @@ -0,0 +1,16 @@ +import "dotenv/config"; +import express from "express"; +import { createServer } from "http"; +import router from "routes/router"; +import cors from "cors"; + +const app = express(); +const server = createServer(app); + +app.use(cors()); +app.use(express.json()); +app.use(router); + +server.listen(process.env.DISCORD_SERVER_PORT, () => { + console.log(`Server running on port ${process.env.DISCORD_SERVER_PORT}`); +}); diff --git a/apps/discord-server/modules/discord.ts b/apps/discord-server/modules/discord.ts new file mode 100644 index 00000000..95bc7052 --- /dev/null +++ b/apps/discord-server/modules/discord.ts @@ -0,0 +1,19 @@ +import { Client, GatewayIntentBits } from "discord.js"; + +const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers], +}); + +const token = process.env.DISCORD_BOT_TOKEN; + +if (!token) { + throw new Error("DISCORD_BOT_TOKEN environment variable is not set."); +} + +client.login(token); + +client.on("ready", () => { + console.log(`Logged in as ${client.user?.tag}`); +}); + +export default client; diff --git a/apps/discord-server/nodemon.json b/apps/discord-server/nodemon.json new file mode 100644 index 00000000..8bb27568 --- /dev/null +++ b/apps/discord-server/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["."], + "ext": "ts", + "exec": "tsx index.ts" +} \ No newline at end of file diff --git a/apps/discord-server/package.json b/apps/discord-server/package.json new file mode 100644 index 00000000..93ac4140 --- /dev/null +++ b/apps/discord-server/package.json @@ -0,0 +1,34 @@ +{ + "name": "discord-server", + "exports": { + "helpers": "./helper" + }, + "scripts": { + "dev": "nodemon --signal SIGINT", + "start": "tsx index.ts --transpile-only", + "build": "tsc" + }, + "packageManager": "pnpm@10.11.0", + "devDependencies": { + "@repo/db": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@types/cors": "^2.8.18", + "@types/express": "^5.0.2", + "@types/node": "^22.15.29", + "@types/nodemailer": "^6.4.17", + "concurrently": "^9.1.2", + "typescript": "latest" + }, + "dependencies": { + "axios": "^1.9.0", + "cors": "^2.8.5", + "cron": "^4.3.1", + "discord.js": "^14.19.3", + "dotenv": "^16.5.0", + "express": "^5.1.0", + "node-cron": "^4.1.0", + "nodemon": "^3.1.10", + "react": "^19.1.0", + "tsx": "^4.19.4" + } +} diff --git a/apps/discord-server/routes/helper.ts b/apps/discord-server/routes/helper.ts new file mode 100644 index 00000000..2ba6edfc --- /dev/null +++ b/apps/discord-server/routes/helper.ts @@ -0,0 +1,56 @@ +import { DISCORD_ROLES, Event, getPublicUser, Participant, prisma } from "@repo/db"; +import { Router } from "express"; +import { changeMemberRoles, getMember } from "routes/member"; + +const router: Router = Router(); + +export const eventCompleted = (event: Event, participant?: Participant) => { + if (!participant) return false; + if (event.finisherMoodleCourseId && !participant.finisherMoodleCurseCompleted) return false; + if (event.hasPresenceEvents && !participant.attended) return false; + return true; +}; + +router.post("/set-standard-name", async (req, res) => { + const { memberId, userId } = req.body; + + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + console.log(`Setting standard name for user ${userId} (${user?.publicId}) to member ${memberId}`); + if (!user) { + res.status(404).json({ error: "User not found" }); + return; + } + const participant = await prisma.participant.findMany({ + where: { + userId: user.id, + }, + include: { + Event: true, + }, + }); + + participant.forEach(async (p) => { + if (!p.Event.discordRoleId) return; + if (eventCompleted(p.Event, p)) { + await changeMemberRoles(memberId, [p.Event.discordRoleId], "remove"); + } else { + await changeMemberRoles(memberId, [p.Event.discordRoleId], "add"); + } + }); + + const publicUser = getPublicUser(user); + const member = await getMember(memberId); + + await member.setNickname(`${publicUser.fullName} - ${user.publicId}`); + const isPilot = user.permissions.includes("PILOT"); + const isDispatcher = user.permissions.includes("DISPO"); + + await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove"); + await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove"); +}); + +export default router; diff --git a/apps/discord-server/routes/member.ts b/apps/discord-server/routes/member.ts new file mode 100644 index 00000000..3625daef --- /dev/null +++ b/apps/discord-server/routes/member.ts @@ -0,0 +1,78 @@ +import { Router, Request, Response } from "express"; +import client from "modules/discord"; + +const GUILD_ID = process.env.DISCORD_GUILD_ID; +if (!GUILD_ID) { + throw new Error("DISCORD_GUILD_ID environment variable is not set."); +} + +const router: Router = Router(); + +export const getMember = async (memberId: string) => { + const guild = client.guilds.cache.get(GUILD_ID); + if (!guild) throw new Error("Guild not found"); + try { + return guild.members.cache.get(memberId) ?? (await guild.members.fetch(memberId)); + } catch (error) { + console.error("Error fetching member:", error); + throw new Error("Member not found"); + } +}; + +router.post("/rename", async (req: Request, res: Response) => { + const { newName, memberId } = req.body; + if (typeof newName !== "string" || !memberId) { + res.status(400).json({ error: "Invalid or missing newName or memberId" }); + return; + } + try { + const member = await getMember(memberId); + await member.setNickname(newName); + console.log(`Member ${member.id} renamed to ${newName}`); + res.status(200).json({ message: "Member renamed successfully" }); + } catch (error) { + console.error("Error renaming member:", error); + res.status(500).json({ error: "Failed to rename member" }); + } +}); + +export const changeMemberRoles = async ( + memberId: string, + roleIds: string[], + action: "add" | "remove", +) => { + const member = await getMember(memberId); + + const currentRoleIds = member.roles.cache.map((role) => role.id); + const filteredRoleIds = + action === "add" + ? roleIds.filter((id: string) => !currentRoleIds.includes(id)) + : roleIds.filter((id: string) => currentRoleIds.includes(id)); + + if (filteredRoleIds.length === 0) { + return { message: `No roles to ${action}` }; + } + + await member.roles[action](filteredRoleIds); + return { message: `Roles ${action}ed successfully` }; +}; + +const handleRoleChange = (action: "add" | "remove") => async (req: Request, res: Response) => { + const { roleIds, memberId } = req.body; + if (!Array.isArray(roleIds) || !memberId) { + res.status(400).json({ error: "Invalid or missing roleIds or memberId" }); + return; + } + try { + const result = await changeMemberRoles(memberId, roleIds, action); + res.status(200).json(result); + } catch (error) { + console.error(`Error ${action}ing roles:`, error); + res.status(500).json({ error: `Failed to ${action} roles` }); + } +}; + +router.post("/add-role", handleRoleChange("add")); +router.post("/remove-role", handleRoleChange("remove")); + +export default router; diff --git a/apps/discord-server/routes/router.ts b/apps/discord-server/routes/router.ts new file mode 100644 index 00000000..ea5ee59b --- /dev/null +++ b/apps/discord-server/routes/router.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import memberRouter from "./member"; +import helperRouter from "./helper"; + +const router: Router = Router(); + +router.use("/member", memberRouter); +router.use("/helper", helperRouter); + +export default router; diff --git a/apps/discord-server/tsconfig.json b/apps/discord-server/tsconfig.json new file mode 100644 index 00000000..f3c1bbd3 --- /dev/null +++ b/apps/discord-server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "allowImportingTsExtensions": false, + "baseUrl": ".", + "jsx": "react" + }, + "include": ["**/*.ts", "./index.ts", "**/*.d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/dispatch-server/.env.example b/apps/dispatch-server/.env.example index 2fed12b1..d117b33b 100644 --- a/apps/dispatch-server/.env.example +++ b/apps/dispatch-server/.env.example @@ -1,6 +1,7 @@ DISPATCH_SERVER_PORT=3002 REDIS_HOST=localhost REDIS_PORT=6379 +DISCORD_SERVER_URL=http://discord-server DISPATCH_APP_TOKEN=dispatch LIVEKIT_API_KEY=APIAnsGdtdYp2Ho LIVEKIT_API_SECRET=tdPjVsYUx8ddC7K9NvdmVAeLRF9GeADD6Fedm1x63fWC \ No newline at end of file diff --git a/apps/dispatch-server/modules/discord.ts b/apps/dispatch-server/modules/discord.ts new file mode 100644 index 00000000..94317358 --- /dev/null +++ b/apps/dispatch-server/modules/discord.ts @@ -0,0 +1,38 @@ +import axios from "axios"; + +const discordAxiosClient = axios.create({ + baseURL: process.env.DISCORD_SERVER_URL || "https://discord.com/api/v10", +}); + +export const renameMember = async (memberId: string, newName: string) => { + discordAxiosClient + .post("/member/rename", { + memberId, + newName, + }) + .catch((error) => { + console.error("Error renaming member:", error); + }); +}; + +export const addRolesToMember = async (memberId: string, roleIds: string[]) => { + discordAxiosClient + .post("/member/add-role", { + memberId, + roleIds, + }) + .catch((error) => { + console.error("Error adding roles to member:", error); + }); +}; + +export const removeRolesFromMember = async (memberId: string, roleIds: string[]) => { + discordAxiosClient + .post("/member/remove-role", { + memberId, + roleIds, + }) + .catch((error) => { + console.error("Error removing roles from member:", error); + }); +}; diff --git a/apps/dispatch-server/modules/mission.ts b/apps/dispatch-server/modules/mission.ts index 0e33eb58..70f12f72 100644 --- a/apps/dispatch-server/modules/mission.ts +++ b/apps/dispatch-server/modules/mission.ts @@ -49,6 +49,10 @@ export const sendAlert = async ( ...mission, Stations, }); + io.to(`desktop:${aircraft.userId}`).emit("mission-alert", { + missionId: mission.id, + }); + const user = await prisma.user.findUnique({ where: { id: aircraft.userId }, }); diff --git a/apps/dispatch-server/modules/socketJWTmiddleware.ts b/apps/dispatch-server/modules/socketJWTmiddleware.ts index 0670870f..252f55fe 100644 --- a/apps/dispatch-server/modules/socketJWTmiddleware.ts +++ b/apps/dispatch-server/modules/socketJWTmiddleware.ts @@ -1,6 +1,7 @@ import { ExtendedError, Server, Socket } from "socket.io"; import { prisma } from "@repo/db"; -if (!process.env.DISPATCH_APP_TOKEN) throw new Error("DISPATCH_APP_TOKEN is not defined"); +import jwt from "jsonwebtoken"; +/* if (!process.env.DISPATCH_APP_TOKEN) throw new Error("DISPATCH_APP_TOKEN is not defined"); */ export const jwtMiddleware = async (socket: Socket, next: (err?: ExtendedError) => void) => { try { @@ -18,7 +19,6 @@ export const jwtMiddleware = async (socket: Socket, next: (err?: ExtendedError) next(); } catch (err) { - console.error(err); next(new Error("Authentication error")); } }; diff --git a/apps/dispatch-server/package.json b/apps/dispatch-server/package.json index c659b110..35e57464 100644 --- a/apps/dispatch-server/package.json +++ b/apps/dispatch-server/package.json @@ -24,6 +24,7 @@ "@react-email/components": "^0.0.41", "@redis/json": "^5.1.1", "@socket.io/redis-adapter": "^8.3.0", + "@types/jsonwebtoken": "^9.0.9", "axios": "^1.9.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", diff --git a/apps/dispatch-server/routes/aircraft.ts b/apps/dispatch-server/routes/aircraft.ts index 576d3e5f..7c1522b2 100644 --- a/apps/dispatch-server/routes/aircraft.ts +++ b/apps/dispatch-server/routes/aircraft.ts @@ -1,4 +1,11 @@ -import { ConnectedAircraft, getPublicUser, MissionLog, Prisma, prisma } from "@repo/db"; +import { + AdminMessage, + ConnectedAircraft, + getPublicUser, + MissionLog, + Prisma, + prisma, +} from "@repo/db"; import { Router } from "express"; import { io } from "../index"; @@ -95,6 +102,7 @@ router.patch("/:id", async (req, res) => { // When change is only the estimated logout time, we don't need to emit an event if (Object.keys(aircraftUpdate).length === 1 && aircraftUpdate.esimatedLogoutTime) return; io.to("dispatchers").emit("update-connectedAircraft", updatedConnectedAircraft); + io.to(`user:${updatedConnectedAircraft.userId}`).emit( "aircraft-update", updatedConnectedAircraft, @@ -105,18 +113,61 @@ router.patch("/:id", async (req, res) => { } }); -// Delete a connectedAircraft by ID +// Kick a connectedAircraft by ID router.delete("/:id", async (req, res) => { const { id } = req.params; + const bann = req.body?.bann as boolean; + + const requiredPermission = bann ? "ADMIN_USER" : "ADMIN_KICK"; + + if (!req.user) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + + if (!req.user.permissions.includes(requiredPermission)) { + res.status(403).json({ error: "Forbidden" }); + return; + } + try { - await prisma.connectedAircraft.delete({ + const aircraft = await prisma.connectedAircraft.update({ where: { id: Number(id) }, + data: { logoutTime: new Date() }, + include: bann ? { User: true } : undefined, }); - io.to("dispatchers").emit("delete-connectedAircraft", id); + + if (!aircraft) { + res.status(404).json({ error: "ConnectedAircraft not found" }); + return; + } + + const status = bann ? "ban" : "kick"; + + io.to(`user:${aircraft.userId}`).emit("notification", { + type: "admin-message", + message: "Verbindung durch einen Administrator getrennt", + status, + data: { admin: getPublicUser(req.user) }, + } as AdminMessage); + + io.in(`user:${aircraft.userId}`).disconnectSockets(true); + + if (bann) { + await prisma.user.update({ + where: { id: aircraft.userId }, + data: { + permissions: { + set: req.user.permissions.filter((p) => p !== "PILOT"), + }, + }, + }); + } + res.status(204).send(); } catch (error) { console.error(error); - res.status(500).json({ error: "Failed to delete connectedAircraft" }); + res.status(500).json({ error: "Failed to disconnect pilot" }); } }); diff --git a/apps/dispatch-server/routes/dispatcher.ts b/apps/dispatch-server/routes/dispatcher.ts index a3c79915..61f85e29 100644 --- a/apps/dispatch-server/routes/dispatcher.ts +++ b/apps/dispatch-server/routes/dispatcher.ts @@ -1,5 +1,6 @@ -import { Prisma, prisma } from "@repo/db"; +import { AdminMessage, getPublicUser, Prisma, prisma } from "@repo/db"; import { Router } from "express"; +import { io } from "index"; import { pubClient } from "modules/redis"; const router: Router = Router(); @@ -28,4 +29,63 @@ router.patch("/:id", async (req, res) => { res.json(newDispatcher); }); +import { Request, Response } from "express"; + +router.delete("/:id", async (req, res) => { + const { id } = req.params; + const bann = req.body?.bann as boolean; + + const requiredPermission = bann ? "ADMIN_USER" : "ADMIN_KICK"; + + if (!req.user) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + + if (!req.user.permissions.includes(requiredPermission)) { + res.status(403).json({ error: "Forbidden" }); + return; + } + + try { + const dispatcher = await prisma.connectedDispatcher.update({ + where: { id: Number(id) }, + data: { logoutTime: new Date() }, + include: bann ? { user: true } : undefined, + }); + + if (!dispatcher) { + res.status(404).json({ error: "ConnectedDispatcher not found" }); + return; + } + + const status = bann ? "ban" : "kick"; + + io.to(`user:${dispatcher.userId}`).emit("notification", { + type: "admin-message", + message: "Verbindung durch einen Administrator getrennt", + status, + data: { admin: getPublicUser(req.user) }, + } as AdminMessage); + + io.in(`user:${dispatcher.userId}`).disconnectSockets(true); + + if (bann) { + await prisma.user.update({ + where: { id: dispatcher.userId }, + data: { + permissions: { + set: req.user.permissions.filter((p) => p !== "DISPO"), + }, + }, + }); + } + + res.status(204).send(); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Failed to disconnect dispatcher" }); + } +}); + export default router; diff --git a/apps/dispatch-server/routes/mission.ts b/apps/dispatch-server/routes/mission.ts index 00198812..7dd40b94 100644 --- a/apps/dispatch-server/routes/mission.ts +++ b/apps/dispatch-server/routes/mission.ts @@ -1,17 +1,13 @@ import { getPublicUser, HpgValidationState, - MissionAlertLog, 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 = Router(); @@ -118,7 +114,7 @@ router.post("/:id/send-alert", async (req, res) => { const { id } = req.params; const { stationId, vehicleName } = req.body as { stationId?: number; - vehicleName?: "ambulance" | "police" | "firebrigade"; + vehicleName?: "RTW" | "POL" | "FW"; }; if (!req.user) { @@ -136,81 +132,143 @@ router.post("/:id/send-alert", async (req, res) => { }, }); - const newMission = await prisma.mission.update({ - where: { - id: Number(id), - }, - data: { - hpgAmbulanceState: vehicleName === "ambulance" ? "DISPATCHED" : undefined, - hpgFireEngineState: vehicleName === "firebrigade" ? "DISPATCHED" : undefined, - hpgPoliceState: vehicleName === "police" ? "DISPATCHED" : undefined, - missionLog: { - push: { - type: "alert-log", - auto: false, - timeStamp: new Date().toISOString(), - data: { - vehicle: vehicleName, - user: getPublicUser(req.user as User), - }, - } as any, + const updateData: any = { + missionLog: { + push: { + type: "alert-log", + auto: false, + timeStamp: new Date().toISOString(), + data: { + vehicle: vehicleName, + user: getPublicUser(req.user as User, { ignorePrivacy: true }), + }, }, }, + }; + + if (vehicleName === "RTW") updateData.hpgAmbulanceState = "DISPATCHED"; + if (vehicleName === "FW") updateData.hpgFireEngineState = "DISPATCHED"; + if (vehicleName === "POL") updateData.hpgPoliceState = "DISPATCHED"; + + const newMission = await prisma.mission.update({ + where: { id: Number(id) }, + data: updateData, }); + hpgAircrafts.forEach((aircraft) => { io.to(`desktop:${aircraft.userId}`).emit("hpg-vehicle-update", { missionId: id, - vehicleData: { - ambulanceState: newMission.hpgAmbulanceState, - fireEngineState: newMission.hpgFireEngineState, - policeState: newMission.hpgPoliceState, - }, + ambulanceState: newMission.hpgAmbulanceState, + fireEngineState: newMission.hpgFireEngineState, + policeState: newMission.hpgPoliceState, }); }); + io.to("dispatchers").emit("update-mission", newMission); res.status(200).json({ message: `Rettungsmittel disponiert (${hpgAircrafts.length} Nutzer)`, }); - io.to("dispatchers").emit("update-mission", newMission); return; } - const { connectedAircrafts, mission } = await sendAlert( - Number(id), - { - stationId, - }, - req.user, - ); - res.status(200).json({ - message: `Einsatz gesendet (${connectedAircrafts.length} Nutzer) `, - }); + const { connectedAircrafts, mission } = await sendAlert(Number(id), { stationId }, req.user); + io.to("dispatchers").emit("update-mission", mission); + res.status(200).json({ + message: `Einsatz gesendet (${connectedAircrafts.length} Nutzer)`, + }); + return; } catch (error) { console.error(error); res.status(500).json({ error: "Failed to send mission" }); + return; } }); -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, - }, - }, - }); +router.post("/send-sds", async (req, res) => { + const { sdsMessage, missionId } = req.body as { + missionId?: number; + sdsMessage: MissionSdsLog; + }; 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); + if (missionId) { + const newMission = await prisma.mission.update({ + where: { + id: Number(missionId), + }, + data: { + missionLog: { + push: sdsMessage as any, + }, + }, + }); + + res.json({ + message: "SDS message sent", + mission: newMission, + }); + io.to("dispatchers").emit("update-mission", newMission); + } else { + res.json({ + message: "SDS message sent", + }); + } +}); + +router.post("/:id/hpg-validation-result", async (req, res) => { + try { + const missionId = req.params.id; + const result = req.body as { + state: HpgValidationState; + lat: number; + lng: number; + alertWhenValid?: boolean; + userId?: number; + }; + + if (!result) return; + + const newMission = await prisma.mission.update({ + where: { id: Number(missionId) }, + data: { + // save position of new mission + addressLat: result.state === "POSITION_AMANDED" ? result.lat : undefined, + addressLng: result.state === "POSITION_AMANDED" ? result.lng : undefined, + hpgLocationLat: result.lat, + hpgLocationLng: result.lng, + hpgValidationState: result.state, + }, + }); + io.to("dispatchers").emit("update-mission", newMission); + + const noActionRequired = result.state === "VALID"; + if (noActionRequired) { + io.to(`user:${result.userId}`).emit("notification", { + type: "hpg-validation", + status: "success", + message: `HPG Validierung erfolgreich`, + } as NotificationPayload); + + if (result.alertWhenValid) { + if (!req.user) return; + sendAlert(Number(missionId), {}, req.user); + } + } else { + io.to(`user:${result.userId}`).emit("notification", { + type: "hpg-validation", + status: "failed", + message: result.state, + } as NotificationPayload); + } + res.json({ + message: `HPG Validation result processed`, + }); + } catch (error) { + console.error("Error in HPG validation result:", error); + res.status(500).json({ error: "Failed to process HPG validation result" }); + return; + } }); router.post("/:id/validate-hpg", async (req, res) => { @@ -252,53 +310,25 @@ router.post("/:id/validate-hpg", async (req, res) => { }); return; } + + const newMission = await prisma.mission.update({ + where: { + id: Number(id), + }, + data: { + hpgValidationState: "PENDING", + }, + }); + io.to("dispatchers").emit("update-mission", newMission); + res.json({ message: "HPG validierung gestartet", }); - - io.to(`desktop:${activeAircraftinMission}`).emit( - "hpg-validation", - { - hpgMissionType: mission?.hpgMissionString, - lat: mission?.addressLat, - lng: mission?.addressLng, - }, - async (result: { state: HpgValidationState; lat: number; lng: number }) => { - console.log("response from user:", result); - - const newMission = await prisma.mission.update({ - where: { id: Number(id) }, - data: { - // save position of new mission - addressLat: result.state === "POSITION_AMANDED" ? result.lat : mission.addressLat, - addressLng: result.state === "POSITION_AMANDED" ? result.lng : mission.addressLng, - hpgLocationLat: result.lat, - hpgLocationLng: result.lng, - hpgValidationState: result.state, - }, - }); - 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) { - if (!req.user) return; - sendAlert(Number(id), {}, req.user); - } - } else { - io.to(`user:${req.user?.id}`).emit("notification", { - type: "hpg-validation", - status: "failed", - message: `HPG Validation fehlgeschlagen`, - } as NotificationPayload); - } - }, - ); + io.to(`desktop:${activeAircraftinMission?.userId}`).emit("hpg-validation", { + missionId: parseInt(id), + userId: req.user?.id, + alertWhenValid: config?.alertWhenValid || false, + }); } catch (error) { console.error(error); res.json({ error: (error as Error).message || "Failed to validate HPG" }); diff --git a/apps/dispatch-server/socket-events/connect-desktop.ts b/apps/dispatch-server/socket-events/connect-desktop.ts index a6d1ce3f..1a4b78e8 100644 --- a/apps/dispatch-server/socket-events/connect-desktop.ts +++ b/apps/dispatch-server/socket-events/connect-desktop.ts @@ -14,32 +14,5 @@ export const handleConnectDesktop = (socket: Socket, io: Server) => () => { socket.on("ptt", async (data: PTTData) => { socket.to(`user:${user.id}`).emit("ptt", data); - const connectedAircraft = await prisma.connectedAircraft.findFirst({ - where: { - userId: user.id, - logoutTime: null, - }, - include: { - Station: true, - }, - }); - - const connectedDispatcher = await prisma.connectedDispatcher.findFirst({ - where: { - userId: user.id, - logoutTime: null, - }, - }); - const otherPttData = { - publicUser: getPublicUser(user), - source: - connectedAircraft?.Station.bosCallsignShort || connectedDispatcher - ? "Leitstelle" - : user.publicId, - }; - if (data.shouldTransmit) { - socket.to("dispatchers").emit("other-ptt", otherPttData); - socket.to("pilots").emit("other-ptt", otherPttData); - } }); }; diff --git a/apps/dispatch-server/socket-events/connect-dispatch.ts b/apps/dispatch-server/socket-events/connect-dispatch.ts index d4cce20e..c6494c22 100644 --- a/apps/dispatch-server/socket-events/connect-dispatch.ts +++ b/apps/dispatch-server/socket-events/connect-dispatch.ts @@ -1,4 +1,6 @@ import { getPublicUser, prisma, User } from "@repo/db"; +import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord"; +import { DISCORD_ROLES } from "@repo/db"; import { Server, Socket } from "socket.io"; export const handleConnectDispatch = @@ -67,6 +69,21 @@ export const handleConnectDispatch = }, }); + const discordAccount = await prisma.discordAccount.findFirst({ + where: { + userId: user.id, + }, + }); + if (discordAccount?.id) { + await renameMember( + discordAccount.discordId.toString(), + `${getPublicUser(user).fullName} • ${selectedZone}`, + ); + await addRolesToMember(discordAccount.discordId.toString(), [ + DISCORD_ROLES.ONLINE_DISPATCHER, + ]); + } + socket.join("dispatchers"); // Dem Dispatcher-Raum beitreten socket.join(`user:${user.id}`); // Dem User-Raum beitreten @@ -74,31 +91,6 @@ export const handleConnectDispatch = io.to("dispatchers").emit("dispatchers-update", connectedDispatcherEntry); io.to("pilots").emit("dispatchers-update", connectedDispatcherEntry); - socket.on("stop-other-transmition", async ({ ownRole, otherRole }) => { - const aircrafts = await prisma.connectedAircraft.findMany({ - where: { - Station: { - bosCallsignShort: otherRole, - }, - logoutTime: null, - }, - include: { - Station: true, - }, - }); - const dispatchers = await prisma.connectedDispatcher.findMany({ - where: { - zone: otherRole, - logoutTime: null, - }, - }); - [...aircrafts, ...dispatchers].forEach((entry) => { - io.to(`user:${entry.userId}`).emit("force-end-transmission", { - by: ownRole, - }); - }); - }); - socket.on("disconnect", async () => { await prisma.connectedDispatcher.update({ where: { @@ -110,6 +102,15 @@ export const handleConnectDispatch = }); io.to("dispatchers").emit("dispatchers-update"); io.to("pilots").emit("dispatchers-update"); + if (discordAccount?.id) { + await renameMember( + discordAccount.discordId.toString(), + `${getPublicUser(user).fullName} - ${user.publicId}`, + ); + await removeRolesFromMember(discordAccount.discordId.toString(), [ + DISCORD_ROLES.ONLINE_DISPATCHER, + ]); + } }); } catch (error) { console.error("Error connecting to dispatch server:", error); diff --git a/apps/dispatch-server/socket-events/connect-pilot.ts b/apps/dispatch-server/socket-events/connect-pilot.ts index c5e743f0..285fc28b 100644 --- a/apps/dispatch-server/socket-events/connect-pilot.ts +++ b/apps/dispatch-server/socket-events/connect-pilot.ts @@ -1,5 +1,6 @@ import { getPublicUser, prisma, User } from "@repo/db"; -import { channel } from "diagnostics_channel"; +import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord"; +import { DISCORD_ROLES } from "@repo/db"; import { Server, Socket } from "socket.io"; export const handleConnectPilot = @@ -18,7 +19,6 @@ export const handleConnectPilot = if (!user) return Error("User not found"); - console.log("Pilot connected:", user.publicId); const existingConnection = await prisma.connectedAircraft.findFirst({ where: { userId: user.id, @@ -55,20 +55,50 @@ export const handleConnectPilot = } } + // Set "now" to 2 hours in the future + const nowPlus2h = new Date(); + nowPlus2h.setHours(nowPlus2h.getHours() + 2); + + // Generate a random position in Germany (approximate bounding box) + function getRandomGermanPosition() { + const minLat = 47.2701; + const maxLat = 55.0581; + const minLng = 5.8663; + const maxLng = 15.0419; + const lat = Math.random() * (maxLat - minLat) + minLat; + const lng = Math.random() * (maxLng - minLng) + minLng; + return { lat, lng }; + } + + const randomPos = + process.env.environment === "development" ? getRandomGermanPosition() : undefined; + const connectedAircraftEntry = await prisma.connectedAircraft.create({ data: { publicUser: getPublicUser(user) as any, esimatedLogoutTime: parsedLogoffDate?.toISOString() || null, - lastHeartbeat: new Date().toISOString(), userId: userId, - loginTime: new Date().toISOString(), stationId: parseInt(stationId), - // TODO: remove this after testing - posLat: 51.45, - posLng: 9.77, - posH145active: true, + lastHeartbeat: + process.env.environment === "development" ? nowPlus2h.toISOString() : undefined, + posLat: randomPos?.lat, + posLng: randomPos?.lng, }, }); + + const discordAccount = await prisma.discordAccount.findFirst({ + where: { + userId: user.id, + }, + }); + if (discordAccount?.id) { + await renameMember( + discordAccount.discordId.toString(), + `${getPublicUser(user).fullName} • ${Station?.bosCallsignShort}`, + ); + await addRolesToMember(discordAccount.discordId.toString(), [DISCORD_ROLES.ONLINE_PILOT]); + } + socket.join("dispatchers"); // Join the dispatchers room socket.join(`user:${userId}`); // Join the user-specific room socket.join(`station:${stationId}`); // Join the station-specific room @@ -81,22 +111,6 @@ export const handleConnectPilot = connectedAircraftEntry, ); - // Add a listener for station-specific events - socket.on("ptt", async ({ shouldTransmit, channel }) => { - if (shouldTransmit) { - io.to("dispatchers").emit("other-ptt", { - publicUser: getPublicUser(user), - channel, - source: Station?.bosCallsignShort, - }); - io.to("piots").emit("other-ptt", { - publicUser: getPublicUser(user), - channel, - source: Station?.bosCallsignShort, - }); - } - }); - socket.on("disconnect", async () => { await prisma.connectedAircraft .update({ @@ -110,6 +124,15 @@ export const handleConnectPilot = .catch(console.error); io.to("dispatchers").emit("update-connectedAircraft"); io.to("pilots").emit("pilots-update"); + if (discordAccount?.id) { + await renameMember( + discordAccount.discordId.toString(), + `${getPublicUser(user).fullName} - ${user.publicId}`, + ); + await removeRolesFromMember(discordAccount.discordId.toString(), [ + DISCORD_ROLES.ONLINE_PILOT, + ]); + } }); } catch (error) { console.error("Error connecting to dispatch server:", error); diff --git a/apps/dispatch/Dockerfile b/apps/dispatch/Dockerfile index 52967528..0923d88c 100644 --- a/apps/dispatch/Dockerfile +++ b/apps/dispatch/Dockerfile @@ -5,12 +5,14 @@ ARG NEXT_PUBLIC_DISPATCH_SERVER_URL ARG NEXT_PUBLIC_HUB_URL ARG NEXT_PUBLIC_DISPATCH_SERVICE_ID ARG NEXT_PUBLIC_LIVEKIT_URL +ARG NEXT_PUBLIC_DISCORD_URL ENV NEXT_PUBLIC_DISPATCH_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL ENV NEXT_PUBLIC_DISPATCH_URL=$NEXT_PUBLIC_DISPATCH_URL ENV NEXT_PUBLIC_HUB_URL=$NEXT_PUBLIC_HUB_URL ENV NEXT_PUBLIC_DISPATCH_SERVICE_ID=$NEXT_PUBLIC_DISPATCH_SERVICE_ID ENV NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL +ENV NEXT_PUBLIC_DISCORD_URL=$NEXT_PUBLIC_DISCORD_URL ENV PNPM_HOME="/usr/local/pnpm" ENV PATH="${PNPM_HOME}:${PATH}" diff --git a/apps/dispatch/app/_components/Audio/Audio.tsx b/apps/dispatch/app/_components/Audio/Audio.tsx index df531c20..46d96120 100644 --- a/apps/dispatch/app/_components/Audio/Audio.tsx +++ b/apps/dispatch/app/_components/Audio/Audio.tsx @@ -20,7 +20,6 @@ import { ConnectionQuality } from "livekit-client"; import { ROOMS } from "_data/livekitRooms"; import { useDispatchConnectionStore } from "_store/dispatch/connectionStore"; import { useSession } from "next-auth/react"; -import { dispatchSocket } from "dispatch/socket"; import { useSounds } from "_components/Audio/useSounds"; export const Audio = () => { @@ -28,6 +27,7 @@ export const Audio = () => { speakingParticipants, isTalking, toggleTalking, + transmitBlocked, connect, state, connectionQuality, @@ -42,13 +42,13 @@ export const Audio = () => { isReceiving: speakingParticipants.length > 0, isTransmitting: isTalking, unpausedTracks: speakingParticipants, + transmitBlocked, }); const { selectedStation, status: pilotState } = usePilotConnectionStore((state) => state); - const { selectedZone, status: dispatcherState } = useDispatchConnectionStore((state) => state); const session = useSession(); - + const [isReceivingBlick, setIsReceivingBlick] = useState(false); const [recentSpeakers, setRecentSpeakers] = useState([]); useEffect(() => { @@ -63,6 +63,20 @@ export const Audio = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [speakingParticipants]); + useEffect(() => { + if (speakingParticipants.length > 0) { + setIsReceivingBlick(true); + + const timeout = setInterval(() => { + setIsReceivingBlick((s) => !s); + }, 1000); + return () => { + clearTimeout(timeout); + setIsReceivingBlick(false); + }; + } + }, [setIsReceivingBlick, speakingParticipants]); + useEffect(() => { if (message && state !== "error") { const timeout = setTimeout(() => { @@ -93,39 +107,37 @@ export const Audio = () => { className={cn("btn btn-sm btn-ghost border-warning bg-transparent ")} onClick={() => { removeMessage(); - // Probably via socket event to set ppt = false for participant - if (!canStopOtherSpeakers) return; - speakingParticipants.forEach((p) => { - dispatchSocket.emit("stop-other-transmition", { - ownRole: role, - otherRole: p.attributes.role, - }); - }); }} > {message} )} - {(displayedSpeakers.length || message) && ( + {displayedSpeakers.length > 0 && (
0 && "tooltip", )} data-tip="Funkspruch unterbrechen" > +
+ + ); +}; diff --git a/apps/dispatch/app/_components/customToasts/HPGnotification.tsx b/apps/dispatch/app/_components/customToasts/HPGnotification.tsx index b566c0ca..5437598b 100644 --- a/apps/dispatch/app/_components/customToasts/HPGnotification.tsx +++ b/apps/dispatch/app/_components/customToasts/HPGnotification.tsx @@ -1,4 +1,4 @@ -import { NotificationPayload } from "@repo/db"; +import { NotificationPayload, ValidationFailed, ValidationSuccess } from "@repo/db"; import { BaseNotification } from "_components/customToasts/BaseNotification"; import { MapStore, useMapStore } from "_store/mapStore"; import { Check, Cross } from "lucide-react"; @@ -9,7 +9,7 @@ export const HPGnotificationToast = ({ t, mapStore, }: { - event: NotificationPayload; + event: ValidationFailed | ValidationSuccess; t: Toast; mapStore: MapStore; }) => { diff --git a/apps/dispatch/app/_components/StationStatusToast.tsx b/apps/dispatch/app/_components/customToasts/StationStatusToast.tsx similarity index 100% rename from apps/dispatch/app/_components/StationStatusToast.tsx rename to apps/dispatch/app/_components/customToasts/StationStatusToast.tsx diff --git a/apps/dispatch/app/_components/left/Chat.tsx b/apps/dispatch/app/_components/left/Chat.tsx index 579a5e17..c07485f1 100644 --- a/apps/dispatch/app/_components/left/Chat.tsx +++ b/apps/dispatch/app/_components/left/Chat.tsx @@ -6,7 +6,8 @@ import { Fragment, useEffect, useState } from "react"; import { cn } from "_helpers/cn"; import { asPublicUser } from "@repo/db"; import { useQuery } from "@tanstack/react-query"; -import { getConnectedUserAPI } from "_querys/connected-user"; +import { getConnectedDispatcherAPI } from "_querys/dispatcher"; +import { getConnectedAircraftsAPI } from "_querys/aircrafts"; export const Chat = () => { const { @@ -26,23 +27,27 @@ export const Chat = () => { const [addTabValue, setAddTabValue] = useState("default"); const [message, setMessage] = useState(""); - const { data: connectedUser } = useQuery({ - queryKey: ["connected-users"], - queryFn: async () => { - const user = await getConnectedUserAPI(); - return user.filter((u) => u.userId !== session.data?.user.id); - }, + const { data: dispatcher } = useQuery({ + queryKey: ["dispatcher"], + queryFn: () => getConnectedDispatcherAPI(), + refetchInterval: 10000, + }); + const { data: aircrafts } = useQuery({ + queryKey: ["aircrafts"], + queryFn: () => getConnectedAircraftsAPI(), refetchInterval: 10000, - refetchOnWindowFocus: true, }); useEffect(() => { if (!session.data?.user.id) return; - setOwnId(session.data.user.id); - }, [session, setOwnId]); + setOwnId(session.data?.user.id); + }, [session.data?.user.id]); + + const filteredDispatcher = dispatcher?.filter((d) => d.userId !== session.data?.user.id); + const filteredAircrafts = aircrafts?.filter((a) => a.userId !== session.data?.user.id); return ( -
+
{Object.values(chats).some((c) => c.notification) && ( @@ -63,9 +68,9 @@ export const Chat = () => { {chatOpen && (
-
+

Chat

@@ -75,62 +80,59 @@ export const Chat = () => { value={addTabValue} onChange={(e) => setAddTabValue(e.target.value)} > - {!connectedUser?.length && ( + {!filteredDispatcher?.length && !filteredAircrafts?.length && ( )} - {connectedUser?.length && ( + {(filteredDispatcher?.length || filteredAircrafts?.length) && ( )} - {[ - ...(connectedUser?.filter( - (user, idx, arr) => arr.findIndex((u) => u.userId === user.userId) === idx, - ) || []), - ].map((user) => ( - + ))} + + {filteredAircrafts?.map((aircraft) => ( + ))}
-
+
{Object.keys(chats).map((userId) => { const chat = chats[userId]; if (!chat) return null; return ( - `} - checked={selectedChat === userId} - onClick={() => { - setChatNotification(userId, false); - }} - onChange={(e) => { - if (e.target.checked) { - // Handle tab change - setSelectedChat(userId); - } - }} - /> -
+ setSelectedChat(userId)} + > + {chat.name} + {chat.notification && } + +
{chat.messages.map((chatMessage) => { const isSender = chatMessage.senderId === session.data?.user.id; return ( @@ -153,48 +155,71 @@ export const Chat = () => { ); })}
-
-
- + {!selectedChat && ( +
+ Wähle einen Nutzer aus und drücke auf + um einen Chat zu starten
- -
+ )} + {selectedChat && ( +
+
+ +
+ +
+ )}
)} diff --git a/apps/dispatch/app/_components/left/Report.tsx b/apps/dispatch/app/_components/left/Report.tsx index 27c78da4..795a7831 100644 --- a/apps/dispatch/app/_components/left/Report.tsx +++ b/apps/dispatch/app/_components/left/Report.tsx @@ -7,8 +7,9 @@ import { toast } from "react-hot-toast"; import { useLeftMenuStore } from "_store/leftMenuStore"; import { asPublicUser } from "@repo/db"; import { useQuery } from "@tanstack/react-query"; -import { getConnectedUserAPI } from "_querys/connected-user"; +import { getConnectedDispatcherAPI } from "_querys/dispatcher"; import { sendReportAPI } from "_querys/report"; +import { getConnectedAircraftsAPI } from "_querys/aircrafts"; export const Report = () => { const { setChatOpen, setReportTabOpen, reportTabOpen, setOwnId } = useLeftMenuStore(); @@ -22,18 +23,24 @@ export const Report = () => { setOwnId(session.data.user.id); }, [session, setOwnId]); - const { data: connectedUser } = useQuery({ - queryKey: ["connected-users"], - queryFn: async () => { - const user = await getConnectedUserAPI(); - return user.filter((u) => u.userId !== session.data?.user.id); - }, + const { data: dispatcher } = useQuery({ + queryKey: ["dispatcher"], + queryFn: () => getConnectedDispatcherAPI(), + refetchInterval: 10000, + }); + const { data: aircrafts } = useQuery({ + queryKey: ["aircrafts"], + queryFn: () => getConnectedAircraftsAPI(), refetchInterval: 10000, - refetchOnWindowFocus: true, }); + const filteredDispatcher = dispatcher?.filter((d) => d.userId !== session.data?.user.id); + const filteredAircrafts = aircrafts?.filter((a) => a.userId !== session.data?.user.id); + return ( -
+
diff --git a/apps/dispatch/app/_components/map/Map.tsx b/apps/dispatch/app/_components/map/Map.tsx index 70a64142..7bd7b6b7 100644 --- a/apps/dispatch/app/_components/map/Map.tsx +++ b/apps/dispatch/app/_components/map/Map.tsx @@ -1,5 +1,6 @@ "use client"; import "leaflet/dist/leaflet.css"; +import "./mapStyles.css"; import { useMapStore } from "_store/mapStore"; import { MapContainer } from "react-leaflet"; import { BaseMaps } from "_components/map/BaseMaps"; @@ -10,6 +11,7 @@ import { AircraftLayer } from "_components/map/AircraftMarker"; import { MarkerCluster } from "_components/map/_components/MarkerCluster"; import { useEffect, useRef } from "react"; import { Map as TMap } from "leaflet"; +import { DistanceLayer } from "_components/map/Measurement"; const Map = () => { const ref = useRef(null); @@ -38,7 +40,7 @@ const Map = () => { return ( { + ); }; diff --git a/apps/dispatch/app/_components/map/Measurement.tsx b/apps/dispatch/app/_components/map/Measurement.tsx new file mode 100644 index 00000000..1bd691aa --- /dev/null +++ b/apps/dispatch/app/_components/map/Measurement.tsx @@ -0,0 +1,102 @@ +import "leaflet.polylinemeasure"; +import "leaflet.polylinemeasure/Leaflet.PolylineMeasure.css"; +import { useEffect, useRef } from "react"; +import { useMap } from "react-leaflet"; +import L from "leaflet"; + +export const DistanceLayer = () => { + const map = useMap(); + const added = useRef(false); + useEffect(() => { + if (added.current) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (L.control as any) + .polylineMeasure({ + position: "topleft", + unit: "metres", // Show imperial or metric distances. Values: 'metres', 'landmiles', 'nauticalmiles' + clearMeasurementsOnStop: true, // Clear all the measurements when the control is unselected + showBearings: true, // Whether bearings are displayed within the tooltips + bearingTextIn: "In", // language dependend label for inbound bearings + bearingTextOut: "Out", // language dependend label for outbound bearings + tooltipTextFinish: "Klicken zum Beenden
", + tooltipTextDelete: "SHIFT+Click zum Löschen", + tooltipTextMove: "Klicken und ziehen zum Verschieben
", + tooltipTextResume: "
CTRL+Click zum Forsetzen", + tooltipTextAdd: "CTRL+Click zum Hinzufügen", + // language dependend labels for point's tooltips + measureControlTitleOn: "Messung starten", // Title for the control going to be switched on + measureControlTitleOff: "Messung beenden", // Title for the control going to be switched off + measureControlLabel: "📏", // Label of the Measure control (maybe a unicode symbol) + measureControlClasses: ["pointer"], // Classes to apply to the Measure control + showClearControl: true, // Show a control to clear all the measurements + clearControlTitle: "Messung löschen", // Title text to show on the clear measurements control button + clearControlLabel: "×", // Label of the Clear control (maybe a unicode symbol) + clearControlClasses: ["pointer"], // Classes to apply to clear control button + unitControlClasses: ["pointer"], + showUnitControl: true, // Show a control to change the units of measurements + unitControlTitle: { + // Title texts to show on the Unit Control + text: "Einheit ändern", + kilometres: "Kilometer", + nauticalmiles: "Nautische Meilen", + }, + unitControlLabel: { + // Unit symbols to show in the Unit Control and measurement labels + metres: "m", + kilometres: "km", + feet: "ft", + landmiles: "mi", + nauticalmiles: "nm", + }, + tempLine: { + // Styling settings for the temporary dashed line + color: "#00f", // Dashed line color + weight: 2, // Dashed line weight + }, + fixedLine: { + // Styling for the solid line + color: "#006", // Solid line color + weight: 2, // Solid line weight + }, + arrow: { + // Styling of the midway arrow + color: "#000", // Color of the arrow + }, + startCircle: { + // Style settings for circle marker indicating the starting point of the polyline + color: "#000000", // Color of the border of the circle + weight: 1, // Weight of the circle + fillColor: "#000000", // Fill color of the circle + fillOpacity: 1, // Fill opacity of the circle + radius: 3, // Radius of the circle + }, + intermedCircle: { + // Style settings for all circle markers between startCircle and endCircle + color: "#000", // Color of the border of the circle + weight: 1, // Weight of the circle + fillColor: "#000000", // Fill color of the circle + fillOpacity: 1, // Fill opacity of the circle + radius: 3, // Radius of the circle + }, + currentCircle: { + // Style settings for circle marker indicating the latest point of the polyline during drawing a line + color: "#000000", // Color of the border of the circle + weight: 1, // Weight of the circle + fillColor: "#FFFFFF", // Fill color of the circle + fillOpacity: 1, // Fill opacity of the circle + radius: 3, // Radius of the circle + }, + endCircle: { + // Style settings for circle marker indicating the last point of the polyline + color: "#000", // Color of the border of the circle + weight: 1, // Weight of the circle + fillColor: "#ffffff", // Fill color of the circle + fillOpacity: 1, // Fill opacity of the circle + radius: 3, // Radius of the circle + }, + }) + .addTo(map); + added.current = true; + }, [map]); + return null; +}; diff --git a/apps/dispatch/app/_components/map/MissionMarkers.tsx b/apps/dispatch/app/_components/map/MissionMarkers.tsx index b57e3977..2cf1a4a0 100644 --- a/apps/dispatch/app/_components/map/MissionMarkers.tsx +++ b/apps/dispatch/app/_components/map/MissionMarkers.tsx @@ -175,7 +175,7 @@ const MissionPopupContent = ({ hpgLocationLat: mission.hpgLocationLat ?? undefined, hpgLocationLng: mission.hpgLocationLng ?? undefined, }); - setEditingMission(true, String(mission.id)); + setEditingMission(true, mission.id); setOpen(true); }} > @@ -365,21 +365,25 @@ const MissionMarker = ({ mission }: { mission: Mission }) => { }; export const MissionLayer = () => { - const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected"; + const dispatchState = useDispatchConnectionStore((s) => s); + const dispatcherConnected = dispatchState.status === "connected"; + const { data: missions = [] } = useQuery({ queryKey: ["missions"], queryFn: () => getMissionsAPI({ OR: [{ state: "draft" }, { state: "running" }], }), + refetchInterval: 10_000, }); const filteredMissions = useMemo(() => { - if (!dispatcherConnected) { - return missions.filter((m: Mission) => m.state === "running"); - } - return missions; - }, [missions, dispatcherConnected]); + return missions.filter((m: Mission) => { + if (m.state === "draft" && !dispatcherConnected) return false; + if (dispatchState.hideDraftMissions && m.state === "draft") return false; + return true; + }); + }, [missions, dispatcherConnected, dispatchState.hideDraftMissions]); // IDEA: Add Marker to Map Layer / LayerGroup return ( diff --git a/apps/dispatch/app/_components/map/SearchElements.tsx b/apps/dispatch/app/_components/map/SearchElements.tsx index 916c281c..2724965d 100644 --- a/apps/dispatch/app/_components/map/SearchElements.tsx +++ b/apps/dispatch/app/_components/map/SearchElements.tsx @@ -1,18 +1,15 @@ import { useMapStore } from "_store/mapStore"; -import { Marker as LMarker } from "leaflet"; -import { Fragment, useEffect, useRef } from "react"; import { Marker, Polygon, Popup } from "react-leaflet"; -import L from "leaflet"; import { useQuery } from "@tanstack/react-query"; import { getMissionsAPI } from "_querys/missions"; -import { OSMWay } from "@repo/db"; import { usePannelStore } from "_store/pannelStore"; +import { OSMWay } from "@repo/db"; +import { useEffect } from "react"; export const SearchElements = () => { - const { searchElements, searchPopup, setSearchPopup, setContextMenu, openMissionMarker } = - useMapStore(); - const { missionFormValues, setMissionFormValues } = usePannelStore((state) => state); - const missions = useQuery({ + const { searchElements, openMissionMarker, setSearchElements } = useMapStore(); + const { isOpen: pannelOpen, editingMissionId } = usePannelStore((state) => state); + const { data: missions } = useQuery({ queryKey: ["missions"], queryFn: () => getMissionsAPI({ @@ -26,131 +23,56 @@ export const SearchElements = () => { ], }), }); - const poppupRef = useRef(null); - const searchPopupElement = searchElements.find( - (element) => element.wayID === searchPopup?.elementId, - ); - const SearchElement = ({ - element, - isActive = false, - }: { - element: (typeof searchElements)[1]; - isActive?: boolean; - }) => { - const ref = useRef(null); - - useEffect(() => { - if (ref.current) { - ref.current.on("click", () => { - const center = ref.current?.getBounds().getCenter(); - if (center && searchPopup?.elementId !== element.wayID) { - setSearchPopup({ - lat: center.lat, - lng: center.lng, - elementId: element.wayID, - }); - } else { - setSearchPopup(null); - } - setContextMenu(null); - }); + useEffect(() => { + if (pannelOpen) { + const missionEdited = missions?.find((m) => m.id === editingMissionId); + if (missionEdited) { + const elements = missionEdited.addressOSMways.map((e) => ({ + ...(e as unknown as OSMWay), + isSelected: true, + })); + setSearchElements(elements); } - }, [element.wayID]); + } else { + const openMissions = openMissionMarker.map((m) => { + const mission = missions?.find((mi) => mi.id === m.id); + return mission; + }); + const elements = openMissions + .filter((m) => m?.addressOSMways) + .flatMap((m) => + m?.addressOSMways.map((e) => ({ + ...(e as unknown as OSMWay), + isSelected: true, + })), + ); + setSearchElements(elements.filter((e) => !!e)); + } + }, [openMissionMarker, pannelOpen, missions]); + const SearchElement = ({ element }: { element: OSMWay }) => { + const { toggleSearchElementSelection } = useMapStore(); if (!element.nodes) return null; return ( [node.lat, node.lon])} - color={searchPopup?.elementId === element.wayID || isActive ? "#ff4500" : "#46b7a3"} + color={element.isSelected ? "#ff4500" : "#46b7a3"} eventHandlers={{ click: () => { - const addressOSMways = missionFormValues?.addressOSMways || []; - - addressOSMways.push(JSON.parse(JSON.stringify(element))); - - setMissionFormValues({ - ...missionFormValues, - addressOSMways, - }); + if (!pannelOpen) return; + toggleSearchElementSelection(element.wayID); }, }} - ref={ref} /> ); }; - const SearchElementPopup = ({ element }: { element: (typeof searchElements)[1] }) => { - if (!searchPopup) return null; - return ( - -
-

- {element.tags?.building === "yes" ? "Gebäude" : element.tags?.building} - {!element.tags?.building && "unbekannt"} -

-

- {element.tags?.["addr:street"]} {element.tags?.["addr:housenumber"]} -

-

- {element.tags?.["addr:suburb"]} {element.tags?.["addr:postcode"]} -

-
- -
-
-
- ); - }; - return ( <> - {openMissionMarker.map(({ id }) => { - const mission = missions.data?.find((m) => m.id === id); - if (!mission) return null; - return ( - - {(mission.addressOSMways as (OSMWay | null)[]) - .filter((element): element is OSMWay => element !== null) - .map((element: OSMWay, i) => ( - - ))} - - ); - })} {searchElements.map((element, i) => { - if ( - missions.data?.some( - (mission) => - (mission.addressOSMways as (OSMWay | null)[]) - .filter((e): e is OSMWay => e !== null) - .some((e) => e.wayID === element.wayID) && - openMissionMarker.some((m) => m.id === mission.id), - ) - ) - return null; return ; })} - {searchPopup && ( - - {!searchPopupElement &&
} -
- )} - {searchPopupElement && } ); }; diff --git a/apps/dispatch/app/_components/map/_components/AircraftMarkerTabs.tsx b/apps/dispatch/app/_components/map/_components/AircraftMarkerTabs.tsx index 5ea062eb..bcda4889 100644 --- a/apps/dispatch/app/_components/map/_components/AircraftMarkerTabs.tsx +++ b/apps/dispatch/app/_components/map/_components/AircraftMarkerTabs.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; -import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "../AircraftMarker"; +import React, { useEffect, useMemo, useState } from "react"; +import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors"; import { ConnectedAircraft, getPublicUser, @@ -13,7 +13,7 @@ import { Station, } from "@repo/db"; import { toast } from "react-hot-toast"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { editConnectedAircraftAPI } from "_querys/aircrafts"; import { useDispatchConnectionStore } from "_store/dispatch/connectionStore"; import { cn } from "_helpers/cn"; @@ -29,6 +29,7 @@ import { Hash, ListCollapse, LocateFixed, + Lollipop, MapPin, Mountain, Navigation, @@ -38,7 +39,9 @@ import { TextSearch, } from "lucide-react"; import { useSession } from "next-auth/react"; -import { editMissionAPI, sendSdsMessageAPI } from "_querys/missions"; +import { sendSdsMessageAPI } from "_querys/missions"; +import { getLivekitRooms } from "_querys/livekit"; +import { findLeitstelleForPosition } from "_helpers/findLeitstelleinPoint"; const FMSStatusHistory = ({ aircraft, @@ -215,14 +218,35 @@ const RettungsmittelTab = ({ aircraft: ConnectedAircraft & { Station: Station }; }) => { const station = aircraft.Station; + const { data: livekitRooms } = useQuery({ + queryKey: ["livekit-rooms"], + queryFn: () => getLivekitRooms(), + refetchInterval: 10000, + }); + + const participants = + livekitRooms?.flatMap((room) => + room.participants.map((p) => ({ + ...p, + roomName: room.room.name, + })), + ) || []; + + const livekitUser = participants.find((p) => (p.attributes.userId = aircraft.userId)); + + const lstName = useMemo(() => { + if (!aircraft.posLng || !aircraft.posLat) return station.bosRadioArea; + return findLeitstelleForPosition(aircraft.posLng, aircraft.posLat); + }, [aircraft.posLng, aircraft.posLat]); + return (
  • - Aktuelle Rufgruppe: LST_01 + Aktuelle Rufgruppe: {livekitUser?.roomName || "Nicht verbunden"}
  • - Leitstellenbereich: Florian Berlin + Leitstellenbereich: {lstName || station.bosRadioArea}
@@ -252,6 +276,14 @@ const RettungsmittelTab = ({ ALT: {aircraft.posAlt} ft
+
+ + {" "} + + {aircraft.posH145active ? "H145 Aktiv" : "H145 Inaktiv"} + + +
); }; @@ -302,25 +334,42 @@ const SDSTab = ({ const [isChatOpen, setIsChatOpen] = useState(false); const [note, setNote] = useState(""); const queryClient = useQueryClient(); + const textInputRef = React.useRef(null); const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected"; const sendSdsMutation = useMutation({ - mutationFn: async ({ id, message }: { id: number; message: MissionSdsLog }) => { - await sendSdsMessageAPI(id, message); + mutationFn: async ({ + missionId, + sdsMessage, + }: { + missionId?: number; + sdsMessage: MissionSdsLog; + }) => { + await sendSdsMessageAPI({ missionId, sdsMessage }); queryClient.invalidateQueries({ queryKey: ["missions"], }); }, }); - const log = - (mission?.missionLog as unknown as MissionLog[]) - ?.slice() - .reverse() - .filter( - (entry) => entry.type === "sds-log" && entry.data.stationId === aircraft.Station.id, - ) || []; + const log = useMemo( + () => + (mission?.missionLog as unknown as MissionLog[]) + ?.slice() + .reverse() + .filter( + (entry) => entry.type === "sds-log" && entry.data.stationId === aircraft.Station.id, + ) || [], + [mission?.missionLog, aircraft.Station.id], + ); + + useEffect(() => { + const interval = setInterval(() => { + textInputRef.current?.focus(); + }, 100); + return () => clearInterval(interval); + }); return (
@@ -333,26 +382,27 @@ const SDSTab = ({ onClick={() => setIsChatOpen(true)} > - Notiz hinzufügen + SDS senden ) : (
setNote(e.target.value)} + ref={textInputRef} />
); })} - {aircrafts - .filter((a) => checkSimulatorConnected(a.lastHeartbeat)) - .map((aircraft) => ( -
( +
{ + setOpenAircraftMarker({ + open: [ + { + id: aircraft.id, + tab: "aircraft", + }, + ], + close: [], + }); + map.setView([aircraft.posLat!, aircraft.posLng!], 12, { + animate: true, + }); + }} + > + { - setOpenAircraftMarker({ - open: [ - { - id: aircraft.id, - tab: "aircraft", - }, - ], - close: [], - }); - map.setView([aircraft.posLat!, aircraft.posLng!], 12, { - animate: true, - }); + color: FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus], }} > - - {aircraft.fmsStatus} - - {aircraft.Station.bosCallsign} -
- ))} + {aircraft.fmsStatus} + + {aircraft.Station.bosCallsign} +
+ ))}
); @@ -138,12 +135,14 @@ const PopupContent = ({ export const MarkerCluster = () => { const map = useMap(); + const dispatchState = useDispatchConnectionStore((s) => s); + const dispatcherConnected = dispatchState.status === "connected"; const { data: aircrafts } = useQuery({ queryKey: ["aircrafts"], queryFn: getConnectedAircraftsAPI, + refetchInterval: 10_000, }); - const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected"; const { data: missions = [] } = useQuery({ queryKey: ["missions"], queryFn: () => @@ -153,11 +152,12 @@ export const MarkerCluster = () => { }); const filteredMissions = useMemo(() => { - if (!dispatcherConnected) { - return missions.filter((m: Mission) => m.state === "running"); - } - return missions; - }, [missions, dispatcherConnected]); + return missions.filter((m: Mission) => { + if (m.state === "draft" && !dispatcherConnected) return false; + if (dispatchState.hideDraftMissions && m.state === "draft") return false; + return true; + }); + }, [missions, dispatcherConnected, dispatchState.hideDraftMissions]); // Track zoom level in state const [zoom, setZoom] = useState(() => map.getZoom()); @@ -178,38 +178,36 @@ export const MarkerCluster = () => { lat: number; lng: number; }[] = []; - aircrafts - ?.filter((a) => checkSimulatorConnected(a.lastHeartbeat)) - .forEach((aircraft) => { - const lat = aircraft.posLat!; - const lng = aircraft.posLng!; + aircrafts?.forEach((aircraft) => { + const lat = aircraft.posLat!; + const lng = aircraft.posLng!; - const existingClusterIndex = newCluster.findIndex( - (c) => Math.abs(c.lat - lat) < 1 && Math.abs(c.lng - lng) < 1, - ); - const existingCluster = newCluster[existingClusterIndex]; - if (existingCluster) { - newCluster = [...newCluster].map((c, i) => { - if (i === existingClusterIndex) { - return { - ...c, - aircrafts: [...c.aircrafts, aircraft], - }; - } - return c; - }); - } else { - newCluster = [ - ...newCluster, - { - aircrafts: [aircraft], - missions: [], - lat, - lng, - }, - ]; - } - }); + const existingClusterIndex = newCluster.findIndex( + (c) => Math.abs(c.lat - lat) < 1 && Math.abs(c.lng - lng) < 1, + ); + const existingCluster = newCluster[existingClusterIndex]; + if (existingCluster) { + newCluster = [...newCluster].map((c, i) => { + if (i === existingClusterIndex) { + return { + ...c, + aircrafts: [...c.aircrafts, aircraft], + }; + } + return c; + }); + } else { + newCluster = [ + ...newCluster, + { + aircrafts: [aircraft], + missions: [], + lat, + lng, + }, + ]; + } + }); filteredMissions?.forEach((mission) => { const lat = mission.addressLat; const lng = mission.addressLng; diff --git a/apps/dispatch/app/_components/map/_components/MissionMarkerTabs.tsx b/apps/dispatch/app/_components/map/_components/MissionMarkerTabs.tsx index 44d6ad12..d47edeed 100644 --- a/apps/dispatch/app/_components/map/_components/MissionMarkerTabs.tsx +++ b/apps/dispatch/app/_components/map/_components/MissionMarkerTabs.tsx @@ -1,6 +1,6 @@ "use client"; import React, { useEffect, useState } from "react"; -import { FMS_STATUS_TEXT_COLORS } from "../AircraftMarker"; +import { FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors"; import { toast } from "react-hot-toast"; import { Ban, @@ -18,6 +18,7 @@ import { SmartphoneNfc, CheckCheck, Cross, + Radio, } from "lucide-react"; import { getPublicUser, @@ -34,13 +35,19 @@ import { import { usePannelStore } from "_store/pannelStore"; import { useSession } from "next-auth/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { deleteMissionAPI, editMissionAPI, sendMissionAPI } from "_querys/missions"; +import { + deleteMissionAPI, + editMissionAPI, + sendMissionAPI, + startHpgValidation, +} from "_querys/missions"; import { getConnectedAircraftsAPI } from "_querys/aircrafts"; import { getStationsAPI } from "_querys/stations"; import { useDispatchConnectionStore } from "_store/dispatch/connectionStore"; import { HPGValidationRequired } from "_helpers/hpgValidationRequired"; import { getOsmAddress } from "_querys/osm"; import { hpgStateToFMSStatus } from "_helpers/hpgStateToFmsStatus"; +import { cn } from "_helpers/cn"; const Einsatzdetails = ({ mission, @@ -60,10 +67,6 @@ const Einsatzdetails = ({ }); }, }); - const { data: aircrafts } = useQuery({ - queryKey: ["aircrafts"], - queryFn: getConnectedAircraftsAPI, - }); const sendAlertMutation = useMutation({ mutationKey: ["missions"], mutationFn: (id: number) => sendMissionAPI(id, {}), @@ -89,6 +92,7 @@ const Einsatzdetails = ({ }); }, }); + const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected"; const { setMissionFormValues, setOpen, setEditingMission } = usePannelStore((state) => state); const [ignoreHpg, setIgnoreHpg] = useState(false); @@ -191,12 +195,8 @@ const Einsatzdetails = ({
- {HPGValidationRequired( - mission.missionStationIds, - aircrafts, - mission.hpgMissionString, - ) && ( -
+ {true && ( +
+
)} @@ -346,10 +358,10 @@ const Patientdetails = ({ mission }: { mission: Mission }) => { const Rettungsmittel = ({ mission }: { mission: Mission }) => { const queryClient = useQueryClient(); - const [selectedStation, setSelectedStation] = useState< - Station | "ambulance" | "police" | "firebrigade" | null - >(null); - const { data: conenctedAircrafts } = useQuery({ + const [selectedStation, setSelectedStation] = useState( + null, + ); + const { data: connectedAircrafts } = useQuery({ queryKey: ["aircrafts"], queryFn: getConnectedAircraftsAPI, }); @@ -388,17 +400,6 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => { queryFn: () => getStationsAPI(), }); - useEffect(() => { - if (allStations) { - const stationsNotItMission = allStations.filter( - (s) => !mission.missionStationIds.includes(s.id), - ); - if (stationsNotItMission[0]) { - setSelectedStation(stationsNotItMission[0]); - } - } - }, [allStations, mission.missionStationIds]); - const sendAlertMutation = useMutation({ mutationKey: ["missions"], mutationFn: ({ @@ -408,7 +409,7 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => { }: { id: number; stationId?: number; - vehicleName?: "ambulance" | "police" | "firebrigade"; + vehicleName?: "RTW" | "POL" | "FW"; }) => sendMissionAPI(id, { stationId, vehicleName }), onError: (error) => { console.error(error); @@ -419,6 +420,39 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => { }, }); + const stationsOptions = [ + ...(allStations + ?.filter((s) => !mission.missionStationIds.includes(s.id)) + ?.map((station) => ({ + label: station.bosCallsign, + value: station.id, + type: "station" as const, + isOnline: !!connectedAircrafts?.find((a) => a.stationId === station.id), + })) || []), + ...(!mission.hpgFireEngineState || mission.hpgFireEngineState === "NOT_REQUESTED" + ? [{ label: "Feuerwehr", value: "FW", type: "vehicle" as const }] + : []), + ...(!mission.hpgAmbulanceState || mission.hpgAmbulanceState === "NOT_REQUESTED" + ? [{ label: "Rettungsdienst", value: "RTW", type: "vehicle" as const }] + : []), + ...(!mission.hpgPoliceState || mission.hpgPoliceState === "NOT_REQUESTED" + ? [{ label: "Polizei", value: "POL", type: "vehicle" as const }] + : []), + ].sort((a, b) => { + // 1. Vehicles first + if (a.type === "vehicle" && b.type !== "vehicle") return -1; + if (a.type !== "vehicle" && b.type === "vehicle") return 1; + + // 2. Online stations before offline stations + if (a.type === "station" && b.type === "station") { + if (a.isOnline && !b.isOnline) return -1; + if (!a.isOnline && b.isOnline) return 1; + } + + // 3. Otherwise, sort alphabetically by label + return a.label.localeCompare(b.label); + }); + const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected"; const HPGVehicle = ({ state, name }: { state: HpgState; name: string }) => ( @@ -466,7 +500,7 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
    {missionStations?.map((station, index) => { - const connectedAircraft = conenctedAircrafts?.find( + const connectedAircraft = connectedAircrafts?.find( (aircraft) => aircraft.stationId === station.id, ); @@ -510,32 +544,28 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => { + +
    +
    + +
    +

    + Admin Panel +

    +
    +
    +
    +
    + Verbundene Clients +
    + + + + + + + + + + + + {pilots?.map((p) => { + const publicUser = p.publicUser as unknown as PublicUser; + const livekitParticipant = participants.find( + (p) => p.participant.identity === publicUser.publicId, + ); + return ( + + + + + + + + ); + })} + {dispatcher?.map((d) => { + const publicUser = d.publicUser as unknown as PublicUser; + const livekitParticipant = participants.find( + (p) => p.participant.identity === publicUser.publicId, + ); + return ( + + + + + + + + ); + })} + {livekitUserNotConnected.map((p) => { + const publicUser = JSON.parse( + p.participant.attributes.publicUser || "{}", + ) as PublicUser; + return ( + + + + + + + + ); + })} + +
    VAR #NameStationVoiceActions
    + {publicUser.publicId} + {publicUser.fullName}{p.Station.bosCallsign} + {!livekitParticipant ? ( + Nicht verbunden + ) : ( + {livekitParticipant.room} + )} + + + + + + +
    + {publicUser.publicId} + {publicUser.fullName}{d.zone} + {!livekitParticipant ? ( + Nicht verbunden + ) : ( + {livekitParticipant.room} + )} + + + + + + +
    + {p.participant.identity} + {publicUser?.fullName} + Nicht verbunden + + {p.room} + + + + + + +
    +
    +
    +
    + {/*
    +
    +
    + Allgemeine Befehle +
    +
    +
    + + + +
    + + +
    +
    +
    */} +
    +
    + +
    +
    +
+ ); +} diff --git a/apps/dispatch/app/_components/navbar/ModeSwitchDropdown.tsx b/apps/dispatch/app/_components/navbar/ModeSwitchDropdown.tsx index a79fbb70..84fb3ec1 100644 --- a/apps/dispatch/app/_components/navbar/ModeSwitchDropdown.tsx +++ b/apps/dispatch/app/_components/navbar/ModeSwitchDropdown.tsx @@ -1,30 +1,45 @@ "use client"; -import { ArrowLeftRight, Plane, Workflow } from "lucide-react"; +import { cn } from "_helpers/cn"; +import { ArrowLeftRight, Plane, Radar, Workflow } from "lucide-react"; +import { useSession } from "next-auth/react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -export default function ModeSwitchDropdown() { +export default function ModeSwitchDropdown({ className }: { className?: string }) { const path = usePathname(); + const session = useSession(); return ( -
- - {path.includes("pilot") && "Pilot"} +
+
+ {path.includes("pilot") && "Pilot"} {path.includes("dispatch") && "Leitstelle"} -
-
    +
+
    + {session.data?.user.permissions?.includes("DISPO") && ( +
  • + + Leitstelle + +
  • + )} + {session.data?.user.permissions?.includes("PILOT") && ( +
  • + + Pilot + +
  • + )}
  • - - Leitstelle - -
  • -
  • - - Pilot + + Tracker
- +
); } diff --git a/apps/dispatch/app/_components/navbar/Settings.tsx b/apps/dispatch/app/_components/navbar/Settings.tsx index 5d72b0be..1889edb9 100644 --- a/apps/dispatch/app/_components/navbar/Settings.tsx +++ b/apps/dispatch/app/_components/navbar/Settings.tsx @@ -19,8 +19,7 @@ export const SettingsBtn = () => { const testSoundRef = useRef(null); const editUserMutation = useMutation({ - mutationFn: ({ user }: { user: Prisma.UserUpdateInput }) => - editUserAPI(session.data!.user.id, user), + mutationFn: editUserAPI, }); useEffect(() => { @@ -37,6 +36,7 @@ export const SettingsBtn = () => { ); const [showIndication, setShowIndication] = useState(false); const [micVol, setMicVol] = useState(1); + const [funkVolume, setFunkVol] = useState(0.8); const [dmeVolume, setDmeVol] = useState(0.8); const setMic = useAudioStore((state) => state.setMic); @@ -46,14 +46,17 @@ export const SettingsBtn = () => { setSelectedDevice(user.settingsMicDevice); setMic(user.settingsMicDevice, user.settingsMicVolume || 1); setMicVol(user.settingsMicVolume || 1); + setFunkVol(user.settingsRadioVolume || 0.8); setDmeVol(user.settingsDmeVolume || 0.8); } }, [user, setMic]); useEffect(() => { - navigator.mediaDevices.enumerateDevices().then((devices) => { - setInputDevices(devices.filter((d) => d.kind === "audioinput")); - }); + if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) { + navigator.mediaDevices.enumerateDevices().then((devices) => { + setInputDevices(devices.filter((d) => d.kind === "audioinput")); + }); + } }, []); return ( @@ -74,7 +77,7 @@ export const SettingsBtn = () => { Einstellungen
-
+
-

- Microfonlautstärke +

+ Eingabelautstärke

{ className="range range-xs range-accent w-full" />
- 0 - 100 - 200 - 300 + 0% + 25% + 50% + 75% + 100%
{showIndication && ( @@ -125,6 +129,33 @@ export const SettingsBtn = () => { )}
+

+ Funk Lautstärke +

+
+ { + const value = parseFloat(e.target.value); + setFunkVol(value); + }} + value={funkVolume} + className="range range-xs range-primary w-full" + /> +
+ 0% + 25% + 50% + 75% + 100% +
+
+
+
+

Melder Lautstärke

@@ -142,11 +173,14 @@ export const SettingsBtn = () => { testSoundRef.current.play(); }} value={dmeVolume} - className="range range-xs range-accent w-full" + className="range range-xs range-primary w-full" />
- 0 - 100 + 0% + 25% + 50% + 75% + 100%
@@ -168,10 +202,12 @@ export const SettingsBtn = () => { onSubmit={() => false} onClick={async () => { testSoundRef.current?.pause(); - const res = await editUserMutation.mutateAsync({ + await editUserMutation.mutateAsync({ + id: session.data!.user.id, user: { settingsMicDevice: selectedDevice, settingsMicVolume: micVol, + settingsRadioVolume: funkVolume, settingsDmeVolume: dmeVolume, }, }); diff --git a/apps/dispatch/app/_helpers/LivekitRoomManager.ts b/apps/dispatch/app/_helpers/LivekitRoomManager.ts new file mode 100644 index 00000000..96890f7a --- /dev/null +++ b/apps/dispatch/app/_helpers/LivekitRoomManager.ts @@ -0,0 +1,9 @@ +import { RoomServiceClient } from "livekit-server-sdk"; + +if (!process.env.NEXT_PUBLIC_LIVEKIT_URL) throw new Error("NEXT_PUBLIC_LIVEKIT_URL is not defined"); + +export const RoomManager = new RoomServiceClient( + process.env.NEXT_PUBLIC_LIVEKIT_URL!, + process.env.LIVEKIT_API_KEY, + process.env.LIVEKIT_API_SECRET, +); diff --git a/apps/dispatch/app/_helpers/findLeitstelleinPoint.ts b/apps/dispatch/app/_helpers/findLeitstelleinPoint.ts new file mode 100644 index 00000000..c4bfc5fd --- /dev/null +++ b/apps/dispatch/app/_helpers/findLeitstelleinPoint.ts @@ -0,0 +1,29 @@ +import { point, multiPolygon, booleanPointInPolygon, booleanIntersects, polygon } from "@turf/turf"; +import leitstellenGeoJSON from "../_components/map/_geojson/Leitstellen.json"; // Pfad anpassen + +export function findLeitstelleForPosition(lat: number, lng: number) { + const heliPoint = point([lat, lng]); + + for (const feature of (leitstellenGeoJSON as any).features) { + const geom = feature.geometry; + + if (geom.type === "Polygon") { + const turfPolygon = polygon(geom.coordinates); + if (booleanPointInPolygon(heliPoint, turfPolygon)) { + return feature.properties?.name ?? "Unbenannte Leitstelle"; + } + } else if (geom.type === "MultiPolygon") { + // MultiPolygon: check each polygon + for (const coords of geom.coordinates) { + const turfPolygon = polygon(coords); + if (booleanPointInPolygon(heliPoint, turfPolygon)) { + return feature.properties?.name ?? "Unbenannte Leitstelle"; + } + } + } else { + continue; // Andere Geometrietypen ignorieren + } + } + + return null; +} diff --git a/apps/dispatch/app/_helpers/fmsStatusColors.ts b/apps/dispatch/app/_helpers/fmsStatusColors.ts new file mode 100644 index 00000000..fa5a3c37 --- /dev/null +++ b/apps/dispatch/app/_helpers/fmsStatusColors.ts @@ -0,0 +1,46 @@ +export const FMS_STATUS_COLORS: { [key: string]: string } = { + "0": "rgb(140,10,10)", + "1": "rgb(10,134,25)", + "2": "rgb(10,134,25)", + "3": "rgb(140,10,10)", + "4": "rgb(140,10,10)", + "5": "rgb(231,77,22)", + "6": "rgb(85,85,85)", + "7": "rgb(140,10,10)", + "8": "rgb(186,105,0)", + "9": "rgb(10,134,25)", + E: "rgb(186,105,0)", + C: "rgb(186,105,0)", + F: "rgb(186,105,0)", + J: "rgb(186,105,0)", + L: "rgb(186,105,0)", + c: "rgb(186,105,0)", + d: "rgb(186,105,0)", + h: "rgb(186,105,0)", + o: "rgb(186,105,0)", + u: "rgb(186,105,0)", +}; + +export const FMS_STATUS_TEXT_COLORS: { [key: string]: string } = { + "0": "rgb(243,27,25)", + "1": "rgb(9,212,33)", + "2": "rgb(9,212,33)", + "3": "rgb(243,27,25)", + "4": "rgb(243,27,25)", + "5": "rgb(251,176,158)", + "6": "rgb(153,153,153)", + "7": "rgb(243,27,25)", + "8": "rgb(255,143,0)", + "9": "rgb(9,212,33)", + N: "rgb(9,212,33)", + E: "rgb(255,143,0)", + C: "rgb(255,143,0)", + F: "rgb(255,143,0)", + J: "rgb(255,143,0)", + L: "rgb(255,143,0)", + c: "rgb(255,143,0)", + d: "rgb(255,143,0)", + h: "rgb(255,143,0)", + o: "rgb(255,143,0)", + u: "rgb(255,143,0)", +}; diff --git a/apps/dispatch/app/_helpers/liveKitEventHandler.ts b/apps/dispatch/app/_helpers/liveKitEventHandler.ts index 16e2cdaa..33af642a 100644 --- a/apps/dispatch/app/_helpers/liveKitEventHandler.ts +++ b/apps/dispatch/app/_helpers/liveKitEventHandler.ts @@ -20,13 +20,9 @@ export const handleTrackSubscribed = ( } track.on("unmuted", () => { useAudioStore.getState().addSpeakingParticipant(participant); - - console.log("Track unmuted:", track); }); track.on("muted", () => { useAudioStore.getState().removeSpeakingParticipant(participant); - - console.log("Track muted:", track); }); if (track.kind === Track.Kind.Video || track.kind === Track.Kind.Audio) { // attach it to a new HTMLVideoElement or HTMLAudioElement diff --git a/apps/dispatch/app/_helpers/simulatorConnected.ts b/apps/dispatch/app/_helpers/simulatorConnected.ts index 35e3a98d..cf38f637 100644 --- a/apps/dispatch/app/_helpers/simulatorConnected.ts +++ b/apps/dispatch/app/_helpers/simulatorConnected.ts @@ -1,2 +1,7 @@ -export const checkSimulatorConnected = (date: Date) => - date && Date.now() - new Date(date).getTime() <= 3000_000; +import { ConnectedAircraft } from "@repo/db"; + +export const checkSimulatorConnected = (a: ConnectedAircraft) => { + if (!a.lastHeartbeat || Date.now() - new Date(a.lastHeartbeat).getTime() > 30_000) return false; // 30 seconds + if (!a.posLat || !a.posLng) return false; + return true; +}; diff --git a/apps/dispatch/app/_querys/aircrafts.ts b/apps/dispatch/app/_querys/aircrafts.ts index c95db5ab..f6e11767 100644 --- a/apps/dispatch/app/_querys/aircrafts.ts +++ b/apps/dispatch/app/_querys/aircrafts.ts @@ -1,13 +1,14 @@ -import { ConnectedAircraft, Prisma, PublicUser, Station } from "@repo/db"; +import { ConnectedAircraft, PositionLog, Prisma, PublicUser, Station } from "@repo/db"; import axios from "axios"; import { serverApi } from "_helpers/axios"; +import { checkSimulatorConnected } from "_helpers/simulatorConnected"; export const getConnectedAircraftsAPI = async () => { const res = await axios.get<(ConnectedAircraft & { Station: Station })[]>("/api/aircrafts"); // return only connected aircrafts if (res.status !== 200) { throw new Error("Failed to fetch stations"); } - return res.data; + return res.data.filter((a) => checkSimulatorConnected(a)); }; export const editConnectedAircraftAPI = async ( @@ -17,3 +18,24 @@ export const editConnectedAircraftAPI = async ( const respone = await serverApi.patch(`/aircrafts/${id}`, mission); return respone.data; }; + +export const getConnectedAircraftPositionLogAPI = async ({ id }: { id: number }) => { + const res = await axios.get("/api/aircrafts/positionlog", { + params: { connectedAircraftId: id }, + }); + if (res.status !== 200) { + throw new Error("Failed to fetch aircraft position log"); + } + return res.data; +}; + +export const kickAircraftAPI = async ({ id, bann }: { id: number; bann?: boolean }) => { + const res = await serverApi.delete(`/aircrafts/${id}`, { + data: { bann }, + }); + console.log(res.status); + if (res.status != 204) { + throw new Error("Failed to kick aircraft"); + } + return res.data; +}; diff --git a/apps/dispatch/app/_querys/connected-user.ts b/apps/dispatch/app/_querys/dispatcher.ts similarity index 75% rename from apps/dispatch/app/_querys/connected-user.ts rename to apps/dispatch/app/_querys/dispatcher.ts index b3d86ab7..319d50af 100644 --- a/apps/dispatch/app/_querys/connected-user.ts +++ b/apps/dispatch/app/_querys/dispatcher.ts @@ -2,17 +2,6 @@ import { ConnectedAircraft, ConnectedDispatcher, Prisma } from "@repo/db"; import { serverApi } from "_helpers/axios"; import axios from "axios"; -export const getConnectedUserAPI = async () => { - const res = await axios.get<(ConnectedAircraft | ConnectedDispatcher)[]>( - "/api/connected-user", - {}, - ); - if (res.status !== 200) { - throw new Error("Failed to fetch Connected User"); - } - return res.data; -}; - export const changeDispatcherAPI = async ( id: number, data: Prisma.ConnectedDispatcherUpdateInput, @@ -35,3 +24,14 @@ export const getConnectedDispatcherAPI = async (filter?: Prisma.ConnectedDispatc } return res.data; }; + +export const kickDispatcherAPI = async ({ id, bann }: { id: number; bann?: boolean }) => { + const res = await serverApi.delete(`/dispatcher/${id}`, { + data: { bann }, + }); + console.log(res.status); + if (res.status != 204) { + throw new Error("Failed to kick aircraft"); + } + return res.data; +}; diff --git a/apps/dispatch/app/_querys/livekit.ts b/apps/dispatch/app/_querys/livekit.ts new file mode 100644 index 00000000..031b7d12 --- /dev/null +++ b/apps/dispatch/app/_querys/livekit.ts @@ -0,0 +1,28 @@ +import axios from "axios"; +import { Room } from "livekit-client"; +import { ParticipantInfo } from "livekit-server-sdk"; + +export const getLivekitRooms = async () => { + const res = await axios.get< + { + room: Room; + participants: ParticipantInfo[]; + }[] + >("/api/livekit-participant"); + + if (res.status !== 200) { + throw new Error("Failed to fetch keywords"); + } + return res.data; +}; + +export const kickLivekitParticipant = async (body: { identity: string; roomName: string }) => { + const res = await axios.delete("/api/livekit-participant", { + params: body, + }); + + if (res.status !== 200) { + throw new Error("Failed to kick participant"); + } + return res.data; +}; diff --git a/apps/dispatch/app/_querys/missions.ts b/apps/dispatch/app/_querys/missions.ts index 12d45cc9..5b106beb 100644 --- a/apps/dispatch/app/_querys/missions.ts +++ b/apps/dispatch/app/_querys/missions.ts @@ -29,8 +29,14 @@ export const editMissionAPI = async (id: number, mission: Prisma.MissionUpdateIn 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); +export const sendSdsMessageAPI = async ({ + missionId, + sdsMessage, +}: { + missionId?: number; + sdsMessage: MissionSdsLog; +}) => { + const respone = await serverApi.post(`/mission/send-sds`, { sdsMessage, missionId }); return respone.data; }; @@ -51,7 +57,7 @@ export const sendMissionAPI = async ( vehicleName, }: { stationId?: number; - vehicleName?: "ambulance" | "police" | "firebrigade"; + vehicleName?: "RTW" | "POL" | "FW"; }, ) => { const respone = await serverApi.post<{ diff --git a/apps/dispatch/app/_querys/user.ts b/apps/dispatch/app/_querys/user.ts index 6e4cb8cf..55524cab 100644 --- a/apps/dispatch/app/_querys/user.ts +++ b/apps/dispatch/app/_querys/user.ts @@ -1,7 +1,7 @@ import { Prisma, User } from "@repo/db"; import axios from "axios"; -export const editUserAPI = async (id: string, user: Prisma.UserUpdateInput) => { +export const editUserAPI = async ({ id, user }: { id: string; user: Prisma.UserUpdateInput }) => { const response = await axios.post(`/api/user?id=${id}`, user); return response.data; }; diff --git a/apps/dispatch/app/_store/audioStore.ts b/apps/dispatch/app/_store/audioStore.ts index e18c97c0..a736deee 100644 --- a/apps/dispatch/app/_store/audioStore.ts +++ b/apps/dispatch/app/_store/audioStore.ts @@ -1,16 +1,16 @@ -import { PublicUser } from "@repo/db"; import { dispatchSocket } from "dispatch/socket"; -import { serverApi } from "_helpers/axios"; import { handleDisconnect, handleLocalTrackUnpublished, handleTrackSubscribed, handleTrackUnsubscribed, } from "_helpers/liveKitEventHandler"; -import { ConnectionQuality, Participant, Room, RoomEvent } from "livekit-client"; +import { ConnectionQuality, Participant, Room, RoomEvent, RpcInvocationData } from "livekit-client"; import { pilotSocket } from "pilot/socket"; import { create } from "zustand"; import axios from "axios"; +import { useDispatchConnectionStore } from "_store/dispatch/connectionStore"; +import { changeDispatcherAPI } from "_querys/dispatcher"; let interval: NodeJS.Timeout; @@ -18,6 +18,7 @@ type TalkState = { micDeviceId: string | null; micVolume: number; isTalking: boolean; + transmitBlocked: boolean; removeMessage: () => void; state: "connecting" | "connected" | "disconnected" | "error"; message: string | null; @@ -40,12 +41,12 @@ const getToken = async (roomName: string) => { export const useAudioStore = create((set, get) => ({ isTalking: false, + transmitBlocked: false, message: null, micDeviceId: null, speakingParticipants: [], micVolume: 1, - state: "disconnected", - source: "", + state: "disconnected" as const, remoteParticipants: 0, connectionQuality: ConnectionQuality.Unknown, room: null, @@ -61,43 +62,49 @@ export const useAudioStore = create((set, get) => ({ set({ message: null }); }, removeSpeakingParticipant: (participant) => { + const newSpeaktingParticipants = get().speakingParticipants.filter( + (p) => !(p.identity === participant.identity), + ); set((state) => ({ - speakingParticipants: state.speakingParticipants.filter( - (p) => !(p.identity === participant.identity), - ), + speakingParticipants: newSpeaktingParticipants, })); + if (newSpeaktingParticipants.length === 0 && get().transmitBlocked) { + get().room?.localParticipant.setMicrophoneEnabled(true); + set({ transmitBlocked: false, message: null, isTalking: true }); + } }, setMic: (micDeviceId, micVolume) => { set({ micDeviceId, micVolume }); }, toggleTalking: () => { - const { room, isTalking, micDeviceId, micVolume } = get(); + const { room, isTalking, micDeviceId, micVolume, speakingParticipants, transmitBlocked } = + get(); if (!room) return; + + if (speakingParticipants.length > 0 && !isTalking && !transmitBlocked) { + // Wenn andere sprechen, nicht reden + set({ + message: "Rufgruppe besetzt", + transmitBlocked: true, + }); + return; + } else if (!isTalking && transmitBlocked) { + set({ + message: null, + transmitBlocked: false, + }); + return; + } // Todo: use micVolume room.localParticipant.setMicrophoneEnabled(!isTalking, { deviceId: micDeviceId ?? undefined, }); - if (!isTalking) { - // If old status was not talking, we need to emit the PTT event - if (pilotSocket.connected) { - pilotSocket.emit("ptt", { - shouldTransmit: true, - channel: room.name, - }); - } - if (dispatchSocket.connected) - dispatchSocket.emit("ptt", { - shouldTransmit: true, - channel: room.name, - }); - } - - set((state) => ({ isTalking: !state.isTalking })); + set((state) => ({ isTalking: !state.isTalking, transmitBlocked: false })); }, connect: async (roomName, role) => { set({ state: "connecting" }); - console.log("Connecting to room: ", roomName); + try { // Clean old room const connectedRoom = get().room; @@ -116,7 +123,15 @@ export const useAudioStore = create((set, get) => ({ await room.prepareConnection(url, token); room // Connection events - .on(RoomEvent.Connected, () => { + .on(RoomEvent.Connected, async () => { + const dispatchState = useDispatchConnectionStore.getState(); + + if (dispatchState.status === "connected" && dispatchState.connectedDispatcher?.id) { + changeDispatcherAPI(dispatchState.connectedDispatcher?.id, { + zone: roomName, + }); + } + set({ state: "connected", room, message: null }); }) .on(RoomEvent.Disconnected, () => { @@ -131,11 +146,22 @@ export const useAudioStore = create((set, get) => ({ .on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed) .on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnpublished); await room.connect(url, token, {}); + room.localParticipant.setAttributes({ role, }); set({ room }); + room.registerRpcMethod("force-mute", async (data: RpcInvocationData) => { + const { by } = JSON.parse(data.payload); + room.localParticipant.setMicrophoneEnabled(false); + useAudioStore.setState({ + isTalking: false, + message: `Ruf beendet durch ${by || "eine unsichtbare Macht"}`, + }); + return `Hello, ${data.callerIdentity}!`; + }); + interval = setInterval(() => { set({ remoteParticipants: room.numParticipants === 0 ? 0 : room.numParticipants - 1, // Unreliable and delayed @@ -162,11 +188,21 @@ interface PTTData { const handlePTT = (data: PTTData) => { const { shouldTransmit, source } = data; - const { room } = useAudioStore.getState(); + const { room, speakingParticipants } = useAudioStore.getState(); if (!room) return; + if (speakingParticipants.length > 0 && shouldTransmit) { + // Wenn andere sprechen, nicht reden + useAudioStore.setState({ + message: "Rufgruppe besetzt", + transmitBlocked: true, + }); + return; + } + useAudioStore.setState({ isTalking: shouldTransmit, + transmitBlocked: false, }); if (shouldTransmit) { @@ -176,19 +212,5 @@ const handlePTT = (data: PTTData) => { } }; -const handleForceEndTransmission = ({ by }: { by?: string }) => { - const { room } = useAudioStore.getState(); - - if (!room) return; - room.localParticipant.setMicrophoneEnabled(false); - useAudioStore.setState({ - isTalking: false, - message: `Ruf beendet durch ${by || "unknown"}`, - }); -}; - pilotSocket.on("ptt", handlePTT); dispatchSocket.on("ptt", handlePTT); - -pilotSocket.on("force-end-transmission", handleForceEndTransmission); -dispatchSocket.on("force-end-transmission", handleForceEndTransmission); diff --git a/apps/dispatch/app/_store/dispatch/connectionStore.ts b/apps/dispatch/app/_store/dispatch/connectionStore.ts index 5ed3dddc..356b3060 100644 --- a/apps/dispatch/app/_store/dispatch/connectionStore.ts +++ b/apps/dispatch/app/_store/dispatch/connectionStore.ts @@ -5,6 +5,8 @@ import { ConnectedDispatcher } from "@repo/db"; interface ConnectionStore { status: "connected" | "disconnected" | "connecting" | "error"; + hideDraftMissions: boolean; + setHideDraftMissions: (hide: boolean) => void; connectedDispatcher: ConnectedDispatcher | null; message: string; selectedZone: string; @@ -15,6 +17,8 @@ interface ConnectionStore { export const useDispatchConnectionStore = create((set) => ({ status: "disconnected", + hideDraftMissions: false, + setHideDraftMissions: (hide) => set({ hideDraftMissions: hide }), connectedDispatcher: null, message: "", selectedZone: "LST_01", @@ -65,7 +69,6 @@ dispatchSocket.on("force-disconnect", (reason: string) => { }); }); dispatchSocket.on("dispatchers-update", (dispatch: ConnectedDispatcher) => { - console.log("dispatchers-update", dispatch); useDispatchConnectionStore.setState({ connectedDispatcher: dispatch, }); diff --git a/apps/dispatch/app/_store/leftMenuStore.ts b/apps/dispatch/app/_store/leftMenuStore.ts index 6c613f0b..653e93d9 100644 --- a/apps/dispatch/app/_store/leftMenuStore.ts +++ b/apps/dispatch/app/_store/leftMenuStore.ts @@ -30,18 +30,38 @@ export const useLeftMenuStore = create((set, get) => ({ chatOpen: false, selectedChat: null, setChatOpen: (open: boolean) => set({ chatOpen: open }), - setSelectedChat: (chatId: string | null) => set({ selectedChat: chatId }), + setSelectedChat: (chatId: string | null) => { + const { setChatNotification } = get(); + set({ selectedChat: chatId }); + if (chatId) { + setChatNotification(chatId, false); // Set notification to false when chat is selected + } + }, setOwnId: (id: string) => set({ ownId: id }), chats: {}, sendMessage: (userId: string, message: string) => { return new Promise((resolve, reject) => { - dispatchSocket.emit("send-message", { userId, message }, ({ error }: { error?: string }) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); + if (dispatchSocket.connected) { + dispatchSocket.emit( + "send-message", + { userId, message }, + ({ error }: { error?: string }) => { + if (error) { + reject(error); + } else { + resolve(); + } + }, + ); + } else if (pilotSocket.connected) { + pilotSocket.emit("send-message", { userId, message }, ({ error }: { error?: string }) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + } }); }, addChat: (userId, name) => { @@ -55,7 +75,6 @@ export const useLeftMenuStore = create((set, get) => ({ setChatNotification: (userId, notification) => { const chat = get().chats[userId]; if (!chat) return; - console.log("setChatNotification", userId, notification); set((state) => { return { chats: { @@ -84,7 +103,7 @@ export const useLeftMenuStore = create((set, get) => ({ [userId]: { ...user, name: isSender ? message.receiverName : message.senderName, - notification: !isSender && (state.selectedChat !== userId || !state.chatOpen), + notification: state.selectedChat !== userId || !state.chatOpen, messages: [...user.messages, message], // Neuen Zustand erzeugen }, }, diff --git a/apps/dispatch/app/_store/mapStore.ts b/apps/dispatch/app/_store/mapStore.ts index 06a5ea71..86d9ef48 100644 --- a/apps/dispatch/app/_store/mapStore.ts +++ b/apps/dispatch/app/_store/mapStore.ts @@ -15,10 +15,7 @@ export interface MapStore { id: number; tab: "home" | "details" | "patient" | "log"; }[]; - setOpenMissionMarker: (mission: { - open: MapStore["openMissionMarker"]; - close: number[]; - }) => void; + setOpenMissionMarker: (mission: { open: MapStore["openMissionMarker"]; close: number[] }) => void; openAircraftMarker: { id: number; tab: "home" | "fms" | "aircraft" | "mission" | "chat"; @@ -29,6 +26,7 @@ export interface MapStore { }) => void; searchElements: OSMWay[]; setSearchElements: (elements: MapStore["searchElements"]) => void; + toggleSearchElementSelection: (elementId: number) => void; setContextMenu: (popup: MapStore["contextMenu"]) => void; searchPopup: { lat: number; @@ -39,10 +37,7 @@ export interface MapStore { aircraftTabs: { [aircraftId: string]: "home" | "fms" | "aircraft" | "mission" | "chat"; }; - setAircraftTab: ( - aircraftId: number, - tab: MapStore["aircraftTabs"][number], - ) => void; + setAircraftTab: (aircraftId: number, tab: MapStore["aircraftTabs"][number]) => void; } export const useMapStore = create((set, get) => ({ @@ -88,6 +83,15 @@ export const useMapStore = create((set, get) => ({ set(() => ({ searchElements: elements, })), + toggleSearchElementSelection: (elementId) => { + const searchElements = get().searchElements; + const element = searchElements.find((e) => e.wayID === elementId); + if (!element) return; + element.isSelected = !element.isSelected; + set(() => ({ + searchElements: [...searchElements], + })); + }, aircraftTabs: {}, setAircraftTab: (aircraftId, tab) => set((state) => ({ diff --git a/apps/dispatch/app/_store/pannelStore.ts b/apps/dispatch/app/_store/pannelStore.ts index 3fc53353..de5b53ba 100644 --- a/apps/dispatch/app/_store/pannelStore.ts +++ b/apps/dispatch/app/_store/pannelStore.ts @@ -8,8 +8,8 @@ interface PannelStore { missionFormValues?: Partial; setMissionFormValues: (values: Partial) => void; isEditingMission: boolean; - editingMissionId: string | null; - setEditingMission: (isEditing: boolean, missionId: string | null) => void; + editingMissionId: number | null; + setEditingMission: (isEditing: boolean, missionId: number | null) => void; } export const usePannelStore = create((set) => ({ diff --git a/apps/dispatch/app/_store/pilot/MrtStore.ts b/apps/dispatch/app/_store/pilot/MrtStore.ts index f8d70138..ea60174f 100644 --- a/apps/dispatch/app/_store/pilot/MrtStore.ts +++ b/apps/dispatch/app/_store/pilot/MrtStore.ts @@ -122,7 +122,7 @@ export const useMrtStore = create( }, { textLeft: "ILS VAR#", textSize: "3" }, { - textLeft: "new status received", + textLeft: "empfangen", style: { fontWeight: "bold" }, textSize: "4", }, @@ -132,18 +132,29 @@ export const useMrtStore = create( } case "sds": { const { sdsMessage } = pageData as SetSdsPageParams; + const msg = sdsMessage.data.message; set({ page: "sds", lines: [ { - textLeft: `neue SDS-Nachricht`, + textLeft: `SDS-Nachricht`, style: { fontWeight: "bold" }, textSize: "2", }, { - textLeft: sdsMessage.data.message, - style: {}, - textSize: "1", + textLeft: msg, + style: { + whiteSpace: "normal", + overflowWrap: "break-word", + wordBreak: "break-word", + display: "block", + maxWidth: "100%", + maxHeight: "100%", + overflow: "auto", + textOverflow: "ellipsis", + lineHeight: "1.2em", + }, + textSize: "2", }, ], }); diff --git a/apps/dispatch/app/_store/pilot/connectionStore.ts b/apps/dispatch/app/_store/pilot/connectionStore.ts index 1c3044d3..3d40e2c5 100644 --- a/apps/dispatch/app/_store/pilot/connectionStore.ts +++ b/apps/dispatch/app/_store/pilot/connectionStore.ts @@ -9,6 +9,7 @@ import { useAudioStore } from "_store/audioStore"; interface ConnectionStore { status: "connected" | "disconnected" | "connecting" | "error"; message: string; + logoffTime: string; selectedStation: Station | null; connectedAircraft: ConnectedAircraft | null; @@ -34,6 +35,7 @@ export const usePilotConnectionStore = create((set) => ({ selectedStation: null, connectedAircraft: null, activeMission: null, + connect: async (uid, stationId, logoffTime, station, user) => new Promise((resolve) => { set({ diff --git a/apps/dispatch/app/api/aircrafts/positionlog/route.ts b/apps/dispatch/app/api/aircrafts/positionlog/route.ts new file mode 100644 index 00000000..61e14582 --- /dev/null +++ b/apps/dispatch/app/api/aircrafts/positionlog/route.ts @@ -0,0 +1,33 @@ +import { prisma, Prisma } from "@repo/db"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest): Promise { + try { + const connectedAircraftId = request.nextUrl.searchParams.get("connectedAircraftId"); + + const aircraft = await prisma.connectedAircraft.findUnique({ + where: { + id: connectedAircraftId ? parseInt(connectedAircraftId) : undefined, + }, + }); + if (!aircraft) return NextResponse.json({ error: "Aircraft not found" }, { status: 404 }); + + const positionLog = await prisma.positionLog.findMany({ + where: { + id: { + in: aircraft.positionLogIds, + }, + timestamp: { + gte: new Date(Date.now() - 2 * 60 * 60 * 1000), // Last 2 hours + }, + }, + }); + + return NextResponse.json(positionLog, { + status: 200, + }); + } catch (error) { + console.error(error); + return NextResponse.json({ error: "Failed to fetch Aircrafts" }, { status: 500 }); + } +} diff --git a/apps/dispatch/app/api/livekit-participant/route.ts b/apps/dispatch/app/api/livekit-participant/route.ts new file mode 100644 index 00000000..2cf644ee --- /dev/null +++ b/apps/dispatch/app/api/livekit-participant/route.ts @@ -0,0 +1,82 @@ +import { prisma } from "@repo/db"; +import { RoomManager } from "_helpers/LivekitRoomManager"; +import { getServerSession } from "api/auth/[...nextauth]/auth"; +import { NextRequest } from "next/server"; + +export const GET = async (request: NextRequest) => { + const session = await getServerSession(); + + if (!session) return Response.json({ message: "Unauthorized" }, { status: 401 }); + const user = await prisma.user.findUnique({ + where: { + id: session.user.id, + }, + }); + + const rooms = await RoomManager.listRooms(); + + const roomsWithParticipants = rooms.map(async (room) => { + const participants = await RoomManager.listParticipants(room.name); + return { + room, + participants, + }; + }); + + return Response.json(await Promise.all(roomsWithParticipants), { status: 200 }); +}; + +export const DELETE = async (request: NextRequest) => { + try { + const identity = request.nextUrl.searchParams.get("identity"); + const roomName = request.nextUrl.searchParams.get("roomName"); + const ban = request.nextUrl.searchParams.get("ban"); + + if (!identity) return Response.json({ message: "Missing User identity" }, { status: 400 }); + if (!roomName) return Response.json({ message: "Missing roomName" }, { status: 400 }); + + const session = await getServerSession(); + + if (!session) return Response.json({ message: "Unauthorized" }, { status: 401 }); + const user = await prisma.user.findUnique({ + where: { + id: session.user.id, + }, + }); + + if (!user || !user.permissions.includes("AUDIO_ADMIN")) + return Response.json({ message: "Missing permissions" }, { status: 401 }); + + if (ban && !user.permissions.includes("ADMIN_USER")) { + return Response.json({ message: "Missing permissions to ban user" }, { status: 401 }); + } + + if (ban) { + const participant = await RoomManager.getParticipant(roomName, identity); + const pUser = await prisma.user.findUnique({ + where: { + id: participant.attributes.userId, + }, + }); + if (!pUser) return; + // If the user is banned, we need to remove their permissions + await prisma.user.update({ + where: { id: session.user.id }, + data: { + permissions: { + set: pUser.permissions.filter((p) => p !== "AUDIO"), + }, + }, + }); + } + + await RoomManager.removeParticipant(roomName, identity); + return Response.json( + { message: `User ${identity} kicked from room ${roomName}` }, + { status: 200 }, + ); + } catch (error) { + console.error("Error in DELETE /api/livekit-participant:", error); + return Response.json({ message: "Internal Server Error" }, { status: 500 }); + } +}; diff --git a/apps/dispatch/app/api/livekit-token/route.ts b/apps/dispatch/app/api/livekit-token/route.ts index 7740e8f5..23e5219f 100644 --- a/apps/dispatch/app/api/livekit-token/route.ts +++ b/apps/dispatch/app/api/livekit-token/route.ts @@ -25,12 +25,9 @@ export const GET = async (request: NextRequest) => { if (!user || !user.permissions.includes("AUDIO")) return Response.json({ message: "Missing permissions" }, { status: 401 }); - const participantName = user.publicId; - const at = new AccessToken(process.env.LIVEKIT_API_KEY, process.env.LIVEKIT_API_SECRET, { - identity: participantName, - // Token to expire after 10 minutes - ttl: "1d", + identity: user.publicId, + ttl: "1h", }); at.addGrant({ @@ -43,6 +40,8 @@ export const GET = async (request: NextRequest) => { at.attributes = { publicId: user.publicId, + publicUser: JSON.stringify(getPublicUser(user)), + userId: user.id, }; const token = await at.toJwt(); diff --git a/apps/dispatch/app/api/position-log/route.ts b/apps/dispatch/app/api/position-log/route.ts index 4ebf0269..42dd9960 100644 --- a/apps/dispatch/app/api/position-log/route.ts +++ b/apps/dispatch/app/api/position-log/route.ts @@ -24,13 +24,10 @@ export const PUT = async (req: Request) => { position: PositionLog; h145: boolean; }; - console.log("position", userId); if (!position) { return Response.json({ message: "Missing id or position" }); } - console.log("position", position); - const activeAircraft = await prisma.connectedAircraft.findFirst({ where: { userId, diff --git a/apps/dispatch/app/dispatch/_components/navbar/Navbar.tsx b/apps/dispatch/app/dispatch/_components/navbar/Navbar.tsx index f7e8ad49..9568f968 100644 --- a/apps/dispatch/app/dispatch/_components/navbar/Navbar.tsx +++ b/apps/dispatch/app/dispatch/_components/navbar/Navbar.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Connection } from "./_components/Connection"; /* import { ThemeSwap } from "./_components/ThemeSwap"; */ import { Audio } from "../../../_components/Audio/Audio"; @@ -8,22 +6,18 @@ import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons"; import Link from "next/link"; import { Settings } from "_components/navbar/Settings"; import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown"; +import AdminPanel from "_components/navbar/AdminPanel"; +import { getServerSession } from "api/auth/[...nextauth]/auth"; -export default function Navbar() { - /* const [isDark, setIsDark] = useState(false); - - const toggleTheme = () => { - const newTheme = !isDark; - setIsDark(newTheme); - document.documentElement.setAttribute( - "data-theme", - newTheme ? "nord" : "dark", - ); - }; */ +export default async function Navbar() { + const session = await getServerSession(); return (
- +
+ + {session?.user.permissions.includes("ADMIN_KICK") && } +