Late scheduling

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-04-02 10:55:50 +02:00
parent 10f201c5ca
commit c4ed8cfeeb
12 changed files with 543 additions and 183 deletions
+5 -4
View File
@@ -51,10 +51,11 @@ var CampaignEventPriority = map[string]int{
data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT: 20,
data.EVENT_CAMPAIGN_RECIPIENT_SCHEDULED: 10,
// campaign events
data.EVENT_CAMPAIGN_CLOSED: 30,
data.EVENT_CAMPAIGN_ACTIVE: 20,
data.EVENT_CAMPAIGN_SELF_MANAGED: 20,
data.EVENT_CAMPAIGN_SCHEDULED: 10,
data.EVENT_CAMPAIGN_CLOSED: 30,
data.EVENT_CAMPAIGN_ACTIVE: 20,
data.EVENT_CAMPAIGN_SELF_MANAGED: 20,
data.EVENT_CAMPAIGN_SCHEDULED: 10,
data.EVENT_CAMPAIGN_PENDING_SCHEDULE: 5,
}
// IsMoreNotableCampaignRecipientEvent returns true if newEvent is more notable than currentEvent
+6 -4
View File
@@ -1,10 +1,11 @@
package data
const (
EVENT_CAMPAIGN_SCHEDULED = "campaign_scheduled"
EVENT_CAMPAIGN_ACTIVE = "campaign_active"
EVENT_CAMPAIGN_SELF_MANAGED = "campaign_self_managed"
EVENT_CAMPAIGN_CLOSED = "campaign_closed"
EVENT_CAMPAIGN_SCHEDULED = "campaign_scheduled"
EVENT_CAMPAIGN_ACTIVE = "campaign_active"
EVENT_CAMPAIGN_SELF_MANAGED = "campaign_self_managed"
EVENT_CAMPAIGN_PENDING_SCHEDULE = "campaign_pending_schedule"
EVENT_CAMPAIGN_CLOSED = "campaign_closed"
EVENT_CAMPAIGN_RECIPIENT_SCHEDULED = "campaign_recipient_scheduled"
EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT = "campaign_recipient_message_sent"
@@ -26,6 +27,7 @@ var Events = []string{
EVENT_CAMPAIGN_SCHEDULED,
EVENT_CAMPAIGN_ACTIVE,
EVENT_CAMPAIGN_SELF_MANAGED,
EVENT_CAMPAIGN_PENDING_SCHEDULE,
EVENT_CAMPAIGN_CLOSED,
// campaign recipient events
EVENT_CAMPAIGN_RECIPIENT_SCHEDULED,
+9
View File
@@ -25,6 +25,15 @@ type Campaign struct {
SortOrder string `gorm:";"` // 'asc,desc,random'
SendStartAt *time.Time `gorm:"index;"`
SendEndAt *time.Time `gorm:"index;"`
// ScheduleAt is set when the campaign uses late-scheduling.
// the task runner will call schedule() when now >= ScheduleAt.
// null means the campaign was scheduled immediately at creation.
ScheduleAt *time.Time `gorm:"index;"`
// JitterMin and JitterMax are persisted only when ScheduleAt is set (late-scheduling).
// They are cleared after the campaign is scheduled by the task runner.
// For immediately-scheduled campaigns these columns remain null.
JitterMin *int `gorm:""`
JitterMax *int `gorm:""`
// ConstraintWeekDays is a binary format.
// 0b00000001 = 1 = sunday
+22 -1
View File
@@ -28,11 +28,14 @@ type Campaign struct {
SortOrder nullable.Nullable[vo.CampaignSendingOrder] `json:"sortOrder"`
SendStartAt nullable.Nullable[time.Time] `json:"sendStartAt"`
SendEndAt nullable.Nullable[time.Time] `json:"sendEndAt"`
ScheduleAt nullable.Nullable[time.Time] `json:"scheduleAt"`
ConstraintWeekDays nullable.Nullable[vo.CampaignWeekDays] `json:"constraintWeekDays"`
ConstraintStartTime nullable.Nullable[vo.CampaignTimeConstraint] `json:"constraintStartTime"`
ConstraintEndTime nullable.Nullable[vo.CampaignTimeConstraint] `json:"constraintEndTime"`
// jitter is used only during scheduling, not persisted to database
// jitter is persisted to the database only when ScheduleAt is set (late-scheduling),
// so the task runner can apply it when schedule() is called hours later.
// For immediately-scheduled campaigns jitter lives in memory only and is never written to the DB.
JitterMin nullable.Nullable[int] `json:"jitterMin,omitempty"`
JitterMax nullable.Nullable[int] `json:"jitterMax,omitempty"`
@@ -410,6 +413,12 @@ func (c *Campaign) ToDBMap() map[string]any {
m["send_end_at"] = utils.RFC3339UTC(v)
}
}
if c.ScheduleAt.IsSpecified() {
m["schedule_at"] = nil
if v, err := c.ScheduleAt.Get(); err == nil {
m["schedule_at"] = utils.RFC3339UTC(v)
}
}
if c.CloseAt.IsSpecified() {
m["close_at"] = nil
if v, err := c.CloseAt.Get(); err == nil {
@@ -504,6 +513,18 @@ func (c *Campaign) ToDBMap() map[string]any {
if v, err := c.NotableEventID.Get(); err == nil {
m["notable_event_id"] = v.String()
}
if c.JitterMin.IsSpecified() {
m["jitter_min"] = nil
if v, err := c.JitterMin.Get(); err == nil {
m["jitter_min"] = v
}
}
if c.JitterMax.IsSpecified() {
m["jitter_max"] = nil
if v, err := c.JitterMax.Get(); err == nil {
m["jitter_max"] = v
}
}
return m
}
+56
View File
@@ -1366,6 +1366,41 @@ func (r *Campaign) GetReadyToAnonymize(
return result, nil
}
// GetReadyToLateSchedule returns campaigns where schedule_at <= now and
// the campaign has not yet been scheduled (notable event is still pending_schedule).
func (r *Campaign) GetReadyToLateSchedule(
ctx context.Context,
options *CampaignOption,
) (*model.Result[model.Campaign], error) {
result := model.NewEmptyResult[model.Campaign]()
db := r.load(r.DB, options)
db, err := useQuery(db, database.CAMPAIGN_TABLE, options.QueryArgs)
if err != nil {
return result, errs.Wrap(err)
}
pendingEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_PENDING_SCHEDULE]
var dbCampaigns []database.Campaign
res := db.
Where("schedule_at <= ? AND notable_event_id = ?", utils.NowRFC3339UTC(), pendingEventID).
Find(&dbCampaigns)
if res.Error != nil {
return result, res.Error
}
hasNextPage, err := useHasNextPage(db, database.CAMPAIGN_TABLE, options.QueryArgs)
if err != nil {
return result, errs.Wrap(err)
}
result.HasNextPage = hasNextPage
for _, dbCampaign := range dbCampaigns {
campaign, err := ToCampaign(&dbCampaign)
if err != nil {
return nil, errs.Wrap(err)
}
result.Rows = append(result.Rows, campaign)
}
return result, nil
}
// SaveEvent saves a campaign event
func (r *Campaign) SaveEvent(
ctx context.Context,
@@ -1793,6 +1828,24 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) {
} else {
sendEndAt.SetNull()
}
var scheduleAt nullable.Nullable[time.Time]
if row.ScheduleAt != nil {
scheduleAt = nullable.NewNullableWithValue(*row.ScheduleAt)
} else {
scheduleAt.SetNull()
}
var jitterMin nullable.Nullable[int]
if row.JitterMin != nil {
jitterMin = nullable.NewNullableWithValue(*row.JitterMin)
} else {
jitterMin.SetNull()
}
var jitterMax nullable.Nullable[int]
if row.JitterMax != nil {
jitterMax = nullable.NewNullableWithValue(*row.JitterMax)
} else {
jitterMax.SetNull()
}
saveSubmittedData := nullable.NewNullableWithValue(row.SaveSubmittedData)
saveBrowserMetadata := nullable.NewNullableWithValue(row.SaveBrowserMetadata)
isAnonymous := nullable.NewNullableWithValue(row.IsAnonymous)
@@ -1927,6 +1980,9 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) {
SortOrder: sortOrder,
SendStartAt: sendStartAt,
SendEndAt: sendEndAt,
ScheduleAt: scheduleAt,
JitterMin: jitterMin,
JitterMax: jitterMax,
ConstraintWeekDays: constraintWeekDays,
ConstraintStartTime: constraintStartTime,
ConstraintEndTime: constraintEndTime,
+177 -15
View File
@@ -118,6 +118,33 @@ func (c *Campaign) Create(
return nil, errs.Wrap(err)
}
}
// late-schedule (indicated by a non-null schedule_at) is not compatible with self-managed campaigns
if campaign.ScheduleAt.IsSpecified() && !campaign.ScheduleAt.IsNull() {
if campaign.IsSelfManaged() {
return nil, validate.WrapErrorWithField(
errors.New("late scheduling is not available for self-managed campaigns"),
"scheduleAt",
)
}
// scheduleAt must be in the future
scheduleAt := campaign.ScheduleAt.MustGet()
if !scheduleAt.After(time.Now().UTC()) {
return nil, validate.WrapErrorWithField(
errors.New("schedule time must be in the future"),
"scheduleAt",
)
}
// send_start_at must be more than 24h after schedule_at
if campaign.SendStartAt.IsSpecified() && !campaign.SendStartAt.IsNull() {
sendStartAt := campaign.SendStartAt.MustGet()
if sendStartAt.Sub(scheduleAt) < 24*time.Hour {
return nil, validate.WrapErrorWithField(
errors.New("send start must be at least 24 hours after the schedule-at time"),
"scheduleAt",
)
}
}
}
// validate
if err := campaign.Validate(); err != nil {
return nil, errs.Wrap(err)
@@ -213,14 +240,26 @@ 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)
// TODO we should delete the campaign as it was not scheduled
return nil, errs.Wrap(err)
if createdCampaign.ScheduleAt.IsSpecified() && !createdCampaign.ScheduleAt.IsNull() {
// Late-scheduling: persist jitter to the DB so the task runner can apply it
// when schedule() is called hours later. The in-memory copy on createdCampaign
// is set too so setMostNotableCampaignEvent's UpdateByID writes the columns.
createdCampaign.JitterMin = campaign.JitterMin
createdCampaign.JitterMax = campaign.JitterMax
err = c.setMostNotableCampaignEvent(ctx, createdCampaign, data.EVENT_CAMPAIGN_PENDING_SCHEDULE)
if err != nil {
return nil, errs.Wrap(err)
}
} else {
// Immediate scheduling: jitter lives in memory only for this call, never written 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)
// TODO we should delete the campaign as it was not scheduled
return nil, errs.Wrap(err)
}
}
ae.Details["id"] = id.String()
c.AuditLogAuthorized(ae)
@@ -1482,6 +1521,15 @@ func (c *Campaign) UpdateByID(
if v, err := incoming.AnonymizedAt.Get(); err == nil {
current.AnonymizedAt.Set(v.Truncate(time.Minute))
}
if v, err := incoming.ScheduleAt.Get(); err == nil {
current.ScheduleAt.Set(v)
} else if incoming.ScheduleAt.IsSpecified() {
// incoming was explicitly null — clear the scheduled time, reverting to immediate scheduling
current.ScheduleAt.SetNull()
// also clear any persisted jitter — it was stored for late-scheduling and is no longer needed
current.JitterMin.SetNull()
current.JitterMax.SetNull()
}
if v, err := incoming.RecipientGroupIDs.Get(); err == nil {
current.RecipientGroupIDs.Set(v)
}
@@ -1565,6 +1613,47 @@ func (c *Campaign) UpdateByID(
current.EvasionPageID.Set(incoming.EvasionPageID.MustGet())
}
}
// late-schedule (indicated by a non-null schedule_at) is not compatible with self-managed campaigns
if current.ScheduleAt.IsSpecified() && !current.ScheduleAt.IsNull() {
if current.IsSelfManaged() {
return validate.WrapErrorWithField(
errors.New("late scheduling is not available for self-managed campaigns"),
"scheduleAt",
)
}
// reject scheduleAt if the campaign has already moved past pending_schedule —
// at that point recipients have been resolved and scheduling is done; there is
// nothing meaningful for a new scheduleAt to do and it would revert the campaign
// back to pending_schedule state unexpectedly.
if currentNotableEventID, err := current.NotableEventID.Get(); err == nil {
if currentEventName, ok := cache.EventNameByID[currentNotableEventID.String()]; ok {
if cache.IsMoreNotableCampaignRecipientEvent(currentEventName, data.EVENT_CAMPAIGN_PENDING_SCHEDULE) {
return validate.WrapErrorWithField(
errors.New("scheduleAt cannot be set on a campaign that has already been scheduled"),
"scheduleAt",
)
}
}
}
// scheduleAt must be in the future
scheduleAt := current.ScheduleAt.MustGet()
if !scheduleAt.After(time.Now().UTC()) {
return validate.WrapErrorWithField(
errors.New("schedule time must be in the future"),
"scheduleAt",
)
}
// send_start_at must be more than 24h after schedule_at
if current.SendStartAt.IsSpecified() && !current.SendStartAt.IsNull() {
sendStartAt := current.SendStartAt.MustGet()
if sendStartAt.Sub(scheduleAt) < 24*time.Hour {
return validate.WrapErrorWithField(
errors.New("send start must be at least 24 hours after the schedule-at time"),
"scheduleAt",
)
}
}
}
// validate and update
if err := current.Validate(); err != nil {
return errs.Wrap(err)
@@ -1638,13 +1727,27 @@ 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)
return errs.Wrap(err)
if current.ScheduleAt.IsSpecified() && !current.ScheduleAt.IsNull() {
// Late-scheduling: persist jitter to the DB so the task runner can apply it
// when schedule() is called hours later. Only overwrite jitter when the incoming
// payload explicitly specifies it — unspecified means "leave existing value alone".
if incoming.JitterMin.IsSpecified() {
current.JitterMin = incoming.JitterMin
current.JitterMax = incoming.JitterMax
}
err = c.setMostNotableCampaignEvent(ctx, current, data.EVENT_CAMPAIGN_PENDING_SCHEDULE)
if err != nil {
return errs.Wrap(err)
}
} else {
// Immediate scheduling: jitter lives in memory only for this call, never written 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)
return errs.Wrap(err)
}
}
c.AuditLogAuthorized(ae)
return nil
@@ -2906,6 +3009,65 @@ func (c *Campaign) HandleAnonymizeCampaigns(
return nil
}
// SchedulePendingCampaigns is called by the task runner. It finds all campaigns
// whose schedule_at time has passed and triggers schedule() for each.
func (c *Campaign) SchedulePendingCampaigns(
ctx context.Context,
session *model.Session,
) error {
ae := NewAuditEvent("Campaign.SchedulePendingCampaigns", session)
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
pending, err := c.CampaignRepository.GetReadyToLateSchedule(
ctx,
&repository.CampaignOption{
WithRecipientGroups: true,
WithAllowDeny: true,
},
)
if err != nil {
c.Logger.Errorw("failed to get campaigns ready for late scheduling", "error", err)
return errs.Wrap(err)
}
for _, campaign := range pending.Rows {
campaignID := campaign.ID.MustGet()
// Clear schedule_at BEFORE calling schedule() so that a concurrent task runner tick
// or a second server instance cannot pick up the same campaign and double-schedule it.
// If schedule() subsequently fails the campaign will remain in pending_schedule state
// with a null schedule_at; an operator can re-set schedule_at to retry.
clearScheduleAt := model.Campaign{}
clearScheduleAt.ScheduleAt.SetNull()
if err := c.CampaignRepository.UpdateByID(ctx, &campaignID, &clearScheduleAt); err != nil {
c.Logger.Errorw("failed to clear schedule_at before late-scheduling, skipping campaign", "campaignID", campaignID, "error", err)
// skip this campaign — better to retry next tick than to risk a double-schedule
continue
}
// Jitter is loaded from the DB columns (persisted at creation/update time).
if err := c.schedule(ctx, session, campaign); err != nil {
c.Logger.Errorw("failed to late-schedule campaign", "campaignID", campaignID, "error", err)
// continue to next — don't abort the whole run
continue
}
// Clear persisted jitter now that scheduling is done — it is no longer needed.
clearJitter := model.Campaign{}
clearJitter.JitterMin.SetNull()
clearJitter.JitterMax.SetNull()
if err := c.CampaignRepository.UpdateByID(ctx, &campaignID, &clearJitter); err != nil {
c.Logger.Errorw("failed to clear jitter after late-scheduling", "campaignID", campaignID, "error", err)
// non-fatal — jitter columns being non-null is harmless after scheduling
}
c.Logger.Infow("late-scheduled campaign", "campaignID", campaignID)
}
return nil
}
// CloseCampaignByID closes a campaign by id
// DeleteDeviceCodesByCampaignID deletes all device codes for a campaign so every recipient
// gets a fresh code (and picks up any proxy change) on their next page visit.
+3
View File
@@ -195,6 +195,9 @@ func (d *Runner) ProcessSystemTasks(
d.runTask("system - prune orphaned recipients", func() error {
return d.PruneOrphanedRecipients(ctx, session)
})
d.runTask("system - late schedule campaigns", func() error {
return d.CampaignService.SchedulePendingCampaigns(ctx, session)
})
}
// PruneOrphanedRecipients prunes orphaned recipients for global scope and all companies
+6
View File
@@ -513,6 +513,7 @@ export class API {
* @param {string} campaign.sendEndAt
* @param {string} [campaign.closeAt]
* @param {string} [campaign.anonymizeAt]
* @param {string} [campaign.scheduleAt]
* @param {string[]} campaign.recipientGroupIDs []uuid
* @param {string[]} campaign.allowDenyIDs []uuid
* @param {string} campaign.denyPageID uuid
@@ -540,6 +541,7 @@ export class API {
sendEndAt,
closeAt,
anonymizeAt,
scheduleAt,
recipientGroupIDs,
allowDenyIDs,
denyPageID,
@@ -566,6 +568,7 @@ export class API {
sendEndAt,
closeAt,
anonymizeAt,
scheduleAt,
recipientGroupIDs,
allowDenyIDs,
denyPageID,
@@ -594,6 +597,7 @@ export class API {
* @param {string} campaign.sendEndAt
* @param {string} [campaign.closeAt]
* @param {string} [campaign.anonymizeAt]
* @param {string} [campaign.scheduleAt]
* @param {string} campaign.templateID uuid
* @param {string[]} campaign.recipientGroupIDs []uuid
* @param {string[]} campaign.allowDenyIDs []uuid
@@ -622,6 +626,7 @@ export class API {
sendEndAt,
closeAt,
anonymizeAt,
scheduleAt,
recipientGroupIDs,
allowDenyIDs,
denyPageID,
@@ -647,6 +652,7 @@ export class API {
sendEndAt,
closeAt,
anonymizeAt,
scheduleAt,
recipientGroupIDs,
allowDenyIDs,
denyPageID,
@@ -9,6 +9,7 @@
export let optional = false;
export let id = null;
export let inline = false;
export let disabled = false;
let parentForm = null;
let parentFormResetListener = null;
@@ -44,6 +45,8 @@
class:flex-col={!inline}
class:flex-row={inline}
class:items-center={inline}
class:opacity-50={disabled}
class:cursor-not-allowed={disabled}
>
<div class="flex items-center">
<p class="font-semibold text-slate-600 dark:text-gray-400 py-2 transition-colors duration-200">
@@ -65,7 +68,11 @@
{/if}
</div>
<div class="mt-1" class:mt-0={inline} class:ml-3={inline}>
<label class="relative flex items-center cursor-pointer">
<label
class="relative flex items-center"
class:cursor-pointer={!disabled}
class:cursor-not-allowed={disabled}
>
<input
{id}
type="checkbox"
@@ -74,6 +81,7 @@
bind:checked={value}
on:change
tabindex="0"
{disabled}
/>
<div
class="w-5 h-5 border-2 border-slate-300 dark:border-gray-700/60 rounded
+1
View File
@@ -50,6 +50,7 @@ const eventNameMap = {
campaign_scheduled: { name: 'Scheduled', priority: 10 },
campaign_active: { name: 'Active', priority: 20 },
campaign_self_managed: { name: 'Self managed', priority: 20 },
campaign_pending_schedule: { name: 'Pending Schedule', priority: 5 },
campaign_closed: { name: 'Closed', priority: 30, color: 'bg-closed' }
};
+87 -9
View File
@@ -252,9 +252,41 @@
let allowDenyType = 'none';
let allAllowDeny = [];
let showSecurityOptions = false;
let lateScheduleEnabled = false;
let showAdvancedOptionsStep3 = false;
let showAdvancedOptionsStep4 = false;
// reactive: true when at least one selected recipient group is dynamic
$: hasDynamicGroup = formValues.recipientGroups.some((label) => {
const id = recipientGroupMap.byValue(label);
return recipientGroupsByID[id]?.isDynamic === true;
});
// reset distribution speed to manual when a dynamic group is selected —
// we don't know the final recipient count so automatic spreading is meaningless
$: if (hasDynamicGroup) {
spreadOption = SPREAD_MANUAL;
}
// reactive statement to keep scheduleAt in sync when sendStartAt changes while late scheduling is enabled.
// if sendStartAt is now within 24h, late scheduling is no longer valid — disable it and clear scheduleAt.
$: if (lateScheduleEnabled) {
if (!formValues.sendStartAt || !lateScheduleAvailable(formValues.sendStartAt)) {
lateScheduleEnabled = false;
formValues.scheduleAt = null;
} else {
formValues.scheduleAt = new Date(
new Date(formValues.sendStartAt).getTime() - 24 * 60 * 60 * 1000
).toISOString();
}
}
// returns true if sendStartAt is more than 24h in the future (late scheduling is meaningful)
const lateScheduleAvailable = (sendStartAt) => {
if (!sendStartAt) return false;
return new Date(sendStartAt).getTime() - Date.now() > 24 * 60 * 60 * 1000;
};
// reactive statement to enable security options when deny page is set
$: if (formValues.denyPageValue && formValues.denyPageValue.trim() !== '') {
showSecurityOptions = true;
@@ -760,6 +792,9 @@
const contraintEndTimeUTC = formValues.contraintEndTime
? localTimeToUTC(formValues.contraintEndTime)
: null;
const scheduleAtUTC = formValues.scheduleAt
? new Date(formValues.scheduleAt).toISOString()
: null;
const res = await api.campaign.create({
name: formValues.name,
@@ -791,7 +826,8 @@
webhookEvents: webhookEventsToBinary(wh.events)
})),
jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null,
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null,
scheduleAt: scheduleAtUTC
});
if (!res.success) {
@@ -831,6 +867,9 @@
const contraintEndTimeUTC = formValues.contraintEndTime
? localTimeToUTC(formValues.contraintEndTime)
: null;
const scheduleAtUTC = formValues.scheduleAt
? new Date(formValues.scheduleAt).toISOString()
: null;
const res = await api.campaign.update({
id: formValues.id,
@@ -862,7 +901,8 @@
webhookEvents: webhookEventsToBinary(wh.events)
})),
jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null,
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null,
scheduleAt: scheduleAtUTC
});
if (!res.success) {
@@ -1029,6 +1069,8 @@
formValues.contraintEndTime = null;
formValues.sendStartAt = null;
formValues.sendEndAt = null;
lateScheduleEnabled = false;
formValues.scheduleAt = null;
};
const onChangeAllowDenyType = () => {
@@ -1106,6 +1148,7 @@
: campaign.sendEndAt
? local_yyyy_mm_dd(new Date(campaign.sendEndAt))
: null,
scheduleAt: copyMode ? null : (campaign.scheduleAt ?? null),
constraintWeekDays: copyMode ? [] : weekDayBinaryToAvailable(campaign.constraintWeekDays),
contraintStartTime: copyMode ? null : utcTimeToLocal(campaign.constraintStartTime),
contraintEndTime: copyMode ? null : utcTimeToLocal(campaign.constraintEndTime),
@@ -1180,7 +1223,8 @@
}
// set advanced options visibility based on campaign configuration
showAdvancedOptionsStep3 = !!(campaign.closeAt || campaign.anonymizeAt);
lateScheduleEnabled = !!campaign.scheduleAt;
showAdvancedOptionsStep3 = !!(campaign.closeAt || campaign.anonymizeAt || campaign.scheduleAt);
showAdvancedOptionsStep4 = !!(
campaign.webhookID ||
@@ -1672,7 +1716,7 @@
</button>
</div>
{#if formValues.sendStartAt}
{#if formValues.sendStartAt && !hasDynamicGroup}
<div class="pt-4 pb-6">
<div class="flex flex-col gap-2">
<p
@@ -1885,6 +1929,23 @@
{/if}
{#if showAdvancedOptionsStep3}
<CheckboxField
bind:value={lateScheduleEnabled}
disabled={!lateScheduleAvailable(formValues.sendStartAt)}
toolTipText={!lateScheduleAvailable(formValues.sendStartAt)
? 'Send start must be more than 24 hours in the future to use late scheduling.'
: 'When enabled, recipients are resolved and the campaign is scheduled 24 hours before send start, not at creation.'}
on:change={() => {
if (lateScheduleEnabled && formValues.sendStartAt) {
formValues.scheduleAt = new Date(
new Date(formValues.sendStartAt).getTime() - 24 * 60 * 60 * 1000
).toISOString();
} else {
formValues.scheduleAt = null;
}
}}>Late Schedule</CheckboxField
>
<TextFieldSelect
id="sortField"
bind:value={formValues.sortField}
@@ -2251,13 +2312,20 @@
: 'None selected'}
</span>
<span class="text-grayblue-dark font-medium">Total:</span>
<span class="text-pc-darkblue dark:text-white"
>{formValues.selectedCount} recipients</span
>
{#if !lateScheduleEnabled}
<span class="text-grayblue-dark font-medium">Total:</span>
<span class="text-pc-darkblue dark:text-white"
>{formValues.selectedCount} recipients</span
>
{/if}
</div>
{#if formValues.recipientGroups.length > 0}
{#if lateScheduleEnabled}
<p class="text-sm text-amber-600 dark:text-amber-400">
Recipients will be resolved when the campaign is scheduled.<br />
Campaign is scheduled 24 hours before send start.
</p>
{:else if formValues.recipientGroups.length > 0}
<button
type="button"
class="text-xs font-medium text-white dark:text-white hover:text-gray-200 dark:hover:text-gray-300 flex items-center gap-1"
@@ -2431,6 +2499,16 @@
{/if}
{/if}
{#if formValues.scheduleAt}
<span class="text-grayblue-dark font-medium">Schedule at:</span>
<span
class="text-pc-darkblue dark:text-gray-100 transition-colors duration-200"
>
<Datetime value={formValues.scheduleAt} />
<RelativeTime value={formValues.scheduleAt} />
</span>
{/if}
{#if formValues.closeAt}
<span class="text-grayblue-dark font-medium">Close at:</span>
<span class="text-pc-darkblue dark:text-white">
+162 -149
View File
@@ -70,6 +70,7 @@
saveSubmittedData: false,
saveBrowserMetadata: false,
isAnonymous: false,
scheduleAt: null,
allowDenyIDs: [],
webhookID: null,
@@ -286,6 +287,7 @@
}
campaign.recipientGroups = t.recipientGroupIDs.map((id) => recipientGroupMap.byKey(id));
campaign.notableEventName = t.notableEventName;
campaign.scheduleAt = t.scheduleAt ?? null;
if (t.sendStartAt === null && t.sendEndAt === null) {
isSelfManaged = true;
}
@@ -1622,6 +1624,15 @@
</div>
{#if !isSelfManaged}
{#if campaign.scheduleAt}
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Schedules at:</span>
<span class="text-pc-darkblue dark:text-white text-right"
><Datetime value={campaign.scheduleAt} /></span
>
</div>
{/if}
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Delivery start:</span>
<span class="text-pc-darkblue dark:text-white text-right"
@@ -1932,172 +1943,174 @@
{/each}
</Table>
</div>
<SubHeadline>Recipients overview</SubHeadline>
<Table
columns={[
{ column: 'First name', size: 'small' },
{ column: 'Last name', size: 'small' },
{ column: 'Email', size: 'large' },
{ column: 'Status', size: 'small' },
{ column: 'Send at', title: 'Scheduled', size: 'small' },
{ column: 'Sent at', title: 'Delivered', size: 'small' },
{ column: 'Cancelled at', size: 'small' }
]}
sortable={[
'First name',
'Last name',
'Email',
'Status',
'Send at',
'Sent at',
'Cancelled at'
]}
pagination={recipientTableUrlParams}
plural="recipients"
hasData={!!campaignRecipients.length}
hasNextPage={campaignRecipientsHasNextPage}
isGhost={isRecipientTableLoading}
>
{#each campaignRecipients as recp (recp.id)}
<TableRow>
{#if recp?.anonymizedID}
<TableCell value={'anonymized'} />
<TableCell value={'anonymized'} />
<TableCell value={'anonymized'} />
{:else}
<TableCell>
<button
on:click={() => openEventsModal(recp.recipientID)}
class="block w-full py-1 text-left"
>
{recp.recipient.firstName}
</button>
</TableCell>
<TableCell>
<button
on:click={() => openEventsModal(recp.recipientID)}
class="block w-full py-1 text-left"
>
{recp.recipient.lastName}
</button>
</TableCell>
<TableCell>
{#if recp?.recipient?.email}
{#if campaign.notableEventName !== 'campaign_pending_schedule'}
<SubHeadline>Recipients overview</SubHeadline>
<Table
columns={[
{ column: 'First name', size: 'small' },
{ column: 'Last name', size: 'small' },
{ column: 'Email', size: 'large' },
{ column: 'Status', size: 'small' },
{ column: 'Send at', title: 'Scheduled', size: 'small' },
{ column: 'Sent at', title: 'Delivered', size: 'small' },
{ column: 'Cancelled at', size: 'small' }
]}
sortable={[
'First name',
'Last name',
'Email',
'Status',
'Send at',
'Sent at',
'Cancelled at'
]}
pagination={recipientTableUrlParams}
plural="recipients"
hasData={!!campaignRecipients.length}
hasNextPage={campaignRecipientsHasNextPage}
isGhost={isRecipientTableLoading}
>
{#each campaignRecipients as recp (recp.id)}
<TableRow>
{#if recp?.anonymizedID}
<TableCell value={'anonymized'} />
<TableCell value={'anonymized'} />
<TableCell value={'anonymized'} />
{:else}
<TableCell>
<button
on:click={() => openEventsModal(recp.recipientID)}
class="block w-full py-1 text-left"
>
{recp.recipient.email}
{recp.recipient.firstName}
</button>
{/if}
</TableCell>
{/if}
<TableCell>
<EventName eventName={recp?.notableEventName} />
</TableCell>
<TableCell value={recp?.sendAt} isDate />
<TableCell value={recp?.sentAt} isDate />
<TableCell value={recp?.cancelledAt} isDate />
{#if !campaign.sentAt}
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableViewButton
name="Events"
disabled={!recp.recipient}
</TableCell>
<TableCell>
<button
on:click={() => openEventsModal(recp.recipientID)}
/>
class="block w-full py-1 text-left"
>
{recp.recipient.lastName}
</button>
</TableCell>
<TableCell>
{#if recp?.recipient?.email}
<button
on:click={() => openEventsModal(recp.recipientID)}
class="block w-full py-1 text-left"
>
{recp.recipient.email}
</button>
{/if}
</TableCell>
{/if}
<TableCell>
<EventName eventName={recp?.notableEventName} />
</TableCell>
<TableCell value={recp?.sendAt} isDate />
<TableCell value={recp?.sentAt} isDate />
<TableCell value={recp?.cancelledAt} isDate />
{#if !campaign.sentAt}
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableViewButton
name="Events"
disabled={!recp.recipient}
on:click={() => openEventsModal(recp.recipientID)}
/>
<TableDropDownButton
name={recp.sentAt ? `Send message again` : `Send message`}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: recp.closedAt
? 'Campaign is closed'
: recp.cancelledAt
? 'Recipient cancelled'
: recp.sentAt
? `Send message again (last sent: ${new Date(recp.sentAt).toLocaleDateString()})`
: `Send message to recipient`}
on:click={() => showSendMessageModal(recp.id, recp.recipient)}
disabled={!!campaign.closedAt || recp.cancelledAt || isContextMismatch()}
/>
<TableUpdateButton
name="Copy lure URL"
disabled={!!campaign.closedAt ||
!!campaign.anonymizedAt ||
!recp.recipient ||
isContextMismatch()}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: campaign.closedAt
? 'Campaign is closed'
: campaign.anonymizedAt
? 'Campaign is anonymized'
: !recp.recipient
? 'Recipient not available'
: ''}
on:click={() => onClickCopyURL(recp.id)}
/>
<TableUpdateButton
name="Copy email content"
disabled={!!campaign.closedAt || !!campaign.anonymizedAt || isContextMismatch()}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: campaign.closedAt
? 'Campaign is closed'
: campaign.anonymizedAt
? 'Campaign is anonymized'
: ''}
on:click={() => onClickCopyEmailContent(recp.id)}
/>
{#if !campaign.sendStartAt}
<!-- self managed campaign -->
<TableDropDownButton
name="Set as message sent"
name={recp.sentAt ? `Send message again` : `Send message`}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: recp.closedAt
? 'Campaign is closed'
: ''}
on:click={() => onClickSetEmailSent(recp.id, recp.recipient)}
: recp.cancelledAt
? 'Recipient cancelled'
: recp.sentAt
? `Send message again (last sent: ${new Date(recp.sentAt).toLocaleDateString()})`
: `Send message to recipient`}
on:click={() => showSendMessageModal(recp.id, recp.recipient)}
disabled={!!campaign.closedAt || recp.cancelledAt || isContextMismatch()}
/>
{/if}
<TableViewButton
name="View email"
disabled={!!campaign.closedAt ||
!!campaign.anonymizedAt ||
!recp.recipient ||
isContextMismatch()}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: campaign.closedAt
? 'Campaign is closed'
: campaign.anonymizedAt
? 'Campaign is anonymized'
: !recp.recipient
? 'Recipient not available'
<TableUpdateButton
name="Copy lure URL"
disabled={!!campaign.closedAt ||
!!campaign.anonymizedAt ||
!recp.recipient ||
isContextMismatch()}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: campaign.closedAt
? 'Campaign is closed'
: campaign.anonymizedAt
? 'Campaign is anonymized'
: !recp.recipient
? 'Recipient not available'
: ''}
on:click={() => onClickCopyURL(recp.id)}
/>
<TableUpdateButton
name="Copy email content"
disabled={!!campaign.closedAt || !!campaign.anonymizedAt || isContextMismatch()}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: campaign.closedAt
? 'Campaign is closed'
: campaign.anonymizedAt
? 'Campaign is anonymized'
: ''}
on:click={() => onClickPreviewEmail(recp.id)}
/>
</TableDropDownEllipsis>
</TableCellAction>
{/if}
</TableRow>
{/each}
</Table>
on:click={() => onClickCopyEmailContent(recp.id)}
/>
{#if !campaign.sendStartAt}
<!-- self managed campaign -->
<TableDropDownButton
name="Set as message sent"
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: recp.closedAt
? 'Campaign is closed'
: ''}
on:click={() => onClickSetEmailSent(recp.id, recp.recipient)}
disabled={!!campaign.closedAt || recp.cancelledAt || isContextMismatch()}
/>
{/if}
<TableViewButton
name="View email"
disabled={!!campaign.closedAt ||
!!campaign.anonymizedAt ||
!recp.recipient ||
isContextMismatch()}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: campaign.closedAt
? 'Campaign is closed'
: campaign.anonymizedAt
? 'Campaign is anonymized'
: !recp.recipient
? 'Recipient not available'
: ''}
on:click={() => onClickPreviewEmail(recp.id)}
/>
</TableDropDownEllipsis>
</TableCellAction>
{/if}
</TableRow>
{/each}
</Table>
{/if}
{/if}
<Modal headerText={'Events'} visible={isEventsModalVisible} onClose={closeEventsModal}>
<div class="mt-8"></div>