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

294 lines
7.7 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"
"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
}