disabled logic for event_queue

This commit is contained in:
Marco Pedone 2026-05-08 16:18:36 +02:00
parent d0f146b99e
commit fecac3428d
15 changed files with 119 additions and 37 deletions

View file

@ -0,0 +1,6 @@
-- 55_nulla_fatto_flag.up.sql
ALTER TABLE IF EXISTS public.servizio
ADD COLUMN IF NOT EXISTS "isDisabled" boolean NOT NULL DEFAULT false;
ALTER TABLE IF EXISTS public.event_queue
ADD COLUMN IF NOT EXISTS "isActive" boolean NOT NULL DEFAULT true;

View file

@ -1,3 +0,0 @@
-- nulla_fatto_flag.up.sql
ALTER TABLE IF EXISTS public.servizio
ADD COLUMN IF NOT EXISTS "isDisabled" boolean NOT NULL DEFAULT false;

View file

@ -5,8 +5,8 @@ import Base from "./base";
export const GenericMailTag = "generic"; export const GenericMailTag = "generic";
const props_base = z.object({ export const GenericPropsSchema = z.object({
title: z.string(), title: z.string({error:"Il campo non può essere vuoto"}).nonempty("Il campo non può essere vuoto"),
noreply: z.boolean().optional(), noreply: z.boolean().optional(),
link: z link: z
.object({ .object({
@ -14,21 +14,10 @@ const props_base = z.object({
label: z.string().optional(), label: z.string().optional(),
}) })
.optional(), .optional(),
corpo: z.custom<ReactNode>().optional(),
testo: z.string({error:"Il campo non può essere vuoto"}).nonempty("Il campo non può essere vuoto"),
}); });
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 = ({ const Generic = ({
title, title,
noreply = false, noreply = false,
@ -41,9 +30,7 @@ const Generic = ({
<> <>
<Heading className="text-center text-3xl leading-8">{title}</Heading> <Heading className="text-center text-3xl leading-8">{title}</Heading>
<Section className="px-5 text-left text-base md:px-12"> <Section className="px-5 text-left text-base md:px-12">
{corpo ? ( {corpo || (
corpo
) : (
<Row> <Row>
<Text>{testo}</Text> <Text>{testo}</Text>
</Row> </Row>

View file

@ -176,6 +176,7 @@ export const AdminComunicazioni = () => {
minute: "2-digit", minute: "2-digit",
})} })}
</span> </span>
{!email.isActive && <div className="text-sm">(Disattivata)</div>}
</div> </div>
); );
})} })}

View file

@ -13,7 +13,7 @@ import ReactSelect, {
type MenuListProps, type MenuListProps,
} from "react-select"; } from "react-select";
import type {} from "react-select/base"; import type {} from "react-select/base";
import { cn } from "~/lib/utils"; import { cn, passwordManagerIgnore } from "~/lib/utils";
declare module "react-select/base" { declare module "react-select/base" {
export interface Props< export interface Props<
@ -37,7 +37,7 @@ type BaseProps = {
disabled?: boolean; disabled?: boolean;
menuPlacement?: "auto" | "bottom" | "top"; menuPlacement?: "auto" | "bottom" | "top";
defaultValue?: ListOption | ListOption[]; defaultValue?: ListOption | ListOption[];
autoComplete?: HTMLInputAutoCompleteAttribute; //autoComplete?: HTMLInputAutoCompleteAttribute;
}; };
type MultiProps = { type MultiProps = {
@ -119,8 +119,8 @@ const CustomInput = (
<components.Input <components.Input
{...props} {...props}
// This pulls the value from the main ReactSelect wrapper // This pulls the value from the main ReactSelect wrapper
autoComplete={props.selectProps.autoComplete} autoComplete={props.selectProps.autoComplete}
{...passwordManagerIgnore}
/> />
); );
}; };
@ -154,13 +154,13 @@ export const MultiSelect = ({
placeholder, placeholder,
disabled, disabled,
menuPlacement, menuPlacement,
autoComplete, //autoComplete,
}: MultiSelectProps) => { }: MultiSelectProps) => {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
return ( return (
<ReactSelect <ReactSelect
autoComplete={autoComplete} //autoComplete={autoComplete}
classNamePrefix="select" classNamePrefix="select"
classNames={{ classNames={{
control: () => control: () =>
@ -197,6 +197,7 @@ export const MultiSelect = ({
MenuList: VirtualizedMenuList, MenuList: VirtualizedMenuList,
Input: CustomInput, Input: CustomInput,
}} }}
data-bitwarden-ignore
defaultValue={defaultValue} defaultValue={defaultValue}
inputId={elementId} inputId={elementId}
instanceId={elementId} instanceId={elementId}

View file

@ -13,6 +13,7 @@ import {
import { DateInput } from "~/components/custom_ui/inputDate"; import { DateInput } from "~/components/custom_ui/inputDate";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import Input from "~/components/ui/input"; import Input from "~/components/ui/input";
import { Switch } from "~/components/ui/switch";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { useZodForm } from "~/lib/zodForm"; import { useZodForm } from "~/lib/zodForm";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
@ -22,6 +23,7 @@ const Schema = z.object({
lock_date: z.date(), lock_date: z.date(),
lock_time: z.string(), lock_time: z.string(),
data: z.string(), data: z.string(),
isActive: z.boolean(),
}); });
type FormValues = z.infer<typeof Schema>; type FormValues = z.infer<typeof Schema>;
@ -43,6 +45,7 @@ export const FormEditQueueMail = ({
lock_date: new Date(data.lock_expires), lock_date: new Date(data.lock_expires),
lock_time: format(new Date(data.lock_expires), "HH:mm:ss"), lock_time: format(new Date(data.lock_expires), "HH:mm:ss"),
data: JSON.stringify(data.data, null, 2), data: JSON.stringify(data.data, null, 2),
isActive: data.isActive,
}, },
}); });
@ -54,6 +57,7 @@ export const FormEditQueueMail = ({
"T" + "T" +
fields.lock_time, fields.lock_time,
), ),
isActive: fields.isActive,
}); });
} }
@ -119,6 +123,26 @@ export const FormEditQueueMail = ({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="attivo">Attivo</FormLabel>
<FormControl>
<Switch
className="data-[state=checked]:bg-neutral-700"
defaultChecked={field.value}
id="attivo"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</div>
</FormItem>
)}
/>
<div className="flex w-full justify-end gap-2"> <div className="flex w-full justify-end gap-2">
<Button type="submit">Salva</Button> <Button type="submit">Salva</Button>
<Confirm <Confirm

