add path param lure customization

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing
2026-07-27 17:57:53 +02:00
parent dfcfafd502
commit fd7ca01b22
29 changed files with 1818 additions and 75 deletions
+9
View File
@@ -64,6 +64,15 @@ type Campaign struct {
WebhookIncludeData string `gorm:"not null;default:'full'"`
WebhookEvents int `gorm:"not null;default:0"`
// LureURLMode, LureCodeAlgo and LureCodeLength are snapshotted from the
// campaign template when the campaign is scheduled. Reading them from the
// campaign rather than the template keeps recipients added later to a self
// managed campaign consistent with the ones already scheduled, and keeps a
// template edit from changing the form of URLs already delivered.
LureURLMode string `gorm:"not null;default:'query'"`
LureCodeAlgo string `gorm:"not null;default:'crockford32'"`
LureCodeLength int `gorm:"not null;default:12"`
// has one
CampaignTemplateID *uuid.UUID `gorm:"index;type:uuid;"`
CampaignTemplate *CampaignTemplate
+63
View File
@@ -4,6 +4,7 @@ import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
@@ -45,8 +46,70 @@ type CampaignRecipient struct {
// 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()
}
+12
View File
@@ -21,6 +21,18 @@ type CampaignTemplate struct {
URLPath string `gorm:"not null;default:'';index"`
// LureURLMode selects how a delivered lure URL carries the recipient
// identifier. "query" appends ?id=<uuid>, "path" appends /<code> after
// URLPath. The resolver accepts both forms regardless of this setting, so
// changing it never breaks links that are already out.
LureURLMode string `gorm:"not null;default:'query'"`
// LureCodeAlgo names the generator that allocates codes for campaigns
// created from this template.
LureCodeAlgo string `gorm:"not null;default:'crockford32'"`
// LureCodeLength is the number of characters in a generated code. Shorter
// codes are easier to read aloud and easier to guess.
LureCodeLength int `gorm:"not null;default:12"`
// IsUsable indicates if a template is usable based on if it has all the required
// data such as domainID, landingPage and etc to be used in a campaign
IsUsable bool `gorm:"not null;default:false;index"`