mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-23 10:52:44 +02:00
Vendored
+5
-4
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user