View file

@ -14,6 +14,7 @@ import { DateInput } from "~/components/custom_ui/inputDate";
import { MultiSelect } from "~/components/custom_ui/multiselect"; import { MultiSelect } from "~/components/custom_ui/multiselect";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import Input from "~/components/ui/input"; import Input from "~/components/ui/input";
import { Switch } from "~/components/ui/switch";
import { useZodForm } from "~/lib/zodForm"; import { useZodForm } from "~/lib/zodForm";
import { import {
type MailsTemplates, type MailsTemplates,
@ -27,6 +28,7 @@ type FormProps = {
lock_expires: Date; lock_expires: Date;
template: MailsTemplates; template: MailsTemplates;
subject: string; subject: string;
isActive: boolean;
}) => void; }) => void;
}; };
@ -45,7 +47,8 @@ const Schema = z
expires_time: z.string(), expires_time: z.string(),
mailType: z.enum(TemplateKeys), mailType: z.enum(TemplateKeys),
mailProps: z.record(z.string(), z.unknown()).optional(), mailProps: z.record(z.string(), z.unknown()).optional(),
subject: z.string(), subject: z.string().nonempty("Il campo non può essere vuoto"),
isActive: z.boolean(),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
const propsSchema = Templates[data.mailType as TemplateType].props; const propsSchema = Templates[data.mailType as TemplateType].props;
@ -55,7 +58,7 @@ const Schema = z
for (const issue of result.error.issues) { for (const issue of result.error.issues) {
ctx.addIssue({ ctx.addIssue({
...issue, ...issue,
path: ["mailProps", ...(issue.path ?? [])], path: ["mailProps", ...issue.path],
}); });
} }
} }
@ -71,6 +74,7 @@ export const FormAddCodaEmail = ({ submitMutation }: FormProps) => {
mailType: TemplateKeys[0], mailType: TemplateKeys[0],
mailProps: undefined, mailProps: undefined,
subject: "", subject: "",
isActive: true,
}, },
}); });
@ -84,12 +88,14 @@ export const FormAddCodaEmail = ({ submitMutation }: FormProps) => {
lock_expires: Date; lock_expires: Date;
template: MailsTemplates; template: MailsTemplates;
subject: string; subject: string;
isActive: boolean;
} = { } = {
lock_expires: new Date( lock_expires: new Date(
`${fields.expires_date.toISOString().split("T")[0]}T${fields.expires_time}`, `${fields.expires_date.toISOString().split("T")[0]}T${fields.expires_time}`,
), ),
template, template,
subject: fields.subject, subject: fields.subject,
isActive: fields.isActive,
}; };
submitMutation(values); submitMutation(values);
} }
@ -103,10 +109,11 @@ export const FormAddCodaEmail = ({ submitMutation }: FormProps) => {
useWatch({ useWatch({
name: "mailProps", name: "mailProps",
control: form.control, control: form.control,
compute: (props) => { compute: async (props) => {
if (!props) return; if (!props) return;
if ("title" in props) { if ("title" in props) {
form.setValue("subject", props.title as string); form.setValue("subject", props.title as string);
await form.trigger("subject");
return; return;
} }
}, },
@ -216,6 +223,26 @@ export const FormAddCodaEmail = ({ submitMutation }: FormProps) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="attivo">Attivo</FormLabel>
<FormControl>
<Switch
className="data-[state=checked]:bg-neutral-700"
defaultChecked={field.value}
id="attivo"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</div>
</FormItem>
)}
/>
<Button type="submit">Salva</Button> <Button type="submit">Salva</Button>
</form> </form>
@ -332,12 +359,11 @@ const FieldRenderer = ({
return ( return (
<FormField <FormField
control={control} control={control}
// biome-ignore lint/suspicious/noExplicitAny: dynamic field name name={name}
name={name as any}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="flex flex-wrap items-center gap-x-2"> <div className="flex flex-wrap items-center gap-x-2">
<FormLabel> <FormLabel htmlFor={`${name}-input`}>
{label} {label}
{optional && ( {optional && (
<span className="ml-1 text-muted-foreground text-xs"> <span className="ml-1 text-muted-foreground text-xs">
@ -352,11 +378,13 @@ const FieldRenderer = ({
<input <input
checked={!!(field.value as boolean)} checked={!!(field.value as boolean)}
className="h-4 w-4" className="h-4 w-4"
id={`${name}-input`}
onChange={(e) => field.onChange(e.target.checked)} onChange={(e) => field.onChange(e.target.checked)}
type="checkbox" type="checkbox"
/> />
) : ( ) : (
<Input <Input
id={`${name}-input`}
onChange={ onChange={
fieldType === "number" fieldType === "number"
? (e) => field.onChange(Number(e.target.value)) ? (e) => field.onChange(Number(e.target.value))

View file

@ -69,3 +69,11 @@ export const debounce = <T extends unknown[]>(
}, delay); }, delay);
}; };
}; };
export const passwordManagerIgnore = {
autocomplete: "off",
"data-1p-ignore": true, // 1Password
"data-lpignore": "true", // LastPass
"data-protonpass-ignore": "true", // ProtonPass
"data-bwignore": "1", // Bitwarden
};

View file

@ -279,6 +279,7 @@ const Coda = ({ userId }: { userId: UsersId }) => {
minute: "2-digit", minute: "2-digit",
})} })}
</div> </div>
{!email.isActive && <div className="text-sm">(Disattivata)</div>}
</div> </div>
); );
})} })}

