thread-master/apps/backend/internal/module/crm/repository/memory.go

324 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}