FEATURE (notifiers): Add webhook notifier

This commit is contained in:
Rostislav Dugin
2025-06-18 23:45:09 +03:00
parent 9d528820c7
commit a94a4d2c36
18 changed files with 366 additions and 10 deletions

View File

@@ -5,4 +5,5 @@ type NotifierType string
const (
NotifierTypeEmail NotifierType = "EMAIL"
NotifierTypeTelegram NotifierType = "TELEGRAM"
NotifierTypeWebhook NotifierType = "WEBHOOK"
)

View File

@@ -4,6 +4,7 @@ import (
"errors"
"postgresus-backend/internal/features/notifiers/notifiers/email_notifier"
telegram_notifier "postgresus-backend/internal/features/notifiers/notifiers/telegram"
webhook_notifier "postgresus-backend/internal/features/notifiers/notifiers/webhook"
"github.com/google/uuid"
)
@@ -18,6 +19,7 @@ type Notifier struct {
// specific notifier
TelegramNotifier *telegram_notifier.TelegramNotifier `json:"telegramNotifier" gorm:"foreignKey:NotifierID"`
EmailNotifier *email_notifier.EmailNotifier `json:"emailNotifier" gorm:"foreignKey:NotifierID"`
WebhookNotifier *webhook_notifier.WebhookNotifier `json:"webhookNotifier" gorm:"foreignKey:NotifierID"`
}
func (n *Notifier) TableName() string {
@@ -34,6 +36,7 @@ func (n *Notifier) Validate() error {
func (n *Notifier) Send(heading string, message string) error {
err := n.getSpecificNotifier().Send(heading, message)
if err != nil {
lastSendError := err.Error()
n.LastSendError = &lastSendError
@@ -50,6 +53,8 @@ func (n *Notifier) getSpecificNotifier() NotificationSender {
return n.TelegramNotifier
case NotifierTypeEmail:
return n.EmailNotifier
case NotifierTypeWebhook:
return n.WebhookNotifier
default:
panic("unknown notifier type: " + string(n.NotifierType))
}

View File

@@ -0,0 +1,8 @@
package webhook_notifier
type WebhookMethod string
const (
WebhookMethodPOST WebhookMethod = "POST"
WebhookMethodGET WebhookMethod = "GET"
)

View File

@@ -0,0 +1,106 @@
package webhook_notifier
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"postgresus-backend/internal/util/logger"
"github.com/google/uuid"
)
var log = logger.GetLogger()
type WebhookNotifier struct {
NotifierID uuid.UUID `json:"notifierId" gorm:"primaryKey;column:notifier_id"`
WebhookURL string `json:"webhookUrl" gorm:"not null;column:webhook_url"`
WebhookMethod WebhookMethod `json:"webhookMethod" gorm:"not null;column:webhook_method"`
}
func (t *WebhookNotifier) TableName() string {
return "webhook_notifiers"
}
func (t *WebhookNotifier) Validate() error {
if t.WebhookURL == "" {
return errors.New("webhook URL is required")
}
if t.WebhookMethod == "" {
return errors.New("webhook method is required")
}
return nil
}
func (t *WebhookNotifier) Send(heading string, message string) error {
switch t.WebhookMethod {
case WebhookMethodGET:
reqURL := fmt.Sprintf("%s?heading=%s&message=%s",
t.WebhookURL,
url.QueryEscape(heading),
url.QueryEscape(message),
)
resp, err := http.Get(reqURL)
if err != nil {
return fmt.Errorf("failed to send GET webhook: %w", err)
}
defer func() {
if cerr := resp.Body.Close(); cerr != nil {
log.Error("failed to close response body", "error", cerr)
}
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf(
"webhook GET returned status: %s, body: %s",
resp.Status,
string(body),
)
}
return nil
case WebhookMethodPOST:
payload := map[string]string{
"heading": heading,
"message": message,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal webhook payload: %w", err)
}
resp, err := http.Post(t.WebhookURL, "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to send POST webhook: %w", err)
}
defer func() {
if cerr := resp.Body.Close(); cerr != nil {
log.Error("failed to close response body", "error", cerr)
}
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf(
"webhook POST returned status: %s, body: %s",
resp.Status,
string(body),
)
}
return nil
default:
return fmt.Errorf("unsupported webhook method: %s", t.WebhookMethod)
}
}

View File

@@ -22,17 +22,21 @@ func (r *NotifierRepository) Save(notifier *Notifier) error {
if notifier.EmailNotifier != nil {
notifier.EmailNotifier.NotifierID = notifier.ID
}
case NotifierTypeWebhook:
if notifier.WebhookNotifier != nil {
notifier.WebhookNotifier.NotifierID = notifier.ID
}
}
if notifier.ID == uuid.Nil {
if err := tx.Create(notifier).
Omit("TelegramNotifier", "EmailNotifier").
Omit("TelegramNotifier", "EmailNotifier", "WebhookNotifier").
Error; err != nil {
return err
}
} else {
if err := tx.Save(notifier).
Omit("TelegramNotifier", "EmailNotifier").
Omit("TelegramNotifier", "EmailNotifier", "WebhookNotifier").
Error; err != nil {
return err
}
@@ -53,6 +57,13 @@ func (r *NotifierRepository) Save(notifier *Notifier) error {
return err
}
}
case NotifierTypeWebhook:
if notifier.WebhookNotifier != nil {
notifier.WebhookNotifier.NotifierID = notifier.ID // Ensure ID is set
if err := tx.Save(notifier.WebhookNotifier).Error; err != nil {
return err
}
}
}
return nil
@@ -66,6 +77,7 @@ func (r *NotifierRepository) FindByID(id uuid.UUID) (*Notifier, error) {
GetDb().
Preload("TelegramNotifier").
Preload("EmailNotifier").
Preload("WebhookNotifier").
Where("id = ?", id).
First(&notifier).Error; err != nil {
return nil, err
@@ -81,6 +93,7 @@ func (r *NotifierRepository) FindByUserID(userID uuid.UUID) ([]*Notifier, error)
GetDb().
Preload("TelegramNotifier").
Preload("EmailNotifier").
Preload("WebhookNotifier").
Where("user_id = ?", userID).
Find(&notifiers).Error; err != nil {
return nil, err
@@ -105,6 +118,12 @@ func (r *NotifierRepository) Delete(notifier *Notifier) error {
return err
}
}
case NotifierTypeWebhook:
if notifier.WebhookNotifier != nil {
if err := tx.Delete(notifier.WebhookNotifier).Error; err != nil {
return err
}
}
}
// Delete the main notifier

View File

@@ -0,0 +1,24 @@
-- +goose Up
-- +goose StatementBegin
-- Create webhook notifiers table
CREATE TABLE webhook_notifiers (
notifier_id UUID PRIMARY KEY,
webhook_url TEXT NOT NULL,
webhook_method TEXT NOT NULL
);
ALTER TABLE webhook_notifiers
ADD CONSTRAINT fk_webhook_notifiers_notifier
FOREIGN KEY (notifier_id)
REFERENCES notifiers (id)
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS webhook_notifiers;
-- +goose StatementEnd

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg version="1.1" baseProfile="tiny" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 24 24" overflow="visible" xml:space="preserve">
<g >
<rect y="0" fill="none" width="24" height="24"/>
<g transform="translate(1.000000, 8.000000)">
<path fill-rule="evenodd" fill="#5C85DE" d="M2-1.9c-1.1,0-2.3,1.1-2.3,2.2V10H2V5.5h2.2V10h2.2V0.3c0-1.1-1.1-2.2-2.3-2.2H2
L2-1.9z M2,3.2v-3h2.2v3H2L2,3.2z"/>
<path fill-rule="evenodd" fill="#5C85DE" d="M10.3-2C9.1-2,8-0.9,8,0.2V10l2.2,0V5.5h2.2c1.1,0,2.3-1.1,2.3-2.2l0-3
c0-1.1-1.1-2.2-2.3-2.2H10.3L10.3-2z M10.2,3.2v-3h2.2v3H10.2L10.2,3.2z"/>
<polygon fill-rule="evenodd" fill="#5C85DE" points="18.5,0.3 18.5,7.8 16.2,7.8 16.2,10 23,10 23,7.8 20.8,7.8 20.8,0.3 23,0.3
23,-1.9 16.2,-1.9 16.2,0.3 "/>
<polygon fill-rule="evenodd" fill="#3367D6" points="2,5.5 2,3.2 3.5,3.2 "/>
<polygon fill-rule="evenodd" fill="#3367D6" points="10.2,5.5 10.2,3.2 11.5,3.2 "/>
<polygon fill-rule="evenodd" fill="#3367D6" points="18.5,1.8 18.5,1.8 18.5,0.3 20.8,0.3 "/>
</g>
</g>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -2,4 +2,6 @@ export { notifierApi } from './api/notifierApi';
export type { Notifier } from './models/Notifier';
export type { EmailNotifier } from './models/EmailNotifier';
export type { TelegramNotifier } from './models/TelegramNotifier';
export type { WebhookNotifier } from './models/WebhookNotifier';
export { WebhookMethod } from './models/WebhookMethod';
export { NotifierType } from './models/NotifierType';

View File

@@ -1,6 +1,7 @@
import type { EmailNotifier } from './EmailNotifier';
import type { NotifierType } from './NotifierType';
import type { TelegramNotifier } from './TelegramNotifier';
import type { WebhookNotifier } from './WebhookNotifier';
export interface Notifier {
id: string;
@@ -11,4 +12,5 @@ export interface Notifier {
// specific notifier
telegramNotifier?: TelegramNotifier;
emailNotifier?: EmailNotifier;
webhookNotifier?: WebhookNotifier;
}

View File

@@ -1,4 +1,5 @@
export enum NotifierType {
EMAIL = 'EMAIL',
TELEGRAM = 'TELEGRAM',
WEBHOOK = 'WEBHOOK',
}

View File

@@ -0,0 +1,4 @@
export enum WebhookMethod {
POST = 'POST',
GET = 'GET',
}

View File

@@ -0,0 +1,6 @@
import type { WebhookMethod } from './WebhookMethod';
export interface WebhookNotifier {
webhookUrl: string;
webhookMethod: WebhookMethod;
}

View File

@@ -6,6 +6,8 @@ export const getNotifierLogoFromType = (type: NotifierType) => {
return '/icons/notifiers/email.svg';
case NotifierType.TELEGRAM:
return '/icons/notifiers/telegram.svg';
case NotifierType.WEBHOOK:
return '/icons/notifiers/webhook.svg';
default:
return '';
}

View File

@@ -6,6 +6,8 @@ export const getNotifierNameFromType = (type: NotifierType) => {
return 'Email';
case NotifierType.TELEGRAM:
return 'Telegram';
case NotifierType.WEBHOOK:
return 'Webhook';
default:
return '';
}

View File

@@ -1,10 +1,17 @@
import { Button, Input, Select } from 'antd';
import { useEffect, useState } from 'react';
import { type Notifier, NotifierType, notifierApi } from '../../../../entity/notifiers';
import {
type Notifier,
NotifierType,
WebhookMethod,
notifierApi,
} from '../../../../entity/notifiers';
import { getNotifierLogoFromType } from '../../../../entity/notifiers/models/getNotifierLogoFromType';
import { ToastHelper } from '../../../../shared/toast';
import { EditEmailNotifierComponent } from './notifiers/EditEmailNotifierComponent';
import { EditTelegramNotifierComponent } from './notifiers/EditTelegramNotifierComponent';
import { EditWebhookNotifierComponent } from './notifiers/EditWebhookNotifierComponent';
interface Props {
isShowClose: boolean;
@@ -88,6 +95,13 @@ export function EditNotifierComponent({
};
}
if (type === NotifierType.WEBHOOK) {
notifier.webhookNotifier = {
webhookUrl: '',
webhookMethod: WebhookMethod.POST,
};
}
setNotifier(
JSON.parse(
JSON.stringify({
@@ -134,6 +148,10 @@ export function EditNotifierComponent({
);
}
if (notifier.notifierType === NotifierType.WEBHOOK) {
return notifier.webhookNotifier?.webhookUrl;
}
return false;
};
@@ -166,6 +184,7 @@ export function EditNotifierComponent({
options={[
{ label: 'Telegram', value: NotifierType.TELEGRAM },
{ label: 'Email', value: NotifierType.EMAIL },
{ label: 'Webhook', value: NotifierType.WEBHOOK },
]}
onChange={(value) => {
setNotifierType(value);
@@ -175,13 +194,7 @@ export function EditNotifierComponent({
className="w-full max-w-[250px]"
/>
{notifier?.notifierType === NotifierType.TELEGRAM && (
<img src="/icons/notifiers/telegram.svg" className="ml-2 h-4 w-4" />
)}
{notifier?.notifierType === NotifierType.EMAIL && (
<img src="/icons/notifiers/email.svg" className="ml-2 h-4 w-4" />
)}
<img src={getNotifierLogoFromType(notifier?.notifierType)} className="ml-2 h-4 w-4" />
</div>
<div className="mt-5" />
@@ -202,6 +215,14 @@ export function EditNotifierComponent({
setIsUnsaved={setIsUnsaved}
/>
)}
{notifier?.notifierType === NotifierType.WEBHOOK && (
<EditWebhookNotifierComponent
notifier={notifier}
setNotifier={setNotifier}
setIsUnsaved={setIsUnsaved}
/>
)}
</div>
<div className="mt-3 flex">

View File

@@ -0,0 +1,107 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Input, Select, Tooltip } from 'antd';
import type { Notifier } from '../../../../../entity/notifiers';
import { WebhookMethod } from '../../../../../entity/notifiers/models/WebhookMethod';
interface Props {
notifier: Notifier;
setNotifier: (notifier: Notifier) => void;
setIsUnsaved: (isUnsaved: boolean) => void;
}
export function EditWebhookNotifierComponent({ notifier, setNotifier, setIsUnsaved }: Props) {
return (
<>
<div className="flex items-center">
<div className="min-w-[110px]">Webhook URL</div>
<div className="w-[250px]">
<Input
value={notifier?.webhookNotifier?.webhookUrl || ''}
onChange={(e) => {
setNotifier({
...notifier,
webhookNotifier: {
...(notifier.webhookNotifier || { webhookMethod: WebhookMethod.POST }),
webhookUrl: e.target.value.trim(),
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full"
placeholder="https://example.com/webhook"
/>
</div>
<Tooltip
className="cursor-pointer"
title="The URL that will be called when a notification is triggered"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
<div className="mt-1 flex items-center">
<div className="min-w-[110px]">Method</div>
<div className="w-[250px]">
<Select
value={notifier?.webhookNotifier?.webhookMethod || WebhookMethod.POST}
onChange={(value) => {
setNotifier({
...notifier,
webhookNotifier: {
...(notifier.webhookNotifier || { webhookUrl: '' }),
webhookMethod: value,
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full"
options={[
{ value: WebhookMethod.POST, label: 'POST' },
{ value: WebhookMethod.GET, label: 'GET' },
]}
/>
</div>
<Tooltip
className="cursor-pointer"
title="The HTTP method that will be used to call the webhook"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
{notifier?.webhookNotifier?.webhookUrl && (
<div className="mt-3">
<div className="mb-1">Example request</div>
{notifier?.webhookNotifier?.webhookMethod === WebhookMethod.GET && (
<div className="rounded bg-gray-100 p-2 px-3 text-sm break-all">
GET {notifier?.webhookNotifier?.webhookUrl}?heading= Backup completed for
database&message=Backup completed successfully in 2m 17s.\nCompressed backup size:
1.7GB
</div>
)}
{notifier?.webhookNotifier?.webhookMethod === WebhookMethod.POST && (
<div className="rounded bg-gray-100 p-2 px-3 font-mono text-sm break-all whitespace-pre-line">
{`POST ${notifier?.webhookNotifier?.webhookUrl}
Content-Type: application/json
{
"heading": "✅ Backup completed for database",
"message": "Backup completed successfully in 2m 17s.\\nCompressed backup size: 1.7GB"
}
`}
</div>
)}
</div>
)}
</>
);
}

View File

@@ -3,6 +3,7 @@ import { getNotifierLogoFromType } from '../../../../entity/notifiers/models/get
import { getNotifierNameFromType } from '../../../../entity/notifiers/models/getNotifierNameFromType';
import { ShowEmailNotifierComponent } from './notifier/ShowEmailNotifierComponent';
import { ShowTelegramNotifierComponent } from './notifier/ShowTelegramNotifierComponent';
import { ShowWebhookNotifierComponent } from './notifier/ShowWebhookNotifierComponent';
interface Props {
notifier: Notifier;
@@ -26,6 +27,10 @@ export function ShowNotifierComponent({ notifier }: Props) {
{notifier?.notifierType === NotifierType.EMAIL && (
<ShowEmailNotifierComponent notifier={notifier} />
)}
{notifier?.notifierType === NotifierType.WEBHOOK && (
<ShowWebhookNotifierComponent notifier={notifier} />
)}
</div>
</div>
);

View File

@@ -0,0 +1,22 @@
import type { Notifier } from '../../../../../entity/notifiers';
interface Props {
notifier: Notifier;
}
export function ShowWebhookNotifierComponent({ notifier }: Props) {
return (
<>
<div className="flex items-center">
<div className="min-w-[110px]">Webhook URL</div>
<div className="w-[250px]">{notifier?.webhookNotifier?.webhookUrl || '-'}</div>
</div>
<div className="mt-1 mb-1 flex items-center">
<div className="min-w-[110px]">Method</div>
<div>{notifier?.webhookNotifier?.webhookMethod || '-'}</div>
</div>
</>
);
}