package notify import ( "context" "fmt" "log/slog" "time" "github.com/orchard9/rdev/internal/domain" ) // Provisioner implements port.NotifyProvisioner using the notify admin API. // Each project gets an isolated notify account and send key scoped to the // shared sending host (e.g., "threesix.ai"). type Provisioner struct { client *adminClient host string // shared sending host slug (e.g., "threesix.ai") from string // from-address (e.g., "noreply@threesix.ai") logger *slog.Logger } // Config holds configuration for the notify provisioner. type Config struct { BaseURL string // Required: notify service URL (e.g., "https://notify.orchard9.ai") AdminKey string // Required: admin API key (notify_admin_...) Host string // Shared host slug for all projects (e.g., "threesix.ai") From string // Default from-address (e.g., "noreply@threesix.ai") } // NewProvisioner creates a new notify provisioner. func NewProvisioner(cfg Config, logger *slog.Logger) *Provisioner { host := cfg.Host if host == "" { host = "threesix.ai" } from := cfg.From if from == "" { from = "noreply@threesix.ai" } return &Provisioner{ client: newAdminClient(cfg.BaseURL, cfg.AdminKey), host: host, from: from, logger: logger, } } // CreateProjectNotify provisions a notify account and send key for the project. // Steps: // 1. Create account named "project-{projectID}" // 2. Create send API key via POST /admin/api-keys // 3. Grant account access to the shared host func (p *Provisioner) CreateProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error) { accountName := "project-" + projectID // 1. Create account acct, err := p.client.createAccount(ctx, accountName) if err != nil { return nil, fmt.Errorf("notify: create account for project %s: %w", projectID, err) } // 2. Create send key (plaintext key only returned here) key, err := p.client.createSendKey(ctx, acct.ID, accountName+"-send") if err != nil { // Best-effort cleanup if delErr := p.client.deleteAccount(ctx, acct.ID); delErr != nil { p.logger.Warn("failed to clean up notify account after key creation failure", "account_id", acct.ID, "project_id", projectID, "error", delErr, ) } return nil, fmt.Errorf("notify: create send key for project %s: %w", projectID, err) } // 3. Grant host access if err := p.client.grantHostAccess(ctx, p.host, acct.ID); err != nil { p.logger.Warn("failed to grant notify host access", "host", p.host, "account_id", acct.ID, "project_id", projectID, "error", err, ) } return &domain.NotifyCredentials{ ProjectID: projectID, AccountID: acct.ID, APIKey: key.Key, Host: p.host, From: p.from, CreatedAt: time.Now(), }, nil } // DeleteProjectNotify removes the notify account for the project. func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID string) error { acct, err := p.findAccountByProject(ctx, projectID) if err != nil { return fmt.Errorf("notify: find account for project %s: %w", projectID, err) } if acct == nil { return nil // Already deleted or never provisioned } if err := p.client.deleteAccount(ctx, acct.ID); err != nil { return fmt.Errorf("notify: delete account %s for project %s: %w", acct.ID, projectID, err) } return nil } // GetProjectNotify returns notify credentials for the project, or nil if not provisioned. // Note: APIKey cannot be retrieved after creation — returns empty string. func (p *Provisioner) GetProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error) { acct, err := p.findAccountByProject(ctx, projectID) if err != nil { return nil, fmt.Errorf("notify: find account for project %s: %w", projectID, err) } if acct == nil { return nil, nil } return &domain.NotifyCredentials{ ProjectID: projectID, AccountID: acct.ID, Host: p.host, From: p.from, CreatedAt: acct.CreatedAt, }, nil } // TestConnection verifies the notify admin API is reachable. func (p *Provisioner) TestConnection(ctx context.Context) error { _, err := p.client.listAccounts(ctx) if err != nil { return fmt.Errorf("notify admin API unreachable: %w", err) } return nil } // findAccountByProject looks up the account named "project-{projectID}". func (p *Provisioner) findAccountByProject(ctx context.Context, projectID string) (*accountResponse, error) { accounts, err := p.client.listAccounts(ctx) if err != nil { return nil, err } targetName := "project-" + projectID for i := range accounts { if accounts[i].Name == targetName { return &accounts[i], nil } } return nil, nil }