package repository import ( "context" "fmt" "sort" "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] // 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) 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 { 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 { 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 { if (x.Status == domain.FollowUpScheduled || x.Status == domain.FollowUpSnoozed) && x.DueAt <= now { cp := *x out = append(out, &cp) } } if limit > 0 && len(out) > limit { out = out[:limit] } return out, nil }