mirror of
https://github.com/rajnandan1/kener.git
synced 2026-06-23 04:10:22 +00:00
refactor: implement global maintenance notification settings and update related event handling
This commit is contained in:
@@ -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[]> {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()}`,
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user