Files
yovision/Bell/server/app/bell/contact/service.go
T

252 lines
8.2 KiB
Go

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
}