diff --git a/backend/model/campaign.go b/backend/model/campaign.go index cdebd35..bb837b1 100644 --- a/backend/model/campaign.go +++ b/backend/model/campaign.go @@ -31,6 +31,10 @@ type Campaign struct { ConstraintStartTime nullable.Nullable[vo.CampaignTimeConstraint] `json:"constraintStartTime"` ConstraintEndTime nullable.Nullable[vo.CampaignTimeConstraint] `json:"constraintEndTime"` + // jitter is used only during scheduling, not persisted to database + JitterMin nullable.Nullable[int] `json:"jitterMin,omitempty"` + JitterMax nullable.Nullable[int] `json:"jitterMax,omitempty"` + Name nullable.Nullable[vo.String64] `json:"name"` SaveSubmittedData nullable.Nullable[bool] `json:"saveSubmittedData"` @@ -166,6 +170,39 @@ func (c *Campaign) Validate() error { } } } + // validate jitter values if specified + // negative values are allowed for asymmetric jitter (e.g., -10 to 20 means send 10 min early to 20 min late) + if c.JitterMin.IsSpecified() && !c.JitterMin.IsNull() && c.JitterMax.IsSpecified() && !c.JitterMax.IsNull() { + jitterMin := c.JitterMin.MustGet() + jitterMax := c.JitterMax.MustGet() + if jitterMax < jitterMin { + return validate.WrapErrorWithField(errors.New("jitter max must be greater than or equal to jitter min"), "jitterMax") + } + // validate jitter doesn't exceed campaign duration + if c.SendStartAt.IsSpecified() && !c.SendStartAt.IsNull() && c.SendEndAt.IsSpecified() && !c.SendEndAt.IsNull() { + startAt := c.SendStartAt.MustGet() + endAt := c.SendEndAt.MustGet() + campaignDuration := endAt.Sub(startAt) + + // check if max jitter (positive) could push beyond end time + maxJitterDuration := time.Duration(jitterMax) * time.Minute + if maxJitterDuration > campaignDuration { + return validate.WrapErrorWithField( + errors.New("jitter max cannot exceed campaign duration"), + "jitterMax", + ) + } + + // check if min jitter (negative) could push before start time + minJitterDuration := time.Duration(jitterMin) * time.Minute + if minJitterDuration < -campaignDuration { + return validate.WrapErrorWithField( + errors.New("jitter min cannot exceed campaign duration"), + "jitterMin", + ) + } + } + } // must not be set from api consumers if c.NotableEventID.IsSpecified() && !c.NotableEventID.IsNull() { c.NotableEventID.SetNull() diff --git a/backend/service/campaign.go b/backend/service/campaign.go index ff24fb9..56134ae 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -167,6 +167,9 @@ func (c *Campaign) Create( c.Logger.Errorw("failed to get campaign by id", "error", err) return nil, errs.Wrap(err) } + // preserve jitter values from original campaign (not persisted to db) + createdCampaign.JitterMin = campaign.JitterMin + createdCampaign.JitterMax = campaign.JitterMax err = c.schedule(ctx, session, createdCampaign) if err != nil { c.Logger.Errorw("failed to schedule campaign", "error", err) @@ -180,6 +183,49 @@ func (c *Campaign) Create( // schedule campaign schedules the campaign // this is a service method that does not perform auth, use with consideration +// applyJitter applies random jitter to a time based on jitter min/max in minutes +// if min is negative of max (symmetric), randomly applies as positive or negative +// e.g., min=-10, max=10 means randomly offset by 0-10 minutes, then randomly early or late +// clamps the result to stay within startBound and endBound +func applyJitter(baseTime time.Time, jitterMin, jitterMax int, startBound, endBound time.Time) time.Time { + if jitterMin == 0 && jitterMax == 0 { + return baseTime + } + + var randomJitter int + + // check if symmetric jitter (min = -max) + if jitterMin == -jitterMax { + // symmetric: pick random magnitude, then randomly apply as positive or negative + magnitude := rand.Intn(jitterMax + 1) + if rand.Intn(2) == 0 { + randomJitter = -magnitude + } else { + randomJitter = magnitude + } + } else { + // asymmetric: generate random jitter between min and max + jitterRange := jitterMax - jitterMin + if jitterRange > 0 { + randomJitter = jitterMin + rand.Intn(jitterRange+1) + } else { + randomJitter = jitterMin + } + } + + jitteredTime := baseTime.Add(time.Duration(randomJitter) * time.Minute) + + // clamp to campaign bounds + if jitteredTime.Before(startBound) { + return startBound + } + if jitteredTime.After(endBound) { + return endBound + } + + return jitteredTime +} + func (c *Campaign) schedule( ctx context.Context, session *model.Session, @@ -329,10 +375,27 @@ func (c *Campaign) schedule( return fmt.Errorf("no recipients to schedule for '%s'", campaign.Name.MustGet()) } scheduledEvent := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SCHEDULED] + + // get jitter values if specified + jitterMin := 0 + jitterMax := 0 + if campaign.JitterMin.IsSpecified() && !campaign.JitterMin.IsNull() { + jitterMin = campaign.JitterMin.MustGet() + } + if campaign.JitterMax.IsSpecified() && !campaign.JitterMax.IsNull() { + jitterMax = campaign.JitterMax.MustGet() + } + c.Logger.Debugw("scheduling campaign with jitter", + "campaignName", campaign.Name.MustGet(), + "jitterMin", jitterMin, + "jitterMax", jitterMax, + ) + // handle single recipient if recipientsCount == 1 { recpID := nullable.NewNullableWithValue(recipients[0].ID.MustGet()) campaignID := nullable.NewNullableWithValue(campaign.ID.MustGet()) + startAt = applyJitter(startAt, jitterMin, jitterMax, startAt, endAt) campaignRecipient := &model.CampaignRecipient{ RecipientID: recpID, CampaignID: campaignID, @@ -406,6 +469,8 @@ func (c *Campaign) schedule( // get the next recipient recipient := recipients[0] recipients = recipients[1:] + // apply jitter to send time + currentDayStart = applyJitter(currentDayStart, jitterMin, jitterMax, startAt, endAt) // save campaignRecipient := &model.CampaignRecipient{ RecipientID: recipient.ID, @@ -451,6 +516,8 @@ func (c *Campaign) schedule( sa := campaignRecipients[i-1].SendAt.MustGet().Add(interval * time.Duration(1)) sentAt = sa } + // apply jitter to send time + sentAt = applyJitter(sentAt, jitterMin, jitterMax, startAt, endAt) // todo perhaps this array is unnecesssary //recpID := recipient.ID.MustGet() //campaignID := campaign.ID.MustGet() @@ -1411,6 +1478,9 @@ func (c *Campaign) UpdateByID( c.Logger.Errorw("failed to add recipient groups", "error", err) return errs.Wrap(err) } + // preserve jitter values from incoming campaign (not persisted to db) + current.JitterMin = incoming.JitterMin + current.JitterMax = incoming.JitterMax err = c.schedule(ctx, session, current) if err != nil { c.Logger.Errorw("failed to re-schedule campaign", "error", err) diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index f859ef4..7b9d384 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -516,6 +516,8 @@ export class API { * @param {Array} [campaign.constraintWeekDays] * @param {string} [campaign.constraintStartTime] * @param {string} [campaign.constraintEndTime] + * @param {number} [campaign.jitterMin] + * @param {number} [campaign.jitterMax] * @returns {Promise} */ create: async ({ @@ -541,7 +543,9 @@ export class API { webhookIncludeData, constraintWeekDays, constraintStartTime, - constraintEndTime + constraintEndTime, + jitterMin, + jitterMax }) => { return await postJSON(this.getPath('/campaign'), { companyID, @@ -566,7 +570,9 @@ export class API { webhookIncludeData, constraintWeekDays, constraintStartTime, - constraintEndTime + constraintEndTime, + jitterMin, + jitterMax }); }, @@ -595,6 +601,8 @@ export class API { * @param {Array} [campaign.constraintWeekDays] * @param {string} [campaign.constraintStartTime] * @param {string} [campaign.constraintEndTime] + * @param {number} [campaign.jitterMin] + * @param {number} [campaign.jitterMax] * @returns {Promise} */ update: async ({ @@ -620,7 +628,9 @@ export class API { webhookIncludeData, constraintWeekDays, constraintStartTime, - constraintEndTime + constraintEndTime, + jitterMin, + jitterMax }) => { return await postJSON(this.getPath(`/campaign/${id}`), { templateID, @@ -644,7 +654,9 @@ export class API { webhookIncludeData, constraintWeekDays, constraintStartTime, - constraintEndTime + constraintEndTime, + jitterMin, + jitterMax }); }, diff --git a/frontend/src/lib/components/JitterSlider.svelte b/frontend/src/lib/components/JitterSlider.svelte new file mode 100644 index 0000000..b85746c --- /dev/null +++ b/frontend/src/lib/components/JitterSlider.svelte @@ -0,0 +1,57 @@ + + +
+
+

+ Jitter + + + ({jitterOptions[selectedIndex].label}) + +

+
+ +
+
+
diff --git a/frontend/src/routes/campaign/+page.svelte b/frontend/src/routes/campaign/+page.svelte index 0cf09e2..a36e04a 100644 --- a/frontend/src/routes/campaign/+page.svelte +++ b/frontend/src/routes/campaign/+page.svelte @@ -48,6 +48,7 @@ import BigButton from '$lib/components/BigButton.svelte'; import ToIcon from '$lib/components/ToIcon.svelte'; import Datetime from '$lib/components/Datetime.svelte'; + import JitterSlider from '$lib/components/JitterSlider.svelte'; import RelativeTime from '$lib/components/RelativeTime.svelte'; import AutoRefresh from '$lib/components/AutoRefresh.svelte'; import CheckboxField from '$lib/components/CheckboxField.svelte'; @@ -203,16 +204,30 @@ const SPREAD_MANUAL = 'manual'; const SPREAD_IMMEDIATE = 'immediate'; const SPREAD_1MIN = '1min'; + const SPREAD_2MIN = '2min'; const SPREAD_5MIN = '5min'; + const SPREAD_10MIN = '10min'; const SPREAD_20MIN = '20min'; + const SPREAD_30MIN = '30min'; const SPREAD_1HOUR = '1hour'; + const SPREAD_2HOUR = '2hour'; + const SPREAD_5HOUR = '5hour'; + const SPREAD_12HOUR = '12hour'; + const SPREAD_24HOUR = '24hour'; const spreadOptionMap = new BiMap({ Manual: SPREAD_MANUAL, '1 minute': SPREAD_1MIN, + '2 minutes': SPREAD_2MIN, '5 minutes': SPREAD_5MIN, + '10 minutes': SPREAD_10MIN, '20 minutes': SPREAD_20MIN, - '1 hour': SPREAD_1HOUR + '30 minutes': SPREAD_30MIN, + '1 hour': SPREAD_1HOUR, + '2 hours': SPREAD_2HOUR, + '5 hours': SPREAD_5HOUR, + '12 hours': SPREAD_12HOUR, + '24 hours': SPREAD_24HOUR }); let spreadOption = SPREAD_MANUAL; @@ -223,12 +238,26 @@ return 0; case SPREAD_1MIN: return 60000; + case SPREAD_2MIN: + return 120000; case SPREAD_5MIN: return 300000; + case SPREAD_10MIN: + return 600000; case SPREAD_20MIN: return 1200000; + case SPREAD_30MIN: + return 1800000; case SPREAD_1HOUR: return 3600000; + case SPREAD_2HOUR: + return 7200000; + case SPREAD_5HOUR: + return 18000000; + case SPREAD_12HOUR: + return 43200000; + case SPREAD_24HOUR: + return 86400000; default: return null; } @@ -260,7 +289,9 @@ obfuscate: false, selectedCount: 0, webhookValue: null, - webhookIncludeData: false + webhookIncludeData: false, + jitterMin: 0, + jitterMax: 0 }; let modalError = ''; @@ -640,7 +671,9 @@ constraintStartTime: contraintStartTimeUTC, constraintEndTime: contraintEndTimeUTC, webhookID: webhookMap.byValueOrNull(formValues.webhookValue), - webhookIncludeData: formValues.webhookIncludeData + webhookIncludeData: formValues.webhookIncludeData, + jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null, + jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null }); if (!res.success) { @@ -704,7 +737,9 @@ denyPageID: denyPageMap.byValueOrNull(formValues.denyPageValue), evasionPageID: denyPageMap.byValueOrNull(formValues.evasionPageValue), webhookID: webhookMap.byValueOrNull(formValues.webhookValue), - webhookIncludeData: formValues.webhookIncludeData + webhookIncludeData: formValues.webhookIncludeData, + jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null, + jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null }); if (!res.success) { @@ -830,7 +865,9 @@ obfuscate: false, selectedCount: 0, webhookValue: null, - webhookIncludeData: false + webhookIncludeData: false, + jitterMin: 0, + jitterMax: 0 }; scheduleType = 'basic'; allowDenyType = 'none'; @@ -1313,10 +1350,10 @@ {#if formValues.sendStartAt} -
+

Distribution Speed @@ -1325,32 +1362,53 @@ {#if spreadOption === SPREAD_MANUAL} Manual timing {:else if spreadOption === SPREAD_1MIN} - 1 minutes apart + 1 minute apart + {:else if spreadOption === SPREAD_2MIN} + 2 minutes apart {:else if spreadOption === SPREAD_5MIN} 5 minutes apart + {:else if spreadOption === SPREAD_10MIN} + 10 minutes apart {:else if spreadOption === SPREAD_20MIN} 20 minutes apart + {:else if spreadOption === SPREAD_30MIN} + 30 minutes apart {:else if spreadOption === SPREAD_1HOUR} 1 hour apart + {:else if spreadOption === SPREAD_2HOUR} + 2 hours apart + {:else if spreadOption === SPREAD_5HOUR} + 5 hours apart + {:else if spreadOption === SPREAD_12HOUR} + 12 hours apart + {:else if spreadOption === SPREAD_24HOUR} + 24 hours apart {/if} )

-
+
{ const index = parseInt(event.currentTarget.value); const speeds = [ SPREAD_MANUAL, SPREAD_1MIN, + SPREAD_2MIN, SPREAD_5MIN, + SPREAD_10MIN, SPREAD_20MIN, - SPREAD_1HOUR + SPREAD_30MIN, + SPREAD_1HOUR, + SPREAD_2HOUR, + SPREAD_5HOUR, + SPREAD_12HOUR, + SPREAD_24HOUR ]; spreadOption = speeds[index]; const milliseconds = getSpreadMilliseconds(spreadOption); @@ -1520,6 +1578,14 @@ options={Array.from(sortOrder.keys())}>Delivery order + + Jitter + + {/if} + + {#if formValues.jitterMin !== 0 || formValues.jitterMax !== 0} + Jitter: + + {formValues.jitterMin} to {formValues.jitterMax} minutes + + {/if} {:else if scheduleType === 'schedule'} Active days: {/if} + + {#if formValues.jitterMin !== 0 || formValues.jitterMax !== 0} + Jitter: + + {formValues.jitterMin} to {formValues.jitterMax} minutes + + {/if} {/if} {#if formValues.closeAt}