package database import ( "time" "github.com/google/uuid" "gorm.io/gorm" ) const ( CAMPAIGN_RECIPIENT_TABLE_NAME = "campaign_recipients" ) // CampaigReciever is gorm data model // this model/table is primarily used to keep track of who and when should recieve a campaign type CampaignRecipient struct { ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"` CreatedAt *time.Time `gorm:"not null;index;"` UpdatedAt *time.Time `gorm:"not null;index;"` Campaign *Campaign CampaignID *uuid.UUID `gorm:"not null;type:uuid;uniqueIndex:idx_campaign_recipients_campaign_id_recipient_id;"` // CancelledAt *time.Time `gorm:"index;"` CancelledAt *time.Time `gorm:"index;"` // when it should be send SendAt *time.Time `gorm:"index;"` // when it was last attempted send LastAttemptAt *time.Time `gorm:"index;"` // when it was sent SentAt *time.Time `gorm:"index;"` // self-managed SelfManaged bool `gorm:"not null;default:false;"` // AnonymizedID is set when the recipient has been anonymized AnonymizedID *uuid.UUID `gorm:"type:uuid;"` Recipient *Recipient // A null recipientID means that the data has been anonymized RecipientID *uuid.UUID `gorm:"type:uuid;index;uniqueIndex:idx_campaign_recipients_campaign_id_recipient_id;"` // NotableEventID is the most notable event for this recipient NotableEvent *Event `gorm:"foreignKey:NotableEventID;references:ID"` NotableEventID *uuid.UUID `gorm:"type:uuid;index"` // LureCode is the short identifier that appears in the lure URL instead of // the campaign recipient UUID, for example https://example.com/4H7K9QM2XR3T. // It is stored exactly as written and matched exactly, which is what lets an // operator pick a code such as Special-42 and get that link verbatim. // // Releasing a code sets this back to null. Nulling rather than flagging is // what keeps a reclaimed code from still showing against its former owner, // and it lets the unique index below need nothing but IS NOT NULL. LureCode *string `gorm:"type:varchar(64)"` // LureCodeCustom marks a code the operator set by hand. A custom code cannot // be told apart from a generated one by shape, so the distinction is stored. LureCodeCustom bool `gorm:"not null;default:false"` } func (CampaignRecipient) TableName() string { return CAMPAIGN_RECIPIENT_TABLE_NAME } // Migrate creates the partial unique index backing lure code allocation. // // The predicate has to exclude rows carrying no code, otherwise every recipient // in a query mode campaign enters the index under the same value and the second // insert fails. Because releasing a code nulls it, that single predicate also // covers reuse: a released code leaves the index and is free to claim again. // // This is raw SQL rather than a gorm index tag because only the sqlite driver // emits the WHERE clause. The generic migrator drops it, which would silently // produce a full unique index and break reuse. func (CampaignRecipient) Migrate(db *gorm.DB) error { // lure_code is the only code column this table carries. AutoMigrate adds // columns but never drops them, so any other lure column and its indexes are // removed here. errors are ignored because on a fresh install there is // nothing to drop. db.Exec(`DROP INDEX IF EXISTS idx_campaign_recipients_lure_code_lookup`) db.Exec(`DROP INDEX IF EXISTS idx_campaign_recipients_lure_code_released_at`) db.Exec(`DROP INDEX IF EXISTS idx_campaign_recipients_lure_code_folded`) db.Exec(`ALTER TABLE campaign_recipients DROP COLUMN lure_code_lookup`) db.Exec(`ALTER TABLE campaign_recipients DROP COLUMN lure_code_released_at`) db.Exec(`ALTER TABLE campaign_recipients DROP COLUMN lure_code_folded`) createIndex := func() error { return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_campaign_recipients_lure_code ON campaign_recipients (lure_code) WHERE lure_code IS NOT NULL`).Error } err := createIndex() if err == nil { return nil } // the index fails when two rows already hold the same code. keep the first // row for each code and release the rest, then build the index. // // this runs only after a failure, because the scan and grouping it needs // are too costly to pay on every startup. if dedupErr := db.Exec(`UPDATE campaign_recipients SET lure_code = NULL WHERE lure_code IS NOT NULL AND rowid NOT IN ( SELECT MIN(rowid) FROM campaign_recipients WHERE lure_code IS NOT NULL GROUP BY lure_code )`).Error; dedupErr != nil { return dedupErr } return createIndex() }