75 Commits

Author SHA1 Message Date
PxlLoewe
7175f6571e Revert "PR v2.0.7" 2026-01-15 23:35:14 +01:00
PxlLoewe
614b92325e Merge pull request #147 from VAR-Virtual-Air-Rescue/staging
@everyone | Wir haben eine kurze Downtime überwunden und stellen euch heute v2.0.7 vor.

In der Vergangenheit haben wir viel an der Dispositionsseite gearbeitet. Das ändert sich heute. Wir aktualisieren die Bedieneinheit für Piloten auf eine neue Softwareversion, die dem Sepura SCG22 nachempfunden ist und so tatsächlich in vielen Hubschraubern verbaut ist.

### Neue Features:
- Ladescreen beim Einschalten
- Wechseln der Rufgruppe direkt im Gerät
- Status senden/empfangen
- SDS-Text bzw. senden/empfangen
- Nachtmodus ab 22:00 Uhr
- Popup bei Funkverkehr auf der Rufgruppe

Eine entsprechende Dokumentation findet ihr[ in den Docs](https://docs.virtualairrescue.com/allgemein/var-systeme/leitstelle/pilot.html).

### Weiteres:
- kleinere Bugfixes
- Performanceupgrades durch verbessertes Backup-Handling
2026-01-15 22:59:21 +01:00
PxlLoewe
b5d67e55b4 night mode nur wenn das mrt an ist 2026-01-15 22:51:10 +01:00
PxlLoewe
ea9c2c0f38 added night iamge 2026-01-15 22:37:29 +01:00
PxlLoewe
72c214a189 fixed Sound nach Verbinden auf RG 2026-01-15 22:22:36 +01:00
PxlLoewe
022d20356c Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2026-01-15 22:08:07 +01:00
PxlLoewe
228b0617e6 Mrt Button bug 2026-01-15 22:06:39 +01:00
PxlLoewe
3413f74fcd Merge pull request #145 from VAR-Virtual-Air-Rescue/mrt-rework
repaired nextJS dockerfiles
2026-01-15 21:38:06 +01:00
PxlLoewe
90fcaf259e repaired nextJS dockerfiles 2026-01-15 21:35:28 +01:00
PxlLoewe
bfe4d56cf7 Merge pull request #144 from VAR-Virtual-Air-Rescue/mrt-rework
Mrt rework
2026-01-15 21:14:59 +01:00
PxlLoewe
48d36af382 MRT: Rufgruppenauswahl, Herunterfahren, Hilfe 2026-01-15 21:12:15 +01:00
PxlLoewe
a65af7f011 Ealisitische Sequenz im HEader vom call-Bildschirm im MRT 2026-01-15 14:09:47 +01:00
PxlLoewe
0b30936f73 Neues MRt eingefügt. Status 059 sind nun keine FMS status mehr 2026-01-15 00:18:50 +01:00
PxlLoewe
edfaf7a228 Fehlender EventID Filter zu Teilnehmer tabelle hinzugefügt. Adatar-alternative in Nutzer übersicht 2026-01-13 13:11:54 +01:00
PxlLoewe
b1d1e7f2bf reduce image size of hub and disptach container 2026-01-13 12:35:44 +01:00
PxlLoewe
b1e508ef36 release v2.0.6
v2.0.6
2026-01-06 13:58:24 +01:00
PxlLoewe
c5c3bc0775 Changelog-Seite, option zum verstecken von Einträgen auf dieser 2026-01-06 12:19:10 +01:00
PxlLoewe
dd39331c1a cron performance improved 2026-01-06 03:08:16 +01:00
PxlLoewe
0ac943c63f Discord account Linkage, penalty update 2026-01-06 03:07:09 +01:00
PxlLoewe
6e8884f3fb Merge pull request #141 from VAR-Virtual-Air-Rescue/staging
v2.0.5
2025-12-27 16:23:33 +01:00
PxlLoewe
b16b719c74 Redesigned Search, removed Unused Admin Route 2025-12-27 15:33:00 +01:00
PxlLoewe
e9a4c50a12 fixed admin search 2025-12-26 01:25:17 +01:00
PxlLoewe
17208eded9 Added Account Dublicate fucntion, improved default sorts 2025-12-26 01:23:32 +01:00
PxlLoewe
51ef9cd90c use fetch to get Aircraft Marker 2025-12-15 21:19:39 +01:00
PxlLoewe
434154e26d Security Fixes 2025-12-15 02:55:44 +01:00
PxlLoewe
dde52bde39 Release v2.0.4
Release v2.0.4
2025-12-08 19:48:53 +01:00
PxlLoewe
483b5eba46 Merge branch 'release' into staging 2025-12-08 19:40:07 +01:00
PxlLoewe
bc61144258 Fixed Buchungssystem 2025-12-08 19:30:08 +01:00
PxlLoewe
1e36622289 update nextJS 2025-12-08 18:48:28 +01:00
PxlLoewe
b9e871ae01 Dispo-Option, die HPG validierung nicht zu nutzen 2025-11-27 22:21:27 +01:00
PxlLoewe
6081c1e38d vm network 2025-11-08 12:03:00 +01:00
PxlLoewe
d6bfcd3061 merge prometheus 2025-11-08 11:45:08 +01:00
PxlLoewe
59357a2ae6 VM volume 2025-11-08 11:41:50 +01:00
PxlLoewe
e639ba6704 added victoriametrics 2025-11-08 11:41:50 +01:00
PxlLoewe
6a739f4871 VM volume 2025-11-08 11:31:27 +01:00
PxlLoewe
cce2c246f6 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-11-08 11:27:44 +01:00
PxlLoewe
238fae694c added victoriametrics 2025-11-08 11:27:39 +01:00
PxlLoewe
60e60ea069 Merge pull request #139 from VAR-Virtual-Air-Rescue/release
Maerge Commits from release to staging
2025-11-08 10:40:39 +01:00
PxlLoewe
f0d133d827 rename Map-Aircraft cache key 2025-11-08 10:38:57 +01:00
PxlLoewe
cda2f272cc rename Map-Aircraft cache key 2025-11-08 09:28:23 +01:00
PxlLoewe
33c33b4de1 Doppeltes "Einsatz" bei benachrichtigungen entfernt 2025-10-28 02:35:12 +01:00
PxlLoewe
4d43e2a36d Einsatz geschlossen event wird richtig an piloten gesendet 2025-10-28 02:19:55 +01:00
PxlLoewe
da9b957fcf XPlane objecte können wegebt und per rechts-click gelöscht werden 2025-10-28 01:52:55 +01:00
PxlLoewe
5af68b8a70 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-10-24 22:41:25 +02:00
PxlLoewe
192ad7dedd .env example anbepasst 2025-10-24 22:41:21 +02:00
PxlLoewe
4d93ceaf1c Kein Szenerie als Standart + keine Validierung für dieses Szenario 2025-10-16 18:05:15 +02:00
PxlLoewe
3d77ab3b90 mission closed socket event 2025-10-16 14:30:01 +02:00
PxlLoewe
c4e0213a5f Plazierung von X Plane Objekten 2025-10-16 14:23:50 +02:00
PxlLoewe
b5f07071a5 dev 2025-10-16 11:17:52 +02:00
PxlLoewe
1919227cd4 redis not req for local dev 2025-10-04 21:58:34 +02:00
PxlLoewe
a2c320ddbe XPlane Plugin anzeige auf tracker 2025-10-04 21:16:24 +02:00
PxlLoewe
13ce99da96 Merge pull request #137 from VAR-Virtual-Air-Rescue/staging
Datenschutzerklärung im Registrierungsformular
2025-09-10 23:42:33 +02:00
PxlLoewe
9a26920d7d Merge pull request #133 from VAR-Virtual-Air-Rescue/staging
v2.0.3
2025-07-29 16:33:11 -07:00
PxlLoewe
2a859b3415 Merge pull request #128 from VAR-Virtual-Air-Rescue/staging
remove old User File from repo
2025-07-27 20:34:45 -07:00
PxlLoewe
6aa6329d83 Merge pull request #127 from VAR-Virtual-Air-Rescue/staging
Bugfixes + Manuelle reports
2025-07-27 17:00:57 -07:00
PxlLoewe
2c6913eeb9 Merge pull request #126 from VAR-Virtual-Air-Rescue/staging
Prometheus rückgänging
2025-07-26 14:00:34 -07:00
PxlLoewe
15f9512d8e Merge pull request #125 from VAR-Virtual-Air-Rescue/staging
vlt jetzt?
2025-07-26 13:58:22 -07:00
PxlLoewe
daf5759778 Merge pull request #124 from VAR-Virtual-Air-Rescue/staging
prometheus config
2025-07-26 13:32:05 -07:00
PxlLoewe
de54103e6e Merge pull request #123 from VAR-Virtual-Air-Rescue/staging
Bugfux und Node Exporter für Prometheus
2025-07-26 12:34:57 -07:00
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
PxlLoewe
53de66e811 Merge pull request #119 from VAR-Virtual-Air-Rescue/staging
v2.0.2
2025-07-25 22:55:41 -07: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
Nicolas
4f22d48e83 Merge pull request #99 from VAR-Virtual-Air-Rescue/staging
Release v2.0.1
2025-07-25 02:25:07 +02:00
Nicolas
e9c1cf0c94 Merge pull request #84 from VAR-Virtual-Air-Rescue/staging
Namenseinstellungen und Markdown Bug Fix
2025-07-23 12:14:33 +02:00
Nicolas
940d62fdd5 Merge pull request #83 from VAR-Virtual-Air-Rescue/staging
make V1 login not case sensitive
2025-07-23 10:33:21 +02:00
PxlLoewe
644fee3e29 Merge pull request #81 from VAR-Virtual-Air-Rescue/staging
fixed condition for discord role assignment
2025-07-22 21:40:07 -07:00
PxlLoewe
d2a865c955 Merge pull request #80 from VAR-Virtual-Air-Rescue/staging
typos
2025-07-22 19:38:40 -07:00
PxlLoewe
33ec5574f2 Merge pull request #79 from VAR-Virtual-Air-Rescue/staging
added more delay to moodle ID lookup
2025-07-22 13:19:17 -07:00
PxlLoewe
8c6057fe6a Merge pull request #78 from VAR-Virtual-Air-Rescue/staging
Bug Fixes
2025-07-22 12:16:10 -07:00
PxlLoewe
25769f551a Merge pull request #77 from VAR-Virtual-Air-Rescue/staging
Final Bugfixes
2025-07-22 11:16:44 -07:00
PxlLoewe
a5998fbe0f Merge pull request #75 from VAR-Virtual-Air-Rescue/staging
Nutzerliste aus V1 übernommen
2025-07-22 09:12:47 -07:00
PxlLoewe
92e550736b Merge pull request #74 from VAR-Virtual-Air-Rescue/staging
release V2.0
2025-07-22 09:05:14 -07:00
PxlLoewe
616d3d3a61 Merge pull request #70 from VAR-Virtual-Air-Rescue/staging
Fix prod-workflow
2025-07-18 13:40:07 -07:00
PxlLoewe
df7f1b8cd1 Merge pull request #69 from VAR-Virtual-Air-Rescue/staging
Datentypen in Prod DB vorbereiten für release
2025-07-18 13:05:13 -07:00
PxlLoewe
eb98971e8a Merge pull request #47 from VAR-Virtual-Air-Rescue/staging
CD Deployment
2025-07-08 23:23:24 -07:00
94 changed files with 2141 additions and 699 deletions

View File

@@ -1,7 +1,5 @@
{
"recommendations": [
"EthanSK.restore-terminals",
"dbaeumer.vscode-eslint",
"VisualStudioExptTeam.vscodeintellicode"
]
}

View File

@@ -1,6 +1,7 @@
import { MissionLog, NotificationPayload, prisma } from "@repo/db";
import { DISCORD_ROLES, MissionLog, NotificationPayload, prisma } from "@repo/db";
import { io } from "index";
import cron from "node-cron";
import { changeMemberRoles } from "routes/member";
const removeMission = async (id: number, reason: string) => {
const log: MissionLog = {
@@ -34,7 +35,6 @@ const removeMission = async (id: number, reason: string) => {
console.log(`Mission ${updatedMission.id} closed due to inactivity.`);
};
const removeClosedMissions = async () => {
const oldMissions = await prisma.mission.findMany({
where: {
@@ -140,6 +140,57 @@ const removeConnectedAircrafts = async () => {
}
});
};
const removePermissionsForBannedUsers = async () => {
const activePenalties = await prisma.penalty.findMany({
where: {
OR: [
{
type: "BAN",
suspended: false,
},
{
type: "TIME_BAN",
suspended: false,
until: {
gt: new Date().toISOString(),
},
},
],
},
include: {
User: {
include: {
DiscordAccount: true,
FormerDiscordAccounts: true,
},
},
},
});
for (const penalty of activePenalties) {
const user = penalty.User;
if (user.DiscordAccount) {
await changeMemberRoles(
user.DiscordAccount.discordId,
[DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER],
"remove",
);
}
for (const formerAccount of user.FormerDiscordAccounts) {
await changeMemberRoles(
formerAccount.discordId,
[DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER],
"remove",
);
}
}
};
cron.schedule("*/5 * * * *", async () => {
await removePermissionsForBannedUsers();
});
cron.schedule("*/1 * * * *", async () => {
try {

View File

@@ -32,6 +32,25 @@ router.post("/set-standard-name", async (req, res) => {
},
});
const activePenaltys = await prisma.penalty.findMany({
where: {
userId: user.id,
OR: [
{
type: "BAN",
suspended: false,
},
{
type: "TIME_BAN",
suspended: false,
until: {
gt: new Date().toISOString(),
},
},
],
},
});
participant.forEach(async (p) => {
if (!p.Event.discordRoleId) return;
if (eventCompleted(p.Event, p)) {
@@ -48,8 +67,12 @@ router.post("/set-standard-name", async (req, res) => {
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");
if (activePenaltys.length > 0) {
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER], "remove");
} else {
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove");
await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove");
}
});
export default router;

View File

@@ -1,7 +1,8 @@
DISPATCH_SERVER_PORT=3002
REDIS_HOST=localhost
REDIS_PORT=6379
CORE_SERVER_URL=http://core-server
CORE_SERVER_URL=http://localhost:3005
DISPATCH_APP_TOKEN=dispatch
LIVEKIT_API_KEY=APIAnsGdtdYp2Ho
LIVEKIT_API_SECRET=tdPjVsYUx8ddC7K9NvdmVAeLRF9GeADD6Fedm1x63fWC
LIVEKIT_API_SECRET=tdPjVsYUx8ddC7K9NvdmVAeLRF9GeADD6Fedm1x63fWC
AUTH_HUB_SECRET=var

View File

@@ -18,7 +18,10 @@ const app = express();
const server = createServer(app);
export const io = new Server(server, {
adapter: createAdapter(pubClient, subClient),
adapter:
process.env.REDIS_HOST && process.env.REDIS_PORT
? createAdapter(pubClient, subClient)
: undefined,
cors: {},
});
io.use(jwtMiddleware);

View File

@@ -1,13 +1,17 @@
import { createClient, RedisClientType } from "redis";
export const pubClient: RedisClientType = createClient({
url: `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`,
url: `redis://${process.env.REDIS_HOST || "localhost"}:${process.env.REDIS_PORT || 6379}`,
});
export const subClient: RedisClientType = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
console.log("Redis connected");
});
if (!process.env.REDIS_HOST || !process.env.REDIS_PORT) {
console.warn("REDIS_HOST or REDIS_PORT not set, skipping Redis connection");
} else {
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
console.log("Redis connected");
});
}
pubClient.on("error", (err) => console.log("Redis Client Error", err));
subClient.on("error", (err) => console.log("Redis Client Error", err));

View File

@@ -87,6 +87,29 @@ router.patch("/:id", async (req, res) => {
data: req.body,
});
io.to("dispatchers").emit("update-mission", { updatedMission });
if (req.body.state === "finished") {
const missionUsers = await prisma.missionOnStationUsers.findMany({
where: {
missionId: updatedMission.id,
},
select: {
userId: true,
},
});
console.log("Notifying users about mission closure:", missionUsers);
missionUsers?.forEach(({ userId }) => {
io.to(`user:${userId}`).emit("notification", {
type: "mission-closed",
status: "closed",
message: `Einsatz ${updatedMission.publicId} wurde beendet`,
data: {
missionId: updatedMission.id,
publicMissionId: updatedMission.publicId,
},
} as NotificationPayload);
});
}
res.json(updatedMission);
} catch (error) {
console.error(error);

View File

View File

@@ -96,6 +96,8 @@ export const handleConnectPilot =
lastHeartbeat: debug ? nowPlus2h.toISOString() : undefined,
posLat: randomPos?.lat,
posLng: randomPos?.lng,
posXplanePluginActive: debug ? true : undefined,
posH145active: debug ? true : undefined,
},
});

