mirror of
https://github.com/rajnandan1/kener.git
synced 2026-06-23 04:10:22 +00:00
changes
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}`;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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 || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
// };
|
||||
|
||||
@@ -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, "Apple Color Emoji",
|
||||
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
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'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
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 <email@example.com></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 <email@example.com></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>
|
||||
|
||||
Reference in New Issue
Block a user