17 Commits

Author SHA1 Message Date
Nicolas
f0dfe91a00 Merge pull request #120 from VAR-Virtual-Air-Rescue/staging
Fix Chats können nur mit Disponenten eröffnet werden
2025-07-26 12:40:38 +02:00
nocnico
f32ed62a76 Fix Chats können nur mit Disponenten eröffnet werden 2025-07-26 12:21:23 +02:00
PxlLoewe
53de66e811 Merge pull request #119 from VAR-Virtual-Air-Rescue/staging
v2.0.2
2025-07-25 22:55:41 -07:00
PxlLoewe
5b57b31706 fixes #118 2025-07-25 22:37:46 -07:00
PxlLoewe
2abdbf168f fixes #117 2025-07-25 21:30:03 -07:00
PxlLoewe
1da23c3412 resolves #116 2025-07-25 21:02:18 -07:00
PxlLoewe
f8e9ad84b9 resolves #103 2025-07-25 20:56:21 -07:00
PxlLoewe
f534bbc902 implemented #112 2025-07-25 16:53:22 -07:00
PxlLoewe
14ea5fcf55 fixed User argument is missing on Discord-connect 2025-07-25 16:49:50 -07:00
PxlLoewe
383e15f38a Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-25 10:32:45 -07:00
PxlLoewe
afe32306d3 fixed #106 2025-07-25 10:32:35 -07:00
PxlLoewe
1b23aede89 fixed #105 2025-07-25 10:04:44 -07:00
PxlLoewe
54e0bc0b12 fixed #104 2025-07-25 09:53:58 -07:00
nocnico
2671571bfb maybe ghostmode fix now? 2025-07-25 17:43:05 +02:00
nocnico
b602a5836c Ghostmode fix now? 2025-07-25 17:19:58 +02:00
Nicolas
1bcb2dbff7 Merge pull request #101 from VAR-Virtual-Air-Rescue/staging
fixed #100 // fix Cluster naming, fix Marker Popup settings not used
2025-07-25 16:45:24 +02:00
nocnico
4eb46cb783 fixed #100 // fix Marker Cluster naming, fix Marker Popup settings not used 2025-07-25 16:39:21 +02:00
24 changed files with 356 additions and 232 deletions

View File

@@ -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 };
};

View File

@@ -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);
}
};

View File

@@ -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}`,

View File

@@ -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;
}
});

View File

@@ -70,7 +70,7 @@ export const handleConnectDispatch =
userId: user.id,
},
});
if (discordAccount?.id) {
if (discordAccount?.id && !ghostMode) {
await renameMember(
discordAccount.discordId.toString(),
`${getPublicUser(user).fullName}${selectedZone}`,

View File

@@ -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"

View File

@@ -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>

View File

@@ -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

View File

@@ -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) => {

View File

@@ -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],
})}

View File

@@ -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> = {
@@ -253,7 +253,7 @@ const MissionMarker = ({
tab: "home",
},
],
close: openMissionMarker?.map((m) => m.id) || [],
close: [],
});
}
};

View File

@@ -158,7 +158,7 @@ const PopupContent = ({
</span>
<span>
{aircraft.Station.bosCallsign.length > 15
? aircraft.Station.locationStateShort
? aircraft.Station.bosCallsignShort
: aircraft.Station.bosCallsign}
</span>
</div>

View File

@@ -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) {

View File

@@ -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) => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -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 (

View File

@@ -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) =>

View File

@@ -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

View File

@@ -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;
});

View File

@@ -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;

View File

@@ -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:

View File

@@ -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:

View File

@@ -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,
};

View File

@@ -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>