Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
338 lines
11 KiB
Go
338 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/worker"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const TaskTypeSearchIndex = "search:index"
|
|
|
|
type searchIndexJob struct {
|
|
Operation string `json:"operation"`
|
|
Entity string `json:"entity"`
|
|
AccountID uint `json:"account_id"`
|
|
ID uint `json:"id"`
|
|
}
|
|
|
|
// DurableSearchIndexer defers Meilisearch write-side synchronization to the
|
|
// durable worker while keeping read paths pointed at the real SearchService.
|
|
type DurableSearchIndexer struct {
|
|
db *gorm.DB
|
|
worker *worker.WorkerPool
|
|
delegate SearchIndexer
|
|
}
|
|
|
|
func NewDurableSearchIndexer(db *gorm.DB, wp *worker.WorkerPool, delegate SearchIndexer) *DurableSearchIndexer {
|
|
indexer := &DurableSearchIndexer{db: db, worker: wp, delegate: delegate}
|
|
if wp != nil {
|
|
wp.Register(TaskTypeSearchIndex, indexer.perform)
|
|
}
|
|
return indexer
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) IndexConversation(ctx context.Context, conversation *model.Conversation) error {
|
|
if conversation == nil {
|
|
return nil
|
|
}
|
|
return i.enqueueOrIndex(ctx, "conversation", conversation.AccountID, conversation.ID, func() error {
|
|
enriched, err := i.loadConversation(ctx, conversation.AccountID, conversation.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if enriched == nil {
|
|
return nil
|
|
}
|
|
return i.delegate.IndexConversation(ctx, enriched)
|
|
})
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) DeleteConversation(ctx context.Context, accountID uint, id uint) error {
|
|
return i.enqueueOrDelete(ctx, "conversation", accountID, id, func() error { return i.delegate.DeleteConversation(ctx, accountID, id) })
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) IndexMessage(ctx context.Context, message *model.Message) error {
|
|
if message == nil {
|
|
return nil
|
|
}
|
|
return i.enqueueOrIndex(ctx, "message", message.AccountID, message.ID, func() error {
|
|
if enriched, err := i.loadMessage(ctx, message.AccountID, message.ID); err == nil && enriched != nil {
|
|
return i.delegate.IndexMessage(ctx, enriched)
|
|
}
|
|
return i.delegate.IndexMessage(ctx, message)
|
|
})
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) DeleteMessage(ctx context.Context, accountID uint, id uint) error {
|
|
return i.enqueueOrDelete(ctx, "message", accountID, id, func() error { return i.delegate.DeleteMessage(ctx, accountID, id) })
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) IndexContact(ctx context.Context, contact *model.Contact) error {
|
|
if contact == nil {
|
|
return nil
|
|
}
|
|
return i.enqueueOrIndex(ctx, "contact", contact.AccountID, contact.ID, func() error {
|
|
enriched, err := i.loadContact(ctx, contact.AccountID, contact.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if enriched == nil {
|
|
return nil
|
|
}
|
|
return i.delegate.IndexContact(ctx, enriched)
|
|
})
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) DeleteContact(ctx context.Context, accountID uint, id uint) error {
|
|
return i.enqueueOrDelete(ctx, "contact", accountID, id, func() error { return i.delegate.DeleteContact(ctx, accountID, id) })
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) IndexCompany(ctx context.Context, company *model.Company) error {
|
|
if company == nil {
|
|
return nil
|
|
}
|
|
return i.enqueueOrIndex(ctx, "company", company.AccountID, company.ID, func() error {
|
|
return i.delegate.IndexCompany(ctx, company)
|
|
})
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) DeleteCompany(ctx context.Context, accountID uint, id uint) error {
|
|
return i.enqueueOrDelete(ctx, "company", accountID, id, func() error { return i.delegate.DeleteCompany(ctx, accountID, id) })
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) IndexArticle(ctx context.Context, article *model.Article) error {
|
|
if article == nil {
|
|
return nil
|
|
}
|
|
return i.enqueueOrIndex(ctx, "article", article.AccountID, article.ID, func() error {
|
|
if enriched, err := i.loadArticle(ctx, article.AccountID, article.ID); err == nil && enriched != nil {
|
|
return i.delegate.IndexArticle(ctx, enriched)
|
|
}
|
|
return i.delegate.IndexArticle(ctx, article)
|
|
})
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) DeleteArticle(ctx context.Context, accountID uint, id uint) error {
|
|
return i.enqueueOrDelete(ctx, "article", accountID, id, func() error { return i.delegate.DeleteArticle(ctx, accountID, id) })
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) enqueueOrIndex(ctx context.Context, entity string, accountID, id uint, fallback func() error) error {
|
|
return i.enqueueOrRun(ctx, searchIndexJob{Operation: "index", Entity: entity, AccountID: accountID, ID: id}, fallback)
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) enqueueOrDelete(ctx context.Context, entity string, accountID, id uint, fallback func() error) error {
|
|
return i.enqueueOrRun(ctx, searchIndexJob{Operation: "delete", Entity: entity, AccountID: accountID, ID: id}, fallback)
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) enqueueOrRun(ctx context.Context, payload searchIndexJob, fallback func() error) error {
|
|
if i.delegate == nil {
|
|
return nil
|
|
}
|
|
if i.worker == nil {
|
|
return fallback()
|
|
}
|
|
_, err := i.worker.Enqueue(ctx, TaskTypeSearchIndex, payload, worker.WithQueue("search"), worker.WithMaxAttempts(3))
|
|
return err
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) perform(ctx context.Context, job *model.BackgroundJob) error {
|
|
if i.delegate == nil {
|
|
return nil
|
|
}
|
|
var payload searchIndexJob
|
|
if err := json.Unmarshal(job.Payload, &payload); err != nil {
|
|
return fmt.Errorf("unmarshal search index job: %w", err)
|
|
}
|
|
if payload.ID == 0 || payload.Entity == "" || payload.Operation == "" {
|
|
return fmt.Errorf("invalid search index job payload: %#v", payload)
|
|
}
|
|
switch payload.Operation {
|
|
case "index":
|
|
return i.performIndex(ctx, payload)
|
|
case "delete":
|
|
return i.performDelete(ctx, payload)
|
|
default:
|
|
return fmt.Errorf("unsupported search index operation %q", payload.Operation)
|
|
}
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) performIndex(ctx context.Context, payload searchIndexJob) error {
|
|
switch payload.Entity {
|
|
case "conversation":
|
|
item, err := i.loadConversation(ctx, payload.AccountID, payload.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if item == nil {
|
|
return nil
|
|
}
|
|
return i.delegate.IndexConversation(ctx, item)
|
|
case "message":
|
|
item, err := i.loadMessage(ctx, payload.AccountID, payload.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if item == nil {
|
|
return nil
|
|
}
|
|
return i.delegate.IndexMessage(ctx, item)
|
|
case "contact":
|
|
item, err := i.loadContact(ctx, payload.AccountID, payload.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if item == nil {
|
|
return nil
|
|
}
|
|
return i.delegate.IndexContact(ctx, item)
|
|
case "company":
|
|
var item model.Company
|
|
if err := i.load(ctx, payload, &item); err != nil {
|
|
return err
|
|
}
|
|
return i.delegate.IndexCompany(ctx, &item)
|
|
case "article":
|
|
item, err := i.loadArticle(ctx, payload.AccountID, payload.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if item == nil {
|
|
return nil
|
|
}
|
|
return i.delegate.IndexArticle(ctx, item)
|
|
default:
|
|
return fmt.Errorf("unsupported search index entity %q", payload.Entity)
|
|
}
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) loadContact(ctx context.Context, accountID, id uint) (*model.Contact, error) {
|
|
if i.db == nil {
|
|
return nil, nil
|
|
}
|
|
var item model.Contact
|
|
err := i.db.WithContext(ctx).Where("id = ? AND account_id = ?", id, accountID).First(&item).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, i.performDelete(ctx, searchIndexJob{Operation: "delete", Entity: "contact", AccountID: accountID, ID: id})
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
labels, err := contactLabelsForSearch(ctx, i.db, accountID, []uint{id})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
item.Labels = labels[id]
|
|
return &item, nil
|
|
}
|
|
|
|
func contactLabelsForSearch(ctx context.Context, db *gorm.DB, accountID uint, contactIDs []uint) (map[uint][]string, error) {
|
|
result := map[uint][]string{}
|
|
if db == nil || len(contactIDs) == 0 {
|
|
return result, nil
|
|
}
|
|
var rows []struct {
|
|
ContactID uint
|
|
Name string
|
|
}
|
|
if err := db.WithContext(ctx).Table("contact_labels").
|
|
Select("contact_labels.contact_id, tags.name").
|
|
Joins("JOIN tags ON tags.id = contact_labels.tag_id AND tags.account_id = contact_labels.account_id").
|
|
Where("contact_labels.account_id = ? AND contact_labels.contact_id IN ?", accountID, contactIDs).
|
|
Order("contact_labels.created_at ASC, tags.name ASC").
|
|
Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, row := range rows {
|
|
result[row.ContactID] = append(result[row.ContactID], row.Name)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) loadMessage(ctx context.Context, accountID, id uint) (*model.Message, error) {
|
|
if i.db == nil {
|
|
return nil, nil
|
|
}
|
|
var item model.Message
|
|
err := i.db.WithContext(ctx).
|
|
Preload("Conversation").
|
|
Preload("Attachments", func(db *gorm.DB) *gorm.DB { return db.Order("id ASC") }).
|
|
Where("id = ? AND account_id = ?", id, accountID).
|
|
First(&item).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, i.performDelete(ctx, searchIndexJob{Operation: "delete", Entity: "message", AccountID: accountID, ID: id})
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &item, nil
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) loadArticle(ctx context.Context, accountID, id uint) (*model.Article, error) {
|
|
if i.db == nil {
|
|
return nil, nil
|
|
}
|
|
var item model.Article
|
|
err := i.db.WithContext(ctx).
|
|
Preload("Portal").
|
|
Preload("Category").
|
|
Where("id = ? AND account_id = ?", id, accountID).
|
|
First(&item).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, i.performDelete(ctx, searchIndexJob{Operation: "delete", Entity: "article", AccountID: accountID, ID: id})
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &item, nil
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) loadConversation(ctx context.Context, accountID, id uint) (*model.Conversation, error) {
|
|
var item model.Conversation
|
|
err := i.db.WithContext(ctx).
|
|
Preload("Contact").
|
|
Preload("Inbox").
|
|
Preload("Assignee").
|
|
Preload("Messages", func(db *gorm.DB) *gorm.DB {
|
|
return db.Order("created_at ASC, id ASC")
|
|
}).
|
|
Where("id = ? AND account_id = ?", id, accountID).
|
|
First(&item).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, i.performDelete(ctx, searchIndexJob{Operation: "delete", Entity: "conversation", AccountID: accountID, ID: id})
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &item, nil
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) load(ctx context.Context, payload searchIndexJob, dest any) error {
|
|
err := i.db.WithContext(ctx).Where("id = ? AND account_id = ?", payload.ID, payload.AccountID).First(dest).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return i.performDelete(ctx, payload)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (i *DurableSearchIndexer) performDelete(ctx context.Context, payload searchIndexJob) error {
|
|
switch payload.Entity {
|
|
case "conversation":
|
|
return i.delegate.DeleteConversation(ctx, payload.AccountID, payload.ID)
|
|
case "message":
|
|
return i.delegate.DeleteMessage(ctx, payload.AccountID, payload.ID)
|
|
case "contact":
|
|
return i.delegate.DeleteContact(ctx, payload.AccountID, payload.ID)
|
|
case "company":
|
|
return i.delegate.DeleteCompany(ctx, payload.AccountID, payload.ID)
|
|
case "article":
|
|
return i.delegate.DeleteArticle(ctx, payload.AccountID, payload.ID)
|
|
default:
|
|
return fmt.Errorf("unsupported search index entity %q", payload.Entity)
|
|
}
|
|
}
|