package repositories import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "strings" "agent-desk/internal/models" "agent-desk/internal/pkg/enums" "gorm.io/gorm" ) // ErrAmbiguousCustomerIdentity requires deliberate operator reconciliation. Never // choose the first row: the historical unique index also includes customer_id. var ErrAmbiguousCustomerIdentity = errors.New("ambiguous customer identity") // GetUniqueBy includes disabled rows when detecting conflicts. A disabled mapping // must not let a caller create a replacement customer and reacquire the identity. func (r *customerIdentityRepository) GetUniqueBy(db *gorm.DB, scope string, source enums.ExternalSource, externalID string) (*models.CustomerIdentity, error) { if strings.TrimSpace(string(source)) == "" || strings.TrimSpace(externalID) == "" { return nil, errors.New("invalid customer identity") } var identities []models.CustomerIdentity query := db.Where("identity_scope = ? AND external_source = ? AND external_id = ?", scope, source, strings.TrimSpace(externalID)) if key := ScopedCustomerIdentityKey(scope, source, externalID); key != nil { // Digest comparison is independent of MySQL's external_id collation. The // NULL branch also detects manually migrated/inconsistent scoped mappings. query = db.Where("scoped_key = ? OR (scoped_key IS NULL AND identity_scope = ? AND external_source = ? AND external_id = ?)", *key, scope, source, strings.TrimSpace(externalID)) } err := query.Limit(2).Find(&identities).Error if err != nil { return nil, err } if len(identities) > 1 { return nil, ErrAmbiguousCustomerIdentity } if len(identities) == 0 { return nil, nil } if identities[0].IdentityScope != scope || identities[0].ExternalSource != source || identities[0].ExternalID != strings.TrimSpace(externalID) { return nil, ErrAmbiguousCustomerIdentity } if identities[0].Status != enums.StatusOk { return nil, errors.New("inactive customer identity") } return &identities[0], nil } // ScopedCustomerIdentityKey gives new scoped registrations a database-enforced // claim without imposing a new unique constraint on dirty legacy data. Both // SQLite and MySQL permit multiple NULL values in a unique index. Encoding an // array avoids delimiter collisions and hashing avoids collation ambiguity. func ScopedCustomerIdentityKey(scope string, source enums.ExternalSource, externalID string) *string { if scope == "" { return nil } encoded, _ := json.Marshal([]string{scope, string(source), strings.TrimSpace(externalID)}) digest := sha256.Sum256(encoded) key := hex.EncodeToString(digest[:]) return &key }