mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-22 00:01:25 +00:00
feat: implement the coming-soon notification delivery channels — Email (SES/SMTP to the account email) and Slack (posts to the org's connected workspace via a new integration NotifySlack), wired in both backend and consumer with per-channel gating, real toggles replacing the coming-soon labels, and updated docs
This commit is contained in:
@@ -870,6 +870,7 @@ func main() {
|
||||
// onto the backend's advanced service (deliverability webhooks can ingest
|
||||
// here too).
|
||||
notificationService = notification.NewService(repository.NewNotificationRepository(primaryDB.Pool), streamingPublisher)
|
||||
notificationService.WireDelivery(emailNotificationService, integrationServiceForHandler, userRepostory)
|
||||
advancedService.WireNotifier(notificationService)
|
||||
advancedService.WireRealtime(streamingPublisher)
|
||||
emailSender := tasks.NewEmailSender(emailRepostory, eventsPublisher)
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/infrastructure/kms"
|
||||
"github.com/warmbly/warmbly/internal/infrastructure/pubsub"
|
||||
"github.com/warmbly/warmbly/internal/infrastructure/storage"
|
||||
"github.com/warmbly/warmbly/internal/notify"
|
||||
"github.com/warmbly/warmbly/internal/observability"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
@@ -228,6 +229,19 @@ func main() {
|
||||
// notifier must be wired here. Missing this = notifications silently never
|
||||
// created.
|
||||
notificationService := notification.NewService(repository.NewNotificationRepository(primaryDB.Pool), streamingPublisher)
|
||||
// Email + Slack delivery for notifications. Email is best-effort: the
|
||||
// SES/SMTP service only constructs when email config is present (prod, or
|
||||
// a dev env that sets it), so a bare dev consumer simply skips the email
|
||||
// channel. Slack reuses the integration service (token decryption).
|
||||
var notifEmail notification.EmailSender
|
||||
if emailCfg, ecErr := cfg.LoadEmailConfig(ctx); ecErr == nil {
|
||||
if smtpCfg := cfg.LoadSMTPConfig(ctx); smtpCfg != nil {
|
||||
notifEmail = notify.NewSMTPEmailNotificationService(emailCfg.EmailName, emailCfg.EmailAddress, smtpCfg.Host, smtpCfg.Port)
|
||||
} else if ses, sErr := notify.NewEmailNotficiationService(ctx, emailCfg.EmailName, emailCfg.EmailAddress); sErr == nil {
|
||||
notifEmail = ses
|
||||
}
|
||||
}
|
||||
notificationService.WireDelivery(notifEmail, integrationServiceC, repository.NewUserRepostory(primaryDB, kmsClient))
|
||||
advancedService.WireNotifier(notificationService)
|
||||
// Reply pulses fire in THIS process too (inbox ingest classifies replies).
|
||||
advancedService.WireRealtime(streamingPublisher)
|
||||
|
||||
@@ -74,13 +74,13 @@ Even with the reply notification off, you never miss responses. Every reply stil
|
||||
|
||||
## Channels
|
||||
|
||||
The settings page also shows where notifications are delivered.
|
||||
The settings page controls where enabled notifications are delivered. The channel toggles apply across every category above.
|
||||
|
||||
- **In-app**: the bell in the dashboard. This is the channel that is live today, and it is controlled by the per-category toggles above.
|
||||
- **Email**: delivery to your account email. Marked **Coming soon**.
|
||||
- **Slack**: delivery through a connected Slack integration. Marked **Coming soon**.
|
||||
- **In-app**: the bell in the dashboard. Always on, controlled by the per-category toggles above.
|
||||
- **Email**: delivery to your account email. Turn it on to also receive each enabled notification as an email with a link back into the app.
|
||||
- **Slack**: posts each enabled notification to your workspace's connected Slack, on the channel you chose when connecting. Connect Slack from the [Integrations](/guides/integrations) tab first; until then the toggle saves but nothing is delivered.
|
||||
|
||||
For now, the in-app feed is the delivery channel. If you want event-driven Slack or webhook delivery in the meantime, that is what [Automations](/guides/automations) are for: you can route events like replies, bookings, and record changes to outside tools there.
|
||||
For richer, event-specific routing (custom messages, branching, multiple destinations), use [Automations](/guides/automations) instead: they can route events like replies, bookings, and record changes to outside tools with full control.
|
||||
|
||||
## Practical tips
|
||||
|
||||
|
||||
@@ -139,6 +139,10 @@ type Service interface {
|
||||
// Dispatch; struct payloads are ignored.
|
||||
DispatchAny(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any)
|
||||
|
||||
// NotifySlack posts a plain message to the org's connected Slack on its
|
||||
// configured default channel. No-op (nil) when no Slack is connected.
|
||||
NotifySlack(ctx context.Context, orgID uuid.UUID, title, body string) error
|
||||
|
||||
// Repo exposes the underlying repository for the inbound webhook handlers.
|
||||
Repo() repository.IntegrationRepository
|
||||
}
|
||||
@@ -1105,3 +1109,33 @@ func buildDisplayFields(provider models.IntegrationProvider, config map[string]a
|
||||
}
|
||||
return df
|
||||
}
|
||||
|
||||
// NotifySlack posts a one-off message to the org's connected Slack workspace,
|
||||
// on the default channel chosen at connect time. Used by the notification
|
||||
// system's Slack delivery channel (distinct from event-subscription actions).
|
||||
// Best-effort: returns nil when no healthy Slack connection exists.
|
||||
func (s *service) NotifySlack(ctx context.Context, orgID uuid.UUID, title, body string) error {
|
||||
conns, err := s.repo.ListConnections(ctx, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range conns {
|
||||
if c.Provider != models.IntegrationSlack || c.Status != models.IntegrationStatusConnected {
|
||||
continue
|
||||
}
|
||||
channel := configString(c.DisplayFields, "channel")
|
||||
if channel == "" {
|
||||
continue
|
||||
}
|
||||
sec, serr := s.repo.GetConnectionSecrets(ctx, c.ID)
|
||||
if serr != nil {
|
||||
continue
|
||||
}
|
||||
token, terr := s.accessTokenFor(ctx, sec)
|
||||
if terr != nil {
|
||||
continue
|
||||
}
|
||||
return slackPostMessage(ctx, token, channel, eventMessage{Title: title, Detail: body})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -16,6 +19,24 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
// EmailSender delivers a notification to a user's account email. Satisfied by
|
||||
// notify.EmailNotificationService.
|
||||
type EmailSender interface {
|
||||
Send(ctx context.Context, to, cc, bcc []string, subject, message string) error
|
||||
}
|
||||
|
||||
// SlackNotifier posts to the org's connected Slack. Satisfied by the
|
||||
// integration service (NotifySlack).
|
||||
type SlackNotifier interface {
|
||||
NotifySlack(ctx context.Context, orgID uuid.UUID, title, body string) error
|
||||
}
|
||||
|
||||
// UserLookup resolves a user's email + name for email delivery. Satisfied by
|
||||
// the user repository.
|
||||
type UserLookup interface {
|
||||
GetUser(ctx context.Context, id uuid.UUID) (*models.User, error)
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
GetPreferences(ctx context.Context, userID uuid.UUID) (*models.NotificationPreferences, *errx.Error)
|
||||
UpdatePreferences(ctx context.Context, userID uuid.UUID, prefs *models.NotificationPreferences) *errx.Error
|
||||
@@ -26,11 +47,25 @@ type Service interface {
|
||||
|
||||
// Notify is the gated ingress — best-effort, never errors out the caller.
|
||||
Notify(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any)
|
||||
|
||||
// WireDelivery attaches the email + Slack + user-lookup dependencies for
|
||||
// the email/Slack channels (wired post-construction in both mains). Any
|
||||
// may be nil — the matching channel is then skipped.
|
||||
WireDelivery(email EmailSender, slack SlackNotifier, users UserLookup)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
repo repository.NotificationRepository
|
||||
publisher *pubsub.StreamingPublisher
|
||||
email EmailSender
|
||||
slack SlackNotifier
|
||||
users UserLookup
|
||||
}
|
||||
|
||||
func (s *service) WireDelivery(email EmailSender, slack SlackNotifier, users UserLookup) {
|
||||
s.email = email
|
||||
s.slack = slack
|
||||
s.users = users
|
||||
}
|
||||
|
||||
func NewService(repo repository.NotificationRepository, publisher *pubsub.StreamingPublisher) Service {
|
||||
@@ -93,22 +128,65 @@ func (s *service) Notify(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID
|
||||
return
|
||||
}
|
||||
cat := prefs.CategoryPref(category)
|
||||
if !cat.Enabled || !cat.Channels.InApp {
|
||||
return // the gate
|
||||
if !cat.Enabled {
|
||||
return // category off — no channel fires
|
||||
}
|
||||
created, cerr := s.repo.Create(ctx, &models.Notification{
|
||||
UserID: userID,
|
||||
OrganizationID: orgID,
|
||||
Category: category,
|
||||
Title: title,
|
||||
Body: body,
|
||||
Link: link,
|
||||
Metadata: meta,
|
||||
})
|
||||
if cerr != nil || created == nil {
|
||||
return
|
||||
|
||||
// In-app: persist the feed row + push the realtime event.
|
||||
if cat.Channels.InApp {
|
||||
created, cerr := s.repo.Create(ctx, &models.Notification{
|
||||
UserID: userID,
|
||||
OrganizationID: orgID,
|
||||
Category: category,
|
||||
Title: title,
|
||||
Body: body,
|
||||
Link: link,
|
||||
Metadata: meta,
|
||||
})
|
||||
if cerr == nil && created != nil && s.publisher != nil {
|
||||
s.publisher.PublishNotificationCreated(ctx, userID.String(), created.ID.String(), string(category), title, link)
|
||||
}
|
||||
}
|
||||
if s.publisher != nil {
|
||||
s.publisher.PublishNotificationCreated(ctx, userID.String(), created.ID.String(), string(category), title, link)
|
||||
|
||||
// Email: deliver to the user's account email (detached, best-effort).
|
||||
if cat.Channels.Email && s.email != nil && s.users != nil {
|
||||
go s.deliverEmail(userID, category, title, body, link)
|
||||
}
|
||||
|
||||
// Slack: post to the org's connected workspace (detached, best-effort).
|
||||
if cat.Channels.Slack && s.slack != nil && orgID != nil {
|
||||
org := *orgID
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
_ = s.slack.NotifySlack(ctx, org, title, body)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// deliverEmail renders a minimal HTML notification and emails it to the user.
|
||||
func (s *service) deliverEmail(userID uuid.UUID, category models.NotificationCategory, title, body, link string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
user, err := s.users.GetUser(ctx, userID)
|
||||
if err != nil || user == nil || user.Email == "" {
|
||||
return
|
||||
}
|
||||
href := link
|
||||
if href != "" && len(href) > 0 && href[0] == '/' {
|
||||
href = "https://app.warmbly.com" + href
|
||||
}
|
||||
cta := ""
|
||||
if href != "" {
|
||||
cta = fmt.Sprintf(`<p><a href="%s" style="display:inline-block;padding:10px 20px;background:#0284c7;color:white;text-decoration:none;border-radius:6px;">Open in Warmbly</a></p>`, href)
|
||||
}
|
||||
html := fmt.Sprintf(`<h2 style="margin:0 0 8px;">%s</h2><p style="color:#475569;">%s</p>%s<p style="color:#94a3b8;font-size:12px;margin-top:24px;">You're receiving this because email notifications are on for %s. Manage them in Settings → Notifications.</p>`,
|
||||
htmlEscape(title), htmlEscape(body), cta, htmlEscape(string(category)))
|
||||
_ = s.email.Send(ctx, []string{user.Email}, nil, nil, title, html)
|
||||
}
|
||||
|
||||
func htmlEscape(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
@@ -20,10 +20,11 @@ const (
|
||||
)
|
||||
|
||||
// ChannelPrefs is the per-category delivery toggles. Only InApp is delivered
|
||||
// today; Email/Slack are modeled for forward-compat and rendered "coming soon".
|
||||
// today across in-app, email, and a connected Slack workspace.
|
||||
type ChannelPrefs struct {
|
||||
InApp bool `json:"in_app"`
|
||||
Email bool `json:"email"` // reserved; not enforced yet
|
||||
Email bool `json:"email"`
|
||||
Slack bool `json:"slack"`
|
||||
}
|
||||
|
||||
// CategoryPref is the enable flag + channel toggles for one category.
|
||||
|
||||
@@ -35,6 +35,26 @@ export default function NotificationsSettingsPage() {
|
||||
const setEnabled = (key: NotificationCategoryKey, on: boolean) =>
|
||||
setDraft((d) => (d ? { ...d, [key]: { ...d[key], enabled: on } } : d));
|
||||
|
||||
const CATEGORY_KEYS: NotificationCategoryKey[] = [
|
||||
"inbound_reply",
|
||||
"inbound_out_of_office",
|
||||
"health_bounce",
|
||||
"health_complaint",
|
||||
"health_worker_downtime",
|
||||
];
|
||||
// Channels present globally: "on" when every category carries the channel.
|
||||
const channelOn = (ch: "email" | "slack") =>
|
||||
!!draft && CATEGORY_KEYS.every((k) => draft[k].channels[ch]);
|
||||
const setChannel = (ch: "email" | "slack", on: boolean) =>
|
||||
setDraft((d) => {
|
||||
if (!d) return d;
|
||||
const next = { ...d };
|
||||
for (const k of CATEGORY_KEYS) {
|
||||
next[k] = { ...d[k], channels: { ...d[k].channels, [ch]: on } };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
if (!draft || !dirty || update.isPending) return;
|
||||
try {
|
||||
@@ -55,7 +75,7 @@ export default function NotificationsSettingsPage() {
|
||||
return (
|
||||
<SectionShell
|
||||
title="Notifications"
|
||||
description="Which events show up in your in-app feed (the bell). Defaults reflect the recommendation."
|
||||
description="Which events notify you, and where they are delivered. Defaults reflect the recommendation."
|
||||
actions={
|
||||
dirty ? (
|
||||
<>
|
||||
@@ -82,15 +102,15 @@ export default function NotificationsSettingsPage() {
|
||||
<Section eyebrow="Health" description="Deliverability + infrastructure alerts. Recommended on.">
|
||||
{rows(HEALTH)}
|
||||
</Section>
|
||||
<Section eyebrow="Channels" description="Where notifications are delivered.">
|
||||
<Section eyebrow="Channels" description="Where enabled notifications are delivered. Applies across every category above.">
|
||||
<Row label="In-app" description="The bell in the dashboard chrome (controlled per category above).">
|
||||
<span className="text-[11px] font-medium text-emerald-600">On</span>
|
||||
</Row>
|
||||
<Row label="Email" description="Delivery to your account email.">
|
||||
<span className="text-[11px] text-slate-400">Coming soon</span>
|
||||
<Toggle on={channelOn("email")} onChange={(v) => setChannel("email", v)} />
|
||||
</Row>
|
||||
<Row label="Slack" description="Connect via the Integrations tab.">
|
||||
<span className="text-[11px] text-slate-400">Coming soon</span>
|
||||
<Row label="Slack" description="Posts to your workspace's connected Slack. Connect it in the Integrations tab first.">
|
||||
<Toggle on={channelOn("slack")} onChange={(v) => setChannel("slack", v)} />
|
||||
</Row>
|
||||
</Section>
|
||||
</>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
export interface ChannelPrefs {
|
||||
in_app: boolean;
|
||||
email: boolean; // reserved; not delivered yet
|
||||
email: boolean;
|
||||
slack: boolean;
|
||||
}
|
||||
|
||||
export interface CategoryPref {
|
||||
|
||||
Reference in New Issue
Block a user