fix(shangwutong): close contact sync review gaps (#4)
Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -782,7 +782,7 @@ func Bootstrap(env string) (*App, error) {
|
||||
Auth: v1.NewAuthHandler(authService, profileService),
|
||||
Account: v1.NewAccountHandler(accountService),
|
||||
EnterpriseAccount: v1.NewEnterpriseAccountHandler(accountService),
|
||||
Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService).WithContactPresence(presenceTracker).WithEventPublisher(eventPublisher).WithChannelDispatcher(channelDispatcher),
|
||||
Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService).WithContactPresence(presenceTracker).WithEventPublisher(eventPublisher),
|
||||
Conversation: v1.NewConversationHandler(conversationService, messageService).WithAuditService(auditService).WithContactPresence(presenceTracker),
|
||||
Inbox: v1.NewInboxHandler(inboxService).WithAuditService(auditService),
|
||||
InboxMember: v1.NewInboxMemberHandler(inboxMemberService),
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/search"
|
||||
@@ -24,14 +23,13 @@ import (
|
||||
// ContactHandler handles contact-related API endpoints.
|
||||
// Reference: Chatwoot app/controllers/api/v1/contacts_controller.rb
|
||||
type ContactHandler struct {
|
||||
svc *service.ContactService
|
||||
contactInboxSvc *service.ContactInboxService
|
||||
mergeSvc *service.ContactMergeService
|
||||
contactNoteSvc *service.ContactNoteService
|
||||
conversationSvc *service.ConversationService
|
||||
presence contactPresenceReader
|
||||
eventPublisher *ws.EventPublisher
|
||||
channelDispatcher *channel.Dispatcher
|
||||
svc *service.ContactService
|
||||
contactInboxSvc *service.ContactInboxService
|
||||
mergeSvc *service.ContactMergeService
|
||||
contactNoteSvc *service.ContactNoteService
|
||||
conversationSvc *service.ConversationService
|
||||
presence contactPresenceReader
|
||||
eventPublisher *ws.EventPublisher
|
||||
}
|
||||
|
||||
const chatwootContactResultsPerPage = 15
|
||||
@@ -50,11 +48,6 @@ func (h *ContactHandler) WithEventPublisher(publisher *ws.EventPublisher) *Conta
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *ContactHandler) WithChannelDispatcher(dispatcher *channel.Dispatcher) *ContactHandler {
|
||||
h.channelDispatcher = dispatcher
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *ContactHandler) WithContactPresence(presence contactPresenceReader) *ContactHandler {
|
||||
h.presence = presence
|
||||
return h
|
||||
@@ -276,7 +269,6 @@ func (h *ContactHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
before, _ := h.svc.GetByAccountAndID(c.Request.Context(), accountID, contactID)
|
||||
contact, svcErr := h.svc.Update(c.Request.Context(), accountID, contactID, req)
|
||||
if svcErr != nil {
|
||||
var validationErr *service.ContactValidationError
|
||||
@@ -290,12 +282,6 @@ func (h *ContactHandler) Update(c *gin.Context) {
|
||||
|
||||
payload := contactPayloadResponse(h.requestContext(c), h.svc.DB(), contact, includeContactInboxes(c))
|
||||
h.publishContactEvent(accountID, ws.EventContactUpdated, contact)
|
||||
if h.channelDispatcher != nil && req.Name != "" && (before == nil || before.Name != contact.Name) {
|
||||
event := channel.NewChannelEvent(channel.EventContactUpdated, channel.ChannelAPI, accountID, 0)
|
||||
event.ContactID, event.Data["contact"] = contact.ID, contact
|
||||
event.Data["changed_attributes"] = map[string]any{"name": contact.Name}
|
||||
_ = h.channelDispatcher.Dispatch(c.Request.Context(), event)
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -22,6 +23,50 @@ type ShangwutongConnectorHandler struct {
|
||||
messageSvc *service.MessageService
|
||||
}
|
||||
|
||||
type shangwutongContactMetadataRequest struct {
|
||||
CID string `json:"cid"`
|
||||
}
|
||||
|
||||
func (h *ShangwutongConnectorHandler) UpdateContactMetadata(c *gin.Context) {
|
||||
inbox, ok := h.authorizedInbox(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(c.Param("source_id")) == "" {
|
||||
h.connectorError(c, http.StatusBadRequest, "invalid_source_id", "source_id is required", false)
|
||||
return
|
||||
}
|
||||
var request shangwutongContactMetadataRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.CID) == "" {
|
||||
h.connectorError(c, http.StatusUnprocessableEntity, "invalid_contact_metadata", "cid is required", false)
|
||||
return
|
||||
}
|
||||
var contactInbox model.ContactInbox
|
||||
if err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ? AND source_id = ?", inbox.ID, c.Param("source_id")).First(&contactInbox).Error; err != nil {
|
||||
h.connectorError(c, http.StatusNotFound, "not_found", "contact source not found", false)
|
||||
return
|
||||
}
|
||||
metadata := map[string]any{}
|
||||
if len(contactInbox.ChannelMetadata) > 0 {
|
||||
_ = json.Unmarshal(contactInbox.ChannelMetadata, &metadata)
|
||||
}
|
||||
if metadata["cid"] == strings.TrimSpace(request.CID) {
|
||||
c.JSON(http.StatusOK, gin.H{"updated": false})
|
||||
return
|
||||
}
|
||||
metadata["cid"] = strings.TrimSpace(request.CID)
|
||||
encoded, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
h.connectorError(c, http.StatusInternalServerError, "contact_metadata_update_failed", "failed to encode contact metadata", true)
|
||||
return
|
||||
}
|
||||
if err := h.db.WithContext(c.Request.Context()).Model(&contactInbox).Update("channel_metadata", encoded).Error; err != nil {
|
||||
h.connectorError(c, http.StatusInternalServerError, "contact_metadata_update_failed", "failed to update contact metadata", true)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
func NewShangwutongConnectorHandler(db *gorm.DB, messageSvc *service.MessageService) *ShangwutongConnectorHandler {
|
||||
return &ShangwutongConnectorHandler{db: db, messageSvc: messageSvc}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,21 @@ func TestShangwutongConnectorMessageResultIsVersionedAndIdempotent(t *testing.T)
|
||||
require.JSONEq(t, `{"shangwutong":["123456","123457"]}`, string(message.ExternalSourceIDs))
|
||||
}
|
||||
|
||||
func TestShangwutongConnectorContactMetadataIsScopedAndIdempotent(t *testing.T) {
|
||||
router, db, token, inbox, _ := setupShangwutongConnectorAPI(t)
|
||||
contact := &model.Contact{AccountID: inbox.AccountID, Name: "Visitor"}
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
ci := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "visitor"}
|
||||
require.NoError(t, db.Create(ci).Error)
|
||||
path := fmt.Sprintf("/api/v1/connector/shangwutong/inboxes/%d/contacts/visitor", inbox.ID)
|
||||
response := connectorRequest(t, router, token, http.MethodPatch, path, map[string]string{"cid": "cid-1"})
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
response = connectorRequest(t, router, token, http.MethodPatch, path, map[string]string{"cid": "cid-1"})
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
require.NoError(t, db.First(ci, ci.ID).Error)
|
||||
require.JSONEq(t, `{"cid":"cid-1"}`, string(ci.ChannelMetadata))
|
||||
}
|
||||
|
||||
func setupShangwutongConnectorAPI(t *testing.T) (*gin.Engine, *gorm.DB, string, *model.Inbox, *model.Inbox) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -132,6 +147,7 @@ func setupShangwutongConnectorAPI(t *testing.T) (*gin.Engine, *gorm.DB, string,
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&model.Account{}, &model.Inbox{}, &model.ChannelShangwutongConfig{}, &channelmodel.ChannelAPI{},
|
||||
&model.PlatformApp{}, &model.AccessToken{}, &model.Permissible{}, &model.Contact{}, &model.Conversation{},
|
||||
&model.ContactInbox{},
|
||||
&model.Message{}, &model.Attachment{}, &model.BackgroundJob{},
|
||||
))
|
||||
active := true
|
||||
@@ -173,6 +189,7 @@ func setupShangwutongConnectorAPI(t *testing.T) (*gin.Engine, *gorm.DB, string,
|
||||
group.GET("/inboxes/:inbox_id", handler.GetInbox)
|
||||
group.PUT("/inboxes/:inbox_id/status", handler.UpdateInboxStatus)
|
||||
group.PUT("/inboxes/:inbox_id/messages/:message_id/status", handler.UpdateMessageStatus)
|
||||
group.PATCH("/inboxes/:inbox_id/contacts/:source_id", handler.UpdateContactMetadata)
|
||||
return router, db, token, inboxes[0], inboxes[1]
|
||||
}
|
||||
|
||||
|
||||
@@ -771,6 +771,20 @@ func TestWidgetHandler_Cov3_PublicCreateContact_Success_Cov3(t *testing.T) {
|
||||
assert.Equal(t, "New Contact", resp["name"])
|
||||
}
|
||||
|
||||
func TestWidgetHandler_PublicContactIgnoresCID(t *testing.T) {
|
||||
db, router, _ := setupWidgetHandlerTest(t)
|
||||
_, _, channelAPI := seedPublicAPIInbox(t, db)
|
||||
body, _ := json.Marshal(map[string]any{"name": "Visitor", "source_id": "visitor", "channel_metadata": map[string]any{"cid": "forged"}})
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, "/public/api/v1/inboxes/"+channelAPI.Identifier+"/contacts", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
var contactInbox model.ContactInbox
|
||||
require.NoError(t, db.Where("source_id = ?", "visitor").First(&contactInbox).Error)
|
||||
assert.NotContains(t, string(contactInbox.ChannelMetadata), "forged")
|
||||
}
|
||||
|
||||
func TestWidgetHandler_Cov3_PublicUpdateContact_Success_Cov3(t *testing.T) {
|
||||
db, router, _ := setupWidgetHandlerTest(t)
|
||||
_, _, channelAPI := seedPublicAPIInbox(t, db)
|
||||
|
||||
@@ -1402,7 +1402,6 @@ func bindPublicContactRequest(c *gin.Context) (service.PublicContactRequest, err
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
CustomAttributes map[string]any `json:"custom_attributes"`
|
||||
AdditionalAttributes map[string]any `json:"additional_attributes"`
|
||||
ChannelMetadata map[string]any `json:"channel_metadata"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil && c.Request.ContentLength != 0 {
|
||||
return service.PublicContactRequest{}, err
|
||||
@@ -1426,7 +1425,6 @@ func bindPublicContactRequest(c *gin.Context) (service.PublicContactRequest, err
|
||||
PhoneNumber: body.PhoneNumber,
|
||||
CustomAttributes: body.CustomAttributes,
|
||||
AdditionalAttributes: body.AdditionalAttributes,
|
||||
ChannelMetadata: body.ChannelMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -269,6 +269,13 @@ func RegisterRoutes(
|
||||
}
|
||||
handlers.ShangwutongConnector.UpdateMessageStatus(c)
|
||||
})
|
||||
connector.PATCH("/inboxes/:inbox_id/contacts/:source_id", func(c *gin.Context) {
|
||||
if handlers == nil || handlers.ShangwutongConnector == nil {
|
||||
webhookProviderUnavailable(c)
|
||||
return
|
||||
}
|
||||
handlers.ShangwutongConnector.UpdateContactMetadata(c)
|
||||
})
|
||||
|
||||
// API v1 routes — authenticated, account-scoped
|
||||
apiV1 := engine.Group("/api/v1")
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/search"
|
||||
@@ -549,6 +550,7 @@ func (s *ContactService) Update(ctx context.Context, accountID, id uint, req Upd
|
||||
if validationErr := s.validateContactUniqueness(ctx, accountID, contact.ID, req.Email, req.contactPhoneNumber(), req.Identifier); validationErr != nil {
|
||||
return nil, validationErr
|
||||
}
|
||||
previousName := contact.Name
|
||||
|
||||
if req.Name != "" {
|
||||
contact.Name = req.Name
|
||||
@@ -590,9 +592,28 @@ func (s *ContactService) Update(ctx context.Context, accountID, id uint, req Upd
|
||||
contact.CompanyID = req.CompanyID
|
||||
}
|
||||
|
||||
if err := s.repo.Update(ctx, contact); err != nil {
|
||||
var eventJob *model.BackgroundJob
|
||||
var eventCreated bool
|
||||
if err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(contact).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if s.worker != nil && req.Name != "" && req.Name != previousName {
|
||||
event := channel.NewChannelEvent(channel.EventContactUpdated, channel.ChannelAPI, accountID, 0)
|
||||
event.ContactID, event.Data["contact"] = contact.ID, contact
|
||||
event.Data["changed_attributes"] = map[string]any{"name": contact.Name}
|
||||
var err error
|
||||
eventJob, eventCreated, err = s.worker.EnqueueInTransaction(ctx, tx, channel.TaskTypeEventDispatch, event,
|
||||
worker.WithQueue("events"), worker.WithMaxAttempts(10), worker.WithIdempotencyKey(fmt.Sprintf("contact:%d:name:%d", contact.ID, contact.UpdatedAt.UnixNano())))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if eventCreated {
|
||||
s.worker.Publish(ctx, eventJob)
|
||||
}
|
||||
s.indexContact(ctx, contact)
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -779,7 +780,11 @@ func (s *WidgetService) PublicCreateContact(ctx context.Context, inboxIdentifier
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setContactInboxChannelMetadata(existingInbox, req.ChannelMetadata)
|
||||
if setPublicContactInboxChannelMetadata(existingInbox, req.ChannelMetadata) {
|
||||
if err := s.contactInboxRepo.Update(ctx, existingInbox); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
existingInbox.Contact = *contact
|
||||
return &PublicContactResponse{ContactInbox: existingInbox, Contact: contact}, nil
|
||||
}
|
||||
@@ -803,7 +808,7 @@ func (s *WidgetService) PublicCreateContact(ctx context.Context, inboxIdentifier
|
||||
HMACToken: hmacToken,
|
||||
HMACVerified: req.IdentifierHash != "",
|
||||
}
|
||||
setContactInboxChannelMetadata(contactInbox, req.ChannelMetadata)
|
||||
setPublicContactInboxChannelMetadata(contactInbox, req.ChannelMetadata)
|
||||
if err := s.contactInboxRepo.Create(ctx, contactInbox); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -838,7 +843,7 @@ func (s *WidgetService) PublicUpdateContact(ctx context.Context, inboxIdentifier
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if metadataChanged := setContactInboxChannelMetadata(contactInbox, req.ChannelMetadata); metadataChanged {
|
||||
if setPublicContactInboxChannelMetadata(contactInbox, req.ChannelMetadata) {
|
||||
if err := s.contactInboxRepo.Update(ctx, contactInbox); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -852,16 +857,24 @@ func (s *WidgetService) PublicUpdateContact(ctx context.Context, inboxIdentifier
|
||||
return &PublicContactResponse{ContactInbox: contactInbox, Contact: contact}, nil
|
||||
}
|
||||
|
||||
func setContactInboxChannelMetadata(contactInbox *model.ContactInbox, metadata map[string]any) bool {
|
||||
func setPublicContactInboxChannelMetadata(contactInbox *model.ContactInbox, metadata map[string]any) bool {
|
||||
if contactInbox == nil || len(metadata) == 0 {
|
||||
return false
|
||||
}
|
||||
cid, ok := metadata["cid"].(string)
|
||||
if !ok || strings.TrimSpace(cid) == "" {
|
||||
return false
|
||||
current := jsonMap(contactInbox.ChannelMetadata)
|
||||
changed := false
|
||||
for key, value := range metadata {
|
||||
if key == "cid" {
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(current[key], value) {
|
||||
current[key], changed = value, true
|
||||
}
|
||||
}
|
||||
contactInbox.ChannelMetadata = mustJSON(map[string]any{"cid": strings.TrimSpace(cid)})
|
||||
return true
|
||||
if changed {
|
||||
contactInbox.ChannelMetadata = mustJSON(current)
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (s *WidgetService) PublicListConversations(ctx context.Context, inboxIdentifier, sourceID string) ([]model.Conversation, error) {
|
||||
|
||||
@@ -14,7 +14,9 @@ CREATE TABLE outbound_operations_old (
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
INSERT INTO outbound_operations_old SELECT * FROM outbound_operations WHERE operation = 'end_conversation';
|
||||
-- The old CHECK constraint intentionally rejects change_contact_name rows.
|
||||
-- SQLite aborts this INSERT before the source table is dropped, preserving data.
|
||||
INSERT INTO outbound_operations_old SELECT * FROM outbound_operations;
|
||||
DROP TABLE outbound_operations;
|
||||
ALTER TABLE outbound_operations_old RENAME TO outbound_operations;
|
||||
CREATE INDEX idx_outbound_operations_ready ON outbound_operations(delivery_status, next_attempt_at, account_id, id);
|
||||
|
||||
@@ -23,6 +23,7 @@ const echoMatchWindow = 10 * time.Minute
|
||||
type InboundClient interface {
|
||||
EnsureContact(context.Context, string, string, gochat.ContactRequest) (gochat.Contact, error)
|
||||
UpdateContact(context.Context, string, string, string, gochat.ContactRequest) (gochat.Contact, error)
|
||||
UpdateContactChannelMetadata(context.Context, int64, string, string) error
|
||||
EnsureConversation(context.Context, string, string, map[string]any) (gochat.PublicConversation, error)
|
||||
ImportMessage(context.Context, int64, int64, gochat.MessageImport) (gochat.ImportedMessage, error)
|
||||
ImportMessageWithAttachments(context.Context, int64, int64, gochat.MessageImport, []gochat.AttachmentUpload) (gochat.ImportedMessage, error)
|
||||
@@ -103,6 +104,12 @@ func (i *Inbound) processInbound(ctx context.Context) (bool, error) {
|
||||
if mapped.RetractionTarget != "" {
|
||||
return true, i.deliverRetraction(ctx, account, event, mapped)
|
||||
}
|
||||
if mapped.ContactCID != "" {
|
||||
if err := i.client.UpdateContactChannelMetadata(ctx, account.GochatInboxID, event.SwtSid, mapped.ContactCID); err != nil {
|
||||
return true, i.retryOrFail(event, mapped, err)
|
||||
}
|
||||
return true, i.complete(event, mapped, nil)
|
||||
}
|
||||
if mapped.RawOnly && !mapped.RequiresContact && !mapped.RequiresConversation && mapped.Message == nil {
|
||||
return true, i.complete(event, mapped, nil)
|
||||
}
|
||||
@@ -162,9 +169,6 @@ func (i *Inbound) ensureResources(ctx context.Context, account *dbgen.Account, e
|
||||
SourceID: event.SwtSid, Name: mapped.ContactName, PhoneNumber: mapped.ContactPhone,
|
||||
CustomAttributes: contactAttributes, AdditionalAttributes: contactAdditional,
|
||||
}
|
||||
if mapped.ContactCID != "" {
|
||||
contactRequest.ChannelMetadata = map[string]any{"cid": mapped.ContactCID}
|
||||
}
|
||||
needsContactWrite := state.contactID == 0 || mapped.ContactName != "" || mapped.ContactPhone != "" || len(mapped.ContactAttributes) > 0
|
||||
if mapped.RequiresContact && needsContactWrite {
|
||||
var contact gochat.Contact
|
||||
|
||||
@@ -417,6 +417,10 @@ func (r *inboundRecorder) UpdateContact(_ context.Context, _, _, sourceID string
|
||||
return gochat.Contact{ID: 456, SourceID: sourceID, Name: request.Name}, nil
|
||||
}
|
||||
|
||||
func (*inboundRecorder) UpdateContactChannelMetadata(context.Context, int64, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *inboundRecorder) EnsureConversation(context.Context, string, string, map[string]any) (gochat.PublicConversation, error) {
|
||||
return gochat.PublicConversation{ID: 88, InternalID: 100, InboxID: 10, Status: "open"}, nil
|
||||
}
|
||||
|
||||
@@ -31,7 +31,15 @@ type ContactRequest struct {
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
CustomAttributes map[string]any `json:"custom_attributes,omitempty"`
|
||||
AdditionalAttributes map[string]any `json:"additional_attributes,omitempty"`
|
||||
ChannelMetadata map[string]any `json:"channel_metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) UpdateContactChannelMetadata(ctx context.Context, inboxID int64, sourceID, cid string) error {
|
||||
if inboxID <= 0 || strings.TrimSpace(sourceID) == "" || strings.TrimSpace(cid) == "" {
|
||||
return errors.New("contact channel metadata identifiers are required")
|
||||
}
|
||||
path := fmt.Sprintf("/api/v1/connector/shangwutong/inboxes/%d/contacts/%s", inboxID, url.PathEscape(sourceID))
|
||||
body := map[string]any{"cid": strings.TrimSpace(cid)}
|
||||
return c.doJSON(ctx, http.MethodPatch, path, body, nil, fmt.Sprintf("swt-contact-metadata:%d:%s:%s", inboxID, sourceID, cid))
|
||||
}
|
||||
|
||||
type Contact struct {
|
||||
|
||||
@@ -67,6 +67,30 @@ func TestMessagingClientEnsuresIdentityAndImportsWithStableKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagingClientUpdatesContactMetadataThroughConnectorRoute(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPatch || request.URL.Path != "/api/v1/connector/shangwutong/inboxes/7/contacts/visitor" {
|
||||
t.Fatalf("request = %s %s", request.Method, request.URL.Path)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["cid"] != "cid-7" {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
response.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := NewClient(server.URL, "token", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.UpdateContactChannelMetadata(context.Background(), 7, "visitor", "cid-7"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagingClientImportsMultipartAttachments(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Header.Get("Idempotency-Key") != "swt:2:visitor:2:10:0" {
|
||||
|
||||
@@ -102,6 +102,36 @@ func TestMigrationVersionParser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackMigrationRefusesRenameOperations(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "rollback.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`CREATE TABLE outbound_operations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL, swt_sid TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL UNIQUE, operation TEXT NOT NULL, payload TEXT NOT NULL,
|
||||
occurred_at DATETIME NOT NULL, delivery_status TEXT NOT NULL, claimed_at DATETIME,
|
||||
attempts INTEGER NOT NULL, next_attempt_at DATETIME, last_error TEXT,
|
||||
created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO outbound_operations(account_id,swt_sid,event_id,operation,payload,occurred_at,delivery_status,attempts,created_at,updated_at) VALUES (1,'sid','event','change_contact_name','{}',CURRENT_TIMESTAMP,'pending',0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rollback, err := os.ReadFile(filepath.Join("..", "..", "db", "migrations", "002_add_rename_operation.down.sql"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(string(rollback)); err == nil {
|
||||
t.Fatal("expected rollback to reject change_contact_name")
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM outbound_operations").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("operation count = %d, err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundClaimStopsBehindUncertainMessage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, err := Open(ctx, filepath.Join(t.TempDir(), "connector.db"))
|
||||
|
||||
Reference in New Issue
Block a user