mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-18 00:17:14 +02:00
916 lines
26 KiB
Go
916 lines
26 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/oapi-codegen/nullable"
|
|
"github.com/phishingclub/phishingclub/cache"
|
|
"github.com/phishingclub/phishingclub/data"
|
|
"github.com/phishingclub/phishingclub/database"
|
|
"github.com/phishingclub/phishingclub/errs"
|
|
"github.com/phishingclub/phishingclub/model"
|
|
"github.com/phishingclub/phishingclub/utils"
|
|
"github.com/phishingclub/phishingclub/vo"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var allowedCampaignRecipientColumns = []string{
|
|
"campaign_recipients.created_at",
|
|
"campaign_recipients.updated_at",
|
|
"campaign_recipients.send_at",
|
|
"campaign_recipients.sent_at",
|
|
"campaign_recipients.cancelled_at",
|
|
"campaign_recipients.notable_event_id",
|
|
"recipients.first_name",
|
|
"recipients.last_name",
|
|
"recipients.email",
|
|
}
|
|
|
|
// CampaignRecipientOption is options for preloading
|
|
type CampaignRecipientOption struct {
|
|
*vo.QueryArgs
|
|
WithCampaign bool
|
|
WithRecipient bool
|
|
}
|
|
|
|
// CampaignRecipient is a CampaignRecipient repository
|
|
// this holds campaign-recipients and their campaign results
|
|
type CampaignRecipient struct {
|
|
DB *gorm.DB
|
|
}
|
|
|
|
// Preload preloads the campaign recipients
|
|
func (r *CampaignRecipient) preload(db *gorm.DB, options *CampaignRecipientOption) *gorm.DB {
|
|
if options.WithRecipient {
|
|
db = db.Preload("Recipient")
|
|
}
|
|
if options.WithCampaign {
|
|
db = db.Preload("Campaign")
|
|
}
|
|
return db
|
|
}
|
|
|
|
// Cancel cancels recipients
|
|
func (r *CampaignRecipient) Cancel(
|
|
ctx context.Context,
|
|
campaignRecipientUUIDs []*uuid.UUID,
|
|
) error {
|
|
if len(campaignRecipientUUIDs) == 0 {
|
|
return nil
|
|
}
|
|
row := map[string]any{
|
|
"cancelled_at": utils.NowRFC3339UTC(),
|
|
}
|
|
AddUpdatedAt(row)
|
|
result := r.DB.
|
|
Model(&database.CampaignRecipient{}).
|
|
Where(
|
|
fmt.Sprintf(
|
|
"%s IN ?",
|
|
TableColumnID(database.CAMPAIGN_RECIPIENT_TABLE_NAME),
|
|
),
|
|
UUIDsToStrings(campaignRecipientUUIDs),
|
|
).
|
|
Updates(row)
|
|
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
// set notable event
|
|
if len(campaignRecipientUUIDs) == 0 {
|
|
return nil
|
|
}
|
|
row = map[string]any{
|
|
"notable_event_id": cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_CANCELLED],
|
|
}
|
|
AddUpdatedAt(row)
|
|
result = r.DB.
|
|
Model(&database.CampaignRecipient{}).
|
|
Where(
|
|
fmt.Sprintf(
|
|
"%s IN ? AND sent_at IS NULL AND cancelled_at IS NOT NULL",
|
|
TableColumnID(database.CAMPAIGN_RECIPIENT_TABLE_NAME),
|
|
),
|
|
UUIDsToStrings(campaignRecipientUUIDs),
|
|
).
|
|
Where(
|
|
"notable_event_id IS NULL OR notable_event_id IS ?",
|
|
cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SCHEDULED],
|
|
).
|
|
Updates(row)
|
|
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Insert inserts a new campaign recipient
|
|
func (r *CampaignRecipient) Insert(
|
|
ctx context.Context,
|
|
campaignRecipient *model.CampaignRecipient,
|
|
//campaignRecipient *database.CampaignRecipient,
|
|
) (*uuid.UUID, error) {
|
|
id := uuid.New()
|
|
row := campaignRecipient.ToDBMap()
|
|
row["id"] = id
|
|
AddTimestamps(row)
|
|
// ToDBMap omits the lure code, so it is carried here
|
|
if code, err := campaignRecipient.LureCode.Get(); err == nil && code != "" {
|
|
row["lure_code"] = code
|
|
}
|
|
if custom, err := campaignRecipient.LureCodeCustom.Get(); err == nil {
|
|
row["lure_code_custom"] = custom
|
|
}
|
|
|
|
res := r.DB.
|
|
Model(&database.CampaignRecipient{}).
|
|
Create(row)
|
|
|
|
if res.Error != nil {
|
|
return nil, res.Error
|
|
}
|
|
return &id, nil
|
|
}
|
|
|
|
// SetLureCodeByID assigns an operator chosen code to one recipient. The only
|
|
// path that writes a code after insert, and it touches nothing else, so a caller
|
|
// holding a stale recipient model cannot restate a code through it.
|
|
func (r *CampaignRecipient) SetLureCodeByID(
|
|
ctx context.Context,
|
|
id *uuid.UUID,
|
|
code string,
|
|
) error {
|
|
row := map[string]any{
|
|
"lure_code": code,
|
|
"lure_code_custom": true,
|
|
}
|
|
AddUpdatedAt(row)
|
|
res := r.DB.
|
|
Model(&database.CampaignRecipient{}).
|
|
Where(
|
|
fmt.Sprintf("%s = ?", TableColumnID(database.CAMPAIGN_RECIPIENT_TABLE_NAME)),
|
|
id.String(),
|
|
).
|
|
Updates(row)
|
|
|
|
return res.Error
|
|
}
|
|
|
|
// DeleteRecipientsNotIn deletes recipients in campaign that are
|
|
// not in the slice recipient ids supplied
|
|
func (r *CampaignRecipient) DeleteRecipientsNotIn(
|
|
ctx context.Context,
|
|
campaignID *uuid.UUID,
|
|
recipientIDs []*uuid.UUID,
|
|
) error {
|
|
res := r.DB.
|
|
Where(
|
|
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "campaign_id")),
|
|
campaignID,
|
|
).
|
|
Where(
|
|
fmt.Sprintf("%s NOT IN ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "recipient_id")),
|
|
UUIDsToStrings(recipientIDs),
|
|
).
|
|
Delete(&database.CampaignRecipient{})
|
|
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetRecipiensByCampaignID gets all campaignrecipients by campaign id
|
|
func (r *CampaignRecipient) GetByCampaignID(
|
|
ctx context.Context,
|
|
campaignID *uuid.UUID,
|
|
options *CampaignRecipientOption,
|
|
) (*model.Result[model.CampaignRecipient], error) {
|
|
result := model.NewEmptyResult[model.CampaignRecipient]()
|
|
db, err := useQuery(r.DB, database.CAMPAIGN_TABLE, options.QueryArgs, allowedCampaignRecipientColumns...)
|
|
if err != nil {
|
|
return result, errs.Wrap(err)
|
|
}
|
|
db = r.preload(db, options)
|
|
var dbCampaignRecipients []database.CampaignRecipient
|
|
res := db.
|
|
Joins("LEFT JOIN recipients ON recipients.id = campaign_recipients.recipient_id").
|
|
Where(
|
|
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "campaign_id")),
|
|
campaignID,
|
|
).
|
|
Find(&dbCampaignRecipients)
|
|
|
|
if res.Error != nil {
|
|
return result, res.Error
|
|
}
|
|
|
|
hasNextPage, err := useHasNextPage(db, database.CAMPAIGN_RECIPIENT_TABLE_NAME, options.QueryArgs, allowedCampaignRecipientColumns...)
|
|
if err != nil {
|
|
return result, errs.Wrap(err)
|
|
}
|
|
result.HasNextPage = hasNextPage
|
|
|
|
for _, dbCampaignRecipient := range dbCampaignRecipients {
|
|
r, err := ToCampaignRecipient(&dbCampaignRecipient)
|
|
if err != nil {
|
|
return result, nil
|
|
}
|
|
result.Rows = append(result.Rows, r)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetByID gets a campaign recipient by id
|
|
func (r *CampaignRecipient) GetByID(
|
|
ctx context.Context,
|
|
id *uuid.UUID,
|
|
options *CampaignRecipientOption,
|
|
) (*model.CampaignRecipient, error) {
|
|
db := r.preload(r.DB, options)
|
|
db, err := useQuery(db, database.CAMPAIGN_RECIPIENT_TABLE_NAME, options.QueryArgs)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
var dbCampaignRecipient database.CampaignRecipient
|
|
res := db.
|
|
Where(
|
|
fmt.Sprintf("%s = ?", TableColumnID(database.CAMPAIGN_RECIPIENT_TABLE_NAME)),
|
|
id.String(),
|
|
).
|
|
First(&dbCampaignRecipient)
|
|
|
|
if res.Error != nil {
|
|
return nil, res.Error
|
|
}
|
|
return ToCampaignRecipient(&dbCampaignRecipient)
|
|
}
|
|
|
|
// GetByCampaignAndRecipientID gets a campaign recipient by campaign and recipient id
|
|
func (r *CampaignRecipient) GetByCampaignAndRecipientID(
|
|
ctx context.Context,
|
|
campaignID *uuid.UUID,
|
|
recipientID *uuid.UUID,
|
|
options *CampaignRecipientOption,
|
|
) (*model.CampaignRecipient, error) {
|
|
db := r.preload(r.DB, options)
|
|
db, err := useQuery(db, database.CAMPAIGN_RECIPIENT_TABLE_NAME, options.QueryArgs)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
var dbCampaignRecipient database.CampaignRecipient
|
|
res := db.
|
|
Where(
|
|
fmt.Sprintf(
|
|
"%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "campaign_id"),
|
|
),
|
|
campaignID.String(),
|
|
).
|
|
Where(
|
|
fmt.Sprintf(
|
|
"%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "recipient_id"),
|
|
),
|
|
recipientID.String(),
|
|
).
|
|
First(&dbCampaignRecipient)
|
|
|
|
if res.Error != nil {
|
|
return nil, res.Error
|
|
}
|
|
return ToCampaignRecipient(&dbCampaignRecipient)
|
|
}
|
|
|
|
// GetByCampaignRecipientID gets a campaign and recipient by campaign recipient id
|
|
func (r *CampaignRecipient) GetByCampaignRecipientID(
|
|
ctx context.Context,
|
|
id *uuid.UUID,
|
|
) (*model.CampaignRecipient, error) {
|
|
var dbCampaignRecipient database.CampaignRecipient
|
|
res := r.DB.
|
|
Where(
|
|
fmt.Sprintf("%s = ?", TableColumnID(database.CAMPAIGN_RECIPIENT_TABLE_NAME)),
|
|
id.String(),
|
|
).
|
|
First(&dbCampaignRecipient)
|
|
|
|
if res.Error != nil {
|
|
return nil, res.Error
|
|
}
|
|
return ToCampaignRecipient(&dbCampaignRecipient)
|
|
}
|
|
|
|
// GetByLureCodeOnDomain gets a campaign recipient by the code carried in a
|
|
// request, but only when the code belongs to a campaign reachable on the domain
|
|
// serving it.
|
|
//
|
|
// The match is case sensitive, keeping Special-42 distinct from special-42 as
|
|
// base58 requires. A released code is null and so drops out without a separate
|
|
// predicate, which is also what stops an anonymized recipient's link working.
|
|
//
|
|
// The domain predicate keeps a guessed code from reaching across the instance.
|
|
// Codes are unique instance wide and short enough to guess, so without it a
|
|
// guess on any domain would resolve a recipient of any campaign, including
|
|
// another company's. A campaign is reachable on its template's own domain and on
|
|
// a proxy domain whose proxy the template uses for one of its pages.
|
|
func (r *CampaignRecipient) GetByLureCodeOnDomain(
|
|
ctx context.Context,
|
|
code string,
|
|
domain *database.Domain,
|
|
) (*model.CampaignRecipient, error) {
|
|
if code == "" || domain == nil {
|
|
return nil, gorm.ErrRecordNotFound
|
|
}
|
|
var dbCampaignRecipient database.CampaignRecipient
|
|
query := r.DB.
|
|
Model(&database.CampaignRecipient{}).
|
|
Joins(fmt.Sprintf(
|
|
"JOIN `%s` ON %s = %s",
|
|
database.CAMPAIGN_TABLE,
|
|
TableColumnID(database.CAMPAIGN_TABLE),
|
|
TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "campaign_id"),
|
|
)).
|
|
Joins(fmt.Sprintf(
|
|
"JOIN `%s` ON %s = %s",
|
|
database.CAMPAIGN_TEMPLATE_TABLE,
|
|
TableColumnID(database.CAMPAIGN_TEMPLATE_TABLE),
|
|
TableColumn(database.CAMPAIGN_TABLE, "campaign_template_id"),
|
|
)).
|
|
Where(
|
|
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")),
|
|
code,
|
|
).
|
|
// restating the index predicate. equality already excludes nulls, so no
|
|
// result changes, but the planner matches the partial unique index without
|
|
// deriving it. this sits on the request path, where a scan is paid on
|
|
// every visit.
|
|
Where(
|
|
fmt.Sprintf("%s IS NOT NULL", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")),
|
|
)
|
|
|
|
if domain.ProxyID != nil {
|
|
// a proxy domain serves whichever template routes a page through that
|
|
// proxy, while the template's own domain still serves evasion and deny.
|
|
// the parentheses are written here rather than left to the builder:
|
|
// unparenthesised this would widen to match any campaign using the proxy
|
|
// regardless of the rest of the clause, and it is what confines a guessed
|
|
// code to one domain.
|
|
query = query.Where(
|
|
fmt.Sprintf(
|
|
"(%s = ? OR %s = ? OR %s = ? OR %s = ?)",
|
|
TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "domain_id"),
|
|
TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "before_landing_proxy_id"),
|
|
TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "landing_proxy_id"),
|
|
TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "after_landing_proxy_id"),
|
|
),
|
|
domain.ID,
|
|
domain.ProxyID,
|
|
domain.ProxyID,
|
|
domain.ProxyID,
|
|
)
|
|
} else {
|
|
query = query.Where(
|
|
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "domain_id")),
|
|
domain.ID,
|
|
)
|
|
}
|
|
|
|
res := query.
|
|
Select(TableColumnAll(database.CAMPAIGN_RECIPIENT_TABLE_NAME)).
|
|
First(&dbCampaignRecipient)
|
|
|
|
if res.Error != nil {
|
|
return nil, res.Error
|
|
}
|
|
return ToCampaignRecipient(&dbCampaignRecipient)
|
|
}
|
|
|
|
// findTakenLureCodesChunkSize keeps each IN clause inside the sqlite bound
|
|
// variable limit, 999 on the most restrictive builds.
|
|
const findTakenLureCodesChunkSize = 500
|
|
|
|
// FindTakenLureCodes returns the subset of codes already claimed by a recipient
|
|
// whose code has not been released.
|
|
//
|
|
// Querying lure_code, the column carrying the uniqueness constraint, catches a
|
|
// collision with an operator written code here rather than through a failed
|
|
// insert part way through scheduling. Probing a whole batch costs a handful of
|
|
// queries instead of one insert and retry per recipient.
|
|
func (r *CampaignRecipient) FindTakenLureCodes(
|
|
ctx context.Context,
|
|
codes []string,
|
|
) ([]string, error) {
|
|
taken := []string{}
|
|
for start := 0; start < len(codes); start += findTakenLureCodesChunkSize {
|
|
end := start + findTakenLureCodesChunkSize
|
|
if end > len(codes) {
|
|
end = len(codes)
|
|
}
|
|
chunk := []string{}
|
|
res := r.DB.
|
|
Model(&database.CampaignRecipient{}).
|
|
Where(
|
|
fmt.Sprintf("%s IN ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")),
|
|
codes[start:end],
|
|
).
|
|
Where(
|
|
fmt.Sprintf("%s IS NOT NULL", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")),
|
|
).
|
|
Pluck("lure_code", &chunk)
|
|
|
|
if res.Error != nil {
|
|
return nil, res.Error
|
|
}
|
|
taken = append(taken, chunk...)
|
|
}
|
|
return taken, nil
|
|
}
|
|
|
|
// GetActiveByLureCode returns the recipient holding a code, so the owning
|
|
// campaign can be named when an operator tries to reuse it.
|
|
func (r *CampaignRecipient) GetActiveByLureCode(
|
|
ctx context.Context,
|
|
code string,
|
|
) (*model.CampaignRecipient, error) {
|
|
var dbCampaignRecipient database.CampaignRecipient
|
|
res := r.DB.
|
|
Preload("Campaign").
|
|
Where(
|
|
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")),
|
|
code,
|
|
).
|
|
Where(
|
|
fmt.Sprintf("%s IS NOT NULL", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")),
|
|
).
|
|
First(&dbCampaignRecipient)
|
|
|
|
if res.Error != nil {
|
|
return nil, res.Error
|
|
}
|
|
return ToCampaignRecipient(&dbCampaignRecipient)
|
|
}
|
|
|
|
// ReleaseLureCodeByID frees a recipient's code for reuse. Nulling rather than
|
|
// flagging takes it out of the unique index and stops it showing against a
|
|
// recipient who no longer owns it. The row and its events are untouched.
|
|
func (r *CampaignRecipient) ReleaseLureCodeByID(
|
|
ctx context.Context,
|
|
id *uuid.UUID,
|
|
) error {
|
|
row := map[string]any{
|
|
"lure_code": nil,
|
|
}
|
|
AddUpdatedAt(row)
|
|
res := r.DB.
|
|
Model(&database.CampaignRecipient{}).
|
|
Where(
|
|
fmt.Sprintf("%s = ?", TableColumnID(database.CAMPAIGN_RECIPIENT_TABLE_NAME)),
|
|
id.String(),
|
|
).
|
|
Updates(row)
|
|
|
|
return res.Error
|
|
}
|
|
|
|
// HasRecipientsByCampaignID reports whether the campaign still holds any
|
|
// recipient row. Scheduling reads it to decide whether the lure settings are
|
|
// settled: a campaign holding recipients has links out resolving through those
|
|
// rows. A non self managed reschedule deletes them first, which is what lets it
|
|
// pick up a template change, its old links being dead either way.
|
|
func (r *CampaignRecipient) HasRecipientsByCampaignID(
|
|
ctx context.Context,
|
|
campaignID *uuid.UUID,
|
|
) (bool, error) {
|
|
// campaign_id leads the campaign and recipient unique index, so this stops at
|
|
// the first entry rather than counting the campaign
|
|
ids := []string{}
|
|
res := r.DB.
|
|
Model(&database.CampaignRecipient{}).
|
|
Where(
|
|
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "campaign_id")),
|
|
campaignID,
|
|
).
|
|
Limit(1).
|
|
Pluck("id", &ids)
|
|
|
|
if res.Error != nil {
|
|
return false, res.Error
|
|
}
|
|
return len(ids) > 0, nil
|
|
}
|
|
|
|
// HasCustomLureCodesByCampaignID reports whether any recipient in the campaign
|
|
// carries an operator set code. The recipient table is paginated, so this cannot
|
|
// be derived from one page of rows.
|
|
func (r *CampaignRecipient) HasCustomLureCodesByCampaignID(
|
|
ctx context.Context,
|
|
campaignID *uuid.UUID,
|
|
) (bool, error) {
|
|
// the flag is a literal because sqlite chooses the partial index at prepare
|
|
// time, where a bound value is unknown. keep it in step with the predicate
|
|
// in database.CampaignRecipient.Migrate. campaign_id is plucked so the
|
|
// answer comes from the index without reading the row.
|
|
ids := []string{}
|
|
res := r.DB.
|
|
Model(&database.CampaignRecipient{}).
|
|
Where(
|
|
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "campaign_id")),
|
|
campaignID,
|
|
).
|
|
Where(
|
|
fmt.Sprintf("%s = 1", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code_custom")),
|
|
).
|
|
Limit(1).
|
|
Pluck("campaign_id", &ids)
|
|
|
|
if res.Error != nil {
|
|
return false, res.Error
|
|
}
|
|
return len(ids) > 0, nil
|
|
}
|
|
|
|
// GetUnsendRecipients gets campaign recipients that were never attempted and are
|
|
// not already cancelled (cancelled_at IS NULL AND last_attempt_at IS NULL). Used
|
|
// at campaign close to cancel only sends that never started; recipients that were
|
|
// attempted (sent, failed, or still in flight) are left untouched so their
|
|
// existing state is not overwritten.
|
|
// if limit is larger than 0 it will limit the number of results
|
|
// if campaignID is not nil, it will filter by that campaign
|
|
func (r *CampaignRecipient) GetUnsendRecipients(
|
|
ctx context.Context,
|
|
campaignID *uuid.UUID,
|
|
limit int,
|
|
options *CampaignRecipientOption,
|
|
) ([]*model.CampaignRecipient, error) {
|
|
recps := []*model.CampaignRecipient{}
|
|
db := r.preload(r.DB, options)
|
|
db, err := useQuery(db, database.CAMPAIGN_RECIPIENT_TABLE_NAME, options.QueryArgs)
|
|
if err != nil {
|
|
return recps, errs.Wrap(err)
|
|
}
|
|
var dbCampaignRecipients []database.CampaignRecipient
|
|
|
|
q := db.Where(
|
|
fmt.Sprintf(
|
|
"%s IS NULL AND %s IS NULL",
|
|
TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "cancelled_at"),
|
|
TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "last_attempt_at"),
|
|
),
|
|
)
|
|
if campaignID != nil {
|
|
q = q.Where(
|
|
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "campaign_id")),
|
|
campaignID,
|
|
)
|
|
}
|
|
if limit > 0 {
|
|
q = q.Limit(limit)
|
|
}
|
|
res := q.
|
|
Find(&dbCampaignRecipients)
|
|
|
|
if res.Error != nil {
|
|
return recps, res.Error
|
|
}
|
|
for _, dbCampaignRecipient := range dbCampaignRecipients {
|
|
r, err := ToCampaignRecipient(&dbCampaignRecipient)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
recps = append(recps, r)
|
|
}
|
|
return recps, nil
|
|
}
|
|
|
|
// GetUnsendRecipientsForSending gets all campaign recipients that are not sent
|
|
// and have not reached the max send attempts or been cancelled
|
|
// the limit is only used if it is larger than 0
|
|
func (r *CampaignRecipient) GetUnsendRecipientsForSending(
|
|
ctx context.Context,
|
|
limit int,
|
|
options *CampaignRecipientOption,
|
|
) ([]*model.CampaignRecipient, error) {
|
|
recps := []*model.CampaignRecipient{}
|
|
db := r.preload(r.DB, options)
|
|
db, err := useQuery(db, database.CAMPAIGN_RECIPIENT_TABLE_NAME, options.QueryArgs)
|
|
if err != nil {
|
|
return recps, errs.Wrap(err)
|
|
}
|
|
var dbCampaignRecipients []database.CampaignRecipient
|
|
q := db.
|
|
// authoritative guard: never deliver to a SCIM-disabled recipient, even if
|
|
// their row was not cancelled (e.g. disabled while the campaign was not yet
|
|
// active). A subquery is used instead of a join so the campaign_recipients
|
|
// SELECT is never widened with ambiguous recipient columns.
|
|
Where(fmt.Sprintf(
|
|
"NOT EXISTS (SELECT 1 FROM %s sd WHERE sd.id = %s.recipient_id AND sd.scim_soft_deleted_at IS NOT NULL)",
|
|
database.RECIPIENT_TABLE,
|
|
database.CAMPAIGN_RECIPIENT_TABLE_NAME,
|
|
)).
|
|
Where(
|
|
fmt.Sprintf(
|
|
"%s IS NULL"+
|
|
" AND %s <= ?"+
|
|
" AND %s IS NULL"+
|
|
" AND %s IS NULL"+
|
|
" AND %s = false",
|
|
TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "sent_at"),
|
|
TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "send_at"),
|
|
TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "cancelled_at"),
|
|
TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "last_attempt_at"),
|
|
TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "self_managed"),
|
|
), utils.NowRFC3339UTC(),
|
|
)
|
|
|
|
if limit > 0 {
|
|
q = q.Limit(limit)
|
|
}
|
|
res := q.
|
|
Find(&dbCampaignRecipients)
|
|
|
|
if res.Error != nil {
|
|
return recps, res.Error
|
|
}
|
|
for _, dbCampaignRecipient := range dbCampaignRecipients {
|
|
r, err := ToCampaignRecipient(&dbCampaignRecipient)
|
|
if err != nil {
|
|
return recps, errs.Wrap(err)
|
|
}
|
|
recps = append(recps, r)
|
|
}
|
|
return recps, nil
|
|
}
|
|
|
|
// DeleteByCampaigID removes all campaign recipients from a campaign
|
|
func (r *CampaignRecipient) DeleteByCampaigID(
|
|
ctx context.Context,
|
|
campaignID *uuid.UUID,
|
|
) error {
|
|
res := r.DB.
|
|
Where(
|
|
fmt.Sprintf(
|
|
"%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "campaign_id"),
|
|
),
|
|
campaignID,
|
|
).
|
|
Delete(&database.CampaignRecipient{})
|
|
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateByID updates a campaign recipient by id
|
|
func (c *CampaignRecipient) UpdateByID(
|
|
ctx context.Context,
|
|
id *uuid.UUID,
|
|
campaignRecipient *model.CampaignRecipient,
|
|
) error {
|
|
row := campaignRecipient.ToDBMap()
|
|
AddUpdatedAt(row)
|
|
|
|
res := c.DB.
|
|
Model(&database.CampaignRecipient{}).
|
|
Where(
|
|
fmt.Sprintf(
|
|
"%s = ?", TableColumnID(database.CAMPAIGN_RECIPIENT_TABLE_NAME),
|
|
),
|
|
id.String(),
|
|
).
|
|
Updates(row)
|
|
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateNotableEventByID updates only the notable event id for a campaign recipient
|
|
func (c *CampaignRecipient) UpdateNotableEventByID(
|
|
ctx context.Context,
|
|
campaignRecipientID *uuid.UUID,
|
|
notableEventTypeID *uuid.UUID,
|
|
) error {
|
|
if campaignRecipientID == nil {
|
|
return nil
|
|
}
|
|
|
|
row := map[string]any{
|
|
"notable_event_id": notableEventTypeID,
|
|
}
|
|
AddUpdatedAt(row)
|
|
|
|
res := c.DB.
|
|
WithContext(ctx).
|
|
Model(&database.CampaignRecipient{}).
|
|
Where("id = ?", campaignRecipientID).
|
|
Updates(row)
|
|
|
|
return res.Error
|
|
}
|
|
|
|
// Anonymize adds an anonymized id to a campaign recipient
|
|
func (r *CampaignRecipient) Anonymize(
|
|
ctx context.Context,
|
|
campaignID *uuid.UUID,
|
|
recipientID *uuid.UUID,
|
|
anonymizedID *uuid.UUID,
|
|
) error {
|
|
// releasing here stops the recipient's lure link resolving and hands the code
|
|
// back for reuse
|
|
row := map[string]interface{}{
|
|
"anonymized_id": anonymizedID.String(),
|
|
"lure_code": nil,
|
|
}
|
|
AddUpdatedAt(row)
|
|
db := r.DB.Model(&database.CampaignRecipient{})
|
|
|
|
// if campaignID is nil, anonymize across all campaigns (e.g., when deleting recipient)
|
|
// otherwise, only anonymize for the specific campaign
|
|
if campaignID != nil {
|
|
db = db.Where("campaign_id = ? AND recipient_id = ?", campaignID, recipientID)
|
|
} else {
|
|
db = db.Where("recipient_id = ?", recipientID)
|
|
}
|
|
|
|
res := db.Updates(row)
|
|
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *CampaignRecipient) CancelInActiveCampaigns(
|
|
ctx context.Context,
|
|
recipientID *uuid.UUID,
|
|
) error {
|
|
row := map[string]any{
|
|
"cancelled_at": utils.NowRFC3339UTC(),
|
|
}
|
|
AddUpdatedAt(row)
|
|
subSelect := r.DB.Table(database.CAMPAIGN_TABLE).Select("id")
|
|
subSelect = appendWhereCampaignIsActive(subSelect)
|
|
|
|
res := r.DB.
|
|
Model(&database.CampaignRecipient{}).
|
|
Where("campaign_id IN (?)", subSelect).
|
|
Where("recipient_id = ?", recipientID).
|
|
Updates(row)
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CancelUnsentInOpenCampaigns cancels a recipient's not-yet-sent rows in any
|
|
// campaign that is not closed, regardless of whether it has started. Used when a
|
|
// recipient is deleted/anonymized so no uncancelled pending row is left behind in
|
|
// a future-scheduled campaign. Already-sent rows are left untouched as history.
|
|
func (r *CampaignRecipient) CancelUnsentInOpenCampaigns(
|
|
ctx context.Context,
|
|
recipientID *uuid.UUID,
|
|
) error {
|
|
row := map[string]any{
|
|
"cancelled_at": utils.NowRFC3339UTC(),
|
|
}
|
|
AddUpdatedAt(row)
|
|
openCampaigns := r.DB.Table(database.CAMPAIGN_TABLE).
|
|
Select("id").
|
|
Where(fmt.Sprintf("%s IS NULL", TableColumn(database.CAMPAIGN_TABLE, "closed_at")))
|
|
|
|
res := r.DB.
|
|
Model(&database.CampaignRecipient{}).
|
|
Where("campaign_id IN (?)", openCampaigns).
|
|
Where("recipient_id = ?", recipientID).
|
|
Where(fmt.Sprintf("%s IS NULL", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "sent_at"))).
|
|
Where(fmt.Sprintf("%s IS NULL", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "cancelled_at"))).
|
|
Updates(row)
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemoveRecipientIDByCampaignID removes a recipient id from all campaign recipients
|
|
// related to a campaign, this is used when anonymizing a campaign
|
|
func (r *CampaignRecipient) RemoveRecipientIDByCampaignID(
|
|
ctx context.Context,
|
|
campaignID *uuid.UUID,
|
|
) error {
|
|
row := map[string]interface{}{
|
|
"recipient_id": nil,
|
|
}
|
|
AddUpdatedAt(row)
|
|
res := r.DB.
|
|
Model(&database.CampaignRecipient{}).
|
|
Where("campaign_id = ?", campaignID).
|
|
Updates(row)
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemoveRecipientIDByRecipientID removes a recipient id from a campaign recipient
|
|
func (r *CampaignRecipient) RemoveRecipientIDByRecipientID(
|
|
ctx context.Context,
|
|
recipientID *uuid.UUID,
|
|
) error {
|
|
row := map[string]interface{}{
|
|
"recipient_id": nil,
|
|
}
|
|
AddUpdatedAt(row)
|
|
res := r.DB.
|
|
Model(&database.CampaignRecipient{}).
|
|
Where("recipient_id = ?", recipientID).
|
|
Updates(row)
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ToCampaignRecipient converts a database campaign recipient to a model campaign recipient
|
|
func ToCampaignRecipient(row *database.CampaignRecipient) (*model.CampaignRecipient, error) {
|
|
id := nullable.NewNullableWithValue(*row.ID)
|
|
var cancelledAt nullable.Nullable[time.Time]
|
|
cancelledAt.SetNull()
|
|
if row.CancelledAt != nil {
|
|
cancelledAt = nullable.NewNullableWithValue(*row.CancelledAt)
|
|
}
|
|
var sendAt nullable.Nullable[time.Time]
|
|
sendAt.SetNull()
|
|
if row.SendAt != nil {
|
|
sendAt = nullable.NewNullableWithValue(*row.SendAt)
|
|
}
|
|
var sentAt nullable.Nullable[time.Time]
|
|
sentAt.SetNull()
|
|
if row.SentAt != nil {
|
|
sentAt = nullable.NewNullableWithValue(*row.SentAt)
|
|
}
|
|
var lastAttemptAt nullable.Nullable[time.Time]
|
|
lastAttemptAt.SetNull()
|
|
if row.LastAttemptAt != nil {
|
|
lastAttemptAt = nullable.NewNullableWithValue(*row.LastAttemptAt)
|
|
}
|
|
selfManaged := nullable.NewNullableWithValue(row.SelfManaged)
|
|
campaignID := nullable.NewNullableWithValue(*row.CampaignID)
|
|
var recipientID nullable.Nullable[uuid.UUID]
|
|
recipientID.SetNull()
|
|
if row.RecipientID != nil {
|
|
recipientID = nullable.NewNullableWithValue(*row.RecipientID)
|
|
}
|
|
var anonymizedID nullable.Nullable[uuid.UUID]
|
|
anonymizedID.SetNull()
|
|
if row.AnonymizedID != nil {
|
|
anonymizedID = nullable.NewNullableWithValue(*row.AnonymizedID)
|
|
}
|
|
var recipient *model.Recipient
|
|
if row.Recipient != nil {
|
|
r, err := ToRecipient(row.Recipient)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
recipient = r
|
|
}
|
|
var campaign *model.Campaign
|
|
if row.Campaign != nil {
|
|
campaign, _ = ToCampaign(row.Campaign)
|
|
}
|
|
var notableEventName string
|
|
var notableEventID nullable.Nullable[uuid.UUID]
|
|
notableEventID.SetNull()
|
|
if row.NotableEventID != nil {
|
|
notableEventID = nullable.NewNullableWithValue(*row.NotableEventID)
|
|
notableEventName = cache.EventNameByID[row.NotableEventID.String()]
|
|
}
|
|
var lureCode nullable.Nullable[string]
|
|
lureCode.SetNull()
|
|
if row.LureCode != nil {
|
|
lureCode = nullable.NewNullableWithValue(*row.LureCode)
|
|
}
|
|
return &model.CampaignRecipient{
|
|
ID: id,
|
|
CancelledAt: cancelledAt,
|
|
SendAt: sendAt,
|
|
SentAt: sentAt,
|
|
LastAttemptAt: lastAttemptAt,
|
|
SelfManaged: selfManaged,
|
|
CampaignID: campaignID,
|
|
Campaign: campaign,
|
|
AnonymizedID: anonymizedID,
|
|
RecipientID: recipientID,
|
|
Recipient: recipient,
|
|
NotableEventID: notableEventID,
|
|
NotableEventName: notableEventName,
|
|
LureCode: lureCode,
|
|
LureCodeCustom: nullable.NewNullableWithValue(row.LureCodeCustom),
|
|
}, nil
|
|
}
|