"use client"; import { useEffect, useMemo, useState } from "react"; import { useI18n } from "@/lib/i18n/useI18n"; type RoleName = "MEMBER" | "ADMIN" | "OWNER"; type Channel = "email" | "sms"; type RoleRule = { enabled: boolean; afterMinutes: number; channels: Channel[]; }; type AlertRule = { id: string; eventType: string; roles: Record; repeatMinutes?: number; }; type AlertPolicy = { version: number; defaults: Record; rules: AlertRule[]; }; type AlertContact = { id: string; name: string; roleScope: string; email?: string | null; phone?: string | null; eventTypes?: string[] | null; isActive: boolean; userId?: string | null; }; type ContactDraft = { name: string; roleScope: string; email: string; phone: string; eventTypes: string[]; isActive: boolean; }; const ROLE_ORDER: RoleName[] = ["MEMBER", "ADMIN", "OWNER"]; const CHANNELS: Channel[] = ["email", "sms"]; const EVENT_TYPES = [ { value: "macrostop", labelKey: "alerts.event.macrostop" }, { value: "microstop", labelKey: "alerts.event.microstop" }, { value: "slow-cycle", labelKey: "alerts.event.slow-cycle" }, { value: "offline", labelKey: "alerts.event.offline" }, { value: "error", labelKey: "alerts.event.error" }, ] as const; function normalizeContactDraft(contact: AlertContact): ContactDraft { return { name: contact.name, roleScope: contact.roleScope, email: contact.email ?? "", phone: contact.phone ?? "", eventTypes: Array.isArray(contact.eventTypes) ? contact.eventTypes : [], isActive: contact.isActive, }; } export default function AlertsPage() { const { t } = useI18n(); const [policy, setPolicy] = useState(null); const [policyDraft, setPolicyDraft] = useState(null); const [contacts, setContacts] = useState([]); const [contactEdits, setContactEdits] = useState>({}); const [loading, setLoading] = useState(true); const [role, setRole] = useState("MEMBER"); const [savingPolicy, setSavingPolicy] = useState(false); const [policyError, setPolicyError] = useState(null); const [contactsError, setContactsError] = useState(null); const [savingContactId, setSavingContactId] = useState(null); const [deletingContactId, setDeletingContactId] = useState(null); const [selectedEventType, setSelectedEventType] = useState(""); const [newContact, setNewContact] = useState({ name: "", roleScope: "CUSTOM", email: "", phone: "", eventTypes: [], isActive: true, }); const [creatingContact, setCreatingContact] = useState(false); const [createError, setCreateError] = useState(null); useEffect(() => { let alive = true; async function load() { setLoading(true); setPolicyError(null); setContactsError(null); try { const [policyRes, contactsRes, meRes] = await Promise.all([ fetch("/api/alerts/policy", { cache: "no-store" }), fetch("/api/alerts/contacts", { cache: "no-store" }), fetch("/api/me", { cache: "no-store" }), ]); const policyJson = await policyRes.json().catch(() => ({})); const contactsJson = await contactsRes.json().catch(() => ({})); const meJson = await meRes.json().catch(() => ({})); if (!alive) return; if (!policyRes.ok || !policyJson?.ok) { setPolicyError(policyJson?.error || t("alerts.error.loadPolicy")); } else { setPolicy(policyJson.policy); setPolicyDraft(policyJson.policy); if (!selectedEventType && policyJson.policy?.rules?.length) { setSelectedEventType(policyJson.policy.rules[0].eventType); } } if (!contactsRes.ok || !contactsJson?.ok) { setContactsError(contactsJson?.error || t("alerts.error.loadContacts")); } else { setContacts(contactsJson.contacts ?? []); const nextEdits: Record = {}; for (const contact of contactsJson.contacts ?? []) { nextEdits[contact.id] = normalizeContactDraft(contact); } setContactEdits(nextEdits); } if (meRes.ok && meJson?.ok && meJson?.membership?.role) { setRole(String(meJson.membership.role).toUpperCase() as RoleName); } } catch { if (!alive) return; setPolicyError(t("alerts.error.loadPolicy")); setContactsError(t("alerts.error.loadContacts")); } finally { if (alive) setLoading(false); } } load(); return () => { alive = false; }; }, [t]); useEffect(() => { if (!policyDraft?.rules?.length) return; setSelectedEventType((prev) => { if (prev && policyDraft.rules.some((rule) => rule.eventType === prev)) { return prev; } return policyDraft.rules[0].eventType; }); }, [policyDraft]); function updatePolicyDefaults(role: RoleName, patch: Partial) { setPolicyDraft((prev) => { if (!prev) return prev; return { ...prev, defaults: { ...prev.defaults, [role]: { ...prev.defaults[role], ...patch, }, }, }; }); } function updateRule(eventType: string, patch: Partial) { setPolicyDraft((prev) => { if (!prev) return prev; return { ...prev, rules: prev.rules.map((rule) => rule.eventType === eventType ? { ...rule, ...patch } : rule ), }; }); } function updateRuleRole(eventType: string, role: RoleName, patch: Partial) { setPolicyDraft((prev) => { if (!prev) return prev; return { ...prev, rules: prev.rules.map((rule) => { if (rule.eventType !== eventType) return rule; return { ...rule, roles: { ...rule.roles, [role]: { ...rule.roles[role], ...patch, }, }, }; }), }; }); } function applyDefaultsToEvent(eventType: string) { setPolicyDraft((prev) => { if (!prev) return prev; return { ...prev, rules: prev.rules.map((rule) => { if (rule.eventType !== eventType) return rule; return { ...rule, roles: { MEMBER: { ...prev.defaults.MEMBER }, ADMIN: { ...prev.defaults.ADMIN }, OWNER: { ...prev.defaults.OWNER }, }, }; }), }; }); } async function savePolicy() { if (!policyDraft) return; setSavingPolicy(true); setPolicyError(null); try { const res = await fetch("/api/alerts/policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ policy: policyDraft }), }); const json = await res.json().catch(() => ({})); if (!res.ok || !json?.ok) { setPolicyError(json?.error || t("alerts.error.savePolicy")); return; } setPolicy(policyDraft); } catch { setPolicyError(t("alerts.error.savePolicy")); } finally { setSavingPolicy(false); } } function updateContactDraft(id: string, patch: Partial) { setContactEdits((prev) => ({ ...prev, [id]: { ...(prev[id] ?? normalizeContactDraft(contacts.find((c) => c.id === id)!)), ...patch, }, })); } async function saveContact(id: string) { const draft = contactEdits[id]; if (!draft) return; setSavingContactId(id); try { const eventTypes = draft.eventTypes.length ? draft.eventTypes : null; const res = await fetch(`/api/alerts/contacts/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: draft.name, roleScope: draft.roleScope, email: draft.email || null, phone: draft.phone || null, eventTypes, isActive: draft.isActive, }), }); const json = await res.json().catch(() => ({})); if (!res.ok || !json?.ok) { setContactsError(json?.error || t("alerts.error.saveContacts")); return; } const updated = json.contact as AlertContact; setContacts((prev) => prev.map((c) => (c.id === id ? updated : c))); setContactEdits((prev) => ({ ...prev, [id]: normalizeContactDraft(updated) })); } catch { setContactsError(t("alerts.error.saveContacts")); } finally { setSavingContactId(null); } } async function deleteContact(id: string) { setDeletingContactId(id); try { const res = await fetch(`/api/alerts/contacts/${id}`, { method: "DELETE" }); const json = await res.json().catch(() => ({})); if (!res.ok || !json?.ok) { setContactsError(json?.error || t("alerts.error.deleteContact")); return; } setContacts((prev) => prev.filter((c) => c.id !== id)); setContactEdits((prev) => { const next = { ...prev }; delete next[id]; return next; }); } catch { setContactsError(t("alerts.error.deleteContact")); } finally { setDeletingContactId(null); } } async function createContact() { setCreatingContact(true); setCreateError(null); try { const eventTypes = newContact.eventTypes.length ? newContact.eventTypes : null; const res = await fetch("/api/alerts/contacts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newContact.name, roleScope: newContact.roleScope, email: newContact.email || null, phone: newContact.phone || null, eventTypes, }), }); const json = await res.json().catch(() => ({})); if (!res.ok || !json?.ok) { setCreateError(json?.error || t("alerts.error.createContact")); return; } const contact = json.contact as AlertContact; setContacts((prev) => [contact, ...prev]); setContactEdits((prev) => ({ ...prev, [contact.id]: normalizeContactDraft(contact) })); setNewContact({ name: "", roleScope: "CUSTOM", email: "", phone: "", eventTypes: [], isActive: true, }); } catch { setCreateError(t("alerts.error.createContact")); } finally { setCreatingContact(false); } } const policyDirty = useMemo( () => JSON.stringify(policy) !== JSON.stringify(policyDraft), [policy, policyDraft] ); const canEdit = role === "OWNER"; return (

{t("alerts.title")}

{t("alerts.subtitle")}

{loading && (
{t("alerts.loading")}
)} {!loading && policyError && (
{policyError}
)} {!loading && policyDraft && (
{t("alerts.policy.title")}
{t("alerts.policy.subtitle")}
{!canEdit && (
{t("alerts.policy.readOnly")}
)}
{t("alerts.policy.defaults")}
{t("alerts.policy.defaultsHelp")}
{ROLE_ORDER.map((role) => { const rule = policyDraft.defaults[role]; return (
{role}
{t("alerts.policy.channels")}
{CHANNELS.map((channel) => ( ))}
); })}
{t("alerts.policy.eventSelectLabel")}
{t("alerts.policy.eventSelectHelper")}
{policyDraft.rules .filter((rule) => rule.eventType === selectedEventType) .map((rule) => (
{t(`alerts.event.${rule.eventType}`)}
{ROLE_ORDER.map((role) => { const roleRule = rule.roles[role]; return (
{role}
{t("alerts.policy.channels")}
{CHANNELS.map((channel) => ( ))}
); })}
))}
)}
{t("alerts.contacts.title")}
{t("alerts.contacts.subtitle")}
{!canEdit && (
{t("alerts.contacts.readOnly")}
)} {contactsError && (
{contactsError}
)}
{createError && (
{createError}
)}
{contacts.length === 0 && (
{t("alerts.contacts.empty")}
)} {contacts.map((contact) => { const draft = contactEdits[contact.id] ?? normalizeContactDraft(contact); const locked = !!contact.userId; return (
{locked && ( {t("alerts.contacts.linkedUser")} )}
); })}
); }