This commit is contained in:
Raj Nandan Sharma
2026-01-28 22:46:11 +05:30
parent d0d8e60a8f
commit af829fa73e
59 changed files with 2714 additions and 7485 deletions
@@ -1,20 +0,0 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable("templates", (table) => {
table.increments("id").primary();
table.string("template_name", 255).notNullable();
table.string("template_type", 50).notNullable(); // EMAIL, WEBHOOK, SLACK, DISCORD
table.string("template_usage", 50).notNullable(); // ALERT, SUBSCRIPTION
table.text("template_json").notNullable(); // JSON string for template content
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Add index for faster queries
table.index(["template_type", "template_usage"]);
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTable("templates");
}
@@ -1,15 +0,0 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("triggers", (table) => {
table.integer("template_id").unsigned().nullable();
table.foreign("template_id").references("id").inTable("templates").onDelete("SET NULL");
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable("triggers", (table) => {
table.dropForeign(["template_id"]);
table.dropColumn("template_id");
});
}
@@ -1,41 +0,0 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Create subscription_config table for admin settings
await knex.schema.createTable("subscription_config", (table) => {
table.increments("id").primary();
// JSON string with enabled events: { incidentUpdatesAll: boolean, maintenanceUpdatesAll: boolean, monitorUpdatesAll: boolean }
table.text("events_enabled").notNullable().defaultTo("{}");
// JSON string with enabled methods: { email: boolean, webhook: boolean, slack: boolean, discord: boolean }
table.text("methods_enabled").notNullable().defaultTo("{}");
// JSON string mapping method to trigger_id: { email: number | null, webhook: number | null, slack: number | null, discord: number | null }
table.text("method_triggers").notNullable().defaultTo("{}");
table.datetime("created_at").defaultTo(knex.fn.now());
table.datetime("updated_at").defaultTo(knex.fn.now());
});
// Insert default config row
await knex("subscription_config").insert({
events_enabled: JSON.stringify({
incidentUpdatesAll: false,
maintenanceUpdatesAll: false,
monitorUpdatesAll: false,
}),
methods_enabled: JSON.stringify({
email: false,
webhook: false,
slack: false,
discord: false,
}),
method_triggers: JSON.stringify({
email: null,
webhook: null,
slack: null,
discord: null,
}),
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("subscription_config");
}
@@ -1,24 +1,5 @@
import type { Knex } from "knex";
/**
* Redesign subscription system for better UX:
*
* OLD APPROACH:
* - subscribers table mixed identity with subscription method
* - Same user with email vs webhook = 2 different subscriber records
* - Confusing for users who want multiple notification methods
*
* NEW APPROACH:
* - subscriber_users: Core user identity (email-based, with verification)
* - subscriber_methods: Methods a user has configured (email, webhook URL, slack, discord)
* - user_subscriptions: What events/entities a user subscribes to, via which method
*
* Flow:
* 1. User enters email -> verification code sent -> verified -> subscriber_user created
* 2. User adds methods (their email is auto-added, can add webhook/slack/discord)
* 3. User subscribes to events choosing which methods to use
*/
export async function up(knex: Knex): Promise<void> {
// 1. Create subscriber_users table - the actual user identity
await knex.schema.createTable("subscriber_users", (table) => {
@@ -32,10 +13,10 @@ export async function up(knex: Knex): Promise<void> {
// Verification code for email verification (6 digit)
table.string("verification_code", 10).nullable();
table.datetime("verification_expires_at").nullable();
table.timestamp("verification_expires_at").nullable();
table.datetime("created_at").defaultTo(knex.fn.now());
table.datetime("updated_at").defaultTo(knex.fn.now());
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Indexes
table.index(["status"]);
@@ -61,8 +42,8 @@ export async function up(knex: Knex): Promise<void> {
// For webhook methods, we might want to store additional config
table.text("meta").nullable(); // JSON for extra config like headers
table.datetime("created_at").defaultTo(knex.fn.now());
table.datetime("updated_at").defaultTo(knex.fn.now());
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Indexes
table.index(["subscriber_user_id"]);
@@ -86,28 +67,23 @@ export async function up(knex: Knex): Promise<void> {
// Link to subscriber_method (which method to use for this subscription)
table.integer("subscriber_method_id").unsigned().notNullable();
// What event type: incidentUpdatesAll, maintenanceUpdatesAll, monitorUpdatesAll
// What event type: incidents, maintenance
table.string("event_type", 50).notNullable();
// Reference to specific entity (optional - null means "all")
table.string("entity_type", 50).nullable(); // 'monitor', 'incident', 'maintenance', or null
table.string("entity_id").nullable(); // The specific ID/tag
// Status: ACTIVE, INACTIVE
table.string("status", 20).notNullable().defaultTo("ACTIVE");
table.datetime("created_at").defaultTo(knex.fn.now());
table.datetime("updated_at").defaultTo(knex.fn.now());
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Indexes
table.index(["subscriber_user_id"]);
table.index(["subscriber_method_id"]);
table.index(["event_type"]);
table.index(["entity_type", "entity_id"]);
table.index(["status"]);
// Unique: one subscription per user-method-event-entity
table.unique(["subscriber_user_id", "subscriber_method_id", "event_type", "entity_type", "entity_id"]);
table.unique(["subscriber_user_id", "subscriber_method_id", "event_type"]);
// Foreign keys
table.foreign("subscriber_user_id").references("id").inTable("subscriber_users").onDelete("CASCADE");
@@ -3,23 +3,14 @@ import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Create email_templates_config table
await knex.schema.createTable("email_templates_config", (table) => {
table.increments("id").primary();
table.string("email_type", 50).notNullable().unique(); // email_code, forgot_password, verify_email
table.integer("email_template_id").unsigned().nullable();
table.string("is_active", 3).notNullable().defaultTo("NO"); // YES or NO
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Foreign key to templates table
table.foreign("email_template_id").references("id").inTable("templates").onDelete("SET NULL");
table.string("email_type", 50).notNullable().primary(); // email_code, forgot_password, verify_email
//template_subject
table.text("template_subject").notNullable();
//template_html_body
table.text("template_html_body").notNullable();
//template_text_body
table.text("template_text_body").notNullable();
});
// Pre-fill with default email types
await knex("email_templates_config").insert([
{ email_type: "email_code", email_template_id: null, is_active: "NO" },
{ email_type: "forgot_password", email_template_id: null, is_active: "NO" },
{ email_type: "verify_email", email_template_id: null, is_active: "NO" },
]);
}
export async function down(knex: Knex): Promise<void> {
@@ -0,0 +1,14 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable("general_email_templates", (table) => {
table.string("template_id").primary();
table.string("template_subject");
table.text("template_html_body");
table.text("template_text_body");
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("general_email_templates");
}
@@ -0,0 +1,49 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("subscription_triggers");
await knex.schema.dropTableIfExists("subscriptions");
await knex.schema.dropTableIfExists("subscribers");
}
export async function down(knex: Knex): Promise<void> {
await knex.schema
.createTable("subscribers", (table) => {
table.increments("id").primary();
table.string("subscriber_send").notNullable();
table.text("subscriber_meta").nullable();
table.string("subscriber_type").notNullable();
table.string("subscriber_status").notNullable();
table.dateTime("created_at").defaultTo(knex.fn.now());
table.dateTime("updated_at").defaultTo(knex.fn.now());
// Add unique constraint on subscriber_send and subscriber_type
table.unique(["subscriber_send", "subscriber_type"]);
// Add index on subscriber_send for better query performance
table.index(["subscriber_send"]);
})
.createTable("subscriptions", (table) => {
table.increments("id").primary();
table.integer("subscriber_id").unsigned().notNullable();
table.string("subscriptions_status").notNullable();
table.string("subscriptions_monitors").notNullable();
table.text("subscriptions_meta").nullable();
table.dateTime("created_at").defaultTo(knex.fn.now());
table.dateTime("updated_at").defaultTo(knex.fn.now());
// Add unique constraint on subscriber_id and subscriptions_monitors
table.unique(["subscriber_id", "subscriptions_monitors"]);
// Add index to optimize queries filtering by status and monitors
table.index(["subscriptions_status", "subscriptions_monitors"]);
})
.createTable("subscription_triggers", (table) => {
table.increments("id").primary();
table.string("subscription_trigger_type").notNullable().unique();
table.string("subscription_trigger_status").notNullable();
table.text("config").nullable();
table.dateTime("created_at").defaultTo(knex.fn.now());
table.dateTime("updated_at").defaultTo(knex.fn.now());
});
}
@@ -1,16 +0,0 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Create vault table for storing encrypted secrets
await knex.schema.createTable("vault", (table) => {
table.increments("id").primary();
table.string("secret_name", 255).notNullable().unique();
table.text("secret_value").notNullable(); // Encrypted value
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("vault");
}
+20
View File
@@ -0,0 +1,20 @@
import subscriptionAccountCodeTemplate from "../src/lib/server/templates/general/subscrption_account_code_template.ts";
import type { Knex } from "knex";
export async function seed(knex: Knex): Promise<void> {
// Check if the table has template_id 'subscription_account_code'
let count = await knex("general_email_templates")
.where({ template_id: subscriptionAccountCodeTemplate.template_id })
.count("template_id as CNT")
.first();
if (count && count.CNT === 0) {
await knex("general_email_templates").insert({
template_id: subscriptionAccountCodeTemplate.template_id,
template_subject: subscriptionAccountCodeTemplate.template_subject,
template_html_body: subscriptionAccountCodeTemplate.template_html_body,
template_text_body: subscriptionAccountCodeTemplate.template_text_body,
});
}
}
-44
View File
@@ -1,44 +0,0 @@
import defaultEmailTemplate from "../src/lib/server/templates/email_alert_template.ts";
import defaultWebhookTemplate from "../src/lib/server/templates/webhook_alert_template.ts";
import defaultSlackTemplate from "../src/lib/server/templates/slack_alert_template.ts";
import defaultDiscordTemplate from "../src/lib/server/templates/discord_alert_template.ts";
import defaultLoginEmailCodeTemplate from "../src/lib/server/templates/email_code_template.ts";
import defaultEmailUpdateTemplate from "../src/lib/server/templates/email_update_template.ts";
import type { Knex } from "knex";
export async function seed(knex: Knex): Promise<void> {
const templateCount = await knex("templates").count("id as CNT").first();
if (templateCount && templateCount.CNT == 0) {
await knex("templates").insert({
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
...defaultSlackTemplate,
});
await knex("templates").insert({
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
...defaultDiscordTemplate,
});
await knex("templates").insert({
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
...defaultWebhookTemplate,
});
await knex("templates").insert({
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
...defaultEmailTemplate,
});
await knex("templates").insert({
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
...defaultLoginEmailCodeTemplate,
});
await knex("templates").insert({
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
...defaultEmailUpdateTemplate,
});
}
}
+382
View File
@@ -0,0 +1,382 @@
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog/index.js";
import * as InputOTP from "$lib/components/ui/input-otp/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Switch } from "$lib/components/ui/switch/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { onMount } from "svelte";
import Mail from "@lucide/svelte/icons/mail";
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
import LogOut from "@lucide/svelte/icons/log-out";
import Loader2 from "@lucide/svelte/icons/loader-2";
import Bell from "@lucide/svelte/icons/bell";
import AlertTriangle from "@lucide/svelte/icons/alert-triangle";
import Wrench from "@lucide/svelte/icons/wrench";
interface Props {
open: boolean;
monitor_tags?: string[];
incident_ids?: number[];
}
let { open = $bindable(false), monitor_tags = [], incident_ids = [] }: Props = $props();
const STORAGE_KEY = "subscriber_token";
// UI States
type View = "loading" | "login" | "otp" | "preferences" | "error";
let currentView = $state<View>("loading");
let isSubmitting = $state(false);
let errorMessage = $state("");
// Form data
let email = $state("");
let otpValue = $state("");
// Preferences data
let subscriberEmail = $state("");
let incidentsEnabled = $state(false);
let maintenancesEnabled = $state(false);
let availableSubscriptions = $state<{ incidents: boolean; maintenances: boolean }>({
incidents: false,
maintenances: false
});
// Check token on mount
onMount(() => {
checkExistingToken();
});
// Also check when dialog opens
$effect(() => {
if (open) {
checkExistingToken();
}
});
async function checkExistingToken() {
const token = localStorage.getItem(STORAGE_KEY);
if (!token) {
currentView = "login";
return;
}
currentView = "loading";
try {
const response = await fetch("/dashboard-apis/subscription", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "getPreferences", token })
});
if (!response.ok) {
// Token invalid or expired
localStorage.removeItem(STORAGE_KEY);
currentView = "login";
return;
}
const data = await response.json();
subscriberEmail = data.email || "";
incidentsEnabled = data.subscriptions?.incidents || false;
maintenancesEnabled = data.subscriptions?.maintenances || false;
availableSubscriptions = data.availableSubscriptions || { incidents: false, maintenances: false };
currentView = "preferences";
} catch (err) {
localStorage.removeItem(STORAGE_KEY);
currentView = "login";
}
}
async function handleLogin() {
if (!email.trim()) {
errorMessage = "Please enter your email address";
return;
}
isSubmitting = true;
errorMessage = "";
try {
const response = await fetch("/dashboard-apis/subscription", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "login", email: email.trim() })
});
if (!response.ok) {
const data = await response.json();
errorMessage = data.message || "Failed to send verification code";
return;
}
currentView = "otp";
otpValue = "";
} catch (err) {
errorMessage = "Network error. Please try again.";
} finally {
isSubmitting = false;
}
}
async function handleVerifyOTP() {
if (otpValue.length !== 6) {
errorMessage = "Please enter the complete 6-digit code";
return;
}
isSubmitting = true;
errorMessage = "";
try {
const response = await fetch("/dashboard-apis/subscription", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "verify", email: email.trim(), code: otpValue })
});
if (!response.ok) {
const data = await response.json();
errorMessage = data.message || "Verification failed";
return;
}
const data = await response.json();
localStorage.setItem(STORAGE_KEY, data.token);
await checkExistingToken();
} catch (err) {
errorMessage = "Network error. Please try again.";
} finally {
isSubmitting = false;
}
}
async function handlePreferenceChange(type: "incidents" | "maintenances", value: boolean) {
const token = localStorage.getItem(STORAGE_KEY);
if (!token) {
currentView = "login";
return;
}
try {
const response = await fetch("/dashboard-apis/subscription", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "updatePreferences",
token,
[type]: value
})
});
if (!response.ok) {
const data = await response.json();
errorMessage = data.message || "Failed to update preference";
// Revert the toggle
if (type === "incidents") {
incidentsEnabled = !value;
} else {
maintenancesEnabled = !value;
}
return;
}
// Update local state
if (type === "incidents") {
incidentsEnabled = value;
} else {
maintenancesEnabled = value;
}
} catch (err) {
errorMessage = "Network error. Please try again.";
// Revert the toggle
if (type === "incidents") {
incidentsEnabled = !value;
} else {
maintenancesEnabled = !value;
}
}
}
function handleLogout() {
localStorage.removeItem(STORAGE_KEY);
email = "";
otpValue = "";
subscriberEmail = "";
incidentsEnabled = false;
maintenancesEnabled = false;
errorMessage = "";
currentView = "login";
}
function handleBackToEmail() {
currentView = "login";
otpValue = "";
errorMessage = "";
}
function handleClose() {
open = false;
errorMessage = "";
}
</script>
<Dialog.Root bind:open>
<Dialog.Overlay class="backdrop-blur-[2px]" />
<Dialog.Content class="max-w-sm rounded-3xl">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<Bell class="h-5 w-5" />
Subscribe to Updates
</Dialog.Title>
<Dialog.Description>
{#if currentView === "login"}
Get notified about incidents and scheduled maintenance.
{:else if currentView === "otp"}
Enter the verification code sent to your email.
{:else if currentView === "preferences"}
Manage your notification preferences.
{:else if currentView === "loading"}
Loading your preferences...
{/if}
</Dialog.Description>
</Dialog.Header>
<div class="py-4">
{#if currentView === "loading"}
<div class="flex items-center justify-center py-8">
<Loader2 class="text-muted-foreground h-8 w-8 animate-spin" />
</div>
{:else if currentView === "login"}
<!-- Login View -->
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<Label for="email">Email address</Label>
<div class="relative">
<Mail class="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
type="email"
placeholder="you@example.com"
class="pl-10"
bind:value={email}
disabled={isSubmitting}
onkeydown={(e) => e.key === "Enter" && handleLogin()}
/>
</div>
</div>
{#if errorMessage}
<p class="text-destructive text-sm">{errorMessage}</p>
{/if}
<Button onclick={handleLogin} disabled={isSubmitting} class="w-full">
{#if isSubmitting}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Sending...
{:else}
Continue
{/if}
</Button>
</div>
{:else if currentView === "otp"}
<!-- OTP View -->
<div class="flex flex-col gap-4">
<div class="flex flex-col items-center gap-4">
<p class="text-muted-foreground text-center text-sm">
We sent a 6-digit code to <strong class="text-foreground">{email}</strong>
</p>
<InputOTP.Root maxlength={6} bind:value={otpValue}>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells as cell, i (i)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
</div>
{#if errorMessage}
<p class="text-destructive text-center text-sm">{errorMessage}</p>
{/if}
<div class="flex gap-2">
<Button variant="outline" onclick={handleBackToEmail} disabled={isSubmitting} class="flex-1">
<ArrowLeft class="mr-2 h-4 w-4" />
Back
</Button>
<Button onclick={handleVerifyOTP} disabled={isSubmitting || otpValue.length !== 6} class="flex-1">
{#if isSubmitting}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Verifying...
{:else}
Verify
{/if}
</Button>
</div>
<Button variant="link" onclick={handleLogin} disabled={isSubmitting} class="text-xs">
Didn't receive the code? Resend
</Button>
</div>
{:else if currentView === "preferences"}
<!-- Preferences View -->
<div class="flex flex-col gap-6">
<div class="rounded-lg border p-4">
<div class="flex items-center justify-between gap-2">
<div class="flex gap-2">
<Mail class="text-muted-foreground h-4 w-4" />
<span class="text-sm font-medium">{subscriberEmail}</span>
</div>
<Button variant="ghost" size="icon-sm" onclick={handleLogout} class="rounded-btn">
<LogOut class="h-4 w-4" />
</Button>
</div>
</div>
<div class="flex flex-col gap-4">
{#if availableSubscriptions.incidents}
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<AlertTriangle class="h-5 w-5 text-orange-500" />
<div>
<Label class="font-medium">Incident Updates</Label>
<p class="text-muted-foreground text-xs">Get notified about incidents updates</p>
</div>
</div>
<Switch
checked={incidentsEnabled}
onCheckedChange={(value) => handlePreferenceChange("incidents", value)}
/>
</div>
{/if}
{#if availableSubscriptions.maintenances}
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Wrench class="h-5 w-5 text-blue-500" />
<div>
<Label class="font-medium">Maintenance Updates</Label>
<p class="text-muted-foreground text-xs">Get notified about scheduled maintenance</p>
</div>
</div>
<Switch
checked={maintenancesEnabled}
onCheckedChange={(value) => handlePreferenceChange("maintenances", value)}
/>
</div>
{/if}
</div>
{#if errorMessage}
<p class="text-destructive text-sm">{errorMessage}</p>
{/if}
</div>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -18,7 +18,7 @@
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
import ICONS from "$lib/icons";
import { format } from "date-fns";
import SubscribeMenuV2 from "$lib/components/SubscribeMenuV2.svelte";
import SubscribeMenu from "$lib/components/SubscribeMenu.svelte";
import CopyButton from "$lib/components/CopyButton.svelte";
import BadgesMenu from "$lib/components/BadgesMenu.svelte";
import EmbedMenu from "$lib/components/EmbedMenu.svelte";
@@ -179,6 +179,6 @@
</div>
</div>
<SubscribeMenuV2 bind:open={openSubscribeMenu} {monitor_tags} {incident_ids} />
<SubscribeMenu bind:open={openSubscribeMenu} {monitor_tags} {incident_ids} />
<BadgesMenu bind:open={openBadgesMenu} monitorTag={embedMonitorTag} {protocol} {domain} />
<EmbedMenu bind:open={openEmbedMenu} monitorTag={embedMonitorTag} {protocol} {domain} />
@@ -1,621 +0,0 @@
import { json, error } from "@sveltejs/kit";
import type { APIServerRequest } from "$lib/server/types/api-server";
import { ValidateEmail, GenerateRandomNumber, ValidateURL } from "$lib/server/tool";
import { GenerateTokenWithExpiry, VerifyToken } from "$lib/server/controllers/commonController";
import { SendEmailWithTemplate, IsEmailSetup } from "$lib/server/controllers/emailController";
import { GetSiteLogoURL, GetAllSiteData } from "$lib/server/controllers/siteDataController";
import { GetSubscriptionConfig } from "$lib/server/controllers/subscriptionConfigController";
import db from "$lib/server/db/db";
import emailCodeTemplate from "$lib/server/templates/email_code";
import type { SubscriptionMethodType, SubscriptionEventType, SubscriptionEntityType } from "$lib/server/types/db";
import { getPreferredEmailConfiguration, siteDataToVariables } from "$lib/server/notification/notification_utils";
import type { EmailCodeVariableMap } from "$lib/server/notification/types";
import sendEmail from "$lib/server/notification/email_notification.js";
import { GetTemplateByEmailType } from "$lib/server/controllers/emailTemplateConfigController";
interface SubscriptionV2RequestBody {
action:
| "getConfig"
| "login"
| "verify"
| "fetchUser"
| "addMethod"
| "removeMethod"
| "fetchSubscriptions"
| "subscribe"
| "unsubscribe"
| "deleteAccount";
email?: string;
token?: string;
code?: string;
method_type?: SubscriptionMethodType;
method_value?: string;
method_id?: number;
subscription_id?: number;
subscriptions?: Array<{
method_id: number;
event_type: SubscriptionEventType;
entity_type?: SubscriptionEntityType;
entity_id?: string;
}>;
}
/**
* POST /dashboard-apis/subscription-v2
* Handles all V2 user subscription-related actions
*
* Flow:
* 1. User enters email → login() → sends verification code
* 2. User enters code → verify() → returns auth token + creates/retrieves user
* 3. User can then:
* - fetchUser() → get user profile with methods
* - addMethod() → add webhook/slack/discord methods
* - removeMethod() → remove a method
* - fetchSubscriptions() → get all subscriptions
* - subscribe() → subscribe to events via specific methods
* - unsubscribe() → remove a subscription
* - deleteAccount() → delete user and all their data
*/
export default async function post(req: APIServerRequest): Promise<Response> {
const body = req.body as SubscriptionV2RequestBody;
const { action } = body;
try {
switch (action) {
case "getConfig":
return await handleGetConfig();
case "login":
return await handleLogin(body.email);
case "verify":
return await handleVerify(body.token, body.code);
case "fetchUser":
return await handleFetchUser(body.token);
case "addMethod":
return await handleAddMethod(body.token, body.method_type, body.method_value);
case "removeMethod":
return await handleRemoveMethod(body.token, body.method_id);
case "fetchSubscriptions":
return await handleFetchSubscriptions(body.token);
case "subscribe":
return await handleSubscribe(body.token, body.subscriptions);
case "unsubscribe":
return await handleUnsubscribe(body.token, body.subscription_id);
case "deleteAccount":
return await handleDeleteAccount(body.token);
default:
return error(400, { message: "Invalid action" });
}
} catch (e) {
console.error("Subscription V2 API error:", e);
const message = e instanceof Error ? e.message : "Error processing subscription request";
return error(500, { message });
}
}
/**
* Get subscription configuration for users
*/
async function handleGetConfig(): Promise<Response> {
const config = await GetSubscriptionConfig();
if (!config) {
return json({
enabled: false,
events_enabled: {
incidentUpdatesAll: false,
maintenanceUpdatesAll: false,
monitorUpdatesAll: false,
},
methods_enabled: {
email: false,
webhook: false,
slack: false,
discord: false,
},
});
}
// Check if email is actually set up
const emailSetup = IsEmailSetup();
const methodsEnabled = {
...config.methods_enabled,
email: config.methods_enabled.email && emailSetup,
};
// Check if any event is enabled
const anyEventEnabled =
config.events_enabled.incidentUpdatesAll ||
config.events_enabled.maintenanceUpdatesAll ||
config.events_enabled.monitorUpdatesAll;
// Check if any method is enabled
const anyMethodEnabled =
methodsEnabled.email || methodsEnabled.webhook || methodsEnabled.slack || methodsEnabled.discord;
return json({
enabled: anyEventEnabled && anyMethodEnabled,
events_enabled: config.events_enabled,
methods_enabled: methodsEnabled,
});
}
/**
* Login with email - sends verification code
*/
async function handleLogin(email?: string): Promise<Response> {
if (!email) {
return error(400, { message: "Email is required" });
}
if (!ValidateEmail(email)) {
return error(400, { message: "Invalid email address" });
}
const normalizedEmail = email.toLowerCase().trim();
const config = await GetSubscriptionConfig();
if (!config?.methods_enabled?.email) {
return error(400, { message: "Email subscriptions are not enabled" });
}
if (!IsEmailSetup()) {
return error(400, { message: "Email service is not configured" });
}
// Generate 6-digit verification code
const code = String(GenerateRandomNumber(6));
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
// Check if user exists
let user = await db.getSubscriberUserByEmail(normalizedEmail);
if (user) {
// Update verification code
await db.updateSubscriberUser(user.id, {
verification_code: code,
verification_expires_at: expiresAt,
});
} else {
// Create new user in PENDING status
user = await db.createSubscriberUser({
email: normalizedEmail,
status: "PENDING",
verification_code: code,
verification_expires_at: expiresAt,
});
}
// Generate token for verification
const token = await GenerateTokenWithExpiry({ user_id: user.id, email: normalizedEmail, action: "verify" }, "10m");
// Send verification email
const siteData = await GetAllSiteData();
const siteLogo = await GetSiteLogoURL(siteData.siteURL || "", siteData.logo || "", "/");
const siteName = siteData.siteName || "Status Page";
const templateSiteVars = siteDataToVariables(siteData);
const emailCodeVars: EmailCodeVariableMap = {
email_code: code,
email_subject: `Your Verification Code - ${siteName}`,
action: "login",
};
const emailConfig = getPreferredEmailConfiguration();
const template = await GetTemplateByEmailType("email_code");
if (!template) {
return error(500, { message: "Email template not found" });
}
await sendEmail(emailConfig, emailCodeVars, template, templateSiteVars, [normalizedEmail]);
return json({
success: true,
message: "Verification code sent to your email",
token,
});
}
/**
* Verify the code and return auth token
*/
async function handleVerify(token?: string, code?: string): Promise<Response> {
if (!token || !code) {
return error(400, { message: "Token and code are required" });
}
// Verify the token
let decoded: { user_id: number; email: string; action: string };
try {
const tokenPayload = await VerifyToken(token);
if (!tokenPayload) {
return error(400, { message: "Invalid or expired token" });
}
decoded = tokenPayload as unknown as { user_id: number; email: string; action: string };
if (decoded.action !== "verify") {
return error(400, { message: "Invalid token" });
}
} catch {
return error(400, { message: "Invalid or expired token" });
}
// Get user and check code
const user = await db.getSubscriberUserById(decoded.user_id);
if (!user) {
return error(404, { message: "User not found" });
}
if (user.verification_code !== code) {
return error(400, { message: "Invalid verification code" });
}
if (user.verification_expires_at && new Date(user.verification_expires_at).getTime() < Date.now()) {
return error(400, { message: "Verification code expired" });
}
// Activate user
await db.updateSubscriberUser(user.id, {
status: "ACTIVE",
verification_code: undefined,
verification_expires_at: undefined,
});
// Check if user has email method, if not create it
const emailMethod = await db.getSubscriberMethodByUserAndType(user.id, "email", user.email);
if (!emailMethod) {
await db.createSubscriberMethod({
subscriber_user_id: user.id,
method_type: "email",
method_value: user.email,
status: "ACTIVE",
});
}
// Generate long-lived auth token
const authToken = await GenerateTokenWithExpiry({ user_id: user.id, email: user.email, action: "auth" }, "30d");
return json({
success: true,
token: authToken,
user: {
id: user.id,
email: user.email,
status: "ACTIVE",
},
});
}
/**
* Fetch user profile with methods
*/
async function handleFetchUser(token?: string): Promise<Response> {
const userAuth = await verifyAuthToken(token);
// Get full user record for created_at
const userRecord = await db.getSubscriberUserById(userAuth.id);
// Get user's methods
const methods = await db.getSubscriberMethodsByUserId(userAuth.id);
return json({
user: {
id: userAuth.id,
email: userAuth.email,
status: userAuth.status,
created_at: userRecord?.created_at,
},
methods: methods.map((m) => ({
id: m.id,
method_type: m.method_type,
method_value: m.method_type === "email" ? maskEmail(m.method_value) : m.method_value,
status: m.status,
created_at: m.created_at,
})),
});
}
/**
* Add a notification method (webhook, slack, discord)
*/
async function handleAddMethod(
token?: string,
methodType?: SubscriptionMethodType,
methodValue?: string,
): Promise<Response> {
const user = await verifyAuthToken(token);
if (!methodType || !methodValue) {
return error(400, { message: "Method type and value are required" });
}
const config = await GetSubscriptionConfig();
if (!config?.methods_enabled?.[methodType]) {
return error(400, { message: `${methodType} notifications are not enabled` });
}
// Validate method value based on type
const trimmedValue = methodValue.trim();
if (methodType === "email") {
if (!ValidateEmail(trimmedValue)) {
return error(400, { message: "Invalid email address" });
}
// Only allow the user's own email
if (trimmedValue.toLowerCase() !== user.email.toLowerCase()) {
return error(400, { message: "You can only add your own email address" });
}
} else if (methodType === "webhook" || methodType === "slack" || methodType === "discord") {
if (!ValidateURL(trimmedValue)) {
return error(400, { message: "Invalid URL" });
}
}
// Check if method already exists for this user
const existingMethod = await db.getSubscriberMethodByUserAndType(user.id, methodType, trimmedValue);
if (existingMethod) {
return error(400, { message: "This notification method already exists" });
}
// Create the method
const method = await db.createSubscriberMethod({
subscriber_user_id: user.id,
method_type: methodType,
method_value: trimmedValue,
status: "ACTIVE",
});
return json({
success: true,
method: {
id: method.id,
method_type: method.method_type,
method_value: methodType === "email" ? maskEmail(method.method_value) : method.method_value,
status: method.status,
created_at: method.created_at,
},
});
}
/**
* Remove a notification method
*/
async function handleRemoveMethod(token?: string, methodId?: number): Promise<Response> {
const user = await verifyAuthToken(token);
if (!methodId) {
return error(400, { message: "Method ID is required" });
}
// Get method and verify ownership
const method = await db.getSubscriberMethodById(methodId);
if (!method || method.subscriber_user_id !== user.id) {
return error(404, { message: "Method not found" });
}
// Don't allow removing the primary email method
if (method.method_type === "email" && method.method_value === user.email) {
return error(400, { message: "Cannot remove your primary email method" });
}
// Delete the method (CASCADE will delete related subscriptions)
await db.deleteSubscriberMethod(methodId);
return json({
success: true,
message: "Method removed successfully",
});
}
/**
* Fetch all subscriptions for the user
*/
async function handleFetchSubscriptions(token?: string): Promise<Response> {
const user = await verifyAuthToken(token);
const subscriptionsWithMethods = await db.getSubscriptionsWithMethodsForUser(user.id);
return json({
subscriptions: subscriptionsWithMethods.map((sw) => ({
id: sw.subscription.id,
event_type: sw.subscription.event_type,
entity_type: sw.subscription.entity_type,
entity_id: sw.subscription.entity_id,
method: {
id: sw.method.id,
method_type: sw.method.method_type,
method_value: sw.method.method_type === "email" ? maskEmail(sw.method.method_value) : sw.method.method_value,
},
created_at: sw.subscription.created_at,
})),
});
}
/**
* Subscribe to events using specific methods
*/
async function handleSubscribe(
token?: string,
subscriptions?: Array<{
method_id: number;
event_type: SubscriptionEventType;
entity_type?: SubscriptionEntityType;
entity_id?: string;
}>,
): Promise<Response> {
const user = await verifyAuthToken(token);
if (!subscriptions || subscriptions.length === 0) {
return error(400, { message: "At least one subscription is required" });
}
const config = await GetSubscriptionConfig();
const created: Array<{
id: number;
event_type: SubscriptionEventType;
entity_type: SubscriptionEntityType;
entity_id: string | null;
method_id: number;
}> = [];
const errors: string[] = [];
for (const sub of subscriptions) {
try {
// Verify method ownership
const method = await db.getSubscriberMethodById(sub.method_id);
if (!method || method.subscriber_user_id !== user.id) {
errors.push(`Invalid method ID: ${sub.method_id}`);
continue;
}
// Verify event is enabled
if (sub.event_type === "incidentUpdatesAll" && !config?.events_enabled?.incidentUpdatesAll) {
errors.push("Incident notifications are not enabled");
continue;
}
if (sub.event_type === "maintenanceUpdatesAll" && !config?.events_enabled?.maintenanceUpdatesAll) {
errors.push("Maintenance notifications are not enabled");
continue;
}
if (sub.event_type === "monitorUpdatesAll" && !config?.events_enabled?.monitorUpdatesAll) {
errors.push("Monitor notifications are not enabled");
continue;
}
// Check if subscription already exists
const exists = await db.subscriptionV2Exists(
user.id,
sub.method_id,
sub.event_type,
sub.entity_type || null,
sub.entity_id || null,
);
if (exists) {
errors.push(`Subscription already exists for ${sub.event_type}`);
continue;
}
// Create subscription
const newSub = await db.createUserSubscriptionV2({
subscriber_user_id: user.id,
subscriber_method_id: sub.method_id,
event_type: sub.event_type,
entity_type: sub.entity_type || null,
entity_id: sub.entity_id || null,
status: "ACTIVE",
});
created.push({
id: newSub.id,
event_type: newSub.event_type,
entity_type: newSub.entity_type,
entity_id: newSub.entity_id,
method_id: newSub.subscriber_method_id,
});
} catch (e) {
console.error("Error creating subscription:", e);
errors.push(`Failed to create subscription for ${sub.event_type}`);
}
}
return json({
success: created.length > 0,
created,
errors: errors.length > 0 ? errors : undefined,
});
}
/**
* Remove a subscription
*/
async function handleUnsubscribe(token?: string, subscriptionId?: number): Promise<Response> {
const user = await verifyAuthToken(token);
if (!subscriptionId) {
return error(400, { message: "Subscription ID is required" });
}
// Get subscription and verify ownership
const subscription = await db.getUserSubscriptionV2ById(subscriptionId);
if (!subscription || subscription.subscriber_user_id !== user.id) {
return error(404, { message: "Subscription not found" });
}
await db.deleteUserSubscriptionV2(subscriptionId);
return json({
success: true,
message: "Subscription removed successfully",
});
}
/**
* Delete user account and all data
*/
async function handleDeleteAccount(token?: string): Promise<Response> {
const user = await verifyAuthToken(token);
// Delete user (CASCADE will delete methods and subscriptions)
await db.deleteSubscriberUser(user.id);
return json({
success: true,
message: "Account deleted successfully",
});
}
// ============ Helpers ============
/**
* Verify auth token and return user
*/
async function verifyAuthToken(token?: string): Promise<{
id: number;
email: string;
status: string;
}> {
if (!token) {
throw error(401, { message: "Authentication required" });
}
let decoded: { user_id: number; email: string; action: string };
try {
const tokenPayload = await VerifyToken(token);
if (!tokenPayload) {
throw error(401, { message: "Invalid or expired token" });
}
decoded = tokenPayload as unknown as { user_id: number; email: string; action: string };
if (decoded.action !== "auth") {
throw error(401, { message: "Invalid token" });
}
} catch (e) {
// Re-throw HttpError from error()
if (e && typeof e === "object" && "status" in e) {
throw e;
}
throw error(401, { message: "Invalid or expired token" });
}
const user = await db.getSubscriberUserById(decoded.user_id);
if (!user) {
throw error(404, { message: "User not found" });
}
if (user.status !== "ACTIVE") {
throw error(403, { message: "Account is not active" });
}
return {
id: user.id,
email: user.email,
status: user.status,
};
}
/**
* Mask email for display (show first 2 chars and domain)
*/
function maskEmail(email: string): string {
const [local, domain] = email.split("@");
if (local.length <= 2) {
return `${local}***@${domain}`;
}
return `${local.slice(0, 2)}***@${domain}`;
}
+103 -190
View File
@@ -1,223 +1,136 @@
import { json, error } from "@sveltejs/kit";
import type { APIServerRequest } from "$lib/server/types/api-server";
import { ValidateEmail, GenerateRandomNumber } from "$lib/server/tool";
import { GenerateTokenWithExpiry, VerifyToken } from "$lib/server/controllers/commonController";
import { SendEmailWithTemplate } from "$lib/server/controllers/emailController";
import { GetSiteLogoURL, GetAllSiteData } from "$lib/server/controllers/siteDataController";
import type { SubscriptionsConfig } from "$lib/server/types/db.js";
import { GetSiteDataByKey } from "$lib/server/controllers/siteDataController";
import {
CreateNewSubscriber,
GetSubscriberByEmailAndType,
CreateNewSubscription,
UpdateSubscriberMeta,
RemoveAllSubscriptions,
GetSubscriptionsBySubscriberID,
UpdateSubscriberStatus,
GetSubscriberByID,
} from "$lib/server/controllers/subscriberController";
import emailCodeTemplate from "$lib/server/templates/email_code";
SubscriberLogin,
VerifySubscriberOTP,
VerifySubscriberToken,
UpdateSubscriberPreferences,
} from "$lib/server/controllers/userSubscriptionsController";
interface SubscriptionRequestBody {
action: "login" | "verify" | "fetch" | "subscribe" | "unsubscribe";
userEmail?: string;
token?: string;
code?: string;
monitors?: string[];
allMonitors?: boolean;
interface LoginRequest {
action: "login";
email: string;
}
/**
* POST /dashboard-apis/subscription
* Handles all subscription-related actions: login, verify, fetch, subscribe, unsubscribe
*/
interface VerifyRequest {
action: "verify";
email: string;
code: string;
}
interface GetPreferencesRequest {
action: "getPreferences";
token: string;
}
interface UpdatePreferencesRequest {
action: "updatePreferences";
token: string;
incidents?: boolean;
maintenances?: boolean;
}
type PostRequestBody = LoginRequest | VerifyRequest | GetPreferencesRequest | UpdatePreferencesRequest;
export default async function post(req: APIServerRequest): Promise<Response> {
const body = req.body as SubscriptionRequestBody;
const body = req.body as PostRequestBody;
const { action } = body;
try {
switch (action) {
case "login":
return await handleLogin(body.userEmail);
case "verify":
return await handleVerify(body.token, body.code);
case "fetch":
return await handleFetch(body.token);
case "subscribe":
return await handleSubscribe(body.token, body.monitors, body.allMonitors);
case "unsubscribe":
return await handleUnsubscribe(body.token);
default:
return error(400, { message: "Invalid action" });
}
} catch (e) {
console.error("Subscription API error:", e);
return error(500, { message: "Error processing subscription request" });
// Check if subscriptions are enabled
const config = await GetSubscriptionConfig();
if (!config || !config.enable) {
return error(400, { message: "Subscriptions are not enabled" });
}
const emailEnabled = config.methods?.emails?.incidents === true || config.methods?.emails?.maintenances === true;
if (!emailEnabled) {
return error(400, { message: "Email subscriptions are not enabled" });
}
switch (action) {
case "login":
return handleLogin((body as LoginRequest).email, config);
case "verify":
return handleVerify((body as VerifyRequest).email, (body as VerifyRequest).code);
case "getPreferences":
return handleGetPreferences((body as GetPreferencesRequest).token, config);
case "updatePreferences":
return handleUpdatePreferences(
(body as UpdatePreferencesRequest).token,
(body as UpdatePreferencesRequest).incidents,
(body as UpdatePreferencesRequest).maintenances,
config,
);
default:
return error(400, { message: "Invalid action" });
}
}
async function handleLogin(email?: string): Promise<Response> {
if (!email || !ValidateEmail(email)) {
return error(400, { message: "Invalid email address" });
async function GetSubscriptionConfig(): Promise<SubscriptionsConfig | null> {
const subscriptionsSettings = await GetSiteDataByKey("subscriptionsSettings");
if (!subscriptionsSettings) {
return null;
}
const subscriberMeta = {
email_code: GenerateRandomNumber(6),
};
const existingUser = await GetSubscriberByEmailAndType(email, "email");
if (!existingUser) {
await CreateNewSubscriber({
subscriber_send: email,
subscriber_type: "email",
subscriber_status: "PENDING",
subscriber_meta: JSON.stringify(subscriberMeta),
});
} else {
await UpdateSubscriberMeta(existingUser.id, JSON.stringify(subscriberMeta));
}
// Send email with code
const siteData = await GetAllSiteData();
const emailData = {
brand_name: siteData.siteName || "Kener",
logo_url: await GetSiteLogoURL(siteData.siteURL || "", siteData.logo || "", "/"),
email_code: String(subscriberMeta.email_code),
action: "login",
};
try {
await SendEmailWithTemplate(
emailCodeTemplate,
emailData,
email,
`[Important] Verify code to login to ${emailData.brand_name}`,
`Your verification code is: ${emailData.email_code}`,
);
} catch (e) {
console.error("Error sending email:", e);
return error(500, { message: "Error sending email" });
}
const token = await GenerateTokenWithExpiry({ email }, "5m");
return json({ newUser: !existingUser, token });
return subscriptionsSettings as SubscriptionsConfig;
}
async function handleVerify(token?: string, code?: string): Promise<Response> {
if (!token || !code) {
return error(400, { message: "Token and code are required" });
async function handleLogin(email: string, config: SubscriptionsConfig): Promise<Response> {
const result = await SubscriberLogin(email);
if (!result.success) {
return error(400, { message: result.error || "Failed to send verification code" });
}
const decoded = await VerifyToken(token);
if (!decoded || !decoded.email) {
return error(400, { message: "Invalid or expired token" });
}
const email = decoded.email as string;
const existingSubscriber = await GetSubscriberByEmailAndType(email, "email");
if (!existingSubscriber) {
return error(400, { message: "Invalid user" });
}
const subscriberMeta = JSON.parse(existingSubscriber.subscriber_meta || "{}");
const storedCode = subscriberMeta.email_code;
if (!storedCode || String(storedCode) !== String(code)) {
return error(400, { message: "Invalid code" });
}
// Update subscriber status to active and clear the code
await UpdateSubscriberStatus(existingSubscriber.id, "ACTIVE");
await UpdateSubscriberMeta(existingSubscriber.id, JSON.stringify({}));
// Generate long-lived token
const authToken = await GenerateTokenWithExpiry(
{
email: email,
subscriber_id: existingSubscriber.id,
},
"1y",
);
return json({ token: authToken });
return json({ success: true, message: "Verification code sent" });
}
async function handleFetch(token?: string): Promise<Response> {
if (!token) {
return error(400, { message: "Token is required" });
async function handleVerify(email: string, code: string): Promise<Response> {
const result = await VerifySubscriberOTP(email, code);
if (!result.success) {
return error(400, { message: result.error || "Verification failed" });
}
const decoded = await VerifyToken(token);
if (!decoded || !decoded.email || !decoded.subscriber_id) {
return error(400, { message: "Invalid token" });
}
return json({ success: true, token: result.token });
}
const subscriberId = decoded.subscriber_id as number;
const existingSubscriber = await GetSubscriberByID(subscriberId);
if (!existingSubscriber) {
return error(400, { message: "No active subscription found" });
async function handleGetPreferences(token: string, config: SubscriptionsConfig): Promise<Response> {
const result = await VerifySubscriberToken(token);
if (!result.success) {
return error(401, { message: result.error || "Invalid token" });
}
const allSubscriptions = await GetSubscriptionsBySubscriberID(subscriberId);
const monitors = allSubscriptions.map((item) => item.subscriptions_monitors);
return json({
monitors,
email: existingSubscriber.subscriber_send,
success: true,
email: result.user?.email,
subscriptions: result.subscriptions,
availableSubscriptions: {
incidents: config.methods?.emails?.incidents === true,
maintenances: config.methods?.emails?.maintenances === true,
},
});
}
async function handleSubscribe(token?: string, monitors?: string[], allMonitors?: boolean): Promise<Response> {
if (!token) {
return error(400, { message: "Token is required" });
async function handleUpdatePreferences(
token: string,
incidents: boolean | undefined,
maintenances: boolean | undefined,
config: SubscriptionsConfig,
): Promise<Response> {
// Only allow updating subscriptions that are enabled in config
const preferences: { incidents?: boolean; maintenances?: boolean } = {};
if (incidents !== undefined && config.methods?.emails?.incidents) {
preferences.incidents = incidents;
}
if (maintenances !== undefined && config.methods?.emails?.maintenances) {
preferences.maintenances = maintenances;
}
const decoded = await VerifyToken(token);
if (!decoded || !decoded.email || !decoded.subscriber_id) {
return error(400, { message: "Invalid token" });
const result = await UpdateSubscriberPreferences(token, preferences);
if (!result.success) {
return error(400, { message: result.error || "Failed to update preferences" });
}
const subscriberId = decoded.subscriber_id as number;
const existingSubscriber = await GetSubscriberByID(subscriberId);
if (!existingSubscriber) {
return error(400, { message: "No active subscription found" });
}
let monitorList = monitors || [];
if (allMonitors) {
monitorList = ["_"];
}
if (monitorList.length === 0) {
// Remove all subscriptions
await RemoveAllSubscriptions(subscriberId);
return json({ message: "Unsubscribed successfully" });
}
try {
await CreateNewSubscription(subscriberId, monitorList);
} catch (e) {
console.error("Error in CreateNewSubscription:", e);
return error(500, { message: "Error creating subscription" });
}
return json({ message: "Subscription updated successfully" });
}
async function handleUnsubscribe(token?: string): Promise<Response> {
if (!token) {
return error(400, { message: "Token is required" });
}
const decoded = await VerifyToken(token);
if (!decoded || !decoded.email || !decoded.subscriber_id) {
return error(400, { message: "Invalid token" });
}
const subscriberId = decoded.subscriber_id as number;
const existingSubscriber = await GetSubscriberByID(subscriberId);
if (!existingSubscriber) {
return error(400, { message: "No active subscription found" });
}
await RemoveAllSubscriptions(subscriberId);
return json({ message: "Unsubscribed successfully" });
return json({ success: true });
}
@@ -1,438 +0,0 @@
import { json, error } from "@sveltejs/kit";
import type { APIServerRequest } from "$lib/server/types/api-server";
import { ValidateEmail, GenerateRandomNumber, ValidateURL } from "$lib/server/tool";
import { GenerateTokenWithExpiry, VerifyToken } from "$lib/server/controllers/commonController";
import { SendEmailWithTemplate, IsEmailSetup } from "$lib/server/controllers/emailController";
import { GetSiteLogoURL, GetAllSiteData } from "$lib/server/controllers/siteDataController";
import { GetSubscriptionConfig } from "$lib/server/controllers/subscriptionConfigController";
import {
CreateNewSubscriber,
GetSubscriberByEmailAndType,
UpdateSubscriberMeta,
UpdateSubscriberStatus,
GetSubscriberByID,
} from "$lib/server/controllers/subscriberController";
import db from "$lib/server/db/db";
import emailCodeTemplate from "$lib/server/templates/email_code";
import type { SubscriptionMethodType, SubscriptionEventType, SubscriptionEntityType } from "$lib/server/types/db";
interface UserSubscriptionRequestBody {
action: "getConfig" | "login" | "verify" | "fetch" | "subscribe" | "unsubscribe" | "deleteSubscription";
method?: SubscriptionMethodType;
email?: string;
webhookUrl?: string;
token?: string;
code?: string;
subscriptions?: Array<{
event_type: SubscriptionEventType;
entity_type?: SubscriptionEntityType;
entity_id?: string;
}>;
subscriptionId?: number;
}
/**
* POST /dashboard-apis/user-subscription
* Handles all user subscription-related actions
*/
export default async function post(req: APIServerRequest): Promise<Response> {
const body = req.body as UserSubscriptionRequestBody;
const { action } = body;
try {
switch (action) {
case "getConfig":
return await handleGetConfig();
case "login":
return await handleLogin(body.method, body.email, body.webhookUrl);
case "verify":
return await handleVerify(body.token, body.code);
case "fetch":
return await handleFetch(body.token, body.method);
case "subscribe":
return await handleSubscribe(body.token, body.method, body.subscriptions);
case "unsubscribe":
return await handleUnsubscribe(body.token, body.method);
case "deleteSubscription":
return await handleDeleteSubscription(body.token, body.subscriptionId);
default:
return error(400, { message: "Invalid action" });
}
} catch (e) {
console.error("User Subscription API error:", e);
const message = e instanceof Error ? e.message : "Error processing subscription request";
return error(500, { message });
}
}
/**
* Get subscription configuration for users
* Returns which events and methods are enabled
*/
async function handleGetConfig(): Promise<Response> {
const config = await GetSubscriptionConfig();
if (!config) {
return json({
enabled: false,
events_enabled: {
incidentUpdatesAll: false,
maintenanceUpdatesAll: false,
monitorUpdatesAll: false,
},
methods_enabled: {
email: false,
webhook: false,
slack: false,
discord: false,
},
});
}
// Check if email is actually set up
const emailSetup = IsEmailSetup();
const methodsEnabled = {
...config.methods_enabled,
email: config.methods_enabled.email && emailSetup,
};
// Check if any event is enabled
const anyEventEnabled =
config.events_enabled.incidentUpdatesAll ||
config.events_enabled.maintenanceUpdatesAll ||
config.events_enabled.monitorUpdatesAll;
// Check if any method is enabled
const anyMethodEnabled =
methodsEnabled.email || methodsEnabled.webhook || methodsEnabled.slack || methodsEnabled.discord;
return json({
enabled: anyEventEnabled && anyMethodEnabled,
events_enabled: config.events_enabled,
methods_enabled: methodsEnabled,
});
}
/**
* Handle login - for email: send verification code, for webhook: validate URL and create subscriber
*/
async function handleLogin(method?: SubscriptionMethodType, email?: string, webhookUrl?: string): Promise<Response> {
if (!method) {
return error(400, { message: "Method is required" });
}
// Check if method is enabled
const config = await GetSubscriptionConfig();
if (!config || !config.methods_enabled[method]) {
return error(400, { message: "This subscription method is not enabled" });
}
if (method === "email") {
if (!email || !ValidateEmail(email)) {
return error(400, { message: "Invalid email address" });
}
const subscriberMeta = {
email_code: GenerateRandomNumber(6),
};
const existingUser = await GetSubscriberByEmailAndType(email, "email");
if (!existingUser) {
await CreateNewSubscriber({
subscriber_send: email,
subscriber_type: "email",
subscriber_status: "PENDING",
subscriber_meta: JSON.stringify(subscriberMeta),
});
} else {
await UpdateSubscriberMeta(existingUser.id, JSON.stringify(subscriberMeta));
}
// Send email with code
const siteData = await GetAllSiteData();
const emailData = {
brand_name: siteData.siteName || "Kener",
logo_url: await GetSiteLogoURL(siteData.siteURL || "", siteData.logo || "", "/"),
email_code: String(subscriberMeta.email_code),
action: "login",
};
try {
await SendEmailWithTemplate(
emailCodeTemplate,
emailData,
email,
`[Important] Verify code to manage subscriptions on ${emailData.brand_name}`,
`Your verification code is: ${emailData.email_code}`,
);
} catch (e) {
console.error("Error sending email:", e);
return error(500, { message: "Error sending email. Please try again later." });
}
const token = await GenerateTokenWithExpiry({ email, method: "email" }, "10m");
return json({ newUser: !existingUser, token, requiresVerification: true });
} else if (method === "webhook") {
if (!webhookUrl || !ValidateURL(webhookUrl)) {
return error(400, { message: "Invalid webhook URL" });
}
// For webhook, we don't need email verification - just create/get subscriber and return token
let existingUser = await GetSubscriberByEmailAndType(webhookUrl, "webhook");
if (!existingUser) {
await CreateNewSubscriber({
subscriber_send: webhookUrl,
subscriber_type: "webhook",
subscriber_status: "ACTIVE",
subscriber_meta: JSON.stringify({}),
});
existingUser = await GetSubscriberByEmailAndType(webhookUrl, "webhook");
}
if (!existingUser) {
return error(500, { message: "Failed to create subscriber" });
}
// Generate long-lived token
const authToken = await GenerateTokenWithExpiry(
{
webhook_url: webhookUrl,
subscriber_id: existingUser.id,
method: "webhook",
},
"1y",
);
return json({ token: authToken, requiresVerification: false });
} else {
// Slack and Discord would follow similar patterns with OAuth
return error(400, { message: "This method is not yet supported for self-service subscription" });
}
}
/**
* Verify email login with code
*/
async function handleVerify(token?: string, code?: string): Promise<Response> {
if (!token || !code) {
return error(400, { message: "Token and code are required" });
}
const decoded = await VerifyToken(token);
if (!decoded || !decoded.email || decoded.method !== "email") {
return error(400, { message: "Invalid or expired token" });
}
const email = decoded.email as string;
const existingSubscriber = await GetSubscriberByEmailAndType(email, "email");
if (!existingSubscriber) {
return error(400, { message: "Invalid user" });
}
const subscriberMeta = JSON.parse(existingSubscriber.subscriber_meta || "{}");
const storedCode = subscriberMeta.email_code;
if (!storedCode || String(storedCode) !== String(code)) {
return error(400, { message: "Invalid verification code" });
}
// Update subscriber status to active and clear the code
await UpdateSubscriberStatus(existingSubscriber.id, "ACTIVE");
await UpdateSubscriberMeta(existingSubscriber.id, JSON.stringify({}));
// Generate long-lived token
const authToken = await GenerateTokenWithExpiry(
{
email: email,
subscriber_id: existingSubscriber.id,
method: "email",
},
"1y",
);
return json({ token: authToken });
}
/**
* Fetch current subscriptions for a subscriber
*/
async function handleFetch(token?: string, method?: SubscriptionMethodType): Promise<Response> {
if (!token) {
return error(400, { message: "Token is required" });
}
const decoded = await VerifyToken(token);
if (!decoded || !decoded.subscriber_id) {
return error(400, { message: "Invalid token" });
}
const subscriberId = decoded.subscriber_id as number;
const tokenMethod = decoded.method as SubscriptionMethodType;
const queryMethod = method || tokenMethod;
const existingSubscriber = await GetSubscriberByID(subscriberId);
if (!existingSubscriber) {
return error(400, { message: "No subscription found" });
}
// Get subscriptions from new user_subscriptions table
const subscriptions = await db.getUserSubscriptions({
subscriber_id: subscriberId,
subscription_method: queryMethod,
status: "ACTIVE",
});
return json({
subscriber_send: existingSubscriber.subscriber_send,
method: queryMethod,
subscriptions: subscriptions.map((sub) => ({
id: sub.id,
event_type: sub.event_type,
entity_type: sub.entity_type,
entity_id: sub.entity_id,
status: sub.status,
created_at: sub.created_at,
})),
});
}
/**
* Create new subscriptions
*/
async function handleSubscribe(
token?: string,
method?: SubscriptionMethodType,
subscriptions?: Array<{
event_type: SubscriptionEventType;
entity_type?: SubscriptionEntityType;
entity_id?: string;
}>,
): Promise<Response> {
if (!token) {
return error(400, { message: "Token is required" });
}
if (!subscriptions || subscriptions.length === 0) {
return error(400, { message: "At least one subscription is required" });
}
const decoded = await VerifyToken(token);
if (!decoded || !decoded.subscriber_id) {
return error(400, { message: "Invalid token" });
}
const subscriberId = decoded.subscriber_id as number;
const tokenMethod = decoded.method as SubscriptionMethodType;
const subscriptionMethod = method || tokenMethod;
// Validate against config
const config = await GetSubscriptionConfig();
if (!config || !config.methods_enabled[subscriptionMethod]) {
return error(400, { message: "This subscription method is not enabled" });
}
const existingSubscriber = await GetSubscriberByID(subscriberId);
if (!existingSubscriber || existingSubscriber.subscriber_status !== "ACTIVE") {
return error(400, { message: "Invalid or inactive subscriber" });
}
// Validate each subscription against enabled events
for (const sub of subscriptions) {
const eventKey = sub.event_type as keyof typeof config.events_enabled;
if (!config.events_enabled[eventKey]) {
return error(400, { message: `Event type "${sub.event_type}" is not enabled for subscriptions` });
}
}
// Create subscriptions
const created: number[] = [];
const skipped: number[] = [];
for (const sub of subscriptions) {
const exists = await db.subscriptionExists(
subscriberId,
subscriptionMethod,
sub.event_type,
sub.entity_type ?? null,
sub.entity_id ?? null,
);
if (!exists) {
await db.insertUserSubscription({
subscriber_id: subscriberId,
subscription_method: subscriptionMethod,
event_type: sub.event_type,
entity_type: sub.entity_type,
entity_id: sub.entity_id,
status: "ACTIVE",
});
created.push(1);
} else {
skipped.push(1);
}
}
return json({
message: "Subscriptions updated successfully",
created: created.length,
skipped: skipped.length,
});
}
/**
* Unsubscribe from all subscriptions for a method
*/
async function handleUnsubscribe(token?: string, method?: SubscriptionMethodType): Promise<Response> {
if (!token) {
return error(400, { message: "Token is required" });
}
const decoded = await VerifyToken(token);
if (!decoded || !decoded.subscriber_id) {
return error(400, { message: "Invalid token" });
}
const subscriberId = decoded.subscriber_id as number;
const tokenMethod = decoded.method as SubscriptionMethodType;
const subscriptionMethod = method || tokenMethod;
// Get and delete all subscriptions for this method
const subscriptions = await db.getUserSubscriptions({
subscriber_id: subscriberId,
subscription_method: subscriptionMethod,
});
for (const sub of subscriptions) {
await db.deleteUserSubscription(sub.id);
}
return json({
message: "Unsubscribed successfully",
deleted: subscriptions.length,
});
}
/**
* Delete a single subscription
*/
async function handleDeleteSubscription(token?: string, subscriptionId?: number): Promise<Response> {
if (!token || !subscriptionId) {
return error(400, { message: "Token and subscriptionId are required" });
}
const decoded = await VerifyToken(token);
if (!decoded || !decoded.subscriber_id) {
return error(400, { message: "Invalid token" });
}
const subscriberId = decoded.subscriber_id as number;
// Verify the subscription belongs to this subscriber
const subscription = await db.getUserSubscriptionById(subscriptionId);
if (!subscription || subscription.subscriber_id !== subscriberId) {
return error(400, { message: "Subscription not found" });
}
await db.deleteUserSubscription(subscriptionId);
return json({ message: "Subscription deleted successfully" });
}
@@ -39,6 +39,7 @@ export const VerifyPassword = async (plainTextPassword: string, hashedPassword:
}
};
import type { TokenPayload } from "$lib/server/types/auth.js";
import type { SMTPConfiguration } from "../notification/types";
export const VerifyToken = async (token: string): Promise<TokenPayload | undefined> => {
try {
@@ -52,7 +53,7 @@ export const VerifyToken = async (token: string): Promise<TokenPayload | undefin
}
};
export const GetSMTPFromENV = () => {
export const GetSMTPFromENV = (): SMTPConfiguration | null => {
//if variables are not return null
if (
!!!process.env.SMTP_HOST ||
@@ -68,7 +69,7 @@ export const GetSMTPFromENV = () => {
smtp_host: process.env.SMTP_HOST,
smtp_port: process.env.SMTP_PORT,
smtp_user: process.env.SMTP_USER,
smtp_from_email: process.env.SMTP_FROM_EMAIL,
smtp_sender: process.env.SMTP_FROM_EMAIL,
smtp_pass: process.env.SMTP_PASS,
smtp_secure: !!Number(process.env.SMTP_SECURE),
};
-1
View File
@@ -7,7 +7,6 @@ export * from "./monitorsController.js";
export * from "./queueController.js";
export * from "./siteDataController.js";
export * from "./siteDataKeys.js";
export * from "./subscriberController.js";
export * from "./triggerController.js";
export * from "./userController.js";
export * from "./pagesController.js";
@@ -0,0 +1,33 @@
import db from "$lib/server/db/db";
import type { GeneralEmailTemplateRecord, GeneralEmailTemplateRecordInsert } from "$lib/server/types/db";
/**
* Get all general email templates
*/
export async function GetAllGeneralEmailTemplates(): Promise<GeneralEmailTemplateRecord[]> {
return await db.getAllEmailTemplates();
}
/**
* Get a general email template by ID
*/
export async function GetGeneralEmailTemplateById(templateId: string): Promise<GeneralEmailTemplateRecord | undefined> {
return await db.getEmailTemplateById(templateId);
}
/**
* Update a general email template
*/
export async function UpdateGeneralEmailTemplate(
templateId: string,
data: Partial<Omit<GeneralEmailTemplateRecordInsert, "template_id">>,
): Promise<{ success: boolean; error?: string }> {
// Check if template exists
const existingTemplate = await db.getEmailTemplateById(templateId);
if (!existingTemplate) {
return { success: false, error: `Template with ID ${templateId} not found` };
}
await db.updateEmailTemplate(templateId, data);
return { success: true };
}
@@ -16,7 +16,6 @@ import type {
MonitorAlertStatusType,
MonitorAlertV2WithConfig,
TriggerRecord,
TriggerRecordParsed,
} from "../types/db.js";
// Re-export types for convenience
@@ -377,41 +376,12 @@ export async function GetMonitorAlertConfigsCount(filter: MonitorAlertConfigFilt
}
/**
* Get all triggers for a monitor alert config
* Get all triggers for a monitor alert config with parsed trigger_meta
*/
export async function GetTriggersByMonitorAlertConfigId(monitorAlertsId: number): Promise<TriggerRecord[]> {
const triggerIds = await db.getMonitorAlertConfigTriggerIds(monitorAlertsId);
const triggers: TriggerRecord[] = [];
for (const triggerId of triggerIds) {
const trigger = await db.getTriggerByID(triggerId);
if (trigger) {
triggers.push(trigger);
}
}
return triggers;
}
/**
* Get all triggers for a monitor alert config with parsed trigger_meta
*/
export async function GetTriggersParsedByMonitorAlertConfigId(monitorAlertsId: number): Promise<TriggerRecordParsed[]> {
const triggerIds = await db.getMonitorAlertConfigTriggerIds(monitorAlertsId);
const triggers: TriggerRecordParsed[] = [];
for (const triggerId of triggerIds) {
const trigger = await db.getTriggerByID(triggerId);
if (trigger) {
const triggerParsed: TriggerRecordParsed = {
...trigger,
trigger_meta: JSON.parse(trigger.trigger_meta),
};
triggers.push(triggerParsed);
}
}
return triggers;
return await db.getTriggersByIDs(triggerIds);
}
// ============ Monitor Alerts V2 Operations ============
@@ -451,9 +421,7 @@ export async function CreateMonitorAlertV2(
alert_status: "TRIGGERED",
};
const [id] = await db.insertMonitorAlertV2(insertData);
const result = await db.getMonitorAlertV2ById(id);
const result = await db.insertMonitorAlertV2(insertData);
if (!result) {
throw new Error("Failed to retrieve created monitor alert");
}
@@ -59,6 +59,15 @@ export interface SiteDataTransformed {
};
};
kenerTheme?: string;
subscriptionsSettings?: {
enable: boolean;
methods: {
emails: {
incidents: boolean;
maintenance: boolean;
};
};
};
showSiteStatus?: string;
monitorSort?: number[];
[key: string]: unknown;
@@ -226,4 +226,9 @@ export const siteDataKeys: SiteDataKey[] = [
isValid: IsValidJSONString,
data_type: "object",
},
{
key: "subscriptionsSettings",
isValid: IsValidJSONString,
data_type: "object",
},
];
@@ -1,190 +0,0 @@
import type {
MonitorRecordInsert,
TriggerRecordInsert,
MonitoringDataInsert,
MonitorAlertInsert,
IncidentFilter,
MonitorFilter,
TriggerFilter,
SubscriberRecordInsert,
UserRecordInsert,
UserRecord,
MonitorRecordTyped,
MonitorRecord,
SubscriberRecord,
SubscriptionRecord,
SubscriptionTriggerRecord,
} from "../types/db.js";
import db from "../db/db.js";
import type { PaginationInput } from "../../types/common.js";
interface SubscriptionTriggerInput {
subscription_trigger_type: string;
subscription_trigger_status?: string;
config: string;
}
export const CreateNewSubscription = async (
subscriber_id: number,
monitors: string[],
): Promise<{ message: string; count: number }> => {
if (!monitors || monitors.length === 0) {
throw new Error("No monitors found");
}
await db.removeAllDataFromSubscriptions(subscriber_id);
for (let i = 0; i < monitors.length; i++) {
let tag = monitors[i];
let subscription = {
subscriber_id: subscriber_id,
subscriptions_status: "ACTIVE",
subscriptions_monitors: tag,
subscriptions_meta: "",
};
await db.insertSubscription(subscription);
}
return {
message: "Subscriptions created successfully",
count: monitors.length,
};
};
// fetch the single subscription_trigger (only type=email supported)
export const GetSubscriptionTriggerByEmail = async (): Promise<SubscriptionTriggerRecord | null> => {
return await db.getSubscriptionTriggerByType("email");
};
// create a subscription_trigger record for email type
export const CreateSubscriptionTrigger = async (data: SubscriptionTriggerInput): Promise<SubscriptionTriggerInput> => {
// only email supported
if (data.subscription_trigger_type !== "email") {
throw new Error("Only email trigger type is supported");
}
//update subscription_trigger_status and subscription_trigger_id given subscription_trigger_type, if not present insert otherwise update
let subscriptionTrigger = await db.getSubscriptionTriggerByType(data.subscription_trigger_type);
if (!subscriptionTrigger) {
await db.insertSubscriptionTrigger({
subscription_trigger_type: data.subscription_trigger_type,
subscription_trigger_status: "ACTIVE",
config: data.config,
});
} else {
await db.updateSubscriptionTrigger({
id: subscriptionTrigger.id,
subscription_trigger_status: data.subscription_trigger_status || "ACTIVE",
subscription_trigger_type: subscriptionTrigger.subscription_trigger_type,
config: data.config,
} as SubscriptionTriggerRecord);
}
return {
subscription_trigger_type: data.subscription_trigger_type,
subscription_trigger_status: data.subscription_trigger_status,
config: data.config,
};
};
//updateSubscriptionTriggerStatus
export const UpdateSubscriptionTriggerStatus = async (id: number, status: string): Promise<number> => {
return await db.updateSubscriptionTriggerStatus(id, status);
};
// Get subscribers paginated
export const GetSubscribersPaginated = async (
data: PaginationInput,
): Promise<{ subscriptions: unknown[]; total: number }> => {
const page = parseInt(String(data.page)) || 1;
const limit = parseInt(String(data.limit)) || 10;
const subscriptions = (await db.getSubscriptionsPaginated(page, limit)) as (SubscriptionRecord & {
subscriber?: unknown;
monitor?: unknown;
})[];
const total = await db.getTotalSubscriptionCount();
//all monitor tags
let allTags = subscriptions.map((subscription) => subscription.subscriptions_monitors);
//get all monitors by tags
let monitors = await db.getMonitorsByTags(allTags);
let tagMonitor: Record<string, { name: string; tag: string; image: string | null }> = {};
//convert monitors to map in tagMonitor
for (let i = 0; i < monitors.length; i++) {
let m = monitors[i];
tagMonitor[monitors[i].tag] = {
name: m.name,
tag: m.tag,
image: m.image,
};
}
let subscriberIDObj: Record<number, { id: number; email: string; status: string }> = {};
//for each subscription get subscriber details
for (let i = 0; i < subscriptions.length; i++) {
let subsID = subscriptions[i].subscriber_id;
if (!subscriberIDObj[subsID]) {
const subscriber = await db.getSubscriberById(subscriptions[i].subscriber_id);
if (subscriber) {
subscriberIDObj[subsID] = {
id: subscriber.id,
email: subscriber.subscriber_send,
status: subscriber.subscriber_status,
};
}
}
subscriptions[i].subscriber = subscriberIDObj[subsID];
subscriptions[i].monitor = tagMonitor[subscriptions[i].subscriptions_monitors];
}
return {
subscriptions: subscriptions,
total: Number(total),
};
};
//updateSubscriptionStatus
export const UpdateSubscriptionStatus = async (subscription_id: number, status: string): Promise<number> => {
return await db.updateSubscriptionStatus(subscription_id, status);
};
export const CreateNewSubscriber = async (data: SubscriberRecordInsert): Promise<SubscriberRecord | undefined> => {
await db.insertSubscriber(data);
return await GetSubscriberByEmailAndType(data.subscriber_send, data.subscriber_type);
};
export const GetSubscriberByEmailAndType = async (
email: string,
type: string,
): Promise<SubscriberRecord | undefined> => {
return await db.getSubscriberByDetails(email, type);
};
//get subscriber by id
export const GetSubscriberByID = async (id: number): Promise<Omit<SubscriberRecord, "updated_at"> | undefined> => {
return await db.getSubscriberById(id);
};
//remove all subscriptions for a subscriber
export const RemoveAllSubscriptions = async (subscriber_id: number): Promise<number> => {
return await db.removeAllDataFromSubscriptions(subscriber_id);
};
//updateSubscriberMeta given id
export const UpdateSubscriberMeta = async (id: number, meta: string): Promise<number> => {
return await db.updateSubscriberMeta(id, meta);
};
//updateSubscriberStatus
export const UpdateSubscriberStatus = async (id: number, status: string): Promise<number> => {
return await db.updateSubscriberStatus(id, status);
};
//delete subscriber by id
export const DeleteSubscriberByID = async (id: number): Promise<number> => {
return await db.deleteSubscriberById(id);
};
//get subscriptions by subscriber id
export const GetSubscriptionsBySubscriberID = async (subscriber_id: number): Promise<SubscriptionRecord[]> => {
return await db.getSubscriptionsBySubscriberId(subscriber_id);
};
@@ -1,170 +0,0 @@
import db from "../db/db.js";
import type {
SubscriptionConfigParsed,
SubscriptionEventsEnabled,
SubscriptionMethodsEnabled,
SubscriptionMethodTriggers,
TriggerRecord,
} from "../types/db.js";
// Re-export types for convenience
export type {
SubscriptionConfigParsed,
SubscriptionEventsEnabled,
SubscriptionMethodsEnabled,
SubscriptionMethodTriggers,
};
/**
* Get the subscription configuration with parsed JSON fields
*/
export async function GetSubscriptionConfig(): Promise<SubscriptionConfigParsed | undefined> {
return await db.getSubscriptionConfigParsed();
}
/**
* Update the events that users can subscribe to
*/
export async function UpdateSubscriptionEventsEnabled(
events: SubscriptionEventsEnabled,
): Promise<SubscriptionConfigParsed> {
const config = await db.ensureSubscriptionConfig();
await db.updateSubscriptionConfig(config.id, {
events_enabled: JSON.stringify(events),
});
const updated = await db.getSubscriptionConfigParsed();
if (!updated) {
throw new Error("Failed to update subscription config");
}
return updated;
}
/**
* Update the methods that users can use to subscribe
*/
export async function UpdateSubscriptionMethodsEnabled(
methods: SubscriptionMethodsEnabled,
): Promise<SubscriptionConfigParsed> {
const config = await db.ensureSubscriptionConfig();
await db.updateSubscriptionConfig(config.id, {
methods_enabled: JSON.stringify(methods),
});
const updated = await db.getSubscriptionConfigParsed();
if (!updated) {
throw new Error("Failed to update subscription config");
}
return updated;
}
/**
* Update the trigger mappings for each method
*/
export async function UpdateSubscriptionMethodTriggers(
triggers: SubscriptionMethodTriggers,
): Promise<SubscriptionConfigParsed> {
const config = await db.ensureSubscriptionConfig();
await db.updateSubscriptionConfig(config.id, {
method_triggers: JSON.stringify(triggers),
});
const updated = await db.getSubscriptionConfigParsed();
if (!updated) {
throw new Error("Failed to update subscription config");
}
return updated;
}
/**
* Update the entire subscription configuration at once
*/
export async function UpdateSubscriptionConfigFull(data: {
events_enabled: SubscriptionEventsEnabled;
methods_enabled: SubscriptionMethodsEnabled;
method_triggers: SubscriptionMethodTriggers;
}): Promise<SubscriptionConfigParsed> {
const config = await db.ensureSubscriptionConfig();
await db.updateSubscriptionConfig(config.id, {
events_enabled: JSON.stringify(data.events_enabled),
methods_enabled: JSON.stringify(data.methods_enabled),
method_triggers: JSON.stringify(data.method_triggers),
});
const updated = await db.getSubscriptionConfigParsed();
if (!updated) {
throw new Error("Failed to update subscription config");
}
return updated;
}
/**
* Get triggers for enabled methods with their full records
*/
export async function GetEnabledMethodTriggersWithDetails(): Promise<{
email: TriggerRecord | null;
webhook: TriggerRecord | null;
slack: TriggerRecord | null;
discord: TriggerRecord | null;
}> {
const config = await db.getSubscriptionConfigParsed();
if (!config) {
return { email: null, webhook: null, slack: null, discord: null };
}
const result: {
email: TriggerRecord | null;
webhook: TriggerRecord | null;
slack: TriggerRecord | null;
discord: TriggerRecord | null;
} = {
email: null,
webhook: null,
slack: null,
discord: null,
};
const methods = ["email", "webhook", "slack", "discord"] as const;
for (const method of methods) {
const triggerId = config.method_triggers[method];
if (triggerId && config.methods_enabled[method]) {
const trigger = await db.getTriggerByID(triggerId);
if (trigger) {
result[method] = trigger;
}
}
}
return result;
}
/**
* Validate that method triggers exist and are of the correct type
*/
export async function ValidateMethodTriggers(
triggers: SubscriptionMethodTriggers,
): Promise<{ valid: boolean; errors: string[] }> {
const errors: string[] = [];
const methodTypeMap: Record<string, string> = {
email: "email",
webhook: "webhook",
slack: "slack",
discord: "discord",
};
for (const [method, triggerId] of Object.entries(triggers)) {
if (triggerId !== null) {
const trigger = await db.getTriggerByID(triggerId);
if (!trigger) {
errors.push(`Trigger with ID ${triggerId} not found for ${method}`);
} else if (trigger.trigger_type !== methodTypeMap[method]) {
errors.push(
`Trigger ${triggerId} is of type ${trigger.trigger_type}, but ${method} requires type ${methodTypeMap[method]}`,
);
}
}
}
return {
valid: errors.length === 0,
errors,
};
}
@@ -1,346 +0,0 @@
import db from "../db/db.js";
import type {
TemplateRecord,
TemplateInsert,
TemplateUpdate,
TemplateFilter,
TemplateType,
TemplateUsageType,
TemplateJsonType,
EmailTemplateJson,
WebhookTemplateJson,
SlackTemplateJson,
DiscordTemplateJson,
TemplateParsed,
} from "../types/db.js";
// Re-export types for convenience
export type {
TemplateRecord,
TemplateType,
TemplateUsageType,
TemplateJsonType,
EmailTemplateJson,
WebhookTemplateJson,
SlackTemplateJson,
DiscordTemplateJson,
TemplateParsed,
};
// ============ Validation ============
const VALID_TEMPLATE_TYPES: TemplateType[] = ["EMAIL", "WEBHOOK", "SLACK", "DISCORD"];
const VALID_TEMPLATE_USAGES: TemplateUsageType[] = ["ALERT", "SUBSCRIPTION", "GENERAL"];
function validateTemplateType(value: string): asserts value is TemplateType {
if (!VALID_TEMPLATE_TYPES.includes(value as TemplateType)) {
throw new Error(`Invalid template_type value: ${value}. Must be one of: ${VALID_TEMPLATE_TYPES.join(", ")}`);
}
}
function validateTemplateUsage(value: string): asserts value is TemplateUsageType {
if (!VALID_TEMPLATE_USAGES.includes(value as TemplateUsageType)) {
throw new Error(`Invalid template_usage value: ${value}. Must be one of: ${VALID_TEMPLATE_USAGES.join(", ")}`);
}
}
function validateTemplateName(value: string): void {
if (!value || value.trim().length === 0) {
throw new Error("Template name is required");
}
if (value.length > 255) {
throw new Error("Template name must be 255 characters or less");
}
}
function validateEmailTemplateJson(json: unknown): asserts json is EmailTemplateJson {
if (typeof json !== "object" || json === null) {
throw new Error("Email template JSON must be an object");
}
const obj = json as Record<string, unknown>;
if (typeof obj.email_subject !== "string") {
throw new Error("Email template must have email_subject as string");
}
if (typeof obj.email_body !== "string") {
throw new Error("Email template must have email_body as string");
}
}
function validateWebhookTemplateJson(json: unknown): asserts json is WebhookTemplateJson {
if (typeof json !== "object" || json === null) {
throw new Error("Webhook template JSON must be an object");
}
const obj = json as Record<string, unknown>;
if (typeof obj.webhook_body !== "string") {
throw new Error("Webhook template must have webhook_body as string");
}
}
function validateSlackTemplateJson(json: unknown): asserts json is SlackTemplateJson {
if (typeof json !== "object" || json === null) {
throw new Error("Slack template JSON must be an object");
}
const obj = json as Record<string, unknown>;
if (typeof obj.slack_body !== "string") {
throw new Error("Slack template must have slack_body as string");
}
}
function validateDiscordTemplateJson(json: unknown): asserts json is DiscordTemplateJson {
if (typeof json !== "object" || json === null) {
throw new Error("Discord template JSON must be an object");
}
const obj = json as Record<string, unknown>;
if (typeof obj.discord_body !== "string") {
throw new Error("Discord template must have discord_body as string");
}
}
function validateTemplateJson(templateType: TemplateType, jsonString: string): TemplateJsonType {
let parsed: unknown;
try {
parsed = JSON.parse(jsonString);
} catch {
throw new Error("Invalid JSON format for template_json");
}
switch (templateType) {
case "EMAIL":
validateEmailTemplateJson(parsed);
return parsed;
case "WEBHOOK":
validateWebhookTemplateJson(parsed);
return parsed;
case "SLACK":
validateSlackTemplateJson(parsed);
return parsed;
case "DISCORD":
validateDiscordTemplateJson(parsed);
return parsed;
default:
throw new Error(`Unknown template type: ${templateType}`);
}
}
// ============ CRUD Operations ============
export interface CreateTemplateInput {
template_name: string;
template_type: TemplateType;
template_usage: TemplateUsageType;
template_json: string;
}
export interface UpdateTemplateInput {
id: number;
template_name?: string;
template_type?: TemplateType;
template_usage?: TemplateUsageType;
template_json?: string;
}
/**
* Create a new template
*/
export async function CreateTemplate(input: CreateTemplateInput): Promise<TemplateRecord> {
// Validate inputs
validateTemplateName(input.template_name);
validateTemplateType(input.template_type);
validateTemplateUsage(input.template_usage);
validateTemplateJson(input.template_type, input.template_json);
// Check for duplicate name
const exists = await db.templateNameExists(input.template_name, input.template_type, input.template_usage);
if (exists) {
throw new Error(
`Template with name "${input.template_name}" already exists for type ${input.template_type} and usage ${input.template_usage}`,
);
}
const insertData: TemplateInsert = {
template_name: input.template_name.trim(),
template_type: input.template_type,
template_usage: input.template_usage,
template_json: input.template_json,
};
const [id] = await db.insertTemplate(insertData);
const result = await db.getTemplateById(id);
if (!result) {
throw new Error("Failed to create template");
}
return result;
}
/**
* Update an existing template
*/
export async function UpdateTemplate(input: UpdateTemplateInput): Promise<TemplateRecord> {
// Verify template exists
const existing = await db.getTemplateById(input.id);
if (!existing) {
throw new Error(`Template with id ${input.id} not found`);
}
const updateData: TemplateUpdate = {};
if (input.template_name !== undefined) {
validateTemplateName(input.template_name);
updateData.template_name = input.template_name.trim();
}
if (input.template_type !== undefined) {
validateTemplateType(input.template_type);
updateData.template_type = input.template_type;
}
if (input.template_usage !== undefined) {
validateTemplateUsage(input.template_usage);
updateData.template_usage = input.template_usage;
}
// Determine template type for JSON validation
const effectiveType = input.template_type || existing.template_type;
if (input.template_json !== undefined) {
validateTemplateJson(effectiveType, input.template_json);
updateData.template_json = input.template_json;
}
// Check for duplicate name if name is being changed
if (updateData.template_name) {
const effectiveUsage = input.template_usage || existing.template_usage;
const exists = await db.templateNameExists(updateData.template_name, effectiveType, effectiveUsage, input.id);
if (exists) {
throw new Error(
`Template with name "${updateData.template_name}" already exists for type ${effectiveType} and usage ${effectiveUsage}`,
);
}
}
await db.updateTemplate(input.id, updateData);
const result = await db.getTemplateById(input.id);
if (!result) {
throw new Error("Failed to update template");
}
return result;
}
/**
* Get template by ID
*/
export async function GetTemplateById(id: number): Promise<TemplateRecord | undefined> {
return await db.getTemplateById(id);
}
/**
* Get templates with optional filtering
*/
export async function GetTemplates(filter: TemplateFilter = {}): Promise<TemplateRecord[]> {
return await db.getTemplates(filter);
}
/**
* Get all templates
*/
export async function GetAllTemplates(): Promise<TemplateRecord[]> {
return await db.getAllTemplates();
}
/**
* Get templates by type
*/
export async function GetTemplatesByType(templateType: TemplateType): Promise<TemplateRecord[]> {
validateTemplateType(templateType);
return await db.getTemplatesByType(templateType);
}
/**
* Get templates by usage
*/
export async function GetTemplatesByUsage(templateUsage: TemplateUsageType): Promise<TemplateRecord[]> {
validateTemplateUsage(templateUsage);
return await db.getTemplatesByUsage(templateUsage);
}
/**
* Get templates by type and usage
*/
export async function GetTemplatesByTypeAndUsage(
templateType: TemplateType,
templateUsages: TemplateUsageType[],
): Promise<TemplateRecord[]> {
validateTemplateType(templateType);
templateUsages.forEach((usage) => validateTemplateUsage(usage));
return await db.getTemplatesByTypeAndUsage(templateType, templateUsages);
}
/**
* Delete a template
*/
export async function DeleteTemplate(id: number): Promise<void> {
const existing = await db.getTemplateById(id);
if (!existing) {
throw new Error(`Template with id ${id} not found`);
}
await db.deleteTemplate(id);
}
/**
* Get templates count with optional filtering
*/
export async function GetTemplatesCount(filter: TemplateFilter = {}): Promise<number> {
const result = await db.getTemplatesCount(filter);
return Number(result?.count) || 0;
}
// ============ Utility Functions ============
/**
* Parse template JSON string to typed object
*/
export function parseTemplateJson<T extends TemplateJsonType>(template: TemplateRecord): TemplateParsed<T> {
return {
...template,
template_json: JSON.parse(template.template_json) as T,
};
}
/**
* Get default template JSON for a given template type
*/
export function getDefaultTemplateJson(templateType: TemplateType): TemplateJsonType {
switch (templateType) {
case "EMAIL":
return {
email_subject: "",
email_body: "",
};
case "WEBHOOK":
return {
webhook_body: "{}",
};
case "SLACK":
return {
slack_body: "{}",
};
case "DISCORD":
return {
discord_body: "{}",
};
default:
throw new Error(`Unknown template type: ${templateType}`);
}
}
/**
* Stringify template JSON object to string
*/
export function stringifyTemplateJson(json: TemplateJsonType): string {
return JSON.stringify(json);
}
@@ -6,11 +6,9 @@ import type {
SubscriptionMethodType,
SubscriptionEventType,
SubscriberSummary,
SubscriberRecord,
UserSubscriptionV2Record,
SubscriberUserRecord,
SubscriberMethodRecord,
SubscriptionEntityType,
} from "$lib/server/types/db.js";
// ============ V2 Admin Functions ============
@@ -67,18 +65,10 @@ export async function GetSubscribersByMethod(
*/
export async function GetSubscriberCountsByMethod(): Promise<{
email: number;
webhook: number;
slack: number;
discord: number;
}> {
const [email, webhook, slack, discord] = await Promise.all([
db.getMethodsCountByType("email"),
db.getMethodsCountByType("webhook"),
db.getMethodsCountByType("slack"),
db.getMethodsCountByType("discord"),
]);
const [email] = await Promise.all([db.getMethodsCountByType("email")]);
return { email, webhook, slack, discord };
return { email };
}
/**
@@ -94,68 +84,11 @@ export async function GetSubscriberWithSubscriptionsV2(methodId: number): Promis
// ============ Legacy Functions (still using old tables for backward compatibility) ============
/**
* Get subscriber with all their subscriptions for a specific method
* @deprecated Use GetSubscriberWithSubscriptionsV2 instead
*/
export async function GetSubscriberWithSubscriptions(
subscriberId: number,
method: SubscriptionMethodType,
): Promise<{
subscriber: SubscriberRecord | undefined;
subscriptions: UserSubscriptionRecord[];
}> {
return await db.getSubscriberWithSubscriptions(subscriberId, method);
}
/**
* Get all subscriptions for a subscriber (all methods)
*/
export async function GetSubscriberAllSubscriptions(subscriberId: number): Promise<UserSubscriptionRecord[]> {
return await db.getSubscriptionsBySubscriberIdNew(subscriberId);
}
/**
* Create a new subscription for a subscriber
*/
export async function CreateUserSubscription(
data: UserSubscriptionRecordInsert,
): Promise<{ success: boolean; error?: string }> {
// Check if subscription already exists
const exists = await db.subscriptionExists(
data.subscriber_id,
data.subscription_method,
data.event_type,
data.entity_type ?? null,
data.entity_id ?? null,
);
if (exists) {
return { success: false, error: "Subscription already exists" };
}
await db.insertUserSubscription(data);
return { success: true };
}
/**
* Delete a subscription - V2 version
*/
export async function DeleteUserSubscription(id: number): Promise<{ success: boolean; error?: string }> {
// Try V2 first
const subscriptionV2 = await db.getUserSubscriptionV2ById(id);
if (subscriptionV2) {
await db.deleteUserSubscriptionV2(id);
return { success: true };
}
// Fall back to V1 for backward compatibility
const subscription = await db.getUserSubscriptionById(id);
if (!subscription) {
return { success: false, error: "Subscription not found" };
}
await db.deleteUserSubscription(id);
await db.deleteUserSubscriptionV2(id);
return { success: true };
}
@@ -166,94 +99,504 @@ export async function UpdateUserSubscriptionStatus(
id: number,
status: "ACTIVE" | "INACTIVE",
): Promise<{ success: boolean; error?: string }> {
// Try V2 first
const subscriptionV2 = await db.getUserSubscriptionV2ById(id);
if (subscriptionV2) {
await db.updateUserSubscriptionV2(id, { status });
return { success: true };
}
// Fall back to V1 for backward compatibility
const subscription = await db.getUserSubscriptionById(id);
if (!subscription) {
return { success: false, error: "Subscription not found" };
}
await db.updateUserSubscriptionStatus(id, status);
await db.updateUserSubscriptionV2(id, { status });
return { success: true };
}
/**
* Get subscriptions by filter
*/
export async function GetUserSubscriptions(filter: UserSubscriptionFilter): Promise<UserSubscriptionRecord[]> {
return await db.getUserSubscriptions(filter);
}
/**
* Get all active subscriptions for an event (for sending notifications)
*/
export async function GetActiveSubscriptionsForEvent(
eventType: SubscriptionEventType,
entityType: string | null = null,
entityId: string | null = null,
): Promise<Array<UserSubscriptionRecord & { subscriber: SubscriberRecord }>> {
return await db.getActiveSubscriptionsForEvent(eventType, entityType, entityId);
}
/**
* Delete all subscriptions for a subscriber
*/
export async function DeleteAllSubscriptionsForSubscriber(
subscriberId: number,
): Promise<{ success: boolean; deletedCount: number }> {
const deletedCount = await db.deleteAllSubscriptionsBySubscriberId(subscriberId);
return { success: true, deletedCount };
}
/**
* Format event type for display
*/
export function FormatEventType(eventType: SubscriptionEventType): string {
switch (eventType) {
case "incidentUpdatesAll":
case "incidents":
return "Incident Updates";
case "maintenanceUpdatesAll":
case "maintenances":
return "Maintenance Updates";
case "monitorUpdatesAll":
return "Monitor Updates";
default:
return eventType;
}
}
// ============ Admin Subscriber Management Functions ============
export interface AdminSubscriberRecord {
user_id: number;
method_id: number;
email: string;
incidents_enabled: boolean;
maintenances_enabled: boolean;
incidents_subscription_id: number | null;
maintenances_subscription_id: number | null;
created_at: Date;
}
/**
* Format entity type for display
* Get paginated list of subscribers for admin view
*/
export function FormatEntityType(entityType: string | null): string {
if (!entityType) return "All";
switch (entityType) {
case "monitor":
return "Monitor";
case "incident":
return "Incident";
case "maintenance":
return "Maintenance";
default:
return entityType;
export async function GetAdminSubscribersPaginated(
page: number = 1,
limit: number = 10,
): Promise<{
subscribers: AdminSubscriberRecord[];
total: number;
totalPages: number;
page: number;
limit: number;
}> {
// Get all active subscriber users with email methods
const result = await db.getSubscribersByMethodTypeV2("email", page, limit);
const total = await db.getMethodsCountByType("email");
const totalPages = Math.ceil(total / limit);
// Transform to admin record format with subscription statuses
const subscribers: AdminSubscriberRecord[] = [];
for (const item of result) {
// Get subscriptions for this method
const subscriptions = await db.getUserSubscriptionsV2({
subscriber_method_id: item.method_id,
});
const incidentsSub = subscriptions.find((s) => s.event_type === "incidents");
const maintenancesSub = subscriptions.find((s) => s.event_type === "maintenances");
subscribers.push({
user_id: item.id,
method_id: item.method_id,
email: item.email,
incidents_enabled: incidentsSub?.status === "ACTIVE",
maintenances_enabled: maintenancesSub?.status === "ACTIVE",
incidents_subscription_id: incidentsSub?.id || null,
maintenances_subscription_id: maintenancesSub?.id || null,
created_at: item.created_at,
});
}
return {
subscribers,
total,
totalPages,
page,
limit,
};
}
/**
* Admin: Update a subscription's status
*/
export async function AdminUpdateSubscriptionStatus(
methodId: number,
eventType: SubscriptionEventType,
enabled: boolean,
): Promise<{ success: boolean; error?: string }> {
// Get method details to get user_id
const methodDetails = await db.getSubscriberMethodById(methodId);
if (!methodDetails) {
return { success: false, error: "Method not found" };
}
// Get existing subscription
const subs = await db.getUserSubscriptionsV2({
subscriber_method_id: methodId,
event_type: eventType,
});
if (enabled) {
// Enable subscription
if (subs.length === 0) {
// Create new subscription
await db.createUserSubscriptionV2({
subscriber_user_id: methodDetails.subscriber_user_id,
subscriber_method_id: methodId,
event_type: eventType,
status: "ACTIVE",
});
} else if (subs[0].status !== "ACTIVE") {
// Reactivate existing
await db.updateUserSubscriptionV2(subs[0].id, { status: "ACTIVE" });
}
} else {
// Disable subscription
if (subs.length > 0 && subs[0].status === "ACTIVE") {
await db.updateUserSubscriptionV2(subs[0].id, { status: "INACTIVE" });
}
}
return { success: true };
}
/**
* Admin: Delete a subscriber completely (user, method, and all subscriptions)
*/
export async function AdminDeleteSubscriber(methodId: number): Promise<{ success: boolean; error?: string }> {
// Get method to find user
const method = await db.getSubscriberMethodById(methodId);
if (!method) {
return { success: false, error: "Subscriber not found" };
}
// Delete all subscriptions for this method
const subscriptions = await db.getUserSubscriptionsV2({
subscriber_method_id: methodId,
});
for (const sub of subscriptions) {
await db.deleteUserSubscriptionV2(sub.id);
}
// Delete the method
await db.deleteSubscriberMethod(methodId);
// Check if user has any other methods
const otherMethods = await db.getSubscriberMethodsByUserId(method.subscriber_user_id);
if (otherMethods.length === 0) {
// Delete the user too
await db.deleteSubscriberUser(method.subscriber_user_id);
}
return { success: true };
}
/**
* Admin: Add a new subscriber with specified subscriptions
*/
export async function AdminAddSubscriber(
email: string,
incidents: boolean,
maintenances: boolean,
): Promise<{ success: boolean; error?: string; subscriber?: AdminSubscriberRecord }> {
if (!ValidateEmail(email)) {
return { success: false, error: "Invalid email address" };
}
const normalizedEmail = email.toLowerCase().trim();
// Check if user already exists
let user = await db.getSubscriberUserByEmail(normalizedEmail);
if (!user) {
// Create new user
user = await db.createSubscriberUser({
email: normalizedEmail,
status: "ACTIVE",
});
} else if (user.status !== "ACTIVE") {
// Activate existing user
await db.updateSubscriberUser(user.id, { status: "ACTIVE" });
}
// Check if email method exists
let method = await db.getSubscriberMethodByUserAndType(user.id, "email", normalizedEmail);
if (!method) {
method = await db.createSubscriberMethod({
subscriber_user_id: user.id,
method_type: "email",
method_value: normalizedEmail,
status: "ACTIVE",
});
} else if (method.status !== "ACTIVE") {
await db.updateSubscriberMethod(method.id, { status: "ACTIVE" });
}
// Create/update subscriptions
let incidentsSub = null;
let maintenancesSub = null;
if (incidents) {
const existing = await db.getUserSubscriptionsV2({
subscriber_method_id: method.id,
event_type: "incidents",
});
if (existing.length === 0) {
incidentsSub = await db.createUserSubscriptionV2({
subscriber_user_id: user.id,
subscriber_method_id: method.id,
event_type: "incidents",
status: "ACTIVE",
});
} else {
await db.updateUserSubscriptionV2(existing[0].id, { status: "ACTIVE" });
incidentsSub = existing[0];
}
}
if (maintenances) {
const existing = await db.getUserSubscriptionsV2({
subscriber_method_id: method.id,
event_type: "maintenances",
});
if (existing.length === 0) {
maintenancesSub = await db.createUserSubscriptionV2({
subscriber_user_id: user.id,
subscriber_method_id: method.id,
event_type: "maintenances",
status: "ACTIVE",
});
} else {
await db.updateUserSubscriptionV2(existing[0].id, { status: "ACTIVE" });
maintenancesSub = existing[0];
}
}
return {
success: true,
subscriber: {
user_id: user.id,
method_id: method.id,
email: normalizedEmail,
incidents_enabled: incidents,
maintenances_enabled: maintenances,
incidents_subscription_id: incidentsSub?.id || null,
maintenances_subscription_id: maintenancesSub?.id || null,
created_at: method.created_at,
},
};
}
// ============ Public Subscription Functions ============
import { GenerateTokenWithExpiry, VerifyToken } from "./commonController.js";
import { GenerateRandomNumber, ValidateEmail, GetNowTimestampUTC } from "../tool.js";
import sendEmail from "../notification/email_notification.js";
import { GetAllSiteData } from "./controller.js";
import { siteDataToVariables } from "../notification/notification_utils.js";
import { GetGeneralEmailTemplateById } from "./generalTemplateController.js";
interface SubscriberTokenPayload {
subscriber_user_id: number;
subscriber_method_id: number;
email: string;
type: "subscriber";
}
/**
* Verify subscriber token and return subscriber info
*/
export async function VerifySubscriberToken(token: string): Promise<{
success: boolean;
error?: string;
user?: SubscriberUserRecord;
method?: SubscriberMethodRecord;
subscriptions?: { incidents: boolean; maintenances: boolean };
}> {
const decoded = await VerifyToken(token);
if (!decoded || (decoded as unknown as SubscriberTokenPayload).type !== "subscriber") {
return { success: false, error: "Invalid or expired token" };
}
const payload = decoded as unknown as SubscriberTokenPayload;
const user = await db.getSubscriberUserById(payload.subscriber_user_id);
if (!user || user.status !== "ACTIVE") {
return { success: false, error: "User not found or inactive" };
}
const method = await db.getSubscriberMethodById(payload.subscriber_method_id);
if (!method || method.status !== "ACTIVE") {
return { success: false, error: "Subscription method not found or inactive" };
}
// Get subscriptions
const allSubs = await db.getUserSubscriptionsV2({
subscriber_user_id: user.id,
subscriber_method_id: method.id,
});
const subscriptions = {
incidents: allSubs.some((s) => s.event_type === "incidents" && s.status === "ACTIVE"),
maintenances: allSubs.some((s) => s.event_type === "maintenances" && s.status === "ACTIVE"),
};
return { success: true, user, method, subscriptions };
}
/**
* Login/Register subscriber - send verification code
*/
export async function SubscriberLogin(email: string): Promise<{ success: boolean; error?: string }> {
if (!ValidateEmail(email)) {
return { success: false, error: "Invalid email address" };
}
const normalizedEmail = email.toLowerCase().trim();
const verificationCode = String(GenerateRandomNumber(6));
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes
// Check if user exists
let user = await db.getSubscriberUserByEmail(normalizedEmail);
if (!user) {
// Create new user
user = await db.createSubscriberUser({
email: normalizedEmail,
status: "PENDING",
verification_code: verificationCode,
verification_expires_at: expiresAt,
});
} else {
// Update verification code
await db.updateSubscriberUser(user.id, {
verification_code: verificationCode,
verification_expires_at: expiresAt,
});
}
// Get email template
const template = await GetGeneralEmailTemplateById("subscription_account_code");
if (!template) {
return { success: false, error: "Email template not found" };
}
// Get site data for variables
const siteData = await GetAllSiteData();
const siteVars = siteDataToVariables(siteData);
// Prepare variables
const emailVars = {
...siteVars,
email_code: verificationCode,
};
// Send email
try {
await sendEmail(
template.template_html_body || "",
template.template_subject || "Your Verification Code",
emailVars,
[normalizedEmail],
);
return { success: true };
} catch (error) {
console.error("Failed to send verification email:", error);
return { success: false, error: "Failed to send verification email" };
}
}
//getSubscriptionMethodsByEntity
export async function GetSubscriptionMethodsByEntity(
entity_type: string,
entity_id: string,
): Promise<
Array<{
method: SubscriberMethodRecord;
user: SubscriberUserRecord;
subscription: UserSubscriptionV2Record;
}>
> {
return await db.getSubscriptionMethodsByEntity(entity_type as SubscriptionEntityType, entity_id);
/**
* Verify OTP and return token
*/
export async function VerifySubscriberOTP(
email: string,
code: string,
): Promise<{ success: boolean; error?: string; token?: string }> {
if (!ValidateEmail(email)) {
return { success: false, error: "Invalid email address" };
}
const normalizedEmail = email.toLowerCase().trim();
const user = await db.getSubscriberUserByEmail(normalizedEmail);
if (!user) {
return { success: false, error: "User not found" };
}
// Check verification code
if (user.verification_code !== code) {
return { success: false, error: "Invalid verification code" };
}
// Check expiry
if (user.verification_expires_at && new Date(user.verification_expires_at) < new Date()) {
return { success: false, error: "Verification code expired" };
}
// Activate user
await db.updateSubscriberUser(user.id, {
status: "ACTIVE",
verification_code: null,
verification_expires_at: null,
});
// Create or get email method
let method = await db.getSubscriberMethodByUserAndType(user.id, "email", normalizedEmail);
if (!method) {
method = await db.createSubscriberMethod({
subscriber_user_id: user.id,
method_type: "email",
method_value: normalizedEmail,
status: "ACTIVE",
});
}
// Generate token (1 year expiry)
const tokenPayload: SubscriberTokenPayload = {
subscriber_user_id: user.id,
subscriber_method_id: method.id,
email: normalizedEmail,
type: "subscriber",
};
const token = await GenerateTokenWithExpiry(tokenPayload, "1y");
return { success: true, token };
}
/**
* Update subscription preferences
*/
export async function UpdateSubscriberPreferences(
token: string,
preferences: { incidents?: boolean; maintenances?: boolean },
): Promise<{ success: boolean; error?: string }> {
const verifyResult = await VerifySubscriberToken(token);
if (!verifyResult.success || !verifyResult.user || !verifyResult.method) {
return { success: false, error: verifyResult.error || "Invalid token" };
}
const { user, method } = verifyResult;
// Handle incidents subscription
if (preferences.incidents !== undefined) {
const existingSub = await db.getUserSubscriptionsV2({
subscriber_user_id: user.id,
subscriber_method_id: method.id,
event_type: "incidents",
});
if (preferences.incidents) {
// Enable
if (existingSub.length === 0) {
await db.createUserSubscriptionV2({
subscriber_user_id: user.id,
subscriber_method_id: method.id,
event_type: "incidents",
status: "ACTIVE",
});
} else if (existingSub[0].status !== "ACTIVE") {
await db.updateUserSubscriptionV2(existingSub[0].id, { status: "ACTIVE" });
}
} else {
// Disable
if (existingSub.length > 0 && existingSub[0].status === "ACTIVE") {
await db.updateUserSubscriptionV2(existingSub[0].id, { status: "INACTIVE" });
}
}
}
// Handle maintenances subscription
if (preferences.maintenances !== undefined) {
const existingSub = await db.getUserSubscriptionsV2({
subscriber_user_id: user.id,
subscriber_method_id: method.id,
event_type: "maintenances",
});
if (preferences.maintenances) {
// Enable
if (existingSub.length === 0) {
await db.createUserSubscriptionV2({
subscriber_user_id: user.id,
subscriber_method_id: method.id,
event_type: "maintenances",
status: "ACTIVE",
});
} else if (existingSub[0].status !== "ACTIVE") {
await db.updateUserSubscriptionV2(existingSub[0].id, { status: "ACTIVE" });
}
} else {
// Disable
if (existingSub.length > 0 && existingSub[0].status === "ACTIVE") {
await db.updateUserSubscriptionV2(existingSub[0].id, { status: "INACTIVE" });
}
}
}
return { success: true };
}
+16 -198
View File
@@ -8,14 +8,10 @@ import { AlertsRepository } from "./repositories/alerts.js";
import { UsersRepository } from "./repositories/users.js";
import { SiteDataRepository } from "./repositories/site-data.js";
import { IncidentsRepository } from "./repositories/incidents.js";
import { SubscribersRepository } from "./repositories/subscribers.js";
import { ImagesRepository } from "./repositories/images.js";
import { PagesRepository } from "./repositories/pages.js";
import { MaintenancesRepository } from "./repositories/maintenances.js";
import { MonitorAlertConfigRepository } from "./repositories/monitorAlertConfig.js";
import { TemplatesRepository } from "./repositories/templates.js";
import { SubscriptionConfigRepository } from "./repositories/subscriptionConfig.js";
import { UserSubscriptionsRepository } from "./repositories/userSubscriptions.js";
import { SubscriptionSystemRepository } from "./repositories/subscriptionSystem.js";
import { EmailTemplateConfigRepository } from "./repositories/emailTemplateConfig.js";
import { VaultRepository } from "./repositories/vault.js";
@@ -42,14 +38,10 @@ class DbImpl {
private users!: UsersRepository;
private siteData!: SiteDataRepository;
private incidents!: IncidentsRepository;
private subscribers!: SubscribersRepository;
private images!: ImagesRepository;
private pages!: PagesRepository;
private maintenances!: MaintenancesRepository;
private monitorAlertConfig!: MonitorAlertConfigRepository;
private templates!: TemplatesRepository;
private subscriptionConfig!: SubscriptionConfigRepository;
private userSubscriptions!: UserSubscriptionsRepository;
private subscriptionSystem!: SubscriptionSystemRepository;
private emailTemplateConfig!: EmailTemplateConfigRepository;
private vault!: VaultRepository;
@@ -104,6 +96,7 @@ class DbImpl {
updateTrigger!: AlertsRepository["updateTrigger"];
getTriggers!: AlertsRepository["getTriggers"];
getTriggerByID!: AlertsRepository["getTriggerByID"];
getTriggersByIDs!: AlertsRepository["getTriggersByIDs"];
// ============ Users ============
getUsersCount!: UsersRepository["getUsersCount"];
@@ -189,38 +182,6 @@ class DbImpl {
updateIncidentCommentStatusByID!: IncidentsRepository["updateIncidentCommentStatusByID"];
getIncidentCommentByID!: IncidentsRepository["getIncidentCommentByID"];
// ============ Subscribers ============
insertSubscriber!: SubscribersRepository["insertSubscriber"];
updateSubscriberMeta!: SubscribersRepository["updateSubscriberMeta"];
updateSubscriberStatus!: SubscribersRepository["updateSubscriberStatus"];
deleteSubscriberById!: SubscribersRepository["deleteSubscriberById"];
getAllActiveSubscribers!: SubscribersRepository["getAllActiveSubscribers"];
getSubscriberByDetails!: SubscribersRepository["getSubscriberByDetails"];
getSubscribersByType!: SubscribersRepository["getSubscribersByType"];
getSubscriberById!: SubscribersRepository["getSubscriberById"];
getSubscribersPaginated!: SubscribersRepository["getSubscribersPaginated"];
getSubscribersCount!: SubscribersRepository["getSubscribersCount"];
// ============ Subscriptions ============
insertSubscription!: SubscribersRepository["insertSubscription"];
removeAllDataFromSubscriptions!: SubscribersRepository["removeAllDataFromSubscriptions"];
getSubscriptionsBySubscriberId!: SubscribersRepository["getSubscriptionsBySubscriberId"];
updateSubscriptionStatus!: SubscribersRepository["updateSubscriptionStatus"];
getSubscriptionsForMonitor!: SubscribersRepository["getSubscriptionsForMonitor"];
getSubscriptionsPaginated!: SubscribersRepository["getSubscriptionsPaginated"];
getTotalSubscriptionCount!: SubscribersRepository["getTotalSubscriptionCount"];
getSubscriberEmails!: SubscribersRepository["getSubscriberEmails"];
// ============ Subscription Triggers ============
insertSubscriptionTrigger!: SubscribersRepository["insertSubscriptionTrigger"];
getSubscriptionTriggerById!: SubscribersRepository["getSubscriptionTriggerById"];
getAllSubscriptionTriggers!: SubscribersRepository["getAllSubscriptionTriggers"];
getSubscriptionTriggerByType!: SubscribersRepository["getSubscriptionTriggerByType"];
updateSubscriptionTrigger!: SubscribersRepository["updateSubscriptionTrigger"];
updateSubscriptionTriggerStatus!: SubscribersRepository["updateSubscriptionTriggerStatus"];
deleteSubscriptionTriggerByType!: SubscribersRepository["deleteSubscriptionTriggerByType"];
deleteSubscriptionTriggerById!: SubscribersRepository["deleteSubscriptionTriggerById"];
// ============ Images ============
insertImage!: ImagesRepository["insertImage"];
getImageById!: ImagesRepository["getImageById"];
@@ -331,39 +292,6 @@ class DbImpl {
getAlertsByIncidentId!: MonitorAlertConfigRepository["getAlertsByIncidentId"];
getMonitorAlertsV2Count!: MonitorAlertConfigRepository["getMonitorAlertsV2Count"];
// ============ Templates ============
insertTemplate!: TemplatesRepository["insertTemplate"];
updateTemplate!: TemplatesRepository["updateTemplate"];
getTemplateById!: TemplatesRepository["getTemplateById"];
getTemplates!: TemplatesRepository["getTemplates"];
getAllTemplates!: TemplatesRepository["getAllTemplates"];
getTemplatesByType!: TemplatesRepository["getTemplatesByType"];
getTemplatesByUsage!: TemplatesRepository["getTemplatesByUsage"];
getTemplatesByTypeAndUsage!: TemplatesRepository["getTemplatesByTypeAndUsage"];
deleteTemplate!: TemplatesRepository["deleteTemplate"];
getTemplatesCount!: TemplatesRepository["getTemplatesCount"];
templateNameExists!: TemplatesRepository["templateNameExists"];
// ============ Subscription Config ============
getSubscriptionConfig!: SubscriptionConfigRepository["getSubscriptionConfig"];
getSubscriptionConfigParsed!: SubscriptionConfigRepository["getSubscriptionConfigParsed"];
updateSubscriptionConfig!: SubscriptionConfigRepository["updateSubscriptionConfig"];
ensureSubscriptionConfig!: SubscriptionConfigRepository["ensureSubscriptionConfig"];
// ============ User Subscriptions ============
insertUserSubscription!: UserSubscriptionsRepository["insertUserSubscription"];
getUserSubscriptionById!: UserSubscriptionsRepository["getUserSubscriptionById"];
getUserSubscriptions!: UserSubscriptionsRepository["getUserSubscriptions"];
getSubscriptionsBySubscriberIdNew!: UserSubscriptionsRepository["getSubscriptionsBySubscriberId"];
updateUserSubscriptionStatus!: UserSubscriptionsRepository["updateUserSubscriptionStatus"];
deleteUserSubscription!: UserSubscriptionsRepository["deleteUserSubscription"];
deleteAllSubscriptionsBySubscriberId!: UserSubscriptionsRepository["deleteAllSubscriptionsBySubscriberId"];
subscriptionExists!: UserSubscriptionsRepository["subscriptionExists"];
getSubscribersByMethodPaginated!: UserSubscriptionsRepository["getSubscribersByMethodPaginated"];
getSubscribersCountByMethod!: UserSubscriptionsRepository["getSubscribersCountByMethod"];
getSubscriberWithSubscriptions!: UserSubscriptionsRepository["getSubscriberWithSubscriptions"];
getActiveSubscriptionsForEvent!: UserSubscriptionsRepository["getActiveSubscriptionsForEvent"];
// ============ Subscription System V2 (subscriber_users, subscriber_methods, user_subscriptions_v2) ============
createSubscriberUser!: SubscriptionSystemRepository["createSubscriberUser"];
getSubscriberUserById!: SubscriptionSystemRepository["getSubscriberUserById"];
@@ -391,18 +319,14 @@ class DbImpl {
getMethodsCountByType!: SubscriptionSystemRepository["getMethodsCountByType"];
getSubscribersByMethodTypeV2!: SubscriptionSystemRepository["getSubscribersByMethodTypeV2"];
getSubscriberDetailsByMethodId!: SubscriptionSystemRepository["getSubscriberDetailsByMethodId"];
getSubscriptionMethodsByEntity!: SubscriptionSystemRepository["getSubscriptionMethodsByEntity"];
// ============ Email Template Config ============
getAllEmailTemplateConfigs!: EmailTemplateConfigRepository["getAllEmailTemplateConfigs"];
getAllEmailTemplateConfigsWithTemplates!: EmailTemplateConfigRepository["getAllEmailTemplateConfigsWithTemplates"];
getEmailTemplateConfigByType!: EmailTemplateConfigRepository["getEmailTemplateConfigByType"];
getEmailTemplateConfigById!: EmailTemplateConfigRepository["getEmailTemplateConfigById"];
updateEmailTemplateConfigByType!: EmailTemplateConfigRepository["updateEmailTemplateConfigByType"];
updateEmailTemplateConfigById!: EmailTemplateConfigRepository["updateEmailTemplateConfigById"];
getActiveEmailTemplateConfigs!: EmailTemplateConfigRepository["getActiveEmailTemplateConfigs"];
getActiveEmailTemplateConfigByType!: EmailTemplateConfigRepository["getActiveEmailTemplateConfigByType"];
ensureDefaultEmailTypes!: EmailTemplateConfigRepository["ensureDefaultEmailTypes"];
// ============ General Email Templates ============
insertEmailTemplate!: EmailTemplateConfigRepository["insertEmailTemplate"];
updateEmailTemplate!: EmailTemplateConfigRepository["updateEmailTemplate"];
getAllEmailTemplates!: EmailTemplateConfigRepository["getAllEmailTemplates"];
getEmailTemplateById!: EmailTemplateConfigRepository["getEmailTemplateById"];
deleteEmailTemplate!: EmailTemplateConfigRepository["deleteEmailTemplate"];
upsertEmailTemplate!: EmailTemplateConfigRepository["upsertEmailTemplate"];
// ============ Vault ============
getAllSecrets!: VaultRepository["getAllSecrets"];
@@ -426,14 +350,10 @@ class DbImpl {
this.users = new UsersRepository(this.knex);
this.siteData = new SiteDataRepository(this.knex);
this.incidents = new IncidentsRepository(this.knex);
this.subscribers = new SubscribersRepository(this.knex);
this.images = new ImagesRepository(this.knex);
this.pages = new PagesRepository(this.knex);
this.maintenances = new MaintenancesRepository(this.knex);
this.monitorAlertConfig = new MonitorAlertConfigRepository(this.knex);
this.templates = new TemplatesRepository(this.knex);
this.subscriptionConfig = new SubscriptionConfigRepository(this.knex);
this.userSubscriptions = new UserSubscriptionsRepository(this.knex);
this.subscriptionSystem = new SubscriptionSystemRepository(this.knex);
this.emailTemplateConfig = new EmailTemplateConfigRepository(this.knex);
this.vault = new VaultRepository(this.knex);
@@ -445,14 +365,10 @@ class DbImpl {
this.bindUsersMethods();
this.bindSiteDataMethods();
this.bindIncidentsMethods();
this.bindSubscribersMethods();
this.bindImagesMethods();
this.bindPagesMethods();
this.bindMaintenancesMethods();
this.bindMonitorAlertConfigMethods();
this.bindTemplatesMethods();
this.bindSubscriptionConfigMethods();
this.bindUserSubscriptionsMethods();
this.bindSubscriptionSystemMethods();
this.bindEmailTemplateConfigMethods();
this.bindVaultMethods();
@@ -509,6 +425,7 @@ class DbImpl {
this.updateTrigger = this.alerts.updateTrigger.bind(this.alerts);
this.getTriggers = this.alerts.getTriggers.bind(this.alerts);
this.getTriggerByID = this.alerts.getTriggerByID.bind(this.alerts);
this.getTriggersByIDs = this.alerts.getTriggersByIDs.bind(this.alerts);
}
private bindUsersMethods(): void {
@@ -595,35 +512,6 @@ class DbImpl {
this.getIncidentCommentByID = this.incidents.getIncidentCommentByID.bind(this.incidents);
}
private bindSubscribersMethods(): void {
this.insertSubscriber = this.subscribers.insertSubscriber.bind(this.subscribers);
this.updateSubscriberMeta = this.subscribers.updateSubscriberMeta.bind(this.subscribers);
this.updateSubscriberStatus = this.subscribers.updateSubscriberStatus.bind(this.subscribers);
this.deleteSubscriberById = this.subscribers.deleteSubscriberById.bind(this.subscribers);
this.getAllActiveSubscribers = this.subscribers.getAllActiveSubscribers.bind(this.subscribers);
this.getSubscriberByDetails = this.subscribers.getSubscriberByDetails.bind(this.subscribers);
this.getSubscribersByType = this.subscribers.getSubscribersByType.bind(this.subscribers);
this.getSubscriberById = this.subscribers.getSubscriberById.bind(this.subscribers);
this.getSubscribersPaginated = this.subscribers.getSubscribersPaginated.bind(this.subscribers);
this.getSubscribersCount = this.subscribers.getSubscribersCount.bind(this.subscribers);
this.insertSubscription = this.subscribers.insertSubscription.bind(this.subscribers);
this.removeAllDataFromSubscriptions = this.subscribers.removeAllDataFromSubscriptions.bind(this.subscribers);
this.getSubscriptionsBySubscriberId = this.subscribers.getSubscriptionsBySubscriberId.bind(this.subscribers);
this.updateSubscriptionStatus = this.subscribers.updateSubscriptionStatus.bind(this.subscribers);
this.getSubscriptionsForMonitor = this.subscribers.getSubscriptionsForMonitor.bind(this.subscribers);
this.getSubscriptionsPaginated = this.subscribers.getSubscriptionsPaginated.bind(this.subscribers);
this.getTotalSubscriptionCount = this.subscribers.getTotalSubscriptionCount.bind(this.subscribers);
this.getSubscriberEmails = this.subscribers.getSubscriberEmails.bind(this.subscribers);
this.insertSubscriptionTrigger = this.subscribers.insertSubscriptionTrigger.bind(this.subscribers);
this.getSubscriptionTriggerById = this.subscribers.getSubscriptionTriggerById.bind(this.subscribers);
this.getAllSubscriptionTriggers = this.subscribers.getAllSubscriptionTriggers.bind(this.subscribers);
this.getSubscriptionTriggerByType = this.subscribers.getSubscriptionTriggerByType.bind(this.subscribers);
this.updateSubscriptionTrigger = this.subscribers.updateSubscriptionTrigger.bind(this.subscribers);
this.updateSubscriptionTriggerStatus = this.subscribers.updateSubscriptionTriggerStatus.bind(this.subscribers);
this.deleteSubscriptionTriggerByType = this.subscribers.deleteSubscriptionTriggerByType.bind(this.subscribers);
this.deleteSubscriptionTriggerById = this.subscribers.deleteSubscriptionTriggerById.bind(this.subscribers);
}
private bindImagesMethods(): void {
this.insertImage = this.images.insertImage.bind(this.images);
this.getImageById = this.images.getImageById.bind(this.images);
@@ -792,56 +680,6 @@ class DbImpl {
this.getMonitorAlertsV2Count = this.monitorAlertConfig.getMonitorAlertsV2Count.bind(this.monitorAlertConfig);
}
private bindTemplatesMethods(): void {
this.insertTemplate = this.templates.insertTemplate.bind(this.templates);
this.updateTemplate = this.templates.updateTemplate.bind(this.templates);
this.getTemplateById = this.templates.getTemplateById.bind(this.templates);
this.getTemplates = this.templates.getTemplates.bind(this.templates);
this.getAllTemplates = this.templates.getAllTemplates.bind(this.templates);
this.getTemplatesByType = this.templates.getTemplatesByType.bind(this.templates);
this.getTemplatesByUsage = this.templates.getTemplatesByUsage.bind(this.templates);
this.getTemplatesByTypeAndUsage = this.templates.getTemplatesByTypeAndUsage.bind(this.templates);
this.deleteTemplate = this.templates.deleteTemplate.bind(this.templates);
this.getTemplatesCount = this.templates.getTemplatesCount.bind(this.templates);
this.templateNameExists = this.templates.templateNameExists.bind(this.templates);
}
private bindSubscriptionConfigMethods(): void {
this.getSubscriptionConfig = this.subscriptionConfig.getSubscriptionConfig.bind(this.subscriptionConfig);
this.getSubscriptionConfigParsed = this.subscriptionConfig.getSubscriptionConfigParsed.bind(
this.subscriptionConfig,
);
this.updateSubscriptionConfig = this.subscriptionConfig.updateSubscriptionConfig.bind(this.subscriptionConfig);
this.ensureSubscriptionConfig = this.subscriptionConfig.ensureSubscriptionConfig.bind(this.subscriptionConfig);
}
private bindUserSubscriptionsMethods(): void {
this.insertUserSubscription = this.userSubscriptions.insertUserSubscription.bind(this.userSubscriptions);
this.getUserSubscriptionById = this.userSubscriptions.getUserSubscriptionById.bind(this.userSubscriptions);
this.getUserSubscriptions = this.userSubscriptions.getUserSubscriptions.bind(this.userSubscriptions);
this.getSubscriptionsBySubscriberIdNew = this.userSubscriptions.getSubscriptionsBySubscriberId.bind(
this.userSubscriptions,
);
this.updateUserSubscriptionStatus = this.userSubscriptions.updateUserSubscriptionStatus.bind(
this.userSubscriptions,
);
this.deleteUserSubscription = this.userSubscriptions.deleteUserSubscription.bind(this.userSubscriptions);
this.deleteAllSubscriptionsBySubscriberId = this.userSubscriptions.deleteAllSubscriptionsBySubscriberId.bind(
this.userSubscriptions,
);
this.subscriptionExists = this.userSubscriptions.subscriptionExists.bind(this.userSubscriptions);
this.getSubscribersByMethodPaginated = this.userSubscriptions.getSubscribersByMethodPaginated.bind(
this.userSubscriptions,
);
this.getSubscribersCountByMethod = this.userSubscriptions.getSubscribersCountByMethod.bind(this.userSubscriptions);
this.getSubscriberWithSubscriptions = this.userSubscriptions.getSubscriberWithSubscriptions.bind(
this.userSubscriptions,
);
this.getActiveSubscriptionsForEvent = this.userSubscriptions.getActiveSubscriptionsForEvent.bind(
this.userSubscriptions,
);
}
private bindSubscriptionSystemMethods(): void {
// Subscriber Users
this.createSubscriberUser = this.subscriptionSystem.createSubscriberUser.bind(this.subscriptionSystem);
@@ -890,36 +728,16 @@ class DbImpl {
this.getSubscriberDetailsByMethodId = this.subscriptionSystem.getSubscriberDetailsByMethodId.bind(
this.subscriptionSystem,
);
this.getSubscriptionMethodsByEntity = this.subscriptionSystem.getSubscriptionMethodsByEntity.bind(
this.subscriptionSystem,
);
}
private bindEmailTemplateConfigMethods(): void {
this.getAllEmailTemplateConfigs = this.emailTemplateConfig.getAllEmailTemplateConfigs.bind(
this.emailTemplateConfig,
);
this.getAllEmailTemplateConfigsWithTemplates =
this.emailTemplateConfig.getAllEmailTemplateConfigsWithTemplates.bind(this.emailTemplateConfig);
this.getEmailTemplateConfigByType = this.emailTemplateConfig.getEmailTemplateConfigByType.bind(
this.emailTemplateConfig,
);
this.getEmailTemplateConfigById = this.emailTemplateConfig.getEmailTemplateConfigById.bind(
this.emailTemplateConfig,
);
this.updateEmailTemplateConfigByType = this.emailTemplateConfig.updateEmailTemplateConfigByType.bind(
this.emailTemplateConfig,
);
this.updateEmailTemplateConfigById = this.emailTemplateConfig.updateEmailTemplateConfigById.bind(
this.emailTemplateConfig,
);
this.getActiveEmailTemplateConfigs = this.emailTemplateConfig.getActiveEmailTemplateConfigs.bind(
this.emailTemplateConfig,
);
this.getActiveEmailTemplateConfigByType = this.emailTemplateConfig.getActiveEmailTemplateConfigByType.bind(
this.emailTemplateConfig,
);
this.ensureDefaultEmailTypes = this.emailTemplateConfig.ensureDefaultEmailTypes.bind(this.emailTemplateConfig);
// General Email Templates
this.insertEmailTemplate = this.emailTemplateConfig.insertEmailTemplate.bind(this.emailTemplateConfig);
this.updateEmailTemplate = this.emailTemplateConfig.updateEmailTemplate.bind(this.emailTemplateConfig);
this.getAllEmailTemplates = this.emailTemplateConfig.getAllEmailTemplates.bind(this.emailTemplateConfig);
this.getEmailTemplateById = this.emailTemplateConfig.getEmailTemplateById.bind(this.emailTemplateConfig);
this.deleteEmailTemplate = this.emailTemplateConfig.deleteEmailTemplate.bind(this.emailTemplateConfig);
this.upsertEmailTemplate = this.emailTemplateConfig.upsertEmailTemplate.bind(this.emailTemplateConfig);
}
private bindVaultMethods(): void {
+4 -2
View File
@@ -100,7 +100,6 @@ export class AlertsRepository extends BaseRepository {
trigger_status: data.trigger_status,
trigger_meta: data.trigger_meta,
trigger_desc: data.trigger_desc,
template_id: data.template_id,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
@@ -113,7 +112,6 @@ export class AlertsRepository extends BaseRepository {
trigger_status: data.trigger_status,
trigger_desc: data.trigger_desc,
trigger_meta: data.trigger_meta,
template_id: data.template_id,
updated_at: this.knex.fn.now(),
});
}
@@ -132,4 +130,8 @@ export class AlertsRepository extends BaseRepository {
async getTriggerByID(id: number): Promise<TriggerRecord | undefined> {
return await this.knex("triggers").where("id", id).first();
}
//get by ids
async getTriggersByIDs(ids: number[]): Promise<TriggerRecord[]> {
return await this.knex("triggers").whereIn("id", ids);
}
}
@@ -1,136 +1,56 @@
import { BaseRepository } from "./base.js";
export interface EmailTemplateConfigRecord {
id: number;
email_type: string;
email_template_id: number | null;
is_active: string; // "YES" or "NO"
created_at: Date;
updated_at: Date;
}
export interface EmailTemplateConfigInsert {
email_type: string;
email_template_id?: number | null;
is_active?: string;
}
export interface EmailTemplateConfigUpdate {
email_template_id?: number | null;
is_active?: string;
}
export interface EmailTemplateConfigWithTemplate extends EmailTemplateConfigRecord {
template_name?: string | null;
}
import type { GeneralEmailTemplateRecord, GeneralEmailTemplateRecordInsert } from "../../types/db.js";
/**
* Repository for email template configuration operations
* Repository for general email templates operations
*/
export class EmailTemplateConfigRepository extends BaseRepository {
/**
* Get all email template configs
* Insert a new email template
*/
async getAllEmailTemplateConfigs(): Promise<EmailTemplateConfigRecord[]> {
return await this.knex("email_templates_config").orderBy("id", "asc");
async insertEmailTemplate(template: GeneralEmailTemplateRecordInsert): Promise<string[]> {
return await this.knex("general_email_templates").insert(template);
}
/**
* Get all email template configs with template details
* Update an existing email template by template_id
*/
async getAllEmailTemplateConfigsWithTemplates(): Promise<EmailTemplateConfigWithTemplate[]> {
return await this.knex("email_templates_config")
.leftJoin("templates", "email_templates_config.email_template_id", "templates.id")
.select("email_templates_config.*", "templates.template_name as template_name")
.orderBy("email_templates_config.id", "asc");
async updateEmailTemplate(
template_id: string,
updates: Partial<Omit<GeneralEmailTemplateRecordInsert, "template_id">>,
): Promise<number> {
return await this.knex("general_email_templates").where({ template_id }).update(updates);
}
/**
* Get email template config by email type
* Get all email templates
*/
async getEmailTemplateConfigByType(emailType: string): Promise<EmailTemplateConfigRecord | undefined> {
return await this.knex("email_templates_config").where({ email_type: emailType }).first();
async getAllEmailTemplates(): Promise<GeneralEmailTemplateRecord[]> {
return await this.knex("general_email_templates").select("*");
}
/**
* Get email template config by ID
* Get a specific email template by template_id
*/
async getEmailTemplateConfigById(id: number): Promise<EmailTemplateConfigRecord | undefined> {
return await this.knex("email_templates_config").where({ id }).first();
async getEmailTemplateById(template_id: string): Promise<GeneralEmailTemplateRecord | undefined> {
return await this.knex("general_email_templates").where({ template_id }).first();
}
/**
* Update email template config by email type
* Delete an email template by template_id
*/
async updateEmailTemplateConfigByType(emailType: string, data: EmailTemplateConfigUpdate): Promise<number> {
const updateData: Record<string, unknown> = {
updated_at: this.knex.fn.now(),
};
if (data.email_template_id !== undefined) {
updateData.email_template_id = data.email_template_id;
}
if (data.is_active !== undefined) {
updateData.is_active = data.is_active;
}
return await this.knex("email_templates_config").where({ email_type: emailType }).update(updateData);
async deleteEmailTemplate(template_id: string): Promise<number> {
return await this.knex("general_email_templates").where({ template_id }).delete();
}
/**
* Update email template config by ID
* Insert or update an email template (upsert)
*/
async updateEmailTemplateConfigById(id: number, data: EmailTemplateConfigUpdate): Promise<number> {
const updateData: Record<string, unknown> = {
updated_at: this.knex.fn.now(),
};
if (data.email_template_id !== undefined) {
updateData.email_template_id = data.email_template_id;
}
if (data.is_active !== undefined) {
updateData.is_active = data.is_active;
}
return await this.knex("email_templates_config").where({ id }).update(updateData);
}
/**
* Get active email template configs (is_active = "YES")
*/
async getActiveEmailTemplateConfigs(): Promise<EmailTemplateConfigRecord[]> {
return await this.knex("email_templates_config").where({ is_active: "YES" }).orderBy("id", "asc");
}
/**
* Get active email template config by type with template details
*/
async getActiveEmailTemplateConfigByType(emailType: string): Promise<EmailTemplateConfigWithTemplate | undefined> {
return await this.knex("email_templates_config")
.leftJoin("templates", "email_templates_config.email_template_id", "templates.id")
.select("email_templates_config.*", "templates.template_name as template_name")
.where({
"email_templates_config.email_type": emailType,
"email_templates_config.is_active": "YES",
})
.first();
}
/**
* Ensure default email types exist (for migration safety)
*/
async ensureDefaultEmailTypes(): Promise<void> {
const defaultTypes = ["email_code", "forgot_password", "verify_email"];
for (const emailType of defaultTypes) {
const existing = await this.getEmailTemplateConfigByType(emailType);
if (!existing) {
await this.knex("email_templates_config").insert({
email_type: emailType,
email_template_id: null,
is_active: "NO",
});
}
}
async upsertEmailTemplate(template: GeneralEmailTemplateRecordInsert): Promise<number[]> {
return await this.knex("general_email_templates").insert(template).onConflict("template_id").merge({
template_subject: template.template_subject,
template_html_body: template.template_html_body,
template_text_body: template.template_text_body,
});
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ export { AlertsRepository } from "./alerts.js";
export { UsersRepository } from "./users.js";
export { SiteDataRepository } from "./site-data.js";
export { IncidentsRepository } from "./incidents.js";
export { SubscribersRepository } from "./subscribers.js";
export { ImagesRepository } from "./images.js";
export { PagesRepository } from "./pages.js";
export { MaintenancesRepository } from "./maintenances.js";
export { EmailTemplateConfigRepository } from "./emailTemplateConfig.js";
@@ -15,6 +15,7 @@ import type {
MonitorAlertStatusType,
MonitorAlertV2WithConfig,
} from "../../types/db.js";
import { GetDbType } from "../../tool.js";
/**
* Repository for monitor alert configuration operations
@@ -315,14 +316,24 @@ export class MonitorAlertConfigRepository extends BaseRepository {
/**
* Insert a new monitor alert v2 record
*/
async insertMonitorAlertV2(data: MonitorAlertV2Insert): Promise<number[]> {
return await this.knex("monitor_alerts_v2").insert({
async insertMonitorAlertV2(data: MonitorAlertV2Insert): Promise<MonitorAlertV2Record> {
const dbType = GetDbType();
const insertData: Record<string, unknown> = {
config_id: data.config_id,
incident_id: data.incident_id || null,
alert_status: data.alert_status,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
};
if (dbType === "postgresql") {
const [record] = await this.knex("monitor_alerts_v2").insert(insertData).returning("*");
return record;
} else {
const [id] = await this.knex("monitor_alerts_v2").insert(insertData);
const record = await this.knex("monitor_alerts_v2").where({ id }).first();
return record as MonitorAlertV2Record;
}
}
/**
@@ -1,248 +0,0 @@
import { BaseRepository, type CountResult } from "./base.js";
import { GetDbType } from "../../tool.js";
import type {
SubscriberRecord,
SubscriberRecordInsert,
SubscriptionRecord,
SubscriptionRecordInsert,
SubscriptionTriggerRecord,
SubscriptionTriggerRecordInsert,
} from "../../types/db.js";
/**
* Repository for subscribers, subscriptions, and subscription triggers operations
*/
export class SubscribersRepository extends BaseRepository {
// ============ Subscribers ============
async insertSubscriber(data: SubscriberRecordInsert): Promise<number[]> {
return await this.knex("subscribers").insert({
subscriber_send: data.subscriber_send,
subscriber_meta: data.subscriber_meta,
subscriber_type: data.subscriber_type,
subscriber_status: data.subscriber_status,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
}
async updateSubscriberMeta(id: number, subscriber_meta: string | null): Promise<number> {
return await this.knex("subscribers").where({ id }).update({
subscriber_meta,
updated_at: this.knex.fn.now(),
});
}
async updateSubscriberStatus(id: number, subscriber_status: string): Promise<number> {
return await this.knex("subscribers").where({ id }).update({
subscriber_status,
updated_at: this.knex.fn.now(),
});
}
async deleteSubscriberById(id: number): Promise<number> {
return await this.knex("subscribers").where({ id }).del();
}
async getAllActiveSubscribers(): Promise<SubscriberRecord[]> {
return await this.knex("subscribers").where("subscriber_status", "ACTIVE");
}
async getSubscriberByDetails(
subscriber_send: string,
subscriber_type: string,
): Promise<SubscriberRecord | undefined> {
return await this.knex("subscribers").where({ subscriber_send, subscriber_type }).first();
}
async getSubscribersByType(subscriber_type: string): Promise<SubscriberRecord[]> {
return await this.knex("subscribers").where("subscriber_type", subscriber_type).orderBy("id", "desc");
}
async getSubscriberById(id: number): Promise<Omit<SubscriberRecord, "updated_at"> | undefined> {
return await this.knex("subscribers")
.select("id", "subscriber_send", "subscriber_meta", "subscriber_type", "subscriber_status", "created_at")
.where("id", id)
.first();
}
async getSubscribersPaginated(
page: number,
limit: number,
): Promise<
Array<{
id: number;
subscriber_send: string;
subscriber_status: string;
created_at: Date;
subscriptions_monitors: string[];
}>
> {
const subquery = this.knex("subscribers")
.select("id")
.orderBy("created_at", "desc")
.limit(limit)
.offset((page - 1) * limit)
.as("paginated_subscribers");
const dbType = GetDbType();
const aggregationFunction = dbType === "postgresql" ? "STRING_AGG" : "GROUP_CONCAT";
const results = await this.knex("subscribers as s")
.select(
"s.id",
"s.subscriber_send",
"s.subscriber_status",
"s.created_at",
this.knex.raw(`${aggregationFunction}(sub.subscriptions_monitors, ',') as monitors_agg`),
)
.innerJoin(subquery, "s.id", "paginated_subscribers.id")
.leftJoin("subscriptions as sub", "s.id", "sub.subscriber_id")
.groupBy("s.id", "s.subscriber_send", "s.subscriber_status", "s.created_at")
.orderBy("s.created_at", "desc");
return results.map(
(row: {
id: number;
subscriber_send: string;
subscriber_status: string;
created_at: Date;
monitors_agg: string | null;
}) => ({
id: row.id,
subscriber_send: row.subscriber_send,
subscriber_status: row.subscriber_status,
created_at: row.created_at,
subscriptions_monitors: row.monitors_agg ? row.monitors_agg.split(",") : [],
}),
);
}
async getSubscribersCount(): Promise<number | string> {
const result = await this.knex("subscribers").count("* as count").first<CountResult>();
return result?.count || 0;
}
// ============ Subscriptions ============
async insertSubscription(data: SubscriptionRecordInsert): Promise<number[]> {
return await this.knex("subscriptions").insert({
subscriber_id: data.subscriber_id,
subscriptions_status: data.subscriptions_status,
subscriptions_monitors: data.subscriptions_monitors,
subscriptions_meta: data.subscriptions_meta,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
}
async removeAllDataFromSubscriptions(subscriber_id: number): Promise<number> {
return await this.knex("subscriptions").where({ subscriber_id }).del();
}
async getSubscriptionsBySubscriberId(subscriber_id: number): Promise<SubscriptionRecord[]> {
return await this.knex("subscriptions").where("subscriber_id", subscriber_id).orderBy("id", "desc");
}
async updateSubscriptionStatus(id: number, subscriptions_status: string): Promise<number> {
return await this.knex("subscriptions").where({ id }).update({
subscriptions_status,
updated_at: this.knex.fn.now(),
});
}
async getSubscriptionsForMonitor(monitor_tag: string): Promise<
Array<{
subscriber_send: string;
subscriber_type: string;
subscriber_meta: string | null;
subscriptions_meta: string | null;
}>
> {
return await this.knex("subscriptions as s")
.join("subscribers as sub", "s.subscriber_id", "sub.id")
.where("s.subscriptions_status", "ACTIVE")
.where("sub.subscriber_status", "ACTIVE")
.whereRaw("s.subscriptions_monitors = ? OR s.subscriptions_monitors = 'ALL'", [monitor_tag])
.select("sub.subscriber_send", "sub.subscriber_type", "sub.subscriber_meta", "s.subscriptions_meta");
}
async getSubscriptionsPaginated(page: number, limit: number): Promise<Array<Omit<SubscriptionRecord, "updated_at">>> {
return await this.knex("subscriptions")
.select(
"id",
"subscriber_id",
"subscriptions_status",
"subscriptions_monitors",
"subscriptions_meta",
"created_at",
)
.orderBy("id", "desc")
.limit(limit)
.offset((page - 1) * limit);
}
async getTotalSubscriptionCount(): Promise<number | string> {
const result = await this.knex("subscriptions").count("* as count").first<CountResult>();
return result?.count || 0;
}
async getSubscriberEmails(monitor_tags: string[]): Promise<Array<{ subscriber_send: string }>> {
return await this.knex("subscriptions")
.join("subscribers", "subscribers.id", "subscriptions.subscriber_id")
.distinct("subscribers.subscriber_send as subscriber_send")
.where("subscriptions.subscriptions_status", "ACTIVE")
.whereIn("subscriptions.subscriptions_monitors", monitor_tags)
.orderBy("subscriber_send");
}
// ============ Subscription Triggers ============
async insertSubscriptionTrigger(data: SubscriptionTriggerRecordInsert): Promise<number[]> {
return await this.knex("subscription_triggers").insert({
subscription_trigger_type: data.subscription_trigger_type,
subscription_trigger_status: data.subscription_trigger_status,
config: data.config,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
}
async getSubscriptionTriggerById(id: number): Promise<SubscriptionTriggerRecord | undefined> {
return await this.knex("subscription_triggers").where({ id }).first();
}
async getAllSubscriptionTriggers(): Promise<SubscriptionTriggerRecord[]> {
return await this.knex("subscription_triggers").orderBy("id", "desc");
}
async getSubscriptionTriggerByType(subscription_trigger_type: string): Promise<SubscriptionTriggerRecord | null> {
return await this.knex("subscription_triggers")
.where("subscription_trigger_type", subscription_trigger_type)
.first();
}
async updateSubscriptionTrigger(data: SubscriptionTriggerRecord): Promise<number> {
return await this.knex("subscription_triggers").where({ id: data.id }).update({
subscription_trigger_type: data.subscription_trigger_type,
subscription_trigger_status: data.subscription_trigger_status,
config: data.config,
updated_at: this.knex.fn.now(),
});
}
async updateSubscriptionTriggerStatus(id: number, subscription_trigger_status: string): Promise<number> {
return await this.knex("subscription_triggers").where({ id }).update({
subscription_trigger_status,
updated_at: this.knex.fn.now(),
});
}
async deleteSubscriptionTriggerByType(subscription_trigger_type: string): Promise<number> {
return await this.knex("subscription_triggers").where({ subscription_trigger_type }).del();
}
async deleteSubscriptionTriggerById(id: number): Promise<number> {
return await this.knex("subscription_triggers").where({ id }).del();
}
}
@@ -1,70 +0,0 @@
import type { Knex } from "knex";
import type { SubscriptionConfigRecord, SubscriptionConfigUpdate, SubscriptionConfigParsed } from "../../types/db.js";
export class SubscriptionConfigRepository {
constructor(private knex: Knex) {}
/**
* Get the subscription config (there should only be one row)
*/
async getSubscriptionConfig(): Promise<SubscriptionConfigRecord | undefined> {
return await this.knex("subscription_config").first();
}
/**
* Get the subscription config with parsed JSON fields
*/
async getSubscriptionConfigParsed(): Promise<SubscriptionConfigParsed | undefined> {
const config = await this.getSubscriptionConfig();
if (!config) return undefined;
return {
...config,
events_enabled: JSON.parse(config.events_enabled),
methods_enabled: JSON.parse(config.methods_enabled),
method_triggers: JSON.parse(config.method_triggers),
};
}
/**
* Update the subscription config
*/
async updateSubscriptionConfig(id: number, data: SubscriptionConfigUpdate): Promise<number> {
return await this.knex("subscription_config")
.where({ id })
.update({
...data,
updated_at: this.knex.fn.now(),
});
}
/**
* Create a subscription config if it doesn't exist
*/
async ensureSubscriptionConfig(): Promise<SubscriptionConfigRecord> {
let config = await this.getSubscriptionConfig();
if (!config) {
await this.knex("subscription_config").insert({
events_enabled: JSON.stringify({
incidentUpdatesAll: false,
maintenanceUpdatesAll: false,
monitorUpdatesAll: false,
}),
methods_enabled: JSON.stringify({
email: false,
webhook: false,
slack: false,
discord: false,
}),
method_triggers: JSON.stringify({
email: null,
webhook: null,
slack: null,
discord: null,
}),
});
config = await this.getSubscriptionConfig();
}
return config!;
}
}
@@ -11,7 +11,6 @@ import type {
UserSubscriptionV2RecordInsert,
UserSubscriptionV2Filter,
SubscriptionEventType,
SubscriptionEntityType,
} from "../../types/db.js";
import { GetDbType } from "../../tool.js";
@@ -168,8 +167,6 @@ export class SubscriptionSystemRepository extends BaseRepository {
subscriber_user_id: data.subscriber_user_id,
subscriber_method_id: data.subscriber_method_id,
event_type: data.event_type,
entity_type: data.entity_type || null,
entity_id: data.entity_id || null,
status: data.status || "ACTIVE",
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
@@ -201,16 +198,7 @@ export class SubscriptionSystemRepository extends BaseRepository {
if (filter.event_type !== undefined) {
query = query.where("event_type", filter.event_type);
}
if (filter.entity_type !== undefined) {
if (filter.entity_type === null) {
query = query.whereNull("entity_type");
} else {
query = query.where("entity_type", filter.entity_type);
}
}
if (filter.entity_id !== undefined) {
query = query.where("entity_id", filter.entity_id);
}
if (filter.status !== undefined) {
query = query.where("status", filter.status);
}
@@ -235,26 +223,12 @@ export class SubscriptionSystemRepository extends BaseRepository {
subscriberUserId: number,
subscriberMethodId: number,
eventType: SubscriptionEventType,
entityType: SubscriptionEntityType,
entityId: string | null,
): Promise<boolean> {
let query = this.knex("user_subscriptions_v2")
.where("subscriber_user_id", subscriberUserId)
.andWhere("subscriber_method_id", subscriberMethodId)
.andWhere("event_type", eventType);
if (entityType === null) {
query = query.whereNull("entity_type");
} else {
query = query.where("entity_type", entityType);
}
if (entityId === null) {
query = query.whereNull("entity_id");
} else {
query = query.where("entity_id", entityId);
}
const result = await query.first();
return !!result;
}
@@ -304,11 +278,7 @@ export class SubscriptionSystemRepository extends BaseRepository {
/**
* Get subscribers for a specific event (for sending notifications)
*/
async getSubscribersForEvent(
eventType: SubscriptionEventType,
entityType?: SubscriptionEntityType,
entityId?: string,
): Promise<
async getSubscribersForEvent(eventType: SubscriptionEventType): Promise<
Array<{
user: SubscriberUserRecord;
method: SubscriberMethodRecord;
@@ -323,15 +293,6 @@ export class SubscriptionSystemRepository extends BaseRepository {
.andWhere("su.status", "ACTIVE")
.andWhere("sm.status", "ACTIVE");
// Match subscriptions that are for this specific entity OR for "all" (entity_type is null)
if (entityType && entityId) {
query = query.andWhere(function () {
this.whereNull("us.entity_type").orWhere(function () {
this.where("us.entity_type", entityType).andWhere("us.entity_id", entityId);
});
});
}
const rows = await query.select(
"su.id as user_id",
"su.email as user_email",
@@ -344,8 +305,6 @@ export class SubscriptionSystemRepository extends BaseRepository {
"sm.meta as method_meta",
"us.id as sub_id",
"us.event_type",
"us.entity_type",
"us.entity_id",
"us.status as sub_status",
"us.created_at as sub_created_at",
);
@@ -518,89 +477,4 @@ export class SubscriptionSystemRepository extends BaseRepository {
return { user, method, subscriptions };
}
/**
* Get subscription method details for a specific entity
* Returns all active subscriber methods that are subscribed to this entity
*/
async getSubscriptionMethodsByEntity(
entityType: SubscriptionEntityType,
entityId: string,
): Promise<
Array<{
method: SubscriberMethodRecord;
user: SubscriberUserRecord;
subscription: UserSubscriptionV2Record;
}>
> {
const rows = await this.knex("user_subscriptions_v2 as us")
.join("subscriber_methods as sm", "us.subscriber_method_id", "sm.id")
.join("subscriber_users as su", "us.subscriber_user_id", "su.id")
.where("us.status", "ACTIVE")
.andWhere("sm.status", "ACTIVE")
.andWhere("su.status", "ACTIVE")
.andWhere(function () {
// Match subscriptions for this specific entity OR global subscriptions (entity_type is null)
this.where(function () {
this.where("us.entity_type", entityType).andWhere("us.entity_id", entityId);
}).orWhereNull("us.entity_type");
})
.select(
"sm.id as method_id",
"sm.subscriber_user_id",
"sm.method_type",
"sm.method_value",
"sm.status as method_status",
"sm.meta as method_meta",
"sm.created_at as method_created_at",
"sm.updated_at as method_updated_at",
"su.id as user_id",
"su.email as user_email",
"su.status as user_status",
"su.verification_code",
"su.verification_expires_at",
"su.created_at as user_created_at",
"su.updated_at as user_updated_at",
"us.id as sub_id",
"us.event_type",
"us.entity_type",
"us.entity_id",
"us.status as sub_status",
"us.created_at as sub_created_at",
"us.updated_at as sub_updated_at",
);
return rows.map((row) => ({
method: {
id: row.method_id,
subscriber_user_id: row.subscriber_user_id,
method_type: row.method_type,
method_value: row.method_value,
status: row.method_status,
meta: row.method_meta,
created_at: row.method_created_at,
updated_at: row.method_updated_at,
},
user: {
id: row.user_id,
email: row.user_email,
status: row.user_status,
verification_code: row.verification_code,
verification_expires_at: row.verification_expires_at,
created_at: row.user_created_at,
updated_at: row.user_updated_at,
},
subscription: {
id: row.sub_id,
subscriber_user_id: row.subscriber_user_id,
subscriber_method_id: row.method_id,
event_type: row.event_type,
entity_type: row.entity_type,
entity_id: row.entity_id,
status: row.sub_status,
created_at: row.sub_created_at,
updated_at: row.sub_updated_at,
},
}));
}
}
-153
View File
@@ -1,153 +0,0 @@
import { BaseRepository, type CountResult } from "./base.js";
import type {
TemplateRecord,
TemplateInsert,
TemplateUpdate,
TemplateFilter,
TemplateType,
TemplateUsageType,
} from "../../types/db.js";
/**
* Repository for template operations
*/
export class TemplatesRepository extends BaseRepository {
// ============ Template CRUD ============
/**
* Insert a new template
*/
async insertTemplate(data: TemplateInsert): Promise<number[]> {
return await this.knex("templates").insert({
template_name: data.template_name,
template_type: data.template_type,
template_usage: data.template_usage,
template_json: data.template_json,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
}
/**
* Update an existing template by ID
*/
async updateTemplate(id: number, data: TemplateUpdate): Promise<number> {
const updateData: Record<string, unknown> = {
updated_at: this.knex.fn.now(),
};
if (data.template_name !== undefined) updateData.template_name = data.template_name;
if (data.template_type !== undefined) updateData.template_type = data.template_type;
if (data.template_usage !== undefined) updateData.template_usage = data.template_usage;
if (data.template_json !== undefined) updateData.template_json = data.template_json;
return await this.knex("templates").where({ id }).update(updateData);
}
/**
* Get a single template by ID
*/
async getTemplateById(id: number): Promise<TemplateRecord | undefined> {
return await this.knex("templates").where({ id }).first();
}
/**
* Get templates with optional filtering
*/
async getTemplates(filter: TemplateFilter): Promise<TemplateRecord[]> {
let query = this.knex("templates").whereRaw("1=1");
if (filter.id !== undefined) {
query = query.andWhere("id", filter.id);
}
if (filter.template_type !== undefined) {
query = query.andWhere("template_type", filter.template_type);
}
if (filter.template_usage !== undefined) {
query = query.andWhere("template_usage", filter.template_usage);
}
return await query.orderBy("id", "desc");
}
/**
* Get all templates
*/
async getAllTemplates(): Promise<TemplateRecord[]> {
return await this.knex("templates").orderBy("id", "desc");
}
/**
* Get templates by type
*/
async getTemplatesByType(templateType: TemplateType): Promise<TemplateRecord[]> {
return await this.knex("templates").where({ template_type: templateType }).orderBy("id", "desc");
}
/**
* Get templates by usage
*/
async getTemplatesByUsage(templateUsage: TemplateUsageType): Promise<TemplateRecord[]> {
return await this.knex("templates").where({ template_usage: templateUsage }).orderBy("id", "desc");
}
/**
* Get templates by type and usage
*/
async getTemplatesByTypeAndUsage(
templateType: TemplateType,
templateUsages: TemplateUsageType[],
): Promise<TemplateRecord[]> {
return await this.knex("templates")
.where({ template_type: templateType })
.whereIn("template_usage", templateUsages)
.orderBy("id", "desc");
}
/**
* Delete a template by ID
*/
async deleteTemplate(id: number): Promise<number> {
return await this.knex("templates").where({ id }).del();
}
/**
* Count templates with optional filtering
*/
async getTemplatesCount(filter: TemplateFilter): Promise<CountResult | undefined> {
let query = this.knex("templates").count("* as count");
if (filter.id !== undefined) {
query = query.andWhere("id", filter.id);
}
if (filter.template_type !== undefined) {
query = query.andWhere("template_type", filter.template_type);
}
if (filter.template_usage !== undefined) {
query = query.andWhere("template_usage", filter.template_usage);
}
return await query.first<CountResult>();
}
/**
* Check if a template name exists (for a given type and usage)
*/
async templateNameExists(
templateName: string,
templateType: TemplateType,
templateUsage: TemplateUsageType,
excludeId?: number,
): Promise<boolean> {
let query = this.knex("templates")
.count("* as count")
.where({ template_name: templateName, template_type: templateType, template_usage: templateUsage });
if (excludeId !== undefined) {
query = query.andWhereNot("id", excludeId);
}
const result = await query.first<CountResult>();
return Number(result?.count) > 0;
}
}
@@ -1,294 +0,0 @@
import type { Knex } from "knex";
import type {
UserSubscriptionRecord,
UserSubscriptionRecordInsert,
UserSubscriptionFilter,
SubscriptionMethodType,
SubscriptionEventType,
SubscriptionEntityType,
SubscriberRecord,
SubscriberSummary,
} from "../../types/db.js";
import type { CountResult } from "./base.js";
/**
* Repository for user subscriptions operations
*/
export class UserSubscriptionsRepository {
constructor(private knex: Knex) {}
// ============ User Subscriptions ============
/**
* Insert a new user subscription
*/
async insertUserSubscription(data: UserSubscriptionRecordInsert): Promise<number[]> {
return await this.knex("user_subscriptions").insert({
subscriber_id: data.subscriber_id,
subscription_method: data.subscription_method,
event_type: data.event_type,
entity_type: data.entity_type ?? null,
entity_id: data.entity_id ?? null,
status: data.status ?? "ACTIVE",
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
}
/**
* Get user subscription by ID
*/
async getUserSubscriptionById(id: number): Promise<UserSubscriptionRecord | undefined> {
return await this.knex("user_subscriptions").where({ id }).first();
}
/**
* Get user subscriptions by filter
*/
async getUserSubscriptions(filter: UserSubscriptionFilter): Promise<UserSubscriptionRecord[]> {
const query = this.knex("user_subscriptions");
if (filter.subscriber_id !== undefined) {
query.where("subscriber_id", filter.subscriber_id);
}
if (filter.subscription_method !== undefined) {
query.where("subscription_method", filter.subscription_method);
}
if (filter.event_type !== undefined) {
query.where("event_type", filter.event_type);
}
if (filter.entity_type !== undefined) {
if (filter.entity_type === null) {
query.whereNull("entity_type");
} else {
query.where("entity_type", filter.entity_type);
}
}
if (filter.entity_id !== undefined) {
query.where("entity_id", filter.entity_id);
}
if (filter.status !== undefined) {
query.where("status", filter.status);
}
return await query.orderBy("created_at", "desc");
}
/**
* Get all subscriptions for a subscriber
*/
async getSubscriptionsBySubscriberId(subscriber_id: number): Promise<UserSubscriptionRecord[]> {
return await this.knex("user_subscriptions").where({ subscriber_id }).orderBy("created_at", "desc");
}
/**
* Update user subscription status
*/
async updateUserSubscriptionStatus(id: number, status: "ACTIVE" | "INACTIVE"): Promise<number> {
return await this.knex("user_subscriptions").where({ id }).update({
status,
updated_at: this.knex.fn.now(),
});
}
/**
* Delete user subscription
*/
async deleteUserSubscription(id: number): Promise<number> {
return await this.knex("user_subscriptions").where({ id }).del();
}
/**
* Delete all subscriptions for a subscriber
*/
async deleteAllSubscriptionsBySubscriberId(subscriber_id: number): Promise<number> {
return await this.knex("user_subscriptions").where({ subscriber_id }).del();
}
/**
* Check if a subscription already exists
*/
async subscriptionExists(
subscriber_id: number,
subscription_method: SubscriptionMethodType,
event_type: SubscriptionEventType,
entity_type: string | null,
entity_id: string | null,
): Promise<boolean> {
const query = this.knex("user_subscriptions").where({ subscriber_id, subscription_method, event_type });
if (entity_type === null) {
query.whereNull("entity_type");
} else {
query.where("entity_type", entity_type);
}
if (entity_id === null) {
query.whereNull("entity_id");
} else {
query.where("entity_id", entity_id);
}
const result = await query.first();
return !!result;
}
// ============ Admin Subscriber Listing ============
/**
* Get subscribers by method type with pagination
*/
async getSubscribersByMethodPaginated(
method: SubscriptionMethodType,
page: number,
limit: number,
): Promise<SubscriberSummary[]> {
// Get unique subscriber IDs that have subscriptions with this method
const subquery = this.knex("user_subscriptions")
.distinct("subscriber_id")
.where("subscription_method", method)
.as("filtered_subscribers");
const knexInstance = this.knex;
const results = await this.knex("subscribers as s")
.select(
"s.id",
"s.subscriber_send",
"s.subscriber_type",
"s.subscriber_status",
"s.created_at",
this.knex.raw("COUNT(us.id) as subscription_count"),
)
.innerJoin(subquery, "s.id", "filtered_subscribers.subscriber_id")
.leftJoin("user_subscriptions as us", function () {
this.on("s.id", "=", "us.subscriber_id").andOn("us.subscription_method", "=", knexInstance.raw("?", [method]));
})
.groupBy("s.id", "s.subscriber_send", "s.subscriber_type", "s.subscriber_status", "s.created_at")
.orderBy("s.created_at", "desc")
.limit(limit)
.offset((page - 1) * limit);
// Now get event types for each subscriber
const subscriberIds = results.map((r: { id: number }) => r.id);
const eventTypesMap = new Map<number, SubscriptionEventType[]>();
if (subscriberIds.length > 0) {
const eventTypesData = await this.knex("user_subscriptions")
.select("subscriber_id", "event_type")
.whereIn("subscriber_id", subscriberIds)
.where("subscription_method", method)
.distinct();
for (const row of eventTypesData) {
if (!eventTypesMap.has(row.subscriber_id)) {
eventTypesMap.set(row.subscriber_id, []);
}
eventTypesMap.get(row.subscriber_id)!.push(row.event_type as SubscriptionEventType);
}
}
return results.map(
(row: {
id: number;
subscriber_send: string;
subscriber_type: string;
subscriber_status: string;
created_at: Date;
subscription_count: number | string;
}) => ({
id: row.id,
subscriber_send: row.subscriber_send,
subscriber_type: row.subscriber_type,
subscriber_status: row.subscriber_status,
created_at: row.created_at,
subscription_count: Number(row.subscription_count),
event_types: eventTypesMap.get(row.id) || [],
}),
);
}
/**
* Get count of subscribers by method type
*/
async getSubscribersCountByMethod(method: SubscriptionMethodType): Promise<number> {
const result = await this.knex("user_subscriptions")
.countDistinct("subscriber_id as count")
.where("subscription_method", method)
.first<CountResult>();
return Number(result?.count || 0);
}
/**
* Get subscriber with all their subscriptions for a specific method
*/
async getSubscriberWithSubscriptions(
subscriber_id: number,
method: SubscriptionMethodType,
): Promise<{
subscriber: SubscriberRecord | undefined;
subscriptions: UserSubscriptionRecord[];
}> {
const subscriber = (await this.knex("subscribers").where({ id: subscriber_id }).first()) as
| SubscriberRecord
| undefined;
const subscriptions = (await this.knex("user_subscriptions")
.where({ subscriber_id, subscription_method: method })
.orderBy("created_at", "desc")) as UserSubscriptionRecord[];
return { subscriber, subscriptions };
}
/**
* Get all active subscriptions for a specific event type and entity
* Useful for sending notifications
*/
async getActiveSubscriptionsForEvent(
event_type: SubscriptionEventType,
entity_type: string | null,
entity_id: string | null,
): Promise<Array<UserSubscriptionRecord & { subscriber: SubscriberRecord }>> {
const query = this.knex("user_subscriptions as us")
.select("us.*", "s.subscriber_send", "s.subscriber_meta", "s.subscriber_type", "s.subscriber_status")
.join("subscribers as s", "us.subscriber_id", "=", "s.id")
.where("us.event_type", event_type)
.where("us.status", "ACTIVE")
.where("s.subscriber_status", "ACTIVE");
// Match subscriptions that either:
// 1. Subscribe to "all" (entity_type is null)
// 2. Subscribe to this specific entity
if (entity_type && entity_id) {
query.andWhere(function () {
this.whereNull("us.entity_type").orWhere(function () {
this.where("us.entity_type", entity_type).where("us.entity_id", entity_id);
});
});
} else {
query.whereNull("us.entity_type");
}
const results = await query;
return results.map((row: Record<string, unknown>) => ({
id: row.id as number,
subscriber_id: row.subscriber_id as number,
subscription_method: row.subscription_method as SubscriptionMethodType,
event_type: row.event_type as SubscriptionEventType,
entity_type: row.entity_type as SubscriptionEntityType,
entity_id: row.entity_id as string | null,
status: row.status as "ACTIVE" | "INACTIVE",
created_at: row.created_at as Date,
updated_at: row.updated_at as Date,
subscriber: {
id: row.subscriber_id as number,
subscriber_send: row.subscriber_send as string,
subscriber_meta: row.subscriber_meta as string | null,
subscriber_type: row.subscriber_type as string,
subscriber_status: row.subscriber_status as string,
created_at: row.created_at as Date,
updated_at: row.updated_at as Date,
},
}));
}
}
+9
View File
@@ -113,6 +113,15 @@ const seedSiteData = {
},
kenerTheme: "default",
showSiteStatus: "NO",
subscriptionsSettings: {
enable: false,
methods: {
emails: {
incidents: false,
maintenances: false,
},
},
},
};
export default seedSiteData;
@@ -1,65 +1,58 @@
import type {
TriggerRecord,
TemplateRecord,
TemplateJsonType,
TriggerRecordParsed,
TriggerMetaEmailJson,
EmailTemplateJson,
} from "../types/db";
import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
import Mustache from "mustache";
import striptags from "striptags";
import { Resend, type CreateEmailOptions } from "resend";
import type {
ResendAPIConfiguration,
SiteDataForNotification,
SMTPConfiguration,
TemplateVariableMap,
} from "./types.js";
import type { SiteDataForNotification, SMTPConfiguration, TemplateVariableMap } from "./types.js";
import getSMTPTransport from "./smtps.js";
import { GetSMTPFromENV } from "../controllers/commonController.js";
import { IsEmailSetup, IsResendSetup } from "../controllers/emailController.js";
export default async function send(
triggerRecord: SMTPConfiguration | ResendAPIConfiguration,
variables: TemplateVariableMap,
template: TemplateRecord,
siteData: SiteDataForNotification,
emailBody: string,
emailSubject: string,
variables: Record<string, string | number | boolean>,
to: string[],
from?: string,
) {
// Implementation for sending email notification using the provided triggerRecord, variables, and template
let emailBody = template.template_json;
let emailBodyJson = JSON.parse(emailBody) as EmailTemplateJson;
let envSecretsTemplate = GetRequiredSecrets(emailBody);
let envSecretsTemplate = GetRequiredSecrets(emailBody + emailSubject);
for (let i = 0; i < envSecretsTemplate.length; i++) {
const secret = envSecretsTemplate[i];
if (secret.replace !== undefined) {
emailBody = ReplaceAllOccurrences(emailBody, secret.find, secret.replace);
emailSubject = ReplaceAllOccurrences(emailSubject, secret.find, secret.replace);
}
}
const subject = Mustache.render(emailBodyJson.email_subject, { ...variables, ...siteData });
const htmlBody = Mustache.render(emailBodyJson.email_body, { ...variables, ...siteData });
const subject = Mustache.render(emailSubject, variables);
const htmlBody = Mustache.render(emailBody, variables);
const textBody = striptags(htmlBody);
try {
//check if triggerRecord is of type ResendAPIConfiguration
if ("resend_api_key" in triggerRecord) {
const resend = new Resend(triggerRecord.resend_api_key);
let isEmailSetupDone = IsEmailSetup();
if (!isEmailSetupDone) {
throw new Error("Email not configured properly. Please check SMTP or Resend configuration.");
}
let isResend = IsResendSetup();
let mySMTPData = GetSMTPFromENV();
if (isResend) {
//check if triggerRecord is of type ResendAPIConfiguration
const resend = new Resend(process.env.RESEND_API_KEY || "");
const emailBody: CreateEmailOptions = {
from: triggerRecord.resend_sender_email,
from: from || process.env.RESEND_SENDER_EMAIL || "",
to: to,
subject: subject,
html: htmlBody,
text: textBody,
};
return await resend.emails.send(emailBody);
} else {
} else if (mySMTPData) {
// SMTP Configuration
const transport = getSMTPTransport(triggerRecord as SMTPConfiguration);
const transport = getSMTPTransport(mySMTPData as SMTPConfiguration);
const mailOptions = {
from: triggerRecord.smtp_sender, // sender address
from: from || mySMTPData.smtp_sender, // sender address
to: to, // recipient address(es)
subject: subject, // email subject
text: textBody, // plain text body
@@ -1,4 +1,4 @@
import type { MonitorAlertConfigRecord, MonitorAlertV2Record, TriggerMetaEmailJson } from "../types/db";
import type { MonitorAlertConfigRecord, MonitorAlertV2Record } from "../types/db";
import type {
AlertVariableMap,
ResendAPIConfiguration,
@@ -11,7 +11,6 @@ import type { SiteDataTransformed } from "../controllers/siteDataController.js";
import { GetSMTPFromENV } from "../controllers/controller.js";
export function alertToVariables(config: MonitorAlertConfigRecord, alert: MonitorAlertV2Record): AlertVariableMap {
// Ensure created_at is a Date object
const createdAtDate = alert.created_at instanceof Date ? alert.created_at : new Date(alert.created_at);
const alert_name = `Alert ${config.monitor_tag} for ${config.alert_for} ${config.alert_value} ${alert.alert_status} at ${createdAtDate.toISOString()}`;
@@ -46,45 +45,3 @@ export function siteDataToVariables(siteData: SiteDataTransformed): SiteDataForN
colors_maintenance: siteData.colors.MAINTENANCE,
};
}
//return SMTPConfiguration or ResendAPIConfiguration given TriggerMetaEmailJson
export function getEmailConfigFromTriggerMeta(
triggerMeta: TriggerMetaEmailJson,
): SMTPConfiguration | ResendAPIConfiguration {
if (triggerMeta.email_type.toUpperCase() === "SMTP") {
return {
smtp_host: triggerMeta.smtp_host || "",
smtp_port: Number(triggerMeta.smtp_port) || 587,
smtp_secure: triggerMeta.smtp_secure,
smtp_user: triggerMeta.smtp_user || "",
smtp_pass: triggerMeta.smtp_pass || "",
smtp_sender: triggerMeta.from || "",
};
} else {
return {
resend_api_key: process.env.RESEND_API_KEY || "",
resend_sender_email: triggerMeta.from || "",
};
}
}
//get preferred email type from environment variable or default to SMTP
export function getPreferredEmailConfiguration(): SMTPConfiguration | ResendAPIConfiguration {
let smtpData = GetSMTPFromENV();
if (smtpData) {
return {
smtp_host: smtpData.smtp_host,
smtp_port: smtpData.smtp_port,
smtp_secure: smtpData.smtp_secure,
smtp_user: smtpData.smtp_user,
smtp_pass: smtpData.smtp_pass,
smtp_sender: smtpData.smtp_from_email,
};
} else {
return {
resend_api_key: process.env.RESEND_API_KEY || "",
resend_sender_email: process.env.RESEND_SENDER_EMAIL || "",
};
}
}
+22 -57
View File
@@ -11,23 +11,13 @@ import {
CreateNewIncidentWithCommentAndMonitor,
} from "../controllers/controller.js";
import { SetLastMonitoringValue } from "../cache/setGet.js";
import type {
MonitorSettings,
MonitorAlertConfigRecord,
MonitorAlertV2Record,
TriggerRecordParsed,
TriggerMetaEmailJson,
TriggerMetaWebhookJson,
TriggerMetaSlackJson,
TriggerMetaDiscordJson,
} from "../types/db.js";
import type { MonitorSettings, MonitorAlertConfigRecord, MonitorAlertV2Record, TriggerMeta } from "../types/db.js";
import { GetMonitorsParsed } from "../controllers/controller.js";
import {
AddIncidentToAlert,
CreateMonitorAlertV2,
GetMonitorAlertConfigs,
GetTriggersByMonitorAlertConfigId,
GetTriggersParsedByMonitorAlertConfigId,
UpdateMonitorAlertV2Status,
} from "../controllers/monitorAlertConfigController.js";
import type { IncidentInput } from "../controllers/incidentController.js";
@@ -37,18 +27,13 @@ import { GetMonitorAlertsV2 } from "../controllers/monitorAlertConfigController.
import db from "../db/db.js";
import { getUnixTime, differenceInSeconds } from "date-fns";
import GC from "../../global-constants.js";
import {
alertToVariables,
getEmailConfigFromTriggerMeta,
siteDataToVariables,
} from "../notification/notification_utils.js";
import { alertToVariables, siteDataToVariables } from "../notification/notification_utils.js";
import sendEmail from "../notification/email_notification.js";
import sendWebhook from "$lib/server/notification/webhook_notification.js";
import sendSlack from "$lib/server/notification/slack_notification.js";
import sendDiscord from "$lib/server/notification/discord_notification.js";
import subscriberQueue from "./subscriberQueue.js";
import { GetTemplateById } from "../controllers/templateController.js";
import type { SiteDataForNotification } from "../notification/types.js";
let worker: Worker | null = null;
const queueName = "alertingQueue";
@@ -152,58 +137,38 @@ function createClosureComment(
async function sendAlertNotifications(
activeAlert: MonitorAlertV2Record,
monitor_alerts_configured: MonitorAlertConfigRecord,
templateSiteVars: any,
templateSiteVars: SiteDataForNotification,
): Promise<void> {
const templateAlertVars = alertToVariables(monitor_alerts_configured, activeAlert);
const triggers = await GetTriggersParsedByMonitorAlertConfigId(monitor_alerts_configured.id);
const triggers = await GetTriggersByMonitorAlertConfigId(monitor_alerts_configured.id);
for (let i = 0; i < triggers.length; i++) {
const trigger = triggers[i];
if (!trigger.template_id) {
console.error(`No template associated with trigger ID ${trigger.id}`);
continue;
}
// Fetch trigger template
const template = await GetTemplateById(trigger.template_id);
if (!template) {
console.error(`Template not found for trigger ID ${trigger.id}`);
continue;
}
const triggerMetaParsed = JSON.parse(trigger.trigger_meta) as TriggerMeta;
// Handle only email for now
if (trigger.trigger_type === "email") {
const emailSendingConfig = getEmailConfigFromTriggerMeta(trigger.trigger_meta as TriggerMetaEmailJson);
const toAddresses = (trigger.trigger_meta as TriggerMetaEmailJson).to;
await sendEmail(emailSendingConfig, templateAlertVars, template, templateSiteVars, toAddresses);
const toAddresses = triggerMetaParsed.to
.trim()
.split(",")
.map((addr) => addr.trim())
.filter((addr) => addr.length > 0);
if (toAddresses.length === 0) {
continue;
}
await sendEmail(
triggerMetaParsed.email_body,
triggerMetaParsed.email_subject,
{ ...templateAlertVars, ...templateSiteVars },
toAddresses,
triggerMetaParsed.from,
);
// await sendEmail(emailSendingConfig, templateAlertVars, template, templateSiteVars, toAddresses);
}
}
}
/**
* Send notifications to subscribers when an alert is triggered or resolved
*/
// async function sendSubscriberNotifications(
// activeAlert: MonitorAlertV2Record,
// monitor_alerts_configured: MonitorAlertConfigRecord,
// monitor_name: string,
// monitor_tag: string,
// ): Promise<void> {
// const isResolved = activeAlert.alert_status === GC.RESOLVED;
// const statusText = isResolved ? "Resolved" : "Triggered";
// const subscriptionVariables = {
// title: `${monitor_name} - Alert ${statusText}`,
// cta_url: "", // Can be populated with link to status page
// cta_text: "View Status Page",
// update_text: `Alert for ${monitor_name} (${monitor_tag}) has been ${statusText.toLowerCase()}. Alert type: ${monitor_alerts_configured.alert_for}, Value: ${monitor_alerts_configured.alert_value}, Severity: ${monitor_alerts_configured.severity}`,
// update_subject: `[${statusText}] ${monitor_name} - ${monitor_alerts_configured.alert_for} ${monitor_alerts_configured.alert_value}`,
// };
// // Push to subscriber queue with the monitor tag
// await subscriberQueue.push(subscriptionVariables, [monitor_tag]);
// }
const getQueue = () => {
if (!alertingQueue) {
alertingQueue = q.createQueue(queueName);
+118 -118
View File
@@ -1,139 +1,139 @@
/**
* Subscriber Queue - receives subscription variable data and trigger id
* Finds all subscribers for a given trigger and sends emails via senderQueue
*/
// /**
// * Subscriber Queue - receives subscription variable data and trigger id
// * Finds all subscribers for a given trigger and sends emails via senderQueue
// */
import { Queue, Worker, Job, type JobsOptions } from "bullmq";
import q from "./q.js";
import db from "../db/db.js";
import { CreateHash, CreateMD5Hash, GetAllSiteData } from "../controllers/controller.js";
import { siteDataToVariables, getPreferredEmailConfiguration } from "../notification/notification_utils.js";
import { GetTemplateByEmailType } from "../controllers/emailTemplateConfigController.js";
import senderQueue from "./senderQueue.js";
import type { SubscriptionVariableMap } from "../notification/types.js";
import { GetSubscriptionMethodsByEntity } from "../controllers/userSubscriptionsController.js";
// import { Queue, Worker, Job, type JobsOptions } from "bullmq";
// import q from "./q.js";
// import db from "../db/db.js";
// import { CreateHash, CreateMD5Hash, GetAllSiteData } from "../controllers/controller.js";
// import { siteDataToVariables, getPreferredEmailConfiguration } from "../notification/notification_utils.js";
// import { GetTemplateByEmailType } from "../controllers/emailTemplateConfigController.js";
// import senderQueue from "./senderQueue.js";
// import type { SubscriptionVariableMap } from "../notification/types.js";
// import { GetSubscriptionMethodsByEntity } from "../controllers/userSubscriptionsController.js";
let subscriberQueue: Queue | null = null;
let worker: Worker | null = null;
const queueName = "subscriberQueue";
const jobNamePrefix = "subscriberJob";
// let subscriberQueue: Queue | null = null;
// let worker: Worker | null = null;
// const queueName = "subscriberQueue";
// const jobNamePrefix = "subscriberJob";
interface SubscriberJobData {
variables: SubscriptionVariableMap;
entity_type: string;
entity_id: string;
}
// interface SubscriberJobData {
// variables: SubscriptionVariableMap;
// entity_type: string;
// entity_id: string;
// }
const getQueue = () => {
if (!subscriberQueue) {
subscriberQueue = q.createQueue(queueName);
}
return subscriberQueue;
};
// const getQueue = () => {
// if (!subscriberQueue) {
// subscriberQueue = q.createQueue(queueName);
// }
// return subscriberQueue;
// };
const addWorker = () => {
if (worker) return worker;
// const addWorker = () => {
// if (worker) return worker;
worker = q.createWorker(getQueue(), async (job: Job): Promise<void> => {
const { variables, entity_type, entity_id } = job.data as SubscriberJobData;
// worker = q.createWorker(getQueue(), async (job: Job): Promise<void> => {
// const { variables, entity_type, entity_id } = job.data as SubscriberJobData;
try {
// Get site data for template variables
const siteData = await GetAllSiteData();
const templateSiteVars = siteDataToVariables(siteData);
// try {
// // Get site data for template variables
// const siteData = await GetAllSiteData();
// const templateSiteVars = siteDataToVariables(siteData);
//given entity_type = 'incident' and entity_id get subscription method details
// //given entity_type = 'incident' and entity_id get subscription method details
let subscribers = await GetSubscriptionMethodsByEntity(entity_type, entity_id);
if (subscribers.length === 0) {
console.log(`No active subscribers found for ${entity_type} with ID: ${entity_id}`);
return;
}
// let subscribers = await GetSubscriptionMethodsByEntity(entity_type, entity_id);
// if (subscribers.length === 0) {
// console.log(`No active subscribers found for ${entity_type} with ID: ${entity_id}`);
// return;
// }
//get template for email
const emailTemplate = await GetTemplateByEmailType("subscription_update");
// //get template for email
// const emailTemplate = await GetTemplateByEmailType("subscription_update");
for (const subscriber of subscribers) {
const methodDetails = subscriber.method;
if (methodDetails.method_type === "email" && emailTemplate) {
const emailID = methodDetails.method_value;
const methodId = methodDetails.id;
const messageHash = CreateMD5Hash(
JSON.stringify({
variables,
}),
);
await senderQueue.push({
emailConfig: getPreferredEmailConfiguration(),
variables,
template: emailTemplate,
templateSiteVars,
emailTo: emailID,
type: "email",
id: `${entity_type}-${entity_id}-${methodId}-${messageHash}`,
});
}
}
// for (const subscriber of subscribers) {
// const methodDetails = subscriber.method;
// if (methodDetails.method_type === "email" && emailTemplate) {
// const emailID = methodDetails.method_value;
// const methodId = methodDetails.id;
// const messageHash = CreateMD5Hash(
// JSON.stringify({
// variables,
// }),
// );
// await senderQueue.push({
// emailConfig: getPreferredEmailConfiguration(),
// variables,
// template: emailTemplate,
// templateSiteVars,
// emailTo: emailID,
// type: "email",
// id: `${entity_type}-${entity_id}-${methodId}-${messageHash}`,
// });
// }
// }
// console.log(`📮 Queued ${subscriberEmails.length} subscription emails for monitors: ${monitor_tags.join(", ")}`);
} catch (error) {
console.error("Error processing subscriber queue job:", error);
throw error;
}
});
// // console.log(`📮 Queued ${subscriberEmails.length} subscription emails for monitors: ${monitor_tags.join(", ")}`);
// } catch (error) {
// console.error("Error processing subscriber queue job:", error);
// throw error;
// }
// });
worker.on("completed", (job: Job) => {
// const { monitor_tags } = job.data as SubscriberJobData;
// console.log(`✅ Subscriber job completed for monitors: ${monitor_tags.join(", ")}`);
});
// worker.on("completed", (job: Job) => {
// // const { monitor_tags } = job.data as SubscriberJobData;
// // console.log(`✅ Subscriber job completed for monitors: ${monitor_tags.join(", ")}`);
// });
worker.on("failed", (job: Job | undefined, err: Error) => {
console.error("❌ Subscriber job failed:", err.message);
});
// worker.on("failed", (job: Job | undefined, err: Error) => {
// console.error("❌ Subscriber job failed:", err.message);
// });
return worker;
};
// return worker;
// };
/**
* Push a subscriber notification job to the queue
*/
export const push = async (variables: SubscriptionVariableMap, monitor_tags: string[], options?: JobsOptions) => {
if (!options) {
options = {};
}
// /**
// * Push a subscriber notification job to the queue
// */
// export const push = async (variables: SubscriptionVariableMap, monitor_tags: string[], options?: JobsOptions) => {
// if (!options) {
// options = {};
// }
const queue = getQueue();
addWorker();
// const queue = getQueue();
// addWorker();
// Use deduplication to prevent duplicate notifications
const deDupId = `subscriber-${monitor_tags.join("-")}-${variables.update_subject}-${Date.now()}`;
if (!options.deduplication) {
options.deduplication = {
id: deDupId,
};
}
// // Use deduplication to prevent duplicate notifications
// const deDupId = `subscriber-${monitor_tags.join("-")}-${variables.update_subject}-${Date.now()}`;
// if (!options.deduplication) {
// options.deduplication = {
// id: deDupId,
// };
// }
await queue.add(
`${jobNamePrefix}_${monitor_tags.join("_")}`,
{
variables,
monitor_tags,
},
options,
);
};
// await queue.add(
// `${jobNamePrefix}_${monitor_tags.join("_")}`,
// {
// variables,
// monitor_tags,
// },
// options,
// );
// };
/**
* Graceful shutdown
*/
export const shutdown = async () => {
if (worker) {
await worker.close();
worker = null;
}
};
// /**
// * Graceful shutdown
// */
// export const shutdown = async () => {
// if (worker) {
// await worker.close();
// worker = null;
// }
// };
export default {
push,
shutdown,
};
// export default {
// push,
// shutdown,
// };
-10
View File
@@ -28,16 +28,6 @@ const addWorker = () => {
if (worker) return worker;
worker = q.createWorker(getQueue(), async (job: Job) => {
// Fetch secrets from vault and store in environment variables
try {
const secrets = await GetAllSecrets();
for (const secret of secrets) {
process.env[secret.secret_name] = secret.secret_value;
}
} catch (error) {
console.error("Failed to sync vault secrets to environment:", error);
}
const activeMonitors = (await GetMonitorsParsed({ status: "ACTIVE" })).map((monitor) => ({
...monitor,
hash: monitor.tag + "::" + HashString(JSON.stringify(monitor)),
@@ -55,10 +55,5 @@ const template = `{
}`;
export default {
template_name: "Default Discord Alert",
template_type: "DISCORD",
template_usage: "ALERT",
template_json: {
discord_body: template,
},
discord_body: template,
};
@@ -135,15 +135,6 @@ const emailTemplate = `<!DOCTYPE html>
</html>`;
export default {
template_name: "Default Email Alert",
template_type: "EMAIL",
template_usage: "ALERT",
template_json: JSON.stringify(
{
email_subject: "{{alert_name}}",
email_body: emailTemplate,
},
null,
2,
),
email_subject: "{{alert_name}}",
email_body: emailTemplate,
};
@@ -0,0 +1,185 @@
const emailTemplate = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<link rel="preload" as="image" href="{{site_logo_url}}" />
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body
style="
background-color: rgb(243, 244, 246);
font-family:
ui-sans-serif, system-ui, sans-serif, &quot;Apple Color Emoji&quot;,
&quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;
padding-top: 40px;
padding-bottom: 40px;
"
>
<!--$-->
<div
style="
display: none;
overflow: hidden;
line-height: 1px;
opacity: 0;
max-height: 0;
max-width: 0;
"
>
Your verification code: {{email_code}}
</div>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
background-color: rgb(255, 255, 255);
border-radius: 8px;
margin-left: auto;
margin-right: auto;
margin-top: 0px;
margin-bottom: 0px;
padding: 24px;
max-width: 600px;
"
>
<tbody>
<tr style="width: 100%">
<td>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="margin-top: 8px; margin-bottom: 32px; text-align: center"
>
<tbody>
<tr>
<td>
<img
alt="{{site_name}}"
height="40"
src="{{site_logo_url}}"
style="
margin-left: auto;
margin-right: auto;
display: block;
outline: none;
border: none;
text-decoration: none;
"
width="120"
/>
</td>
</tr>
</tbody>
</table>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
>
<tbody>
<tr>
<td>
<h1
style="
font-size: 24px;
font-weight: 700;
color: rgb(31, 41, 55);
margin-bottom: 16px;
text-align: center;
"
>
Verification Code
</h1>
<p
style="
font-size: 16px;
color: rgb(75, 85, 99);
margin-bottom: 24px;
line-height: 24px;
margin-top: 16px;
"
>
To complete your subscription for {{site_name}}, please use the
verification code below:
</p>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
background-color: rgb(249, 250, 251);
border-width: 1px;
border-color: rgb(229, 231, 235);
border-radius: 8px;
padding: 24px;
margin-bottom: 24px;
text-align: center;
"
>
<tbody>
<tr>
<td>
<p
style="
font-size: 32px;
font-weight: 700;
letter-spacing: 4px;
color: rgb(31, 41, 55);
margin: 0px;
line-height: 24px;
margin-bottom: 0px;
margin-top: 0px;
margin-left: 0px;
margin-right: 0px;
"
>
{{email_code}}
</p>
</td>
</tr>
</tbody>
</table>
<p
style="
font-size: 16px;
color: rgb(75, 85, 99);
margin-bottom: 24px;
line-height: 24px;
margin-top: 16px;
"
>
This code will expire in 5 minutes. If you didn&#x27;t request this code, you
can safely ignore this email.
</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--7--><!--/$-->
</body>
</html>`;
export default {
template_id: "subscription_account_code",
template_subject: "Your Subscription Account Verification Code",
template_html_body: emailTemplate,
template_text_body: `Your verification code: {{email_code}}`,
};
@@ -99,10 +99,5 @@ const template = `{
}`;
export default {
template_name: "Default Slack Alert",
template_type: "SLACK",
template_usage: "ALERT",
template_json: {
slack_body: template,
},
slack_body: template,
};
@@ -17,10 +17,5 @@ const template =
' "colors_maintenance": "{{colors_maintenance}}"\n}';
export default {
template_name: "Default Webhook Alert",
template_type: "WEBHOOK",
template_usage: "ALERT",
template_json: {
webhook_body: template,
},
webhook_body: template,
};
+86 -167
View File
@@ -150,53 +150,60 @@ export interface TriggerRecord {
trigger_desc: string | null;
trigger_status: string | null;
trigger_meta: string;
template_id?: number | null;
created_at: Date;
updated_at: Date;
}
export interface TriggerRecordParsed<T extends TriggerMetaJson = TriggerMetaJson> extends Omit<
TriggerRecord,
"trigger_meta"
> {
trigger_meta: T;
// Template JSON types for each template type
export interface EmailTemplateJson {
email_subject: string;
email_body: string; // HTML string
}
export interface TriggerMetaEmailJson {
to: string[];
export interface WebhookTemplateJson {
webhook_body: string; // JSON string
}
export interface SlackTemplateJson {
slack_body: string; // JSON string
}
export interface DiscordTemplateJson {
discord_body: string; // JSON string
}
export interface TriggerHeader {
key: string;
value: string;
}
export interface TriggerMeta extends EmailTemplateJson, WebhookTemplateJson, SlackTemplateJson, DiscordTemplateJson {
url: string;
headers: TriggerHeader[];
to: string;
from: string;
email_type: string;
smtp_host?: string;
smtp_port?: string | number;
smtp_secure?: boolean;
smtp_user?: string;
smtp_pass?: string;
}
export interface TriggerMetaWebhookJson {
url: string;
headers?: { key: string; value: string }[];
}
export interface TriggerMetaSlackJson {
url: string;
}
export interface TriggerMetaDiscordJson {
url: string;
}
export type TriggerMetaJson =
| TriggerMetaEmailJson
| TriggerMetaWebhookJson
| TriggerMetaSlackJson
| TriggerMetaDiscordJson;
export interface TriggerRecordInsert {
name: string;
trigger_type?: string | null;
trigger_desc?: string | null;
trigger_status?: string | null;
trigger_meta?: string | null;
template_id?: number | null;
}
// ============ general_email_templates table ============
export interface GeneralEmailTemplateRecord {
template_id: string;
template_subject: string | null;
template_html_body: string | null;
template_text_body: string | null;
}
export interface GeneralEmailTemplateRecordInsert {
template_id: string;
template_subject?: string | null;
template_html_body?: string | null;
template_text_body?: string | null;
}
// ============ users table ============
@@ -352,58 +359,6 @@ export interface InvitationRecordInsert {
invitation_status?: string;
}
// ============ subscribers table ============
export interface SubscriberRecord {
id: number;
subscriber_send: string;
subscriber_meta: string | null;
subscriber_type: string;
subscriber_status: string;
created_at: Date;
updated_at: Date;
}
export interface SubscriberRecordInsert {
subscriber_send: string;
subscriber_meta?: string | null;
subscriber_type: string;
subscriber_status: string;
}
// ============ subscriptions table ============
export interface SubscriptionRecord {
id: number;
subscriber_id: number;
subscriptions_status: string;
subscriptions_monitors: string;
subscriptions_meta: string | null;
created_at: Date;
updated_at: Date;
}
export interface SubscriptionRecordInsert {
subscriber_id: number;
subscriptions_status: string;
subscriptions_monitors: string;
subscriptions_meta?: string | null;
}
// ============ subscription_triggers table ============
export interface SubscriptionTriggerRecord {
id: number;
subscription_trigger_type: string;
subscription_trigger_status: string;
config: string | null;
created_at: Date;
updated_at: Date;
}
export interface SubscriptionTriggerRecordInsert {
subscription_trigger_type: string;
subscription_trigger_status: string;
config?: string | null;
}
// ============ Filter types ============
export interface IncidentFilter {
status?: string;
@@ -745,69 +700,6 @@ export interface MonitorAlertV2WithConfig extends MonitorAlertV2Record {
config: MonitorAlertConfigRecord;
}
// ============ templates table ============
export type TemplateType = "EMAIL" | "WEBHOOK" | "SLACK" | "DISCORD";
export type TemplateUsageType = "ALERT" | "SUBSCRIPTION" | "GENERAL";
// Template JSON types for each template type
export interface EmailTemplateJson {
email_subject: string;
email_body: string; // HTML string
}
export interface WebhookTemplateJson {
webhook_body: string; // JSON string
}
export interface SlackTemplateJson {
slack_body: string; // JSON string
}
export interface DiscordTemplateJson {
discord_body: string; // JSON string
}
// Union type for all template JSON types
export type TemplateJsonType = EmailTemplateJson | WebhookTemplateJson | SlackTemplateJson | DiscordTemplateJson;
export interface TemplateRecord {
id: number;
template_name: string;
template_type: TemplateType;
template_usage: TemplateUsageType;
template_json: string; // Stored as text, parsed to TemplateJsonType
created_at: Date;
updated_at: Date;
}
export interface TemplateInsert {
template_name: string;
template_type: TemplateType;
template_usage: TemplateUsageType;
template_json: string;
}
export interface TemplateUpdate {
template_name?: string;
template_type?: TemplateType;
template_usage?: TemplateUsageType;
template_json?: string;
}
export interface TemplateFilter {
id?: number;
template_type?: TemplateType;
template_usage?: TemplateUsageType;
}
// Parsed template record with JSON already parsed
export interface TemplateParsed<T extends TemplateJsonType = TemplateJsonType> extends Omit<
TemplateRecord,
"template_json"
> {
template_json: T;
}
// ============ subscription_config table ============
export interface SubscriptionEventsEnabled {
incidentUpdatesAll: boolean;
@@ -855,9 +747,8 @@ export interface SubscriptionConfigUpdate {
// ============ New Subscription System (v2) ============
export type SubscriptionMethodType = "email" | "webhook" | "slack" | "discord";
export type SubscriptionEventType = "incidentUpdatesAll" | "maintenanceUpdatesAll" | "monitorUpdatesAll";
export type SubscriptionEntityType = "monitor" | "incident" | "maintenance" | null;
export type SubscriptionMethodType = "email";
export type SubscriptionEventType = "incidents" | "maintenances";
export type SubscriptionStatus = "ACTIVE" | "INACTIVE";
export type SubscriberUserStatus = "PENDING" | "ACTIVE" | "INACTIVE";
@@ -905,8 +796,7 @@ export interface UserSubscriptionV2Record {
subscriber_user_id: number;
subscriber_method_id: number;
event_type: SubscriptionEventType;
entity_type: SubscriptionEntityType;
entity_id: string | null;
status: SubscriptionStatus;
created_at: Date;
updated_at: Date;
@@ -916,8 +806,7 @@ export interface UserSubscriptionV2RecordInsert {
subscriber_user_id: number;
subscriber_method_id: number;
event_type: SubscriptionEventType;
entity_type?: SubscriptionEntityType;
entity_id?: string | null;
status?: SubscriptionStatus;
}
@@ -925,8 +814,7 @@ export interface UserSubscriptionV2Filter {
subscriber_user_id?: number;
subscriber_method_id?: number;
event_type?: SubscriptionEventType;
entity_type?: SubscriptionEntityType;
entity_id?: string;
status?: SubscriptionStatus;
}
@@ -937,8 +825,7 @@ export interface UserSubscriptionRecord {
subscriber_id: number;
subscription_method: SubscriptionMethodType;
event_type: SubscriptionEventType;
entity_type: SubscriptionEntityType;
entity_id: string | null;
status: SubscriptionStatus;
created_at: Date;
updated_at: Date;
@@ -948,8 +835,7 @@ export interface UserSubscriptionRecordInsert {
subscriber_id: number;
subscription_method: SubscriptionMethodType;
event_type: SubscriptionEventType;
entity_type?: SubscriptionEntityType;
entity_id?: string | null;
status?: SubscriptionStatus;
}
@@ -957,14 +843,8 @@ export interface UserSubscriptionFilter {
subscriber_id?: number;
subscription_method?: SubscriptionMethodType;
event_type?: SubscriptionEventType;
entity_type?: SubscriptionEntityType;
entity_id?: string;
status?: SubscriptionStatus;
}
// Subscriber with their subscriptions
export interface SubscriberWithSubscriptions extends SubscriberRecord {
subscriptions: UserSubscriptionRecord[];
status?: SubscriptionStatus;
}
// Aggregated view for admin
@@ -977,3 +857,42 @@ export interface SubscriberSummary {
subscription_count: number;
event_types: SubscriptionEventType[];
}
// Template JSON types for each template type
export interface EmailTemplateJson {
email_subject: string;
email_body: string; // HTML string
}
export interface WebhookTemplateJson {
webhook_body: string; // JSON string
}
export interface SlackTemplateJson {
slack_body: string; // JSON string
}
export interface DiscordTemplateJson {
discord_body: string; // JSON string
}
export interface TriggerHeader {
key: string;
value: string;
}
export interface TriggerMeta extends EmailTemplateJson, WebhookTemplateJson, SlackTemplateJson, DiscordTemplateJson {
url: string;
headers: TriggerHeader[];
to: string;
from: string;
}
export interface SubscriptionsConfig {
enable: boolean;
methods: {
emails: {
incidents: boolean;
maintenances: boolean;
};
};
}
-4
View File
@@ -10,7 +10,6 @@ import {
IsLoggedInSession,
GetLocaleFromCookie,
GetAllPages,
GetSubscriptionTriggerByEmail,
} from "$lib/server/controllers/controller.js";
import type { PageNavItem } from "$lib/server/controllers/dashboardController";
@@ -38,7 +37,6 @@ export const load: LayoutServerLoad = async ({ cookies, request, url }) => {
page_path: p.page_path,
}));
const emailSubscriptionTrigger = await GetSubscriptionTriggerByEmail();
return {
isMobile,
isSetupComplete,
@@ -52,7 +50,5 @@ export const load: LayoutServerLoad = async ({ cookies, request, url }) => {
logo: siteData.logo,
favicon: siteData.favicon,
footerHTML: siteData.footerHTML || "",
isEmailSubscriptionEnabled:
!!emailSubscriptionTrigger && emailSubscriptionTrigger.subscription_trigger_status === "ACTIVE",
};
};
+140 -154
View File
@@ -17,11 +17,8 @@ import {
InsertKeyValue,
GetMonitors,
CreateUpdateTrigger,
UpdateSubscriptionTriggerStatus,
GetAllTriggers,
UpdateTriggerData,
GetSubscriptionTriggerByEmail,
CreateSubscriptionTrigger,
GetAllAlertsPaginated,
GetIncidentByIDDashboard,
GetMonitorsParsed,
@@ -54,8 +51,7 @@ import {
GetUserByEmail,
ManualUpdateUserData,
DeleteMonitorCompletelyUsingTag,
GetSubscribersPaginated,
UpdateSubscriptionStatus,
GetSiteDataByKey,
} from "$lib/server/controllers/controller.js";
import { INVITE_VERIFY_EMAIL, MANUAL } from "$lib/server/constants.js";
@@ -96,29 +92,17 @@ import {
type MonitorAlertConfigRecord,
type MonitorAlertV2Record,
} from "$lib/server/controllers/monitorAlertConfigController.js";
import {
CreateTemplate,
UpdateTemplate,
GetTemplateById,
GetTemplates,
GetAllTemplates,
GetTemplatesByType,
GetTemplatesByUsage,
GetTemplatesByTypeAndUsage,
DeleteTemplate,
} from "$lib/server/controllers/templateController.js";
import {
GetSubscriptionConfig,
UpdateSubscriptionConfigFull,
ValidateMethodTriggers,
} from "$lib/server/controllers/subscriptionConfigController.js";
import {
GetSubscribersByMethod,
GetSubscriberWithSubscriptions,
GetSubscriberWithSubscriptionsV2,
GetSubscriberCountsByMethod,
DeleteUserSubscription,
UpdateUserSubscriptionStatus,
GetAdminSubscribersPaginated,
AdminUpdateSubscriptionStatus,
AdminDeleteSubscriber,
AdminAddSubscriber,
} from "$lib/server/controllers/userSubscriptionsController.js";
import {
GetAllEmailTemplateConfigsWithTemplates,
@@ -134,20 +118,13 @@ import {
UpdateSecret,
DeleteSecret,
} from "$lib/server/controllers/vaultController.js";
import type { AlertData, SiteDataForNotification } from "$lib/server/notification/variables";
import {
alertToVariables,
getEmailConfigFromTriggerMeta,
siteDataToVariables,
} from "$lib/server/notification/notification_utils";
import type {
TriggerRecordParsed,
TriggerMetaEmailJson,
TemplateRecord,
TriggerMetaWebhookJson,
TriggerMetaSlackJson,
TriggerMetaDiscordJson,
} from "../../../../lib/server/types/db";
GetAllGeneralEmailTemplates,
GetGeneralEmailTemplateById,
UpdateGeneralEmailTemplate,
} from "$lib/server/controllers/generalTemplateController.js";
import type { AlertData, SiteDataForNotification } from "$lib/server/notification/variables";
import { alertToVariables, siteDataToVariables } from "$lib/server/notification/notification_utils";
function AdminCan(role: string) {
if (role !== "admin") {
@@ -345,54 +322,54 @@ export async function POST({ request, cookies }) {
updated_at: new Date(),
};
//throw error if not template id
if (!trigger.template_id) {
throw new Error("No template associated with this trigger");
}
//get template by id
const template = await GetTemplateById(trigger.template_id);
if (!template) {
throw new Error("No template found for this trigger");
}
// if (!trigger.template_id) {
// throw new Error("No template associated with this trigger");
// }
// //get template by id
// const template = await GetTemplateById(trigger.template_id);
// if (!template) {
// throw new Error("No template found for this trigger");
// }
const templateAlertVars = alertToVariables(testAlert, testAlertData);
const templateSiteVars = siteDataToVariables(siteData);
// const templateAlertVars = alertToVariables(testAlert, testAlertData);
// const templateSiteVars = siteDataToVariables(siteData);
let triggerParsed: TriggerRecordParsed = {
...trigger,
trigger_meta: trigger.trigger_meta ? JSON.parse(trigger.trigger_meta) : { to: [], from: "" },
};
if (trigger.trigger_type === "webhook") {
resp = await sendWebhook(
triggerParsed as TriggerRecordParsed<TriggerMetaWebhookJson>,
templateAlertVars as AlertData,
template,
templateSiteVars,
);
} else if (trigger.trigger_type === "email") {
const emailSendingConfig = getEmailConfigFromTriggerMeta(triggerParsed.trigger_meta as TriggerMetaEmailJson);
const toAddresses = (triggerParsed.trigger_meta as TriggerMetaEmailJson).to;
resp = await sendEmail(
emailSendingConfig,
templateAlertVars as AlertData,
template,
templateSiteVars,
toAddresses,
);
} else if (trigger.trigger_type === "slack") {
resp = await sendSlack(
triggerParsed as TriggerRecordParsed<TriggerMetaSlackJson>,
templateAlertVars as AlertData,
template,
templateSiteVars,
);
} else if (trigger.trigger_type === "discord") {
resp = await sendDiscord(
triggerParsed as TriggerRecordParsed<TriggerMetaDiscordJson>,
templateAlertVars as AlertData,
template,
templateSiteVars,
);
}
// let triggerParsed: TriggerRecordParsed = {
// ...trigger,
// trigger_meta: trigger.trigger_meta ? JSON.parse(trigger.trigger_meta) : { to: [], from: "" },
// };
// if (trigger.trigger_type === "webhook") {
// resp = await sendWebhook(
// triggerParsed as TriggerRecordParsed<TriggerMetaWebhookJson>,
// templateAlertVars as AlertData,
// template,
// templateSiteVars,
// );
// } else if (trigger.trigger_type === "email") {
// const emailSendingConfig = getEmailConfigFromTriggerMeta(triggerParsed.trigger_meta as TriggerMetaEmailJson);
// const toAddresses = (triggerParsed.trigger_meta as TriggerMetaEmailJson).to;
// resp = await sendEmail(
// emailSendingConfig,
// templateAlertVars as AlertData,
// template,
// templateSiteVars,
// toAddresses,
// );
// } else if (trigger.trigger_type === "slack") {
// resp = await sendSlack(
// triggerParsed as TriggerRecordParsed<TriggerMetaSlackJson>,
// templateAlertVars as AlertData,
// template,
// templateSiteVars,
// );
// } else if (trigger.trigger_type === "discord") {
// resp = await sendDiscord(
// triggerParsed as TriggerRecordParsed<TriggerMetaDiscordJson>,
// templateAlertVars as AlertData,
// template,
// templateSiteVars,
// );
// }
} else if (action == "testMonitor") {
let monitorID = data.monitor_id;
let monitors = await GetMonitorsParsed({ id: monitorID });
@@ -408,16 +385,6 @@ export async function POST({ request, cookies }) {
};
const serviceClient = new Service(monitorReducedType);
resp = await serviceClient.execute();
} else if (action == "createSubscriptionTrigger") {
resp = await CreateSubscriptionTrigger(data);
} else if (action == "getSubscriptionTrigger") {
resp = await GetSubscriptionTriggerByEmail();
} else if (action == "getSubscribers") {
resp = await GetSubscribersPaginated(data);
} else if (action == "updateSubscriptionStatus") {
resp = await UpdateSubscriptionStatus(data.id, data.status);
} else if (action == "updateSubscriptionTriggerStatus") {
resp = await UpdateSubscriptionTriggerStatus(data.id, data.status);
} else if (action == "uploadImage") {
AdminEditorCan(userDB.role);
resp = await uploadImage(data);
@@ -530,67 +497,7 @@ export async function POST({ request, cookies }) {
AdminEditorCan(userDB.role);
resp = await ToggleMonitorAlertConfigStatus(data.id);
}
// ============ Template Actions ============
else if (action == "createTemplate") {
AdminEditorCan(userDB.role);
resp = await CreateTemplate(data);
} else if (action == "updateTemplate") {
AdminEditorCan(userDB.role);
resp = await UpdateTemplate(data);
} else if (action == "getTemplateById") {
resp = await GetTemplateById(data.id);
if (!resp) {
throw new Error("Template not found");
}
} else if (action == "getTemplates") {
resp = await GetTemplates(data || {});
} else if (action == "getAllTemplates") {
resp = await GetAllTemplates();
} else if (action == "getTemplatesByType") {
resp = await GetTemplatesByType(data.template_type);
} else if (action == "getTemplatesByUsage") {
resp = await GetTemplatesByUsage(data.template_usage);
} else if (action == "getTemplatesByTypeAndUsage") {
resp = await GetTemplatesByTypeAndUsage(data.template_type, data.template_usages);
} else if (action == "deleteTemplate") {
AdminEditorCan(userDB.role);
await DeleteTemplate(data.id);
resp = { success: true };
}
// ============ Subscription Config ============
else if (action == "getSubscriptionConfig") {
resp = await GetSubscriptionConfig();
if (!resp) {
// Return default config structure if none exists yet
resp = {
events_enabled: {
incidentUpdatesAll: false,
maintenanceUpdatesAll: false,
monitorUpdatesAll: false,
},
methods_enabled: {
email: false,
webhook: false,
slack: false,
discord: false,
},
method_triggers: {
email: null,
webhook: null,
slack: null,
discord: null,
},
};
}
} else if (action == "updateSubscriptionConfig") {
AdminCan(userDB.role);
// Validate triggers if any are set
const validation = await ValidateMethodTriggers(data.method_triggers);
if (!validation.valid) {
throw new Error(validation.errors.join("; "));
}
resp = await UpdateSubscriptionConfigFull(data);
}
// ============ User Subscriptions (Admin) ============
else if (action == "getSubscribersByMethod") {
const { method, page = 1, limit = 25 } = data;
@@ -623,8 +530,6 @@ export async function POST({ request, cookies }) {
subscriber_id: s.subscriber_method_id,
subscription_method: result.method.method_type,
event_type: s.event_type,
entity_type: s.entity_type,
entity_id: s.entity_id,
status: s.status,
created_at: s.created_at,
updated_at: s.updated_at,
@@ -669,6 +574,69 @@ export async function POST({ request, cookies }) {
} else if (action == "getEmailTemplates") {
resp = await GetEmailTemplates();
}
// ============ General Email Templates ============
else if (action == "getGeneralEmailTemplates") {
resp = await GetAllGeneralEmailTemplates();
} else if (action == "getGeneralEmailTemplateById") {
const { templateId } = data;
if (!templateId) {
throw new Error("Template ID is required");
}
resp = await GetGeneralEmailTemplateById(templateId);
if (!resp) {
throw new Error("Template not found");
}
} else if (action == "updateGeneralEmailTemplate") {
AdminEditorCan(userDB.role);
const { templateId, template_subject, template_html_body, template_text_body } = data;
if (!templateId) {
throw new Error("Template ID is required");
}
resp = await UpdateGeneralEmailTemplate(templateId, {
template_subject,
template_html_body,
template_text_body,
});
if (!resp.success) {
throw new Error(resp.error);
}
}
// ============ Admin Subscribers Management ============
else if (action == "getAdminSubscribers") {
const page = parseInt(String(data.page)) || 1;
const limit = parseInt(String(data.limit)) || 10;
resp = await GetAdminSubscribersPaginated(page, limit);
} else if (action == "adminUpdateSubscriptionStatus") {
AdminEditorCan(userDB.role);
const { methodId, eventType, enabled } = data;
if (!methodId || !eventType) {
throw new Error("Method ID and event type are required");
}
resp = await AdminUpdateSubscriptionStatus(methodId, eventType, enabled);
if (!resp.success) {
throw new Error(resp.error);
}
} else if (action == "adminDeleteSubscriber") {
AdminEditorCan(userDB.role);
const { methodId } = data;
if (!methodId) {
throw new Error("Method ID is required");
}
resp = await AdminDeleteSubscriber(methodId);
if (!resp.success) {
throw new Error(resp.error);
}
} else if (action == "adminAddSubscriber") {
AdminEditorCan(userDB.role);
const { email, incidents, maintenances } = data;
if (!email) {
throw new Error("Email is required");
}
resp = await AdminAddSubscriber(email, incidents ?? false, maintenances ?? false);
if (!resp.success) {
throw new Error(resp.error);
}
}
// ============ Vault ============
else if (action == "getVaultSecrets") {
AdminCan(userDB.role);
@@ -710,6 +678,24 @@ export async function POST({ request, cookies }) {
if (!resp.success) {
throw new Error(resp.error);
}
} else if (action == "getSubscriptionsConfig") {
AdminCan(userDB.role);
let subscriptionsSettings = await GetSiteDataByKey("subscriptionsSettings");
if (!!!subscriptionsSettings) {
subscriptionsSettings = {
enable: false,
methods: {
emails: {
incidents: true,
maintenances: true,
},
},
};
}
resp = subscriptionsSettings;
} else if (action == "updateSubscriptionsConfig") {
AdminCan(userDB.role);
resp = await InsertKeyValue("subscriptionsSettings", JSON.stringify(data));
}
} catch (error: unknown) {
console.log(error);
File diff suppressed because it is too large Load Diff
@@ -1,16 +0,0 @@
import type { PageServerLoad } from "./$types";
import { error } from "@sveltejs/kit";
const validMethods = ["email", "webhook", "slack", "discord"];
export const load: PageServerLoad = async ({ params }) => {
const { method } = params;
if (!validMethods.includes(method)) {
throw error(404, "Invalid method");
}
return {
method,
};
};
@@ -1,233 +0,0 @@
<script lang="ts">
import * as Card from "$lib/components/ui/card/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import MailIcon from "@lucide/svelte/icons/mail";
import WebhookIcon from "@lucide/svelte/icons/webhook";
import HashIcon from "@lucide/svelte/icons/hash";
import MessageSquareIcon from "@lucide/svelte/icons/message-square";
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import UsersIcon from "@lucide/svelte/icons/users";
import { toast } from "svelte-sonner";
import { format } from "date-fns";
interface PageData {
method: "email" | "webhook" | "slack" | "discord";
}
interface SubscriberSummary {
id: number;
subscriber_send: string;
subscriber_type: string;
subscriber_status: string;
created_at: string;
subscription_count: number;
event_types: string[];
}
let { data }: { data: PageData } = $props();
// State
let loading = $state(true);
let subscribers = $state<SubscriberSummary[]>([]);
let page = $state(1);
let limit = $state(25);
let total = $state(0);
let totalPages = $state(0);
const methodLabels: Record<string, string> = {
email: "Email",
webhook: "Webhook",
slack: "Slack",
discord: "Discord"
};
const eventLabels: Record<string, string> = {
incidentUpdatesAll: "Incidents",
maintenanceUpdatesAll: "Maintenance",
monitorUpdatesAll: "Monitors"
};
function getMethodIcon(method: string) {
switch (method) {
case "email":
return MailIcon;
case "webhook":
return WebhookIcon;
case "slack":
return HashIcon;
case "discord":
return MessageSquareIcon;
default:
return MailIcon;
}
}
async function loadSubscribers() {
loading = true;
try {
const res = await fetch("/manage/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "getSubscribersByMethod",
data: { method: data.method, page, limit }
})
});
const result = await res.json();
if (result.error) {
toast.error(result.error);
} else {
subscribers = result.subscribers;
total = result.total;
totalPages = result.totalPages;
}
} catch (error) {
console.error("Error loading subscribers:", error);
toast.error("Failed to load subscribers");
} finally {
loading = false;
}
}
function handlePageChange(newPage: number) {
page = newPage;
loadSubscribers();
}
// Initial load
$effect(() => {
loadSubscribers();
});
// Derived
let MethodIcon = $derived(getMethodIcon(data.method));
</script>
<div class="container mx-auto space-y-6 py-6">
<!-- Header -->
<div class="flex items-center gap-4">
<a href="/manage/app/subscriptions" class="hover:bg-muted rounded-lg p-2 transition-colors">
<ChevronLeftIcon class="size-5" />
</a>
<div class="flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<MethodIcon class="text-primary size-6" />
</div>
<div>
<h1 class="text-2xl font-bold">{methodLabels[data.method]} Subscribers</h1>
<p class="text-muted-foreground text-sm">
{total} subscriber{total !== 1 ? "s" : ""} using {methodLabels[data.method].toLowerCase()} notifications
</p>
</div>
</div>
</div>
{#if loading}
<div class="flex h-96 items-center justify-center">
<Spinner class="size-8" />
</div>
{:else if subscribers.length === 0}
<Card.Root>
<Card.Content class="flex flex-col items-center justify-center py-12">
<UsersIcon class="text-muted-foreground mb-4 size-12" />
<h3 class="mb-2 text-lg font-medium">No {methodLabels[data.method]} Subscribers</h3>
<p class="text-muted-foreground mb-4 text-center text-sm">
No users have subscribed via {methodLabels[data.method].toLowerCase()} yet.
</p>
<a href="/manage/app/subscriptions">
<Button variant="outline">
<ChevronLeftIcon class="mr-2 size-4" />
Back to Subscriptions
</Button>
</a>
</Card.Content>
</Card.Root>
{:else}
<Card.Root>
<Card.Content class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Subscriber</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Subscribed Events</Table.Head>
<Table.Head>Subscriptions</Table.Head>
<Table.Head>Joined</Table.Head>
<Table.Head></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each subscribers as subscriber}
<Table.Row>
<Table.Cell>
<div class="flex items-center gap-2">
<div class="bg-primary/10 rounded p-1.5">
<MethodIcon class="text-primary size-4" />
</div>
<span class="max-w-[200px] truncate font-medium" title={subscriber.subscriber_send}>
{subscriber.subscriber_send}
</span>
</div>
</Table.Cell>
<Table.Cell>
<Badge variant={subscriber.subscriber_status === "ACTIVE" ? "default" : "secondary"}>
{subscriber.subscriber_status}
</Badge>
</Table.Cell>
<Table.Cell>
<div class="flex flex-wrap gap-1">
{#each subscriber.event_types as eventType}
<Badge variant="outline" class="text-xs">
{eventLabels[eventType] || eventType}
</Badge>
{/each}
{#if subscriber.event_types.length === 0}
<span class="text-muted-foreground text-sm">None</span>
{/if}
</div>
</Table.Cell>
<Table.Cell>
<span class="font-medium">{subscriber.subscription_count}</span>
</Table.Cell>
<Table.Cell>
<span class="text-muted-foreground text-sm">
{format(new Date(subscriber.created_at), "MMM d, yyyy")}
</span>
</Table.Cell>
<Table.Cell>
<a href="/manage/app/subscriptions/{data.method}/{subscriber.id}">
<Button variant="ghost" size="sm">
View
<ChevronRightIcon class="ml-1 size-4" />
</Button>
</a>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-center gap-4">
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => handlePageChange(page - 1)}>
<ChevronLeftIcon class="mr-1 size-4" />
Previous
</Button>
<span class="text-muted-foreground text-sm">
Page {page} of {totalPages}
</span>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => handlePageChange(page + 1)}>
Next
<ChevronRightIcon class="ml-1 size-4" />
</Button>
</div>
{/if}
{/if}
</div>
@@ -1,22 +0,0 @@
import type { PageServerLoad } from "./$types";
import { error } from "@sveltejs/kit";
const validMethods = ["email", "webhook", "slack", "discord"];
export const load: PageServerLoad = async ({ params }) => {
const { method, id } = params;
if (!validMethods.includes(method)) {
throw error(404, "Invalid method");
}
const subscriberId = parseInt(id, 10);
if (isNaN(subscriberId)) {
throw error(404, "Invalid subscriber ID");
}
return {
method,
subscriberId,
};
};
@@ -1,407 +0,0 @@
<script lang="ts">
import * as Card from "$lib/components/ui/card/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import * as Alert from "$lib/components/ui/alert/index.js";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import MailIcon from "@lucide/svelte/icons/mail";
import WebhookIcon from "@lucide/svelte/icons/webhook";
import HashIcon from "@lucide/svelte/icons/hash";
import MessageSquareIcon from "@lucide/svelte/icons/message-square";
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import TrashIcon from "@lucide/svelte/icons/trash-2";
import AlertCircleIcon from "@lucide/svelte/icons/alert-circle";
import WrenchIcon from "@lucide/svelte/icons/wrench";
import ActivityIcon from "@lucide/svelte/icons/activity";
import UserIcon from "@lucide/svelte/icons/user";
import { toast } from "svelte-sonner";
import { format } from "date-fns";
interface PageData {
method: "email" | "webhook" | "slack" | "discord";
subscriberId: number;
}
interface SubscriberRecord {
id: number;
subscriber_send: string;
subscriber_meta: string | null;
subscriber_type: string;
subscriber_status: string;
created_at: string;
updated_at: string;
}
interface UserSubscriptionRecord {
id: number;
subscriber_id: number;
subscription_method: string;
event_type: string;
entity_type: string | null;
entity_id: string | null;
status: string;
created_at: string;
updated_at: string;
}
let { data }: { data: PageData } = $props();
// State
let loading = $state(true);
let subscriber = $state<SubscriberRecord | null>(null);
let subscriptions = $state<UserSubscriptionRecord[]>([]);
let deleteDialogOpen = $state(false);
let subscriptionToDelete = $state<UserSubscriptionRecord | null>(null);
let deleting = $state(false);
const methodLabels: Record<string, string> = {
email: "Email",
webhook: "Webhook",
slack: "Slack",
discord: "Discord"
};
const eventLabels: Record<string, string> = {
incidentUpdatesAll: "Incident Updates",
maintenanceUpdatesAll: "Maintenance Updates",
monitorUpdatesAll: "Monitor Updates"
};
const entityLabels: Record<string, string> = {
monitor: "Monitor",
incident: "Incident",
maintenance: "Maintenance"
};
function getMethodIcon(method: string) {
switch (method) {
case "email":
return MailIcon;
case "webhook":
return WebhookIcon;
case "slack":
return HashIcon;
case "discord":
return MessageSquareIcon;
default:
return MailIcon;
}
}
function getEventIcon(eventType: string) {
switch (eventType) {
case "incidentUpdatesAll":
return AlertCircleIcon;
case "maintenanceUpdatesAll":
return WrenchIcon;
case "monitorUpdatesAll":
return ActivityIcon;
default:
return AlertCircleIcon;
}
}
function getEventColorClass(eventType: string): string {
switch (eventType) {
case "incidentUpdatesAll":
return "text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/30";
case "maintenanceUpdatesAll":
return "text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/30";
case "monitorUpdatesAll":
return "text-blue-600 dark:text-blue-400 bg-blue-100 dark:bg-blue-900/30";
default:
return "text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-900/30";
}
}
async function loadSubscriberWithSubscriptions() {
loading = true;
try {
const res = await fetch("/manage/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "getSubscriberWithSubscriptions",
data: { subscriberId: data.subscriberId, method: data.method }
})
});
const result = await res.json();
if (result.error) {
toast.error(result.error);
} else {
subscriber = result.subscriber;
subscriptions = result.subscriptions;
}
} catch (error) {
console.error("Error loading subscriber:", error);
toast.error("Failed to load subscriber details");
} finally {
loading = false;
}
}
function openDeleteDialog(subscription: UserSubscriptionRecord) {
subscriptionToDelete = subscription;
deleteDialogOpen = true;
}
async function deleteSubscription() {
if (!subscriptionToDelete) return;
deleting = true;
try {
const res = await fetch("/manage/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "deleteUserSubscription",
data: { subscriptionId: subscriptionToDelete.id }
})
});
const result = await res.json();
if (result.error) {
toast.error(result.error);
} else {
toast.success("Subscription deleted successfully");
subscriptions = subscriptions.filter((s) => s.id !== subscriptionToDelete?.id);
deleteDialogOpen = false;
subscriptionToDelete = null;
}
} catch (error) {
console.error("Error deleting subscription:", error);
toast.error("Failed to delete subscription");
} finally {
deleting = false;
}
}
// Initial load
$effect(() => {
loadSubscriberWithSubscriptions();
});
// Derived
let MethodIcon = $derived(getMethodIcon(data.method));
// Group subscriptions by event type
let subscriptionsByEvent = $derived(() => {
const grouped: Record<string, UserSubscriptionRecord[]> = {};
for (const sub of subscriptions) {
if (!grouped[sub.event_type]) {
grouped[sub.event_type] = [];
}
grouped[sub.event_type].push(sub);
}
return grouped;
});
</script>
<div class="container mx-auto space-y-6 py-6">
<!-- Header -->
<div class="flex items-center gap-4">
<a href="/manage/app/subscriptions/{data.method}" class="hover:bg-muted rounded-lg p-2 transition-colors">
<ChevronLeftIcon class="size-5" />
</a>
<div class="flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<MethodIcon class="text-primary size-6" />
</div>
<div>
<h1 class="text-2xl font-bold">Subscriber Details</h1>
<p class="text-muted-foreground text-sm">
{methodLabels[data.method]} subscriber #{data.subscriberId}
</p>
</div>
</div>
</div>
{#if loading}
<div class="flex h-96 items-center justify-center">
<Spinner class="size-8" />
</div>
{:else if !subscriber}
<Alert.Root variant="destructive">
<Alert.Title>Subscriber Not Found</Alert.Title>
<Alert.Description>The subscriber you're looking for doesn't exist or has been deleted.</Alert.Description>
</Alert.Root>
{:else}
<!-- Subscriber Info Card -->
<Card.Root>
<Card.Header>
<div class="flex items-center gap-4">
<div class="bg-primary/10 rounded-full p-3">
<UserIcon class="text-primary size-6" />
</div>
<div class="flex-1">
<Card.Title class="flex items-center gap-2">
{subscriber.subscriber_send}
<Badge variant={subscriber.subscriber_status === "ACTIVE" ? "default" : "secondary"}>
{subscriber.subscriber_status}
</Badge>
</Card.Title>
<Card.Description>
{#if subscriber.subscriber_meta}
User: {subscriber.subscriber_meta}
{/if}
Subscribed on {format(new Date(subscriber.created_at), "MMMM d, yyyy 'at' h:mm a")}
</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content>
<div class="grid gap-4 md:grid-cols-3">
<div>
<span class="text-muted-foreground text-sm">Method</span>
<div class="flex items-center gap-2 font-medium">
<MethodIcon class="text-primary size-4" />
{methodLabels[data.method]}
</div>
</div>
<div>
<span class="text-muted-foreground text-sm">Total Subscriptions</span>
<div class="font-medium">{subscriptions.length}</div>
</div>
<div>
<span class="text-muted-foreground text-sm">Type</span>
<div class="font-medium">{subscriber.subscriber_type}</div>
</div>
</div>
</Card.Content>
</Card.Root>
<!-- Subscriptions by Event Type -->
<div class="space-y-4">
<h2 class="text-lg font-semibold">Subscriptions</h2>
{#if subscriptions.length === 0}
<Card.Root>
<Card.Content class="flex flex-col items-center justify-center py-12">
<ActivityIcon class="text-muted-foreground mb-4 size-12" />
<h3 class="mb-2 text-lg font-medium">No Active Subscriptions</h3>
<p class="text-muted-foreground text-center text-sm">
This subscriber doesn't have any active subscriptions via {methodLabels[data.method].toLowerCase()}.
</p>
</Card.Content>
</Card.Root>
{:else}
{#each Object.entries(subscriptionsByEvent()) as [eventType, subs]}
{@const EventIcon = getEventIcon(eventType)}
<Card.Root>
<Card.Header class="pb-3">
<div class="flex items-center gap-3">
<div class="rounded-lg p-2 {getEventColorClass(eventType)}">
<EventIcon class="size-5" />
</div>
<div>
<Card.Title class="text-base">{eventLabels[eventType] || eventType}</Card.Title>
<Card.Description>
{subs.length} subscription{subs.length !== 1 ? "s" : ""}
</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content class="pt-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Scope</Table.Head>
<Table.Head>Entity ID</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Subscribed</Table.Head>
<Table.Head></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each subs as sub}
<Table.Row>
<Table.Cell>
{#if sub.entity_type}
<Badge variant="outline">
{entityLabels[sub.entity_type] || sub.entity_type}
</Badge>
{:else}
<Badge variant="secondary">All</Badge>
{/if}
</Table.Cell>
<Table.Cell>
{#if sub.entity_id}
<code class="bg-muted rounded px-2 py-1 text-sm">{sub.entity_id}</code>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell>
<Badge variant={sub.status === "ACTIVE" ? "default" : "secondary"}>
{sub.status}
</Badge>
</Table.Cell>
<Table.Cell>
<span class="text-muted-foreground text-sm">
{format(new Date(sub.created_at), "MMM d, yyyy")}
</span>
</Table.Cell>
<Table.Cell>
<Button
variant="ghost"
size="sm"
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
onclick={() => openDeleteDialog(sub)}
>
<TrashIcon class="size-4" />
</Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>
{/each}
{/if}
</div>
{/if}
</div>
<!-- Delete Confirmation Dialog -->
<Dialog.Root bind:open={deleteDialogOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Delete Subscription</Dialog.Title>
<Dialog.Description>
Are you sure you want to delete this subscription? The subscriber will no longer receive notifications for this
event.
</Dialog.Description>
</Dialog.Header>
{#if subscriptionToDelete}
<div class="bg-muted rounded-lg p-4">
<div class="grid gap-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Event:</span>
<span class="font-medium"
>{eventLabels[subscriptionToDelete.event_type] || subscriptionToDelete.event_type}</span
>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Scope:</span>
<span class="font-medium">
{subscriptionToDelete.entity_type
? `${entityLabels[subscriptionToDelete.entity_type] || subscriptionToDelete.entity_type}: ${subscriptionToDelete.entity_id}`
: "All"}
</span>
</div>
</div>
</div>
{/if}
<Dialog.Footer>
<Button variant="outline" onclick={() => (deleteDialogOpen = false)}>Cancel</Button>
<Button variant="destructive" onclick={deleteSubscription} disabled={deleting}>
{#if deleting}
<Spinner class="mr-2 size-4" />
{/if}
Delete Subscription
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -4,65 +4,81 @@
import { Badge } from "$lib/components/ui/badge/index.js";
import * as Card from "$lib/components/ui/card/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import PlusIcon from "@lucide/svelte/icons/plus";
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import PencilIcon from "@lucide/svelte/icons/pencil";
import { Input } from "$lib/components/ui/input/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import SaveIcon from "@lucide/svelte/icons/save";
import FileTextIcon from "@lucide/svelte/icons/file-text";
import WebhookIcon from "@lucide/svelte/icons/webhook";
import MailIcon from "@lucide/svelte/icons/mail";
import MessageSquareIcon from "@lucide/svelte/icons/message-square";
import HashIcon from "@lucide/svelte/icons/hash";
import TrashIcon from "@lucide/svelte/icons/trash";
import Loader from "@lucide/svelte/icons/loader";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import { mode } from "mode-watcher";
import CodeMirror from "svelte-codemirror-editor";
import { html } from "@codemirror/lang-html";
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
import { onMount } from "svelte";
// Types
interface Template {
id: number;
template_name: string;
template_type: "EMAIL" | "WEBHOOK" | "SLACK" | "DISCORD";
template_usage: "ALERT" | "SUBSCRIPTION";
template_json: string;
created_at: string;
updated_at: string;
interface GeneralEmailTemplate {
template_id: string;
template_subject: string | null;
template_html_body: string | null;
template_text_body: string | null;
}
// State
let loading = $state(true);
let templates = $state<Template[]>([]);
let totalPages = $state(0);
let totalCount = $state(0);
let pageNo = $state(1);
let typeFilter = $state("ALL");
let usageFilter = $state("ALL");
let deleteDialogOpen = $state(false);
let templateToDelete = $state<Template | null>(null);
let deleting = $state(false);
const limit = 10;
let saving = $state(false);
let templates = $state<GeneralEmailTemplate[]>([]);
let selectedTemplateId = $state<string>("");
// Fetch templates
async function fetchData() {
// Form state for selected template
let templateSubject = $state("");
let templateHtmlBody = $state("");
let templateTextBody = $state("");
// Derived: selected template
let selectedTemplate = $derived(templates.find((t) => t.template_id === selectedTemplateId));
// Fetch templates on mount
onMount(() => {
fetchTemplates();
});
// Handle template selection change
function handleTemplateSelect(templateId: string) {
selectedTemplateId = templateId;
const template = templates.find((t) => t.template_id === templateId);
if (template) {
templateSubject = template.template_subject || "";
templateHtmlBody = template.template_html_body || "";
templateTextBody = template.template_text_body || "";
} else {
templateSubject = "";
templateHtmlBody = "";
templateTextBody = "";
}
}
async function fetchTemplates() {
loading = true;
try {
const filter: Record<string, string> = {};
if (typeFilter !== "ALL") filter.template_type = typeFilter;
if (usageFilter !== "ALL") filter.template_usage = usageFilter;
const response = await fetch("/manage/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "getTemplates",
data: filter
action: "getGeneralEmailTemplates",
data: {}
})
});
const result = await response.json();
if (!result.error) {
if (result.error) {
toast.error(result.error);
} else {
templates = result;
totalCount = templates.length;
totalPages = Math.ceil(totalCount / limit);
// Auto-select first template if available
if (templates.length > 0 && !selectedTemplateId) {
handleTemplateSelect(templates[0].template_id);
}
}
} catch (error) {
console.error("Error fetching templates:", error);
@@ -72,214 +88,169 @@
}
}
function handleTypeChange(value: string | undefined) {
if (value) {
typeFilter = value;
pageNo = 1;
fetchData();
async function updateTemplate() {
if (!selectedTemplateId) {
toast.error("Please select a template");
return;
}
}
function handleUsageChange(value: string | undefined) {
if (value) {
usageFilter = value;
pageNo = 1;
fetchData();
}
}
function goToPage(page: number) {
pageNo = page;
}
function getTemplateIcon(type: string) {
switch (type) {
case "WEBHOOK":
return WebhookIcon;
case "EMAIL":
return MailIcon;
case "SLACK":
return HashIcon;
case "DISCORD":
return MessageSquareIcon;
default:
return FileTextIcon;
}
}
function confirmDelete(template: Template) {
templateToDelete = template;
deleteDialogOpen = true;
}
async function deleteTemplate() {
if (!templateToDelete) return;
deleting = true;
saving = true;
try {
const response = await fetch("/manage/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "deleteTemplate",
data: { id: templateToDelete.id }
action: "updateGeneralEmailTemplate",
data: {
templateId: selectedTemplateId,
template_subject: templateSubject,
template_html_body: templateHtmlBody,
template_text_body: templateTextBody
}
})
});
const result = await response.json();
if (result.error) {
toast.error(result.error);
} else {
toast.success("Template deleted successfully");
await fetchData();
toast.success("Template updated successfully");
// Update local state
const index = templates.findIndex((t) => t.template_id === selectedTemplateId);
if (index !== -1) {
templates[index] = {
...templates[index],
template_subject: templateSubject,
template_html_body: templateHtmlBody,
template_text_body: templateTextBody
};
}
}
} catch (error) {
toast.error("Failed to delete template");
console.error("Error updating template:", error);
toast.error("Failed to update template");
} finally {
deleting = false;
deleteDialogOpen = false;
templateToDelete = null;
saving = false;
}
}
// Paginated templates
const paginatedTemplates = $derived(templates.slice((pageNo - 1) * limit, pageNo * limit));
$effect(() => {
fetchData();
});
function formatTemplateId(id: string): string {
// Convert snake_case or kebab-case to Title Case
return id.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
</script>
<div class="container mx-auto space-y-6 py-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<FileTextIcon class="text-muted-foreground size-6" />
<div>
<h1 class="text-2xl font-bold">Templates</h1>
<p class="text-muted-foreground text-sm">Manage notification templates for alerts and subscriptions</p>
</div>
</div>
<div class="flex items-center gap-3">
<Select.Root type="single" value={typeFilter} onValueChange={handleTypeChange}>
<Select.Trigger class="w-36">
{typeFilter === "ALL" ? "All Types" : typeFilter}
</Select.Trigger>
<Select.Content>
<Select.Item value="ALL">All Types</Select.Item>
<Select.Item value="EMAIL">Email</Select.Item>
<Select.Item value="WEBHOOK">Webhook</Select.Item>
<Select.Item value="SLACK">Slack</Select.Item>
<Select.Item value="DISCORD">Discord</Select.Item>
</Select.Content>
</Select.Root>
<Select.Root type="single" value={usageFilter} onValueChange={handleUsageChange}>
<Select.Trigger class="w-36">
{usageFilter === "ALL" ? "All Usage" : usageFilter}
</Select.Trigger>
<Select.Content>
<Select.Item value="ALL">All Usage</Select.Item>
<Select.Item value="ALERT">Alert</Select.Item>
<Select.Item value="SUBSCRIPTION">Subscription</Select.Item>
</Select.Content>
</Select.Root>
{#if loading}
<Spinner class="size-5" />
{/if}
<Button onclick={() => goto("/manage/app/templates/new")}>
<PlusIcon class="mr-2 size-4" />
New Template
</Button>
<div>
<h1 class="text-2xl font-bold tracking-tight">Email Templates</h1>
<p class="text-muted-foreground">Manage your email notification templates</p>
</div>
</div>
<!-- Templates List -->
<div class="grid gap-4">
{#if paginatedTemplates.length === 0 && !loading}
<Card.Root>
<Card.Content class="text-muted-foreground py-8 text-center">No templates found</Card.Content>
</Card.Root>
{:else}
{#each paginatedTemplates as template}
{@const TemplateIcon = getTemplateIcon(template.template_type)}
<Card.Root class="hover:bg-muted/30 transition-colors">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<div class="flex items-center gap-3">
<div class="bg-muted flex size-10 items-center justify-center rounded-lg">
<TemplateIcon class="size-5" />
</div>
<div>
<Card.Title class="text-base">{template.template_name}</Card.Title>
<p class="text-muted-foreground text-xs">
Created: {new Date(template.created_at).toLocaleDateString()}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<Badge variant="outline">{template.template_type}</Badge>
<Badge variant={template.template_usage === "ALERT" ? "default" : "secondary"}>
{template.template_usage}
</Badge>
<Button variant="ghost" size="icon" onclick={() => goto(`/manage/app/templates/${template.id}`)}>
<PencilIcon class="size-4" />
</Button>
<Button variant="ghost" size="icon" onclick={() => confirmDelete(template)}>
<TrashIcon class="text-destructive size-4" />
</Button>
</div>
</Card.Header>
</Card.Root>
{/each}
{/if}
</div>
<!-- Pagination -->
{#if totalCount > limit}
{@const startItem = (pageNo - 1) * limit + 1}
{@const endItem = Math.min(pageNo * limit, totalCount)}
<div class="flex items-center justify-between">
<span class="text-muted-foreground text-sm">Showing {startItem}-{endItem} of {totalCount}</span>
{#if totalPages > 1}
<div class="flex items-center gap-2">
<Button variant="outline" size="icon" disabled={pageNo === 1} onclick={() => goToPage(pageNo - 1)}>
<ChevronLeftIcon class="size-4" />
</Button>
<div class="flex items-center gap-1">
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
{#if page === 1 || page === totalPages || (page >= pageNo - 1 && page <= pageNo + 1)}
<Button variant={page === pageNo ? "default" : "ghost"} size="sm" onclick={() => goToPage(page)}>
{page}
</Button>
{:else if page === pageNo - 2 || page === pageNo + 2}
<span class="text-muted-foreground px-1">...</span>
{#if loading}
<div class="flex items-center justify-center py-12">
<Spinner class="size-8" />
</div>
{:else if templates.length === 0}
<Card.Root>
<Card.Content class="flex flex-col items-center justify-center py-12">
<MailIcon class="text-muted-foreground mb-4 size-12" />
<h3 class="mb-2 text-lg font-semibold">No Templates Found</h3>
<p class="text-muted-foreground text-center">There are no email templates configured yet.</p>
</Card.Content>
</Card.Root>
{:else}
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<FileTextIcon class="size-5" />
Edit Template
</Card.Title>
<Card.Description>Select a template from the dropdown to view and edit its content</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<!-- Template Selector -->
<div class="space-y-2">
<Label for="template-select">Select Template</Label>
<Select.Root
type="single"
value={selectedTemplateId}
onValueChange={(value) => {
if (value) handleTemplateSelect(value);
}}
>
<Select.Trigger class="w-full md:w-[400px]">
{#if selectedTemplateId}
{formatTemplateId(selectedTemplateId)}
{:else}
Select a template...
{/if}
{/each}
</div>
<Button variant="outline" size="icon" disabled={pageNo === totalPages} onclick={() => goToPage(pageNo + 1)}>
<ChevronRightIcon class="size-4" />
</Button>
</Select.Trigger>
<Select.Content>
{#each templates as template (template.template_id)}
<Select.Item value={template.template_id}>
{formatTemplateId(template.template_id)}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{#if selectedTemplateId}
<!-- Subject -->
<div class="space-y-2">
<Label for="template-subject">Subject</Label>
<Input id="template-subject" bind:value={templateSubject} placeholder="Email subject line" />
<p class="text-muted-foreground text-xs">
The subject line for the email. You can use Mustache variables like <code class="bg-muted rounded px-1"
>{"{{variable}}"}</code
>
</p>
</div>
<!-- HTML Body -->
<div class="space-y-2">
<Label>HTML Body</Label>
<p class="text-muted-foreground text-xs">
The HTML content of the email. Use Mustache variables for dynamic content.
</p>
<div class="overflow-hidden rounded-md border">
<CodeMirror
bind:value={templateHtmlBody}
lang={html()}
theme={mode.current === "dark" ? githubDark : githubLight}
styles={{ "&": { width: "100%", height: "400px" } }}
/>
</div>
</div>
<!-- Text Body -->
<div class="space-y-2">
<Label for="template-text-body">Text Body</Label>
<p class="text-muted-foreground text-xs">
Plain text version of the email for clients that don't support HTML
</p>
<Textarea
id="template-text-body"
bind:value={templateTextBody}
placeholder="Plain text email content"
rows={8}
/>
</div>
{/if}
</Card.Content>
{#if selectedTemplateId}
<Card.Footer class="flex justify-end">
<Button onclick={updateTemplate} disabled={saving}>
{#if saving}
<Loader class="mr-2 size-4 animate-spin" />
{:else}
<SaveIcon class="mr-2 size-4" />
{/if}
Update Template
</Button>
</Card.Footer>
{/if}
</div>
</Card.Root>
{/if}
</div>
<!-- Delete Confirmation Dialog -->
<AlertDialog.Root bind:open={deleteDialogOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete Template</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete the template "{templateToDelete?.template_name}"? This action cannot be undone.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={deleteTemplate} disabled={deleting}>
{#if deleting}
<Spinner class="mr-2 size-4" />
{/if}
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -1,475 +0,0 @@
<script lang="ts">
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { Button } from "$lib/components/ui/button/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import * as Card from "$lib/components/ui/card/index.js";
import * as Breadcrumb from "$lib/components/ui/breadcrumb/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import SaveIcon from "@lucide/svelte/icons/save";
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
import FileTextIcon from "@lucide/svelte/icons/file-text";
import WebhookIcon from "@lucide/svelte/icons/webhook";
import MailIcon from "@lucide/svelte/icons/mail";
import MessageSquareIcon from "@lucide/svelte/icons/message-square";
import HashIcon from "@lucide/svelte/icons/hash";
import { toast } from "svelte-sonner";
import { mode } from "mode-watcher";
import { DiscordJSONTemplate, WebhookJSONTemplate, SlackJSONTemplate, EmailHTMLTemplate } from "$lib/anywhere";
import CodeMirror from "svelte-codemirror-editor";
import { json } from "@codemirror/lang-json";
import { html } from "@codemirror/lang-html";
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
// Types
type TemplateType = "EMAIL" | "WEBHOOK" | "SLACK" | "DISCORD";
type TemplateUsage = "ALERT" | "SUBSCRIPTION";
interface EmailTemplateJson {
email_subject: string;
email_body: string;
}
interface WebhookTemplateJson {
webhook_body: string;
}
interface SlackTemplateJson {
slack_body: string;
}
interface DiscordTemplateJson {
discord_body: string;
}
// State
let loading = $state(true);
let saving = $state(false);
let invalidFormMessage = $state("");
// Get template ID from URL params
const templateId = $derived($page.params.template_id);
const isNew = $derived(templateId === "new");
// Form state
let template = $state<{
id: number;
template_name: string;
template_type: TemplateType;
template_usage: TemplateUsage;
email_subject: string;
email_body: string;
webhook_body: string;
slack_body: string;
discord_body: string;
}>({
id: 0,
template_name: "",
template_type: "EMAIL",
template_usage: "ALERT",
email_subject: "",
email_body: EmailHTMLTemplate,
webhook_body: WebhookJSONTemplate,
slack_body: SlackJSONTemplate,
discord_body: DiscordJSONTemplate
});
async function fetchTemplate() {
if (isNew) {
loading = false;
return;
}
loading = true;
try {
const response = await fetch("/manage/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "getTemplateById",
data: { id: parseInt(templateId || "0") }
})
});
const result = await response.json();
if (result.error) {
toast.error(result.error);
goto("/manage/app/templates");
return;
}
const parsedJson = JSON.parse(result.template_json);
template = {
id: result.id,
template_name: result.template_name,
template_type: result.template_type,
template_usage: result.template_usage,
email_subject: parsedJson.email_subject || "",
email_body: parsedJson.email_body || EmailHTMLTemplate,
webhook_body: parsedJson.webhook_body || WebhookJSONTemplate,
slack_body: parsedJson.slack_body || SlackJSONTemplate,
discord_body: parsedJson.discord_body || DiscordJSONTemplate
};
} catch (error) {
console.error("Error fetching template:", error);
toast.error("Failed to load template");
goto("/manage/app/templates");
} finally {
loading = false;
}
}
function getTemplateJson(): string {
switch (template.template_type) {
case "EMAIL":
return JSON.stringify({
email_subject: template.email_subject,
email_body: template.email_body
} as EmailTemplateJson);
case "WEBHOOK":
return JSON.stringify({
webhook_body: template.webhook_body
} as WebhookTemplateJson);
case "SLACK":
return JSON.stringify({
slack_body: template.slack_body
} as SlackTemplateJson);
case "DISCORD":
return JSON.stringify({
discord_body: template.discord_body
} as DiscordTemplateJson);
default:
return "{}";
}
}
async function saveTemplate() {
invalidFormMessage = "";
// Validation
if (!template.template_name.trim()) {
invalidFormMessage = "Template Name is required";
return;
}
if (!template.template_type) {
invalidFormMessage = "Template Type is required";
return;
}
if (!template.template_usage) {
invalidFormMessage = "Template Usage is required";
return;
}
saving = true;
try {
const action = isNew ? "createTemplate" : "updateTemplate";
const data = isNew
? {
template_name: template.template_name,
template_type: template.template_type,
template_usage: template.template_usage,
template_json: getTemplateJson()
}
: {
id: template.id,
template_name: template.template_name,
template_type: template.template_type,
template_usage: template.template_usage,
template_json: getTemplateJson()
};
const response = await fetch("/manage/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, data })
});
const result = await response.json();
if (result.error) {
invalidFormMessage = result.error;
} else {
toast.success(isNew ? "Template created successfully" : "Template updated successfully");
if (isNew) {
goto("/manage/app/templates");
}
}
} catch (error) {
invalidFormMessage = "Failed to save template";
} finally {
saving = false;
}
}
function getTemplateIcon(type: TemplateType) {
switch (type) {
case "WEBHOOK":
return WebhookIcon;
case "EMAIL":
return MailIcon;
case "SLACK":
return HashIcon;
case "DISCORD":
return MessageSquareIcon;
default:
return FileTextIcon;
}
}
$effect(() => {
fetchTemplate();
});
</script>
<div class="container space-y-6 py-6">
<!-- Breadcrumb -->
{#if loading}
<div class="flex h-96 items-center justify-center">
<Spinner class="size-8" />
</div>
{:else}
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item>
<Breadcrumb.Link href="/manage/app/templates">Templates</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator />
<Breadcrumb.Item>
<Breadcrumb.Page>{isNew ? "New Template" : template.template_name}</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Button variant="ghost" size="icon" onclick={() => goto("/manage/app/templates")}>
<ArrowLeftIcon class="size-5" />
</Button>
{#if template.template_type === "WEBHOOK"}
<WebhookIcon class="text-muted-foreground size-6" />
{:else if template.template_type === "EMAIL"}
<MailIcon class="text-muted-foreground size-6" />
{:else if template.template_type === "SLACK"}
<HashIcon class="text-muted-foreground size-6" />
{:else if template.template_type === "DISCORD"}
<MessageSquareIcon class="text-muted-foreground size-6" />
{:else}
<FileTextIcon class="text-muted-foreground size-6" />
{/if}
<div>
<h1 class="text-2xl font-bold">{isNew ? "New Template" : "Edit Template"}</h1>
<p class="text-muted-foreground text-sm">
{isNew ? "Create a new notification template" : `Editing ${template.template_name}`}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<Button onclick={saveTemplate} disabled={saving}>
{#if saving}
<Spinner class="mr-2 size-4" />
{:else}
<SaveIcon class="mr-2 size-4" />
{/if}
{isNew ? "Create Template" : "Save Changes"}
</Button>
</div>
</div>
<!-- Error Message -->
{#if invalidFormMessage}
<div class="bg-destructive/10 text-destructive rounded-lg border p-4">{invalidFormMessage}</div>
{/if}
<!-- Template Type Selection -->
<Card.Root>
<Card.Header>
<Card.Title>Template Type</Card.Title>
<Card.Description>Select the type of notification template</Card.Description>
</Card.Header>
<Card.Content>
<div class="grid grid-cols-4 gap-3">
{#each ["EMAIL", "WEBHOOK", "SLACK", "DISCORD"] as type}
<Button
variant={template.template_type === type ? "default" : "outline"}
class="h-20 flex-col gap-2"
onclick={() => (template.template_type = type as TemplateType)}
>
{#if type === "WEBHOOK"}
<WebhookIcon class="size-6" />
{:else if type === "SLACK"}
<HashIcon class="size-6" />
{:else if type === "DISCORD"}
<MessageSquareIcon class="size-6" />
{:else if type === "EMAIL"}
<MailIcon class="size-6" />
{:else}
<FileTextIcon class="size-6" />
{/if}
<span class="capitalize">{type.toLowerCase()}</span>
</Button>
{/each}
</div>
</Card.Content>
</Card.Root>
<!-- Basic Info -->
<Card.Root>
<Card.Header>
<Card.Title>Basic Information</Card.Title>
<Card.Description>Name and usage for this template</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<!-- Name -->
<div class="space-y-2">
<Label for="template-name">
Template Name <span class="text-destructive">*</span>
</Label>
<Input id="template-name" bind:value={template.template_name} placeholder="My Template" />
</div>
<!-- Usage -->
<div class="space-y-2">
<Label>Template Usage <span class="text-destructive">*</span></Label>
<div class="flex gap-3">
<Button
variant={template.template_usage === "ALERT" ? "default" : "outline"}
onclick={() => (template.template_usage = "ALERT")}
>
Alert
</Button>
<Button
variant={template.template_usage === "SUBSCRIPTION" ? "default" : "outline"}
onclick={() => (template.template_usage = "SUBSCRIPTION")}
>
Subscription
</Button>
</div>
<p class="text-muted-foreground text-xs">
{template.template_usage === "ALERT"
? "Used for alert notifications sent to triggers"
: "Used for subscription notifications sent to subscribers"}
</p>
</div>
</Card.Content>
</Card.Root>
<!-- Email Template -->
{#if template.template_type === "EMAIL"}
<Card.Root>
<Card.Header>
<Card.Title>Email Template</Card.Title>
<Card.Description>Configure the email subject and body</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<!-- Subject -->
<div class="space-y-2">
<Label for="email-subject">Email Subject</Label>
<Input
id="email-subject"
bind:value={template.email_subject}
placeholder={"Alert: {{alert_name}} is {{status}}"}
/>
<p class="text-muted-foreground text-xs">
Use Mustache variables like <code class="bg-muted rounded px-1">{"{{variable}}"}</code>. Available:
alert_name, status, severity, description, etc.
</p>
</div>
<!-- Body -->
<div class="space-y-2">
<Label>Email Body (HTML)</Label>
<p class="text-muted-foreground text-xs">
Use Mustache variables. Available: site_name, logo_url, alert_name, status, is_triggered, is_resolved,
description, metric, severity, id, current_value, threshold, source, action_text, action_url
</p>
<div class="overflow-hidden rounded-md border">
<CodeMirror
bind:value={template.email_body}
lang={html()}
theme={mode.current === "dark" ? githubDark : githubLight}
styles={{ "&": { width: "100%", height: "400px" } }}
/>
</div>
</div>
</Card.Content>
</Card.Root>
{/if}
<!-- Webhook Template -->
{#if template.template_type === "WEBHOOK"}
<Card.Root>
<Card.Header>
<Card.Title>Webhook Template</Card.Title>
<Card.Description>Configure the webhook payload template</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<p class="text-muted-foreground text-xs">
Use Mustache variables like <code class="bg-muted rounded px-1">{"{{variable}}"}</code>. Available: id,
alert_name, severity, status, source, timestamp, description, metric, current_value, threshold, action_text,
action_url
</p>
<div class="space-y-2">
<Label>Webhook Body Template</Label>
<textarea
bind:value={template.webhook_body}
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[400px] w-full rounded-md border px-3 py-2 font-mono text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Enter webhook body template"
></textarea>
</div>
</Card.Content>
</Card.Root>
{/if}
<!-- Slack Template -->
{#if template.template_type === "SLACK"}
<Card.Root>
<Card.Header>
<Card.Title>Slack Template</Card.Title>
<Card.Description>Configure the Slack payload template</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<p class="text-muted-foreground text-xs">
Use Mustache variables like <code class="bg-muted rounded px-1">{"{{variable}}"}}</code>. Available:
site_name, logo_url, alert_name, status, is_triggered, is_resolved, description, metric, severity, id,
current_value, threshold, source, action_text, action_url
</p>
<div class="space-y-2">
<Label>Slack Body Template</Label>
<textarea
bind:value={template.slack_body}
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[400px] w-full rounded-md border px-3 py-2 font-mono text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Enter Slack body template"
></textarea>
</div>
</Card.Content>
</Card.Root>
{/if}
<!-- Discord Template -->
{#if template.template_type === "DISCORD"}
<Card.Root>
<Card.Header>
<Card.Title>Discord Template</Card.Title>
<Card.Description>Configure the Discord payload template</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<p class="text-muted-foreground text-xs">
Use Mustache variables like <code class="bg-muted rounded px-1">{"{{variable}}"}}</code>. Available:
site_name, logo_url, alert_name, status, is_triggered, is_resolved, description, metric, severity, id,
current_value, threshold, source, action_text, action_url
</p>
<div class="space-y-2">
<Label>Discord Body Template</Label>
<textarea
bind:value={template.discord_body}
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[400px] w-full rounded-md border px-3 py-2 font-mono text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Enter Discord body template"
></textarea>
</div>
</Card.Content>
</Card.Root>
{/if}
{/if}
</div>
@@ -0,0 +1,18 @@
import { error } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
import emailTemplate from "$lib/server/templates/email_alert_template";
import webhookTemplate from "$lib/server/templates/webhook_alert_template";
import slackTemplate from "$lib/server/templates/slack_alert_template";
import discordTemplate from "$lib/server/templates/discord_alert_template";
export const load: PageServerLoad = async ({ params }) => {
return {
...{
trigger_id: params.trigger_id,
email_template: emailTemplate,
webhook_template: webhookTemplate,
slack_template: slackTemplate,
discord_template: discordTemplate,
},
};
};
@@ -1,62 +1,45 @@
<script lang="ts">
import { page } from "$app/stores";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { Button } from "$lib/components/ui/button/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import * as Card from "$lib/components/ui/card/index.js";
import * as Breadcrumb from "$lib/components/ui/breadcrumb/index.js";
import * as RadioGroup from "$lib/components/ui/radio-group/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Switch } from "$lib/components/ui/switch/index.js";
import SaveIcon from "@lucide/svelte/icons/save";
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
import PlusIcon from "@lucide/svelte/icons/plus";
import XIcon from "@lucide/svelte/icons/x";
import WebhookIcon from "@lucide/svelte/icons/webhook";
import MailIcon from "@lucide/svelte/icons/mail";
import ZapIcon from "@lucide/svelte/icons/zap";
import Loader from "@lucide/svelte/icons/loader";
import CheckIcon from "@lucide/svelte/icons/check";
import { toast } from "svelte-sonner";
import { mode } from "mode-watcher";
import { IsValidURL } from "$lib/clientTools";
import CodeMirror from "svelte-codemirror-editor";
import { json } from "@codemirror/lang-json";
import { html } from "@codemirror/lang-html";
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
import type { TriggerMeta } from "$lib/server/types/db";
let { data } = page;
// Types
interface TriggerHeader {
key: string;
value: string;
}
interface TriggerMeta {
url: string;
headers: TriggerHeader[];
to: string;
from: string;
email_type: "resend" | "smtp";
smtp_host: string;
smtp_port: string;
smtp_user: string;
smtp_pass: string;
smtp_secure: boolean;
}
interface TemplateRecord {
id: number;
template_name: string;
template_type: string;
template_usage: string;
template_json: string;
created_at: string;
updated_at: string;
}
// State
let loading = $state(true);
let saving = $state(false);
let testing = $state<"idle" | "loading" | "success" | "error">("idle");
let invalidFormMessage = $state("");
let templates = $state<TemplateRecord[]>([]);
let loadingTemplates = $state(false);
// Get trigger ID from URL params
const triggerId = $derived($page.params.trigger_id);
const triggerId = $derived(data.trigger_id);
const isNew = $derived(triggerId === "new");
// Form state
@@ -66,7 +49,6 @@
trigger_type: string;
trigger_desc: string;
trigger_status: string;
template_id: number | null;
trigger_meta: TriggerMeta;
}>({
id: 0,
@@ -74,58 +56,22 @@
trigger_type: "webhook",
trigger_desc: "",
trigger_status: "ACTIVE",
template_id: null,
trigger_meta: {
url: "",
headers: [],
to: "",
from: "",
email_type: "resend",
smtp_host: "",
smtp_port: "",
smtp_user: "",
smtp_pass: "",
smtp_secure: false
webhook_body: data.webhook_template.webhook_body,
discord_body: data.discord_template.discord_body,
slack_body: data.slack_template.slack_body,
email_body: data.email_template.email_body,
email_subject: data.email_template.email_subject
}
});
const triggerTypes = [
{ value: "webhook", label: "Webhook", icon: "/webhooks.svg" },
{ value: "discord", label: "Discord", icon: "/discord.svg" },
{ value: "slack", label: "Slack", icon: "/slack.svg" },
{ value: "email", label: "Email", icon: "/email.png" }
];
async function fetchTemplates(triggerType: string) {
loadingTemplates = true;
try {
const templateType = triggerType.toUpperCase();
const response = await fetch("/manage/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "getTemplatesByTypeAndUsage",
data: { template_type: templateType, template_usages: ["ALERT", "SUBSCRIPTION"] }
})
});
const result = await response.json();
if (result.error) {
templates = [];
} else {
templates = result;
}
} catch (error) {
console.error("Error fetching templates:", error);
templates = [];
} finally {
loadingTemplates = false;
}
}
async function fetchTrigger() {
if (isNew) {
loading = false;
await fetchTemplates(trigger.trigger_type);
return;
}
@@ -149,21 +95,18 @@
trigger_type: foundTrigger.trigger_type,
trigger_desc: foundTrigger.trigger_desc || "",
trigger_status: foundTrigger.trigger_status || "ACTIVE",
template_id: foundTrigger.template_id || null,
trigger_meta: {
url: meta.url || "",
headers: meta.headers || [],
to: meta.to || "",
from: meta.from || "",
email_type: meta.email_type || "resend",
smtp_host: meta.smtp_host || "",
smtp_port: meta.smtp_port || "",
smtp_user: meta.smtp_user || "",
smtp_pass: meta.smtp_pass || "",
smtp_secure: meta.smtp_secure || false
webhook_body: meta.webhook_body || data.webhook_template.webhook_body,
discord_body: meta.discord_body || data.discord_template.discord_body,
slack_body: meta.slack_body || data.slack_template.slack_body,
email_body: meta.email_body || data.email_template.email_body,
email_subject: meta.email_subject || data.email_template.email_subject
}
};
await fetchTemplates(trigger.trigger_type);
} else {
toast.error("Trigger not found");
goto("/manage/app/triggers");
@@ -176,6 +119,7 @@
}
}
// Validation
function validateNameEmailPattern(input: string): { isValid: boolean; name: string | null; email: string | null } {
const pattern = /^([\w\s]+)\s*<([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>$/;
const match = input.match(pattern);
@@ -188,6 +132,7 @@
async function saveTrigger() {
invalidFormMessage = "";
// Validation
if (!trigger.name.trim()) {
invalidFormMessage = "Trigger Name is required";
return;
@@ -198,15 +143,6 @@
return;
}
if (trigger.trigger_type === "webhook") {
for (const header of trigger.trigger_meta.headers) {
if (!header.key.trim() || !header.value.trim()) {
invalidFormMessage = "All header keys and values are required";
return;
}
}
}
if (trigger.trigger_type === "email") {
if (!trigger.trigger_meta.to.trim()) {
invalidFormMessage = "To Email Address is required";
@@ -216,27 +152,10 @@
invalidFormMessage = "Invalid Sender. Format: Name <email@example.com>";
return;
}
if (trigger.trigger_meta.email_type === "smtp") {
if (!trigger.trigger_meta.smtp_host.trim()) {
invalidFormMessage = "SMTP Host is required";
return;
}
if (!trigger.trigger_meta.smtp_port.trim()) {
invalidFormMessage = "SMTP Port is required";
return;
}
if (!trigger.trigger_meta.smtp_user.trim()) {
invalidFormMessage = "SMTP User is required";
return;
}
if (!trigger.trigger_meta.smtp_pass.trim()) {
invalidFormMessage = "SMTP Password is required";
return;
}
}
} else {
// URL validation for non-email triggers
if (!trigger.trigger_meta.url.trim()) {
invalidFormMessage = "URL is required";
invalidFormMessage = "Trigger URL is required";
return;
}
if (!IsValidURL(trigger.trigger_meta.url)) {
@@ -258,7 +177,6 @@
trigger_type: trigger.trigger_type,
trigger_status: trigger.trigger_status,
trigger_desc: trigger.trigger_desc,
template_id: trigger.template_id,
trigger_meta: JSON.stringify(trigger.trigger_meta)
}
})
@@ -321,128 +239,113 @@
trigger.trigger_meta.headers = trigger.trigger_meta.headers.filter((_, i) => i !== index);
}
async function handleTriggerTypeChange(value: string | undefined) {
if (!value) return;
trigger.trigger_type = value;
trigger.template_id = null;
await fetchTemplates(value);
}
function handleTemplateChange(value: string | undefined) {
trigger.template_id = value ? parseInt(value) : null;
}
$effect(() => {
fetchTrigger();
});
</script>
<div class="container max-w-2xl space-y-6 py-6">
<div class="container space-y-6 py-6">
<!-- Breadcrumb -->
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item>
<Breadcrumb.Link href="/manage/app">Dashboard</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator />
<Breadcrumb.Item>
<Breadcrumb.Link href="/manage/app/triggers">Triggers</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator />
<Breadcrumb.Item>
<Breadcrumb.Page>{isNew ? "New Trigger" : trigger.name || "Edit Trigger"}</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
{#if loading}
<div class="flex items-center justify-center py-12">
<Spinner class="size-8" />
</div>
{:else}
<!-- Header -->
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item>
<Breadcrumb.Link href="/manage/app">Dashboard</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator />
<Breadcrumb.Item>
<Breadcrumb.Link href="/manage/app/triggers">Triggers</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator />
<Breadcrumb.Item>
<Breadcrumb.Page>{isNew ? "New Trigger" : trigger.name || "Edit Trigger"}</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
<!-- Error Message -->
{#if invalidFormMessage}
<div class="bg-destructive/10 text-destructive rounded-lg p-4 text-sm">{invalidFormMessage}</div>
{/if}
<Card.Root>
<Card.Header>
<Card.Title>{isNew ? "Create Trigger" : "Edit Trigger"}</Card.Title>
<Card.Description>Configure how alerts are sent when monitors fail</Card.Description>
<Card.Title>{isNew ? "New Trigger" : "Edit Trigger"}</Card.Title>
<Card.Description>Configure notification triggers for your monitors</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<!-- Trigger Type -->
<div class="space-y-2">
<Label>Type <span class="text-destructive">*</span></Label>
<Select.Root type="single" value={trigger.trigger_type} onValueChange={handleTriggerTypeChange}>
<Select.Trigger class="w-full">
<div class="flex items-center gap-2">
{#if trigger.trigger_type}
<img src={triggerTypes.find((t) => t.value === trigger.trigger_type)?.icon} class="size-5" alt="" />
<!-- Error Message -->
{#if invalidFormMessage}
<div class="bg-destructive/10 text-destructive rounded-lg p-4 text-sm">{invalidFormMessage}</div>
{/if}
<!-- Trigger Type Selection -->
<div class="space-y-3">
<Label>Trigger Type</Label>
<p class="text-muted-foreground text-sm">Select the type of notification to send</p>
<div class="grid grid-cols-4 gap-3">
{#each ["webhook", "discord", "slack", "email"] as type}
<Button
variant={trigger.trigger_type === type ? "default" : "outline"}
class="h-20 flex-col gap-2"
onclick={() => (trigger.trigger_type = type)}
>
{#if type === "webhook"}
<img src="/webhooks.svg" class="size-6" alt="webhook" />
{:else if type === "slack"}
<img src="/slack.svg" class="size-6" alt="slack" />
{:else if type === "discord"}
<img src="/discord.svg" class="size-6" alt="discord" />
{:else if type === "email"}
<img src="/email.png" class="size-6" alt="email" />
{:else}
<ZapIcon class="size-6" />
{/if}
<span>{triggerTypes.find((t) => t.value === trigger.trigger_type)?.label || "Select trigger type"}</span
>
</div>
</Select.Trigger>
<Select.Content>
{#each triggerTypes as type}
<Select.Item value={type.value}>
<div class="flex items-center gap-2">
<img src={type.icon} class="size-5" alt="" />
{type.label}
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Basic Info -->
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="trigger-name">Name <span class="text-destructive">*</span></Label>
<Input id="trigger-name" bind:value={trigger.name} placeholder="My Trigger" />
</div>
<div class="space-y-2">
<Label for="trigger-desc">Description</Label>
<Input id="trigger-desc" bind:value={trigger.trigger_desc} placeholder="Optional description" />
<span class="capitalize">{type}</span>
</Button>
{/each}
</div>
</div>
<!-- Status -->
<div class="flex items-center justify-between rounded-lg border p-3">
<Label>Active</Label>
<!-- Status Toggle -->
<div class="flex items-center justify-between rounded-lg border p-4">
<div>
<Label>Status</Label>
<p class="text-muted-foreground text-sm">Enable or disable this trigger</p>
</div>
<Switch
checked={trigger.trigger_status === "ACTIVE"}
onCheckedChange={(checked) => (trigger.trigger_status = checked ? "ACTIVE" : "INACTIVE")}
/>
</div>
<!-- URL (for non-email types) -->
<!-- Name -->
<div class="space-y-2">
<Label for="trigger-name">
Name <span class="text-destructive">*</span>
</Label>
<Input id="trigger-name" bind:value={trigger.name} placeholder="My Trigger" />
</div>
<!-- Description -->
<div class="space-y-2">
<Label for="trigger-desc">Description</Label>
<Input id="trigger-desc" bind:value={trigger.trigger_desc} placeholder="Optional description" />
</div>
<!-- URL (for non-email) -->
{#if trigger.trigger_type !== "email"}
<div class="space-y-2">
<Label for="trigger-url">
{trigger.trigger_type === "discord"
? "Discord Webhook URL"
: trigger.trigger_type === "slack"
? "Slack Webhook URL"
: "Webhook URL"}
<span class="text-destructive">*</span>
URL <span class="text-destructive">*</span>
</Label>
<Input
id="trigger-url"
bind:value={trigger.trigger_meta.url}
placeholder={trigger.trigger_type === "discord"
? "https://discord.com/api/webhooks/..."
: trigger.trigger_type === "slack"
? "https://hooks.slack.com/services/..."
: "https://example.com/webhook"}
/>
<Input id="trigger-url" bind:value={trigger.trigger_meta.url} placeholder="https://example.com/webhook" />
<p class="text-muted-foreground text-xs">The URL to send notifications to</p>
</div>
{/if}
<!-- Webhook Headers -->
<!-- Webhook Specific -->
{#if trigger.trigger_type === "webhook"}
<!-- Headers -->
<div class="space-y-3">
<Label>Headers</Label>
<div class="space-y-2">
@@ -461,141 +364,121 @@
Add Header
</Button>
</div>
<!-- Custom Body -->
<div class="space-y-3">
<div>
<Label>Custom Webhook Body</Label>
<p class="text-muted-foreground text-sm">Override the default JSON payload</p>
</div>
<p class="text-muted-foreground text-xs">
Use Mustache variables like <code class="bg-muted rounded px-1">{"{{variable}}"}</code>. Available: id,
alert_name, severity, status, source, timestamp, description, metric, current_value, threshold,
action_text, action_url
</p>
<div class="overflow-hidden rounded-md border">
<Textarea bind:value={trigger.trigger_meta.webhook_body} />
</div>
</div>
{/if}
<!-- Email Settings -->
<!-- Discord Specific -->
{#if trigger.trigger_type === "discord"}
<div class="space-y-3">
<div>
<Label>Custom Discord Payload</Label>
<p class="text-muted-foreground text-sm">Override the default Discord message</p>
</div>
<p class="text-muted-foreground text-xs">
Use Mustache variables. Available: site_name, logo_url, alert_name, status, is_triggered, is_resolved,
description, metric, severity, id, current_value, threshold, source, action_text, action_url
</p>
<div class="overflow-hidden rounded-md border">
<Textarea bind:value={trigger.trigger_meta.discord_body} />
</div>
</div>
{/if}
<!-- Slack Specific -->
{#if trigger.trigger_type === "slack"}
<div class="space-y-3">
<div>
<Label>Custom Slack Payload</Label>
<p class="text-muted-foreground text-sm">Override the default Slack message</p>
</div>
<p class="text-muted-foreground text-xs">
Use Mustache variables. Available: site_name, logo_url, alert_name, status, is_triggered, is_resolved,
description, metric, severity, id, current_value, threshold, source, action_text, action_url
</p>
<div class="overflow-hidden rounded-md border">
<Textarea bind:value={trigger.trigger_meta.slack_body} />
</div>
</div>
{/if}
<!-- Email Specific -->
{#if trigger.trigger_type === "email"}
<!-- Email Provider -->
<!-- Email Recipients -->
<div class="space-y-2">
<Label>Email Provider</Label>
<RadioGroup.Root bind:value={trigger.trigger_meta.email_type} class="flex gap-6">
<div class="flex items-center gap-2">
<RadioGroup.Item value="resend" id="email-resend" />
<Label for="email-resend" class="cursor-pointer">Resend</Label>
</div>
<div class="flex items-center gap-2">
<RadioGroup.Item value="smtp" id="email-smtp" />
<Label for="email-smtp" class="cursor-pointer">SMTP</Label>
</div>
</RadioGroup.Root>
{#if trigger.trigger_meta.email_type === "resend"}
<p class="text-muted-foreground text-xs">
Make sure <code class="bg-muted rounded px-1">RESEND_API_KEY</code> environment variable is set.
</p>
{/if}
<Label for="email-to">
To (comma separated) <span class="text-destructive">*</span>
</Label>
<Input
id="email-to"
bind:value={trigger.trigger_meta.to}
placeholder="john@example.com, jane@example.com"
/>
</div>
<div class="space-y-2">
<Label for="email-from">
From <span class="text-destructive">*</span>
</Label>
<Input id="email-from" bind:value={trigger.trigger_meta.from} placeholder="Alerts <alert@example.com>" />
<p class="text-muted-foreground text-xs">Format: Name &lt;email@example.com&gt;</p>
</div>
<!-- SMTP Settings -->
{#if trigger.trigger_meta.email_type === "smtp"}
<div class="space-y-3 rounded-lg border p-4">
<Label class="text-sm font-medium">SMTP Settings</Label>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<Label for="smtp-host" class="text-xs">Host <span class="text-destructive">*</span></Label>
<Input id="smtp-host" bind:value={trigger.trigger_meta.smtp_host} placeholder="smtp.example.com" />
</div>
<div class="space-y-2">
<Label for="smtp-port" class="text-xs">Port <span class="text-destructive">*</span></Label>
<Input id="smtp-port" bind:value={trigger.trigger_meta.smtp_port} placeholder="587" />
</div>
<div class="space-y-2">
<Label for="smtp-user" class="text-xs">Username <span class="text-destructive">*</span></Label>
<Input id="smtp-user" bind:value={trigger.trigger_meta.smtp_user} placeholder="user@example.com" />
</div>
<div class="space-y-2">
<Label for="smtp-pass" class="text-xs">Password <span class="text-destructive">*</span></Label>
<Input
id="smtp-pass"
type="password"
bind:value={trigger.trigger_meta.smtp_pass}
placeholder="********"
/>
</div>
</div>
<div class="flex items-center gap-2">
<Switch
id="smtp-secure"
checked={trigger.trigger_meta.smtp_secure}
onCheckedChange={(checked) => (trigger.trigger_meta.smtp_secure = checked)}
/>
<Label for="smtp-secure" class="text-xs">Use Secure Connection (TLS)</Label>
</div>
<!-- Custom Email Template -->
<div class="space-y-3">
<div>
<Label>Custom HTML Template</Label>
<p class="text-muted-foreground text-sm">Create your own email design</p>
</div>
{/if}
<!-- Email Recipients -->
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="email-to">To (comma separated) <span class="text-destructive">*</span></Label>
<Input
id="email-to"
bind:value={trigger.trigger_meta.to}
placeholder="john@example.com, jane@example.com"
<p class="text-muted-foreground text-xs">
Use Mustache variables. Available: site_name, site_url, logo_url, alert_name, status, severity,
description, metric, current_value, threshold, action_text, action_url, is_triggered, is_resolved,
color_up, color_down, color_degraded, color_maintenance
</p>
<div class="overflow-hidden rounded-md border">
<CodeMirror
bind:value={trigger.trigger_meta.email_body}
lang={html()}
theme={mode.current === "dark" ? githubDark : githubLight}
styles={{ "&": { width: "100%", height: "400px" } }}
/>
</div>
<div class="space-y-2">
<Label for="email-from">From <span class="text-destructive">*</span></Label>
<Input id="email-from" bind:value={trigger.trigger_meta.from} placeholder="Alerts <alert@example.com>" />
<p class="text-muted-foreground text-xs">Format: Name &lt;email@example.com&gt;</p>
</div>
</div>
{/if}
<!-- Template Selection -->
<div class="space-y-2">
<Label>Template</Label>
{#if loadingTemplates}
<div class="text-muted-foreground flex items-center gap-2 text-sm">
<Loader class="size-4 animate-spin" />
Loading templates...
</div>
{:else if templates.length === 0}
<p class="text-muted-foreground text-sm">
No ALERT templates available for {trigger.trigger_type}.
<a href="/manage/app/templates/new" class="text-primary underline">Create one</a>
</p>
{:else}
<Select.Root
type="single"
value={trigger.template_id ? String(trigger.template_id) : undefined}
onValueChange={handleTemplateChange}
>
<Select.Trigger class="w-full">
{trigger.template_id
? templates.find((t) => t.id === trigger.template_id)?.template_name
: "Select a template (optional)"}
</Select.Trigger>
<Select.Content>
{#each templates as template}
<Select.Item value={String(template.id)}>{template.template_name}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}
</div>
</Card.Content>
<Card.Footer class="flex justify-between">
<div class="flex gap-2">
{#if !isNew}
<Button variant="outline" onclick={testTrigger} disabled={testing === "loading"}>
{#if testing === "loading"}
<Loader class="mr-2 size-4 animate-spin" />
{:else if testing === "success"}
<CheckIcon class="mr-2 size-4 text-green-500" />
{:else if testing === "error"}
<XIcon class="mr-2 size-4 text-red-500" />
{/if}
Test
</Button>
{/if}
</div>
<Card.Footer class="flex justify-end gap-2">
{#if !isNew}
<Button variant="outline" onclick={testTrigger} disabled={testing === "loading"}>
{#if testing === "loading"}
<Loader class="mr-2 size-4 animate-spin" />
{:else if testing === "success"}
<CheckIcon class="mr-2 size-4 text-green-500" />
{:else if testing === "error"}
<XIcon class="mr-2 size-4 text-red-500" />
{/if}
Test Trigger
</Button>
{/if}
<Button onclick={saveTrigger} disabled={saving}>
{#if saving}
<Loader class="mr-2 size-4 animate-spin" />
{:else}
<SaveIcon class="mr-2 size-4" />
{/if}
{isNew ? "Create" : "Save"}
{isNew ? "Create" : "Save"} Trigger
</Button>
</Card.Footer>
</Card.Root>