feat: implement user labels management with CRUD operations and UI integration

This commit is contained in:
Marco Pedone 2026-03-10 16:56:26 +01:00
parent eedaab6e9b
commit a04e3c2fd3
8 changed files with 322 additions and 11 deletions

View file

@ -0,0 +1,34 @@
CREATE TABLE IF NOT EXISTS public.user_etichette (
"userId" UUID NOT NULL,
"etichettaId" integer NOT NULL,
);
DO $$ BEGIN
ALTER TABLE public.user_etichette
ADD CONSTRAINT user_etichette_pkey PRIMARY KEY ("userId", "etichettaId");
EXCEPTION
WHEN invalid_table_definition THEN
RAISE NOTICE 'Primary key already exists. Ignoring...';
END $$;
-- Foreign Key to Chats
DO $$ BEGIN IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'user_etichetta_fkey'
) THEN
ALTER TABLE public.user_etichette
ADD CONSTRAINT user_etichetta_fkey
FOREIGN KEY ("userId") REFERENCES public.users (id)
ON UPDATE CASCADE ON DELETE CASCADE;
END IF;
END $$;
-- Foreign Key to Etichette
DO $$ BEGIN IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'etichetta_user_etichetta_fkey'
) THEN
ALTER TABLE public.user_etichette
ADD CONSTRAINT etichetta_user_etichetta_fkey
FOREIGN KEY ("etichettaId") REFERENCES public.etichette (id_etichetta)
ON UPDATE CASCADE ON DELETE CASCADE;
END IF;
END $$;

View file

