Files
gochat/internal/service/company_service.go
T

445 lines
14 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/search"
applogger "github.com/gochat/gochat/pkg/logger"
pkgvalidator "github.com/gochat/gochat/pkg/validator"
)
// CompanyService implements business logic for Company operations.
// Reference: Chatwoot app/controllers/api/v1/companies_controller.rb
type CompanyService struct {
companyRepo *repository.CompanyRepo
contactRepo *repository.ContactRepo
conversationRepo *repository.ConversationRepo
searchIndexer SearchIndexer
searchReader CompanySearchReader
}
const CompanyResultsPerPage = 25
// NewCompanyService creates a new Company service.
func NewCompanyService(companyRepo *repository.CompanyRepo, contactRepo *repository.ContactRepo, conversationRepo *repository.ConversationRepo) *CompanyService {
return &CompanyService{
companyRepo: companyRepo,
contactRepo: contactRepo,
conversationRepo: conversationRepo,
}
}
func (s *CompanyService) SetSearchIndexer(indexer SearchIndexer) {
s.searchIndexer = indexer
}
func (s *CompanyService) SetSearchReader(reader CompanySearchReader) {
s.searchReader = reader
}
func (s *CompanyService) DB() *gorm.DB {
if s == nil || s.companyRepo == nil {
return nil
}
return s.companyRepo.DB()
}
func (s *CompanyService) indexCompany(ctx context.Context, company *model.Company) {
if s.searchIndexer != nil {
logSearchIndexError("company", company.ID, s.searchIndexer.IndexCompany(ctx, company))
}
}
func (s *CompanyService) deleteCompanyIndex(ctx context.Context, accountID uint, id uint) {
if s.searchIndexer != nil {
logSearchIndexError("company", id, s.searchIndexer.DeleteCompany(ctx, accountID, id))
}
}
// CreateCompanyRequest is the DTO for creating a company.
type CreateCompanyRequest struct {
Name string `json:"name" validate:"required,min=1"`
Description string `json:"description,omitempty"`
WebsiteURL string `json:"website_url,omitempty"`
FaviconURL string `json:"favicon_url,omitempty"`
Domain string `json:"domain,omitempty"`
CustomAttributes datatypes.JSON `json:"custom_attributes,omitempty"`
Company *CompanyParams `json:"company,omitempty"`
}
type CompanyParams struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
WebsiteURL string `json:"website_url,omitempty"`
FaviconURL string `json:"favicon_url,omitempty"`
Domain string `json:"domain,omitempty"`
CustomAttributes datatypes.JSON `json:"custom_attributes,omitempty"`
}
// UpdateCompanyRequest is the DTO for updating a company.
type UpdateCompanyRequest struct {
Name string `json:"name,omitempty" validate:"omitempty,min=1"`
Description string `json:"description,omitempty"`
WebsiteURL string `json:"website_url,omitempty"`
FaviconURL string `json:"favicon_url,omitempty"`
Domain string `json:"domain,omitempty"`
CustomAttributes datatypes.JSON `json:"custom_attributes,omitempty"`
Company *CompanyParams `json:"company,omitempty"`
}
// CreateCompanyNoteRequest is the DTO for creating a company note.
type CreateCompanyNoteRequest struct {
Content string `json:"content" validate:"required,min=1"`
}
// List retrieves all companies for an account.
func (s *CompanyService) List(ctx context.Context, accountID uint, offset, limit int, sort string) ([]model.Company, int64, error) {
return s.companyRepo.ListByAccount(ctx, accountID, offset, limit, sort)
}
// Search searches companies by query with optional sort.
func (s *CompanyService) Search(ctx context.Context, accountID uint, query string, offset, limit int, sort string, searchMode search.SearchMode) ([]model.Company, int64, error) {
if query == "" {
return s.companyRepo.ListByAccount(ctx, accountID, offset, limit, sort)
}
if s.searchReader != nil {
filter := serviceSearchFilter(offset, limit, sort, searchMode, search.ResultTypeCompany)
results, total, err := s.searchReader.SearchCompanies(ctx, accountID, query, filter)
if err != nil {
return nil, 0, err
}
companies, err := s.companiesFromSearchResults(ctx, accountID, results)
if err != nil {
return nil, 0, err
}
return companies, total, nil
}
return s.companyRepo.Search(ctx, accountID, query, offset, limit, sort, searchMode)
}
func (s *CompanyService) companiesFromSearchResults(ctx context.Context, accountID uint, results []search.SearchResult) ([]model.Company, error) {
companies := make([]model.Company, 0, len(results))
for _, result := range results {
if result.ID == 0 || result.AccountID != accountID {
continue
}
company, err := s.companyRepo.FindByIDAndAccount(ctx, result.ID, accountID)
if err != nil {
continue
}
companies = append(companies, *company)
}
return companies, nil
}
// Get retrieves a single company by ID scoped to an account.
func (s *CompanyService) Get(ctx context.Context, id, accountID uint) (*model.Company, error) {
company, err := s.companyRepo.FindByIDAndAccount(ctx, id, accountID)
if err != nil {
return nil, err
}
return company, nil
}
// Create creates a new company.
func (s *CompanyService) Create(ctx context.Context, accountID uint, req *CreateCompanyRequest) (*model.Company, error) {
if req.Company != nil {
req.Name = req.Company.Name
req.Description = req.Company.Description
req.WebsiteURL = req.Company.WebsiteURL
req.FaviconURL = req.Company.FaviconURL
req.Domain = req.Company.Domain
req.CustomAttributes = req.Company.CustomAttributes
}
if req.Name == "" {
return nil, errors.New("name is required")
}
if err := pkgvalidator.ValidateStruct(req); err != nil {
return nil, err
}
company := &model.Company{
AccountID: accountID,
Name: req.Name,
Description: req.Description,
WebsiteURL: req.WebsiteURL,
FaviconURL: req.FaviconURL,
Domain: req.Domain,
CustomAttributes: req.CustomAttributes,
}
if err := s.companyRepo.Create(ctx, company); err != nil {
applogger.L().Errorf("Create company for account %d: %v", accountID, err)
return nil, err
}
s.indexCompany(ctx, company)
return company, nil
}
// Update updates a company scoped to an account.
func (s *CompanyService) Update(ctx context.Context, id, accountID uint, req *UpdateCompanyRequest) (*model.Company, error) {
if req.Company != nil {
req.Name = req.Company.Name
req.Description = req.Company.Description
req.WebsiteURL = req.Company.WebsiteURL
req.FaviconURL = req.Company.FaviconURL
req.Domain = req.Company.Domain
req.CustomAttributes = req.Company.CustomAttributes
}
company, err := s.companyRepo.FindByIDAndAccount(ctx, id, accountID)
if err != nil {
return nil, err
}
if req.Name != "" {
company.Name = req.Name
}
if req.Description != "" {
company.Description = req.Description
}
if req.WebsiteURL != "" {
company.WebsiteURL = req.WebsiteURL
}
if req.FaviconURL != "" {
company.FaviconURL = req.FaviconURL
}
if req.Domain != "" {
company.Domain = req.Domain
}
if req.CustomAttributes != nil {
company.CustomAttributes = mergeJSON(company.CustomAttributes, req.CustomAttributes)
}
if err := pkgvalidator.ValidateStruct(company); err != nil {
return nil, err
}
if err := s.companyRepo.Update(ctx, company); err != nil {
applogger.L().Errorf("Update company %d for account %d: %v", id, accountID, err)
return nil, err
}
s.indexCompany(ctx, company)
return company, nil
}
// Delete deletes a company scoped to an account.
func (s *CompanyService) Delete(ctx context.Context, id, accountID uint) error {
company, err := s.companyRepo.FindByIDAndAccount(ctx, id, accountID)
if err != nil {
return err
}
if company == nil {
return errors.New("company not found")
}
if err := s.companyRepo.Delete(ctx, id); err != nil {
return err
}
s.deleteCompanyIndex(ctx, accountID, id)
return nil
}
// ListContacts retrieves contacts associated with a company.
func (s *CompanyService) ListContacts(ctx context.Context, companyID, accountID uint, offset, limit int) ([]model.Contact, int64, error) {
// Verify company exists and belongs to the account
_, err := s.companyRepo.FindByIDAndAccount(ctx, companyID, accountID)
if err != nil {
return nil, 0, err
}
return s.companyRepo.ListContacts(ctx, companyID, accountID, offset, limit)
}
func (s *CompanyService) SearchContacts(ctx context.Context, companyID, accountID uint, query string, offset, limit int) ([]model.Contact, int64, error) {
_, err := s.companyRepo.FindByIDAndAccount(ctx, companyID, accountID)
if err != nil {
return nil, 0, err
}
if query == "" {
return nil, 0, errors.New("query is required")
}
return s.companyRepo.SearchAssignableContacts(ctx, companyID, accountID, query, offset, limit)
}
func (s *CompanyService) GetContact(ctx context.Context, companyID, accountID, contactID uint) (*model.Contact, error) {
_, err := s.companyRepo.FindByIDAndAccount(ctx, companyID, accountID)
if err != nil {
return nil, err
}
return s.contactRepo.FindByAccountAndID(ctx, accountID, contactID)
}
// ListConversations retrieves conversations for all contacts of a company.
func (s *CompanyService) ListConversations(ctx context.Context, companyID, accountID uint, offset, limit int) ([]model.Conversation, int64, error) {
// Verify company exists and belongs to the account
_, err := s.companyRepo.FindByIDAndAccount(ctx, companyID, accountID)
if err != nil {
return nil, 0, err
}
// Get contact IDs for this company via the join table
contacts, _, err := s.companyRepo.ListContacts(ctx, companyID, accountID, 0, 1000)
if err != nil {
return nil, 0, err
}
if len(contacts) == 0 {
return []model.Conversation{}, 0, nil
}
contactIDs := make([]uint, len(contacts))
for i, c := range contacts {
contactIDs[i] = c.ID
}
return s.conversationRepo.FindByContactIDs(ctx, accountID, contactIDs, offset, limit)
}
// ListNotes retrieves notes for a company.
func (s *CompanyService) ListNotes(ctx context.Context, companyID, accountID uint, offset, limit int) ([]model.CompanyNote, int64, error) {
// Verify company exists and belongs to the account
_, err := s.companyRepo.FindByIDAndAccount(ctx, companyID, accountID)
if err != nil {
return nil, 0, err
}
return s.companyRepo.ListNotes(ctx, companyID, offset, limit)
}
// CreateNote creates a note for a company.
func (s *CompanyService) CreateNote(ctx context.Context, companyID, accountID, userID uint, req *CreateCompanyNoteRequest) (*model.CompanyNote, error) {
if err := pkgvalidator.ValidateStruct(req); err != nil {
return nil, err
}
// Verify company exists and belongs to the account
_, err := s.companyRepo.FindByIDAndAccount(ctx, companyID, accountID)
if err != nil {
return nil, err
}
note := &model.CompanyNote{
CompanyID: companyID,
UserID: userID,
Content: req.Content,
}
if err := s.companyRepo.CreateNote(ctx, note); err != nil {
applogger.L().Errorf("Create note for company %d: %v", companyID, err)
return nil, err
}
return note, nil
}
// DeleteNote deletes a note from a company.
func (s *CompanyService) DeleteNote(ctx context.Context, noteID, companyID, accountID uint) error {
// Verify company exists and belongs to the account
_, err := s.companyRepo.FindByIDAndAccount(ctx, companyID, accountID)
if err != nil {
return err
}
if err := s.companyRepo.DeleteNote(ctx, companyID, noteID); err != nil {
applogger.L().Errorf("Delete note %d for company %d: %v", noteID, companyID, err)
return err
}
return nil
}
// AddContact adds a contact to a company.
func (s *CompanyService) AddContact(ctx context.Context, companyID, accountID, contactID uint) error {
// Verify company exists and belongs to the account
_, err := s.companyRepo.FindByIDAndAccount(ctx, companyID, accountID)
if err != nil {
return err
}
// Verify contact exists and belongs to the account
_, err = s.contactRepo.FindByAccountAndID(ctx, accountID, contactID)
if err != nil {
return err
}
if err := s.companyRepo.AddContact(ctx, companyID, contactID); err != nil {
applogger.L().Errorf("Add contact %d to company %d: %v", contactID, companyID, err)
return err
}
return nil
}
func (s *CompanyService) DestroyCustomAttributes(ctx context.Context, companyID, accountID uint, keys []string) (*model.Company, error) {
company, err := s.companyRepo.FindByIDAndAccount(ctx, companyID, accountID)
if err != nil {
return nil, err
}
attrs := map[string]any{}
if len(company.CustomAttributes) > 0 {
_ = json.Unmarshal(company.CustomAttributes, &attrs)
}
for _, key := range keys {
delete(attrs, key)
}
bytes, _ := json.Marshal(attrs)
company.CustomAttributes = datatypes.JSON(bytes)
if err := s.companyRepo.Update(ctx, company); err != nil {
return nil, err
}
s.indexCompany(ctx, company)
return company, nil
}
func (s *CompanyService) DeleteAvatar(ctx context.Context, companyID, accountID uint) (*model.Company, error) {
company, err := s.companyRepo.FindByIDAndAccount(ctx, companyID, accountID)
if err != nil {
return nil, err
}
company.FaviconURL = ""
if err := s.companyRepo.Update(ctx, company); err != nil {
return nil, err
}
s.indexCompany(ctx, company)
return company, nil
}
func mergeJSON(current datatypes.JSON, incoming datatypes.JSON) datatypes.JSON {
if len(incoming) == 0 {
return current
}
merged := map[string]any{}
if len(current) > 0 {
_ = json.Unmarshal(current, &merged)
}
incomingMap := map[string]any{}
_ = json.Unmarshal(incoming, &incomingMap)
for key, value := range incomingMap {
merged[key] = value
}
bytes, _ := json.Marshal(merged)
return datatypes.JSON(bytes)
}
// RemoveContact removes a contact from a company.
func (s *CompanyService) RemoveContact(ctx context.Context, companyID, accountID, contactID uint) error {
// Verify company exists and belongs to the account
_, err := s.companyRepo.FindByIDAndAccount(ctx, companyID, accountID)
if err != nil {
return err
}
// Verify contact exists and belongs to the account
_, err = s.contactRepo.FindByAccountAndID(ctx, accountID, contactID)
if err != nil {
return err
}
if err := s.companyRepo.RemoveContact(ctx, companyID, contactID); err != nil {
applogger.L().Errorf("Remove contact %d from company %d: %v", contactID, companyID, err)
return err
}
return nil
}