252 lines
8.2 KiB
Go
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
|
|
}
|