diff --git a/backend/internal/features/notifiers/enums.go b/backend/internal/features/notifiers/enums.go
index a9adce7..8e960bb 100644
--- a/backend/internal/features/notifiers/enums.go
+++ b/backend/internal/features/notifiers/enums.go
@@ -6,4 +6,5 @@ const (
NotifierTypeEmail NotifierType = "EMAIL"
NotifierTypeTelegram NotifierType = "TELEGRAM"
NotifierTypeWebhook NotifierType = "WEBHOOK"
+ NotifierTypeSlack NotifierType = "SLACK"
)
diff --git a/backend/internal/features/notifiers/model.go b/backend/internal/features/notifiers/model.go
index b083885..6d75c13 100644
--- a/backend/internal/features/notifiers/model.go
+++ b/backend/internal/features/notifiers/model.go
@@ -4,6 +4,7 @@ import (
"errors"
"log/slog"
"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"
webhook_notifier "postgresus-backend/internal/features/notifiers/models/webhook"
@@ -21,6 +22,7 @@ type Notifier struct {
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"`
+ SlackNotifier *slack_notifier.SlackNotifier `json:"slackNotifier" gorm:"foreignKey:NotifierID"`
}
func (n *Notifier) TableName() string {
@@ -56,6 +58,8 @@ func (n *Notifier) getSpecificNotifier() NotificationSender {
return n.EmailNotifier
case NotifierTypeWebhook:
return n.WebhookNotifier
+ case NotifierTypeSlack:
+ return n.SlackNotifier
default:
panic("unknown notifier type: " + string(n.NotifierType))
}
diff --git a/backend/internal/features/notifiers/models/slack/model.go b/backend/internal/features/notifiers/models/slack/model.go
new file mode 100644
index 0000000..a743a7b
--- /dev/null
+++ b/backend/internal/features/notifiers/models/slack/model.go
@@ -0,0 +1,134 @@
+package slack_notifier
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type SlackNotifier struct {
+ NotifierID uuid.UUID `json:"notifierId" gorm:"primaryKey;column:notifier_id"`
+ BotToken string `json:"botToken" gorm:"not null;column:bot_token"`
+ TargetChatID string `json:"targetChatId" gorm:"not null;column:target_chat_id"`
+}
+
+func (s *SlackNotifier) TableName() string { return "slack_notifiers" }
+
+func (s *SlackNotifier) Validate() error {
+ if s.BotToken == "" {
+ return errors.New("bot token is required")
+ }
+
+ if s.TargetChatID == "" {
+ return errors.New("target channel ID is required")
+ }
+
+ if !strings.HasPrefix(s.TargetChatID, "C") && !strings.HasPrefix(s.TargetChatID, "G") &&
+ !strings.HasPrefix(s.TargetChatID, "D") &&
+ !strings.HasPrefix(s.TargetChatID, "U") {
+ return errors.New(
+ "target channel ID must be a valid Slack channel ID (starts with C, G, D) or User ID (starts with U)",
+ )
+ }
+
+ return nil
+}
+
+func (s *SlackNotifier) Send(logger *slog.Logger, heading, message string) error {
+ full := fmt.Sprintf("*%s*", heading)
+
+ if message != "" {
+ full = fmt.Sprintf("%s\n\n%s", full, message)
+ }
+
+ payload, _ := json.Marshal(map[string]any{
+ "channel": s.TargetChatID,
+ "text": full,
+ "mrkdwn": true,
+ })
+
+ const (
+ maxAttempts = 5
+ defaultBackoff = 2 * time.Second // when Retry-After header missing
+ backoffMultiplier = 1.5 // use exponential growth
+ )
+
+ var (
+ backoff = defaultBackoff
+ attempts = 0
+ )
+
+ for {
+ attempts++
+
+ req, err := http.NewRequest(
+ "POST",
+ "https://slack.com/api/chat.postMessage",
+ bytes.NewReader(payload),
+ )
+ if err != nil {
+ return fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json; charset=utf-8")
+ req.Header.Set("Authorization", "Bearer "+s.BotToken)
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("send slack message: %w", err)
+ }
+
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ logger.Warn("Failed to close response body", "error", err)
+ }
+ }()
+
+ if resp.StatusCode == http.StatusTooManyRequests { // 429
+ retryAfter := backoff
+ if h := resp.Header.Get("Retry-After"); h != "" {
+ if seconds, _ := strconv.Atoi(h); seconds > 0 {
+ retryAfter = time.Duration(seconds) * time.Second
+ }
+ }
+
+ if attempts >= maxAttempts {
+ return fmt.Errorf("rate-limited after %d attempts, giving up", attempts)
+ }
+
+ logger.Warn("Slack rate-limited, retrying", "after", retryAfter, "attempt", attempts)
+ time.Sleep(retryAfter)
+ backoff = time.Duration(float64(backoff) * backoffMultiplier)
+
+ continue
+ }
+
+ // Slack always returns 200 for logical errors, so decode body
+ var respBody struct {
+ OK bool `json:"ok"`
+ Error string `json:"error,omitempty"`
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
+ raw, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("decode response: %v – raw: %s", err, raw)
+ }
+
+ if !respBody.OK {
+ return fmt.Errorf("slack API error: %s", respBody.Error)
+ }
+
+ logger.Info("Slack message sent", "channel", s.TargetChatID, "attempts", attempts)
+
+ return nil
+ }
+}
diff --git a/backend/internal/features/notifiers/repository.go b/backend/internal/features/notifiers/repository.go
index a1b0672..9040de8 100644
--- a/backend/internal/features/notifiers/repository.go
+++ b/backend/internal/features/notifiers/repository.go
@@ -26,17 +26,21 @@ func (r *NotifierRepository) Save(notifier *Notifier) error {
if notifier.WebhookNotifier != nil {
notifier.WebhookNotifier.NotifierID = notifier.ID
}
+ case NotifierTypeSlack:
+ if notifier.SlackNotifier != nil {
+ notifier.SlackNotifier.NotifierID = notifier.ID
+ }
}
if notifier.ID == uuid.Nil {
if err := tx.Create(notifier).
- Omit("TelegramNotifier", "EmailNotifier", "WebhookNotifier").
+ Omit("TelegramNotifier", "EmailNotifier", "WebhookNotifier", "SlackNotifier").
Error; err != nil {
return err
}
} else {
if err := tx.Save(notifier).
- Omit("TelegramNotifier", "EmailNotifier", "WebhookNotifier").
+ Omit("TelegramNotifier", "EmailNotifier", "WebhookNotifier", "SlackNotifier").
Error; err != nil {
return err
}
@@ -64,6 +68,13 @@ func (r *NotifierRepository) Save(notifier *Notifier) error {
return err
}
}
+ case NotifierTypeSlack:
+ if notifier.SlackNotifier != nil {
+ notifier.SlackNotifier.NotifierID = notifier.ID // Ensure ID is set
+ if err := tx.Save(notifier.SlackNotifier).Error; err != nil {
+ return err
+ }
+ }
}
return nil
@@ -78,6 +89,7 @@ func (r *NotifierRepository) FindByID(id uuid.UUID) (*Notifier, error) {
Preload("TelegramNotifier").
Preload("EmailNotifier").
Preload("WebhookNotifier").
+ Preload("SlackNotifier").
Where("id = ?", id).
First(¬ifier).Error; err != nil {
return nil, err
@@ -94,6 +106,7 @@ func (r *NotifierRepository) FindByUserID(userID uuid.UUID) ([]*Notifier, error)
Preload("TelegramNotifier").
Preload("EmailNotifier").
Preload("WebhookNotifier").
+ Preload("SlackNotifier").
Where("user_id = ?", userID).
Find(¬ifiers).Error; err != nil {
return nil, err
@@ -124,6 +137,12 @@ func (r *NotifierRepository) Delete(notifier *Notifier) error {
return err
}
}
+ case NotifierTypeSlack:
+ if notifier.SlackNotifier != nil {
+ if err := tx.Delete(notifier.SlackNotifier).Error; err != nil {
+ return err
+ }
+ }
}
// Delete the main notifier
diff --git a/backend/migrations/20250624065518_add_slack_notifier.sql b/backend/migrations/20250624065518_add_slack_notifier.sql
new file mode 100644
index 0000000..47fca12
--- /dev/null
+++ b/backend/migrations/20250624065518_add_slack_notifier.sql
@@ -0,0 +1,24 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- Create slack notifiers table
+CREATE TABLE slack_notifiers (
+ notifier_id UUID PRIMARY KEY,
+ bot_token TEXT NOT NULL,
+ target_chat_id TEXT NOT NULL
+);
+
+ALTER TABLE slack_notifiers
+ ADD CONSTRAINT fk_slack_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 slack_notifiers;
+
+-- +goose StatementEnd
diff --git a/frontend/public/icons/notifiers/slack.svg b/frontend/public/icons/notifiers/slack.svg
new file mode 100644
index 0000000..519730c
--- /dev/null
+++ b/frontend/public/icons/notifiers/slack.svg
@@ -0,0 +1,7 @@
+
+
\ No newline at end of file
diff --git a/frontend/src/entity/notifiers/index.ts b/frontend/src/entity/notifiers/index.ts
index f348efd..c5c66b4 100644
--- a/frontend/src/entity/notifiers/index.ts
+++ b/frontend/src/entity/notifiers/index.ts
@@ -1,7 +1,16 @@
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';
+
+export type { EmailNotifier } from './models/email/EmailNotifier';
+export { validateEmailNotifier } from './models/email/validateEmailNotifier';
+
+export type { TelegramNotifier } from './models/telegram/TelegramNotifier';
+export { validateTelegramNotifier } from './models/telegram/validateTelegramNotifier';
+
+export type { WebhookNotifier } from './models/webhook/WebhookNotifier';
+export { validateWebhookNotifier } from './models/webhook/validateWebhookNotifier';
+export { WebhookMethod } from './models/webhook/WebhookMethod';
+
+export type { SlackNotifier } from './models/slack/SlackNotifier';
+export { validateSlackNotifier } from './models/slack/validateSlackNotifier';
diff --git a/frontend/src/entity/notifiers/models/Notifier.ts b/frontend/src/entity/notifiers/models/Notifier.ts
index c8a72ed..e99f80c 100644
--- a/frontend/src/entity/notifiers/models/Notifier.ts
+++ b/frontend/src/entity/notifiers/models/Notifier.ts
@@ -1,7 +1,8 @@
-import type { EmailNotifier } from './EmailNotifier';
import type { NotifierType } from './NotifierType';
-import type { TelegramNotifier } from './TelegramNotifier';
-import type { WebhookNotifier } from './WebhookNotifier';
+import type { SlackNotifier } from './slack/SlackNotifier';
+import type { EmailNotifier } from './email/EmailNotifier';
+import type { TelegramNotifier } from './telegram/TelegramNotifier';
+import type { WebhookNotifier } from './webhook/WebhookNotifier';
export interface Notifier {
id: string;
@@ -13,4 +14,5 @@ export interface Notifier {
telegramNotifier?: TelegramNotifier;
emailNotifier?: EmailNotifier;
webhookNotifier?: WebhookNotifier;
+ slackNotifier?: SlackNotifier;
}
diff --git a/frontend/src/entity/notifiers/models/NotifierType.ts b/frontend/src/entity/notifiers/models/NotifierType.ts
index 627bd82..f842904 100644
--- a/frontend/src/entity/notifiers/models/NotifierType.ts
+++ b/frontend/src/entity/notifiers/models/NotifierType.ts
@@ -2,4 +2,5 @@ export enum NotifierType {
EMAIL = 'EMAIL',
TELEGRAM = 'TELEGRAM',
WEBHOOK = 'WEBHOOK',
+ SLACK = 'SLACK',
}
diff --git a/frontend/src/entity/notifiers/models/EmailNotifier.ts b/frontend/src/entity/notifiers/models/email/EmailNotifier.ts
similarity index 100%
rename from frontend/src/entity/notifiers/models/EmailNotifier.ts
rename to frontend/src/entity/notifiers/models/email/EmailNotifier.ts
diff --git a/frontend/src/entity/notifiers/models/email/validateEmailNotifier.ts b/frontend/src/entity/notifiers/models/email/validateEmailNotifier.ts
new file mode 100644
index 0000000..8cdb4ba
--- /dev/null
+++ b/frontend/src/entity/notifiers/models/email/validateEmailNotifier.ts
@@ -0,0 +1,25 @@
+import type { EmailNotifier } from './EmailNotifier';
+
+export const validateEmailNotifier = (notifier: EmailNotifier): boolean => {
+ if (!notifier.targetEmail) {
+ return false;
+ }
+
+ if (!notifier.smtpHost) {
+ return false;
+ }
+
+ if (!notifier.smtpPort) {
+ return false;
+ }
+
+ if (!notifier.smtpUser) {
+ return false;
+ }
+
+ if (!notifier.smtpPassword) {
+ return false;
+ }
+
+ return true;
+};
diff --git a/frontend/src/entity/notifiers/models/getNotifierLogoFromType.ts b/frontend/src/entity/notifiers/models/getNotifierLogoFromType.ts
index 326fb13..7690c7c 100644
--- a/frontend/src/entity/notifiers/models/getNotifierLogoFromType.ts
+++ b/frontend/src/entity/notifiers/models/getNotifierLogoFromType.ts
@@ -8,6 +8,8 @@ export const getNotifierLogoFromType = (type: NotifierType) => {
return '/icons/notifiers/telegram.svg';
case NotifierType.WEBHOOK:
return '/icons/notifiers/webhook.svg';
+ case NotifierType.SLACK:
+ return '/icons/notifiers/slack.svg';
default:
return '';
}
diff --git a/frontend/src/entity/notifiers/models/getNotifierNameFromType.ts b/frontend/src/entity/notifiers/models/getNotifierNameFromType.ts
index 7dac91e..4597f50 100644
--- a/frontend/src/entity/notifiers/models/getNotifierNameFromType.ts
+++ b/frontend/src/entity/notifiers/models/getNotifierNameFromType.ts
@@ -8,6 +8,8 @@ export const getNotifierNameFromType = (type: NotifierType) => {
return 'Telegram';
case NotifierType.WEBHOOK:
return 'Webhook';
+ case NotifierType.SLACK:
+ return 'Slack';
default:
return '';
}
diff --git a/frontend/src/entity/notifiers/models/slack/SlackNotifier.ts b/frontend/src/entity/notifiers/models/slack/SlackNotifier.ts
new file mode 100644
index 0000000..a658761
--- /dev/null
+++ b/frontend/src/entity/notifiers/models/slack/SlackNotifier.ts
@@ -0,0 +1,4 @@
+export interface SlackNotifier {
+ botToken: string;
+ targetChatId: string;
+}
diff --git a/frontend/src/entity/notifiers/models/slack/validateSlackNotifier.ts b/frontend/src/entity/notifiers/models/slack/validateSlackNotifier.ts
new file mode 100644
index 0000000..63665d8
--- /dev/null
+++ b/frontend/src/entity/notifiers/models/slack/validateSlackNotifier.ts
@@ -0,0 +1,13 @@
+import type { SlackNotifier } from './SlackNotifier';
+
+export const validateSlackNotifier = (notifier: SlackNotifier): boolean => {
+ if (!notifier.botToken) {
+ return false;
+ }
+
+ if (!notifier.targetChatId) {
+ return false;
+ }
+
+ return true;
+};
diff --git a/frontend/src/entity/notifiers/models/TelegramNotifier.ts b/frontend/src/entity/notifiers/models/telegram/TelegramNotifier.ts
similarity index 100%
rename from frontend/src/entity/notifiers/models/TelegramNotifier.ts
rename to frontend/src/entity/notifiers/models/telegram/TelegramNotifier.ts
diff --git a/frontend/src/entity/notifiers/models/telegram/validateTelegramNotifier.ts b/frontend/src/entity/notifiers/models/telegram/validateTelegramNotifier.ts
new file mode 100644
index 0000000..36fe2eb
--- /dev/null
+++ b/frontend/src/entity/notifiers/models/telegram/validateTelegramNotifier.ts
@@ -0,0 +1,13 @@
+import type { TelegramNotifier } from './TelegramNotifier';
+
+export const validateTelegramNotifier = (notifier: TelegramNotifier): boolean => {
+ if (!notifier.botToken) {
+ return false;
+ }
+
+ if (!notifier.targetChatId) {
+ return false;
+ }
+
+ return true;
+};
diff --git a/frontend/src/entity/notifiers/models/WebhookMethod.ts b/frontend/src/entity/notifiers/models/webhook/WebhookMethod.ts
similarity index 100%
rename from frontend/src/entity/notifiers/models/WebhookMethod.ts
rename to frontend/src/entity/notifiers/models/webhook/WebhookMethod.ts
diff --git a/frontend/src/entity/notifiers/models/WebhookNotifier.ts b/frontend/src/entity/notifiers/models/webhook/WebhookNotifier.ts
similarity index 100%
rename from frontend/src/entity/notifiers/models/WebhookNotifier.ts
rename to frontend/src/entity/notifiers/models/webhook/WebhookNotifier.ts
diff --git a/frontend/src/entity/notifiers/models/webhook/validateWebhookNotifier.ts b/frontend/src/entity/notifiers/models/webhook/validateWebhookNotifier.ts
new file mode 100644
index 0000000..5d71f74
--- /dev/null
+++ b/frontend/src/entity/notifiers/models/webhook/validateWebhookNotifier.ts
@@ -0,0 +1,9 @@
+import type { WebhookNotifier } from './WebhookNotifier';
+
+export const validateWebhookNotifier = (notifier: WebhookNotifier): boolean => {
+ if (!notifier.webhookUrl) {
+ return false;
+ }
+
+ return true;
+};
diff --git a/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx b/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx
index 94962a3..63bb6c0 100644
--- a/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx
+++ b/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx
@@ -6,10 +6,15 @@ import {
NotifierType,
WebhookMethod,
notifierApi,
+ validateEmailNotifier,
+ validateSlackNotifier,
+ validateTelegramNotifier,
+ validateWebhookNotifier,
} from '../../../../entity/notifiers';
import { getNotifierLogoFromType } from '../../../../entity/notifiers/models/getNotifierLogoFromType';
import { ToastHelper } from '../../../../shared/toast';
import { EditEmailNotifierComponent } from './notifiers/EditEmailNotifierComponent';
+import { EditSlackNotifierComponent } from './notifiers/EditSlackNotifierComponent';
import { EditTelegramNotifierComponent } from './notifiers/EditTelegramNotifierComponent';
import { EditWebhookNotifierComponent } from './notifiers/EditWebhookNotifierComponent';
@@ -67,6 +72,9 @@ export function EditNotifierComponent({
});
} catch (e) {
alert((e as Error).message);
+ alert(
+ 'Make sure channel is public or bot is added to the private channel (via @invite) or group. For direct messages use User ID from Slack profile.',
+ );
}
setIsSendingTestNotification(false);
@@ -102,6 +110,13 @@ export function EditNotifierComponent({
};
}
+ if (type === NotifierType.SLACK) {
+ notifier.slackNotifier = {
+ botToken: '',
+ targetChatId: '',
+ };
+ }
+
setNotifier(
JSON.parse(
JSON.stringify({
@@ -129,27 +144,28 @@ export function EditNotifierComponent({
);
}, [editingNotifier]);
+ useEffect(() => {
+ setIsTestNotificationSuccess(false);
+ }, [notifier]);
+
const isAllDataFilled = () => {
if (!notifier) return false;
-
if (!notifier.name) return false;
- if (notifier.notifierType === NotifierType.TELEGRAM) {
- return notifier.telegramNotifier?.botToken && notifier.telegramNotifier?.targetChatId;
+ if (notifier.notifierType === NotifierType.TELEGRAM && notifier.telegramNotifier) {
+ return validateTelegramNotifier(notifier.telegramNotifier);
}
- if (notifier.notifierType === NotifierType.EMAIL) {
- return (
- notifier.emailNotifier?.targetEmail &&
- notifier.emailNotifier?.smtpHost &&
- notifier.emailNotifier?.smtpPort &&
- notifier.emailNotifier?.smtpUser &&
- notifier.emailNotifier?.smtpPassword
- );
+ if (notifier.notifierType === NotifierType.EMAIL && notifier.emailNotifier) {
+ return validateEmailNotifier(notifier.emailNotifier);
}
- if (notifier.notifierType === NotifierType.WEBHOOK) {
- return notifier.webhookNotifier?.webhookUrl;
+ if (notifier.notifierType === NotifierType.WEBHOOK && notifier.webhookNotifier) {
+ return validateWebhookNotifier(notifier.webhookNotifier);
+ }
+
+ if (notifier.notifierType === NotifierType.SLACK && notifier.slackNotifier) {
+ return validateSlackNotifier(notifier.slackNotifier);
}
return false;
@@ -185,6 +201,7 @@ export function EditNotifierComponent({
{ label: 'Telegram', value: NotifierType.TELEGRAM },
{ label: 'Email', value: NotifierType.EMAIL },
{ label: 'Webhook', value: NotifierType.WEBHOOK },
+ { label: 'Slack', value: NotifierType.SLACK },
]}
onChange={(value) => {
setNotifierType(value);
@@ -223,6 +240,14 @@ export function EditNotifierComponent({
setIsUnsaved={setIsUnsaved}
/>
)}
+
+ {notifier?.notifierType === NotifierType.SLACK && (
+