diff --git a/migrations/20250211040017_create_subscribers_table.js b/migrations/20250211040017_create_subscribers_table.js new file mode 100644 index 00000000..8fce51b0 --- /dev/null +++ b/migrations/20250211040017_create_subscribers_table.js @@ -0,0 +1,16 @@ +export function up(knex) { + return knex.schema.createTable("subscribers", function (table) { + table.increments("id").primary(); + table.string("email").notNullable(); + table.integer("incident_id").notNullable().defaultTo(0); + table.timestamp("created_at").defaultTo(knex.fn.now()); + table.timestamp("updated_at").defaultTo(knex.fn.now()); + table.string("status", 255).defaultTo("ACTIVE"); + table.string("token").notNullable().unique(); + table.unique(["email", "incident_id"]); + }); +} + +export function down(knex) { + return knex.schema.dropTableIfExists("subscribers"); +} diff --git a/src/lib/components/IncidentNew.svelte b/src/lib/components/IncidentNew.svelte index d9d98947..37ced440 100644 --- a/src/lib/components/IncidentNew.svelte +++ b/src/lib/components/IncidentNew.svelte @@ -7,6 +7,7 @@ import { Button } from "$lib/components/ui/button"; import GMI from "$lib/components/gmi.svelte"; import { page } from "$app/stores"; + import { createEventDispatcher } from "svelte"; export let incident; export let index; export let lang; @@ -21,6 +22,8 @@ const lastedFor = fd(startTime, endTime, selectedLang); const startedAt = fdn(startTime, selectedLang); + export let showSubButton = false; + let isFuture = false; //is future incident if (nowTime < startTime) { @@ -62,6 +65,10 @@ maintenanceBadge = "Upcoming Maintenance"; maintenanceBadgeColor = "text-upcoming-maintenance"; } + let dispatch = createEventDispatcher(); + function showSubscribeFn() { + dispatch("subscribe"); + }
@@ -101,6 +108,11 @@ {/if}
+ {#if showSubButton} +
+ +
+ {/if}
{#if incident.monitors.length > 0} diff --git a/src/lib/components/IncidentSubscribe.svelte b/src/lib/components/IncidentSubscribe.svelte new file mode 100644 index 00000000..b777fbc4 --- /dev/null +++ b/src/lib/components/IncidentSubscribe.svelte @@ -0,0 +1,90 @@ + + +
+
+ + +
+
+ {#if invalidFormMessage != ""} +
+

{invalidFormMessage}

+
+ {:else if validFormMessage != ""} +
+

{validFormMessage}

+
+ {/if} + +
+
diff --git a/src/lib/components/manage/subscriberInfo.svelte b/src/lib/components/manage/subscriberInfo.svelte new file mode 100644 index 00000000..50f03de9 --- /dev/null +++ b/src/lib/components/manage/subscriberInfo.svelte @@ -0,0 +1,151 @@ + + +
+
+ + +
+
+
+

{invalidFormMessage}

+
+ +
+
+
+ {#if loadingData} +
+ +
+ {/if} + + + + + + + + + + + + {#each subscribers as subscriber, i} + + + + + + + + {/each} + +
idemailincident_idstatustoken
{subscriber.id}{subscriber.email}{subscriber.incident_id}{subscriber.status}{subscriber.token}
+
+
+
diff --git a/src/lib/components/subscribeModal.svelte b/src/lib/components/subscribeModal.svelte new file mode 100644 index 00000000..2b7828b6 --- /dev/null +++ b/src/lib/components/subscribeModal.svelte @@ -0,0 +1,40 @@ + + +
{ + showSubscribe = false; + }} +> +
+ +
+

Subscribe to {selectedIncident.title}

+

+ Subscribe to updates for {selectedIncident.title} via email. You'll receive email notifications when incidents are + updated. +

+
+ +
+
+
diff --git a/src/lib/components/unsubscribeModal.svelte b/src/lib/components/unsubscribeModal.svelte new file mode 100644 index 00000000..3db2d285 --- /dev/null +++ b/src/lib/components/unsubscribeModal.svelte @@ -0,0 +1,135 @@ + + +{#if showUnsubscribe} + +{/if} diff --git a/src/lib/server/controllers/controller.js b/src/lib/server/controllers/controller.js index bca98757..1353e44c 100644 --- a/src/lib/server/controllers/controller.js +++ b/src/lib/server/controllers/controller.js @@ -17,6 +17,7 @@ import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import crypto from "crypto"; import { format, subMonths, addMonths, startOfMonth } from "date-fns"; +import EmailingSub from "../notification/notifSub.js"; import { UP, DOWN, DEGRADED, NO_DATA, REALTIME, SIGNAL } from "../constants.js"; import { GetMinuteStartNowTimestampUTC, GetNowTimestampUTC } from "../tool.js"; @@ -691,7 +692,13 @@ export const UpdateCommentByID = async (incident_id, comment_id, comment, state, } return c; }; -export const AddIncidentComment = async (incident_id, comment, state, commented_at) => { +export const AddIncidentComment = async (incident_id, comment, state, commented_at, notifySubscribersBool = true) => { + let toPass = { + incident_id, + comment, + state, + commented_at, + }; let incidentExists = await db.getIncidentById(incident_id); if (!incidentExists) { throw new Error(`Incident with id ${incident_id} does not exist`); @@ -716,11 +723,48 @@ export const AddIncidentComment = async (incident_id, comment, state, commented_ } } await UpdateIncident(incident_id, incidentUpdate); + if (!!notifySubscribersBool) { + try { + await NotifySubscribers(toPass); + } catch (error) { + console.log(error); + return { error: true, message: error.message }; + } + } } return c; }; +async function NotifySubscribers(data) { + let incident = await db.getIncidentById(data.incident_id); + let incidentData = { + id: incident.id, + incident_type: incident.incident_type, + incident_id: incident.id, + status: incident.state, + incident_name: incident.title, + timestamp: data.commented_at, + description: data.comment, + }; + + try { + let sendEmail = await SendEmailByIncidentID(incidentData); + console.log("Response from sending notification to subscribers... " + JSON.stringify(sendEmail.message)); + + if (sendEmail.error) { + // Return an error response + return { error: true, message: sendEmail.error || "Error in Email API response" }; + } + + return { error: false, message: "Emails sent successfully" }; + } catch (error) { + // Catch errors and return structured response + + throw new Error("Failed to send emails, " + error.message); + } +} + export const UpdateCommentStatusByID = async (incident_id, comment_id, status) => { let commentExists = await db.getIncidentCommentByIDAndIncident(incident_id, comment_id); if (!commentExists) { @@ -988,3 +1032,119 @@ export const GetSiteMap = async (cookies) => { .join("")} `; }; + +function GenerateHash(value, salt) { + const hash = crypto.createHash("sha256"); + hash.update(value + salt); + return hash.digest("hex"); +} + +async function GenerateHashedToken(data) { + try { + const uniqueData = `${data.email}:${data.incident_id}`; + const salt = crypto.randomBytes(16).toString("hex"); + const hashedToken = GenerateHash(uniqueData, salt); + return hashedToken; + } catch (err) { + console.error("Error generating token:", err.message); + throw new Error("Failed to generate hashed token"); + } +} + +export const GetSubscribers = async () => { + try { + return await db.getSubscribers(); + } catch (error) { + console.error("Error fetching subscribers:", error.message); + throw new Error("Failed to fetch subscribers: " + error.message); + } +}; + +export const SubscribeToIncidentID = async (data) => { + try { + if (await db.subscriberExists(data)) { + const subInactive = await db.subscriberIsInactive(data); + if (subInactive) { + return await db.activateSubscriberByID(data); + } + return { + error: true, + message: "Email already subscribed", + }; + } + + do { + data.token = await GenerateHashedToken(data); + } while (await db.tokenExists(data)); + + return await db.subscribeToIncidentID(data); + } catch (error) { + console.error("Error subscribing to incident ID:", error.message); + throw new Error("Failed to subscribe to incident ID: " + error.message); + } +}; + +export const GetSubscriberByIncidentID = async (data) => { + try { + return await db.getSubscriberByIncidentID(data); + } catch (error) { + console.error("Error fetching subscriber by incident ID:", error.message); + throw new Error("Failed to fetch subscriber by incident ID: " + error.message); + } +}; + +export const SendEmailByIncidentID = async (data) => { + try { + let siteData = await GetAllSiteData(); + const emailClient = new EmailingSub(siteData); + return await emailClient.send(data); + } catch (error) { + console.error("Error sending email by incident ID:", error.message); + throw error; + } +}; + +export const GetGlobalSubscribers = async () => { + try { + return await db.getGlobalSubscribers(); + } catch (error) { + console.error("Error fetching global subscribers:", error.message); + throw new Error("Failed to fetch global subscribers: " + error.message); + } +}; + +export const UnsubscribeBySubscriberToken = async (data) => { + try { + return await db.unsubscribeBySubscriberToken(data); + } catch (error) { + console.error("Error unsubscribing by token:", error.message); + throw new Error("Failed to unsubscribe by token: " + error.message); + } +}; + +export const GetSubscriberByID = async (data) => { + try { + return await db.getSubscriberByID(data); + } catch (error) { + console.error("Error fetching subscriber by ID:", error.message); + throw new Error("Failed to fetch subscriber by ID: " + error.message); + } +}; + +export const GetIncidentByID = async (data) => { + try { + return await db.getIncidentById(data.id); + } catch (error) { + console.error("Error fetching incident by ID:", error.message); + throw new Error("Failed to fetch incident by ID: " + error.message); + } +}; + +export const GetSubscriberByToken = async (data) => { + try { + return await db.getSubscriberByToken(data); + } catch (error) { + console.error("Error fetching subscriber by token:", error.message); + throw new Error("Failed to fetch subscriber by token: " + error.message); + } +}; diff --git a/src/lib/server/db/dbimpl.js b/src/lib/server/db/dbimpl.js index 5ace2f14..fdcd896c 100644 --- a/src/lib/server/db/dbimpl.js +++ b/src/lib/server/db/dbimpl.js @@ -1,4 +1,5 @@ // @ts-nocheck +import { Database } from "lucide-svelte"; import { GetMinuteStartNowTimestampUTC } from "../tool.js"; import { MANUAL, SIGNAL } from "../constants.js"; import Knex from "knex"; @@ -723,6 +724,81 @@ class DbImpl { async getIncidentCommentByID(id) { return await this.knex("incident_comments").where({ id }).first(); } + //getIncidentCommentByID + async getIncidentCommentByID(id) { + return await this.knex("incident_comments").where({ id }).first(); + } + //validate if subscriber exists + async subscriberExists(data) { + const result = await this.knex("subscribers") + .where({ + email: data.email, + incident_id: data.incident_id, + }) + .first(); + return !!result; + } + + async tokenExists(data) { + const result = await this.knex("subscribers").where("token", data.token).first(); + return !!result; + } + + //get all subscribers + async getSubscribers() { + return await this.knex("subscribers").orderBy("id", "desc"); + } + async getGlobalSubscribers() { + return await this.knex("subscribers").where("incident_id", 0).where("status", "ACTIVE"); + } + + //subscriber subscribe to incident + async subscribeToIncidentID(data) { + return await this.knex("subscribers").insert({ + email: data.email, + incident_id: data.incident_id, + token: data.token, + }); + } + + async getSubscriberByIncidentID(data) { + return await this.knex("subscribers").where("incident_id", data.incident_id).where("status", "ACTIVE"); + } + + async unsubscribeBySubscriberToken(data) { + return await this.knex("subscribers").where("token", data.token).update({ + status: "INACTIVE", + updated_at: this.knex.fn.now(), + }); + } + + async getSubscriberByID(data) { + return await this.knex("subscribers").where("id", data.id).first(); + } + + async subscriberIsInactive(data) { + const result = await this.knex("subscribers") + .where("email", data.email) + .where("status", "INACTIVE") + .where("incident_id", data.incident_id) + .first(); + return !!result; + } + + async activateSubscriberByID(data) { + let result = await this.knex("subscribers") + .where("email", data.email) + .where("incident_id", data.incident_id) + .update({ + status: "ACTIVE", + updated_at: this.knex.fn.now(), + }); + return result; + } + + async getSubscriberByToken(data) { + return await this.knex("subscribers").where("token", data.token).first(); + } } export default DbImpl; diff --git a/src/lib/server/notification/notifSub.js b/src/lib/server/notification/notifSub.js new file mode 100644 index 00000000..481a74b9 --- /dev/null +++ b/src/lib/server/notification/notifSub.js @@ -0,0 +1,243 @@ +// @ts-nocheck +import { Resend } from "resend"; +import nodemailer from "nodemailer"; +import getSMTPTransport from "./smtps.js"; +import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js"; +import { + GetGlobalSubscribers, + GetSMTPFromENV, + GetSubscriberByIncidentID, + GetSubscribers, +} from "../controllers/controller.js"; +class EmailingSub { + to = ""; + from; + siteData; + subscribers = []; + meta = { + from: "", + smtp_host: "", + smtp_port: "", + smtp_secure: "", + smtp_user: "", + smtp_pass: "", + }; + constructor(siteData) { + this.siteData = siteData; + } + + transformData(data, token) { + let formattedTime = new Date(data.timestamp * 1000).toISOString(); + let smtp = GetSMTPFromENV(); + this.meta = { + from: smtp.smtp_from_email, + smtp_host: smtp.smtp_host, + smtp_port: smtp.smtp_port, + smtp_user: smtp.smtp_user, + smtp_pass: smtp.smtp_pass, + smtp_secure: smtp.smtp_secure, + }; + let emailApiRequest = { + from: this.meta.from, + subject: `[${data.status}] ${data.incident_name} at ${formattedTime}`, + text: `Incident Update: ${data.incident_name} — STATUS: ${data.status} (as of ${formattedTime}).`, + html: "", + }; + + let bgColor = "#f4f4f4"; + if (data.status === "INVESTIGATING") { + bgColor = "#fe588a"; + } else if (data.status === "MONITORING") { + bgColor = "#ffaf4d"; + } else if (data.status === "IDENTIFIED") { + bgColor = "#b987f7"; + } else if (data.status === "RESOLVED") { + bgColor = "#7aba78"; + } + if (data.incident_type == "MAINTENANCE") { + data.status = "MAINTENANCE UPDATE"; + bgColor = "#505050"; + } + let html = ` + + + + + + Alert Notification + + + +
+ + + + +
+ + ${this.siteData.siteName} + +
+ +
+ +

${data.incident_type}

+

+ ${data.incident_name} +

+
${data.status}
+
+ + + + + + + + + + +
Status${data.status}
Time${formattedTime}
+ +

New Event Update: ${data.description}

+
+ View Status Page +
+ +
+ + + `; + + emailApiRequest.html = html; + + return emailApiRequest; + } + + type() { + return "email"; + } + + async send(data) { + // Configure the SMTP transporter using the stored SMTP details + + try { + // Fetch all subscribers for the given incidentID + let subscribers = await GetSubscriberByIncidentID(data); + let globalSubscribers = await GetGlobalSubscribers(); + + // Combine local and global subscribers + let subscriberList = [...subscribers, ...globalSubscribers].map((sub) => ({ + email: sub.email, + token: sub.token, + })); + if (!subscriberList || subscriberList.length === 0) { + throw new Error("No subscribers found for this incident."); + } + // Loop through each subscriber and send an email individually + for (let i = 0; i < subscriberList.length; i++) { + const subscriberEmail = subscriberList[i].email; + const emailBody = this.transformData(data, subscriberList[i].token); // Generate email body for each subscriber + const transporter = getSMTPTransport(this.meta); + + // Prepare email options for each subscriber + const mailOptions = { + from: emailBody.from, // Sender address + to: subscriberEmail, // Send email to one subscriber + subject: emailBody.subject, // Email subject + text: emailBody.text, // Plain text body + html: emailBody.html, // HTML body + }; + + try { + // Send email to individual subscriber + let result = await transporter.sendMail(mailOptions); + console.log(`Email sent successfully to ${subscriberEmail}:`, result); + } catch (error) { + console.error(`Error sending email to ${subscriberEmail}: ${error.message}`); + throw error; + } + } + + return { message: "Emails sent individually", success: true }; + } catch (error) { + console.error("Error sending email via SMTP: " + error.message); + throw error; + } + } +} +export default EmailingSub; diff --git a/src/routes/(kener)/+page.svelte b/src/routes/(kener)/+page.svelte index 8f8bdffa..fc8d67b7 100644 --- a/src/routes/(kener)/+page.svelte +++ b/src/routes/(kener)/+page.svelte @@ -13,9 +13,14 @@ import { scale } from "svelte/transition"; import { format } from "date-fns"; import GMI from "$lib/components/gmi.svelte"; + import SubscribeModal from "$lib/components/subscribeModal.svelte"; + import UnsubscribeModal from "$lib/components/unsubscribeModal.svelte"; export let data; + let token; + let email; let shareMenusToggle = false; + let showUnsubscribe = false; function showShareMenu(e) { shareMenusToggle = true; activeMonitor = e.detail.monitor; @@ -43,6 +48,35 @@ if (data.allRecentIncidents.length == 0) { kindOfIncidents("MAINTENANCE"); } + let isMounted = false; + + onMount(async () => { + isMounted = true; + + function checkForToken() { + let params = new URLSearchParams(window.location.search); + token = params.get("token"); + + if (token) { + openUnsubscribe(); + } + } + + checkForToken(); + window.onpopstate = () => { + checkForToken(); + }; + }); + + function openUnsubscribe() { + showUnsubscribe = true; + } + let showSubscribe = false; + let selectedIncident; + function handleSubscription(incident) { + showSubscribe = true; + selectedIncident = incident; + } @@ -141,12 +175,26 @@ {#if kindFilter == "INCIDENT"} {#each data.allRecentIncidents as incident, index} - + {/each} {:else if kindFilter == "MAINTENANCE"} {#each data.allRecentMaintenances as incident, index} - + {/each} {/if} @@ -280,3 +328,9 @@
{/if} +{#if showSubscribe} + +{/if} +{#if showUnsubscribe} + +{/if} diff --git a/src/routes/(kener)/api/subscribe/+server.js b/src/routes/(kener)/api/subscribe/+server.js new file mode 100644 index 00000000..0539e672 --- /dev/null +++ b/src/routes/(kener)/api/subscribe/+server.js @@ -0,0 +1,27 @@ +// @ts-nocheck +// @ts-ignore +import { GetIncidentByID, GetSubscriberByToken, SubscribeToIncidentID } from "$lib/server/controllers/controller"; +import { json } from "@sveltejs/kit"; + +export async function POST({ request }) { + const payload = await request.json(); + let action = payload.action; + let data = payload.data || {}; + let resp = {}; + + try { + if (action == "subscribeToIncidentID") { + resp = await SubscribeToIncidentID(data); + } else if (action == "getIncidentByID") { + resp = await GetIncidentByID(data); + } else if (action == "getSubscriberByToken") { + resp = await GetSubscriberByToken(data); + } + } catch (error) { + resp = { error: error.message }; + return json(resp, { status: 500 }); + } + return json(resp, { + status: 200, + }); +} diff --git a/src/routes/(kener)/api/unsubscribe/+server.js b/src/routes/(kener)/api/unsubscribe/+server.js new file mode 100644 index 00000000..bb7032d3 --- /dev/null +++ b/src/routes/(kener)/api/unsubscribe/+server.js @@ -0,0 +1,23 @@ +// @ts-nocheck +// @ts-ignore +import { SubscribeToIncidentID, UnsubscribeBySubscriberToken } from "$lib/server/controllers/controller"; +import { json } from "@sveltejs/kit"; + +export async function POST({ request }) { + const payload = await request.json(); + let action = payload.action; + let data = payload.data || {}; + let resp = {}; + + try { + if (action == "UnsubscribeBySubscriberToken") { + resp = await UnsubscribeBySubscriberToken(data); + } + } catch (error) { + resp = { error: error.message }; + return json(resp, { status: 500 }); + } + return json(resp, { + status: 200, + }); +} diff --git a/src/routes/(manage)/manage/(app)/+layout.svelte b/src/routes/(manage)/manage/(app)/+layout.svelte index 04c93d0f..1e890943 100644 --- a/src/routes/(manage)/manage/(app)/+layout.svelte +++ b/src/routes/(manage)/manage/(app)/+layout.svelte @@ -53,6 +53,11 @@ name: "API Keys", url: `${base}/manage/app/api-keys`, id: "/(manage)/manage/(app)/app/api-keys" + }, + { + name: "Subscribers", + url: `${base}/manage/app/subscribers`, + id: "/(manage)/manage/(app)/app/subscribers" } ]; @@ -158,7 +163,7 @@