H-337: restore Web widget reply visibility (#64)

* H-337: restore widget reply delivery

* H-337: harden widget conversation ownership

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-20 22:22:27 +08:00
committed by GitHub
co-authored by rogee
parent abb1bed424
commit b31b1b9562
22 changed files with 847 additions and 83 deletions
@@ -39,6 +39,16 @@ func (r *ContactInboxRepo) FindByContactAndInbox(ctx context.Context, contactID,
return &ci, nil
}
// CountByContactAndInbox returns the number of channel identities for a contact
// in one inbox. Historical conversations without contact_inbox_id are safe to
// resolve only when this count is exactly one.
func (r *ContactInboxRepo) CountByContactAndInbox(ctx context.Context, contactID, inboxID uint) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.ContactInbox{}).
Where("contact_id = ? AND inbox_id = ?", contactID, inboxID).Count(&count).Error
return count, err
}
// FindByContactInboxSource retrieves a contact_inbox by the Chatwoot builder identity.
func (r *ContactInboxRepo) FindByContactInboxSource(ctx context.Context, contactID, inboxID uint, sourceID string) (*model.ContactInbox, error) {
var ci model.ContactInbox
@@ -190,6 +190,22 @@ func (r *ConversationRepo) FindByContact(ctx context.Context, accountID, contact
return conversations, total, err
}
// FindByContactInbox retrieves conversations belonging to one channel identity.
// A contact may have several inbox identities, but a widget token must only see
// the conversations created through its own ContactInbox.
func (r *ConversationRepo) FindByContactInbox(ctx context.Context, accountID, contactID, inboxID, contactInboxID uint, includeLegacy bool, offset, limit int) ([]model.Conversation, int64, error) {
var conversations []model.Conversation
var total int64
query := r.db.WithContext(ctx).Model(&model.Conversation{}).
Where("account_id = ? AND (contact_inbox_id = ? OR (contact_inbox_id IS NULL AND contact_id = ? AND inbox_id = ? AND ?))",
accountID, contactInboxID, contactID, inboxID, includeLegacy)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
err := query.Offset(offset).Limit(limit).Order("id DESC").Find(&conversations).Error
return conversations, total, err
}
// FindRecentByContact retrieves the latest conversations for a contact.
// Reference: Chatwoot contacts/conversations#index limits to the latest 20 conversations ordered by last_activity_at.
func (r *ConversationRepo) FindRecentByContact(ctx context.Context, accountID, contactID uint, inboxID *uint, limit int) ([]model.Conversation, error) {
@@ -58,6 +58,33 @@ func TestConversationRepo_FindByID_NotFound(t *testing.T) {
assert.Nil(t, found)
}
func TestConversationRepo_FindByContactInboxIncludesLegacyOnlyWhenAllowed(t *testing.T) {
db := setupTestDB(t)
repo := NewConversationRepo(db)
account := &model.Account{Name: "Widget identity account", Active: true}
require.NoError(t, db.Create(account).Error)
inbox := &model.Inbox{AccountID: account.ID, Name: "Widget", ChannelType: "web_widget"}
require.NoError(t, db.Create(inbox).Error)
contact := &model.Contact{AccountID: account.ID, Name: "Visitor"}
require.NoError(t, db.Create(contact).Error)
contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "token-a"}
require.NoError(t, db.Create(contactInbox).Error)
legacy := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
owned := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
require.NoError(t, db.Model(owned).Update("contact_inbox_id", contactInbox.ID).Error)
conversations, total, err := repo.FindByContactInbox(context.Background(), account.ID, contact.ID, inbox.ID, contactInbox.ID, true, 0, 10)
require.NoError(t, err)
assert.Equal(t, int64(2), total)
assert.ElementsMatch(t, []uint{legacy.ID, owned.ID}, []uint{conversations[0].ID, conversations[1].ID})
conversations, total, err = repo.FindByContactInbox(context.Background(), account.ID, contact.ID, inbox.ID, contactInbox.ID, false, 0, 10)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, conversations, 1)
assert.Equal(t, owned.ID, conversations[0].ID)
}
func TestConversationRepo_FindByAccountAndID(t *testing.T) {
db := setupTestDB(t)
repo := NewConversationRepo(db)