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

@ -4,13 +4,17 @@ import {
Mail, Mail,
MessagesSquare, MessagesSquare,
Paperclip, Paperclip,
Plus,
Search, Search,
ShoppingBag, ShoppingBag,
Tags,
User, User,
UserCog, UserCog,
X,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { DataTable } from "~/components/custom_ui/data-table"; import { DataTable } from "~/components/custom_ui/data-table";
import { DataTableColumnHeader } from "~/components/custom_ui/dataTable-header"; import { DataTableColumnHeader } from "~/components/custom_ui/dataTable-header";
@ -20,15 +24,26 @@ import type {
} from "~/components/custom_ui/dataTable-toolbar"; } from "~/components/custom_ui/dataTable-toolbar";
import { UserAvatar } from "~/components/user_avatar"; import { UserAvatar } from "~/components/user_avatar";
import { cn } from "~/lib/utils"; 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 { api } from "~/utils/api";
import { Etichetta } from "../etichette";
import { WhatsAppIcon2 } from "../svgs"; import { WhatsAppIcon2 } from "../svgs";
import { Button } from "../ui/button";
import { import {
ContextMenuContent, ContextMenuContent,
ContextMenuItem, ContextMenuItem,
ContextMenuLabel, ContextMenuLabel,
ContextMenuSeparator, ContextMenuSeparator,
} from "../ui/context-menu"; } from "../ui/context-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Separator } from "../ui/separator";
type UserWChatInfo = Pick< type UserWChatInfo = Pick<
Users, Users,
@ -39,7 +54,7 @@ type UserWChatInfo = Pick<
| "isAdmin" | "isAdmin"
| "isBlocked" | "isBlocked"
| "created_at" | "created_at"
> & { chatid: string | null }; > & { chatid: string | null; etichette: Etichette[] };
export const UsersTable = (props: { data: UserWChatInfo[] }) => { export const UsersTable = (props: { data: UserWChatInfo[] }) => {
const router = useRouter(); 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 { data } = props;
const tabledata = data.map((user) => { const tabledata = data.map((user) => {
@ -60,8 +80,9 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
telefono: user.telefono, telefono: user.telefono,
id: user.id, id: user.id,
isAdmin: user.isAdmin ? "Admin" : "Utente", isAdmin: user.isAdmin ? "Admin" : "Utente",
isBanned: user.isBlocked ? "Bloccato" : "Attivo", isBlocked: user.isBlocked ? "Bloccato" : "Attivo",
username: user.username, username: user.username,
etichette: user.etichette,
}; };
}); });
@ -75,12 +96,37 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
email: "Email", email: "Email",
telefono: "Telefono", telefono: "Telefono",
isAdmin: "Ruolo", isAdmin: "Ruolo",
isBanned: "Status", isBlocked: "Status",
username: "User", username: "User",
etichette: "📎",
}; };
type Column = (typeof tabledata)[number]; type Column = (typeof tabledata)[number];
const columns: ColumnDef<Column>[] = [ 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", accessorKey: "username",
cell: ({ row }) => { cell: ({ row }) => {
@ -170,14 +216,14 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
}, },
{ {
accessorKey: "isBanned", accessorKey: "isBlocked",
filterFn: (row, id, value) => { filterFn: (row, id, value) => {
return value.includes(row.getValue(id)); return value.includes(row.getValue(id));
}, },
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader <DataTableColumnHeader
column={column} column={column}
title={columns_titles.isBanned} title={columns_titles.isBlocked}
/> />
), ),
}, },
@ -192,7 +238,7 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
title: "Ruolo", title: "Ruolo",
}, },
{ {
columnName: "isBanned", columnName: "isBlocked",
options: [ options: [
{ label: "Bloccato", value: "Bloccato" }, { label: "Bloccato", value: "Bloccato" },
{ label: "Attivo", value: "Attivo" }, { label: "Attivo", value: "Attivo" },
@ -307,6 +353,17 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
<User /> Login <User /> Login
</button> </button>
</ContextMenuItem> </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> </ContextMenuContent>
)} )}
data={tabledata} data={tabledata}
@ -323,6 +380,140 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
searchColumn={searchFiltro} searchColumn={searchFiltro}
withContextMenu={true} withContextMenu={true}
/> />
<UsersEtichetteModal selected={selected} setSelected={setSelected} />
</div> </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 UsersTable } from './Users';
import type { default as ChatsEtichetteTable } from './ChatsEtichette'; import type { default as ChatsEtichetteTable } from './ChatsEtichette';
import type { default as TestiEStringheTable } from './TestiEStringhe'; 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 UsersStorageTable } from './UsersStorage';
import type { default as ServizioAnnunciTable } from './ServizioAnnunci'; import type { default as ServizioAnnunciTable } from './ServizioAnnunci';
import type { default as UserInvitesTable } from './UserInvites'; import type { default as UserInvitesTable } from './UserInvites';
@ -43,6 +44,8 @@ export default interface PublicSchema {
testi_e_stringhe: TestiEStringheTable; testi_e_stringhe: TestiEStringheTable;
user_etichette: UserEtichetteTable;
users_storage: UsersStorageTable; users_storage: UsersStorageTable;
servizio_annunci: ServizioAnnunciTable; 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 { z } from "zod/v4";
import { adminProcedure, createTRPCRouter } from "~/server/api/trpc"; import { adminProcedure, createTRPCRouter } from "~/server/api/trpc";
import { import {
@ -7,7 +8,7 @@ import {
updateEtichetta, updateEtichetta,
} from "~/server/controllers/etichette.controller"; } from "~/server/controllers/etichette.controller";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { zEtichettaId } from "~/server/utils/zod_types"; import { zEtichettaId, zUserId } from "~/server/utils/zod_types";
export const etichetteRouter = createTRPCRouter({ export const etichetteRouter = createTRPCRouter({
addEtichetta: adminProcedure addEtichetta: adminProcedure
@ -51,4 +52,49 @@ export const etichetteRouter = createTRPCRouter({
title: input.title, 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 () => { getUsersWChatInfo: adminProcedure.query(async () => {
return await getUsersWithChatInfoHandler({ db }); return await getUsersWithChatInfoHandler();
}), }),
}); });

View file

@ -1,4 +1,5 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import type { UsersId, UsersUpdate } from "~/schemas/public/Users"; import type { UsersId, UsersUpdate } from "~/schemas/public/Users";
import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica"; import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica";
import { db, type Querier } from "~/server/db"; 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 return await db
.selectFrom("users") .selectFrom("users")
.select([ .select([
@ -181,6 +182,23 @@ export const getUsersWithChatInfoHandler = async ({ db }: { db: Querier }) => {
]) ])
.leftJoin("chats", "chats.user_ref", "users.id") .leftJoin("chats", "chats.user_ref", "users.id")
.select(["chats.chatid"]) .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") .orderBy("users.id", "desc")
.select(["users.id as id"]) .select(["users.id as id"])
.execute(); .execute();