324 lines
7.8 KiB
Go
324 lines
7.8 KiB
Go
package repository
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
|
||
"apps/backend/internal/module/crm/domain"
|
||
)
|
||
|
||
type Memory struct {
|
||
mu sync.Mutex
|
||
contacts map[string]*domain.Contact
|
||
identity map[string]string // owner|platform|handle → id
|
||
touches map[string]*domain.ContactTouch
|
||
followups map[string]*domain.FollowUp
|
||
}
|
||
|
||
func NewMemory() *Memory {
|
||
return &Memory{
|
||
contacts: map[string]*domain.Contact{},
|
||
identity: map[string]string{},
|
||
touches: map[string]*domain.ContactTouch{},
|
||
followups: map[string]*domain.FollowUp{},
|
||
}
|
||
}
|
||
|
||
func idKey(owner int64, platform, handle string) string {
|
||
return fmt.Sprintf("%d|%s|%s", owner, platform, handle)
|
||
}
|
||
|
||
func (m *Memory) UpsertContactByIdentity(_ context.Context, c *domain.Contact) (*domain.Contact, error) {
|
||
if err := c.Normalize(); err != nil {
|
||
return nil, err
|
||
}
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
key := idKey(c.OwnerUID, c.SourcePlatform, c.AuthorHandle)
|
||
if id, ok := m.identity[key]; ok {
|
||
ex := m.contacts[id]
|
||
if ex.RemovedAt > 0 {
|
||
ex.RemovedAt = 0
|
||
ex.Stage = domain.StageNewFound
|
||
ex.NeedsFollowUp = false
|
||
ex.LastTouchAt = c.LastTouchAt
|
||
}
|
||
// merge opportunity ids
|
||
seen := map[string]bool{}
|
||
for _, x := range ex.OpportunityIDs {
|
||
seen[x] = true
|
||
}
|
||
for _, x := range c.OpportunityIDs {
|
||
if x != "" && !seen[x] {
|
||
ex.OpportunityIDs = append(ex.OpportunityIDs, x)
|
||
}
|
||
}
|
||
if c.TopIntentScore > ex.TopIntentScore {
|
||
ex.TopIntentScore = c.TopIntentScore
|
||
ex.TopIntentBand = c.TopIntentBand
|
||
}
|
||
ex.UpdatedAt = domain.NowNano()
|
||
cp := *ex
|
||
return &cp, nil
|
||
}
|
||
if c.ID == "" {
|
||
c.ID = domain.NewID()
|
||
}
|
||
now := domain.NowNano()
|
||
if c.CreatedAt == 0 {
|
||
c.CreatedAt = now
|
||
}
|
||
c.UpdatedAt = now
|
||
cp := *c
|
||
cp.OpportunityIDs = append([]string(nil), c.OpportunityIDs...)
|
||
m.contacts[cp.ID] = &cp
|
||
m.identity[key] = cp.ID
|
||
out := cp
|
||
out.OpportunityIDs = append([]string(nil), cp.OpportunityIDs...)
|
||
return &out, nil
|
||
}
|
||
|
||
func (m *Memory) GetContact(_ context.Context, id string) (*domain.Contact, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
c, ok := m.contacts[id]
|
||
if !ok {
|
||
return nil, domain.ErrNotFound
|
||
}
|
||
cp := *c
|
||
cp.OpportunityIDs = append([]string(nil), c.OpportunityIDs...)
|
||
cp.MergedFrom = append([]string(nil), c.MergedFrom...)
|
||
return &cp, nil
|
||
}
|
||
|
||
func (m *Memory) SaveContact(_ context.Context, c *domain.Contact) error {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
cp := *c
|
||
cp.OpportunityIDs = append([]string(nil), c.OpportunityIDs...)
|
||
cp.MergedFrom = append([]string(nil), c.MergedFrom...)
|
||
m.contacts[c.ID] = &cp
|
||
m.identity[idKey(c.OwnerUID, c.SourcePlatform, c.AuthorHandle)] = c.ID
|
||
return nil
|
||
}
|
||
|
||
func (m *Memory) RemoveContact(_ context.Context, ownerUID int64, id string, at int64) error {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
c, ok := m.contacts[id]
|
||
if !ok || c.RemovedAt > 0 {
|
||
return domain.ErrNotFound
|
||
}
|
||
if c.OwnerUID != ownerUID {
|
||
return domain.ErrForbidden
|
||
}
|
||
if at <= 0 {
|
||
at = domain.NowNano()
|
||
}
|
||
c.RemovedAt = at
|
||
c.NeedsFollowUp = false
|
||
c.UpdatedAt = at
|
||
for _, followUp := range m.followups {
|
||
if followUp.OwnerUID != ownerUID || followUp.ContactID != id {
|
||
continue
|
||
}
|
||
if followUp.Status == domain.FollowUpScheduled || followUp.Status == domain.FollowUpSnoozed || followUp.Status == domain.FollowUpNotified {
|
||
followUp.Status = domain.FollowUpDone
|
||
followUp.UpdatedAt = at
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (m *Memory) ListContacts(_ context.Context, ownerUID int64, f domain.ContactListFilter) ([]*domain.Contact, int64, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
matched := make([]*domain.Contact, 0)
|
||
for _, c := range m.contacts {
|
||
if c.OwnerUID != ownerUID || c.RemovedAt > 0 {
|
||
continue
|
||
}
|
||
q := strings.ToLower(strings.TrimSpace(f.Query))
|
||
if q != "" && !strings.Contains(strings.ToLower(c.AuthorHandle), q) && !strings.Contains(strings.ToLower(c.DisplayName), q) {
|
||
continue
|
||
}
|
||
if f.Stage != "" && c.Stage != f.Stage {
|
||
continue
|
||
}
|
||
if f.FollowUp != nil && c.NeedsFollowUp != *f.FollowUp {
|
||
continue
|
||
}
|
||
if f.Band != "" && c.TopIntentBand != f.Band {
|
||
continue
|
||
}
|
||
cp := *c
|
||
cp.OpportunityIDs = append([]string(nil), c.OpportunityIDs...)
|
||
matched = append(matched, &cp)
|
||
}
|
||
sort.Slice(matched, func(i, j int) bool {
|
||
if f.Sort == "intent_score" {
|
||
if matched[i].TopIntentScore != matched[j].TopIntentScore {
|
||
return matched[i].TopIntentScore > matched[j].TopIntentScore
|
||
}
|
||
}
|
||
if matched[i].LastTouchAt != matched[j].LastTouchAt {
|
||
return matched[i].LastTouchAt > matched[j].LastTouchAt
|
||
}
|
||
return matched[i].CreatedAt > matched[j].CreatedAt
|
||
})
|
||
total := int64(len(matched))
|
||
page, ps := f.Page, f.PageSize
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if ps < 1 {
|
||
ps = 20
|
||
}
|
||
start := (page - 1) * ps
|
||
if start >= len(matched) {
|
||
return nil, total, nil
|
||
}
|
||
end := start + ps
|
||
if end > len(matched) {
|
||
end = len(matched)
|
||
}
|
||
return matched[start:end], total, nil
|
||
}
|
||
|
||
func (m *Memory) CountByStage(_ context.Context, ownerUID int64) (map[string]int, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
out := map[string]int{}
|
||
follow := 0
|
||
for _, c := range m.contacts {
|
||
if c.OwnerUID != ownerUID || c.RemovedAt > 0 {
|
||
continue
|
||
}
|
||
out[c.Stage]++
|
||
if c.NeedsFollowUp {
|
||
follow++
|
||
}
|
||
}
|
||
out["needs_follow_up"] = follow
|
||
return out, nil
|
||
}
|
||
|
||
func (m *Memory) InsertTouch(_ context.Context, t *domain.ContactTouch) error {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
if t.ID == "" {
|
||
t.ID = domain.NewID()
|
||
}
|
||
cp := *t
|
||
m.touches[t.ID] = &cp
|
||
return nil
|
||
}
|
||
|
||
func (m *Memory) ListTouches(_ context.Context, ownerUID int64, contactID string, page, pageSize int) ([]*domain.ContactTouch, int64, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
matched := make([]*domain.ContactTouch, 0)
|
||
for _, t := range m.touches {
|
||
if t.OwnerUID == ownerUID && t.ContactID == contactID {
|
||
cp := *t
|
||
matched = append(matched, &cp)
|
||
}
|
||
}
|
||
sort.Slice(matched, func(i, j int) bool { return matched[i].CreatedAt > matched[j].CreatedAt })
|
||
total := int64(len(matched))
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize < 1 {
|
||
pageSize = 20
|
||
}
|
||
start := (page - 1) * pageSize
|
||
if start >= len(matched) {
|
||
return nil, total, nil
|
||
}
|
||
end := start + pageSize
|
||
if end > len(matched) {
|
||
end = len(matched)
|
||
}
|
||
return matched[start:end], total, nil
|
||
}
|
||
|
||
func (m *Memory) SaveFollowUp(_ context.Context, f *domain.FollowUp) error {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
cp := *f
|
||
m.followups[f.ID] = &cp
|
||
return nil
|
||
}
|
||
|
||
func (m *Memory) GetFollowUp(_ context.Context, id string) (*domain.FollowUp, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
f, ok := m.followups[id]
|
||
if !ok {
|
||
return nil, domain.ErrNotFound
|
||
}
|
||
cp := *f
|
||
return &cp, nil
|
||
}
|
||
|
||
func (m *Memory) ListFollowUps(_ context.Context, ownerUID int64, f domain.FollowUpListFilter) ([]*domain.FollowUp, int64, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
matched := make([]*domain.FollowUp, 0)
|
||
for _, x := range m.followups {
|
||
if x.OwnerUID != ownerUID {
|
||
continue
|
||
}
|
||
if f.Status != "" && x.Status != f.Status {
|
||
continue
|
||
}
|
||
cp := *x
|
||
matched = append(matched, &cp)
|
||
}
|
||
sort.Slice(matched, func(i, j int) bool { return matched[i].DueAt < matched[j].DueAt })
|
||
total := int64(len(matched))
|
||
page, ps := f.Page, f.PageSize
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if ps < 1 {
|
||
ps = 20
|
||
}
|
||
start := (page - 1) * ps
|
||
if start >= len(matched) {
|
||
return nil, total, nil
|
||
}
|
||
end := start + ps
|
||
if end > len(matched) {
|
||
end = len(matched)
|
||
}
|
||
return matched[start:end], total, nil
|
||
}
|
||
|
||
func (m *Memory) ListDueFollowUps(_ context.Context, now int64, limit int) ([]*domain.FollowUp, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
out := make([]*domain.FollowUp, 0)
|
||
for _, x := range m.followups {
|
||
// notified 也要再掃:第二次通知(進而 escalated)靠的是它下一次到期。
|
||
// snoozed 已不再寫入,保留以相容既有資料。
|
||
switch x.Status {
|
||
case domain.FollowUpScheduled, domain.FollowUpSnoozed, domain.FollowUpNotified:
|
||
default:
|
||
continue
|
||
}
|
||
if x.DueAt <= now {
|
||
cp := *x
|
||
out = append(out, &cp)
|
||
}
|
||
}
|
||
if limit > 0 && len(out) > limit {
|
||
out = out[:limit]
|
||
}
|
||
return out, nil
|
||
}
|