Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions backend/internal/services/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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++
Expand Down
25 changes: 25 additions & 0 deletions backend/internal/services/auth/signup_notify_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions backend/internal/services/email/resend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions backend/internal/services/email/resend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions backend/internal/services/orgs/notify_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 20 additions & 11 deletions backend/internal/services/orgs/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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++
Expand Down
Loading