package repository import ( "context" libmongo "apps/backend/internal/lib/mongo" "apps/backend/internal/module/crm/domain" "github.com/zeromicro/go-zero/core/stores/mon" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" ) type MonStore struct { contacts *mon.Model touches *mon.Model followups *mon.Model } func NewMonStore(uri, database string) *MonStore { uri = libmongo.MustMongoURI(uri) return &MonStore{ contacts: mon.MustNewModel(uri, database, "crm_contacts"), touches: mon.MustNewModel(uri, database, "crm_touches"), followups: mon.MustNewModel(uri, database, "crm_followups"), } } func (s *MonStore) UpsertContactByIdentity(ctx context.Context, c *domain.Contact) (*domain.Contact, error) { if err := c.Normalize(); err != nil { return nil, err } filter := bson.M{ "owner_uid": c.OwnerUID, "source_platform": c.SourcePlatform, "author_handle": c.AuthorHandle, } var existing domain.Contact err := s.contacts.FindOne(ctx, &existing, filter) if err == nil { // merge seen := map[string]bool{} for _, id := range existing.OpportunityIDs { seen[id] = true } for _, id := range c.OpportunityIDs { if id != "" && !seen[id] { existing.OpportunityIDs = append(existing.OpportunityIDs, id) } } if c.TopIntentScore > existing.TopIntentScore { existing.TopIntentScore = c.TopIntentScore existing.TopIntentBand = c.TopIntentBand } existing.UpdatedAt = domain.NowNano() _, err = s.contacts.ReplaceOne(ctx, bson.M{"_id": existing.ID}, &existing) return &existing, err } if err != mon.ErrNotFound { return nil, err } if c.ID == "" { c.ID = domain.NewID() } now := domain.NowNano() if c.CreatedAt == 0 { c.CreatedAt = now } c.UpdatedAt = now _, err = s.contacts.InsertOne(ctx, c) if err != nil && mongo.IsDuplicateKeyError(err) { return s.UpsertContactByIdentity(ctx, c) } return c, err } func (s *MonStore) GetContact(ctx context.Context, id string) (*domain.Contact, error) { var c domain.Contact err := s.contacts.FindOne(ctx, &c, bson.M{"_id": id}) if err == mon.ErrNotFound { return nil, domain.ErrNotFound } if err != nil { return nil, err } return &c, nil } func (s *MonStore) SaveContact(ctx context.Context, c *domain.Contact) error { _, err := s.contacts.ReplaceOne(ctx, bson.M{"_id": c.ID}, c, options.Replace().SetUpsert(true)) return err } func (s *MonStore) ListContacts(ctx context.Context, ownerUID int64, f domain.ContactListFilter) ([]*domain.Contact, int64, error) { q := bson.M{"owner_uid": ownerUID} if f.Stage != "" { q["stage"] = f.Stage } if f.FollowUp != nil { q["needs_follow_up"] = *f.FollowUp } if f.Band != "" { q["top_intent_band"] = f.Band } total, err := s.contacts.CountDocuments(ctx, q) if err != nil { return nil, 0, err } page, ps := f.Page, f.PageSize if page < 1 { page = 1 } if ps < 1 { ps = 20 } sortKey := "last_touch_at" if f.Sort == "intent_score" { sortKey = "top_intent_score" } var list []*domain.Contact err = s.contacts.Find(ctx, &list, q, options.Find(). SetSort(bson.D{{Key: sortKey, Value: -1}}). SetSkip(int64((page-1)*ps)). SetLimit(int64(ps))) return list, total, err } func (s *MonStore) CountByStage(ctx context.Context, ownerUID int64) (map[string]int, error) { // simple: list all and count (contacts per owner are bounded) list, _, err := s.ListContacts(ctx, ownerUID, domain.ContactListFilter{Page: 1, PageSize: 500}) if err != nil { return nil, err } out := map[string]int{} follow := 0 for _, c := range list { out[c.Stage]++ if c.NeedsFollowUp { follow++ } } out["needs_follow_up"] = follow return out, nil } func (s *MonStore) InsertTouch(ctx context.Context, t *domain.ContactTouch) error { if t.ID == "" { t.ID = domain.NewID() } _, err := s.touches.InsertOne(ctx, t) return err } func (s *MonStore) ListTouches(ctx context.Context, ownerUID int64, contactID string, page, pageSize int) ([]*domain.ContactTouch, int64, error) { q := bson.M{"owner_uid": ownerUID, "contact_id": contactID} total, err := s.touches.CountDocuments(ctx, q) if err != nil { return nil, 0, err } if page < 1 { page = 1 } if pageSize < 1 { pageSize = 20 } var list []*domain.ContactTouch err = s.touches.Find(ctx, &list, q, options.Find(). SetSort(bson.D{{Key: "created_at", Value: -1}}). SetSkip(int64((page-1)*pageSize)). SetLimit(int64(pageSize))) return list, total, err } func (s *MonStore) SaveFollowUp(ctx context.Context, f *domain.FollowUp) error { _, err := s.followups.ReplaceOne(ctx, bson.M{"_id": f.ID}, f, options.Replace().SetUpsert(true)) return err } func (s *MonStore) GetFollowUp(ctx context.Context, id string) (*domain.FollowUp, error) { var f domain.FollowUp err := s.followups.FindOne(ctx, &f, bson.M{"_id": id}) if err == mon.ErrNotFound { return nil, domain.ErrNotFound } if err != nil { return nil, err } return &f, nil } func (s *MonStore) ListFollowUps(ctx context.Context, ownerUID int64, f domain.FollowUpListFilter) ([]*domain.FollowUp, int64, error) { q := bson.M{"owner_uid": ownerUID} if f.Status != "" { q["status"] = f.Status } total, err := s.followups.CountDocuments(ctx, q) if err != nil { return nil, 0, err } page, ps := f.Page, f.PageSize if page < 1 { page = 1 } if ps < 1 { ps = 20 } var list []*domain.FollowUp err = s.followups.Find(ctx, &list, q, options.Find(). SetSort(bson.D{{Key: "due_at", Value: 1}}). SetSkip(int64((page-1)*ps)). SetLimit(int64(ps))) return list, total, err } func (s *MonStore) ListDueFollowUps(ctx context.Context, now int64, limit int) ([]*domain.FollowUp, error) { if limit <= 0 { limit = 50 } var list []*domain.FollowUp err := s.followups.Find(ctx, &list, bson.M{ "status": bson.M{"$in": []string{domain.FollowUpScheduled, domain.FollowUpSnoozed}}, "due_at": bson.M{"$lte": now}, }, options.Find().SetLimit(int64(limit))) return list, err }