Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.
Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.
Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.
Expected source tree: 0a4a7528e9.
64 lines
2.6 KiB
Go
64 lines
2.6 KiB
Go
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
|
|
}
|