Files
agent-desk-ng/internal/repositories/customer_identity_scope.go
mono 2f7a491666 feat: harden support, localize knowledge and add operator availability [skip ci]
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.
2026-10-16 15:21:42 +00:00

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
}