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 = () => {
+ ({
+ 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={
-
+
Erstellen
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") && (
+
+
+
+ Duplikat markieren & sperren
+
+
+
+ )}
+ {(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 (
+
+ );
+};
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[]