View File

@@ -3,16 +3,17 @@ import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons";
import { SettingsIcon, Volume2 } from "lucide-react";
import MicVolumeBar from "_components/MicVolumeIndication";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { editUserAPI, getUserAPI } from "_querys/user";
import { useSession } from "next-auth/react";
import { useAudioStore } from "_store/audioStore";
import toast from "react-hot-toast";
import { useMapStore } from "_store/mapStore";
import { set } from "date-fns";
import { Button } from "@repo/shared-components";
export const SettingsBtn = () => {
const session = useSession();
const queryClient = useQueryClient();
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const { data: user } = useQuery({
@@ -23,6 +24,10 @@ export const SettingsBtn = () => {
const editUserMutation = useMutation({
mutationFn: editUserAPI,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["user", session.data?.user.id] });
},
});
useEffect(() => {
@@ -40,6 +45,7 @@ export const SettingsBtn = () => {
micVolume: user?.settingsMicVolume || 1,
radioVolume: user?.settingsRadioVolume || 0.8,
autoCloseMapPopup: user?.settingsAutoCloseMapPopup || false,
useHPGAsDispatcher: user?.settingsUseHPGAsDispatcher || false,
});
const { setSettings: setAudioSettings } = useAudioStore((state) => state);
@@ -57,7 +63,8 @@ export const SettingsBtn = () => {
micDeviceId: user.settingsMicDevice,
micVolume: user.settingsMicVolume || 1,
radioVolume: user.settingsRadioVolume || 0.8,
autoCloseMapPopup: user.settingsAutoCloseMapPopup || false,
autoCloseMapPopup: user.settingsAutoCloseMapPopup,
useHPGAsDispatcher: user.settingsUseHPGAsDispatcher,
});
setUserSettings({
settingsAutoCloseMapPopup: user.settingsAutoCloseMapPopup || false,
@@ -198,6 +205,17 @@ export const SettingsBtn = () => {
/>
Popups automatisch schließen
</div>
<div className="mt-2 flex w-full items-center gap-2">
<input
type="checkbox"
className="toggle"
checked={settings.useHPGAsDispatcher}
onChange={(e) => {
setSettingsPartial({ useHPGAsDispatcher: e.target.checked });
}}
/>
HPG als Disponent verwenden
</div>
<div className="modal-action flex justify-between">
<button
@@ -211,7 +229,7 @@ export const SettingsBtn = () => {
>
Schließen
</button>
<button
<Button
className="btn btn-soft btn-success"
type="submit"
onSubmit={() => false}
@@ -224,6 +242,7 @@ export const SettingsBtn = () => {
settingsMicVolume: settings.micVolume,
settingsRadioVolume: settings.radioVolume,
settingsAutoCloseMapPopup: settings.autoCloseMapPopup,
settingsUseHPGAsDispatcher: settings.useHPGAsDispatcher,
},
});
setAudioSettings({
@@ -239,7 +258,7 @@ export const SettingsBtn = () => {
}}
>
Speichern
</button>
</Button>
</div>
</div>
</dialog>

View File

@@ -28,8 +28,11 @@ import { selectRandomHPGMissionSzenery } from "_helpers/selectRandomHPGMission";
import { AxiosError } from "axios";
import { cn } from "@repo/shared-components";
import { StationsSelect } from "(app)/dispatch/_components/StationSelect";
import { getUserAPI } from "_querys/user";
export const MissionForm = () => {
const session = useSession();
const { editingMissionId, setEditingMission } = usePannelStore();
const queryClient = useQueryClient();
const { setSearchElements, searchElements, setContextMenu } = useMapStore((s) => s);
@@ -44,6 +47,10 @@ export const MissionForm = () => {
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000,
});
const { data: user } = useQuery({
queryKey: ["user", session.data?.user.id],
queryFn: () => getUserAPI(session.data!.user.id),
});
const createMissionMutation = useMutation({
mutationFn: createMissionAPI,
@@ -81,7 +88,6 @@ export const MissionForm = () => {
},
});
const session = useSession();
const defaultFormValues = React.useMemo(
() =>
({
@@ -108,6 +114,7 @@ export const MissionForm = () => {
hpgSelectedMissionString: null,
hpg: null,
missionLog: [],
xPlaneObjects: [],
}) as MissionOptionalDefaults,
[session.data?.user.id],
);
@@ -116,13 +123,16 @@ export const MissionForm = () => {
resolver: zodResolver(MissionOptionalDefaultsSchema),
defaultValues: defaultFormValues,
});
const { missionFormValues, setOpen } = usePannelStore((state) => state);
const { missionFormValues, setOpen, setMissionFormValues } = usePannelStore((state) => state);
const validationRequired = HPGValidationRequired(
form.watch("missionStationIds"),
aircrafts,
form.watch("hpgMissionString"),
);
const validationRequired =
HPGValidationRequired(
form.watch("missionStationIds"),
aircrafts,
form.watch("hpgMissionString"),
) &&
!form.watch("hpgMissionString")?.startsWith("kein Szenario") &&
user?.settingsUseHPGAsDispatcher;
useEffect(() => {
if (session.data?.user.id) {
@@ -144,6 +154,7 @@ export const MissionForm = () => {
return;
}
for (const key in missionFormValues) {
console.debug(key, missionFormValues[key as keyof MissionOptionalDefaults]);
if (key === "addressOSMways") continue; // Skip addressOSMways as it is handled separately
form.setValue(
key as keyof MissionOptionalDefaults,
@@ -153,6 +164,22 @@ export const MissionForm = () => {
}
}, [missionFormValues, form, defaultFormValues]);
// Sync form state to store (avoid infinity loops by using watch)
useEffect(() => {
const subscription = form.watch((values) => {
// Only update store if values actually changed to prevent loops
const currentStoreValues = JSON.stringify(missionFormValues);
const newFormValues = JSON.stringify(values);
if (currentStoreValues !== newFormValues) {
console.debug("Updating store missionFormValues", values);
setMissionFormValues(values as MissionOptionalDefaults);
}
});
return () => subscription.unsubscribe();
}, [form, setMissionFormValues, missionFormValues]);
const saveMission = async (
mission: MissionOptionalDefaults,
{ alertWhenValid = false, createNewMission = false } = {},
@@ -369,6 +396,7 @@ export const MissionForm = () => {
<option disabled value="please_select">
Einsatz Szenario auswählen...
</option>
<option value={"kein Szenario:3_1_1_1-4_1"}>Kein Szenario</option>
{keywords &&
keywords
.find((k) => k.name === form.watch("missionKeywordName"))
@@ -415,6 +443,21 @@ export const MissionForm = () => {
In diesem Einsatz gibt es {form.watch("addressOSMways").length} Gebäude
</p>
<div className="flex items-center justify-between">
<p
className={cn("text-sm text-gray-500", form.watch("xPlaneObjects").length && "text-info")}
>
In diesem Einsatz gibt es {form.watch("xPlaneObjects").length} Objekte
</p>
<button
disabled={!(form.watch("xPlaneObjects")?.length > 0)}
className="btn btn-xs btn-error mt-2"
onClick={() => form.setValue("xPlaneObjects", [])}
>
löschen
</button>
</div>
<div className="form-control min-h-[140px]">
<div className="flex gap-2">
<button
@@ -430,7 +473,11 @@ export const MissionForm = () => {
setSearchElements([]); // Reset search elements
setEditingMission(null);
setContextMenu(null);
toast.success(`Einsatz ${newMission.publicId} erstellt`);
if (editingMissionId) {
toast.success(`${newMission.publicId} bearbeitet`);
} else {
toast.success(`${newMission.publicId} erstellt`);
}
form.reset();
setOpen(false);
} catch (error) {
@@ -455,7 +502,11 @@ export const MissionForm = () => {
setSearchElements([]); // Reset search elements
setContextMenu(null);
toast.success(`Einsatz ${newMission.publicId} erstellt`);
if (editingMissionId) {
toast.success(`${newMission.publicId} bearbeitet`);
} else {
toast.success(`${newMission.publicId} erstellt`);
}
form.reset();
setOpen(false);
} catch (error) {

View File

@@ -3,15 +3,17 @@ import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons";
import { Bell, SettingsIcon, Volume2 } from "lucide-react";
import MicVolumeBar from "_components/MicVolumeIndication";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { editUserAPI, getUserAPI } from "_querys/user";
import { useSession } from "next-auth/react";
import { useAudioStore } from "_store/audioStore";
import toast from "react-hot-toast";
import Link from "next/link";
import { Button } from "@repo/shared-components";
export const SettingsBtn = () => {
const session = useSession();
const queryClient = useQueryClient();
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const { data: user } = useQuery({
@@ -22,6 +24,10 @@ export const SettingsBtn = () => {
const editUserMutation = useMutation({
mutationFn: editUserAPI,
mutationKey: ["user", session.data?.user.id],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["user", session.data?.user.id] });
},
});
useEffect(() => {
@@ -248,7 +254,7 @@ export const SettingsBtn = () => {
>
Schließen
</button>
<button
<Button
className="btn btn-soft btn-success"
type="submit"
onSubmit={() => false}
@@ -275,7 +281,7 @@ export const SettingsBtn = () => {
}}
>
Speichern
</button>
</Button>
</div>
</div>
</dialog>

View File

@@ -23,7 +23,7 @@ const Map = dynamic(() => import("_components/map/Map"), {
});
const PilotPage = () => {
const { connectedAircraft, status } = usePilotConnectionStore((state) => state);
const { connectedAircraft, status, } = usePilotConnectionStore((state) => state);
const { latestMission } = useDmeStore((state) => state);
// Query will be cached anyway, due to this, displayed Markers are in sync with own Aircraft connection-warning
const { data: aircrafts } = useQuery({

View File

@@ -10,7 +10,7 @@ export default () => {
}, []);
return (
<div className="card-body">
<h1 className="text-5xl">logging out...</h1>
<h1 className="text-5xl">ausloggen...</h1>
</div>
);
};

View File

@@ -3,7 +3,7 @@
import { toast } from "react-hot-toast";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useEffect, useState } from "react";
import { ReactNode, useEffect, useRef, useState } from "react";
import { dispatchSocket } from "(app)/dispatch/socket";
import { NotificationPayload } from "@repo/db";
import { HPGnotificationToast } from "_components/customToasts/HPGnotification";
@@ -15,6 +15,7 @@ import { MissionAutoCloseToast } from "_components/customToasts/MissionAutoClose
export function QueryProvider({ children }: { children: ReactNode }) {
const mapStore = useMapStore((s) => s);
const notificationSound = useRef<HTMLAudioElement | null>(null);
const [queryClient] = useState(
() =>
@@ -22,7 +23,7 @@ export function QueryProvider({ children }: { children: ReactNode }) {
defaultOptions: {
mutations: {
onError: (error) => {
toast.error("An error occurred: " + (error as Error).message, {
toast.error("Ein Fehler ist aufgetreten: " + (error as Error).message, {
position: "top-right",
});
},
@@ -30,6 +31,9 @@ export function QueryProvider({ children }: { children: ReactNode }) {
},
}),
);
useEffect(() => {
notificationSound.current = new Audio("/sounds/notification.mp3");
}, []);
useEffect(() => {
const invalidateMission = () => {
queryClient.invalidateQueries({
@@ -59,8 +63,18 @@ export function QueryProvider({ children }: { children: ReactNode }) {
};
const handleNotification = (notification: NotificationPayload) => {
const playNotificationSound = () => {
if (notificationSound.current) {
notificationSound.current.currentTime = 0;
notificationSound.current
.play()
.catch((e) => console.error("Notification sound error:", e));
}
}
switch (notification.type) {
case "hpg-validation":
playNotificationSound();
toast.custom(
(t) => <HPGnotificationToast event={notification} mapStore={mapStore} t={t} />,
{
@@ -70,6 +84,7 @@ export function QueryProvider({ children }: { children: ReactNode }) {
break;
case "admin-message":
playNotificationSound();
toast.custom((t) => <AdminMessageToast event={notification} t={t} />, {
duration: 999999,
});
@@ -81,12 +96,17 @@ export function QueryProvider({ children }: { children: ReactNode }) {
});
break;
case "mission-auto-close":
playNotificationSound();
toast.custom(
(t) => <MissionAutoCloseToast event={notification} t={t} mapStore={mapStore} />,
{
duration: 60000,
},
);
break;
case "mission-closed":
toast("Dein aktueller Einsatz wurde geschlossen.");
break;
default:
toast("unbekanntes Notification-Event");

View File

@@ -0,0 +1,24 @@
import { BaseNotification } from "_components/customToasts/BaseNotification"
import { TriangleAlert } from "lucide-react"
import toast, { Toast } from "react-hot-toast"
export const HPGnotValidatedToast = ({_toast}: {_toast: Toast}) => {
return <BaseNotification icon={<TriangleAlert />} className="flex flex-row">
<div className="flex-1">
<h1 className="font-bold text-red-600">Einsatz nicht HPG-validiert</h1>
<p className="text-sm">Vergleiche die Position des Einsatzes mit der HPG-Position in Hubschrauber</p>
</div>
<div className="ml-11">
<button className="btn" onClick={() => toast.dismiss(_toast.id)}>
schließen
</button>
</div>
</BaseNotification>
}
export const showToast = () => {
toast.custom((t) => {
return (<HPGnotValidatedToast _toast={t} />);
}, {duration: 1000 * 60 * 10}); // 10 minutes
}

View File

@@ -2,7 +2,7 @@ import { Marker, Polyline, useMap } from "react-leaflet";
import { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet";
import { useMapStore } from "_store/mapStore";
import { Fragment, useCallback, useEffect, useRef, useState, useMemo } from "react";
import { cn } from "@repo/shared-components";
import { checkSimulatorConnected, cn } from "@repo/shared-components";
import { ChevronsRightLeft, House, MessageSquareText, Minimize2 } from "lucide-react";
import { SmartPopup, calculateAnchor, useSmartPopup } from "_components/SmartPopup";
import FMSStatusHistory, {
@@ -396,11 +396,27 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
};
export const AircraftLayer = () => {
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10_000,
});
const [aircrafts, setAircrafts] = useState<(ConnectedAircraft & { Station: Station })[]>([]);
useEffect(() => {
const fetchAircrafts = async () => {
try {
const res = await fetch("/api/aircrafts");
if (!res.ok) {
throw new Error("Failed to fetch aircrafts");
}
const data: (ConnectedAircraft & { Station: Station })[] = await res.json();
setAircrafts(data.filter((a) => checkSimulatorConnected(a)));
} catch (error) {
console.error("Failed to fetch aircrafts:", error);
}
};
fetchAircrafts();
const interval = setInterval(fetchAircrafts, 10_000);
return () => clearInterval(interval);
}, []);
const { setMap } = useMapStore((state) => state);
const map = useMap();
const {
@@ -434,8 +450,10 @@ export const AircraftLayer = () => {
}
}, [pilotConnectionStatus, followOwnAircraft, ownAircraft, setMap, map]);
console.debug("Hubschrauber auf Karte:", filteredAircrafts.length, filteredAircrafts);
console.debug("Daten vom Server:", aircrafts?.length, aircrafts);
console.debug("Hubschrauber auf Karte:", {
total: aircrafts?.length,
displayed: filteredAircrafts.length,
});
return (
<>

View File

@@ -3,15 +3,22 @@ import { OSMWay } from "@repo/db";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { useMapStore } from "_store/mapStore";
import { usePannelStore } from "_store/pannelStore";
import { MapPin, MapPinned, Radius, Ruler, Search, RulerDimensionLine, Scan } from "lucide-react";
import { MapPin, MapPinned, Search, Car, Ambulance, Siren, Flame } from "lucide-react";
import { getOsmAddress } from "_querys/osm";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { Popup, useMap } from "react-leaflet";
import { findClosestPolygon } from "_helpers/findClosestPolygon";
import { xPlaneObjectsAvailable } from "_helpers/xPlaneObjectsAvailable";
import { useQuery } from "@tanstack/react-query";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
export const ContextMenu = () => {
const map = useMap();
const { data: aircrafts } = useQuery({
queryKey: ["connectedAircrafts"],
queryFn: getConnectedAircraftsAPI,
});
const {
contextMenu,
searchElements,
@@ -26,15 +33,16 @@ export const ContextMenu = () => {
setOpen,
isOpen: isPannelOpen,
} = usePannelStore((state) => state);
const [showRulerOptions, setShowRulerOptions] = useState(false);
const [showObjectOptions, setShowObjectOptions] = useState(false);
const [rulerHover, setRulerHover] = useState(false);
const [rulerOptionsHover, setRulerOptionsHover] = useState(false);
const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected";
useEffect(() => {
setShowRulerOptions(rulerHover || rulerOptionsHover);
}, [rulerHover, rulerOptionsHover]);
const showObjectOptions = rulerHover || rulerOptionsHover;
setShowObjectOptions(showObjectOptions);
}, [isPannelOpen, rulerHover, rulerOptionsHover, setOpen]);
useEffect(() => {
const handleContextMenu = (e: any) => {
@@ -150,9 +158,12 @@ export const ContextMenu = () => {
style={{ transform: "translateY(-50%)" }}
onMouseEnter={() => setRulerHover(true)}
onMouseLeave={() => setRulerHover(false)}
disabled
disabled={
!isPannelOpen ||
!xPlaneObjectsAvailable(missionFormValues?.missionStationIds, aircrafts)
}
>
<Ruler size={20} />
<Car size={20} />
</button>
{/* Bottom Button */}
<button
@@ -178,64 +189,75 @@ export const ContextMenu = () => {
>
<Search size={20} />
</button>
{/* Ruler Options - shown when Ruler button is hovered or options are hovered */}
{showRulerOptions && (
{/* XPlane Object Options - shown when Ruler button is hovered or options are hovered */}
{showObjectOptions && (
<div
className="pointer-events-auto absolute flex flex-col items-center"
style={{
left: "-100px", // position to the right of the left button
top: "50%",
transform: "translateY(-50%)",
zIndex: 10,
width: "120px", // Make the hover area wider
height: "200px", // Make the hover area taller
padding: "20px 0", // Add vertical padding
display: "flex",
justifyContent: "center",
pointerEvents: "auto",
}}
className="pointer-events-auto absolute -left-[100px] top-1/2 z-10 flex h-[200px] w-[120px] -translate-y-1/2 flex-col items-center justify-center py-5"
onMouseEnter={() => setRulerOptionsHover(true)}
onMouseLeave={() => setRulerOptionsHover(false)}
>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<div className="flex w-full flex-col">
<button
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 h-10 w-10 opacity-80"
data-tip="Strecke Messen"
style={{
transform: "translateX(100%)",
}}
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 ml-[30px] h-10 w-10 opacity-80"
data-tip="Rettungswagen platzieren"
onClick={() => {
/* ... */
setMissionFormValues({
...missionFormValues,
xPlaneObjects: [
...(missionFormValues?.xPlaneObjects ?? []),
{
objectName: "ambulance",
alt: 0,
lat: contextMenu.lat,
lon: contextMenu.lng,
},
],
});
}}
>
<RulerDimensionLine size={20} />
<Ambulance size={20} />
</button>
<button
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 h-10 w-10 opacity-80"
data-tip="Radius Messen"
data-tip="LF platzieren"
onClick={() => {
/* ... */
console.log("Add fire engine");
setMissionFormValues({
...missionFormValues,
xPlaneObjects: [
...(missionFormValues?.xPlaneObjects ?? []),
{
objectName: "fire_engine",
alt: 0,
lat: contextMenu.lat,
lon: contextMenu.lng,
},
],
});
}}
>
<Radius size={20} />
<Flame size={20} />
</button>
<button
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent h-10 w-10 opacity-80"
data-tip="Fläche Messen"
style={{
transform: "translateX(100%)",
}}
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent ml-[30px] h-10 w-10 opacity-80"
data-tip="Streifenwagen platzieren"
onClick={() => {
/* ... */
console.log("Add police");
setMissionFormValues({
...missionFormValues,
xPlaneObjects: [
...(missionFormValues?.xPlaneObjects ?? []),
{
objectName: "police",
alt: 0,
lat: contextMenu.lat,
lon: contextMenu.lng,
},
],
});
}}
>
<Scan size={20} />
<Siren size={20} />
</button>
</div>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { usePannelStore } from "_store/pannelStore";
import { Marker } from "react-leaflet";
import { Marker, useMap } from "react-leaflet";
import L from "leaflet";
import { useQuery } from "@tanstack/react-query";
import { getMissionsAPI } from "_querys/missions";
@@ -8,10 +8,13 @@ import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { useMapStore } from "_store/mapStore";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { XplaneObject } from "@repo/db";
import { useEffect, useState } from "react";
export const MapAdditionals = () => {
const { isOpen, missionFormValues } = usePannelStore((state) => state);
const { isOpen, missionFormValues, setMissionFormValues } = usePannelStore((state) => state);
const dispatcherConnectionState = useDispatchConnectionStore((state) => state.status);
const { openMissionMarker } = useMapStore((state) => state);
const { data: missions = [] } = useQuery({
queryKey: ["missions"],
@@ -21,13 +24,28 @@ export const MapAdditionals = () => {
}),
refetchInterval: 10_000,
});
const mapStore = useMapStore((state) => state);
const { setOpenMissionMarker } = useMapStore((state) => state);
const [showDetailedAdditionals, setShowDetailedAdditionals] = useState(false);
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000,
});
const leafletMap = useMap();
useEffect(() => {
const handleZoomEnd = () => {
const currentZoom = leafletMap.getZoom();
setShowDetailedAdditionals(currentZoom > 10);
};
leafletMap.on("zoomend", handleZoomEnd);
return () => {
leafletMap.off("zoomend", handleZoomEnd);
};
}, [leafletMap]);
const markersNeedingAttention = missions.filter(
(m) =>
@@ -37,7 +55,7 @@ export const MapAdditionals = () => {
m.hpgLocationLat &&
dispatcherConnectionState === "connected" &&
m.hpgLocationLng &&
mapStore.openMissionMarker.find((openMission) => openMission.id === m.id),
openMissionMarker.find((openMission) => openMission.id === m.id),
);
return (
@@ -50,9 +68,78 @@ export const MapAdditionals = () => {
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
interactive={false}
draggable={true}
eventHandlers={{
dragend: (e) => {
const marker = e.target;
const position = marker.getLatLng();
setMissionFormValues({
...missionFormValues,
addressLat: position.lat,
addressLng: position.lng,
});
},
}}
/>
)}
{showDetailedAdditionals &&
openMissionMarker.map((mission) => {
if (missionFormValues?.id === mission.id) return null;
const missionData = missions.find((m) => m.id === mission.id);
if (!missionData?.addressLat || !missionData?.addressLng) return null;
return (missionData.xPlaneObjects as unknown as XplaneObject[]).map((obj, index) => (
<Marker
key={`${mission.id}-additional-${index}`}
position={[obj.lat, obj.lon]}
icon={L.icon({
iconUrl: `/icons/${obj.objectName}.png`,
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
interactive={false}
/>
));
})}
{isOpen &&
missionFormValues?.xPlaneObjects &&
(missionFormValues.xPlaneObjects as unknown as XplaneObject[]).map((obj, index) => (
<Marker
key={index}
position={[obj.lat, obj.lon]}
icon={L.icon({
iconUrl: `/icons/${obj.objectName}.png`,
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
draggable={true}
eventHandlers={{
dragend: (e) => {
const marker = e.target;
const position = marker.getLatLng();
console.log("Marker dragged to:", position);
setMissionFormValues({
...missionFormValues,
xPlaneObjects: (missionFormValues.xPlaneObjects as unknown as XplaneObject[]).map(
(obj, objIndex) =>
objIndex === index ? { ...obj, lat: position.lat, lon: position.lng } : obj,
),
});
},
contextmenu: (e) => {
e.originalEvent.preventDefault();
const updatedObjects = (
missionFormValues.xPlaneObjects as unknown as XplaneObject[]
).filter((_, objIndex) => objIndex !== index);
setMissionFormValues({
...missionFormValues,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
xPlaneObjects: updatedObjects as unknown as any[],
});
},
}}
/>
))}
{markersNeedingAttention.map((mission) => (
<Marker
key={mission.id}
@@ -64,7 +151,7 @@ export const MapAdditionals = () => {
})}
eventHandlers={{
click: () =>
mapStore.setOpenMissionMarker({
setOpenMissionMarker({
open: [
{
id: mission.id,

View File

@@ -0,0 +1,3 @@
export const XPlaneObjects = () => {
return <div>XPlaneObjects</div>;
};

View File

@@ -296,6 +296,12 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
{aircraft.posH145active ? "H145 Aktiv" : "H145 Inaktiv"}
</span>
</span>
<span className="flex items-center gap-2">
<Lollipop size={16} />{" "}
<span className={cn(aircraft.posXplanePluginActive && "text-green-500")}>
{aircraft.posXplanePluginActive ? "X-Plane Plugin Aktiv" : "X-Plane Plugin Inaktiv"}
</span>
</span>
</div>
</div>
);

View File

@@ -0,0 +1,11 @@
import { ConnectedAircraft } from "@repo/db";
export const xPlaneObjectsAvailable = (
missionStationIds?: number[],
aircrafts?: ConnectedAircraft[],
) => {
return missionStationIds?.some((id) => {
const aircraft = aircrafts?.find((a) => a.stationId === id);
return aircraft?.posXplanePluginActive;
});
};

View File

@@ -14,11 +14,14 @@ export const changeDispatcherAPI = async (
};
export const getConnectedDispatcherAPI = async (filter?: Prisma.ConnectedDispatcherWhereInput) => {
const res = await axios.get<ConnectedDispatcher[]>("/api/dispatcher", {
params: {
filter: JSON.stringify(filter),
const res = await axios.get<(ConnectedDispatcher & { settingsUseHPGAsDispatcher: boolean })[]>(
"/api/dispatcher",
{
params: {
filter: JSON.stringify(filter),
},
},
});
);
if (res.status !== 200) {
throw new Error("Failed to fetch Connected Dispatcher");
}

View File

@@ -1,6 +1,7 @@
import { create } from "zustand";
import { dispatchSocket } from "../../(app)/dispatch/socket";
import { ConnectedAircraft, Mission, MissionSdsLog, Station, User } from "@repo/db";
import { showToast } from "../../_components/customToasts/HPGnotValidated";
import { pilotSocket } from "(app)/pilot/socket";
import { useDmeStore } from "_store/pilot/dmeStore";
import { useMrtStore } from "_store/pilot/MrtStore";
@@ -132,6 +133,12 @@ pilotSocket.on("mission-alert", (data: Mission & { Stations: Station[] }) => {
useDmeStore.getState().setPage({
page: "new-mission",
});
if (
data.hpgValidationState === "NOT_VALIDATED" &&
usePilotConnectionStore.getState().connectedAircraft?.posH145active
) {
showToast();
}
});
pilotSocket.on("sds-message", (sdsMessage: MissionSdsLog) => {

View File

@@ -24,6 +24,7 @@ export async function GET(request: Request): Promise<NextResponse> {
...d,
user: undefined,
publicUser: getPublicUser(d.user),
settingsUseHPGAsDispatcher: d.user.settingsUseHPGAsDispatcher,
};
}),
{

View File

@@ -21,9 +21,10 @@ export const PUT = async (req: Request) => {
if (!session && !payload) return Response.json({ message: "Unauthorized" }, { status: 401 });
const userId = session?.user.id || payload.id;
const { position, h145 } = (await req.json()) as {
const { position, h145, xPlanePluginActive } = (await req.json()) as {
position: PositionLog;
h145: boolean;
xPlanePluginActive: boolean;
};
if (!position) {
return Response.json({ message: "Missing id or position" });
@@ -61,6 +62,7 @@ export const PUT = async (req: Request) => {
posHeading: position.heading,
posSpeed: position.speed,
posH145active: h145,
posXplanePluginActive: xPlanePluginActive,
},
});

View File

@@ -78,6 +78,15 @@ export const ConnectedDispatcher = () => {
<div>{asPublicUser(d.publicUser).fullName}</div>
<div className="text-xs font-semibold uppercase opacity-60">{d.zone}</div>
</div>
<div className="mr-2 flex flex-col justify-center">
{d.settingsUseHPGAsDispatcher ? (
<span className="badge badge-sm badge-success badge-outline">HPG aktiv</span>
) : (
<span className="badge badge-sm badge-info badge-outline">
HPG deaktiviert
</span>
)}
</div>
<div>
{(() => {
const badges = (d.publicUser as unknown as PublicUser).badges

View File

@@ -44,7 +44,7 @@
"livekit-client": "^2.15.3",
"livekit-server-sdk": "^2.13.1",
"lucide-react": "^0.525.0",
"next": "^15.4.2",
"next": "^15.4.8",
"next-auth": "^4.24.11",
"npm": "^11.4.2",
"postcss": "^8.5.6",

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

View File

@@ -20,7 +20,7 @@ router.post("/handle-participant-finished", async (req, res) => {
Event: true,
User: {
include: {
discordAccounts: true,
DiscordAccount: true,
},
},
},
@@ -94,7 +94,7 @@ router.post("/handle-participant-enrolled", async (req, res) => {
Event: true,
User: {
include: {
discordAccounts: true,
DiscordAccount: true,
},
},
},

View File

@@ -1,6 +1,5 @@
import { Calendar } from "lucide-react";
import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import { Badge } from "@repo/shared-components";
import { JSX } from "react";
import { getPublicUser, prisma } from "@repo/db";
import { formatTimeRange } from "../../../helper/timerange";
@@ -9,9 +8,9 @@ export const Bookings: () => Promise<JSX.Element> = async () => {
const session = await getServerSession();
const futureBookings = await prisma.booking.findMany({
where: {
userId: session?.user.id,
startTime: {
gte: new Date(),
lte: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
},
orderBy: {

View File

@@ -1,5 +1,5 @@
"use client";
import { Mission, MissionAlertLog, MissionLog, Station } from "@repo/db";
import { Mission, MissionAlertLog, MissionLog, Prisma, Station } from "@repo/db";
import { ColumnDef } from "@tanstack/react-table";
import { PaginatedTable } from "_components/PaginatedTable";
import { ArrowRight, NotebookText } from "lucide-react";
@@ -12,20 +12,22 @@ export const RecentFlights = () => {
<div className="card-body">
<h2 className="card-title justify-between">
<span className="card-title">
<NotebookText className="w-4 h-4" /> Logbook
<NotebookText className="h-4 w-4" /> Logbook
</span>
<Link className="badge badge-sm badge-info badge-outline" href="/logbook">
Zum vollständigen Logbook <ArrowRight className="w-4 h-4" />
Zum vollständigen Logbook <ArrowRight className="h-4 w-4" />
</Link>
</h2>
<PaginatedTable
prismaModel={"missionOnStationUsers"}
filter={{
userId: session.data?.user?.id ?? "",
Mission: {
state: "finished",
},
}}
getFilter={() =>
({
User: { id: session.data?.user.id },
Mission: {
state: { in: ["finished"] },
},
}) as Prisma.MissionOnStationUsersWhereInput
}
include={{
Station: true,
User: true,

View File

@@ -23,6 +23,7 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
title: changelog?.title || "",
text: changelog?.text || "",
previewImage: changelog?.previewImage || "", // Changed to accept a URL as a string
showOnChangelogPage: changelog?.showOnChangelogPage || true,
},
});
const [skipUserUpdate, setSkipUserUpdate] = useState(false);
@@ -84,6 +85,7 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
placeholder="Titel (vX.X.X)"
className="input-sm"
/>
<Input
form={form}
label="Bild-URL"
@@ -146,6 +148,16 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
</span>
</label>
)}
<label className="label mx-6 mt-6 w-full cursor-pointer">
<input
type="checkbox"
className={cn("toggle")}
{...form.register("showOnChangelogPage", {})}
/>
<span className={cn("label-text w-full text-left")}>
Auf der Changelog-Seite anzeigen
</span>
</label>
<div className="card-body">
<div className="flex w-full gap-4">
<Button

View File

@@ -1,24 +1,42 @@
"use client";
import { DatabaseBackupIcon } from "lucide-react";
import { Check, Cross, DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Keyword } from "@repo/db";
import { Changelog, Keyword, Prisma } from "@repo/db";
export default () => {
return (
<>
<PaginatedTable
stickyHeaders
initialOrderBy={[{ id: "title", desc: true }]}
initialOrderBy={[{ id: "createdAt", desc: true }]}
prismaModel="changelog"
searchFields={["title"]}
showSearch
getFilter={(search) =>
({
OR: [
{ title: { contains: search, mode: "insensitive" } },
{ text: { contains: search, mode: "insensitive" } },
],
}) as Prisma.ChangelogWhereInput
}
columns={
[
{
header: "Title",
accessorKey: "title",
},
{
header: "Auf Changelog Seite anzeigen",
accessorKey: "showOnChangelogPage",
cell: ({ row }) => (row.original.showOnChangelogPage ? <Check /> : <Cross />),
},
{
header: "Erstellt am",
accessorKey: "createdAt",
cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(),
},
{
header: "Aktionen",
cell: ({ row }) => (
@@ -29,7 +47,7 @@ export default () => {
</div>
),
},
] as ColumnDef<Keyword>[]
] as ColumnDef<Changelog>[]
}
leftOfSearch={
<span className="flex items-center gap-2">

View File

@@ -1,4 +1,4 @@
import { Event, Participant } from "@repo/db";
import { Event, Participant, Prisma } from "@repo/db";
import { EventAppointmentOptionalDefaults, InputJsonValueType } from "@repo/db/zod";
import { ColumnDef } from "@tanstack/react-table";
import { useSession } from "next-auth/react";
@@ -64,7 +64,7 @@ export const AppointmentModal = ({
</div>
<div>
<PaginatedTable
hide={appointmentForm.watch("id") === undefined}
supressQuery={appointmentForm.watch("id") === undefined}
ref={participantTableRef}
columns={
[
@@ -167,9 +167,11 @@ export const AppointmentModal = ({
] as ColumnDef<Participant>[]
}
prismaModel={"participant"}
filter={{
eventAppointmentId: appointmentForm.watch("id"),
}}
getFilter={() =>
({
eventAppointmentId: appointmentForm.watch("id")!,
}) as Prisma.ParticipantWhereInput
}
include={{ User: true }}
leftOfPagination={
<div className="flex gap-2">

View File

@@ -1,6 +1,6 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, User } from "@repo/db";
import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, Prisma, User } from "@repo/db";
import {
EventAppointmentOptionalDefaults,
EventAppointmentOptionalDefaultsSchema,
@@ -159,9 +159,11 @@ export const Form = ({ event }: { event?: Event }) => {
<PaginatedTable
ref={appointmentsTableRef}
prismaModel={"eventAppointment"}
filter={{
eventId: event?.id,
}}
getFilter={() =>
({
eventId: event?.id,
}) as Prisma.EventAppointmentWhereInput
}
include={{
Presenter: true,
Participants: true,
@@ -250,92 +252,107 @@ export const Form = ({ event }: { event?: Event }) => {
{!form.watch("hasPresenceEvents") ? (
<div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body">
<PaginatedTable
leftOfSearch={
<h2 className="card-title">
<UserIcon className="h-5 w-5" /> Teilnehmer
</h2>
}
searchFields={["User.firstname", "User.lastname", "User.publicId"]}
ref={appointmentsTableRef}
prismaModel={"participant"}
filter={{
eventId: event?.id,
}}
include={{
User: true,
}}
columns={
[
{
header: "Vorname",
accessorKey: "User.firstname",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.firstname}
</Link>
);
},
},
{
header: "Nachname",
accessorKey: "User.lastname",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.lastname}
</Link>
);
},
},
{
header: "VAR-Nummer",
accessorKey: "User.publicId",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.publicId}
</Link>
);
},
},
{
header: "Moodle Kurs abgeschlossen",
accessorKey: "finisherMoodleCurseCompleted",
},
{
header: "Aktionen",
cell: ({ row }) => {
return (
<div className="flex gap-2">
<button
onSubmit={() => false}
type="button"
onClick={() => {
participantForm.reset(row.original);
participantModal.current?.showModal();
}}
className="btn btn-sm btn-outline"
{
<PaginatedTable
leftOfSearch={
<h2 className="card-title">
<UserIcon className="h-5 w-5" /> Teilnehmer
</h2>
}
ref={appointmentsTableRef}
prismaModel={"participant"}
showSearch
getFilter={(searchTerm) =>
({
OR: [
{
User: {
OR: [
{ firstname: { contains: searchTerm, mode: "insensitive" } },
{ lastname: { contains: searchTerm, mode: "insensitive" } },
{ publicId: { contains: searchTerm, mode: "insensitive" } },
],
},
},
],
}) as Prisma.ParticipantWhereInput
}
include={{
User: true,
}}
supressQuery={!event}
columns={
[
{
header: "Vorname",
accessorKey: "User.firstname",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
Bearbeiten
</button>
</div>
);
{row.original.User.firstname}
</Link>
);
},
},
},
] as ColumnDef<Participant & { User: User }>[]
}
/>
{
header: "Nachname",
accessorKey: "User.lastname",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.lastname}
</Link>
);
},
},
{
header: "VAR-Nummer",
accessorKey: "User.publicId",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.publicId}
</Link>
);
},
},
{
header: "Moodle Kurs abgeschlossen",
accessorKey: "finisherMoodleCurseCompleted",
},
{
header: "Aktionen",
cell: ({ row }) => {
return (
<div className="flex gap-2">
<button
onSubmit={() => false}
type="button"
onClick={() => {
participantForm.reset(row.original);
participantModal.current?.showModal();
}}
className="btn btn-sm btn-outline"
>
Bearbeiten
</button>
</div>
);
},
},
] as ColumnDef<Participant & { User: User }>[]
}
/>
}
</div>
</div>
) : null}

View File

@@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Heliport } from "@repo/db";
import { Heliport, Prisma } from "@repo/db";
const page = () => {
return (
@@ -11,7 +11,17 @@ const page = () => {
<PaginatedTable
stickyHeaders
prismaModel="heliport"
searchFields={["siteName", "info", "hospital", "designator"]}
getFilter={(searchTerm) =>
({
OR: [
{ siteName: { contains: searchTerm, mode: "insensitive" } },
{ info: { contains: searchTerm, mode: "insensitive" } },
{ hospital: { contains: searchTerm, mode: "insensitive" } },
{ designator: { contains: searchTerm, mode: "insensitive" } },
],
}) as Prisma.HeliportWhereInput
}
showSearch
columns={
[
{

View File

@@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Keyword } from "@repo/db";
import { Keyword, Prisma } from "@repo/db";
export default () => {
return (
@@ -12,7 +12,16 @@ export default () => {
stickyHeaders
initialOrderBy={[{ id: "category", desc: true }]}
prismaModel="keyword"
searchFields={["name", "abreviation", "description"]}
showSearch
getFilter={(searchTerm) =>
({
OR: [
{ name: { contains: searchTerm, mode: "insensitive" } },
{ abreviation: { contains: searchTerm, mode: "insensitive" } },
{ category: { contains: searchTerm, mode: "insensitive" } },
],
}) as Prisma.KeywordWhereInput
}
columns={
[
{
@@ -41,11 +50,11 @@ export default () => {
}
leftOfSearch={
<span className="flex items-center gap-2">
<DatabaseBackupIcon className="w-5 h-5" /> Stichwörter
<DatabaseBackupIcon className="h-5 w-5" /> Stichwörter
</span>
}
rightOfSearch={
<p className="text-2xl font-semibold text-left flex items-center gap-2 justify-between">
<p className="flex items-center justify-between gap-2 text-left text-2xl font-semibold">
<Link href={"/admin/keyword/new"}>
<button className="btn btn-sm btn-outline btn-primary">Erstellen</button>
</Link>

View File

@@ -2,7 +2,7 @@
import { penaltyColumns as penaltyColumns } from "(app)/admin/penalty/columns";
import { editReport } from "(app)/admin/report/actions";
import { zodResolver } from "@hookform/resolvers/zod";
import { Report as IReport, User } from "@repo/db";
import { Report as IReport, Prisma, User } from "@repo/db";
import { ReportSchema, Report as IReportZod } from "@repo/db/zod";
import { PaginatedTable } from "_components/PaginatedTable";
import { Button } from "_components/ui/Button";
@@ -149,9 +149,11 @@ export const ReportPenalties = ({
CreatedUser: true,
Report: true,
}}
filter={{
reportId: report.id,
}}
getFilter={() =>
({
reportId: report.id,
}) as Prisma.PenaltyWhereInput
}
columns={penaltyColumns}
/>
</div>

View File

@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { StationOptionalDefaultsSchema } from "@repo/db/zod";
import { useForm } from "react-hook-form";
import { BosUse, ConnectedAircraft, Country, Station, User } from "@repo/db";
import { BosUse, ConnectedAircraft, Country, Prisma, Station, User } from "@repo/db";
import { FileText, LocateIcon, PlaneIcon, UserIcon } from "lucide-react";
import { Input } from "../../../../_components/ui/Input";
import { deleteStation, upsertStation } from "../action";
@@ -198,10 +198,17 @@ export const StationForm = ({ station }: { station?: Station }) => {
Verbundene Piloten
</div>
}
filter={{
stationId: station?.id,
}}
searchFields={["User.firstname", "User.lastname", "User.publicId"]}
getFilter={(searchField) =>
({
stationId: station?.id,
OR: [
{ User: { firstname: { contains: searchField, mode: "insensitive" } } },
{ User: { lastname: { contains: searchField, mode: "insensitive" } } },
{ User: { publicId: { contains: searchField, mode: "insensitive" } } },
],
}) as Prisma.ConnectedAircraftWhereInput
}
showSearch
prismaModel={"connectedAircraft"}
include={{ Station: true, User: true }}
columns={

View File

@@ -3,14 +3,22 @@ import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Station } from "@repo/db";
import { Prisma, Station } from "@repo/db";
const page = () => {
return (
<>
<PaginatedTable
prismaModel="station"
searchFields={["bosCallsign", "operator"]}
showSearch
getFilter={(searchField) =>
({
OR: [
{ bosCallsign: { contains: searchField, mode: "insensitive" } },
{ operator: { contains: searchField, mode: "insensitive" } },
],
}) as Prisma.StationWhereInput
}
stickyHeaders
columns={
[
@@ -44,11 +52,11 @@ const page = () => {
}
leftOfSearch={
<span className="flex items-center gap-2">
<DatabaseBackupIcon className="w-5 h-5" /> Stationen
<DatabaseBackupIcon className="h-5 w-5" /> Stationen
</span>
}
rightOfSearch={
<p className="text-2xl font-semibold text-left flex items-center gap-2 justify-between">
<p className="flex items-center justify-between gap-2 text-left text-2xl font-semibold">
<Link href={"/admin/station/new"}>
<button className="btn btn-sm btn-outline btn-primary">Erstellen</button>
</Link>

View File

@@ -6,8 +6,10 @@ import {
ConnectedAircraft,
ConnectedDispatcher,
DiscordAccount,
FormerDiscordAccount,
Penalty,
PERMISSION,
Prisma,
Station,
User,
} from "@repo/db";
@@ -59,6 +61,7 @@ import { penaltyColumns } from "(app)/admin/penalty/columns";
import { addPenalty, editPenaltys } from "(app)/admin/penalty/actions";
import { reportColumns } from "(app)/admin/report/columns";
import { sendMailByTemplate } from "../../../../../../helper/mail";
import Image from "next/image";
interface ProfileFormProps {
user: User;
@@ -76,6 +79,21 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
className="card-body"
onSubmit={form.handleSubmit(async (values) => {
if (!values.id) return;
if (values.id === session.data?.user.id && values.permissions !== user.permissions) {
toast.error("Du kannst deine eigenen Berechtigungen nicht ändern.");
return;
}
if (values.permissions?.some((perm) => !session.data?.user.permissions.includes(perm))) {
toast.error("Du kannst Berechtigungen nicht hinzufügen, die du selbst nicht besitzt.");
return;
}
const removedPermissions =
user.permissions?.filter((perm) => !values.permissions?.includes(perm)) || [];
if (removedPermissions.some((perm) => !session.data?.user.permissions.includes(perm))) {
toast.error("Du kannst Berechtigungen nicht entfernen, die du selbst nicht besitzt.");
return;
}
await editUser(values.id, {
...values,
email: values.email.toLowerCase(),
@@ -266,10 +284,18 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
</h2>
<PaginatedTable
ref={dispoTableRef}
filter={{
userId: user.id,
}}
getFilter={() =>
({
userId: user.id,
}) as Prisma.ConnectedDispatcherWhereInput
}
prismaModel={"connectedDispatcher"}
initialOrderBy={[
{
id: "loginTime",
desc: true,
},
]}
columns={
[
{
@@ -328,11 +354,19 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
</h2>
<PaginatedTable
ref={pilotTableRef}
filter={{
userId: user.id,
}}
getFilter={() =>
({
userId: user.id,
}) as Prisma.ConnectedAircraftWhereInput
}
prismaModel={"connectedAircraft"}
include={{ Station: true }}
initialOrderBy={[
{
id: "loginTime",
desc: true,
},
]}
columns={
[
{
@@ -478,9 +512,7 @@ export const UserPenalties = ({ user }: { user: User }) => {
CreatedUser: true,
Report: true,
}}
filter={{
userId: user.id,
}}
getFilter={() => ({ userId: user.id }) as Prisma.PenaltyWhereInput}
columns={penaltyColumns}
/>
</div>
@@ -502,9 +534,17 @@ export const UserReports = ({ user }: { user: User }) => {
</div>
<PaginatedTable
prismaModel="report"
filter={{
reportedUserId: user.id,
}}
getFilter={() =>
({
reportedUserId: user.id,
}) as Prisma.ReportWhereInput
}
initialOrderBy={[
{
id: "timestamp",
desc: true,
},
]}
include={{
Sender: true,
Reported: true,
@@ -517,7 +557,7 @@ export const UserReports = ({ user }: { user: User }) => {
interface AdminFormProps {
discordAccount?: DiscordAccount;
user: User;
user: User & { CanonicalUser?: User | null; Duplicates?: User[] | null };
dispoTime: {
hours: number;
minutes: number;
@@ -528,6 +568,7 @@ interface AdminFormProps {
minutes: number;
lastLogin?: Date;
};
formerDiscordAccounts: (FormerDiscordAccount & { DiscordAccount: DiscordAccount | null })[];
reports: {
total: number;
open: number;
@@ -547,6 +588,7 @@ export const AdminForm = ({
pilotTime,
reports,
discordAccount,
formerDiscordAccounts,
openBans,
openTimebans,
}: AdminFormProps) => {
@@ -639,7 +681,57 @@ export const AdminForm = ({
</div>
)}
</div>
{session?.user.permissions.includes("ADMIN_USER_ADVANCED") && (
<div className="mt-2 space-y-1">
<Link href={`/admin/user/${user.id}/duplicate`}>
<Button className="btn-sm btn-outline w-full">
<LockKeyhole className="h-4 w-4" /> Duplikat markieren & sperren
</Button>
</Link>
</div>
)}
</div>
{(user.CanonicalUser || (user.Duplicates && user.Duplicates.length > 0)) && (
<div role="alert" className="alert alert-error alert-outline flex flex-col">
<div className="flex items-center gap-2">
<TriangleAlert />
<div>
{user.CanonicalUser && (
<div>
<h3 className="text-lg font-semibold">Als Duplikat markiert</h3>
<p>
Dieser Account wurde als Duplikat von{" "}
<Link
href={`/admin/user/${user.CanonicalUser.id}`}
className="link link-hover font-semibold"
>
{user.CanonicalUser.firstname} {user.CanonicalUser.lastname} (
{user.CanonicalUser.publicId})
</Link>{" "}
markiert.
</p>
</div>
)}
{user.Duplicates && user.Duplicates.length > 0 && (
<div className="mt-2">
<h3 className="text-lg font-semibold">Duplikate erkannt</h3>
<p>Folgende Accounts wurden als Duplikate dieses Accounts markiert:</p>
<ul className="ml-4 mt-1 list-inside list-disc">
{user.Duplicates.map((duplicate) => (
<li key={duplicate.id}>
<Link href={`/admin/user/${duplicate.id}`} className="link link-hover">
{duplicate.firstname} {duplicate.lastname} ({duplicate.publicId})
</Link>
</li>
))}
</ul>
</div>
)}
</div>
</div>
<p className="text-sm text-gray-400">{user.duplicateReason || "Keine Grund angegeben"}</p>
</div>
)}
{(!!openBans.length || !!openTimebans.length) && (
<div role="alert" className="alert alert-warning alert-outline flex flex-col">
<div className="flex items-center gap-2">
@@ -667,6 +759,65 @@ export const AdminForm = ({
</p>
</div>
)}
<h2 className="card-title mt-2">
<DiscordLogoIcon className="h-5 w-5" /> Frühere Discord Accounts
</h2>
<div className="overflow-x-auto">
<table className="table-sm table">
<thead>
<tr>
<th>Avatar</th>
<th>Benutzername</th>
<th>Discord ID</th>
<th>getrennt am</th>
</tr>
</thead>
<tbody>
{discordAccount && (
<tr>
<td>
<Image
src={`https://cdn.discordapp.com/avatars/${discordAccount.discordId}/${discordAccount.avatar}.png`}
alt="Discord Avatar"
width={40}
height={40}
className="h-10 w-10 rounded-full"
/>
</td>
<td>{discordAccount.username}</td>
<td>{discordAccount.discordId}</td>
<td>N/A (Aktuell verbunden)</td>
</tr>
)}
{formerDiscordAccounts.map((account) => (
<tr key={account.discordId}>
<td>
{account.DiscordAccount && (
<Image
src={`https://cdn.discordapp.com/avatars/${account.DiscordAccount.discordId}/${account.DiscordAccount.avatar}.png`}
alt="Discord Avatar"
width={40}
height={40}
className="h-10 w-10 rounded-full"
/>
)}
</td>
<td>{account.DiscordAccount?.username || "Unbekannt"}</td>
<td>{account.DiscordAccount?.discordId || "Unbekannt"}</td>
<td>{new Date(account.removedAt).toLocaleDateString()}</td>
</tr>
))}
{!discordAccount && formerDiscordAccounts.length === 0 && (
<tr>
<td colSpan={3} className="text-center text-gray-400">
Keine Discord Accounts verknüpft
</td>
</tr>
)}
</tbody>
</table>
</div>
<h2 className="card-title">
<ChartBarBigIcon className="h-5 w-5" /> Aktivität
</h2>

View File

@@ -0,0 +1,109 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useQuery } from "@tanstack/react-query";
import { getUser, markDuplicate } from "(app)/admin/user/action";
import { Button } from "@repo/shared-components";
import { Select } from "_components/ui/Select";
import toast from "react-hot-toast";
import { TriangleAlert } from "lucide-react";
import { useState } from "react";
const DuplicateSchema = z.object({
canonicalUserId: z.string().min(1, "Bitte Nutzer auswählen"),
reason: z.string().max(500).optional(),
});
export const DuplicateForm = ({ duplicateUserId }: { duplicateUserId: string }) => {
const form = useForm<z.infer<typeof DuplicateSchema>>({
resolver: zodResolver(DuplicateSchema),
defaultValues: { canonicalUserId: "", reason: "" },
});
const [search, setSearch] = useState("");
const { data: users } = useQuery({
queryKey: ["duplicate-search"],
queryFn: async () =>
getUser({
OR: [
{ firstname: { contains: search, mode: "insensitive" } },
{ lastname: { contains: search, mode: "insensitive" } },
{ publicId: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
],
}),
enabled: search.length > 0,
refetchOnWindowFocus: false,
});
return (
<form
className="flex flex-wrap gap-3"
onSubmit={form.handleSubmit(async (values) => {
try {
// find selected canonical user by id to obtain publicId
const canonical = (users || []).find((u) => u.id === values.canonicalUserId);
if (!canonical) {
toast.error("Bitte wähle einen Original-Account aus.");
return;
}
await markDuplicate({
duplicateUserId,
canonicalPublicId: canonical.publicId,
reason: values.reason,
});
toast.success("Duplikat verknüpft und Nutzer gesperrt.");
} catch (e: unknown) {
const message =
typeof e === "object" && e && "message" in e
? (e as { message?: string }).message || "Fehler beim Verknüpfen"
: "Fehler beim Verknüpfen";
toast.error(message);
}
})}
>
<div className="card bg-base-200 flex-1 basis-[800px] shadow-xl">
<div className="card-body">
<h2 className="card-title">
<TriangleAlert /> Duplikat markieren & sperren
</h2>
<Select
form={form}
name="canonicalUserId"
label="Original-Nutzer suchen & auswählen"
onInputChange={(v) => setSearch(String(v))}
options={
users?.map((u) => ({
label: `${u.firstname} ${u.lastname} (${u.publicId})`,
value: u.id,
})) || [{ label: "Kein Nutzer gefunden", value: "", disabled: true }]
}
/>
<label className="floating-label w-full">
<span className="flex items-center gap-2 text-lg">Grund (optional)</span>
<input
{...form.register("reason")}
type="text"
className="input input-bordered w-full"
placeholder="Begründung/Audit-Hinweis"
/>
</label>
</div>
</div>
<div className="card bg-base-200 flex-1 basis-[800px] shadow-xl">
<div className="card-body">
<div className="flex w-full gap-4">
<Button
isLoading={form.formState.isSubmitting}
type="submit"
className="btn btn-primary flex-1"
>
Als Duplikat verknüpfen & sperren
</Button>
</div>
</div>
</div>
</form>
);
};

View File

@@ -0,0 +1,37 @@
import { prisma } from "@repo/db";
import { DuplicateForm } from "./_components/DuplicateForm";
import { PersonIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, firstname: true, lastname: true, publicId: true },
});
if (!user) {
return (
<div className="card bg-base-200 shadow-xl">
<div className="card-body">Nutzer nicht gefunden</div>
</div>
);
}
return (
<>
<div className="my-3">
<div className="text-left">
<Link href={`/admin/user/${user.id}`} className="link-hover l-0 text-gray-500">
<ArrowLeft className="mb-1 mr-1 inline h-4 w-4" />
Zurück zum Nutzer
</Link>
</div>
<p className="text-left text-2xl font-semibold">
<PersonIcon className="mr-2 inline h-5 w-5" /> Duplikat für {user.firstname}{" "}
{user.lastname} #{user.publicId}
</p>
</div>
<DuplicateForm duplicateUserId={user.id} />
</>
);
}

View File

@@ -12,12 +12,35 @@ import { getUserPenaltys } from "@repo/shared-components";
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await prisma.user.findUnique({
let user = await prisma.user.findUnique({
where: {
id: id,
},
include: {
discordAccounts: true,
DiscordAccount: true,
CanonicalUser: true,
Duplicates: true,
},
});
if (!user) {
user = await prisma.user.findFirst({
where: {
publicId: id,
},
include: {
DiscordAccount: true,
CanonicalUser: true,
Duplicates: true,
},
});
}
const formerDiscordAccounts = await prisma.formerDiscordAccount.findMany({
where: {
userId: user?.id,
},
include: {
DiscordAccount: true,
},
});
if (!user) return <Error statusCode={404} title="User not found" />;
@@ -119,11 +142,12 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<AdminForm
formerDiscordAccounts={formerDiscordAccounts}
user={user}
dispoTime={dispoTime}
pilotTime={pilotTime}
reports={reports}
discordAccount={user.discordAccounts[0]}
discordAccount={user.DiscordAccount ?? undefined}
openBans={openBans}
openTimebans={openTimeban}
/>

View File

@@ -2,6 +2,7 @@
import { prisma, Prisma } from "@repo/db";
import bcrypt from "bcryptjs";
import { sendMailByTemplate } from "../../../../helper/mail";
import { getServerSession } from "api/auth/[...nextauth]/auth";
export const getUser = async (where: Prisma.UserWhereInput) => {
return await prisma.user.findMany({
@@ -82,3 +83,52 @@ export const sendVerificationLink = async (userId: string) => {
code,
});
};
export const markDuplicate = async (params: {
duplicateUserId: string;
canonicalPublicId: string;
reason?: string;
}) => {
// Then in your function:
const session = await getServerSession();
if (!session?.user) throw new Error("Nicht authentifiziert");
const canonical = await prisma.user.findUnique({
where: { publicId: params.canonicalPublicId },
select: { id: true },
});
if (!canonical) throw new Error("Original-Account (canonical) nicht gefunden");
if (canonical.id === params.duplicateUserId)
throw new Error("Duplikat und Original dürfen nicht identisch sein");
const updated = await prisma.user.update({
where: { id: params.duplicateUserId },
data: {
canonicalUserId: canonical.id,
isBanned: true,
duplicateDetectedAt: new Date(),
duplicateReason: params.reason ?? undefined,
},
});
await prisma.penalty.create({
data: {
userId: params.duplicateUserId,
type: "BAN",
reason: `Account als Duplikat von #${params.canonicalPublicId} markiert.`,
createdUserId: session.user.id,
},
});
return updated;
};
export const clearDuplicateLink = async (duplicateUserId: string) => {
const updated = await prisma.user.update({
where: { id: duplicateUserId },
data: {
canonicalUserId: null,
duplicateDetectedAt: null,
duplicateReason: null,
},
});
return updated;
};

View File

@@ -3,17 +3,34 @@ import { User2 } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { User } from "@repo/db";
import { DiscordAccount, Penalty, Prisma, User } from "@repo/db";
import { useSession } from "next-auth/react";
const AdminUserPage = () => {
const { data: session } = useSession();
return (
<>
<PaginatedTable
stickyHeaders
prismaModel="user"
searchFields={["publicId", "firstname", "lastname", "email"]}
showSearch
getFilter={(searchTerm) => {
return {
OR: [
{ firstname: { contains: searchTerm, mode: "insensitive" } },
{ lastname: { contains: searchTerm, mode: "insensitive" } },
{ email: { contains: searchTerm, mode: "insensitive" } },
{ publicId: { contains: searchTerm, mode: "insensitive" } },
{ DiscordAccount: { username: { contains: searchTerm, mode: "insensitive" } } },
],
} as Prisma.UserWhereInput;
}}
include={{
DiscordAccount: true,
ReceivedReports: true,
Penaltys: true,
}}
initialOrderBy={[
{
id: "publicId",
@@ -37,6 +54,15 @@ const AdminUserPage = () => {
{
header: "Berechtigungen",
cell(props) {
const activePenaltys = props.row.original.Penaltys.filter(
(penalty) =>
!penalty.suspended &&
(penalty.type === "BAN" ||
(penalty.type === "TIME_BAN" && penalty!.until! > new Date())),
);
if (activePenaltys.length > 0) {
return <span className="font-bold text-red-600">AKTIVE STRAFE</span>;
}
if (props.row.original.permissions.length === 0) {
return <span className="text-gray-700">Keine</span>;
} else if (props.row.original.permissions.includes("ADMIN_USER_ADVANCED")) {
@@ -51,6 +77,28 @@ const AdminUserPage = () => {
);
},
},
{
header: "Strafen / Reports",
cell(props) {
const penaltyCount = props.row.original.Penaltys.length;
const reportCount = props.row.original.ReceivedReports.length;
return (
<span className="w-full text-center">
{penaltyCount} / {reportCount}
</span>
);
},
},
{
header: "Discord",
cell(props) {
const discord = props.row.original.DiscordAccount;
if (!discord) {
return <span className="text-gray-700">Nicht verbunden</span>;
}
return <span>{discord.username}</span>;
},
},
...(session?.user.permissions.includes("ADMIN_USER_ADVANCED")
? [
{
@@ -69,7 +117,13 @@ const AdminUserPage = () => {
</div>
),
},
] as ColumnDef<User>[]
] as ColumnDef<
User & {
DiscordAccount: DiscordAccount;
ReceivedReports: Report[];
Penaltys: Penalty[];
}
>[]
} // Define the columns for the user table
leftOfSearch={
<p className="flex items-center gap-2 text-left text-2xl font-semibold">

View File

@@ -0,0 +1,68 @@
"use client";
import MDEditor from "@uiw/react-md-editor";
import Image from "next/image";
export type TimelineEntry = {
id: number;
title: string;
text: string;
previewImage?: string | null;
createdAt: string;
};
const formatReleaseDate = (value: string) =>
new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(value));
export const ChangelogTimeline = ({ entries }: { entries: TimelineEntry[] }) => {
if (!entries.length)
return <p className="text-base-content/70">Es sind noch keine Changelog-Einträge vorhanden.</p>;
return (
<div className="relative mt-6 pl-6">
<div className="bg-base-300 absolute bottom-0 left-2 top-0 w-px" aria-hidden />
<div className="space-y-8">
{entries.map((entry, idx) => (
<article key={entry.id ?? `${entry.title}-${idx}`} className="relative pl-4">
<div className="bg-primary ring-base-100 absolute -left-[9px] top-3 h-4 w-4 rounded-full ring-4" />
<div className="bg-base-200/80 rounded-xl p-5 shadow">
<div className="flex flex-col gap-1 text-left md:flex-row md:justify-between">
<div>
<h3 className="text-lg font-semibold leading-tight">{entry.title}</h3>
<p className="text-base-content/60 text-sm">
Release Date: {formatReleaseDate(entry.createdAt)}
</p>
</div>
{entry.previewImage && (
<div className="absolute right-5 top-5 md:pl-4">
<Image
src={entry.previewImage}
width={300}
height={300}
alt={`${entry.title} preview`}
className="mt-3 max-w-[300px] rounded-lg object-cover md:mt-0"
/>
</div>
)}
</div>
<div className="text-base-content/80 text-left" data-color-mode="dark">
<MDEditor.Markdown
source={entry.text}
style={{
backgroundColor: "transparent",
fontSize: "0.95rem",
}}
/>
</div>
</div>
</article>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,26 @@
import { prisma } from "@repo/db";
import { ChangelogTimeline } from "./_components/Timeline";
import { ActivityLogIcon } from "@radix-ui/react-icons";
export default async function Page() {
const changelog = await prisma.changelog.findMany({
where: { showOnChangelogPage: true },
orderBy: { createdAt: "desc" },
});
const entries = changelog.map((entry) => ({
...entry,
createdAt: entry.createdAt.toISOString(),
}));
return (
<>
<div className="w-full px-4">
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
<ActivityLogIcon className="h-5 w-5" /> Changelog
</p>
</div>
<ChangelogTimeline entries={entries} />
</>
);
}

View File

@@ -27,6 +27,10 @@ const page = async () => {
},
},
},
orderBy: {
id: "desc",
},
});
const appointments = await prisma.eventAppointment.findMany({
where: {

View File

@@ -1,5 +1,5 @@
"use client";
import { Mission, MissionAlertLog, MissionLog, Station } from "@repo/db";
import { Mission, MissionAlertLog, MissionLog, Prisma, Station } from "@repo/db";
import { ColumnDef } from "@tanstack/react-table";
import { Error } from "_components/Error";
import { PaginatedTable } from "_components/PaginatedTable";
@@ -14,19 +14,21 @@ const Page = () => {
return (
<div className="grid grid-cols-6 gap-4">
<div className="col-span-full">
<p className="text-2xl font-semibold text-left flex items-center gap-2">
<NotebookText className="w-5 h-5" /> Einsatzhistorie
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
<NotebookText className="h-5 w-5" /> Einsatzhistorie
</p>
</div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6">
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl">
<PaginatedTable
prismaModel={"missionOnStationUsers"}
filter={{
userId: session.data?.user?.id ?? "",
Mission: {
state: "finished",
},
}}
getFilter={() =>
({
userId: session.data?.user?.id ?? "",
Mission: {
state: "finished",
},
}) as Prisma.MissionOnStationUsersWhereInput
}
include={{
Station: true,
User: true,

View File

@@ -19,10 +19,10 @@ export default async function Home({
<RecentFlights />
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<Badges />
<Bookings />
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<Bookings />
<Badges />
</div>
</div>
<Events />

View File

@@ -1,6 +1,6 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { DiscordAccount, Penalty, User } from "@repo/db";
import { DiscordAccount, Penalty, Report, User } from "@repo/db";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -31,7 +31,7 @@ export const ProfileForm = ({
}: {
user: User;
penaltys: Penalty[];
discordAccount?: DiscordAccount;
discordAccount: DiscordAccount | null;
}): React.JSX.Element => {
const canEdit = penaltys.length === 0 && !user.isBanned;
@@ -215,9 +215,11 @@ export const ProfileForm = ({
export const SocialForm = ({
discordAccount,
user,
penaltys,
}: {
discordAccount?: DiscordAccount;
discordAccount: DiscordAccount | null;
user: User;
penaltys: Penalty[];
}): React.JSX.Element | null => {
const [isLoading, setIsLoading] = useState(false);
const [vatsimLoading, setVatsimLoading] = useState(false);
@@ -235,6 +237,7 @@ export const SocialForm = ({
},
resolver: zodResolver(schema),
});
const canUnlinkDiscord = !user.isBanned && penaltys.length === 0;
if (!user) return null;
return (
@@ -262,7 +265,7 @@ export const SocialForm = ({
</h2>
<div>
<div>
{discordAccount ? (
{discordAccount && canUnlinkDiscord ? (
<Button
className="btn-success btn-block btn-outline hover:btn-error group transition-all duration-0"
isLoading={isLoading}
@@ -326,7 +329,14 @@ export const SocialForm = ({
);
};
export const DeleteForm = ({ user, penaltys }: { user: User; penaltys: Penalty[] }) => {
export const DeleteForm = ({
user,
penaltys,
}: {
user: User;
penaltys: Penalty[];
reports: Report[];
}) => {
const router = useRouter();
const userCanDelete = penaltys.length === 0 && !user.isBanned;
return (
@@ -336,10 +346,11 @@ export const DeleteForm = ({ user, penaltys }: { user: User; penaltys: Penalty[]
</h2>
{!userCanDelete && (
<div className="text-left">
<h2 className="text-warning text-lg">Du kannst dein Konto zurzeit nicht löschen!</h2>
<h2 className="text-warning text-lg">Du kannst dein Konto nicht löschen!</h2>
<p className="text-sm text-gray-400">
Scheinbar hast du aktuell zurzeit aktive Strafen. Um unsere Community zu schützen kannst
du einen Account erst schen wenn deine Strafe nicht mehr aktiv ist
Da du Strafen hast oder hattest, kannst du deinen Account nicht schen. Um unsere
Community zu schützen kannst du deinen Account nicht löschen. Bitte erstelle ein
Support-Ticket, wenn du Fragen dazu hast.
</p>
</div>
)}

View File

@@ -4,9 +4,19 @@ import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import bcrypt from "bcryptjs";
export const unlinkDiscord = async (userId: string) => {
await prisma.discordAccount.deleteMany({
const discordAccount = await prisma.discordAccount.update({
where: {
userId: userId,
},
data: {
userId: null,
},
});
await prisma.formerDiscordAccount.create({
data: {
userId,
discordId: discordAccount.discordId,
},
});
};

View File

@@ -3,6 +3,7 @@ import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import { ProfileForm, SocialForm, PasswordForm, DeleteForm } from "./_components/forms";
import { GearIcon } from "@radix-ui/react-icons";
import { Error } from "_components/Error";
import { getUserPenaltys } from "@repo/shared-components";
export default async function Page() {
const session = await getServerSession();
@@ -13,43 +14,52 @@ export default async function Page() {
id: session.user.id,
},
include: {
discordAccounts: true,
DiscordAccount: true,
Penaltys: true,
},
});
const userPenaltys = await prisma.penalty.findMany({
where: {
userId: session.user.id,
until: {
gte: new Date(),
},
type: {
in: ["TIME_BAN", "BAN"],
},
suspended: false,
},
});
const activePenaltys = await getUserPenaltys(session.user.id);
const userReports = await prisma.report.findMany({
where: {
reportedUserId: session.user.id,
},
});
if (!user) return <Error statusCode={401} title="Dein Account wurde nicht gefunden" />;
const discordAccount = user?.discordAccounts[0];
const discordAccount = user?.DiscordAccount;
return (
<div className="grid grid-cols-6 gap-4">
<div className="col-span-full">
<p className="text-2xl font-semibold text-left flex items-center gap-2">
<GearIcon className="w-5 h-5" /> Einstellungen
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
<GearIcon className="h-5 w-5" /> Einstellungen
</p>
</div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<ProfileForm user={user} penaltys={userPenaltys} discordAccount={discordAccount} />
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<ProfileForm
user={user}
discordAccount={discordAccount}
penaltys={[...activePenaltys.openBans, ...activePenaltys.openTimeban]}
/>
</div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<SocialForm discordAccount={discordAccount} user={user} />
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<SocialForm
user={user}
discordAccount={discordAccount}
penaltys={[...activePenaltys.openBans, ...activePenaltys.openTimeban]}
/>
</div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<PasswordForm />
</div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<DeleteForm user={user} penaltys={userPenaltys} />
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<DeleteForm user={user} reports={userReports} penaltys={userPenaltys} />
</div>
</div>
);

View File

@@ -1,16 +1,16 @@
'use client';
import { signOut } from 'next-auth/react';
import { useEffect } from 'react';
"use client";
import { signOut } from "next-auth/react";
import { useEffect } from "react";
export default () => {
useEffect(() => {
signOut({
callbackUrl: '/login',
});
}, []);
return (
<div className="card-body">
<h1 className="text-5xl">logging out...</h1>
</div>
);
useEffect(() => {
signOut({
callbackUrl: "/login",
});
}, []);
return (
<div className="card-body">
<h1 className="text-5xl">ausloggen...</h1>
</div>
);
};

View File

@@ -8,6 +8,7 @@ import { deleteBookingAPI, getBookingsAPI } from "(app)/_querys/bookings";
import { Button } from "@repo/shared-components";
import { formatTimeRange } from "../../helper/timerange";
import toast from "react-hot-toast";
import Link from "next/link";
interface BookingTimelineModalProps {
isOpen: boolean;
@@ -298,7 +299,17 @@ export const BookingTimelineModal = ({
? "LST"
: booking.Station.bosCallsignShort || booking.Station.bosCallsign}
</span>
<span className="text-sm font-medium">{booking.User.fullName}</span>
{currentUser?.permissions.includes("ADMIN_USER") ? (
<Link
href={`/admin/user/${booking.User.publicId}`}
className="link link-hover text-xs opacity-70"
>
{booking.User.fullName}
</Link>
) : (
<span className="text-sm font-medium">{booking.User.fullName}</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="text-right">

View File

@@ -6,13 +6,15 @@ import {
RocketIcon,
ReaderIcon,
DownloadIcon,
UpdateIcon,
ActivityLogIcon,
} from "@radix-ui/react-icons";
import Link from "next/link";
import { WarningAlert } from "./ui/PageAlert";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { Error } from "./Error";
import Image from "next/image";
import { Plane, Radar, Workflow } from "lucide-react";
import { Loader, Plane, Radar, Workflow } from "lucide-react";
import { BookingButton } from "./BookingButton";
export const VerticalNav = async () => {
@@ -22,93 +24,101 @@ export const VerticalNav = async () => {
return p.startsWith("ADMIN");
});
return (
<ul className="menu bg-base-300 w-64 flex-nowrap rounded-lg p-3 font-semibold shadow-md">
<li>
<Link href="/">
<HomeIcon /> Dashboard
</Link>
</li>
<li>
<Link href="/events">
<RocketIcon />
Events & Kurse
</Link>
</li>
<li>
<Link href="/logbook">
<ReaderIcon />
Einsatzhistorie
</Link>
</li>
<li>
<Link href="/settings">
<GearIcon />
Einstellungen
</Link>
</li>
<li>
<Link href="/resources">
<DownloadIcon />
Downloads / Links
</Link>
</li>
{viewAdminMenu && (
<ul className="menu bg-base-300 flex w-64 flex-nowrap justify-between rounded-lg p-3 font-semibold shadow-md">
<div className="border-none">
<li>
<details open>
<summary>
<LockClosedIcon />
Admin
</summary>
<ul>
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/user">Benutzer</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_STATION") && (
<li>
<Link href="/admin/station">Stationen</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_KEYWORD") && (
<li>
<Link href="/admin/keyword">Stichworte</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_HELIPORT") && (
<li>
<Link href="/admin/heliport">Heliports</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_EVENT") && (
<li>
<Link href="/admin/event">Events</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_MESSAGE") && (
<li>
<Link href="/admin/config">Config</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/report">Reports</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/penalty">Audit-Log</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_CHANGELOG") && (
<li>
<Link href="/admin/changelog">Changelog</Link>
</li>
)}
</ul>
</details>
<Link href="/">
<HomeIcon /> Dashboard
</Link>
</li>
)}
<li>
<Link href="/events">
<RocketIcon />
Events & Kurse
</Link>
</li>
<li>
<Link href="/logbook">
<ReaderIcon />
Einsatzhistorie
</Link>
</li>
<li>
<Link href="/settings">
<GearIcon />
Einstellungen
</Link>
</li>
<li>
<Link href="/resources">
<DownloadIcon />
Downloads / Links
</Link>
</li>
{viewAdminMenu && (
<li>
<details open>
<summary>
<LockClosedIcon />
Admin
</summary>
<ul>
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/user">Benutzer</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_STATION") && (
<li>
<Link href="/admin/station">Stationen</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_KEYWORD") && (
<li>
<Link href="/admin/keyword">Stichworte</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_HELIPORT") && (
<li>
<Link href="/admin/heliport">Heliports</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_EVENT") && (
<li>
<Link href="/admin/event">Events</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_MESSAGE") && (
<li>
<Link href="/admin/config">Config</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/report">Reports</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/penalty">Audit-Log</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_CHANGELOG") && (
<li>
<Link href="/admin/changelog">Changelog</Link>
</li>
)}
</ul>
</details>
</li>
)}
</div>
<li>
<Link href="/changelog">
<ActivityLogIcon />
Changelog
</Link>
</li>
</ul>
);
};

View File

@@ -13,6 +13,7 @@ import { AxiosError } from "axios";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { DateInput } from "_components/ui/DateInput";
import { Select } from "_components/ui/Select";
interface NewBookingFormData {
type: "STATION" | "LST_01" | "LST_02" | "LST_03" | "LST_04";
@@ -85,6 +86,9 @@ export const NewBookingModal = ({
}
});
const form = useForm<NewBookingFormData>({
resolver: zodResolver(newBookingSchema),
});
const {
register,
handleSubmit,
@@ -92,10 +96,7 @@ export const NewBookingModal = ({
setValue,
reset,
formState: { errors },
} = useForm<NewBookingFormData>({
resolver: zodResolver(newBookingSchema),
});
} = form;
const selectedType = watch("type");
const hasDISPOPermission = userPermissions.includes("DISPO");
@@ -168,20 +169,17 @@ export const NewBookingModal = ({
{isLoadingStations ? (
<div className="skeleton h-12 w-full"></div>
) : (
<select
{...register("stationId", {
required:
selectedType === "STATION" ? "Bitte wählen Sie eine Station aus" : false,
<Select
label="Station"
name="stationId"
form={form}
options={stations?.map((s) => {
return {
value: s.id,
label: `${s.bosCallsign} - ${s.locationState} (${s.aircraft})`,
};
})}
className="select select-bordered w-full"
>
<option value="">Station auswählen...</option>
{stations?.map((station) => (
<option key={station.id} value={station.id}>
{station.bosCallsignShort} - {station.locationState} ({station.aircraft})
</option>
))}
</select>
/>
)}
{errors.stationId && (
<label className="label">

View File

@@ -9,26 +9,27 @@ export interface PaginatedTableRef {
refresh: () => void;
}
interface PaginatedTableProps<TData> extends Omit<SortableTableProps<TData>, "data"> {
interface PaginatedTableProps<TData, TWhere extends object>
extends Omit<SortableTableProps<TData>, "data"> {
prismaModel: keyof PrismaClient;
stickyHeaders?: boolean;
filter?: Record<string, unknown>;
initialRowsPerPage?: number;
searchFields?: string[];
showSearch?: boolean;
getFilter?: (searchTerm: string) => TWhere;
include?: Record<string, boolean>;
strictQuery?: boolean;
leftOfSearch?: React.ReactNode;
rightOfSearch?: React.ReactNode;
leftOfPagination?: React.ReactNode;
hide?: boolean;
supressQuery?: boolean;
ref?: Ref<PaginatedTableRef>;
}
export function PaginatedTable<TData>({
export function PaginatedTable<TData, TWhere extends object>({
prismaModel,
initialRowsPerPage = 30,
searchFields = [],
filter,
getFilter,
showSearch = false,
include,
ref,
strictQuery = false,
@@ -36,9 +37,9 @@ export function PaginatedTable<TData>({
leftOfSearch,
rightOfSearch,
leftOfPagination,
hide,
supressQuery,
...restProps
}: PaginatedTableProps<TData>) {
}: PaginatedTableProps<TData, TWhere>) {
const [data, setData] = useState<TData[]>([]);
const [rowsPerPage, setRowsPerPage] = useState(initialRowsPerPage);
const [page, setPage] = useState(0);
@@ -58,17 +59,19 @@ export function PaginatedTable<TData>({
const [loading, setLoading] = useState(false);
const refreshTableData = useCallback(async () => {
if (supressQuery) {
setLoading(false);
return;
}
setLoading(true);
getData(
prismaModel,
rowsPerPage,
page * rowsPerPage,
searchTerm,
searchFields,
filter,
getData({
model: prismaModel,
limit: rowsPerPage,
offset: page * rowsPerPage,
where: getFilter ? getFilter(searchTerm) : undefined,
include,
orderBy,
strictQuery
select: strictQuery
? restProps.columns
.filter(
(col): col is { accessorKey: string } =>
@@ -80,7 +83,7 @@ export function PaginatedTable<TData>({
return acc;
}, {})
: undefined,
)
})
.then((result) => {
if (result) {
setData(result.data);
@@ -91,12 +94,12 @@ export function PaginatedTable<TData>({
setLoading(false);
});
}, [
supressQuery,
prismaModel,
rowsPerPage,
page,
searchTerm,
searchFields,
filter,
getFilter,
include,
orderBy,
strictQuery,
@@ -111,31 +114,33 @@ export function PaginatedTable<TData>({
// useEffect to show loading spinner
useEffect(() => {
if (supressQuery) return;
setLoading(true);
}, [searchTerm, page, rowsPerPage, orderBy, filter, setLoading]);
}, [searchTerm, page, rowsPerPage, orderBy, getFilter, setLoading, supressQuery]);
useDebounce(
() => {
refreshTableData();
},
500,
[searchTerm, page, rowsPerPage, orderBy, filter],
[searchTerm, page, rowsPerPage, orderBy, getFilter],
);
return (
<div className="space-y-4 m-4">
{(rightOfSearch || leftOfSearch || searchFields.length > 0) && (
<div className="m-4 space-y-4">
{(rightOfSearch || leftOfSearch || showSearch) && (
<div
className={cn(
"flex items-center gap-2 sticky py-2 z-20",
stickyHeaders && "sticky top-0 bg-base-100/80 backdrop-blur border-b",
"sticky z-20 flex items-center gap-2 py-2",
stickyHeaders && "bg-base-100/80 sticky top-0 border-b backdrop-blur",
)}
>
<div className="flex-1 flex gap-2">
<div className="flex flex-1 gap-2">
<div>{leftOfSearch}</div>
<div>{loading && <span className="loading loading-dots loading-md" />}</div>
</div>
{searchFields.length > 0 && (
{showSearch && (
<input
type="text"
placeholder="Suchen..."
@@ -150,22 +155,14 @@ export function PaginatedTable<TData>({
<div className="flex justify-center">{rightOfSearch}</div>
</div>
)}
{!hide && (
<SortableTable
data={data}
prismaModel={prismaModel}
setOrderBy={setOrderBy}
{...restProps}
/>
)}
<div className="flex items-between">
<SortableTable data={data} prismaModel={prismaModel} setOrderBy={setOrderBy} {...restProps} />
<div className="items-between flex">
{leftOfPagination}
{!hide && (
<>
<RowsPerPage rowsPerPage={rowsPerPage} setRowsPerPage={setRowsPerPage} />
<Pagination totalPages={Math.ceil(total / rowsPerPage)} page={page} setPage={setPage} />
</>
)}
<>
<RowsPerPage rowsPerPage={rowsPerPage} setRowsPerPage={setRowsPerPage} />
<Pagination totalPages={Math.ceil(total / rowsPerPage)} page={page} setPage={setPage} />
</>
</div>
</div>
);

View File

@@ -49,13 +49,13 @@ export default function SortableTable<TData>({
return (
<div className="overflow-x-auto">
<table className="table table-zebra w-full">
<table className="table-zebra table w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} onClick={header.column.getToggleSortingHandler()}>
<div className="flex items-center gap-1 cursor-pointer">
<div className="flex cursor-pointer items-center gap-1">
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === "asc" && <ChevronUp size={16} />}
{header.column.getIsSorted() === "desc" && <ChevronDown size={16} />}
@@ -75,7 +75,7 @@ export default function SortableTable<TData>({
))}
{table.getRowModel().rows.length === 0 && (
<tr>
<td colSpan={columns.length} className="text-center font-bold text-sm text-gray-500">
<td colSpan={columns.length} className="text-center text-sm font-bold text-gray-500">
Keine Daten gefunden
</td>
</tr>
@@ -104,6 +104,8 @@ export const RowsPerPage = ({
<option value={50}>50</option>
<option value={100}>100</option>
<option value={300}>300</option>
<option value={1000}>1000</option>
<option value={5000}>5000</option>
</select>
);
};

View File

@@ -2,56 +2,30 @@
"use server";
import { prisma, PrismaClient } from "@repo/db";
export async function getData(
model: keyof PrismaClient,
limit: number,
offset: number,
searchTerm: string,
searchFields: string[],
filter?: Record<string, any>,
include?: Record<string, boolean>,
orderBy?: Record<string, "asc" | "desc">,
select?: Record<string, any>,
) {
if (!model || !prisma[model]) {
export async function getData<Twhere>({
model,
limit,
offset,
where,
include,
orderBy,
select,
}: {
model: keyof PrismaClient;
limit: number;
offset: number;
where: Twhere;
include?: Record<string, boolean>;
orderBy?: Record<string, "asc" | "desc">;
select?: Record<string, any>;
}) {
if (!model || !(prisma as any)[model]) {
return { data: [], total: 0 };
}
const formattedId = searchTerm.match(/^VAR(\d+)$/)?.[1];
const delegate = (prisma as any)[model];
const where = searchTerm
? {
OR: [
formattedId ? { id: formattedId } : undefined,
...searchFields.map((field) => {
if (field.includes(".")) {
const parts: string[] = field.split(".");
// Helper function to build nested object
const buildNestedFilter = (parts: string[], index = 0): any => {
if (index === parts.length - 1) {
// Reached the last part - add the contains filter
return { [parts[index] as string]: { contains: searchTerm } };
}
// For intermediate levels, nest the next level
return { [parts[index] as string]: buildNestedFilter(parts, index + 1) };
};
return buildNestedFilter(parts);
}
return { [field]: { contains: searchTerm } };
}),
].filter(Boolean),
...filter,
}
: { ...filter };
if (!prisma[model]) {
return { data: [], total: 0 };
}
const data = await (prisma[model] as any).findMany({
const data = await delegate.findMany({
where,
orderBy,
take: limit,
@@ -60,7 +34,7 @@ export async function getData(
select,
});
const total = await (prisma[model] as any).count({ where });
const total = await delegate.count({ where });
return { data, total };
}

View File

@@ -31,6 +31,7 @@ const customStyles: StylesConfig<OptionType, false> = {
backgroundColor: state.isSelected ? "hsl(var(--p))" : "hsl(var(--b1))",
color: "var(--color-primary-content)",
"&:hover": { backgroundColor: "var(--color-base-200)" }, // DaisyUI secondary color
cursor: "pointer",
}),
multiValueLabel: (provided) => ({
...provided,
@@ -49,6 +50,11 @@ const customStyles: StylesConfig<OptionType, false> = {
backgroundColor: "var(--color-base-100)",
borderRadius: "0.5rem",
}),
input: (provided) => ({
...provided,
color: "var(--color-primary-content)",
cursor: "text",
}),
};
const SelectCom = <T extends FieldValues>({
@@ -61,7 +67,7 @@ const SelectCom = <T extends FieldValues>({
}: SelectProps<T>) => {
return (
<div>
<span className="label-text text-lg flex items-center gap-2">{label}</span>
<span className="label-text flex items-center gap-2 text-lg">{label}</span>
<SelectTemplate
onChange={(newValue: any) => {
if (Array.isArray(newValue)) {

View File

@@ -3,6 +3,8 @@ import { NextRequest, NextResponse } from "next/server";
import { DiscordAccount, prisma } from "@repo/db";
import { getServerSession } from "../auth/[...nextauth]/auth";
import { setStandardName } from "../../../helper/discord";
import { getUserPenaltys } from "@repo/shared-components";
import { markDuplicate } from "(app)/admin/user/action";
export const GET = async (req: NextRequest) => {
const session = await getServerSession();
@@ -77,6 +79,29 @@ export const GET = async (req: NextRequest) => {
userId: user.id,
});
}
const formerDiscordAccount = await prisma.formerDiscordAccount.findMany({
where: {
discordId: discordUser.id,
userId: {
not: session.user.id,
},
User: {
canonicalUserId: null,
},
},
include: { User: true },
});
// Account is suspicious to multiaccounting
if (formerDiscordAccount.length > 0) {
formerDiscordAccount.forEach(async (account) => {
await markDuplicate({
duplicateUserId: session.user.id,
canonicalPublicId: account.User!.publicId,
reason: "Multiaccounting Verdacht, gleicher Discord Account wie ein anderer Nutzer.",
});
});
}
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_HUB_URL}/settings`);
} catch (error) {

View File

@@ -1,5 +1,5 @@
"use client";
import { User } from "@repo/db";
import { Prisma, User } from "@repo/db";
import { ColumnDef } from "@tanstack/react-table";
import { PaginatedTable } from "_components/PaginatedTable";
@@ -7,13 +7,24 @@ export default function () {
return (
<PaginatedTable
strictQuery
searchFields={["firstname", "lastname", "vatsimCid"]}
showSearch
prismaModel={"user"}
filter={{
vatsimCid: {
gt: 1,
},
}}
getFilter={(searchTerm) =>
({
AND: [
{
vatsimCid: {
not: "",
},
OR: [
{ firstname: { contains: searchTerm, mode: "insensitive" } },
{ lastname: { contains: searchTerm, mode: "insensitive" } },
{ vatsimCid: { contains: searchTerm, mode: "insensitive" } },
],
},
],
}) as Prisma.UserWhereInput
}
leftOfSearch={<h1 className="text-2xl font-bold">Vatsim-Nutzer</h1>}
columns={
[

View File

@@ -1,6 +1,10 @@
/** @type {import('next').NextConfig} */
/* const removeImports = require("next-remove-imports")(); */
/* const nextConfig = removeImports({}); */
const nextConfig = {};
const nextConfig = {
images: {
domains: ["cdn.discordapp.com", "nextcloud.virtualairrescue.com"],
},
};
export default nextConfig;

View File

@@ -37,7 +37,7 @@
"lodash": "^4.17.21",
"lucide-react": "^0.525.0",
"moment": "^2.30.1",
"next": "^15.4.2",
"next": "^15.4.8",
"next-auth": "^4.24.11",
"next-remove-imports": "^1.0.12",
"npm": "^11.4.2",

View File

@@ -21,6 +21,18 @@ services:
command:
- "--config.file=/etc/prometheus/prometheus.yml"
victoriametrics:
image: victoriametrics/victoria-metrics:latest
container_name: victoria-metrics
restart: unless-stopped
ports:
- "8428:8428" # VM Web UI + API + Prometheus-compatible read endpoint
volumes:
- victoria-metrics-data:/storage
command:
- "-storageDataPath=/storage"
- "-retentionPeriod=24" # 24 Monate retention
redis:
container_name: redis
image: redis/redis-stack:latest
@@ -47,5 +59,6 @@ services:
volumes:
postgres-data:
victoria-metrics-data:
redis_data:
driver: local

View File

@@ -138,6 +138,21 @@ services:
- /sys:/sys:ro
networks:
- core_network
victoriametrics:
image: victoriametrics/victoria-metrics:latest
container_name: victoria-metrics
restart: unless-stopped
ports:
- "8428:8428" # VM Web UI + API + Prometheus-compatible read endpoint
volumes:
- victoria-metrics-data:/storage
command:
- "-storageDataPath=/storage"
- "-retentionPeriod=24" # 24 Monate retention
networks:
- core_network
- traefik
prometheus:
restart: unless-stopped
image: prom/prometheus:latest
@@ -235,3 +250,4 @@ volumes:
driver: local
portainer_data:
prometheus_data:
victoria-metrics-data:

View File

@@ -132,6 +132,20 @@ services:
networks:
- core_network
restart: unless-stopped
victoriametrics:
image: victoriametrics/victoria-metrics:latest
container_name: victoria-metrics
restart: unless-stopped
ports:
- "8428:8428" # VM Web UI + API + Prometheus-compatible read endpoint
volumes:
- victoria-metrics-data:/storage
command:
- "-storageDataPath=/storage"
- "-retentionPeriod=24" # 24 Monate retention
networks:
- core_network
prometheus:
image: prom/prometheus:latest
container_name: prometheus
@@ -228,3 +242,4 @@ volumes:
driver: local
portainer_data:
prometheus_data:
victoria-metrics-data:

View File

@@ -8,10 +8,10 @@
"schema": "./prisma/schema/"
},
"scripts": {
"generate": "npx prisma generate && npx prisma generate zod",
"migrate": "npx prisma migrate dev",
"deploy": "npx prisma migrate deploy",
"dev": "npx prisma studio --browser none"
"generate": "npx prisma@6.12.0 generate && npx prisma@6.12.0 generate zod",
"migrate": "npx prisma@6.12.0 migrate dev",
"deploy": "npx prisma@6.12.0 migrate deploy",
"dev": "npx prisma@6.12.0 studio --browser none"
},
"exports": {
".": "./index.ts",

View File

@@ -0,0 +1,7 @@
export interface XplaneObject {
objectName: string;
lat: number;
lon: number;
heading: number;
alt: number;
}

View File

@@ -50,9 +50,20 @@ export type MissionAutoClose = {
};
};
export type MissionClosed = {
type: "mission-closed";
status: "closed";
message: string;
data: {
missionId: number;
publicMissionId: string;
};
};
export type NotificationPayload =
| ValidationFailed
| ValidationSuccess
| AdminMessage
| StationStatus
| MissionAutoClose;
| MissionAutoClose
| MissionClosed;

View File

@@ -3,3 +3,4 @@ export * from "./MissionVehicleLog";
export * from "./User";
export * from "./OSMway";
export * from "./SocketEvents";
export * from "./MissionXplaneObjects";

View File

@@ -1,7 +1,8 @@
model Changelog {
id Int @id @default(autoincrement())
title String
previewImage String?
text String
createdAt DateTime @default(now())
id Int @id @default(autoincrement())
title String
previewImage String?
text String
createdAt DateTime @default(now())
showOnChangelogPage Boolean @default(true)
}

View File

@@ -1,21 +1,22 @@
model ConnectedAircraft {
id Int @id @default(autoincrement())
userId String
publicUser Json
lastHeartbeat DateTime @default(now())
fmsStatus String @default("6")
id Int @id @default(autoincrement())
userId String
publicUser Json
lastHeartbeat DateTime @default(now())
fmsStatus String @default("6")
// position:
posLat Float?
posLng Float?
posAlt Int?
posSpeed Int?
posHeading Int?
simulator String?
posH145active Boolean @default(false)
stationId Int
loginTime DateTime @default(now())
esimatedLogoutTime DateTime?
logoutTime DateTime?
posLat Float?
posLng Float?
posAlt Int?
posSpeed Int?
posHeading Int?
simulator String?
posH145active Boolean @default(false)
posXplanePluginActive Boolean @default(false)
stationId Int
loginTime DateTime @default(now())
esimatedLogoutTime DateTime?
logoutTime DateTime?
// relations:
User User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ConnectedAircraft" ADD COLUMN "posXplanePluginActive" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Mission" ADD COLUMN "xPlaneObjects" JSONB[] DEFAULT ARRAY[]::JSONB[];

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "settings_use_hpg_as_dispatcher" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ALTER COLUMN "settings_use_hpg_as_dispatcher" SET DEFAULT true;

View File

@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "canonical_user_id" TEXT,
ADD COLUMN "duplicate_detected_at" TIMESTAMP(3),
ADD COLUMN "duplicate_reason" TEXT;
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_canonical_user_id_fkey" FOREIGN KEY ("canonical_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,33 @@
/*
Warnings:
- You are about to drop the column `user_id` on the `discord_accounts` table. All the data in the column will be lost.
*/
-- DropForeignKey
ALTER TABLE "discord_accounts" DROP CONSTRAINT "discord_accounts_user_id_fkey";
-- AlterTable
ALTER TABLE "discord_accounts" RENAME COLUMN "user_id" TO "userId";
-- CreateTable
CREATE TABLE "former_discord_accounts" (
"id" SERIAL NOT NULL,
"discord_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"removed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "former_discord_accounts_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "former_discord_accounts_discord_id_key" ON "former_discord_accounts"("discord_id");
-- AddForeignKey
ALTER TABLE "former_discord_accounts" ADD CONSTRAINT "former_discord_accounts_discord_id_fkey" FOREIGN KEY ("discord_id") REFERENCES "discord_accounts"("discord_id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "former_discord_accounts" ADD CONSTRAINT "former_discord_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "discord_accounts" ADD CONSTRAINT "discord_accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,35 @@
/*
Warnings:
- You are about to drop the `former_discord_accounts` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "former_discord_accounts" DROP CONSTRAINT "former_discord_accounts_discord_id_fkey";
-- DropForeignKey
ALTER TABLE "former_discord_accounts" DROP CONSTRAINT "former_discord_accounts_user_id_fkey";
-- AlterTable
ALTER TABLE "discord_accounts" ALTER COLUMN "userId" DROP NOT NULL;
-- DropTable
DROP TABLE "former_discord_accounts";
-- CreateTable
CREATE TABLE "FormerDiscordAccount" (
"discord_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"removed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FormerDiscordAccount_pkey" PRIMARY KEY ("discord_id","user_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "FormerDiscordAccount_discord_id_key" ON "FormerDiscordAccount"("discord_id");
-- AddForeignKey
ALTER TABLE "FormerDiscordAccount" ADD CONSTRAINT "FormerDiscordAccount_discord_id_fkey" FOREIGN KEY ("discord_id") REFERENCES "discord_accounts"("discord_id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormerDiscordAccount" ADD CONSTRAINT "FormerDiscordAccount_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[userId]` on the table `discord_accounts` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "discord_accounts_userId_key" ON "discord_accounts"("userId");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Changelog" ADD COLUMN "showOnChangelogPage" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -19,6 +19,7 @@ model Mission {
missionStationIds Int[] @default([])
missionStationUserIds String[] @default([])
missionLog Json[] @default([])
xPlaneObjects Json[] @default([])
hpgMissionString String?
hpgSelectedMissionString String?
hpgAmbulanceState HpgState? @default(NOT_REQUESTED)

View File

@@ -38,15 +38,16 @@ model User {
changelogAck Boolean @default(false)
// Settings:
pathSelected Boolean @default(false)
migratedFromV1 Boolean @default(false)
settingsNtfyRoom String? @map(name: "settings_ntfy_room")
settingsMicDevice String? @map(name: "settings_mic_device")
settingsMicVolume Float? @map(name: "settings_mic_volume")
settingsDmeVolume Float? @map(name: "settings_dme_volume")
settingsRadioVolume Float? @map(name: "settings_funk_volume")
settingsHideLastname Boolean @default(false) @map(name: "settings_hide_lastname")
settingsAutoCloseMapPopup Boolean @default(false) @map(name: "settings_auto_close_map_popup")
pathSelected Boolean @default(false)
migratedFromV1 Boolean @default(false)
settingsNtfyRoom String? @map(name: "settings_ntfy_room")
settingsMicDevice String? @map(name: "settings_mic_device")
settingsMicVolume Float? @map(name: "settings_mic_volume")
settingsDmeVolume Float? @map(name: "settings_dme_volume")
settingsRadioVolume Float? @map(name: "settings_funk_volume")
settingsHideLastname Boolean @default(false) @map(name: "settings_hide_lastname")
settingsAutoCloseMapPopup Boolean @default(false) @map(name: "settings_auto_close_map_popup")
settingsUseHPGAsDispatcher Boolean @default(true) @map(name: "settings_use_hpg_as_dispatcher")
// email Verification:
emailVerificationToken String? @map(name: "email_verification_token")
@@ -59,9 +60,14 @@ model User {
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at")
isBanned Boolean @default(false) @map(name: "is_banned")
// Duplicate handling:
canonicalUserId String? @map(name: "canonical_user_id")
CanonicalUser User? @relation("CanonicalUser", fields: [canonicalUserId], references: [id])
Duplicates User[] @relation("CanonicalUser")
duplicateDetectedAt DateTime? @map(name: "duplicate_detected_at")
duplicateReason String? @map(name: "duplicate_reason")
// relations:
oauthTokens OAuthToken[]
discordAccounts DiscordAccount[]
participants Participant[]
EventAppointmentUser EventAppointment[] @relation("EventAppointmentUser")
EventAppointment EventAppointment[]
@@ -79,13 +85,26 @@ model User {
CreatedPenalties Penalty[] @relation("CreatedPenalties")
Bookings Booking[]
DiscordAccount DiscordAccount?
FormerDiscordAccounts FormerDiscordAccount[]
@@map(name: "users")
}
model FormerDiscordAccount {
discordId String @unique @map(name: "discord_id")
userId String @map(name: "user_id")
removedAt DateTime @default(now()) @map(name: "removed_at")
DiscordAccount DiscordAccount? @relation(fields: [discordId], references: [discordId], onDelete: SetNull)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([discordId, userId])
}
model DiscordAccount {
id Int @id @default(autoincrement())
discordId String @unique @map(name: "discord_id")
userId String @map(name: "user_id")
email String @map(name: "email")
username String @map(name: "username")
avatar String? @map(name: "avatar")
@@ -97,8 +116,10 @@ model DiscordAccount {
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at")
// relations:
user User @relation(fields: [userId], references: [id], onDelete: Cascade) // Beziehung zu User
// Related User
userId String? @unique
User User? @relation(fields: [userId], references: [id], onDelete: SetNull)
formerDiscordAccount FormerDiscordAccount?
@@map(name: "discord_accounts")
}

View File

@@ -2,10 +2,7 @@ global:
scrape_interval: 40s
remote_write:
- url: https://prometheus-prod-36-prod-us-west-0.grafana.net/api/prom/push
basic_auth:
username: 2527367
password: glc_eyJvIjoiMTMzOTM4MiIsIm4iOiJzdGFjay0xMzAxNTY2LWFsbG95LWxvY2FsLWRldiIsImsiOiI1YkM0SkFvODU3NjJCaTFlQnkwY0xySjEiLCJtIjp7InIiOiJwcm9kLXVzLXdlc3QtMCJ9fQ==
- url: "http://victoriametrics:8428/api/v1/write"
scrape_configs:
- job_name: core-server

View File

@@ -1,6 +1,9 @@
global:
scrape_interval: 40s
remote_write:
- url: "http://victoriametrics:8428/api/v1/write"
scrape_configs:
- job_name: core-server
static_configs:
@@ -20,10 +23,29 @@ scrape_configs:
- job_name: "traefik"
static_configs:
- targets: ["traefik:8080"] # Traefik dashboard endpoint
# - job_name: "Node Exporter"
# static_configs:
# - targets:
# [
# "var01.virtualairrescue.com:9100/metrics",
# "var01.virtualairrescue.com:9100/probe?target=https://virtualairrescue.com&module=http_2xx",
# ]
- job_name: node_exporter
scrape_interval: 15s
static_configs:
- targets: ["node_exporter:9100"]
- job_name: blackbox
metrics_path: /probe
params:
module: [http_2xx]
scrape_interval: 60s
static_configs:
- targets:
- https://status.virtualairrescue.com
- https://virtualairrescue.com
- https://ops.virtualairrescue.com
- https://nextcloud.virtualairrescue.com
- https://moodle.virtualairrescue.com
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox_exporter:9115

430
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,28 @@
packages:
- apps/*
- packages/*
onlyBuiltDependencies:
- "@prisma/client"
- "@prisma/engines"
- "@tailwindcss/oxide"
- '@prisma/client'
- '@prisma/engines'
- '@tailwindcss/oxide'
- esbuild
- prisma
- sharp
- unrs-resolver
overrides:
'@eslint/plugin-kit@<0.3.4': '>=0.3.4'
axios@>=1.0.0 <1.12.0: '>=1.12.0'
body-parser@>=2.2.0 <2.2.1: '>=2.2.1'
form-data@>=4.0.0 <4.0.4: '>=4.0.4'
js-yaml@>=4.0.0 <4.1.1: '>=4.1.1'
jws@<3.2.3: '>=3.2.3'
mdast-util-to-hast@>=13.0.0 <13.2.1: '>=13.2.1'
next-auth@<4.24.12: '>=4.24.12'
next@>=15.0.0 <=15.4.4: '>=15.4.5'
next@>=15.0.0-canary.0 <15.4.7: '>=15.4.7'
next@>=15.4.0-canary.0 <15.4.8: '>=15.4.8'
nodemailer@<7.0.7: '>=7.0.7'
nodemailer@<=7.0.10: '>=7.0.11'
playwright@<1.55.1: '>=1.55.1'