diff --git a/backend/internal/services/auth/auth.go b/backend/internal/services/auth/auth.go index 20e9ad87..6808245f 100644 --- a/backend/internal/services/auth/auth.go +++ b/backend/internal/services/auth/auth.go @@ -152,18 +152,27 @@ func (s *Service) notifyTrialSignup(ctx context.Context, org organization.Organi return 0 } - admins, err := s.storage.ListSuperadmins(ctx) - if err != nil { - fmt.Printf("warning: failed to list superadmins for trial signup notification: %v\n", err) - return 0 + // Recipients default to every active superadmin, unless ORG_CREATE_NOTIFY_ADDR + // overrides the fan-out with a single address (e.g. in preview, so e2e signup + // churn notifies one operator instead of paging every superadmin). + recipients := email.OrgNotifyOverride() + if recipients == nil { + admins, err := s.storage.ListSuperadmins(ctx) + if err != nil { + fmt.Printf("warning: failed to list superadmins for trial signup notification: %v\n", err) + return 0 + } + for _, admin := range admins { + recipients = append(recipients, admin.Email) + } } sent := 0 - for _, admin := range admins { + for _, addr := range recipients { if err := s.emailClient.SendTrialSignupNotification( - admin.Email, org.Name, org.Identifier, signupEmail, org.SubscriptionExpiresAt, + addr, org.Name, org.Identifier, signupEmail, org.SubscriptionExpiresAt, ); err != nil { - fmt.Printf("warning: failed to send trial signup notification to %s: %v\n", admin.Email, err) + fmt.Printf("warning: failed to send trial signup notification to %s: %v\n", addr, err) continue } sent++ diff --git a/backend/internal/services/auth/signup_notify_integration_test.go b/backend/internal/services/auth/signup_notify_integration_test.go index c970ea55..619b7d35 100644 --- a/backend/internal/services/auth/signup_notify_integration_test.go +++ b/backend/internal/services/auth/signup_notify_integration_test.go @@ -49,6 +49,31 @@ func TestNotifyTrialSignup_NotifiesAllSuperadmins(t *testing.T) { require.Equal(t, 2, sent, "should notify exactly the two superadmins") } +// ORG_CREATE_NOTIFY_ADDR overrides the superadmin fan-out with a single address, +// so preview self-service signup churn notifies one operator, not every superadmin. +func TestNotifyTrialSignup_OverrideAddr(t *testing.T) { + t.Setenv("RESEND_API_KEY", "dummy-never-used") + t.Setenv("ORG_CREATE_NOTIFY_ADDR", "solo-ops@example.com") + store, cleanup := testutil.SetupTestDB(t) + defer cleanup() + pool := store.Pool().(*pgxpool.Pool) + ctx := context.Background() + + for _, e := range []string{"ops1@example.com", "ops2@example.com"} { + _, err := pool.Exec(ctx, + `INSERT INTO trakrf.users (name, email, password_hash, is_superadmin) + VALUES ($1, $1, 'stub', true)`, e) + require.NoError(t, err) + } + + svc := NewService(pool, store, email.NewClient()) + expires := time.Now().Add(720 * time.Hour) + org := organization.Organization{Name: "Acme Co", Identifier: "acme-co", SubscriptionExpiresAt: &expires} + + sent := svc.notifyTrialSignup(ctx, org, "newuser@example.com") + require.Equal(t, 1, sent, "override sends to exactly one address, not the two superadmins") +} + // A nil emailClient (as wired in many tests) must be a no-op, never a panic. func TestNotifyTrialSignup_NilClientIsNoOp(t *testing.T) { store, cleanup := testutil.SetupTestDB(t) diff --git a/backend/internal/services/email/resend.go b/backend/internal/services/email/resend.go index 2b974655..06e3f2da 100644 --- a/backend/internal/services/email/resend.go +++ b/backend/internal/services/email/resend.go @@ -70,6 +70,19 @@ func getEmailPrefix() string { return fmt.Sprintf("[TrakRF %s]", caser.String(env)) } +// OrgNotifyOverride returns a single-recipient override list for org-lifecycle +// superadmin notifications (self-service signup, internal create, delete) when +// ORG_CREATE_NOTIFY_ADDR is set, or nil to keep the default all-superadmins +// fan-out. It exists so a non-prod environment (e.g. preview) can point the +// e2e-churn notification firehose at a single operator instead of paging every +// superadmin. Empty/whitespace is treated as unset. +func OrgNotifyOverride() []string { + if addr := strings.TrimSpace(os.Getenv("ORG_CREATE_NOTIFY_ADDR")); addr != "" { + return []string{addr} + } + return nil +} + // getEnvironmentNotice returns an HTML notice for non-production environments. // Returns empty string for production/empty. func getEnvironmentNotice() string { diff --git a/backend/internal/services/email/resend_test.go b/backend/internal/services/email/resend_test.go index fb4ffbd3..4f25e123 100644 --- a/backend/internal/services/email/resend_test.go +++ b/backend/internal/services/email/resend_test.go @@ -7,6 +7,38 @@ import ( "time" ) +func TestOrgNotifyOverride(t *testing.T) { + tests := []struct { + name string + addr string + want []string + }{ + {"unset", "", nil}, + {"whitespace only", " ", nil}, + {"single address", "ops@trakrf.id", []string{"ops@trakrf.id"}}, + {"trimmed", " ops@trakrf.id ", []string{"ops@trakrf.id"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.addr == "" { + os.Unsetenv("ORG_CREATE_NOTIFY_ADDR") + } else { + t.Setenv("ORG_CREATE_NOTIFY_ADDR", tt.addr) + } + got := OrgNotifyOverride() + if len(got) != len(tt.want) { + t.Fatalf("OrgNotifyOverride() = %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("OrgNotifyOverride()[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + func TestGetEmailPrefix(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/services/orgs/notify_integration_test.go b/backend/internal/services/orgs/notify_integration_test.go index 7f50f087..fcc1e2f1 100644 --- a/backend/internal/services/orgs/notify_integration_test.go +++ b/backend/internal/services/orgs/notify_integration_test.go @@ -61,6 +61,38 @@ func TestNotifyOrgDeleted_NotifiesAllSuperadmins(t *testing.T) { require.Equal(t, 2, sent, "should notify exactly the two superadmins") } +// ORG_CREATE_NOTIFY_ADDR overrides the superadmin fan-out with a single address +// (preview e2e churn → one operator). Reserved test-domain keeps the send stubbed. +func TestNotifyOrgCreated_OverrideAddr(t *testing.T) { + t.Setenv("RESEND_API_KEY", "dummy-never-used") + t.Setenv("ORG_CREATE_NOTIFY_ADDR", "solo-ops@example.com") + store, cleanup := testutil.SetupTestDB(t) + defer cleanup() + pool := store.Pool().(*pgxpool.Pool) + seedSuperadmins(t, pool, "ops1@example.com", "ops2@example.com") + + svc := NewService(pool, store, email.NewClient()) + org := organization.Organization{Name: "Acme Co", Identifier: "acme-co"} + + sent := svc.notifyOrgCreated(context.Background(), org, "creator@example.com") + require.Equal(t, 1, sent, "override sends to exactly one address, not the two superadmins") +} + +// The override also governs the delete (churn) notification. +func TestNotifyOrgDeleted_OverrideAddr(t *testing.T) { + t.Setenv("RESEND_API_KEY", "dummy-never-used") + t.Setenv("ORG_CREATE_NOTIFY_ADDR", "solo-ops@example.com") + store, cleanup := testutil.SetupTestDB(t) + defer cleanup() + pool := store.Pool().(*pgxpool.Pool) + seedSuperadmins(t, pool, "ops1@example.com", "ops2@example.com") + + svc := NewService(pool, store, email.NewClient()) + + sent := svc.notifyOrgDeleted(context.Background(), "Acme Co", "acme-co", "actor@example.com", time.Now()) + require.Equal(t, 1, sent, "override sends to exactly one address, not the two superadmins") +} + // A nil emailClient must be a no-op, never a panic. func TestNotifyOrg_NilClientIsNoOp(t *testing.T) { store, cleanup := testutil.SetupTestDB(t) diff --git a/backend/internal/services/orgs/service.go b/backend/internal/services/orgs/service.go index 7409bf0f..4e847b3b 100644 --- a/backend/internal/services/orgs/service.go +++ b/backend/internal/services/orgs/service.go @@ -110,25 +110,34 @@ func (s *Service) DeleteOrgWithConfirmation(ctx context.Context, orgID int, conf return nil } -// notifySuperadmins lists every active superadmin and invokes send for each. -// Best-effort: a lookup failure is logged not returned, and a send failure to -// one superadmin does not stop the others. Returns the number successfully -// notified (used by tests). Shared by the org create/delete notifications. +// notifySuperadmins resolves the notification recipients and invokes send for +// each. Recipients default to every active superadmin, unless ORG_CREATE_NOTIFY_ADDR +// overrides the fan-out with a single address (see email.OrgNotifyOverride) — in +// which case the superadmin lookup is skipped entirely. Best-effort: a lookup +// failure is logged not returned, and a send failure to one recipient does not +// stop the others. Returns the number successfully notified (used by tests). +// Shared by the org create/delete notifications. func (s *Service) notifySuperadmins(ctx context.Context, send func(adminEmail string) error) int { if s.emailClient == nil { return 0 } - admins, err := s.storage.ListSuperadmins(ctx) - if err != nil { - fmt.Printf("warning: failed to list superadmins for org notification: %v\n", err) - return 0 + recipients := email.OrgNotifyOverride() + if recipients == nil { + admins, err := s.storage.ListSuperadmins(ctx) + if err != nil { + fmt.Printf("warning: failed to list superadmins for org notification: %v\n", err) + return 0 + } + for _, admin := range admins { + recipients = append(recipients, admin.Email) + } } sent := 0 - for _, admin := range admins { - if err := send(admin.Email); err != nil { - fmt.Printf("warning: failed to send org notification to %s: %v\n", admin.Email, err) + for _, addr := range recipients { + if err := send(addr); err != nil { + fmt.Printf("warning: failed to send org notification to %s: %v\n", addr, err) continue } sent++