mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
fix: feature-base-refactoring (#297)
* docs: add AGENTS.md with feature-based architecture conventions Defines the target src/ structure, feature list, naming conventions (kebab-case, file type patterns), import direction rules, and feature boundary guidelines for the planned big-bang refactor. * docs: add features/channel/ to architecture conventions Generic channel UI (card, modal, form) goes to features/channel/. Provider-specific sub-folders: channel/notifications/ and channel/storages/. * refactor: move event emitter to lib/, project service to db/services/, dedup use-mobile hook * fix: remove unused auth import in db/services/project.ts * refactor: migrate wrappers/common to components/common (flat) * fix: remove dead commented-out code from button-with-loading.tsx * refactor: merge features/browser into features/theme, flatten upload/updates, migrate statistics * refactor: migrate wrappers/auth to features/auth Move all auth components and forms from src/components/wrappers/auth/ into src/features/auth/ with flat kebab-case naming. Handle extra reset-password-form.tsx and differing reset-password-schema.ts by copying them as reset-password-page-form.tsx and reset-password-page.schema.ts. * refactor: remove dead auth files and fix zPassword schema Delete reset-password-section.tsx and reset-password-page-form.tsx (zero consumers). Fix reset-password-page.schema.ts to import zPassword from @/lib/zod. * refactor: migrate wrappers/dashboard/common to features/layout, rename Header.tsx to header.tsx * fix: decouple layout from theme/updates cross-feature imports Use slot props (actions, updateNotification) so app/ composes ModeToggle and UpdateNotification into Header and AppSidebar. Delete dead side-bar-logo.tsx (zero consumers). * refactor: merge features/keys into features/agents, flatten sub-dirs, migrate agent wrappers - Merge src/features/keys/keys.action.ts into src/features/agents/ - Flatten src/features/agents/components/ and hooks/ into feature root - Migrate all src/components/wrappers/dashboard/agent/ files into src/features/agents/ - Update all import paths across app/ and src/ accordingly - Fix broken code-snippet import in agent-key-modal.tsx (was pointing to non-existent wrappers path) * fix: move github-releases to lib/, remove dead code and unused imports Move GitHub release fetch logic from features/updates/github.ts to lib/github-releases.ts so both features/updates and features/agents can import it without cross-feature imports. Remove commented-out impersonation block from agent-database-card.tsx. Remove unused imports (CopyButton, Terminal, Key, Info). * refactor: flatten features/projects/components, migrate project wrappers Move project.dialog/form out of sub-dir, migrate project-card, project-database-card, and delete-project button/action from wrappers into features/projects/. Update all import paths across src/ and app/. Leave wrappers/dashboard/projects/database/ untouched for Task 10. * fix: extract DatabaseCard to components/common, remove cross-feature import DatabaseCard was imported by both features/projects and features/agents, violating the no-cross-feature-import rule. Extract to components/common/ database-card.tsx so both features import from the shared layer. Also tighten extendedProps type and remove commented-out maxCount prop. * refactor: migrate all database/backup/restore/health UI to features/database Consolidates ~40 files from 5 source locations (features/dashboard/backup, features/dashboard/restore, wrappers/dashboard/backup, wrappers/dashboard/database, wrappers/dashboard/health, wrappers/dashboard/projects/database) into the new flat features/database directory with kebab-case filenames and updated @/ imports. * fix: remove dead restore-form files and console.log from health-grid Delete restore-form.tsx (186 lines all commented-out, zero consumers) and restore-form.schema.ts (only a commented import). Remove console.log(logs) from getOldestLog helper in health-grid.tsx. Cross-feature imports (database→storages for dispatchStorage/storeBackupFiles) are accepted as known architectural coupling — storage dispatch is called from database server actions at runtime and cannot route through app/ props. * refactor: migrate wrappers/admin/users to features/users, fix table-colums typo * fix: fix relative imports, string interpolation, and console.error in users feature Replace relative imports with @/ aliases in user-actions-cell.tsx and admin-user-edit-modal.tsx. Fix string interpolation bug in dialog title (was regular string, now template literal). Remove console.error from admin-user-change-role-modal.tsx. * refactor: migrate wrappers/profile to features/profile, fix profile-apperance typo * fix: remove console.error calls and ts-ignore in profile feature Remove console.error from profile-account.tsx, 2fa-form.tsx (x2). Replace @ts-ignore on fileInput.onchange with typed cast in avatar-with-upload.tsx. * refactor: rename features/organization to features/organizations, migrate all org wrappers Moves 37 org-related files from features/organization/, features/dashboard/, hooks/, wrappers/admin/organizations/, wrappers/admin/channels/organization/, and wrappers/dashboard/organization/ into a flat features/organizations/ feature directory. Updates all import paths codebase-wide. Fixes @ts-ignore and removes unused @better-fetch/fetch import in admin-organization-form. * fix: remove console.error, @ts-ignore, and unused destructures in features/organizations * refactor: migrate wrappers/organization/migration to features/migration Move all 5 migration files to src/features/migration/, update internal imports to @/features/migration/ absolute paths, remove console.log, and delete src/components/wrappers/dashboard/organization/ entirely. * refactor: migrate wrappers/admin/settings to features/settings Move all 14 settings files (email, notification, storage, s3) from wrappers/dashboard/admin/settings/ into features/settings/, rewriting imports, replacing @ts-ignore with @ts-expect-error, and renaming error params to _error where applicable. * refactor: rename notifications files to kebab convention, migrate notification logs - Rename dispatch.ts/helpers.ts/types.ts to notifications.*.ts - Migrate notification logs components from wrappers/dashboard/admin/notifications/logs/ to features/notifications/ - Fix all import paths across codebase and in providers/ - Remove unused useRouter import, replace @ts-ignore with @ts-expect-error - Delete src/components/wrappers/dashboard/admin/notifications/ * refactor: rename storages dispatch/helpers/types to kebab convention - Rename dispatch.ts/helpers.ts/types.ts to storages.*.ts - Fix all import paths across codebase and in providers/ - Fix relative ../types and ../../types imports in all storage providers to absolute @/ paths - Replace @ts-ignore with @ts-expect-error, remove console.debug/console.error * refactor: create features/channel with generic UI and notifications/storages provider sub-folders Consolidates 4 source locations into src/features/channel/: - components/wrappers/dashboard/admin/channels/ → features/channel/ (11 generic UI files) - features/notifications/providers/ → features/channel/notifications/ (9 backend + 18 form files) - features/storages/providers/ → features/channel/storages/ (backend + forms) The two conflicting google-drive/helpers.ts files (client helpers vs OAuth action) are kept separate: helpers.ts and google-drive-refresh.action.ts. Rewrites all imports codebase-wide to point to new locations. Removes console.log/console.error calls, upgrades @ts-ignore to @ts-expect-error. * fix: remove console.error from local storage provider * fix: multi-select.tsx scroll issue * fix: multi-select.tsx scroll issue
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
"use server";
|
||||
|
||||
import {z} from "zod";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||
import {db} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {
|
||||
NotificationChannelFormSchema
|
||||
} from "@/features/channel/channel-form.schema";
|
||||
|
||||
|
||||
export const addNotificationChannelAction = userAction.schema(
|
||||
z.object({
|
||||
organizationId: z.string().optional(),
|
||||
data: NotificationChannelFormSchema
|
||||
})
|
||||
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
|
||||
const {organizationId, data} = parsedInput;
|
||||
try {
|
||||
const [channel] = await db
|
||||
.insert(drizzleDb.schemas.notificationChannel)
|
||||
.values({
|
||||
provider: data.provider,
|
||||
name: data.name,
|
||||
config: data.config,
|
||||
enabled: data.enabled ?? true,
|
||||
organizationId: organizationId ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (organizationId) {
|
||||
await db.insert(drizzleDb.schemas.organizationNotificationChannel).values({
|
||||
organizationId,
|
||||
notificationChannelId: channel.id,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...channel,
|
||||
config: channel.config as JSON
|
||||
},
|
||||
actionSuccess: {
|
||||
message: "Notification channel has been successfully created.",
|
||||
messageParams: {notificationChannelId: channel.id},
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
const error = _error;
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to create notification channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {notificationChannelId: ""},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const removeNotificationChannelAction = userAction.schema(
|
||||
z.object({
|
||||
organizationId: z.string().optional(),
|
||||
notificationChannelId: z.string(),
|
||||
})
|
||||
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
|
||||
const {organizationId, notificationChannelId} = parsedInput;
|
||||
|
||||
try {
|
||||
if (organizationId) {
|
||||
await db
|
||||
.delete(drizzleDb.schemas.organizationNotificationChannel)
|
||||
.where(
|
||||
and(
|
||||
eq(drizzleDb.schemas.organizationNotificationChannel.organizationId, organizationId),
|
||||
eq(drizzleDb.schemas.organizationNotificationChannel.notificationChannelId, notificationChannelId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [deletedChannel] = await db
|
||||
.delete(drizzleDb.schemas.notificationChannel)
|
||||
.where(eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId))
|
||||
.returning();
|
||||
|
||||
if (!deletedChannel) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Notification channel not found.",
|
||||
status: 404,
|
||||
messageParams: {notificationChannelId: notificationChannelId},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...deletedChannel,
|
||||
config: deletedChannel.config as JSON
|
||||
},
|
||||
actionSuccess: {
|
||||
message: "Notification channel has been successfully removed.",
|
||||
messageParams: {notificationChannelId: notificationChannelId},
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
const error = _error;
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to remove notification channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {notificationChannelId: notificationChannelId},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export const updateNotificationChannelAction = userAction.schema(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
data: NotificationChannelFormSchema
|
||||
})
|
||||
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
|
||||
const {id, data} = parsedInput;
|
||||
|
||||
try {
|
||||
const [channel] = await db
|
||||
.update(drizzleDb.schemas.notificationChannel)
|
||||
.set(withUpdatedAt({
|
||||
provider: data.provider,
|
||||
name: data.name,
|
||||
config: data.config,
|
||||
enabled: data.enabled ?? true,
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.notificationChannel.id, id))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...channel,
|
||||
config: channel.config as JSON
|
||||
},
|
||||
actionSuccess: {
|
||||
message: `Notification channel "${channel.name}" has been successfully updated.`,
|
||||
messageParams: {id: channel.id},
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
const error = _error;
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to update notification channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {id: ""},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierDiscordFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierDiscordForm = ({ form }: NotifierDiscordFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.discordWebhook"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Discord Webhook URL *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
placeholder="e.g. https://discord.com/api/webhooks/..."
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const DiscordChannelConfigSchema = z.object({
|
||||
discordWebhook: z.string().url("Must be a valid URL"),
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
|
||||
|
||||
export async function sendDiscord(
|
||||
config: { discordWebhook: string },
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {discordWebhook: webhookUrl} = config;
|
||||
|
||||
const embeds = [
|
||||
{
|
||||
title: `[${payload.level.toUpperCase()}] ${payload.title}`,
|
||||
description: payload.message,
|
||||
color: payload.level === 'critical' ? 15158332 : payload.level === 'warning' ? 16776960 : 3447003,
|
||||
fields: payload.data ? [
|
||||
{
|
||||
name: 'Data',
|
||||
value: `
|
||||
${JSON.stringify(payload.data, null, 2).substring(0, 1000)}
|
||||
`,
|
||||
inline: false
|
||||
}
|
||||
] : [],
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
];
|
||||
|
||||
const body = {
|
||||
embeds,
|
||||
};
|
||||
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Discord error: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: 'discord',
|
||||
message: 'Sent to Discord',
|
||||
response: res.statusText,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierGotifyFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierGotifyForm = ({ form }: NotifierGotifyFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gotifyServerUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gotify Server URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. https://gotify.example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gotifyAppToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Token *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="e.g. gotify-app-token" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const GotifyChannelConfigSchema = z.object({
|
||||
gotifyServerUrl: z.string().url("Must be a valid URL"),
|
||||
gotifyAppToken: z.string().min(1, "App token is required"),
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
|
||||
|
||||
export async function sendGotify(
|
||||
config: { gotifyServerUrl: string; gotifyAppToken: string },
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {gotifyServerUrl, gotifyAppToken} = config;
|
||||
|
||||
const baseUrl = gotifyServerUrl.replace(/\/$/, "");
|
||||
|
||||
const body = {
|
||||
title: `[${payload.level.toUpperCase()}] ${payload.title}`,
|
||||
message: `${payload.message}\n\n${payload.data ? `Data:\n${JSON.stringify(payload.data, null, 2)}` : ''}`,
|
||||
priority: payload.level === 'critical' ? 8 : payload.level === 'warning' ? 5 : 2,
|
||||
};
|
||||
|
||||
const res = await fetch(`${baseUrl}/message?token=${gotifyAppToken}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Gotify error: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: 'gotify',
|
||||
message: 'Sent to Gotify',
|
||||
response: await res.json(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use server"
|
||||
import type {ProviderKind, EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
|
||||
import {sendSlack} from './slack';
|
||||
import {sendSmtp} from './smtp';
|
||||
import {sendDiscord} from "@/features/channel/notifications/discord";
|
||||
import {sendTelegram} from "@/features/channel/notifications/telegram";
|
||||
import {sendGotify} from "@/features/channel/notifications/gotify";
|
||||
import {sendNtfy} from "@/features/channel/notifications/ntfy";
|
||||
import {sendWebhook} from "@/features/channel/notifications/webhook";
|
||||
import {sendNextcloud} from "@/features/channel/notifications/nextcloud";
|
||||
|
||||
const handlers: Record<
|
||||
ProviderKind,
|
||||
(config: any, payload: EventPayload) => Promise<DispatchResult>
|
||||
> = {
|
||||
slack: sendSlack,
|
||||
smtp: sendSmtp,
|
||||
discord: sendDiscord,
|
||||
telegram: sendTelegram,
|
||||
gotify: sendGotify,
|
||||
ntfy: sendNtfy,
|
||||
webhook: sendWebhook,
|
||||
nextcloud: sendNextcloud,
|
||||
};
|
||||
|
||||
export async function dispatchViaProvider(
|
||||
kind: ProviderKind,
|
||||
config: any,
|
||||
payload: EventPayload,
|
||||
channelId: string
|
||||
): Promise<DispatchResult> {
|
||||
const handler = handlers[kind];
|
||||
if (!handler) {
|
||||
return {
|
||||
success: false,
|
||||
channelId,
|
||||
provider: kind,
|
||||
error: `Unsupported provider: ${kind}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return await handler(config, payload);
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
channelId,
|
||||
provider: kind,
|
||||
error: err.message || 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierNextcloudFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierNextcloudForm = ({ form }: NotifierNextcloudFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.nextcloudUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nextcloud URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. https://cloud.example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.nextcloudBotToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Token *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. j3yujpuh" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.nextcloudBotSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Secret *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="HMAC signing secret" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const NextcloudChannelConfigSchema = z.object({
|
||||
nextcloudUrl: z.string().url("Must be a valid URL"),
|
||||
nextcloudBotToken: z.string().min(1, "Bot token is required"),
|
||||
nextcloudBotSecret: z.string().min(1, "Bot secret is required"),
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createHmac, randomBytes } from "crypto";
|
||||
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
|
||||
|
||||
type NextcloudConfig = {
|
||||
nextcloudUrl: string;
|
||||
nextcloudBotToken: string;
|
||||
nextcloudBotSecret: string;
|
||||
};
|
||||
|
||||
function formatPayloadData(data: unknown): string {
|
||||
if (!data) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendNextcloud(
|
||||
config: NextcloudConfig,
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {
|
||||
nextcloudUrl,
|
||||
nextcloudBotToken,
|
||||
nextcloudBotSecret,
|
||||
} = config;
|
||||
|
||||
const payloadData = formatPayloadData(payload.data);
|
||||
|
||||
const messageParts = [
|
||||
`[${payload.level.toUpperCase()}] ${payload.title}`,
|
||||
payload.message,
|
||||
];
|
||||
|
||||
if (payloadData) {
|
||||
messageParts.push(`Payload:\n${payloadData}`);
|
||||
}
|
||||
|
||||
const message = messageParts.join("\n\n");
|
||||
|
||||
const random = randomBytes(32).toString("hex");
|
||||
|
||||
const signature = createHmac("sha256", nextcloudBotSecret)
|
||||
.update(random + message)
|
||||
.digest("hex");
|
||||
|
||||
const baseUrl = nextcloudUrl.replace(/\/$/, "");
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${nextcloudBotToken}/message`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"OCS-APIRequest": "true",
|
||||
"X-Nextcloud-Talk-Bot-Random": random,
|
||||
"X-Nextcloud-Talk-Bot-Signature": signature,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
|
||||
throw new Error(
|
||||
`Nextcloud error: ${res.status} ${err}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "nextcloud",
|
||||
message: "Sent to Nextcloud Talk",
|
||||
response: await res.text(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
type NotifierNtfyFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyTopic"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Topic Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. team-alerts"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyServerUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. https://ntfy.example.com"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">Leave empty to use the official ntfy.sh server.</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyToken"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Token</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="e.g. tk_xxxxx"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Separator className="my-1"/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyUsername"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Basic Auth Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. notifier-user"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyPassword"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Basic Auth Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder="e.g. notifier-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const NtfyChannelConfigSchema = z.object({
|
||||
ntfyTopic: z.string().min(1, "Topic is required"),
|
||||
ntfyServerUrl: z.string().url("Must be a valid URL").optional().or(z.literal('')),
|
||||
ntfyToken: z.string().optional(),
|
||||
ntfyUsername: z.string().optional(),
|
||||
ntfyPassword: z.string().optional(),
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
|
||||
|
||||
const getPriority = (level?: string): number => {
|
||||
switch (level) {
|
||||
case 'critical': return 5;
|
||||
case 'error': return 4;
|
||||
case 'warning': return 3;
|
||||
case 'info':
|
||||
default: return 2;
|
||||
}
|
||||
};
|
||||
|
||||
const formatData = (data: any): string => {
|
||||
if (!data || typeof data !== 'object') return '';
|
||||
return '\n\nDetails:\n' + Object.entries(data)
|
||||
.map(([key, value]) => {
|
||||
const stringVal = value !== null && typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
return `- ${key}: ${stringVal}`;
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const getTags = (level?: string): string[] => {
|
||||
const baseTags = ['floppy_disk'];
|
||||
|
||||
if (level === 'critical') baseTags.push('rotating_light');
|
||||
else if (level === 'error') baseTags.push('x');
|
||||
else if (level === 'warning') baseTags.push('warning');
|
||||
else baseTags.push('information_source');
|
||||
|
||||
return baseTags;
|
||||
};
|
||||
|
||||
export async function sendNtfy(
|
||||
config: { ntfyServerUrl?: string; ntfyTopic: string; ntfyToken?: string, ntfyUsername?: string, ntfyPassword?: string },
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {ntfyServerUrl, ntfyTopic, ntfyToken, ntfyUsername, ntfyPassword} = config;
|
||||
|
||||
const baseUrl = (ntfyServerUrl || "https://ntfy.sh").replace(/\/$/, "");
|
||||
|
||||
const body = {
|
||||
topic: ntfyTopic,
|
||||
title: payload.title,
|
||||
message: payload.message + formatData(payload.data),
|
||||
priority: getPriority(payload.level),
|
||||
tags: getTags(payload.level),
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (ntfyToken) {
|
||||
headers['Authorization'] = `Bearer ${ntfyToken}`;
|
||||
}
|
||||
|
||||
if (ntfyUsername && ntfyPassword) {
|
||||
const credentials = btoa(`${ntfyUsername}:${ntfyPassword}`);
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`ntfy error: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: 'ntfy',
|
||||
message: 'Sent to ntfy',
|
||||
response: await res.json(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierSmtpFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierSlackForm = ({ form }: NotifierSmtpFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.slackWebhook"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slack Webhook URL *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
placeholder="e.g. https://hooks.slack.com/services/..."
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const SlackChannelConfigSchema = z.object({
|
||||
slackWebhook: z.string().url("Must be a valid URL"),
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
|
||||
|
||||
export async function sendSlack(
|
||||
config: { slackWebhook: string },
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {slackWebhook: webhookUrl} = config;
|
||||
|
||||
const text = `*[${payload.level}] ${payload.title}*\n${payload.message}`;
|
||||
const blocks = [
|
||||
{
|
||||
type: 'section',
|
||||
text: {type: 'mrkdwn', text: `*[${payload.level.toUpperCase()}] ${payload.title}*`},
|
||||
},
|
||||
{type: 'section', text: {type: 'mrkdwn', text: payload.message}},
|
||||
payload.data
|
||||
? {
|
||||
type: 'context',
|
||||
elements: [{type: 'mrkdwn', text: `*Data:* \`\`\`${JSON.stringify(payload.data, null, 2)}\`\`\``}],
|
||||
}
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
const body = {
|
||||
text,
|
||||
blocks,
|
||||
};
|
||||
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Slack error: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: 'slack',
|
||||
message: 'Sent to Slack',
|
||||
response: await res.text(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
|
||||
type NotifierSmtpFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
|
||||
export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.host"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Host *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. smtp.example.com" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.port"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Port *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. 587" type="number" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.user"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. noreply@example.com" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.password"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.from"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Email *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={`e.g. "Portabase" <noreply@example.com>`} {...field}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.to"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>To Email *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"e.g. alerts@example.com, team@example.com"} {...field}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const SmtpChannelConfigSchema = z.object({
|
||||
host: z.string().min(1, "Host is required"),
|
||||
port: z.coerce.number().min(1, "Port is required"),
|
||||
user: z.string().min(1, "User is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
from: z.string().min(1, "From is required"),
|
||||
to: z.string().min(1, "To is required"),
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
"use server";
|
||||
import { render } from "@react-email/render";
|
||||
import nodemailer from "nodemailer";
|
||||
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
|
||||
import EmailNotification from "@/components/emails/email-notification";
|
||||
|
||||
export async function sendSmtp(
|
||||
config: {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
user: string;
|
||||
password: string;
|
||||
from: string;
|
||||
to: string | string[];
|
||||
},
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
pool: true,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: { user: config.user, pass: config.password },
|
||||
});
|
||||
|
||||
await transporter.verify();
|
||||
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: config.from,
|
||||
to: Array.isArray(config.to) ? config.to.join(", ") : config.to,
|
||||
subject: `[${payload.level.toUpperCase()}] ${payload.title}`,
|
||||
html: await render(EmailNotification({
|
||||
payload: payload
|
||||
})),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "smtp",
|
||||
message: `Email sent: ${config.to}`,
|
||||
response: info,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierTelegramFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierTelegramForm = ({ form }: NotifierTelegramFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramBotToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Bot Token *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
placeholder="e.g. 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramChatId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Chat ID *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. -100123456789 or 123456789" />
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You must start the conversation with the bot first (
|
||||
<strong>/start</strong>). <br />
|
||||
For groups, add the bot to the group. <br />
|
||||
You can use{" "}
|
||||
<a
|
||||
href="https://t.me/userinfobot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
@userinfobot
|
||||
</a>{" "}
|
||||
to find your ID.
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramTopicId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Topic ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. 123456" />
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Unique identifier for the target message thread (topic) of the
|
||||
forum; for forum supergroups only
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const TelegramChannelConfigSchema = z
|
||||
.object({
|
||||
telegramBotToken: z.string().min(1, "Bot token is required"),
|
||||
telegramChatId: z.string().min(1, "Chat ID is required"),
|
||||
telegramTopicId: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.telegramTopicId && !data.telegramChatId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Chat ID is required when a Topic ID is provided",
|
||||
path: ["telegramChatId"],
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
|
||||
|
||||
export async function sendTelegram(
|
||||
config: {
|
||||
telegramBotToken: string;
|
||||
telegramChatId: string;
|
||||
telegramTopicId?: string;
|
||||
},
|
||||
payload: EventPayload,
|
||||
): Promise<DispatchResult> {
|
||||
const { telegramBotToken, telegramChatId, telegramTopicId } = config;
|
||||
|
||||
// Helper to escape HTML characters
|
||||
const escapeHtml = (unsafe: string) => {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
const title = escapeHtml(payload.title);
|
||||
const message = escapeHtml(payload.message);
|
||||
const level = escapeHtml(payload.level.toUpperCase());
|
||||
const dataString = payload.data
|
||||
? escapeHtml(JSON.stringify(payload.data, null, 2).substring(0, 1000))
|
||||
: "";
|
||||
|
||||
const text = `<b>${title}</b>\n\n${message}\n\nLevel: <code>${level}</code>${payload.data ? `\n\nData:\n<pre>${dataString}</pre>` : ""}`;
|
||||
|
||||
const body: Record<string, any> = {
|
||||
chat_id: telegramChatId,
|
||||
text,
|
||||
parse_mode: "HTML",
|
||||
};
|
||||
|
||||
if (telegramTopicId) {
|
||||
body.message_thread_id = Number(telegramTopicId);
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.telegram.org/bot${telegramBotToken}/sendMessage`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Telegram error: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "telegram",
|
||||
message: "Sent to Telegram",
|
||||
response: await res.text(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useFieldArray, UseFormReturn } from "react-hook-form";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type NotifierWebhookFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => {
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "config.webhookHeaders",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="e.g. https://example.com/api/webhook"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Custom Headers</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ key: "", value: "" })}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No custom headers. Add headers to send with each webhook request.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{fields.map((headerField, index) => (
|
||||
<div key={headerField.id} className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`config.webhookHeaders.${index}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Header Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="e.g. X-Api-Key"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`config.webhookHeaders.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Header Value</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Header value"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="self-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const WebhookChannelConfigSchema = z.object({
|
||||
webhookUrl: z.string().url("Must be a valid URL"),
|
||||
webhookHeaders: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().min(1, "Header name is required"),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export type WebhookChannelConfig = z.infer<typeof WebhookChannelConfigSchema>;
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
|
||||
|
||||
type WebhookConfig = {
|
||||
webhookUrl: string;
|
||||
webhookHeaders?: { key: string; value: string }[];
|
||||
webhookSecret?: string;
|
||||
webhookSecretHeader?: string;
|
||||
};
|
||||
|
||||
export async function sendWebhook(
|
||||
config: WebhookConfig,
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const { webhookUrl, webhookHeaders, webhookSecret, webhookSecretHeader } = config;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Portabase-Notifier/1.0',
|
||||
};
|
||||
|
||||
// New format: iterate custom headers array
|
||||
if (webhookHeaders && webhookHeaders.length > 0) {
|
||||
const RESERVED = new Set(['content-type', 'user-agent']);
|
||||
for (const { key, value } of webhookHeaders) {
|
||||
if (key && !RESERVED.has(key.toLowerCase())) headers[key] = value;
|
||||
}
|
||||
} else if (webhookSecret) {
|
||||
// Legacy format: single secret header pair
|
||||
headers[webhookSecretHeader || 'X-Webhook-Secret'] = webhookSecret;
|
||||
}
|
||||
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Webhook error: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: 'webhook',
|
||||
message: 'Sent to Webhook',
|
||||
response: await res.text(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user