feat: enhance user management and document handling; refactor service and schema integrations

This commit is contained in:
Marco Pedone 2025-11-10 16:27:15 +01:00
parent fa8dcb7d86
commit d894f9d6ec
16 changed files with 381 additions and 98 deletions

View file

@ -0,0 +1,26 @@
-- Add column only if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'servizio_annunci'
AND column_name = 'servizio_annunci_id'
) THEN
ALTER TABLE public.servizio_annunci
ADD COLUMN servizio_annunci_id serial NOT NULL;
END IF;
END $$;
-- Add primary key only if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'servizio_annunci_pkey'
AND conrelid = 'public.servizio_annunci'::regclass
) THEN
ALTER TABLE public.servizio_annunci
ADD PRIMARY KEY (servizio_annunci_id);
END IF;
END $$;

View file

@ -5,6 +5,7 @@ services:
environment:
PGADMIN_DEFAULT_EMAIL: m.pedone98@gmail.com
PGADMIN_DEFAULT_PASSWORD: rootpost
PGADMIN_CONFIG_CONSOLE_LOG_LEVEL: 10
ports:
- "5050:80"
volumes:

View file

@ -0,0 +1,198 @@
import { ExternalLink, Trash2 } from "lucide-react";
import Link from "next/link";
import toast from "react-hot-toast";
import { useSession } from "~/providers/SessionProvider";
import type { UsersStorageUserStorageId } from "~/schemas/public/UsersStorage";
import type { UserWithAnagrafica } from "~/server/services/user.service";
import { api } from "~/utils/api";
import { Confirm } from "../confirm";
import { ImageFlbk } from "../ImageWithFallback";
import { LoadingPage } from "../loading";
import { Button } from "../ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "../ui/card";
import { UploadModal } from "../upload_modal";
export const DocumentiPersonali = ({
userData,
}: {
userData: UserWithAnagrafica;
}) => {
const utils = api.useUtils();
const { mutate, isPending } = api.users.editUserAnagrafica.useMutation({
onSuccess: async () => {
await utils.users.getClientProfilo.invalidate({
userId: userData.id,
});
},
onError: (err) => {
toast.error(`Errore aggiornamento servizio: ${err.message}`);
},
});
const handleUpload = (
field: "doc_personale_fronte_ref" | "doc_personale_retro_ref",
id: UsersStorageUserStorageId,
) => {
mutate({
userId: userData.id,
data: { [field]: id },
});
};
const { user } = useSession();
if (!user) return null;
if (isPending) return <LoadingPage />;
return (
<Card>
<CardHeader>
<CardTitle>Documenti personali</CardTitle>
<CardDescription>
<p>I tuoi documenti sono necessari per usufruire del servizio.</p>
<p>
Nel caso di un unico documento (es: scansione fronte e retro del
documento) caricarlo 2 volte.
</p>
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<div>
<h4 className="mb-2 font-medium">
Documento d'identità - Fronte
</h4>
{userData.doc_personale_fronte?.storageId ? (
<div className="flex items-center gap-4">
<ImageFlbk
alt="documento personale fronte"
className="size-24 rounded-md object-cover"
height={50}
src={`/storage-api/get/${userData.doc_personale_fronte.storageId}`}
width={50}
/>
<div className="flex flex-col justify-center gap-2">
<Link
href={`/area-riservata/allegato-view/${userData.doc_personale_fronte.storageId}`}
target="_blank"
>
<Button size="sm">
<span>Visualizza</span>
<ExternalLink />
</Button>
</Link>
<Confirm
description="Sei sicuro di voler eliminare il documento?"
onConfirm={() => {
mutate({
userId: userData.id,
data: { doc_personale_fronte_ref: null },
});
}}
title="Elimina Documento"
>
<Button
aria-label="Elimina Doc"
size="sm"
variant="destructive"
>
<span>Elimina Documento</span>
<Trash2 />
</Button>
</Confirm>
</div>
</div>
) : (
<UploadModal
cb_onUpload={({ userStorageIds }) =>
userStorageIds.length > 0 &&
userStorageIds[0] &&
handleUpload("doc_personale_fronte_ref", userStorageIds[0])
}
isAdmin={user.isAdmin}
maxFiles={1}
userId={userData.id}
/>
)}
</div>
</div>
<div className="flex flex-col gap-4">
<div>
<h4 className="mb-2 font-medium">Documento d'identità - Retro</h4>
{userData.doc_personale_retro?.storageId ? (
<div className="flex items-center gap-4">
<ImageFlbk
alt="documento personale retro"
className="size-24 rounded-md object-cover"
height={50}
src={`/storage-api/get/${userData.doc_personale_retro.storageId}`}
width={50}
/>
<div className="flex flex-col justify-center gap-2">
<Link
href={`/area-riservata/allegato-view/${userData.doc_personale_retro.storageId}`}
target="_blank"
>
<Button size="sm">
<span>Visualizza</span>
<ExternalLink />
</Button>
</Link>
<Confirm
description="Sei sicuro di voler eliminare il documento?"
onConfirm={() => {
mutate({
userId: userData.id,
data: { doc_personale_retro_ref: null },
});
}}
title="Elimina Documento"
>
<Button
aria-label="Elimina Doc"
size="sm"
variant="destructive"
>
<span>Elimina Documento</span>
<Trash2 />
</Button>
</Confirm>
</div>
</div>
) : (
<UploadModal
cb_onUpload={({ userStorageIds }) =>
userStorageIds.length > 0 &&
userStorageIds[0] &&
handleUpload("doc_personale_retro_ref", userStorageIds[0])
}
isAdmin={user.isAdmin}
maxFiles={1}
userId={userData.id}
/>
)}
</div>
</div>
</div>
</CardContent>
<CardFooter>
<p className="text-muted-foreground text-sm">
I tuoi dati saranno conservati solamente per la durata del servizio,
in conformità con la nostra{" "}
<Link className="underline" href="/privacy-policy" target="_blank">
Informativa sulla Privacy
</Link>
</p>
</CardFooter>
</Card>
);
};