View file

@ -11,6 +11,8 @@ export default interface EventQueueTable {
lock_expires: ColumnType<Date, Date | string, Date | string>; lock_expires: ColumnType<Date, Date | string, Date | string>;
data: ColumnType<unknown | null, unknown | null, unknown | null>; data: ColumnType<unknown | null, unknown | null, unknown | null>;
isActive: ColumnType<boolean, boolean | undefined, boolean>;
} }
export type EventQueue = Selectable<EventQueueTable>; export type EventQueue = Selectable<EventQueueTable>;
@ -25,16 +27,19 @@ export const EventQueueSchema = z.object({
event_id: eventQueueEventId, event_id: eventQueueEventId,
lock_expires: z.date(), lock_expires: z.date(),
data: z.unknown().nullable(), data: z.unknown().nullable(),
isActive: z.boolean(),
}); });
export const NewEventQueueSchema = z.object({ export const NewEventQueueSchema = z.object({
event_id: eventQueueEventId.optional(), event_id: eventQueueEventId.optional(),
lock_expires: z.union([z.date(), z.string()]).pipe(z.coerce.date()), lock_expires: z.union([z.date(), z.string()]).pipe(z.coerce.date()),
data: z.unknown().optional().nullable(), data: z.unknown().optional().nullable(),
isActive: z.boolean().optional(),
}); });
export const EventQueueUpdateSchema = z.object({ export const EventQueueUpdateSchema = z.object({
event_id: eventQueueEventId.optional(), event_id: eventQueueEventId.optional(),
lock_expires: z.union([z.date(), z.string()]).pipe(z.coerce.date()).optional(), lock_expires: z.union([z.date(), z.string()]).pipe(z.coerce.date()).optional(),
data: z.unknown().optional().nullable(), data: z.unknown().optional().nullable(),
isActive: z.boolean().optional(),
}); });

