diff --git a/backend/internal/features/notifiers/enums.go b/backend/internal/features/notifiers/enums.go index 8e960bb..bfff440 100644 --- a/backend/internal/features/notifiers/enums.go +++ b/backend/internal/features/notifiers/enums.go @@ -7,4 +7,5 @@ const ( NotifierTypeTelegram NotifierType = "TELEGRAM" NotifierTypeWebhook NotifierType = "WEBHOOK" NotifierTypeSlack NotifierType = "SLACK" + NotifierTypeDiscord NotifierType = "DISCORD" ) diff --git a/backend/internal/features/notifiers/model.go b/backend/internal/features/notifiers/model.go index 6d75c13..32baf16 100644 --- a/backend/internal/features/notifiers/model.go +++ b/backend/internal/features/notifiers/model.go @@ -3,6 +3,7 @@ package notifiers import ( "errors" "log/slog" + discord_notifier "postgresus-backend/internal/features/notifiers/models/discord" "postgresus-backend/internal/features/notifiers/models/email_notifier" slack_notifier "postgresus-backend/internal/features/notifiers/models/slack" telegram_notifier "postgresus-backend/internal/features/notifiers/models/telegram" @@ -23,6 +24,7 @@ type Notifier struct { EmailNotifier *email_notifier.EmailNotifier `json:"emailNotifier" gorm:"foreignKey:NotifierID"` WebhookNotifier *webhook_notifier.WebhookNotifier `json:"webhookNotifier" gorm:"foreignKey:NotifierID"` SlackNotifier *slack_notifier.SlackNotifier `json:"slackNotifier" gorm:"foreignKey:NotifierID"` + DiscordNotifier *discord_notifier.DiscordNotifier `json:"discordNotifier" gorm:"foreignKey:NotifierID"` } func (n *Notifier) TableName() string { @@ -60,6 +62,8 @@ func (n *Notifier) getSpecificNotifier() NotificationSender { return n.WebhookNotifier case NotifierTypeSlack: return n.SlackNotifier + case NotifierTypeDiscord: + return n.DiscordNotifier default: panic("unknown notifier type: " + string(n.NotifierType)) } diff --git a/backend/internal/features/notifiers/models/discord/model.go b/backend/internal/features/notifiers/models/discord/model.go new file mode 100644 index 0000000..1317775 --- /dev/null +++ b/backend/internal/features/notifiers/models/discord/model.go @@ -0,0 +1,73 @@ +package discord_notifier + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + + "github.com/google/uuid" +) + +type DiscordNotifier struct { + NotifierID uuid.UUID `json:"notifierId" gorm:"primaryKey;column:notifier_id"` + ChannelWebhookURL string `json:"channelWebhookUrl" gorm:"not null;column:channel_webhook_url"` +} + +func (d *DiscordNotifier) TableName() string { + return "discord_notifiers" +} + +func (d *DiscordNotifier) Validate() error { + if d.ChannelWebhookURL == "" { + return errors.New("webhook URL is required") + } + + return nil +} + +func (d *DiscordNotifier) Send(logger *slog.Logger, heading string, message string) error { + fullMessage := heading + if message != "" { + fullMessage = fmt.Sprintf("%s\n\n%s", heading, message) + } + + payload := map[string]interface{}{ + "content": fullMessage, + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal Discord payload: %w", err) + } + + req, err := http.NewRequest("POST", d.ChannelWebhookURL, bytes.NewReader(jsonPayload)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send Discord message: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf( + "discord API returned non-OK status: %s. Error: %s", + resp.Status, + string(bodyBytes), + ) + } + + return nil +} diff --git a/backend/internal/features/notifiers/models/email_notifier/model.go b/backend/internal/features/notifiers/models/email_notifier/model.go index 73dab3a..3dde579 100644 --- a/backend/internal/features/notifiers/models/email_notifier/model.go +++ b/backend/internal/features/notifiers/models/email_notifier/model.go @@ -80,7 +80,7 @@ func (e *EmailNotifier) Send(logger *slog.Logger, heading string, message string timeout := DefaultTimeout // Determine if authentication is required - authRequired := e.SMTPUser != "" && e.SMTPPassword != "" + isAuthRequired := e.SMTPUser != "" && e.SMTPPassword != "" // Handle different port scenarios if e.SMTPPort == ImplicitTLSPort { @@ -110,7 +110,7 @@ func (e *EmailNotifier) Send(logger *slog.Logger, heading string, message string }() // Set up authentication only if credentials are provided - if authRequired { + if isAuthRequired { auth := smtp.PlainAuth("", e.SMTPUser, e.SMTPPassword, e.SMTPHost) if err := client.Auth(auth); err != nil { return fmt.Errorf("SMTP authentication failed: %w", err) @@ -173,7 +173,7 @@ func (e *EmailNotifier) Send(logger *slog.Logger, heading string, message string } // Authenticate only if credentials are provided - if authRequired { + if isAuthRequired { auth := smtp.PlainAuth("", e.SMTPUser, e.SMTPPassword, e.SMTPHost) if err := client.Auth(auth); err != nil { return fmt.Errorf("SMTP authentication failed: %w", err) diff --git a/backend/internal/features/notifiers/repository.go b/backend/internal/features/notifiers/repository.go index f3bc98b..6b4f9de 100644 --- a/backend/internal/features/notifiers/repository.go +++ b/backend/internal/features/notifiers/repository.go @@ -30,17 +30,33 @@ func (r *NotifierRepository) Save(notifier *Notifier) (*Notifier, error) { if notifier.SlackNotifier != nil { notifier.SlackNotifier.NotifierID = notifier.ID } + case NotifierTypeDiscord: + if notifier.DiscordNotifier != nil { + notifier.DiscordNotifier.NotifierID = notifier.ID + } } if notifier.ID == uuid.Nil { if err := tx.Create(notifier). - Omit("TelegramNotifier", "EmailNotifier", "WebhookNotifier", "SlackNotifier"). + Omit( + "TelegramNotifier", + "EmailNotifier", + "WebhookNotifier", + "SlackNotifier", + "DiscordNotifier", + ). Error; err != nil { return err } } else { if err := tx.Save(notifier). - Omit("TelegramNotifier", "EmailNotifier", "WebhookNotifier", "SlackNotifier"). + Omit( + "TelegramNotifier", + "EmailNotifier", + "WebhookNotifier", + "SlackNotifier", + "DiscordNotifier", + ). Error; err != nil { return err } @@ -75,6 +91,13 @@ func (r *NotifierRepository) Save(notifier *Notifier) (*Notifier, error) { return err } } + case NotifierTypeDiscord: + if notifier.DiscordNotifier != nil { + notifier.DiscordNotifier.NotifierID = notifier.ID // Ensure ID is set + if err := tx.Save(notifier.DiscordNotifier).Error; err != nil { + return err + } + } } return nil @@ -96,6 +119,7 @@ func (r *NotifierRepository) FindByID(id uuid.UUID) (*Notifier, error) { Preload("EmailNotifier"). Preload("WebhookNotifier"). Preload("SlackNotifier"). + Preload("DiscordNotifier"). Where("id = ?", id). First(¬ifier).Error; err != nil { return nil, err @@ -113,7 +137,9 @@ func (r *NotifierRepository) FindByUserID(userID uuid.UUID) ([]*Notifier, error) Preload("EmailNotifier"). Preload("WebhookNotifier"). Preload("SlackNotifier"). + Preload("DiscordNotifier"). Where("user_id = ?", userID). + Order("name ASC"). Find(¬ifiers).Error; err != nil { return nil, err } @@ -149,6 +175,12 @@ func (r *NotifierRepository) Delete(notifier *Notifier) error { return err } } + case NotifierTypeDiscord: + if notifier.DiscordNotifier != nil { + if err := tx.Delete(notifier.DiscordNotifier).Error; err != nil { + return err + } + } } // Delete the main notifier diff --git a/backend/migrations/20250716072247_add_discord_notifier.sql b/backend/migrations/20250716072247_add_discord_notifier.sql new file mode 100644 index 0000000..3750af4 --- /dev/null +++ b/backend/migrations/20250716072247_add_discord_notifier.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- +goose StatementBegin + +-- Create discord notifiers table +CREATE TABLE discord_notifiers ( + notifier_id UUID PRIMARY KEY, + channel_webhook_url TEXT NOT NULL +); + +ALTER TABLE discord_notifiers + ADD CONSTRAINT fk_discord_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 discord_notifiers; + +-- +goose StatementEnd diff --git a/frontend/public/icons/notifiers/discord.svg b/frontend/public/icons/notifiers/discord.svg new file mode 100644 index 0000000..c03e8e1 --- /dev/null +++ b/frontend/public/icons/notifiers/discord.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/src/entity/notifiers/index.ts b/frontend/src/entity/notifiers/index.ts index c5c66b4..1ff42ab 100644 --- a/frontend/src/entity/notifiers/index.ts +++ b/frontend/src/entity/notifiers/index.ts @@ -14,3 +14,6 @@ export { WebhookMethod } from './models/webhook/WebhookMethod'; export type { SlackNotifier } from './models/slack/SlackNotifier'; export { validateSlackNotifier } from './models/slack/validateSlackNotifier'; + +export type { DiscordNotifier } from './models/discord/DiscordNotifier'; +export { validateDiscordNotifier } from './models/discord/validateDiscordNotifier'; diff --git a/frontend/src/entity/notifiers/models/Notifier.ts b/frontend/src/entity/notifiers/models/Notifier.ts index ff31c1c..5adea8e 100644 --- a/frontend/src/entity/notifiers/models/Notifier.ts +++ b/frontend/src/entity/notifiers/models/Notifier.ts @@ -1,4 +1,5 @@ import type { NotifierType } from './NotifierType'; +import type { DiscordNotifier } from './discord/DiscordNotifier'; import type { EmailNotifier } from './email/EmailNotifier'; import type { SlackNotifier } from './slack/SlackNotifier'; import type { TelegramNotifier } from './telegram/TelegramNotifier'; @@ -15,4 +16,5 @@ export interface Notifier { emailNotifier?: EmailNotifier; webhookNotifier?: WebhookNotifier; slackNotifier?: SlackNotifier; + discordNotifier?: DiscordNotifier; } diff --git a/frontend/src/entity/notifiers/models/NotifierType.ts b/frontend/src/entity/notifiers/models/NotifierType.ts index f842904..31839ab 100644 --- a/frontend/src/entity/notifiers/models/NotifierType.ts +++ b/frontend/src/entity/notifiers/models/NotifierType.ts @@ -3,4 +3,5 @@ export enum NotifierType { TELEGRAM = 'TELEGRAM', WEBHOOK = 'WEBHOOK', SLACK = 'SLACK', + DISCORD = 'DISCORD', } diff --git a/frontend/src/entity/notifiers/models/discord/DiscordNotifier.ts b/frontend/src/entity/notifiers/models/discord/DiscordNotifier.ts new file mode 100644 index 0000000..4ebac50 --- /dev/null +++ b/frontend/src/entity/notifiers/models/discord/DiscordNotifier.ts @@ -0,0 +1,3 @@ +export interface DiscordNotifier { + channelWebhookUrl: string; +} diff --git a/frontend/src/entity/notifiers/models/discord/validateDiscordNotifier.ts b/frontend/src/entity/notifiers/models/discord/validateDiscordNotifier.ts new file mode 100644 index 0000000..b9bdb7e --- /dev/null +++ b/frontend/src/entity/notifiers/models/discord/validateDiscordNotifier.ts @@ -0,0 +1,9 @@ +import type { DiscordNotifier } from './DiscordNotifier'; + +export const validateDiscordNotifier = (notifier: DiscordNotifier): boolean => { + if (!notifier.channelWebhookUrl) { + return false; + } + + return true; +}; diff --git a/frontend/src/entity/notifiers/models/getNotifierLogoFromType.ts b/frontend/src/entity/notifiers/models/getNotifierLogoFromType.ts index 7690c7c..5fa5adb 100644 --- a/frontend/src/entity/notifiers/models/getNotifierLogoFromType.ts +++ b/frontend/src/entity/notifiers/models/getNotifierLogoFromType.ts @@ -10,6 +10,8 @@ export const getNotifierLogoFromType = (type: NotifierType) => { return '/icons/notifiers/webhook.svg'; case NotifierType.SLACK: return '/icons/notifiers/slack.svg'; + case NotifierType.DISCORD: + return '/icons/notifiers/discord.svg'; default: return ''; } diff --git a/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx b/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx index 89b8b99..3244f8b 100644 --- a/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx +++ b/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx @@ -6,6 +6,7 @@ import { NotifierType, WebhookMethod, notifierApi, + validateDiscordNotifier, validateEmailNotifier, validateSlackNotifier, validateTelegramNotifier, @@ -13,6 +14,7 @@ import { } from '../../../../entity/notifiers'; import { getNotifierLogoFromType } from '../../../../entity/notifiers/models/getNotifierLogoFromType'; import { ToastHelper } from '../../../../shared/toast'; +import { EditDiscordNotifierComponent } from './notifiers/EditDiscordNotifierComponent'; import { EditEmailNotifierComponent } from './notifiers/EditEmailNotifierComponent'; import { EditSlackNotifierComponent } from './notifiers/EditSlackNotifierComponent'; import { EditTelegramNotifierComponent } from './notifiers/EditTelegramNotifierComponent'; @@ -120,6 +122,12 @@ export function EditNotifierComponent({ }; } + if (type === NotifierType.DISCORD) { + notifier.discordNotifier = { + channelWebhookUrl: '', + }; + } + setNotifier( JSON.parse( JSON.stringify({ @@ -171,6 +179,10 @@ export function EditNotifierComponent({ return validateSlackNotifier(notifier.slackNotifier); } + if (notifier.notifierType === NotifierType.DISCORD && notifier.discordNotifier) { + return validateDiscordNotifier(notifier.discordNotifier); + } + return false; }; @@ -205,6 +217,7 @@ export function EditNotifierComponent({ { label: 'Email', value: NotifierType.EMAIL }, { label: 'Webhook', value: NotifierType.WEBHOOK }, { label: 'Slack', value: NotifierType.SLACK }, + { label: 'Discord', value: NotifierType.DISCORD }, ]} onChange={(value) => { setNotifierType(value); @@ -251,6 +264,14 @@ export function EditNotifierComponent({ setIsUnsaved={setIsUnsaved} /> )} + + {notifier?.notifierType === NotifierType.DISCORD && ( + + )}
diff --git a/frontend/src/features/notifiers/ui/edit/notifiers/EditDiscordNotifierComponent.tsx b/frontend/src/features/notifiers/ui/edit/notifiers/EditDiscordNotifierComponent.tsx new file mode 100644 index 0000000..0b1e680 --- /dev/null +++ b/frontend/src/features/notifiers/ui/edit/notifiers/EditDiscordNotifierComponent.tsx @@ -0,0 +1,59 @@ +import { Input } from 'antd'; + +import type { Notifier } from '../../../../../entity/notifiers'; + +interface Props { + notifier: Notifier; + setNotifier: (notifier: Notifier) => void; + setIsUnsaved: (isUnsaved: boolean) => void; +} + +export function EditDiscordNotifierComponent({ notifier, setNotifier, setIsUnsaved }: Props) { + return ( + <> +
+
Channel webhook URL
+ +
+ { + if (!notifier?.discordNotifier) return; + setNotifier({ + ...notifier, + discordNotifier: { + ...notifier.discordNotifier, + channelWebhookUrl: e.target.value.trim(), + }, + }); + setIsUnsaved(true); + }} + size="small" + className="w-full" + placeholder="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ" + /> +
+
+ +
+
+ How to get Discord webhook URL: +
+
+ 1. Create or select a Discord channel +
+ 2. Go to channel settings (gear icon) +
+ 3. Navigate to Integrations +
+ 4. Create a new webhook +
+ 5. Copy the webhook URL +
+
+ Note: make sure make channel private if needed +
+
+ + ); +} diff --git a/frontend/src/features/notifiers/ui/edit/notifiers/EditWebhookNotifierComponent.tsx b/frontend/src/features/notifiers/ui/edit/notifiers/EditWebhookNotifierComponent.tsx index d67a30d..246dc21 100644 --- a/frontend/src/features/notifiers/ui/edit/notifiers/EditWebhookNotifierComponent.tsx +++ b/frontend/src/features/notifiers/ui/edit/notifiers/EditWebhookNotifierComponent.tsx @@ -34,13 +34,6 @@ export function EditWebhookNotifierComponent({ notifier, setNotifier, setIsUnsav placeholder="https://example.com/webhook" />
- - - -
diff --git a/frontend/src/features/notifiers/ui/show/ShowNotifierComponent.tsx b/frontend/src/features/notifiers/ui/show/ShowNotifierComponent.tsx index 15fa46d..2a8a5a5 100644 --- a/frontend/src/features/notifiers/ui/show/ShowNotifierComponent.tsx +++ b/frontend/src/features/notifiers/ui/show/ShowNotifierComponent.tsx @@ -1,6 +1,7 @@ import { type Notifier, NotifierType } from '../../../../entity/notifiers'; import { getNotifierLogoFromType } from '../../../../entity/notifiers/models/getNotifierLogoFromType'; import { getNotifierNameFromType } from '../../../../entity/notifiers/models/getNotifierNameFromType'; +import { ShowDiscordNotifierComponent } from './notifier/ShowDiscordNotifierComponent'; import { ShowEmailNotifierComponent } from './notifier/ShowEmailNotifierComponent'; import { ShowSlackNotifierComponent } from './notifier/ShowSlackNotifierComponent'; import { ShowTelegramNotifierComponent } from './notifier/ShowTelegramNotifierComponent'; @@ -36,6 +37,10 @@ export function ShowNotifierComponent({ notifier }: Props) { {notifier?.notifierType === NotifierType.SLACK && ( )} + + {notifier?.notifierType === NotifierType.DISCORD && ( + + )}
); diff --git a/frontend/src/features/notifiers/ui/show/notifier/ShowDiscordNotifierComponent.tsx b/frontend/src/features/notifiers/ui/show/notifier/ShowDiscordNotifierComponent.tsx new file mode 100644 index 0000000..cbf60d7 --- /dev/null +++ b/frontend/src/features/notifiers/ui/show/notifier/ShowDiscordNotifierComponent.tsx @@ -0,0 +1,17 @@ +import type { Notifier } from '../../../../../entity/notifiers'; + +interface Props { + notifier: Notifier; +} + +export function ShowDiscordNotifierComponent({ notifier }: Props) { + return ( + <> +
+
Channel webhook URL
+ +
{notifier.webhookNotifier?.webhookUrl.slice(0, 10)}*******
+
+ + ); +}