Merge pull request #119 from VAR-Virtual-Air-Rescue/staging
v2.0.2
This commit was merged in pull request #119.
This commit is contained in:
@@ -14,113 +14,118 @@ export const sendAlert = async (
|
||||
connectedAircrafts: ConnectedAircraft[];
|
||||
mission: Mission;
|
||||
}> => {
|
||||
const mission = await prisma.mission.findUnique({
|
||||
where: { id: id },
|
||||
});
|
||||
const Stations = await prisma.station.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: mission?.missionStationIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!mission) {
|
||||
throw new Error("Mission not found");
|
||||
}
|
||||
|
||||
// connectedAircrafts the alert is sent to
|
||||
const connectedAircrafts = await prisma.connectedAircraft.findMany({
|
||||
where: {
|
||||
stationId: stationId
|
||||
? stationId
|
||||
: {
|
||||
in: mission.missionStationIds,
|
||||
},
|
||||
logoutTime: null,
|
||||
},
|
||||
include: {
|
||||
Station: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const aircraft of connectedAircrafts) {
|
||||
io.to(`station:${aircraft.stationId}`).emit("mission-alert", {
|
||||
...mission,
|
||||
Stations,
|
||||
try {
|
||||
const mission = await prisma.mission.findUnique({
|
||||
where: { id: id },
|
||||
});
|
||||
io.to(`desktop:${aircraft.userId}`).emit("mission-alert", {
|
||||
missionId: mission.id,
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: aircraft.userId },
|
||||
});
|
||||
if (!user) continue;
|
||||
if (user.settingsNtfyRoom) {
|
||||
await sendNtfyMission(mission, Stations, aircraft.Station, user.settingsNtfyRoom);
|
||||
}
|
||||
const existingMissionOnStationUser = await prisma.missionOnStationUsers.findFirst({
|
||||
const Stations = await prisma.station.findMany({
|
||||
where: {
|
||||
missionId: mission.id,
|
||||
userId: aircraft.userId,
|
||||
stationId: aircraft.stationId,
|
||||
id: {
|
||||
in: mission?.missionStationIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingMissionOnStationUser)
|
||||
await prisma.missionOnStationUsers.create({
|
||||
data: {
|
||||
if (!mission) {
|
||||
throw new Error("Mission not found");
|
||||
}
|
||||
|
||||
// connectedAircrafts the alert is sent to
|
||||
const connectedAircrafts = await prisma.connectedAircraft.findMany({
|
||||
where: {
|
||||
stationId: stationId
|
||||
? stationId
|
||||
: {
|
||||
in: mission.missionStationIds,
|
||||
},
|
||||
logoutTime: null,
|
||||
},
|
||||
include: {
|
||||
Station: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const aircraft of connectedAircrafts) {
|
||||
io.to(`station:${aircraft.stationId}`).emit("mission-alert", {
|
||||
...mission,
|
||||
Stations,
|
||||
});
|
||||
io.to(`desktop:${aircraft.userId}`).emit("mission-alert", {
|
||||
missionId: mission.id,
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: aircraft.userId },
|
||||
});
|
||||
if (!user) continue;
|
||||
if (user.settingsNtfyRoom) {
|
||||
await sendNtfyMission(mission, Stations, aircraft.Station, user.settingsNtfyRoom);
|
||||
}
|
||||
const existingMissionOnStationUser = await prisma.missionOnStationUsers.findFirst({
|
||||
where: {
|
||||
missionId: mission.id,
|
||||
userId: aircraft.userId,
|
||||
stationId: aircraft.stationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// for statistics only
|
||||
await prisma.missionsOnStations
|
||||
.createMany({
|
||||
data: mission.missionStationIds.map((stationId) => ({
|
||||
missionId: mission.id,
|
||||
stationId,
|
||||
})),
|
||||
})
|
||||
.catch((err) => {
|
||||
// Ignore if the entry already exists
|
||||
});
|
||||
if (user === "HPG") {
|
||||
await prisma.mission.update({
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
state: "running",
|
||||
missionLog: {
|
||||
push: {
|
||||
type: "alert-log",
|
||||
auto: true,
|
||||
timeStamp: new Date().toISOString(),
|
||||
} as any,
|
||||
if (!existingMissionOnStationUser)
|
||||
await prisma.missionOnStationUsers.create({
|
||||
data: {
|
||||
missionId: mission.id,
|
||||
userId: aircraft.userId,
|
||||
stationId: aircraft.stationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// for statistics only
|
||||
await prisma.missionsOnStations
|
||||
.createMany({
|
||||
data: mission.missionStationIds.map((stationId) => ({
|
||||
missionId: mission.id,
|
||||
stationId,
|
||||
})),
|
||||
})
|
||||
.catch((err) => {
|
||||
// Ignore if the entry already exists
|
||||
});
|
||||
if (user === "HPG") {
|
||||
await prisma.mission.update({
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
state: "running",
|
||||
missionLog: {
|
||||
push: {
|
||||
type: "alert-log",
|
||||
auto: true,
|
||||
timeStamp: new Date().toISOString(),
|
||||
} as any,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.mission.update({
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
state: "running",
|
||||
missionLog: {
|
||||
push: {
|
||||
type: "alert-log",
|
||||
auto: false,
|
||||
timeStamp: new Date().toISOString(),
|
||||
data: {
|
||||
stationId: stationId,
|
||||
user: getPublicUser(user, { ignorePrivacy: true }),
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
} else {
|
||||
await prisma.mission.update({
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
state: "running",
|
||||
missionLog: {
|
||||
push: {
|
||||
type: "alert-log",
|
||||
auto: false,
|
||||
timeStamp: new Date().toISOString(),
|
||||
data: {
|
||||
stationId: stationId,
|
||||
user: getPublicUser(user, { ignorePrivacy: true }),
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
return { connectedAircrafts, mission };
|
||||
} catch (error) {
|
||||
console.error("Error sending mission alert:", error);
|
||||
throw new Error("Ein Fehler ist aufgetreten. Bitte melde den Fehler als Bug");
|
||||
}
|
||||
return { connectedAircrafts, mission };
|
||||
};
|
||||
|
||||
@@ -50,10 +50,7 @@ const getRthCallsigns = (mission: Mission, stations: Station[]) => {
|
||||
return `🚁 RTH${callsigns.length > 1 ? "s" : ""}: ${callsigns.join(" / ")} `;
|
||||
};
|
||||
|
||||
const getNtfyHeader = (
|
||||
mission: Mission,
|
||||
clientStation: Station,
|
||||
): NtfyHeader => ({
|
||||
const getNtfyHeader = (mission: Mission, clientStation: Station): NtfyHeader => ({
|
||||
headers: {
|
||||
Title: `${clientStation.bosCallsignShort} / ${mission.missionKeywordAbbreviation} / ${mission.missionKeywordCategory}`,
|
||||
Tags: "pager",
|
||||
@@ -76,9 +73,13 @@ export const sendNtfyMission = async (
|
||||
clientStation: Station,
|
||||
ntfyRoom: string,
|
||||
) => {
|
||||
axios.post(
|
||||
`https://ntfy.sh/${ntfyRoom}`,
|
||||
getNtfyData(mission, stations),
|
||||
getNtfyHeader(mission, clientStation),
|
||||
);
|
||||
try {
|
||||
await axios.post(
|
||||
`https://ntfy.sh/${ntfyRoom}`,
|
||||
getNtfyData(mission, stations),
|
||||
getNtfyHeader(mission, clientStation),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error sending Ntfy mission:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ router.patch("/:id", async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
if (discordAccount?.id) {
|
||||
if (discordAccount?.id && !disaptcherUpdate.ghostMode) {
|
||||
await renameMember(
|
||||
discordAccount.discordId.toString(),
|
||||
`${getPublicUser(newDispatcher.user).fullName} • ${newDispatcher.zone}`,
|
||||
|
||||
@@ -189,7 +189,11 @@ router.post("/:id/send-alert", async (req, res) => {
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: "Failed to send mission" });
|
||||
res
|
||||
.status(500)
|
||||
.json({
|
||||
error: `Ein Fehler ist aufgetreten. Bitte melde den Fehler als Bug (${(error as Error).message})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -99,7 +99,7 @@ export const handleConnectDispatch =
|
||||
|
||||
io.to("dispatchers").emit("dispatchers-update");
|
||||
io.to("pilots").emit("dispatchers-update");
|
||||
if (discordAccount?.id && !ghostMode) {
|
||||
if (discordAccount?.id) {
|
||||
await renameMember(
|
||||
discordAccount.discordId.toString(),
|
||||
`${getPublicUser(user).fullName} - ${user.publicId}`,
|
||||
|
||||
@@ -6,12 +6,14 @@ ARG NEXT_PUBLIC_HUB_URL
|
||||
ARG NEXT_PUBLIC_DISPATCH_SERVICE_ID
|
||||
ARG NEXT_PUBLIC_LIVEKIT_URL
|
||||
ARG NEXT_PUBLIC_DISCORD_URL
|
||||
ARG NEXT_PUBLIC_OPENAIP_ACCESS
|
||||
|
||||
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_OPENAIP_ACCESS=$NEXT_PUBLIC_OPENAIP_ACCESS
|
||||
ENV NEXT_PUBLIC_DISCORD_URL=$NEXT_PUBLIC_DISCORD_URL
|
||||
|
||||
ENV PNPM_HOME="/usr/local/pnpm"
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useSounds } from "_components/Audio/useSounds";
|
||||
export const Audio = () => {
|
||||
const {
|
||||
speakingParticipants,
|
||||
resetSpeakingParticipants,
|
||||
isTalking,
|
||||
toggleTalking,
|
||||
transmitBlocked,
|
||||
@@ -104,7 +105,7 @@ export const Audio = () => {
|
||||
data-tip="Nachricht entfernen"
|
||||
>
|
||||
<button
|
||||
className={cn("btn btn-sm btn-ghost border-warning bg-transparent ")}
|
||||
className={cn("btn btn-sm btn-ghost border-warning bg-transparent")}
|
||||
onClick={() => {
|
||||
removeMessage();
|
||||
}}
|
||||
@@ -123,9 +124,9 @@ export const Audio = () => {
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"btn btn-sm btn-soft bg-transparent border",
|
||||
"btn btn-sm btn-soft border bg-transparent",
|
||||
canStopOtherSpeakers && speakingParticipants.length > 0 && "hover:bg-error",
|
||||
speakingParticipants.length > 0 && " hover:bg-errorborder",
|
||||
speakingParticipants.length > 0 && "hover:bg-errorborder",
|
||||
isReceivingBlick && "border-warning",
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -133,6 +134,7 @@ export const Audio = () => {
|
||||
const payload = JSON.stringify({
|
||||
by: role,
|
||||
});
|
||||
resetSpeakingParticipants("dich");
|
||||
speakingParticipants.forEach(async (p) => {
|
||||
await room?.localParticipant.performRpc({
|
||||
destinationIdentity: p.identity,
|
||||
@@ -159,24 +161,24 @@ export const Audio = () => {
|
||||
transmitBlocked && "bg-yellow-500 hover:bg-yellow-500",
|
||||
state === "disconnected" && "bg-red-500 hover:bg-red-500",
|
||||
state === "error" && "bg-red-500 hover:bg-red-500",
|
||||
state === "connecting" && "bg-yellow-500 hover:bg-yellow-500 cursor-default",
|
||||
state === "connecting" && "cursor-default bg-yellow-500 hover:bg-yellow-500",
|
||||
)}
|
||||
>
|
||||
{state === "connected" && <Mic className="w-5 h-5" />}
|
||||
{state === "disconnected" && <WifiOff className="w-5 h-5" />}
|
||||
{state === "connecting" && <PlugZap className="w-5 h-5" />}
|
||||
{state === "error" && <ServerCrash className="w-5 h-5" />}
|
||||
{state === "connected" && <Mic className="h-5 w-5" />}
|
||||
{state === "disconnected" && <WifiOff className="h-5 w-5" />}
|
||||
{state === "connecting" && <PlugZap className="h-5 w-5" />}
|
||||
{state === "error" && <ServerCrash className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
{state === "connected" && (
|
||||
<details className="dropdown relative z-[1050]">
|
||||
<summary className="dropdown btn btn-ghost flex items-center gap-1">
|
||||
{connectionQuality === ConnectionQuality.Excellent && <Signal className="w-5 h-5" />}
|
||||
{connectionQuality === ConnectionQuality.Good && <SignalMedium className="w-5 h-5" />}
|
||||
{connectionQuality === ConnectionQuality.Poor && <SignalLow className="w-5 h-5" />}
|
||||
{connectionQuality === ConnectionQuality.Lost && <ZapOff className="w-5 h-5" />}
|
||||
{connectionQuality === ConnectionQuality.Excellent && <Signal className="h-5 w-5" />}
|
||||
{connectionQuality === ConnectionQuality.Good && <SignalMedium className="h-5 w-5" />}
|
||||
{connectionQuality === ConnectionQuality.Poor && <SignalLow className="h-5 w-5" />}
|
||||
{connectionQuality === ConnectionQuality.Lost && <ZapOff className="h-5 w-5" />}
|
||||
{connectionQuality === ConnectionQuality.Unknown && (
|
||||
<ShieldQuestion className="w-5 h-5" />
|
||||
<ShieldQuestion className="h-5 w-5" />
|
||||
)}
|
||||
<div className="badge badge-sm badge-soft badge-success">{remoteParticipants}</div>
|
||||
</summary>
|
||||
@@ -184,7 +186,7 @@ export const Audio = () => {
|
||||
{ROOMS.map((r) => (
|
||||
<li key={r}>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost text-left flex items-center justify-start gap-2 relative"
|
||||
className="btn btn-sm btn-ghost relative flex items-center justify-start gap-2 text-left"
|
||||
onClick={() => {
|
||||
if (!role) return;
|
||||
if (selectedRoom === r) return;
|
||||
@@ -193,7 +195,7 @@ export const Audio = () => {
|
||||
}}
|
||||
>
|
||||
{room?.name === r && (
|
||||
<Disc className="text-success text-sm absolute left-2" width={15} />
|
||||
<Disc className="text-success absolute left-2 text-sm" width={15} />
|
||||
)}
|
||||
<span className="flex-1 text-center">{r}</span>
|
||||
</button>
|
||||
@@ -201,12 +203,12 @@ export const Audio = () => {
|
||||
))}
|
||||
<li>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost text-left flex items-center justify-start gap-2 relative"
|
||||
className="btn btn-sm btn-ghost relative flex items-center justify-start gap-2 text-left"
|
||||
onClick={() => {
|
||||
disconnect();
|
||||
}}
|
||||
>
|
||||
<WifiOff className="text-error text-sm absolute left-2" width={15} />
|
||||
<WifiOff className="text-error absolute left-2 text-sm" width={15} />
|
||||
<span className="flex-1 text-center">Disconnect</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -10,9 +10,11 @@ import { getConnectedDispatcherAPI } from "_querys/dispatcher";
|
||||
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
|
||||
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
||||
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||
import { Trash } from "lucide-react";
|
||||
|
||||
export const Chat = () => {
|
||||
const {
|
||||
removeChat,
|
||||
setReportTabOpen,
|
||||
chatOpen,
|
||||
setChatOpen,
|
||||
@@ -50,13 +52,21 @@ export const Chat = () => {
|
||||
setOwnId(session.data?.user.id);
|
||||
}, [session.data?.user.id, setOwnId]);
|
||||
|
||||
const filteredDispatcher = dispatcher?.filter((d) => d.userId !== session.data?.user.id);
|
||||
const filteredDispatcher = dispatcher?.filter(
|
||||
(d) => d.userId !== session.data?.user.id && !chats[d.userId],
|
||||
);
|
||||
const filteredAircrafts = aircrafts?.filter(
|
||||
(a) => a.userId !== session.data?.user.id && dispatcherConnected,
|
||||
(a) => a.userId !== session.data?.user.id && dispatcherConnected && chats[a.userId],
|
||||
);
|
||||
|
||||
const btnActive = pilotConnected || dispatcherConnected;
|
||||
|
||||
useEffect(() => {
|
||||
if (!filteredDispatcher?.length && !filteredAircrafts?.length) {
|
||||
setAddTabValue("default");
|
||||
}
|
||||
}, [filteredDispatcher, filteredAircrafts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!btnActive) {
|
||||
setChatOpen(false);
|
||||
@@ -146,13 +156,17 @@ export const Chat = () => {
|
||||
<button
|
||||
className="btn btn-sm btn-soft btn-primary join-item"
|
||||
onClick={() => {
|
||||
if (addTabValue === "default") return;
|
||||
const aircraftUser = aircrafts?.find((a) => a.userId === addTabValue);
|
||||
const dispatcherUser = dispatcher?.find((d) => d.userId === addTabValue);
|
||||
const user = aircraftUser || dispatcherUser;
|
||||
console.log("Adding chat for user:", addTabValue, user);
|
||||
if (!user) return;
|
||||
const role = "Station" in user ? user.Station.bosCallsignShort : user.zone;
|
||||
console.log("Adding chat for user:", addTabValue);
|
||||
addChat(addTabValue, `${asPublicUser(user.publicUser).fullName} (${role})`);
|
||||
setSelectedChat(addTabValue);
|
||||
setAddTabValue("default");
|
||||
}}
|
||||
>
|
||||
<span className="text-xl">+</span>
|
||||
@@ -164,7 +178,7 @@ export const Chat = () => {
|
||||
if (!chat) return null;
|
||||
return (
|
||||
<Fragment key={userId}>
|
||||
<a
|
||||
<div
|
||||
className={cn("indicator tab", selectedChat === userId && "tab-active")}
|
||||
onClick={() => {
|
||||
if (selectedChat === userId) {
|
||||
@@ -176,7 +190,7 @@ export const Chat = () => {
|
||||
>
|
||||
{chat.name}
|
||||
{chat.notification && <span className="indicator-item status status-info" />}
|
||||
</a>
|
||||
</div>
|
||||
<div className="tab-content bg-base-100 border-base-300 max-h-[250px] overflow-y-auto p-6">
|
||||
{/* So macht man kein overflow handeling, weiß ich. Aber es funktioniert... */}
|
||||
{chat.messages.map((chatMessage) => {
|
||||
@@ -208,6 +222,16 @@ export const Chat = () => {
|
||||
)}
|
||||
{selectedChat && (
|
||||
<div className="join">
|
||||
<button
|
||||
className="join-item btn btn-error btn-outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeChat(selectedChat);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
<div className="w-full">
|
||||
<label className="input join-item w-full">
|
||||
<input
|
||||
|
||||
@@ -122,7 +122,7 @@ const HeliportsLayer = () => {
|
||||
};
|
||||
|
||||
const filterVisibleHeliports = () => {
|
||||
const bounds = map.getBounds();
|
||||
const bounds = map?.getBounds();
|
||||
if (!heliports?.length) return;
|
||||
// Filtere die Heliports, die innerhalb der Kartenansicht liegen
|
||||
const visibleHeliports = heliports.filter((heliport) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
||||
export const MapAdditionals = () => {
|
||||
const { isOpen, missionFormValues } = usePannelStore((state) => state);
|
||||
const dispatcherConnectionState = useDispatchConnectionStore((state) => state.status);
|
||||
|
||||
const { data: missions = [] } = useQuery({
|
||||
queryKey: ["missions"],
|
||||
queryFn: () =>
|
||||
@@ -35,7 +36,8 @@ export const MapAdditionals = () => {
|
||||
m.state === "draft" &&
|
||||
m.hpgLocationLat &&
|
||||
dispatcherConnectionState === "connected" &&
|
||||
m.hpgLocationLng,
|
||||
m.hpgLocationLng &&
|
||||
mapStore.openMissionMarker.find((openMission) => openMission.id === m.id),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -56,7 +58,7 @@ export const MapAdditionals = () => {
|
||||
key={mission.id}
|
||||
position={[mission.hpgLocationLat!, mission.hpgLocationLng!]}
|
||||
icon={L.icon({
|
||||
iconUrl: "/icons/mapMarker.png",
|
||||
iconUrl: "/icons/mapMarkerAttention.png",
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 35],
|
||||
})}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const MISSION_STATUS_COLORS: Record<MissionState | "attention", string> =
|
||||
draft: "#0092b8",
|
||||
running: "#155dfc",
|
||||
finished: "#155dfc",
|
||||
attention: "rgb(186,105,0)",
|
||||
attention: "#ba6900",
|
||||
};
|
||||
|
||||
export const MISSION_STATUS_TEXT_COLORS: Record<MissionState, string> = {
|
||||
|
||||
@@ -25,28 +25,29 @@ import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
type TalkState = {
|
||||
addSpeakingParticipant: (participant: Participant) => void;
|
||||
connect: (roomName: string, role: string) => void;
|
||||
connectionQuality: ConnectionQuality;
|
||||
disconnect: () => void;
|
||||
isTalking: boolean;
|
||||
localRadioTrack: LocalTrackPublication | undefined;
|
||||
message: string | null;
|
||||
removeMessage: () => void;
|
||||
removeSpeakingParticipant: (speakingParticipants: Participant) => void;
|
||||
remoteParticipants: number;
|
||||
resetSpeakingParticipants: (source: string) => void;
|
||||
room: Room | null;
|
||||
setSettings: (settings: Partial<TalkState["settings"]>) => void;
|
||||
settings: {
|
||||
micDeviceId: string | null;
|
||||
micVolume: number;
|
||||
radioVolume: number;
|
||||
dmeVolume: number;
|
||||
};
|
||||
isTalking: boolean;
|
||||
transmitBlocked: boolean;
|
||||
removeMessage: () => void;
|
||||
state: "connecting" | "connected" | "disconnected" | "error";
|
||||
message: string | null;
|
||||
connectionQuality: ConnectionQuality;
|
||||
remoteParticipants: number;
|
||||
toggleTalking: () => void;
|
||||
setSettings: (settings: Partial<TalkState["settings"]>) => void;
|
||||
connect: (roomName: string, role: string) => void;
|
||||
disconnect: () => void;
|
||||
speakingParticipants: Participant[];
|
||||
addSpeakingParticipant: (participant: Participant) => void;
|
||||
removeSpeakingParticipant: (speakingParticipants: Participant) => void;
|
||||
room: Room | null;
|
||||
localRadioTrack: LocalTrackPublication | undefined;
|
||||
state: "connecting" | "connected" | "disconnected" | "error";
|
||||
toggleTalking: () => void;
|
||||
transmitBlocked: boolean;
|
||||
};
|
||||
const getToken = async (roomName: string) => {
|
||||
const response = await axios.get(`/api/livekit-token?roomName=${roomName}`);
|
||||
@@ -71,6 +72,15 @@ export const useAudioStore = create<TalkState>((set, get) => ({
|
||||
remoteParticipants: 0,
|
||||
connectionQuality: ConnectionQuality.Unknown,
|
||||
room: null,
|
||||
resetSpeakingParticipants: (source: string) => {
|
||||
set({
|
||||
speakingParticipants: [],
|
||||
isTalking: false,
|
||||
transmitBlocked: false,
|
||||
message: `Ruf beendet durch ${source || "eine unsichtbare Macht"}`,
|
||||
});
|
||||
get().room?.localParticipant.setMicrophoneEnabled(false);
|
||||
},
|
||||
addSpeakingParticipant: (participant) => {
|
||||
set((state) => {
|
||||
if (!state.speakingParticipants.some((p) => p.identity === participant.identity)) {
|
||||
@@ -177,6 +187,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
|
||||
if (dispatchState.status === "connected" && dispatchState.connectedDispatcher?.id) {
|
||||
changeDispatcherAPI(dispatchState.connectedDispatcher?.id, {
|
||||
zone: roomName,
|
||||
ghostMode: dispatchState.ghostMode,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,10 +211,15 @@ export const useAudioStore = create<TalkState>((set, get) => ({
|
||||
|
||||
set({ localRadioTrack: publishedTrack });
|
||||
|
||||
set({ state: "connected", room, message: null });
|
||||
set({ state: "connected", room, isTalking: false, message: null });
|
||||
})
|
||||
.on(RoomEvent.Disconnected, () => {
|
||||
set({ state: "disconnected", speakingParticipants: [], transmitBlocked: false });
|
||||
set({
|
||||
state: "disconnected",
|
||||
speakingParticipants: [],
|
||||
transmitBlocked: false,
|
||||
isTalking: false,
|
||||
});
|
||||
|
||||
handleDisconnect();
|
||||
})
|
||||
@@ -222,17 +238,22 @@ export const useAudioStore = create<TalkState>((set, get) => ({
|
||||
|
||||
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}!`;
|
||||
get().resetSpeakingParticipants(by);
|
||||
return "OK";
|
||||
});
|
||||
|
||||
interval = setInterval(() => {
|
||||
// Filter forgotten participants
|
||||
const oldSpeakingParticipants = get().speakingParticipants;
|
||||
const speakingParticipants = oldSpeakingParticipants.filter((oP) => {
|
||||
return Array.from(room.remoteParticipants.values()).find(
|
||||
(p) => p.identity === oP.identity,
|
||||
);
|
||||
});
|
||||
|
||||
set({
|
||||
remoteParticipants: room.numParticipants === 0 ? 0 : room.numParticipants - 1, // Unreliable and delayed
|
||||
speakingParticipants,
|
||||
});
|
||||
}, 500);
|
||||
} catch (error: Error | unknown) {
|
||||
|
||||
@@ -19,6 +19,7 @@ interface ChatStore {
|
||||
sendMessage: (userId: string, message: string) => Promise<void>;
|
||||
addChat: (userId: string, name: string) => void;
|
||||
addMessage: (userId: string, message: ChatMessage) => void;
|
||||
removeChat: (userId: string) => void;
|
||||
}
|
||||
|
||||
export const useLeftMenuStore = create<ChatStore>((set, get) => ({
|
||||
@@ -37,6 +38,15 @@ export const useLeftMenuStore = create<ChatStore>((set, get) => ({
|
||||
setChatNotification(chatId, false); // Set notification to false when chat is selected
|
||||
}
|
||||
},
|
||||
removeChat: (userId: string) => {
|
||||
const { chats, setSelectedChat, selectedChat } = get();
|
||||
const newChats = { ...chats };
|
||||
delete newChats[userId];
|
||||
set({ chats: newChats });
|
||||
if (selectedChat === userId) {
|
||||
setSelectedChat(null);
|
||||
}
|
||||
},
|
||||
setOwnId: (id: string) => set({ ownId: id }),
|
||||
chats: {},
|
||||
sendMessage: (userId: string, message: string) => {
|
||||
|
||||
BIN
apps/dispatch/public/icons/mapMarkerAttention.png
Normal file
BIN
apps/dispatch/public/icons/mapMarkerAttention.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
@@ -7,55 +7,59 @@ export const handleParticipantFinished = async (
|
||||
participant: Participant,
|
||||
user: User,
|
||||
) => {
|
||||
const discordAccount = await prisma.discordAccount.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const badgedToAdd = event.finishedBadges.filter((badge) => {
|
||||
return !user.badges.includes(badge);
|
||||
});
|
||||
const permissionsToAdd = event.finishedPermissions.filter((permission) => {
|
||||
return !user.permissions.includes(permission);
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
badges: {
|
||||
push: badgedToAdd,
|
||||
try {
|
||||
const discordAccount = await prisma.discordAccount.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
permissions: {
|
||||
push: permissionsToAdd,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (discordAccount) {
|
||||
await setStandardName({
|
||||
memberId: discordAccount.discordId,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
await sendCourseCompletedEmail(user.email, user, event);
|
||||
|
||||
await prisma.participant.update({
|
||||
where: {
|
||||
id: participant.id,
|
||||
},
|
||||
data: {
|
||||
statusLog: {
|
||||
push: {
|
||||
event: "Berechtigungen und Badges vergeben",
|
||||
timestamp: new Date(),
|
||||
user: "system",
|
||||
const badgedToAdd = event.finishedBadges.filter((badge) => {
|
||||
return !user.badges.includes(badge);
|
||||
});
|
||||
const permissionsToAdd = event.finishedPermissions.filter((permission) => {
|
||||
return !user.permissions.includes(permission);
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
badges: {
|
||||
push: badgedToAdd,
|
||||
},
|
||||
permissions: {
|
||||
push: permissionsToAdd,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (discordAccount) {
|
||||
await setStandardName({
|
||||
memberId: discordAccount.discordId,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
await sendCourseCompletedEmail(user.email, user, event);
|
||||
|
||||
await prisma.participant.update({
|
||||
where: {
|
||||
id: participant.id,
|
||||
},
|
||||
data: {
|
||||
statusLog: {
|
||||
push: {
|
||||
event: "Berechtigungen und Badges vergeben",
|
||||
timestamp: new Date(),
|
||||
user: "system",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error handling participant finished:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleParticipantEnrolled = async (
|
||||
|
||||
@@ -34,43 +34,61 @@ const initTransporter = () => {
|
||||
initTransporter();
|
||||
|
||||
export const sendCourseCompletedEmail = async (to: string, user: User, event: Event) => {
|
||||
const emailHtml = await renderCourseCompleted({ user, event });
|
||||
try {
|
||||
const emailHtml = await renderCourseCompleted({ user, event });
|
||||
|
||||
if (!transporter) {
|
||||
console.error("Transporter is not initialized");
|
||||
return;
|
||||
if (!transporter) {
|
||||
console.error("Transporter is not initialized");
|
||||
return;
|
||||
}
|
||||
await sendMail(to, `Kurs ${event.name} erfolgreich abgeschlossen`, emailHtml);
|
||||
} catch (error) {
|
||||
console.error("Error sending course completed email:", error);
|
||||
}
|
||||
sendMail(to, `Kurs ${event.name} erfolgreich abgeschlossen`, emailHtml);
|
||||
};
|
||||
|
||||
export const sendPasswordChanged = async (to: string, user: User, password: string) => {
|
||||
const emailHtml = await renderPasswordChanged({ user, password });
|
||||
try {
|
||||
const emailHtml = await renderPasswordChanged({ user, password });
|
||||
|
||||
await sendMail(to, `Dein Passwort wurde geändert`, emailHtml);
|
||||
await sendMail(to, `Dein Passwort wurde geändert`, emailHtml);
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
export const sendEmailVerification = async (to: string, user: User, code: string) => {
|
||||
const emailHtml = await renderVerificationCode({
|
||||
user,
|
||||
code,
|
||||
});
|
||||
await sendMail(to, "Bestätige deine E-Mail-Adresse", emailHtml);
|
||||
try {
|
||||
const emailHtml = await renderVerificationCode({
|
||||
user,
|
||||
code,
|
||||
});
|
||||
await sendMail(to, "Bestätige deine E-Mail-Adresse", emailHtml);
|
||||
} catch (error) {
|
||||
console.error("Error sending email verification:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const sendBannEmail = async (to: string, user: User, staffName: string) => {
|
||||
const emailHtml = await renderBannNotice({
|
||||
user,
|
||||
staffName,
|
||||
});
|
||||
await sendMail(to, "Deine Sperrung bei Virtual Air Rescue", emailHtml);
|
||||
try {
|
||||
const emailHtml = await renderBannNotice({
|
||||
user,
|
||||
staffName,
|
||||
});
|
||||
await sendMail(to, "Deine Sperrung bei Virtual Air Rescue", emailHtml);
|
||||
} catch (error) {
|
||||
console.error("Error sending ban email:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const sendTimebannEmail = async (to: string, user: User, staffName: string) => {
|
||||
const emailHtml = await renderTimeBanNotice({
|
||||
user,
|
||||
staffName,
|
||||
});
|
||||
await sendMail(to, "Deine vorrübergehende Sperrung bei Virtual Air Rescue", emailHtml);
|
||||
try {
|
||||
const emailHtml = await renderTimeBanNotice({
|
||||
user,
|
||||
staffName,
|
||||
});
|
||||
await sendMail(to, "Deine vorrübergehende Sperrung bei Virtual Air Rescue", emailHtml);
|
||||
} catch (error) {
|
||||
console.error("Error sending time ban email:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const sendMail = async (to: string, subject: string, html: string) =>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Button } from "../../../../_components/ui/Button";
|
||||
import { redirect } from "next/navigation";
|
||||
import dynamic from "next/dynamic";
|
||||
import toast from "react-hot-toast";
|
||||
import { cn } from "@repo/shared-components";
|
||||
|
||||
const MarkdownEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false });
|
||||
|
||||
@@ -24,6 +25,7 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
|
||||
previewImage: changelog?.previewImage || "", // Changed to accept a URL as a string
|
||||
},
|
||||
});
|
||||
const [skipUserUpdate, setSkipUserUpdate] = useState(false);
|
||||
const [markdownText, setMarkdownText] = useState(changelog?.text || "");
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [showImage, setShowImage] = useState(false);
|
||||
@@ -61,6 +63,9 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
|
||||
text: markdownText,
|
||||
},
|
||||
changelog?.id,
|
||||
{
|
||||
skipUserUpdate: skipUserUpdate,
|
||||
},
|
||||
);
|
||||
toast.success("Daten gespeichert");
|
||||
if (!changelog) redirect(`/admin/changelog`);
|
||||
@@ -96,6 +101,7 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
|
||||
{(() => {
|
||||
if (showImage && isValidImageUrl(previewImage) && !imageError) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={previewImage}
|
||||
alt="Preview"
|
||||
@@ -127,6 +133,19 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-200 col-span-6 shadow-xl">
|
||||
{!changelog?.id && (
|
||||
<label className="label mx-6 mt-6 w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className={cn("toggle")}
|
||||
checked={skipUserUpdate}
|
||||
onChange={(e) => setSkipUserUpdate(e.target.checked)}
|
||||
/>
|
||||
<span className={cn("label-text w-full text-left")}>
|
||||
Kein Popup für Nutzer für dieses Update anzeigen
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="card-body">
|
||||
<div className="flex w-full gap-4">
|
||||
<Button
|
||||
|
||||
@@ -4,6 +4,9 @@ import { prisma, Prisma, Changelog } from "@repo/db";
|
||||
export const upsertChangelog = async (
|
||||
changelog: Prisma.ChangelogCreateInput,
|
||||
id?: Changelog["id"],
|
||||
options?: {
|
||||
skipUserUpdate?: boolean;
|
||||
},
|
||||
) => {
|
||||
const newChangelog = id
|
||||
? await prisma.changelog.update({
|
||||
@@ -12,10 +15,13 @@ export const upsertChangelog = async (
|
||||
})
|
||||
: await prisma.$transaction(async (prisma) => {
|
||||
const createdChangelog = await prisma.changelog.create({ data: changelog });
|
||||
if (!options?.skipUserUpdate) {
|
||||
// Update all users to acknowledge the new changelog
|
||||
|
||||
await prisma.user.updateMany({
|
||||
data: { changelogAck: false },
|
||||
});
|
||||
await prisma.user.updateMany({
|
||||
data: { changelogAck: false },
|
||||
});
|
||||
}
|
||||
|
||||
return createdChangelog;
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ export const GET = async (req: NextRequest) => {
|
||||
email: discordUser.email,
|
||||
avatar: discordUser.avatar,
|
||||
username: discordUser.username,
|
||||
globalName: discordUser.global_name,
|
||||
globalName: discordUser.global_name || discordUser.username,
|
||||
verified: discordUser.verified,
|
||||
tokenType: authData.token_type,
|
||||
} as DiscordAccount;
|
||||
|
||||
@@ -79,6 +79,7 @@ services:
|
||||
- NEXT_PUBLIC_DISPATCH_SERVICE_ID=1
|
||||
- NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL
|
||||
- NEXT_PUBLIC_DISPATCH_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL
|
||||
- NEXT_PUBLIC_OPENAIP_ACCESS=$NEXT_PUBLIC_OPENAIP_ACCESS
|
||||
env_file:
|
||||
- .env.prod
|
||||
deploy:
|
||||
|
||||
@@ -74,6 +74,7 @@ services:
|
||||
- NEXT_PUBLIC_DISPATCH_SERVICE_ID=1
|
||||
- NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL
|
||||
- NEXT_PUBLIC_DISPATCH_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL
|
||||
- NEXT_PUBLIC_OPENAIP_ACCESS=$NEXT_PUBLIC_OPENAIP_ACCESS
|
||||
env_file:
|
||||
- .env.prod
|
||||
deploy:
|
||||
|
||||
@@ -22,13 +22,16 @@ export const getPublicUser = (
|
||||
},
|
||||
): PublicUser => {
|
||||
const lastName = user.lastname
|
||||
.trim()
|
||||
.split(" ")
|
||||
.map((part) => `${part[0]}.`)
|
||||
.map((part) => `${part[0] || ""}.`)
|
||||
.join(" ");
|
||||
|
||||
return {
|
||||
firstname: user.firstname,
|
||||
lastname: user.settingsHideLastname && !options.ignorePrivacy ? "" : lastName, // Only take the first letter of each section of the last name
|
||||
fullName: `${user.firstname} ${user.settingsHideLastname && !options.ignorePrivacy ? "" : lastName}`,
|
||||
lastname: user.settingsHideLastname && !options.ignorePrivacy ? "" : lastName.trim(), // Only take the first letter of each section of the last name
|
||||
fullName:
|
||||
`${user.firstname} ${user.settingsHideLastname && !options.ignorePrivacy ? "" : lastName}`.trim(),
|
||||
publicId: user.publicId,
|
||||
badges: user.badges,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useState } from "react";
|
||||
import { Button, cn } from "@repo/shared-components";
|
||||
import MDEditor from "@uiw/react-md-editor";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Check, RefreshCw } from "lucide-react";
|
||||
import { Changelog } from "@repo/db";
|
||||
|
||||
export const ChangelogModal = ({
|
||||
@@ -50,7 +50,8 @@ export const ChangelogModal = ({
|
||||
|
||||
<div className="modal-action">
|
||||
<Button className="btn btn-info btn-outline" onClick={onClose}>
|
||||
Weiter zum HUB
|
||||
<Check size={20} />
|
||||
gelesen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user