64 Commits

Author SHA1 Message Date
PxlLoewe
d4c1c816ff Merge pull request #165 from VAR-Virtual-Air-Rescue/staging 2026-02-08 23:46:22 +01:00
PxlLoewe
1b425d82e2 cron zum aktuallisieren der Discord Avatare hinzugefügt 2026-02-08 22:22:25 +01:00
PxlLoewe
5d0a36f967 Report anzeige für auto-reports verbessert 2026-02-08 21:37:10 +01:00
PxlLoewe
b46ad25bde Imports entfernt 2026-02-08 20:17:44 +01:00
PxlLoewe
d5a4118025 Admin-Panel hinzugefügt 2026-02-08 20:12:45 +01:00
PxlLoewe
aded6d1492 HPG Warnung in Dispatch Settings, Status Notification 2026-02-08 13:03:11 +01:00
PxlLoewe
8340c2408c Fixed Acc deleted Warnung im Profil 2026-02-07 13:43:37 +01:00
PxlLoewe
31c17e2dda Merge pull request #161 from VAR-Virtual-Air-Rescue/staging
admin link
2026-02-01 12:52:46 +01:00
PxlLoewe
2e9bb95d12 admin link 2026-02-01 12:49:05 +01:00
PxlLoewe
c8eeee1452 Merge pull request #160 from VAR-Virtual-Air-Rescue/staging
Fixed Wrong IP being loged
2026-02-01 11:50:14 +01:00
PxlLoewe
824d2e40a9 Fixed Wrong IP being loged 2026-02-01 11:45:18 +01:00
PxlLoewe
3d1b83cd32 Merge pull request #159 from VAR-Virtual-Air-Rescue/staging
List headers
2026-02-01 10:26:35 +01:00
PxlLoewe
cc29ac3e14 List headers 2026-02-01 00:49:17 +01:00
PxlLoewe
195f1dc9c0 Fixed Account Log filter 2026-02-01 00:41:45 +01:00
PxlLoewe
40b11d2501 Merge pull request #158 from VAR-Virtual-Air-Rescue/staging
Catch Blocks
2026-02-01 00:19:22 +01:00
PxlLoewe
4ae2e93249 Error handling Rename 2026-02-01 00:18:26 +01:00
PxlLoewe
a60cd67c44 Catch Blocks 2026-02-01 00:01:06 +01:00
PxlLoewe
9303878d8d Merge pull request #157 from VAR-Virtual-Air-Rescue/staging
fixed permacrash core-server
2026-01-31 23:42:07 +01:00
PxlLoewe
829d6d8cde member, guild fetch improved 2026-01-31 23:39:35 +01:00
PxlLoewe
25692b66be Merge pull request #156 from VAR-Virtual-Air-Rescue/staging
v2.0.8
2026-01-31 23:08:50 +01:00
PxlLoewe
f0c138655e added getMember into catch block 2026-01-31 23:02:29 +01:00
PxlLoewe
ac441e908d Discord Permissions will be revoked, when under a penalty 2026-01-31 22:48:26 +01:00
PxlLoewe
d1c49a3208 Moved Dispatch NAvbar component, to remove code dupl.; Fixed timezone bug in hub 2026-01-31 22:11:46 +01:00
PxlLoewe
580dc32ad0 fix Type errors by ESM Module of Prisma 2026-01-30 19:42:34 +01:00
PxlLoewe
2d8a282cec add log for delet account 2026-01-30 19:00:01 +01:00
PxlLoewe
5607aacd16 Account deleted flag 2026-01-30 17:29:50 +01:00
PxlLoewe
8555b901a5 Fixe type errors 2026-01-30 17:14:16 +01:00
PxlLoewe
76d4355320 Merge pull request #154 from VAR-Virtual-Air-Rescue/Enhanced-Audit-log-for-user-Profiles
Enhanced audit log for user profiles
2026-01-30 17:01:50 +01:00
PxlLoewe
10af6bf71a Added Account log for registration 2026-01-30 16:56:22 +01:00
PxlLoewe
2154684223 completed Account Log 2026-01-30 16:19:00 +01:00
PxlLoewe
ea8d63ce0b Merge branch 'Enhanced-Audit-log-for-user-Profiles' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into Enhanced-Audit-log-for-user-Profiles 2026-01-30 00:27:09 +01:00
PxlLoewe
e4aae9804b Continue Account log 2026-01-30 00:25:51 +01:00
PxlLoewe
005509598c Include Profile log in renamed penalty model -> Audit log 2026-01-29 21:54:04 +01:00
PxlLoewe
b250fa46c2 Merge pull request #153 from VAR-Virtual-Air-Rescue/event-admin-redesign
Event admin redesign
2026-01-29 21:49:05 +01:00
PxlLoewe
e4fa011d96 upgrade pnpm, Table auf Event seite 2026-01-29 21:47:49 +01:00
PxlLoewe
bdc35ea6b3 Include Profile log in renamed penalty model -> Audit log 2026-01-21 19:38:55 +01:00
PxlLoewe
9129652912 remove appointment from events 2026-01-18 01:09:39 +01:00
PxlLoewe
606379d151 Remove Event-Appointment 2026-01-18 01:01:15 +01:00
PxlLoewe
ad15f2d942 Discord Einladungslink 2026-01-17 20:52:16 +01:00
PxlLoewe
2638ad473f Dockerfile Hub 2026-01-16 00:03:33 +01:00
PxlLoewe
da93b5e60c Update Dockerfile 2026-01-16 00:03:33 +01:00
PxlLoewe
15118cac66 Revert "Revert "PR v2.0.7"" 2026-01-16 00:03:33 +01:00
PxlLoewe
c254cd0774 Revert "PR v2.0.7" 2026-01-16 00:03:33 +01:00
PxlLoewe
062e7d44c0 dev 2026-01-16 00:03:33 +01:00
PxlLoewe
8956204a2f Dockerfile Hub 2026-01-15 23:54:51 +01:00
PxlLoewe
a5b4696644 Update Dockerfile 2026-01-15 23:46:49 +01:00
PxlLoewe
a9ecf7e7b8 Merge branch 'release' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into release 2026-01-15 23:46:16 +01:00
PxlLoewe
547768410f Merge pull request #149 from VAR-Virtual-Air-Rescue/revert-148-revert-147-staging
Revert "Revert "PR v2.0.7""
2026-01-15 23:45:26 +01:00
PxlLoewe
2c2eca6084 Revert "Revert "PR v2.0.7"" 2026-01-15 23:45:06 +01:00
PxlLoewe
5898dc6477 Merge branch 'release' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into release 2026-01-15 23:44:08 +01:00
PxlLoewe
c9499e08be dev 2026-01-15 23:43:53 +01:00
PxlLoewe
0429d8b770 Merge pull request #148 from VAR-Virtual-Air-Rescue/revert-147-staging
Revert "PR v2.0.7"
2026-01-15 23:35:32 +01:00
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
bfe4d56cf7 Merge pull request #144 from VAR-Virtual-Air-Rescue/mrt-rework
Mrt rework
2026-01-15 21:14:59 +01:00
PxlLoewe
b1e508ef36 release v2.0.6
v2.0.6
2026-01-06 13:58:24 +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
dde52bde39 Release v2.0.4
Release v2.0.4
2025-12-08 19:48:53 +01:00
94 changed files with 1679 additions and 1288 deletions

View File

@@ -1,7 +1,9 @@
import { DISCORD_ROLES, MissionLog, NotificationPayload, prisma } from "@repo/db"; import { DISCORD_ROLES, MissionLog, NotificationPayload, prisma } from "@repo/db";
import { io } from "index"; import { io } from "index";
import cron from "node-cron"; import cron from "node-cron";
import { setUserStandardNamePermissions } from "routes/helper";
import { changeMemberRoles } from "routes/member"; import { changeMemberRoles } from "routes/member";
import client from "./discord";
const removeMission = async (id: number, reason: string) => { const removeMission = async (id: number, reason: string) => {
const log: MissionLog = { const log: MissionLog = {
@@ -120,6 +122,37 @@ const removeClosedMissions = async () => {
return removeMission(mission.id, "dem freimelden aller Stationen"); return removeMission(mission.id, "dem freimelden aller Stationen");
}); });
}; };
const syncDiscordImgUrls = async () => {
try {
const discordAccounts = await prisma.discordAccount.findMany({
where: {
updatedAt: {
lt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
},
User: {
isNot: null,
},
},
});
for (const account of discordAccounts) {
client.users.fetch(account.discordId).then((discordUser) => {
const nextAvatar = discordUser?.avatar ?? null;
if (typeof nextAvatar !== "string") return;
if (nextAvatar === account.avatar) return;
prisma.discordAccount.update({
where: {
id: account.id,
},
data: {
avatar: nextAvatar,
},
});
});
}
} catch (error) {}
};
const removeConnectedAircrafts = async () => { const removeConnectedAircrafts = async () => {
const connectedAircrafts = await prisma.connectedAircraft.findMany({ const connectedAircrafts = await prisma.connectedAircraft.findMany({
where: { where: {
@@ -141,59 +174,91 @@ const removeConnectedAircrafts = async () => {
}); });
}; };
const removePermissionsForBannedUsers = async () => { const removePermissionsForBannedUsers = async () => {
const activePenalties = await prisma.penalty.findMany({ try {
where: { const removePermissionsPenaltys = await prisma.penalty.findMany({
OR: [ where: {
{ removePermissionApplied: false,
type: "BAN", User: {
suspended: false, DiscordAccount: { isNot: null },
},
{
type: "TIME_BAN",
suspended: false,
until: {
gt: new Date().toISOString(),
},
},
],
},
include: {
User: {
include: {
DiscordAccount: true,
FormerDiscordAccounts: true,
}, },
}, },
}, include: {
}); User: {
include: {
DiscordAccount: true,
FormerDiscordAccounts: true,
},
},
},
});
for (const penalty of activePenalties) { const addPermissionsPenaltys = await prisma.penalty.findMany({
const user = penalty.User; where: {
addPermissionApplied: false,
User: {
DiscordAccount: { isNot: null },
},
OR: [{ suspended: true }, { until: { lt: new Date().toISOString() } }],
},
include: {
User: {
include: {
DiscordAccount: true,
FormerDiscordAccounts: true,
},
},
},
});
for (const penalty of removePermissionsPenaltys) {
const user = penalty.User;
console.log(`Removing roles for user ${user.id} due to penalty ${penalty.id}`);
if (user.DiscordAccount) {
await changeMemberRoles( await changeMemberRoles(
user.DiscordAccount.discordId, user.DiscordAccount!.discordId,
[DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER], [DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER],
"remove", "remove",
); );
}
for (const formerAccount of user.FormerDiscordAccounts) { for (const formerAccount of user.FormerDiscordAccounts) {
await changeMemberRoles( await changeMemberRoles(
formerAccount.discordId, formerAccount.discordId,
[DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER], [DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER],
"remove", "remove",
); );
}
await prisma.penalty.update({
where: { id: penalty.id },
data: { removePermissionApplied: true },
});
} }
for (const penalty of addPermissionsPenaltys) {
console.log(`Restoring roles for user ${penalty.userId} due to penalty ${penalty.id}`);
await setUserStandardNamePermissions({
memberId: penalty.User.DiscordAccount!.discordId,
userId: penalty.userId,
});
await prisma.penalty.update({
where: { id: penalty.id },
data: { addPermissionApplied: true },
});
}
} catch (error) {
console.error("Error removing permissions for banned users:", error);
} }
}; };
cron.schedule("*/5 * * * *", async () => { cron.schedule("0 0 * * *", async () => {
await removePermissionsForBannedUsers(); try {
await syncDiscordImgUrls();
} catch (error) {
console.error("Error on daily cron job:", error);
}
}); });
cron.schedule("*/1 * * * *", async () => { cron.schedule("*/1 * * * *", async () => {
try { try {
await removePermissionsForBannedUsers();
await removeClosedMissions(); await removeClosedMissions();
await removeConnectedAircrafts(); await removeConnectedAircrafts();
} catch (error) { } catch (error) {

View File

@@ -7,22 +7,25 @@ const router: Router = Router();
export const eventCompleted = (event: Event, participant?: Participant) => { export const eventCompleted = (event: Event, participant?: Participant) => {
if (!participant) return false; if (!participant) return false;
if (event.finisherMoodleCourseId && !participant.finisherMoodleCurseCompleted) return false; if (event.finisherMoodleCourseId && !participant.finisherMoodleCurseCompleted) return false;
if (event.hasPresenceEvents && !participant.attended) return false;
return true; return true;
}; };
router.post("/set-standard-name", async (req, res) => { export const setUserStandardNamePermissions = async ({
const { memberId, userId } = req.body; memberId,
userId,
}: {
memberId: string;
userId: string;
}) => {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
id: userId, id: userId,
}, },
}); });
if (!user) { if (!user) {
res.status(404).json({ error: "User not found" });
return; return;
} }
const participant = await prisma.participant.findMany({ const participant = await prisma.participant.findMany({
where: { where: {
userId: user.id, userId: user.id,
@@ -63,6 +66,8 @@ router.post("/set-standard-name", async (req, res) => {
const publicUser = getPublicUser(user); const publicUser = getPublicUser(user);
const member = await getMember(memberId); const member = await getMember(memberId);
if (!member) throw new Error("Member not found");
await member.setNickname(`${publicUser.fullName} - ${user.publicId}`); await member.setNickname(`${publicUser.fullName} - ${user.publicId}`);
const isPilot = user.permissions.includes("PILOT"); const isPilot = user.permissions.includes("PILOT");
const isDispatcher = user.permissions.includes("DISPO"); const isDispatcher = user.permissions.includes("DISPO");
@@ -73,6 +78,17 @@ router.post("/set-standard-name", async (req, res) => {
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove"); await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove");
await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove"); await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove");
} }
};
router.post("/set-standard-name", async (req, res) => {
try {
const { memberId, userId } = req.body;
await setUserStandardNamePermissions({ memberId, userId });
res.status(200).json({ message: "Standard name and permissions set" });
} catch (error) {
res.status(500).json({ error: (error as unknown as Error).message });
}
}); });
export default router; export default router;

View File

@@ -9,13 +9,23 @@ if (!GUILD_ID) {
const router: Router = Router(); const router: Router = Router();
export const getMember = async (memberId: string) => { export const getMember = async (memberId: string) => {
const guild = client.guilds.cache.get(GUILD_ID); let guild = client.guilds.cache.get(GUILD_ID);
if (!guild) {
guild = await client.guilds.fetch(GUILD_ID);
}
if (!guild) throw new Error("Guild not found"); if (!guild) throw new Error("Guild not found");
try { try {
return guild.members.cache.get(memberId) ?? (await guild.members.fetch(memberId)); let member = guild.members.cache.get(memberId);
if (!member) {
member = await guild.members.fetch(memberId).catch((e) => undefined);
}
return member;
} catch (error) { } catch (error) {
console.error("Error fetching member:", error); console.error("Error fetching member:", error);
throw new Error("Member not found"); return null;
} }
}; };
@@ -27,11 +37,15 @@ router.post("/rename", async (req: Request, res: Response) => {
} }
try { try {
const member = await getMember(memberId); const member = await getMember(memberId);
if (!member) {
res.status(404).json({ error: "Member not found" });
return;
}
await member.setNickname(newName); await member.setNickname(newName);
console.log(`Member ${member.id} renamed to ${newName}`); console.log(`Member ${member.id} renamed to ${newName}`);
res.status(200).json({ message: "Member renamed successfully" }); res.status(200).json({ message: "Member renamed successfully" });
} catch (error) { } catch (error) {
console.error("Error renaming member:", error); console.error("Error renaming member:", (error as Error).message);
res.status(500).json({ error: "Failed to rename member" }); res.status(500).json({ error: "Failed to rename member" });
} }
}); });
@@ -42,6 +56,9 @@ export const changeMemberRoles = async (
action: "add" | "remove", action: "add" | "remove",
) => { ) => {
const member = await getMember(memberId); const member = await getMember(memberId);
if (!member) {
throw new Error("Member not found");
}
const currentRoleIds = member.roles.cache.map((role) => role.id); const currentRoleIds = member.roles.cache.map((role) => role.id);
const filteredRoleIds = const filteredRoleIds =
@@ -67,7 +84,7 @@ const handleRoleChange = (action: "add" | "remove") => async (req: Request, res:
const result = await changeMemberRoles(memberId, roleIds, action); const result = await changeMemberRoles(memberId, roleIds, action);
res.status(200).json(result); res.status(200).json(result);
} catch (error) { } catch (error) {
console.error(`Error ${action}ing roles:`, error); console.error(`Error ${action}ing roles:`, (error as Error).message);
res.status(500).json({ error: `Failed to ${action} roles` }); res.status(500).json({ error: `Failed to ${action} roles` });
} }
}; };

View File

@@ -43,14 +43,20 @@ router.post("/admin-embed", async (req, res) => {
{ name: "angemeldet als", value: report.reportedUserRole, inline: true }, { name: "angemeldet als", value: report.reportedUserRole, inline: true },
{ {
name: "gemeldet von", name: "gemeldet von",
value: `${report.Sender?.firstname} ${report.Sender?.lastname} (${report.Sender?.publicId})`, value: report.Sender
? `${report.Sender?.firstname} ${report.Sender?.lastname} (${report.Sender?.publicId})`
: "System",
}, },
) )
.setFooter({ .setFooter({
text: "Bitte reagiere mit 🫡, wenn du den Report bearbeitet hast, oder mit ✅, wenn er abgeschlossen ist.", text: "Bitte reagiere mit 🫡, wenn du den Report bearbeitet hast, oder mit ✅, wenn er abgeschlossen ist.",
}) })
.setTimestamp(new Date(report.timestamp)) .setTimestamp(new Date(report.timestamp));
.setColor("DarkRed"); if (report.reviewed) {
embed.setColor("DarkGreen");
} else {
embed.setColor("DarkRed");
}
const reportsChannel = await client.channels.fetch(process.env.DISCORD_REPORT_CHANNEL!); const reportsChannel = await client.channels.fetch(process.env.DISCORD_REPORT_CHANNEL!);
if (!reportsChannel || !reportsChannel.isSendable()) { if (!reportsChannel || !reportsChannel.isSendable()) {
@@ -59,7 +65,9 @@ router.post("/admin-embed", async (req, res) => {
} }
const message = await reportsChannel.send({ embeds: [embed] }); const message = await reportsChannel.send({ embeds: [embed] });
message.react("🫡").catch(console.error); message.react("🫡").catch(console.error);
message.react("✅").catch(console.error); if (!report.reviewed) {
message.react("✅").catch(console.error);
}
res.json({ res.json({
message: "Report embed sent to Discord channel", message: "Report embed sent to Discord channel",
}); });

View File

@@ -1,4 +1,4 @@
import axios from "axios"; import axios, { AxiosError } from "axios";
const discordAxiosClient = axios.create({ const discordAxiosClient = axios.create({
baseURL: process.env.CORE_SERVER_URL, baseURL: process.env.CORE_SERVER_URL,
@@ -11,7 +11,10 @@ export const renameMember = async (memberId: string, newName: string) => {
newName, newName,
}) })
.catch((error) => { .catch((error) => {
console.error("Error renaming member:", error); console.error(
"Error renaming member:",
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
);
}); });
}; };
@@ -22,7 +25,10 @@ export const addRolesToMember = async (memberId: string, roleIds: string[]) => {
roleIds, roleIds,
}) })
.catch((error) => { .catch((error) => {
console.error("Error adding roles to member:", error); console.error(
"Error adding roles to member:",
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
);
}); });
}; };
@@ -33,7 +39,10 @@ export const removeRolesFromMember = async (memberId: string, roleIds: string[])
roleIds, roleIds,
}) })
.catch((error) => { .catch((error) => {
console.error("Error removing roles from member:", error); console.error(
"Error removing roles from member:",
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
);
}); });
}; };
@@ -43,6 +52,9 @@ export const sendReportEmbed = async (reportId: number) => {
reportId, reportId,
}) })
.catch((error) => { .catch((error) => {
console.error("Error removing roles from member:", error); console.error(
"Error removing roles from member:",
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
);
}); });
}; };

View File

