feat: implement comprehensive notification engine with policies, preferences, and automated dispatching services

This commit is contained in:
2026-07-21 18:53:11 -03:00
parent 40090cdec5
commit 10ca449f88
82 changed files with 10249 additions and 171 deletions
@@ -0,0 +1,101 @@
.container {
margin-top: 32px;
display: flex;
flex-direction: column;
gap: 10px;
width: min(100%, 900px);
margin-left: auto;
margin-right: auto;
}
.channels {
display: flex;
flex-direction: column;
gap: 10px;
}
.card {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(360px, 1fr);
gap: 16px;
align-items: center;
padding: 16px;
border-radius: 10px;
border: 1px solid var(--border-color);
background-color: var(--bg-primary);
}
.channelTitle {
color: var(--text-primary);
font-weight: 700;
font-size: 16px;
}
.channelDescription {
color: var(--text-secondary);
font-size: 13px;
margin-top: 4px;
}
.options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.optionButton {
cursor: pointer;
width: 100%;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid var(--border-color);
background-color: var(--bg-primary);
color: var(--text-secondary);
font-weight: 500;
font-size: 14px;
transition: all 0.2s ease;
}
.optionButtonActive {
border-color: var(--primary-color);
background-color: rgba(25, 118, 210, 0.12);
color: var(--primary-color);
font-weight: 700;
}
.saveButton {
align-self: flex-end;
padding: 12px 24px;
border-radius: 8px;
border: none;
background-color: var(--primary-color);
color: white;
cursor: pointer;
font-weight: bold;
font-size: 16px;
}
@media (max-width: 760px) {
.card {
grid-template-columns: 1fr;
gap: 12px;
}
}
@media (max-width: 480px) {
.container {
margin-top: 24px;
}
.card {
padding: 14px;
}
.options {
grid-template-columns: 1fr;
}
.saveButton {
width: 100%;
}
}
@@ -0,0 +1,7 @@
"use client";
import Saveable from "@components/Saveable/Saveable";
export default function NotificationsPreferencesLayout({ children }: { children: React.ReactNode }) {
return <Saveable>{children}</Saveable>;
}
@@ -0,0 +1,181 @@
"use client";
import { useEffect } from "react";
import Location from "@components/Location/Location";
import { useLocationStore } from "@store/Location.Store";
import { useNotificationPreferencesStore } from "@store/NotificationPreferences.Store";
import { useSessionStore } from "@store/Sesion.Store";
import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store";
import { useAlert } from "@store/Alert.Store";
import { getClientPreferences, upsertClientPreferences } from "@services/Notifications.Service";
import { NotificationChannel } from "@models/NotificationPreferences.model";
import AccountCircleOutlinedIcon from "@mui/icons-material/AccountCircleOutlined";
import NotificationsIcon from "@mui/icons-material/NotificationsOutlined";
import MaterialAlert from "@mui/material/Alert";
import styles from "./NotificationsPreferencesPage.module.css";
const CHANNELS: { key: NotificationChannel; label: string }[] = [
{ key: "whatsapp", label: "WhatsApp" },
{ key: "email", label: "Email" },
{ key: "system", label: "Sistema" }
];
type ChannelPreference = "default" | "preferred" | "muted";
export default function NotificationsPreferencesPage() {
const location = useLocationStore();
const store = useNotificationPreferencesStore();
const SessionInfo = useSessionStore();
const eventHandler = useEventHandlerStore();
const alert = useAlert();
useEffect(() => {
location.clear();
location.add({
url: "/user/profile",
title: "Perfil de usuario",
icon: <AccountCircleOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />
});
location.add({
url: "",
title: "Preferencias de notificación",
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />
});
}, []);
useEffect(() => {
if (!SessionInfo.userId) {
return;
}
store.setClientId(SessionInfo.userId);
store.setMode("client-preferences");
eventHandler.setEventType(EVENT_TYPES.LOADING);
getClientPreferences(SessionInfo.userId, SessionInfo.userId)
.then((preferences) => {
store.setClientPreferences(preferences);
store.updateFormData({
preferredChannels: preferences.preferredChannels || [],
mutedChannels: preferences.mutedChannels || []
});
store.setDirty(false);
})
.catch((error) => {
alert.showError(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
}, [SessionInfo.userId]);
const getChannelPreference = (channel: NotificationChannel): ChannelPreference => {
if (store.formData.mutedChannels.includes(channel)) {
return "muted";
}
if (store.formData.preferredChannels.includes(channel)) {
return "preferred";
}
return "default";
};
const selectPreference = (channel: NotificationChannel, preference: ChannelPreference) => {
store.updateFormData({
preferredChannels:
preference === "preferred"
? [...store.formData.preferredChannels.filter((c) => c !== channel), channel]
: store.formData.preferredChannels.filter((c) => c !== channel),
mutedChannels:
preference === "muted"
? [...store.formData.mutedChannels.filter((c) => c !== channel), channel]
: store.formData.mutedChannels.filter((c) => c !== channel)
});
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
};
const handleSave = () => {
eventHandler.setEventType(EVENT_TYPES.LOADING);
upsertClientPreferences(SessionInfo.userId, SessionInfo.userId, {
preferredChannels: store.formData.preferredChannels as NotificationChannel[],
mutedChannels: store.formData.mutedChannels as NotificationChannel[]
})
.then((preferences) => {
store.setClientPreferences(preferences);
store.setDirty(false);
})
.catch((error) => {
alert.showError(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
useEffect(() => {
if (eventHandler.eventType === EVENT_TYPES.SAVE) {
handleSave();
}
}, [eventHandler.eventType]);
return (
<>
<Location />
<div className={styles.container}>
<MaterialAlert severity="info" sx={{ marginBottom: "0" }}>
<b>Preferencias globales:</b> Configure cómo prefiere recibir notificaciones. Si deja un canal en estándar, cada organización podrá usar su configuración general.
</MaterialAlert>
<div className={styles.channels}>
{CHANNELS.map((channel) => {
const selectedPreference = getChannelPreference(channel.key);
return (
<div key={channel.key} className={styles.card}>
<div>
<div className={styles.channelTitle}>{channel.label}</div>
<div className={styles.channelDescription}>
{selectedPreference === "preferred" && "Priorizar este canal para tus notificaciones."}
{selectedPreference === "muted" && "No recibir notificaciones por este canal."}
{selectedPreference === "default" && "Usar la configuración estándar de cada organización."}
</div>
</div>
<div className={styles.options}>
{[
{ key: "default" as ChannelPreference, label: "Estándar" },
{ key: "preferred" as ChannelPreference, label: "Preferido" },
{ key: "muted" as ChannelPreference, label: "Silenciado" }
].map((option) => {
const isSelected = selectedPreference === option.key;
return (
<button
key={option.key}
type="button"
onClick={() => selectPreference(channel.key, option.key)}
className={`${styles.optionButton} ${isSelected ? styles.optionButtonActive : ""}`}
>
{option.label}
</button>
);
})}
</div>
</div>
);
})}
</div>
{store.isDirty && (
<button onClick={handleSave} className={styles.saveButton}>
Guardar preferencias
</button>
)}
</div>
<div style={{ marginBottom: "40px" }}>&nbsp;</div>
</>
);
}
+6
View File
@@ -5,6 +5,7 @@ import Location from "@components/Location/Location";
import { useLocationStore } from "@store/Location.Store";
import { useEffect } from "react";
import AccountCircleOutlinedIcon from "@mui/icons-material/AccountCircleOutlined";
import NotificationsIcon from "@mui/icons-material/Notifications";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
export default function Profile() {
@@ -52,6 +53,11 @@ export default function Profile() {
icon="/notifications-icon.svg"
onClick={() => goTo("/user/profile/notifications")}
/>
<ButtonOption
text="Preferencias de notificación"
icon="/notifications-icon.svg"
onClick={() => goTo("/user/profile/notifications-preferences")}
/>
<ButtonOption
text="Avanzado"
icon="/services-icon.svg"