From 5ffcf89bfc14329ae1b73b6b8b32098a6c9408ec Mon Sep 17 00:00:00 2001 From: Adhish-Krishna Date: Sat, 4 Jul 2026 18:37:55 +0530 Subject: [PATCH 01/14] fix: add authentication middleware to admin channel and plugin routes --- src/api/server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/server.ts b/src/api/server.ts index 2e6f9d9..8c5a722 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -48,8 +48,8 @@ app.get("/health", (req: Request, res: Response) => { }); app.use('/api/notification', auth_middleware, notification_router); -app.use('/api/plugins', plugins_router); -app.use('/api/admin-channels', admin_channels_router); +app.use('/api/plugins', auth_middleware, plugins_router); +app.use('/api/admin-channels', auth_middleware, admin_channels_router); app.use('/api/templates', auth_middleware, notification_templates_router); const start_server = async () => { From 88422fe89e44b96bf977fa8b5feb5c5a06181fea Mon Sep 17 00:00:00 2001 From: Adhish-Krishna Date: Sat, 4 Jul 2026 19:04:53 +0530 Subject: [PATCH 02/14] refractor: migrate all dashboard api endpoints to api service --- .../app/api/admin-channels/[id]/route.ts | 105 ---- .../app/api/admin-channels/providers/route.ts | 34 -- dashboard/app/api/admin-channels/route.ts | 93 ---- .../app/api/admin-channels/test/route.ts | 29 -- .../app/api/alerts/[id]/resolve/route.ts | 152 ------ dashboard/app/api/alerts/[id]/route.ts | 56 --- .../app/api/alerts/bulk-resolve/route.ts | 138 ------ dashboard/app/api/alerts/route.ts | 65 --- dashboard/app/api/dashboard/stats/route.ts | 73 --- dashboard/app/api/dashboard/trends/route.ts | 78 --- .../app/api/notifications/[id]/retry/route.ts | 121 ----- dashboard/app/api/notifications/[id]/route.ts | 81 ---- .../app/api/notifications/recent/route.ts | 49 -- dashboard/app/api/notifications/route.ts | 124 ----- dashboard/app/api/plugins/route.ts | 35 -- dashboard/app/api/send/route.ts | 68 --- .../app/api/templates/[template_id]/route.ts | 97 ---- dashboard/app/api/templates/route.ts | 77 --- dashboard/app/payload-studio/page.tsx | 12 +- dashboard/app/plugins/page.tsx | 13 +- dashboard/lib/api-client.ts | 25 + dashboard/lib/db.ts | 49 -- dashboard/lib/encryption.ts | 77 --- dashboard/lib/models/admin-channel.ts | 67 --- dashboard/lib/models/alert.ts | 63 --- dashboard/lib/models/notification.ts | 80 ---- dashboard/lib/models/outbox.ts | 51 -- dashboard/lib/models/system-config.ts | 29 -- dashboard/middleware.ts | 42 +- dashboard/package.json | 1 - .../admin-channels-crud.controller.ts | 231 +++++++++ src/api/controllers/alerts.controller.ts | 310 ++++++++++++ src/api/controllers/dashboard.controller.ts | 133 ++++++ ...trollers.ts => notification.controller.ts} | 0 .../notifications-management.controller.ts | 316 ++++++++++++ src/api/routes/admin-channels.routes.ts | 22 + src/api/routes/alerts.routes.ts | 23 + src/api/routes/dashboard.routes.ts | 15 + src/api/routes/notification.routes.ts | 2 +- .../routes/notifications-management.routes.ts | 27 ++ src/api/server.ts | 6 + tests/integration/api/dashboard_api.test.ts | 449 ++++++++++++++++++ .../api/notification.controller.test.ts | 2 +- 43 files changed, 1615 insertions(+), 1905 deletions(-) delete mode 100644 dashboard/app/api/admin-channels/[id]/route.ts delete mode 100644 dashboard/app/api/admin-channels/providers/route.ts delete mode 100644 dashboard/app/api/admin-channels/route.ts delete mode 100644 dashboard/app/api/admin-channels/test/route.ts delete mode 100644 dashboard/app/api/alerts/[id]/resolve/route.ts delete mode 100644 dashboard/app/api/alerts/[id]/route.ts delete mode 100644 dashboard/app/api/alerts/bulk-resolve/route.ts delete mode 100644 dashboard/app/api/alerts/route.ts delete mode 100644 dashboard/app/api/dashboard/stats/route.ts delete mode 100644 dashboard/app/api/dashboard/trends/route.ts delete mode 100644 dashboard/app/api/notifications/[id]/retry/route.ts delete mode 100644 dashboard/app/api/notifications/[id]/route.ts delete mode 100644 dashboard/app/api/notifications/recent/route.ts delete mode 100644 dashboard/app/api/notifications/route.ts delete mode 100644 dashboard/app/api/plugins/route.ts delete mode 100644 dashboard/app/api/send/route.ts delete mode 100644 dashboard/app/api/templates/[template_id]/route.ts delete mode 100644 dashboard/app/api/templates/route.ts create mode 100644 dashboard/lib/api-client.ts delete mode 100644 dashboard/lib/db.ts delete mode 100644 dashboard/lib/encryption.ts delete mode 100644 dashboard/lib/models/admin-channel.ts delete mode 100644 dashboard/lib/models/alert.ts delete mode 100644 dashboard/lib/models/notification.ts delete mode 100644 dashboard/lib/models/outbox.ts delete mode 100644 dashboard/lib/models/system-config.ts create mode 100644 src/api/controllers/admin-channels-crud.controller.ts create mode 100644 src/api/controllers/alerts.controller.ts create mode 100644 src/api/controllers/dashboard.controller.ts rename src/api/controllers/{notification.controllers.ts => notification.controller.ts} (100%) create mode 100644 src/api/controllers/notifications-management.controller.ts create mode 100644 src/api/routes/alerts.routes.ts create mode 100644 src/api/routes/dashboard.routes.ts create mode 100644 src/api/routes/notifications-management.routes.ts create mode 100644 tests/integration/api/dashboard_api.test.ts diff --git a/dashboard/app/api/admin-channels/[id]/route.ts b/dashboard/app/api/admin-channels/[id]/route.ts deleted file mode 100644 index b1b60bf..0000000 --- a/dashboard/app/api/admin-channels/[id]/route.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * API Route: /api/admin-channels/[id] - * GET, PATCH, DELETE single channel - */ - -import { NextRequest, NextResponse } from "next/server"; -import mongoose from "mongoose"; -import { connectDB } from "@/lib/db"; -import { AdminChannelModel } from "@/lib/models/admin-channel"; -import { getOrCreateEncryptionKey, encrypt } from "@/lib/encryption"; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - try { - await connectDB(); - - if (!mongoose.Types.ObjectId.isValid(id)) { - return NextResponse.json({ error: "Invalid ID" }, { status: 400 }); - } - - const channel = await AdminChannelModel.findById(id) - .select('-config') - .lean(); - - if (!channel) { - return NextResponse.json({ error: "Channel not found" }, { status: 404 }); - } - - return NextResponse.json({ channel }); - } catch (error) { - console.error("Error fetching channel:", error); - return NextResponse.json({ error: "Failed to fetch channel" }, { status: 500 }); - } -} - -export async function PATCH( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - try { - await connectDB(); - - if (!mongoose.Types.ObjectId.isValid(id)) { - return NextResponse.json({ error: "Invalid ID" }, { status: 400 }); - } - - const body = await request.json(); - const updateData: Record = {}; - - // Only update provided fields - if (body.name) updateData.name = body.name; - if (typeof body.enabled === 'boolean') updateData.enabled = body.enabled; - if (body.alert_filters) updateData.alert_filters = body.alert_filters; - - // If config is being updated, re-encrypt it - if (body.config) { - const key = await getOrCreateEncryptionKey(); - updateData.config = encrypt(JSON.stringify(body.config), key); - } - - const channel = await AdminChannelModel.findByIdAndUpdate( - id, - updateData, - { new: true } - ).select('-config').lean(); - - if (!channel) { - return NextResponse.json({ error: "Channel not found" }, { status: 404 }); - } - - return NextResponse.json({ success: true, channel }); - } catch (error) { - console.error("Error updating channel:", error); - return NextResponse.json({ error: "Failed to update channel" }, { status: 500 }); - } -} - -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - try { - await connectDB(); - - if (!mongoose.Types.ObjectId.isValid(id)) { - return NextResponse.json({ error: "Invalid ID" }, { status: 400 }); - } - - const result = await AdminChannelModel.findByIdAndDelete(id); - - if (!result) { - return NextResponse.json({ error: "Channel not found" }, { status: 404 }); - } - - return NextResponse.json({ success: true, message: "Channel deleted" }); - } catch (error) { - console.error("Error deleting channel:", error); - return NextResponse.json({ error: "Failed to delete channel" }, { status: 500 }); - } -} diff --git a/dashboard/app/api/admin-channels/providers/route.ts b/dashboard/app/api/admin-channels/providers/route.ts deleted file mode 100644 index 44086a7..0000000 --- a/dashboard/app/api/admin-channels/providers/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * API Route: GET /api/admin-channels/providers - * Fetches available admin channel providers from backend - */ - -import { NextResponse } from "next/server"; -import { API_BASE_URL } from "@/lib/api-config"; - -export const dynamic = "force-dynamic"; - -export async function GET() { - try { - const response = await fetch(`${API_BASE_URL}/api/admin-channels/providers`, { - headers: { 'Content-Type': 'application/json' }, - cache: 'no-store', - }); - - if (!response.ok) { - return NextResponse.json( - { error: "Failed to fetch providers from backend" }, - { status: response.status } - ); - } - - const data = await response.json(); - return NextResponse.json(data); - } catch (error) { - console.error("Error fetching providers:", error); - return NextResponse.json( - { error: "Could not reach backend API" }, - { status: 503 } - ); - } -} diff --git a/dashboard/app/api/admin-channels/route.ts b/dashboard/app/api/admin-channels/route.ts deleted file mode 100644 index 1e2a353..0000000 --- a/dashboard/app/api/admin-channels/route.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * API Route: /api/admin-channels - * GET - List all channels, POST - Create new channel - */ - -import { NextRequest, NextResponse } from "next/server"; -import { connectDB } from "@/lib/db"; -import { AdminChannelModel } from "@/lib/models/admin-channel"; -import { getOrCreateEncryptionKey, encrypt } from "@/lib/encryption"; -import { API_BASE_URL } from "@/lib/api-config"; - -export const dynamic = "force-dynamic"; - -export async function GET() { - try { - await connectDB(); - const channels = await AdminChannelModel.find({}) - .select('-config') // Exclude encrypted config for security - .sort({ created_at: -1 }) - .lean(); - - return NextResponse.json({ channels }); - } catch (error) { - console.error("Error fetching admin channels:", error); - return NextResponse.json( - { error: "Failed to fetch channels" }, - { status: 500 } - ); - } -} - -export async function POST(request: NextRequest) { - try { - await connectDB(); - const body = await request.json(); - const { channel_type, name, config, alert_filters } = body; - - // Validate required fields - if (!channel_type || !name || !config) { - return NextResponse.json( - { error: "Missing required fields: channel_type, name, config" }, - { status: 400 } - ); - } - - // Validate channel config against backend schema - const validationRes = await fetch(`${API_BASE_URL}/api/admin-channels/validate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ channel_type, config }), - }); - - if (!validationRes.ok) { - const errorData = await validationRes.json(); - return NextResponse.json( - { error: "Validation failed", details: errorData.errors || [] }, - { status: 400 } - ); - } - - // Encrypt config before storing - const encryptionKey = await getOrCreateEncryptionKey(); - const encryptedConfig = encrypt(JSON.stringify(config), encryptionKey); - - const channel = await AdminChannelModel.create({ - channel_type, - name, - enabled: true, - config: encryptedConfig, - alert_filters: alert_filters || { - failed_notifications: true, - service_health: true, - stuck_processing: true, - orphaned_pending: true, - ghost_delivery: false, - }, - }); - - // Return channel without encrypted config - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { config: _config, ...channelData } = channel.toObject(); - return NextResponse.json( - { success: true, channel: channelData }, - { status: 201 } - ); - } catch (error) { - console.error("Error creating admin channel:", error); - return NextResponse.json( - { error: "Failed to create channel" }, - { status: 500 } - ); - } -} diff --git a/dashboard/app/api/admin-channels/test/route.ts b/dashboard/app/api/admin-channels/test/route.ts deleted file mode 100644 index 0520c91..0000000 --- a/dashboard/app/api/admin-channels/test/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * API Route: POST /api/admin-channels/test - * Proxies test request to backend API - */ - -import { NextRequest, NextResponse } from "next/server"; -import { API_BASE_URL } from "@/lib/api-config"; - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - - // Proxy to backend API - const response = await fetch(`${API_BASE_URL}/api/admin-channels/test`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - const data = await response.json(); - return NextResponse.json(data, { status: response.status }); - } catch (error) { - console.error("Error testing channel:", error); - return NextResponse.json( - { success: false, error: "Test failed - could not reach backend" }, - { status: 500 } - ); - } -} diff --git a/dashboard/app/api/alerts/[id]/resolve/route.ts b/dashboard/app/api/alerts/[id]/resolve/route.ts deleted file mode 100644 index 9ab6aa8..0000000 --- a/dashboard/app/api/alerts/[id]/resolve/route.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * API Route: POST /api/alerts/[id]/resolve - * Resolves an alert and retries the notification - * Channel-agnostic - */ - -import { NextRequest, NextResponse } from "next/server"; -import mongoose from "mongoose"; -import { connectDB } from "@/lib/db"; -import AlertModel from "@/lib/models/alert"; -import { NotificationModel } from "@/lib/models/notification"; -import OutboxModel from "@/lib/models/outbox"; - -// Helper to get topic from channel -const getTopicForChannel = (channel: string): string => `${channel}_notification`; - -// Helper to extract channel-specific content for plugins -// Dashboard stores: content: { mock: { message: "..." } } -// Plugin expects: content: { message: "..." } -const extractChannelContent = (content: Record, channel: string): Record => { - const channelContent = content[channel] as Record | undefined; - return channelContent || content; -}; - -// Helper to get message from content (handles any channel) - currently unused -// const getMessageFromContent = (content: Record, channel: string): string | undefined => { -// const channelContent = content[channel] as Record | undefined; -// if (channelContent?.message) { -// return String(channelContent.message); -// } -// if (content.message) { -// return String(content.message); -// } -// return undefined; -// }; - -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - - try { - await connectDB(); - - const { appendWarning } = await request.json() as { - appendWarning?: boolean; - }; - console.log(`[AlertResolve] Alert ${id}: Resolving with appendWarning=${appendWarning}`); - - if (!mongoose.Types.ObjectId.isValid(id)) { - return NextResponse.json( - { error: "Invalid alert ID" }, - { status: 400 } - ); - } - - const alert = await AlertModel.findById(id); - if (!alert) { - return NextResponse.json( - { error: "Alert not found" }, - { status: 404 } - ); - } - - if (alert.resolved) { - return NextResponse.json( - { error: "Alert already resolved" }, - { status: 400 } - ); - } - - const session = await mongoose.startSession(); - - try { - await session.withTransaction(async () => { - const notification = await NotificationModel.findById(alert.notification_id); - if (!notification) { - throw new Error("Notification not found"); - } - - // Update content with warning if requested - if (appendWarning) { - const warningMessage = "\n\nāš ļø Ignore this message if you already received it!"; - const content = notification.content as Record; - - // Try to append to channel-specific message - const channelContent = content[notification.channel] as Record | undefined; - if (channelContent?.message) { - channelContent.message = String(channelContent.message) + warningMessage; - } else if (content.message) { - content.message = String(content.message) + warningMessage; - } - } - - // Reset notification to pending - notification.status = "pending"; - notification.error_message = undefined; - notification.updated_at = new Date(); - - console.log(`[AlertResolve] Notification ${notification._id}: Retrying with original provider=${notification.provider}`); - - await notification.save({ session }); - - // Create outbox entry with dynamic topic - const topic = getTopicForChannel(notification.channel); - // Extract channel-specific content for plugin validation - const rawContent = notification.content as Record; - const extractedContent = extractChannelContent(rawContent, notification.channel); - - const payload = { - notification_id: notification._id, - request_id: notification.request_id, - client_id: notification.client_id, - channel: notification.channel, - provider: notification.provider, // Use existing provider - recipient: notification.recipient, - content: extractedContent, - variables: notification.variables, - webhook_url: notification.webhook_url, - retry_count: notification.retry_count, - created_at: new Date(), - }; - - await OutboxModel.create( - [{ notification_id: notification._id, topic, payload, status: "pending" }], - { session } - ); - - // Mark alert as resolved - alert.resolved = true; - alert.resolved_at = new Date(); - await alert.save({ session }); - }); - - return NextResponse.json({ - success: true, - message: appendWarning - ? "Alert resolved and notification retried with warning" - : "Alert resolved and notification retried", - }); - } finally { - await session.endSession(); - } - } catch (error) { - console.error("Error resolving alert:", error); - return NextResponse.json( - { error: "Failed to resolve alert" }, - { status: 500 } - ); - } -} diff --git a/dashboard/app/api/alerts/[id]/route.ts b/dashboard/app/api/alerts/[id]/route.ts deleted file mode 100644 index efeb46d..0000000 --- a/dashboard/app/api/alerts/[id]/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * API Route: DELETE /api/alerts/[id] - * Dismisses an alert without retrying the notification - */ - -import { NextRequest, NextResponse } from "next/server"; -import mongoose from "mongoose"; -import { connectDB } from "@/lib/db"; -import AlertModel from "@/lib/models/alert"; - -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - - try { - await connectDB(); - - // Validate alert ID - if (!mongoose.Types.ObjectId.isValid(id)) { - return NextResponse.json( - { error: "Invalid alert ID" }, - { status: 400 } - ); - } - - // Find and update the alert - const alert = await AlertModel.findByIdAndUpdate( - id, - { - resolved: true, - resolved_at: new Date(), - }, - { new: true } - ); - - if (!alert) { - return NextResponse.json( - { error: "Alert not found" }, - { status: 404 } - ); - } - - return NextResponse.json({ - success: true, - message: "Alert dismissed", - }); - } catch (error) { - console.error("Error dismissing alert:", error); - return NextResponse.json( - { error: "Failed to dismiss alert" }, - { status: 500 } - ); - } -} diff --git a/dashboard/app/api/alerts/bulk-resolve/route.ts b/dashboard/app/api/alerts/bulk-resolve/route.ts deleted file mode 100644 index 68cfc04..0000000 --- a/dashboard/app/api/alerts/bulk-resolve/route.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * API Route: POST /api/alerts/bulk-resolve - * Resolves multiple alerts at once - * Channel-agnostic - */ - -import { NextRequest, NextResponse } from "next/server"; -import mongoose from "mongoose"; -import { connectDB } from "@/lib/db"; -import AlertModel from "@/lib/models/alert"; -import { NotificationModel } from "@/lib/models/notification"; -import OutboxModel from "@/lib/models/outbox"; - -// Helper to get topic from channel -const getTopicForChannel = (channel: string): string => `${channel}_notification`; - -// Helper to extract channel-specific content for plugins -// Dashboard stores: content: { mock: { message: "..." } } -// Plugin expects: content: { message: "..." } -const extractChannelContent = (content: Record, channel: string): Record => { - const channelContent = content[channel] as Record | undefined; - return channelContent || content; -}; - -export async function POST(request: NextRequest) { - try { - await connectDB(); - - const { appendWarning, limit = 50 } = await request.json() as { - appendWarning?: boolean; - limit?: number; - }; - console.log("[BulkResolve] Request body:", { appendWarning, limit }); - - const alerts = await AlertModel.find({ resolved: false }) - .sort({ created_at: 1 }) - .limit(limit); - - if (alerts.length === 0) { - return NextResponse.json({ - success: true, - resolved: 0, - message: "No alerts to resolve", - }); - } - - let resolved = 0; - let failed = 0; - - for (const alert of alerts) { - const session = await mongoose.startSession(); - - try { - await session.withTransaction(async () => { - const notification = await NotificationModel.findById(alert.notification_id); - if (!notification) { - failed++; - return; - } - - // Update content with warning if requested - if (appendWarning) { - const warningMessage = "\n\nāš ļø Ignore this message if you already received it!"; - const content = notification.content as Record; - - // Try to append to channel-specific message - const channelContent = content[notification.channel] as Record | undefined; - if (channelContent?.message) { - channelContent.message = String(channelContent.message) + warningMessage; - } else if (content.message) { - content.message = String(content.message) + warningMessage; - } - } - - // Reset notification to pending - notification.status = "pending"; - notification.error_message = undefined; - notification.updated_at = new Date(); - - console.log(`[BulkResolve] Notification ${notification._id}: Retrying with original provider=${notification.provider}`); - - await notification.save({ session }); - - // Create outbox entry with dynamic topic - const topic = getTopicForChannel(notification.channel); - - // Extract channel-specific content for plugin validation - const rawContent = notification.content as Record; - const extractedContent = extractChannelContent(rawContent, notification.channel); - - const payload = { - notification_id: notification._id, - request_id: notification.request_id, - client_id: notification.client_id, - channel: notification.channel, - provider: notification.provider, // Use existing provider - recipient: notification.recipient, - content: extractedContent, - variables: notification.variables, - webhook_url: notification.webhook_url, - retry_count: notification.retry_count, - created_at: new Date(), - }; - - await OutboxModel.create( - [{ notification_id: notification._id, topic, payload, status: "pending" }], - { session } - ); - - // Mark alert as resolved - alert.resolved = true; - alert.resolved_at = new Date(); - await alert.save({ session }); - - resolved++; - }); - } catch (err) { - console.error(`Failed to resolve alert ${alert._id}:`, err); - failed++; - } finally { - await session.endSession(); - } - } - - return NextResponse.json({ - success: true, - resolved, - failed, - message: `Resolved ${resolved} alerts${failed > 0 ? `, ${failed} failed` : ""}`, - }); - } catch (error) { - console.error("Error bulk resolving alerts:", error); - return NextResponse.json( - { error: "Failed to bulk resolve alerts" }, - { status: 500 } - ); - } -} diff --git a/dashboard/app/api/alerts/route.ts b/dashboard/app/api/alerts/route.ts deleted file mode 100644 index 1d35265..0000000 --- a/dashboard/app/api/alerts/route.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * API Route: GET /api/alerts - * Lists unresolved alerts with pagination - */ - -import { NextRequest, NextResponse } from "next/server"; -import { connectDB } from "@/lib/db"; -import AlertModel from "@/lib/models/alert"; - -export const dynamic = "force-dynamic"; - -export async function GET(request: NextRequest) { - try { - await connectDB(); - - const searchParams = request.nextUrl.searchParams; - const page = parseInt(searchParams.get("page") || "1", 10); - const limit = parseInt(searchParams.get("limit") || "50", 10); - const type = searchParams.get("type"); // Filter by alert type - const skip = (page - 1) * limit; - - // Build query filter - const baseFilter: { resolved: boolean; alert_type?: string } = { resolved: false }; - if (type && type !== "all") { - baseFilter.alert_type = type; - } - - // Get filtered count (for pagination) - const count = await AlertModel.countDocuments(baseFilter); - - // Get counts by alert type (always unfiltered for summary) - const countsByType = await AlertModel.aggregate([ - { $match: { resolved: false } }, - { $group: { _id: "$alert_type", count: { $sum: 1 } } } - ]); - - const byType: Record = {}; - for (const item of countsByType) { - byType[item._id] = item.count; - } - - // Get paginated alerts with optional filter - const alerts = await AlertModel.find(baseFilter) - .sort({ created_at: -1 }) - .skip(skip) - .limit(limit) - .lean(); - - return NextResponse.json({ - alerts, - count, // Total unresolved count (not just returned alerts) - byType, // Counts by alert type - page, - limit, - totalPages: Math.ceil(count / limit), - }); - } catch (error) { - console.error("Error fetching alerts:", error); - return NextResponse.json( - { error: "Failed to fetch alerts" }, - { status: 500 } - ); - } -} - diff --git a/dashboard/app/api/dashboard/stats/route.ts b/dashboard/app/api/dashboard/stats/route.ts deleted file mode 100644 index 9df03bd..0000000 --- a/dashboard/app/api/dashboard/stats/route.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Dashboard statistics API - * Returns aggregate counts for notifications with dynamic channel support - */ - -import { NextResponse } from 'next/server'; -import { connectDB } from '@/lib/db'; -import { NotificationModel } from '@/lib/models/notification'; -import type { DashboardStats } from '@/lib/types'; - -export async function GET() { - try { - await connectDB(); - - // Aggregate stats by status - const statusStats = await NotificationModel.aggregate([ - { - $group: { - _id: null, - total: { $sum: 1 }, - pending: { $sum: { $cond: [{ $eq: ["$status", "pending"] }, 1, 0] } }, - processing: { $sum: { $cond: [{ $eq: ["$status", "processing"] }, 1, 0] } }, - delivered: { $sum: { $cond: [{ $eq: ["$status", "delivered"] }, 1, 0] } }, - failed: { $sum: { $cond: [{ $eq: ["$status", "failed"] }, 1, 0] } } - } - } - ]); - - // Aggregate by channel - dynamic, not hardcoded - const channelStats = await NotificationModel.aggregate([ - { - $group: { - _id: "$channel", - count: { $sum: 1 } - } - } - ]); - - // Build dynamic byChannel object - const byChannel: Record = {}; - channelStats.forEach((item: { _id: string; count: number }) => { - if (item._id) { - byChannel[item._id] = item.count; - } - }); - - const stats: DashboardStats = statusStats.length > 0 - ? { - total: statusStats[0].total, - pending: statusStats[0].pending, - processing: statusStats[0].processing, - delivered: statusStats[0].delivered, - failed: statusStats[0].failed, - byChannel - } - : { - total: 0, - pending: 0, - processing: 0, - delivered: 0, - failed: 0, - byChannel - }; - - return NextResponse.json(stats); - } catch (error) { - console.error('Error fetching dashboard stats:', error); - return NextResponse.json( - { error: 'Failed to fetch dashboard statistics' }, - { status: 500 } - ); - } -} diff --git a/dashboard/app/api/dashboard/trends/route.ts b/dashboard/app/api/dashboard/trends/route.ts deleted file mode 100644 index eb128b8..0000000 --- a/dashboard/app/api/dashboard/trends/route.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Dashboard trends API - * Returns notification counts over time for charts - */ - -import { NextRequest, NextResponse } from 'next/server'; -import { connectDB } from '@/lib/db'; -import { NotificationModel } from '@/lib/models/notification'; - -export async function GET(request: NextRequest) { - try { - await connectDB(); - - const searchParams = request.nextUrl.searchParams; - const period = searchParams.get('period') || '24h'; - - // Calculate time range - let hoursAgo: number; - switch (period) { - case '7d': - hoursAgo = 24 * 7; - break; - case '30d': - hoursAgo = 24 * 30; - break; - case '24h': - default: - hoursAgo = 24; - break; - } - - const startDate = new Date(Date.now() - hoursAgo * 60 * 60 * 1000); - - // Aggregate by hour for 24h, by day for 7d/30d - const groupBy = period === '24h' - ? { $hour: "$created_at" } - : { $dayOfYear: "$created_at" }; - - const trends = await NotificationModel.aggregate([ - { - $match: { - created_at: { $gte: startDate } - } - }, - { - $group: { - _id: { - time: groupBy, - status: "$status" - }, - count: { $sum: 1 } - } - }, - { - $sort: { "_id.time": 1 } - } - ]); - - // Transform the data for the frontend - const formattedTrends = trends.map((item: { _id: { time: number; status: string }; count: number }) => ({ - time: item._id.time, - status: item._id.status, - count: item.count - })); - - return NextResponse.json({ - period, - startDate: startDate.toISOString(), - data: formattedTrends - }); - } catch (error) { - console.error('Error fetching trends:', error); - return NextResponse.json( - { error: 'Failed to fetch trends' }, - { status: 500 } - ); - } -} diff --git a/dashboard/app/api/notifications/[id]/retry/route.ts b/dashboard/app/api/notifications/[id]/retry/route.ts deleted file mode 100644 index 5fc3c5b..0000000 --- a/dashboard/app/api/notifications/[id]/retry/route.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Retry notification API - * Resets a failed notification to pending status for reprocessing - * Channel-agnostic - */ - -import { NextRequest, NextResponse } from 'next/server'; -import { connectDB } from '@/lib/db'; -import { NotificationModel } from '@/lib/models/notification'; -import OutboxModel from '@/lib/models/outbox'; -import { NOTIFICATION_STATUS } from '@/lib/types'; -import mongoose from 'mongoose'; - -interface RouteParams { - params: Promise<{ id: string }>; -} - -// Helper to get topic from channel -const getTopicForChannel = (channel: string): string => `${channel}_notification`; - -// Helper to extract channel-specific content for plugins -// Dashboard stores: content: { mock: { message: "..." } } -// Plugin expects: content: { message: "..." } -const extractChannelContent = (content: Record, channel: string): Record => { - const channelContent = content[channel] as Record | undefined; - return channelContent || content; -}; - -export async function POST(request: NextRequest, { params }: RouteParams) { - try { - await connectDB(); - const { id } = await params; - - // Find the notification - const notification = await NotificationModel.findById(id); - - if (!notification) { - return NextResponse.json( - { error: 'Notification not found' }, - { status: 404 } - ); - } - - // Only allow retry for failed notifications - if (notification.status !== NOTIFICATION_STATUS.failed) { - return NextResponse.json( - { error: 'Only failed notifications can be retried' }, - { status: 400 } - ); - } - - // Start a session for atomic operations - const session = await mongoose.startSession(); - - try { - await session.withTransaction(async () => { - // Update status for retry - const updateFields: Record = { - status: NOTIFICATION_STATUS.pending, - error_message: null, - retry_count: 0, - updated_at: new Date() - }; - - console.log(`[Retry] Notification ${id}: Retrying with original provider=${notification.provider}`); - - // Reset the notification status to pending - await NotificationModel.findByIdAndUpdate( - id, - updateFields, - { session, new: true } - ); - - // Determine the topic based on channel dynamically - const topic = getTopicForChannel(notification.channel); - - // Extract channel-specific content for plugin validation - const rawContent = notification.content as Record; - const extractedContent = extractChannelContent(rawContent, notification.channel); - - // Build the payload for the outbox (channel-agnostic) - const payload = { - notification_id: notification._id, - request_id: notification.request_id, - client_id: notification.client_id, - channel: notification.channel, - provider: notification.provider, // Use existing provider - recipient: notification.recipient, - content: extractedContent, - variables: notification.variables, - webhook_url: notification.webhook_url, - retry_count: 0, - created_at: new Date() - }; - - // Insert into outbox for processing - await OutboxModel.create([{ - notification_id: notification._id, - topic, - payload, - status: 'pending', - created_at: new Date(), - updated_at: new Date() - }], { session }); - }); - - return NextResponse.json({ - success: true, - message: 'Notification queued for retry' - }); - } finally { - await session.endSession(); - } - } catch (error) { - console.error('Error retrying notification:', error); - return NextResponse.json( - { error: 'Failed to retry notification' }, - { status: 500 } - ); - } -} diff --git a/dashboard/app/api/notifications/[id]/route.ts b/dashboard/app/api/notifications/[id]/route.ts deleted file mode 100644 index 599c659..0000000 --- a/dashboard/app/api/notifications/[id]/route.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Single notification API - * GET: Fetch a single notification - * DELETE: Remove a notification - */ - -import { NextRequest, NextResponse } from 'next/server'; -import { connectDB } from '@/lib/db'; -import { NotificationModel } from '@/lib/models/notification'; -import type { Notification } from '@/lib/types'; - -interface RouteParams { - params: Promise<{ id: string }>; -} - -export async function GET(request: NextRequest, { params }: RouteParams) { - try { - await connectDB(); - const { id } = await params; - - const notification = await NotificationModel.findById(id).lean(); - - if (!notification) { - return NextResponse.json( - { error: 'Notification not found' }, - { status: 404 } - ); - } - - const data: Notification = { - _id: notification._id.toString(), - request_id: notification.request_id, - client_id: notification.client_id, - client_name: notification.client_name, - channel: notification.channel, - provider: notification.provider, - recipient: notification.recipient, - content: notification.content, - variables: notification.variables ?? undefined, - webhook_url: notification.webhook_url, - status: notification.status, - scheduled_at: notification.scheduled_at, - error_message: notification.error_message, - retry_count: notification.retry_count, - created_at: notification.created_at, - updated_at: notification.updated_at - }; - - return NextResponse.json(data); - } catch (error) { - console.error('Error fetching notification:', error); - return NextResponse.json( - { error: 'Failed to fetch notification' }, - { status: 500 } - ); - } -} - -export async function DELETE(request: NextRequest, { params }: RouteParams) { - try { - await connectDB(); - const { id } = await params; - - const result = await NotificationModel.findByIdAndDelete(id); - - if (!result) { - return NextResponse.json( - { error: 'Notification not found' }, - { status: 404 } - ); - } - - return NextResponse.json({ success: true, message: 'Notification deleted' }); - } catch (error) { - console.error('Error deleting notification:', error); - return NextResponse.json( - { error: 'Failed to delete notification' }, - { status: 500 } - ); - } -} diff --git a/dashboard/app/api/notifications/recent/route.ts b/dashboard/app/api/notifications/recent/route.ts deleted file mode 100644 index fef1bf7..0000000 --- a/dashboard/app/api/notifications/recent/route.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Recent notifications API - * Returns the latest notifications for the activity feed - */ - -import { NextRequest, NextResponse } from 'next/server'; -import { connectDB } from '@/lib/db'; -import { NotificationModel } from '@/lib/models/notification'; -import type { Notification } from '@/lib/types'; - -export async function GET(request: NextRequest) { - try { - await connectDB(); - - const searchParams = request.nextUrl.searchParams; - const limit = parseInt(searchParams.get('limit') || '10'); - - const notifications = await NotificationModel.find() - .sort({ created_at: -1 }) - .limit(limit) - .lean(); - - const data: Notification[] = notifications.map((doc) => ({ - _id: doc._id.toString(), - request_id: doc.request_id, - client_id: doc.client_id, - client_name: doc.client_name, - channel: doc.channel, - recipient: doc.recipient, - content: doc.content, - variables: doc.variables ?? undefined, - webhook_url: doc.webhook_url, - status: doc.status, - scheduled_at: doc.scheduled_at, - error_message: doc.error_message, - retry_count: doc.retry_count, - created_at: doc.created_at, - updated_at: doc.updated_at - })); - - return NextResponse.json(data); - } catch (error) { - console.error('Error fetching recent notifications:', error); - return NextResponse.json( - { error: 'Failed to fetch recent notifications' }, - { status: 500 } - ); - } -} diff --git a/dashboard/app/api/notifications/route.ts b/dashboard/app/api/notifications/route.ts deleted file mode 100644 index ddd3c97..0000000 --- a/dashboard/app/api/notifications/route.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Notifications list API - * Returns paginated list of notifications with filtering - */ - -import { NextRequest, NextResponse } from 'next/server'; -import { connectDB } from '@/lib/db'; -import { NotificationModel } from '@/lib/models/notification'; -import type { PaginatedResponse, Notification, NOTIFICATION_STATUS, Channel } from '@/lib/types'; - -// Define filter type inline to avoid mongoose version issues -interface NotificationFilter { - status?: string; - channel?: string; - provider?: string; - created_at?: { $gte?: Date; $lte?: Date }; - $or?: Array>; -} - -export async function GET(request: NextRequest) { - try { - await connectDB(); - - const searchParams = request.nextUrl.searchParams; - const page = parseInt(searchParams.get('page') || '1'); - const limit = parseInt(searchParams.get('limit') || '20'); - const status = searchParams.get('status') as NOTIFICATION_STATUS | null; - const channel = searchParams.get('channel') as Channel | null; - const provider = searchParams.get('provider'); - const search = searchParams.get('search'); - const from = searchParams.get('from'); - const to = searchParams.get('to'); - - // Build filter query - const filter: NotificationFilter = {}; - - if (status) { - filter.status = status; - } - - if (channel) { - filter.channel = channel; - } - - if (provider) { - filter.provider = provider; - } - - if (search) { - // Search only indexed fields for performance - filter.$or = [ - { request_id: { $regex: search, $options: 'i' } }, - { client_id: { $regex: search, $options: 'i' } }, - { client_name: { $regex: search, $options: 'i' } } - ]; - // Also search by notification ID if it looks like a valid ObjectId - if (/^[a-f0-9]{24}$/i.test(search)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - filter.$or.push({ _id: search } as any); - } - } - - if (from || to) { - filter.created_at = {}; - if (from) filter.created_at.$gte = new Date(from); - if (to) filter.created_at.$lte = new Date(to); - } - - const skip = (page - 1) * limit; - - // Parse sort parameter - const sortBy = searchParams.get('sortBy') || 'created_at_desc'; - const [sortField, sortOrder] = sortBy.split('_').length === 3 - ? [sortBy.substring(0, sortBy.lastIndexOf('_')), sortBy.substring(sortBy.lastIndexOf('_') + 1)] - : ['created_at', 'desc']; - const sortDirection = sortOrder === 'asc' ? 1 : -1; - const sortQuery = { [sortField]: sortDirection }; - - const [notifications, total] = await Promise.all([ - NotificationModel.find(filter) - .sort(sortQuery as Record) - .skip(skip) - .limit(limit) - .lean(), - NotificationModel.countDocuments(filter) - ]); - - // Transform MongoDB documents to our type - const data: Notification[] = notifications.map((doc) => ({ - _id: doc._id.toString(), - request_id: doc.request_id, - client_id: doc.client_id, - client_name: doc.client_name, - channel: doc.channel, - provider: doc.provider, - recipient: doc.recipient, - content: doc.content, - variables: doc.variables ?? undefined, - webhook_url: doc.webhook_url, - status: doc.status, - scheduled_at: doc.scheduled_at, - error_message: doc.error_message, - retry_count: doc.retry_count, - created_at: doc.created_at, - updated_at: doc.updated_at - })); - - const response: PaginatedResponse = { - data, - total, - page, - totalPages: Math.ceil(total / limit), - limit - }; - - return NextResponse.json(response); - } catch (error) { - console.error('Error fetching notifications:', error); - return NextResponse.json( - { error: 'Failed to fetch notifications' }, - { status: 500 } - ); - } -} diff --git a/dashboard/app/api/plugins/route.ts b/dashboard/app/api/plugins/route.ts deleted file mode 100644 index 6de6f60..0000000 --- a/dashboard/app/api/plugins/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextResponse } from 'next/server'; -import { API_BASE_URL } from '@/lib/api-config'; - -/** - * GET /api/plugins - * Fetches available channels and their schemas from the notification service - */ -export async function GET() { - try { - const response = await fetch(`${API_BASE_URL}/api/plugins`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - cache: 'no-store', - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({})); - return NextResponse.json( - { error: error.message || 'Failed to fetch plugins' }, - { status: response.status } - ); - } - - const data = await response.json(); - return NextResponse.json(data); - } catch (error) { - console.error('Error fetching plugins:', error); - return NextResponse.json( - { error: 'Failed to connect to notification service' }, - { status: 503 } - ); - } -} diff --git a/dashboard/app/api/send/route.ts b/dashboard/app/api/send/route.ts deleted file mode 100644 index 1c34870..0000000 --- a/dashboard/app/api/send/route.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { NextResponse } from "next/server"; -import { API_BASE_URL, NS_API_KEY, WEBHOOK_HOST, WEBHOOK_PORT } from "@/lib/api-config"; - -/** - * Builds the webhook URL based on environment. - * In Docker: uses host.docker.internal - * Locally: uses localhost - */ -function getWebhookUrl(): string { - return `http://${WEBHOOK_HOST}:${WEBHOOK_PORT}/api/webhook`; -} - -/** - * Proxy endpoint to send notifications to the backend API. - * Automatically sets the webhook URL based on environment. - */ -export async function POST(request: Request) { - try { - const body = await request.json(); - const { type, ...payload } = body; - - if (!NS_API_KEY) { - return NextResponse.json( - { error: "NS_API_KEY not configured" }, - { status: 500 } - ); - } - - // Add webhook URL to payload - const payloadWithWebhook = { - ...payload, - webhook_url: getWebhookUrl(), - }; - - // Determine endpoint based on type - const endpoint = type === "batch" - ? `${API_BASE_URL}/api/notification/batch` - : `${API_BASE_URL}/api/notification`; - - // Forward request to notification service - const response = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${NS_API_KEY}`, - }, - body: JSON.stringify(payloadWithWebhook), - }); - - const data = await response.json(); - - if (!response.ok) { - return NextResponse.json(data, { status: response.status }); - } - - return NextResponse.json({ - success: true, - message: data.message || "Notification(s) queued for processing", - webhook_url: getWebhookUrl(), - }); - } catch (error) { - console.error("[Send API] Error:", error); - return NextResponse.json( - { error: "Failed to send notification" }, - { status: 500 } - ); - } -} diff --git a/dashboard/app/api/templates/[template_id]/route.ts b/dashboard/app/api/templates/[template_id]/route.ts deleted file mode 100644 index 99c436b..0000000 --- a/dashboard/app/api/templates/[template_id]/route.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { API_BASE_URL, NS_API_KEY } from "@/lib/api-config"; - -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ template_id: string }> } -) { - try { - if (!NS_API_KEY) { - return NextResponse.json({ error: "NS_API_KEY not configured" }, { status: 500 }); - } - - const { template_id } = await params; - const response = await fetch(`${API_BASE_URL}/api/templates/${encodeURIComponent(template_id)}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${NS_API_KEY}`, - }, - cache: "no-store", - }); - - const data = await response.json().catch(() => ({})); - if (!response.ok) { - return NextResponse.json(data, { status: response.status }); - } - - return NextResponse.json(data); - } catch (error) { - console.error("Error fetching template by id:", error); - return NextResponse.json({ error: "Failed to fetch template" }, { status: 503 }); - } -} - -export async function PUT( - request: NextRequest, - { params }: { params: Promise<{ template_id: string }> } -) { - try { - if (!NS_API_KEY) { - return NextResponse.json({ error: "NS_API_KEY not configured" }, { status: 500 }); - } - - const { template_id } = await params; - const body = await request.json(); - - const response = await fetch(`${API_BASE_URL}/api/templates/${encodeURIComponent(template_id)}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${NS_API_KEY}`, - }, - body: JSON.stringify(body), - }); - - const data = await response.json().catch(() => ({})); - if (!response.ok) { - return NextResponse.json(data, { status: response.status }); - } - - return NextResponse.json(data, { status: response.status }); - } catch (error) { - console.error("Error updating template:", error); - return NextResponse.json({ error: "Failed to update template" }, { status: 500 }); - } -} - -export async function DELETE( - _request: NextRequest, - { params }: { params: Promise<{ template_id: string }> } -) { - try { - if (!NS_API_KEY) { - return NextResponse.json({ error: "NS_API_KEY not configured" }, { status: 500 }); - } - - const { template_id } = await params; - - const response = await fetch(`${API_BASE_URL}/api/templates/${encodeURIComponent(template_id)}`, { - method: "DELETE", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${NS_API_KEY}`, - }, - }); - - const data = await response.json().catch(() => ({})); - if (!response.ok) { - return NextResponse.json(data, { status: response.status }); - } - - return NextResponse.json(data, { status: response.status }); - } catch (error) { - console.error("Error deleting template:", error); - return NextResponse.json({ error: "Failed to delete template" }, { status: 500 }); - } -} diff --git a/dashboard/app/api/templates/route.ts b/dashboard/app/api/templates/route.ts deleted file mode 100644 index 6506fc0..0000000 --- a/dashboard/app/api/templates/route.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { API_BASE_URL, NS_API_KEY } from "@/lib/api-config"; - -export async function GET(request: NextRequest) { - try { - const packageName = request.nextUrl.searchParams - .get("package_name") - ?.trim(); - - if (!NS_API_KEY) { - return NextResponse.json( - { error: "NS_API_KEY not configured" }, - { status: 500 }, - ); - } - - const backendUrl = packageName - ? `${API_BASE_URL}/api/templates?package_name=${encodeURIComponent(packageName)}` - : `${API_BASE_URL}/api/templates`; - - const response = await fetch(backendUrl, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${NS_API_KEY}`, - }, - cache: "no-store", - }); - - const data = await response.json().catch(() => ({})); - if (!response.ok) { - return NextResponse.json(data, { status: response.status }); - } - - return NextResponse.json(data); - } catch (error) { - console.error("Error fetching templates:", error); - return NextResponse.json( - { error: "Failed to fetch templates" }, - { status: 503 }, - ); - } -} - -export async function POST(request: Request) { - try { - if (!NS_API_KEY) { - return NextResponse.json( - { error: "NS_API_KEY not configured" }, - { status: 500 }, - ); - } - - const body = await request.json(); - const response = await fetch(`${API_BASE_URL}/api/templates/create`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${NS_API_KEY}`, - }, - body: JSON.stringify(body), - }); - - const data = await response.json().catch(() => ({})); - if (!response.ok) { - return NextResponse.json(data, { status: response.status }); - } - - return NextResponse.json(data, { status: response.status }); - } catch (error) { - console.error("Error creating template:", error); - return NextResponse.json( - { error: "Failed to create template" }, - { status: 500 }, - ); - } -} diff --git a/dashboard/app/payload-studio/page.tsx b/dashboard/app/payload-studio/page.tsx index dae7122..04de46d 100644 --- a/dashboard/app/payload-studio/page.tsx +++ b/dashboard/app/payload-studio/page.tsx @@ -306,8 +306,16 @@ export default function ApiDesignerPage() { ))} {channelSelections.length === 0 && ( -

- No plugins installed. Install plugins with: npm run plugin:install +

+ No plugins installed. + + View Plugin Installation Guide +

)} diff --git a/dashboard/app/plugins/page.tsx b/dashboard/app/plugins/page.tsx index 0a975fc..0652ad2 100644 --- a/dashboard/app/plugins/page.tsx +++ b/dashboard/app/plugins/page.tsx @@ -308,9 +308,16 @@ export default function PluginsPage() {

Get started by installing your first notification plugin.

- - npm run plugin:install <package-name> - + )} diff --git a/dashboard/lib/api-client.ts b/dashboard/lib/api-client.ts new file mode 100644 index 0000000..43e7dfe --- /dev/null +++ b/dashboard/lib/api-client.ts @@ -0,0 +1,25 @@ +/** + * Dashboard API Client + * Centralized client-side fetch wrapper + */ +import { withBasePath } from "./utils"; + +export async function apiRequest( + path: string, + options: RequestInit = {} +): Promise { + const response = await fetch(withBasePath(path), { + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.message || `API request failed with status ${response.status}`); + } + + return response.json(); +} diff --git a/dashboard/lib/db.ts b/dashboard/lib/db.ts deleted file mode 100644 index 1c1226c..0000000 --- a/dashboard/lib/db.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * MongoDB connection for the dashboard - * Connects to the same database as the notification service - */ - -import mongoose from 'mongoose'; - -const MONGO_URI = process.env.MONGO_URI || - 'mongodb://localhost:27017/notification_service?replicaSet=rs0'; - -interface CachedConnection { - conn: typeof mongoose | null; - promise: Promise | null; -} - -declare global { - var mongoose: CachedConnection | undefined; -} - -const cached: CachedConnection = global.mongoose || { conn: null, promise: null }; - -if (!global.mongoose) { - global.mongoose = cached; -} - -export async function connectDB(): Promise { - if (cached.conn) { - return cached.conn; - } - - if (!cached.promise) { - const opts = { - bufferCommands: false, - }; - - cached.promise = mongoose.connect(MONGO_URI, opts).then((mongoose) => { - return mongoose; - }); - } - - try { - cached.conn = await cached.promise; - } catch (e) { - cached.promise = null; - throw e; - } - - return cached.conn; -} diff --git a/dashboard/lib/encryption.ts b/dashboard/lib/encryption.ts deleted file mode 100644 index 2d766e2..0000000 --- a/dashboard/lib/encryption.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Encryption utilities for dashboard - */ - -import crypto from 'crypto'; -import { SystemConfigModel } from './models/system-config'; -import { connectDB } from './db'; - -const ALGORITHM = 'aes-256-gcm'; -const IV_LENGTH = 16; -const AUTH_TAG_LENGTH = 16; -const KEY_CONFIG_NAME = 'admin_alert_encryption_key'; - -export interface EncryptedData { - encrypted_data: string; - iv: string; - auth_tag: string; -} - -let cachedKey: Buffer | null = null; - -/** - * Get or create encryption key - * Priority: ENV var > MongoDB > Generate new - */ -export async function getOrCreateEncryptionKey(): Promise { - if (cachedKey) return cachedKey; - - // Check env var first - if (process.env.ADMIN_ALERT_ENCRYPTION_KEY) { - cachedKey = Buffer.from(process.env.ADMIN_ALERT_ENCRYPTION_KEY, 'hex'); - return cachedKey; - } - - await connectDB(); - - let config = await SystemConfigModel.findOne({ key: KEY_CONFIG_NAME }); - if (!config) { - // Generate new key - const newKey = crypto.randomBytes(32); - config = await SystemConfigModel.create({ - key: KEY_CONFIG_NAME, - value: newKey.toString('hex'), - }); - } - - cachedKey = Buffer.from(config.value, 'hex'); - return cachedKey; -} - -/** - * Encrypt plaintext using AES-256-GCM - */ -export function encrypt(plaintext: string, key: Buffer): EncryptedData { - const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); - let encrypted = cipher.update(plaintext, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - return { - encrypted_data: encrypted, - iv: iv.toString('hex'), - auth_tag: cipher.getAuthTag().toString('hex'), - }; -} - -/** - * Decrypt data using AES-256-GCM - */ -export function decrypt(data: EncryptedData, key: Buffer): string { - const iv = Buffer.from(data.iv, 'hex'); - const authTag = Buffer.from(data.auth_tag, 'hex'); - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); - decipher.setAuthTag(authTag); - let decrypted = decipher.update(data.encrypted_data, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; -} diff --git a/dashboard/lib/models/admin-channel.ts b/dashboard/lib/models/admin-channel.ts deleted file mode 100644 index b470026..0000000 --- a/dashboard/lib/models/admin-channel.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Admin Notification Channel model for dashboard - */ - -import mongoose, { Schema, Document } from 'mongoose'; -import { ADMIN_CHANNEL_TYPE } from '@/lib/types'; - -export interface AdminChannelDocument extends Document { - channel_type: string; - name: string; - enabled: boolean; - config: { - encrypted_data: string; - iv: string; - auth_tag: string; - }; - alert_filters: { - failed_notifications: boolean; - service_health: boolean; - stuck_processing: boolean; - orphaned_pending: boolean; - ghost_delivery: boolean; - }; - created_at: Date; - updated_at: Date; -} - -const adminChannelSchema = new Schema( - { - channel_type: { - type: String, - enum: ADMIN_CHANNEL_TYPE, - required: true, - }, - name: { - type: String, - required: true, - maxlength: 100, - }, - enabled: { - type: Boolean, - default: true, - }, - config: { - encrypted_data: { type: String, required: true }, - iv: { type: String, required: true }, - auth_tag: { type: String, required: true }, - }, - alert_filters: { - failed_notifications: { type: Boolean, default: true }, - service_health: { type: Boolean, default: true }, - stuck_processing: { type: Boolean, default: true }, - orphaned_pending: { type: Boolean, default: true }, - ghost_delivery: { type: Boolean, default: false }, - }, - }, - { - timestamps: { - createdAt: 'created_at', - updatedAt: 'updated_at', - }, - } -); - -export const AdminChannelModel = - mongoose.models.AdminNotificationChannel || - mongoose.model('AdminNotificationChannel', adminChannelSchema); diff --git a/dashboard/lib/models/alert.ts b/dashboard/lib/models/alert.ts deleted file mode 100644 index c16c7b4..0000000 --- a/dashboard/lib/models/alert.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Alert Model for Dashboard - * Used to display alerts for notifications requiring manual inspection. - */ - -import mongoose from "mongoose"; - -const alertSchema = new mongoose.Schema( - { - notification_id: { - type: mongoose.Schema.Types.ObjectId, - ref: "Notification", - required: true, - index: true, - }, - alert_type: { - type: String, - enum: ["ghost_delivery", "stuck_processing", "orphaned_pending"], - required: true, - index: true, - }, - reason: { - type: String, - required: true, - }, - redis_status: { - type: String, - default: null, - }, - db_status: { - type: String, - enum: ["pending", "processing", "delivered", "failed"], - required: true, - }, - retry_count: { - type: Number, - default: 0, - }, - resolved: { - type: Boolean, - default: false, - index: true, - }, - resolved_at: { - type: Date, - default: null, - }, - }, - { - timestamps: { - createdAt: "created_at", - updatedAt: "updated_at", - }, - } -); - -alertSchema.index({ notification_id: 1, alert_type: 1 }, { unique: true }); -alertSchema.index({ resolved: 1, created_at: -1 }); - -const AlertModel = - mongoose.models?.Alert || mongoose.model("Alert", alertSchema); - -export default AlertModel; diff --git a/dashboard/lib/models/notification.ts b/dashboard/lib/models/notification.ts deleted file mode 100644 index a28859b..0000000 --- a/dashboard/lib/models/notification.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Notification model for the dashboard - * Channel-agnostic - supports any channel registered via plugins - */ - -import mongoose, { Schema, Document } from 'mongoose'; -import { NOTIFICATION_STATUS, type Notification } from '@/lib/types'; - -export interface NotificationDocument extends Omit, Document { } - -const notificationSchema = new Schema( - { - request_id: { - type: String, - required: true, - }, - client_id: { - type: String, - required: true, - index: true, - }, - client_name: { - type: String, - }, - channel: { - type: String, - required: true, - index: true, - }, - provider: { - type: String, - index: true, - }, - // Dynamic recipient - structure depends on channel - recipient: { - type: Schema.Types.Mixed, - required: true, - }, - // Dynamic content - structure depends on channel - content: { - type: Schema.Types.Mixed, - required: true, - }, - variables: { - type: Map, - of: String, - }, - webhook_url: { - type: String, - required: true, - }, - status: { - type: String, - enum: Object.values(NOTIFICATION_STATUS), - default: NOTIFICATION_STATUS.pending, - index: true, - }, - scheduled_at: { - type: Date, - }, - error_message: { - type: String, - }, - retry_count: { - type: Number, - default: 0, - }, - }, - { - timestamps: { - createdAt: 'created_at', - updatedAt: 'updated_at', - }, - } -); - -// Prevent model overwrite error in development -export const NotificationModel = - mongoose.models.Notification || - mongoose.model('Notification', notificationSchema); diff --git a/dashboard/lib/models/outbox.ts b/dashboard/lib/models/outbox.ts deleted file mode 100644 index 000495f..0000000 --- a/dashboard/lib/models/outbox.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Outbox Model for Dashboard - * Channel-agnostic - topic is a dynamic string - */ - -import mongoose from "mongoose"; - -const outboxSchema = new mongoose.Schema( - { - notification_id: { - type: mongoose.Schema.Types.ObjectId, - ref: "Notification", - required: true, - index: true, - }, - topic: { - type: String, - required: true, - index: true, - }, - payload: { - type: mongoose.Schema.Types.Mixed, - required: true, - }, - status: { - type: String, - enum: ["pending", "processing", "published"], - default: "pending", - index: true, - }, - claimed_by: { - type: String, - default: null, - }, - claimed_at: { - type: Date, - default: null, - }, - }, - { - timestamps: { - createdAt: "created_at", - updatedAt: "updated_at", - }, - } -); - -const OutboxModel = - mongoose.models?.Outbox || mongoose.model("Outbox", outboxSchema); - -export default OutboxModel; diff --git a/dashboard/lib/models/system-config.ts b/dashboard/lib/models/system-config.ts deleted file mode 100644 index e515c90..0000000 --- a/dashboard/lib/models/system-config.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * System Config model for dashboard - */ - -import mongoose, { Schema, Document } from 'mongoose'; - -export interface SystemConfigDocument extends Document { - key: string; - value: string; - created_at: Date; - updated_at: Date; -} - -const systemConfigSchema = new Schema( - { - key: { type: String, required: true, unique: true }, - value: { type: String, required: true }, - }, - { - timestamps: { - createdAt: 'created_at', - updatedAt: 'updated_at', - }, - } -); - -export const SystemConfigModel = - mongoose.models.SystemConfig || - mongoose.model('SystemConfig', systemConfigSchema); diff --git a/dashboard/middleware.ts b/dashboard/middleware.ts index c8d2ca4..8bcf4c1 100644 --- a/dashboard/middleware.ts +++ b/dashboard/middleware.ts @@ -4,6 +4,29 @@ import type { NextRequest } from "next/server"; const basePath = process.env.BASE_PATH || ""; const SESSION_COOKIE_NAME = "simplens_session"; const NS_API_KEY = process.env.NS_API_KEY || ""; +const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3000"; + +const localApiPaths = [ + "/api/auth/", + "/api/runtime-config", + "/api/webhook" +]; + +function isLocalApiRoute(pathname: string): boolean { + return localApiPaths.some((p) => pathname.startsWith(p)); +} + +function proxyToBackend(request: NextRequest, pathname: string) { + const requestHeaders = new Headers(request.headers); + requestHeaders.set("Authorization", `Bearer ${NS_API_KEY}`); + + const backendUrl = new URL(`${API_BASE_URL}${pathname}${request.nextUrl.search}`); + return NextResponse.rewrite(backendUrl, { + request: { + headers: requestHeaders, + }, + }); +} // Routes that don't require authentication const publicRoutes = [ @@ -260,12 +283,15 @@ export async function middleware(request: NextRequest) { const apiKeyAuth = await validateApiKeyFromRequest(request); if (apiKeyAuth.isValid) { // Valid API key — allow the request through - if (basePath && request.nextUrl.pathname.startsWith(basePath)) { - const url = request.nextUrl.clone(); - url.pathname = pathname; - return NextResponse.rewrite(url); + if (isLocalApiRoute(pathname)) { + if (basePath && request.nextUrl.pathname.startsWith(basePath)) { + const url = request.nextUrl.clone(); + url.pathname = pathname; + return NextResponse.rewrite(url); + } + return NextResponse.next(); } - return NextResponse.next(); + return proxyToBackend(request, pathname); } // Invalid or missing API key on an API route — return 401 JSON instead of redirect return NextResponse.json( @@ -280,7 +306,11 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(loginUrl); } - // STEP 8: If we stripped base path earlier, rewrite the request for Next.js + // STEP 8: Proxy non-local API requests or perform normal next.js routing/basePath stripping + if (pathname.startsWith("/api/") && !isLocalApiRoute(pathname)) { + return proxyToBackend(request, pathname); + } + if (basePath && request.nextUrl.pathname.startsWith(basePath)) { const url = request.nextUrl.clone(); url.pathname = pathname; diff --git a/dashboard/package.json b/dashboard/package.json index 9531ac3..6b06ca7 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -34,7 +34,6 @@ "date-fns": "^4.1.0", "driver.js": "^1.4.0", "lucide-react": "^0.556.0", - "mongoose": "^9.0.1", "motion": "^12.23.25", "next": "16.0.7", "next-themes": "^0.4.6", diff --git a/src/api/controllers/admin-channels-crud.controller.ts b/src/api/controllers/admin-channels-crud.controller.ts new file mode 100644 index 0000000..3c19915 --- /dev/null +++ b/src/api/controllers/admin-channels-crud.controller.ts @@ -0,0 +1,231 @@ +import type { Request, Response } from 'express'; +import admin_channel_model from '@src/database/models/admin-channel.models.js'; +import { getOrCreateEncryptionKey } from '@src/admin-alerts/key-manager.js'; +import { encrypt } from '@src/utils/encryption.utils.js'; +import { getChannelProvider, hasChannelProvider } from '@src/admin-alerts/channel-registry.js'; +import type { AdminChannelType } from '@src/types/types.js'; +import mongoose from 'mongoose'; +import { apiLogger as logger } from '@src/workers/utils/logger.js'; + +/** + * GET /api/admin-channels + * Lists all admin channels, excluding the encrypted config + */ +export const listAdminChannels = async (req: Request, res: Response): Promise => { + try { + const channels = await admin_channel_model.find({}) + .select('-config') // Exclude encrypted config for security + .sort({ created_at: -1 }) + .lean(); + + res.json({ channels }); + } catch (error) { + logger.error('Error fetching admin channels:', error); + res.status(500).json({ error: 'Failed to fetch channels' }); + } +}; + +/** + * POST /api/admin-channels + * Creates a new admin channel (validates config first) + */ +export const createAdminChannel = async (req: Request, res: Response): Promise => { + try { + const { channel_type, name, config, alert_filters } = req.body; + + // Validate required fields + if (!channel_type || !name || !config) { + res.status(400).json({ error: 'Missing required fields: channel_type, name, config' }); + return; + } + + // Validate channel config + if (!hasChannelProvider(channel_type as AdminChannelType)) { + res.status(400).json({ error: `Unsupported channel type: ${channel_type}` }); + return; + } + + // Validate against schema + const providerInstance = getChannelProvider(channel_type as AdminChannelType, config); + const schema = providerInstance.getCredentialSchema(); + const errors: string[] = []; + + for (const field of schema) { + const value = config[field.name]; + if (field.required && (!value || value.trim() === '')) { + errors.push(`${field.label} is required`); + continue; + } + if (value && field.pattern) { + const regex = new RegExp(field.pattern); + if (!regex.test(value)) { + errors.push(`${field.label} has invalid format`); + } + } + } + + if (errors.length > 0) { + res.status(400).json({ error: 'Validation failed', details: errors }); + return; + } + + // Encrypt config before storing + const encryptionKey = await getOrCreateEncryptionKey(); + const encryptedConfig = encrypt(JSON.stringify(config), encryptionKey); + + const channel = await admin_channel_model.create({ + channel_type, + name, + enabled: true, + config: encryptedConfig, + alert_filters: alert_filters || { + failed_notifications: true, + service_health: true, + stuck_processing: true, + orphaned_pending: true, + ghost_delivery: false, + }, + }); + + const channelObj = channel.toObject(); + // Delete config from response object + delete (channelObj as Record).config; + + res.status(201).json({ success: true, channel: channelObj }); + } catch (error) { + logger.error('Error creating admin channel:', error); + res.status(500).json({ error: 'Failed to create channel' }); + } +}; + +/** + * GET /api/admin-channels/:id + * Fetches a single admin channel by id (excluding config) + */ +export const getAdminChannelById = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + res.status(400).json({ error: 'Invalid ID' }); + return; + } + + const channel = await admin_channel_model.findById(id) + .select('-config') + .lean(); + + if (!channel) { + res.status(404).json({ error: 'Channel not found' }); + return; + } + + res.json({ channel }); + } catch (error) { + logger.error('Error fetching channel:', error); + res.status(500).json({ error: 'Failed to fetch channel' }); + } +}; + +/** + * PATCH /api/admin-channels/:id + * Updates an admin channel + */ +export const updateAdminChannel = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + res.status(400).json({ error: 'Invalid ID' }); + return; + } + + const body = req.body; + const updateData: Record = {}; + + if (body.name) updateData.name = body.name; + if (typeof body.enabled === 'boolean') updateData.enabled = body.enabled; + if (body.alert_filters) updateData.alert_filters = body.alert_filters; + + // If config is being updated, validate and re-encrypt it + if (body.config) { + const existingChannel = await admin_channel_model.findById(id).lean(); + if (!existingChannel) { + res.status(404).json({ error: 'Channel not found' }); + return; + } + + const channelType = existingChannel.channel_type; + + // Validate config + const providerInstance = getChannelProvider(channelType as AdminChannelType, body.config); + const schema = providerInstance.getCredentialSchema(); + const errors: string[] = []; + + for (const field of schema) { + const value = body.config[field.name]; + if (field.required && (!value || value.trim() === '')) { + errors.push(`${field.label} is required`); + continue; + } + if (value && field.pattern) { + const regex = new RegExp(field.pattern); + if (!regex.test(value)) { + errors.push(`${field.label} has invalid format`); + } + } + } + + if (errors.length > 0) { + res.status(400).json({ error: 'Validation failed', details: errors }); + return; + } + + const key = await getOrCreateEncryptionKey(); + updateData.config = encrypt(JSON.stringify(body.config), key); + } + + const channel = await admin_channel_model.findByIdAndUpdate( + id, + updateData, + { new: true } + ).select('-config').lean(); + + if (!channel) { + res.status(404).json({ error: 'Channel not found' }); + return; + } + + res.json({ success: true, channel }); + } catch (error) { + logger.error('Error updating channel:', error); + res.status(500).json({ error: 'Failed to update channel' }); + } +}; + +/** + * DELETE /api/admin-channels/:id + * Deletes an admin channel by id + */ +export const deleteAdminChannel = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + res.status(400).json({ error: 'Invalid ID' }); + return; + } + + const result = await admin_channel_model.findByIdAndDelete(id); + + if (!result) { + res.status(404).json({ error: 'Channel not found' }); + return; + } + + res.json({ success: true, message: 'Channel deleted' }); + } catch (error) { + logger.error('Error deleting channel:', error); + res.status(500).json({ error: 'Failed to delete channel' }); + } +}; diff --git a/src/api/controllers/alerts.controller.ts b/src/api/controllers/alerts.controller.ts new file mode 100644 index 0000000..d48be66 --- /dev/null +++ b/src/api/controllers/alerts.controller.ts @@ -0,0 +1,310 @@ +import type { Request, Response } from 'express'; +import alert_model from '@src/database/models/alert.models.js'; +import notification_model from '@src/database/models/notification.models.js'; +import outbox_model from '@src/database/models/outbox.models.js'; +import { NOTIFICATION_STATUS, OUTBOX_STATUS, getTopicForChannel } from '@src/types/types.js'; +import mongoose from 'mongoose'; +import { apiLogger as logger } from '@src/workers/utils/logger.js'; + +// Helper to extract channel-specific content for plugins +// Dashboard stores: content: { mock: { message: "..." } } +// Plugin expects: content: { message: "..." } +const extractChannelContent = (content: Record, channel: string): Record => { + const channelContent = content[channel] as Record | undefined; + return channelContent || content; +}; + +/** + * GET /api/alerts + * Lists unresolved alerts with pagination and type counts + */ +export const listAlerts = async (req: Request, res: Response): Promise => { + try { + const page = parseInt(req.query.page as string || '1', 10); + const limit = parseInt(req.query.limit as string || '50', 10); + const type = req.query.type as string; // Filter by alert type + const skip = (page - 1) * limit; + + // Build query filter + const baseFilter: { resolved: boolean; alert_type?: string } = { resolved: false }; + if (type && type !== 'all') { + baseFilter.alert_type = type; + } + + // Get filtered count (for pagination) + const count = await alert_model.countDocuments(baseFilter); + + // Get counts by alert type (always unresolved for summary) + const countsByType = await alert_model.aggregate([ + { $match: { resolved: false } }, + { $group: { _id: '$alert_type', count: { $sum: 1 } } } + ]); + + const byType: Record = {}; + for (const item of countsByType) { + byType[item._id] = item.count; + } + + // Get paginated alerts with optional filter + const alerts = await alert_model.find(baseFilter) + .sort({ created_at: -1 }) + .skip(skip) + .limit(limit) + .lean(); + + res.json({ + alerts, + count, // Total unresolved count (not just returned alerts) + byType, // Counts by alert type + page, + limit, + totalPages: Math.ceil(count / limit), + }); + } catch (error) { + logger.error('Error fetching alerts:', error); + res.status(500).json({ error: 'Failed to fetch alerts' }); + } +}; + +/** + * DELETE /api/alerts/:id + * Dismisses an alert without retrying the notification + */ +export const dismissAlert = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + res.status(400).json({ error: 'Invalid alert ID' }); + return; + } + + const alert = await alert_model.findByIdAndUpdate( + id, + { + resolved: true, + resolved_at: new Date(), + }, + { new: true } + ); + + if (!alert) { + res.status(404).json({ error: 'Alert not found' }); + return; + } + + res.json({ + success: true, + message: 'Alert dismissed', + }); + } catch (error) { + logger.error('Error dismissing alert:', error); + res.status(500).json({ error: 'Failed to dismiss alert' }); + } +}; + +/** + * POST /api/alerts/:id/resolve + * Resolves an alert and retries the notification + */ +export const resolveAlert = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const { appendWarning } = req.body as { appendWarning?: boolean }; + + if (!mongoose.Types.ObjectId.isValid(id)) { + res.status(400).json({ error: 'Invalid alert ID' }); + return; + } + + const alert = await alert_model.findById(id); + if (!alert) { + res.status(404).json({ error: 'Alert not found' }); + return; + } + + if (alert.resolved) { + res.status(400).json({ error: 'Alert already resolved' }); + return; + } + + const session = await mongoose.startSession(); + + try { + await session.withTransaction(async () => { + const notification = await notification_model.findById(alert.notification_id); + if (!notification) { + throw new Error('Notification not found'); + } + + // Update content with warning if requested + if (appendWarning) { + const warningMessage = '\n\nāš ļø Ignore this message if you already received it!'; + const content = notification.content as Record; + + const channelContent = content[notification.channel] as Record | undefined; + if (channelContent?.message) { + channelContent.message = String(channelContent.message) + warningMessage; + } else if (content.message) { + content.message = String(content.message) + warningMessage; + } + } + + // Reset notification to pending + notification.status = NOTIFICATION_STATUS.pending; + notification.error_message = undefined; + notification.updated_at = new Date(); + + logger.info(`[AlertResolve] Notification ${notification._id}: Retrying with original provider=${notification.provider}`); + + await notification.save({ session }); + + // Create outbox entry + const topic = getTopicForChannel(notification.channel); + const rawContent = notification.content as Record; + const extractedContent = extractChannelContent(rawContent, notification.channel); + + const payload = { + notification_id: notification._id, + request_id: notification.request_id, + client_id: notification.client_id, + channel: notification.channel, + provider: notification.provider, + recipient: notification.recipient, + content: extractedContent, + variables: notification.variables, + webhook_url: notification.webhook_url, + retry_count: notification.retry_count, + created_at: new Date(), + }; + + await outbox_model.create( + [{ notification_id: notification._id, topic, payload, status: OUTBOX_STATUS.pending }], + { session } + ); + + // Mark alert as resolved + alert.resolved = true; + alert.resolved_at = new Date(); + await alert.save({ session }); + }); + + res.json({ + success: true, + message: appendWarning + ? 'Alert resolved and notification retried with warning' + : 'Alert resolved and notification retried', + }); + } finally { + await session.endSession(); + } + } catch (error) { + logger.error('Error resolving alert:', error); + res.status(500).json({ error: 'Failed to resolve alert' }); + } +}; + +/** + * POST /api/alerts/bulk-resolve + * Resolves multiple alerts at once + */ +export const bulkResolveAlerts = async (req: Request, res: Response): Promise => { + try { + const { appendWarning, limit = 50 } = req.body as { + appendWarning?: boolean; + limit?: number; + }; + + const alerts = await alert_model.find({ resolved: false }) + .sort({ created_at: 1 }) + .limit(limit); + + if (alerts.length === 0) { + res.json({ + success: true, + resolved: 0, + message: 'No alerts to resolve', + }); + return; + } + + let resolved = 0; + let failed = 0; + + for (const alert of alerts) { + const session = await mongoose.startSession(); + + try { + await session.withTransaction(async () => { + const notification = await notification_model.findById(alert.notification_id); + if (!notification) { + failed++; + return; + } + + if (appendWarning) { + const warningMessage = '\n\nāš ļø Ignore this message if you already received it!'; + const content = notification.content as Record; + + const channelContent = content[notification.channel] as Record | undefined; + if (channelContent?.message) { + channelContent.message = String(channelContent.message) + warningMessage; + } else if (content.message) { + content.message = String(content.message) + warningMessage; + } + } + + notification.status = NOTIFICATION_STATUS.pending; + notification.error_message = undefined; + notification.updated_at = new Date(); + + await notification.save({ session }); + + const topic = getTopicForChannel(notification.channel); + const rawContent = notification.content as Record; + const extractedContent = extractChannelContent(rawContent, notification.channel); + + const payload = { + notification_id: notification._id, + request_id: notification.request_id, + client_id: notification.client_id, + channel: notification.channel, + provider: notification.provider, + recipient: notification.recipient, + content: extractedContent, + variables: notification.variables, + webhook_url: notification.webhook_url, + retry_count: notification.retry_count, + created_at: new Date(), + }; + + await outbox_model.create( + [{ notification_id: notification._id, topic, payload, status: OUTBOX_STATUS.pending }], + { session } + ); + + alert.resolved = true; + alert.resolved_at = new Date(); + await alert.save({ session }); + + resolved++; + }); + } catch (err) { + logger.error(`Failed to resolve alert ${alert._id}:`, err); + failed++; + } finally { + await session.endSession(); + } + } + + res.json({ + success: true, + resolved, + failed, + message: `Resolved ${resolved} alerts${failed > 0 ? `, ${failed} failed` : ''}`, + }); + } catch (error) { + logger.error('Error bulk resolving alerts:', error); + res.status(500).json({ error: 'Failed to bulk resolve alerts' }); + } +}; diff --git a/src/api/controllers/dashboard.controller.ts b/src/api/controllers/dashboard.controller.ts new file mode 100644 index 0000000..bfe1d10 --- /dev/null +++ b/src/api/controllers/dashboard.controller.ts @@ -0,0 +1,133 @@ +import type { Request, Response } from 'express'; +import notification_model from '@src/database/models/notification.models.js'; +import { apiLogger as logger } from '@src/workers/utils/logger.js'; + +/** + * GET /api/dashboard/stats + * Returns notification statistics grouped by status and channel + */ +export const getDashboardStats = async (req: Request, res: Response): Promise => { + try { + // Aggregate stats by status + const statusStats = await notification_model.aggregate([ + { + $group: { + _id: null, + total: { $sum: 1 }, + pending: { $sum: { $cond: [{ $eq: ['$status', 'pending'] }, 1, 0] } }, + processing: { $sum: { $cond: [{ $eq: ['$status', 'processing'] }, 1, 0] } }, + delivered: { $sum: { $cond: [{ $eq: ['$status', 'delivered'] }, 1, 0] } }, + failed: { $sum: { $cond: [{ $eq: ['$status', 'failed'] }, 1, 0] } } + } + } + ]); + + // Aggregate by channel - dynamic + const channelStats = await notification_model.aggregate([ + { + $group: { + _id: '$channel', + count: { $sum: 1 } + } + } + ]); + + const byChannel: Record = {}; + channelStats.forEach((item: { _id: string; count: number }) => { + if (item._id) { + byChannel[item._id] = item.count; + } + }); + + const stats = statusStats.length > 0 + ? { + total: statusStats[0].total, + pending: statusStats[0].pending, + processing: statusStats[0].processing, + delivered: statusStats[0].delivered, + failed: statusStats[0].failed, + byChannel + } + : { + total: 0, + pending: 0, + processing: 0, + delivered: 0, + failed: 0, + byChannel + }; + + res.json(stats); + } catch (error) { + logger.error('Error fetching dashboard stats:', error); + res.status(500).json({ error: 'Failed to fetch dashboard statistics' }); + } +}; + +/** + * GET /api/dashboard/trends + * Returns notification counts over time for trends charts + */ +export const getDashboardTrends = async (req: Request, res: Response): Promise => { + try { + const period = (req.query.period as string) || '24h'; + + // Calculate time range + let hoursAgo: number; + switch (period) { + case '7d': + hoursAgo = 24 * 7; + break; + case '30d': + hoursAgo = 24 * 30; + break; + case '24h': + default: + hoursAgo = 24; + break; + } + + const startDate = new Date(Date.now() - hoursAgo * 60 * 60 * 1000); + + // Aggregate by hour for 24h, by day for 7d/30d + const groupBy = period === '24h' + ? { $hour: '$created_at' } + : { $dayOfYear: '$created_at' }; + + const trends = await notification_model.aggregate([ + { + $match: { + created_at: { $gte: startDate } + } + }, + { + $group: { + _id: { + time: groupBy, + status: '$status' + }, + count: { $sum: 1 } + } + }, + { + $sort: { '_id.time': 1 } + } + ]); + + // Transform the data for the frontend + const formattedTrends = trends.map((item: { _id: { time: number; status: string }; count: number }) => ({ + time: item._id.time, + status: item._id.status, + count: item.count + })); + + res.json({ + period, + startDate: startDate.toISOString(), + data: formattedTrends + }); + } catch (error) { + logger.error('Error fetching trends:', error); + res.status(500).json({ error: 'Failed to fetch trends' }); + } +}; diff --git a/src/api/controllers/notification.controllers.ts b/src/api/controllers/notification.controller.ts similarity index 100% rename from src/api/controllers/notification.controllers.ts rename to src/api/controllers/notification.controller.ts diff --git a/src/api/controllers/notifications-management.controller.ts b/src/api/controllers/notifications-management.controller.ts new file mode 100644 index 0000000..df92194 --- /dev/null +++ b/src/api/controllers/notifications-management.controller.ts @@ -0,0 +1,316 @@ +import type { Request, Response } from 'express'; +import notification_model from '@src/database/models/notification.models.js'; +import outbox_model from '@src/database/models/outbox.models.js'; +import { NOTIFICATION_STATUS, OUTBOX_STATUS, getTopicForChannel } from '@src/types/types.js'; +import mongoose from 'mongoose'; +import { apiLogger as logger } from '@src/workers/utils/logger.js'; + +// Helper to extract channel-specific content for plugins +// Dashboard stores: content: { mock: { message: "..." } } +// Plugin expects: content: { message: "..." } +const extractChannelContent = (content: Record, channel: string): Record => { + const channelContent = content[channel] as Record | undefined; + return channelContent || content; +}; + +/** + * GET /api/notifications + * Lists notifications with pagination, sorting, and filtering + */ +export const listNotifications = async (req: Request, res: Response): Promise => { + try { + const page = parseInt(req.query.page as string || '1', 10); + const limit = parseInt(req.query.limit as string || '20', 10); + const status = req.query.status as string; + const channel = req.query.channel as string; + const provider = req.query.provider as string; + const search = req.query.search as string; + const from = req.query.from as string; + const to = req.query.to as string; + + const filter: any = {}; + + if (status) { + filter.status = status; + } + + if (channel) { + filter.channel = channel; + } + + if (provider) { + filter.provider = provider; + } + + if (search) { + filter.$or = [ + { request_id: { $regex: search, $options: 'i' } }, + { client_id: { $regex: search, $options: 'i' } }, + { client_name: { $regex: search, $options: 'i' } } + ]; + if (/^[a-f0-9]{24}$/i.test(search)) { + filter.$or.push({ _id: new mongoose.Types.ObjectId(search) }); + } + } + + if (from || to) { + filter.created_at = {}; + if (from) filter.created_at.$gte = new Date(from); + if (to) filter.created_at.$lte = new Date(to); + } + + const skip = (page - 1) * limit; + + const sortBy = (req.query.sortBy as string) || 'created_at_desc'; + let sortField = 'created_at'; + let sortOrder = 'desc'; + + if (sortBy.split('_').length === 3) { + sortField = sortBy.substring(0, sortBy.lastIndexOf('_')); + sortOrder = sortBy.substring(sortBy.lastIndexOf('_') + 1); + } else { + const lastUnderscore = sortBy.lastIndexOf('_'); + if (lastUnderscore !== -1) { + sortField = sortBy.substring(0, lastUnderscore); + sortOrder = sortBy.substring(lastUnderscore + 1); + } + } + + const sortDirection = sortOrder === 'asc' ? 1 : -1; + const sortQuery = { [sortField]: sortDirection }; + + const [notifications, total] = await Promise.all([ + notification_model.find(filter) + .sort(sortQuery as Record) + .skip(skip) + .limit(limit) + .lean(), + notification_model.countDocuments(filter) + ]); + + const data = notifications.map((doc) => ({ + _id: doc._id.toString(), + request_id: doc.request_id, + client_id: doc.client_id, + client_name: doc.client_name, + channel: doc.channel, + provider: doc.provider, + recipient: doc.recipient, + content: doc.content, + variables: doc.variables ?? undefined, + webhook_url: doc.webhook_url, + status: doc.status, + scheduled_at: doc.scheduled_at, + error_message: doc.error_message, + retry_count: doc.retry_count, + created_at: doc.created_at, + updated_at: doc.updated_at + })); + + res.json({ + data, + total, + page, + totalPages: Math.ceil(total / limit), + limit + }); + } catch (error) { + logger.error('Error fetching notifications:', error); + res.status(500).json({ error: 'Failed to fetch notifications' }); + } +}; + +/** + * GET /api/notifications/recent + * Returns the latest notifications + */ +export const getRecentNotifications = async (req: Request, res: Response): Promise => { + try { + const limit = parseInt(req.query.limit as string || '10', 10); + + const notifications = await notification_model.find() + .sort({ created_at: -1 }) + .limit(limit) + .lean(); + + const data = notifications.map((doc) => ({ + _id: doc._id.toString(), + request_id: doc.request_id, + client_id: doc.client_id, + client_name: doc.client_name, + channel: doc.channel, + provider: doc.provider, + recipient: doc.recipient, + content: doc.content, + variables: doc.variables ?? undefined, + webhook_url: doc.webhook_url, + status: doc.status, + scheduled_at: doc.scheduled_at, + error_message: doc.error_message, + retry_count: doc.retry_count, + created_at: doc.created_at, + updated_at: doc.updated_at + })); + + res.json(data); + } catch (error) { + logger.error('Error fetching recent notifications:', error); + res.status(500).json({ error: 'Failed to fetch recent notifications' }); + } +}; + +/** + * GET /api/notifications/:id + * Fetches a single notification by id + */ +export const getNotificationById = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + res.status(400).json({ error: 'Invalid ID' }); + return; + } + + const notification = await notification_model.findById(id).lean(); + + if (!notification) { + res.status(404).json({ error: 'Notification not found' }); + return; + } + + res.json({ + _id: notification._id.toString(), + request_id: notification.request_id, + client_id: notification.client_id, + client_name: notification.client_name, + channel: notification.channel, + provider: notification.provider, + recipient: notification.recipient, + content: notification.content, + variables: notification.variables ?? undefined, + webhook_url: notification.webhook_url, + status: notification.status, + scheduled_at: notification.scheduled_at, + error_message: notification.error_message, + retry_count: notification.retry_count, + created_at: notification.created_at, + updated_at: notification.updated_at + }); + } catch (error) { + logger.error('Error fetching notification:', error); + res.status(500).json({ error: 'Failed to fetch notification' }); + } +}; + +/** + * DELETE /api/notifications/:id + * Deletes a single notification by id + */ +export const deleteNotification = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + res.status(400).json({ error: 'Invalid ID' }); + return; + } + + const result = await notification_model.findByIdAndDelete(id); + + if (!result) { + res.status(404).json({ error: 'Notification not found' }); + return; + } + + res.json({ success: true, message: 'Notification deleted' }); + } catch (error) { + logger.error('Error deleting notification:', error); + res.status(500).json({ error: 'Failed to delete notification' }); + } +}; + +/** + * POST /api/notifications/:id/retry + * Resets a failed notification to pending status for reprocessing + */ +export const retryNotification = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + res.status(400).json({ error: 'Invalid ID' }); + return; + } + + const notification = await notification_model.findById(id); + + if (!notification) { + res.status(404).json({ error: 'Notification not found' }); + return; + } + + if (notification.status !== NOTIFICATION_STATUS.failed) { + res.status(400).json({ error: 'Only failed notifications can be retried' }); + return; + } + + const session = await mongoose.startSession(); + + try { + await session.withTransaction(async () => { + const updateFields = { + status: NOTIFICATION_STATUS.pending, + error_message: null, + retry_count: 0, + updated_at: new Date() + }; + + logger.info(`[Retry] Notification ${id}: Retrying with original provider=${notification.provider}`); + + await notification_model.findByIdAndUpdate( + id, + updateFields, + { session, new: true } + ); + + const topic = getTopicForChannel(notification.channel); + const rawContent = notification.content as Record; + const extractedContent = extractChannelContent(rawContent, notification.channel); + + const payload = { + notification_id: notification._id, + request_id: notification.request_id, + client_id: notification.client_id, + channel: notification.channel, + provider: notification.provider, + recipient: notification.recipient, + content: extractedContent, + variables: notification.variables, + webhook_url: notification.webhook_url, + retry_count: 0, + created_at: new Date() + }; + + await outbox_model.create([{ + notification_id: notification._id, + topic, + payload, + status: OUTBOX_STATUS.pending, + created_at: new Date(), + updated_at: new Date() + }], { session }); + }); + + res.json({ + success: true, + message: 'Notification queued for retry' + }); + } finally { + await session.endSession(); + } + } catch (error) { + logger.error('Error retrying notification:', error); + res.status(500).json({ error: 'Failed to retry notification' }); + } +}; diff --git a/src/api/routes/admin-channels.routes.ts b/src/api/routes/admin-channels.routes.ts index 4802d90..dfbd469 100644 --- a/src/api/routes/admin-channels.routes.ts +++ b/src/api/routes/admin-channels.routes.ts @@ -6,6 +6,13 @@ import { Router } from 'express'; import { getProviders, testConnection, validateConfig } from '../controllers/admin-channel.controller.js'; +import { + listAdminChannels, + createAdminChannel, + getAdminChannelById, + updateAdminChannel, + deleteAdminChannel +} from '../controllers/admin-channels-crud.controller.js'; const router = Router(); @@ -18,4 +25,19 @@ router.post('/test', testConnection); // POST /admin-channels/validate - Validate config against schema router.post('/validate', validateConfig); +// GET /admin-channels - List all channels +router.get('/', listAdminChannels); + +// POST /admin-channels - Create new channel +router.post('/', createAdminChannel); + +// GET /admin-channels/:id - Get single channel +router.get('/:id', getAdminChannelById); + +// PATCH /admin-channels/:id - Update channel +router.patch('/:id', updateAdminChannel); + +// DELETE /admin-channels/:id - Delete channel +router.delete('/:id', deleteAdminChannel); + export default router; diff --git a/src/api/routes/alerts.routes.ts b/src/api/routes/alerts.routes.ts new file mode 100644 index 0000000..1fabdfd --- /dev/null +++ b/src/api/routes/alerts.routes.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import { + listAlerts, + dismissAlert, + resolveAlert, + bulkResolveAlerts +} from '../controllers/alerts.controller.js'; + +const router = Router(); + +// GET /api/alerts - List all unresolved alerts (paginated, type-filtered) +router.get('/', listAlerts); + +// DELETE /api/alerts/:id - Dismiss alert (without retry) +router.delete('/:id', dismissAlert); + +// POST /api/alerts/:id/resolve - Resolve alert (with retry) +router.post('/:id/resolve', resolveAlert); + +// POST /api/alerts/bulk-resolve - Bulk resolve alerts +router.post('/bulk-resolve', bulkResolveAlerts); + +export default router; diff --git a/src/api/routes/dashboard.routes.ts b/src/api/routes/dashboard.routes.ts new file mode 100644 index 0000000..accb296 --- /dev/null +++ b/src/api/routes/dashboard.routes.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { + getDashboardStats, + getDashboardTrends +} from '../controllers/dashboard.controller.js'; // Wait, let's verify if TS compilation uses .js extension! Yes, ES modules imports use .js extension. So dashboard.controller.js is correct. Let's write that. + +const router = Router(); + +// GET /api/dashboard/stats - Get notification status & channel breakdown +router.get('/stats', getDashboardStats); + +// GET /api/dashboard/trends - Get notification trends over time (24h, 7d, 30d) +router.get('/trends', getDashboardTrends); + +export default router; diff --git a/src/api/routes/notification.routes.ts b/src/api/routes/notification.routes.ts index 5f849be..309506b 100644 --- a/src/api/routes/notification.routes.ts +++ b/src/api/routes/notification.routes.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { batch_notification_controller, notification_controller } from "../controllers/notification.controllers.js"; +import { batch_notification_controller, notification_controller } from "../controllers/notification.controller.js"; const notification_router= Router(); diff --git a/src/api/routes/notifications-management.routes.ts b/src/api/routes/notifications-management.routes.ts new file mode 100644 index 0000000..88fb15f --- /dev/null +++ b/src/api/routes/notifications-management.routes.ts @@ -0,0 +1,27 @@ +import { Router } from 'express'; +import { + listNotifications, + getRecentNotifications, + getNotificationById, + deleteNotification, + retryNotification +} from '../controllers/notifications-management.controller.js'; + +const router = Router(); + +// GET /api/notifications - List all notifications (paginated, sorted, filtered) +router.get('/', listNotifications); + +// GET /api/notifications/recent - Activity feed +router.get('/recent', getRecentNotifications); + +// GET /api/notifications/:id - Fetch single notification +router.get('/:id', getNotificationById); + +// DELETE /api/notifications/:id - Delete single notification +router.delete('/:id', deleteNotification); + +// POST /api/notifications/:id/retry - Retry failed notification +router.post('/:id/retry', retryNotification); + +export default router; diff --git a/src/api/server.ts b/src/api/server.ts index 8c5a722..8d68b4b 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -7,6 +7,9 @@ import notification_router from './routes/notification.routes.js'; import plugins_router from './routes/plugins.routes.js'; import admin_channels_router from './routes/admin-channels.routes.js'; import notification_templates_router from '@src/api/routes/notification_templates.routes.js'; +import notifications_management_router from './routes/notifications-management.routes.js'; +import alerts_router from './routes/alerts.routes.js'; +import dashboard_router from './routes/dashboard.routes.js'; import { auth_middleware } from './middlewares/auth_middleware.js'; import http from 'http'; import helmet from 'helmet'; @@ -48,6 +51,9 @@ app.get("/health", (req: Request, res: Response) => { }); app.use('/api/notification', auth_middleware, notification_router); +app.use('/api/notifications', auth_middleware, notifications_management_router); +app.use('/api/alerts', auth_middleware, alerts_router); +app.use('/api/dashboard', auth_middleware, dashboard_router); app.use('/api/plugins', auth_middleware, plugins_router); app.use('/api/admin-channels', auth_middleware, admin_channels_router); app.use('/api/templates', auth_middleware, notification_templates_router); diff --git a/tests/integration/api/dashboard_api.test.ts b/tests/integration/api/dashboard_api.test.ts new file mode 100644 index 0000000..74bbe52 --- /dev/null +++ b/tests/integration/api/dashboard_api.test.ts @@ -0,0 +1,449 @@ +import { + describe, + it, + expect, + vi, + beforeAll, + beforeEach, +} from 'vitest'; +import request from 'supertest'; +import express from 'express'; +import { randomUUID } from 'crypto'; + +// Mock mongoose to support sessions without actual DB connections +vi.mock('mongoose', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual.default, + startSession: vi.fn().mockResolvedValue({ + startTransaction: vi.fn(), + commitTransaction: vi.fn(), + abortTransaction: vi.fn(), + endSession: vi.fn(), + withTransaction: vi.fn().mockImplementation(async (fn) => { + await fn(); + }) + }) + } + }; +}); + +// Setup Mock Models +const mockNotificationFind = vi.fn(); +const mockNotificationCountDocuments = vi.fn(); +const mockNotificationFindById = vi.fn(); +const mockNotificationFindByIdAndUpdate = vi.fn(); +const mockNotificationFindByIdAndDelete = vi.fn(); +const mockNotificationAggregate = vi.fn(); + +const mockAlertFind = vi.fn(); +const mockAlertCountDocuments = vi.fn(); +const mockAlertAggregate = vi.fn(); +const mockAlertFindById = vi.fn(); +const mockAlertFindByIdAndUpdate = vi.fn(); + +const mockAdminChannelFind = vi.fn(); +const mockAdminChannelCreate = vi.fn(); +const mockAdminChannelFindById = vi.fn(); +const mockAdminChannelFindByIdAndUpdate = vi.fn(); +const mockAdminChannelFindByIdAndDelete = vi.fn(); + +const mockOutboxCreate = vi.fn(); + +// Mock dependencies +vi.mock('../../../src/database/models/notification.models.js', () => ({ + default: { + find: () => { + const queryMock = { + sort: () => queryMock, + skip: () => queryMock, + limit: () => queryMock, + select: () => queryMock, + lean: mockNotificationFind + }; + return queryMock; + }, + countDocuments: mockNotificationCountDocuments, + findById: mockNotificationFindById, + findByIdAndUpdate: mockNotificationFindByIdAndUpdate, + findByIdAndDelete: mockNotificationFindByIdAndDelete, + aggregate: mockNotificationAggregate + } +})); + +vi.mock('../../../src/database/models/alert.models.js', () => ({ + default: { + find: () => { + const queryMock = { + sort: () => queryMock, + skip: () => queryMock, + limit: () => queryMock, + select: () => queryMock, + lean: mockAlertFind + }; + return queryMock; + }, + countDocuments: mockAlertCountDocuments, + aggregate: mockAlertAggregate, + findById: mockAlertFindById, + findByIdAndUpdate: mockAlertFindByIdAndUpdate + } +})); + +vi.mock('../../../src/database/models/admin-channel.models.js', () => ({ + default: { + find: () => { + const queryMock = { + sort: () => queryMock, + skip: () => queryMock, + limit: () => queryMock, + select: () => queryMock, + lean: mockAdminChannelFind + }; + return queryMock; + }, + create: mockAdminChannelCreate, + findById: () => { + const queryMock = { + sort: () => queryMock, + skip: () => queryMock, + limit: () => queryMock, + select: () => queryMock, + lean: mockAdminChannelFindById + }; + return queryMock; + }, + findByIdAndUpdate: () => { + const queryMock = { + sort: () => queryMock, + skip: () => queryMock, + limit: () => queryMock, + select: () => queryMock, + lean: mockAdminChannelFindByIdAndUpdate + }; + return queryMock; + }, + findByIdAndDelete: mockAdminChannelFindByIdAndDelete + } +})); + +vi.mock('../../../src/database/models/outbox.models.js', () => ({ + default: { + create: mockOutboxCreate + } +})); + +vi.mock('../../../src/workers/utils/logger.js', () => ({ + apiLogger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + success: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock Key Manager & Registry +vi.mock('../../../src/admin-alerts/key-manager.js', () => ({ + getOrCreateEncryptionKey: vi.fn().mockResolvedValue(Buffer.alloc(32, 1)), +})); + +vi.mock('../../../src/admin-alerts/channel-registry.js', () => ({ + hasChannelProvider: vi.fn().mockReturnValue(true), + getChannelProvider: vi.fn().mockReturnValue({ + getCredentialSchema: () => [{ name: 'webhook_url', required: true, label: 'Webhook URL' }], + testConnection: () => Promise.resolve({ success: true }) + }), +})); + +// Express App Creator +const createTestApp = async () => { + const app = express(); + app.use(express.json()); + + // Mock Authentication Middleware + app.use((req, res, next) => { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ message: 'API KEY missing' }); + } + next(); + }); + + const notificationsRouter = (await import('../../../src/api/routes/notifications-management.routes.js')).default; + const alertsRouter = (await import('../../../src/api/routes/alerts.routes.js')).default; + const dashboardRouter = (await import('../../../src/api/routes/dashboard.routes.js')).default; + const adminChannelsRouter = (await import('../../../src/api/routes/admin-channels.routes.js')).default; + + app.use('/api/notifications', notificationsRouter); + app.use('/api/alerts', alertsRouter); + app.use('/api/dashboard', dashboardRouter); + app.use('/api/admin-channels', adminChannelsRouter); + + return app; +}; + +describe('Dashboard APIs Integration Tests', () => { + let app: express.Express; + + beforeAll(async () => { + app = await createTestApp(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Notifications APIs', () => { + it('GET /api/notifications should return paginated list', async () => { + const mockList = [{ + _id: '507f1f77bcf86cd799439011', + request_id: randomUUID(), + client_id: randomUUID(), + channel: 'email', + recipient: { user_id: 'user1' }, + content: { email: { message: 'hello' } }, + webhook_url: 'http://cb.com', + status: 'delivered', + retry_count: 0, + created_at: new Date(), + updated_at: new Date() + }]; + + mockNotificationFind.mockResolvedValueOnce(mockList); + mockNotificationCountDocuments.mockResolvedValueOnce(1); + + const res = await request(app) + .get('/api/notifications') + .set('Authorization', 'Bearer key'); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(1); + expect(res.body.total).toBe(1); + }); + + it('GET /api/notifications/recent should return latest activities', async () => { + const mockList = [{ + _id: '507f1f77bcf86cd799439011', + request_id: randomUUID(), + client_id: randomUUID(), + channel: 'email', + recipient: { user_id: 'user1' }, + content: { email: { message: 'hello' } }, + webhook_url: 'http://cb.com', + status: 'delivered', + retry_count: 0, + created_at: new Date(), + updated_at: new Date() + }]; + + mockNotificationFind.mockResolvedValueOnce(mockList); + + const res = await request(app) + .get('/api/notifications/recent') + .set('Authorization', 'Bearer key'); + + expect(res.status).toBe(200); + expect(res.body.length).toBe(1); + }); + + it('GET /api/notifications/:id should return single notification', async () => { + const mockDoc = { + _id: '507f1f77bcf86cd799439011', + request_id: randomUUID(), + client_id: randomUUID(), + channel: 'email', + recipient: { user_id: 'user1' }, + content: { email: { message: 'hello' } }, + webhook_url: 'http://cb.com', + status: 'delivered', + retry_count: 0, + created_at: new Date(), + updated_at: new Date() + }; + + mockNotificationFindById.mockReturnValueOnce({ + lean: () => Promise.resolve(mockDoc) + }); + + const res = await request(app) + .get('/api/notifications/507f1f77bcf86cd799439011') + .set('Authorization', 'Bearer key'); + + expect(res.status).toBe(200); + expect(res.body._id).toBe('507f1f77bcf86cd799439011'); + }); + + it('DELETE /api/notifications/:id should hard delete notification', async () => { + mockNotificationFindByIdAndDelete.mockResolvedValueOnce({ _id: '507f1f77bcf86cd799439011' }); + + const res = await request(app) + .delete('/api/notifications/507f1f77bcf86cd799439011') + .set('Authorization', 'Bearer key'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it('POST /api/notifications/:id/retry should re-queue failed notification', async () => { + const mockDoc = { + _id: '507f1f77bcf86cd799439011', + request_id: randomUUID(), + client_id: randomUUID(), + channel: 'email', + recipient: { user_id: 'user1' }, + content: { email: { message: 'hello' } }, + webhook_url: 'http://cb.com', + status: 'failed', + retry_count: 1, + created_at: new Date(), + updated_at: new Date() + }; + + mockNotificationFindById.mockResolvedValueOnce(mockDoc); + mockNotificationFindByIdAndUpdate.mockResolvedValueOnce({ ...mockDoc, status: 'pending' }); + + const res = await request(app) + .post('/api/notifications/507f1f77bcf86cd799439011/retry') + .set('Authorization', 'Bearer key'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + }); + + describe('Alerts APIs', () => { + it('GET /api/alerts should return unresolved alerts', async () => { + mockAlertFind.mockResolvedValueOnce([{ _id: 'alert-1', resolved: false }]); + mockAlertCountDocuments.mockResolvedValueOnce(1); + mockAlertAggregate.mockResolvedValueOnce([{ _id: 'stuck_processing', count: 1 }]); + + const res = await request(app) + .get('/api/alerts') + .set('Authorization', 'Bearer key'); + + expect(res.status).toBe(200); + expect(res.body.alerts.length).toBe(1); + expect(res.body.byType.stuck_processing).toBe(1); + }); + + it('DELETE /api/alerts/:id should dismiss alert', async () => { + mockAlertFindByIdAndUpdate.mockResolvedValueOnce({ _id: 'alert-1', resolved: true }); + + const res = await request(app) + .delete('/api/alerts/507f1f77bcf86cd799439011') + .set('Authorization', 'Bearer key'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it('POST /api/alerts/:id/resolve should resolve and retry', async () => { + const mockAlert = { + _id: 'alert-1', + notification_id: '507f1f77bcf86cd799439011', + resolved: false, + save: vi.fn().mockResolvedValue(true) + }; + + const mockNotification = { + _id: '507f1f77bcf86cd799439011', + request_id: randomUUID(), + client_id: randomUUID(), + channel: 'email', + recipient: { user_id: 'user1' }, + content: { email: { message: 'hello' } }, + webhook_url: 'http://cb.com', + status: 'failed', + retry_count: 1, + save: vi.fn().mockResolvedValue(true) + }; + + mockAlertFindById.mockResolvedValueOnce(mockAlert); + mockNotificationFindById.mockResolvedValueOnce(mockNotification); + + const res = await request(app) + .post('/api/alerts/507f1f77bcf86cd799439011/resolve') + .set('Authorization', 'Bearer key') + .send({ appendWarning: true }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + }); + + describe('Admin Channels APIs', () => { + it('GET /api/admin-channels should list channels', async () => { + mockAdminChannelFind.mockResolvedValueOnce([{ _id: 'channel-1', name: 'Discord' }]); + + const res = await request(app) + .get('/api/admin-channels') + .set('Authorization', 'Bearer key'); + + expect(res.status).toBe(200); + expect(res.body.channels.length).toBe(1); + }); + + it('POST /api/admin-channels should create channel', async () => { + mockAdminChannelCreate.mockResolvedValueOnce({ + toObject: () => ({ _id: 'channel-1', name: 'Slack' }) + }); + + const res = await request(app) + .post('/api/admin-channels') + .set('Authorization', 'Bearer key') + .send({ + channel_type: 'slack', + name: 'Slack Alerts', + config: { webhook_url: 'https://slack.com/hook' } + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.channel.name).toBe('Slack'); + }); + + it('DELETE /api/admin-channels/:id should delete channel', async () => { + mockAdminChannelFindByIdAndDelete.mockResolvedValueOnce({ _id: 'channel-1' }); + + const res = await request(app) + .delete('/api/admin-channels/507f1f77bcf86cd799439011') + .set('Authorization', 'Bearer key'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + }); + + describe('Dashboard Analytics APIs', () => { + it('GET /api/dashboard/stats should return stats', async () => { + mockNotificationAggregate + .mockResolvedValueOnce([{ _id: null, total: 2, pending: 1, failed: 1 }]) // status stats + .mockResolvedValueOnce([{ _id: 'email', count: 2 }]); // channel stats + + const res = await request(app) + .get('/api/dashboard/stats') + .set('Authorization', 'Bearer key'); + + expect(res.status).toBe(200); + expect(res.body.total).toBe(2); + expect(res.body.byChannel.email).toBe(2); + }); + + it('GET /api/dashboard/trends should return trends', async () => { + mockNotificationAggregate.mockResolvedValueOnce([ + { _id: { time: 10, status: 'delivered' }, count: 5 } + ]); + + const res = await request(app) + .get('/api/dashboard/trends') + .set('Authorization', 'Bearer key'); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(1); + expect(res.body.period).toBe('24h'); + }); + }); +}); diff --git a/tests/integration/api/notification.controller.test.ts b/tests/integration/api/notification.controller.test.ts index 65e5ef6..97c08f5 100644 --- a/tests/integration/api/notification.controller.test.ts +++ b/tests/integration/api/notification.controller.test.ts @@ -37,7 +37,7 @@ const createTestApp = async () => { // Import controllers const { notification_controller, batch_notification_controller } = - await import("../../../src/api/controllers/notification.controllers.js"); + await import("../../../src/api/controllers/notification.controller.js"); app.post("/api/notification", notification_controller); app.post("/api/notification/batch", batch_notification_controller); From d3dda2cea0ad97484f510b61d5a611bbada2d496 Mon Sep 17 00:00:00 2001 From: Adhish-Krishna Date: Sat, 4 Jul 2026 19:05:35 +0530 Subject: [PATCH 03/14] fix(mcp-server): add auth headers to the fetch plugins api request --- packages/mcp-server/src/api-client.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/src/api-client.ts b/packages/mcp-server/src/api-client.ts index 899cd54..88e2f2e 100644 --- a/packages/mcp-server/src/api-client.ts +++ b/packages/mcp-server/src/api-client.ts @@ -105,7 +105,9 @@ export class CoreApiClient { /** GET /api/plugins - List installed plugins */ async getPlugins(): Promise { - return request(this.baseUrl, '/api/plugins'); + return request(this.baseUrl, '/api/plugins', { + headers: this.authHeader, + }); } } From a74e20317d27ac444ed42123c96b977bb4236c24 Mon Sep 17 00:00:00 2001 From: Adhish-Krishna Date: Sat, 4 Jul 2026 20:25:58 +0530 Subject: [PATCH 04/14] refractor(mcp-server): remove DASHBOARd_URL from header as it is now not needed. All requests route to the API service --- packages/mcp-server/README.md | 23 +++++++++++++++++++++-- packages/mcp-server/package.json | 2 +- packages/mcp-server/src/api-client.ts | 6 +++--- packages/mcp-server/src/auth.ts | 15 +++------------ 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index b908c1f..60cf973 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -31,6 +31,26 @@ The server starts default at port: `3001` Then point your MCP client at the local HTTP endpoint and pass headers on every request: +```json +{ + "mcpServers": { + "simplens-local-http": { + "type": "streamable-http", + "url": "http://localhost:3001/mcp", + "headers": { + "X-SimpleNS-API-Key": "your-ns-api-key", + "X-SimpleNS-Core-URL": "http://localhost:3000" + } + } + } +} +``` + +> [!NOTE] +> **Version 1.1 Update**: The Dashboard URL (`X-SimpleNS-Dashboard-URL` header / `SIMPLENS_DASHBOARD_URL` environment variable) is no longer required. All tools (including alerts, failure queries, and retries) now route directly to the core Express API service. *Note: Versions below 1.1 still require the dashboard URL to be provided.* + +> Sample Config for versions < `1.1` + ```json { "mcpServers": { @@ -61,8 +81,7 @@ Add to your MCP Client config: "args": ["-y", "@simplens/mcp", "--stdio"], "env": { "NS_API_KEY": "your-local-api-key", - "SIMPLENS_CORE_URL": "http://localhost:3000", - "SIMPLENS_DASHBOARD_URL": "http://localhost:3002" + "SIMPLENS_CORE_URL": "http://localhost:3000" } } } diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index a82299f..363dd07 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@simplens/mcp", - "version": "1.0.3", + "version": "1.1.0", "description": "Remote MCP server for SimpleNS notification orchestration engine", "type": "module", "main": "dist/index.js", diff --git a/packages/mcp-server/src/api-client.ts b/packages/mcp-server/src/api-client.ts index 88e2f2e..5070900 100644 --- a/packages/mcp-server/src/api-client.ts +++ b/packages/mcp-server/src/api-client.ts @@ -120,9 +120,9 @@ export class DashboardApiClient { private authHeader: Record; constructor(credentials: UserCredentials) { - this.baseUrl = credentials.dashboardUrl.endsWith('/') - ? credentials.dashboardUrl - : `${credentials.dashboardUrl}/`; + this.baseUrl = credentials.coreUrl.endsWith('/') + ? credentials.coreUrl + : `${credentials.coreUrl}/`; this.authHeader = { Authorization: `Bearer ${credentials.apiKey}` }; } diff --git a/packages/mcp-server/src/auth.ts b/packages/mcp-server/src/auth.ts index 21a453b..45cbb33 100644 --- a/packages/mcp-server/src/auth.ts +++ b/packages/mcp-server/src/auth.ts @@ -11,7 +11,6 @@ import { serverConfig } from './config.js'; export interface UserCredentials { apiKey: string; coreUrl: string; - dashboardUrl: string; } /** @@ -23,11 +22,10 @@ export function extractCredentials(req: Request): UserCredentials { const coreUrl = req.headers['x-simplens-core-url'] as string | undefined; const dashboardUrl = req.headers['x-simplens-dashboard-url'] as string | undefined; - if (!apiKey || !coreUrl || !dashboardUrl) { + if (!apiKey || !coreUrl) { const missing: string[] = []; if (!apiKey) missing.push('X-SimpleNS-API-Key'); if (!coreUrl) missing.push('X-SimpleNS-Core-URL'); - if (!dashboardUrl) missing.push('X-SimpleNS-Dashboard-URL'); throw new Error(`Missing required headers: ${missing.join(', ')}`); } @@ -38,13 +36,7 @@ export function extractCredentials(req: Request): UserCredentials { throw new Error(`Invalid X-SimpleNS-Core-URL: ${coreUrl}`); } - try { - new URL(dashboardUrl); - } catch { - throw new Error(`Invalid X-SimpleNS-Dashboard-URL: ${dashboardUrl}`); - } - - return { apiKey, coreUrl, dashboardUrl }; + return { apiKey, coreUrl }; } /** @@ -59,7 +51,6 @@ export function getStdioCredentials(): UserCredentials { return { apiKey: SIMPLENS_API_KEY, - coreUrl: SIMPLENS_CORE_URL, - dashboardUrl: SIMPLENS_DASHBOARD_URL, + coreUrl: SIMPLENS_CORE_URL }; } From da8cd444fbc1b38a8ca09bc17d377c6e5e5b8606 Mon Sep 17 00:00:00 2001 From: Adhish-Krishna Date: Sat, 4 Jul 2026 20:37:20 +0530 Subject: [PATCH 05/14] refractor(dashboard): use env variables from api-config.ts file in middleware.ts file --- dashboard/middleware.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dashboard/middleware.ts b/dashboard/middleware.ts index 8bcf4c1..4098b46 100644 --- a/dashboard/middleware.ts +++ b/dashboard/middleware.ts @@ -1,10 +1,9 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { API_BASE_URL, NS_API_KEY } from "./lib/api-config"; const basePath = process.env.BASE_PATH || ""; const SESSION_COOKIE_NAME = "simplens_session"; -const NS_API_KEY = process.env.NS_API_KEY || ""; -const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3000"; const localApiPaths = [ "/api/auth/", From 96c4b5adf9653821053def60fc5d7a04ac71cae4 Mon Sep 17 00:00:00 2001 From: Adhish-Krishna Date: Sat, 4 Jul 2026 22:40:43 +0530 Subject: [PATCH 06/14] fix(dashboard): fix fetching of WEBHOOK_HOSt and PORT values and fixed api calls in template and send section --- dashboard/app/api/runtime-config/route.ts | 2 ++ dashboard/app/templates/editor/page.tsx | 2 +- .../send/batch-notification-form.tsx | 4 +++- .../send/single-notification-form.tsx | 4 +++- dashboard/entrypoint.sh | 10 ++++++++-- dashboard/lib/api-config.ts | 18 ++++++++++++++++-- dashboard/lib/utils.ts | 2 ++ 7 files changed, 35 insertions(+), 7 deletions(-) diff --git a/dashboard/app/api/runtime-config/route.ts b/dashboard/app/api/runtime-config/route.ts index e2872ec..e649856 100644 --- a/dashboard/app/api/runtime-config/route.ts +++ b/dashboard/app/api/runtime-config/route.ts @@ -8,6 +8,8 @@ import { NextResponse } from "next/server"; export async function GET() { return NextResponse.json({ basePath: process.env.BASE_PATH || "", + webhookHost: process.env.WEBHOOK_HOST || "localhost", + webhookPort: process.env.WEBHOOK_PORT || "3002", }); } diff --git a/dashboard/app/templates/editor/page.tsx b/dashboard/app/templates/editor/page.tsx index 772126f..bc00ba4 100644 --- a/dashboard/app/templates/editor/page.tsx +++ b/dashboard/app/templates/editor/page.tsx @@ -274,7 +274,7 @@ function TemplateEditorContent() { try { const url = mode === "create" - ? withBasePath("/api/templates") + ? withBasePath("/api/templates/create") : withBasePath( `/api/templates/${encodeURIComponent(form.template_id)}`, ); diff --git a/dashboard/components/send/batch-notification-form.tsx b/dashboard/components/send/batch-notification-form.tsx index da96d78..f770b97 100644 --- a/dashboard/components/send/batch-notification-form.tsx +++ b/dashboard/components/send/batch-notification-form.tsx @@ -15,6 +15,7 @@ import { PluginMetadata, FieldDefinition, NotificationTemplateDetail, Notificati import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { withBasePath } from "@/lib/utils"; +import { WEBHOOK_HOST, WEBHOOK_PORT } from "@/lib/api-config"; // Generate a UUIDv4 function generateUUID(): string { @@ -412,6 +413,7 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) client_id: clientId, channel: selectedChannels, recipients: processedRecipients, + webhook_url: `http://${WEBHOOK_HOST}:${WEBHOOK_PORT}/api/webhook` }; if (inputMode === "template") { @@ -446,7 +448,7 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) } } - const response = await fetch(withBasePath("/api/send"), { + const response = await fetch(withBasePath("/api/notification/batch"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), diff --git a/dashboard/components/send/single-notification-form.tsx b/dashboard/components/send/single-notification-form.tsx index c6aca08..32d1e82 100644 --- a/dashboard/components/send/single-notification-form.tsx +++ b/dashboard/components/send/single-notification-form.tsx @@ -15,6 +15,7 @@ import { PluginMetadata, ProviderMetadata, NotificationTemplateDetail, Notificat import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { withBasePath } from "@/lib/utils"; +import { WEBHOOK_HOST, WEBHOOK_PORT } from "@/lib/api-config"; // Generate a UUIDv4 function generateUUID(): string { @@ -337,6 +338,7 @@ export function SingleNotificationForm({ onSuccess }: SingleNotificationFormProp client_id: clientId, channel: selectedChannels, recipient: { ...recipientData }, + webhook_url: `http://${WEBHOOK_HOST}:${WEBHOOK_PORT}/api/webhook` }; if (inputMode === "template") { @@ -386,7 +388,7 @@ export function SingleNotificationForm({ onSuccess }: SingleNotificationFormProp } } - const response = await fetch(withBasePath("/api/send"), { + const response = await fetch(withBasePath("/api/notification"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), diff --git a/dashboard/entrypoint.sh b/dashboard/entrypoint.sh index 7657354..1038e57 100644 --- a/dashboard/entrypoint.sh +++ b/dashboard/entrypoint.sh @@ -1,16 +1,22 @@ #!/bin/sh set -e -# Get base path from environment (defaults to empty) +# Get configuration from environment BASE_PATH="${BASE_PATH:-}" +WEBHOOK_HOST="${WEBHOOK_HOST:-localhost}" +WEBHOOK_PORT="${WEBHOOK_PORT:-3002}" echo "šŸš€ Starting SimpleNS Dashboard..." echo "šŸ“ Base Path: ${BASE_PATH:-'/ (root)'}" +echo "šŸ“ Webhook Host: ${WEBHOOK_HOST}" +echo "šŸ“ Webhook Port: ${WEBHOOK_PORT}" # Generate runtime configuration accessible to client cat > /app/public/runtime-config.js << EOF window.__RUNTIME_CONFIG__ = { - basePath: "${BASE_PATH}" + basePath: "${BASE_PATH}", + webhookHost: "${WEBHOOK_HOST}", + webhookPort: "${WEBHOOK_PORT}" }; EOF diff --git a/dashboard/lib/api-config.ts b/dashboard/lib/api-config.ts index 4fd8475..f7e2f9d 100644 --- a/dashboard/lib/api-config.ts +++ b/dashboard/lib/api-config.ts @@ -3,7 +3,21 @@ * Centralized configuration for API endpoints */ +const getWebhookHost = (): string => { + if (typeof window !== 'undefined' && window.__RUNTIME_CONFIG__) { + return window.__RUNTIME_CONFIG__.webhookHost || "localhost"; + } + return process.env.WEBHOOK_HOST || "localhost"; +}; + +const getWebhookPort = (): string => { + if (typeof window !== 'undefined' && window.__RUNTIME_CONFIG__) { + return window.__RUNTIME_CONFIG__.webhookPort || "3002"; + } + return process.env.WEBHOOK_PORT || "3002"; +}; + export const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3000'; export const NS_API_KEY = process.env.NS_API_KEY || ""; -export const WEBHOOK_HOST = process.env.WEBHOOK_HOST || "localhost"; -export const WEBHOOK_PORT = process.env.WEBHOOK_PORT || "3002"; \ No newline at end of file +export const WEBHOOK_HOST = getWebhookHost(); +export const WEBHOOK_PORT = getWebhookPort(); \ No newline at end of file diff --git a/dashboard/lib/utils.ts b/dashboard/lib/utils.ts index 5043bf1..394c7d8 100644 --- a/dashboard/lib/utils.ts +++ b/dashboard/lib/utils.ts @@ -12,6 +12,8 @@ declare global { interface Window { __RUNTIME_CONFIG__?: { basePath: string; + webhookHost?: string; + webhookPort?: string; }; } } From 813865fb85fd7c05bac0138ab606c2f9e68a5000 Mon Sep 17 00:00:00 2001 From: Adhish-Krishna Date: Sat, 4 Jul 2026 22:50:04 +0530 Subject: [PATCH 07/14] fix(notifications): add eslint disable comment for explicit any type in listNotifications --- src/api/controllers/notifications-management.controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/controllers/notifications-management.controller.ts b/src/api/controllers/notifications-management.controller.ts index df92194..5f7b030 100644 --- a/src/api/controllers/notifications-management.controller.ts +++ b/src/api/controllers/notifications-management.controller.ts @@ -28,6 +28,7 @@ export const listNotifications = async (req: Request, res: Response): Promise Date: Sun, 5 Jul 2026 00:16:12 +0530 Subject: [PATCH 08/14] feat: refactor API client to use Axios for improved error handling and request management - Replaced fetch-based API request implementation with Axios. - Added centralized error handling through Axios interceptors. - Introduced ApiError class for better error representation. - Updated service modules (auth, notification, template, alert, plugin, admin channel, dashboard) to utilize the new Axios-based client. - Enhanced AdminChannelFormData to include a config field and added AdminChannelProviderMeta interface for provider metadata. - Updated package.json and package-lock.json to include Axios as a dependency. --- dashboard/app/admin-alerts/page.tsx | 12 +- dashboard/app/alerts/page.tsx | 33 +- dashboard/app/analytics/page.tsx | 5 +- dashboard/app/events/[id]/page.tsx | 45 +-- dashboard/app/events/page.tsx | 4 +- dashboard/app/failed/page.tsx | 15 +- dashboard/app/login/page.tsx | 24 +- dashboard/app/payload-studio/page.tsx | 33 +- dashboard/app/plugins/page.tsx | 32 +- dashboard/app/templates/editor/page.tsx | 36 +- dashboard/app/templates/page.tsx | 38 +- .../admin-alerts/add-channel-dialog.tsx | 73 ++-- dashboard/components/auth-provider.tsx | 6 +- .../components/dashboard/recent-activity.tsx | 4 +- .../components/dashboard/stats-cards.tsx | 4 +- .../components/layout/dashboard-layout.tsx | 4 +- dashboard/components/logout-button.tsx | 13 +- .../send/batch-notification-form.tsx | 37 +- .../send/single-notification-form.tsx | 38 +- dashboard/lib/api-client.ts | 169 ++++++++- dashboard/lib/types.ts | 19 +- dashboard/package-lock.json | 352 +++++++----------- dashboard/package.json | 1 + 23 files changed, 414 insertions(+), 583 deletions(-) diff --git a/dashboard/app/admin-alerts/page.tsx b/dashboard/app/admin-alerts/page.tsx index 6a2bb0c..bc6767e 100644 --- a/dashboard/app/admin-alerts/page.tsx +++ b/dashboard/app/admin-alerts/page.tsx @@ -22,8 +22,8 @@ import { useState } from "react"; import { AddChannelDialog } from "@/components/admin-alerts/add-channel-dialog"; import type { AdminChannel } from "@/lib/types"; import { CHANNEL_ICONS } from "@/components/admin-alerts/add-channel-dialog"; -import { withBasePath } from "@/lib/utils"; -const fetcher = (url: string) => fetch(withBasePath(url)).then((res) => res.json()); +import { apiClient, adminChannelService } from "@/lib/api-client"; +const fetcher = (url: string): Promise => apiClient.get(url) as unknown as Promise; const ALERT_TYPE_LABELS: Record = { failed_notifications: "Failed Notifications", @@ -43,11 +43,7 @@ export default function AdminAlertsPage() { const handleToggle = async (id: string, enabled: boolean) => { try { - await fetch(withBasePath(`/api/admin-channels/${id}`), { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled }), - }); + await adminChannelService.update(id, { enabled }); mutate(); } catch (error) { console.error("Failed to toggle channel:", error); @@ -56,7 +52,7 @@ export default function AdminAlertsPage() { const handleDelete = async (id: string) => { try { - await fetch(withBasePath(`/api/admin-channels/${id}`), { method: "DELETE" }); + await adminChannelService.delete(id); mutate(); } catch (error) { console.error("Failed to delete channel:", error); diff --git a/dashboard/app/alerts/page.tsx b/dashboard/app/alerts/page.tsx index 7bb715b..5bb3e45 100644 --- a/dashboard/app/alerts/page.tsx +++ b/dashboard/app/alerts/page.tsx @@ -42,6 +42,7 @@ import { } from "@/components/ui/dialog"; import { Alert as AlertType, ALERT_TYPE } from "@/lib/types"; +import { alertService } from "@/lib/api-client"; import { withBasePath } from "@/lib/utils"; interface AlertWithNotification extends AlertType { @@ -80,8 +81,7 @@ export default function AlertsPage() { const fetchAlerts = useCallback(async (pageNum = 1, append = false, type = "all") => { try { - const response = await fetch(withBasePath(`/api/alerts?page=${pageNum}&limit=50&type=${type}`)); - const data = await response.json(); + const data = await alertService.list(pageNum, 50, type); if (append) { setAlerts(prev => [...prev, ...(data.alerts || [])]); @@ -131,17 +131,12 @@ export default function AlertsPage() { setBulkLoading(true); try { - const response = await fetch(withBasePath("/api/alerts/bulk-resolve"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - appendWarning: appendWarningOption, - limit: 100 - }), + const data = await alertService.bulkResolve({ + appendWarning: appendWarningOption, + limit: 100 }); - if (response.ok) { - const data = await response.json(); + if (data) { // Refresh the list after bulk action setPage(1); await fetchAlerts(1, false); @@ -161,15 +156,9 @@ export default function AlertsPage() { const handleRetry = async (alertId: string, appendWarning: boolean) => { setActionLoading(alertId); try { - const response = await fetch(withBasePath(`/api/alerts/${alertId}/resolve`), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - appendWarning - }), - }); + const data = await alertService.resolve(alertId, { appendWarning }); - if (response.ok) { + if (data) { setAlerts((prev) => prev.filter((a) => a._id !== alertId)); setTotalCount((prev) => Math.max(0, prev - 1)); toast.success("Alert resolved and notification retried"); @@ -186,11 +175,9 @@ export default function AlertsPage() { const handleDismiss = async (alertId: string) => { setActionLoading(alertId); try { - const response = await fetch(withBasePath(`/api/alerts/${alertId}`), { - method: "DELETE", - }); + const data = await alertService.delete(alertId); - if (response.ok) { + if (data) { setAlerts((prev) => prev.filter((a) => a._id !== alertId)); setTotalCount((prev) => Math.max(0, prev - 1)); } diff --git a/dashboard/app/analytics/page.tsx b/dashboard/app/analytics/page.tsx index b161a19..26d9332 100644 --- a/dashboard/app/analytics/page.tsx +++ b/dashboard/app/analytics/page.tsx @@ -19,9 +19,8 @@ import { Legend, } from "recharts"; import type { DashboardStats } from "@/lib/types"; -import { withBasePath } from "@/lib/utils"; - -const fetcher = (url: string) => fetch(withBasePath(url)).then((res) => res.json()); +import { apiClient } from "@/lib/api-client"; +const fetcher = (url: string): Promise => apiClient.get(url) as unknown as Promise; const STATUS_COLORS = { pending: "#fbbf24", diff --git a/dashboard/app/events/[id]/page.tsx b/dashboard/app/events/[id]/page.tsx index f998828..df5f451 100644 --- a/dashboard/app/events/[id]/page.tsx +++ b/dashboard/app/events/[id]/page.tsx @@ -27,8 +27,9 @@ import Link from "next/link"; import { useState } from "react"; import { toast } from "sonner"; import { withBasePath } from "@/lib/utils"; +import { apiClient, notificationService } from "@/lib/api-client"; -const fetcher = (url: string) => fetch(withBasePath(url)).then((res) => res.json()); +const fetcher = (url: string): Promise => apiClient.get(url) as unknown as Promise; interface PageProps { params: Promise<{ id: string }>; @@ -69,22 +70,13 @@ export default function EventDetailPage({ params }: PageProps) { setIsRetrying(true); try { - const response = await fetch(withBasePath(`/api/notifications/${notification._id}/retry`), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - - if (response.ok) { - toast.success("Notification queued for retry"); - mutate(); - setRetryDialogOpen(false); - } else { - const data = await response.json(); - toast.error(data.error || "Failed to retry notification"); - } - } catch { - toast.error("Failed to retry notification"); + await notificationService.retry(notification._id); + toast.success("Notification queued for retry"); + mutate(); + setRetryDialogOpen(false); + } catch (err) { + const error = err as Error; + toast.error(error.message || "Failed to retry notification"); } finally { setIsRetrying(false); } @@ -95,19 +87,12 @@ export default function EventDetailPage({ params }: PageProps) { setIsDeleting(true); try { - const response = await fetch(withBasePath(`/api/notifications/${notification._id}`), { - method: "DELETE", - }); - - if (response.ok) { - toast.success("Notification deleted"); - router.push(withBasePath("/events")); - } else { - const data = await response.json(); - toast.error(data.error || "Failed to delete notification"); - } - } catch { - toast.error("Failed to delete notification"); + await notificationService.delete(notification._id); + toast.success("Notification deleted"); + router.push(withBasePath("/events")); + } catch (err) { + const error = err as Error; + toast.error(error.message || "Failed to delete notification"); } finally { setIsDeleting(false); setDeleteDialogOpen(false); diff --git a/dashboard/app/events/page.tsx b/dashboard/app/events/page.tsx index ca0a9fd..0b8665b 100644 --- a/dashboard/app/events/page.tsx +++ b/dashboard/app/events/page.tsx @@ -37,8 +37,8 @@ import { format } from "date-fns"; import type { PaginatedResponse, Notification, PluginMetadata } from "@/lib/types"; import Link from "next/link"; import { withBasePath } from "@/lib/utils"; - -const fetcher = (url: string) => fetch(withBasePath(url)).then((res) => res.json()); +import { apiClient } from "@/lib/api-client"; +const fetcher = (url: string): Promise => apiClient.get(url) as unknown as Promise; export default function EventsPage() { const [page, setPage] = useState(1); diff --git a/dashboard/app/failed/page.tsx b/dashboard/app/failed/page.tsx index 356b202..8d71930 100644 --- a/dashboard/app/failed/page.tsx +++ b/dashboard/app/failed/page.tsx @@ -39,7 +39,8 @@ import type { PaginatedResponse, Notification } from "@/lib/types"; import Link from "next/link"; import { toast } from "sonner"; import { withBasePath } from "@/lib/utils"; -const fetcher = (url: string) => fetch(withBasePath(url)).then((res) => res.json()); +import { apiClient, notificationService } from "@/lib/api-client"; +const fetcher = (url: string): Promise => apiClient.get(url) as unknown as Promise; export default function FailedPage() { const [selectedIds, setSelectedIds] = useState([]); @@ -80,16 +81,8 @@ export default function FailedPage() { for (const id of selectedIds) { try { - const response = await fetch(withBasePath(`/api/notifications/${id}/retry`), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - if (response.ok) { - successCount++; - } else { - failCount++; - } + await notificationService.retry(id); + successCount++; } catch { failCount++; } diff --git a/dashboard/app/login/page.tsx b/dashboard/app/login/page.tsx index 754585c..e640518 100644 --- a/dashboard/app/login/page.tsx +++ b/dashboard/app/login/page.tsx @@ -10,6 +10,7 @@ import { Separator } from "@/components/ui/separator"; import { AlertTriangle, Eye, EyeOff, Loader2, Lock, User } from "lucide-react"; import { ElegantShape } from "@/components/elegant-shape"; import { withBasePath } from "@/lib/utils"; +import { authService } from "@/lib/api-client"; import { useState } from "react"; export default function LoginPage() { @@ -26,28 +27,15 @@ export default function LoginPage() { setIsLoading(true); try { - const response = await fetch(withBasePath("/api/auth/login"), { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ username, password }), - }); - - const data = await response.json(); - - if (!response.ok) { - setError(data.error || "Invalid username or password"); - setIsLoading(false); - return; - } + const data = await authService.login({ username, password }); setIsLoading(false); // Redirect to dashboard - router.push(withBasePath(`/dashboard`)); + router.push(data.redirectUrl || withBasePath(`/dashboard`)); router.refresh(); return; - } catch { - setError("An error occurred. Please try again."); + } catch (err) { + const error = err as Error; + setError(error.message || "An error occurred. Please try again."); setIsLoading(false); } }; diff --git a/dashboard/app/payload-studio/page.tsx b/dashboard/app/payload-studio/page.tsx index 04de46d..60e07d4 100644 --- a/dashboard/app/payload-studio/page.tsx +++ b/dashboard/app/payload-studio/page.tsx @@ -13,35 +13,8 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import useSWR from "swr"; import { useState, useMemo, useCallback, useEffect } from "react"; import { Copy, Check, Code, Zap, AlertCircle } from "lucide-react"; -import { withBasePath } from "@/lib/utils"; - -// Types -interface FieldDefinition { - name: string; - type: string; - required: boolean; - description?: string; -} - -interface ProviderMetadata { - id: string; - name: string; - displayName: string; - description?: string; - priority: number; - recipientFields: FieldDefinition[]; - contentFields: FieldDefinition[]; -} - -interface ChannelMetadata { - providers: ProviderMetadata[]; - default?: string; - fallback?: string; -} - -interface PluginsResponse { - channels: Record; -} +import { apiClient } from "@/lib/api-client"; +import { FieldDefinition, PluginMetadata as PluginsResponse } from "@/lib/types"; interface ChannelSelection { channel: string; @@ -49,7 +22,7 @@ interface ChannelSelection { enabled: boolean; } -const fetcher = (url: string) => fetch(withBasePath(url)).then((res) => res.json()); +const fetcher = (url: string): Promise => apiClient.get(url) as unknown as Promise; // Map field type to schema type display - show (optional) for non-required fields function getTypeDisplay(field: FieldDefinition): string { diff --git a/dashboard/app/plugins/page.tsx b/dashboard/app/plugins/page.tsx index 0652ad2..decf9d4 100644 --- a/dashboard/app/plugins/page.tsx +++ b/dashboard/app/plugins/page.tsx @@ -10,36 +10,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import useSWR from "swr"; import { Puzzle, RefreshCw, Mail, MessageCircle, TestTube2, Zap, User, FileText } from "lucide-react"; import { useMemo, useState } from "react"; -import { withBasePath } from "@/lib/utils"; +import { apiClient } from "@/lib/api-client"; +import { ProviderMetadata, PluginMetadata as PluginsResponse } from "@/lib/types"; -interface FieldDefinition { - name: string; - type: string; - required: boolean; - description?: string; -} - -interface ProviderMetadata { - id: string; - name: string; - displayName: string; - description?: string; - priority: number; - recipientFields: FieldDefinition[]; - contentFields: FieldDefinition[]; -} - -interface ChannelMetadata { - providers: ProviderMetadata[]; - default?: string; - fallback?: string; -} - -interface PluginsResponse { - channels: Record; -} - -const fetcher = (url: string) => fetch(withBasePath(url)).then((res) => res.json()); +const fetcher = (url: string): Promise => apiClient.get(url) as unknown as Promise; // Channel-specific colors and icons const channelConfig: Record; color: string }> = { diff --git a/dashboard/app/templates/editor/page.tsx b/dashboard/app/templates/editor/page.tsx index bc00ba4..af6aca1 100644 --- a/dashboard/app/templates/editor/page.tsx +++ b/dashboard/app/templates/editor/page.tsx @@ -36,16 +36,15 @@ import type { FieldDefinition, PluginMetadata, NotificationTemplateDetail, + NotificationTemplateCreatePayload, + NotificationTemplateUpdatePayload, } from "@/lib/types"; import { Switch } from "@/components/ui/switch"; import { StableMonacoEditor } from "@/components/ui/monaco-wrapper"; -const fetcher = (url: string) => - fetch(withBasePath(url)).then((res) => { - if (!res.ok) throw new Error(`Request failed: ${res.status}`); - return res.json(); - }); +import { apiClient, templateService } from "@/lib/api-client"; +const fetcher = (url: string): Promise => apiClient.get(url) as unknown as Promise; function TemplateEditorContent() { const router = useRouter(); @@ -189,11 +188,7 @@ function TemplateEditorContent() { useEffect(() => { if (mode !== "edit" || !templateId) return; setIsLoading(true); - fetch(withBasePath(`/api/templates/${encodeURIComponent(templateId)}`)) - .then((res) => { - if (!res.ok) throw new Error("Failed to load template"); - return res.json(); - }) + templateService.get(templateId) .then((data: NotificationTemplateDetail) => { setForm({ package: data.package, @@ -272,13 +267,6 @@ function TemplateEditorContent() { setIsSaving(true); try { - const url = - mode === "create" - ? withBasePath("/api/templates/create") - : withBasePath( - `/api/templates/${encodeURIComponent(form.template_id)}`, - ); - const method = mode === "create" ? "POST" : "PUT"; const body = mode === "create" ? { @@ -295,16 +283,10 @@ function TemplateEditorContent() { content: form.content, }; - const response = await fetch(url, { - method, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error( - data.message || data.error || `Failed to ${mode} template`, - ); + if (mode === "create") { + await templateService.create(body as NotificationTemplateCreatePayload); + } else { + await templateService.update(templateId, body as NotificationTemplateUpdatePayload); } toast.success( diff --git a/dashboard/app/templates/page.tsx b/dashboard/app/templates/page.tsx index 4ae3255..87c9a5a 100644 --- a/dashboard/app/templates/page.tsx +++ b/dashboard/app/templates/page.tsx @@ -43,6 +43,7 @@ import { Trash2, } from "lucide-react"; import {withBasePath } from "@/lib/utils"; +import { apiClient, templateService } from "@/lib/api-client"; import type { PluginMetadata, NotificationTemplateDetail, @@ -51,13 +52,7 @@ import type { import { HtmlPreview } from "@/components/send/html-preview"; import { ScrollArea } from "@/components/ui/scroll-area"; -const fetcher = (url: string) => - fetch(withBasePath(url)).then((res) => { - if (!res.ok) { - throw new Error(`Request failed: ${res.status}`); - } - return res.json(); - }); +const fetcher = (url: string): Promise => apiClient.get(url) as unknown as Promise; export default function TemplatesPage() { const router = useRouter(); @@ -147,22 +142,7 @@ export default function TemplatesPage() { setIsDeleting(true); try { - const response = await fetch( - withBasePath( - `/api/templates/${encodeURIComponent(deleteTarget.template_id)}`, - ), - { - method: "DELETE", - }, - ); - - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error( - data.message || data.error || "Failed to delete template", - ); - } - + await templateService.delete(deleteTarget.template_id); toast.success("Template deleted"); setDeleteTarget(null); await mutateTemplates(); @@ -180,16 +160,8 @@ export default function TemplatesPage() { setDetailLoading(true); setDetailData(null); try { - const response = await fetch( - withBasePath(`/api/templates/${encodeURIComponent(templateId)}`), - ); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error( - data.message || data.error || "Failed to load template", - ); - } - setDetailData(data as NotificationTemplateDetail); + const data = await templateService.get(templateId); + setDetailData(data); } catch (error) { toast.error( error instanceof Error ? error.message : "Failed to load template", diff --git a/dashboard/components/admin-alerts/add-channel-dialog.tsx b/dashboard/components/admin-alerts/add-channel-dialog.tsx index 2096404..267ba53 100644 --- a/dashboard/components/admin-alerts/add-channel-dialog.tsx +++ b/dashboard/components/admin-alerts/add-channel-dialog.tsx @@ -21,8 +21,8 @@ import { SelectValue, } from "@/components/ui/select"; import { Loader2, TestTube2, Save, AlertCircle } from "lucide-react"; -import type { AlertFilters } from "@/lib/types"; -import { withBasePath } from "@/lib/utils"; +import type { AlertFilters, AdminChannelType, AdminChannelProviderMeta as ProviderMeta } from "@/lib/types"; +import { adminChannelService } from "@/lib/api-client"; interface AddChannelDialogProps { open: boolean; @@ -30,21 +30,7 @@ interface AddChannelDialogProps { onSuccess: () => void; } -interface CredentialField { - name: string; - type: 'string' | 'url' | 'secret'; - label: string; - placeholder?: string; - description?: string; - required: boolean; - pattern?: string; -} - -interface ProviderMeta { - channelType: string; - displayName: string; - credentialFields: CredentialField[]; -} +// Types are imported from @/lib/types const DEFAULT_FILTERS: AlertFilters = { failed_notifications: true, @@ -123,9 +109,7 @@ export function AddChannelDialog({ open, onOpenChange, onSuccess }: AddChannelDi setLoadingProviders(true); setProviderError(null); try { - const res = await fetch(withBasePath("/api/admin-channels/providers")); - if (!res.ok) throw new Error("Failed to fetch providers"); - const data = await res.json(); + const data = await adminChannelService.getProviders(); setProviders(data.providers || []); // Set default channel type to first provider if (data.providers?.length > 0 && !channelType) { @@ -175,21 +159,17 @@ export function AddChannelDialog({ open, onOpenChange, onSuccess }: AddChannelDi setTesting(true); setTestResult(null); try { - const res = await fetch(withBasePath("/api/admin-channels/test"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - channel_type: channelType, - config, - }), + const data = await adminChannelService.test({ + channel_type: channelType, + config, }); - const data = await res.json(); setTestResult({ success: data.success, - message: data.success ? `Test message sent! Check your ${currentProvider?.displayName || 'channel'}.` : data.error, + message: data.success ? `Test message sent! Check your ${currentProvider?.displayName || 'channel'}.` : data.error || "Test failed", }); - } catch { - setTestResult({ success: false, message: "Test failed - network error" }); + } catch (err) { + const error = err as Error; + setTestResult({ success: false, message: error.message || "Test failed - network error" }); } finally { setTesting(false); } @@ -198,27 +178,18 @@ export function AddChannelDialog({ open, onOpenChange, onSuccess }: AddChannelDi const handleSubmit = async () => { setLoading(true); try { - const res = await fetch(withBasePath("/api/admin-channels"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - channel_type: channelType, - name, - config, - alert_filters: filters, - }), + await adminChannelService.create({ + channel_type: channelType as AdminChannelType, + name, + config, + alert_filters: filters, }); - - if (res.ok) { - onSuccess(); - onOpenChange(false); - resetForm(); - } else { - const data = await res.json(); - alert(data.error || "Failed to create channel"); - } - } catch { - alert("Failed to create channel - network error"); + onSuccess(); + onOpenChange(false); + resetForm(); + } catch (err) { + const error = err as Error; + alert(error.message || "Failed to create channel"); } finally { setLoading(false); } diff --git a/dashboard/components/auth-provider.tsx b/dashboard/components/auth-provider.tsx index 8bb9b77..f612a4a 100644 --- a/dashboard/components/auth-provider.tsx +++ b/dashboard/components/auth-provider.tsx @@ -3,6 +3,7 @@ import { createContext, useContext, useEffect, useState, useCallback } from "react"; import { useRouter } from "next/navigation"; import { withBasePath } from "@/lib/utils"; +import { authService } from "@/lib/api-client"; interface User { id: string; @@ -26,8 +27,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const refreshSession = useCallback(async () => { try { - const response = await fetch(withBasePath(`/api/auth/session`)); - const data = await response.json(); + const data = await authService.getSession(); if (data.authenticated && data.user) { setUser(data.user); @@ -44,7 +44,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const logout = useCallback(async () => { try { - await fetch(withBasePath(`/api/auth/logout`), { method: "POST" }); + await authService.logout(); setUser(null); router.push(withBasePath(`/login`)); router.refresh(); diff --git a/dashboard/components/dashboard/recent-activity.tsx b/dashboard/components/dashboard/recent-activity.tsx index 8556ab1..d295cd0 100644 --- a/dashboard/components/dashboard/recent-activity.tsx +++ b/dashboard/components/dashboard/recent-activity.tsx @@ -9,8 +9,8 @@ import { ChannelBadge } from "@/components/events/channel-badge"; import type { Notification } from "@/lib/types"; import { formatDistanceToNow } from "date-fns"; import { withBasePath } from "@/lib/utils"; - -const fetcher = (url: string) => fetch(withBasePath(url)).then((res) => res.json()); +import { apiClient } from "@/lib/api-client"; +const fetcher = (url: string): Promise => apiClient.get(url) as unknown as Promise; export function RecentActivity() { const { data: notifications, isLoading, error } = useSWR( diff --git a/dashboard/components/dashboard/stats-cards.tsx b/dashboard/components/dashboard/stats-cards.tsx index a827067..e4e34fd 100644 --- a/dashboard/components/dashboard/stats-cards.tsx +++ b/dashboard/components/dashboard/stats-cards.tsx @@ -12,8 +12,8 @@ import { } from "lucide-react"; import { cn, withBasePath } from "@/lib/utils"; import type { DashboardStats } from "@/lib/types"; - -const fetcher = (url: string) => fetch(withBasePath(url)).then((res) => res.json()); +import { apiClient } from "@/lib/api-client"; +const fetcher = (url: string): Promise => apiClient.get(url) as unknown as Promise; interface StatsCardConfig { title: string; diff --git a/dashboard/components/layout/dashboard-layout.tsx b/dashboard/components/layout/dashboard-layout.tsx index 5e0e263..ece91f5 100644 --- a/dashboard/components/layout/dashboard-layout.tsx +++ b/dashboard/components/layout/dashboard-layout.tsx @@ -39,8 +39,8 @@ import { SidebarThemeToggle } from "@/components/theme-toggle"; import { LogoutButton } from "@/components/logout-button"; import type { DashboardStats } from "@/lib/types"; import { withBasePath } from "@/lib/utils"; - -const fetcher = (url: string) => fetch(withBasePath(url)).then((res) => res.json()); +import { apiClient } from "@/lib/api-client"; +const fetcher = (url: string): Promise => apiClient.get(url) as unknown as Promise; interface NavItem { title: string; diff --git a/dashboard/components/logout-button.tsx b/dashboard/components/logout-button.tsx index c13a034..fbee19c 100644 --- a/dashboard/components/logout-button.tsx +++ b/dashboard/components/logout-button.tsx @@ -4,21 +4,16 @@ import { LogOut } from "lucide-react"; import { useRouter } from "next/navigation"; import { SidebarMenuButton } from "@/components/ui/sidebar"; import { withBasePath } from "@/lib/utils"; +import { authService } from "@/lib/api-client"; export function LogoutButton() { const router = useRouter(); const handleLogout = async () => { try { - const response = await fetch( - withBasePath("/api/auth/logout"), - { method: "POST" } - ); - - if (response.ok) { - router.push(withBasePath(`/login`)); - router.refresh(); - } + await authService.logout(); + router.push(withBasePath(`/login`)); + router.refresh(); } catch (error) { console.error("Logout failed:", error); // Force redirect even on error diff --git a/dashboard/components/send/batch-notification-form.tsx b/dashboard/components/send/batch-notification-form.tsx index f770b97..d58ca86 100644 --- a/dashboard/components/send/batch-notification-form.tsx +++ b/dashboard/components/send/batch-notification-form.tsx @@ -14,8 +14,8 @@ import { DynamicField } from "./dynamic-field"; import { PluginMetadata, FieldDefinition, NotificationTemplateDetail, NotificationTemplateListItem } from "@/lib/types"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { withBasePath } from "@/lib/utils"; import { WEBHOOK_HOST, WEBHOOK_PORT } from "@/lib/api-config"; +import { pluginService, templateService, notificationService } from "@/lib/api-client"; // Generate a UUIDv4 function generateUUID(): string { @@ -167,9 +167,7 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) useEffect(() => { const fetchPlugins = async () => { try { - const res = await fetch(withBasePath('/api/plugins')); - if (!res.ok) throw new Error(`Failed to load plugins: ${res.statusText}`); - const data = await res.json(); + const data = await pluginService.getMetadata(); setPlugins(data); // Select first channel by default @@ -272,15 +270,7 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) setTemplatesLoadingByChannel((prev) => ({ ...prev, [channel]: true })); try { - const response = await fetch( - withBasePath(`/api/templates?package_name=${encodeURIComponent(packageName)}`) - ); - - if (!response.ok) { - throw new Error(`Failed to fetch templates for ${channel}`); - } - - const data = await response.json(); + const data = await templateService.list(packageName); if (!cancelled) { const templates = Array.isArray(data) ? data : []; setTemplatesByChannel((prev) => ({ ...prev, [channel]: templates })); @@ -333,14 +323,7 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) } try { - const response = await fetch(withBasePath(`/api/templates/${encodeURIComponent(templateId)}`)); - const data = await response.json().catch(() => ({})); - - if (!response.ok) { - throw new Error(data.message || data.error || "Failed to load template detail"); - } - - const template = data as NotificationTemplateDetail; + const template = await templateService.get(templateId); const variableNames = extractTemplateVariables(template.content); if (!cancelled) { @@ -448,17 +431,7 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) } } - const response = await fetch(withBasePath("/api/notification/batch"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || data.error || "Failed to send"); - } + await notificationService.sendBatch(payload); toast.success(`Batch notification sent to ${recipients.length} recipients!`); onSuccess?.(); diff --git a/dashboard/components/send/single-notification-form.tsx b/dashboard/components/send/single-notification-form.tsx index 32d1e82..2bdf0dd 100644 --- a/dashboard/components/send/single-notification-form.tsx +++ b/dashboard/components/send/single-notification-form.tsx @@ -14,8 +14,8 @@ import { DynamicField } from "./dynamic-field"; import { PluginMetadata, ProviderMetadata, NotificationTemplateDetail, NotificationTemplateListItem } from "@/lib/types"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { withBasePath } from "@/lib/utils"; import { WEBHOOK_HOST, WEBHOOK_PORT } from "@/lib/api-config"; +import { pluginService, templateService, notificationService } from "@/lib/api-client"; // Generate a UUIDv4 function generateUUID(): string { @@ -111,11 +111,7 @@ export function SingleNotificationForm({ onSuccess }: SingleNotificationFormProp useEffect(() => { const fetchPlugins = async () => { try { - const res = await fetch(withBasePath('/api/plugins')); - if (!res.ok) { - throw new Error(`Failed to load plugins: ${res.statusText}`); - } - const data = await res.json(); + const data = await pluginService.getMetadata(); setPlugins(data); // Select first channel by default if available @@ -217,15 +213,7 @@ export function SingleNotificationForm({ onSuccess }: SingleNotificationFormProp setTemplatesLoadingByChannel((prev) => ({ ...prev, [channel]: true })); try { - const response = await fetch( - withBasePath(`/api/templates?package_name=${encodeURIComponent(packageName)}`) - ); - - if (!response.ok) { - throw new Error(`Failed to fetch templates for ${channel}`); - } - - const data = await response.json(); + const data = await templateService.list(packageName); if (!cancelled) { const templates = Array.isArray(data) ? data : []; setTemplatesByChannel((prev) => ({ ...prev, [channel]: templates })); @@ -278,13 +266,7 @@ export function SingleNotificationForm({ onSuccess }: SingleNotificationFormProp } try { - const response = await fetch(withBasePath(`/api/templates/${encodeURIComponent(templateId)}`)); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(data.message || data.error || "Failed to load template detail"); - } - - const template = data as NotificationTemplateDetail; + const template = await templateService.get(templateId); const variableNames = extractTemplateVariables(template.content); if (!cancelled) { @@ -388,17 +370,7 @@ export function SingleNotificationForm({ onSuccess }: SingleNotificationFormProp } } - const response = await fetch(withBasePath("/api/notification"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || data.error || "Failed to send"); - } + await notificationService.send(payload); toast.success("Notification sent successfully!"); onSuccess?.(); diff --git a/dashboard/lib/api-client.ts b/dashboard/lib/api-client.ts index 43e7dfe..091b110 100644 --- a/dashboard/lib/api-client.ts +++ b/dashboard/lib/api-client.ts @@ -1,25 +1,154 @@ /** * Dashboard API Client - * Centralized client-side fetch wrapper + * Centralized client-side fetch wrapper using Axios */ -import { withBasePath } from "./utils"; - -export async function apiRequest( - path: string, - options: RequestInit = {} -): Promise { - const response = await fetch(withBasePath(path), { - ...options, - headers: { - "Content-Type": "application/json", - ...options.headers, - }, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new Error(error.message || `API request failed with status ${response.status}`); - } +import axios, { AxiosError } from 'axios'; +import { getBasePath } from './utils'; +import { + Notification, + DashboardStats, + TrendDataPoint, + PaginatedResponse, + NotificationFilters, + Alert, + PluginMetadata, + NotificationTemplateListItem, + NotificationTemplateDetail, + NotificationTemplateCreatePayload, + NotificationTemplateUpdatePayload, + AdminChannel, + AdminChannelFormData, + AdminChannelProviderMeta, +} from './types'; + +export class ApiError extends Error { + status?: number; + details?: unknown; - return response.json(); + constructor(message: string, status?: number, details?: unknown) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.details = details; + } } + +export const apiClient = axios.create({ + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Dynamic Base URL Resolution +apiClient.interceptors.request.use((config) => { + config.baseURL = getBasePath(); + return config; +}); + +// Response Interceptor for Centralized Error Handling +apiClient.interceptors.response.use( + (response) => response.data, + (error: AxiosError) => { + let message = 'An unexpected error occurred'; + let details: unknown = null; + + if (error.response) { + // Backend core error formats: { error: 'msg' } or { message: 'msg' } + const data = error.response.data as Record | null | undefined; + message = (data?.error as string) || (data?.message as string) || `Request failed with status ${error.response.status}`; + details = data?.details || null; + } else if (error.request) { + message = 'No response received from server. Check your network connection.'; + } else { + message = error.message; + } + + return Promise.reject(new ApiError(message, error.response?.status, details)); + } +); + +// Service Modules +export const authService = { + login: (payload: Record): Promise<{ success: boolean; redirectUrl: string }> => + apiClient.post('/api/auth/login', payload), + logout: (): Promise => apiClient.post('/api/auth/logout'), + getSession: (): Promise<{ authenticated: boolean; user?: { id: string; username: string } }> => + apiClient.get('/api/auth/session'), +}; + +export const notificationService = { + list: (params: NotificationFilters): Promise> => + apiClient.get('/api/notifications', { params }), + get: (id: string): Promise => + apiClient.get(`/api/notifications/${id}`), + retry: (id: string): Promise<{ success: boolean; message: string }> => + apiClient.post(`/api/notifications/${id}/retry`), + send: (payload: Record): Promise => + apiClient.post('/api/notification', payload), + sendBatch: (payload: Record): Promise => + apiClient.post('/api/notification/batch', payload), + delete: (id: string): Promise<{ success: boolean }> => + apiClient.delete(`/api/notifications/${id}`), +}; + +export const templateService = { + list: (packageName?: string): Promise => + apiClient.get('/api/templates', { + params: packageName ? { package_name: packageName } : {}, + }), + get: (id: string): Promise => + apiClient.get(`/api/templates/${encodeURIComponent(id)}`), + create: (payload: NotificationTemplateCreatePayload): Promise => + apiClient.post('/api/templates/create', payload), + update: (id: string, payload: NotificationTemplateUpdatePayload): Promise => + apiClient.put(`/api/templates/${encodeURIComponent(id)}`, payload), + delete: (id: string): Promise => + apiClient.delete(`/api/templates/${encodeURIComponent(id)}`), +}; + +export const alertService = { + list: (page: number, limit: number, type?: string): Promise<{ + alerts: Alert[]; + count: number; + byType: Record; + page: number; + limit: number; + totalPages: number; + }> => + apiClient.get('/api/alerts', { + params: { page, limit, type }, + }), + get: (id: string): Promise => + apiClient.get(`/api/alerts/${id}`), + resolve: (id: string, payload: { appendWarning?: boolean }): Promise<{ success: boolean }> => + apiClient.post(`/api/alerts/${id}/resolve`, payload), + bulkResolve: (payload: { appendWarning?: boolean; limit?: number }): Promise<{ success: boolean; message: string }> => + apiClient.post('/api/alerts/bulk-resolve', payload), + delete: (id: string): Promise<{ success: boolean }> => + apiClient.delete(`/api/alerts/${id}`), +}; + +export const pluginService = { + getMetadata: (): Promise => apiClient.get('/api/plugins'), +}; + +export const adminChannelService = { + list: (): Promise => apiClient.get('/api/admin-channels'), + get: (id: string): Promise => apiClient.get(`/api/admin-channels/${id}`), + create: (payload: AdminChannelFormData): Promise<{ success: boolean; channel: AdminChannel }> => + apiClient.post('/api/admin-channels', payload), + update: (id: string, payload: Partial): Promise<{ success: boolean; channel: AdminChannel }> => + apiClient.patch(`/api/admin-channels/${id}`, payload), + delete: (id: string): Promise<{ success: boolean }> => + apiClient.delete(`/api/admin-channels/${id}`), + getProviders: (): Promise<{ providers: AdminChannelProviderMeta[] }> => + apiClient.get('/api/admin-channels/providers'), + test: (payload: { channel_type: string; config: Record }): Promise<{ success: boolean; message?: string; error?: string }> => + apiClient.post('/api/admin-channels/test', payload), +}; + +export const dashboardService = { + getStats: (): Promise => apiClient.get('/api/dashboard/stats'), + getTrends: (hours?: number): Promise => + apiClient.get('/api/dashboard/trends', { params: { hours } }), +}; diff --git a/dashboard/lib/types.ts b/dashboard/lib/types.ts index 90ca90e..c8b8171 100644 --- a/dashboard/lib/types.ts +++ b/dashboard/lib/types.ts @@ -191,6 +191,23 @@ export interface AdminChannel { export interface AdminChannelFormData { channel_type: AdminChannelType; name: string; - webhook_url: string; + config: Record; alert_filters: AlertFilters; + enabled?: boolean; +} + +export interface AdminChannelProviderField { + name: string; + type: 'string' | 'url' | 'secret'; + label: string; + placeholder?: string; + description?: string; + required: boolean; + pattern?: string; +} + +export interface AdminChannelProviderMeta { + channelType: string; + displayName: string; + credentialFields: AdminChannelProviderField[]; } diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 05955ec..4083ed1 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -25,12 +25,12 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "axios": "^1.18.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", "driver.js": "^1.4.0", "lucide-react": "^0.556.0", - "mongoose": "^9.0.1", "motion": "^12.23.25", "next": "16.0.7", "next-themes": "^0.4.6", @@ -55,6 +55,9 @@ "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5" + }, + "engines": { + "node": ">=22.0.0" } }, "node_modules/@alloc/quick-lru": { @@ -1129,15 +1132,6 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz", - "integrity": "sha512-QgA5AySqB27cGTXBFmnpifAi7HxoGUeezwo6p9dI03MuDB6Pp33zgclqVb6oVK3j6I9Vesg0+oojW2XxB59SGg==", - "license": "MIT", - "dependencies": { - "sparse-bitfield": "^3.0.3" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -3897,12 +3891,6 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, - "node_modules/@types/webidl-conversions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", - "license": "MIT" - }, "node_modules/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", @@ -3910,15 +3898,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/whatwg-url": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", - "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", - "license": "MIT", - "dependencies": { - "@types/webidl-conversions": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.48.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", @@ -4488,6 +4467,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4727,6 +4718,12 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -4753,6 +4750,18 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -4838,15 +4847,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bson": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-7.0.0.tgz", - "integrity": "sha512-Kwc6Wh4lQ5OmkqqKhYGKIuELXl+EPYSCObVE6bWsp1T/cGkOCBN0I8wF/T44BiuhHyNi1mmKVPXk60d41xZ7kw==", - "license": "Apache-2.0", - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -4870,7 +4870,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4991,6 +4990,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5229,7 +5240,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5292,6 +5302,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -5350,7 +5369,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -5462,7 +5480,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5472,7 +5489,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5510,7 +5526,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -5523,7 +5538,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6161,6 +6175,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -6177,6 +6211,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/framer-motion": { "version": "12.23.25", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.25.tgz", @@ -6208,7 +6258,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6269,7 +6318,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -6303,7 +6351,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -6391,7 +6438,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6470,7 +6516,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6483,7 +6528,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -6496,10 +6540,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6525,6 +6568,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -7136,15 +7192,6 @@ "node": ">=4.0" } }, - "node_modules/kareem": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.0.0.tgz", - "integrity": "sha512-RKhaOBSPN8L7y4yAgNhDT2602G5FD6QbOIISbjN9D6mjHPeqeg7K+EB5IGSU5o81/X2Gzm3ICnAvQW3x3OP8HA==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7532,18 +7579,11 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, - "node_modules/memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "license": "MIT" - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7575,6 +7615,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -7609,86 +7670,6 @@ "marked": "14.0.0" } }, - "node_modules/mongodb": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.0.0.tgz", - "integrity": "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg==", - "license": "Apache-2.0", - "dependencies": { - "@mongodb-js/saslprep": "^1.3.0", - "bson": "^7.0.0", - "mongodb-connection-string-url": "^7.0.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.806.0", - "@mongodb-js/zstd": "^7.0.0", - "gcp-metadata": "^7.0.1", - "kerberos": "^7.0.0", - "mongodb-client-encryption": ">=7.0.0 <7.1.0", - "snappy": "^7.3.2", - "socks": "^2.8.6" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.0.tgz", - "integrity": "sha512-irhhjRVLE20hbkRl4zpAYLnDMM+zIZnp0IDB9akAFFUZp/3XdOfwwddc7y6cNvF2WCEtfTYRwYbIfYa2kVY0og==", - "license": "Apache-2.0", - "dependencies": { - "@types/whatwg-url": "^13.0.0", - "whatwg-url": "^14.1.0" - }, - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/mongoose": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.0.1.tgz", - "integrity": "sha512-aHPfQx2YX5UwAmMVud7OD4lIz9AEO4jI+oDnRh3lPZq9lrKTiHmOzszVffDMyQHXvrf4NXsJ34kpmAhyYAZGbw==", - "license": "MIT", - "dependencies": { - "kareem": "3.0.0", - "mongodb": "~7.0", - "mpath": "0.9.0", - "mquery": "6.0.0", - "ms": "2.1.3", - "sift": "17.1.3" - }, - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mongoose" - } - }, "node_modules/motion": { "version": "12.23.25", "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.25.tgz", @@ -7730,24 +7711,6 @@ "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", "license": "MIT" }, - "node_modules/mpath": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", - "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mquery": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz", - "integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==", - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8203,10 +8166,20 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8821,12 +8794,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sift": { - "version": "17.1.3", - "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", - "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", - "license": "MIT" - }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -8846,15 +8813,6 @@ "node": ">=0.10.0" } }, - "node_modules/sparse-bitfield": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", - "license": "MIT", - "dependencies": { - "memory-pager": "^1.0.2" - } - }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -9184,18 +9142,6 @@ "node": ">=8.0" } }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -9556,28 +9502,6 @@ "d3-timer": "^3.0.1" } }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 6b06ca7..f2590d0 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -29,6 +29,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "axios": "^1.18.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", From f33aff66ccc86a11eb8916d717680b7a70eb5422 Mon Sep 17 00:00:00 2001 From: Adhish-Krishna Date: Sun, 5 Jul 2026 01:21:25 +0530 Subject: [PATCH 09/14] fix(admin-channel): update list method to return channels in a structured object --- dashboard/lib/api-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/lib/api-client.ts b/dashboard/lib/api-client.ts index 091b110..dd1e9cc 100644 --- a/dashboard/lib/api-client.ts +++ b/dashboard/lib/api-client.ts @@ -133,7 +133,7 @@ export const pluginService = { }; export const adminChannelService = { - list: (): Promise => apiClient.get('/api/admin-channels'), + list: (): Promise<{ channels: AdminChannel[] }> => apiClient.get('/api/admin-channels'), get: (id: string): Promise => apiClient.get(`/api/admin-channels/${id}`), create: (payload: AdminChannelFormData): Promise<{ success: boolean; channel: AdminChannel }> => apiClient.post('/api/admin-channels', payload), From 7a37cf227c17369e899b2827f1f442da16fae2bd Mon Sep 17 00:00:00 2001 From: Adhish-Krishna Date: Sun, 5 Jul 2026 16:21:30 +0530 Subject: [PATCH 10/14] refactor(dashboard): remove webhook configuration from runtime and API config --- dashboard/app/api/runtime-config/route.ts | 2 -- .../send/batch-notification-form.tsx | 4 ++-- .../send/single-notification-form.tsx | 4 ++-- dashboard/entrypoint.sh | 8 +------- dashboard/lib/api-config.ts | 18 +----------------- 5 files changed, 6 insertions(+), 30 deletions(-) diff --git a/dashboard/app/api/runtime-config/route.ts b/dashboard/app/api/runtime-config/route.ts index e649856..e2872ec 100644 --- a/dashboard/app/api/runtime-config/route.ts +++ b/dashboard/app/api/runtime-config/route.ts @@ -8,8 +8,6 @@ import { NextResponse } from "next/server"; export async function GET() { return NextResponse.json({ basePath: process.env.BASE_PATH || "", - webhookHost: process.env.WEBHOOK_HOST || "localhost", - webhookPort: process.env.WEBHOOK_PORT || "3002", }); } diff --git a/dashboard/components/send/batch-notification-form.tsx b/dashboard/components/send/batch-notification-form.tsx index d58ca86..b1d2911 100644 --- a/dashboard/components/send/batch-notification-form.tsx +++ b/dashboard/components/send/batch-notification-form.tsx @@ -14,7 +14,6 @@ import { DynamicField } from "./dynamic-field"; import { PluginMetadata, FieldDefinition, NotificationTemplateDetail, NotificationTemplateListItem } from "@/lib/types"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { WEBHOOK_HOST, WEBHOOK_PORT } from "@/lib/api-config"; import { pluginService, templateService, notificationService } from "@/lib/api-client"; // Generate a UUIDv4 @@ -396,7 +395,8 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps) client_id: clientId, channel: selectedChannels, recipients: processedRecipients, - webhook_url: `http://${WEBHOOK_HOST}:${WEBHOOK_PORT}/api/webhook` + //hard-coded the webhook url as the simplest solution. This url may not be correct all the times. Nop issues, the worker just fire and forgets while sending webhook, it does not retires + webhook_url: "http://localhost:3002/api/webhook" }; if (inputMode === "template") { diff --git a/dashboard/components/send/single-notification-form.tsx b/dashboard/components/send/single-notification-form.tsx index 2bdf0dd..e8b720f 100644 --- a/dashboard/components/send/single-notification-form.tsx +++ b/dashboard/components/send/single-notification-form.tsx @@ -14,7 +14,6 @@ import { DynamicField } from "./dynamic-field"; import { PluginMetadata, ProviderMetadata, NotificationTemplateDetail, NotificationTemplateListItem } from "@/lib/types"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { WEBHOOK_HOST, WEBHOOK_PORT } from "@/lib/api-config"; import { pluginService, templateService, notificationService } from "@/lib/api-client"; // Generate a UUIDv4 @@ -320,7 +319,8 @@ export function SingleNotificationForm({ onSuccess }: SingleNotificationFormProp client_id: clientId, channel: selectedChannels, recipient: { ...recipientData }, - webhook_url: `http://${WEBHOOK_HOST}:${WEBHOOK_PORT}/api/webhook` + //hard-coded the webhook url as the simplest solution. This url may not be correct all the times. Nop issues, the worker just fire and forgets while sending webhook, it does not retires + webhook_url: "http://localhost:3002/api/webhook" }; if (inputMode === "template") { diff --git a/dashboard/entrypoint.sh b/dashboard/entrypoint.sh index 1038e57..0300be5 100644 --- a/dashboard/entrypoint.sh +++ b/dashboard/entrypoint.sh @@ -3,20 +3,14 @@ set -e # Get configuration from environment BASE_PATH="${BASE_PATH:-}" -WEBHOOK_HOST="${WEBHOOK_HOST:-localhost}" -WEBHOOK_PORT="${WEBHOOK_PORT:-3002}" echo "šŸš€ Starting SimpleNS Dashboard..." echo "šŸ“ Base Path: ${BASE_PATH:-'/ (root)'}" -echo "šŸ“ Webhook Host: ${WEBHOOK_HOST}" -echo "šŸ“ Webhook Port: ${WEBHOOK_PORT}" # Generate runtime configuration accessible to client cat > /app/public/runtime-config.js << EOF window.__RUNTIME_CONFIG__ = { - basePath: "${BASE_PATH}", - webhookHost: "${WEBHOOK_HOST}", - webhookPort: "${WEBHOOK_PORT}" + basePath: "${BASE_PATH}" }; EOF diff --git a/dashboard/lib/api-config.ts b/dashboard/lib/api-config.ts index f7e2f9d..c777abd 100644 --- a/dashboard/lib/api-config.ts +++ b/dashboard/lib/api-config.ts @@ -3,21 +3,5 @@ * Centralized configuration for API endpoints */ -const getWebhookHost = (): string => { - if (typeof window !== 'undefined' && window.__RUNTIME_CONFIG__) { - return window.__RUNTIME_CONFIG__.webhookHost || "localhost"; - } - return process.env.WEBHOOK_HOST || "localhost"; -}; - -const getWebhookPort = (): string => { - if (typeof window !== 'undefined' && window.__RUNTIME_CONFIG__) { - return window.__RUNTIME_CONFIG__.webhookPort || "3002"; - } - return process.env.WEBHOOK_PORT || "3002"; -}; - export const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3000'; -export const NS_API_KEY = process.env.NS_API_KEY || ""; -export const WEBHOOK_HOST = getWebhookHost(); -export const WEBHOOK_PORT = getWebhookPort(); \ No newline at end of file +export const NS_API_KEY = process.env.NS_API_KEY || ""; \ No newline at end of file From ced568b96d9a7f640aa3cb9ecf0498cb52968009 Mon Sep 17 00:00:00 2001 From: Adhish-Krishna Date: Tue, 14 Jul 2026 11:24:15 +0530 Subject: [PATCH 11/14] refactor(dashboard): update API client and types for dashboard trends response --- dashboard/lib/api-client.ts | 8 +++----- dashboard/lib/types.ts | 11 ++++++++--- packages/mcp-server/src/auth.ts | 3 +-- src/api/routes/dashboard.routes.ts | 2 +- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/dashboard/lib/api-client.ts b/dashboard/lib/api-client.ts index dd1e9cc..7d6fa92 100644 --- a/dashboard/lib/api-client.ts +++ b/dashboard/lib/api-client.ts @@ -7,7 +7,7 @@ import { getBasePath } from './utils'; import { Notification, DashboardStats, - TrendDataPoint, + DashboardTrendsResponse, PaginatedResponse, NotificationFilters, Alert, @@ -118,8 +118,6 @@ export const alertService = { apiClient.get('/api/alerts', { params: { page, limit, type }, }), - get: (id: string): Promise => - apiClient.get(`/api/alerts/${id}`), resolve: (id: string, payload: { appendWarning?: boolean }): Promise<{ success: boolean }> => apiClient.post(`/api/alerts/${id}/resolve`, payload), bulkResolve: (payload: { appendWarning?: boolean; limit?: number }): Promise<{ success: boolean; message: string }> => @@ -149,6 +147,6 @@ export const adminChannelService = { export const dashboardService = { getStats: (): Promise => apiClient.get('/api/dashboard/stats'), - getTrends: (hours?: number): Promise => - apiClient.get('/api/dashboard/trends', { params: { hours } }), + getTrends: (period?: string): Promise => + apiClient.get('/api/dashboard/trends', { params: { period } }), }; diff --git a/dashboard/lib/types.ts b/dashboard/lib/types.ts index c8b8171..6437ed0 100644 --- a/dashboard/lib/types.ts +++ b/dashboard/lib/types.ts @@ -51,10 +51,15 @@ export interface DashboardStats { } export interface TrendDataPoint { - timestamp: string; - hour: number; - count: number; + time: number; status: NOTIFICATION_STATUS; + count: number; +} + +export interface DashboardTrendsResponse { + period: string; + startDate: string; + data: TrendDataPoint[]; } export interface PaginatedResponse { diff --git a/packages/mcp-server/src/auth.ts b/packages/mcp-server/src/auth.ts index 45cbb33..053bf8c 100644 --- a/packages/mcp-server/src/auth.ts +++ b/packages/mcp-server/src/auth.ts @@ -20,7 +20,6 @@ export interface UserCredentials { export function extractCredentials(req: Request): UserCredentials { const apiKey = req.headers['x-simplens-api-key'] as string | undefined; const coreUrl = req.headers['x-simplens-core-url'] as string | undefined; - const dashboardUrl = req.headers['x-simplens-dashboard-url'] as string | undefined; if (!apiKey || !coreUrl) { const missing: string[] = []; @@ -43,7 +42,7 @@ export function extractCredentials(req: Request): UserCredentials { * Get credentials from environment variables (for stdio transport). */ export function getStdioCredentials(): UserCredentials { - const { SIMPLENS_API_KEY, SIMPLENS_CORE_URL, SIMPLENS_DASHBOARD_URL } = serverConfig; + const { SIMPLENS_API_KEY, SIMPLENS_CORE_URL } = serverConfig; if (!SIMPLENS_API_KEY) { throw new Error('NS_API_KEY environment variable is required for stdio mode'); diff --git a/src/api/routes/dashboard.routes.ts b/src/api/routes/dashboard.routes.ts index accb296..8d5e273 100644 --- a/src/api/routes/dashboard.routes.ts +++ b/src/api/routes/dashboard.routes.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import { getDashboardStats, getDashboardTrends -} from '../controllers/dashboard.controller.js'; // Wait, let's verify if TS compilation uses .js extension! Yes, ES modules imports use .js extension. So dashboard.controller.js is correct. Let's write that. +} from '../controllers/dashboard.controller.js'; const router = Router(); From c2b5114a84c0985db3be123735ba00f6f02fbe10 Mon Sep 17 00:00:00 2001 From: Adhish-Krishna Date: Wed, 15 Jul 2026 14:29:22 +0530 Subject: [PATCH 12/14] fix(api): call markModified func on document object to inlcude the changes for saving it in mongoDB --- src/api/controllers/alerts.controller.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/api/controllers/alerts.controller.ts b/src/api/controllers/alerts.controller.ts index d48be66..58fe425 100644 --- a/src/api/controllers/alerts.controller.ts +++ b/src/api/controllers/alerts.controller.ts @@ -148,6 +148,7 @@ export const resolveAlert = async (req: Request, res: Response): Promise = } else if (content.message) { content.message = String(content.message) + warningMessage; } + notification.markModified('content'); } // Reset notification to pending @@ -252,6 +253,7 @@ export const bulkResolveAlerts = async (req: Request, res: Response): Promise Date: Wed, 15 Jul 2026 17:09:19 +0530 Subject: [PATCH 13/14] fix(admin-channel): enhance validation for channel config fields to ensure string type and required checks --- .../admin-channels-crud.controller.ts | 26 +++++++++++++------ tests/integration/api/dashboard_api.test.ts | 3 ++- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/api/controllers/admin-channels-crud.controller.ts b/src/api/controllers/admin-channels-crud.controller.ts index 3c19915..adb4a0c 100644 --- a/src/api/controllers/admin-channels-crud.controller.ts +++ b/src/api/controllers/admin-channels-crud.controller.ts @@ -51,14 +51,19 @@ export const createAdminChannel = async (req: Request, res: Response): Promise { webhook_url: 'http://cb.com', status: 'failed', retry_count: 1, - save: vi.fn().mockResolvedValue(true) + save: vi.fn().mockResolvedValue(true), + markModified: vi.fn() }; mockAlertFindById.mockResolvedValueOnce(mockAlert); From 933e51b2bf97c5e96d57a84f9d8be782181f7988 Mon Sep 17 00:00:00 2001 From: Adhish-Krishna Date: Wed, 15 Jul 2026 17:21:22 +0530 Subject: [PATCH 14/14] refactor(dashboard): update trends aggregation to use timestamp buckets for improved date handling --- src/api/controllers/dashboard.controller.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/api/controllers/dashboard.controller.ts b/src/api/controllers/dashboard.controller.ts index bfe1d10..4fbd7b9 100644 --- a/src/api/controllers/dashboard.controller.ts +++ b/src/api/controllers/dashboard.controller.ts @@ -89,11 +89,12 @@ export const getDashboardTrends = async (req: Request, res: Response): Promise