diff --git a/internal/app/placement/monitor.go b/internal/app/placement/monitor.go index e469f71eb..b83ac77f9 100644 --- a/internal/app/placement/monitor.go +++ b/internal/app/placement/monitor.go @@ -49,7 +49,7 @@ func (s *service) GetMonitor(ctx context.Context, orgID, campaignID uuid.UUID) ( } // PutMonitor creates or updates a campaign's monitor. A new or re-enabled -// monitor runs its first test within the hour, not an interval from now. +// monitor runs its first test within minutes, not an interval from now. func (s *service) PutMonitor(ctx context.Context, orgID, userID, campaignID uuid.UUID, in MonitorInput) (*models.PlacementMonitor, *errx.Error) { if _, xerr := s.ownedCampaign(ctx, orgID, campaignID); xerr != nil { return nil, xerr @@ -59,10 +59,15 @@ func (s *service) PutMonitor(ctx context.Context, orgID, userID, campaignID uuid errs.CaptureException(err) return nil, errx.InternalError() } + // An API key has no user behind it. + var createdBy *uuid.UUID + if userID != uuid.Nil { + createdBy = &userID + } m := models.PlacementMonitor{ OrganizationID: orgID, CampaignID: campaignID, - CreatedBy: &userID, + CreatedBy: createdBy, Enabled: true, IntervalDays: config.PlacementMonitorIntervalDaysDef, Panel: models.PlacementPanelInstance, diff --git a/internal/app/placement/service.go b/internal/app/placement/service.go index c328d6707..22bb656d4 100644 --- a/internal/app/placement/service.go +++ b/internal/app/placement/service.go @@ -338,6 +338,17 @@ func (s *service) CreateTests(ctx context.Context, in CreateInput) ([]TestView, if s.Cloud == nil { return nil, placementErr(errx.Conflict, "placement_panel_unavailable", "Link this instance to Warmbly Cloud to test on its seed panel.") } + // The cloud charges its allowance when it opens the test, so what can + // be refused here is refused before that. + if panel, xerr := s.Cloud.PlacementPanel(ctx); xerr == nil && panel != nil { + if lim := panel.Usage.Limit; lim != nil && panel.Usage.Used+len(variants) > *lim { + return nil, placementErr(errx.PaymentRequired, "placement_quota_exceeded", + "The linked Warmbly Cloud workspace has used its placement tests for the month.") + } + if xerr := s.checkBudget(ctx, sender, len(variants)*min(panel.Panel.Seeds, pol.SeedsPerTest)); xerr != nil { + return nil, xerr + } + } start, xerr := s.Cloud.StartPlacement(ctx, models.PlacementCloudStartRequest{SenderDomain: senderDomain, Tests: len(variants)}) if xerr != nil { return nil, xerr @@ -356,18 +367,8 @@ func (s *service) CreateTests(ctx context.Context, in CreateInput) ([]TestView, } // Budget: every probe is a send from the sender's day. - probes := len(seeds) * len(variants) - if s.Tasks != nil { - sent, err := s.Tasks.CountCampaignEmailsSentToday(ctx, sender.ID) - if err != nil { - errs.CaptureException(err) - return nil, errx.InternalError() - } - if left := sender.CampaignLimit - sent; probes > left { - return nil, placementErr(errx.Conflict, "placement_daily_budget", - "This test sends "+strconv.Itoa(probes)+" emails from "+sender.Email+", which has "+strconv.Itoa(max(0, left))+ - " left of its daily limit today.") - } + if xerr := s.checkBudget(ctx, sender, len(seeds)*len(variants)); xerr != nil { + return nil, xerr } // Build the tests, their probes and one task per probe. @@ -451,6 +452,25 @@ func (s *service) CreateTests(ctx context.Context, in CreateInput) ([]TestView, return views, nil } +// checkBudget refuses a test whose copies do not fit in what is left of the +// sender's daily campaign limit. +func (s *service) checkBudget(ctx context.Context, sender *models.Email, probes int) *errx.Error { + if s.Tasks == nil { + return nil + } + sent, err := s.Tasks.CountCampaignEmailsSentToday(ctx, sender.ID) + if err != nil { + errs.CaptureException(err) + return errx.InternalError() + } + if left := sender.CampaignLimit - sent; probes > left { + return placementErr(errx.Conflict, "placement_daily_budget", + "This test sends "+strconv.Itoa(probes)+" emails from "+sender.Email+", which has "+strconv.Itoa(max(0, left))+ + " left of its daily limit today.") + } + return nil +} + // enqueue hands each probe's task to the scheduler. The local scheduler picks // due pending rows on its own; Cloud Tasks needs the handle stored. func (s *service) enqueue(ctx context.Context, tasks []repository.Task, results []models.PlacementResult) {