fix(conversations): 修复会话标识与解决后跳转
This commit is contained in:
@@ -272,17 +272,29 @@ func (r *ConversationRepo) Search(ctx context.Context, accountID uint, query str
|
||||
|
||||
// Create inserts a new conversation.
|
||||
func (r *ConversationRepo) Create(ctx context.Context, conversation *model.Conversation) error {
|
||||
if conversation.DisplayID == nil || *conversation.DisplayID == 0 {
|
||||
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
|
||||
return r.db.WithContext(ctx).Create(conversation).Error
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// PostgreSQL needs serialization because MAX(display_id)+1 is otherwise
|
||||
// racy when a channel imports several conversations concurrently.
|
||||
if tx.Dialector != nil && tx.Dialector.Name() == "postgres" {
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(conversation.AccountID)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var next uint
|
||||
if err := r.db.WithContext(ctx).Model(&model.Conversation{}).
|
||||
if err := tx.Model(&model.Conversation{}).
|
||||
Select("COALESCE(MAX(display_id), 0) + 1").
|
||||
Where("account_id = ?", conversation.AccountID).
|
||||
Scan(&next).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
conversation.DisplayID = &next
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(conversation).Error
|
||||
return tx.Create(conversation).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Update modifies an existing conversation.
|
||||
|
||||
@@ -90,6 +90,21 @@ func TestConversationRepo_FindByAccountAndDisplayIDOrID_PrefersLegacyNullDisplay
|
||||
assert.Nil(t, found.DisplayID)
|
||||
}
|
||||
|
||||
func TestConversationRepo_Create_AssignsDistinctSequentialDisplayIDs(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
repo := NewConversationRepo(db)
|
||||
ctx := context.Background()
|
||||
account, inbox, contact := createConvAccount(t, db)
|
||||
|
||||
first := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
||||
second := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
||||
require.NoError(t, repo.Create(ctx, first))
|
||||
require.NoError(t, repo.Create(ctx, second))
|
||||
require.NotNil(t, first.DisplayID)
|
||||
require.NotNil(t, second.DisplayID)
|
||||
assert.Equal(t, *first.DisplayID+1, *second.DisplayID)
|
||||
}
|
||||
|
||||
func TestCov2_ConversationRepo_FindByContact(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
repo := NewConversationRepo(db)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS idx_conversations_account_display_id;
|
||||
CREATE INDEX idx_conversations_account_display_id
|
||||
ON conversations(account_id, display_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- Repaired display IDs are intentionally retained: their previous duplicate
|
||||
-- values cannot be restored without reintroducing ambiguous conversation URLs.
|
||||
@@ -0,0 +1,57 @@
|
||||
-- Repair duplicate and missing account-scoped conversation display IDs before
|
||||
-- enforcing the Chatwoot routing invariant. Keep already-unique IDs stable so
|
||||
-- existing conversation URLs continue to resolve to the same records.
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
account_id,
|
||||
display_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY account_id, display_id
|
||||
ORDER BY id
|
||||
) AS duplicate_rank
|
||||
FROM conversations
|
||||
WHERE deleted_at IS NULL
|
||||
AND display_id IS NOT NULL
|
||||
AND display_id > 0
|
||||
),
|
||||
needs_repair AS (
|
||||
SELECT id, account_id
|
||||
FROM ranked
|
||||
WHERE duplicate_rank > 1
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT id, account_id
|
||||
FROM conversations
|
||||
WHERE deleted_at IS NULL
|
||||
AND (display_id IS NULL OR display_id = 0)
|
||||
),
|
||||
account_max AS (
|
||||
SELECT account_id, COALESCE(MAX(display_id), 0) AS max_display_id
|
||||
FROM conversations
|
||||
WHERE deleted_at IS NULL
|
||||
AND display_id IS NOT NULL
|
||||
AND display_id > 0
|
||||
GROUP BY account_id
|
||||
),
|
||||
replacements AS (
|
||||
SELECT
|
||||
repair.id,
|
||||
COALESCE(account_max.max_display_id, 0)
|
||||
+ ROW_NUMBER() OVER (
|
||||
PARTITION BY repair.account_id
|
||||
ORDER BY repair.id
|
||||
) AS display_id
|
||||
FROM needs_repair AS repair
|
||||
LEFT JOIN account_max ON account_max.account_id = repair.account_id
|
||||
)
|
||||
UPDATE conversations AS conversation
|
||||
SET display_id = replacements.display_id
|
||||
FROM replacements
|
||||
WHERE conversation.id = replacements.id;
|
||||
|
||||
DROP INDEX IF EXISTS idx_conversations_account_display_id;
|
||||
CREATE UNIQUE INDEX idx_conversations_account_display_id
|
||||
ON conversations(account_id, display_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -182,6 +182,7 @@ const onDeleteConversation = () => {
|
||||
<!-- Expanded layout: wide screen + expanded setting -->
|
||||
<ConversationCardExpanded
|
||||
v-if="showExpanded"
|
||||
:data-conversation-id="source.id"
|
||||
:chat="source"
|
||||
:current-contact="currentContact"
|
||||
:assignee="assignee"
|
||||
@@ -200,6 +201,7 @@ const onDeleteConversation = () => {
|
||||
<!-- Default (condensed) layout -->
|
||||
<ConversationCard
|
||||
v-else
|
||||
:data-conversation-id="source.id"
|
||||
:chat="source"
|
||||
:current-contact="currentContact"
|
||||
:assignee="assignee"
|
||||
|
||||
@@ -22,7 +22,10 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||
import {
|
||||
conversationListPageURL,
|
||||
conversationUrl,
|
||||
frontendURL,
|
||||
} from 'dashboard/helper/URLHelper';
|
||||
import { getAdjacentConversationId } from 'dashboard/helper/conversationNavigation';
|
||||
import {
|
||||
isOnMentionsView,
|
||||
isOnParticipatingView,
|
||||
@@ -47,6 +50,37 @@ const openDropdown = () => toggleDropdown(true);
|
||||
|
||||
const currentChat = computed(() => getters.getSelectedChat.value);
|
||||
|
||||
const openConversation = conversationId => {
|
||||
if (!conversationId) {
|
||||
redirectToConversationList();
|
||||
return;
|
||||
}
|
||||
const {
|
||||
params: { accountId, inbox_id: inboxId, label, teamId, id: customViewId },
|
||||
name,
|
||||
} = route;
|
||||
let conversationType = '';
|
||||
if (isOnMentionsView({ route: { name } })) conversationType = 'mention';
|
||||
else if (isOnParticipatingView({ route: { name } })) {
|
||||
conversationType = 'participating';
|
||||
} else if (isOnUnattendedView({ route: { name } })) {
|
||||
conversationType = 'unattended';
|
||||
}
|
||||
router.push({
|
||||
path: frontendURL(
|
||||
conversationUrl({
|
||||
accountId,
|
||||
activeInbox: inboxId,
|
||||
id: conversationId,
|
||||
label,
|
||||
teamId,
|
||||
foldersId: isOnFoldersView({ route: { name } }) ? customViewId : 0,
|
||||
conversationType,
|
||||
})
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const redirectToConversationList = () => {
|
||||
const {
|
||||
params: { accountId, inbox_id: inboxId, label, teamId, id: customViewId },
|
||||
@@ -112,6 +146,15 @@ const getConversationParams = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const getAdjacentConversationID = conversationId => {
|
||||
const conversationIds = [
|
||||
...document.querySelectorAll('.conversations-list .conversation'),
|
||||
]
|
||||
.map(element => Number(element.dataset.conversationId))
|
||||
.filter(Boolean);
|
||||
return getAdjacentConversationId(conversationIds, conversationId);
|
||||
};
|
||||
|
||||
const openSnoozeModal = () => {
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja.open({ parent: 'snooze_conversation' });
|
||||
@@ -121,9 +164,7 @@ const toggleStatus = (status, snoozedUntil, customAttributes = null) => {
|
||||
closeDropdown();
|
||||
isLoading.value = true;
|
||||
const conversationId = currentChat.value.id;
|
||||
const { all, activeIndex, lastIndex } = getConversationParams();
|
||||
const adjacentConversation =
|
||||
activeIndex < lastIndex ? all[activeIndex + 1] : all[activeIndex - 1];
|
||||
const adjacentConversationId = getAdjacentConversationID(conversationId);
|
||||
|
||||
const payload = {
|
||||
conversationId,
|
||||
@@ -140,11 +181,7 @@ const toggleStatus = (status, snoozedUntil, customAttributes = null) => {
|
||||
.then(() => {
|
||||
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||||
if (status !== wootConstants.STATUS_TYPE.OPEN) {
|
||||
if (adjacentConversation) {
|
||||
adjacentConversation.click();
|
||||
} else {
|
||||
redirectToConversationList();
|
||||
}
|
||||
openConversation(adjacentConversationId);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const getAdjacentConversationId = (conversationIds, currentId) => {
|
||||
const currentIndex = conversationIds.indexOf(currentId);
|
||||
if (currentIndex === -1) return null;
|
||||
|
||||
return (
|
||||
conversationIds[currentIndex + 1] ??
|
||||
conversationIds[currentIndex - 1] ??
|
||||
null
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getAdjacentConversationId } from '../conversationNavigation';
|
||||
|
||||
describe('getAdjacentConversationId', () => {
|
||||
const conversationIds = [15, 19, 25];
|
||||
|
||||
it('selects the immediate next conversation', () => {
|
||||
expect(getAdjacentConversationId(conversationIds, 15)).toBe(19);
|
||||
});
|
||||
|
||||
it('selects the previous conversation for the last item', () => {
|
||||
expect(getAdjacentConversationId(conversationIds, 25)).toBe(19);
|
||||
});
|
||||
|
||||
it('returns null for a single-item list', () => {
|
||||
expect(getAdjacentConversationId([15], 15)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the current conversation is absent', () => {
|
||||
expect(getAdjacentConversationId(conversationIds, 99)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user