@ -113,7 +113,7 @@ export const EtichetteModal = ({
/>
<Button
onClick={() => {
remove({ chatId, etichettaId: etichetta.id_etichetta });
remove({ chatId, etichettaId: etichetta.id_etichetta });
}}
>
Rimuovi

View file

@ -4,13 +4,17 @@ import {
Mail,
MessagesSquare,
Paperclip,
Plus,
Search,
ShoppingBag,
Tags,
User,
UserCog,
X,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import toast from "react-hot-toast";
import { DataTable } from "~/components/custom_ui/data-table";
import { DataTableColumnHeader } from "~/components/custom_ui/dataTable-header";
@ -20,15 +24,26 @@ import type {
} from "~/components/custom_ui/dataTable-toolbar";
import { UserAvatar } from "~/components/user_avatar";
import { cn } from "~/lib/utils";
import type { Users } from "~/schemas/public/Users";
import type { Etichette } from "~/schemas/public/Etichette";
import type { Users, UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api";
import { Etichetta } from "../etichette";
import { WhatsAppIcon2 } from "../svgs";
import { Button } from "../ui/button";
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
} from "../ui/context-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Separator } from "../ui/separator";
type UserWChatInfo = Pick<
Users,
@ -39,7 +54,7 @@ type UserWChatInfo = Pick<
| "isAdmin"
| "isBlocked"
| "created_at"
> & { chatid: string | null };
> & { chatid: string | null; etichette: Etichette[] };
export const UsersTable = (props: { data: UserWChatInfo[] }) => {
const router = useRouter();
@ -50,6 +65,11 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
},
});
const [selected, setSelected] = useState<{
id: UsersId;
etichette: Etichette[];
} | null>(null);
const { data } = props;
const tabledata = data.map((user) => {
@ -60,8 +80,9 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
telefono: user.telefono,
id: user.id,
isAdmin: user.isAdmin ? "Admin" : "Utente",
isBanned: user.isBlocked ? "Bloccato" : "Attivo",
isBlocked: user.isBlocked ? "Bloccato" : "Attivo",
username: user.username,
etichette: user.etichette,
};
});
@ -75,12 +96,37 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
email: "Email",
telefono: "Telefono",
isAdmin: "Ruolo",
isBanned: "Status",
isBlocked: "Status",
username: "User",
etichette: "📎",
};
type Column = (typeof tabledata)[number];
const columns: ColumnDef<Column>[] = [
{
accessorKey: "etichette",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={columns_titles.etichette}
/>
),
cell: ({ row }) => {
return (
<div className="flex max-w-32 flex-wrap items-center gap-2">
{row.original.etichette.map((e) => (
<Etichetta
color_hex={e.color_hex}
key={e.id_etichetta}
title={e.title}
/>
))}
</div>
);
},
enableHiding: false,
enableSorting: false,
},
{
accessorKey: "username",
cell: ({ row }) => {
@ -170,14 +216,14 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
},
{
accessorKey: "isBanned",
accessorKey: "isBlocked",
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={columns_titles.isBanned}
title={columns_titles.isBlocked}
/>
),
},
@ -192,7 +238,7 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
title: "Ruolo",
},
{
columnName: "isBanned",
columnName: "isBlocked",
options: [
{ label: "Bloccato", value: "Bloccato" },
{ label: "Attivo", value: "Attivo" },
@ -307,6 +353,17 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
<User /> Login
</button>
</ContextMenuItem>
<ContextMenuItem>
<button
className="flex w-full cursor-pointer items-center gap-2"
onClick={() => {
setSelected({ id: data.id, etichette: data.etichette });
}}
type="button"
>
<Tags /> Etichette
</button>
</ContextMenuItem>
</ContextMenuContent>
)}
data={tabledata}
@ -323,6 +380,140 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
searchColumn={searchFiltro}
withContextMenu={true}
/>
<UsersEtichetteModal selected={selected} setSelected={setSelected} />
</div>
);
};
const UsersEtichetteModal = ({
selected,
setSelected,
}: {
selected: { id: UsersId; etichette: Etichette[] } | null;
setSelected: React.Dispatch<
React.SetStateAction<{ id: UsersId; etichette: Etichette[] } | null>
>;
}) => {
const utils = api.useUtils();
const { data: etichette } = api.etichette.getAllEtichette.useQuery();
const { mutate: addEtichetta } = api.etichette.addUserEtichetta.useMutation({
onSuccess: async () => {
toast.success("Etichetta aggiunta con successo");
await utils.etichette.getAllEtichette.invalidate();
await utils.users.getUsersWChatInfo.invalidate();
},
onError: () => {
toast.error("Errore nell'aggiunta dell'etichetta");
},
});
const { mutate: removeEtichetta } =
api.etichette.removeUserEtichetta.useMutation({
onSuccess: async () => {
toast.success("Etichetta rimossa con successo");
await utils.etichette.getAllEtichette.invalidate();
await utils.users.getUsersWChatInfo.invalidate();
},
onError: () => {
toast.error("Errore nella rimozione dell'etichetta");
},
});
return (
<Dialog
onOpenChange={(v) => {
if (!v) setSelected(null);
}}
open={!!selected}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Etichette</DialogTitle>
<DialogDescription className="sr-only">Etichette</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<div className="flex flex-col gap-2">
<h3>Etichette Attive</h3>
{selected?.etichette.map((etichetta) => (
<div
className="flex items-center gap-2"
key={`${etichetta.id_etichetta}${selected.id}`}
>
<Etichetta
color_hex={etichetta.color_hex}
title={etichetta.title}
/>
<Button
onClick={() => {
removeEtichetta({
userId: selected.id,
etichettaId: etichetta.id_etichetta,
});
setSelected((prev) => {
if (!prev) return prev;
return {
...prev,
etichette: prev.etichette.filter(
(e) => e.id_etichetta !== etichetta.id_etichetta,
),
};
});
}}
size="sm"
variant="destructive"
>
<X className="size-4" />
</Button>
</div>
))}
</div>
<Separator />
<div className="flex flex-col gap-2">
<h3>Aggiungi Etichetta</h3>
{etichette
?.filter(
(e) =>
!selected?.etichette.some(
(etichetta) => etichetta.id_etichetta === e.id_etichetta,
),
)
.map((e) => (
<div className="flex items-center gap-2" key={e.id_etichetta}>
<Etichetta color_hex={e.color_hex} title={e.title} />
<Button
onClick={() => {
if (selected) {
addEtichetta({
userId: selected.id,
etichettaId: e.id_etichetta,
});
setSelected((prev) => {
if (!prev) return prev;
return {
...prev,
etichette: [
...prev.etichette,
{
id_etichetta: e.id_etichetta,
color_hex: e.color_hex,
title: e.title,
},
],
};
});
}
}}
size="sm"
variant="success"
>
<Plus className="size-4" />
</Button>
</div>
))}
</div>
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -7,6 +7,7 @@ import type { default as AnnunciTable } from './Annunci';
import type { default as UsersTable } from './Users';
import type { default as ChatsEtichetteTable } from './ChatsEtichette';
import type { default as TestiEStringheTable } from './TestiEStringhe';
import type { default as UserEtichetteTable } from './UserEtichette';
import type { default as UsersStorageTable } from './UsersStorage';
import type { default as ServizioAnnunciTable } from './ServizioAnnunci';
import type { default as UserInvitesTable } from './UserInvites';
@ -43,6 +44,8 @@ export default interface PublicSchema {
testi_e_stringhe: TestiEStringheTable;
user_etichette: UserEtichetteTable;
users_storage: UsersStorageTable;
servizio_annunci: ServizioAnnunciTable;

View file

@ -0,0 +1,19 @@
// @generated
// This file is automatically generated by Kanel. Do not modify manually.
import type { UsersId } from './Users';
import type { EtichetteIdEtichetta } from './Etichette';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Represents the table public.user_etichette */
export default interface UserEtichetteTable {
userId: ColumnType<UsersId, UsersId, UsersId>;
etichettaId: ColumnType<EtichetteIdEtichetta, EtichetteIdEtichetta, EtichetteIdEtichetta>;
}
export type UserEtichette = Selectable<UserEtichetteTable>;
export type NewUserEtichette = Insertable<UserEtichetteTable>;
export type UserEtichetteUpdate = Updateable<UserEtichetteTable>;

View file

@ -1,3 +1,4 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { adminProcedure, createTRPCRouter } from "~/server/api/trpc";
import {
@ -7,7 +8,7 @@ import {
updateEtichetta,
} from "~/server/controllers/etichette.controller";
import { db } from "~/server/db";
import { zEtichettaId } from "~/server/utils/zod_types";
import { zEtichettaId, zUserId } from "~/server/utils/zod_types";
export const etichetteRouter = createTRPCRouter({
addEtichetta: adminProcedure
@ -51,4 +52,49 @@ export const etichetteRouter = createTRPCRouter({
title: input.title,
});
}),
addUserEtichetta: adminProcedure
.input(
z.object({
userId: zUserId,
etichettaId: zEtichettaId,
}),
)
.mutation(async ({ input }) => {
try {
await db
.insertInto("user_etichette")
.values({
etichettaId: input.etichettaId,
userId: input.userId,
})
.execute();
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore aggiunta etichetta all'utente: ${error instanceof Error ? error.message : "Errore sconosciuto"}`,
});
}
}),
removeUserEtichetta: adminProcedure
.input(
z.object({
userId: zUserId,
etichettaId: zEtichettaId,
}),
)
.mutation(async ({ input }) => {
try {
await db
.deleteFrom("user_etichette")
.where("etichettaId", "=", input.etichettaId)
.where("userId", "=", input.userId)
.execute();
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore rimozione etichetta all'utente: ${error instanceof Error ? error.message : "Errore sconosciuto"}`,
});
}
return "ok";
}),
});