@@ -154,6 +154,15 @@ router.post("/:id/send-sds-message", async (req, res) => {
}, },
}); });
const user = await prisma.user.findFirst({
where: { publicId: sdsMessage.data.user.publicId, firstname: sdsMessage.data.user.firstname },
});
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
io.to( io.to(
sdsMessage.data.direction === "to-lst" ? "dispatchers" : `station:${sdsMessage.data.stationId}`, sdsMessage.data.direction === "to-lst" ? "dispatchers" : `station:${sdsMessage.data.stationId}`,
).emit(sdsMessage.data.direction === "to-lst" ? "notification" : "sds-status", { ).emit(sdsMessage.data.direction === "to-lst" ? "notification" : "sds-status", {
@@ -163,6 +172,7 @@ router.post("/:id/send-sds-message", async (req, res) => {
data: { data: {
aircraftId: parseInt(id), aircraftId: parseInt(id),
stationId: sdsMessage.data.stationId, stationId: sdsMessage.data.stationId,
userId: user.id,
}, },
} as NotificationPayload); } as NotificationPayload);

View File

@@ -75,5 +75,6 @@ USER nextjs
# Expose the application port # Expose the application port
EXPOSE 3000 EXPOSE 3000
ENV HOST=0.0.0.0
CMD ["node", "apps/dispatch/server.js"] CMD ["node", "apps/dispatch/server.js"]

View File

@@ -0,0 +1,36 @@
import { ExitIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { prisma } from "@repo/db";
import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper";
import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import AdminPanel from "_components/navbar/AdminPanel";
export default async function Navbar({ children }: { children: React.ReactNode }) {
const session = await getServerSession();
const latestChangelog = await prisma.changelog.findFirst({
orderBy: {
createdAt: "desc",
},
});
return (
<div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
<div className="flex items-center gap-2">
<div>
<p className="text-xl font-semibold normal-case">VAR Operations Center</p>
<ChangelogWrapper latestChangelog={latestChangelog} />
</div>
{session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
</div>
<div className="flex items-center gap-2">
{children}
<ModeSwitchDropdown className="dropdown-center" btnClassName="btn-ghost" />
<Link href={"/logout"}>
<button className="btn btn-ghost">
<ExitIcon className="h-4 w-4" />
</button>
</Link>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
"use client"; "use client";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useDispatchConnectionStore } from "../../../../../_store/dispatch/connectionStore"; import { useDispatchConnectionStore } from "../../../../_store/dispatch/connectionStore";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { Prisma } from "@repo/db"; import { Prisma } from "@repo/db";

View File

@@ -1,63 +0,0 @@
import { Connection } from "./_components/Connection";
import { Audio } from "../../../../_components/Audio/Audio";
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { Settings } from "./_components/Settings";
import AdminPanel from "_components/navbar/AdminPanel";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { WarningAlert } from "_components/navbar/PageAlert";
import { Radar } from "lucide-react";
import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper";
import { prisma } from "@repo/db";
export default async function Navbar() {
const session = await getServerSession();
const latestChangelog = await prisma.changelog.findFirst({
orderBy: {
createdAt: "desc",
},
});
return (
<div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
<div className="flex items-center gap-2">
<div>
<p className="text-xl font-semibold normal-case">VAR Leitstelle</p>
<ChangelogWrapper latestChangelog={latestChangelog} />
</div>
{session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
</div>
<WarningAlert />
<div className="flex items-center gap-5">
<div className="flex items-center gap-2">
<Audio />
</div>
<div className="flex items-center">
<Connection />
</div>
<div className="flex items-center">
<Settings />
<Link href={"/tracker"} target="_blank" rel="noopener noreferrer">
<button className="btn btn-ghost">
<Radar size={19} /> Tracker
</button>
</Link>
<Link
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
target="_blank"
rel="noopener noreferrer"
>
<button className="btn btn-ghost">
<ExternalLinkIcon className="h-4 w-4" /> HUB
</button>
</Link>
<Link href={"/logout"}>
<button className="btn btn-ghost">
<ExitIcon className="h-4 w-4" />
</button>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons"; import { GearIcon } from "@radix-ui/react-icons";
import { SettingsIcon, Volume2 } from "lucide-react"; import { Info, SettingsIcon, Volume2 } from "lucide-react";
import MicVolumeBar from "_components/MicVolumeIndication"; import MicVolumeBar from "_components/MicVolumeIndication";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { editUserAPI, getUserAPI } from "_querys/user"; import { editUserAPI, getUserAPI } from "_querys/user";
@@ -27,7 +27,6 @@ export const SettingsBtn = () => {
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["user", session.data?.user.id] }); await queryClient.invalidateQueries({ queryKey: ["user", session.data?.user.id] });
}, },
}); });
useEffect(() => { useEffect(() => {
@@ -82,10 +81,14 @@ export const SettingsBtn = () => {
useEffect(() => { useEffect(() => {
const setDevices = async () => { const setDevices = async () => {
if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) { if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) {
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true }); try {
const devices = await navigator.mediaDevices.enumerateDevices(); const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
setInputDevices(devices.filter((d) => d.kind === "audioinput")); const devices = await navigator.mediaDevices.enumerateDevices();
stream.getTracks().forEach((track) => track.stop()); setInputDevices(devices.filter((d) => d.kind === "audioinput"));
stream.getTracks().forEach((track) => track.stop());
} catch (error) {
console.error("Error accessing media devices.", error);
}
} }
}; };
@@ -214,7 +217,18 @@ export const SettingsBtn = () => {
setSettingsPartial({ useHPGAsDispatcher: e.target.checked }); setSettingsPartial({ useHPGAsDispatcher: e.target.checked });
}} }}
/> />
HPG als Disponent verwenden HPG Validierung verwenden{" "}
<div
className="tooltip tooltip-warning"
data-tip="Achtung! Mit der Client Version v2.0.1.0 werden Einsätze auch ohne Validierung an die
HPG gesendet. Die Validierung über die HPG kann zu Verzögerungen bei der
Einsatzübermittlung führen, insbesondere wenn das HPG script den Einsatz nicht sofort
validieren kann. Es wird empfohlen, diese Option nur zu aktivieren, wenn es Probleme
mit der Einsatzübermittlung gibt oder wenn die HPG Validierung ausdrücklich gewünscht
wird."
>
<Info className="text-error" size={16} />
</div>
</div> </div>
<div className="modal-action flex justify-between"> <div className="modal-action flex justify-between">

View File

@@ -1,26 +0,0 @@
"use client";
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
interface ThemeSwapProps {
isDark: boolean;
toggleTheme: () => void;
}
export const ThemeSwap: React.FC<ThemeSwapProps> = ({
isDark,
toggleTheme,
}) => {
return (
<label className="swap swap-rotate">
<input
type="checkbox"
className="theme-controller"
checked={isDark}
onChange={toggleTheme}
/>
<MoonIcon className="swap-off h-5 w-5 fill-current" />
<SunIcon className="swap-on h-5 w-5 fill-current" />
</label>
);
};

View File

@@ -1,7 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Navbar from "./_components/navbar/Navbar";
import { getServerSession } from "api/auth/[...nextauth]/auth"; import { getServerSession } from "api/auth/[...nextauth]/auth";
import { Error } from "_components/Error"; import { Error } from "_components/Error";
import Navbar from "(app)/_components/Navbar";
import { Audio } from "_components/Audio/Audio";
import { Connection } from "./_components/navbar/Connection";
import { Settings } from "./_components/navbar/Settings";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "VAR: Disponent", title: "VAR: Disponent",
@@ -26,7 +29,11 @@ export default async function RootLayout({
return ( return (
<> <>
<Navbar /> <Navbar>
<Audio />
<Connection />
<Settings />
</Navbar>
{children} {children}
</> </>
); );

View File

@@ -1,13 +1,27 @@
import { useEffect } from "react"; // ...existing code...
import { useMrtStore } from "_store/pilot/MrtStore"; import { useMrtStore } from "_store/pilot/MrtStore";
import Image from "next/image"; import Image from "next/image";
import DAY_BASE_IMG from "./images/Base_NoScreen_Day.png"; import DAY_BASE_IMG from "./images/Base_NoScreen_Day.png";
import NIGHT_BASE_IMG from "./images/Base_NoScreen_Night.png"; import NIGHT_BASE_IMG from "./images/Base_NoScreen_Night.png";
export const MrtBase = () => { export const MrtBase = () => {
const { nightMode } = useMrtStore((state) => state); const { nightMode, setNightMode, page } = useMrtStore((state) => state);
useEffect(() => {
const checkNightMode = () => {
const currentHour = new Date().getHours();
setNightMode(currentHour >= 22 || currentHour < 8);
};
checkNightMode(); // Initial check
const intervalId = setInterval(checkNightMode, 60000); // Check every minute
return () => clearInterval(intervalId); // Cleanup on unmount
}, [setNightMode]); // ...existing code...
return ( return (
<Image <Image
src={nightMode ? NIGHT_BASE_IMG : DAY_BASE_IMG} src={nightMode && page !== "off" ? NIGHT_BASE_IMG : DAY_BASE_IMG}
alt="" alt=""
className="z-30 col-span-full row-span-full" className="z-30 col-span-full row-span-full"
/> />

View File

@@ -19,7 +19,14 @@ const MrtButton = ({ onClick, onHold, style }: MrtButtonProps) => {
const handleMouseDown = () => { const handleMouseDown = () => {
if (!onHold) return; if (!onHold) return;
timeoutRef.current = setTimeout(onHold, 500); timeoutRef.current = setTimeout(handleTimeoutExpired, 500);
};
const handleTimeoutExpired = () => {
timeoutRef.current = null;
if (onHold) {
onHold();
}
}; };
const handleMouseUp = () => { const handleMouseUp = () => {

View File

@@ -3,6 +3,7 @@
import { SetPageParams, useMrtStore } from "_store/pilot/MrtStore"; import { SetPageParams, useMrtStore } from "_store/pilot/MrtStore";
import Image, { StaticImageData } from "next/image"; import Image, { StaticImageData } from "next/image";
import PAGE_HOME from "./images/PAGE_Home.png"; import PAGE_HOME from "./images/PAGE_Home.png";
import PAGE_HOME_NO_GROUP from "./images/PAGE_Home_no_group.png";
import PAGE_Call from "./images/PAGE_Call.png"; import PAGE_Call from "./images/PAGE_Call.png";
import PAGE_Off from "./images/PAGE_Off.png"; import PAGE_Off from "./images/PAGE_Off.png";
import PAGE_STARTUP from "./images/PAGE_Startup.png"; import PAGE_STARTUP from "./images/PAGE_Startup.png";
@@ -22,7 +23,7 @@ export const MrtDisplay = () => {
const callEstablishedRef = useRef(false); const callEstablishedRef = useRef(false);
const session = useSession(); const session = useSession();
const { connectedAircraft, selectedStation } = usePilotConnectionStore((state) => state); const { connectedAircraft, selectedStation } = usePilotConnectionStore((state) => state);
const { room, speakingParticipants, isTalking } = useAudioStore((state) => state); const { room, speakingParticipants, isTalking, state } = useAudioStore((state) => state);
const [pageImage, setPageImage] = useState<{ const [pageImage, setPageImage] = useState<{
src: StaticImageData; src: StaticImageData;
name: SetPageParams["page"]; name: SetPageParams["page"];
@@ -49,7 +50,6 @@ export const MrtDisplay = () => {
); );
useEffect(() => { useEffect(() => {
console.log("speakingParticipants", speakingParticipants, isTalking, page);
if ((speakingParticipants.length > 0 || isTalking) && page === "home") { if ((speakingParticipants.length > 0 || isTalking) && page === "home") {
setPage({ setPage({
page: "voice-call", page: "voice-call",
@@ -157,7 +157,11 @@ export const MrtDisplay = () => {
switch (page) { switch (page) {
case "home": case "home":
setNextImage({ src: PAGE_HOME, name: "home" }); if (state == "connected") {
setNextImage({ src: PAGE_HOME, name: "home" });
} else {
setNextImage({ src: PAGE_HOME_NO_GROUP, name: "home" });
}
break; break;
case "voice-call": case "voice-call":
setNextImage({ src: PAGE_Call, name: "voice-call" }); setNextImage({ src: PAGE_Call, name: "voice-call" });
@@ -169,7 +173,7 @@ export const MrtDisplay = () => {
setNextImage({ src: PAGE_STARTUP, name: "startup" }); setNextImage({ src: PAGE_STARTUP, name: "startup" });
break; break;
} }
}, [page]); }, [page, state]);
const DisplayText = ({ pageName }: { pageName: SetPageParams["page"] }) => { const DisplayText = ({ pageName }: { pageName: SetPageParams["page"] }) => {
return ( return (
@@ -204,10 +208,10 @@ export const MrtDisplay = () => {
{!connectedAircraft && <>Keine Verbindung</>} {!connectedAircraft && <>Keine Verbindung</>}
</p> </p>
<p className="absolute left-[22.7%] top-[37.8%] flex h-[5%] w-[34%] items-center text-xs"> <p className="absolute left-[22.7%] top-[37.8%] flex h-[5%] w-[34%] items-center text-xs">
{room?.name || "Keine RG gefunden"} {state == "connected" ? room?.name : "Keine RG gewählt"}
</p> </p>
<p className="absolute left-[28%] top-[44.5%] h-[8%] w-[34%] text-xs"> <p className="absolute left-[28%] top-[44.5%] h-[8%] w-[34%] text-xs">
{ROOMS.find((r) => r.name === room?.name)?.id} {state == "connected" && ROOMS.find((r) => r.name === room?.name)?.id}
</p> </p>
</> </>
)} )}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

View File

@@ -1,7 +1,10 @@
"use client"; "use client";
import { useAudioStore } from "_store/audioStore";
import { RoomEvent } from "livekit-client";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
export const useSounds = () => { export const useSounds = () => {
const { room } = useAudioStore((state) => state);
const longBtnPressSoundRef = useRef<HTMLAudioElement>(null); const longBtnPressSoundRef = useRef<HTMLAudioElement>(null);
const statusSentSoundRef = useRef<HTMLAudioElement>(null); const statusSentSoundRef = useRef<HTMLAudioElement>(null);
const sdsReceivedSoundRef = useRef<HTMLAudioElement>(null); const sdsReceivedSoundRef = useRef<HTMLAudioElement>(null);
@@ -14,6 +17,20 @@ export const useSounds = () => {
} }
}, []); }, []);
useEffect(() => {
const handleRoomConnected = () => {
// Play a sound when connected to the room
// connectedSound.play();
statusSentSoundRef.current?.play();
console.log("Room connected - played sound");
};
room?.on(RoomEvent.Connected, handleRoomConnected);
return () => {
room?.off(RoomEvent.Connected, handleRoomConnected);
};
}, [room]);
return { return {
longBtnPressSoundRef, longBtnPressSoundRef,
statusSentSoundRef, statusSentSoundRef,

View File

@@ -76,7 +76,6 @@ export const ConnectionBtn = () => {
const session = useSession(); const session = useSession();
const uid = session.data?.user?.id; const uid = session.data?.user?.id;
if (!uid) return null; if (!uid) return null;
console.log(bookings);
return ( return (
<div className="rounded-box bg-base-200 flex items-center justify-center gap-2 p-1"> <div className="rounded-box bg-base-200 flex items-center justify-center gap-2 p-1">
{connection.message.length > 0 && ( {connection.message.length > 0 && (

View File

@@ -1,58 +0,0 @@
import { Connection } from "./_components/Connection";
import { Audio } from "_components/Audio/Audio";
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { Settings } from "./_components/Settings";
import { WarningAlert } from "_components/navbar/PageAlert";
import { Radar } from "lucide-react";
import { prisma } from "@repo/db";
import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper";
export default async function Navbar() {
const latestChangelog = await prisma.changelog.findFirst({
orderBy: {
createdAt: "desc",
},
});
return (
<div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
<div className="flex items-center gap-2">
<div>
<p className="text-xl font-semibold normal-case">VAR Operations Center</p>
<ChangelogWrapper latestChangelog={latestChangelog} />
</div>
</div>
<WarningAlert />
<div className="flex items-center gap-5">
<div className="flex items-center gap-2">
<Audio />
</div>
<div className="flex items-center">
<Connection />
</div>
<div className="flex items-center">
<Settings />
<Link href={"/tracker"} target="_blank" rel="noopener noreferrer">
<button className="btn btn-ghost">
<Radar size={19} /> Tracker
</button>
</Link>
<Link
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
target="_blank"
rel="noopener noreferrer"
>
<button className="btn btn-ghost">
<ExternalLinkIcon className="h-4 w-4" /> HUB
</button>
</Link>
<Link href={"/logout"}>
<button className="btn btn-ghost">
<ExitIcon className="h-4 w-4" />
</button>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,26 +0,0 @@
"use client";
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
interface ThemeSwapProps {
isDark: boolean;
toggleTheme: () => void;
}
export const ThemeSwap: React.FC<ThemeSwapProps> = ({
isDark,
toggleTheme,
}) => {
return (
<label className="swap swap-rotate">
<input
type="checkbox"
className="theme-controller"
checked={isDark}
onChange={toggleTheme}
/>
<MoonIcon className="swap-off h-5 w-5 fill-current" />
<SunIcon className="swap-on h-5 w-5 fill-current" />
</label>
);
};

View File

@@ -1,7 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Navbar from "./_components/navbar/Navbar";
import { getServerSession } from "api/auth/[...nextauth]/auth"; import { getServerSession } from "api/auth/[...nextauth]/auth";
import { Error } from "_components/Error"; import { Error } from "_components/Error";
import Navbar from "(app)/_components/Navbar";
import { Audio } from "_components/Audio/Audio";
import { Connection } from "./_components/navbar/Connection";
import { Settings } from "./_components/navbar/Settings";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "VAR: Pilot", title: "VAR: Pilot",
@@ -26,7 +29,11 @@ export default async function RootLayout({
return ( return (
<> <>
<Navbar /> <Navbar>
<Audio />
<Connection />
<Settings />
</Navbar>
{children} {children}
</> </>
); );

View File

@@ -78,6 +78,7 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
}); });
}, },
}); });
console.log("Audio Room:", audioRoom, participants, livekitUser, event);
useEffect(() => { useEffect(() => {
let soundRef: React.RefObject<HTMLAudioElement | null> | null = null; let soundRef: React.RefObject<HTMLAudioElement | null> | null = null;
@@ -113,7 +114,6 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
}; };
}, [event.status, livekitUser?.roomName, audioRoom, t.id]); }, [event.status, livekitUser?.roomName, audioRoom, t.id]);
console.log(connectedAircraft, station);
if (!connectedAircraft || !station || !session.data) return null; if (!connectedAircraft || !station || !session.data) return null;
return ( return (
<BaseNotification> <BaseNotification>

View File

@@ -1,18 +1,24 @@
"use client"; "use client";
import { cn } from "@repo/shared-components"; import { cn } from "@repo/shared-components";
import { ArrowLeftRight, Plane, Radar, Workflow } from "lucide-react"; import { ArrowLeftRight, ExternalLinkIcon, Plane, Radar, Workflow } from "lucide-react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
export default function ModeSwitchDropdown({ className }: { className?: string }) { export default function ModeSwitchDropdown({
className,
btnClassName,
}: {
className?: string;
btnClassName?: string;
}) {
const path = usePathname(); const path = usePathname();
const session = useSession(); const session = useSession();
return ( return (
<div className={cn("dropdown z-999999", className)}> <div className={cn("dropdown z-999999", className)}>
<div tabIndex={0} role="button" className="btn m-1"> <div tabIndex={0} role="button" className={cn("btn", btnClassName)}>
<ArrowLeftRight size={22} /> {path.includes("pilot") && "Pilot"} <ArrowLeftRight size={22} /> {path.includes("pilot") && "Pilot"}
{path.includes("dispatch") && "Leitstelle"} {path.includes("dispatch") && "Leitstelle"}
</div> </div>
@@ -39,6 +45,15 @@ export default function ModeSwitchDropdown({ className }: { className?: string }
<Radar size={22} /> Tracker <Radar size={22} /> Tracker
</Link> </Link>
</li> </li>
<li>
<Link
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLinkIcon size={22} /> HUB
</Link>
</li>
</ul> </ul>
</div> </div>
); );

View File

@@ -25,8 +25,6 @@ import { ROOMS } from "_data/livekitRooms";
let interval: NodeJS.Timeout; let interval: NodeJS.Timeout;
const connectedSound = new Audio("/sounds/403.wav");
type TalkState = { type TalkState = {
addSpeakingParticipant: (participant: Participant) => void; addSpeakingParticipant: (participant: Participant) => void;
connect: (room: (typeof ROOMS)[number] | undefined, role: string) => void; connect: (room: (typeof ROOMS)[number] | undefined, role: string) => void;
@@ -190,6 +188,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
if (!token) throw new Error("Fehlende Berechtigung"); if (!token) throw new Error("Fehlende Berechtigung");
const room = new Room({}); const room = new Room({});
await room.prepareConnection(url, token); await room.prepareConnection(url, token);
const roomConnectedSound = new Audio("/sounds/403.wav");
room room
// Connection events // Connection events
.on(RoomEvent.Connected, async () => { .on(RoomEvent.Connected, async () => {
@@ -202,11 +201,23 @@ export const useAudioStore = create<TalkState>((set, get) => ({
}); });
} }
const inputStream = await navigator.mediaDevices.getUserMedia({ let inputStream = await navigator.mediaDevices
audio: { .getUserMedia({
deviceId: get().settings.micDeviceId ?? undefined, audio: {
}, deviceId: get().settings.micDeviceId ?? undefined,
}); },
})
.catch((e) => {
console.error("Konnte das Audio-Gerät nicht öffnen:", e);
return null;
});
if (!inputStream) {
inputStream = await navigator.mediaDevices.getUserMedia({ audio: true }).catch((e) => {
console.error("Konnte das Audio-Gerät nicht öffnen:", e);
return null;
});
}
if (!inputStream) throw new Error("Konnte das Audio-Gerät nicht öffnen");
// Funk-Effekt anwenden // Funk-Effekt anwenden
const radioStream = getRadioStream(inputStream, get().settings.micVolume); const radioStream = getRadioStream(inputStream, get().settings.micVolume);
if (!radioStream) throw new Error("Konnte Funkstream nicht erzeugen"); if (!radioStream) throw new Error("Konnte Funkstream nicht erzeugen");
@@ -219,7 +230,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
source: Track.Source.Microphone, source: Track.Source.Microphone,
}); });
await publishedTrack.mute(); await publishedTrack.mute();
connectedSound.play().catch((e) => console.error("Fehler beim Abspielen des Sounds", e)); roomConnectedSound.play();
set({ localRadioTrack: publishedTrack }); set({ localRadioTrack: publishedTrack });
set({ state: "connected", room, isTalking: false, message: null }); set({ state: "connected", room, isTalking: false, message: null });

View File

@@ -1,6 +1,5 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -84,6 +84,7 @@ const updateParticipantMoodleResults = async () => {
}; };
CronJob.from({ cronTime: "0 * * * *", onTick: syncMoodleIds, start: true }); CronJob.from({ cronTime: "0 * * * *", onTick: syncMoodleIds, start: true });
CronJob.from({ CronJob.from({
cronTime: "*/1 * * * *", cronTime: "*/1 * * * *",
onTick: async () => { onTick: async () => {

View File

@@ -36,6 +36,7 @@ export const removeRolesFromMember = async (memberId: string, roleIds: string[])
console.error("Error removing roles from member:", error); console.error("Error removing roles from member:", error);
}); });
}; };
export const setStandardName = async ({ export const setStandardName = async ({
memberId, memberId,
userId, userId,

View File

@@ -5,6 +5,7 @@ import { renderPasswordChanged } from "./mail-templates/PasswordChanged";
import { renderVerificationCode } from "./mail-templates/ConfirmEmail"; import { renderVerificationCode } from "./mail-templates/ConfirmEmail";
import { renderBannNotice } from "modules/mail-templates/Bann"; import { renderBannNotice } from "modules/mail-templates/Bann";
import { renderTimeBanNotice } from "modules/mail-templates/TimeBann"; import { renderTimeBanNotice } from "modules/mail-templates/TimeBann";
import Mail from "nodemailer/lib/mailer";
let transporter: nodemailer.Transporter | null = null; let transporter: nodemailer.Transporter | null = null;
@@ -23,8 +24,9 @@ const initTransporter = () => {
pass: process.env.MAIL_PASSWORD, pass: process.env.MAIL_PASSWORD,
}, },
}); });
transporter.on("error", (err) => { transporter.on("error", (err) => {
console.error("Mail occurred:", err); console.error("Mail error:", err);
}); });
transporter.on("idle", () => { transporter.on("idle", () => {
console.log("Mail Idle"); console.log("Mail Idle");

View File

@@ -66,5 +66,6 @@ USER nextjs
# Expose the application port # Expose the application port
EXPOSE 3000 EXPOSE 3000
ENV HOST=0.0.0.0
CMD ["node", "apps/hub/server.js"] CMD ["node", "apps/hub/server.js"]

View File

@@ -23,15 +23,6 @@ const page = async () => {
userId: user.id, userId: user.id,
}, },
}, },
Appointments: {
include: {
Participants: {
where: {
appointmentCancelled: false,
},
},
},
},
}, },
}); });
@@ -47,23 +38,13 @@ const page = async () => {
return ( return (
<div> <div>
<div className="col-span-full"> <div className="col-span-full">
<p className="text-xl font-semibold text-left flex items-center gap-2 mb-2 mt-5"> <p className="mb-2 mt-5 flex items-center gap-2 text-left text-xl font-semibold">
<RocketIcon className="w-4 h-4" /> Laufende Events & Kurse <RocketIcon className="h-4 w-4" /> Laufende Events & Kurse
</p> </p>
</div> </div>
<div className="grid grid-cols-6 gap-4"> <div className="grid grid-cols-6 gap-4">
{filteredEvents.map((event) => { {filteredEvents.map((event) => {
return ( return <EventCard user={user} event={event} key={event.id} />;
<EventCard
appointments={event.Appointments}
selectedAppointments={event.Appointments.filter((a) =>
a.Participants.find((p) => p.userId == user.id),
)}
user={user}
event={event}
key={event.id}
/>
);
})} })}
</div> </div>
</div> </div>

View File

@@ -20,18 +20,18 @@ const PathsOptions = ({
<div className="flex gap-6"> <div className="flex gap-6">
{/* Disponent Card */} {/* Disponent Card */}
<div <div
className={`cursor-pointer border rounded-lg p-6 w-80 transition-colors ${ className={`w-80 cursor-pointer rounded-lg border p-6 transition-colors ${
selected === "disponent" ? "border-info ring-2 ring-info" : "border-base-300" selected === "disponent" ? "border-info ring-info ring-2" : "border-base-300"
}`} }`}
onClick={() => setSelected("disponent")} onClick={() => setSelected("disponent")}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-pressed={selected === "disponent"} aria-pressed={selected === "disponent"}
> >
<h1 className="font-semibold text-lg mb-2 flex gap-2 justify-center items-center"> <h1 className="mb-2 flex items-center justify-center gap-2 text-lg font-semibold">
Disponent <Workflow /> Disponent <Workflow />
</h1> </h1>
<div className="text-sm text-base-content/70"> <div className="text-base-content/70 text-sm">
Denkt sich realistische Einsatzszenarien aus, koordiniert deren Ablauf und ist die Denkt sich realistische Einsatzszenarien aus, koordiniert deren Ablauf und ist die
zentrale Schnittstelle zwischen Piloten und bodengebundenen Rettungsmitteln. Er trägt zentrale Schnittstelle zwischen Piloten und bodengebundenen Rettungsmitteln. Er trägt
die Verantwortung für einen reibungslosen Ablauf und der erfolgreichen Durchführung der die Verantwortung für einen reibungslosen Ablauf und der erfolgreichen Durchführung der
@@ -43,18 +43,18 @@ const PathsOptions = ({
</div> </div>
{/* Pilot Card */} {/* Pilot Card */}
<div <div
className={`cursor-pointer border rounded-lg p-6 w-80 transition-colors ${ className={`w-80 cursor-pointer rounded-lg border p-6 transition-colors ${
selected === "pilot" ? "border-info ring-2 ring-info" : "border-base-300" selected === "pilot" ? "border-info ring-info ring-2" : "border-base-300"
}`} }`}
onClick={() => setSelected("pilot")} onClick={() => setSelected("pilot")}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-pressed={selected === "pilot"} aria-pressed={selected === "pilot"}
> >
<h1 className="font-semibold text-lg mb-2 flex gap-2 justify-center items-center"> <h1 className="mb-2 flex items-center justify-center gap-2 text-lg font-semibold">
Pilot <Plane /> Pilot <Plane />
</h1> </h1>
<div className="text-sm text-base-content/70"> <div className="text-base-content/70 text-sm">
Fliegt die vom Disponenten erstellten Einsätze und transportiert die Med-Crew sicher zum Fliegt die vom Disponenten erstellten Einsätze und transportiert die Med-Crew sicher zum
Einsatzort. Er übernimmt die navigatorische Vorbereitung, achtet auf Wetterentwicklungen Einsatzort. Er übernimmt die navigatorische Vorbereitung, achtet auf Wetterentwicklungen
und sorgt für die Sicherheit seiner Crew im Flug. und sorgt für die Sicherheit seiner Crew im Flug.
@@ -76,17 +76,7 @@ const EventSelect = ({ pathSelected }: { pathSelected: "disponent" | "pilot" })
const user = useSession().data?.user; const user = useSession().data?.user;
if (!user) return null; if (!user) return null;
return events?.map((event) => { return events?.map((event) => {
return ( return <EventCard user={user} event={event} key={event.id} />;
<EventCard
appointments={event.Appointments}
selectedAppointments={event.Appointments.filter((a) =>
a.Participants.find((p) => p.userId == user.id),
)}
user={user}
event={event}
key={event.id}
/>
);
}); });
}; };
@@ -107,14 +97,14 @@ export const FirstPath = () => {
return ( return (
<dialog ref={modalRef} className="modal"> <dialog ref={modalRef} className="modal">
<div className="modal-box w-11/12 max-w-5xl"> <div className="modal-box w-11/12 max-w-5xl">
<h3 className="flex items-center gap-2 text-lg font-bold mb-10"> <h3 className="mb-10 flex items-center gap-2 text-lg font-bold">
{session?.user.migratedFromV1 {session?.user.migratedFromV1
? "Hallo, hier hat sich einiges geändert!" ? "Hallo, hier hat sich einiges geändert!"
: "Wähle deinen Einstieg!"} : "Wähle deinen Einstieg!"}
</h3> </h3>
<h2 className="text-2xl font-bold mb-4 text-center">Willkommen bei Virtual Air Rescue!</h2> <h2 className="mb-4 text-center text-2xl font-bold">Willkommen bei Virtual Air Rescue!</h2>
{session?.user.migratedFromV1 ? ( {session?.user.migratedFromV1 ? (
<p className="mb-8 text-base text-base-content/80 text-center"> <p className="text-base-content/80 mb-8 text-center text-base">
Dein Account wurde erfolgreich auf das neue System migriert. Herzlich Willkommen im Dein Account wurde erfolgreich auf das neue System migriert. Herzlich Willkommen im
neuen HUB! Um die Erfahrung für alle Nutzer zu steigern haben wir uns dazu entschlossen, neuen HUB! Um die Erfahrung für alle Nutzer zu steigern haben wir uns dazu entschlossen,
dass alle Nutzer einen Test absolvieren müssen:{" "} dass alle Nutzer einen Test absolvieren müssen:{" "}
@@ -129,12 +119,12 @@ export const FirstPath = () => {
ausprobieren, wenn du möchtest. ausprobieren, wenn du möchtest.
</p> </p>
)} )}
<div className="flex flex-col items-center justify-center m-20"> <div className="m-20 flex flex-col items-center justify-center">
{page === "path" && <PathsOptions selected={selected} setSelected={setSelected} />} {page === "path" && <PathsOptions selected={selected} setSelected={setSelected} />}
{page === "event-select" && ( {page === "event-select" && (
<div className="flex flex-col gap-3 min-w-[800px]"> <div className="flex min-w-[800px] flex-col gap-3">
<div> <div>
<p className="text-left text-gray-400 text-sm">Wähle dein Einführungs-Event aus:</p> <p className="text-left text-sm text-gray-400">Wähle dein Einführungs-Event aus:</p>
</div> </div>
<EventSelect pathSelected={selected!} /> <EventSelect pathSelected={selected!} />
</div> </div>

View File

@@ -2,23 +2,17 @@ import Image from "next/image";
import { DiscordLogoIcon, InstagramLogoIcon, ReaderIcon } from "@radix-ui/react-icons"; import { DiscordLogoIcon, InstagramLogoIcon, ReaderIcon } from "@radix-ui/react-icons";
import YoutubeSvg from "./youtube_wider.svg"; import YoutubeSvg from "./youtube_wider.svg";
import FacebookSvg from "./facebook.svg"; import FacebookSvg from "./facebook.svg";
import { ChangelogModalBtn } from "@repo/shared-components";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { updateUser } from "(app)/settings/actions";
import toast from "react-hot-toast";
import { ChangelogWrapper } from "(app)/_components/ChangelogWrapper"; import { ChangelogWrapper } from "(app)/_components/ChangelogWrapper";
import { prisma } from "@repo/db"; import { prisma } from "@repo/db";
export const Footer = async () => { export const Footer = async () => {
const session = await getServerSession();
const latestChangelog = await prisma.changelog.findFirst({ const latestChangelog = await prisma.changelog.findFirst({
orderBy: { orderBy: {
createdAt: "desc", createdAt: "desc",
}, },
}); });
const autoOpen = !session?.user.changelogAck && !!latestChangelog;
return ( return (
<footer className="footer bg-base-200 mt-4 flex items-center justify-between rounded-lg p-4 shadow-md"> <footer className="footer bg-base-200 mt-4 flex items-center justify-between rounded-lg p-4 shadow-md">
{/* Left: Impressum & Datenschutz */} {/* Left: Impressum & Datenschutz */}
@@ -39,7 +33,7 @@ export const Footer = async () => {
<div className="flex gap-4"> <div className="flex gap-4">
<div className="tooltip tooltip-top" data-tip="Discord"> <div className="tooltip tooltip-top" data-tip="Discord">
<a <a
href="https://discord.gg/yn7uXmmNnG" href="https://discord.gg/virtualairrescue"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:text-primary" className="hover:text-primary"

View File

@@ -0,0 +1,55 @@
import { prisma } from "@repo/db";
import { ParticipantForm } from "../../../_components/ParticipantForm";
import { Error } from "_components/Error";
import Link from "next/link";
import { PersonIcon } from "@radix-ui/react-icons";
import { ArrowLeft } from "lucide-react";
export default async function Page({
params,
}: {
params: Promise<{ id: string; participantId: string }>;
}) {
const { id: eventId, participantId } = await params;
console.log(eventId, participantId);
const event = await prisma.event.findUnique({
where: { id: parseInt(eventId) },
});
const participant = await prisma.participant.findUnique({
where: { id: parseInt(participantId) },
});
if (!participant) {
return <Error title="Teilnehmer nicht gefunden" statusCode={404} />;
}
const user = await prisma.user.findUnique({
where: { id: participant?.userId },
});
if (!event) return <Error title="Event nicht gefunden" statusCode={404} />;
if (!participant || !user) {
return <Error title="Teilnehmer nicht gefunden" statusCode={404} />;
}
return (
<div>
<div className="my-3">
<div className="text-left">
<Link href={`/admin/event/${event.id}`} className="link-hover l-0 text-gray-500">
<ArrowLeft className="mb-1 mr-1 inline h-4 w-4" />
Zurück zum Event
</Link>
</div>
<p className="text-left text-2xl font-semibold">
<PersonIcon className="mr-2 inline h-5 w-5" /> Event-Übersicht für{" "}
{`${user.firstname} ${user.lastname} #${user.publicId}`}
</p>
</div>
<ParticipantForm event={event} participant={participant} />
</div>
);
}

View File

@@ -1,203 +0,0 @@
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";
import { RefObject, useRef } from "react";
import { UseFormReturn } from "react-hook-form";
import { PaginatedTable, PaginatedTableRef } from "../../../../_components/PaginatedTable";
import { Button } from "../../../../_components/ui/Button";
import { DateInput } from "../../../../_components/ui/DateInput";
import { upsertParticipant } from "../../../events/actions";
import { deleteAppoinement, upsertAppointment } from "../action";
import { handleParticipantFinished } from "../../../../../helper/events";
import toast from "react-hot-toast";
interface AppointmentModalProps {
event?: Event;
ref: RefObject<HTMLDialogElement | null>;
participantModal: RefObject<HTMLDialogElement | null>;
appointmentsTableRef: React.RefObject<PaginatedTableRef | null>;
appointmentForm: UseFormReturn<EventAppointmentOptionalDefaults>;
participantForm: UseFormReturn<Participant>;
}
export const AppointmentModal = ({
event,
ref,
participantModal,
appointmentsTableRef,
appointmentForm,
participantForm,
}: AppointmentModalProps) => {
const { data: session } = useSession();
const participantTableRef = useRef<PaginatedTableRef>(null);
return (
<dialog ref={ref} className="modal">
<div className="modal-box min-h-[500px] min-w-[900px]">
<form method="dialog">
{/* if there is a button in form, it will close the modal */}
<button
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onClick={() => ref.current?.close()}
>
</button>
</form>
<form
onSubmit={appointmentForm.handleSubmit(async (values) => {
if (!event) return;
await upsertAppointment(values);
ref.current?.close();
appointmentsTableRef.current?.refresh();
})}
className="flex flex-col"
>
<div className="mr-7 flex justify-between">
<h3 className="text-lg font-bold">Termin {appointmentForm.watch("id")}</h3>
<DateInput
value={new Date(appointmentForm.watch("appointmentDate") || Date.now())}
onChange={(date) => appointmentForm.setValue("appointmentDate", date)}
/>
</div>
<div>
<PaginatedTable
supressQuery={appointmentForm.watch("id") === undefined}
ref={participantTableRef}
columns={
[
{
accessorKey: "User.firstname",
header: "Vorname",
},
{
accessorKey: "User.lastname",
header: "Nachname",
},
{
accessorKey: "enscriptionDate",
header: "Einschreibedatum",
cell: ({ row }) => {
return <span>{new Date(row.original.enscriptionDate).toLocaleString()}</span>;
},
},
{
header: "Anwesend",
cell: ({ row }) => {
if (row.original.attended) {
return <span className="text-green-500">Ja</span>;
} else if (row.original.appointmentCancelled) {
return <span className="text-red-500">Nein (Termin abgesagt)</span>;
} else {
return <span>?</span>;
}
},
},
{
header: "Aktion",
cell: ({ row }) => {
return (
<div className="space-x-2">
<button
onClick={() => {
participantForm.reset(row.original);
participantModal.current?.showModal();
}}
className="btn btn-outline btn-sm"
>
anzeigen
</button>
{!row.original.attended && event?.hasPresenceEvents && (
<button
type="button"
onSubmit={() => {}}
onClick={async () => {
await upsertParticipant({
eventId: event!.id,
userId: row.original.userId,
attended: true,
appointmentCancelled: false,
});
if (!event.finisherMoodleCourseId?.length) {
toast(
"Teilnehmer hat das event abgeschlossen, workflow ausgeführt",
);
await handleParticipantFinished(row.original.id.toString());
}
participantTableRef.current?.refresh();
}}
className="btn btn-outline btn-info btn-sm"
>
Anwesend
</button>
)}
{!row.original.appointmentCancelled && event?.hasPresenceEvents && (
<button
type="button"
onSubmit={() => {}}
onClick={async () => {
await upsertParticipant({
eventId: event!.id,
userId: row.original.userId,
attended: false,
appointmentCancelled: true,
statusLog: [
...(row.original.statusLog as InputJsonValueType[]),
{
event: "Gefehlt an Event",
timestamp: new Date().toISOString(),
user: `${session?.user?.firstname} ${session?.user?.lastname} - ${session?.user?.publicId}`,
},
],
});
participantTableRef.current?.refresh();
}}
className="btn btn-outline btn-error btn-sm"
>
abwesend
</button>
)}
</div>
);
},
},
] as ColumnDef<Participant>[]
}
prismaModel={"participant"}
getFilter={() =>
({
eventAppointmentId: appointmentForm.watch("id")!,
}) as Prisma.ParticipantWhereInput
}
include={{ User: true }}
leftOfPagination={
<div className="flex gap-2">
<Button type="submit" className="btn btn-primary">
Speichern
</Button>
{appointmentForm.watch("id") && (
<Button
type="button"
onSubmit={() => {}}
onClick={async () => {
await deleteAppoinement(appointmentForm.watch("id")!);
ref.current?.close();
appointmentsTableRef.current?.refresh();
}}
className="btn btn-error btn-outline"
>
Löschen
</Button>
)}
</div>
}
/>
</div>
</form>
</div>
</dialog>
);
};

View File

@@ -1,70 +1,37 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, Prisma, User } from "@repo/db"; import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, Prisma } from "@repo/db";
import { import { EventOptionalDefaults, EventOptionalDefaultsSchema } from "@repo/db/zod";
EventAppointmentOptionalDefaults, import { Bot, FileText, UserIcon } from "lucide-react";
EventAppointmentOptionalDefaultsSchema,
EventOptionalDefaults,
EventOptionalDefaultsSchema,
ParticipantSchema,
} from "@repo/db/zod";
import { Bot, Calendar, FileText, UserIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { useRef } from "react";
import "react-datepicker/dist/react-datepicker.css"; import "react-datepicker/dist/react-datepicker.css";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { PaginatedTable, PaginatedTableRef } from "../../../../_components/PaginatedTable";
import { Button } from "../../../../_components/ui/Button"; import { Button } from "../../../../_components/ui/Button";
import { Input } from "../../../../_components/ui/Input"; import { Input } from "../../../../_components/ui/Input";
import { MarkdownEditor } from "../../../../_components/ui/MDEditor"; import { MarkdownEditor } from "../../../../_components/ui/MDEditor";
import { Select } from "../../../../_components/ui/Select"; import { Select } from "../../../../_components/ui/Select";
import { Switch } from "../../../../_components/ui/Switch"; import { Switch } from "../../../../_components/ui/Switch";
import { deleteEvent, upsertEvent } from "../action"; import { deleteEvent, upsertEvent } from "../action";
import { AppointmentModal } from "./AppointmentModal";
import { ParticipantModal } from "./ParticipantModal";
import { ColumnDef } from "@tanstack/react-table";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { PaginatedTable } from "_components/PaginatedTable";
import Link from "next/link"; import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { User } from "next-auth";
export const Form = ({ event }: { event?: Event }) => { export const Form = ({ event }: { event?: Event }) => {
const { data: session } = useSession();
const form = useForm<EventOptionalDefaults>({ const form = useForm<EventOptionalDefaults>({
resolver: zodResolver(EventOptionalDefaultsSchema), resolver: zodResolver(EventOptionalDefaultsSchema),
defaultValues: event defaultValues: event
? { ? {
...event, ...event,
discordRoleId: event.discordRoleId ?? undefined, discordRoleId: event.discordRoleId ?? undefined,
maxParticipants: event.maxParticipants ?? undefined,
finisherMoodleCourseId: event.finisherMoodleCourseId ?? undefined, finisherMoodleCourseId: event.finisherMoodleCourseId ?? undefined,
} }
: undefined, : undefined,
}); });
const appointmentForm = useForm<EventAppointmentOptionalDefaults>({
resolver: zodResolver(EventAppointmentOptionalDefaultsSchema),
defaultValues: {
eventId: event?.id,
presenterId: session?.user?.id,
},
});
const participantForm = useForm<Participant>({
resolver: zodResolver(ParticipantSchema),
});
const appointmentsTableRef = useRef<PaginatedTableRef>(null);
const appointmentModal = useRef<HTMLDialogElement>(null);
const participantModal = useRef<HTMLDialogElement>(null);
return ( return (
<> <>
<AppointmentModal
participantModal={participantModal}
participantForm={participantForm}
appointmentForm={appointmentForm}
ref={appointmentModal}
appointmentsTableRef={appointmentsTableRef}
event={event}
/>
<ParticipantModal participantForm={participantForm} ref={participantModal} />
<form <form
onSubmit={form.handleSubmit(async (values) => { onSubmit={form.handleSubmit(async (values) => {
await upsertEvent(values, event?.id); await upsertEvent(values, event?.id);
@@ -139,224 +106,115 @@ export const Form = ({ event }: { event?: Event }) => {
label="Discord Rolle für eingeschriebene Teilnehmer" label="Discord Rolle für eingeschriebene Teilnehmer"
className="input-sm" className="input-sm"
/> />
<Input
form={form}
label="Maximale Teilnehmer (Nur für live Events)"
className="input-sm"
{...form.register("maxParticipants", {
valueAsNumber: true,
})}
/>
<Switch form={form} name="hasPresenceEvents" label="Hat Live Event" />
<div className="divider w-full" /> <div className="divider w-full" />
<Switch form={form} name="hidden" label="Event verstecken" /> <Switch form={form} name="hidden" label="Event verstecken" />
</div> </div>
</div> </div>
{form.watch("hasPresenceEvents") ? ( <div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card bg-base-200 col-span-6 shadow-xl"> <div className="card-body">
<div className="card-body"> {
<PaginatedTable <PaginatedTable
ref={appointmentsTableRef}
prismaModel={"eventAppointment"}
getFilter={() =>
({
eventId: event?.id,
}) as Prisma.EventAppointmentWhereInput
}
include={{
Presenter: true,
Participants: true,
}}
leftOfSearch={ leftOfSearch={
<h2 className="card-title"> <h2 className="card-title">
<Calendar className="h-5 w-5" /> Termine <UserIcon className="h-5 w-5" /> Teilnehmer
</h2> </h2>
} }
rightOfSearch={ prismaModel={"participant"}
event && ( showSearch
<button getFilter={(searchTerm) =>
className="btn btn-primary btn-outline" ({
onClick={() => { AND: [{ eventId: event?.id }],
appointmentModal.current?.showModal(); OR: [
appointmentForm.reset({ {
id: undefined, User: {
eventId: event.id, OR: [
presenterId: session?.user?.id, { firstname: { contains: searchTerm, mode: "insensitive" } },
}); { lastname: { contains: searchTerm, mode: "insensitive" } },
}} { publicId: { contains: searchTerm, mode: "insensitive" } },
> ],
Hinzufügen },
</button> },
) ],
}) as Prisma.ParticipantWhereInput
} }
include={{
User: true,
}}
supressQuery={!event}
columns={ columns={
[ [
{ {
header: "Datum", header: "Vorname",
accessorKey: "appointmentDate", accessorKey: "User.firstname",
accessorFn: (date) => new Date(date.appointmentDate).toLocaleString(), cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.firstname}
</Link>
);
},
}, },
{ {
header: "Presenter", header: "Nachname",
accessorKey: "presenter", accessorKey: "User.lastname",
cell: ({ row }) => ( cell: ({ row }) => {
<div className="flex items-center"> return (
<span className="ml-2"> <Link
{row.original.Presenter.firstname} {row.original.Presenter.lastname} className="hover:underline"
</span> href={`/admin/user/${row.original.User.id}`}
</div> >
), {row.original.User.lastname}
</Link>
);
},
}, },
{ {
header: "Teilnehmer", header: "VAR-Nummer",
accessorKey: "Participants", accessorKey: "User.publicId",
cell: ({ row }) => ( cell: ({ row }) => {
<div className="flex items-center"> return (
<UserIcon className="h-5 w-5" /> <Link
<span className="ml-2">{row.original.Participants.length}</span> className="hover:underline"
</div> href={`/admin/user/${row.original.User.id}`}
), >
{row.original.User.publicId}
</Link>
);
},
},
{
header: "Moodle Kurs abgeschlossen",
accessorKey: "finisherMoodleCurseCompleted",
}, },
{ {
header: "Aktionen", header: "Aktionen",
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div className="flex gap-2"> <Link
href={`/admin/event/${event?.id}/participant/${row.original.id}`}
className="flex gap-2"
>
<button <button
onSubmit={() => false} onSubmit={() => false}
type="button" type="button"
onClick={() => {
appointmentForm.reset(row.original);
appointmentModal.current?.showModal();
}}
className="btn btn-sm btn-outline" className="btn btn-sm btn-outline"
> >
Bearbeiten Ansehen
</button> </button>
</div> </Link>
); );
}, },
}, },
] as ColumnDef< ] as ColumnDef<Participant & { User: User }>[]
EventAppointmentOptionalDefaults & {
Presenter: User;
Participants: Participant[];
}
>[]
} }
/> />
</div> }
</div> </div>
) : null} </div>
{!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>
}
ref={appointmentsTableRef}
prismaModel={"participant"}
showSearch
getFilter={(searchTerm) =>
({
AND: [{ eventId: event?.id }],
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}`}
>
{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"
>
Bearbeiten
</button>
</div>
);
},
},
] as ColumnDef<Participant & { User: User }>[]
}
/>
}
</div>
</div>
) : null}
<div className="card bg-base-200 col-span-6 shadow-xl"> <div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body"> <div className="card-body">
<div className="flex w-full gap-4"> <div className="flex w-full gap-4">

View File

@@ -0,0 +1,213 @@
"use client";
import { Participant, Event, ParticipantLog, Prisma } from "@repo/db";
import { Users, Activity, Bug } from "lucide-react";
import toast from "react-hot-toast";
import { InputJsonValueType, ParticipantOptionalDefaultsSchema } from "@repo/db/zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Switch } from "_components/ui/Switch";
import { useState } from "react";
import { Button } from "_components/ui/Button";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { upsertParticipant } from "(app)/events/actions";
import { deleteParticipant } from "../action";
import { redirect } from "next/navigation";
interface ParticipantFormProps {
event: Event;
participant: Participant;
}
const checkEventCompleted = (participant: Participant, event: Event): boolean => {
return event.finisherMoodleCourseId ? participant.finisherMoodleCurseCompleted : false;
};
export const ParticipantForm = ({ event, participant }: ParticipantFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryClient = useQueryClient();
const upsertParticipantMutation = useMutation({
mutationKey: ["upsertParticipant"],
mutationFn: async (newData: Prisma.ParticipantUncheckedCreateInput) => {
const data = await upsertParticipant(newData);
await queryClient.invalidateQueries({ queryKey: ["participants", event.id] });
return data;
},
});
const deleteParticipantMutation = useMutation({
mutationKey: ["deleteParticipant"],
mutationFn: async (participantId: number) => {
await deleteParticipant(participantId);
await queryClient.invalidateQueries({ queryKey: ["participants", event.id] });
},
});
const eventCompleted = checkEventCompleted(participant, event);
const form = useForm({
resolver: zodResolver(ParticipantOptionalDefaultsSchema),
defaultValues: participant,
});
const handleEventFinished = async () => {
setIsLoading(true);
try {
await upsertParticipantMutation.mutateAsync({
eventId: event.id,
userId: participant.userId,
});
toast.success("Event als beendet markiert");
} finally {
setIsLoading(false);
}
};
const handleCheckMoodle = async () => {
setIsLoading(true);
try {
toast.success("Moodle-Check durchgeführt");
} finally {
setIsLoading(false);
}
};
return (
<form
onSubmit={form.handleSubmit(async (formData) => {
const data = await upsertParticipantMutation.mutateAsync({
...formData,
statusLog: participant.statusLog as unknown as Prisma.ParticipantCreatestatusLogInput,
eventId: event.id,
userId: participant.userId,
});
form.reset(data);
toast.success("Teilnehmer aktualisiert");
})}
className="bg-base-100 flex flex-wrap gap-6 p-6"
>
{/* Status Section */}
<div className="card bg-base-200 shadow">
<div className="card-body">
<h3 className="card-title text-sm">
<Bug className="mr-2 inline h-5 w-5" />
Debug
</h3>
<Switch form={form} name={"attended"} label="Anwesend" />
<Switch form={form} name={"appointmentCancelled"} label="Termin Abgesagt" />
<Switch
form={form}
name={"finisherMoodleCurseCompleted"}
label="Moodle Kurs abgeschlossen"
/>
<div></div>
</div>
</div>
{/* Info Card */}
<div className="card bg-base-200 min-w-[200px] flex-1 shadow-lg">
<div className="card-body">
<h2 className="card-title text-lg">
<Users className="h-5 w-5" /> Informationen
</h2>
<div className="divider" />
<div>
<p className="text-sm text-gray-600">Kurs-status</p>
<p className="font-semibold">{eventCompleted ? "Abgeschlossen" : "In Bearbeitung"}</p>
<p className="text-sm text-gray-600">Einschreibedatum</p>
<p className="font-semibold">
{participant?.enscriptionDate
? new Date(participant.enscriptionDate).toLocaleDateString()
: "-"}
</p>
</div>
<div className="divider" />
<div className="flex flex-col gap-2">
<Button
onClick={handleEventFinished}
isLoading={isLoading}
className="btn btn-sm btn-success"
>
Event beendet
</Button>
<Button
onClick={handleCheckMoodle}
isLoading={isLoading}
className="btn btn-sm btn-info"
>
Moodle-Check
</Button>
</div>
</div>
</div>
{/* Activity Log */}
<div className="card bg-base-200 min-w-[300px] flex-1 shadow-lg">
<div className="card-body">
<h2 className="card-title text-lg">
<Activity className="h-5 w-5" /> Aktivitätslog
</h2>
<div className="timeline timeline-vertical">
<table className="table-sm table w-full">
<thead>
<tr>
<th>Datum</th>
<th>Event</th>
<th>User</th>
</tr>
</thead>
<tbody>
{participant?.statusLog &&
Array.isArray(participant.statusLog) &&
(participant.statusLog as InputJsonValueType[])
.slice()
.reverse()
.map((log: InputJsonValueType, index: number) => {
const logEntry = log as unknown as ParticipantLog;
return (
<tr key={index}>
<td>
{logEntry.timestamp
? new Date(logEntry.timestamp).toLocaleDateString()
: "-"}
</td>
<td>{logEntry.event || "-"}</td>
<td>{logEntry.user || "-"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
<div className="card bg-base-200 w-full shadow-xl">
<div className="card-body">
<div className="flex w-full gap-4">
<Button
disabled={!form.formState.isDirty}
isLoading={form.formState.isSubmitting}
type="submit"
className="btn btn-primary flex-1"
>
Speichern
</Button>
{event && (
<Button
onClick={async () => {
await deleteParticipantMutation.mutateAsync(participant.id);
redirect(`/admin/event/${event.id}`);
}}
className="btn btn-error"
>
Austragen
</Button>
)}
</div>
</div>
</div>
</form>
);
};

View File

@@ -2,6 +2,7 @@
import { prisma, Prisma, Event, Participant } from "@repo/db"; import { prisma, Prisma, Event, Participant } from "@repo/db";
//############# Event //#############
export const upsertEvent = async (event: Prisma.EventCreateInput, id?: Event["id"]) => { export const upsertEvent = async (event: Prisma.EventCreateInput, id?: Event["id"]) => {
const newEvent = id const newEvent = id
? await prisma.event.update({ ? await prisma.event.update({
@@ -11,34 +12,22 @@ export const upsertEvent = async (event: Prisma.EventCreateInput, id?: Event["id
: await prisma.event.create({ data: event }); : await prisma.event.create({ data: event });
return newEvent; return newEvent;
}; };
export const deleteEvent = async (id: Event["id"]) => { export const deleteEvent = async (id: Event["id"]) => {
await prisma.event.delete({ where: { id: id } }); await prisma.event.delete({ where: { id: id } });
}; };
export const upsertAppointment = async ( //############# Participant //#############
eventAppointment: Prisma.EventAppointmentUncheckedCreateInput,
) => { export const upsertParticipant = async (participant: Prisma.ParticipantUncheckedCreateInput) => {
const newEventAppointment = eventAppointment.id const newParticipant = participant.id
? await prisma.eventAppointment.update({ ? await prisma.participant.update({
where: { id: eventAppointment.id }, where: { id: participant.id },
data: eventAppointment, data: participant,
}) })
: await prisma.eventAppointment.create({ data: eventAppointment }); : await prisma.participant.create({ data: participant });
return newEventAppointment; return newParticipant;
}; };
export const deleteAppoinement = async (id: Event["id"]) => {
await prisma.eventAppointment.delete({ where: { id: id } });
prisma.eventAppointment.findMany({
where: {
eventId: id,
},
orderBy: {
// TODO: add order by in relation to table selected column
},
});
};
export const deleteParticipant = async (id: Participant["id"]) => { export const deleteParticipant = async (id: Participant["id"]) => {
await prisma.participant.delete({ where: { id: id } }); await prisma.participant.delete({ where: { id: id } });
}; };

View File

@@ -0,0 +1,19 @@
import { Error } from "_components/Error";
import { getServerSession } from "api/auth/[...nextauth]/auth";
const AdminAccountLogLayout = async ({ children }: { children: React.ReactNode }) => {
const session = await getServerSession();
if (!session) return <Error title="Nicht eingeloggt" statusCode={401} />;
const user = session.user;
if (!user?.permissions.includes("ADMIN_USER_ADVANCED"))
return <Error title="Keine Berechtigung" statusCode={403} />;
return <>{children}</>;
};
AdminAccountLogLayout.displayName = "AdminAccountLogLayout";
export default AdminAccountLogLayout;

View File

@@ -0,0 +1,91 @@
"use client";
import { LogsIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Log, Prisma, User } from "@repo/db";
export default () => {
return (
<>
<PaginatedTable
stickyHeaders
initialOrderBy={[{ id: "timestamp", desc: true }]}
prismaModel="log"
showSearch
include={{
User: true,
}}
getFilter={(searchTerm) =>
({
OR: [
{
User: {
firstname: { contains: searchTerm, mode: "insensitive" },
lastname: { contains: searchTerm, mode: "insensitive" },
publicId: { contains: searchTerm, mode: "insensitive" },
},
},
{ deviceId: { contains: searchTerm, mode: "insensitive" } },
{ ip: { contains: searchTerm, mode: "insensitive" } },
],
}) as Prisma.LogWhereInput
}
columns={
[
{
header: "ID",
accessorKey: "id",
},
{
header: "Aktion",
accessorKey: "action",
cell: ({ row }) => {
const action = row.original.type;
if (action !== "PROFILE_CHANGE") {
return <span className="text-blue-500">{action}</span>;
} else {
return (
<span className="text-yellow-500">{`${row.original.field} von "${row.original.oldValue}" zu "${row.original.newValue}"`}</span>
);
}
},
},
{
header: "IP",
accessorKey: "ip",
},
{
header: "Browser-ID",
accessorKey: "deviceId",
},
{
header: "Zeitstempel",
accessorKey: "timestamp",
cell: (info) => new Date(info.getValue<string>()).toLocaleString("de-DE"),
},
{
header: "Benutzer",
accessorKey: "userId",
cell: ({ row }) => {
return (
<Link href={`/admin/user/${row.original.userId}`} className={"link"}>
{row.original.User
? `${row.original.User.firstname} ${row.original.User.lastname} - ${row.original.User.publicId}`
: "Unbekannt"}
</Link>
);
},
},
] as ColumnDef<Log & { User: User }>[]
}
leftOfSearch={
<span className="flex items-center gap-2">
<LogsIcon className="h-5 w-5" /> Account Log
</span>
}
/>
</>
);
};

View File

@@ -4,6 +4,7 @@ import { editReport } from "(app)/admin/report/actions";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Report as IReport, Prisma, User } from "@repo/db"; import { Report as IReport, Prisma, User } from "@repo/db";
import { ReportSchema, Report as IReportZod } from "@repo/db/zod"; import { ReportSchema, Report as IReportZod } from "@repo/db/zod";
import { cn } from "@repo/shared-components";
import { PaginatedTable } from "_components/PaginatedTable"; import { PaginatedTable } from "_components/PaginatedTable";
import { Button } from "_components/ui/Button"; import { Button } from "_components/ui/Button";
import { Switch } from "_components/ui/Switch"; import { Switch } from "_components/ui/Switch";
@@ -31,14 +32,23 @@ export const ReportSenderInfo = ({
</Link> </Link>
<span className="text-primary">{report.reportedUserRole}</span> <span className="text-primary">{report.reportedUserRole}</span>
</h2> </h2>
<div className="textarea w-full text-left">{report.text}</div> <div className="textarea w-full whitespace-pre-wrap text-left">{report.text}</div>
<Link {Sender ? (
href={`/admin/user/${Sender?.id}`} <Link
className="link link-hover text-right text-sm text-gray-600" href={`/admin/user/${Sender?.id}`}
> className={cn(
gemeldet von Nutzer {Sender?.firstname} {Sender?.lastname} ({Sender?.publicId}) am{" "} "link link-hover text-right text-sm text-gray-600",
{new Date(report.timestamp).toLocaleString()} !Sender && "text-green-300",
</Link> )}
>
gemeldet von Nutzer {Sender.firstname} {Sender.lastname} ({Sender.publicId}) am{" "}
{new Date(report.timestamp).toLocaleString()}
</Link>
) : (
<p className="text-right text-sm text-green-300">
gemeldet vom System am {new Date(report.timestamp).toLocaleString()}
</p>
)}
</div> </div>
); );
}; };

View File

@@ -12,9 +12,9 @@ export const reportColumns: ColumnDef<Report & { Sender?: User; Reported: User }
return ( return (
<div className="text-center"> <div className="text-center">
{row.getValue("reviewed") ? ( {row.getValue("reviewed") ? (
<Check className="text-green-500 w-5 h-5" /> <Check className="h-5 w-5 text-green-500" />
) : ( ) : (
<X className="text-red-500 w-5 h-5" /> <X className="h-5 w-5 text-red-500" />
)} )}
</div> </div>
); );
@@ -25,19 +25,23 @@ export const reportColumns: ColumnDef<Report & { Sender?: User; Reported: User }
header: "Sender", header: "Sender",
cell: ({ row }) => { cell: ({ row }) => {
const user = row.original.Sender; const user = row.original.Sender;
if (!user) return "Unbekannt"; if (!user) return <p className="text-green-300">System</p>;
return `${user.firstname} ${user.lastname} (${user.publicId})`; return (
<Link
href={`/admin/user/${user.id}`}
>{`${user.firstname} ${user.lastname} (${user.publicId})`}</Link>
);
}, },
}, },
{ {
accessorKey: "reportedUserRole", accessorKey: "reportedUserRole",
header: "Rolle des gemeldeten Nutzers", header: "Rolle",
cell: ({ row }) => { cell: ({ row }) => {
const role = row.getValue("reportedUserRole") as string | undefined; const role = row.getValue("reportedUserRole") as string | undefined;
const Icon = role ? (role.startsWith("LST") ? Workflow : Plane) : ShieldQuestion; const Icon = role ? (role.startsWith("LST") ? Workflow : Plane) : ShieldQuestion;
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Icon className="w-4 h-4" /> <Icon className="h-4 w-4" />
{role || "Unbekannt"} {role || "Unbekannt"}
</span> </span>
); );
@@ -62,7 +66,7 @@ export const reportColumns: ColumnDef<Report & { Sender?: User; Reported: User }
cell: ({ row }) => ( cell: ({ row }) => (
<Link href={`/admin/report/${row.original.id}`}> <Link href={`/admin/report/${row.original.id}`}>
<button className="btn btn-sm btn-outline btn-info flex items-center gap-2"> <button className="btn btn-sm btn-outline btn-info flex items-center gap-2">
<Eye className="w-4 h-4" /> Anzeigen <Eye className="h-4 w-4" /> Anzeigen
</button> </button>
</Link> </Link>
), ),

View File

@@ -0,0 +1,142 @@
"use client";
import { Log, Prisma, User } from "@repo/db";
import { cn } from "@repo/shared-components";
import { ColumnDef } from "@tanstack/react-table";
import { PaginatedTable, PaginatedTableRef } from "_components/PaginatedTable";
import { Printer } from "lucide-react";
import Link from "next/link";
import { useRef, useState } from "react";
export const AccountLog = ({ sameIPLogs, userId }: { sameIPLogs: Log[]; userId: string }) => {
const [onlyImportant, setOnlyImportant] = useState(true);
const tableRef = useRef<PaginatedTableRef>(null);
return (
<div className="card-body">
<PaginatedTable
ref={tableRef}
showSearch
leftOfSearch={
<div className="card-title flex justify-between">
<h2 className="flex items-center gap-2">
<Printer className="h-5 w-5" /> Account Log
</h2>
<p className="text-end text-sm text-gray-500">
Hier werden Logs angezeigt, die dem Nutzer zugeordnet sind oder von der selben IP
stammen.
</p>
</div>
}
rightOfPagination={
<div className="ml-4 flex items-center gap-2">
<input
type="checkbox"
id="ImportantOnly"
checked={onlyImportant}
onChange={() => {
setOnlyImportant(!onlyImportant);
tableRef.current?.refresh();
}}
className="checkbox checkbox-sm"
/>
<label
htmlFor="ImportantOnly"
className="min-w-[210px] cursor-pointer select-none text-sm"
>
Unauffällige Einträge ausblenden
</label>
</div>
}
getFilter={(searchTerm) => {
if (onlyImportant) {
return {
AND: [
{ ip: { contains: searchTerm } },
{ browser: { contains: searchTerm } },
{
id: {
in: sameIPLogs
.filter((log) => log.id.toString().includes(searchTerm))
.map((log) => log.id),
},
},
],
} as Prisma.LogWhereInput;
} else {
return {
OR: [
{
AND: [
{ ip: { contains: searchTerm } },
{ browser: { contains: searchTerm } },
{
id: {
in: sameIPLogs
.filter((log) => log.id.toString().includes(searchTerm))
.map((log) => log.id),
},
},
],
},
{
userId: userId,
},
],
} as Prisma.LogWhereInput;
}
}}
include={{
User: true,
}}
prismaModel={"log"}
columns={
[
{
header: "Zeitstempel",
accessorKey: "timestamp",
cell: (info) => new Date(info.getValue<string>()).toLocaleString("de-DE"),
},
{
header: "Aktion",
accessorKey: "action",
cell: ({ row }) => {
const action = row.original.type;
if (action !== "PROFILE_CHANGE") {
return <span className="text-blue-500">{action}</span>;
} else {
return (
<span className="text-yellow-500">{`${row.original.field} von "${row.original.oldValue}" zu "${row.original.newValue}"`}</span>
);
}
},
},
{
header: "IP-Adresse",
accessorKey: "ip",
},
{
header: "Browser",
accessorKey: "browser",
},
{
header: "Benutzer",
accessorKey: "userId",
cell: ({ row }) => {
return (
<Link
href={`/admin/user/${row.original.userId}`}
className={cn("link", userId !== row.original.userId && "text-red-400")}
>
{row.original.User
? `${row.original.User.firstname} ${row.original.User.lastname} - ${row.original.User.publicId}`
: "Unbekannt"}
</Link>
);
},
},
] as ColumnDef<Log & { User: User }>[]
}
/>
</div>
);
};

View File

@@ -54,7 +54,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { Error } from "_components/Error"; import { Error as ErrorComponent } from "_components/Error";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { setStandardName } from "../../../../../../helper/discord"; import { setStandardName } from "../../../../../../helper/discord";
import { penaltyColumns } from "(app)/admin/penalty/columns"; import { penaltyColumns } from "(app)/admin/penalty/columns";
@@ -73,7 +73,7 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
defaultValues: user, defaultValues: user,
resolver: zodResolver(UserOptionalDefaultsSchema), resolver: zodResolver(UserOptionalDefaultsSchema),
}); });
if (!user) return <Error title="User not found" statusCode={404} />; if (!user) return <ErrorComponent title="User not found" statusCode={404} />;
return ( return (
<form <form
className="card-body" className="card-body"
@@ -663,16 +663,25 @@ export const AdminForm = ({
> >
<Button <Button
onClick={async () => { onClick={async () => {
await setStandardName({ try {
memberId: discordAccount.discordId, const response = await setStandardName({
userId: user.id, memberId: discordAccount.discordId,
}); userId: user.id,
toast.success("Standard Name wurde gesetzt!", { });
style: { if (response?.error) {
background: "var(--color-base-100)", throw new Error(response.error);
color: "var(--color-base-content)", }
}, toast.success("Standard Name wurde gesetzt!", {
}); style: {
background: "var(--color-base-100)",
color: "var(--color-base-content)",
},
});
} catch (error) {
toast.error(
"Fehler beim setzen des Standard Namens: " + (error as Error).message,
);
}
}} }}
className="btn-sm btn-outline btn-info w-full" className="btn-sm btn-outline btn-info w-full"
> >
@@ -691,6 +700,21 @@ export const AdminForm = ({
</div> </div>
)} )}
</div> </div>
{user.isDeleted && (
<div role="alert" className="alert alert-warning alert-outline flex flex-col">
<div className="flex items-center gap-2">
<TriangleAlert />
<div>
<h3 className="text-lg font-semibold">Account gelöscht</h3>
</div>
<div>
<h3 className="text-lg font-semibold">
Dieser Account ist als gelöscht markiert, der Nutzer kann sich nicht mehr anmelden.
</h3>
</div>
</div>
</div>
)}
{(user.CanonicalUser || (user.Duplicates && user.Duplicates.length > 0)) && ( {(user.CanonicalUser || (user.Duplicates && user.Duplicates.length > 0)) && (
<div role="alert" className="alert alert-error alert-outline flex flex-col"> <div role="alert" className="alert alert-error alert-outline flex flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -1,5 +1,5 @@
import { PersonIcon } from "@radix-ui/react-icons"; import { PersonIcon } from "@radix-ui/react-icons";
import { prisma } from "@repo/db"; import { Log, prisma } from "@repo/db";
import { import {
AdminForm, AdminForm,
ConnectionHistory, ConnectionHistory,
@@ -9,6 +9,9 @@ import {
} from "./_components/forms"; } from "./_components/forms";
import { Error } from "../../../../_components/Error"; import { Error } from "../../../../_components/Error";
import { getUserPenaltys } from "@repo/shared-components"; import { getUserPenaltys } from "@repo/shared-components";
import { PaginatedTable } from "_components/PaginatedTable";
import { ColumnDef } from "@tanstack/react-table";
import { AccountLog } from "./_components/AccountLog";
export default async function Page({ params }: { params: Promise<{ id: string }> }) { export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
@@ -35,6 +38,26 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
}); });
} }
const userLog = await prisma.log.findMany({
where: {
userId: user?.id,
},
});
const sameIpLogs = await prisma.log.findMany({
where: {
ip: {
in: userLog.map((log) => log.ip).filter((ip): ip is string => ip !== null),
},
userId: {
not: user?.id,
},
},
include: {
User: true,
},
});
const formerDiscordAccounts = await prisma.formerDiscordAccount.findMany({ const formerDiscordAccounts = await prisma.formerDiscordAccount.findMany({
where: { where: {
userId: user?.id, userId: user?.id,
@@ -153,9 +176,12 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
/> />
</div> </div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-6"> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-6">
<AccountLog sameIPLogs={sameIpLogs} userId={user.id} />
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<UserReports user={user} /> <UserReports user={user} />
</div> </div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-6"> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<UserPenalties user={user} /> <UserPenalties user={user} />
</div> </div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-6"> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-6">

View File

@@ -3,6 +3,7 @@ import { prisma, Prisma } from "@repo/db";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { sendMailByTemplate } from "../../../../helper/mail"; import { sendMailByTemplate } from "../../../../helper/mail";
import { getServerSession } from "api/auth/[...nextauth]/auth"; import { getServerSession } from "api/auth/[...nextauth]/auth";
import { logAction } from "(auth)/login/_components/action";
export const getUser = async (where: Prisma.UserWhereInput) => { export const getUser = async (where: Prisma.UserWhereInput) => {
return await prisma.user.findMany({ return await prisma.user.findMany({
@@ -58,10 +59,14 @@ export const deletePilotHistory = async (id: number) => {
}); });
}; };
export const deleteUser = async (id: string) => { export const deleteUser = async (id: string) => {
return await prisma.user.delete({ await logAction("ACCOUNT_DELETED");
return await prisma.user.update({
where: { where: {
id: id, id: id,
}, },
data: {
isDeleted: true,
},
}); });
}; };

View File

@@ -63,6 +63,9 @@ const AdminUserPage = () => {
if (activePenaltys.length > 0) { if (activePenaltys.length > 0) {
return <span className="font-bold text-red-600">AKTIVE STRAFE</span>; return <span className="font-bold text-red-600">AKTIVE STRAFE</span>;
} }
if (props.row.original.isDeleted) {
return <span className="font-bold text-yellow-600">GELÖSCHT</span>;
}
if (props.row.original.permissions.length === 0) { if (props.row.original.permissions.length === 0) {
return <span className="text-gray-700">Keine</span>; return <span className="text-gray-700">Keine</span>;
} else if (props.row.original.permissions.includes("ADMIN_USER_ADVANCED")) { } else if (props.row.original.permissions.includes("ADMIN_USER_ADVANCED")) {

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { DrawingPinFilledIcon } from "@radix-ui/react-icons"; import { DrawingPinFilledIcon } from "@radix-ui/react-icons";
import { Event, Participant, EventAppointment, User } from "@repo/db"; import { Event, Participant, User } from "@repo/db";
import ModalBtn from "./Modal"; import ModalBtn from "./Modal";
import MDEditor from "@uiw/react-md-editor"; import MDEditor from "@uiw/react-md-editor";
import { Badge } from "@repo/shared-components"; import { Badge } from "@repo/shared-components";
@@ -8,25 +8,18 @@ import { Badge } from "@repo/shared-components";
export const EventCard = ({ export const EventCard = ({
user, user,
event, event,
selectedAppointments,
appointments,
}: { }: {
user: User; user: User;
event: Event & { event: Event & {
Appointments: EventAppointment[];
Participants: Participant[]; Participants: Participant[];
}; };
selectedAppointments: EventAppointment[];
appointments: (EventAppointment & {
Participants: { userId: string }[];
})[];
}) => { }) => {
return ( return (
<div className="col-span-full"> <div className="col-span-full">
<div className="card bg-base-200 shadow-xl mb-4"> <div className="card bg-base-200 mb-4 shadow-xl">
<div className="card-body"> <div className="card-body">
<h2 className="card-title">{event.name}</h2> <h2 className="card-title">{event.name}</h2>
<div className="absolute top-0 right-0 m-4"> <div className="absolute right-0 top-0 m-4">
{event.type === "COURSE" && ( {event.type === "COURSE" && (
<span className="badge badge-info badge-outline">Zusatzqualifikation</span> <span className="badge badge-info badge-outline">Zusatzqualifikation</span>
)} )}
@@ -36,7 +29,7 @@ export const EventCard = ({
</div> </div>
<div className="grid grid-cols-6 gap-4"> <div className="grid grid-cols-6 gap-4">
<div className="col-span-4"> <div className="col-span-4">
<div className="text-left text-balance" data-color-mode="dark"> <div className="text-balance text-left" data-color-mode="dark">
<MDEditor.Markdown <MDEditor.Markdown
source={event.descriptionShort} source={event.descriptionShort}
style={{ style={{
@@ -45,21 +38,21 @@ export const EventCard = ({
/> />
</div> </div>
</div> </div>
<div className="flex col-span-2 justify-end"> <div className="col-span-2 flex justify-end">
{event.finishedBadges.map((b) => { {event.finishedBadges.map((b) => {
return <Badge badge={b} key={b} />; return <Badge badge={b} key={b} />;
})} })}
</div> </div>
</div> </div>
<div className="card-actions flex justify-between items-center mt-5"> <div className="card-actions mt-5 flex items-center justify-between">
<div> <div>
<p className="text-gray-600 text-left flex items-center gap-2"> <p className="flex items-center gap-2 text-left text-gray-600">
<DrawingPinFilledIcon /> <b>Teilnahmevoraussetzungen: </b> <DrawingPinFilledIcon /> <b>Teilnahmevoraussetzungen: </b>
{!event.requiredBadges.length && "Keine"} {!event.requiredBadges.length && "Keine"}
</p> </p>
{!!event.requiredBadges.length && ( {!!event.requiredBadges.length && (
<div className="flex ml-6"> <div className="ml-6 flex">
<b className="text-gray-600 text-left mr-2">Abzeichen:</b> <b className="mr-2 text-left text-gray-600">Abzeichen:</b>
<div className="flex gap-2"> <div className="flex gap-2">
{event.requiredBadges.map((badge) => ( {event.requiredBadges.map((badge) => (
<div className="badge badge-secondary badge-outline" key={badge}> <div className="badge badge-secondary badge-outline" key={badge}>
@@ -71,11 +64,9 @@ export const EventCard = ({
)} )}
</div> </div>
<ModalBtn <ModalBtn
selectedAppointments={selectedAppointments}
user={user} user={user}
event={event} event={event}
title={event.name} title={event.name}
dates={appointments}
participant={event.Participants[0]} participant={event.Participants[0]}
modalId={`${event.name}_modal.${event.id}`} modalId={`${event.name}_modal.${event.id}`}
/> />

View File

@@ -1,56 +1,30 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { CheckCircledIcon, EnterIcon, DrawingPinFilledIcon } from "@radix-ui/react-icons"; import { CheckCircledIcon, EnterIcon, DrawingPinFilledIcon } from "@radix-ui/react-icons";
import { Event, EventAppointment, Participant, User } from "@repo/db"; import { Event, Participant, User } from "@repo/db";
import { cn } from "@repo/shared-components"; import { cn } from "@repo/shared-components";
import { inscribeToMoodleCourse, upsertParticipant } from "../actions"; import { inscribeToMoodleCourse, upsertParticipant } from "../actions";
import { import {
BookCheck, BookCheck,
Calendar,
Check, Check,
CirclePlay, CirclePlay,
Clock10Icon,
ExternalLink, ExternalLink,
EyeIcon, EyeIcon,
Info, Info,
TriangleAlert, TriangleAlert,
} from "lucide-react"; } from "lucide-react";
import { useForm } from "react-hook-form";
import {
InputJsonValueType,
ParticipantOptionalDefaults,
ParticipantOptionalDefaultsSchema,
} from "@repo/db/zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Select } from "../../../_components/ui/Select";
import { useRouter } from "next/navigation";
import { handleParticipantEnrolled } from "../../../../helper/events";
import { eventCompleted } from "@repo/shared-components"; import { eventCompleted } from "@repo/shared-components";
import MDEditor from "@uiw/react-md-editor"; import MDEditor from "@uiw/react-md-editor";
import toast from "react-hot-toast";
import { formatDate } from "date-fns";
interface ModalBtnProps { interface ModalBtnProps {
title: string; title: string;
event: Event; event: Event;
dates: (EventAppointment & {
Participants: { userId: string }[];
})[];
selectedAppointments: EventAppointment[];
participant?: Participant; participant?: Participant;
user: User; user: User;
modalId: string; modalId: string;
} }
const ModalBtn = ({ const ModalBtn = ({ title, modalId, participant, event, user }: ModalBtnProps) => {
title,
dates,
modalId,
participant,
selectedAppointments,
event,
user,
}: ModalBtnProps) => {
useEffect(() => { useEffect(() => {
const modal = document.getElementById(modalId) as HTMLDialogElement; const modal = document.getElementById(modalId) as HTMLDialogElement;
const handleOpen = () => { const handleOpen = () => {
@@ -66,12 +40,6 @@ const ModalBtn = ({
modal?.removeEventListener("close", handleClose); modal?.removeEventListener("close", handleClose);
}; };
}, [modalId]); }, [modalId]);
const router = useRouter();
const canSelectDate =
event.hasPresenceEvents &&
!participant?.attended &&
(selectedAppointments.length === 0 || participant?.appointmentCancelled);
const openModal = () => { const openModal = () => {
const modal = document.getElementById(modalId) as HTMLDialogElement; const modal = document.getElementById(modalId) as HTMLDialogElement;
@@ -82,29 +50,6 @@ const ModalBtn = ({
const modal = document.getElementById(modalId) as HTMLDialogElement; const modal = document.getElementById(modalId) as HTMLDialogElement;
modal?.close(); modal?.close();
}; };
const selectAppointmentForm = useForm<ParticipantOptionalDefaults>({
resolver: zodResolver(ParticipantOptionalDefaultsSchema),
defaultValues: {
eventId: event.id,
userId: user.id,
...participant,
},
});
const selectedAppointment = selectedAppointments[0];
const selectedDate = dates.find(
(date) =>
date.id === selectAppointmentForm.watch("eventAppointmentId") || selectedAppointment?.id,
);
const ownIndexInParticipantList = selectedDate?.Participants?.findIndex(
(p) => p.userId === user.id,
);
const ownPlaceInParticipantList =
typeof ownIndexInParticipantList === "number"
? ownIndexInParticipantList === -1
? (selectedDate?.Participants?.length ?? 0) + 1
: ownIndexInParticipantList + 1
: undefined;
const missingRequirements = const missingRequirements =
event.requiredBadges?.length > 0 && event.requiredBadges?.length > 0 &&
@@ -163,79 +108,6 @@ const ModalBtn = ({
/> />
</div> </div>
</div> </div>
{event.hasPresenceEvents && (
<div className="bg-base-300 flex min-w-[300px] flex-1 flex-col gap-2 rounded-lg p-3 shadow">
<h2 className="flex gap-2 text-lg font-bold">
<Calendar /> Termine
</h2>
<div className="flex flex-1 flex-col items-center justify-center">
{!!dates.length && !selectedDate && (
<>
<p className="text-info text-center">Melde dich zu einem Termin an</p>
<Select
form={selectAppointmentForm}
options={dates.map((date) => ({
label: `${formatDate(date.appointmentDate, "dd.MM.yyyy HH:mm")} - (${date.Participants.length}/${event.maxParticipants})`,
value: date.id,
}))}
name="eventAppointmentId"
label={""}
placeholder="Wähle einen Termin"
className="min-w-[250px]"
/>
</>
)}
{selectedAppointment && !participant?.appointmentCancelled && (
<div className="flex flex-col items-center justify-center gap-2">
<span>Dein ausgewählter Termin (Deutsche Zeit)</span>
<div>
<button
className="input input-border pointer-events-none min-w-[250px]"
style={{ anchorName: "--rdp" } as React.CSSProperties}
>
{new Date(selectedAppointment.appointmentDate).toLocaleString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</button>
</div>
{participant?.attended ? (
<p className="flex items-center justify-center gap-2 py-4">
<CheckCircledIcon className="text-success" />
Du hast an dem Presenztermin teilgenommen
</p>
) : (
<p className="flex items-center justify-center gap-2 py-4">
Bitte erscheine ~5 minuten vor dem Termin im Discord
</p>
)}
</div>
)}
{!dates.length && (
<p className="text-error text-center">Aktuell sind keine Termine verfügbar</p>
)}
{!!selectedDate &&
!!event.maxParticipants &&
!!ownPlaceInParticipantList &&
!!(ownPlaceInParticipantList > event.maxParticipants) && (
<p
role="alert"
className="alert alert-error alert-outline my-5 flex items-center justify-center gap-2 border py-4"
>
<TriangleAlert className="h-6 w-6 shrink-0 stroke-current" fill="none" />
Dieser Termin ist ausgebucht, wahrscheinlich wirst du nicht teilnehmen
können. (Listenplatz: {ownPlaceInParticipantList} , max.{" "}
{event.maxParticipants})
</p>
)}
</div>
</div>
)}
{event.finisherMoodleCourseId && ( {event.finisherMoodleCourseId && (
<div className="bg-base-300 flex min-w-[300px] flex-1 flex-col gap-2 rounded-lg p-3 shadow"> <div className="bg-base-300 flex min-w-[300px] flex-1 flex-col gap-2 rounded-lg p-3 shadow">
<h2 className="flex gap-2 text-lg font-bold"> <h2 className="flex gap-2 text-lg font-bold">
@@ -261,64 +133,6 @@ const ModalBtn = ({
{!event.requiredBadges.length && "Keine"} {!event.requiredBadges.length && "Keine"}
</p> </p>
</div> </div>
<div className="modal-action">
{!!canSelectDate && (
<button
className={cn(
"btn btn-info btn-outline btn-wide",
event.type === "COURSE" && "btn-secondary",
)}
onClick={async () => {
const data = selectAppointmentForm.getValues();
if (!data.eventAppointmentId) return;
const participant = await upsertParticipant({
...data,
enscriptionDate: new Date(),
statusLog: data.statusLog?.filter((log) => log !== null),
appointmentCancelled: false,
});
await handleParticipantEnrolled(participant.id.toString());
router.refresh();
closeModal();
}}
disabled={!selectAppointmentForm.watch("eventAppointmentId")}
>
<EnterIcon /> Anmelden
</button>
)}
{selectedAppointment &&
!participant?.appointmentCancelled &&
!participant?.attended && (
<button
onClick={async () => {
await upsertParticipant({
eventId: event.id,
userId: participant!.userId,
appointmentCancelled: true,
statusLog: [
...(participant?.statusLog as unknown as InputJsonValueType[]),
{
data: {
appointmentId: selectedAppointment.id,
appointmentDate: selectedAppointment.appointmentDate,
},
user: `${user?.firstname} ${user?.lastname} - ${user?.publicId}`,
event: "Termin abgesagt",
timestamp: new Date(),
},
],
});
toast.success("Termin abgesagt");
router.refresh();
}}
className="btn btn-error btn-outline btn-wide"
>
Termin Absagen
</button>
)}
</div>
</div> </div>
</div> </div>
<button className="modal-backdrop" onClick={closeModal}> <button className="modal-backdrop" onClick={closeModal}>
@@ -335,7 +149,6 @@ const MoodleCourseIndicator = ({
completed, completed,
moodleCourseId, moodleCourseId,
event, event,
participant,
user, user,
}: { }: {
user: User; user: User;
@@ -345,13 +158,6 @@ const MoodleCourseIndicator = ({
event: Event; event: Event;
}) => { }) => {
const courseUrl = `${process.env.NEXT_PUBLIC_MOODLE_URL}/course/view.php?id=${moodleCourseId}`; const courseUrl = `${process.env.NEXT_PUBLIC_MOODLE_URL}/course/view.php?id=${moodleCourseId}`;
if (event.hasPresenceEvents && !participant?.attended)
return (
<p className="flex items-center justify-center gap-2 py-4">
<Clock10Icon className="text-error" />
Abschlusstest erst nach Teilnahme verfügbar
</p>
);
if (completed) if (completed)
return ( return (
<p className="flex items-center justify-center gap-2 py-4"> <p className="flex items-center justify-center gap-2 py-4">

View File

@@ -10,63 +10,19 @@ const page = async () => {
if (!user) return null; if (!user) return null;
const events = await prisma.event.findMany({ const events = await prisma.event.findMany({
orderBy: {
id: "desc",
},
where: { where: {
hidden: false, hidden: false,
}, },
include: { include: {
Appointments: {
where: {
appointmentDate: {
gte: new Date(),
},
},
},
Participants: { Participants: {
where: { where: {
userId: user.id, userId: user.id,
}, },
}, },
}, },
orderBy: {
id: "desc",
},
});
const appointments = await prisma.eventAppointment.findMany({
where: {
appointmentDate: {
gte: new Date(),
},
},
include: {
Participants: {
select: {
enscriptionDate: true,
id: true,
userId: true,
},
where: {
appointmentCancelled: false,
},
orderBy: {
enscriptionDate: "asc",
},
},
_count: {
select: {
Participants: true,
},
},
},
});
const userAppointments = await prisma.eventAppointment.findMany({
where: {
Participants: {
some: {
userId: user.id,
},
},
},
}); });
return ( return (
@@ -78,15 +34,7 @@ const page = async () => {
</div> </div>
{events.map((event) => { {events.map((event) => {
return ( return <EventCard user={user} event={event} key={event.id} />;
<EventCard
appointments={appointments}
selectedAppointments={userAppointments}
user={user}
event={event}
key={event.id}
/>
);
})} })}
</div> </div>
); );

View File

@@ -11,7 +11,7 @@ export default function () {
image={Discord} image={Discord}
title="Discord Server" title="Discord Server"
BtnIcon={<DiscordLogoIcon />} BtnIcon={<DiscordLogoIcon />}
btnHref="https://discord.com/invite/x6FAMY7DW6" btnHref="https://discord.gg/virtualairrescue"
btnLabel="Beitreten" btnLabel="Beitreten"
description="Tritt unserem Discordserver bei, um mit der Community in Kontakt zu bleiben, Unterstützung zu erhalten und über die neuesten Updates informiert zu werden. Wenn du beigetreten bist kannst du in deinen Einstellungen dein VAR-Konto mit deinem Discordkonto verknüpfen und eine Rolle zu erhalten. description="Tritt unserem Discordserver bei, um mit der Community in Kontakt zu bleiben, Unterstützung zu erhalten und über die neuesten Updates informiert zu werden. Wenn du beigetreten bist kannst du in deinen Einstellungen dein VAR-Konto mit deinem Discordkonto verknüpfen und eine Rolle zu erhalten.
" "

View File

@@ -23,6 +23,7 @@ import toast from "react-hot-toast";
import { CircleAlert, Trash2 } from "lucide-react"; import { CircleAlert, Trash2 } from "lucide-react";
import { deleteUser, sendVerificationLink } from "(app)/admin/user/action"; import { deleteUser, sendVerificationLink } from "(app)/admin/user/action";
import { setStandardName } from "../../../../helper/discord"; import { setStandardName } from "../../../../helper/discord";
import { logAction } from "(auth)/login/_components/action";
export const ProfileForm = ({ export const ProfileForm = ({
user, user,
@@ -101,6 +102,35 @@ export const ProfileForm = ({
userId: user.id, userId: user.id,
}); });
} }
const ip = await fetch("https://api.ipify.org/?format=json")
.then((res) => res.json())
.then((data) => data.ip);
if (user.firstname !== values.firstname) {
await logAction("PROFILE_CHANGE", {
ip,
field: "firstname",
oldValue: user.firstname,
newValue: values.firstname,
});
}
if (user.lastname !== values.lastname) {
await logAction("PROFILE_CHANGE", {
ip,
field: "lastname",
oldValue: user.lastname,
newValue: values.lastname,
});
}
if (user.email !== values.email) {
await logAction("PROFILE_CHANGE", {
ip,
field: "email",
oldValue: user.email,
newValue: values.email,
});
}
form.reset(values); form.reset(values);
if (user.email !== values.email) { if (user.email !== values.email) {
await sendVerificationLink(user.id); await sendVerificationLink(user.id);
@@ -377,8 +407,11 @@ export const DeleteForm = ({
export const PasswordForm = (): React.JSX.Element => { export const PasswordForm = (): React.JSX.Element => {
const schema = z.object({ const schema = z.object({
password: z.string().min(2).max(30), password: z.string().min(2).max(30),
newPassword: z.string().min(2).max(30), newPassword: z
newPasswordConfirm: z.string().min(2).max(30), .string()
.min(8, { message: "Das Passwort muss mindestens 8 Zeichen lang sein" })
.max(30),
newPasswordConfirm: z.string().min(8).max(30),
}); });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
type IFormInput = z.infer<typeof schema>; type IFormInput = z.infer<typeof schema>;

View File

@@ -9,6 +9,7 @@ import { Toaster, toast } from "react-hot-toast";
import { z } from "zod"; import { z } from "zod";
import { Button } from "../../../_components/ui/Button"; import { Button } from "../../../_components/ui/Button";
import { useErrorBoundary } from "react-error-boundary"; import { useErrorBoundary } from "react-error-boundary";
import { logAction } from "./action";
export const Login = () => { export const Login = () => {
const { showBoundary } = useErrorBoundary(); const { showBoundary } = useErrorBoundary();
@@ -46,6 +47,12 @@ export const Login = () => {
}); });
return; return;
} }
const ip = await fetch("https://api.ipify.org/?format=json")
.then((res) => res.json())
.then((data) => data.ip);
await logAction("LOGIN", { ip });
redirect(searchParams.get("redirect") || "/"); redirect(searchParams.get("redirect") || "/");
} catch (error) { } catch (error) {
showBoundary(error); showBoundary(error);

View File

@@ -0,0 +1,110 @@
"use server";
import { LOG_TYPE, prisma } from "@repo/db";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { randomUUID } from "crypto";
import { cookies, headers } from "next/headers";
import { sendReportEmbed } from "../../../../helper/discord";
export async function getOrSetDeviceId() {
const store = await cookies();
let deviceId = store.get("device_id")?.value;
if (!deviceId) {
deviceId = randomUUID();
store.set("device_id", deviceId, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 365, // 1 Jahr
});
}
return deviceId;
}
export const logAction = async (
type: LOG_TYPE,
otherValues?: {
ip: string;
field?: string;
oldValue?: string;
newValue?: string;
userId?: string;
},
) => {
const headersList = await headers();
const user = await getServerSession();
const deviceId = await getOrSetDeviceId();
if (type == "LOGIN" || type == "REGISTER") {
const existingLogs = await prisma.log.findMany({
where: {
type: "LOGIN",
userId: {
not: user?.user.id,
},
OR: [
{
ip: otherValues?.ip,
},
{
deviceId: deviceId,
},
],
},
});
if (existingLogs.length > 0 && user?.user.id) {
const existingReport = await prisma.report.findFirst({
where: {
reportedUserId: user.user.id,
reportedUserRole: {
contains: "Doppelter Account Verdacht",
},
},
});
// keine Doppel-Reports für denselben Nutzer erstellen
if (!existingReport) {
// Möglicherweise ein doppelter Account, Report erstellen
const report = await prisma.report.create({
data: {
text: `Möglicher doppelter Account erkannt bei Login-Versuch.\n\nÜbereinstimmende Logs:\n${existingLogs
.map((log) => `- Log ID: ${log.id}, IP: ${log.ip}, Zeitstempel: ${log.timestamp}`)
.join("\n")}`,
reportedUserId: user?.user.id,
reportedUserRole: "LOGIN - Doppelter Account Verdacht",
},
});
await sendReportEmbed(report.id);
} else {
// Update report and send it again to Discord
const updatedReport = await prisma.report.update({
where: {
id: existingReport.id,
},
data: {
text: `Möglicher doppelter Account erkannt bei Login-Versuch.\n\nÜbereinstimmende Logs:\n${existingLogs
.map(
(log) =>
`- Log ID: ${log.id}, IP: ${log.ip}, Zeitstempel: ${new Date(log.timestamp).toLocaleString("de-DE")}`,
)
.join("\n")}`,
},
});
await sendReportEmbed(updatedReport.id);
}
}
}
await prisma.log.create({
data: {
type,
browser: headersList.get("user-agent") || "unknown",
userId: user?.user.id || otherValues?.userId,
deviceId: deviceId,
ip: otherValues?.ip,
...otherValues,
},
});
};

View File

@@ -11,6 +11,7 @@ export const resetPassword = async (email: string) => {
let user = await prisma.user.findFirst({ let user = await prisma.user.findFirst({
where: { where: {
email, email,
isDeleted: false,
}, },
}); });
const oldUser = (v1User as OldUser[]).find((u) => u.email.toLowerCase() === email); const oldUser = (v1User as OldUser[]).find((u) => u.email.toLowerCase() === email);

View File

@@ -9,6 +9,7 @@ import { useState } from "react";
import { Button } from "../../../_components/ui/Button"; import { Button } from "../../../_components/ui/Button";
import { sendVerificationLink } from "(app)/admin/user/action"; import { sendVerificationLink } from "(app)/admin/user/action";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { logAction } from "(auth)/login/_components/action";
export const Register = () => { export const Register = () => {
const schema = z const schema = z
@@ -48,8 +49,8 @@ export const Register = () => {
.refine((val) => val.length === 0 || val.includes(" ") || /^[A-ZÄÖÜ]/.test(val), { .refine((val) => val.length === 0 || val.includes(" ") || /^[A-ZÄÖÜ]/.test(val), {
message: "Der Nachname muss mit einem Großbuchstaben beginnen", message: "Der Nachname muss mit einem Großbuchstaben beginnen",
}), }),
password: z.string().min(12, { password: z.string().min(8, {
message: "Das Passwort muss mindestens 12 Zeichen lang sein", message: "Das Passwort muss mindestens 8 Zeichen lang sein",
}), }),
passwordConfirm: z.string(), passwordConfirm: z.string(),
}) })
@@ -93,6 +94,14 @@ export const Register = () => {
return; return;
} }
await sendVerificationLink(user.id); await sendVerificationLink(user.id);
const ip = await fetch("https://api.ipify.org/?format=json")
.then((res) => res.json())
.then((data) => data.ip);
await logAction("REGISTER", {
ip: ip,
userId: user.id,
});
await signIn("credentials", { await signIn("credentials", {
callbackUrl: "/", callbackUrl: "/",
email: user.email, email: user.email,

View File

@@ -24,9 +24,6 @@ export const register = async ({ password, ...user }: Omit<Prisma.UserCreateInpu
where: { where: {
email: user.email, email: user.email,
}, },
select: {
id: true,
},
}); });
const existingOldUser = (v1User as OldUser[]).find( const existingOldUser = (v1User as OldUser[]).find(
@@ -34,9 +31,15 @@ export const register = async ({ password, ...user }: Omit<Prisma.UserCreateInpu
); );
if (existingUser) { if (existingUser) {
return { if (existingUser.isDeleted) {
error: "Ein Nutzer mit dieser E-Mail-Adresse existiert bereits.", return {
}; error: "Diese E-Mail-Adresse kann nicht verwendet werden.",
};
} else {
return {
error: "Ein Nutzer mit dieser E-Mail-Adresse existiert bereits.",
};
}
} }
if (existingOldUser) { if (existingOldUser) {
@@ -53,5 +56,6 @@ export const register = async ({ password, ...user }: Omit<Prisma.UserCreateInpu
password: hashedPassword, password: hashedPassword,
}, },
}); });
return newUser; return newUser;
}; };

View File

@@ -9,6 +9,7 @@ import { Button } from "@repo/shared-components";
import { formatTimeRange } from "../../helper/timerange"; import { formatTimeRange } from "../../helper/timerange";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import Link from "next/link"; import Link from "next/link";
import { error } from "console";
interface BookingTimelineModalProps { interface BookingTimelineModalProps {
isOpen: boolean; isOpen: boolean;
@@ -318,12 +319,7 @@ export const BookingTimelineModal = ({
{canDeleteBooking(booking.User.publicId) && ( {canDeleteBooking(booking.User.publicId) && (
<Button <Button
onClick={() => deleteBooking(booking.id)} onClick={() => deleteBooking(booking.id)}
className={`btn btn-xs ${ className={`btn btn-xs btn-error`}
currentUser?.permissions.includes("ADMIN_EVENT") &&
booking.User.publicId !== currentUser.publicId
? "btn-error"
: "btn-neutral"
}`}
title="Buchung löschen" title="Buchung löschen"
> >
<Trash2 size={12} /> <Trash2 size={12} />

View File

@@ -6,7 +6,6 @@ import {
RocketIcon, RocketIcon,
ReaderIcon, ReaderIcon,
DownloadIcon, DownloadIcon,
UpdateIcon,
ActivityLogIcon, ActivityLogIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import Link from "next/link"; import Link from "next/link";
@@ -14,7 +13,7 @@ import { WarningAlert } from "./ui/PageAlert";
import { getServerSession } from "api/auth/[...nextauth]/auth"; import { getServerSession } from "api/auth/[...nextauth]/auth";
import { Error } from "./Error"; import { Error } from "./Error";
import Image from "next/image"; import Image from "next/image";
import { Loader, Plane, Radar, Workflow } from "lucide-react"; import { Plane, Radar, Workflow } from "lucide-react";
import { BookingButton } from "./BookingButton"; import { BookingButton } from "./BookingButton";
export const VerticalNav = async () => { export const VerticalNav = async () => {
@@ -103,6 +102,11 @@ export const VerticalNav = async () => {
<Link href="/admin/penalty">Audit-Log</Link> <Link href="/admin/penalty">Audit-Log</Link>
</li> </li>
)} )}
{session.user.permissions.includes("ADMIN_USER_ADVANCED") && (
<li>
<Link href="/admin/log">Account Log</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_CHANGELOG") && ( {session.user.permissions.includes("ADMIN_CHANGELOG") && (
<li> <li>
<Link href="/admin/changelog">Changelog</Link> <Link href="/admin/changelog">Changelog</Link>

View File

@@ -21,6 +21,7 @@ interface PaginatedTableProps<TData, TWhere extends object>
leftOfSearch?: React.ReactNode; leftOfSearch?: React.ReactNode;
rightOfSearch?: React.ReactNode; rightOfSearch?: React.ReactNode;
leftOfPagination?: React.ReactNode; leftOfPagination?: React.ReactNode;
rightOfPagination?: React.ReactNode;
supressQuery?: boolean; supressQuery?: boolean;
ref?: Ref<PaginatedTableRef>; ref?: Ref<PaginatedTableRef>;
} }
@@ -37,6 +38,7 @@ export function PaginatedTable<TData, TWhere extends object>({
leftOfSearch, leftOfSearch,
rightOfSearch, rightOfSearch,
leftOfPagination, leftOfPagination,
rightOfPagination,
supressQuery, supressQuery,
...restProps ...restProps
}: PaginatedTableProps<TData, TWhere>) { }: PaginatedTableProps<TData, TWhere>) {
@@ -159,10 +161,9 @@ export function PaginatedTable<TData, TWhere extends object>({
<SortableTable data={data} prismaModel={prismaModel} setOrderBy={setOrderBy} {...restProps} /> <SortableTable data={data} prismaModel={prismaModel} setOrderBy={setOrderBy} {...restProps} />
<div className="items-between flex"> <div className="items-between flex">
{leftOfPagination} {leftOfPagination}
<> <RowsPerPage rowsPerPage={rowsPerPage} setRowsPerPage={setRowsPerPage} />
<RowsPerPage rowsPerPage={rowsPerPage} setRowsPerPage={setRowsPerPage} /> {rightOfPagination}
<Pagination totalPages={Math.ceil(total / rowsPerPage)} page={page} setPage={setPage} /> <Pagination totalPages={Math.ceil(total / rowsPerPage)} page={page} setPage={setPage} />
</>
</div> </div>
</div> </div>
); );

View File

@@ -95,7 +95,7 @@ export const RowsPerPage = ({
}) => { }) => {
return ( return (
<select <select
className="select w-32" className="select select-sm w-32"
value={rowsPerPage} value={rowsPerPage}
onChange={(e) => setRowsPerPage(Number(e.target.value))} onChange={(e) => setRowsPerPage(Number(e.target.value))}
> >
@@ -122,11 +122,15 @@ export const Pagination = ({
if (totalPages === 0) return null; if (totalPages === 0) return null;
return ( return (
<div className="join w-full justify-end"> <div className="join w-full justify-end">
<button className="join-item btn" disabled={page === 0} onClick={() => setPage(page - 1)}> <button
<ArrowLeft size={16} /> className="join-item btn btn-sm"
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
<ArrowLeft size={14} />
</button> </button>
<select <select
className="select join-item w-16" className="select select-sm join-item w-16"
value={page} value={page}
onChange={(e) => setPage(Number(e.target.value))} onChange={(e) => setPage(Number(e.target.value))}
> >
@@ -137,11 +141,11 @@ export const Pagination = ({
))} ))}
</select> </select>
<button <button
className="join-item btn" className="join-item btn btn-sm"
disabled={page === totalPages - 1} disabled={page === totalPages - 1}
onClick={() => page < totalPages && setPage(page + 1)} onClick={() => page < totalPages && setPage(page + 1)}
> >
<ArrowRight size={16} /> <ArrowRight size={14} />
</button> </button>
</div> </div>
); );

View File

@@ -23,6 +23,7 @@ export const options: AuthOptions = {
contains: credentials.email, contains: credentials.email,
mode: "insensitive", mode: "insensitive",
}, },
isDeleted: false,
}, },
}); });
const v1User = (oldUser as OldUser[]).find( const v1User = (oldUser as OldUser[]).find(
@@ -87,6 +88,7 @@ export const options: AuthOptions = {
const dbUser = await prisma.user.findUnique({ const dbUser = await prisma.user.findUnique({
where: { where: {
id: token?.sub, id: token?.sub,
isDeleted: false,
}, },
}); });
if (!dbUser) { if (!dbUser) {

View File

@@ -5,13 +5,14 @@ import { getServerSession } from "../../auth/[...nextauth]/auth";
// DELETE /api/booking/[id] - Delete a booking // DELETE /api/booking/[id] - Delete a booking
export const DELETE = async (req: NextRequest, { params }: { params: { id: string } }) => { export const DELETE = async (req: NextRequest, { params }: { params: { id: string } }) => {
try { try {
console.log(params);
const session = await getServerSession(); const session = await getServerSession();
if (!session?.user) { if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
} }
const bookingId = params.id; const bookingId = (await params).id;
console.log("Attempting to delete booking with ID:", bookingId);
// Find the booking // Find the booking
const booking = await prisma.booking.findUnique({ const booking = await prisma.booking.findUnique({
@@ -40,14 +41,14 @@ export const DELETE = async (req: NextRequest, { params }: { params: { id: strin
}; };
// PUT /api/booking/[id] - Update a booking // PUT /api/booking/[id] - Update a booking
export const PUT = async (req: NextRequest, { params }: { params: { id: string } }) => { export const PATCH = async (req: NextRequest, { params }: { params: { id: string } }) => {
try { try {
const session = await getServerSession(); const session = await getServerSession();
if (!session?.user) { if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
} }
const bookingId = params.id; const bookingId = (await params).id;
const body = await req.json(); const body = await req.json();
const { type, stationId, startTime, endTime } = body; const { type, stationId, startTime, endTime } = body;

View File

@@ -1,6 +1,6 @@
import axios from "axios"; import axios from "axios";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { DiscordAccount, prisma } from "@repo/db"; import { prisma } from "@repo/db";
import { getServerSession } from "../auth/[...nextauth]/auth"; import { getServerSession } from "../auth/[...nextauth]/auth";
import { setStandardName } from "../../../helper/discord"; import { setStandardName } from "../../../helper/discord";
import { getUserPenaltys } from "@repo/shared-components"; import { getUserPenaltys } from "@repo/shared-components";
@@ -52,8 +52,7 @@ export const GET = async (req: NextRequest) => {
}, },
}); });
const discordObject = { const discordData = {
userId: session.user.id,
accessToken: authData.access_token, accessToken: authData.access_token,
refreshToken: authData.refresh_token, refreshToken: authData.refresh_token,
discordId: discordUser.id, discordId: discordUser.id,
@@ -63,12 +62,19 @@ export const GET = async (req: NextRequest) => {
globalName: discordUser.global_name || discordUser.username, globalName: discordUser.global_name || discordUser.username,
verified: discordUser.verified, verified: discordUser.verified,
tokenType: authData.token_type, tokenType: authData.token_type,
} as DiscordAccount; };
await prisma.discordAccount.upsert({ await prisma.discordAccount.upsert({
where: { discordId: discordUser.id }, where: { discordId: discordUser.id },
update: discordObject, // Updates if found update: discordData, // Updates if found
create: discordObject, // Creates if not found create: {
...discordData,
User: {
connect: {
id: session.user.id,
},
},
}, // Creates if not found
}); });
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: session.user.id }, where: { id: session.user.id },

View File

@@ -24,15 +24,6 @@ export async function GET(request: Request): Promise<NextResponse> {
userId: session.user.id, userId: session.user.id,
}, },
}, },
Appointments: {
include: {
Participants: {
where: {
appointmentCancelled: false,
},
},
},
},
}, },
}); });

View File

@@ -1,5 +1,5 @@
"use server"; "use server";
import axios from "axios"; import axios, { AxiosError } from "axios";
const discordAxiosClient = axios.create({ const discordAxiosClient = axios.create({
baseURL: process.env.CORE_SERVER_URL || "http://localhost:3005", baseURL: process.env.CORE_SERVER_URL || "http://localhost:3005",
@@ -38,6 +38,16 @@ export const removeRolesFromMember = async (memberId: string, roleIds: string[])
}); });
}; };
export const sendReportEmbed = async (reportId: number) => {
discordAxiosClient
.post("/report/admin-embed", {
reportId,
})
.catch((error) => {
console.error("Error sending report embed:", error);
});
};
export const setStandardName = async ({ export const setStandardName = async ({
memberId, memberId,
userId, userId,
@@ -45,12 +55,14 @@ export const setStandardName = async ({
memberId: string; memberId: string;
userId: string; userId: string;
}) => { }) => {
discordAxiosClient try {
.post("/helper/set-standard-name", { await discordAxiosClient.post("/helper/set-standard-name", {
memberId, memberId,
userId, userId,
})
.catch((error) => {
console.error("Error removing roles from member:", error);
}); });
} catch (error) {
return {
error: (error as AxiosError<{ error: string }>).response?.data.error || "Unknown error",
};
}
}; };

View File

@@ -1,13 +1,9 @@
import { Event, EventAppointment, Participant, Prisma } from "@repo/db"; import { Event, Participant, Prisma } from "@repo/db";
import axios from "axios"; import axios from "axios";
export const getEvents = async (filter: Prisma.EventWhereInput) => { export const getEvents = async (filter: Prisma.EventWhereInput) => {
const { data } = await axios.get< const { data } = await axios.get<
(Event & { (Event & {
Appointments: (EventAppointment & {
Appointments: EventAppointment[];
Participants: Participant[];
})[];
Participants: Participant[]; Participants: Participant[];
})[] })[]
>(`/api/event`, { >(`/api/event`, {

View File

@@ -3,9 +3,9 @@ import { Booking } from "@repo/db";
export const formatTimeRange = (booking: Booking, options?: { includeDate?: boolean }) => { export const formatTimeRange = (booking: Booking, options?: { includeDate?: boolean }) => {
const start = new Date(booking.startTime); const start = new Date(booking.startTime);
const end = new Date(booking.endTime); const end = new Date(booking.endTime);
const timeRange = `${start.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} - ${end.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })}`; const timeRange = `${start.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" })} - ${end.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" })}`;
if (options?.includeDate) { if (options?.includeDate) {
return `${start.toLocaleDateString("de-DE")} ${timeRange}`; return `${start.toLocaleDateString("de-DE", { timeZone: "Europe/Berlin" })} ${timeRange}`;
} }
return timeRange; return timeRange;
}; };

View File

@@ -21,7 +21,7 @@
"node": ">=18", "node": ">=18",
"pnpm": ">=10" "pnpm": ">=10"
}, },
"packageManager": "pnpm@10.28.0", "packageManager": "pnpm@10.28.2",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"

View File

View File

@@ -1,7 +1,29 @@
export { prisma } from "./prisma/client"; // exports instance of prisma export { prisma } from "./prisma/client"; // Prisma instance
export * from "./generated/client"; // exports generated types from prisma
// ✅ NUR TYPES aus dem Prisma Client
export type * from "./generated/client";
export {
LOG_TYPE,
BOOKING_TYPE,
BADGES,
Country,
BosUse,
EVENT_TYPE,
HeliportType,
GlobalColor,
PERMISSION,
MissionState,
PenaltyType,
missionType,
HpgState,
HpgValidationState,
KEYWORD_CATEGORY,
} from "./generated/client"; // Prisma helpers
// Zod
import * as zodTypes from "./generated/zod"; import * as zodTypes from "./generated/zod";
export const zod = zodTypes; export const zod = zodTypes;
// JSON helpers
export * from "./prisma/json"; export * from "./prisma/json";

View File

@@ -25,6 +25,7 @@
"zod-prisma-types": "^3.2.4" "zod-prisma-types": "^3.2.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.1.0",
"prisma": "^6.12.0" "prisma": "^6.12.0"
} }
} }

View File

@@ -1,5 +1,27 @@
import { User } from "../../generated/client"; import { User } from "../../generated/client";
// USer History
interface UserDeletedEvent {
type: "USER_DELETED";
reason: string;
date: string;
by: string;
}
interface UserProfileUpdatedEvent {
type: "USER_PROFILE_UPDATED";
changes: {
field: string;
oldValue: string;
newValue: string;
};
date: string;
by: string;
}
export type UserHistoryEvent = UserDeletedEvent | UserProfileUpdatedEvent;
export interface PublicUser { export interface PublicUser {
firstname: string; firstname: string;
lastname: string; lastname: string;
@@ -17,8 +39,9 @@ export const DISCORD_ROLES = {
export const getPublicUser = ( export const getPublicUser = (
user: User, user: User,
options = { options?: {
ignorePrivacy: false, ignorePrivacy?: boolean;
fullLastName?: boolean;
}, },
): PublicUser => { ): PublicUser => {
const lastName = user.lastname const lastName = user.lastname
@@ -27,11 +50,22 @@ export const getPublicUser = (
.map((part) => `${part[0] || ""}.`) .map((part) => `${part[0] || ""}.`)
.join(" "); .join(" ");
if (options?.fullLastName) {
return {
firstname: user.firstname,
lastname: user.settingsHideLastname && !options.ignorePrivacy ? "" : user.lastname,
fullName:
`${user.firstname} ${user.settingsHideLastname && !options.ignorePrivacy ? "" : user.lastname}`.trim(),
publicId: user.publicId,
badges: user.badges,
};
}
return { return {
firstname: user.firstname, firstname: user.firstname,
lastname: user.settingsHideLastname && !options.ignorePrivacy ? "" : lastName.trim(), // Only take the first letter of each section of the last name lastname: user.settingsHideLastname && !options?.ignorePrivacy ? "" : lastName.trim(), // Only take the first letter of each section of the last name
fullName: fullName:
`${user.firstname} ${user.settingsHideLastname && !options.ignorePrivacy ? "" : lastName}`.trim(), `${user.firstname} ${user.settingsHideLastname && !options?.ignorePrivacy ? "" : lastName}`.trim(),
publicId: user.publicId, publicId: user.publicId,
badges: user.badges, badges: user.badges,
}; };

View File

@@ -5,33 +5,17 @@ enum EVENT_TYPE {
EVENT EVENT
} }
model EventAppointment {
id Int @id @default(autoincrement())
eventId Int
appointmentDate DateTime
presenterId String
// relations:
Users User[] @relation("EventAppointmentUser")
Participants Participant[]
Event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
Presenter User @relation(fields: [presenterId], references: [id])
}
model Participant { model Participant {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId String @map(name: "user_id") userId String @map(name: "user_id")
finisherMoodleCurseCompleted Boolean @default(false) finisherMoodleCurseCompleted Boolean @default(false)
attended Boolean @default(false)
appointmentCancelled Boolean @default(false)
eventAppointmentId Int?
enscriptionDate DateTime @default(now()) enscriptionDate DateTime @default(now())
statusLog Json[] @default([]) statusLog Json[] @default([])
eventId Int eventId Int
// relations: // relations:
User User @relation(fields: [userId], references: [id], onDelete: Cascade) User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) Event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
EventAppointment EventAppointment? @relation(fields: [eventAppointmentId], references: [id])
} }
model Event { model Event {
@@ -41,8 +25,6 @@ model Event {
description String description String
type EVENT_TYPE @default(EVENT) type EVENT_TYPE @default(EVENT)
discordRoleId String? @default("") discordRoleId String? @default("")
hasPresenceEvents Boolean @default(false)
maxParticipants Int? @default(0)
finisherMoodleCourseId String? @default("") finisherMoodleCourseId String? @default("")
finishedBadges BADGES[] @default([]) finishedBadges BADGES[] @default([])
requiredBadges BADGES[] @default([]) requiredBadges BADGES[] @default([])
@@ -51,7 +33,6 @@ model Event {
// relations: // relations:
Participants Participant[] Participants Participant[]
Appointments EventAppointment[]
} }
model File { model File {

View File

@@ -0,0 +1,23 @@
model Log {
id Int @id @default(autoincrement())
type LOG_TYPE
userId String?
browser String?
deviceId String?
ip String?
field String?
oldValue String?
newValue String?
timestamp DateTime @default(now())
User User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map(name: "logs")
}
enum LOG_TYPE {
LOGIN
PROFILE_CHANGE
REGISTER
ACCOUNT_DELETED
}

View File

@@ -0,0 +1,35 @@
/*
Warnings:
- You are about to drop the column `appointmentCancelled` on the `Participant` table. All the data in the column will be lost.
- You are about to drop the column `attended` on the `Participant` table. All the data in the column will be lost.
- You are about to drop the column `eventAppointmentId` on the `Participant` table. All the data in the column will be lost.
- You are about to drop the `EventAppointment` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `_EventAppointmentUser` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "EventAppointment" DROP CONSTRAINT "EventAppointment_eventId_fkey";
-- DropForeignKey
ALTER TABLE "EventAppointment" DROP CONSTRAINT "EventAppointment_presenterId_fkey";
-- DropForeignKey
ALTER TABLE "Participant" DROP CONSTRAINT "Participant_eventAppointmentId_fkey";
-- DropForeignKey
ALTER TABLE "_EventAppointmentUser" DROP CONSTRAINT "_EventAppointmentUser_A_fkey";
-- DropForeignKey
ALTER TABLE "_EventAppointmentUser" DROP CONSTRAINT "_EventAppointmentUser_B_fkey";
-- AlterTable
ALTER TABLE "Participant" DROP COLUMN "appointmentCancelled",
DROP COLUMN "attended",
DROP COLUMN "eventAppointmentId";
-- DropTable
DROP TABLE "EventAppointment";
-- DropTable
DROP TABLE "_EventAppointmentUser";

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `hasPresenceEvents` on the `Event` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Event" DROP COLUMN "hasPresenceEvents";

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `maxParticipants` on the `Event` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Event" DROP COLUMN "maxParticipants";

View File

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

View File

@@ -0,0 +1,16 @@
-- CreateEnum
CREATE TYPE "LOG_TYLE" AS ENUM ('LOGIN', 'PROFILE_CHANGE');
-- CreateTable
CREATE TABLE "logs" (
"id" SERIAL NOT NULL,
"type" "LOG_TYLE" NOT NULL,
"userId" TEXT,
"ip" TEXT,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "logs_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "logs" ADD CONSTRAINT "logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,16 @@
/*
Warnings:
- Changed the type of `type` on the `logs` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
*/
-- CreateEnum
CREATE TYPE "LOG_TYPE" AS ENUM ('LOGIN', 'PROFILE_CHANGE', 'REGISTER');
-- AlterTable
ALTER TABLE "logs" ADD COLUMN "browser" TEXT,
DROP COLUMN "type",
ADD COLUMN "type" "LOG_TYPE" NOT NULL;
-- DropEnum
DROP TYPE "LOG_TYLE";

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "logs" ADD COLUMN "deviceId" TEXT;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "logs" ADD COLUMN "field" TEXT,
ADD COLUMN "newValue" TEXT,
ADD COLUMN "oldValue" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "LOG_TYPE" ADD VALUE 'ACCOUNT_DELETED';

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Penalty" ADD COLUMN "addPermissionApplied" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "removePermissionApplied" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,13 @@
/*
Warnings:
- The primary key for the `FormerDiscordAccount` table will be changed. If it partially fails, the table could be left without primary key constraint.
*/
-- DropIndex
DROP INDEX "FormerDiscordAccount_discord_id_key";
-- AlterTable
ALTER TABLE "FormerDiscordAccount" DROP CONSTRAINT "FormerDiscordAccount_pkey",
ADD COLUMN "id" SERIAL NOT NULL,
ADD CONSTRAINT "FormerDiscordAccount_pkey" PRIMARY KEY ("id");

View File

@@ -10,10 +10,14 @@ model Penalty {
suspended Boolean @default(false) suspended Boolean @default(false)
// For Chronjob to know if permissions were already applied/removed
removePermissionApplied Boolean @default(false)
addPermissionApplied Boolean @default(false)
timestamp DateTime @default(now()) timestamp DateTime @default(now())
// relations: // relations:
User User @relation(fields: [userId], references: [id], onDelete: Cascade) User User @relation("User", fields: [userId], references: [id], onDelete: Cascade)
CreatedUser User? @relation("CreatedPenalties", fields: [createdUserId], references: [id]) CreatedUser User? @relation("CreatedPenalties", fields: [createdUserId], references: [id])
Report Report? @relation(fields: [reportId], references: [id]) Report Report? @relation(fields: [reportId], references: [id])
} }

View File

@@ -10,8 +10,8 @@ model Report {
reviewerUserId String? reviewerUserId String?
// relations: // relations:
Sender User? @relation("SentReports", fields: [senderUserId], references: [id]) Sender User? @relation("SentReports", fields: [senderUserId], references: [id])
Reported User @relation("ReceivedReports", fields: [reportedUserId], references: [id], onDelete: Cascade) Reported User @relation("ReceivedReports", fields: [reportedUserId], references: [id], onDelete: Cascade)
Reviewer User? @relation("ReviewedReports", fields: [reviewerUserId], references: [id]) Reviewer User? @relation("ReviewedReports", fields: [reviewerUserId], references: [id])
Penalty Penalty[] Penalties Penalty[]
} }

View File

@@ -54,23 +54,24 @@ model User {
emailVerificationExpiresAt DateTime? @map(name: "email_verification_expires_at") emailVerificationExpiresAt DateTime? @map(name: "email_verification_expires_at")
emailVerified Boolean @default(false) emailVerified Boolean @default(false)
image String? image String?
badges BADGES[] @default([]) badges BADGES[] @default([])
permissions PERMISSION[] @default([]) permissions PERMISSION[] @default([])
createdAt DateTime @default(now()) @map(name: "created_at") createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at") updatedAt DateTime @default(now()) @map(name: "updated_at")
isBanned Boolean @default(false) @map(name: "is_banned") isBanned Boolean @default(false) @map(name: "is_banned")
// Duplicate handling: // Duplicate handling:
canonicalUserId String? @map(name: "canonical_user_id") canonicalUserId String? @map(name: "canonical_user_id")
CanonicalUser User? @relation("CanonicalUser", fields: [canonicalUserId], references: [id]) CanonicalUser User? @relation("CanonicalUser", fields: [canonicalUserId], references: [id])
Duplicates User[] @relation("CanonicalUser") Duplicates User[] @relation("CanonicalUser")
duplicateDetectedAt DateTime? @map(name: "duplicate_detected_at") duplicateDetectedAt DateTime? @map(name: "duplicate_detected_at")
duplicateReason String? @map(name: "duplicate_reason") duplicateReason String? @map(name: "duplicate_reason")
isDeleted Boolean @default(false) @map(name: "is_deleted")
// relations: // relations:
oauthTokens OAuthToken[] oauthTokens OAuthToken[]
participants Participant[] participants Participant[]
EventAppointmentUser EventAppointment[] @relation("EventAppointmentUser")
EventAppointment EventAppointment[]
SentMessages ChatMessage[] @relation("SentMessages") SentMessages ChatMessage[] @relation("SentMessages")
ReceivedMessages ChatMessage[] @relation("ReceivedMessages") ReceivedMessages ChatMessage[] @relation("ReceivedMessages")
SentReports Report[] @relation("SentReports") SentReports Report[] @relation("SentReports")
@@ -81,10 +82,11 @@ model User {
ConnectedDispatcher ConnectedDispatcher[] ConnectedDispatcher ConnectedDispatcher[]
ConnectedAircraft ConnectedAircraft[] ConnectedAircraft ConnectedAircraft[]
PositionLog PositionLog[] PositionLog PositionLog[]
Penaltys Penalty[] Penaltys Penalty[] @relation("User")
CreatedPenalties Penalty[] @relation("CreatedPenalties") CreatedPenalties Penalty[] @relation("CreatedPenalties")
Bookings Booking[] Logs Log[]
Bookings Booking[]
DiscordAccount DiscordAccount? DiscordAccount DiscordAccount?
FormerDiscordAccounts FormerDiscordAccount[] FormerDiscordAccounts FormerDiscordAccount[]
@@ -92,14 +94,13 @@ model User {
} }
model FormerDiscordAccount { model FormerDiscordAccount {
discordId String @unique @map(name: "discord_id") id Int @id @default(autoincrement())
discordId String @map(name: "discord_id")
userId String @map(name: "user_id") userId String @map(name: "user_id")
removedAt DateTime @default(now()) @map(name: "removed_at") removedAt DateTime @default(now()) @map(name: "removed_at")
DiscordAccount DiscordAccount? @relation(fields: [discordId], references: [discordId], onDelete: SetNull) DiscordAccount DiscordAccount? @relation(fields: [discordId], references: [discordId], onDelete: SetNull)
User User @relation(fields: [userId], references: [id], onDelete: Cascade) User User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([discordId, userId])
} }
model DiscordAccount { model DiscordAccount {
@@ -117,9 +118,9 @@ model DiscordAccount {
updatedAt DateTime @default(now()) @map(name: "updated_at") updatedAt DateTime @default(now()) @map(name: "updated_at")
// Related User // Related User
userId String? @unique userId String? @unique
User User? @relation(fields: [userId], references: [id], onDelete: SetNull) User User? @relation(fields: [userId], references: [id], onDelete: SetNull)
formerDiscordAccount FormerDiscordAccount? formerDiscordAccount FormerDiscordAccount[]
@@map(name: "discord_accounts") @@map(name: "discord_accounts")
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { ReactNode, useState } from "react"; import { ReactNode, useState } from "react";
import { cn } from "../helper/cn"; import { cn } from "../helper/cn";
import { Button } from "./Button";
export const PenaltyDropdown = ({ export const PenaltyDropdown = ({
onClick, onClick,
@@ -79,10 +80,10 @@ export const PenaltyDropdown = ({
<option value="1y">1 Jahr</option> <option value="1y">1 Jahr</option>
</select> </select>
)} )}
<button <Button
className={cn("btn btn-square btn-soft tooltip tooltip-bottom w-full", btnClassName)} className={cn("btn btn-square btn-soft tooltip tooltip-bottom w-full", btnClassName)}
data-tip={btnTip} data-tip={btnTip}
onClick={() => { onClick={async () => {
let untilDate: Date | null = null; let untilDate: Date | null = null;
if (until !== "default") { if (until !== "default") {
const now = new Date(); const now = new Date();
@@ -124,11 +125,11 @@ export const PenaltyDropdown = ({
untilDate = null; untilDate = null;
} }
} }
onClick({ reason, until: untilDate }); await onClick({ reason, until: untilDate });
}} }}
> >
{Icon} {btnName} {Icon} {btnName}
</button> </Button>
</div> </div>
)} )}
</div> </div>

View File

@@ -3,6 +3,5 @@ import { Event, Participant } from "@repo/db";
export const eventCompleted = (event: Event, participant?: Participant) => { export const eventCompleted = (event: Event, participant?: Participant) => {
if (!participant) return false; if (!participant) return false;
if (event.finisherMoodleCourseId && !participant.finisherMoodleCurseCompleted) return false; if (event.finisherMoodleCourseId && !participant.finisherMoodleCurseCompleted) return false;
if (event.hasPresenceEvents && !participant.attended) return false;
return true; return true;
}; };

194
pnpm-lock.yaml generated
View File

@@ -11,17 +11,25 @@ overrides:
form-data@>=4.0.0 <4.0.4: '>=4.0.4' form-data@>=4.0.0 <4.0.4: '>=4.0.4'
js-yaml@>=4.0.0 <4.1.1: '>=4.1.1' js-yaml@>=4.0.0 <4.1.1: '>=4.1.1'
jws@<3.2.3: '>=3.2.3' jws@<3.2.3: '>=3.2.3'
lodash@>=4.0.0 <=4.17.22: '>=4.17.23'
mdast-util-to-hast@>=13.0.0 <13.2.1: '>=13.2.1' mdast-util-to-hast@>=13.0.0 <13.2.1: '>=13.2.1'
next-auth@<4.24.12: '>=4.24.12' next-auth@<4.24.12: '>=4.24.12'
next@>=15.0.0 <=15.4.4: '>=15.4.5' next@>=15.0.0 <=15.4.4: '>=15.4.5'
next@>=15.0.0-canary.0 <15.4.7: '>=15.4.7' next@>=15.0.0-canary.0 <15.4.7: '>=15.4.7'
next@>=15.4.0-canary.0 <15.4.8: '>=15.4.8' next@>=15.4.0-canary.0 <15.4.8: '>=15.4.8'
next@>=15.4.0-canary.0 <15.4.9: '>=15.4.9' next@>=15.4.0-canary.0 <15.4.9: '>=15.4.9'
next@>=15.6.0-canary.0 <16.1.5: '>=16.1.5'
next@>=16.0.0-beta.0 <16.1.5: '>=16.1.5'
next@>=16.1.0-canary.0 <16.1.5: '>=16.1.5'
nodemailer@<7.0.7: '>=7.0.7' nodemailer@<7.0.7: '>=7.0.7'
nodemailer@<=7.0.10: '>=7.0.11' nodemailer@<=7.0.10: '>=7.0.11'
playwright@<1.55.1: '>=1.55.1' playwright@<1.55.1: '>=1.55.1'
preact@>=10.26.5 <10.26.10: '>=10.26.10' preact@>=10.26.5 <10.26.10: '>=10.26.10'
qs@<6.14.1: '>=6.14.1' qs@<6.14.1: '>=6.14.1'
tar@<7.5.7: '>=7.5.7'
tar@<=7.5.2: '>=7.5.3'
tar@<=7.5.3: '>=7.5.4'
undici@<6.23.0: '>=6.23.0'
importers: importers:
@@ -130,7 +138,7 @@ importers:
version: 0.5.8(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.15.3(@types/dom-mediacapture-record@1.0.22)) version: 0.5.8(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.15.3(@types/dom-mediacapture-record@1.0.22))
'@next-auth/prisma-adapter': '@next-auth/prisma-adapter':
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(@prisma/client@6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3))(next-auth@4.24.13(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) version: 1.0.7(@prisma/client@6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3))(next-auth@4.24.13(next@16.1.1(@babel/core@7.27.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))
'@radix-ui/react-icons': '@radix-ui/react-icons':
specifier: ^1.3.2 specifier: ^1.3.2
version: 1.3.2(react@19.1.0) version: 1.3.2(react@19.1.0)
@@ -211,10 +219,10 @@ importers:
version: 0.525.0(react@19.1.0) version: 0.525.0(react@19.1.0)
next: next:
specifier: '>=15.4.9' specifier: '>=15.4.9'
version: 16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 16.1.1(@babel/core@7.27.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next-auth: next-auth:
specifier: '>=4.24.12' specifier: '>=4.24.12'
version: 4.24.13(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 4.24.13(next@16.1.1(@babel/core@7.27.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
npm: npm:
specifier: ^11.4.2 specifier: ^11.4.2
version: 11.4.2 version: 11.4.2
@@ -430,8 +438,8 @@ importers:
specifier: ^9.0.2 specifier: ^9.0.2
version: 9.0.2 version: 9.0.2
lodash: lodash:
specifier: ^4.17.21 specifier: '>=4.17.23'
version: 4.17.21 version: 4.17.23
lucide-react: lucide-react:
specifier: ^0.525.0 specifier: ^0.525.0
version: 0.525.0(react@19.1.0) version: 0.525.0(react@19.1.0)
@@ -588,6 +596,9 @@ importers:
specifier: ^3.2.4 specifier: ^3.2.4
version: 3.2.4(@prisma/client@6.12.0(prisma@6.12.0(typescript@5.9.3))(typescript@5.9.3))(prisma@6.12.0(typescript@5.9.3)) version: 3.2.4(@prisma/client@6.12.0(prisma@6.12.0(typescript@5.9.3))(typescript@5.9.3))(prisma@6.12.0(typescript@5.9.3))
devDependencies: devDependencies:
'@types/node':
specifier: ^25.1.0
version: 25.1.0
prisma: prisma:
specifier: ^6.12.0 specifier: ^6.12.0
version: 6.12.0(typescript@5.9.3) version: 6.12.0(typescript@5.9.3)
@@ -1109,89 +1120,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4': '@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4': '@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4': '@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4': '@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4': '@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4': '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4': '@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5': '@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5': '@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5': '@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5': '@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5': '@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5': '@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5': '@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5': '@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5': '@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -1315,24 +1342,28 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.1': '@next/swc-linux-arm64-musl@16.1.1':
resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==} resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.1.1': '@next/swc-linux-x64-gnu@16.1.1':
resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==} resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.1.1': '@next/swc-linux-x64-musl@16.1.1':
resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==} resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.1': '@next/swc-win32-arm64-msvc@16.1.1':
resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==} resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==}
@@ -1655,24 +1686,28 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.11': '@tailwindcss/oxide-linux-arm64-musl@4.1.11':
resolution: {integrity: sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==} resolution: {integrity: sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.11': '@tailwindcss/oxide-linux-x64-gnu@4.1.11':
resolution: {integrity: sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==} resolution: {integrity: sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.11': '@tailwindcss/oxide-linux-x64-musl@4.1.11':
resolution: {integrity: sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==} resolution: {integrity: sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.11': '@tailwindcss/oxide-wasm32-wasi@4.1.11':
resolution: {integrity: sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==} resolution: {integrity: sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==}
@@ -2161,6 +2196,9 @@ packages:
'@types/node@22.15.34': '@types/node@22.15.34':
resolution: {integrity: sha512-8Y6E5WUupYy1Dd0II32BsWAx5MWdcnRd8L84Oys3veg1YrYtNtzgO4CFhiBg6MDSjk7Ay36HYOnU7/tuOzIzcw==} resolution: {integrity: sha512-8Y6E5WUupYy1Dd0II32BsWAx5MWdcnRd8L84Oys3veg1YrYtNtzgO4CFhiBg6MDSjk7Ay36HYOnU7/tuOzIzcw==}
'@types/node@25.1.0':
resolution: {integrity: sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==}
'@types/nodemailer@6.4.17': '@types/nodemailer@6.4.17':
resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==} resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==}
@@ -2323,41 +2361,49 @@ packages:
resolution: {integrity: sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA==} resolution: {integrity: sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.9.2': '@unrs/resolver-binding-linux-arm64-musl@1.9.2':
resolution: {integrity: sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA==} resolution: {integrity: sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.9.2': '@unrs/resolver-binding-linux-ppc64-gnu@1.9.2':
resolution: {integrity: sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw==} resolution: {integrity: sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.9.2': '@unrs/resolver-binding-linux-riscv64-gnu@1.9.2':
resolution: {integrity: sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ==} resolution: {integrity: sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.9.2': '@unrs/resolver-binding-linux-riscv64-musl@1.9.2':
resolution: {integrity: sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg==} resolution: {integrity: sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.9.2': '@unrs/resolver-binding-linux-s390x-gnu@1.9.2':
resolution: {integrity: sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ==} resolution: {integrity: sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.9.2': '@unrs/resolver-binding-linux-x64-gnu@1.9.2':
resolution: {integrity: sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w==} resolution: {integrity: sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.9.2': '@unrs/resolver-binding-linux-x64-musl@1.9.2':
resolution: {integrity: sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw==} resolution: {integrity: sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.9.2': '@unrs/resolver-binding-wasm32-wasi@1.9.2':
resolution: {integrity: sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ==} resolution: {integrity: sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ==}
@@ -3810,24 +3856,28 @@ packages:
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1: lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.1: lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.1: lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1: lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
@@ -3899,8 +3949,8 @@ packages:
lodash.snakecase@4.1.1: lodash.snakecase@4.1.1:
resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
lodash@4.17.21: lodash@4.17.23:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
loglevel@1.9.1: loglevel@1.9.1:
resolution: {integrity: sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==} resolution: {integrity: sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==}
@@ -4144,15 +4194,10 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
minizlib@3.0.2: minizlib@3.1.0:
resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
mkdirp@3.0.1:
resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
engines: {node: '>=10'}
hasBin: true
moment@2.30.1: moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
@@ -4187,7 +4232,7 @@ packages:
resolution: {integrity: sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==} resolution: {integrity: sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==}
peerDependencies: peerDependencies:
'@auth/core': 0.34.3 '@auth/core': 0.34.3
next: '>=15.4.9' next: '>=16.1.5'
nodemailer: '>=7.0.11' nodemailer: '>=7.0.11'
react: ^17.0.2 || ^18 || ^19 react: ^17.0.2 || ^18 || ^19
react-dom: ^17.0.2 || ^18 || ^19 react-dom: ^17.0.2 || ^18 || ^19
@@ -5102,8 +5147,8 @@ packages:
resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==}
engines: {node: '>=6'} engines: {node: '>=6'}
tar@7.4.3: tar@7.5.7:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
tdigest@0.1.2: tdigest@0.1.2:
@@ -5290,9 +5335,12 @@ packages:
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici@6.21.3: undici-types@7.16.0:
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
engines: {node: '>=18.17'}
undici@7.19.2:
resolution: {integrity: sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg==}
engines: {node: '>=20.18.1'}
unified@11.0.5: unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -5603,7 +5651,7 @@ snapshots:
'@babel/parser': 7.27.7 '@babel/parser': 7.27.7
'@babel/template': 7.27.2 '@babel/template': 7.27.2
'@babel/types': 7.27.7 '@babel/types': 7.27.7
debug: 4.4.1 debug: 4.4.1(supports-color@5.5.0)
globals: 11.12.0 globals: 11.12.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -5645,7 +5693,7 @@ snapshots:
discord-api-types: 0.38.11 discord-api-types: 0.38.11
magic-bytes.js: 1.12.1 magic-bytes.js: 1.12.1
tslib: 2.8.1 tslib: 2.8.1
undici: 6.21.3 undici: 7.19.2
'@discordjs/util@1.1.1': {} '@discordjs/util@1.1.1': {}
@@ -5852,7 +5900,7 @@ snapshots:
'@eslint/eslintrc@3.3.1': '@eslint/eslintrc@3.3.1':
dependencies: dependencies:
ajv: 6.12.6 ajv: 6.12.6
debug: 4.4.1 debug: 4.4.1(supports-color@5.5.0)
espree: 10.4.0 espree: 10.4.0
globals: 14.0.0 globals: 14.0.0
ignore: 5.3.2 ignore: 5.3.2
@@ -6091,11 +6139,6 @@ snapshots:
'@prisma/client': 6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3) '@prisma/client': 6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3)
next-auth: 4.24.13(next@16.1.1(@babel/core@7.27.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next-auth: 4.24.13(next@16.1.1(@babel/core@7.27.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@next-auth/prisma-adapter@1.0.7(@prisma/client@6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3))(next-auth@4.24.13(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))':
dependencies:
'@prisma/client': 6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3)
next-auth: 4.24.13(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@next/env@16.1.1': {} '@next/env@16.1.1': {}
'@next/eslint-plugin-next@15.4.2': '@next/eslint-plugin-next@15.4.2':
@@ -6347,7 +6390,7 @@ snapshots:
'@sapphire/shapeshift@4.0.0': '@sapphire/shapeshift@4.0.0':
dependencies: dependencies:
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
lodash: 4.17.21 lodash: 4.17.23
'@sapphire/snowflake@3.5.3': {} '@sapphire/snowflake@3.5.3': {}
@@ -6422,7 +6465,7 @@ snapshots:
'@tailwindcss/oxide@4.1.11': '@tailwindcss/oxide@4.1.11':
dependencies: dependencies:
detect-libc: 2.0.4 detect-libc: 2.0.4
tar: 7.4.3 tar: 7.5.7
optionalDependencies: optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.1.11 '@tailwindcss/oxide-android-arm64': 4.1.11
'@tailwindcss/oxide-darwin-arm64': 4.1.11 '@tailwindcss/oxide-darwin-arm64': 4.1.11
@@ -7678,9 +7721,13 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/node@25.1.0':
dependencies:
undici-types: 7.16.0
'@types/nodemailer@6.4.17': '@types/nodemailer@6.4.17':
dependencies: dependencies:
'@types/node': 22.15.29 '@types/node': 22.15.34
'@types/parse-json@4.0.2': {} '@types/parse-json@4.0.2': {}
@@ -7749,7 +7796,7 @@ snapshots:
'@typescript-eslint/types': 8.37.0 '@typescript-eslint/types': 8.37.0
'@typescript-eslint/typescript-estree': 8.37.0(typescript@5.8.3) '@typescript-eslint/typescript-estree': 8.37.0(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.37.0 '@typescript-eslint/visitor-keys': 8.37.0
debug: 4.4.1 debug: 4.4.1(supports-color@5.5.0)
eslint: 9.31.0(jiti@2.4.2) eslint: 9.31.0(jiti@2.4.2)
typescript: 5.8.3 typescript: 5.8.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -7759,7 +7806,7 @@ snapshots:
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3) '@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3)
'@typescript-eslint/types': 8.37.0 '@typescript-eslint/types': 8.37.0
debug: 4.4.1 debug: 4.4.1(supports-color@5.5.0)
typescript: 5.8.3 typescript: 5.8.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -7778,7 +7825,7 @@ snapshots:
'@typescript-eslint/types': 8.37.0 '@typescript-eslint/types': 8.37.0
'@typescript-eslint/typescript-estree': 8.37.0(typescript@5.8.3) '@typescript-eslint/typescript-estree': 8.37.0(typescript@5.8.3)
'@typescript-eslint/utils': 8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) '@typescript-eslint/utils': 8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)
debug: 4.4.1 debug: 4.4.1(supports-color@5.5.0)
eslint: 9.31.0(jiti@2.4.2) eslint: 9.31.0(jiti@2.4.2)
ts-api-utils: 2.1.0(typescript@5.8.3) ts-api-utils: 2.1.0(typescript@5.8.3)
typescript: 5.8.3 typescript: 5.8.3
@@ -7793,7 +7840,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3) '@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3)
'@typescript-eslint/types': 8.37.0 '@typescript-eslint/types': 8.37.0
'@typescript-eslint/visitor-keys': 8.37.0 '@typescript-eslint/visitor-keys': 8.37.0
debug: 4.4.1 debug: 4.4.1(supports-color@5.5.0)
fast-glob: 3.3.3 fast-glob: 3.3.3
is-glob: 4.0.3 is-glob: 4.0.3
minimatch: 9.0.5 minimatch: 9.0.5
@@ -8324,7 +8371,7 @@ snapshots:
concurrently@9.2.0: concurrently@9.2.0:
dependencies: dependencies:
chalk: 4.1.2 chalk: 4.1.2
lodash: 4.17.21 lodash: 4.17.23
rxjs: 7.8.2 rxjs: 7.8.2
shell-quote: 1.8.3 shell-quote: 1.8.3
supports-color: 8.1.1 supports-color: 8.1.1
@@ -8424,10 +8471,6 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
debug@4.4.1:
dependencies:
ms: 2.1.3
debug@4.4.1(supports-color@5.5.0): debug@4.4.1(supports-color@5.5.0):
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@@ -8491,7 +8534,7 @@ snapshots:
lodash.snakecase: 4.1.1 lodash.snakecase: 4.1.1
magic-bytes.js: 1.12.1 magic-bytes.js: 1.12.1
tslib: 2.8.1 tslib: 2.8.1
undici: 6.21.3 undici: 7.19.2
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
@@ -8770,7 +8813,7 @@ snapshots:
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.31.0(jiti@2.4.2)): eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.31.0(jiti@2.4.2)):
dependencies: dependencies:
'@nolyfill/is-core-module': 1.0.39 '@nolyfill/is-core-module': 1.0.39
debug: 4.4.1 debug: 4.4.1(supports-color@5.5.0)
eslint: 9.31.0(jiti@2.4.2) eslint: 9.31.0(jiti@2.4.2)
get-tsconfig: 4.10.1 get-tsconfig: 4.10.1
is-bun-module: 2.0.0 is-bun-module: 2.0.0
@@ -9754,7 +9797,7 @@ snapshots:
lodash.snakecase@4.1.1: {} lodash.snakecase@4.1.1: {}
lodash@4.17.21: {} lodash@4.17.23: {}
loglevel@1.9.1: {} loglevel@1.9.1: {}
@@ -10184,12 +10227,10 @@ snapshots:
minipass@7.1.2: {} minipass@7.1.2: {}
minizlib@3.0.2: minizlib@3.1.0:
dependencies: dependencies:
minipass: 7.1.2 minipass: 7.1.2
mkdirp@3.0.1: {}
moment@2.30.1: {} moment@2.30.1: {}
ms@2.1.3: {} ms@2.1.3: {}
@@ -10223,23 +10264,6 @@ snapshots:
optionalDependencies: optionalDependencies:
nodemailer: 7.0.11 nodemailer: 7.0.11
next-auth@4.24.13(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@babel/runtime': 7.27.6
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
next: 16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.28.2
preact-render-to-string: 5.2.6(preact@10.28.2)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
uuid: 8.3.2
optionalDependencies:
nodemailer: 7.0.11
next-remove-imports@1.0.12(webpack@5.99.9): next-remove-imports@1.0.12(webpack@5.99.9):
dependencies: dependencies:
'@babel/core': 7.27.7 '@babel/core': 7.27.7
@@ -10275,32 +10299,6 @@ snapshots:
- '@babel/core' - '@babel/core'
- babel-plugin-macros - babel-plugin-macros
next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@next/env': 16.1.1
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.9.14
caniuse-lite: 1.0.30001726
postcss: 8.4.31
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
styled-jsx: 5.1.6(react@19.1.0)
optionalDependencies:
'@next/swc-darwin-arm64': 16.1.1
'@next/swc-darwin-x64': 16.1.1
'@next/swc-linux-arm64-gnu': 16.1.1
'@next/swc-linux-arm64-musl': 16.1.1
'@next/swc-linux-x64-gnu': 16.1.1
'@next/swc-linux-x64-musl': 16.1.1
'@next/swc-win32-arm64-msvc': 16.1.1
'@next/swc-win32-x64-msvc': 16.1.1
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.52.0
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
node-cron@4.2.1: {} node-cron@4.2.1: {}
node-releases@2.0.19: {} node-releases@2.0.19: {}
@@ -10616,7 +10614,7 @@ snapshots:
dayjs: 1.11.19 dayjs: 1.11.19
element-resize-detector: 1.2.4 element-resize-detector: 1.2.4
interactjs: 1.10.27 interactjs: 1.10.27
lodash: 4.17.21 lodash: 4.17.23
memoize-one: 6.0.0 memoize-one: 6.0.0
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
@@ -11238,11 +11236,6 @@ snapshots:
optionalDependencies: optionalDependencies:
'@babel/core': 7.27.7 '@babel/core': 7.27.7
styled-jsx@5.1.6(react@19.1.0):
dependencies:
client-only: 0.0.1
react: 19.1.0
stylis@4.2.0: {} stylis@4.2.0: {}
supports-color@5.5.0: supports-color@5.5.0:
@@ -11271,13 +11264,12 @@ snapshots:
tapable@2.2.2: {} tapable@2.2.2: {}
tar@7.4.3: tar@7.5.7:
dependencies: dependencies:
'@isaacs/fs-minipass': 4.0.1 '@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0 chownr: 3.0.0
minipass: 7.1.2 minipass: 7.1.2
minizlib: 3.0.2 minizlib: 3.1.0
mkdirp: 3.0.1
yallist: 5.0.0 yallist: 5.0.0
tdigest@0.1.2: tdigest@0.1.2:
@@ -11463,7 +11455,9 @@ snapshots:
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici@6.21.3: {} undici-types@7.16.0: {}
undici@7.19.2: {}
unified@11.0.5: unified@11.0.5:
dependencies: dependencies:
@@ -11704,7 +11698,7 @@ snapshots:
'@prisma/client': 6.12.0(prisma@6.12.0(typescript@5.9.3))(typescript@5.9.3) '@prisma/client': 6.12.0(prisma@6.12.0(typescript@5.9.3))(typescript@5.9.3)
'@prisma/generator-helper': 6.8.2 '@prisma/generator-helper': 6.8.2
code-block-writer: 12.0.0 code-block-writer: 12.0.0
lodash: 4.17.21 lodash: 4.17.23
prisma: 6.12.0(typescript@5.9.3) prisma: 6.12.0(typescript@5.9.3)
zod: 3.25.49 zod: 3.25.49

View File

@@ -18,14 +18,22 @@ overrides:
form-data@>=4.0.0 <4.0.4: '>=4.0.4' form-data@>=4.0.0 <4.0.4: '>=4.0.4'
js-yaml@>=4.0.0 <4.1.1: '>=4.1.1' js-yaml@>=4.0.0 <4.1.1: '>=4.1.1'
jws@<3.2.3: '>=3.2.3' jws@<3.2.3: '>=3.2.3'
lodash@>=4.0.0 <=4.17.22: '>=4.17.23'
mdast-util-to-hast@>=13.0.0 <13.2.1: '>=13.2.1' mdast-util-to-hast@>=13.0.0 <13.2.1: '>=13.2.1'
next-auth@<4.24.12: '>=4.24.12' next-auth@<4.24.12: '>=4.24.12'
next@>=15.0.0 <=15.4.4: '>=15.4.5' next@>=15.0.0 <=15.4.4: '>=15.4.5'
next@>=15.0.0-canary.0 <15.4.7: '>=15.4.7' next@>=15.0.0-canary.0 <15.4.7: '>=15.4.7'
next@>=15.4.0-canary.0 <15.4.8: '>=15.4.8' next@>=15.4.0-canary.0 <15.4.8: '>=15.4.8'
next@>=15.4.0-canary.0 <15.4.9: '>=15.4.9' next@>=15.4.0-canary.0 <15.4.9: '>=15.4.9'
next@>=15.6.0-canary.0 <16.1.5: '>=16.1.5'
next@>=16.0.0-beta.0 <16.1.5: '>=16.1.5'
next@>=16.1.0-canary.0 <16.1.5: '>=16.1.5'
nodemailer@<7.0.7: '>=7.0.7' nodemailer@<7.0.7: '>=7.0.7'
nodemailer@<=7.0.10: '>=7.0.11' nodemailer@<=7.0.10: '>=7.0.11'
playwright@<1.55.1: '>=1.55.1' playwright@<1.55.1: '>=1.55.1'
preact@>=10.26.5 <10.26.10: '>=10.26.10' preact@>=10.26.5 <10.26.10: '>=10.26.10'
qs@<6.14.1: '>=6.14.1' qs@<6.14.1: '>=6.14.1'
tar@<7.5.7: '>=7.5.7'
tar@<=7.5.2: '>=7.5.3'
tar@<=7.5.3: '>=7.5.4'
undici@<6.23.0: '>=6.23.0'