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

View file

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

View file

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

View file

@ -13,6 +13,7 @@ import {
import { DateInput } from "~/components/custom_ui/inputDate";
import { Button } from "~/components/ui/button";
import Input from "~/components/ui/input";
import { Switch } from "~/components/ui/switch";
import { Textarea } from "~/components/ui/textarea";
import { useZodForm } from "~/lib/zodForm";
import { useTranslation } from "~/providers/I18nProvider";
@ -22,6 +23,7 @@ const Schema = z.object({
lock_date: z.date(),
lock_time: z.string(),
data: z.string(),
isActive: z.boolean(),
});
type FormValues = z.infer<typeof Schema>;
@ -43,6 +45,7 @@ export const FormEditQueueMail = ({
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),
isActive: data.isActive,
},
});
@ -54,6 +57,7 @@ export const FormEditQueueMail = ({
"T" +
fields.lock_time,
),
isActive: fields.isActive,
});
}
@ -119,6 +123,26 @@ export const FormEditQueueMail = ({
</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">
<Button type="submit">Salva</Button>
<Confirm

View file

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

View file

@ -69,3 +69,11 @@ export const debounce = <T extends unknown[]>(
}, 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",
})}
</div>
{!email.isActive && <div className="text-sm">(Disattivata)</div>}
</div>
);
})}

View file

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

View file

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

View file

@ -129,6 +129,7 @@ const getEventsFromQueue = async () => {
const events = await db
.selectFrom("event_queue")
.selectAll()
.where("event_queue.isActive", "=", true)
.where("event_queue.lock_expires", "<=", new Date())
.execute();
return events;
@ -213,6 +214,7 @@ export const getEmailQueue = async () => {
.as("username"),
])
.where(sql`data->>'tipo'`, "=", "email")
.execute();
return events;
} 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 (
eventId: EventQueueEventId,
data: EventQueueUpdate,

View file

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

View file

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

View file

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