Rebase for Dockerfile in hub #150

Merged
PxlLoewe merged 13 commits from release into staging 2026-01-15 23:03:33 +00:00
27 changed files with 684 additions and 275 deletions
Showing only changes of commit 6e8884f3fb - Show all commits

View File

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

View File

@@ -2,7 +2,7 @@ import { Marker, Polyline, useMap } from "react-leaflet";
import { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet";
import { useMapStore } from "_store/mapStore";
import { Fragment, useCallback, useEffect, useRef, useState, useMemo } from "react";
import { cn } from "@repo/shared-components";
import { checkSimulatorConnected, cn } from "@repo/shared-components";
import { ChevronsRightLeft, House, MessageSquareText, Minimize2 } from "lucide-react";
import { SmartPopup, calculateAnchor, useSmartPopup } from "_components/SmartPopup";
import FMSStatusHistory, {
@@ -396,11 +396,27 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
};
export const AircraftLayer = () => {
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts-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 {

View File

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

View File

@@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Keyword } from "@repo/db";
import { Keyword, Prisma } from "@repo/db";
export default () => {
return (
@@ -12,7 +12,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={
[
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
DiscordAccount,
Penalty,
PERMISSION,
Prisma,
Station,
User,
} from "@repo/db";
@@ -76,6 +77,21 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
className="card-body"
onSubmit={form.handleSubmit(async (values) => {
if (!values.id) return;
if (values.id === session.data?.user.id && values.permissions !== user.permissions) {
toast.error("Du kannst deine eigenen Berechtigungen nicht ändern.");
return;
}
if (values.permissions?.some((perm) => !session.data?.user.permissions.includes(perm))) {
toast.error("Du kannst Berechtigungen nicht hinzufügen, die du selbst nicht besitzt.");
return;
}
const removedPermissions =
user.permissions?.filter((perm) => !values.permissions?.includes(perm)) || [];
if (removedPermissions.some((perm) => !session.data?.user.permissions.includes(perm))) {
toast.error("Du kannst Berechtigungen nicht entfernen, die du selbst nicht besitzt.");
return;
}
await editUser(values.id, {
...values,
email: values.email.toLowerCase(),
@@ -266,10 +282,18 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
</h2>
<PaginatedTable
ref={dispoTableRef}
filter={{
userId: user.id,
}}
getFilter={() =>
({
userId: user.id,
}) as Prisma.ConnectedDispatcherWhereInput
}
prismaModel={"connectedDispatcher"}
initialOrderBy={[
{
id: "loginTime",
desc: true,
},
]}
columns={
[
{
@@ -328,11 +352,19 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
</h2>
<PaginatedTable
ref={pilotTableRef}
filter={{
userId: user.id,
}}
getFilter={() =>
({
userId: user.id,
}) as Prisma.ConnectedAircraftWhereInput
}
prismaModel={"connectedAircraft"}
include={{ Station: true }}
initialOrderBy={[
{
id: "loginTime",
desc: true,
},
]}
columns={
[
{
@@ -478,9 +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}
/>
</div>
@@ -502,9 +532,17 @@ export const UserReports = ({ user }: { user: User }) => {
</div>
<PaginatedTable
prismaModel="report"
filter={{
reportedUserId: user.id,
}}
getFilter={() =>
({
reportedUserId: user.id,
}) as Prisma.ReportWhereInput
}
initialOrderBy={[
{
id: "timestamp",
desc: true,
},
]}
include={{
Sender: true,
Reported: true,
@@ -517,7 +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 = ({
</div>
)}
</div>
{session?.user.permissions.includes("ADMIN_USER_ADVANCED") && (
<div className="mt-2 space-y-1">
<Link href={`/admin/user/${user.id}/duplicate`}>
<Button className="btn-sm btn-outline w-full">
<LockKeyhole className="h-4 w-4" /> Duplikat markieren & sperren
</Button>
</Link>
</div>
)}
</div>
{(user.CanonicalUser || (user.Duplicates && user.Duplicates.length > 0)) && (
<div role="alert" className="alert alert-error alert-outline flex flex-col">
<div className="flex items-center gap-2">
<TriangleAlert />
<div>
{user.CanonicalUser && (
<div>
<h3 className="text-lg font-semibold">Als Duplikat markiert</h3>
<p>
Dieser Account wurde als Duplikat von{" "}
<Link
href={`/admin/user/${user.CanonicalUser.id}`}
className="link link-hover font-semibold"
>
{user.CanonicalUser.firstname} {user.CanonicalUser.lastname} (
{user.CanonicalUser.publicId})
</Link>{" "}
markiert.
</p>
</div>
)}
{user.Duplicates && user.Duplicates.length > 0 && (
<div className="mt-2">
<h3 className="text-lg font-semibold">Duplikate erkannt</h3>
<p>Folgende Accounts wurden als Duplikate dieses Accounts markiert:</p>
<ul className="ml-4 mt-1 list-inside list-disc">
{user.Duplicates.map((duplicate) => (
<li key={duplicate.id}>
<Link href={`/admin/user/${duplicate.id}`} className="link link-hover">
{duplicate.firstname} {duplicate.lastname} ({duplicate.publicId})
</Link>
</li>
))}
</ul>
</div>
)}
</div>
</div>
<p className="text-sm text-gray-400">{user.duplicateReason || "Keine Grund angegeben"}</p>
</div>
)}
{(!!openBans.length || !!openTimebans.length) && (
<div role="alert" className="alert alert-warning alert-outline flex flex-col">
<div className="flex items-center gap-2">
@@ -667,6 +755,7 @@ export const AdminForm = ({
</p>
</div>
)}
<h2 className="card-title">
<ChartBarBigIcon className="h-5 w-5" /> Aktivität
</h2>

View File

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

View File

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

View File

@@ -18,6 +18,8 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
},
include: {
discordAccounts: true,
CanonicalUser: true,
Duplicates: true,
},
});
if (!user) return <Error statusCode={404} title="User not found" />;

View File

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

View File

@@ -3,17 +3,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 (
<>
<PaginatedTable
stickyHeaders
prismaModel="user"
searchFields={["publicId", "firstname", "lastname", "email"]}
showSearch
getFilter={(searchTerm) => {
return {
OR: [
{ firstname: { contains: searchTerm, mode: "insensitive" } },
{ lastname: { contains: searchTerm, mode: "insensitive" } },
{ email: { contains: searchTerm, mode: "insensitive" } },
{
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 <span className="text-gray-700">Nicht verbunden</span>;
}
return <span>{discord.map((d) => d.username).join(", ")}</span>;
},
},
...(session?.user.permissions.includes("ADMIN_USER_ADVANCED")
? [
{
@@ -69,7 +97,7 @@ const AdminUserPage = () => {
</div>
),
},
] as ColumnDef<User>[]
] as ColumnDef<User & { discordAccounts: DiscordAccount[] }>[]
} // Define the columns for the user table
leftOfSearch={
<p className="flex items-center gap-2 text-left text-2xl font-semibold">

View File

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

View File

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

View File

@@ -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 (
<div className="card-body">
<h2 className="card-title mb-5">
@@ -338,8 +346,9 @@ export const DeleteForm = ({ user, penaltys }: { user: User; penaltys: Penalty[]
<div className="text-left">
<h2 className="text-warning text-lg">Du kannst dein Konto zurzeit nicht löschen!</h2>
<p className="text-sm text-gray-400">
Scheinbar hast du aktuell zurzeit aktive Strafen. Um unsere Community zu schützen kannst
du einen Account erst 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.
</p>
</div>
)}

View File

@@ -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 <Error statusCode={401} title="Dein Account wurde nicht gefunden" />;
const discordAccount = user?.discordAccounts[0];
return (
<div className="grid grid-cols-6 gap-4">
<div className="col-span-full">
<p className="text-2xl font-semibold text-left flex items-center gap-2">
<GearIcon className="w-5 h-5" /> Einstellungen
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
<GearIcon className="h-5 w-5" /> Einstellungen
</p>
</div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<ProfileForm user={user} penaltys={userPenaltys} discordAccount={discordAccount} />
</div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<SocialForm discordAccount={discordAccount} user={user} />
</div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<PasswordForm />
</div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<DeleteForm user={user} penaltys={userPenaltys} />
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<DeleteForm user={user} penaltys={userPenaltys} reports={userReports} />
</div>
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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