diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 30632bd6c8..f73926cebe 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -41,6 +41,10 @@ import ( agentsettingshandlers "github.com/kandev/kandev/internal/agent/settings/handlers" settingsstore "github.com/kandev/kandev/internal/agent/settings/store" "github.com/kandev/kandev/internal/agent/worktree" + notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" + notificationhandlers "github.com/kandev/kandev/internal/notifications/handlers" + notificationservice "github.com/kandev/kandev/internal/notifications/service" + notificationstore "github.com/kandev/kandev/internal/notifications/store" // Orchestrator packages "github.com/kandev/kandev/internal/orchestrator" @@ -210,6 +214,14 @@ func main() { _ = userRepo.Close() }() + notificationRepo, err := notificationstore.NewSQLiteRepository(dbPath) + if err != nil { + log.Fatal("Failed to initialize notification store", zap.Error(err), zap.String("db_path", dbPath)) + } + defer func() { + _ = notificationRepo.Close() + }() + discoveryRegistry, err := discovery.LoadRegistry() if err != nil { log.Fatal("Failed to load agent discovery config", zap.Error(err)) @@ -218,6 +230,8 @@ func main() { userSvc := userservice.NewService(userRepo, eventBus, log) userCtrl := usercontroller.NewController(userSvc) + var notificationSvc *notificationservice.Service + var notificationCtrl *notificationcontroller.Controller taskSvc := taskservice.NewService( taskRepo, @@ -462,6 +476,22 @@ func main() { gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log) gateways.RegisterUserNotifications(ctx, eventBus, gateway.Hub, log) + notificationSvc = notificationservice.NewService(notificationRepo, taskRepo, gateway.Hub, log) + notificationCtrl = notificationcontroller.NewController(notificationSvc) + if eventBus != nil { + _, err = eventBus.Subscribe(events.TaskSessionStateChanged, func(ctx context.Context, event *bus.Event) error { + data := event.Data + taskID, _ := data["task_id"].(string) + sessionID, _ := data["task_session_id"].(string) + newState, _ := data["new_state"].(string) + notificationSvc.HandleTaskSessionStateChanged(ctx, taskID, sessionID, newState) + return nil + }) + if err != nil { + log.Error("Failed to subscribe to task session notifications", zap.Error(err)) + } + } + // Set up historical logs provider for task subscriptions // Uses messages instead of execution logs - all agent messages are now messages gateway.Hub.SetHistoricalLogsProvider(func(ctx context.Context, taskID string) ([]*ws.Message, error) { @@ -781,6 +811,9 @@ func main() { userhandlers.RegisterRoutes(router, gateway.Dispatcher, userCtrl, log) log.Info("Registered User handlers (HTTP + WebSocket)") + notificationhandlers.RegisterRoutes(router, notificationCtrl, log) + log.Info("Registered Notification handlers (HTTP)") + // Health check (simple HTTP for load balancers/monitoring) router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ diff --git a/apps/backend/internal/notifications/controller/controller.go b/apps/backend/internal/notifications/controller/controller.go new file mode 100644 index 0000000000..c64ff96e64 --- /dev/null +++ b/apps/backend/internal/notifications/controller/controller.go @@ -0,0 +1,94 @@ +package controller + +import ( + "context" + "strings" + + "github.com/kandev/kandev/internal/notifications/dto" + "github.com/kandev/kandev/internal/notifications/models" + "github.com/kandev/kandev/internal/notifications/service" + userstore "github.com/kandev/kandev/internal/user/store" +) + +type Controller struct { + service *service.Service +} + +func NewController(svc *service.Service) *Controller { + return &Controller{service: svc} +} + +func (c *Controller) ListProviders(ctx context.Context) (dto.NotificationProvidersResponse, error) { + userID := userstore.DefaultUserID + providers, subscriptions, err := c.service.ListProviders(ctx, userID) + if err != nil { + return dto.NotificationProvidersResponse{}, err + } + result := make([]dto.NotificationProviderDTO, 0, len(providers)) + for _, provider := range providers { + events := subscriptions[provider.ID] + result = append(result, dto.FromProvider(provider, events)) + } + return dto.NotificationProvidersResponse{ + Providers: result, + AppriseAvailable: c.service.AppriseAvailable(), + Events: c.service.AvailableEvents(), + }, nil +} + +func (c *Controller) CreateProvider(ctx context.Context, req dto.UpsertProviderRequest) (dto.NotificationProviderDTO, error) { + userID := userstore.DefaultUserID + name := strings.TrimSpace(req.Name) + if name == "" { + name = defaultNameForType(req.Type) + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + providerType := models.ProviderType(req.Type) + config := req.Config + provider, err := c.service.CreateProvider(ctx, userID, name, providerType, config, enabled, req.Events) + if err != nil { + return dto.NotificationProviderDTO{}, err + } + return dto.FromProvider(provider, req.Events), nil +} + +func (c *Controller) UpdateProvider(ctx context.Context, providerID string, req dto.UpdateProviderRequest) (dto.NotificationProviderDTO, error) { + var providerType *models.ProviderType + if req.Type != nil { + t := models.ProviderType(*req.Type) + providerType = &t + } + updates := service.ProviderUpdate{ + Name: req.Name, + Enabled: req.Enabled, + Type: providerType, + Config: req.Config, + Events: req.Events, + } + provider, err := c.service.UpdateProvider(ctx, providerID, updates) + if err != nil { + return dto.NotificationProviderDTO{}, err + } + userID := userstore.DefaultUserID + _, subscriptions, err := c.service.ListProviders(ctx, userID) + if err != nil { + return dto.NotificationProviderDTO{}, err + } + return dto.FromProvider(provider, subscriptions[provider.ID]), nil +} + +func (c *Controller) DeleteProvider(ctx context.Context, providerID string) error { + return c.service.DeleteProvider(ctx, providerID) +} + +func defaultNameForType(providerType string) string { + switch providerType { + case string(models.ProviderTypeApprise): + return "Apprise" + default: + return "Notifications" + } +} diff --git a/apps/backend/internal/notifications/dto/dto.go b/apps/backend/internal/notifications/dto/dto.go new file mode 100644 index 0000000000..fa07b6f5b6 --- /dev/null +++ b/apps/backend/internal/notifications/dto/dto.go @@ -0,0 +1,53 @@ +package dto + +import ( + "time" + + "github.com/kandev/kandev/internal/notifications/models" +) + +type NotificationProviderDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Config map[string]interface{} `json:"config"` + Enabled bool `json:"enabled"` + Events []string `json:"events"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type NotificationProvidersResponse struct { + Providers []NotificationProviderDTO `json:"providers"` + AppriseAvailable bool `json:"apprise_available"` + Events []string `json:"events"` +} + +type UpsertProviderRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Config map[string]interface{} `json:"config,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Events []string `json:"events,omitempty"` +} + +type UpdateProviderRequest struct { + Name *string `json:"name,omitempty"` + Type *string `json:"type,omitempty"` + Config map[string]interface{} `json:"config,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Events *[]string `json:"events,omitempty"` +} + +func FromProvider(provider *models.Provider, events []string) NotificationProviderDTO { + return NotificationProviderDTO{ + ID: provider.ID, + Name: provider.Name, + Type: string(provider.Type), + Config: provider.Config, + Enabled: provider.Enabled, + Events: events, + CreatedAt: provider.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: provider.UpdatedAt.UTC().Format(time.RFC3339), + } +} diff --git a/apps/backend/internal/notifications/handlers/handlers.go b/apps/backend/internal/notifications/handlers/handlers.go new file mode 100644 index 0000000000..5d4041e9f0 --- /dev/null +++ b/apps/backend/internal/notifications/handlers/handlers.go @@ -0,0 +1,80 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/notifications/controller" + "github.com/kandev/kandev/internal/notifications/dto" + "go.uber.org/zap" +) + +type Handlers struct { + controller *controller.Controller + logger *logger.Logger +} + +func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, log *logger.Logger) { + h := &Handlers{ + controller: ctrl, + logger: log.WithFields(zap.String("component", "notification-handlers")), + } + api := router.Group("/api/v1") + api.GET("/notification-providers", h.httpListProviders) + api.POST("/notification-providers", h.httpCreateProvider) + api.PATCH("/notification-providers/:id", h.httpUpdateProvider) + api.DELETE("/notification-providers/:id", h.httpDeleteProvider) +} + +func (h *Handlers) httpListProviders(c *gin.Context) { + resp, err := h.controller.ListProviders(c.Request.Context()) + if err != nil { + h.logger.Error("failed to list notification providers", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notification providers"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpCreateProvider(c *gin.Context) { + var body dto.UpsertProviderRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + resp, err := h.controller.CreateProvider(c.Request.Context(), body) + if err != nil { + h.logger.Error("failed to create notification provider", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpUpdateProvider(c *gin.Context) { + providerID := c.Param("id") + var body dto.UpdateProviderRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + resp, err := h.controller.UpdateProvider(c.Request.Context(), providerID, body) + if err != nil { + h.logger.Error("failed to update notification provider", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpDeleteProvider(c *gin.Context) { + providerID := c.Param("id") + if err := h.controller.DeleteProvider(c.Request.Context(), providerID); err != nil { + h.logger.Error("failed to delete notification provider", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete notification provider"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/apps/backend/internal/notifications/models/models.go b/apps/backend/internal/notifications/models/models.go new file mode 100644 index 0000000000..5a7e86ab25 --- /dev/null +++ b/apps/backend/internal/notifications/models/models.go @@ -0,0 +1,40 @@ +package models + +import "time" + +type ProviderType string + +const ( + ProviderTypeLocal ProviderType = "local" + ProviderTypeApprise ProviderType = "apprise" +) + +type Provider struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Type ProviderType `json:"type"` + Config map[string]interface{} `json:"config"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Subscription struct { + ID string `json:"id"` + UserID string `json:"user_id"` + ProviderID string `json:"provider_id"` + EventType string `json:"event_type"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Delivery struct { + ID string `json:"id"` + UserID string `json:"user_id"` + ProviderID string `json:"provider_id"` + EventType string `json:"event_type"` + TaskSessionID string `json:"task_session_id"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/apps/backend/internal/notifications/providers/apprise.go b/apps/backend/internal/notifications/providers/apprise.go new file mode 100644 index 0000000000..b654a7ea1a --- /dev/null +++ b/apps/backend/internal/notifications/providers/apprise.go @@ -0,0 +1,83 @@ +package providers + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" +) + +type AppriseProvider struct{} + +func NewAppriseProvider() *AppriseProvider { + return &AppriseProvider{} +} + +func (p *AppriseProvider) Available() bool { + _, err := exec.LookPath("apprise") + return err == nil +} + +func (p *AppriseProvider) Validate(config map[string]interface{}) error { + _, err := parseAppriseURLs(config) + return err +} + +func (p *AppriseProvider) Send(ctx context.Context, message Message) error { + if !p.Available() { + return fmt.Errorf("apprise not installed") + } + urls, err := parseAppriseURLs(message.Config) + if err != nil { + return err + } + if len(urls) == 0 { + return fmt.Errorf("apprise urls not configured") + } + timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + args := []string{"-t", message.Title, "-b", message.Body} + args = append(args, urls...) + cmd := exec.CommandContext(timeoutCtx, "apprise", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("apprise failed: %w (%s)", err, strings.TrimSpace(string(output))) + } + return nil +} + +func parseAppriseURLs(config map[string]interface{}) ([]string, error) { + if config == nil { + return nil, fmt.Errorf("apprise config missing") + } + raw, ok := config["urls"] + if !ok { + return nil, fmt.Errorf("apprise urls missing") + } + switch value := raw.(type) { + case []string: + return value, nil + case []interface{}: + urls := make([]string, 0, len(value)) + for _, item := range value { + text, ok := item.(string) + if ok && strings.TrimSpace(text) != "" { + urls = append(urls, strings.TrimSpace(text)) + } + } + return urls, nil + case string: + parts := strings.Split(value, "\n") + var urls []string + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + urls = append(urls, part) + } + } + return urls, nil + default: + return nil, fmt.Errorf("apprise urls must be a list of strings") + } +} diff --git a/apps/backend/internal/notifications/providers/local.go b/apps/backend/internal/notifications/providers/local.go new file mode 100644 index 0000000000..464407056e --- /dev/null +++ b/apps/backend/internal/notifications/providers/local.go @@ -0,0 +1,42 @@ +package providers + +import ( + "context" + "fmt" + + gatewayws "github.com/kandev/kandev/internal/gateway/websocket" + ws "github.com/kandev/kandev/pkg/websocket" +) + +type LocalProvider struct { + hub *gatewayws.Hub +} + +func NewLocalProvider(hub *gatewayws.Hub) *LocalProvider { + return &LocalProvider{hub: hub} +} + +func (p *LocalProvider) Available() bool { + return p.hub != nil +} + +func (p *LocalProvider) Validate(_ map[string]interface{}) error { + return nil +} + +func (p *LocalProvider) Send(_ context.Context, message Message) error { + if p.hub == nil { + return fmt.Errorf("websocket hub not available") + } + msg, err := ws.NewNotification(ws.ActionTaskSessionWaitingForInput, map[string]interface{}{ + "task_id": message.TaskID, + "task_session_id": message.TaskSessionID, + "title": message.Title, + "body": message.Body, + }) + if err != nil { + return err + } + p.hub.BroadcastToUser(message.UserID, msg) + return nil +} diff --git a/apps/backend/internal/notifications/providers/provider.go b/apps/backend/internal/notifications/providers/provider.go new file mode 100644 index 0000000000..16591dfe24 --- /dev/null +++ b/apps/backend/internal/notifications/providers/provider.go @@ -0,0 +1,21 @@ +package providers + +import ( + "context" +) + +type Message struct { + EventType string + Title string + Body string + TaskID string + TaskSessionID string + UserID string + Config map[string]interface{} +} + +type Provider interface { + Available() bool + Validate(config map[string]interface{}) error + Send(ctx context.Context, message Message) error +} diff --git a/apps/backend/internal/notifications/service/service.go b/apps/backend/internal/notifications/service/service.go new file mode 100644 index 0000000000..52d3153168 --- /dev/null +++ b/apps/backend/internal/notifications/service/service.go @@ -0,0 +1,288 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + + "github.com/kandev/kandev/internal/common/logger" + gatewayws "github.com/kandev/kandev/internal/gateway/websocket" + "github.com/kandev/kandev/internal/notifications/models" + "github.com/kandev/kandev/internal/notifications/providers" + notificationstore "github.com/kandev/kandev/internal/notifications/store" + "github.com/kandev/kandev/internal/task/repository" + userstore "github.com/kandev/kandev/internal/user/store" + "go.uber.org/zap" +) + +const ( + EventTaskSessionWaitingForInput = "task_session.waiting_for_input" +) + +var ErrProviderNotFound = errors.New("notification provider not found") + +type Service struct { + repo notificationstore.Repository + taskRepo repository.Repository + hub *gatewayws.Hub + logger *logger.Logger + providers map[models.ProviderType]providers.Provider +} + +func NewService(repo notificationstore.Repository, taskRepo repository.Repository, hub *gatewayws.Hub, log *logger.Logger) *Service { + providerMap := map[models.ProviderType]providers.Provider{ + models.ProviderTypeLocal: providers.NewLocalProvider(hub), + models.ProviderTypeApprise: providers.NewAppriseProvider(), + } + return &Service{ + repo: repo, + taskRepo: taskRepo, + hub: hub, + logger: log.WithFields(zap.String("component", "notifications-service")), + providers: providerMap, + } +} + +func (s *Service) AppriseAvailable() bool { + provider := s.providers[models.ProviderTypeApprise] + if provider == nil { + return false + } + return provider.Available() +} + +func (s *Service) AvailableEvents() []string { + return []string{EventTaskSessionWaitingForInput} +} + +func (s *Service) ListProviders(ctx context.Context, userID string) ([]*models.Provider, map[string][]string, error) { + if err := s.ensureDefaultLocalProvider(ctx, userID); err != nil { + return nil, nil, err + } + providers, err := s.repo.ListProvidersByUser(ctx, userID) + if err != nil { + return nil, nil, err + } + subscriptions := make(map[string][]string, len(providers)) + for _, provider := range providers { + subs, err := s.repo.ListSubscriptionsByProvider(ctx, provider.ID) + if err != nil { + return nil, nil, err + } + for _, sub := range subs { + if sub.Enabled { + subscriptions[provider.ID] = append(subscriptions[provider.ID], sub.EventType) + } + } + } + return providers, subscriptions, nil +} + +func (s *Service) CreateProvider(ctx context.Context, userID, name string, providerType models.ProviderType, config map[string]interface{}, enabled bool, events []string) (*models.Provider, error) { + if err := s.validateProvider(providerType, config); err != nil { + return nil, err + } + if err := s.validateEvents(events); err != nil { + return nil, err + } + provider := &models.Provider{ + UserID: userID, + Name: name, + Type: providerType, + Config: config, + Enabled: enabled, + } + if err := s.repo.CreateProvider(ctx, provider); err != nil { + return nil, err + } + if err := s.repo.ReplaceSubscriptions(ctx, provider.ID, userID, events); err != nil { + return nil, err + } + return provider, nil +} + +func (s *Service) UpdateProvider(ctx context.Context, providerID string, updates ProviderUpdate) (*models.Provider, error) { + provider, err := s.repo.GetProvider(ctx, providerID) + if err != nil { + return nil, ErrProviderNotFound + } + if updates.Name != nil { + provider.Name = strings.TrimSpace(*updates.Name) + } + if updates.Enabled != nil { + provider.Enabled = *updates.Enabled + } + if updates.Config != nil { + provider.Config = updates.Config + } + if updates.Type != nil { + provider.Type = *updates.Type + } + if err := s.validateProvider(provider.Type, provider.Config); err != nil { + return nil, err + } + if err := s.repo.UpdateProvider(ctx, provider); err != nil { + return nil, err + } + if updates.Events != nil { + if err := s.validateEvents(*updates.Events); err != nil { + return nil, err + } + if err := s.repo.ReplaceSubscriptions(ctx, provider.ID, provider.UserID, *updates.Events); err != nil { + return nil, err + } + } + return provider, nil +} + +func (s *Service) DeleteProvider(ctx context.Context, providerID string) error { + return s.repo.DeleteProvider(ctx, providerID) +} + +type ProviderUpdate struct { + Name *string + Enabled *bool + Type *models.ProviderType + Config map[string]interface{} + Events *[]string +} + +func (s *Service) HandleTaskSessionStateChanged(ctx context.Context, taskID, sessionID, newState string) { + if newState != "WAITING_FOR_INPUT" { + return + } + userID := userstore.DefaultUserID + providers, subscriptions, err := s.ListProviders(ctx, userID) + if err != nil { + s.logger.Error("failed to load notification providers", zap.Error(err)) + return + } + title, body := s.buildWaitingForInputMessage(ctx, taskID) + for _, provider := range providers { + if !provider.Enabled { + continue + } + events := subscriptions[provider.ID] + if !containsEvent(events, EventTaskSessionWaitingForInput) { + continue + } + delivery := &models.Delivery{ + UserID: userID, + ProviderID: provider.ID, + EventType: EventTaskSessionWaitingForInput, + TaskSessionID: sessionID, + } + inserted, err := s.repo.InsertDelivery(ctx, delivery) + if err != nil { + s.logger.Error("failed to record notification delivery", zap.Error(err)) + continue + } + if !inserted { + continue + } + if err := s.dispatchProvider(ctx, provider, waitingForInputPayload{ + TaskID: taskID, + TaskSessionID: sessionID, + Title: title, + Body: body, + }); err != nil { + s.logger.Warn("notification delivery failed", zap.String("provider_id", provider.ID), zap.Error(err)) + _ = s.repo.DeleteDelivery(ctx, provider.ID, EventTaskSessionWaitingForInput, sessionID) + } + } +} + +type waitingForInputPayload struct { + TaskID string + TaskSessionID string + Title string + Body string +} + +func (s *Service) dispatchProvider(ctx context.Context, provider *models.Provider, payload waitingForInputPayload) error { + adapter := s.providers[provider.Type] + if adapter == nil { + return fmt.Errorf("unknown provider type: %s", provider.Type) + } + return adapter.Send(ctx, providers.Message{ + EventType: EventTaskSessionWaitingForInput, + Title: payload.Title, + Body: payload.Body, + TaskID: payload.TaskID, + TaskSessionID: payload.TaskSessionID, + UserID: userstore.DefaultUserID, + Config: provider.Config, + }) +} + +func (s *Service) buildWaitingForInputMessage(ctx context.Context, taskID string) (string, string) { + title := "Task needs your input" + body := "An agent is waiting for your input." + if taskID == "" || s.taskRepo == nil { + return title, body + } + task, err := s.taskRepo.GetTask(ctx, taskID) + if err != nil || task == nil { + return title, body + } + if task.Title != "" { + body = fmt.Sprintf("An agent is waiting for your input on \"%s\".", task.Title) + } + return title, body +} + +func (s *Service) ensureDefaultLocalProvider(ctx context.Context, userID string) error { + providers, err := s.repo.ListProvidersByUser(ctx, userID) + if err != nil { + return err + } + for _, provider := range providers { + if provider.Type == models.ProviderTypeLocal { + return nil + } + } + provider := &models.Provider{ + ID: uuid.New().String(), + UserID: userID, + Name: "Desktop Notifications", + Type: models.ProviderTypeLocal, + Config: map[string]interface{}{}, + Enabled: true, + } + if err := s.repo.CreateProvider(ctx, provider); err != nil { + return err + } + return s.repo.ReplaceSubscriptions(ctx, provider.ID, userID, []string{EventTaskSessionWaitingForInput}) +} + +func (s *Service) validateProvider(providerType models.ProviderType, config map[string]interface{}) error { + adapter := s.providers[providerType] + if adapter == nil { + return fmt.Errorf("unsupported provider type: %s", providerType) + } + return adapter.Validate(config) +} + +func (s *Service) validateEvents(events []string) error { + allowed := map[string]struct{}{ + EventTaskSessionWaitingForInput: {}, + } + for _, event := range events { + if _, ok := allowed[event]; !ok { + return fmt.Errorf("unsupported event type: %s", event) + } + } + return nil +} + +func containsEvent(events []string, target string) bool { + for _, event := range events { + if event == target { + return true + } + } + return false +} diff --git a/apps/backend/internal/notifications/store/interface.go b/apps/backend/internal/notifications/store/interface.go new file mode 100644 index 0000000000..0b2f895373 --- /dev/null +++ b/apps/backend/internal/notifications/store/interface.go @@ -0,0 +1,23 @@ +package store + +import ( + "context" + + "github.com/kandev/kandev/internal/notifications/models" +) + +type Repository interface { + CreateProvider(ctx context.Context, provider *models.Provider) error + UpdateProvider(ctx context.Context, provider *models.Provider) error + GetProvider(ctx context.Context, id string) (*models.Provider, error) + ListProvidersByUser(ctx context.Context, userID string) ([]*models.Provider, error) + DeleteProvider(ctx context.Context, id string) error + + ListSubscriptionsByProvider(ctx context.Context, providerID string) ([]*models.Subscription, error) + ReplaceSubscriptions(ctx context.Context, providerID, userID string, events []string) error + + InsertDelivery(ctx context.Context, delivery *models.Delivery) (bool, error) + DeleteDelivery(ctx context.Context, providerID, eventType, taskSessionID string) error + + Close() error +} diff --git a/apps/backend/internal/notifications/store/sqlite.go b/apps/backend/internal/notifications/store/sqlite.go new file mode 100644 index 0000000000..b51652c721 --- /dev/null +++ b/apps/backend/internal/notifications/store/sqlite.go @@ -0,0 +1,334 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/google/uuid" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/notifications/models" +) + +type SQLiteRepository struct { + db *sql.DB +} + +var _ Repository = (*SQLiteRepository)(nil) + +func NewSQLiteRepository(dbPath string) (*SQLiteRepository, error) { + normalizedPath := normalizeSQLitePath(dbPath) + if err := ensureSQLiteDir(normalizedPath); err != nil { + return nil, fmt.Errorf("failed to prepare database path: %w", err) + } + if err := ensureSQLiteFile(normalizedPath); err != nil { + return nil, fmt.Errorf("failed to create database file: %w", err) + } + dsn := fmt.Sprintf("file:%s?_foreign_keys=on&_mode=rwc", normalizedPath) + db, err := sql.Open("sqlite3", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + repo := &SQLiteRepository{db: db} + if err := repo.initSchema(); err != nil { + if closeErr := db.Close(); closeErr != nil { + return nil, fmt.Errorf("failed to close database after schema error: %w", closeErr) + } + return nil, fmt.Errorf("failed to initialize schema: %w", err) + } + return repo, nil +} + +func (r *SQLiteRepository) Close() error { + return r.db.Close() +} + +func ensureSQLiteDir(dbPath string) error { + dir := filepath.Dir(dbPath) + if dir == "." || dir == "" { + return nil + } + return os.MkdirAll(dir, 0o755) +} + +func ensureSQLiteFile(dbPath string) error { + file, err := os.OpenFile(dbPath, os.O_RDWR|os.O_CREATE, 0o644) + if err != nil { + return err + } + return file.Close() +} + +func normalizeSQLitePath(dbPath string) string { + if dbPath == "" { + return dbPath + } + abs, err := filepath.Abs(dbPath) + if err != nil { + return dbPath + } + return abs +} + +func (r *SQLiteRepository) initSchema() error { + schema := ` + CREATE TABLE IF NOT EXISTS notification_providers ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + config TEXT DEFAULT '{}', + enabled INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + + CREATE TABLE IF NOT EXISTS notification_subscriptions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + event_type TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE(provider_id, event_type), + FOREIGN KEY (provider_id) REFERENCES notification_providers(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS notification_deliveries ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + event_type TEXT NOT NULL, + task_session_id TEXT NOT NULL, + created_at DATETIME NOT NULL, + UNIQUE(provider_id, event_type, task_session_id) + ); + + CREATE INDEX IF NOT EXISTS idx_notification_providers_user_id ON notification_providers(user_id); + CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_provider_id ON notification_subscriptions(provider_id); + CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_user_id ON notification_subscriptions(user_id); + CREATE INDEX IF NOT EXISTS idx_notification_deliveries_session_id ON notification_deliveries(task_session_id); + ` + + _, err := r.db.Exec(schema) + return err +} + +func (r *SQLiteRepository) CreateProvider(ctx context.Context, provider *models.Provider) error { + provider.ID = uuid.New().String() + now := time.Now().UTC() + provider.CreatedAt = now + provider.UpdatedAt = now + if provider.Config == nil { + provider.Config = map[string]interface{}{} + } + configJSON, err := json.Marshal(provider.Config) + if err != nil { + return fmt.Errorf("failed to serialize provider config: %w", err) + } + _, err = r.db.ExecContext(ctx, ` + INSERT INTO notification_providers (id, user_id, name, type, config, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, provider.ID, provider.UserID, provider.Name, provider.Type, string(configJSON), boolToInt(provider.Enabled), provider.CreatedAt, provider.UpdatedAt) + return err +} + +func (r *SQLiteRepository) UpdateProvider(ctx context.Context, provider *models.Provider) error { + provider.UpdatedAt = time.Now().UTC() + if provider.Config == nil { + provider.Config = map[string]interface{}{} + } + configJSON, err := json.Marshal(provider.Config) + if err != nil { + return fmt.Errorf("failed to serialize provider config: %w", err) + } + _, err = r.db.ExecContext(ctx, ` + UPDATE notification_providers + SET name = ?, type = ?, config = ?, enabled = ?, updated_at = ? + WHERE id = ? + `, provider.Name, provider.Type, string(configJSON), boolToInt(provider.Enabled), provider.UpdatedAt, provider.ID) + return err +} + +func (r *SQLiteRepository) GetProvider(ctx context.Context, id string) (*models.Provider, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT id, user_id, name, type, config, enabled, created_at, updated_at + FROM notification_providers + WHERE id = ? + `, id) + return scanProvider(row) +} + +func (r *SQLiteRepository) ListProvidersByUser(ctx context.Context, userID string) ([]*models.Provider, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, user_id, name, type, config, enabled, created_at, updated_at + FROM notification_providers + WHERE user_id = ? + ORDER BY created_at ASC + `, userID) + if err != nil { + return nil, err + } + defer func() { + _ = rows.Close() + }() + + var providers []*models.Provider + for rows.Next() { + provider, err := scanProvider(rows) + if err != nil { + return nil, err + } + providers = append(providers, provider) + } + if err := rows.Err(); err != nil { + return nil, err + } + return providers, nil +} + +func (r *SQLiteRepository) DeleteProvider(ctx context.Context, id string) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM notification_providers WHERE id = ?`, id) + return err +} + +func (r *SQLiteRepository) ListSubscriptionsByProvider(ctx context.Context, providerID string) ([]*models.Subscription, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, user_id, provider_id, event_type, enabled, created_at, updated_at + FROM notification_subscriptions + WHERE provider_id = ? + ORDER BY created_at ASC + `, providerID) + if err != nil { + return nil, err + } + defer func() { + _ = rows.Close() + }() + + var subs []*models.Subscription + for rows.Next() { + sub, err := scanSubscription(rows) + if err != nil { + return nil, err + } + subs = append(subs, sub) + } + if err := rows.Err(); err != nil { + return nil, err + } + return subs, nil +} + +func (r *SQLiteRepository) ReplaceSubscriptions(ctx context.Context, providerID, userID string, events []string) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `DELETE FROM notification_subscriptions WHERE provider_id = ?`, providerID); err != nil { + _ = tx.Rollback() + return err + } + now := time.Now().UTC() + for _, eventType := range events { + subID := uuid.New().String() + if _, err := tx.ExecContext(ctx, ` + INSERT INTO notification_subscriptions (id, user_id, provider_id, event_type, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, subID, userID, providerID, eventType, boolToInt(true), now, now); err != nil { + _ = tx.Rollback() + return err + } + } + if err := tx.Commit(); err != nil { + return err + } + return nil +} + +func (r *SQLiteRepository) InsertDelivery(ctx context.Context, delivery *models.Delivery) (bool, error) { + delivery.ID = uuid.New().String() + delivery.CreatedAt = time.Now().UTC() + result, err := r.db.ExecContext(ctx, ` + INSERT OR IGNORE INTO notification_deliveries (id, user_id, provider_id, event_type, task_session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `, delivery.ID, delivery.UserID, delivery.ProviderID, delivery.EventType, delivery.TaskSessionID, delivery.CreatedAt) + if err != nil { + return false, err + } + rows, err := result.RowsAffected() + if err != nil { + return false, err + } + return rows > 0, nil +} + +func (r *SQLiteRepository) DeleteDelivery(ctx context.Context, providerID, eventType, taskSessionID string) error { + _, err := r.db.ExecContext(ctx, ` + DELETE FROM notification_deliveries + WHERE provider_id = ? AND event_type = ? AND task_session_id = ? + `, providerID, eventType, taskSessionID) + return err +} + +func scanProvider(scanner interface{ Scan(dest ...any) error }) (*models.Provider, error) { + provider := &models.Provider{} + var configJSON string + var enabledInt int + if err := scanner.Scan( + &provider.ID, + &provider.UserID, + &provider.Name, + &provider.Type, + &configJSON, + &enabledInt, + &provider.CreatedAt, + &provider.UpdatedAt, + ); err != nil { + return nil, err + } + provider.Enabled = enabledInt == 1 + if configJSON != "" && configJSON != "{}" { + if err := json.Unmarshal([]byte(configJSON), &provider.Config); err != nil { + return nil, fmt.Errorf("failed to deserialize provider config: %w", err) + } + } else { + provider.Config = map[string]interface{}{} + } + return provider, nil +} + +func scanSubscription(scanner interface{ Scan(dest ...any) error }) (*models.Subscription, error) { + sub := &models.Subscription{} + var enabledInt int + if err := scanner.Scan( + &sub.ID, + &sub.UserID, + &sub.ProviderID, + &sub.EventType, + &enabledInt, + &sub.CreatedAt, + &sub.UpdatedAt, + ); err != nil { + return nil, err + } + sub.Enabled = enabledInt == 1 + return sub, nil +} + +func boolToInt(value bool) int { + if value { + return 1 + } + return 0 +} diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 1a8fc6aa52..57227c4c81 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -124,6 +124,7 @@ const ( ActionMessageAdded = "message.added" ActionMessageUpdated = "message.updated" ActionTaskSessionStateChanged = "task_session.state_changed" + ActionTaskSessionWaitingForInput = "task_session.waiting_for_input" ActionInputRequested = "input.requested" ActionRepositoryCreated = "repository.created" ActionRepositoryUpdated = "repository.updated" diff --git a/apps/packages/types/src/backend.ts b/apps/packages/types/src/backend.ts index cd06653f9a..fbc2156a24 100644 --- a/apps/packages/types/src/backend.ts +++ b/apps/packages/types/src/backend.ts @@ -16,7 +16,8 @@ export type BackendMessageType = | 'board.deleted' | 'column.created' | 'column.updated' - | 'column.deleted'; + | 'column.deleted' + | 'task_session.waiting_for_input'; export type BackendMessage = { id?: string; @@ -77,6 +78,13 @@ export type SystemErrorPayload = { code?: string; }; +export type TaskSessionWaitingForInputPayload = { + task_id: string; + task_session_id: string; + title: string; + body: string; +}; + export type WorkspacePayload = { id: string; name: string; @@ -125,4 +133,8 @@ export type BackendMessageMap = { 'column.created': BackendMessage<'column.created', ColumnPayload>; 'column.updated': BackendMessage<'column.updated', ColumnPayload>; 'column.deleted': BackendMessage<'column.deleted', ColumnPayload>; + 'task_session.waiting_for_input': BackendMessage< + 'task_session.waiting_for_input', + TaskSessionWaitingForInputPayload + >; }; diff --git a/apps/web/app/settings/general/notifications/page.tsx b/apps/web/app/settings/general/notifications/page.tsx new file mode 100644 index 0000000000..56e68ecca8 --- /dev/null +++ b/apps/web/app/settings/general/notifications/page.tsx @@ -0,0 +1,27 @@ +import { NotificationsSettings } from '@/components/settings/notifications-settings'; +import { listNotificationProviders } from '@/lib/http'; +import type { NotificationProvider } from '@/lib/types/http'; + +export default async function GeneralNotificationsPage() { + let providers: NotificationProvider[] | undefined = []; + let events: string[] = []; + let appriseAvailable = true; + try { + const response = await listNotificationProviders({ cache: 'no-store' }); + providers = response.providers ?? []; + events = response.events ?? []; + appriseAvailable = response.apprise_available ?? true; + } catch { + providers = undefined; + events = []; + appriseAvailable = true; + } + + return ( + + ); +} diff --git a/apps/web/app/settings/general/page.tsx b/apps/web/app/settings/general/page.tsx index c482e689be..2bd8477cb4 100644 --- a/apps/web/app/settings/general/page.tsx +++ b/apps/web/app/settings/general/page.tsx @@ -2,7 +2,7 @@ import { useState, useSyncExternalStore } from 'react'; import { useTheme } from 'next-themes'; -import { IconPalette, IconCode, IconBell, IconServer } from '@tabler/icons-react'; +import { IconPalette, IconCode, IconServer } from '@tabler/icons-react'; import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card'; import { Label } from '@kandev/ui/label'; import { Input } from '@kandev/ui/input'; @@ -11,7 +11,7 @@ import { Separator } from '@kandev/ui/separator'; import { SettingsSection } from '@/components/settings/settings-section'; import { SETTINGS_DATA } from '@/lib/settings/dummy-data'; import { getBackendConfig } from '@/lib/config'; -import type { Theme, Editor, Notifications } from '@/lib/settings/types'; +import type { Theme, Editor } from '@/lib/settings/types'; export default function GeneralSettingsPage() { const { theme: currentTheme, setTheme } = useTheme(); @@ -19,9 +19,6 @@ export default function GeneralSettingsPage() { const [customEditorCommand, setCustomEditorCommand] = useState( SETTINGS_DATA.general.customEditorCommand || '' ); - const [notifications, setNotifications] = useState( - SETTINGS_DATA.general.notifications - ); const [backendUrl] = useState(() => getBackendConfig().apiBaseUrl); const mounted = useSyncExternalStore( () => () => undefined, @@ -29,10 +26,6 @@ export default function GeneralSettingsPage() { () => false ); - const handleNotificationToggle = (key: keyof Notifications) => { - setNotifications((prev) => ({ ...prev, [key]: !prev[key] })); - }; - const displayBackendUrl = backendUrl.replace(/^https?:\/\//, '').replace(/\/$/, ''); if (!mounted) { @@ -128,102 +121,6 @@ export default function GeneralSettingsPage() { - } - title="Notifications" - description="Control what notifications you receive" - > - - -
-
-
- -

- Get notified when tasks change status -

-
- -
- - - -
-
- -

- Get notified when agents complete tasks -

-
- -
- - - -
-
- -

- Get notified about errors and failures -

-
- -
-
-
-
-
- - - } title="Backend Connection" diff --git a/apps/web/components/settings/notifications-settings.tsx b/apps/web/components/settings/notifications-settings.tsx new file mode 100644 index 0000000000..c7d010d267 --- /dev/null +++ b/apps/web/components/settings/notifications-settings.tsx @@ -0,0 +1,695 @@ +'use client'; + +import { useEffect, useMemo, useState, type FormEvent } from 'react'; +import { IconBell, IconRefresh } from '@tabler/icons-react'; +import { Button } from '@kandev/ui/button'; +import { Checkbox } from '@kandev/ui/checkbox'; +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@kandev/ui/hover-card'; +import { Input } from '@kandev/ui/input'; +import { Separator } from '@kandev/ui/separator'; +import { Textarea } from '@kandev/ui/textarea'; +import { SettingsPageTemplate } from '@/components/settings/settings-page-template'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { + createNotificationProvider, + deleteNotificationProvider, + listNotificationProviders, + updateNotificationProvider, +} from '@/lib/http'; +import { useRequest } from '@/lib/http/use-request'; +import { DEFAULT_NOTIFICATION_EVENTS, EVENT_LABELS } from '@/lib/notifications/events'; +import type { NotificationProvider } from '@/lib/types/http'; + +type ProviderUpdatePayload = { + enabled?: boolean; + events?: string[]; + config?: NotificationProvider['config']; + name?: string; +}; + +function formatAppriseUrls(value: unknown): string { + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string').join('\n'); + } + if (typeof value === 'string') { + return value; + } + return ''; +} + +function parseAppriseUrls(value: string): string[] { + return value + .split('\n') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function extractUrls(config: NotificationProvider['config']): string[] { + if (!config) { + return []; + } + const urls = config.urls; + if (Array.isArray(urls)) { + return urls.filter((item): item is string => typeof item === 'string'); + } + if (typeof urls === 'string') { + return parseAppriseUrls(urls); + } + return []; +} + +function normalizeEvents(events?: string[]): string { + if (!Array.isArray(events)) { + return ''; + } + return [...events].sort().join('|'); +} + +type NotificationsSettingsProps = { + initialProviders?: NotificationProvider[]; + initialEvents?: string[]; + initialAppriseAvailable?: boolean; +}; + +type AppriseFormMode = 'create' | 'edit'; + +function buildAppriseEdits(providers: NotificationProvider[]) { + const urls: Record = {}; + const names: Record = {}; + for (const provider of providers) { + if (provider.type !== 'apprise') { + continue; + } + urls[provider.id] = formatAppriseUrls(provider.config?.urls); + names[provider.id] = provider.name; + } + return { urls, names }; +} + +export function NotificationsSettings({ + initialProviders, + initialEvents, + initialAppriseAvailable, +}: NotificationsSettingsProps) { + const [providers, setProviders] = useState(() => initialProviders ?? []); + const [baselineProviders, setBaselineProviders] = useState( + () => initialProviders ?? [] + ); + const [notificationEvents, setNotificationEvents] = useState( + () => initialEvents ?? [] + ); + const [appriseAvailable, setAppriseAvailable] = useState( + () => initialAppriseAvailable ?? true + ); + const [isProvidersLoading, setIsProvidersLoading] = useState(false); + const [appriseName, setAppriseName] = useState(''); + const [appriseUrls, setAppriseUrls] = useState(''); + const [appriseEdits, setAppriseEdits] = useState>(() => { + return buildAppriseEdits(initialProviders ?? []).urls; + }); + const [appriseNameEdits, setAppriseNameEdits] = useState>(() => { + return buildAppriseEdits(initialProviders ?? []).names; + }); + const [showAppriseForm, setShowAppriseForm] = useState(false); + const [appriseFormMode, setAppriseFormMode] = useState('create'); + const [activeAppriseId, setActiveAppriseId] = useState(null); + const [pendingDeletes, setPendingDeletes] = useState>(new Set()); + const [notificationPermission, setNotificationPermission] = useState< + NotificationPermission | 'unsupported' + >('default'); + + const saveRequest = useRequest(async () => { + const updates: Array> = []; + for (const provider of providers) { + const baseline = baselineProviders.find((item) => item.id === provider.id); + if (!baseline) { + continue; + } + const payload = buildProviderUpdate(provider, baseline); + if (!payload) { + continue; + } + updates.push(updateNotificationProvider(provider.id, payload)); + } + const deletions = Array.from(pendingDeletes); + for (const providerId of deletions) { + await deleteNotificationProvider(providerId); + } + const updated = await Promise.all(updates); + if (updated.length === 0) { + setBaselineProviders(providers); + setPendingDeletes(new Set()); + return [] as NotificationProvider[]; + } + const updatedById = new Map(updated.map((provider) => [provider.id, provider])); + const nextProviders = providers.map((provider) => updatedById.get(provider.id) ?? provider); + setProviders(nextProviders); + setBaselineProviders(nextProviders); + setAppriseEdits((prev) => { + const next = { ...prev }; + for (const provider of nextProviders) { + if (provider.type === 'apprise') { + next[provider.id] = formatAppriseUrls(provider.config?.urls); + } + } + return next; + }); + setAppriseNameEdits((prev) => { + const next = { ...prev }; + for (const provider of nextProviders) { + if (provider.type === 'apprise') { + next[provider.id] = provider.name; + } + } + return next; + }); + setPendingDeletes(new Set()); + return updated; + }); + + useEffect(() => { + if (typeof Notification === 'undefined') { + setNotificationPermission('unsupported'); + } else { + setNotificationPermission(Notification.permission); + } + }, []); + + useEffect(() => { + if (initialProviders) { + return; + } + const fetchProviders = async () => { + setIsProvidersLoading(true); + try { + const response = await listNotificationProviders(); + const loaded = response.providers ?? []; + setProviders(loaded); + setBaselineProviders(loaded); + setNotificationEvents(response.events ?? []); + setAppriseAvailable(response.apprise_available ?? true); + const edits = buildAppriseEdits(loaded); + setAppriseEdits(edits.urls); + setAppriseNameEdits(edits.names); + } catch (error) { + console.error('[NotificationsSettings] Failed to load notification providers', error); + } finally { + setIsProvidersLoading(false); + } + }; + fetchProviders(); + }, [initialProviders]); + + const appriseProviders = providers.filter((provider) => provider.type === 'apprise'); + + const tableProviders = useMemo(() => { + return [...providers].sort((left, right) => { + if (left.type === right.type) { + return left.name.localeCompare(right.name); + } + if (left.type === 'local') { + return -1; + } + if (right.type === 'local') { + return 1; + } + return left.type.localeCompare(right.type); + }); + }, [providers]); + const tableEvents = useMemo(() => { + if (notificationEvents.length > 0) { + return notificationEvents; + } + const eventSet = new Set(); + for (const provider of providers) { + for (const event of provider.events ?? []) { + eventSet.add(event); + } + } + return eventSet.size ? Array.from(eventSet) : DEFAULT_NOTIFICATION_EVENTS; + }, [notificationEvents, providers]); + + const isDirty = + pendingDeletes.size > 0 || + providers.some((provider) => { + const baseline = baselineProviders.find((item) => item.id === provider.id); + if (!baseline) { + return false; + } + if (buildProviderUpdate(provider, baseline)) { + return true; + } + if (provider.type === 'apprise') { + const currentValue = appriseEdits[provider.id] ?? formatAppriseUrls(provider.config?.urls); + const baselineValue = formatAppriseUrls(baseline.config?.urls); + const nameValue = appriseNameEdits[provider.id] ?? provider.name; + return currentValue !== baselineValue || nameValue !== baseline.name; + } + return false; + }); + + const handleRequestPermission = async () => { + if (typeof Notification === 'undefined') { + setNotificationPermission('unsupported'); + return; + } + const result = await Notification.requestPermission(); + setNotificationPermission(result); + }; + + const handleRefreshPermission = () => { + if (typeof Notification === 'undefined') { + setNotificationPermission('unsupported'); + return; + } + setNotificationPermission(Notification.permission); + }; + + const handleTestNotification = async () => { + if (typeof Notification === 'undefined') { + return; + } + let permission = Notification.permission; + if (permission === 'default') { + permission = await Notification.requestPermission(); + setNotificationPermission(permission); + } + if (permission !== 'granted') { + return; + } + new Notification('Test notification', { + body: 'If you can read this, browser notifications are working.', + }); + }; + + const updateProviderState = (providerId: string, updater: (provider: NotificationProvider) => NotificationProvider) => { + setProviders((prev) => prev.map((provider) => (provider.id === providerId ? updater(provider) : provider))); + }; + + const handleToggleEvent = (provider: NotificationProvider, eventType: string) => { + const currentEvents = provider.events ?? []; + const hasEvent = currentEvents.includes(eventType); + const nextEvents = hasEvent + ? currentEvents.filter((event) => event !== eventType) + : [...currentEvents, eventType]; + updateProviderState(provider.id, (current) => ({ ...current, events: nextEvents })); + }; + + const handleCreateAppriseProvider = async () => { + const urls = parseAppriseUrls(appriseUrls); + if (urls.length === 0) { + return; + } + const defaultEvents = + notificationEvents.length > 0 ? notificationEvents : DEFAULT_NOTIFICATION_EVENTS; + try { + const created = await createNotificationProvider({ + name: appriseName.trim() || 'Apprise', + type: 'apprise', + config: { urls }, + enabled: true, + events: defaultEvents, + }); + setProviders((prev) => [...prev, created]); + setBaselineProviders((prev) => [...prev, created]); + setAppriseEdits((prev) => ({ ...prev, [created.id]: urls.join('\n') })); + setAppriseNameEdits((prev) => ({ ...prev, [created.id]: created.name })); + setAppriseUrls(''); + setShowAppriseForm(false); + } catch (error) { + console.error('[NotificationsSettings] Failed to create apprise provider', error); + } + }; + + const handleDeleteProvider = (providerId: string) => { + setPendingDeletes((prev) => new Set(prev).add(providerId)); + setProviders((prev) => prev.filter((provider) => provider.id !== providerId)); + }; + + const handleSave = async () => { + try { + await saveRequest.run(); + } catch (error) { + console.error('[NotificationsSettings] Failed to save notifications', error); + } + }; + + const handleAppriseEdit = (providerId: string, value: string) => { + setAppriseEdits((prev) => ({ ...prev, [providerId]: value })); + const urls = parseAppriseUrls(value); + updateProviderState(providerId, (provider) => ({ + ...provider, + config: { ...provider.config, urls }, + })); + }; + + const handleAppriseNameEdit = (providerId: string, value: string) => { + setAppriseNameEdits((prev) => ({ ...prev, [providerId]: value })); + updateProviderState(providerId, (provider) => ({ + ...provider, + name: value, + })); + }; + + const handleTextareaInput = (event: FormEvent) => { + const target = event.currentTarget; + target.style.height = 'auto'; + target.style.height = `${target.scrollHeight}px`; + }; + + const openAppriseForm = (mode: AppriseFormMode, provider?: NotificationProvider) => { + setAppriseFormMode(mode); + setActiveAppriseId(provider?.id ?? null); + if (provider) { + setAppriseName(appriseNameEdits[provider.id] ?? provider.name); + setAppriseUrls(appriseEdits[provider.id] ?? formatAppriseUrls(provider.config?.urls)); + } else { + setAppriseName(''); + setAppriseUrls(''); + } + setShowAppriseForm(true); + }; + + const closeAppriseForm = () => { + setShowAppriseForm(false); + setActiveAppriseId(null); + }; + + return ( + +
+
+
+
Desktop Notifications
+

+ Notify this device when an agent needs your input. +

+
+
+ + + + + + + Refresh permission status + + + + + + + + If you do not see notifications, check your OS settings and allow this browser. + + +
+
+ + {notificationPermission === 'denied' && ( +

+ Notifications are blocked in your browser. Enable them in site settings, then click + Refresh. +

+ )} + {notificationPermission === 'unsupported' && ( +

+ This browser does not support desktop notifications. +

+ )} +
+ + + +
+
+
External Providers
+

+ Configure external providers for remote notifications. +

+
+ {!appriseAvailable && ( +

+ Apprise is not installed yet. You can add it later to enable remote notifications.{' '} + + View installation instructions + + . +

+ )} + {appriseProviders.length === 0 && ( +

No Apprise providers configured yet.

+ )} + {appriseProviders.map((provider) => { + const isEditing = appriseFormMode === 'edit' && activeAppriseId === provider.id; + return ( +
+ {isEditing ? ( + { + setAppriseName(value); + handleAppriseNameEdit(provider.id, value); + }} + onUrlsChange={(value) => { + setAppriseUrls(value); + handleAppriseEdit(provider.id, value); + }} + onSubmit={async () => { + closeAppriseForm(); + }} + onCancel={closeAppriseForm} + onInput={handleTextareaInput} + /> + ) : ( +
+
+
{provider.name}
+
Apprise
+
+
+ + +
+
+ )} +
+ ); + })} + {appriseAvailable && ( +
+ + {showAppriseForm && appriseFormMode === 'create' && ( + setAppriseName(value)} + onUrlsChange={(value) => setAppriseUrls(value)} + onSubmit={async () => { + await handleCreateAppriseProvider(); + closeAppriseForm(); + }} + onCancel={closeAppriseForm} + onInput={handleTextareaInput} + /> + )} +
+ )} +
+ + + +
+
+
Notification Events
+

+ Select which providers should receive each notification type. +

+
+ {isProvidersLoading && ( +

Loading providers...

+ )} + {!isProvidersLoading && tableProviders.length === 0 && ( +

No providers configured yet.

+ )} + {tableProviders.length > 0 && ( +
+ + + + + {tableProviders.map((provider) => ( + + ))} + + + + {tableEvents.map((eventType) => { + const meta = EVENT_LABELS[eventType] ?? { + title: eventType, + description: 'Notify when this event occurs.', + }; + return ( + + + {tableProviders.map((provider) => ( + + ))} + + ); + })} + +
Notification type + {provider.name} +
+
{meta.title}
+
{meta.description}
+
+
+ handleToggleEvent(provider, eventType)} + /> +
+
+
+ )} +
+
+ ); +} + +type AppriseProviderFormProps = { + mode: AppriseFormMode; + name: string; + urls: string; + onNameChange: (value: string) => void; + onUrlsChange: (value: string) => void; + onSubmit: () => void | Promise; + onCancel: () => void; + onInput: (event: FormEvent) => void; +}; + +function AppriseProviderForm({ + mode, + name, + urls, + onNameChange, + onUrlsChange, + onSubmit, + onCancel, + onInput, +}: AppriseProviderFormProps) { + return ( +
+
Apprise Provider
+ onNameChange(event.target.value)} + placeholder="Provider name" + /> +