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:
parent
2acd6eca92
commit
964abfbf46
29 changed files with 1178 additions and 209 deletions
|
|
@ -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">
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
165
apps/infoalloggi/src/forms/FormEditQueueMail.tsx
Normal file
165
apps/infoalloggi/src/forms/FormEditQueueMail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
421
apps/infoalloggi/src/forms/FormNewMail.tsx
Normal file
421
apps/infoalloggi/src/forms/FormNewMail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
108
apps/infoalloggi/src/server/services/mail-templates.ts
Normal file
108
apps/infoalloggi/src/server/services/mail-templates.ts
Normal 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];
|
||||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue