diff --git a/.vscode/extensions.json b/.vscode/extensions.json index ee4d8ed1..d9fa4320 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,5 @@ { "recommendations": [ - "EthanSK.restore-terminals", "dbaeumer.vscode-eslint", - "VisualStudioExptTeam.vscodeintellicode" ] } diff --git a/apps/dispatch/app/_components/map/AircraftMarker.tsx b/apps/dispatch/app/_components/map/AircraftMarker.tsx index 6d635675..9c0adaf3 100644 --- a/apps/dispatch/app/_components/map/AircraftMarker.tsx +++ b/apps/dispatch/app/_components/map/AircraftMarker.tsx @@ -2,7 +2,7 @@ import { Marker, Polyline, useMap } from "react-leaflet"; import { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet"; import { useMapStore } from "_store/mapStore"; import { Fragment, useCallback, useEffect, useRef, useState, useMemo } from "react"; -import { cn } from "@repo/shared-components"; +import { checkSimulatorConnected, cn } from "@repo/shared-components"; import { ChevronsRightLeft, House, MessageSquareText, Minimize2 } from "lucide-react"; import { SmartPopup, calculateAnchor, useSmartPopup } from "_components/SmartPopup"; import FMSStatusHistory, { @@ -396,11 +396,27 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: }; export const AircraftLayer = () => { - const { data: aircrafts } = useQuery({ - queryKey: ["aircrafts-map"], - queryFn: () => getConnectedAircraftsAPI(), - refetchInterval: 10_000, - }); + const [aircrafts, setAircrafts] = useState<(ConnectedAircraft & { Station: Station })[]>([]); + + useEffect(() => { + const fetchAircrafts = async () => { + try { + const res = await fetch("/api/aircrafts"); + if (!res.ok) { + throw new Error("Failed to fetch aircrafts"); + } + const data: (ConnectedAircraft & { Station: Station })[] = await res.json(); + setAircrafts(data.filter((a) => checkSimulatorConnected(a))); + } catch (error) { + console.error("Failed to fetch aircrafts:", error); + } + }; + + fetchAircrafts(); + const interval = setInterval(fetchAircrafts, 10_000); + + return () => clearInterval(interval); + }, []); const { setMap } = useMapStore((state) => state); const map = useMap(); const { diff --git a/apps/hub/app/(app)/_components/RecentFlights.tsx b/apps/hub/app/(app)/_components/RecentFlights.tsx index 9999c414..d9be2342 100644 --- a/apps/hub/app/(app)/_components/RecentFlights.tsx +++ b/apps/hub/app/(app)/_components/RecentFlights.tsx @@ -1,5 +1,5 @@ "use client"; -import { Mission, MissionAlertLog, MissionLog, Station } from "@repo/db"; +import { Mission, MissionAlertLog, MissionLog, Prisma, Station } from "@repo/db"; import { ColumnDef } from "@tanstack/react-table"; import { PaginatedTable } from "_components/PaginatedTable"; import { ArrowRight, NotebookText } from "lucide-react"; @@ -12,20 +12,22 @@ export const RecentFlights = () => {

- Logbook + Logbook - Zum vollständigen Logbook + Zum vollständigen Logbook

+ ({ + User: { id: session.data?.user.id }, + Mission: { + state: { in: ["finished", "archived"] }, + }, + }) as Prisma.MissionOnStationUsersWhereInput + } include={{ Station: true, User: true, diff --git a/apps/hub/app/(app)/admin/changelog/page.tsx b/apps/hub/app/(app)/admin/changelog/page.tsx index b768e357..f24bc0e3 100644 --- a/apps/hub/app/(app)/admin/changelog/page.tsx +++ b/apps/hub/app/(app)/admin/changelog/page.tsx @@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react"; import { PaginatedTable } from "../../../_components/PaginatedTable"; import Link from "next/link"; import { ColumnDef } from "@tanstack/react-table"; -import { Keyword } from "@repo/db"; +import { Keyword, Prisma } from "@repo/db"; export default () => { return ( @@ -12,7 +12,15 @@ export default () => { stickyHeaders initialOrderBy={[{ id: "title", desc: true }]} prismaModel="changelog" - searchFields={["title"]} + showSearch + getFilter={(search) => + ({ + OR: [ + { title: { contains: search, mode: "insensitive" } }, + { description: { contains: search, mode: "insensitive" } }, + ], + }) as Prisma.ChangelogWhereInput + } columns={ [ { diff --git a/apps/hub/app/(app)/admin/event/_components/AppointmentModal.tsx b/apps/hub/app/(app)/admin/event/_components/AppointmentModal.tsx index 2a307895..f2e69dcf 100644 --- a/apps/hub/app/(app)/admin/event/_components/AppointmentModal.tsx +++ b/apps/hub/app/(app)/admin/event/_components/AppointmentModal.tsx @@ -1,4 +1,4 @@ -import { Event, Participant } from "@repo/db"; +import { Event, Participant, Prisma } from "@repo/db"; import { EventAppointmentOptionalDefaults, InputJsonValueType } from "@repo/db/zod"; import { ColumnDef } from "@tanstack/react-table"; import { useSession } from "next-auth/react"; @@ -64,7 +64,7 @@ export const AppointmentModal = ({
[] } prismaModel={"participant"} - filter={{ - eventAppointmentId: appointmentForm.watch("id"), - }} + getFilter={() => + ({ + eventAppointmentId: appointmentForm.watch("id")!, + }) as Prisma.ParticipantWhereInput + } include={{ User: true }} leftOfPagination={
diff --git a/apps/hub/app/(app)/admin/event/_components/Form.tsx b/apps/hub/app/(app)/admin/event/_components/Form.tsx index aba58bb9..0059d196 100644 --- a/apps/hub/app/(app)/admin/event/_components/Form.tsx +++ b/apps/hub/app/(app)/admin/event/_components/Form.tsx @@ -1,6 +1,6 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, User } from "@repo/db"; +import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, Prisma, User } from "@repo/db"; import { EventAppointmentOptionalDefaults, EventAppointmentOptionalDefaultsSchema, @@ -159,9 +159,11 @@ export const Form = ({ event }: { event?: Event }) => { + ({ + eventId: event?.id, + }) as Prisma.EventAppointmentWhereInput + } include={{ Presenter: true, Participants: true, @@ -250,92 +252,107 @@ export const Form = ({ event }: { event?: Event }) => { {!form.watch("hasPresenceEvents") ? (
- - Teilnehmer - - } - searchFields={["User.firstname", "User.lastname", "User.publicId"]} - ref={appointmentsTableRef} - prismaModel={"participant"} - filter={{ - eventId: event?.id, - }} - include={{ - User: true, - }} - columns={ - [ - { - header: "Vorname", - accessorKey: "User.firstname", - cell: ({ row }) => { - return ( - - {row.original.User.firstname} - - ); - }, - }, - { - header: "Nachname", - accessorKey: "User.lastname", - cell: ({ row }) => { - return ( - - {row.original.User.lastname} - - ); - }, - }, - { - header: "VAR-Nummer", - accessorKey: "User.publicId", - cell: ({ row }) => { - return ( - - {row.original.User.publicId} - - ); - }, - }, - { - header: "Moodle Kurs abgeschlossen", - accessorKey: "finisherMoodleCurseCompleted", - }, - { - header: "Aktionen", - cell: ({ row }) => { - return ( -
- -
- ); + {row.original.User.firstname} + + ); + }, }, - }, - ] as ColumnDef[] - } - /> + { + header: "Nachname", + accessorKey: "User.lastname", + cell: ({ row }) => { + return ( + + {row.original.User.lastname} + + ); + }, + }, + { + header: "VAR-Nummer", + accessorKey: "User.publicId", + cell: ({ row }) => { + return ( + + {row.original.User.publicId} + + ); + }, + }, + { + header: "Moodle Kurs abgeschlossen", + accessorKey: "finisherMoodleCurseCompleted", + }, + { + header: "Aktionen", + cell: ({ row }) => { + return ( +
+ +
+ ); + }, + }, + ] as ColumnDef[] + } + /> + }
) : null} diff --git a/apps/hub/app/(app)/admin/heliport/page.tsx b/apps/hub/app/(app)/admin/heliport/page.tsx index 4d805e6c..08717fe5 100644 --- a/apps/hub/app/(app)/admin/heliport/page.tsx +++ b/apps/hub/app/(app)/admin/heliport/page.tsx @@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react"; import { PaginatedTable } from "../../../_components/PaginatedTable"; import Link from "next/link"; import { ColumnDef } from "@tanstack/react-table"; -import { Heliport } from "@repo/db"; +import { Heliport, Prisma } from "@repo/db"; const page = () => { return ( @@ -11,7 +11,17 @@ const page = () => { + ({ + OR: [ + { siteName: { contains: searchTerm, mode: "insensitive" } }, + { info: { contains: searchTerm, mode: "insensitive" } }, + { hospital: { contains: searchTerm, mode: "insensitive" } }, + { designator: { contains: searchTerm, mode: "insensitive" } }, + ], + }) as Prisma.HeliportWhereInput + } + showSearch columns={ [ { diff --git a/apps/hub/app/(app)/admin/keyword/page.tsx b/apps/hub/app/(app)/admin/keyword/page.tsx index 73a75e0c..8d673a21 100644 --- a/apps/hub/app/(app)/admin/keyword/page.tsx +++ b/apps/hub/app/(app)/admin/keyword/page.tsx @@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react"; import { PaginatedTable } from "../../../_components/PaginatedTable"; import Link from "next/link"; import { ColumnDef } from "@tanstack/react-table"; -import { Keyword } from "@repo/db"; +import { Keyword, Prisma } from "@repo/db"; export default () => { return ( @@ -12,7 +12,16 @@ export default () => { stickyHeaders initialOrderBy={[{ id: "category", desc: true }]} prismaModel="keyword" - searchFields={["name", "abreviation", "description"]} + showSearch + getFilter={(searchTerm) => + ({ + OR: [ + { name: { contains: searchTerm, mode: "insensitive" } }, + { abreviation: { contains: searchTerm, mode: "insensitive" } }, + { category: { contains: searchTerm, mode: "insensitive" } }, + ], + }) as Prisma.KeywordWhereInput + } columns={ [ { @@ -41,11 +50,11 @@ export default () => { } leftOfSearch={ - Stichwörter + Stichwörter } rightOfSearch={ -

+

diff --git a/apps/hub/app/(app)/admin/report/_components/form.tsx b/apps/hub/app/(app)/admin/report/_components/form.tsx index 407c02e0..38e3ca81 100644 --- a/apps/hub/app/(app)/admin/report/_components/form.tsx +++ b/apps/hub/app/(app)/admin/report/_components/form.tsx @@ -2,7 +2,7 @@ import { penaltyColumns as penaltyColumns } from "(app)/admin/penalty/columns"; import { editReport } from "(app)/admin/report/actions"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Report as IReport, User } from "@repo/db"; +import { Report as IReport, Prisma, User } from "@repo/db"; import { ReportSchema, Report as IReportZod } from "@repo/db/zod"; import { PaginatedTable } from "_components/PaginatedTable"; import { Button } from "_components/ui/Button"; @@ -149,9 +149,11 @@ export const ReportPenalties = ({ CreatedUser: true, Report: true, }} - filter={{ - reportId: report.id, - }} + getFilter={() => + ({ + reportId: report.id, + }) as Prisma.PenaltyWhereInput + } columns={penaltyColumns} />

diff --git a/apps/hub/app/(app)/admin/station/_components/Form.tsx b/apps/hub/app/(app)/admin/station/_components/Form.tsx index 207fae6a..c9276919 100644 --- a/apps/hub/app/(app)/admin/station/_components/Form.tsx +++ b/apps/hub/app/(app)/admin/station/_components/Form.tsx @@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { StationOptionalDefaultsSchema } from "@repo/db/zod"; import { useForm } from "react-hook-form"; -import { BosUse, ConnectedAircraft, Country, Station, User } from "@repo/db"; +import { BosUse, ConnectedAircraft, Country, Prisma, Station, User } from "@repo/db"; import { FileText, LocateIcon, PlaneIcon, UserIcon } from "lucide-react"; import { Input } from "../../../../_components/ui/Input"; import { deleteStation, upsertStation } from "../action"; @@ -198,10 +198,17 @@ export const StationForm = ({ station }: { station?: Station }) => { Verbundene Piloten
} - filter={{ - stationId: station?.id, - }} - searchFields={["User.firstname", "User.lastname", "User.publicId"]} + getFilter={(searchField) => + ({ + stationId: station?.id, + OR: [ + { User: { firstname: { contains: searchField, mode: "insensitive" } } }, + { User: { lastname: { contains: searchField, mode: "insensitive" } } }, + { User: { publicId: { contains: searchField, mode: "insensitive" } } }, + ], + }) as Prisma.ConnectedAircraftWhereInput + } + showSearch prismaModel={"connectedAircraft"} include={{ Station: true, User: true }} columns={ diff --git a/apps/hub/app/(app)/admin/station/page.tsx b/apps/hub/app/(app)/admin/station/page.tsx index b8103b06..b4d8b3d9 100644 --- a/apps/hub/app/(app)/admin/station/page.tsx +++ b/apps/hub/app/(app)/admin/station/page.tsx @@ -3,14 +3,22 @@ import { DatabaseBackupIcon } from "lucide-react"; import { PaginatedTable } from "../../../_components/PaginatedTable"; import Link from "next/link"; import { ColumnDef } from "@tanstack/react-table"; -import { Station } from "@repo/db"; +import { Prisma, Station } from "@repo/db"; const page = () => { return ( <> + ({ + OR: [ + { bosCallsign: { contains: searchField, mode: "insensitive" } }, + { operator: { contains: searchField, mode: "insensitive" } }, + ], + }) as Prisma.StationWhereInput + } stickyHeaders columns={ [ @@ -44,11 +52,11 @@ const page = () => { } leftOfSearch={ - Stationen + Stationen } rightOfSearch={ -

+

diff --git a/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx b/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx index 8772a1a4..1b56101d 100644 --- a/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx +++ b/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx @@ -8,6 +8,7 @@ import { DiscordAccount, Penalty, PERMISSION, + Prisma, Station, User, } from "@repo/db"; @@ -76,6 +77,21 @@ export const ProfileForm: React.FC = ({ user }: ProfileFormPro className="card-body" onSubmit={form.handleSubmit(async (values) => { if (!values.id) return; + if (values.id === session.data?.user.id && values.permissions !== user.permissions) { + toast.error("Du kannst deine eigenen Berechtigungen nicht ändern."); + return; + } + if (values.permissions?.some((perm) => !session.data?.user.permissions.includes(perm))) { + toast.error("Du kannst Berechtigungen nicht hinzufügen, die du selbst nicht besitzt."); + return; + } + const removedPermissions = + user.permissions?.filter((perm) => !values.permissions?.includes(perm)) || []; + if (removedPermissions.some((perm) => !session.data?.user.permissions.includes(perm))) { + toast.error("Du kannst Berechtigungen nicht entfernen, die du selbst nicht besitzt."); + return; + } + await editUser(values.id, { ...values, email: values.email.toLowerCase(), @@ -266,10 +282,18 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us + ({ + userId: user.id, + }) as Prisma.ConnectedDispatcherWhereInput + } prismaModel={"connectedDispatcher"} + initialOrderBy={[ + { + id: "loginTime", + desc: true, + }, + ]} columns={ [ { @@ -328,11 +352,19 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us + ({ + userId: user.id, + }) as Prisma.ConnectedAircraftWhereInput + } prismaModel={"connectedAircraft"} include={{ Station: true }} + initialOrderBy={[ + { + id: "loginTime", + desc: true, + }, + ]} columns={ [ { @@ -478,9 +510,7 @@ export const UserPenalties = ({ user }: { user: User }) => { CreatedUser: true, Report: true, }} - filter={{ - userId: user.id, - }} + getFilter={() => ({ userId: user.id }) as Prisma.PenaltyWhereInput} columns={penaltyColumns} /> @@ -502,9 +532,17 @@ export const UserReports = ({ user }: { user: User }) => { + ({ + reportedUserId: user.id, + }) as Prisma.ReportWhereInput + } + initialOrderBy={[ + { + id: "timestamp", + desc: true, + }, + ]} include={{ Sender: true, Reported: true, @@ -517,7 +555,7 @@ export const UserReports = ({ user }: { user: User }) => { interface AdminFormProps { discordAccount?: DiscordAccount; - user: User; + user: User & { CanonicalUser?: User | null; Duplicates?: User[] | null }; dispoTime: { hours: number; minutes: number; @@ -639,7 +677,57 @@ export const AdminForm = ({ )} + {session?.user.permissions.includes("ADMIN_USER_ADVANCED") && ( +

+ + + +
+ )} + {(user.CanonicalUser || (user.Duplicates && user.Duplicates.length > 0)) && ( +
+
+ +
+ {user.CanonicalUser && ( +
+

Als Duplikat markiert

+

+ Dieser Account wurde als Duplikat von{" "} + + {user.CanonicalUser.firstname} {user.CanonicalUser.lastname} ( + {user.CanonicalUser.publicId}) + {" "} + markiert. +

+
+ )} + {user.Duplicates && user.Duplicates.length > 0 && ( +
+

Duplikate erkannt

+

Folgende Accounts wurden als Duplikate dieses Accounts markiert:

+
    + {user.Duplicates.map((duplicate) => ( +
  • + + {duplicate.firstname} {duplicate.lastname} ({duplicate.publicId}) + +
  • + ))} +
+
+ )} +
+
+

{user.duplicateReason || "Keine Grund angegeben"}

+
+ )} {(!!openBans.length || !!openTimebans.length) && (
@@ -667,6 +755,7 @@ export const AdminForm = ({

)} +

Aktivität

diff --git a/apps/hub/app/(app)/admin/user/[id]/duplicate/_components/DuplicateForm.tsx b/apps/hub/app/(app)/admin/user/[id]/duplicate/_components/DuplicateForm.tsx new file mode 100644 index 00000000..60a423f9 --- /dev/null +++ b/apps/hub/app/(app)/admin/user/[id]/duplicate/_components/DuplicateForm.tsx @@ -0,0 +1,109 @@ +"use client"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useQuery } from "@tanstack/react-query"; +import { getUser, markDuplicate } from "(app)/admin/user/action"; +import { Button } from "@repo/shared-components"; +import { Select } from "_components/ui/Select"; +import toast from "react-hot-toast"; +import { TriangleAlert } from "lucide-react"; +import { useState } from "react"; + +const DuplicateSchema = z.object({ + canonicalUserId: z.string().min(1, "Bitte Nutzer auswählen"), + reason: z.string().max(500).optional(), +}); + +export const DuplicateForm = ({ duplicateUserId }: { duplicateUserId: string }) => { + const form = useForm>({ + resolver: zodResolver(DuplicateSchema), + defaultValues: { canonicalUserId: "", reason: "" }, + }); + + const [search, setSearch] = useState(""); + const { data: users } = useQuery({ + queryKey: ["duplicate-search"], + queryFn: async () => + getUser({ + OR: [ + { firstname: { contains: search, mode: "insensitive" } }, + { lastname: { contains: search, mode: "insensitive" } }, + { publicId: { contains: search, mode: "insensitive" } }, + { email: { contains: search, mode: "insensitive" } }, + ], + }), + enabled: search.length > 0, + refetchOnWindowFocus: false, + }); + + return ( +
{ + try { + // find selected canonical user by id to obtain publicId + const canonical = (users || []).find((u) => u.id === values.canonicalUserId); + if (!canonical) { + toast.error("Bitte wähle einen Original-Account aus."); + return; + } + await markDuplicate({ + duplicateUserId, + canonicalPublicId: canonical.publicId, + reason: values.reason, + }); + toast.success("Duplikat verknüpft und Nutzer gesperrt."); + } catch (e: unknown) { + const message = + typeof e === "object" && e && "message" in e + ? (e as { message?: string }).message || "Fehler beim Verknüpfen" + : "Fehler beim Verknüpfen"; + toast.error(message); + } + })} + > +
+
+

+ Duplikat markieren & sperren +

+ + +
+
+
+
+
+ +
+
+
+
+ ); +}; diff --git a/apps/hub/app/(app)/admin/user/[id]/duplicate/page.tsx b/apps/hub/app/(app)/admin/user/[id]/duplicate/page.tsx new file mode 100644 index 00000000..f902bc71 --- /dev/null +++ b/apps/hub/app/(app)/admin/user/[id]/duplicate/page.tsx @@ -0,0 +1,37 @@ +import { prisma } from "@repo/db"; +import { DuplicateForm } from "./_components/DuplicateForm"; +import { PersonIcon } from "@radix-ui/react-icons"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const user = await prisma.user.findUnique({ + where: { id }, + select: { id: true, firstname: true, lastname: true, publicId: true }, + }); + if (!user) { + return ( +
+
Nutzer nicht gefunden
+
+ ); + } + return ( + <> +
+
+ + + Zurück zum Nutzer + +
+

+ Duplikat für {user.firstname}{" "} + {user.lastname} #{user.publicId} +

+
+ + + ); +} diff --git a/apps/hub/app/(app)/admin/user/[id]/page.tsx b/apps/hub/app/(app)/admin/user/[id]/page.tsx index 1c74253d..8998a5a7 100644 --- a/apps/hub/app/(app)/admin/user/[id]/page.tsx +++ b/apps/hub/app/(app)/admin/user/[id]/page.tsx @@ -18,6 +18,8 @@ export default async function Page({ params }: { params: Promise<{ id: string }> }, include: { discordAccounts: true, + CanonicalUser: true, + Duplicates: true, }, }); if (!user) return ; diff --git a/apps/hub/app/(app)/admin/user/action.ts b/apps/hub/app/(app)/admin/user/action.ts index 8de43981..5b623291 100644 --- a/apps/hub/app/(app)/admin/user/action.ts +++ b/apps/hub/app/(app)/admin/user/action.ts @@ -2,6 +2,7 @@ import { prisma, Prisma } from "@repo/db"; import bcrypt from "bcryptjs"; import { sendMailByTemplate } from "../../../../helper/mail"; +import { getServerSession } from "api/auth/[...nextauth]/auth"; export const getUser = async (where: Prisma.UserWhereInput) => { return await prisma.user.findMany({ @@ -82,3 +83,52 @@ export const sendVerificationLink = async (userId: string) => { code, }); }; + +export const markDuplicate = async (params: { + duplicateUserId: string; + canonicalPublicId: string; + reason?: string; +}) => { + // Then in your function: + const session = await getServerSession(); + if (!session?.user) throw new Error("Nicht authentifiziert"); + const canonical = await prisma.user.findUnique({ + where: { publicId: params.canonicalPublicId }, + select: { id: true }, + }); + if (!canonical) throw new Error("Original-Account (canonical) nicht gefunden"); + if (canonical.id === params.duplicateUserId) + throw new Error("Duplikat und Original dürfen nicht identisch sein"); + + const updated = await prisma.user.update({ + where: { id: params.duplicateUserId }, + data: { + canonicalUserId: canonical.id, + isBanned: true, + duplicateDetectedAt: new Date(), + duplicateReason: params.reason ?? undefined, + }, + }); + + await prisma.penalty.create({ + data: { + userId: params.duplicateUserId, + type: "BAN", + reason: `Account als Duplikat von #${params.canonicalPublicId} markiert.`, + createdUserId: session.user.id, + }, + }); + return updated; +}; + +export const clearDuplicateLink = async (duplicateUserId: string) => { + const updated = await prisma.user.update({ + where: { id: duplicateUserId }, + data: { + canonicalUserId: null, + duplicateDetectedAt: null, + duplicateReason: null, + }, + }); + return updated; +}; diff --git a/apps/hub/app/(app)/admin/user/page.tsx b/apps/hub/app/(app)/admin/user/page.tsx index 16c46718..cdeb8b28 100644 --- a/apps/hub/app/(app)/admin/user/page.tsx +++ b/apps/hub/app/(app)/admin/user/page.tsx @@ -3,17 +3,35 @@ import { User2 } from "lucide-react"; import { PaginatedTable } from "../../../_components/PaginatedTable"; import Link from "next/link"; import { ColumnDef } from "@tanstack/react-table"; -import { User } from "@repo/db"; +import { DiscordAccount, Prisma, User } from "@repo/db"; import { useSession } from "next-auth/react"; const AdminUserPage = () => { const { data: session } = useSession(); + return ( <> { + return { + OR: [ + { firstname: { contains: searchTerm, mode: "insensitive" } }, + { lastname: { contains: searchTerm, mode: "insensitive" } }, + { email: { contains: searchTerm, mode: "insensitive" } }, + { + discordAccounts: { + some: { username: { contains: searchTerm, mode: "insensitive" } }, + }, + }, + ], + } as Prisma.UserWhereInput; + }} + include={{ + discordAccounts: true, + }} initialOrderBy={[ { id: "publicId", @@ -51,6 +69,16 @@ const AdminUserPage = () => { ); }, }, + { + header: "Discord", + cell(props) { + const discord = props.row.original.discordAccounts; + if (discord.length === 0) { + return Nicht verbunden; + } + return {discord.map((d) => d.username).join(", ")}; + }, + }, ...(session?.user.permissions.includes("ADMIN_USER_ADVANCED") ? [ { @@ -69,7 +97,7 @@ const AdminUserPage = () => {
), }, - ] as ColumnDef[] + ] as ColumnDef[] } // Define the columns for the user table leftOfSearch={

diff --git a/apps/hub/app/(app)/events/page.tsx b/apps/hub/app/(app)/events/page.tsx index fbd72617..be2e660e 100644 --- a/apps/hub/app/(app)/events/page.tsx +++ b/apps/hub/app/(app)/events/page.tsx @@ -27,6 +27,10 @@ const page = async () => { }, }, }, + + orderBy: { + id: "desc", + }, }); const appointments = await prisma.eventAppointment.findMany({ where: { diff --git a/apps/hub/app/(app)/logbook/page.tsx b/apps/hub/app/(app)/logbook/page.tsx index 9418a114..fd4647f8 100644 --- a/apps/hub/app/(app)/logbook/page.tsx +++ b/apps/hub/app/(app)/logbook/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { Mission, MissionAlertLog, MissionLog, Station } from "@repo/db"; +import { Mission, MissionAlertLog, MissionLog, Prisma, Station } from "@repo/db"; import { ColumnDef } from "@tanstack/react-table"; import { Error } from "_components/Error"; import { PaginatedTable } from "_components/PaginatedTable"; @@ -14,19 +14,21 @@ const Page = () => { return (

-

- Einsatzhistorie +

+ Einsatzhistorie

-
+
+ ({ + userId: session.data?.user?.id ?? "", + Mission: { + state: "finished", + }, + }) as Prisma.MissionOnStationUsersWhereInput + } include={{ Station: true, User: true, diff --git a/apps/hub/app/(app)/settings/_components/forms.tsx b/apps/hub/app/(app)/settings/_components/forms.tsx index d378b84b..93268439 100644 --- a/apps/hub/app/(app)/settings/_components/forms.tsx +++ b/apps/hub/app/(app)/settings/_components/forms.tsx @@ -1,6 +1,6 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { DiscordAccount, Penalty, User } from "@repo/db"; +import { DiscordAccount, Penalty, Report, User } from "@repo/db"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -326,9 +326,17 @@ export const SocialForm = ({ ); }; -export const DeleteForm = ({ user, penaltys }: { user: User; penaltys: Penalty[] }) => { +export const DeleteForm = ({ + user, + penaltys, + reports, +}: { + user: User; + penaltys: Penalty[]; + reports: Report[]; +}) => { const router = useRouter(); - const userCanDelete = penaltys.length === 0 && !user.isBanned; + const userCanDelete = penaltys.length === 0 && !user.isBanned && reports.length === 0; return (

@@ -338,8 +346,9 @@ export const DeleteForm = ({ user, penaltys }: { user: User; penaltys: Penalty[]

Du kannst dein Konto zurzeit nicht löschen!

- Scheinbar hast du aktuell zurzeit aktive Strafen. Um unsere Community zu schützen kannst - du einen Account erst löschen wenn deine Strafe nicht mehr aktiv ist + Scheinbar hast du Strafen oder Reports in deinem Profil hinterlegt. Um unsere Community + zu schützen kannst du deinen Account nicht löschen. Bitte erstelle ein Support-Ticket, + wenn du Fragen dazu hast.

)} diff --git a/apps/hub/app/(app)/settings/page.tsx b/apps/hub/app/(app)/settings/page.tsx index 240e60db..c29d8c62 100644 --- a/apps/hub/app/(app)/settings/page.tsx +++ b/apps/hub/app/(app)/settings/page.tsx @@ -20,36 +20,35 @@ export default async function Page() { const userPenaltys = await prisma.penalty.findMany({ where: { userId: session.user.id, - until: { - gte: new Date(), - }, - type: { - in: ["TIME_BAN", "BAN"], - }, - - suspended: false, }, }); + + const userReports = await prisma.report.findMany({ + where: { + reportedUserId: session.user.id, + }, + }); + if (!user) return ; const discordAccount = user?.discordAccounts[0]; return (
-

- Einstellungen +

+ Einstellungen

-
+
-
+
-
+
-
- +
+
); diff --git a/apps/hub/app/_components/PaginatedTable.tsx b/apps/hub/app/_components/PaginatedTable.tsx index a3af6918..af4aedc0 100644 --- a/apps/hub/app/_components/PaginatedTable.tsx +++ b/apps/hub/app/_components/PaginatedTable.tsx @@ -9,26 +9,27 @@ export interface PaginatedTableRef { refresh: () => void; } -interface PaginatedTableProps extends Omit, "data"> { +interface PaginatedTableProps + extends Omit, "data"> { prismaModel: keyof PrismaClient; stickyHeaders?: boolean; - filter?: Record; initialRowsPerPage?: number; - searchFields?: string[]; + showSearch?: boolean; + getFilter?: (searchTerm: string) => TWhere; include?: Record; strictQuery?: boolean; leftOfSearch?: React.ReactNode; rightOfSearch?: React.ReactNode; leftOfPagination?: React.ReactNode; - hide?: boolean; + supressQuery?: boolean; ref?: Ref; } -export function PaginatedTable({ +export function PaginatedTable({ prismaModel, initialRowsPerPage = 30, - searchFields = [], - filter, + getFilter, + showSearch = false, include, ref, strictQuery = false, @@ -36,9 +37,9 @@ export function PaginatedTable({ leftOfSearch, rightOfSearch, leftOfPagination, - hide, + supressQuery, ...restProps -}: PaginatedTableProps) { +}: PaginatedTableProps) { const [data, setData] = useState([]); const [rowsPerPage, setRowsPerPage] = useState(initialRowsPerPage); const [page, setPage] = useState(0); @@ -58,17 +59,19 @@ export function PaginatedTable({ const [loading, setLoading] = useState(false); const refreshTableData = useCallback(async () => { + if (supressQuery) { + setLoading(false); + return; + } setLoading(true); - getData( - prismaModel, - rowsPerPage, - page * rowsPerPage, - searchTerm, - searchFields, - filter, + getData({ + model: prismaModel, + limit: rowsPerPage, + offset: page * rowsPerPage, + where: getFilter ? getFilter(searchTerm) : undefined, include, orderBy, - strictQuery + select: strictQuery ? restProps.columns .filter( (col): col is { accessorKey: string } => @@ -80,7 +83,7 @@ export function PaginatedTable({ return acc; }, {}) : undefined, - ) + }) .then((result) => { if (result) { setData(result.data); @@ -91,12 +94,12 @@ export function PaginatedTable({ setLoading(false); }); }, [ + supressQuery, prismaModel, rowsPerPage, page, searchTerm, - searchFields, - filter, + getFilter, include, orderBy, strictQuery, @@ -111,31 +114,33 @@ export function PaginatedTable({ // useEffect to show loading spinner useEffect(() => { + if (supressQuery) return; + setLoading(true); - }, [searchTerm, page, rowsPerPage, orderBy, filter, setLoading]); + }, [searchTerm, page, rowsPerPage, orderBy, getFilter, setLoading, supressQuery]); useDebounce( () => { refreshTableData(); }, 500, - [searchTerm, page, rowsPerPage, orderBy, filter], + [searchTerm, page, rowsPerPage, orderBy, getFilter], ); return ( -
- {(rightOfSearch || leftOfSearch || searchFields.length > 0) && ( +
+ {(rightOfSearch || leftOfSearch || showSearch) && (
-
+
{leftOfSearch}
{loading && }
- {searchFields.length > 0 && ( + {showSearch && ( ({
{rightOfSearch}
)} - {!hide && ( - - )} -
+ + +
{leftOfPagination} - {!hide && ( - <> - - - - )} + <> + + +
); diff --git a/apps/hub/app/_components/pagiantedTableActions.ts b/apps/hub/app/_components/pagiantedTableActions.ts index 341e78b6..a4454ec8 100644 --- a/apps/hub/app/_components/pagiantedTableActions.ts +++ b/apps/hub/app/_components/pagiantedTableActions.ts @@ -2,56 +2,30 @@ "use server"; import { prisma, PrismaClient } from "@repo/db"; -export async function getData( - model: keyof PrismaClient, - limit: number, - offset: number, - searchTerm: string, - searchFields: string[], - filter?: Record, - include?: Record, - orderBy?: Record, - select?: Record, -) { - if (!model || !prisma[model]) { +export async function getData({ + model, + limit, + offset, + where, + include, + orderBy, + select, +}: { + model: keyof PrismaClient; + limit: number; + offset: number; + where: Twhere; + include?: Record; + orderBy?: Record; + select?: Record; +}) { + if (!model || !(prisma as any)[model]) { return { data: [], total: 0 }; } - const formattedId = searchTerm.match(/^VAR(\d+)$/)?.[1]; + const delegate = (prisma as any)[model]; - const where = searchTerm - ? { - OR: [ - formattedId ? { id: formattedId } : undefined, - ...searchFields.map((field) => { - if (field.includes(".")) { - const parts: string[] = field.split("."); - - // Helper function to build nested object - const buildNestedFilter = (parts: string[], index = 0): any => { - if (index === parts.length - 1) { - // Reached the last part - add the contains filter - return { [parts[index] as string]: { contains: searchTerm } }; - } - - // For intermediate levels, nest the next level - return { [parts[index] as string]: buildNestedFilter(parts, index + 1) }; - }; - - return buildNestedFilter(parts); - } - - return { [field]: { contains: searchTerm } }; - }), - ].filter(Boolean), - ...filter, - } - : { ...filter }; - - if (!prisma[model]) { - return { data: [], total: 0 }; - } - const data = await (prisma[model] as any).findMany({ + const data = await delegate.findMany({ where, orderBy, take: limit, @@ -60,7 +34,7 @@ export async function getData( select, }); - const total = await (prisma[model] as any).count({ where }); + const total = await delegate.count({ where }); return { data, total }; } diff --git a/apps/hub/app/_components/ui/Select.tsx b/apps/hub/app/_components/ui/Select.tsx index edbcbd9d..b3163667 100644 --- a/apps/hub/app/_components/ui/Select.tsx +++ b/apps/hub/app/_components/ui/Select.tsx @@ -31,6 +31,7 @@ const customStyles: StylesConfig = { backgroundColor: state.isSelected ? "hsl(var(--p))" : "hsl(var(--b1))", color: "var(--color-primary-content)", "&:hover": { backgroundColor: "var(--color-base-200)" }, // DaisyUI secondary color + cursor: "pointer", }), multiValueLabel: (provided) => ({ ...provided, @@ -49,6 +50,11 @@ const customStyles: StylesConfig = { backgroundColor: "var(--color-base-100)", borderRadius: "0.5rem", }), + input: (provided) => ({ + ...provided, + color: "var(--color-primary-content)", + cursor: "text", + }), }; const SelectCom = ({ @@ -61,7 +67,7 @@ const SelectCom = ({ }: SelectProps) => { return (
- {label} + {label} { if (Array.isArray(newValue)) { diff --git a/apps/hub/app/vatsim/page.tsx b/apps/hub/app/vatsim/page.tsx index 0dfff310..1f6edb18 100644 --- a/apps/hub/app/vatsim/page.tsx +++ b/apps/hub/app/vatsim/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { User } from "@repo/db"; +import { Prisma, User } from "@repo/db"; import { ColumnDef } from "@tanstack/react-table"; import { PaginatedTable } from "_components/PaginatedTable"; @@ -7,13 +7,24 @@ export default function () { return ( + ({ + AND: [ + { + vatsimCid: { + not: "", + }, + OR: [ + { firstname: { contains: searchTerm, mode: "insensitive" } }, + { lastname: { contains: searchTerm, mode: "insensitive" } }, + { vatsimCid: { contains: searchTerm, mode: "insensitive" } }, + ], + }, + ], + }) as Prisma.UserWhereInput + } leftOfSearch={

Vatsim-Nutzer

} columns={ [ diff --git a/packages/database/prisma/schema/migrations/20251225225508_added_multi_account_fields/migration.sql b/packages/database/prisma/schema/migrations/20251225225508_added_multi_account_fields/migration.sql new file mode 100644 index 00000000..9b984863 --- /dev/null +++ b/packages/database/prisma/schema/migrations/20251225225508_added_multi_account_fields/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "canonical_user_id" TEXT, +ADD COLUMN "duplicate_detected_at" TIMESTAMP(3), +ADD COLUMN "duplicate_reason" TEXT; + +-- AddForeignKey +ALTER TABLE "users" ADD CONSTRAINT "users_canonical_user_id_fkey" FOREIGN KEY ("canonical_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema/user.prisma b/packages/database/prisma/schema/user.prisma index 1f09f3f0..820115e1 100644 --- a/packages/database/prisma/schema/user.prisma +++ b/packages/database/prisma/schema/user.prisma @@ -60,6 +60,12 @@ model User { createdAt DateTime @default(now()) @map(name: "created_at") updatedAt DateTime @default(now()) @map(name: "updated_at") isBanned Boolean @default(false) @map(name: "is_banned") + // Duplicate handling: + canonicalUserId String? @map(name: "canonical_user_id") + CanonicalUser User? @relation("CanonicalUser", fields: [canonicalUserId], references: [id]) + Duplicates User[] @relation("CanonicalUser") + duplicateDetectedAt DateTime? @map(name: "duplicate_detected_at") + duplicateReason String? @map(name: "duplicate_reason") // relations: oauthTokens OAuthToken[] discordAccounts DiscordAccount[]