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:
Charles GTE
2026-05-24 17:09:12 +02:00
committed by GitHub
parent 4fadfcfaf2
commit 3fa0f9b76d
330 changed files with 627 additions and 1120 deletions
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};
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(),
};
}