Files
gochat/internal/service/contact_inbox_service.go
T

211 lines
6.6 KiB
Go

package service
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/google/uuid"
)
// ContactInboxService implements business logic for ContactInbox operations.
// Reference: Chatwoot app/models/contact_inbox.rb
type ContactInboxService struct {
repo *repository.ContactInboxRepo
}
// NewContactInboxService creates a new ContactInbox service.
func NewContactInboxService(repo *repository.ContactInboxRepo) *ContactInboxService {
return &ContactInboxService{repo: repo}
}
// GetByID retrieves a single contact_inbox.
func (s *ContactInboxService) GetByID(ctx context.Context, id uint) (*model.ContactInbox, error) {
return s.repo.FindByID(ctx, id)
}
// GetByContactAndInbox retrieves a contact_inbox by contact and inbox.
func (s *ContactInboxService) GetByContactAndInbox(ctx context.Context, contactID, inboxID uint) (*model.ContactInbox, error) {
return s.repo.FindByContactAndInbox(ctx, contactID, inboxID)
}
// ListByContact retrieves all contact_inboxes for a contact.
func (s *ContactInboxService) ListByContact(ctx context.Context, contactID uint) ([]model.ContactInbox, error) {
return s.repo.FindByContact(ctx, contactID)
}
// ListByInbox retrieves all contact_inboxes for an inbox (paginated).
func (s *ContactInboxService) ListByInbox(ctx context.Context, inboxID uint, offset, limit int) ([]model.ContactInbox, int64, error) {
return s.repo.FindByInbox(ctx, inboxID, offset, limit)
}
// GetBySourceID retrieves a contact_inbox by source_id and inbox.
func (s *ContactInboxService) GetBySourceID(ctx context.Context, inboxID uint, sourceID string) (*model.ContactInbox, error) {
return s.repo.FindBySourceID(ctx, inboxID, sourceID)
}
// CreateContactInboxRequest is the DTO for creating a contact_inbox.
type CreateContactInboxRequest struct {
ContactID uint `json:"contact_id"`
InboxID uint `json:"inbox_id"`
SourceID string `json:"source_id"`
HMACVerified bool `json:"hmac_verified"`
Contact *model.Contact `json:"-"`
Inbox *model.Inbox `json:"-"`
}
// Create adds a new contact_inbox join record.
func (s *ContactInboxService) Create(ctx context.Context, req CreateContactInboxRequest) (*model.ContactInbox, error) {
if req.ContactID == 0 || req.InboxID == 0 {
return nil, errors.New("contact_id and inbox_id are required")
}
sourceID := strings.TrimSpace(req.SourceID)
if sourceID == "" {
generated, err := generateContactInboxSourceID(req.Contact, req.Inbox)
if err != nil {
return nil, err
}
sourceID = generated
}
if sourceID == "" {
return nil, errors.New("source_id is required")
}
existing, err := s.repo.FindByContactInboxSource(ctx, req.ContactID, req.InboxID, sourceID)
if err == nil && existing != nil {
return existing, nil
}
hmacToken, err := generateToken(24)
if err != nil {
return nil, err
}
pubsubToken, err := generateToken(24)
if err != nil {
return nil, err
}
ci := &model.ContactInbox{
ContactID: req.ContactID,
InboxID: req.InboxID,
SourceID: sourceID,
HMACToken: hmacToken,
PubsubToken: pubsubToken,
HMACVerified: req.HMACVerified,
}
if req.Inbox != nil {
ci.Inbox = *req.Inbox
}
if err := s.repo.Create(ctx, ci); err != nil {
applogger.L().Errorf("ContactInboxService.Create failed: %v", err)
return nil, err
}
return ci, nil
}
func generateContactInboxSourceID(contact *model.Contact, inbox *model.Inbox) (string, error) {
if contact == nil || inbox == nil {
return "", errors.New("contact and inbox are required to generate source_id")
}
switch normalizedInboxChannelType(inbox.ChannelType) {
case "api", "web_widget":
return uuid.NewString(), nil
case "email":
if strings.TrimSpace(contact.Email) == "" {
return "", errors.New("contact email is required")
}
return contact.Email, nil
case "sms":
return contactPhoneSourceID(contact)
case "whatsapp":
phone, err := contactPhoneSourceID(contact)
if err != nil {
return "", err
}
return strings.ReplaceAll(phone, "+", ""), nil
case "twilio_sms":
phone, err := contactPhoneSourceID(contact)
if err != nil {
return "", err
}
if twilioInboxMedium(inbox) == "whatsapp" {
return fmt.Sprintf("whatsapp:%s", phone), nil
}
return phone, nil
default:
return "", fmt.Errorf("unsupported operation for this channel: %s", inbox.ChannelType)
}
}
func contactPhoneSourceID(contact *model.Contact) (string, error) {
if strings.TrimSpace(contact.PhoneNumber) == "" {
return "", errors.New("contact phone number is required")
}
return contact.PhoneNumber, nil
}
func normalizedInboxChannelType(channelType string) string {
normalized := strings.TrimSpace(channelType)
normalized = strings.TrimPrefix(normalized, "Channel::")
normalized = strings.ToLower(normalized)
normalized = strings.ReplaceAll(normalized, "_", "")
switch normalized {
case "webwidget":
return "web_widget"
case "twiliosms":
return "twilio_sms"
case "api", "email", "sms", "whatsapp":
return normalized
default:
return strings.ToLower(channelType)
}
}
func twilioInboxMedium(inbox *model.Inbox) string {
if inbox == nil || strings.TrimSpace(inbox.ChannelConfig) == "" {
return "sms"
}
var config map[string]any
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
return "sms"
}
if medium, ok := config["medium"].(string); ok && strings.TrimSpace(medium) != "" {
return strings.ToLower(strings.TrimSpace(medium))
}
return "sms"
}
// Delete removes a contact_inbox by ID.
func (s *ContactInboxService) Delete(ctx context.Context, id uint) error {
return s.repo.Delete(ctx, id)
}
// DeleteByContactAndInbox removes the join record.
func (s *ContactInboxService) DeleteByContactAndInbox(ctx context.Context, contactID, inboxID uint) error {
return s.repo.DeleteByContactAndInbox(ctx, contactID, inboxID)
}
// FilterContactInboxes retrieves contact_inboxes by account with optional filters.
// GET /api/v1/accounts/:id/contact_inboxes/filter
// Reference: Chatwoot contact_inboxes_controller#filter
func (s *ContactInboxService) FilterContactInboxes(ctx context.Context, accountID uint, inboxID, contactID *uint, sourceID string, offset, limit int) ([]model.ContactInbox, int64, error) {
return s.repo.Filter(ctx, accountID, inboxID, contactID, sourceID, offset, limit)
}
// generateToken creates a random hex token of the specified byte length.
func generateToken(byteLen int) (string, error) {
b := make([]byte, byteLen)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}