package repository import ( "context" "regexp" "strings" 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 if existing.RemovedAt > 0 { existing.RemovedAt = 0 existing.Stage = domain.StageNewFound existing.NeedsFollowUp = false existing.LastTouchAt = c.LastTouchAt } 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) RemoveContact(ctx context.Context, ownerUID int64, id string, at int64) error { if at <= 0 { at = domain.NowNano() } _, err := s.followups.UpdateMany(ctx, bson.M{ "owner_uid": ownerUID, "contact_id": id, "status": bson.M{"$in": bson.A{ domain.FollowUpScheduled, domain.FollowUpSnoozed, domain.FollowUpNotified, }}, }, bson.M{"$set": bson.M{"status": domain.FollowUpDone, "updated_at": at}}) if err != nil { return err } res, err := s.contacts.UpdateOne(ctx, bson.M{ "_id": id, "owner_uid": ownerUID, "$or": bson.A{ bson.M{"removed_at": bson.M{"$exists": false}}, bson.M{"removed_at": 0}, }, }, bson.M{"$set": bson.M{ "removed_at": at, "needs_follow_up": false, "updated_at": at, }}) if err != nil { return err } if res.MatchedCount == 0 { return domain.ErrNotFound } return nil } func activeContactQuery(ownerUID int64) bson.M { return bson.M{ "owner_uid": ownerUID, "$and": bson.A{bson.M{"$or": bson.A{ bson.M{"removed_at": bson.M{"$exists": false}}, bson.M{"removed_at": 0}, }}}, } } func (s *MonStore) ListContacts(ctx context.Context, ownerUID int64, f domain.ContactListFilter) ([]*domain.Contact, int64, error) { q := activeContactQuery(ownerUID) if search := strings.TrimSpace(f.Query); search != "" { pattern := regexp.QuoteMeta(search) q["$and"] = append(q["$and"].(bson.A), bson.M{"$or": bson.A{ bson.M{"author_handle": bson.M{"$regex": pattern, "$options": "i"}}, bson.M{"display_name": bson.M{"$regex": pattern, "$options": "i"}}, }}) } 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) { // Counts describe the complete active pipeline, not just the current page. var list []*domain.Contact err := s.contacts.Find(ctx, &list, activeContactQuery(ownerUID), options.Find().SetProjection(bson.M{"stage": 1, "needs_follow_up": 1})) 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 // notified 也要再掃:第二次通知(進而 escalated)靠的是它下一次到期。 // snoozed 已不再寫入,保留以相容既有資料。 err := s.followups.Find(ctx, &list, bson.M{ "status": bson.M{"$in": []string{ domain.FollowUpScheduled, domain.FollowUpSnoozed, domain.FollowUpNotified, }}, "due_at": bson.M{"$lte": now}, }, options.Find().SetLimit(int64(limit))) return list, err }