Multi-agent / module-state correctness
- Hoist WhatsApp outbound init lock to module scope so header + contact-panel
buttons share one guard; add an active-session guard so a second click
returns { status: 'locked' } instead of cleanup()-ing the live call.
- isLocalWhatsappCall() filter on voice_call.outbound_connected and
voice_call.ended cable handlers — account-wide broadcasts no longer
feed foreign SDP into this tab's PeerConnection or stop its recorder.
- Permission-flow path (200 with no call id) now releases the
prepareOutboundOffer() mic + RTCPeerConnection instead of leaving the
mic indicator stuck on.
- Drop the intentionallyClosing guard around sendWhatsappTerminateBeacon
so a hangup-then-close race still terminates Meta's side (beacon
endpoint is idempotent).
- Distinguish locked init from permission_requested in callers to avoid
a false "call initiated" alert.
Provider routing
- joinCall outbound short-circuit now scoped to WhatsApp-like calls so
FloatingCallWidget's auto-join for outbound Twilio still works.
- isWhatsappLikeCall(callId-keyed) so calls seeded by message.updated /
refresh path (which lack provider metadata) route to the WhatsApp flow.
- syncConversationCallVisibility per-call filter via shouldShowCall, so
outbound calls aren't ripped from under the caller on assignee change.
- removeCallsForConversation tears down each active call via
teardownByProvider — WhatsApp gets cleanupWhatsappSession (closes pc,
stops recorder/mic) instead of a Twilio-only endClientCall.
- await reject before dismissing in rejectIncomingCall so a failing reject
keeps the call surfaced for retry.
Per-bubble overhead
- Split useCallSession into the root-mount hook + a lightweight
useCallActions for components like VoiceCall.vue that just need state +
actions without registering global window/Twilio listeners. Globals
attach once via a refcount, dismissed-call sids live at module scope so
the seed watcher can't re-add a locally dismissed ringing call.
Lookup correctness
- /contacts/:id/conversations accepts an optional inbox_id filter; the
WhatsApp call button passes inboxId so a contact's older WhatsApp
thread doesn't fall outside the BE's 20-row cap.
Twilio lifecycle / security
- Defer accepted_by_agent claim to the participant-join webhook so a
failed agent device init doesn't leave the call ringing-but-claimed
with no recovery path. mark_agent_joined still raises 409 if another
agent has already claimed.
- Verify X-Twilio-Signature on recording_status — the controller fetches
the recording with channel auth credentials, so an unsigned POST
could coerce credential-bearing requests to an attacker-controlled host.
Legacy data
- Migration to delete orphaned inboxes whose channel_type still says
'Channel::Voice' after the model was removed. The polymorphic
belongs_to :channel lookup on those rows otherwise crashes the inbox
serializer with `uninitialized constant Channel::Voice`.
107 lines
2.8 KiB
JavaScript
107 lines
2.8 KiB
JavaScript
/* global axios */
|
|
import ApiClient from './ApiClient';
|
|
|
|
export const buildContactParams = (page, sortAttr, label, search) => {
|
|
let params = `include_contact_inboxes=false&page=${page}&sort=${sortAttr}`;
|
|
if (search) {
|
|
params = `${params}&q=${search}`;
|
|
}
|
|
if (label) {
|
|
params = `${params}&labels[]=${label}`;
|
|
}
|
|
return params;
|
|
};
|
|
|
|
class ContactAPI extends ApiClient {
|
|
constructor() {
|
|
super('contacts', { accountScoped: true });
|
|
}
|
|
|
|
get(page, sortAttr = 'name', label = '') {
|
|
let requestURL = `${this.url}?${buildContactParams(
|
|
page,
|
|
sortAttr,
|
|
label,
|
|
''
|
|
)}`;
|
|
return axios.get(requestURL);
|
|
}
|
|
|
|
show(id) {
|
|
return axios.get(`${this.url}/${id}?include_contact_inboxes=false`);
|
|
}
|
|
|
|
update(id, data) {
|
|
return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
|
|
}
|
|
|
|
getConversations(contactId, { inboxId } = {}) {
|
|
const params = inboxId ? { inbox_id: inboxId } : {};
|
|
return axios.get(`${this.url}/${contactId}/conversations`, { params });
|
|
}
|
|
|
|
getContactableInboxes(contactId) {
|
|
return axios.get(`${this.url}/${contactId}/contactable_inboxes`);
|
|
}
|
|
|
|
getContactLabels(contactId) {
|
|
return axios.get(`${this.url}/${contactId}/labels`);
|
|
}
|
|
|
|
initiateCall(contactId, inboxId, conversationId = null) {
|
|
return axios.post(`${this.url}/${contactId}/call`, {
|
|
inbox_id: inboxId,
|
|
conversation_id: conversationId,
|
|
});
|
|
}
|
|
|
|
updateContactLabels(contactId, labels) {
|
|
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
|
}
|
|
|
|
search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) {
|
|
let requestURL = `${this.url}/search?${buildContactParams(
|
|
page,
|
|
sortAttr,
|
|
label,
|
|
search
|
|
)}`;
|
|
return axios.get(requestURL, { signal: options.signal });
|
|
}
|
|
|
|
active(page = 1, sortAttr = 'name') {
|
|
let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`;
|
|
return axios.get(requestURL);
|
|
}
|
|
|
|
// eslint-disable-next-line default-param-last
|
|
filter(page = 1, sortAttr = 'name', queryPayload) {
|
|
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
|
|
return axios.post(requestURL, queryPayload);
|
|
}
|
|
|
|
importContacts(file) {
|
|
const formData = new FormData();
|
|
formData.append('import_file', file);
|
|
return axios.post(`${this.url}/import`, formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
});
|
|
}
|
|
|
|
destroyCustomAttributes(contactId, customAttributes) {
|
|
return axios.post(`${this.url}/${contactId}/destroy_custom_attributes`, {
|
|
custom_attributes: customAttributes,
|
|
});
|
|
}
|
|
|
|
destroyAvatar(contactId) {
|
|
return axios.delete(`${this.url}/${contactId}/avatar`);
|
|
}
|
|
|
|
exportContacts(queryPayload) {
|
|
return axios.post(`${this.url}/export`, queryPayload);
|
|
}
|
|
}
|
|
|
|
export default new ContactAPI();
|