package contact import ( "context" "encoding/json" "errors" "strings" "time" "github.com/google/uuid" "gorm.io/gorm" ) type PageQuery struct { PageIndex int `form:"pageIndex"` PageSize int `form:"pageSize"` Name string `form:"name"` Enabled *bool `form:"enabled"` } type Service struct { DB *gorm.DB Key []byte } func NewService(db *gorm.DB, key []byte) Service { return Service{DB: db, Key: key} } func (s Service) List(ctx context.Context, query PageQuery) ([]ContactView, int64, error) { page, size := pageValues(query.PageIndex, query.PageSize) db := s.DB.WithContext(ctx).Model(&Contact{}) if name := strings.TrimSpace(query.Name); name != "" { db = db.Where("name ILIKE ? OR role ILIKE ?", "%"+name+"%", "%"+name+"%") } if query.Enabled != nil { db = db.Where("enabled = ?", *query.Enabled) } var count int64 if err := db.Count(&count).Error; err != nil { return nil, 0, err } var contacts []Contact if err := db.Order("created_at DESC,id DESC").Offset((page - 1) * size).Limit(size).Find(&contacts).Error; err != nil { return nil, 0, err } views := make([]ContactView, 0, len(contacts)) for _, item := range contacts { view, err := s.view(ctx, item) if err != nil { return nil, 0, err } views = append(views, view) } return views, count, nil } func (s Service) Create(ctx context.Context, input WriteInput, actor int) (ContactView, error) { input, err := normalizeContact(input) if err != nil { return ContactView{}, err } now := time.Now().UTC() item := Contact{ID: uuid.NewString(), Name: input.Name, Role: input.Role, Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor, CreatedAt: now, UpdatedAt: now} err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(&item).Error; err != nil { return err } return appendAudit(tx, item, "created", actor) }) if err != nil { return ContactView{}, err } return ContactView{Contact: item, Channels: []ChannelView{}}, nil } func (s Service) Update(ctx context.Context, id string, input WriteInput, actor int) (ContactView, error) { if _, err := uuid.Parse(id); err != nil { return ContactView{}, ErrNotFound } input, err := normalizeContact(input) if err != nil { return ContactView{}, err } if input.ExpectedVersion < 1 { return ContactView{}, ErrInvalid } var item Contact err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { result := tx.Model(&Contact{}).Where("id = ? AND version = ?", id, input.ExpectedVersion).Updates(map[string]any{"name": input.Name, "role": input.Role, "version": gorm.Expr("version + 1"), "updated_by": actor, "updated_at": time.Now().UTC()}) if result.Error != nil { return result.Error } if result.RowsAffected != 1 { var count int64 _ = tx.Model(&Contact{}).Where("id = ?", id).Count(&count).Error if count == 0 { return ErrNotFound } return ErrConflict } if err := tx.First(&item, "id = ?", id).Error; err != nil { return err } return appendAudit(tx, item, "updated", actor) }) if err != nil { return ContactView{}, err } return s.view(ctx, item) } func (s Service) SetEnabled(ctx context.Context, id string, enabled bool, expectedVersion, actor int) (ContactView, error) { if _, err := uuid.Parse(id); err != nil { return ContactView{}, ErrNotFound } if expectedVersion < 1 { return ContactView{}, ErrInvalid } var item Contact err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { result := tx.Model(&Contact{}).Where("id = ? AND version = ?", id, expectedVersion).Updates(map[string]any{"enabled": enabled, "version": gorm.Expr("version + 1"), "updated_by": actor, "updated_at": time.Now().UTC()}) if result.Error != nil { return result.Error } if result.RowsAffected != 1 { return ErrConflict } if err := tx.First(&item, "id = ?", id).Error; err != nil { return err } return appendAudit(tx, item, map[bool]string{true: "enabled", false: "disabled"}[enabled], actor) }) if err != nil { return ContactView{}, err } return s.view(ctx, item) } func (s Service) AddChannel(ctx context.Context, contactID string, input ChannelInput, actor int) (ChannelView, error) { if len(s.Key) != 32 { return ChannelView{}, ErrChannelKeyUnavailable } if _, err := uuid.Parse(contactID); err != nil { return ChannelView{}, ErrNotFound } input, err := normalizeChannel(input) if err != nil { return ChannelView{}, err } ciphertext, err := encryptAddress(s.Key, input.Address) if err != nil { return ChannelView{}, err } now := time.Now().UTC() item := Channel{ID: uuid.NewString(), ContactID: contactID, Kind: input.Kind, AddressCiphertext: ciphertext, AddressFingerprint: fingerprint(input.Address), AddressMasked: maskAddress(input.Address), CreatedBy: actor, CreatedAt: now} err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var contact Contact if err := tx.First(&contact, "id = ?", contactID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrNotFound } return err } if err := tx.Create(&item).Error; err != nil { return err } return appendAudit(tx, contact, "channel_added", actor) }) if err != nil { return ChannelView{}, err } return ChannelView{ID: item.ID, Kind: item.Kind, AddressMasked: item.AddressMasked, Status: "pending"}, nil } func (s Service) RecordValidation(ctx context.Context, channelID, status, detail string, actor int) (ChannelView, error) { status = strings.ToLower(strings.TrimSpace(status)) detail = strings.TrimSpace(detail) if status != "verified" && status != "failed" { return ChannelView{}, ErrInvalid } if len([]rune(detail)) > 256 || hasControl(detail) { return ChannelView{}, ErrInvalid } var channel Channel fact := ChannelValidation{ID: uuid.NewString(), ChannelID: channelID, Status: status, Detail: detail, ActorID: actor, CreatedAt: time.Now().UTC()} err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.First(&channel, "id = ?", channelID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrNotFound } return err } if err := tx.Create(&fact).Error; err != nil { return err } var c Contact if err := tx.First(&c, "id = ?", channel.ContactID).Error; err != nil { return err } return appendAudit(tx, c, "channel_validation_"+status, actor) }) if err != nil { return ChannelView{}, err } return ChannelView{ID: channel.ID, Kind: channel.Kind, AddressMasked: channel.AddressMasked, Status: status, ValidatedAt: &fact.CreatedAt}, nil } // DecryptChannelAddress is intentionally server-only. API responses never expose this value. func (s Service) DecryptChannelAddress(ctx context.Context, channelID string) (string, error) { var item Channel if err := s.DB.WithContext(ctx).First(&item, "id = ?", channelID).Error; err != nil { return "", err } return decryptAddress(s.Key, item.AddressCiphertext) } func (s Service) view(ctx context.Context, item Contact) (ContactView, error) { var channels []Channel if err := s.DB.WithContext(ctx).Where("contact_id = ?", item.ID).Order("created_at,id").Find(&channels).Error; err != nil { return ContactView{}, err } views := make([]ChannelView, 0, len(channels)) for _, ch := range channels { view := ChannelView{ID: ch.ID, Kind: ch.Kind, AddressMasked: ch.AddressMasked, Status: "pending"} var fact ChannelValidation err := s.DB.WithContext(ctx).Where("channel_id = ?", ch.ID).Order("created_at DESC,id DESC").Take(&fact).Error if err == nil { view.Status = fact.Status view.ValidatedAt = &fact.CreatedAt } else if !errors.Is(err, gorm.ErrRecordNotFound) { return ContactView{}, err } views = append(views, view) } return ContactView{Contact: item, Channels: views}, nil } func appendAudit(tx *gorm.DB, item Contact, action string, actor int) error { snapshot, err := json.Marshal(map[string]any{"id": item.ID, "name": item.Name, "role": item.Role, "enabled": item.Enabled, "version": item.Version}) if err != nil { return err } return tx.Create(&AuditFact{ID: uuid.NewString(), ContactID: item.ID, Action: action, Snapshot: snapshot, ActorID: actor, CreatedAt: time.Now().UTC()}).Error } func pageValues(page, size int) (int, int) { if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } return page, size }