feat: add email queue management features

- Implemented FormNewMail for creating new email entries in the queue.
- Enhanced AdminPotenziali page layout for better responsiveness.
- Added Coda component to manage user email queues, including adding, removing, and clearing emails.
- Updated API routes for managing email queue operations, including adding, removing, and clearing user email queues.
- Introduced new mail templates and integrated them into the mailer service.
- Improved error handling in the email queue controller and service.
- Added Zod validation for event IDs in the utility types.
This commit is contained in:
Marco Pedone 2026-04-03 11:51:55 +02:00
parent 2acd6eca92
commit 964abfbf46
29 changed files with 1178 additions and 209 deletions

View file

@ -1,18 +1,20 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components";
import z from "zod";
import { env } from "~/env";
import Base from "./base";
export type VerificaEmail_NewMail = {
mailType: "verificaEmail";
props: VerificaEmailProps;
};
type VerificaEmailProps = {
token: string;
userId: string;
redirect?: string;
};
export const VerificaEmailMailTag = "verificaEmail";
export const VerificaEmailPropsSchema = z.object({
token: z.string(),
userId: z.string(),
redirect: z.string().optional(),
});
const VerificaEmail = ({ token, userId, redirect }: VerificaEmailProps) => {
const VerificaEmail = ({
token,
userId,
redirect,
}: z.infer<typeof VerificaEmailPropsSchema>) => {
return (
<Base noreply preview="Verifica la tua email">
<>

View file

@ -1,8 +1,7 @@
import { Heading, Link, Row, Section, Text } from "@react-email/components";
import Base from "./base";
export type AvvisoInterruzione_NewMail = {
mailType: "avvisoInterruzione";
};
export const AvvisoInterruzioneMailTag = "avvisoInterruzione";
const AvvisoInterruzione = () => {
return (

View file

@ -1,25 +1,24 @@
import { Heading, Row, Section, Text } from "@react-email/components";
import z from "zod";
import Base from "./base";
export type ContattoAdmin_NewMail = {
mailType: "contattoAdminEmail";
props: ContattoAdminEmailProps;
};
export const ContattoAdminMailTag = "contattoAdminEmail";
export const ContattoAdminEmailPropsSchema = z.object({
nome: z.string(),
cognome: z.string(),
email: z.email(),
telefono: z.string(),
messaggio: z.string(),
});
type ContattoAdminEmailProps = {
nome: string;
cognome: string;
email: string;
telefono: string;
messaggio: string;
};
const ContattoAdminEmail = ({
nome,
cognome,
email,
telefono,
messaggio,
}: ContattoAdminEmailProps) => {
}: z.infer<typeof ContattoAdminEmailPropsSchema>) => {
return (
<Base preview="Contatto Email">
<>

View file

@ -1,26 +1,25 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components";
import z from "zod";
import { env } from "~/env";
import Base from "./base";
export type ContattoAnnuncio_NewMail = {
mailType: "contattoAnnuncioEmail";
props: ContattoAnnuncioEmailProps;
};
export const ContattoAnnuncioMailTag = "contattoAnnuncioEmail";
export const ContattoAnnuncioPropsSchema = z.object({
nome: z.string(),
codice: z.string(),
email: z.email(),
telefono: z.string(),
messaggio: z.string(),
});
type ContattoAnnuncioEmailProps = {
nome: string;
codice: string;
email: string;
telefono: string;
messaggio: string;
};
const ContattoAnnuncioEmail = ({
nome,
codice,
email,
telefono,
messaggio,
}: ContattoAnnuncioEmailProps) => {
}: z.infer<typeof ContattoAnnuncioPropsSchema>) => {
return (
<Base preview="Contatto Email">
<>

View file

@ -1,16 +1,15 @@
import { Heading, Row, Section, Text } from "@react-email/components";
import z from "zod";
import Base from "./base";
export type ContattoInteressato_NewMail = {
mailType: "emailContattoInteressato";
props: EmailContattoInteressatoProps;
};
type EmailContattoInteressatoProps = {
nome_cognome: string;
nome_cognome_utente: string;
telefono: string;
indirizzo: string | null;
sesso: string | null;
};
export const ContattoInteressatoMailTag = "emailContattoInteressato";
export const ContattoInteressatoPropsSchema = z.object({
nome_cognome: z.string(),
nome_cognome_utente: z.string(),
telefono: z.string(),
indirizzo: z.string().nullable(),
sesso: z.enum(["M", "F"]).nullable(),
});
const EmailContattoInteressato = ({
nome_cognome,
@ -18,7 +17,7 @@ const EmailContattoInteressato = ({
telefono,
indirizzo,
sesso,
}: EmailContattoInteressatoProps) => {
}: z.infer<typeof ContattoInteressatoPropsSchema>) => {
return (
<Base preview="Contatto Email">
<>

View file

@ -1,27 +1,33 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components";
import type { ReactNode } from "react";
import z from "zod";
import Base from "./base";
export type Generic_NewMail = {
mailType: "generic";
props: GenericProps;
};
type Corpo = {
testo?: undefined;
corpo: ReactNode;
};
type Testo = {
testo: string;
corpo?: undefined;
};
type GenericProps = {
title: string;
noreply?: boolean;
link?: {
href: string;
label?: string;
};
} & (Corpo | Testo);
export const GenericMailTag = "generic";
const props_base = z.object({
title: z.string(),
noreply: z.boolean().optional(),
link: z
.object({
href: z.string(),
label: z.string().optional(),
})
.optional(),
});
const props_content = z.union([
z.object({
corpo: z.custom<ReactNode>(),
testo: z.undefined().optional(),
}),
z.object({
testo: z.string(),
corpo: z.undefined().optional(),
}),
]);
export const GenericPropsSchema = props_base.and(props_content);
const Generic = ({
title,
@ -29,7 +35,7 @@ const Generic = ({
testo,
corpo,
link = undefined,
}: GenericProps) => {
}: z.infer<typeof GenericPropsSchema>) => {
return (
<Base noreply={noreply} preview={title}>
<>

View file

@ -1,8 +1,7 @@
import { Heading, Row, Section, Text } from "@react-email/components";
import Base from "./base";
export type Interruzione_NewMail = {
mailType: "interruzione";
};
export const InterruzioneMailTag = "interruzione";
const Interruzione = () => {
return (

View file

@ -1,23 +1,21 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components";
import z from "zod";
import Base from "./base";
export type Invito_NewMail = {
mailType: "invito";
props: InvitoProps;
};
type InvitoProps = {
nome: string;
//email: string;
//password: string;
inviteUrl: string;
};
export const InvitoMailTag = "invito";
export const InvitoPropsSchema = z.object({
nome: z.string(),
//email: z.email(),
//password: z.string(),
inviteUrl: z.url(),
});
const Invito = ({
nome,
inviteUrl,
//email,
//password
}: InvitoProps) => {
}: z.infer<typeof InvitoPropsSchema>) => {
return (
<Base preview="Procedi ora su Infoalloggi.it">
<>

View file

@ -1,8 +1,7 @@
import { Heading, Row, Section, Text } from "@react-email/components";
import Base from "./base";
export type PagamentoConferma_NewMail = {
mailType: "pagamentoConferma";
};
export const PagamentoConfermaMailTag = "pagamentoConferma";
const PagamentoConferma = () => {
return (

View file

@ -1,9 +1,8 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components";
import { env } from "~/env";
import Base from "./base";
export type PagamentoErrore_NewMail = {
mailType: "pagamentoErrore";
};
export const PagamentoErroreMailTag = "pagamentoErrore";
const PagamentoErrore = () => {
return (

View file

@ -1,9 +1,8 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components";
import { env } from "~/env";
import Base from "./base";
export type PagamentoRicezione_NewMail = {
mailType: "pagamentoRicezione";
};
export const PagamentoRicezioneMailTag = "pagamentoRicezione";
const PagamentoRicezione = () => {
return (

View file

@ -1,14 +1,14 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components";
import z from "zod";
import Base from "./base";
export type PwResetLink_NewMail = {
mailType: "pwResetLink";
props: PwResetLinkProps;
};
type PwResetLinkProps = {
resetlink: string;
};
const PwResetLink = ({ resetlink }: PwResetLinkProps) => {
export const PwResetLinkMailTag = "pwResetLink";
export const PwResetLinkPropsSchema = z.object({
resetlink: z.string(),
});
const PwResetLink = ({ resetlink }: z.infer<typeof PwResetLinkPropsSchema>) => {
return (
<Base preview="Link Reset Password">
<>

View file

@ -1,8 +1,7 @@
import { Heading, Row, Section, Text } from "@react-email/components";
import Base from "./base";
export type Recesso_NewMail = {
mailType: "recesso";
};
export const RecessoMailTag = "recesso";
const Recesso = () => {
return (
<Base preview="Richiesta di recesso">

View file

@ -1,10 +1,8 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components";
import { env } from "~/env";
import Base from "./base";
export type RegistrazioneAvvenuta_NewMail = {
mailType: "registrazioneAvvenuta";
};
export const RegistrazioneAvvenutaMailTag = "registrazioneAvvenuta";
const RegistrazioneAvvenuta = () => {
return (
<Base preview="Registrazione Avvenuta">

View file

@ -1,20 +1,18 @@
import { Heading, Row, Section, Text } from "@react-email/components";
import z from "zod";
import Base from "./base";
export type SchedaContatto_NewMail = {
mailType: "emailSchedaContatto";
props: EmailSchedaContattoProps;
};
type EmailSchedaContattoProps = {
nominativo: string;
telefono: string;
indirizzo: string;
};
export const SchedaContattoMailTag = "emailSchedaContatto";
export const SchedaContattoPropsSchema = z.object({
nominativo: z.string(),
telefono: z.string(),
indirizzo: z.string(),
});
const EmailSchedaContatto = ({
nominativo,
telefono,
indirizzo,
}: EmailSchedaContattoProps) => {
}: z.infer<typeof SchedaContattoPropsSchema>) => {
return (
<Base noreply={true} preview="Dati Contatto Annuncio - Infoalloggi.it">
<>

View file

@ -1,8 +1,7 @@
import { Heading, Row, Section, Text } from "@react-email/components";
import Base from "./base";
export type ServizioAttivato_NewMail = {
mailType: "servizioAttivato";
};
export const ServizioAttivatoMailTag = "servizioAttivato";
const ServizioAttivato = () => {
return (

View file

@ -38,7 +38,8 @@ export const EmailAccordion = ({
const iframeHeight = mobile ? 600 : 500;
return (
<div className="mx-auto w-full py-4">
<div className="mx-auto w-full space-y-2 py-4">
<h3>Email inviate:</h3>
<Accordion
className="space-y-4"
collapsible

View file

@ -12,6 +12,16 @@ import {
import { Label } from "src/components/ui/label";
import { cn } from "src/lib/utils";
/*
React Hook Form DevTools, only in development mode.
const DevT: React.ElementType = dynamic(
() => import('@hookform/devtools').then((module) => module.DevTool),
{ ssr: false }
);
*/
const Form = FormProvider;
type FormFieldContextValue<
@ -171,12 +181,12 @@ const FormMessage = React.forwardRef<
FormMessage.displayName = "FormMessage";
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
};

View file

@ -6,7 +6,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
className={cn(
`flex min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 shadow-sm outline-hidden transition-all duration-200 hover:border-muted-foreground focus:border-foreground focus:ring-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:border-input dark:bg-input/30 dark:hover:border-muted-foreground dark:hover:bg-input/50 dark:placeholder:text-neutral-400`,
`flex min-h-20 w-full rounded-lg border border-border bg-card px-3 py-2 shadow-sm outline-hidden transition-all duration-200 hover:border-muted-foreground focus:border-foreground focus:ring-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:border-input dark:bg-input/30 dark:hover:border-muted-foreground dark:hover:bg-input/50 dark:placeholder:text-neutral-400`,
className,
)}
data-slot="textarea"

View file

@ -0,0 +1,165 @@
import { format } from "date-fns";
import { it } from "date-fns/locale";
import { ChevronDownIcon } from "lucide-react";
import { z } from "zod";
import { Confirm } from "~/components/confirm";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/custom_ui/form";
import { Button } from "~/components/ui/button";
import { Calendar } from "~/components/ui/calendar";
import Input from "~/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { Textarea } from "~/components/ui/textarea";
import { useZodForm } from "~/lib/zodForm";
import { useTranslation } from "~/providers/I18nProvider";
import type { EventQueue, EventQueueUpdate } from "~/schemas/public/EventQueue";
const Schema = z.object({
lock_date: z.date(),
lock_time: z.string(),
data: z.string(),
});
type FormValues = z.infer<typeof Schema>;
export const FormEditQueueMail = ({
data,
submitMutation,
del,
}: {
data: EventQueue;
submitMutation: (fields: EventQueueUpdate) => void;
del: () => void;
}) => {
const { locale, t } = useTranslation();
z.config(z.locales[locale]());
const form = useZodForm(Schema, {
defaultValues: {
lock_date: new Date(data.lock_expires),
lock_time: format(new Date(data.lock_expires), "HH:mm:ss"),
data: JSON.stringify(data.data, null, 2),
},
});
function onSubmit(fields: FormValues) {
submitMutation({
data: fields.data,
lock_expires: new Date(
format(fields.lock_date, "yyyy-MM-dd", { locale: it }) +
"T" +
fields.lock_time,
),
});
}
return (
<Form {...form}>
<form className="space-y-8 px-0.5" onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid w-full grid-cols-2 items-center gap-4">
<FormField
control={form.control}
name="lock_date"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="titolo">Data Invio</FormLabel>
<FormMessage />
</div>
<FormControl>
<Popover>
<PopoverTrigger asChild>
<Button
className="w-full justify-between bg-card font-normal"
id="date-picker-optional"
variant="outline"
>
{field.value
? format(field.value, "PPP", { locale: it })
: "Seleziona data"}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-auto overflow-hidden p-0"
>
<Calendar
captionLayout="dropdown"
defaultMonth={field.value}
mode="single"
onSelect={(date) => {
field.onChange(date);
}}
selected={field.value}
/>
</PopoverContent>
</Popover>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="lock_time"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="titolo">Ora Invio</FormLabel>
<FormMessage />
</div>
<FormControl>
<Input
className="appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
defaultValue={field.value}
id="time-picker-optional"
onChange={(e) => field.onChange(e.target.value)}
step="1"
type="time"
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="data"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="data">{t.contact_form.messaggio}</FormLabel>
<FormMessage />
</div>
<FormControl>
<Textarea className="min-h-50" {...field} />
</FormControl>
</FormItem>
)}
/>
<div className="flex w-full justify-end gap-2">
<Button type="submit">Salva</Button>
<Confirm
description="Sei sicuro di voler svuotare la coda email?"
onConfirm={del}
title="Elimina"
>
<Button aria-label="Delete" variant="destructive">
Rimuovi dalla coda
</Button>
</Confirm>
</div>
</form>
</Form>
);
};

View file

@ -0,0 +1,421 @@
import { format, isAfter } from "date-fns";
import { it } from "date-fns/locale";
import { ChevronDownIcon } from "lucide-react";
import { useEffect } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { z } from "zod/v4";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/custom_ui/form";
import { MultiSelect } from "~/components/custom_ui/multiselect";
import { Button } from "~/components/ui/button";
import { Calendar } from "~/components/ui/calendar";
import Input from "~/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { useZodForm } from "~/lib/zodForm";
import {
type MailsTemplates,
TemplateKeys,
Templates,
type TemplateType,
} from "~/server/services/mail-templates";
type FormProps = {
submitMutation: (values: {
lock_expires: Date;
template: MailsTemplates;
subject: string;
}) => void;
};
const Schema = z
.object({
expires_date: z.date().refine((d) => isAfter(d, new Date()), {
message: "La data deve essere nel futuro",
}),
expires_time: z.string(),
mailType: z.enum(TemplateKeys),
mailProps: z.record(z.string(), z.unknown()).optional(),
subject: z.string(),
})
.superRefine((data, ctx) => {
const propsSchema = Templates[data.mailType as TemplateType].props;
if (propsSchema) {
const result = propsSchema.safeParse(data.mailProps);
if (!result.success) {
for (const issue of result.error.issues) {
ctx.addIssue({
...issue,
path: ["mailProps", ...(issue.path ?? [])],
});
}
}
}
});
type FormValues = z.infer<typeof Schema>;
export const FormAddCodaEmail = ({ submitMutation }: FormProps) => {
z.config(z.locales.it());
const form = useZodForm(Schema, {
defaultValues: {
expires_date: new Date(),
expires_time: "00:00:00",
mailType: TemplateKeys[0],
mailProps: undefined,
subject: "",
},
});
function onSubmit(fields: FormValues) {
const template = {
mailType: fields.mailType,
props: fields.mailProps as MailsTemplates["props"],
} as MailsTemplates;
const values: {
lock_expires: Date;
template: MailsTemplates;
subject: string;
} = {
lock_expires: new Date(
`${fields.expires_date.toISOString().split("T")[0]}T${fields.expires_time}`,
),
template,
subject: fields.subject,
};
submitMutation(values);
}
const type = form.watch("mailType");
useEffect(() => {
form.setValue("mailProps", {});
}, [type, form]);
useWatch({
name: "mailProps",
control: form.control,
compute: (props) => {
if (!props) return;
if ("title" in props) {
form.setValue("subject", props.title as string);
return;
}
},
});
return (
<>
<Form {...form}>
<form
className="space-y-8 px-0.5"
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="grid w-full grid-cols-2 items-center gap-4">
<FormField
control={form.control}
name="expires_date"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="titolo">Data Invio</FormLabel>
<FormMessage />
</div>
<FormControl>
<Popover>
<PopoverTrigger asChild>
<Button
className="w-full justify-between bg-card font-normal"
id="date-picker-optional"
variant="outline"
>
{field.value
? format(field.value, "PPP", { locale: it })
: "Seleziona data"}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-auto overflow-hidden p-0"
>
<Calendar
captionLayout="dropdown"
defaultMonth={field.value}
mode="single"
onSelect={(date) => {
field.onChange(date);
}}
selected={field.value}
/>
</PopoverContent>
</Popover>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="expires_time"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="titolo">Ora Invio</FormLabel>
<FormMessage />
</div>
<FormControl>
<Input
className="appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
defaultValue="10:30:00"
id="time-picker-optional"
onChange={(e) => field.onChange(e.target.value)}
step="1"
type="time"
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="mailType"
render={({ field }) => (
<FormItem className="w-full">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="select-mailType">Modello</FormLabel>
<FormMessage />
</div>
<FormControl>
<MultiSelect
defaultValue={
field.value
? {
label: Templates[field.value].title,
value: field.value,
}
: undefined
}
elementId="select-mailType"
elementName="mailType"
isMulti={false}
onValueChange={(value) => {
field.onChange(value.value as TemplateType);
}}
options={TemplateKeys.map((key) => ({
label: Templates[key].title,
value: key,
}))}
placeholder="Seleziona..."
/>
</FormControl>
</FormItem>
)}
/>
<MailPropSection mailType={type as TemplateType} />
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="subject">Oggetto</FormLabel>
<FormMessage />
</div>
<FormControl>
<Input placeholder="" {...field} id="subject" />
</FormControl>
</FormItem>
)}
/>
<Button type="submit">Salva</Button>
</form>
</Form>
</>
);
};
// --- Zod v4 schema introspection helpers ---
function getType(schema: unknown): string {
// biome-ignore lint/suspicious/noExplicitAny: Zod v4 internal introspection
return (schema as any)?.type ?? "unknown";
}
function unwrap(schema: unknown): unknown {
const t = getType(schema);
if (t === "optional" || t === "nullable")
// biome-ignore lint/suspicious/noExplicitAny: Zod v4 internal introspection
return unwrap((schema as any).def.innerType);
return schema;
}
function extractShape(schema: unknown): Record<string, unknown> {
if (!schema || typeof schema !== "object") return {};
// biome-ignore lint/suspicious/noExplicitAny: Zod v4 internal introspection
const s = schema as any;
const t = getType(s);
if (t === "optional" || t === "nullable")
return extractShape(s.def.innerType);
if (s.shape && typeof s.shape === "object") return s.shape;
if (t === "intersection")
return { ...extractShape(s.def.left), ...extractShape(s.def.right) };
if (t === "union" && Array.isArray(s.def.options)) {
const merged: Record<string, unknown> = {};
for (const opt of s.def.options) {
for (const [k, v] of Object.entries(extractShape(opt))) {
if (!merged[k] || getFieldType(merged[k]) === "skip") merged[k] = v;
}
}
return merged;
}
return {};
}
type FieldType = "text" | "checkbox" | "number" | "object" | "skip";
function getFieldType(schema: unknown): FieldType {
const inner = unwrap(schema);
const t = getType(inner);
switch (t) {
case "boolean":
return "checkbox";
case "number":
return "number";
case "object":
return "object";
case "custom":
case "undefined":
case "unknown":
return "skip";
default:
return "text";
}
}
function isOptionalField(schema: unknown): boolean {
const t = getType(schema);
return t === "optional" || t === "nullable";
}
const FieldRenderer = ({
fieldSchema,
name,
label,
}: {
fieldSchema: unknown;
name: string;
label: string;
}) => {
const { control } = useFormContext();
const fieldType = getFieldType(fieldSchema);
const optional = isOptionalField(fieldSchema);
if (fieldType === "skip") return null;
if (fieldType === "object") {
const nestedShape = extractShape(fieldSchema);
const entries = Object.entries(nestedShape);
if (entries.length === 0) return null;
return (
<fieldset className="space-y-3 rounded-md border p-3">
<legend className="px-1 font-medium text-sm">
{label}
{optional && (
<span className="ml-1 text-muted-foreground text-xs">
(opzionale)
</span>
)}
</legend>
{entries.map(([key, nested]) => (
<FieldRenderer
fieldSchema={nested}
key={key}
label={key}
name={`${name}.${key}`}
/>
))}
</fieldset>
);
}
return (
<FormField
control={control}
// biome-ignore lint/suspicious/noExplicitAny: dynamic field name
name={name as any}
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel>
{label}
{optional && (
<span className="ml-1 text-muted-foreground text-xs">
(opzionale)
</span>
)}
</FormLabel>
<FormMessage />
</div>
<FormControl>
{fieldType === "checkbox" ? (
<input
checked={!!(field.value as boolean)}
className="h-4 w-4"
onChange={(e) => field.onChange(e.target.checked)}
type="checkbox"
/>
) : (
<Input
onChange={
fieldType === "number"
? (e) => field.onChange(Number(e.target.value))
: field.onChange
}
type={fieldType}
value={(field.value as string | number) ?? ""}
/>
)}
</FormControl>
</FormItem>
)}
/>
);
};
const MailPropSection = ({ mailType }: { mailType: TemplateType }) => {
const template = Templates[mailType];
if (!template.props) return null;
const shape = extractShape(template.props);
const entries = Object.entries(shape);
if (entries.length === 0) return null;
return (
<div className="space-y-4">
{entries.map(([key, fieldSchema]) => (
<FieldRenderer
fieldSchema={fieldSchema}
key={key}
label={key}
name={`mailProps.${key}`}
/>
))}
</div>
);
};

View file

@ -26,9 +26,9 @@ const AdminPotenziali: NextPageWithLayout = () => {
<Head>
<title>Potenziali - Infoalloggi.it</title>
</Head>
<div className="mx-1 flex-1 overflow-auto">
<div className="mx-auto pt-4">
<div className="mx-3 flex-wrap items-start justify-between pb-5 sm:flex">
<div className="mx-1 w-full flex-1">
<div className="mx-auto w-full pt-4">
<div className="mx-3 flex flex-wrap items-start justify-between pb-5">
<div className="flex max-w-lg items-center gap-3">
<h3 className="font-bold text-accent-foreground text-xl sm:text-2xl">
Potenziali

View file

@ -1,10 +1,19 @@
import { MoreVertical, Trash2 } from "lucide-react";
import { Maximize2, MoreVertical, Plus, Trash2 } from "lucide-react";
import type { GetServerSideProps } from "next";
import { useState } from "react";
import toast from "react-hot-toast";
import { EmailAccordion } from "~/components/area-riservata/comunicazioni";
import { Confirm } from "~/components/confirm";
import { AreaRiservataLayoutUserView } from "~/components/Layout";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Label } from "~/components/ui/label";
import {
Popover,
@ -12,9 +21,13 @@ import {
PopoverTrigger,
} from "~/components/ui/popover";
import { Switch } from "~/components/ui/switch";
import { FormEditQueueMail } from "~/forms/FormEditQueueMail";
import { FormAddCodaEmail } from "~/forms/FormNewMail";
import type { NextPageWithLayout } from "~/pages/_app";
import { useEnforcedSession } from "~/providers/SessionProvider";
import type { UsersId } from "~/schemas/public/Users";
import type { Email_EventData } from "~/server/controllers/event_queue.controller";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zUserId } from "~/server/utils/zod_types";
import { api } from "~/utils/api";
@ -77,6 +90,7 @@ const ComunicazioniUser: NextPageWithLayout<ComunicazioniUserProps> = ({
</Popover>
</div>
<CommsSwitch userId={userId} />
<Coda userId={userId} />
<EmailAccordion isAdmin={user.isAdmin} userId={userId} />
</main>
);
@ -106,6 +120,9 @@ export const getServerSideProps = (async (context) => {
await helpers.trpc.comunicazioni.getEmails.prefetch({
userId: parsed.data,
});
await helpers.trpc.comunicazioni.getUserQueuedEmails.prefetch({
userId: parsed.data,
});
return {
props: {
userId: parsed.data,
@ -154,3 +171,159 @@ const CommsSwitch = ({ userId }: { userId: UsersId }) => {
</div>
);
};
const Coda = ({ userId }: { userId: UsersId }) => {
const { data, isLoading } = api.comunicazioni.getUserQueuedEmails.useQuery({
userId,
});
const utils = api.useUtils();
const { mutate } = api.comunicazioni.clearUserEmailQueue.useMutation({
onSuccess: async () => {
toast.success("Coda email pulita");
await utils.comunicazioni.getUserQueuedEmails.invalidate({ userId });
},
onError: (error) => {
console.error(error);
toast.error("Errore durante la pulizia della coda email");
},
});
const { mutate: removeFromQueue } =
api.comunicazioni.removeFromUserEmailQueue.useMutation({
onSuccess: async () => {
toast.success("Email rimossa dalla coda");
await utils.comunicazioni.getUserQueuedEmails.invalidate({ userId });
},
onError: (error) => {
console.error(error);
toast.error("Errore durante la rimozione dell'email dalla coda");
},
});
const { mutate: addToQueue } =
api.comunicazioni.addToUserEmailQueue.useMutation({
onSuccess: async () => {
toast.success("Email aggiunta alla coda");
await utils.comunicazioni.getUserQueuedEmails.invalidate({ userId });
},
onError: (error) => {
console.error(error);
toast.error("Errore durante l'aggiunta dell'email alla coda");
},
});
const { mutate: updateEvent } = api.comunicazioni.updateEvent.useMutation({
onSuccess: async () => {
toast.success("Email aggiornata nella coda");
await utils.comunicazioni.getUserQueuedEmails.invalidate({ userId });
},
onError: (error) => {
console.error(error);
toast.error("Errore durante l'aggiornamento dell'email nella coda");
},
});
const [newOpen, setNewOpen] = useState(false);
if (isLoading) {
return <div>Loading...</div>;
}
if (!data) {
return <div>Errore nel caricamento coda delle email</div>;
}
return (
<div className="flex w-full flex-col gap-2">
<h3>Email in coda:</h3>
{data.length === 0 && <div>Nessuna email in coda</div>}
{data.map((email) => {
const mailData = email.data as Email_EventData;
return (
<div
className="flex items-center gap-2 rounded-md border p-1.5"
key={email.event_id}
>
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Maximize2 />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Dettagli</DialogTitle>
<DialogDescription className="sr-only">
dettagli dell'email in coda
</DialogDescription>
</DialogHeader>
<FormEditQueueMail
data={email}
del={() => removeFromQueue({ eventId: email.event_id })}
submitMutation={(values) => {
updateEvent({ eventId: email.event_id, data: values });
}}
/>
</DialogContent>
</Dialog>
<div className="font-semibold">
{mailData.data.mail.subject ||
mailData.data.template?.mailType ||
"Nessun oggetto"}
</div>
<div className="text-muted-foreground text-sm">
data invio:{" "}
{new Date(email.lock_expires).toLocaleDateString("it-IT", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
);
})}
<div className="flex w-full justify-start gap-2">
<Dialog onOpenChange={setNewOpen} open={newOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Plus /> Aggiungi email alla coda
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Aggiungi email alla coda</DialogTitle>
<DialogDescription className="sr-only">
aggiungi email alla coda
</DialogDescription>
</DialogHeader>
<FormAddCodaEmail
submitMutation={(values) => {
addToQueue({ userId, ...values });
setNewOpen(false);
}}
/>
</DialogContent>
</Dialog>
<Confirm
description="Sei sicuro di voler svuotare la coda email?"
onConfirm={() => {
mutate({ userId });
}}
title="Elimina"
>
<Button
aria-label="Delete"
disabled={data.length === 0}
size="sm"
variant="destructive"
>
<Trash2 />
Pulisci coda email
</Button>
</Confirm>
</div>
</div>
);
};

View file

@ -1,5 +1,7 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import type { EmailsIdEmail } from "~/schemas/public/Emails";
import type { EventQueueUpdate } from "~/schemas/public/EventQueue";
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
import {
adminProcedure,
@ -9,6 +11,9 @@ import {
} from "~/server/api/trpc";
import {
addEventToQueue,
clearUserEmailQueue,
removeEventFromQueue,
updateEvent,
userEmailQueue,
} from "~/server/controllers/event_queue.controller";
import { UtenteInteressatoAdAnnuncioEmail } from "~/server/controllers/servizio.controller";
@ -18,10 +23,15 @@ import {
deleteEmail,
getEmails,
} from "~/server/services/email.service";
import { NewMail } from "~/server/services/mailer";
import { type MailsTemplates, NewMail } from "~/server/services/mailer";
import { CONDIZIONI_DEFAULT } from "~/server/services/prezziario.service";
import { genPdfSchedaAnnuncioBase64 } from "~/server/services/puppeteer.service";
import { zAnnuncioId, zOrdineId, zUserId } from "~/server/utils/zod_types";
import {
zAnnuncioId,
zEventId,
zOrdineId,
zUserId,
} from "~/server/utils/zod_types";
export const comunicazioniRouter = createTRPCRouter({
getEmails: protectedProcedure
@ -130,9 +140,11 @@ export const comunicazioniRouter = createTRPCRouter({
},
});
} catch (e) {
throw new Error(
`Errore nell'invio dell'email: ${(e as Error).message}`,
);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore nell'invio dell'email: ${(e as Error).message}`,
cause: e instanceof Error ? e : undefined,
});
}
}),
@ -210,9 +222,11 @@ export const comunicazioniRouter = createTRPCRouter({
lock_expires: new Date(Date.now() + 3000), // Lock for 30 sec
});
} catch (e) {
throw new Error(
`Errore nell'invio dell'email: ${(e as Error).message}`,
);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore nell'aggiunta dell'email alla coda: ${(e as Error).message}`,
cause: e instanceof Error ? e : undefined,
});
}
}),
getUserQueuedEmails: protectedProcedure
@ -220,4 +234,73 @@ export const comunicazioniRouter = createTRPCRouter({
.query(async ({ input }) => {
return await userEmailQueue(input.userId);
}),
addToUserEmailQueue: adminProcedure
.input(
z.object({
lock_expires: z.date(),
userId: zUserId,
template: z.custom<MailsTemplates>(),
subject: z.string(),
}),
)
.mutation(async ({ input }) => {
try {
const user = await db
.selectFrom("users")
.selectAll()
.where("id", "=", input.userId)
.executeTakeFirstOrThrow(() => {
throw new Error(`User with id ${input.userId} not found`);
});
await addEventToQueue({
data: {
tipo: "email",
data: {
template: input.template,
mail: {
to: user.email,
subject: input.subject,
},
userId: input.userId,
},
},
lock_expires: input.lock_expires,
});
return { success: true };
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore nell'aggiunta dell'email alla coda: ${
error instanceof Error ? error.message : "Errore sconosciuto"
}`,
cause: error instanceof Error ? error : undefined,
});
}
}),
removeFromUserEmailQueue: adminProcedure
.input(
z.object({
eventId: zEventId,
}),
)
.mutation(async ({ input }) => {
return await removeEventFromQueue(input.eventId);
}),
clearUserEmailQueue: adminProcedure
.input(z.object({ userId: zUserId }))
.mutation(async ({ input }) => {
return await clearUserEmailQueue(input.userId);
}),
updateEvent: adminProcedure
.input(
z.object({
eventId: zEventId,
data: z.custom<EventQueueUpdate>(),
}),
)
.mutation(async ({ input }) => {
return await updateEvent(input.eventId, input.data);
}),
});

View file

@ -4,7 +4,11 @@ import { add, set } from "date-fns";
import { sql } from "kysely";
import type { Attachment } from "nodemailer/lib/mailer";
import type { AnnunciId } from "~/schemas/public/Annunci";
import type { NewEventQueue } from "~/schemas/public/EventQueue";
import type {
EventQueueEventId,
EventQueueUpdate,
NewEventQueue,
} from "~/schemas/public/EventQueue";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
import type { UsersId } from "~/schemas/public/Users";
@ -26,7 +30,7 @@ type GeneratedAttachmentTypes =
| { type: "condizioni"; stringId: TestiEStringheStingaId };
type CustomAttachment = Attachment & { generate?: GeneratedAttachmentTypes };
type Email_EventData = {
export type Email_EventData = {
tipo: "email";
data: NewMailProps & {
mail: {
@ -174,3 +178,55 @@ export const userEmailQueue = async (userId: UsersId) => {
});
}
};
export const removeEventFromQueue = async (eventId: EventQueueEventId) => {
try {
await db
.deleteFrom("event_queue")
.where("event_id", "=", eventId)
.execute();
return true;
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error while removing event from queue, ${(e as Error).message}`,
cause: e,
});
}
};
export const clearUserEmailQueue = async (userId: UsersId) => {
try {
await db
.deleteFrom("event_queue")
.where(sql`data->>'tipo'`, "=", "email")
.where(sql`data->'data'->>'userId'`, "=", userId)
.execute();
return true;
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error while clearing user events from queue, ${(e as Error).message}`,
cause: e,
});
}
};
export const updateEvent = async (
eventId: EventQueueEventId,
data: EventQueueUpdate,
) => {
try {
await db
.updateTable("event_queue")
.set(data)
.where("event_id", "=", eventId)
.execute();
return true;
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error while updating event in queue, ${(e as Error).message}`,
cause: e,
});
}
};

View file

@ -25,6 +25,7 @@ import type { UsersStorageUserStorageId } from "~/schemas/public/UsersStorage";
import {
addEventToQueue,
canSendNotification,
clearUserEmailQueue,
nextTimeForNotification,
} from "~/server/controllers/event_queue.controller";
import {
@ -830,7 +831,7 @@ export const SbloccaContatti = async ({
indirizzo: data.annuncio.indirizzo,
nome_cognome: `${data.annuncio.locatore}${data.annuncio?.tipo_locatore ? ` (${data.annuncio.tipo_locatore})` : ""}`,
nome_cognome_utente: data.user.username,
sesso: data.anagrafica.sesso,
sesso: data.anagrafica.sesso as "M" | "F" | null,
telefono: data.user.telefono,
},
},
@ -971,6 +972,9 @@ export const interruzioneServizio = async (
},
});
// cleanup coda notifiche: rimuove notifiche future relative a questo servizio
await clearUserEmailQueue(utente.id);
return true;
} catch (e) {
throw new TRPCError({

View file

@ -0,0 +1,108 @@
import {
AvvisoInterruzioneMailTag,
} from "emails/avviso-interruzione";
import {
ContattoAdminEmailPropsSchema,
ContattoAdminMailTag,
} from "emails/contatto";
import {
ContattoAnnuncioMailTag,
ContattoAnnuncioPropsSchema,
} from "emails/contatto_annuncio";
import {
ContattoInteressatoMailTag,
ContattoInteressatoPropsSchema,
} from "emails/email-interessato";
import {
GenericMailTag,
GenericPropsSchema,
} from "emails/gereric-email";
import { InterruzioneMailTag } from "emails/interruzione";
import { InvitoMailTag, InvitoPropsSchema } from "emails/invito";
import {
PagamentoConfermaMailTag,
} from "emails/pagamento-conferma";
import {
PagamentoErroreMailTag,
} from "emails/pagamento-errore";
import {
PagamentoRicezioneMailTag,
} from "emails/pagamento-ricezione";
import {
PwResetLinkMailTag,
PwResetLinkPropsSchema,
} from "emails/pw-reset-link";
import { RecessoMailTag } from "emails/recesso";
import {
RegistrazioneAvvenutaMailTag,
} from "emails/registrazione-avvenuta";
import {
SchedaContattoMailTag,
SchedaContattoPropsSchema,
} from "emails/scheda-annuncio";
import {
ServizioAttivatoMailTag,
} from "emails/servizio-attivato";
import {
VerificaEmailMailTag,
VerificaEmailPropsSchema,
} from "emails/VerificaEmail";
import type { ZodSchema, z } from "zod";
export const Templates = {
[GenericMailTag]: { title: "Email Generica", props: GenericPropsSchema },
[AvvisoInterruzioneMailTag]: { title: "Avviso Interruzione", props: null },
[ContattoAnnuncioMailTag]: {
title: "Contatto Annuncio",
props: ContattoAnnuncioPropsSchema,
},
[ContattoAdminMailTag]: {
title: "Contatto Amministratore",
props: ContattoAdminEmailPropsSchema,
},
[ContattoInteressatoMailTag]: {
title: "Contatto Interessato",
props: ContattoInteressatoPropsSchema,
},
[InterruzioneMailTag]: { title: "Interruzione Servizio", props: null },
[InvitoMailTag]: { title: "Invito al sito", props: InvitoPropsSchema },
[PagamentoConfermaMailTag]: { title: "Conferma pagamento", props: null },
[PagamentoErroreMailTag]: { title: "Errore pagamento", props: null },
[PagamentoRicezioneMailTag]: { title: "Ricezione pagamento", props: null },
[PwResetLinkMailTag]: {
title: "Reset password",
props: PwResetLinkPropsSchema,
},
[RecessoMailTag]: { title: "Richiesta di recesso", props: null },
[RegistrazioneAvvenutaMailTag]: {
title: "Registrazione avvenuta",
props: null,
},
[SchedaContattoMailTag]: {
title: "Scheda contatto annuncio",
props: SchedaContattoPropsSchema,
},
[ServizioAttivatoMailTag]: { title: "Servizio Attivato", props: null },
[VerificaEmailMailTag]: {
title: "Verifica email",
props: VerificaEmailPropsSchema,
},
} satisfies Record<string, { title: string; props: ZodSchema | null }>;
export const TemplateKeys = Object.keys(Templates) as Array<
keyof typeof Templates
>;
export const TemplateSchemas = Object.entries(Templates)
.map(([_, value]) => value.props)
.filter((schema) => schema !== null);
export type TemplateType = keyof typeof Templates;
export type MailsTemplates = {
[K in TemplateType]: {
mailType: K;
} & ((typeof Templates)[K] extends { props: z.ZodSchema }
? { props: z.infer<(typeof Templates)[K]["props"]> }
: { props?: never });
}[TemplateType];

View file

@ -1,41 +1,21 @@
import { render } from "@react-email/render";
import { TRPCError } from "@trpc/server";
import type { AvvisoInterruzione_NewMail } from "emails/avviso-interruzione";
import AvvisoInterruzione from "emails/avviso-interruzione";
import ContattoAdminEmail, {
type ContattoAdmin_NewMail,
} from "emails/contatto";
import type { ContattoAnnuncio_NewMail } from "emails/contatto_annuncio";
import ContattoAdminEmail from "emails/contatto";
import ContattoAnnuncioEmail from "emails/contatto_annuncio";
import EmailContattoInteressato, {
type ContattoInteressato_NewMail,
} from "emails/email-interessato";
import type { Generic_NewMail } from "emails/gereric-email";
import EmailContattoInteressato from "emails/email-interessato";
import Generic from "emails/gereric-email";
import type { Interruzione_NewMail } from "emails/interruzione";
import Interruzione from "emails/interruzione";
import type { Invito_NewMail } from "emails/invito";
import Invito from "emails/invito";
import type { PagamentoConferma_NewMail } from "emails/pagamento-conferma";
import PagamentoConferma from "emails/pagamento-conferma";
import type { PagamentoErrore_NewMail } from "emails/pagamento-errore";
import PagamentoErrore from "emails/pagamento-errore";
import type { PagamentoRicezione_NewMail } from "emails/pagamento-ricezione";
import PagamentoRicezione from "emails/pagamento-ricezione";
import type { PwResetLink_NewMail } from "emails/pw-reset-link";
import PwResetLink from "emails/pw-reset-link";
import type { Recesso_NewMail } from "emails/recesso";
import Recesso from "emails/recesso";
import type { RegistrazioneAvvenuta_NewMail } from "emails/registrazione-avvenuta";
import RegistrazioneAvvenuta from "emails/registrazione-avvenuta";
import EmailSchedaContatto, {
type SchedaContatto_NewMail,
} from "emails/scheda-annuncio";
import type { ServizioAttivato_NewMail } from "emails/servizio-attivato";
import EmailSchedaContatto from "emails/scheda-annuncio";
import ServizioAttivato from "emails/servizio-attivato";
import VerificaEmail, {
type VerificaEmail_NewMail,
} from "emails/VerificaEmail";
import VerificaEmail from "emails/VerificaEmail";
import { createTransport } from "nodemailer";
import type { Options } from "nodemailer/lib/mailer";
import { htmlToText } from "nodemailer-html-to-text";
@ -46,6 +26,19 @@ import { GetFlagValueHandler } from "~/server/controllers/flags.controller";
import { db } from "~/server/db";
import { storeEmail } from "~/server/services/email.service";
export {
type MailsTemplates,
TemplateKeys,
TemplateSchemas,
Templates,
type TemplateType,
} from "~/server/services/mail-templates";
import {
type MailsTemplates,
Templates,
} from "~/server/services/mail-templates";
const mailer = async (options: Options) => {
const smtpTransport = createTransport({
auth: {
@ -81,117 +74,74 @@ export type NewMailProps = {
userId?: UsersId;
mail: Options;
};
export type MailsTemplates =
| ContattoAdmin_NewMail
| ContattoAnnuncio_NewMail
| ContattoInteressato_NewMail
| PagamentoConferma_NewMail
| PagamentoErrore_NewMail
| PagamentoRicezione_NewMail
| PwResetLink_NewMail
| Recesso_NewMail
| RegistrazioneAvvenuta_NewMail
| VerificaEmail_NewMail
| Generic_NewMail
| SchedaContatto_NewMail
| Invito_NewMail
| ServizioAttivato_NewMail
| AvvisoInterruzione_NewMail
| Interruzione_NewMail;
export const genMail = async ({ ...mail }: MailsTemplates) => {
let mailComponent: JSX.Element | null = null;
let title = "";
const title = Templates[mail.mailType].title;
switch (mail.mailType) {
case "contattoAdminEmail":
mailComponent = ContattoAdminEmail({
...mail.props,
});
title = "Contatto amministratore";
break;
case "contattoAnnuncioEmail":
mailComponent = ContattoAnnuncioEmail({
...mail.props,
});
title = "Contatto annuncio";
break;
case "emailContattoInteressato":
mailComponent = EmailContattoInteressato({
...mail.props,
});
title = "Contatto interessato";
break;
case "pagamentoConferma":
mailComponent = PagamentoConferma();
title = "Conferma pagamento";
break;
case "pagamentoErrore":
mailComponent = PagamentoErrore();
title = "Errore pagamento";
break;
case "pagamentoRicezione":
mailComponent = PagamentoRicezione();
title = "Ricezione pagamento";
break;
case "pwResetLink":
mailComponent = PwResetLink({
...mail.props,
});
title = "Reset password";
break;
case "recesso":
mailComponent = Recesso();
title = "Recesso";
break;
case "registrazioneAvvenuta":
mailComponent = RegistrazioneAvvenuta();
title = "Registrazione avvenuta";
break;
case "verificaEmail":
mailComponent = VerificaEmail({
...mail.props,
});
title = "Verifica email";
break;
case "generic":
mailComponent = Generic({
...mail.props,
});
title = mail.props.title || "Email Generica";
break;
case "emailSchedaContatto":
mailComponent = EmailSchedaContatto({
...mail.props,
});
title = "Scheda Contatto Annuncio";
break;
case "invito":
mailComponent = Invito({
...mail.props,
});
title = "Invito al sito";
break;
case "servizioAttivato":
mailComponent = ServizioAttivato();
title = "Servizio Attivato";
break;
case "avvisoInterruzione":
mailComponent = AvvisoInterruzione();
title = "Avviso Interruzione";
break;
case "interruzione":
mailComponent = Interruzione();
title = "Interruzione servizio";
break;
default:
console.error(`Tipo di email non valido: ${mail satisfies never}`);

View file

@ -4,6 +4,7 @@ import type { BanlistId } from "~/schemas/public/Banlist";
import BanType from "~/schemas/public/BanType";
import type { ChatsChatid } from "~/schemas/public/Chats";
import type { EtichetteIdEtichetta } from "~/schemas/public/Etichette";
import type { EventQueueEventId } from "~/schemas/public/EventQueue";
import type { FlagsId } from "~/schemas/public/Flags";
import type { MessagesMessageid } from "~/schemas/public/Messages";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
@ -91,3 +92,8 @@ export const zRinnovoId = z.custom<RinnoviId>(
(val) => typeof val === "string",
"falied to validate RinnovoId",
);
export const zEventId = z.custom<EventQueueEventId>(
(val) => typeof val === "number",
"falied to validate EventId",
);