mirror of
https://github.com/rajnandan1/kener.git
synced 2026-06-23 04:10:22 +00:00
changes
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
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");
|
||||
});
|
||||
|
||||
// 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> {
|
||||
await knex.schema.dropTableIfExists("email_templates_config");
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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");
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import defaultEmailTemplate from "../src/lib/server/templates/email_alert_templa
|
||||
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";
|
||||
|
||||
@@ -28,5 +30,15 @@ export async function seed(knex: Knex): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
interface Props {
|
||||
open: boolean;
|
||||
monitor_tags?: string[];
|
||||
incident_ids?: string[];
|
||||
incident_ids?: number[];
|
||||
maintenance_ids?: string[];
|
||||
}
|
||||
let { open = $bindable(false), monitor_tags = [], incident_ids = [], maintenance_ids = [] }: Props = $props();
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
monitor_tags?: string[];
|
||||
shareLinkString?: string;
|
||||
embedMonitorTag?: string;
|
||||
incident_ids?: number[];
|
||||
}
|
||||
|
||||
let openSubscribeMenu = $state(false);
|
||||
@@ -45,7 +46,8 @@
|
||||
showHomeButton = false,
|
||||
monitor_tags = [],
|
||||
shareLinkString = "",
|
||||
embedMonitorTag = ""
|
||||
embedMonitorTag = "",
|
||||
incident_ids = []
|
||||
}: Props = $props();
|
||||
|
||||
let protocol = $state("");
|
||||
@@ -110,7 +112,7 @@
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonGroup.Root class="">
|
||||
{#if monitor_tags.length > 0}
|
||||
{#if monitor_tags.length > 0 || incident_ids.length > 0}
|
||||
<ButtonGroup.Root class="hidden sm:flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -177,6 +179,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SubscribeMenuV2 bind:open={openSubscribeMenu} {monitor_tags} />
|
||||
<SubscribeMenuV2 bind:open={openSubscribeMenu} {monitor_tags} {incident_ids} />
|
||||
<BadgesMenu bind:open={openBadgesMenu} monitorTag={embedMonitorTag} {protocol} {domain} />
|
||||
<EmbedMenu bind:open={openEmbedMenu} monitorTag={embedMonitorTag} {protocol} {domain} />
|
||||
|
||||
@@ -8,6 +8,10 @@ import { GetSubscriptionConfig } from "$lib/server/controllers/subscriptionConfi
|
||||
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:
|
||||
@@ -188,18 +192,20 @@ async function handleLogin(email?: string): Promise<Response> {
|
||||
const siteLogo = await GetSiteLogoURL(siteData.siteURL || "", siteData.logo || "", "/");
|
||||
const siteName = siteData.siteName || "Status Page";
|
||||
|
||||
await SendEmailWithTemplate(
|
||||
emailCodeTemplate,
|
||||
{
|
||||
site_name: siteName,
|
||||
logo_url: siteLogo,
|
||||
code: code,
|
||||
site_url: siteData.siteURL || "",
|
||||
},
|
||||
normalizedEmail,
|
||||
`Your Verification Code - ${siteName}`,
|
||||
`Your verification code is: ${code}`,
|
||||
);
|
||||
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,
|
||||
|
||||
@@ -146,3 +146,8 @@ export const CreateHash = (apiKey: string): string => {
|
||||
.update(apiKey)
|
||||
.digest("hex");
|
||||
};
|
||||
|
||||
//create md5 hash
|
||||
export const CreateMD5Hash = (data: string): string => {
|
||||
return crypto.createHash("md5").update(data).digest("hex");
|
||||
};
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import db from "$lib/server/db/db";
|
||||
import type {
|
||||
EmailTemplateConfigRecord,
|
||||
EmailTemplateConfigUpdate,
|
||||
EmailTemplateConfigWithTemplate,
|
||||
} from "$lib/server/db/repositories/emailTemplateConfig";
|
||||
import type { TemplateRecord } from "$lib/server/types/db";
|
||||
|
||||
// Email type constants
|
||||
export const EMAIL_TYPES = {
|
||||
EMAIL_CODE: "email_code",
|
||||
FORGOT_PASSWORD: "forgot_password",
|
||||
VERIFY_EMAIL: "verify_email",
|
||||
SUBSCRIPTION_UPDATE: "subscription_update",
|
||||
} as const;
|
||||
|
||||
export type EmailType = (typeof EMAIL_TYPES)[keyof typeof EMAIL_TYPES];
|
||||
|
||||
// Human-readable labels for email types
|
||||
export const EMAIL_TYPE_LABELS: Record<EmailType, string> = {
|
||||
email_code: "Verification Code",
|
||||
forgot_password: "Forgot Password",
|
||||
verify_email: "Verify Email",
|
||||
subscription_update: "Subscription Update",
|
||||
};
|
||||
|
||||
// Descriptions for email types
|
||||
export const EMAIL_TYPE_DESCRIPTIONS: Record<EmailType, string> = {
|
||||
email_code: "Email sent when a user requests a verification code for login or actions",
|
||||
forgot_password: "Email sent when a user requests to reset their password",
|
||||
verify_email: "Email sent when a user needs to verify their email address",
|
||||
subscription_update: "Email sent to subscribers when there is an update on monitored services",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all email template configurations
|
||||
*/
|
||||
export async function GetAllEmailTemplateConfigs(): Promise<EmailTemplateConfigRecord[]> {
|
||||
return await db.getAllEmailTemplateConfigs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all email template configurations with template details
|
||||
*/
|
||||
export async function GetAllEmailTemplateConfigsWithTemplates(): Promise<EmailTemplateConfigWithTemplate[]> {
|
||||
return await db.getAllEmailTemplateConfigsWithTemplates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email template config by email type
|
||||
*/
|
||||
export async function GetEmailTemplateConfigByType(emailType: string): Promise<EmailTemplateConfigRecord | undefined> {
|
||||
return await db.getEmailTemplateConfigByType(emailType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update email template config
|
||||
*/
|
||||
export async function UpdateEmailTemplateConfig(
|
||||
emailType: string,
|
||||
data: EmailTemplateConfigUpdate,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Validate email type
|
||||
if (!Object.values(EMAIL_TYPES).includes(emailType as EmailType)) {
|
||||
return { success: false, error: `Invalid email type: ${emailType}` };
|
||||
}
|
||||
|
||||
// Validate template if provided
|
||||
if (data.email_template_id !== null && data.email_template_id !== undefined) {
|
||||
const template = await db.getTemplateById(data.email_template_id);
|
||||
if (!template) {
|
||||
return { success: false, error: `Template with ID ${data.email_template_id} not found` };
|
||||
}
|
||||
// Ensure it's an email template
|
||||
if (template.template_type !== "EMAIL") {
|
||||
return {
|
||||
success: false,
|
||||
error: `Template ${data.email_template_id} is not an email template`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Validate is_active if provided
|
||||
if (data.is_active !== undefined && !["YES", "NO"].includes(data.is_active)) {
|
||||
return { success: false, error: `Invalid is_active value: ${data.is_active}` };
|
||||
}
|
||||
|
||||
await db.updateEmailTemplateConfigByType(emailType, data);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update multiple email template configs
|
||||
*/
|
||||
export async function UpdateAllEmailTemplateConfigs(
|
||||
configs: Array<{ email_type: string; email_template_id: number | null; is_active: string }>,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
for (const config of configs) {
|
||||
const result = await UpdateEmailTemplateConfig(config.email_type, {
|
||||
email_template_id: config.email_template_id,
|
||||
is_active: config.is_active,
|
||||
});
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active email template config by type with template details
|
||||
*/
|
||||
export async function GetActiveEmailTemplateConfigByType(
|
||||
emailType: string,
|
||||
): Promise<EmailTemplateConfigWithTemplate | undefined> {
|
||||
return await db.getActiveEmailTemplateConfigByType(emailType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all email templates (for dropdown)
|
||||
*/
|
||||
export async function GetEmailTemplates(): Promise<
|
||||
Array<{ id: number; template_name: string; template_type: string }>
|
||||
> {
|
||||
const allTemplates = await db.getTemplatesByTypeAndUsage("EMAIL", ["GENERAL"]);
|
||||
return allTemplates.map((t) => ({
|
||||
id: t.id,
|
||||
template_name: t.template_name,
|
||||
template_type: t.template_type,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the template associated with an email type
|
||||
* Returns the template if the config is active and has a valid template_id
|
||||
*/
|
||||
export async function GetTemplateByEmailType(emailType: string): Promise<TemplateRecord | null> {
|
||||
const config = await db.getEmailTemplateConfigByType(emailType);
|
||||
|
||||
// Check if config exists, is active, and has a template assigned
|
||||
if (!config || config.is_active !== "YES" || !config.email_template_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const template = await db.getTemplateById(config.email_template_id);
|
||||
return template || null;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
} from "../types/db.js";
|
||||
import * as queueController from "./queueController.js";
|
||||
import type { NumberWithChange } from "../../types/monitor.js";
|
||||
import GC from "../../global-constants.js";
|
||||
|
||||
interface IncidentsDashboardInput {
|
||||
page: number;
|
||||
@@ -225,6 +226,18 @@ export const GetIncidentsByIDS = async (ids: number[]): Promise<unknown[]> => {
|
||||
return incidents;
|
||||
};
|
||||
|
||||
export const CreateNewIncidentWithCommentAndMonitor = async (
|
||||
data: IncidentInput,
|
||||
update: string,
|
||||
monitorTag: string,
|
||||
monitorStatus: string,
|
||||
): Promise<{ incident_id: number }> => {
|
||||
let incidentCreation = await CreateIncident(data);
|
||||
await AddIncidentComment(incidentCreation.incident_id, update, GC.INVESTIGATING, data.start_date_time);
|
||||
await AddIncidentMonitor(incidentCreation.incident_id, monitorTag, monitorStatus);
|
||||
|
||||
return incidentCreation;
|
||||
};
|
||||
export const CreateIncident = async (data: IncidentInput): Promise<{ incident_id: number }> => {
|
||||
//return error if no title or startDateTime
|
||||
if (!data.title || !data.start_date_time) {
|
||||
@@ -252,10 +265,12 @@ export const CreateIncident = async (data: IncidentInput): Promise<{ incident_id
|
||||
}
|
||||
|
||||
let newIncident = await db.createIncident(incident);
|
||||
queueController.PushDataToQueue(newIncident.id, "createIncident", {
|
||||
message: `${incident.incident_type} Created`,
|
||||
...incident,
|
||||
});
|
||||
|
||||
//we need to
|
||||
// queueController.PushDataToQueue(newIncident.id, "createIncident", {
|
||||
// message: `${incident.incident_type} Created`,
|
||||
// ...incident,
|
||||
// });
|
||||
return {
|
||||
incident_id: newIncident.id,
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ export type {
|
||||
// ============ Validation ============
|
||||
|
||||
const VALID_TEMPLATE_TYPES: TemplateType[] = ["EMAIL", "WEBHOOK", "SLACK", "DISCORD"];
|
||||
const VALID_TEMPLATE_USAGES: TemplateUsageType[] = ["ALERT", "SUBSCRIPTION"];
|
||||
const VALID_TEMPLATE_USAGES: TemplateUsageType[] = ["ALERT", "SUBSCRIPTION", "GENERAL"];
|
||||
|
||||
function validateTemplateType(value: string): asserts value is TemplateType {
|
||||
if (!VALID_TEMPLATE_TYPES.includes(value as TemplateType)) {
|
||||
@@ -272,11 +272,11 @@ export async function GetTemplatesByUsage(templateUsage: TemplateUsageType): Pro
|
||||
*/
|
||||
export async function GetTemplatesByTypeAndUsage(
|
||||
templateType: TemplateType,
|
||||
templateUsage: TemplateUsageType,
|
||||
templateUsages: TemplateUsageType[],
|
||||
): Promise<TemplateRecord[]> {
|
||||
validateTemplateType(templateType);
|
||||
validateTemplateUsage(templateUsage);
|
||||
return await db.getTemplatesByTypeAndUsage(templateType, templateUsage);
|
||||
templateUsages.forEach((usage) => validateTemplateUsage(usage));
|
||||
return await db.getTemplatesByTypeAndUsage(templateType, templateUsages);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
UserSubscriptionV2Record,
|
||||
SubscriberUserRecord,
|
||||
SubscriberMethodRecord,
|
||||
SubscriptionEntityType,
|
||||
} from "$lib/server/types/db.js";
|
||||
|
||||
// ============ V2 Admin Functions ============
|
||||
@@ -242,3 +243,17 @@ export function FormatEntityType(entityType: string | null): string {
|
||||
return entityType;
|
||||
}
|
||||
}
|
||||
|
||||
//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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import crypto from "crypto";
|
||||
import db from "$lib/server/db/db";
|
||||
import type { VaultRecord } from "$lib/server/db/repositories/vault";
|
||||
|
||||
const DUMMY_SECRET = "DUMMY_SECRET_KEY_32_BYTES_LONG!";
|
||||
const ALGORITHM = "aes-256-cbc";
|
||||
const IV_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* Get the encryption key from environment variable
|
||||
* Pads or truncates to 32 bytes for AES-256
|
||||
*/
|
||||
function getEncryptionKey(): Buffer {
|
||||
const key = process.env.KENER_SECRET_KEY || DUMMY_SECRET;
|
||||
// AES-256 requires a 32-byte key
|
||||
const keyBuffer = Buffer.alloc(32);
|
||||
Buffer.from(key).copy(keyBuffer);
|
||||
return keyBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a value using AES-256-CBC
|
||||
*/
|
||||
export function encryptValue(plainText: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
let encrypted = cipher.update(plainText, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
// Prepend IV to encrypted data (IV:encrypted)
|
||||
return iv.toString("hex") + ":" + encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value using AES-256-CBC
|
||||
*/
|
||||
export function decryptValue(encryptedText: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const parts = encryptedText.split(":");
|
||||
if (parts.length !== 2) {
|
||||
throw new Error("Invalid encrypted value format");
|
||||
}
|
||||
const iv = Buffer.from(parts[0], "hex");
|
||||
const encrypted = parts[1];
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for decrypted vault secret
|
||||
*/
|
||||
export interface VaultSecretDecrypted {
|
||||
id: number;
|
||||
secret_name: string;
|
||||
secret_value: string; // Decrypted value
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all secrets with decrypted values
|
||||
*/
|
||||
export async function GetAllSecrets(): Promise<VaultSecretDecrypted[]> {
|
||||
const secrets = await db.getAllSecrets();
|
||||
return secrets.map((secret) => ({
|
||||
...secret,
|
||||
secret_value: decryptValue(secret.secret_value),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a secret by ID with decrypted value
|
||||
*/
|
||||
export async function GetSecretById(id: number): Promise<VaultSecretDecrypted | undefined> {
|
||||
const secret = await db.getSecretById(id);
|
||||
if (!secret) return undefined;
|
||||
return {
|
||||
...secret,
|
||||
secret_value: decryptValue(secret.secret_value),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a secret by name with decrypted value
|
||||
*/
|
||||
export async function GetSecretByName(secretName: string): Promise<VaultSecretDecrypted | undefined> {
|
||||
const secret = await db.getSecretByName(secretName);
|
||||
if (!secret) return undefined;
|
||||
return {
|
||||
...secret,
|
||||
secret_value: decryptValue(secret.secret_value),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new secret (encrypts the value before storing)
|
||||
*/
|
||||
export async function CreateSecret(
|
||||
secretName: string,
|
||||
secretValue: string,
|
||||
): Promise<{ success: boolean; error?: string; id?: number }> {
|
||||
// Validate input
|
||||
if (!secretName || !secretName.trim()) {
|
||||
return { success: false, error: "Secret name is required" };
|
||||
}
|
||||
if (!secretValue) {
|
||||
return { success: false, error: "Secret value is required" };
|
||||
}
|
||||
|
||||
// Check if name already exists
|
||||
const exists = await db.secretNameExists(secretName.trim());
|
||||
if (exists) {
|
||||
return { success: false, error: `Secret with name "${secretName}" already exists` };
|
||||
}
|
||||
|
||||
// Encrypt and store
|
||||
const encryptedValue = encryptValue(secretValue);
|
||||
const ids = await db.insertSecret({
|
||||
secret_name: secretName.trim(),
|
||||
secret_value: encryptedValue,
|
||||
});
|
||||
|
||||
return { success: true, id: ids[0] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a secret by ID (encrypts the new value before storing)
|
||||
*/
|
||||
export async function UpdateSecret(
|
||||
id: number,
|
||||
data: { secret_name?: string; secret_value?: string },
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Check if secret exists
|
||||
const existing = await db.getSecretById(id);
|
||||
if (!existing) {
|
||||
return { success: false, error: `Secret with ID ${id} not found` };
|
||||
}
|
||||
|
||||
// If updating name, check for duplicates
|
||||
if (data.secret_name && data.secret_name !== existing.secret_name) {
|
||||
const nameExists = await db.secretNameExists(data.secret_name.trim(), id);
|
||||
if (nameExists) {
|
||||
return { success: false, error: `Secret with name "${data.secret_name}" already exists` };
|
||||
}
|
||||
}
|
||||
|
||||
// Build update data
|
||||
const updateData: { secret_name?: string; secret_value?: string } = {};
|
||||
if (data.secret_name !== undefined) {
|
||||
updateData.secret_name = data.secret_name.trim();
|
||||
}
|
||||
if (data.secret_value !== undefined) {
|
||||
updateData.secret_value = encryptValue(data.secret_value);
|
||||
}
|
||||
|
||||
await db.updateSecretById(id, updateData);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a secret by ID
|
||||
*/
|
||||
export async function DeleteSecret(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
const existing = await db.getSecretById(id);
|
||||
if (!existing) {
|
||||
return { success: false, error: `Secret with ID ${id} not found` };
|
||||
}
|
||||
|
||||
await db.deleteSecretById(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secrets count
|
||||
*/
|
||||
export async function GetSecretsCount(): Promise<number> {
|
||||
return await db.getSecretsCount();
|
||||
}
|
||||
@@ -17,6 +17,8 @@ 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";
|
||||
|
||||
// Re-export types from base
|
||||
export type { MonitorFilter, TriggerFilter, IncidentFilter, CountResult } from "./repositories/base.js";
|
||||
@@ -49,6 +51,8 @@ class DbImpl {
|
||||
private subscriptionConfig!: SubscriptionConfigRepository;
|
||||
private userSubscriptions!: UserSubscriptionsRepository;
|
||||
private subscriptionSystem!: SubscriptionSystemRepository;
|
||||
private emailTemplateConfig!: EmailTemplateConfigRepository;
|
||||
private vault!: VaultRepository;
|
||||
|
||||
// Method bindings - declared with definite assignment assertion
|
||||
// ============ Monitoring Data ============
|
||||
@@ -387,6 +391,30 @@ 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"];
|
||||
|
||||
// ============ Vault ============
|
||||
getAllSecrets!: VaultRepository["getAllSecrets"];
|
||||
getSecretById!: VaultRepository["getSecretById"];
|
||||
getSecretByName!: VaultRepository["getSecretByName"];
|
||||
insertSecret!: VaultRepository["insertSecret"];
|
||||
updateSecretById!: VaultRepository["updateSecretById"];
|
||||
updateSecretByName!: VaultRepository["updateSecretByName"];
|
||||
deleteSecretById!: VaultRepository["deleteSecretById"];
|
||||
deleteSecretByName!: VaultRepository["deleteSecretByName"];
|
||||
secretNameExists!: VaultRepository["secretNameExists"];
|
||||
getSecretsCount!: VaultRepository["getSecretsCount"];
|
||||
|
||||
constructor(opts: KnexType.Config) {
|
||||
this.knex = Knex(opts);
|
||||
@@ -407,6 +435,8 @@ class DbImpl {
|
||||
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);
|
||||
|
||||
// Bind methods after repositories are initialized
|
||||
this.bindMonitoringMethods();
|
||||
@@ -424,6 +454,8 @@ class DbImpl {
|
||||
this.bindSubscriptionConfigMethods();
|
||||
this.bindUserSubscriptionsMethods();
|
||||
this.bindSubscriptionSystemMethods();
|
||||
this.bindEmailTemplateConfigMethods();
|
||||
this.bindVaultMethods();
|
||||
|
||||
this.init();
|
||||
}
|
||||
@@ -858,6 +890,49 @@ 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);
|
||||
}
|
||||
|
||||
private bindVaultMethods(): void {
|
||||
this.getAllSecrets = this.vault.getAllSecrets.bind(this.vault);
|
||||
this.getSecretById = this.vault.getSecretById.bind(this.vault);
|
||||
this.getSecretByName = this.vault.getSecretByName.bind(this.vault);
|
||||
this.insertSecret = this.vault.insertSecret.bind(this.vault);
|
||||
this.updateSecretById = this.vault.updateSecretById.bind(this.vault);
|
||||
this.updateSecretByName = this.vault.updateSecretByName.bind(this.vault);
|
||||
this.deleteSecretById = this.vault.deleteSecretById.bind(this.vault);
|
||||
this.deleteSecretByName = this.vault.deleteSecretByName.bind(this.vault);
|
||||
this.secretNameExists = this.vault.secretNameExists.bind(this.vault);
|
||||
this.getSecretsCount = this.vault.getSecretsCount.bind(this.vault);
|
||||
}
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository for email template configuration operations
|
||||
*/
|
||||
export class EmailTemplateConfigRepository extends BaseRepository {
|
||||
/**
|
||||
* Get all email template configs
|
||||
*/
|
||||
async getAllEmailTemplateConfigs(): Promise<EmailTemplateConfigRecord[]> {
|
||||
return await this.knex("email_templates_config").orderBy("id", "asc");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all email template configs with template details
|
||||
*/
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email template config by email type
|
||||
*/
|
||||
async getEmailTemplateConfigByType(emailType: string): Promise<EmailTemplateConfigRecord | undefined> {
|
||||
return await this.knex("email_templates_config").where({ email_type: emailType }).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email template config by ID
|
||||
*/
|
||||
async getEmailTemplateConfigById(id: number): Promise<EmailTemplateConfigRecord | undefined> {
|
||||
return await this.knex("email_templates_config").where({ id }).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update email template config by email type
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update email template config by ID
|
||||
*/
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -518,4 +518,89 @@ 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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,10 +96,11 @@ export class TemplatesRepository extends BaseRepository {
|
||||
*/
|
||||
async getTemplatesByTypeAndUsage(
|
||||
templateType: TemplateType,
|
||||
templateUsage: TemplateUsageType,
|
||||
templateUsages: TemplateUsageType[],
|
||||
): Promise<TemplateRecord[]> {
|
||||
return await this.knex("templates")
|
||||
.where({ template_type: templateType, template_usage: templateUsage })
|
||||
.where({ template_type: templateType })
|
||||
.whereIn("template_usage", templateUsages)
|
||||
.orderBy("id", "desc");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { BaseRepository } from "./base.js";
|
||||
|
||||
export interface VaultRecord {
|
||||
id: number;
|
||||
secret_name: string;
|
||||
secret_value: string; // Encrypted value in DB
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface VaultInsert {
|
||||
secret_name: string;
|
||||
secret_value: string; // Should be encrypted before insert
|
||||
}
|
||||
|
||||
export interface VaultUpdate {
|
||||
secret_name?: string;
|
||||
secret_value?: string; // Should be encrypted before update
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository for vault (secrets) operations
|
||||
* Note: Encryption/decryption should be handled at the controller level
|
||||
*/
|
||||
export class VaultRepository extends BaseRepository {
|
||||
/**
|
||||
* Get all vault secrets
|
||||
*/
|
||||
async getAllSecrets(): Promise<VaultRecord[]> {
|
||||
return await this.knex("vault").orderBy("id", "asc");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a secret by ID
|
||||
*/
|
||||
async getSecretById(id: number): Promise<VaultRecord | undefined> {
|
||||
return await this.knex("vault").where({ id }).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a secret by name
|
||||
*/
|
||||
async getSecretByName(secretName: string): Promise<VaultRecord | undefined> {
|
||||
return await this.knex("vault").where({ secret_name: secretName }).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new secret
|
||||
*/
|
||||
async insertSecret(data: VaultInsert): Promise<number[]> {
|
||||
return await this.knex("vault").insert({
|
||||
secret_name: data.secret_name,
|
||||
secret_value: data.secret_value,
|
||||
created_at: this.knex.fn.now(),
|
||||
updated_at: this.knex.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a secret by ID
|
||||
*/
|
||||
async updateSecretById(id: number, data: VaultUpdate): Promise<number> {
|
||||
const updateData: Record<string, unknown> = {
|
||||
updated_at: this.knex.fn.now(),
|
||||
};
|
||||
|
||||
if (data.secret_name !== undefined) {
|
||||
updateData.secret_name = data.secret_name;
|
||||
}
|
||||
if (data.secret_value !== undefined) {
|
||||
updateData.secret_value = data.secret_value;
|
||||
}
|
||||
|
||||
return await this.knex("vault").where({ id }).update(updateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a secret by name
|
||||
*/
|
||||
async updateSecretByName(secretName: string, data: VaultUpdate): Promise<number> {
|
||||
const updateData: Record<string, unknown> = {
|
||||
updated_at: this.knex.fn.now(),
|
||||
};
|
||||
|
||||
if (data.secret_name !== undefined) {
|
||||
updateData.secret_name = data.secret_name;
|
||||
}
|
||||
if (data.secret_value !== undefined) {
|
||||
updateData.secret_value = data.secret_value;
|
||||
}
|
||||
|
||||
return await this.knex("vault").where({ secret_name: secretName }).update(updateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a secret by ID
|
||||
*/
|
||||
async deleteSecretById(id: number): Promise<number> {
|
||||
return await this.knex("vault").where({ id }).del();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a secret by name
|
||||
*/
|
||||
async deleteSecretByName(secretName: string): Promise<number> {
|
||||
return await this.knex("vault").where({ secret_name: secretName }).del();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a secret name already exists
|
||||
*/
|
||||
async secretNameExists(secretName: string, excludeId?: number): Promise<boolean> {
|
||||
let query = this.knex("vault").where({ secret_name: secretName });
|
||||
if (excludeId !== undefined) {
|
||||
query = query.andWhereNot({ id: excludeId });
|
||||
}
|
||||
const result = await query.first();
|
||||
return !!result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secrets count
|
||||
*/
|
||||
async getSecretsCount(): Promise<number> {
|
||||
const result = await this.knex("vault").count("id as count").first();
|
||||
return Number(result?.count || 0);
|
||||
}
|
||||
}
|
||||
@@ -10,28 +10,23 @@ import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
|
||||
import Mustache from "mustache";
|
||||
import striptags from "striptags";
|
||||
import { Resend, type CreateEmailOptions } from "resend";
|
||||
import type { SiteDataForNotification, TemplateVariableMap } from "./types.js";
|
||||
import type {
|
||||
ResendAPIConfiguration,
|
||||
SiteDataForNotification,
|
||||
SMTPConfiguration,
|
||||
TemplateVariableMap,
|
||||
} from "./types.js";
|
||||
import getSMTPTransport from "./smtps.js";
|
||||
|
||||
export default async function send(
|
||||
triggerRecord: TriggerRecordParsed<TriggerMetaEmailJson>,
|
||||
triggerRecord: SMTPConfiguration | ResendAPIConfiguration,
|
||||
variables: TemplateVariableMap,
|
||||
template: TemplateRecord,
|
||||
siteData: SiteDataForNotification,
|
||||
to?: string[],
|
||||
to: string[],
|
||||
) {
|
||||
// Implementation for sending email notification using the provided triggerRecord, variables, and template
|
||||
|
||||
let metaStringified = JSON.stringify(triggerRecord.trigger_meta);
|
||||
let envSecrets = GetRequiredSecrets(metaStringified);
|
||||
|
||||
for (let i = 0; i < envSecrets.length; i++) {
|
||||
const secret = envSecrets[i];
|
||||
if (secret.replace !== undefined) {
|
||||
metaStringified = ReplaceAllOccurrences(metaStringified, secret.find, secret.replace);
|
||||
}
|
||||
}
|
||||
|
||||
let trigger_meta_json = JSON.parse(metaStringified) as TriggerMetaEmailJson;
|
||||
let emailBody = template.template_json;
|
||||
let emailBodyJson = JSON.parse(emailBody) as EmailTemplateJson;
|
||||
|
||||
@@ -44,29 +39,33 @@ export default async function send(
|
||||
}
|
||||
}
|
||||
|
||||
let toAddresses = trigger_meta_json.to;
|
||||
if (to) {
|
||||
toAddresses = to;
|
||||
}
|
||||
const from = trigger_meta_json.from;
|
||||
const subject = Mustache.render(emailBodyJson.email_subject, { ...variables, ...siteData });
|
||||
const htmlBody = Mustache.render(emailBodyJson.email_body, { ...variables, ...siteData });
|
||||
const textBody = striptags(htmlBody);
|
||||
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
try {
|
||||
//check if triggerRecord is of type ResendAPIConfiguration
|
||||
if ("resend_api_key" in triggerRecord) {
|
||||
const resend = new Resend(triggerRecord.resend_api_key);
|
||||
const emailBody: CreateEmailOptions = {
|
||||
from: from,
|
||||
to: toAddresses,
|
||||
from: triggerRecord.resend_sender_email,
|
||||
to: to,
|
||||
subject: subject,
|
||||
html: htmlBody,
|
||||
text: textBody,
|
||||
};
|
||||
return await resend.emails.send(emailBody);
|
||||
} catch (error) {
|
||||
console.error("Error sending webhook", error);
|
||||
return error;
|
||||
} else {
|
||||
// SMTP Configuration
|
||||
const transport = getSMTPTransport(triggerRecord as SMTPConfiguration);
|
||||
const mailOptions = {
|
||||
from: triggerRecord.smtp_sender, // sender address
|
||||
to: to, // recipient address(es)
|
||||
subject: subject, // email subject
|
||||
text: textBody, // plain text body
|
||||
html: htmlBody, // HTML body (if any)
|
||||
};
|
||||
return await transport.sendMail(mailOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending email", error);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { MonitorAlertConfigRecord, MonitorAlertV2Record } from "../types/db";
|
||||
import type { AlertVariableMap, SiteDataForNotification, TemplateVariableMap } from "./types.js";
|
||||
import type { MonitorAlertConfigRecord, MonitorAlertV2Record, TriggerMetaEmailJson } from "../types/db";
|
||||
import type {
|
||||
AlertVariableMap,
|
||||
ResendAPIConfiguration,
|
||||
SiteDataForNotification,
|
||||
SMTPConfiguration,
|
||||
TemplateVariableMap,
|
||||
} from "./types.js";
|
||||
import GC from "../../global-constants.js";
|
||||
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
|
||||
@@ -39,3 +46,45 @@ 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 || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,11 @@ import nodemailer from "nodemailer";
|
||||
import type { Transporter } from "nodemailer";
|
||||
import { HashString } from "../tool.js";
|
||||
import type { TriggerRecord, MonitorRecord } from "../types/db.js";
|
||||
|
||||
interface SMTPMeta {
|
||||
smtp_host: string;
|
||||
smtp_port?: string | number;
|
||||
smtp_secure?: boolean;
|
||||
smtp_user: string;
|
||||
smtp_pass: string;
|
||||
}
|
||||
import type { SMTPConfiguration } from "./types.js";
|
||||
|
||||
const transports: Record<string, Transporter> = {};
|
||||
|
||||
export default function getSMTPTransport(meta: SMTPMeta): Transporter {
|
||||
export default function getSMTPTransport(meta: SMTPConfiguration): Transporter {
|
||||
//convert meta to string and generate has id
|
||||
|
||||
let transportId = "smtp_" + HashString(JSON.stringify(meta));
|
||||
|
||||
@@ -26,7 +26,35 @@ export interface SiteDataForNotification {
|
||||
colors_maintenance: string;
|
||||
}
|
||||
export interface SubscriptionVariableMap {
|
||||
my_name: string;
|
||||
title: string;
|
||||
cta_url: string;
|
||||
cta_text: string;
|
||||
update_text: string;
|
||||
update_subject: string;
|
||||
}
|
||||
|
||||
export type TemplateVariableMap = SubscriptionVariableMap | AlertVariableMap;
|
||||
export interface EmailCodeVariableMap {
|
||||
email_code: string;
|
||||
email_subject: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
export interface SMTPConfiguration {
|
||||
smtp_host: string;
|
||||
smtp_port?: string | number;
|
||||
smtp_secure?: boolean;
|
||||
smtp_user: string;
|
||||
smtp_pass: string;
|
||||
smtp_sender: string;
|
||||
}
|
||||
export interface WebhookConfiguration {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ResendAPIConfiguration {
|
||||
resend_api_key: string;
|
||||
resend_sender_email: string;
|
||||
}
|
||||
|
||||
export type TemplateVariableMap = SubscriptionVariableMap | AlertVariableMap | EmailCodeVariableMap;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
AddIncidentMonitor,
|
||||
GetIncidentByIDDashboard,
|
||||
GetAllSiteData,
|
||||
CreateNewIncidentWithCommentAndMonitor,
|
||||
} from "../controllers/controller.js";
|
||||
import { SetLastMonitoringValue } from "../cache/setGet.js";
|
||||
import type {
|
||||
@@ -36,11 +37,16 @@ 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, siteDataToVariables } from "../notification/notification_utils.js";
|
||||
import {
|
||||
alertToVariables,
|
||||
getEmailConfigFromTriggerMeta,
|
||||
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";
|
||||
|
||||
@@ -74,8 +80,6 @@ async function createNewIncident(
|
||||
incident_source: "ALERT",
|
||||
};
|
||||
|
||||
let newIncident = await CreateIncident(incidentInput);
|
||||
let incidentId = newIncident.incident_id;
|
||||
let update = config.alert_description || "Alert triggered";
|
||||
update = `${config.alert_description || "Alert triggered"}\n\n`;
|
||||
update = update + `| Setting | Value |\n`;
|
||||
@@ -88,13 +92,7 @@ async function createNewIncident(
|
||||
update = update + `| **Alert Value** | ${config.alert_value} |\n`;
|
||||
update = update + `| **Failure Threshold** | ${config.failure_threshold} |\n`;
|
||||
|
||||
//add update to incident
|
||||
await AddIncidentComment(newIncident.incident_id, update, GC.INVESTIGATING, startDateTime);
|
||||
|
||||
//add monitor to incident
|
||||
await AddIncidentMonitor(newIncident.incident_id, monitorTag, config.alert_value);
|
||||
|
||||
return { incident_id: incidentId };
|
||||
return await CreateNewIncidentWithCommentAndMonitor(incidentInput, update, monitorTag, config.alert_value);
|
||||
}
|
||||
|
||||
async function closeIncident(
|
||||
@@ -175,37 +173,37 @@ async function sendAlertNotifications(
|
||||
|
||||
// Handle only email for now
|
||||
if (trigger.trigger_type === "email") {
|
||||
await sendEmail(
|
||||
trigger as TriggerRecordParsed<TriggerMetaEmailJson>,
|
||||
templateAlertVars,
|
||||
template,
|
||||
templateSiteVars,
|
||||
);
|
||||
} else if (trigger.trigger_type === "webhook") {
|
||||
await sendWebhook(
|
||||
trigger as TriggerRecordParsed<TriggerMetaWebhookJson>,
|
||||
templateAlertVars,
|
||||
template,
|
||||
templateSiteVars,
|
||||
);
|
||||
} else if (trigger.trigger_type === "slack") {
|
||||
await sendSlack(
|
||||
trigger as TriggerRecordParsed<TriggerMetaSlackJson>,
|
||||
templateAlertVars,
|
||||
template,
|
||||
templateSiteVars,
|
||||
);
|
||||
} else if (trigger.trigger_type === "discord") {
|
||||
await sendDiscord(
|
||||
trigger as TriggerRecordParsed<TriggerMetaDiscordJson>,
|
||||
templateAlertVars,
|
||||
template,
|
||||
templateSiteVars,
|
||||
);
|
||||
const emailSendingConfig = getEmailConfigFromTriggerMeta(trigger.trigger_meta as TriggerMetaEmailJson);
|
||||
const toAddresses = (trigger.trigger_meta as TriggerMetaEmailJson).to;
|
||||
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);
|
||||
@@ -253,6 +251,8 @@ const addWorker = () => {
|
||||
}
|
||||
// Send triggered alert notifications
|
||||
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars);
|
||||
// Send subscriber notifications
|
||||
//await sendSubscriberNotifications(activeAlert, monitor_alerts_configured, monitor_name, monitor_tag);
|
||||
}
|
||||
} else {
|
||||
//all good, resolve any existing alert
|
||||
@@ -267,6 +267,8 @@ const addWorker = () => {
|
||||
|
||||
// Send resolution notifications
|
||||
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars);
|
||||
// Send subscriber notifications
|
||||
//await sendSubscriberNotifications(activeAlert, monitor_alerts_configured, monitor_name, monitor_tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Sender Queue - handles individual email sending
|
||||
* Receives email configuration, variables, template, and recipient email
|
||||
*/
|
||||
|
||||
import { Queue, Worker, Job, type JobsOptions } from "bullmq";
|
||||
import q from "./q.js";
|
||||
import sendEmail from "../notification/email_notification.js";
|
||||
import type { TemplateRecord } from "../types/db.js";
|
||||
import type {
|
||||
SMTPConfiguration,
|
||||
ResendAPIConfiguration,
|
||||
SiteDataForNotification,
|
||||
SubscriptionVariableMap,
|
||||
WebhookConfiguration,
|
||||
} from "../notification/types.js";
|
||||
|
||||
let senderQueue: Queue | null = null;
|
||||
let worker: Worker | null = null;
|
||||
const queueName = "senderQueue";
|
||||
const jobNamePrefix = "sendJob";
|
||||
|
||||
const getQueue = () => {
|
||||
if (!senderQueue) {
|
||||
senderQueue = q.createQueue(queueName);
|
||||
}
|
||||
return senderQueue;
|
||||
};
|
||||
|
||||
const addWorker = () => {
|
||||
if (worker) return worker;
|
||||
|
||||
worker = q.createWorker(getQueue(), async (job: Job): Promise<void> => {
|
||||
const { details } = job.data as { details: NotificationObject };
|
||||
|
||||
try {
|
||||
if (details.type === "email" && details.emailConfig && details.emailTo) {
|
||||
const { emailConfig, variables, template, templateSiteVars, emailTo } = details;
|
||||
await sendEmail(emailConfig, variables, template, templateSiteVars, [emailTo]);
|
||||
console.log(`📧 Email sent to ${emailTo}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error(`Failed to send email to ${toEmail}:`, error);
|
||||
throw error; // Re-throw to trigger retry
|
||||
}
|
||||
});
|
||||
|
||||
worker.on("completed", (job: Job) => {
|
||||
// const { toEmail } = job.data as SenderJobData;
|
||||
// console.log(`✅ Email job completed for ${toEmail}`);
|
||||
});
|
||||
|
||||
worker.on("failed", (job: Job | undefined, err: Error) => {
|
||||
const toEmail = job?.data?.toEmail || "unknown";
|
||||
console.error(`❌ Email job failed for ${toEmail}:`, err.message);
|
||||
});
|
||||
|
||||
return worker;
|
||||
};
|
||||
|
||||
/**
|
||||
* Push an email sending job to the queue
|
||||
*/
|
||||
|
||||
export interface NotificationObject {
|
||||
emailConfig?: SMTPConfiguration | ResendAPIConfiguration;
|
||||
variables: SubscriptionVariableMap;
|
||||
template: TemplateRecord;
|
||||
templateSiteVars: SiteDataForNotification;
|
||||
emailTo?: string;
|
||||
id: string;
|
||||
type: string;
|
||||
}
|
||||
export const push = async (details: NotificationObject, options?: JobsOptions) => {
|
||||
if (!options) {
|
||||
options = {};
|
||||
}
|
||||
|
||||
const queue = getQueue();
|
||||
addWorker();
|
||||
|
||||
// Use deduplication to prevent duplicate emails
|
||||
const deDupId = details.id;
|
||||
if (!options.deduplication) {
|
||||
options.deduplication = {
|
||||
id: deDupId,
|
||||
};
|
||||
}
|
||||
|
||||
await queue.add(`${jobNamePrefix}_${details.emailTo}`, details, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Graceful shutdown
|
||||
*/
|
||||
export const shutdown = async () => {
|
||||
if (worker) {
|
||||
await worker.close();
|
||||
worker = null;
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
push,
|
||||
shutdown,
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const getQueue = () => {
|
||||
if (!subscriberQueue) {
|
||||
subscriberQueue = q.createQueue(queueName);
|
||||
}
|
||||
return subscriberQueue;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
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
|
||||
|
||||
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");
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
return worker;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// 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,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Graceful shutdown
|
||||
*/
|
||||
export const shutdown = async () => {
|
||||
if (worker) {
|
||||
await worker.close();
|
||||
worker = null;
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
push,
|
||||
shutdown,
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { HashString } from "../tool.js";
|
||||
import { getSchedulers, addJobToSchedulerQueue, removeJobFromSchedulerQueue } from "./monitorSchedulers.js";
|
||||
|
||||
import { GetMonitorsParsed } from "../controllers/controller.js";
|
||||
import { GetAllSecrets } from "../controllers/vaultController.js";
|
||||
|
||||
let appSchedulerQueue: Queue | null = null;
|
||||
let worker: Worker | null = null;
|
||||
@@ -27,6 +28,16 @@ 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)),
|
||||
@@ -91,7 +102,7 @@ export const start = async (options?: JobSchedulerTemplateOptions) => {
|
||||
await queue.upsertJobScheduler(
|
||||
jobNamePrefix + "_main_job",
|
||||
{
|
||||
every: 10000, // Job will repeat every 1000 milliseconds (1 second)
|
||||
every: 10000, // Job will repeat every 10000 milliseconds (10 seconds)
|
||||
},
|
||||
{
|
||||
opts: options,
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
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 {{action}} 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_name: "Default Email Code",
|
||||
template_type: "EMAIL",
|
||||
template_usage: "GENERAL",
|
||||
template_json: JSON.stringify(
|
||||
{
|
||||
email_subject: "{{email_subject}}",
|
||||
email_body: emailTemplate,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,262 @@
|
||||
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;
|
||||
"
|
||||
>
|
||||
{{title}}
|
||||
</div>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
background-color: rgb(255, 255, 255);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
background-color: #e4e5ec;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
>
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="text-align: center">
|
||||
<img
|
||||
alt="{{site_name}}"
|
||||
src="{{site_logo_url}}"
|
||||
style="
|
||||
height: auto;
|
||||
display: block;
|
||||
outline: none;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
"
|
||||
width="80"
|
||||
/>
|
||||
<p
|
||||
style="
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #191919;
|
||||
margin: 8px 0 0 0;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
{{site_name}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-top: 32px;
|
||||
padding-bottom: 32px;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<h1
|
||||
style="
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: rgb(31, 41, 55);
|
||||
margin-bottom: 16px;
|
||||
"
|
||||
>
|
||||
{{title}}
|
||||
</h1>
|
||||
<p
|
||||
style="
|
||||
font-size: 16px;
|
||||
color: rgb(55, 65, 81);
|
||||
margin-bottom: 16px;
|
||||
line-height: 24px;
|
||||
margin-top: 16px;
|
||||
"
|
||||
>
|
||||
Dear Valued Customer,
|
||||
</p>
|
||||
<p
|
||||
style="
|
||||
font-size: 16px;
|
||||
color: rgb(55, 65, 81);
|
||||
margin-bottom: 16px;
|
||||
line-height: 24px;
|
||||
margin-top: 16px;
|
||||
"
|
||||
>
|
||||
We would like to provide you with an update regarding the current system
|
||||
status.
|
||||
</p>
|
||||
<p
|
||||
style="
|
||||
font-size: 16px;
|
||||
color: rgb(55, 65, 81);
|
||||
margin-bottom: 16px;
|
||||
line-height: 24px;
|
||||
margin-top: 16px;
|
||||
"
|
||||
>
|
||||
<strong>Update:</strong>
|
||||
{{update_text}}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="{{cta_url}}"
|
||||
style="
|
||||
background-color: rgb(22, 163, 74);
|
||||
color: rgb(255, 255, 255);
|
||||
font-weight: 700;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
text-decoration-line: none;
|
||||
text-align: center;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
line-height: 100%;
|
||||
text-decoration: none;
|
||||
max-width: 100%;
|
||||
mso-padding-alt: 0px;
|
||||
padding: 12px 24px 12px 24px;
|
||||
"
|
||||
target="_blank"
|
||||
><span
|
||||
><!--[if mso
|
||||
]><i style="mso-font-width: 400%; mso-text-raise: 18" hidden
|
||||
>   </i
|
||||
><!
|
||||
[endif]--></span
|
||||
><span
|
||||
style="
|
||||
max-width: 100%;
|
||||
display: inline-block;
|
||||
line-height: 120%;
|
||||
mso-padding-alt: 0px;
|
||||
mso-text-raise: 9px;
|
||||
"
|
||||
>{{cta_text}}</span
|
||||
><span
|
||||
><!--[if mso
|
||||
]><i style="mso-font-width: 400%" hidden
|
||||
>   ​</i
|
||||
><!
|
||||
[endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr
|
||||
style="
|
||||
border-top-width: 1px;
|
||||
border-color: rgb(209, 213, 219);
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-top: 1px solid #eaeaea;
|
||||
"
|
||||
/>
|
||||
<footer>
|
||||
<p style="text-align: center; color: #6b7280; font-size: 16px; margin: 24px 0 0 0">
|
||||
Thank you,<br />The {{site_name}} Team
|
||||
</p>
|
||||
</footer>
|
||||
<div style="height: 32px"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!--7--><!--/$-->
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export default {
|
||||
template_name: "Default Email Update",
|
||||
template_type: "EMAIL",
|
||||
template_usage: "SUBSCRIPTION",
|
||||
template_json: JSON.stringify(
|
||||
{
|
||||
email_subject: "{{update_subject}}",
|
||||
email_body: emailTemplate,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
};
|
||||
@@ -165,6 +165,12 @@ export interface TriggerRecordParsed<T extends TriggerMetaJson = TriggerMetaJson
|
||||
export interface TriggerMetaEmailJson {
|
||||
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 {
|
||||
@@ -741,7 +747,7 @@ export interface MonitorAlertV2WithConfig extends MonitorAlertV2Record {
|
||||
|
||||
// ============ templates table ============
|
||||
export type TemplateType = "EMAIL" | "WEBHOOK" | "SLACK" | "DISCORD";
|
||||
export type TemplateUsageType = "ALERT" | "SUBSCRIPTION";
|
||||
export type TemplateUsageType = "ALERT" | "SUBSCRIPTION" | "GENERAL";
|
||||
|
||||
// Template JSON types for each template type
|
||||
export interface EmailTemplateJson {
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
</Item.Content>
|
||||
</Item.Root>
|
||||
<div class="my-4 flex justify-end gap-2">
|
||||
<ThemePlus showEventsButton={true} showHomeButton={true} />
|
||||
<ThemePlus showEventsButton={true} showHomeButton={true} incident_ids={[data.incident.id]} />
|
||||
</div>
|
||||
<!-- Incident Meta -->
|
||||
<div class="mb-4 flex flex-col items-start gap-4 rounded-3xl border p-4 text-sm">
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
import Columns3CogIcon from "@lucide/svelte/icons/columns-3-cog";
|
||||
import SiteHeader from "./manage/site-header.svelte";
|
||||
import TemplateIcon from "@lucide/svelte/icons/layout-template";
|
||||
import EmailTemplateIcon from "@lucide/svelte/icons/mail-plus";
|
||||
import VaultIcon from "@lucide/svelte/icons/vault";
|
||||
|
||||
import { Toaster } from "$lib/components/ui/sonner/index.js";
|
||||
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
|
||||
|
||||
@@ -49,7 +52,9 @@
|
||||
{ title: "Users", url: "/manage/app/users", icon: UsersIcon },
|
||||
{ title: "Badges", url: "/manage/app/badges", icon: BadgeIcon },
|
||||
{ title: "Embed", url: "/manage/app/embed", icon: CodeIcon },
|
||||
{ title: "Templates", url: "/manage/app/templates", icon: TemplateIcon }
|
||||
{ title: "Templates", url: "/manage/app/templates", icon: TemplateIcon },
|
||||
{ title: "Email Customization", url: "/manage/app/email-customization", icon: EmailTemplateIcon },
|
||||
{ title: "Vault", url: "/manage/app/vault", icon: VaultIcon }
|
||||
];
|
||||
|
||||
// Derive page title from current URL
|
||||
|
||||
@@ -120,8 +120,26 @@ import {
|
||||
DeleteUserSubscription,
|
||||
UpdateUserSubscriptionStatus,
|
||||
} from "$lib/server/controllers/userSubscriptionsController.js";
|
||||
import {
|
||||
GetAllEmailTemplateConfigsWithTemplates,
|
||||
UpdateAllEmailTemplateConfigs,
|
||||
GetEmailTemplates,
|
||||
EMAIL_TYPE_LABELS,
|
||||
EMAIL_TYPE_DESCRIPTIONS,
|
||||
} from "$lib/server/controllers/emailTemplateConfigController.js";
|
||||
import {
|
||||
GetAllSecrets,
|
||||
GetSecretById,
|
||||
CreateSecret,
|
||||
UpdateSecret,
|
||||
DeleteSecret,
|
||||
} from "$lib/server/controllers/vaultController.js";
|
||||
import type { AlertData, SiteDataForNotification } from "$lib/server/notification/variables";
|
||||
import { alertToVariables, siteDataToVariables } from "$lib/server/notification/notification_utils";
|
||||
import {
|
||||
alertToVariables,
|
||||
getEmailConfigFromTriggerMeta,
|
||||
siteDataToVariables,
|
||||
} from "$lib/server/notification/notification_utils";
|
||||
import type {
|
||||
TriggerRecordParsed,
|
||||
TriggerMetaEmailJson,
|
||||
@@ -351,11 +369,14 @@ export async function POST({ request, cookies }) {
|
||||
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(
|
||||
triggerParsed as TriggerRecordParsed<TriggerMetaEmailJson>,
|
||||
emailSendingConfig,
|
||||
templateAlertVars as AlertData,
|
||||
template,
|
||||
templateSiteVars,
|
||||
toAddresses,
|
||||
);
|
||||
} else if (trigger.trigger_type === "slack") {
|
||||
resp = await sendSlack(
|
||||
@@ -530,7 +551,7 @@ export async function POST({ request, cookies }) {
|
||||
} else if (action == "getTemplatesByUsage") {
|
||||
resp = await GetTemplatesByUsage(data.template_usage);
|
||||
} else if (action == "getTemplatesByTypeAndUsage") {
|
||||
resp = await GetTemplatesByTypeAndUsage(data.template_type, data.template_usage);
|
||||
resp = await GetTemplatesByTypeAndUsage(data.template_type, data.template_usages);
|
||||
} else if (action == "deleteTemplate") {
|
||||
AdminEditorCan(userDB.role);
|
||||
await DeleteTemplate(data.id);
|
||||
@@ -629,6 +650,67 @@ export async function POST({ request, cookies }) {
|
||||
}
|
||||
resp = await UpdateUserSubscriptionStatus(subscriptionId, status);
|
||||
}
|
||||
// ============ Email Template Config ============
|
||||
else if (action == "getEmailTemplateConfigs") {
|
||||
const configs = await GetAllEmailTemplateConfigsWithTemplates();
|
||||
// Add labels and descriptions
|
||||
resp = configs.map((config) => ({
|
||||
...config,
|
||||
label: EMAIL_TYPE_LABELS[config.email_type as keyof typeof EMAIL_TYPE_LABELS] || config.email_type,
|
||||
description: EMAIL_TYPE_DESCRIPTIONS[config.email_type as keyof typeof EMAIL_TYPE_DESCRIPTIONS] || "",
|
||||
}));
|
||||
} else if (action == "updateEmailTemplateConfigs") {
|
||||
AdminCan(userDB.role);
|
||||
const { configs } = data;
|
||||
if (!configs || !Array.isArray(configs)) {
|
||||
throw new Error("configs array is required");
|
||||
}
|
||||
resp = await UpdateAllEmailTemplateConfigs(configs);
|
||||
} else if (action == "getEmailTemplates") {
|
||||
resp = await GetEmailTemplates();
|
||||
}
|
||||
// ============ Vault ============
|
||||
else if (action == "getVaultSecrets") {
|
||||
AdminCan(userDB.role);
|
||||
resp = await GetAllSecrets();
|
||||
} else if (action == "getVaultSecret") {
|
||||
AdminCan(userDB.role);
|
||||
const { id } = data;
|
||||
if (!id) {
|
||||
throw new Error("Secret ID is required");
|
||||
}
|
||||
resp = await GetSecretById(id);
|
||||
if (!resp) {
|
||||
throw new Error("Secret not found");
|
||||
}
|
||||
} else if (action == "createVaultSecret") {
|
||||
AdminCan(userDB.role);
|
||||
const { secret_name, secret_value } = data;
|
||||
resp = await CreateSecret(secret_name, secret_value);
|
||||
if (!resp.success) {
|
||||
throw new Error(resp.error);
|
||||
}
|
||||
} else if (action == "updateVaultSecret") {
|
||||
AdminCan(userDB.role);
|
||||
const { id, secret_name, secret_value } = data;
|
||||
if (!id) {
|
||||
throw new Error("Secret ID is required");
|
||||
}
|
||||
resp = await UpdateSecret(id, { secret_name, secret_value });
|
||||
if (!resp.success) {
|
||||
throw new Error(resp.error);
|
||||
}
|
||||
} else if (action == "deleteVaultSecret") {
|
||||
AdminCan(userDB.role);
|
||||
const { id } = data;
|
||||
if (!id) {
|
||||
throw new Error("Secret ID is required");
|
||||
}
|
||||
resp = await DeleteSecret(id);
|
||||
if (!resp.success) {
|
||||
throw new Error(resp.error);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<script lang="ts">
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import * as Select from "$lib/components/ui/select/index.js";
|
||||
import * as Alert from "$lib/components/ui/alert/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { Switch } from "$lib/components/ui/switch/index.js";
|
||||
import { Label } from "$lib/components/ui/label/index.js";
|
||||
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||
import MailIcon from "@lucide/svelte/icons/mail";
|
||||
import KeyIcon from "@lucide/svelte/icons/key";
|
||||
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
|
||||
import LockIcon from "@lucide/svelte/icons/lock";
|
||||
import SaveIcon from "@lucide/svelte/icons/save";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
// Types
|
||||
interface EmailTemplateConfig {
|
||||
id: number;
|
||||
email_type: string;
|
||||
email_template_id: number | null;
|
||||
is_active: string;
|
||||
template_name: string | null;
|
||||
label: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface TemplateRecord {
|
||||
id: number;
|
||||
template_name: string;
|
||||
template_type: string;
|
||||
}
|
||||
|
||||
// State
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let configs = $state<EmailTemplateConfig[]>([]);
|
||||
let emailTemplates = $state<TemplateRecord[]>([]);
|
||||
|
||||
// Icon mapping for email types
|
||||
const emailTypeIcons: Record<string, typeof MailIcon> = {
|
||||
email_code: KeyIcon,
|
||||
forgot_password: LockIcon,
|
||||
verify_email: ShieldCheckIcon
|
||||
};
|
||||
|
||||
// API functions
|
||||
async function loadConfigs() {
|
||||
try {
|
||||
const res = await fetch("/manage/api", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "getEmailTemplateConfigs" })
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!result.error && Array.isArray(result)) {
|
||||
configs = result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading email template configs:", error);
|
||||
toast.error("Failed to load email configurations");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const res = await fetch("/manage/api", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "getEmailTemplates" })
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!result.error && Array.isArray(result)) {
|
||||
emailTemplates = result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading templates:", error);
|
||||
toast.error("Failed to load email templates");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfigs() {
|
||||
saving = true;
|
||||
try {
|
||||
const configsToSave = configs.map((c) => ({
|
||||
email_type: c.email_type,
|
||||
email_template_id: c.email_template_id,
|
||||
is_active: c.is_active
|
||||
}));
|
||||
|
||||
const res = await fetch("/manage/api", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "updateEmailTemplateConfigs",
|
||||
data: { configs: configsToSave }
|
||||
})
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success("Email configurations saved successfully");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving email configs:", error);
|
||||
toast.error("Failed to save email configurations");
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTemplateChange(emailType: string, value: string) {
|
||||
const configIndex = configs.findIndex((c) => c.email_type === emailType);
|
||||
if (configIndex !== -1) {
|
||||
configs[configIndex].email_template_id = value === "none" ? null : parseInt(value);
|
||||
}
|
||||
}
|
||||
|
||||
function handleActiveChange(emailType: string, checked: boolean) {
|
||||
const configIndex = configs.findIndex((c) => c.email_type === emailType);
|
||||
if (configIndex !== -1) {
|
||||
configs[configIndex].is_active = checked ? "YES" : "NO";
|
||||
}
|
||||
}
|
||||
|
||||
function getTemplateName(templateId: number | null): string {
|
||||
if (!templateId) return "Not selected";
|
||||
const template = emailTemplates.find((t) => t.id === templateId);
|
||||
return template?.template_name || "Unknown template";
|
||||
}
|
||||
|
||||
// Initial load
|
||||
onMount(async () => {
|
||||
await Promise.all([loadConfigs(), loadTemplates()]);
|
||||
loading = false;
|
||||
});
|
||||
</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">
|
||||
<MailIcon class="text-muted-foreground size-6" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Email Customization</h1>
|
||||
<p class="text-muted-foreground text-sm">Configure email templates for different email types</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex h-96 items-center justify-center">
|
||||
<Spinner class="size-8" />
|
||||
</div>
|
||||
{:else}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center gap-2">
|
||||
<MailIcon class="text-muted-foreground size-5" />
|
||||
<div>
|
||||
<Card.Title>Email Template Configuration</Card.Title>
|
||||
<Card.Description>
|
||||
Select which email template to use for each email type and enable/disable them
|
||||
</Card.Description>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
{#if emailTemplates.length === 0}
|
||||
<Alert.Root variant="default">
|
||||
<Alert.Description>
|
||||
No email templates available. <a href="/manage/app/templates/new" class="text-primary underline"
|
||||
>Create an email template</a
|
||||
> first before configuring email customization.
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
{#each configs as config (config.id)}
|
||||
{@const IconComponent = emailTypeIcons[config.email_type] || MailIcon}
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 rounded-lg p-2">
|
||||
<IconComponent class="text-primary size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="font-medium">{config.label}</Label>
|
||||
<p class="text-muted-foreground text-xs">{config.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.is_active === "YES"}
|
||||
onCheckedChange={(checked) => handleActiveChange(config.email_type, checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<Label class="min-w-32 text-sm">Email Template</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={config.email_template_id ? String(config.email_template_id) : "none"}
|
||||
onValueChange={(v) => handleTemplateChange(config.email_type, v)}
|
||||
disabled={emailTemplates.length === 0}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{getTemplateName(config.email_template_id)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="none">Not selected</Select.Item>
|
||||
{#each emailTemplates as template (template.id)}
|
||||
<Select.Item value={String(template.id)}>{template.template_name}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
{#if config.is_active === "YES" && !config.email_template_id}
|
||||
<p class="text-muted-foreground mt-2 text-xs">
|
||||
⚠️ This email type is enabled but no template is selected. Emails won't be sent until a template is
|
||||
configured.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if configs.length === 0}
|
||||
<div class="text-muted-foreground py-8 text-center">
|
||||
No email configurations found. Please run database migrations.
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-end">
|
||||
<Button onclick={saveConfigs} disabled={saving || loading}>
|
||||
{#if saving}
|
||||
<Spinner class="mr-2 size-4" />
|
||||
{:else}
|
||||
<SaveIcon class="mr-2 size-4" />
|
||||
{/if}
|
||||
Save Configuration
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -105,7 +105,7 @@
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "getTemplatesByTypeAndUsage",
|
||||
data: { template_type: templateType, template_usage: "ALERT" }
|
||||
data: { template_type: templateType, template_usages: ["ALERT", "SUBSCRIPTION"] }
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
<script lang="ts">
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import { Label } from "$lib/components/ui/label/index.js";
|
||||
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||
import { Textarea } from "$lib/components/ui/textarea/index.js";
|
||||
import KeyIcon from "@lucide/svelte/icons/key";
|
||||
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||
import PencilIcon from "@lucide/svelte/icons/pencil";
|
||||
import TrashIcon from "@lucide/svelte/icons/trash-2";
|
||||
import EyeIcon from "@lucide/svelte/icons/eye";
|
||||
import EyeOffIcon from "@lucide/svelte/icons/eye-off";
|
||||
import SaveIcon from "@lucide/svelte/icons/save";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
// Types
|
||||
interface VaultSecret {
|
||||
id: number;
|
||||
secret_name: string;
|
||||
secret_value: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// State
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let secrets = $state<VaultSecret[]>([]);
|
||||
let visibleSecrets = new SvelteSet<number>();
|
||||
|
||||
// Form state
|
||||
let isEditing = $state(false);
|
||||
let editingId = $state<number | null>(null);
|
||||
let formName = $state("");
|
||||
let formValue = $state("");
|
||||
|
||||
// Delete confirmation state
|
||||
let deleteDialogOpen = $state(false);
|
||||
let secretToDelete = $state<VaultSecret | null>(null);
|
||||
let isDeleting = $state(false);
|
||||
|
||||
// API functions
|
||||
async function loadSecrets() {
|
||||
try {
|
||||
const res = await fetch("/manage/api", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "getVaultSecrets" })
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else if (Array.isArray(result)) {
|
||||
secrets = result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading secrets:", error);
|
||||
toast.error("Failed to load secrets");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSecret() {
|
||||
if (!formName.trim()) {
|
||||
toast.error("Secret name is required");
|
||||
return;
|
||||
}
|
||||
if (!formValue.trim()) {
|
||||
toast.error("Secret value is required");
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
const action = editingId ? "updateVaultSecret" : "createVaultSecret";
|
||||
const payload: Record<string, unknown> = {
|
||||
action,
|
||||
data: {
|
||||
secret_name: formName.trim(),
|
||||
secret_value: formValue
|
||||
}
|
||||
};
|
||||
if (editingId) {
|
||||
payload.data = { ...(payload.data as object), id: editingId };
|
||||
}
|
||||
|
||||
const res = await fetch("/manage/api", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success(editingId ? "Secret updated successfully" : "Secret created successfully");
|
||||
resetForm();
|
||||
await loadSecrets();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving secret:", error);
|
||||
toast.error("Failed to save secret");
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!secretToDelete) return;
|
||||
|
||||
isDeleting = true;
|
||||
try {
|
||||
const res = await fetch("/manage/api", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "deleteVaultSecret",
|
||||
data: { id: secretToDelete.id }
|
||||
})
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success("Secret deleted successfully");
|
||||
await loadSecrets();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting secret:", error);
|
||||
toast.error("Failed to delete secret");
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
deleteDialogOpen = false;
|
||||
secretToDelete = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(secret: VaultSecret) {
|
||||
editingId = secret.id;
|
||||
formName = secret.secret_name;
|
||||
formValue = secret.secret_value;
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
editingId = null;
|
||||
formName = "";
|
||||
formValue = "";
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingId = null;
|
||||
formName = "";
|
||||
formValue = "";
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
function openDeleteDialog(secret: VaultSecret) {
|
||||
secretToDelete = secret;
|
||||
deleteDialogOpen = true;
|
||||
}
|
||||
|
||||
function toggleSecretVisibility(id: number) {
|
||||
if (visibleSecrets.has(id)) {
|
||||
visibleSecrets.delete(id);
|
||||
} else {
|
||||
visibleSecrets.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
function maskValue(value: string): string {
|
||||
return "*".repeat(Math.min(value.length, 20));
|
||||
}
|
||||
|
||||
// Initial load
|
||||
onMount(async () => {
|
||||
await loadSecrets();
|
||||
loading = false;
|
||||
});
|
||||
</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">
|
||||
<KeyIcon class="text-muted-foreground size-6" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Vault</h1>
|
||||
<p class="text-muted-foreground text-sm">Securely store and manage your secrets</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if !isEditing}
|
||||
<Button onclick={startCreate}>
|
||||
<PlusIcon class="mr-2 size-4" />
|
||||
Add Secret
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex h-96 items-center justify-center">
|
||||
<Spinner class="size-8" />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Form Card -->
|
||||
{#if isEditing}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{editingId ? "Edit Secret" : "Add New Secret"}</Card.Title>
|
||||
<Card.Description>
|
||||
{editingId ? "Update the secret details below" : "Enter the secret name and value below"}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="secret-name">Secret Name</Label>
|
||||
<Input id="secret-name" placeholder="e.g., API_KEY" bind:value={formName} disabled={saving} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="secret-value">Secret Value</Label>
|
||||
<Textarea
|
||||
id="secret-value"
|
||||
placeholder="Enter secret value..."
|
||||
bind:value={formValue}
|
||||
disabled={saving}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-end gap-2">
|
||||
<Button variant="outline" onclick={resetForm} disabled={saving}>
|
||||
<XIcon class="mr-2 size-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onclick={saveSecret} disabled={saving}>
|
||||
{#if saving}
|
||||
<Spinner class="mr-2 size-4" />
|
||||
{:else}
|
||||
<SaveIcon class="mr-2 size-4" />
|
||||
{/if}
|
||||
{editingId ? "Update Secret" : "Save Secret"}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Secrets List -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center gap-2">
|
||||
<KeyIcon class="text-muted-foreground size-5" />
|
||||
<div>
|
||||
<Card.Title>Stored Secrets</Card.Title>
|
||||
<Card.Description>All secrets are encrypted using KENER_SECRET_KEY</Card.Description>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if secrets.length === 0}
|
||||
<div class="text-muted-foreground py-8 text-center">
|
||||
No secrets stored yet. Click "Add Secret" to create your first secret.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each secrets as secret (secret.id)}
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<KeyIcon class="text-muted-foreground size-4" />
|
||||
<span class="font-medium">{secret.secret_name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="bg-muted rounded px-2 py-1 font-mono text-sm">
|
||||
{visibleSecrets.has(secret.id) ? secret.secret_value : maskValue(secret.secret_value)}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 p-0"
|
||||
onclick={() => toggleSecretVisibility(secret.id)}
|
||||
>
|
||||
{#if visibleSecrets.has(secret.id)}
|
||||
<EyeOffIcon class="size-4" />
|
||||
{:else}
|
||||
<EyeIcon class="size-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onclick={() => startEdit(secret)} disabled={isEditing}>
|
||||
<PencilIcon class="mr-2 size-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onclick={() => openDeleteDialog(secret)} disabled={isEditing}>
|
||||
<TrashIcon class="mr-2 size-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<AlertDialog.Root bind:open={deleteDialogOpen}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete Secret</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Are you sure you want to delete the secret "{secretToDelete?.secret_name}"? This action cannot be undone.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel disabled={isDeleting}>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={confirmDelete}
|
||||
disabled={isDeleting}
|
||||
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{#if isDeleting}
|
||||
<Spinner class="mr-2 size-4" />
|
||||
{/if}
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
Reference in New Issue
Block a user