feat: enhance email management with delete functionality and session handling

This commit is contained in:
Marco Pedone 2025-12-01 12:29:48 +01:00
parent 830b89d4b1
commit d93d23c607
7 changed files with 168 additions and 55 deletions

View file

@ -5,7 +5,7 @@
"build": "next build", "build": "next build",
"d": "npm run-p dev stripe-dev -cl", "d": "npm run-p dev stripe-dev -cl",
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"dev-https": "cross-env NEXT_PUBLIC_BASE_URL='https://localhost:3000' INTERNAL_BASE_URL='https://localhost:3000' next dev --experimental-https --turbopack", "dev-https": "cross-env NEXT_PUBLIC_BASE_URL='https://localhost:3000' INTERNAL_BASE_URL='https://localhost:3000' BASE_URL='https://localhost:3000' next dev --experimental-https --turbopack",
"email": "cross-env NEXT_PUBLIC_BASE_URL='https://localhost:3000' email dev --port 3500", "email": "cross-env NEXT_PUBLIC_BASE_URL='https://localhost:3000' email dev --port 3500",
"idev": "cross-env NODE_OPTIONS='--inspect' next dev --turbopack", "idev": "cross-env NODE_OPTIONS='--inspect' next dev --turbopack",
"knip": "knip --no-exit-code --no-config-hints", "knip": "knip --no-exit-code --no-config-hints",

View file