View file

@ -57,7 +57,7 @@ export const AdminLavoraConferma = () => {
Conferma
</Button>
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh] w-full px-3 sm:max-w-4xl">
<CredenzaContent className="max-h-[90vh] w-full px-3 sm:max-w-6xl">
<CredenzaHeader>
<CredenzaTitle>Lavorazione conferma</CredenzaTitle>
<CredenzaDescription className="sr-only">
@ -65,7 +65,7 @@ export const AdminLavoraConferma = () => {
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="max-h-[80vh] w-full overflow-y-auto pb-5">
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<DocSection />
<Caparra />
{servizio.tipologia === TipologiaPosizioneEnum.Transitorio && (

View file

@ -115,7 +115,7 @@ const DocCheckpoint = () => {
const { t } = useTranslation();
const { userId, servizio } = useServizio();
const utils = api.useUtils();
const { mutate, isPending } = api.servizio.updateServizio.useMutation({
const { mutate, isPending } = api.users.editUserAnagrafica.useMutation({
onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate({
userId,
@ -134,8 +134,10 @@ const DocCheckpoint = () => {
id: UsersStorageUserStorageId,
) => {
mutate({
servizioId: servizio.servizio_id,
data: { [field]: id },
userId,
data: {
[field]: id,
},
});
};
return (

View file

@ -66,7 +66,9 @@ export const ProfileFormAccount = ({
toast.success(t.profile.aggiornato);
await utils.auth.getSession.invalidate();
await utils.users.getUser.invalidate();
await utils.users.getClientProfilo.invalidate();
await utils.users.getClientProfilo.invalidate({
userId: id,
});
},
});

View file

@ -210,15 +210,16 @@ export const ProfileFormAnagrafica = ({
toast.success(t.profile.aggiornato);
await utils.auth.getSession.invalidate();
await utils.users.getUser.invalidate();
await utils.users.getClientProfilo.invalidate();
await utils.users.getClientProfilo.invalidate({
userId: userData.id,
});
},
});
function onSubmit(fields: ProfileFormValues) {
mutate({
...fields,
idanagrafica: userData.idanagrafica || undefined,
userid: userData.id,
userId: userData.id,
data: { ...fields },
});
}
const nazioni_options = GetNazioni();

View file

@ -1,5 +1,6 @@
import {
Fingerprint,
IdCard,
KeyRound,
Landmark,
Lock,
@ -10,6 +11,7 @@ import {
import type { GetServerSideProps } from "next";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import { DocumentiPersonali } from "~/components/area-riservata/documenti_personali";
import { AreaRiservataLayoutUserView } from "~/components/Layout";
import { LoadingPage } from "~/components/loading";
import { Status500 } from "~/components/status-page";
@ -46,7 +48,7 @@ const EditUser: NextPageWithLayout<EditUserProps> = ({
const router = useRouter();
const utils = api.useUtils();
const { data: userData, isLoading } = api.users.getClientProfilo.useQuery({
id: userId,
userId,
});
const { mutate: delete_mutate } = api.users.deleteUser.useMutation({
onSuccess: async () => {
@ -60,7 +62,9 @@ const EditUser: NextPageWithLayout<EditUserProps> = ({
const { mutate: ban_mutate } = api.users.blockUser.useMutation({
onSuccess: async (data) => {
await utils.users.getUsers.invalidate();
await utils.users.getClientProfilo.invalidate();
await utils.users.getClientProfilo.invalidate({
userId,
});
if (data[0]?.isBlocked) {
toast("Utente bloccato con successo", { icon: "🔒" });
} else {
@ -93,6 +97,13 @@ const EditUser: NextPageWithLayout<EditUserProps> = ({
<Fingerprint className="size-5" />
Anagrafica
</TabsTrigger>
<TabsTrigger
className="flex items-center gap-1"
value="documenti"
>
<IdCard className="size-5" />
Documenti
</TabsTrigger>
<TabsTrigger
className="flex items-center gap-1"
value="password"
@ -184,6 +195,9 @@ const EditUser: NextPageWithLayout<EditUserProps> = ({
<TabsContent className="mt-5 h-full space-y-6" value="anagrafica">
<ProfileFormAnagrafica userData={userData} />
</TabsContent>
<TabsContent className="mt-5 h-full space-y-6" value="documenti">
<DocumentiPersonali userData={userData} />
</TabsContent>
<TabsContent className="mt-5 space-y-6" value="password">
<OverridePasswordForm id={userData.id} />
</TabsContent>
@ -226,7 +240,7 @@ export const getServerSideProps = (async (context) => {
const helpers = await TrpcAuthedFetchingIstance({ access_token });
if (helpers) {
await helpers.trpc.users.getClientProfilo.prefetch({
id: parsed.data,
userId: parsed.data,
});
return {
props: {

View file

@ -1,5 +1,5 @@
import type { GetServerSideProps } from "next";
import Head from "next/head";
import { DocumentiPersonali } from "~/components/area-riservata/documenti_personali";
import { AreaRiservataLayout } from "~/components/Layout";
import { LoadingPage } from "~/components/loading";
import { Status500 } from "~/components/status-page";
@ -18,13 +18,14 @@ import { ProfileFormAnagrafica } from "~/forms/FormProfilo_Anagrafica";
import type { NextPageWithLayout } from "~/pages/_app";
import { useTranslation } from "~/providers/I18nProvider";
import { useEnforcedSession } from "~/providers/SessionProvider";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { api } from "~/utils/api";
const Dashboard: NextPageWithLayout = () => {
const { t } = useTranslation();
const { user } = useEnforcedSession();
const { data: userData, isLoading } = api.users.getClientProfilo.useQuery({
id: undefined,
userId: user.id,
});
const session = useEnforcedSession();
if (isLoading) return <LoadingPage />;
@ -58,6 +59,7 @@ const Dashboard: NextPageWithLayout = () => {
<TabsList className="mb-2">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="anagrafica">Anagrafica</TabsTrigger>
<TabsTrigger value="documenti">Documenti</TabsTrigger>
</TabsList>
<TabsContent className="flex flex-col gap-8" value="account">
<Card>
@ -106,6 +108,9 @@ const Dashboard: NextPageWithLayout = () => {
</CardContent>
</Card>
</TabsContent>
<TabsContent className="h-full space-y-6" value="documenti">
<DocumentiPersonali userData={userData} />
</TabsContent>
</Tabs>
</div>
</div>
@ -116,23 +121,6 @@ const Dashboard: NextPageWithLayout = () => {
};
export default Dashboard;
export const getServerSideProps = (async (context) => {
const access_token = context.req.cookies.access_token;
const helpers = await TrpcAuthedFetchingIstance({ access_token });
if (helpers) {
await helpers.trpc.users.getClientProfilo.prefetch({ id: undefined });
return {
props: {
trpcState: helpers.trpc.dehydrate(),
},
};
}
return {
props: {},
};
}) satisfies GetServerSideProps;
Dashboard.getLayout = function getLayout(page) {
return <AreaRiservataLayout>{page}</AreaRiservataLayout>;
};

View file

@ -61,10 +61,6 @@ export default interface ServizioTable {
isOkConsulenza: ColumnType<boolean, boolean | undefined, boolean>;
doc_personale_fronte_ref: ColumnType<UsersStorageUserStorageId | null, UsersStorageUserStorageId | null, UsersStorageUserStorageId | null>;
doc_personale_retro_ref: ColumnType<UsersStorageUserStorageId | null, UsersStorageUserStorageId | null, UsersStorageUserStorageId | null>;
doc_motivazione_ref: ColumnType<UsersStorageUserStorageId | null, UsersStorageUserStorageId | null, UsersStorageUserStorageId | null>;
scadenza_motivazione_transitoria: ColumnType<Date | null, Date | string | null, Date | string | null>;

View file

@ -5,8 +5,13 @@ import type { ServizioServizioId } from './Servizio';
import type { AnnunciId } from './Annunci';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.servizio_annunci */
export type ServizioAnnunciServizioAnnunciId = number & { __brand: 'public.servizio_annunci' };
/** Represents the table public.servizio_annunci */
export default interface ServizioAnnunciTable {
servizio_annunci_id: ColumnType<ServizioAnnunciServizioAnnunciId, ServizioAnnunciServizioAnnunciId | undefined, ServizioAnnunciServizioAnnunciId>;
servizio_id: ColumnType<ServizioServizioId, ServizioServizioId, ServizioServizioId>;
annunci_id: ColumnType<AnnunciId, AnnunciId, AnnunciId>;

View file

@ -2,6 +2,7 @@
// This file is automatically generated by Kanel. Do not modify manually.
import type { UsersId } from './Users';
import type { UsersStorageUserStorageId } from './UsersStorage';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.users_anagrafica */
@ -48,6 +49,10 @@ export default interface UsersAnagraficaTable {
codice_destinatario: ColumnType<string | null, string | null, string | null>;
legale_rappresentante: ColumnType<string | null, string | null, string | null>;
doc_personale_fronte_ref: ColumnType<UsersStorageUserStorageId | null, UsersStorageUserStorageId | null, UsersStorageUserStorageId | null>;
doc_personale_retro_ref: ColumnType<UsersStorageUserStorageId | null, UsersStorageUserStorageId | null, UsersStorageUserStorageId | null>;
}
export type UsersAnagrafica = Selectable<UsersAnagraficaTable>;

View file

@ -52,9 +52,14 @@ export const usersRouter = createTRPCRouter({
return await editAccountHandler({ ctx, input });
}),
editUserAnagrafica: protectedProcedure
.input(z.custom<UsersAnagraficaUpdate>())
.input(
z.object({
userId: zUserId,
data: z.custom<UsersAnagraficaUpdate>(),
}),
)
.mutation(async ({ input }) => {
return await editAnagraficaHandler({ input });
return await editAnagraficaHandler({ ...input });
}),
getActiveUsersWithoutChat: adminProcedure.query(async () => {
return await getActiveUserWithoutChatHandler({ db });
@ -62,15 +67,13 @@ export const usersRouter = createTRPCRouter({
getClientProfilo: protectedProcedure
.input(
z.object({
id: zUserId.optional(),
userId: zUserId,
}),
)
.query(async ({ ctx, input }) => {
const userId = input.id || ctx.session.id;
.query(async ({ input }) => {
return getUserWithAnagraficaHandler({
db,
userId: userId,
userId: input.userId,
});
}),
getUser: protectedProcedure

View file

@ -613,6 +613,11 @@ export const getAllServizioAnnunci = async (userId: UsersId) => {
return await db
.selectFrom("servizio")
.where("user_id", "=", userId)
.leftJoin(
"users_anagrafica",
"servizio.user_id",
"users_anagrafica.userid",
)
.select((eb) => [
"servizio.servizio_id",
"servizio.created_at",
@ -632,12 +637,12 @@ export const getAllServizioAnnunci = async (userId: UsersId) => {
.selectFrom("users_storage")
.select([
"users_storage.storageId as storageId",
"servizio.doc_personale_fronte_ref as ref",
"users_anagrafica.doc_personale_fronte_ref as ref",
])
.whereRef(
"users_storage.user_storage_id",
"=",
"servizio.doc_personale_fronte_ref",
"users_anagrafica.doc_personale_fronte_ref",
),
).as("doc_personale_fronte"),
jsonObjectFrom(
@ -645,12 +650,12 @@ export const getAllServizioAnnunci = async (userId: UsersId) => {
.selectFrom("users_storage")
.select([
"users_storage.storageId as storageId",
"servizio.doc_personale_retro_ref as ref",
"users_anagrafica.doc_personale_retro_ref as ref",
])
.whereRef(
"users_storage.user_storage_id",
"=",
"servizio.doc_personale_retro_ref",
"users_anagrafica.doc_personale_retro_ref",
),
).as("doc_personale_retro"),
jsonObjectFrom(

View file

@ -3,7 +3,6 @@ import type { Users, UsersId, UsersUpdate } from "~/schemas/public/Users";
import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica";
import type { Context } from "~/server/api/trpc";
import { db, type Querier } from "~/server/db";
import { upsertAnagrafica } from "~/server/services/user.service";
import { checkCtxAdmin, checkCtxSession } from "~/server/utils/ctxChecker";
type EditAccountInput = {
@ -58,14 +57,66 @@ export const editAccountHandler = async ({
};
export const editAnagraficaHandler = async ({
input,
userId,
data,
}: {
input: UsersAnagraficaUpdate;
userId: UsersId;
data: UsersAnagraficaUpdate;
}) => {
return await upsertAnagrafica({
data: input,
db,
});
try {
// biome-ignore lint/correctness/noUnusedVariables: <ignore>
const { idanagrafica, userid, ...rest } = data;
if (!userId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Errore interno, utente non trovato",
});
}
// if (anagraficaId) {
// return await db
// .updateTable("users_anagrafica")
// .set({
// ...rest,
// })
// .where("userid", "=", userId)
// .returningAll()
// .executeTakeFirst();
// }
// const searched = await db
// .selectFrom("users_anagrafica")
// .select("idanagrafica")
// .where("userid", "=", userId)
// .executeTakeFirst();
// if (searched) {
// return await db
// .updateTable("users_anagrafica")
// .set({
// ...rest,
// })
// .where("userid", "=", userId)
// .returningAll()
// .executeTakeFirst();
// }
return await db
.insertInto("users_anagrafica")
.values({
...rest,
userid: userId,
})
.onConflict((oc) =>
oc.column("userid").doUpdateSet({
...rest,
}),
)
.returningAll()
.executeTakeFirst();
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore updateAnagrafica: ${(e as Error).message}`,
});
}
};
export const deleteUserHandler = async ({

View file

@ -1,6 +1,6 @@
import { TRPCError } from "@trpc/server";
import { jsonObjectFrom } from "kysely/helpers/postgres";
import type { NewUsers, UsersId } from "~/schemas/public/Users";
import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica";
import type { Session } from "~/server/api/trpc";
import { db, type Querier } from "~/server/db";
@ -125,6 +125,34 @@ export const getUserWithAnagraficaHandler = async ({
.where("id", "=", userId)
.leftJoin("users_anagrafica", "users_anagrafica.userid", "users.id")
.selectAll("users_anagrafica")
.select((eb) => [
jsonObjectFrom(
eb
.selectFrom("users_storage")
.select([
"users_storage.storageId as storageId",
"users_anagrafica.doc_personale_fronte_ref as ref",
])
.whereRef(
"users_storage.user_storage_id",
"=",
"users_anagrafica.doc_personale_fronte_ref",
),
).as("doc_personale_fronte"),
jsonObjectFrom(
eb
.selectFrom("users_storage")
.select([
"users_storage.storageId as storageId",
"users_anagrafica.doc_personale_retro_ref as ref",
])
.whereRef(
"users_storage.user_storage_id",
"=",
"users_anagrafica.doc_personale_retro_ref",
),
).as("doc_personale_retro"),
])
.executeTakeFirstOrThrow();
} catch (e) {
throw new TRPCError({
@ -133,45 +161,3 @@ export const getUserWithAnagraficaHandler = async ({
});
}
};
export const upsertAnagrafica = async ({
db,
data,
}: {
db: Querier;
data: UsersAnagraficaUpdate;
}) => {
try {
const { idanagrafica, userid, ...rest } = data;
if (!userid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Errore interno, utente non trovato",
});
}
if (idanagrafica) {
return await db
.updateTable("users_anagrafica")
.set({
...rest,
})
.where("userid", "=", userid)
.returningAll()
.executeTakeFirst();
}
return await db
.insertInto("users_anagrafica")
.values({
userid,
...rest,
})
.onConflict((oc) => oc.column("userid").doNothing())
.returningAll()
.executeTakeFirst();
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore updateAnagrafica: ${(e as Error).message}`,
});
}
};