refactor: implement global maintenance notification settings and update related event handling

This commit is contained in:
Raj Nandan Sharma
2026-03-27 20:46:00 +05:30
parent 1085c3e561
commit 0716f271df
11 changed files with 378 additions and 109 deletions
@@ -16,6 +16,7 @@ import { maintenanceToVariables, siteDataToVariables } from "../notification/not
import { GetAllSiteData } from "./controller.js";
import subscriberQueue from "../queues/subscriberQueue.js";
import GC from "../../global-constants";
import seedSiteData from "../db/seedSiteData.js";
// ============ Input Interfaces ============
@@ -94,31 +95,83 @@ export function determineEventStatus(
return "SCHEDULED";
}
// ============ Helper to create a maintenance event with notification ============
export const CreateMaintenanceEventWithNotification = async (
maintenance_id: number,
start_date_time: number,
end_date_time: number,
title: string,
description: string | null,
): Promise<MaintenanceEventRecord> => {
const event = await db.createMaintenanceEvent({
maintenance_id,
start_date_time,
end_date_time,
status: determineEventStatus(start_date_time, end_date_time),
});
try {
const siteData = await GetAllSiteData();
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
if (notificationSettings.event_types.created) {
const siteVars = siteDataToVariables(siteData);
const siteUrl = siteVars.site_url;
const monitors = await db.getMonitorsByMaintenanceId(maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const eventDetailed: MaintenanceEventRecordDetailed = {
id: event.id,
maintenance_id,
start_date_time,
end_date_time,
status: event.status as MaintenanceEventRecordDetailed["status"],
created_at: new Date(),
updated_at: new Date(),
title,
description,
};
const update = maintenanceToVariables(
eventDetailed,
monitorNames,
"**has been created**",
"created",
"Maintenance Created",
siteUrl,
);
await subscriberQueue.push(update);
}
} catch (err) {
console.error(`Error sending created notification for maintenance event ${event.id}:`, err);
}
return event;
};
// ============ Helper to generate upcoming events from RRULE ============
/**
* Generate maintenance events for the next N days based on the RRULE
* Generate maintenance events based on the RRULE
* @param maintenance_id - The maintenance record ID
* @param start_date_time - Unix timestamp for the DTSTART
* @param rrule - The RRULE string (e.g., FREQ=WEEKLY;BYDAY=SU)
* @param duration_seconds - Duration of each maintenance window
* @param daysAhead - Number of days to look ahead (default 7)
* @param count - Maximum number of events to create (default 1)
*/
export const GenerateMaintenanceEvents = async (
maintenance_id: number,
start_date_time: number,
rrule: string,
duration_seconds: number,
daysAhead: number = 7,
count: number = 1,
): Promise<MaintenanceEventRecord[]> => {
const createdEvents: MaintenanceEventRecord[] = [];
// Convert start timestamp to Date (UTC)
const dtstart = new Date(start_date_time * 1000);
// Define the window to generate events
const now = new Date();
const windowEnd = addDays(now, daysAhead);
try {
// Build the full RRULE string with DTSTART
@@ -127,22 +180,30 @@ export const GenerateMaintenanceEvents = async (
// Parse the RRULE
const rule = rrulestr(fullRrule);
// Get occurrences between now and window end
// For one-time (COUNT=1), we use dtstart as the reference
let occurrences: Date[];
// Get occurrences based on count
let occurrences: Date[] = [];
if (rrule.includes("COUNT=1")) {
// One-time maintenance: only create event if start_date_time is in the future or within window
if (dtstart >= now || (dtstart <= windowEnd && dtstart >= addDays(now, -1))) {
// One-time maintenance: only create event if start_date_time is recent or in the future
if (dtstart >= now || dtstart >= addDays(now, -1)) {
occurrences = [dtstart];
} else {
occurrences = [];
}
} else {
// Recurring: get all occurrences in the window
occurrences = rule.between(now, windowEnd, true);
// Recurring: get the next `count` occurrences from now
let searchFrom = now;
for (let i = 0; i < count; i++) {
const next = rule.after(searchFrom, i === 0);
if (!next) break;
occurrences.push(next);
searchFrom = new Date(next.getTime() + 1000);
}
}
// Fetch maintenance info for notifications
const maintenance = await db.getMaintenanceById(maintenance_id);
const maintenanceTitle = maintenance?.title || "";
const maintenanceDescription = maintenance?.description || null;
// Create events for each occurrence
for (const occurrence of occurrences) {
const eventStart = Math.floor(occurrence.getTime() / 1000);
@@ -153,12 +214,13 @@ export const GenerateMaintenanceEvents = async (
const alreadyExists = existing.some((e) => e.start_date_time === eventStart);
if (!alreadyExists) {
const event = await db.createMaintenanceEvent({
const event = await CreateMaintenanceEventWithNotification(
maintenance_id,
start_date_time: eventStart,
end_date_time: eventEnd,
status: determineEventStatus(eventStart, eventEnd),
});
eventStart,
eventEnd,
maintenanceTitle,
maintenanceDescription,
);
createdEvents.push(event);
}
}
@@ -202,8 +264,8 @@ export const CreateMaintenance = async (data: CreateMaintenanceInput): Promise<{
await db.addMonitorsToMaintenanceWithStatus(maintenance.id, data.monitors);
}
// Generate initial events for the next 7 days
await GenerateMaintenanceEvents(maintenance.id, data.start_date_time, data.rrule, data.duration_seconds, 7);
// Generate initial events
await GenerateMaintenanceEvents(maintenance.id, data.start_date_time, data.rrule, data.duration_seconds, 1);
return {
maintenance_id: maintenance.id,
@@ -336,7 +398,7 @@ export const UpdateMaintenance = async (id: number, data: UpdateMaintenanceInput
}
}
// Regenerate the event
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 7);
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 1);
} else {
// For recurring maintenances: delete future SCHEDULED events and regenerate
for (const event of events) {
@@ -345,8 +407,8 @@ export const UpdateMaintenance = async (id: number, data: UpdateMaintenanceInput
await db.deleteMaintenanceEvent(event.id);
}
}
// Regenerate events for the next 7 days
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 7);
// Regenerate events
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 1);
}
}
}
@@ -534,20 +596,23 @@ export const formatDurationSeconds = (seconds: number): string => {
/**
* Update maintenance event statuses based on current time:
* 1. SCHEDULED events starting within 60 minutes → READY
* 1. SCHEDULED events starting within the reminder buffer → READY
* 2. READY events where current time is within start/end → ONGOING
* 3. ONGOING events where end_date_time has passed → COMPLETED
*/
export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
const currentTimestamp = GetMinuteStartNowTimestampUTC();
const sixtyMinutesInSeconds = 60 * 60;
const siteData = await GetAllSiteData();
const siteVars = siteDataToVariables(siteData);
const siteUrl = siteVars.site_url;
//get global maintenance notification settings
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
try {
// 1. Mark SCHEDULED events starting within 60 minutes as READY
const scheduledEvents = await db.getScheduledEventsStartingSoon(currentTimestamp, sixtyMinutesInSeconds);
// 1. Mark SCHEDULED events starting within the reminder buffer as READY
const scheduledEvents = await db.getScheduledEventsStartingSoon(currentTimestamp, reminderBufferSeconds);
for (const event of scheduledEvents) {
await db.updateMaintenanceEventStatus(event.id, GC.READY);
console.log(`Maintenance event ${event.id} marked as READY (starts at ${event.start_date_time})`);
@@ -557,15 +622,17 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
new Date(event.start_date_time * 1000),
new Date(currentTimestamp * 1000),
);
const update = maintenanceToVariables(
event,
monitorNames,
`**is starting in ${timeUntilStart}**`,
"starting_soon",
"Maintenance Starting Soon",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.reminder) {
const update = maintenanceToVariables(
event,
monitorNames,
`**is starting in ${timeUntilStart}**`,
"starting_soon",
"Maintenance Starting Soon",
siteUrl,
);
await subscriberQueue.push(update);
}
}
// 2. Catch-up: SCHEDULED events that missed the READY window and already started → ONGOING
@@ -575,15 +642,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
console.log(`Maintenance event ${event.id} marked as ONGOING (catch-up from SCHEDULED)`);
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.started) {
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
}
}
// 3. Mark READY events that are now in progress as ONGOING
@@ -593,15 +663,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
console.log(`Maintenance event ${event.id} marked as ONGOING`);
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.started) {
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
}
}
// 4. Mark ONGOING events that have ended as COMPLETED
@@ -611,15 +684,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
console.log(`Maintenance event ${event.id} marked as COMPLETED`);
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const update = maintenanceToVariables(
event,
monitorNames,
"**has been completed**",
"completed",
"Maintenance Completed",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.ended) {
const update = maintenanceToVariables(
event,
monitorNames,
"**has been completed**",
"completed",
"Maintenance Completed",
siteUrl,
);
await subscriberQueue.push(update);
}
}
} catch (error) {
console.error("Error updating maintenance event statuses:", error);
@@ -20,6 +20,7 @@ import type {
SiteDateTimeFormat,
SiteSubscriptionsSettings,
SitemapXMLConfig,
GlobalMaintenanceNotificationSettings,
} from "../../types/site.js";
export interface SiteDataTransformed {
@@ -66,6 +67,7 @@ export interface SiteDataTransformed {
metaSiteTitle?: string;
metaSiteDescription?: string;
sitemap?: SitemapXMLConfig;
globalMaintenanceNotificationSettings?: GlobalMaintenanceNotificationSettings;
}
export function InsertKeyValue(key: string, value: string): Promise<number[]> {
+7 -2
View File
@@ -43,12 +43,12 @@ export const siteDataKeys: SiteDataKey[] = [
},
{
key: "favicon",
isValid: (value) => typeof value === "string" && value.trim().length > 0,
isValid: (value) => typeof value === "string",
data_type: "string",
},
{
key: "logo",
isValid: (value) => typeof value === "string" && value.trim().length > 0,
isValid: (value) => typeof value === "string",
data_type: "string",
},
{
@@ -291,4 +291,9 @@ export const siteDataKeys: SiteDataKey[] = [
isValid: IsValidJSONString,
data_type: "object",
},
{
key: "globalMaintenanceNotificationSettings",
isValid: IsValidJSONString,
data_type: "object",
},
];
+9
View File
@@ -168,6 +168,15 @@ const seedSiteData = {
mode: "off",
urls: [],
},
globalMaintenanceNotificationSettings: {
event_types: {
created: false,
reminder: true,
started: true,
ended: true,
},
reminder_buffer_hours: 1,
},
};
export default seedSiteData;
+1 -1
View File
@@ -46,7 +46,7 @@ const addWorker = () => {
fromEmail,
templateTextBody,
);
console.log(`📧 Email sent to ${toEmails}`);
// console.log(`📧 Email sent to ${toEmails}`);
} catch (error) {
console.error(`Failed to send email to ${toEmails}:`, error);
throw error; // Re-throw to trigger retry
@@ -4,7 +4,7 @@ import db from "../db/db.js";
import { rrulestr } from "rrule";
import { addDays } from "date-fns";
import type { MaintenanceRecord, MaintenanceEventRecord } from "../types/db.js";
import { determineEventStatus } from "../controllers/maintenanceController.js";
import { determineEventStatus, CreateMaintenanceEventWithNotification } from "../controllers/maintenanceController.js";
let maintenanceSchedulerQueue: Queue | null = null;
let worker: Worker | null = null;
@@ -58,12 +58,13 @@ const generateEventsForMaintenance = async (maintenance: MaintenanceRecord): Pro
// Check if event already exists for this start time
if (!existingStartTimes.has(eventStart)) {
await db.createMaintenanceEvent({
maintenance_id: maintenance.id,
start_date_time: eventStart,
end_date_time: eventEnd,
status: determineEventStatus(eventStart, eventEnd),
});
await CreateMaintenanceEventWithNotification(
maintenance.id,
eventStart,
eventEnd,
maintenance.title,
maintenance.description || null,
);
eventsCreated++;
console.log(
`Created maintenance event for "${maintenance.title}" at ${new Date(eventStart * 1000).toISOString()}`,
+10
View File
@@ -148,3 +148,13 @@ export interface SitemapXMLConfig {
loc: string;
}[];
}
export interface GlobalMaintenanceNotificationSettings {
event_types: {
created: boolean;
reminder: boolean;
started: boolean;
ended: boolean;
};
reminder_buffer_hours: number;
}
@@ -264,7 +264,7 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
updatedMaintenance.start_date_time,
updatedMaintenance.rrule,
updatedMaintenance.duration_seconds,
7,
1,
);
}
} else if (!isOneTime && scheduleChanged) {
@@ -287,7 +287,7 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
updatedMaintenance.start_date_time,
updatedMaintenance.rrule,
updatedMaintenance.duration_seconds,
7,
1,
);
}
}
@@ -15,6 +15,7 @@ Use **Manage → Site Configurations** to control identity, navigation, monitor
6. Configure **Social Preview & SEO**.
7. Configure **Data Retention Policy**.
8. Configure **Event Display Settings**.
9. Configure **Maintenance Notification Settings**.
## Runtime impact map {#runtime-impact-map}
@@ -25,6 +26,7 @@ Use **Manage → Site Configurations** to control identity, navigation, monitor
| Monitor sub menu options | `subMenuOptions` | Gates monitor share actions (badges/embed) on public monitor pages |
| Global page visibility | `globalPageVisibilitySettings` | Controls page switcher visibility and page-scoped navigation/events |
| Data retention policy | `dataRetentionPolicy` | Controls daily cleanup of old `monitoring_data` |
| Maintenance notifications | `globalMaintenanceNotificationSettings` | Controls which maintenance lifecycle events notify subscribers and reminder buffer timing |
| Social preview & SEO | `metaSiteTitle`, `metaSiteDescription`, `socialPreviewImage` | Default `<title>`, `og:title`, `<meta description>`, `og:description`, `og:image` for all pages |
## Monitor sub menu options {#monitor-sub-menu-options}
@@ -82,6 +84,25 @@ This affects:
- event sections on status pages
- notifications payload API used by the UI
## Maintenance notification settings {#maintenance-notification-settings}
`globalMaintenanceNotificationSettings` controls which maintenance event lifecycle transitions send subscriber notifications.
### Event types {#event-types}
| Event type | Trigger | Default |
| ---------- | ------------------------------- | ------- |
| `created` | New maintenance event generated | Off |
| `reminder` | Event enters READY state | On |
| `started` | Event enters ONGOING state | On |
| `ended` | Event enters COMPLETED state | On |
### Reminder buffer {#reminder-buffer}
`reminder_buffer_hours` (default `1`, minimum `1`) sets how many hours before the event start time the status transitions from SCHEDULED to READY.
See [Maintenance Events → Subscriber Notifications](/docs/v4/maintenances/events#subscriber-notifications) for the full lifecycle flow.
## Social preview and SEO {#social-preview-and-seo}
The **Social Preview & SEO** card sets site-wide defaults for meta tags used in search engines and link previews.
@@ -107,4 +128,5 @@ These values are used as defaults for every page. Individual pages can override
- notifications calendar opens page-scoped events for the current month.
- Change event display settings and verify incident/maintenance visibility.
- Set retention policy and confirm scheduler logs in server output.
- Toggle maintenance notification event types and verify subscribers receive (or don't receive) emails at each lifecycle stage.
- Set meta title/description and social preview image, then check `<meta>` tags in page source.
+2 -1
View File
@@ -2,7 +2,7 @@ import { redirect } from "@sveltejs/kit";
import MobileDetect from "mobile-detect";
import type { LayoutServerLoad } from "./$types";
import { IsEmailSetup } from "$lib/server/controllers/controller.js";
import GC from "$lib/global-constants";
import seedSiteData from "$lib/server/db/seedSiteData.js";
import serverResolve from "$lib/server/resolver.js";
import { resolve } from "$app/paths";
@@ -38,5 +38,6 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
siteStatusColorsDark,
font,
canSendEmail: IsEmailSetup(),
seedSiteData,
};
};
@@ -21,11 +21,13 @@
import { toast } from "svelte-sonner";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
import { page } from "$app/state";
import type {
DataRetentionPolicy,
EventDisplaySettings,
GlobalPageVisibilitySettings,
SitemapXMLConfig
SitemapXMLConfig,
GlobalMaintenanceNotificationSettings
} from "$lib/types/site.js";
interface NavItem {
@@ -47,25 +49,12 @@
let savingDataRetentionPolicy = $state(false);
let savingEventDisplaySettings = $state(false);
let savingSitemap = $state(false);
let savingMaintenanceNotificationSettings = $state(false);
let uploadingLogo = $state(false);
let uploadingFavicon = $state(false);
let uploadingSocialPreviewImage = $state(false);
const defaultEventDisplaySettings: EventDisplaySettings = {
incidents: {
enabled: true,
ongoing: { show: true },
resolved: { show: true, maxCount: 5, daysInPast: 7 }
},
maintenances: {
enabled: true,
ongoing: {
show: true
},
past: { show: true, maxCount: 5, daysInPast: 7 },
upcoming: { show: true, maxCount: 5, daysInFuture: 7 }
}
};
const defaultEventDisplaySettings: EventDisplaySettings = page.data.seedSiteData.eventDisplaySettings;
interface SiteDataForm {
siteName: string;
@@ -93,30 +82,28 @@
showShareEmbedMonitor: true
});
const defaultGlobalPageVisibilitySettings: GlobalPageVisibilitySettings = {
showSwitcher: true,
forceExclusivity: false
};
const defaultGlobalPageVisibilitySettings: GlobalPageVisibilitySettings =
page.data.seedSiteData.globalPageVisibilitySettings;
let globalPageVisibilitySettings = $state<GlobalPageVisibilitySettings>(
structuredClone(defaultGlobalPageVisibilitySettings)
);
let dataRetentionPolicy = $state<DataRetentionPolicy>({
enabled: true,
retentionDays: 90
});
let dataRetentionPolicy = $state<DataRetentionPolicy>(page.data.seedSiteData.dataRetentionPolicy);
let eventDisplaySettings = $state<EventDisplaySettings>(structuredClone(defaultEventDisplaySettings));
let metaSiteTitle = $state("");
let metaSiteDescription = $state("");
const defaultSitemap: SitemapXMLConfig = {
mode: "off",
urls: []
};
const defaultSitemap: SitemapXMLConfig = page.data.seedSiteData.sitemap;
let sitemap = $state<SitemapXMLConfig>(structuredClone(defaultSitemap));
const defaultMaintenanceNotificationSettings: GlobalMaintenanceNotificationSettings =
page.data.seedSiteData.globalMaintenanceNotificationSettings;
let maintenanceNotificationSettings = $state<GlobalMaintenanceNotificationSettings>(
structuredClone(defaultMaintenanceNotificationSettings)
);
const sitemapURL = $derived(
siteData.siteURL ? siteData.siteURL.replace(/\/$/, "") + clientResolver(resolve, "/sitemap.xml") : ""
);
@@ -244,6 +231,27 @@
} else {
sitemap = structuredClone(defaultSitemap);
}
if (data.globalMaintenanceNotificationSettings) {
try {
const parsed =
typeof data.globalMaintenanceNotificationSettings === "string"
? JSON.parse(data.globalMaintenanceNotificationSettings)
: data.globalMaintenanceNotificationSettings;
maintenanceNotificationSettings = {
...structuredClone(defaultMaintenanceNotificationSettings),
...parsed,
event_types: {
...structuredClone(defaultMaintenanceNotificationSettings.event_types),
...parsed?.event_types
}
};
} catch {
maintenanceNotificationSettings = structuredClone(defaultMaintenanceNotificationSettings);
}
} else {
maintenanceNotificationSettings = structuredClone(defaultMaintenanceNotificationSettings);
}
}
} catch (e) {
toast.error("Failed to load site data");
@@ -543,13 +551,53 @@
}
}
async function saveMaintenanceNotificationSettings() {
//reminder_buffer_hours > 0
if (maintenanceNotificationSettings.reminder_buffer_hours <= 0) {
toast.error("Reminder buffer hours must be at least 1");
return;
}
savingMaintenanceNotificationSettings = true;
try {
const payload: GlobalMaintenanceNotificationSettings = {
event_types: {
created: maintenanceNotificationSettings.event_types.created,
reminder: maintenanceNotificationSettings.event_types.reminder,
started: maintenanceNotificationSettings.event_types.started,
ended: maintenanceNotificationSettings.event_types.ended
},
reminder_buffer_hours: Math.max(1, Number(maintenanceNotificationSettings.reminder_buffer_hours) || 1)
};
const response = await fetch(clientResolver(resolve, "/manage/api"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "storeSiteData",
data: { globalMaintenanceNotificationSettings: JSON.stringify(payload) }
})
});
const result = await response.json();
if (result.error) {
toast.error(result.error);
} else {
maintenanceNotificationSettings.reminder_buffer_hours = payload.reminder_buffer_hours;
toast.success("Maintenance notification settings saved successfully");
}
} catch (e) {
toast.error("Failed to save maintenance notification settings");
} finally {
savingMaintenanceNotificationSettings = false;
}
}
async function handleImageUpload(event: Event, type: "logo" | "favicon" | "socialPreviewImage"): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Validate file type
const allowedTypes = ["image/png", "image/jpeg", "image/jpg", "image/svg+xml", "image/webp"];
const allowedTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
if (!allowedTypes.includes(file.type)) {
toast.error("Invalid file type. Allowed: PNG, JPG, SVG, WebP");
return;
@@ -792,7 +840,7 @@
<input
id="logo-input"
type="file"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp,image/heic,image/heif"
accept="image/png,image/jpeg,image/jpg,image/webp,image/heic,image/heif"
class="hidden"
onchange={(e) => handleImageUpload(e, "logo")}
disabled={uploadingLogo}
@@ -862,7 +910,7 @@
<input
id="favicon-input"
type="file"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp,image/heic,image/heif"
accept="image/png,image/jpeg,image/jpg,image/webp,image/heic,image/heif"
class="hidden"
onchange={(e) => handleImageUpload(e, "favicon")}
disabled={uploadingFavicon}
@@ -1028,7 +1076,7 @@
<input
id="nav-icon-input-{index}"
type="file"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp,image/heic,image/heif"
accept="image/png,image/jpeg,image/jpg,image/webp,image/heic,image/heif"
class="hidden"
onchange={(e) => handleNavIconUpload(e, index)}
/>
@@ -1446,5 +1494,100 @@
</Button>
</Card.Footer>
</Card.Root>
<!-- Maintenance Notification Settings Card -->
<Card.Root>
<Card.Header>
<Card.Title>Maintenance Notification Settings</Card.Title>
<Card.Description
>Configure which maintenance lifecycle events trigger subscriber notifications</Card.Description
>
</Card.Header>
<Card.Content class="space-y-6">
<div class="space-y-4">
<Label>Event Types</Label>
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex items-center justify-between gap-2 rounded-lg border p-3">
<div>
<p class="text-sm font-medium">Created</p>
<p class="text-muted-foreground text-xs">When a maintenance is created</p>
</div>
<Switch
checked={maintenanceNotificationSettings.event_types.created}
onCheckedChange={(v) => {
maintenanceNotificationSettings.event_types.created = v === true;
}}
/>
</div>
<div class="flex items-center justify-between gap-2 rounded-lg border p-3">
<div>
<p class="text-sm font-medium">Reminder</p>
<p class="text-muted-foreground text-xs">Before a scheduled maintenance starts</p>
</div>
<Switch
checked={maintenanceNotificationSettings.event_types.reminder}
onCheckedChange={(v) => {
maintenanceNotificationSettings.event_types.reminder = v === true;
}}
/>
</div>
<div class="flex items-center justify-between gap-2 rounded-lg border p-3">
<div>
<p class="text-sm font-medium">Started</p>
<p class="text-muted-foreground text-xs">When a maintenance begins</p>
</div>
<Switch
checked={maintenanceNotificationSettings.event_types.started}
onCheckedChange={(v) => {
maintenanceNotificationSettings.event_types.started = v === true;
}}
/>
</div>
<div class="flex items-center justify-between gap-2 rounded-lg border p-3">
<div>
<p class="text-sm font-medium">Ended</p>
<p class="text-muted-foreground text-xs">When a maintenance completes</p>
</div>
<Switch
checked={maintenanceNotificationSettings.event_types.ended}
onCheckedChange={(v) => {
maintenanceNotificationSettings.event_types.ended = v === true;
}}
/>
</div>
</div>
</div>
{#if maintenanceNotificationSettings.event_types.reminder}
<div class="space-y-2">
<Label for="reminder-buffer-hours">Reminder Buffer (hours)</Label>
<Input
id="reminder-buffer-hours"
type="number"
min={1}
bind:value={maintenanceNotificationSettings.reminder_buffer_hours}
/>
<p class="text-muted-foreground text-xs">
How many hours before the maintenance start time to send the reminder notification
</p>
</div>
{/if}
</Card.Content>
<Card.Footer class="flex justify-end">
<Button
onclick={saveMaintenanceNotificationSettings}
disabled={savingMaintenanceNotificationSettings}
class="cursor-pointer"
>
{#if savingMaintenanceNotificationSettings}
<Loader class="h-4 w-4 animate-spin" />
Saving...
{:else}
<SaveIcon class="h-4 w-4" />
Save
{/if}
</Button>
</Card.Footer>
</Card.Root>
{/if}
</div>