@ -164,14 +164,21 @@ export const AreaRiservataLayoutUserView = ({
children: ReactNode & { props: { userId: UsersId } }; children: ReactNode & { props: { userId: UsersId } };
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession();
const props = children.props as { userId: UsersId }; const props = children.props as { userId: UsersId };
if (session.status === "pending") {
return <LoadingPage />;
}
if (session.status === "error" || !session.user) {
window.location.reload();
return <LoadingPage />;
}
return ( return (
<div className="flex h-full min-h-screen flex-col"> <div className="flex h-full min-h-screen flex-col">
<SiteHeader /> <SiteHeader />
<main className="flex h-full flex-1 overflow-auto"> <main className="flex h-full flex-1 overflow-auto">
<UserViewProvider userId={props.userId}> <UserViewProvider userId={props.userId}>
<EnforcedSessionContext.Provider value={session as ValidSession}>
<Head> <Head>
<title>{t.heads.area_riservata_titolo}</title> <title>{t.heads.area_riservata_titolo}</title>
<meta <meta
@ -191,6 +198,7 @@ export const AreaRiservataLayoutUserView = ({
{children} {children}
</div> </div>
</div> </div>
</EnforcedSessionContext.Provider>
</UserViewProvider> </UserViewProvider>
</main> </main>

View file

@ -1,4 +1,5 @@
import { useState } from "react"; import { type IframeHTMLAttributes, useState } from "react";
import toast from "react-hot-toast";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@ -8,47 +9,84 @@ import {
import { useMediaQuery } from "~/hooks/use-media-query"; import { useMediaQuery } from "~/hooks/use-media-query";
import type { UsersId } from "~/schemas/public/Users"; import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { Button } from "../ui/button";
export const EmailAccordion = ({ userId }: { userId: UsersId }) => { export const EmailAccordion = ({
userId,
isAdmin,
}: {
userId: UsersId;
isAdmin: boolean;
}) => {
const { data } = api.comunicazioni.getEmails.useQuery({ const { data } = api.comunicazioni.getEmails.useQuery({
userId, userId,
}); });
const utils = api.useUtils();
const { mutate: deleteEmail } = api.comunicazioni.deleteEmail.useMutation({
onSuccess: async () => {
toast.success("Email eliminata con successo");
setOpenEmail("unset");
await utils.comunicazioni.getEmails.invalidate({ userId });
},
});
// Track which accordion item is open const [openEmail, setOpenEmail] = useState<string | "unset">("unset");
const [openIndex, setOpenIndex] = useState<string | undefined>(undefined);
const mobile = useMediaQuery("(max-width: 768px)"); const mobile = useMediaQuery("(max-width: 768px)");
if (!data || data.length === 0) { if (!data || data.length === 0) {
return <div className="text-center">Nessuna comunicazione trovata</div>; return <div className="text-center">Nessuna comunicazione trovata</div>;
} }
const iframeHeight = mobile ? 600 : 500;
return ( return (
<div className="mx-auto py-4"> <div className="mx-auto py-4">
<Accordion <Accordion
className="space-y-4" className="space-y-4"
collapsible collapsible
onValueChange={setOpenIndex} onValueChange={setOpenEmail}
type="single" type="single"
value={openIndex} value={openEmail}
> >
{data.map((email, index) => ( {data.map((email) => {
const isOpen = openEmail === email.id_email.toString();
return (
<AccordionItem <AccordionItem
className="space-y-2 border-none" className="space-y-2 border-none"
key={email.title} key={email.id_email}
value={String(index)} value={email.id_email.toString()}
> >
<AccordionTrigger className="rounded-md bg-muted px-4 py-2"> <AccordionTrigger className="rounded-md bg-muted px-4 py-2">
Email &ldquo;{email.title}&rdquo; del{" "} &ldquo;{email.title}&rdquo; -{" "}
{new Date(email.created_at).toLocaleDateString("it-IT", { {new Date(email.created_at).toLocaleDateString("it-IT", {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "numeric", year: "numeric",
hour: "2-digit",
minute: "2-digit",
})} })}
</AccordionTrigger> </AccordionTrigger>
<AccordionContent> <AccordionContent className="overflow-clip rounded-md border">
{openIndex === String(index) && ( {isAdmin && (
<iframe <div className="flex h-7 w-full items-center justify-end bg-secondary px-4">
height={mobile ? "600px" : "500px"} <Button
className="h-5"
onClick={() =>
confirm(
"Sei sicuro di voler eliminare questa email?",
) &&
deleteEmail({
id_email: email.id_email,
})
}
variant="destructive"
>
Rimuovi
</Button>
</div>
)}
{isOpen && (
<LoadingEmailIframe
height={iframeHeight}
srcDoc={email.html} srcDoc={email.html}
title="Email Content" title="Email Content"
width="100%" width="100%"
@ -56,8 +94,41 @@ export const EmailAccordion = ({ userId }: { userId: UsersId }) => {
)} )}
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
))} );
})}
</Accordion> </Accordion>
</div> </div>
); );
}; };
const LoadingEmailIframe = (props: IframeHTMLAttributes<HTMLIFrameElement>) => {
const [loaded, setLoaded] = useState(false);
return (
<div
className="relative overflow-hidden"
style={{ height: props.height, width: props.width }}
>
<div
className={`absolute inset-0 flex items-center justify-center bg-muted transition-opacity duration-1000 ${loaded ? "pointer-events-none opacity-0" : "opacity-100"}
`}
>
<div className="flex flex-col items-center gap-3">
<div className="size-8 animate-spin rounded-full border-4 border-muted-foreground/20 border-t-primary" />
<p className="animate-pulse text-muted-foreground text-sm">
Caricamento email...
</p>
</div>
</div>
{/** biome-ignore lint/a11y/noNoninteractiveElementInteractions: <fine here> */}
<iframe
{...props}
className={`absolute inset-0 transition-opacity duration-1000 ${loaded ? "opacity-100" : "opacity-0"}
`}
onLoad={() => setLoaded(true)}
title={props.title ?? "Email preview"}
/>
</div>
);
};

View file

@ -2,6 +2,7 @@ import type { GetServerSideProps } from "next";
import { EmailAccordion } from "~/components/area-riservata/comunicazioni"; import { EmailAccordion } from "~/components/area-riservata/comunicazioni";
import { AreaRiservataLayoutUserView } from "~/components/Layout"; import { AreaRiservataLayoutUserView } from "~/components/Layout";
import type { NextPageWithLayout } from "~/pages/_app"; import type { NextPageWithLayout } from "~/pages/_app";
import { useEnforcedSession } from "~/providers/SessionProvider";
import type { UsersId } from "~/schemas/public/Users"; import type { UsersId } from "~/schemas/public/Users";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper"; import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zUserId } from "~/server/utils/zod_types"; import { zUserId } from "~/server/utils/zod_types";
@ -13,6 +14,7 @@ type ComunicazioniUserProps = {
const ComunicazioniUser: NextPageWithLayout<ComunicazioniUserProps> = ({ const ComunicazioniUser: NextPageWithLayout<ComunicazioniUserProps> = ({
userId, userId,
}: ComunicazioniUserProps) => { }: ComunicazioniUserProps) => {
const { user } = useEnforcedSession();
return ( return (
<main className="flex flex-1 flex-col items-start gap-4 md:gap-8"> <main className="flex flex-1 flex-col items-start gap-4 md:gap-8">
<div className="grid w-full auto-rows-max items-start gap-2 md:gap-4"> <div className="grid w-full auto-rows-max items-start gap-2 md:gap-4">
@ -20,7 +22,7 @@ const ComunicazioniUser: NextPageWithLayout<ComunicazioniUserProps> = ({
<h3 className="font-bold text-2xl">Comunicazioni</h3> <h3 className="font-bold text-2xl">Comunicazioni</h3>
<div className="w-full py-4"> <div className="w-full py-4">
<EmailAccordion userId={userId} /> <EmailAccordion isAdmin={user.isAdmin} userId={userId} />
</div> </div>
</div> </div>
</div> </div>

View file

@ -21,7 +21,10 @@ const Comunicazioni: NextPageWithLayout = () => {
<h1 className="font-bold text-2xl text-primary">Comunicazioni</h1> <h1 className="font-bold text-2xl text-primary">Comunicazioni</h1>
</div> </div>
<EmailAccordion userId={session.user.id} /> <EmailAccordion
isAdmin={session.user.isAdmin}
userId={session.user.id}
/>
</div> </div>
</> </>
); );

View file

@ -1,10 +1,11 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { EmailsIdEmail } from "~/schemas/public/Emails";
import { import {
adminProcedure, adminProcedure,
createTRPCRouter, createTRPCRouter,
protectedProcedure, protectedProcedure,
} from "~/server/api/trpc"; } from "~/server/api/trpc";
import { GetEmails } from "~/server/services/email.service"; import { DeleteEmail, GetEmails } from "~/server/services/email.service";
import { NewMail } from "~/server/services/mailer"; import { NewMail } from "~/server/services/mailer";
import { zUserId } from "~/server/utils/zod_types"; import { zUserId } from "~/server/utils/zod_types";
@ -27,6 +28,17 @@ export const comunicazioniRouter = createTRPCRouter({
userId: input.userId, userId: input.userId,
}); });
}), }),
deleteEmail: adminProcedure
.input(
z.object({
id_email: z.custom<EmailsIdEmail>(),
}),
)
.mutation(async ({ input }) => {
return await DeleteEmail({
id_email: input.id_email,
});
}),
// storeEmail: publicProcedure // storeEmail: publicProcedure
// .input( // .input(
// z.object({ // z.object({

View file

@ -1,4 +1,5 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import type { EmailsIdEmail } from "~/schemas/public/Emails";
import type { UsersId } from "~/schemas/public/Users"; import type { UsersId } from "~/schemas/public/Users";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { genMail, type MailsTemplates } from "~/server/services/mailer"; import { genMail, type MailsTemplates } from "~/server/services/mailer";
@ -41,6 +42,7 @@ export const GetEmails = async ({ userId }: { userId: UsersId }) => {
return { return {
...(await genMail(mail.data as MailsTemplates)), ...(await genMail(mail.data as MailsTemplates)),
created_at: mail.created_at, created_at: mail.created_at,
id_email: mail.id_email,
}; };
}), }),
); );
@ -53,3 +55,18 @@ export const GetEmails = async ({ userId }: { userId: UsersId }) => {
}); });
} }
}; };
export const DeleteEmail = async ({
id_email,
}: {
id_email: EmailsIdEmail;
}) => {
try {
await db.deleteFrom("emails").where("id_email", "=", id_email).execute();
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore durante l'eliminazione dell'email: ${(e as Error).message}`,
});
}
};