feat: record no creator on a placement monitor set by an API key, and refuse a cloud-panel placement test on the linked workspace's allowance or the sender's daily limit before the cloud opens and charges it

This commit is contained in:
Matthew Meszaros
2026-09-25 20:58:30 -07:00
parent d84864a8cb
commit c3e48538d6
2 changed files with 39 additions and 14 deletions
+7 -2
View File
@@ -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,
+32 -12
View File
@@ -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) {