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