View file

@ -100,6 +100,6 @@ export const usersRouter = createTRPCRouter({
}),
getUsersWChatInfo: adminProcedure.query(async () => {
return await getUsersWithChatInfoHandler({ db });
return await getUsersWithChatInfoHandler();
}),
});

View file

@ -1,4 +1,5 @@
import { TRPCError } from "@trpc/server";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import type { UsersId, UsersUpdate } from "~/schemas/public/Users";
import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica";
import { db, type Querier } from "~/server/db";
@ -167,7 +168,7 @@ export const getActiveUserWithoutChatHandler = async ({
});
};
export const getUsersWithChatInfoHandler = async ({ db }: { db: Querier }) => {
export const getUsersWithChatInfoHandler = async () => {
return await db
.selectFrom("users")
.select([
@ -181,6 +182,23 @@ export const getUsersWithChatInfoHandler = async ({ db }: { db: Querier }) => {
])
.leftJoin("chats", "chats.user_ref", "users.id")
.select(["chats.chatid"])
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom("etichette")
.innerJoin(
"user_etichette",
"user_etichette.etichettaId",
"etichette.id_etichetta",
)
.whereRef("user_etichette.userId", "=", "users.id")
.select([
"etichette.id_etichetta",
"etichette.title",
"etichette.color_hex",
]),
).as("etichette"),
)
.orderBy("users.id", "desc")
.select(["users.id as id"])
.execute();