300 lines
11 KiB
Go
300 lines
11 KiB
Go
package duty_schedule
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
|
|
"go-admin/app/bell/contact"
|
|
)
|
|
|
|
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 }
|
|
|
|
func NewService(db *gorm.DB) Service { return Service{DB: db} }
|
|
func (s Service) List(ctx context.Context, q PageQuery) ([]GroupView, int64, error) {
|
|
p, z := pageValues(q.PageIndex, q.PageSize)
|
|
db := s.DB.WithContext(ctx).Model(&Group{})
|
|
if name := strings.TrimSpace(q.Name); name != "" {
|
|
db = db.Where("name ILIKE ?", "%"+name+"%")
|
|
}
|
|
if q.Enabled != nil {
|
|
db = db.Where("enabled = ?", *q.Enabled)
|
|
}
|
|
var count int64
|
|
if err := db.Count(&count).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
var groups []Group
|
|
if err := db.Order("created_at DESC,id DESC").Offset((p - 1) * z).Limit(z).Find(&groups).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
views := make([]GroupView, 0, len(groups))
|
|
for _, g := range groups {
|
|
v, err := s.view(ctx, g)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
views = append(views, v)
|
|
}
|
|
return views, count, nil
|
|
}
|
|
func (s Service) CreateGroup(ctx context.Context, input GroupInput, actor int) (GroupView, error) {
|
|
input, err := normalizeGroup(input)
|
|
if err != nil {
|
|
return GroupView{}, err
|
|
}
|
|
now := time.Now().UTC()
|
|
item := Group{ID: uuid.NewString(), Name: input.Name, Timezone: input.Timezone, 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 audit(tx, item.ID, "group_created", item, actor)
|
|
})
|
|
return GroupView{Group: item, Members: []Member{}, Schedules: []ScheduleView{}, Overrides: []Override{}}, err
|
|
}
|
|
func (s Service) UpdateGroup(ctx context.Context, id string, input GroupInput, actor int) (GroupView, error) {
|
|
input, err := normalizeGroup(input)
|
|
if err != nil {
|
|
return GroupView{}, err
|
|
}
|
|
if input.ExpectedVersion < 1 {
|
|
return GroupView{}, ErrInvalid
|
|
}
|
|
var item Group
|
|
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
r := tx.Model(&Group{}).Where("id=? AND version=?", id, input.ExpectedVersion).Updates(map[string]any{"name": input.Name, "timezone": input.Timezone, "version": gorm.Expr("version+1"), "updated_by": actor, "updated_at": time.Now().UTC()})
|
|
if r.Error != nil {
|
|
return r.Error
|
|
}
|
|
if r.RowsAffected != 1 {
|
|
return ErrConflict
|
|
}
|
|
if err := tx.First(&item, "id=?", id).Error; err != nil {
|
|
return err
|
|
}
|
|
return audit(tx, id, "group_updated", item, actor)
|
|
})
|
|
if err != nil {
|
|
return GroupView{}, err
|
|
}
|
|
return s.view(ctx, item)
|
|
}
|
|
func (s Service) AddMember(ctx context.Context, groupID string, input MemberInput, actor int) (Member, error) {
|
|
input.Role = strings.ToLower(strings.TrimSpace(input.Role))
|
|
if input.Role != "primary" && input.Role != "backup" {
|
|
return Member{}, ErrInvalid
|
|
}
|
|
now := time.Now().UTC()
|
|
item := Member{GroupID: groupID, ContactID: input.ContactID, Role: input.Role, CreatedBy: actor, CreatedAt: now}
|
|
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := assertEnabledContact(tx, input.ContactID); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.First(&Group{}, "id=?", groupID).Error; err != nil {
|
|
return ErrNotFound
|
|
}
|
|
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "group_id"}, {Name: "contact_id"}}, DoUpdates: clause.AssignmentColumns([]string{"role", "created_by", "created_at"})}).Create(&item).Error; err != nil {
|
|
return err
|
|
}
|
|
return audit(tx, groupID, "member_saved", item, actor)
|
|
})
|
|
return item, err
|
|
}
|
|
func (s Service) CreateSchedule(ctx context.Context, groupID string, input ScheduleInput, actor int) (ScheduleView, error) {
|
|
if input.EffectiveFrom.IsZero() || (input.EffectiveTo != nil && !input.EffectiveTo.After(input.EffectiveFrom)) {
|
|
return ScheduleView{}, ErrInvalid
|
|
}
|
|
if err := validateSlots(input.Slots); err != nil {
|
|
return ScheduleView{}, err
|
|
}
|
|
var result ScheduleView
|
|
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var group Group
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&group, "id=?", groupID).Error; err != nil {
|
|
return ErrNotFound
|
|
}
|
|
for _, slot := range input.Slots {
|
|
if err := assertGroupMember(tx, groupID, slot.PrimaryContactID); err != nil {
|
|
return err
|
|
}
|
|
if err := assertGroupMember(tx, groupID, slot.BackupContactID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var latest int
|
|
tx.Model(&ScheduleVersion{}).Where("group_id=?", groupID).Select("coalesce(max(version),0)").Scan(&latest)
|
|
now := time.Now().UTC()
|
|
version := ScheduleVersion{ID: uuid.NewString(), GroupID: groupID, Version: latest + 1, Timezone: group.Timezone, EffectiveFrom: input.EffectiveFrom.UTC(), EffectiveTo: input.EffectiveTo, Status: "draft", CreatedBy: actor, CreatedAt: now}
|
|
if err := tx.Create(&version).Error; err != nil {
|
|
return err
|
|
}
|
|
slots := make([]RotationSlot, 0, len(input.Slots))
|
|
for _, in := range input.Slots {
|
|
slots = append(slots, RotationSlot{ID: uuid.NewString(), ScheduleVersionID: version.ID, Weekday: in.Weekday, StartMinute: in.StartMinute, EndMinute: in.EndMinute, PrimaryContactID: in.PrimaryContactID, BackupContactID: in.BackupContactID})
|
|
}
|
|
if err := tx.Create(&slots).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := audit(tx, groupID, "schedule_created", version, actor); err != nil {
|
|
return err
|
|
}
|
|
result = ScheduleView{ScheduleVersion: version, Slots: slots}
|
|
return nil
|
|
})
|
|
return result, err
|
|
}
|
|
func (s Service) Publish(ctx context.Context, id string, actor int) (ScheduleView, error) {
|
|
var result ScheduleView
|
|
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var item ScheduleVersion
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "id=?", id).Error; err != nil {
|
|
return ErrNotFound
|
|
}
|
|
if item.Status != "draft" {
|
|
return ErrConflict
|
|
}
|
|
var slots []RotationSlot
|
|
if err := tx.Where("schedule_version_id=?", id).Find(&slots).Error; err != nil {
|
|
return err
|
|
}
|
|
inputs := make([]SlotInput, 0, len(slots))
|
|
for _, v := range slots {
|
|
inputs = append(inputs, SlotInput{Weekday: v.Weekday, StartMinute: v.StartMinute, EndMinute: v.EndMinute, PrimaryContactID: v.PrimaryContactID, BackupContactID: v.BackupContactID})
|
|
}
|
|
if err := validateSlots(inputs); err != nil {
|
|
return err
|
|
}
|
|
for _, v := range slots {
|
|
if err := assertVerifiedContact(tx, v.PrimaryContactID); err != nil {
|
|
return err
|
|
}
|
|
if err := assertVerifiedContact(tx, v.BackupContactID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
now := time.Now().UTC()
|
|
if err := tx.Model(&item).Updates(map[string]any{"status": "published", "published_by": actor, "published_at": now}).Error; err != nil {
|
|
return err
|
|
}
|
|
item.Status = "published"
|
|
item.PublishedBy = &actor
|
|
item.PublishedAt = &now
|
|
if err := audit(tx, item.GroupID, "schedule_published", item, actor); err != nil {
|
|
return err
|
|
}
|
|
result = ScheduleView{ScheduleVersion: item, Slots: slots}
|
|
return nil
|
|
})
|
|
return result, err
|
|
}
|
|
func (s Service) CreateOverride(ctx context.Context, groupID string, input OverrideInput, actor int) (Override, error) {
|
|
input, err := normalizeOverride(input)
|
|
if err != nil {
|
|
return Override{}, err
|
|
}
|
|
item := Override{ID: uuid.NewString(), GroupID: groupID, OriginalContactID: input.OriginalContactID, ReplacementContactID: input.ReplacementContactID, StartsAt: input.StartsAt.UTC(), EndsAt: input.EndsAt.UTC(), Reason: input.Reason, CreatedBy: actor, CreatedAt: time.Now().UTC()}
|
|
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := assertGroupMember(tx, groupID, input.OriginalContactID); err != nil {
|
|
return err
|
|
}
|
|
if err := assertGroupMember(tx, groupID, input.ReplacementContactID); err != nil {
|
|
return err
|
|
}
|
|
var overlaps int64
|
|
if err := tx.Model(&Override{}).Where("group_id=? AND original_contact_id=? AND starts_at < ? AND ends_at > ?", groupID, input.OriginalContactID, item.EndsAt, item.StartsAt).Count(&overlaps).Error; err != nil {
|
|
return err
|
|
}
|
|
if overlaps > 0 {
|
|
return ErrConflict
|
|
}
|
|
if err := tx.Create(&item).Error; err != nil {
|
|
return err
|
|
}
|
|
return audit(tx, groupID, "override_created", item, actor)
|
|
})
|
|
return item, err
|
|
}
|
|
func (s Service) view(ctx context.Context, g Group) (GroupView, error) {
|
|
v := GroupView{Group: g, Members: []Member{}, Schedules: []ScheduleView{}, Overrides: []Override{}}
|
|
if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("role,contact_id").Find(&v.Members).Error; err != nil {
|
|
return v, err
|
|
}
|
|
var versions []ScheduleVersion
|
|
if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("version DESC").Find(&versions).Error; err != nil {
|
|
return v, err
|
|
}
|
|
for _, sv := range versions {
|
|
var slots []RotationSlot
|
|
if err := s.DB.WithContext(ctx).Where("schedule_version_id=?", sv.ID).Order("weekday,start_minute").Find(&slots).Error; err != nil {
|
|
return v, err
|
|
}
|
|
v.Schedules = append(v.Schedules, ScheduleView{ScheduleVersion: sv, Slots: slots})
|
|
}
|
|
if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("starts_at DESC").Limit(50).Find(&v.Overrides).Error; err != nil {
|
|
return v, err
|
|
}
|
|
return v, nil
|
|
}
|
|
func assertEnabledContact(tx *gorm.DB, id string) error {
|
|
var c contact.Contact
|
|
if err := tx.Where("id=? AND enabled=true", id).First(&c).Error; err != nil {
|
|
return ErrInvalid
|
|
}
|
|
return nil
|
|
}
|
|
func assertGroupMember(tx *gorm.DB, groupID, contactID string) error {
|
|
if err := assertEnabledContact(tx, contactID); err != nil {
|
|
return err
|
|
}
|
|
var count int64
|
|
if err := tx.Model(&Member{}).Where("group_id=? AND contact_id=?", groupID, contactID).Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count != 1 {
|
|
return ErrInvalid
|
|
}
|
|
return nil
|
|
}
|
|
func assertVerifiedContact(tx *gorm.DB, contactID string) error {
|
|
if err := assertEnabledContact(tx, contactID); err != nil {
|
|
return err
|
|
}
|
|
var count int64
|
|
err := tx.Raw(`SELECT count(*) FROM bell_contact_channels c WHERE c.contact_id=? AND (SELECT v.status FROM bell_contact_channel_validations v WHERE v.channel_id=c.id ORDER BY v.created_at DESC,v.id DESC LIMIT 1)='verified'`, contactID).Scan(&count).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return ErrInvalid
|
|
}
|
|
return nil
|
|
}
|
|
func audit(tx *gorm.DB, groupID, action string, value any, actor int) error {
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return tx.Create(&AuditFact{ID: uuid.NewString(), GroupID: groupID, Action: action, Snapshot: data, ActorID: actor, CreatedAt: time.Now().UTC()}).Error
|
|
}
|
|
func pageValues(p, s int) (int, int) {
|
|
if p < 1 {
|
|
p = 1
|
|
}
|
|
if s < 1 || s > 100 {
|
|
s = 20
|
|
}
|
|
return p, s
|
|
}
|