Added Account Dublicate fucntion, improved default sorts

This commit is contained in:
PxlLoewe
2025-12-26 01:23:32 +01:00
parent 51ef9cd90c
commit 17208eded9
18 changed files with 486 additions and 139 deletions

View File

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

View File

@@ -64,7 +64,7 @@ export const AppointmentModal = ({
</div>
<div>
<PaginatedTable
hide={appointmentForm.watch("id") === undefined}
supressQuery={appointmentForm.watch("id") === undefined}
ref={participantTableRef}
columns={
[

View File

@@ -250,92 +250,95 @@ 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>
}
searchFields={["User.firstname", "User.lastname", "User.publicId"]}
ref={appointmentsTableRef}
prismaModel={"participant"}
filter={{
eventId: event?.id,
}}
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

@@ -76,16 +76,17 @@ 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){
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)) ){
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)) ){
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;
}
@@ -284,6 +285,12 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
userId: user.id,
}}
prismaModel={"connectedDispatcher"}
initialOrderBy={[
{
id: "loginTime",
desc: true,
},
]}
columns={
[
{
@@ -347,6 +354,12 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
}}
prismaModel={"connectedAircraft"}
include={{ Station: true }}
initialOrderBy={[
{
id: "loginTime",
desc: true,
},
]}
columns={
[
{
@@ -519,6 +532,12 @@ export const UserReports = ({ user }: { user: User }) => {
filter={{
reportedUserId: user.id,
}}
initialOrderBy={[
{
id: "timestamp",
desc: true,
},
]}
include={{
Sender: true,
Reported: true,
@@ -531,7 +550,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;
@@ -653,7 +672,59 @@ 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">
Achtung! Dieser Account ist als Duplikat markiert oder hat Duplikate!
</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">
@@ -681,6 +752,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,7 +3,7 @@ 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, User } from "@repo/db";
import { useSession } from "next-auth/react";
const AdminUserPage = () => {
@@ -13,7 +13,10 @@ const AdminUserPage = () => {
<PaginatedTable
stickyHeaders
prismaModel="user"
searchFields={["publicId", "firstname", "lastname", "email"]}
searchFields={["publicId", "firstname", "lastname", "email", "discordAccounts.username"]}
include={{
discordAccounts: true,
}}
initialOrderBy={[
{
id: "publicId",
@@ -51,6 +54,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 +82,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,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

@@ -20,7 +20,7 @@ interface PaginatedTableProps<TData> extends Omit<SortableTableProps<TData>, "da
leftOfSearch?: React.ReactNode;
rightOfSearch?: React.ReactNode;
leftOfPagination?: React.ReactNode;
hide?: boolean;
supressQuery?: boolean;
ref?: Ref<PaginatedTableRef>;
}
@@ -36,7 +36,7 @@ export function PaginatedTable<TData>({
leftOfSearch,
rightOfSearch,
leftOfPagination,
hide,
supressQuery,
...restProps
}: PaginatedTableProps<TData>) {
const [data, setData] = useState<TData[]>([]);
@@ -58,6 +58,10 @@ export function PaginatedTable<TData>({
const [loading, setLoading] = useState(false);
const refreshTableData = useCallback(async () => {
if (supressQuery) {
setLoading(false);
return;
}
setLoading(true);
getData(
prismaModel,
@@ -91,6 +95,7 @@ export function PaginatedTable<TData>({
setLoading(false);
});
}, [
supressQuery,
prismaModel,
rowsPerPage,
page,
@@ -111,8 +116,10 @@ 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, filter, setLoading, supressQuery]);
useDebounce(
() => {
@@ -123,15 +130,15 @@ export function PaginatedTable<TData>({
);
return (
<div className="space-y-4 m-4">
<div className="m-4 space-y-4">
{(rightOfSearch || leftOfSearch || searchFields.length > 0) && (
<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>
@@ -150,22 +157,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

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

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { prisma } from "@repo/db";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const publicId = searchParams.get("publicId")?.trim();
const email = searchParams.get("email")?.trim()?.toLowerCase();
if (!publicId && !email) {
return NextResponse.json({ error: "Missing query" }, { status: 400 });
}
try {
const user = await prisma.user.findFirst({
where: {
OR: [publicId ? { publicId } : undefined, email ? { email } : undefined].filter(
Boolean,
) as any,
},
select: {
id: true,
publicId: true,
firstname: true,
lastname: true,
isBanned: true,
},
});
if (!user) return NextResponse.json({ user: null }, { status: 200 });
return NextResponse.json({ user }, { status: 200 });
} catch (e) {
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -11,7 +11,7 @@ export default function () {
prismaModel={"user"}
filter={{
vatsimCid: {
gt: 1,
not: "",
},
}}
leftOfSearch={<h1 className="text-2xl font-bold">Vatsim-Nutzer</h1>}

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