View file

@ -236,6 +236,7 @@ export const comunicazioniRouter = createTRPCRouter({
userId: usersId, userId: usersId,
template: z.custom<MailsTemplates>(), template: z.custom<MailsTemplates>(),
subject: z.string(), subject: z.string(),
isActive: z.boolean(),
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@ -260,6 +261,7 @@ export const comunicazioniRouter = createTRPCRouter({
}, },
}, },
lock_expires: input.lock_expires, lock_expires: input.lock_expires,
isActive: input.isActive,
}); });
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
@ -298,6 +300,7 @@ export const comunicazioniRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
console.log(input.data);
return await updateEvent(input.eventId, input.data); return await updateEvent(input.eventId, input.data);
}), }),
}); });

View file

@ -129,6 +129,7 @@ const getEventsFromQueue = async () => {
const events = await db const events = await db
.selectFrom("event_queue") .selectFrom("event_queue")
.selectAll() .selectAll()
.where("event_queue.isActive", "=", true)
.where("event_queue.lock_expires", "<=", new Date()) .where("event_queue.lock_expires", "<=", new Date())
.execute(); .execute();
return events; return events;
@ -213,6 +214,7 @@ export const getEmailQueue = async () => {
.as("username"), .as("username"),
]) ])
.where(sql`data->>'tipo'`, "=", "email") .where(sql`data->>'tipo'`, "=", "email")
.execute(); .execute();
return events; return events;
} catch (e) { } catch (e) {
@ -286,6 +288,23 @@ export const clearUserEmailQueue = async (userId: UsersId) => {
} }
}; };
export const disableUserEmailQueue = async (userId: UsersId) => {
try {
await db
.updateTable("event_queue")
.set({ isActive: false })
.where(sql`data->>'tipo'`, "=", "email")
.where(sql`data->'data'->>'userId'`, "=", userId)
.execute();
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error while disabling user events from queue, ${(e as Error).message}`,
cause: e,
});
}
};
export const updateEvent = async ( export const updateEvent = async (
eventId: EventQueueEventId, eventId: EventQueueEventId,
data: EventQueueUpdate, data: EventQueueUpdate,

View file

@ -21,7 +21,7 @@ import type { UsersStorageUserStorageId } from "~/schemas/public/UsersStorage";
import { import {
addEventToQueue, addEventToQueue,
canSendNotification, canSendNotification,
clearUserEmailQueue, disableUserEmailQueue,
nextTimeForNotification, nextTimeForNotification,
} from "~/server/controllers/event_queue.controller"; } from "~/server/controllers/event_queue.controller";
import { import {
@ -927,8 +927,8 @@ export const interruzioneServizio = async (
}, },
}); });
// cleanup coda notifiche: rimuove notifiche future relative a questo servizio // disabilita coda notifiche: rimuove notifiche future relative a questo servizio
await clearUserEmailQueue(utente.id); await disableUserEmailQueue(utente.id);
return true; return true;
} catch (e) { } catch (e) {

View file

@ -7,7 +7,7 @@ import type {
ServizioUpdate, ServizioUpdate,
} from "~/schemas/public/Servizio"; } from "~/schemas/public/Servizio";
import type { ServizioAnnunciUpdate } from "~/schemas/public/ServizioAnnunci"; import type { ServizioAnnunciUpdate } from "~/schemas/public/ServizioAnnunci";
import { clearUserEmailQueue } from "~/server/controllers/event_queue.controller"; import { disableUserEmailQueue } from "~/server/controllers/event_queue.controller";
import { db } from "../db"; import { db } from "../db";
export const addServizio = async (data: NewServizio) => { export const addServizio = async (data: NewServizio) => {
@ -97,7 +97,7 @@ export const updateServizio = async ({
); );
if (!isDisabledPrev.isDisabled && updated.isDisabled) { if (!isDisabledPrev.isDisabled && updated.isDisabled) {
// Only the on isDisabled false->true change // Only the on isDisabled false->true change
await clearUserEmailQueue(updated.user_id); await disableUserEmailQueue(updated.user_id);
} }
return updated; return updated;
} catch (e) { } catch (e) {

View file

@ -16,6 +16,8 @@
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"noEmit": true, "noEmit": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true,
"rootDir": ".", "rootDir": ".",
"paths": { "paths": {
"~/*": ["./src/*"], "~/*": ["./src/*"],