Compare commits

...
Author SHA1 Message Date
Shivam Mishra b545653a93 feat: update enums 2025-03-06 20:05:59 +05:30
Shivam Mishra 0f02455224 feat: remove redact contact job 2025-03-06 20:05:59 +05:30
Shivam Mishra 1c2c8f26fc feat: update controllers to create delete request 2025-03-06 20:05:59 +05:30
Shivam Mishra 82408a8e03 feat: add delete requests table 2025-03-06 20:05:59 +05:30
Shivam MishraandGitHub c17c9b10d9 Merge branch 'develop' into feat/meta-delete-request 2025-03-06 13:02:34 +05:30
Sivin VargheseandGitHub 7e1458fd32 fix: Issue when saving hotkeys (#11026)
# Pull Request Template

## Description

This PR fixes an issue when saving send message button hotkeys, where a
TypeError occurs: `this.updateUISettings is not a function`.

This issue arises after merging this PR
https://github.com/chatwoot/chatwoot/pull/10974

Fixes
https://chatwoot-p3.sentry.io/issues/6339976939/events/ca9946f92cb74428a72f1f74976a56a3/

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-03-06 10:53:02 +05:30
PranavandGitHub d017156f32 fix: Disable syncing IMAP if the account is suspended (#11031)
This PR disables the IMAP syncing if the account is suspended.
2025-03-05 17:10:24 -08:00
Sojan JoseandGitHub c1f6d9f76f feat: Add the ability to filter items in Super Admin panel (#11020) 2025-03-05 16:32:54 -08:00
Muhsin KelothandGitHub e5d41f4b03 Merge branch 'develop' into feat/meta-delete-request 2025-03-04 21:20:12 +05:30
ed562832a6 feat: add ui element for secrets in superadmin (#11000)
- add secret type for installation_config in the super admin console
- hide tokens by default on the UI

Before
----
<img width="891" alt="image"
src="https://github.com/user-attachments/assets/1c51b3bb-47f9-49bf-bb0d-75b84d8d2eeb"
/>


After
----
<img width="958" alt="image"
src="https://github.com/user-attachments/assets/b7eca1ff-1cc0-4259-9977-6109eb38ad64"
/>

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-03-04 16:51:40 +05:30
Shivam Mishra 2be2c9aa04 feat: throw malformed request for standard error 2025-03-04 16:32:18 +05:30
Shivam Mishra d9b86182f2 fix: default value 2025-03-04 16:30:31 +05:30
Shivam MishraandGitHub 79e46d1758 Merge branch 'develop' into feat/meta-delete-request 2025-03-04 15:21:49 +05:30
Shivam Mishra 5895d7b2bd test: redact contact job 2025-03-04 15:21:28 +05:30
Shivam Mishra bf5220e881 fix: fn names 2025-03-04 15:14:58 +05:30
Shivam Mishra 6bc29bfef2 fix: fn name 2025-03-04 15:10:25 +05:30
Shivam Mishra 9dddd1b020 refactor: move facebook redact job 2025-03-04 15:09:21 +05:30
Shivam Mishra bc294d8829 chore: add comment for response format 2025-03-04 15:02:09 +05:30
Shivam Mishra 4b243a44ee test: delete controller 2025-03-04 15:01:20 +05:30
Shivam Mishra f2961c4f8e fix: align to facebook spec 2025-03-04 15:01:07 +05:30
Shivam Mishra c4ea779759 test: confirm controller 2025-03-04 14:54:18 +05:30
Shivam Mishra cfb74b12f9 fix: function call in concern 2025-03-04 14:54:09 +05:30
Shivam Mishra 63bea5219b feat: use a shared concern 2025-03-04 14:45:15 +05:30
Shivam Mishra 74c611325f feat: don't remove inbox 2025-03-04 14:32:57 +05:30
Shivam Mishra 71ebacc46e refactor: rename meta to facebook 2025-03-03 17:53:31 +05:30
Shivam Mishra 770e395f17 feat: remove webhook route 2025-03-03 17:52:45 +05:30
Shivam Mishra 4a925619a1 refactor: render plain text 2025-03-03 17:51:09 +05:30
Shivam Mishra db537b5c86 feat: update confirmation url 2025-03-03 17:50:08 +05:30
Vishnu NarayananandGitHub 12d7be62d3 chore: fix nightly linux installer github action (#11009)
- chore: fix nightly linux installer github action
2025-03-03 17:45:57 +05:30
Shivam Mishra cd1dbb67eb feat: update confirmation url 2025-03-03 17:43:48 +05:30
Shivam Mishra 114596db19 feat: move controller to facebook 2025-03-03 17:43:10 +05:30
Shivam Mishra 96a3999c8b feat: add meta deletion callback endpoint 2025-03-03 17:36:10 +05:30
6040e50265 chore: Ability to filter conversations with priority (#10967)
- Ability to filter conversation with priority

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-03-03 16:38:22 +05:30
33 changed files with 753 additions and 118 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ on:
jobs:
nightly:
runs-on: ubuntu-20.04
runs-on: ubuntu-24.04
steps:
- name: get installer
+2 -2
View File
@@ -561,7 +561,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.11)
rack (2.2.12)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -799,7 +799,7 @@ GEM
unf_ext (0.0.8.2)
unicode-display_width (2.4.2)
uniform_notifier (1.16.0)
uri (0.13.0)
uri (1.0.3)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@@ -1,8 +1,8 @@
button,
input[type="button"],
input[type="reset"],
input[type="submit"],
.button {
button:not(.reset-base),
input[type='button']:not(.reset-base),
input[type='reset']:not(.reset-base),
input[type='submit']:not(.reset-base),
.button:not(.reset-base) {
appearance: none;
background-color: $color-woot;
border: 0;
@@ -10,7 +10,6 @@
.icon-container {
margin-right: 2px;
}
.value-container {
@@ -0,0 +1,35 @@
module FacebookConcern
extend ActiveSupport::Concern
class InvalidDigestError < StandardError; end
private
def deletion_processed?(code)
request = DeleteRequest.find_by!(confirmation_code: code)
request.complete?
rescue ActiveRecord::RecordNotFound
false
end
def parse_fb_signed_request(signed_request)
encoded_signature, payload = signed_request.split('.', 2)
decoded_signature = Base64.urlsafe_decode64(encoded_signature)
decoded_payload = JSON.parse(Base64.urlsafe_decode64(payload))
expected_signature = OpenSSL::HMAC.digest('sha256', app_secret, payload)
raise InvalidDigestError if decoded_signature != expected_signature
decoded_payload
end
def app_url_base
ENV.fetch('FRONTEND_URL', nil)
end
def app_secret
GlobalConfigService.load('FB_APP_SECRET', '')
end
end
@@ -0,0 +1,11 @@
class Facebook::ConfirmController < ApplicationController
include FacebookConcern
def show
if deletion_processed?(params[:id])
render plain: 'Data Deleted Successfully', status: :ok
else
render plain: 'Processing. If there is an issue, please contact support', status: :ok
end
end
end
@@ -0,0 +1,23 @@
class Facebook::DeleteController < ApplicationController
include FacebookConcern
def create
signed_request = params['signed_request']
payload = parse_fb_signed_request(signed_request)
id_to_process = payload['user_id']
delete_request = DeleteRequest.create(fb_id: id_to_process)
status_url = "#{app_url_base}/facebook/confirm/#{delete_request.confirmation_code}"
# IMPORTANT: Do not change the response format below.
# Facebook's Data Deletion Request system specifically expects responses in this format
# with a 'url' for status confirmation and a 'confirmation_code' field.
# See: https://developers.facebook.com/docs/development/create-an-app/app-dashboard/data-deletion-callback/#implementing
render json: { url: status_url, confirmation_code: delete_request.confirmation_code }, status: :ok
rescue InvalidDigestError
render json: { error: 'Invalid signature' }, status: :unprocessable_entity
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
render json: { error: e.message }, status: :malformed_request
end
end
+5 -1
View File
@@ -78,7 +78,11 @@ class AccountDashboard < Administrate::BaseDashboard
# COLLECTION_FILTERS = {
# open: ->(resources) { resources.where(open: true) }
# }.freeze
COLLECTION_FILTERS = {}.freeze
COLLECTION_FILTERS = {
active: ->(resources) { resources.where(status: :active) },
suspended: ->(resources) { resources.where(status: :suspended) },
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
}.freeze
# Overwrite this method to customize how accounts are displayed
# across all pages of the admin dashboard.
+6 -1
View File
@@ -94,7 +94,12 @@ class UserDashboard < Administrate::BaseDashboard
# COLLECTION_FILTERS = {
# open: ->(resources) { resources.where(open: true) }
# }.freeze
COLLECTION_FILTERS = {}.freeze
COLLECTION_FILTERS = {
super_admin: ->(resources) { resources.where(type: 'SuperAdmin') },
confirmed: ->(resources) { resources.where.not(confirmed_at: nil) },
unconfirmed: ->(resources) { resources.where(confirmed_at: nil) },
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
}.freeze
# Overwrite this method to customize how users are displayed
# across all pages of the admin dashboard.
@@ -1,4 +1,4 @@
module FilterHelper
module Filters::FilterHelper
def build_condition_query(model_filters, query_hash, current_index)
current_filter = model_filters[query_hash['attribute_key']]
@@ -89,4 +89,18 @@ module FilterHelper
operator = condition['query_operator'].upcase
raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator)
end
def conversation_status_values(values)
return Conversation.statuses.values if values.include?('all')
values.map { |x| Conversation.statuses[x.to_sym] }
end
def conversation_priority_values(values)
values.map { |x| Conversation.priorities[x.to_sym] }
end
def message_type_values(values)
values.map { |x| Message.message_types[x.to_sym] }
end
end
@@ -2,7 +2,10 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useOperators } from './operators';
import { useMapGetter } from 'dashboard/composables/store.js';
import { buildAttributesFilterTypes } from './helper/filterHelper.js';
import {
buildAttributesFilterTypes,
CONTACT_ATTRIBUTES,
} from './helper/filterHelper.js';
import countries from 'shared/constants/countries.js';
/**
@@ -59,7 +62,11 @@ export function useContactFilterContext() {
* @type {import('vue').ComputedRef<FilterType[]>}
*/
const customFilterTypes = computed(() =>
buildAttributesFilterTypes(contactAttributes.value, getOperatorTypes)
buildAttributesFilterTypes(
contactAttributes.value,
getOperatorTypes,
'contact'
)
);
/**
@@ -67,8 +74,8 @@ export function useContactFilterContext() {
*/
const filterTypes = computed(() => [
{
attributeKey: 'name',
value: 'name',
attributeKey: CONTACT_ATTRIBUTES.NAME,
value: CONTACT_ATTRIBUTES.NAME,
attributeName: t('CONTACTS_LAYOUT.FILTER.NAME'),
label: t('CONTACTS_LAYOUT.FILTER.NAME'),
inputType: 'plainText',
@@ -77,8 +84,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'email',
value: 'email',
attributeKey: CONTACT_ATTRIBUTES.EMAIL,
value: CONTACT_ATTRIBUTES.EMAIL,
attributeName: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
label: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
inputType: 'plainText',
@@ -87,8 +94,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'phone_number',
value: 'phone_number',
attributeKey: CONTACT_ATTRIBUTES.PHONE_NUMBER,
value: CONTACT_ATTRIBUTES.PHONE_NUMBER,
attributeName: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
label: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
inputType: 'plainText',
@@ -97,8 +104,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'identifier',
value: 'identifier',
attributeKey: CONTACT_ATTRIBUTES.IDENTIFIER,
value: CONTACT_ATTRIBUTES.IDENTIFIER,
attributeName: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
label: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
inputType: 'plainText',
@@ -107,8 +114,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'country_code',
value: 'country_code',
attributeKey: CONTACT_ATTRIBUTES.COUNTRY_CODE,
value: CONTACT_ATTRIBUTES.COUNTRY_CODE,
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
inputType: 'searchSelect',
@@ -118,8 +125,8 @@ export function useContactFilterContext() {
attributeModel: 'additional',
},
{
attributeKey: 'city',
value: 'city',
attributeKey: CONTACT_ATTRIBUTES.CITY,
value: CONTACT_ATTRIBUTES.CITY,
attributeName: t('CONTACTS_LAYOUT.FILTER.CITY'),
label: t('CONTACTS_LAYOUT.FILTER.CITY'),
inputType: 'plainText',
@@ -128,8 +135,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'created_at',
value: 'created_at',
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
value: CONTACT_ATTRIBUTES.CREATED_AT,
attributeName: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
label: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
inputType: 'date',
@@ -138,8 +145,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'last_activity_at',
value: 'last_activity_at',
attributeKey: CONTACT_ATTRIBUTES.LAST_ACTIVITY_AT,
value: CONTACT_ATTRIBUTES.LAST_ACTIVITY_AT,
attributeName: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
label: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
inputType: 'date',
@@ -148,8 +155,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'referer',
value: 'referer',
attributeKey: CONTACT_ATTRIBUTES.REFERER,
value: CONTACT_ATTRIBUTES.REFERER,
attributeName: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
label: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
inputType: 'plainText',
@@ -158,8 +165,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'blocked',
value: 'blocked',
attributeKey: CONTACT_ATTRIBUTES.BLOCKED,
value: CONTACT_ATTRIBUTES.BLOCKED,
attributeName: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
label: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
inputType: 'searchSelect',
@@ -1,3 +1,35 @@
/**
* Standard attributes of the conversation model
*/
export const CONVERSATION_ATTRIBUTES = {
STATUS: 'status',
PRIORITY: 'priority',
ASSIGNEE_ID: 'assignee_id',
INBOX_ID: 'inbox_id',
TEAM_ID: 'team_id',
DISPLAY_ID: 'display_id',
CAMPAIGN_ID: 'campaign_id',
LABELS: 'labels',
BROWSER_LANGUAGE: 'browser_language',
COUNTRY_CODE: 'country_code',
REFERER: 'referer',
CREATED_AT: 'created_at',
LAST_ACTIVITY_AT: 'last_activity_at',
};
export const CONTACT_ATTRIBUTES = {
NAME: 'name',
EMAIL: 'email',
PHONE_NUMBER: 'phone_number',
IDENTIFIER: 'identifier',
COUNTRY_CODE: 'country_code',
CITY: 'city',
CREATED_AT: 'created_at',
LAST_ACTIVITY_AT: 'last_activity_at',
REFERER: 'referer',
BLOCKED: 'blocked',
};
/**
* Determines the input type for a custom attribute based on its key
* @param {string} key - The attribute display type key
@@ -20,24 +52,37 @@ export const getCustomAttributeInputType = key => {
/**
* Builds filter types for custom attributes
* This also removes any conflicting attributes
* @param {Array} attributes - The attributes array
* @param {Function} getOperatorTypes - Function to get operator types
* @returns {Array} Array of filter types
*/
export const buildAttributesFilterTypes = (attributes, getOperatorTypes) => {
return attributes.map(attr => ({
attributeKey: attr.attributeKey,
value: attr.attributeKey,
attributeName: attr.attributeDisplayName,
label: attr.attributeDisplayName,
inputType: getCustomAttributeInputType(attr.attributeDisplayType),
filterOperators: getOperatorTypes(attr.attributeDisplayType),
options:
attr.attributeDisplayType === 'list'
? attr.attributeValues.map(item => ({ id: item, name: item }))
: [],
attributeModel: 'customAttributes',
}));
export const buildAttributesFilterTypes = (
attributes,
getOperatorTypes,
filterModel = 'conversation'
) => {
const standardAttributes = Object.values(
filterModel === 'conversation'
? CONVERSATION_ATTRIBUTES
: CONTACT_ATTRIBUTES
);
return attributes
.filter(attr => !standardAttributes.includes(attr.attributeKey))
.map(attr => ({
attributeKey: attr.attributeKey,
value: attr.attributeKey,
attributeName: attr.attributeDisplayName,
label: attr.attributeDisplayName,
inputType: getCustomAttributeInputType(attr.attributeDisplayType),
filterOperators: getOperatorTypes(attr.attributeDisplayType),
options:
attr.attributeDisplayType === 'list'
? attr.attributeValues.map(item => ({ id: item, name: item }))
: [],
attributeModel: 'customAttributes',
}));
};
/**
@@ -3,7 +3,10 @@ import { useI18n } from 'vue-i18n';
import { useOperators } from './operators';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useChannelIcon } from 'next/icon/provider';
import { buildAttributesFilterTypes } from './helper/filterHelper';
import {
buildAttributesFilterTypes,
CONVERSATION_ATTRIBUTES,
} from './helper/filterHelper';
import countries from 'shared/constants/countries.js';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
@@ -70,7 +73,11 @@ export function useConversationFilterContext() {
* @type {import('vue').ComputedRef<FilterType[]>}
*/
const customFilterTypes = computed(() =>
buildAttributesFilterTypes(conversationAttributes.value, getOperatorTypes)
buildAttributesFilterTypes(
conversationAttributes.value,
getOperatorTypes,
'conversation'
)
);
/**
@@ -78,8 +85,8 @@ export function useConversationFilterContext() {
*/
const filterTypes = computed(() => [
{
attributeKey: 'status',
value: 'status',
attributeKey: CONVERSATION_ATTRIBUTES.STATUS,
value: CONVERSATION_ATTRIBUTES.STATUS,
attributeName: t('FILTER.ATTRIBUTES.STATUS'),
label: t('FILTER.ATTRIBUTES.STATUS'),
inputType: 'multiSelect',
@@ -94,8 +101,24 @@ export function useConversationFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'assignee_id',
value: 'assignee_id',
attributeKey: CONVERSATION_ATTRIBUTES.PRIORITY,
value: CONVERSATION_ATTRIBUTES.PRIORITY,
attributeName: t('FILTER.ATTRIBUTES.PRIORITY'),
label: t('FILTER.ATTRIBUTES.PRIORITY'),
inputType: 'multiSelect',
options: ['low', 'medium', 'high', 'urgent'].map(id => {
return {
id,
name: t(`CONVERSATION.PRIORITY.OPTIONS.${id.toUpperCase()}`),
};
}),
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.ASSIGNEE_ID,
value: CONVERSATION_ATTRIBUTES.ASSIGNEE_ID,
attributeName: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
label: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
inputType: 'searchSelect',
@@ -110,8 +133,8 @@ export function useConversationFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'inbox_id',
value: 'inbox_id',
attributeKey: CONVERSATION_ATTRIBUTES.INBOX_ID,
value: CONVERSATION_ATTRIBUTES.INBOX_ID,
attributeName: t('FILTER.ATTRIBUTES.INBOX_NAME'),
label: t('FILTER.ATTRIBUTES.INBOX_NAME'),
inputType: 'searchSelect',
@@ -126,8 +149,8 @@ export function useConversationFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'team_id',
value: 'team_id',
attributeKey: CONVERSATION_ATTRIBUTES.TEAM_ID,
value: CONVERSATION_ATTRIBUTES.TEAM_ID,
attributeName: t('FILTER.ATTRIBUTES.TEAM_NAME'),
label: t('FILTER.ATTRIBUTES.TEAM_NAME'),
inputType: 'searchSelect',
@@ -137,8 +160,8 @@ export function useConversationFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'display_id',
value: 'display_id',
attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
attributeName: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
label: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
inputType: 'plainText',
@@ -147,8 +170,8 @@ export function useConversationFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'campaign_id',
value: 'campaign_id',
attributeKey: CONVERSATION_ATTRIBUTES.CAMPAIGN_ID,
value: CONVERSATION_ATTRIBUTES.CAMPAIGN_ID,
attributeName: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
label: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
inputType: 'searchSelect',
@@ -161,8 +184,8 @@ export function useConversationFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'labels',
value: 'labels',
attributeKey: CONVERSATION_ATTRIBUTES.LABELS,
value: CONVERSATION_ATTRIBUTES.LABELS,
attributeName: t('FILTER.ATTRIBUTES.LABELS'),
label: t('FILTER.ATTRIBUTES.LABELS'),
inputType: 'multiSelect',
@@ -185,8 +208,8 @@ export function useConversationFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'browser_language',
value: 'browser_language',
attributeKey: CONVERSATION_ATTRIBUTES.BROWSER_LANGUAGE,
value: CONVERSATION_ATTRIBUTES.BROWSER_LANGUAGE,
attributeName: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
label: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
inputType: 'searchSelect',
@@ -196,8 +219,8 @@ export function useConversationFilterContext() {
attributeModel: 'additional',
},
{
attributeKey: 'country_code',
value: 'country_code',
attributeKey: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
value: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
inputType: 'searchSelect',
@@ -207,8 +230,8 @@ export function useConversationFilterContext() {
attributeModel: 'additional',
},
{
attributeKey: 'referer',
value: 'referer',
attributeKey: CONVERSATION_ATTRIBUTES.REFERER,
value: CONVERSATION_ATTRIBUTES.REFERER,
attributeName: t('FILTER.ATTRIBUTES.REFERER_LINK'),
label: t('FILTER.ATTRIBUTES.REFERER_LINK'),
inputType: 'plainText',
@@ -217,8 +240,8 @@ export function useConversationFilterContext() {
attributeModel: 'additional',
},
{
attributeKey: 'created_at',
value: 'created_at',
attributeKey: CONVERSATION_ATTRIBUTES.CREATED_AT,
value: CONVERSATION_ATTRIBUTES.CREATED_AT,
attributeName: t('FILTER.ATTRIBUTES.CREATED_AT'),
label: t('FILTER.ATTRIBUTES.CREATED_AT'),
inputType: 'date',
@@ -227,8 +250,8 @@ export function useConversationFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'last_activity_at',
value: 'last_activity_at',
attributeKey: CONVERSATION_ATTRIBUTES.LAST_ACTIVITY_AT,
value: CONVERSATION_ATTRIBUTES.LAST_ACTIVITY_AT,
attributeName: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
label: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
inputType: 'date',
@@ -39,13 +39,14 @@ export default {
},
mixins: [globalConfigMixin],
setup() {
const { isEditorHotKeyEnabled } = useUISettings();
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
const { currentFontSize, updateFontSize } = useFontSize();
return {
currentFontSize,
updateFontSize,
isEditorHotKeyEnabled,
updateUISettings,
};
},
data() {
@@ -2,8 +2,15 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
queue_as :scheduled_jobs
def perform
Inbox.where(channel_type: 'Channel::Email').all.find_each(batch_size: 100) do |inbox|
::Inboxes::FetchImapEmailsJob.perform_later(inbox.channel) if inbox.channel.imap_enabled
email_inboxes = Inbox.where(channel_type: 'Channel::Email')
email_inboxes.find_each(batch_size: 100) do |inbox|
::Inboxes::FetchImapEmailsJob.perform_later(inbox.channel) if should_fetch_emails?(inbox)
end
end
private
def should_fetch_emails?(inbox)
inbox.channel.imap_enabled && !inbox.account.suspended?
end
end
+14
View File
@@ -22,6 +22,12 @@
# index_custom_attribute_definitions_on_account_id (account_id)
#
class CustomAttributeDefinition < ApplicationRecord
STANDARD_ATTRIBUTES = {
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at
last_activity_at],
:contact => %w[name email phone_number identifier country_code city created_at last_activity_at referer blocked]
}.freeze
scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
validates :attribute_display_name, presence: true
@@ -31,6 +37,7 @@ class CustomAttributeDefinition < ApplicationRecord
validates :attribute_display_type, presence: true
validates :attribute_model, presence: true
validate :attribute_must_not_conflict, on: :create
enum attribute_model: { conversation_attribute: 0, contact_attribute: 1 }
enum attribute_display_type: { text: 0, number: 1, currency: 2, percent: 3, link: 4, date: 5, list: 6, checkbox: 7 }
@@ -48,4 +55,11 @@ class CustomAttributeDefinition < ApplicationRecord
def update_widget_pre_chat_custom_fields
::Inboxes::UpdateWidgetPreChatCustomFieldsJob.perform_later(account, self)
end
def attribute_must_not_conflict
model_keys = attribute_model.to_sym == :conversation_attribute ? :conversation : :contact
return unless attribute_key.in?(STANDARD_ATTRIBUTES[model_keys])
errors.add(:attribute_key, I18n.t('errors.custom_attribute_definition.key_conflict'))
end
end
+50
View File
@@ -0,0 +1,50 @@
# == Schema Information
#
# Table name: delete_requests
#
# id :bigint not null, primary key
# completed_at :datetime
# confirmation_code :string not null
# deleted_type :string
# status :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint
# deleted_id :integer
# fb_id :string not null
#
# Indexes
#
# index_delete_requests_on_account_id (account_id)
# index_delete_requests_on_confirmation_code (confirmation_code) UNIQUE
#
class DeleteRequest < ApplicationRecord
belongs_to :account, optional: true
enum :status, %w[pending complete].index_by(&:itself), default: :pending
before_create :ensure_unqiue_confirmation_code
private
def ensure_unqiue_confirmation_code
max_retries = 3
retry_count = 0
begin
self.confirmation_code = generate_confirmation_code
raise ActiveRecord::RecordNotUnique if self.class.exists?(confirmation_code: confirmation_code)
rescue ActiveRecord::RecordNotUnique
retry_count += 1
if retry_count > max_retries
# it's really really unlikely that we'll ever ever hit this case, but just in case
self.confirmation_code = generate_confirmation_code + SecureRandom.alphanumeric(3)
else
retry
end
end
end
def generate_confirmation_code
SecureRandom.uuid.delete('-')
end
end
+9 -12
View File
@@ -1,7 +1,7 @@
require 'json'
class FilterService
include FilterHelper
include Filters::FilterHelper
include CustomExceptions::CustomFilter
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
@@ -43,18 +43,15 @@ class FilterService
end
def filter_values(query_hash)
case query_hash['attribute_key']
when 'status'
return Conversation.statuses.values if query_hash['values'].include?('all')
attribute_key = query_hash['attribute_key']
values = query_hash['values']
query_hash['values'].map { |x| Conversation.statuses[x.to_sym] }
when 'message_type'
query_hash['values'].map { |x| Message.message_types[x.to_sym] }
when 'content'
downcase_array_values(query_hash['values'])
else
case_insensitive_values(query_hash)
end
return conversation_status_values(values) if attribute_key == 'status'
return conversation_priority_values(values) if attribute_key == 'priority'
return message_type_values(values) if attribute_key == 'message_type'
return downcase_array_values(values) if attribute_key == 'content'
case_insensitive_values(query_hash)
end
def downcase_array_values(values)
@@ -1,11 +1,19 @@
<% content_for(:title) do %>
Configure Settings - <%= @config.titleize %>
<% end %>
<header class="main-content__header" role="banner">
<h1 class="main-content__page-title" id="page-title">
<%= content_for(:title) %>
</h1>
</header>
<style>
.eye-icon.eye-hide path {
d: path('M3 3l18 18M10.5 10.677a2 2 0 002.823 2.823M7.362 7.561C5.68 8.74 4.279 10.42 3 12c1.889 2.991 5.282 6 9 6 1.55 0 3.043-.523 4.395-1.35M12 6c4.008 0 6.701 3.009 9 6a15.66 15.66 0 01-1.078 1.5');
}
</style>
<section class="main-content__body">
<%= form_with url: super_admin_app_config_url(config: @config) , method: :post do |form| %>
<% @allowed_configs.each do |key| %>
@@ -15,18 +23,36 @@
</div>
<div class="-mt-2 field-unit__field ">
<% if @installation_configs[key]&.dig('type') == 'boolean' %>
<%= form.select "app_config[#{key}]",
[["True", true], ["False", false]],
<%= form.select "app_config[#{key}]",
[["True", true], ["False", false]],
{ selected: ActiveModel::Type::Boolean.new.cast(@app_config[key]) },
class: "mt-2 border border-slate-100 p-1 rounded-md"
class: "mt-2 border border-slate-100 p-1 rounded-md"
%>
<% elsif @installation_configs[key]&.dig('type') == 'code' %>
<%= form.text_area "app_config[#{key}]",
value: @app_config[key],
<%= form.text_area "app_config[#{key}]",
value: @app_config[key],
rows: 12,
wrap: 'off',
class: "mt-2 border font-mono text-xs border-slate-100 p-1 rounded-md overflow-scroll"
%>
<% elsif @installation_configs[key]&.dig('type') == 'secret' %>
<div class="relative">
<%= form.password_field "app_config[#{key}]",
id: "app_config_#{key}",
value: @app_config[key],
class: "mt-2 border border-slate-100 p-1.5 pr-8 rounded-md w-full"
%>
<button
type="button"
class="absolute reset-base !bg-white top-1/2 !outline-0 !text-n-slate-11 -translate-y-1/2 right-2 p-1 hover:!bg-n-slate-5 rounded-sm toggle-password"
data-target="app_config_<%= key %>"
>
<svg class="eye-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5C5.63636 5 2 12 2 12C2 12 5.63636 19 12 19C18.3636 19 22 12 22 12C22 12 18.3636 5 12 5Z"/>
<path d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"/>
</svg>
</button>
</div>
<% else %>
<%= form.text_field "app_config[#{key}]", value: @app_config[key] %>
<% end %>
@@ -43,3 +69,26 @@
</div>
<% end %>
</section>
<% content_for :javascript do %>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.toggle-password').forEach(button => {
button.addEventListener('click', () => {
const targetId = button.dataset.target;
const input = document.getElementById(targetId);
const type = input.type === 'password' ? 'text' : 'password';
input.type = type;
// Toggle icon
const svg = button.querySelector('.eye-icon');
if (type === 'password') {
svg.classList.remove('eye-hide')
} else {
svg.classList.add('eye-hide')
}
});
});
});
</script>
<% end %>
@@ -0,0 +1,63 @@
<%#
# Filters
This partial is used on the `index` page to display available filters
for a collection of resources.
## Local variables:
- `page`:
An instance of [Administrate::Page::Collection][1].
Contains helper methods to help display a table,
and knows which attributes should be displayed in the resource's table.
[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Collection
%>
<%
# Get the dashboard class name from the resource name
resource_name = page.resource_name.classify
dashboard_class_name = "#{resource_name}Dashboard"
dashboard_class = dashboard_class_name.constantize
# Get the current filter if any
current_filter = nil
if params[:search] && params[:search].include?(':')
current_filter = params[:search].split(':').first
end
%>
<% if dashboard_class.const_defined?(:COLLECTION_FILTERS) && !dashboard_class::COLLECTION_FILTERS.empty? %>
<div class="flex items-center bg-gray-100 border-0 rounded-md shadow-none relative w-[260px]">
<div class="flex items-center h-10 px-2 w-full">
<div class="flex items-center justify-center flex-shrink-0 mr-2 text-gray-500" title="Filter by">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
</svg>
</div>
<div class="flex-1 h-full min-w-0 relative">
<select id="filter-select" class="appearance-none bg-gray-100 border-0 text-gray-700 cursor-pointer text-sm h-full overflow-hidden truncate whitespace-nowrap w-full pr-7 pl-0 py-2 focus:outline-none bg-[url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27 viewBox=%270 0 12 12%27%3E%3Cpath fill=%27%23293f54%27 d=%27M6 9L1 4h10z%27/%3E%3C/svg%3E')] bg-[right_0.25rem_center] bg-no-repeat bg-[length:0.75rem]" onchange="applyFilter(this.value)">
<option value="">All records</option>
<% dashboard_class::COLLECTION_FILTERS.each do |filter_name, _| %>
<option value="<%= filter_name %>" <%= 'selected' if filter_name.to_s == current_filter %>>
<%= filter_name.to_s.titleize %>
</option>
<% end %>
</select>
<% if current_filter %>
<a href="?" class="flex items-center justify-center rounded-full text-gray-500 text-xl font-bold h-[18px] w-[18px] leading-none absolute right-5 top-1/2 -translate-y-1/2 no-underline z-2 hover:text-gray-900" title="Clear filter">×</a>
<% end %>
</div>
</div>
</div>
<script>
function applyFilter(filterName) {
if (filterName) {
window.location.href = "?search=" + encodeURIComponent(filterName) + "%3A";
} else {
window.location.href = "?";
}
}
</script>
<% end %>
@@ -0,0 +1,24 @@
<form class="search" role="search">
<label class="search__label" for="search">
<svg class="search__eyeglass-icon" role="img">
<title>
<%= t("administrate.search.label", resource: resource_name) %>
</title>
<use xlink:href="#icon-eyeglass" />
</svg>
</label>
<input class="search__input"
id="search"
type="search"
name="search"
placeholder="<%= t("administrate.search.label", resource: resource_name) %>"
value="<%= search_term %>">
<%= link_to clear_search_params, class: "search__clear-link" do %>
<svg class="search__clear-icon" role="img">
<title><%= t("administrate.search.clear") %></title>
<use xlink:href="#icon-cancel" />
</svg>
<% end %>
</form>
@@ -28,27 +28,36 @@ It renders the `_table` partial to display details about the resources.
<% end %>
<header class="main-content__header" role="banner">
<h1 class="main-content__page-title" id="page-title">
<%= content_for(:title) %>
</h1>
<div class="flex items-center justify-between w-full">
<h1 class="main-content__page-title m-0 mr-6" id="page-title">
<%= content_for(:title) %>
</h1>
<% if show_search_bar %>
<%= render(
"search",
search_term: search_term,
resource_name: display_resource_name(page.resource_name)
) %>
<% end %>
<div class="flex items-center">
<% if show_search_bar %>
<div class="flex items-center">
<%= render("filters", page: page) %>
<div class="ml-3">
<%= render(
"search",
search_term: search_term,
resource_name: display_resource_name(page.resource_name)
) %>
</div>
</div>
<% end %>
<div>
<%= link_to(
t(
"administrate.actions.new_resource",
name: page.resource_name.titleize.downcase
),
[:new, namespace, page.resource_path.to_sym],
class: "button",
) if accessible_action?(new_resource, :new) %>
<div class="whitespace-nowrap ml-4">
<%= link_to(
t(
"administrate.actions.new_resource",
name: page.resource_name.titleize.downcase
),
[:new, namespace, page.resource_path.to_sym],
class: "button",
) if accessible_action?(new_resource, :new) %>
</div>
</div>
</div>
</header>
+14
View File
@@ -103,13 +103,16 @@
display_title: 'Facebook Verify Token'
description: 'The verify token used for Facebook Messenger Webhook'
locked: false
type: secret
- name: FB_APP_SECRET
display_title: 'Facebook App Secret'
locked: false
type: secret
- name: IG_VERIFY_TOKEN
display_title: 'Instagram Verify Token'
description: 'The verify token used for Instagram Webhook'
locked: false
type: secret
- name: FACEBOOK_API_VERSION
display_title: 'Facebook API Version'
description: 'Configure this if you want to use a different Facebook API version. Make sure its prefixed with `v`'
@@ -131,6 +134,7 @@
- name: AZURE_APP_SECRET
display_title: 'Azure App Secret'
locked: false
type: secret
# End of Microsoft Email Channel Config
# MARK: Captain Config
@@ -138,6 +142,7 @@
display_title: 'OpenAI API Key'
description: 'The API key used to authenticate requests to OpenAI services for Captain AI.'
locked: false
type: secret
- name: CAPTAIN_OPEN_AI_MODEL
display_title: 'OpenAI Model'
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4o-mini'
@@ -146,6 +151,7 @@
display_title: 'FireCrawl API Key (optional)'
description: 'The FireCrawl API key for the Captain AI service'
locked: false
type: secret
- name: CAPTAIN_CLOUD_PLAN_LIMITS
display_title: 'Captain Cloud Plan Limits'
description: 'The limits for the Captain AI service for different plans'
@@ -160,11 +166,13 @@
display_title: 'Inbox Token'
description: 'The Chatwoot Inbox Token for Contact Support in Cloud'
locked: false
type: secret
- name: CHATWOOT_INBOX_HMAC_KEY
value:
display_title: 'Inbox HMAC Key'
description: 'The Chatwoot Inbox HMAC Key for Contact Support in Cloud'
locked: false
type: secret
- name: CHATWOOT_CLOUD_PLANS
display_title: 'Cloud Plans'
value:
@@ -180,10 +188,12 @@
value:
display_title: 'Analytics Token'
description: 'The June.so analytics token for Chatwoot cloud'
type: secret
- name: CLEARBIT_API_KEY
value:
display_title: 'Clearbit API Key'
description: 'This API key is used for onboarding the users, to pre-fill account data.'
type: secret
- name: DASHBOARD_SCRIPTS
value:
display_title: 'Dashboard Scripts'
@@ -206,12 +216,14 @@
- name: CHATWOOT_SUPPORT_WEBSITE_TOKEN
value:
description: 'The Chatwoot website token, used to identify the Chatwoot inbox and display the "Contact Support" option on the billing page'
type: secret
- name: CHATWOOT_SUPPORT_SCRIPT_URL
value:
description: 'The Chatwoot script base URL, to display the "Contact Support" option on the billing page'
- name: CHATWOOT_SUPPORT_IDENTIFIER_HASH
value:
description: 'The Chatwoot identifier hash, to validate the contact in the live chat window.'
type: secret
- name: ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL
display_title: Webhook URL to post security analysis
value:
@@ -245,6 +257,7 @@
display_title: 'Firebase Credentials'
value:
locked: false
type: secret
description: 'Contents on your firebase credentials json file'
## ------ End of Configs added for FCM v1 notifications ------ ##
@@ -259,4 +272,5 @@
value:
locked: false
description: 'Linear client secret'
type: secret
## ------ End of Configs added for Linear ------ ##
+2
View File
@@ -82,6 +82,8 @@ en:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
custom_attribute_definition:
key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
+5
View File
@@ -453,6 +453,11 @@ Rails.application.routes.draw do
resource :callback, only: [:show]
end
namespace :facebook do
resources :delete, only: [:create]
resources :confirm, only: [:show]
end
namespace :linear do
resource :callback, only: [:show]
end
@@ -0,0 +1,17 @@
class CreateDeleteRequests < ActiveRecord::Migration[7.0]
def change
create_table :delete_requests do |t|
t.string :fb_id, null: false
t.string :status, null: false
t.string :confirmation_code, null: false
t.datetime :completed_at
t.string :deleted_type
t.integer :deleted_id
t.references :account, index: true, null: true
t.timestamps
end
add_index :delete_requests, :confirmation_code, unique: true
end
end
+15 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2025_02_28_185548) do
ActiveRecord::Schema[7.0].define(version: 2025_03_06_081627) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -639,6 +639,20 @@ ActiveRecord::Schema[7.0].define(version: 2025_02_28_185548) do
t.index ["account_id"], name: "index_data_imports_on_account_id"
end
create_table "delete_requests", force: :cascade do |t|
t.string "fb_id", null: false
t.string "status", null: false
t.string "confirmation_code", null: false
t.datetime "completed_at"
t.string "deleted_type"
t.integer "deleted_id"
t.bigint "account_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_delete_requests_on_account_id"
t.index ["confirmation_code"], name: "index_delete_requests_on_confirmation_code", unique: true
end
create_table "email_templates", force: :cascade do |t|
t.string "name", null: false
t.text "body", null: false
+3
View File
@@ -41,4 +41,7 @@ module Redis::RedisKeys
IG_MESSAGE_MUTEX = 'IG_MESSAGE_CREATE_LOCK::%<sender_id>s::%<ig_account_id>s'.freeze
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%<conversation_id>s::%<reference_id>s'.freeze
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%<inbox_id>s'.freeze
## Meta deletion flags
META_DELETE_PROCESSING = 'META_DELETE_PROCESSING::%<id>s'.freeze
end
@@ -89,6 +89,30 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
json_response = response.parsed_body
expect(json_response['attribute_key']).to eq 'developer_id'
end
context 'when creating with a conflicting attribute_key' do
let(:standard_key) { CustomAttributeDefinition::STANDARD_ATTRIBUTES[:conversation].first }
let(:conflicting_payload) do
{
custom_attribute_definition: {
attribute_display_name: 'Conflicting Key',
attribute_key: standard_key,
attribute_model: 'conversation_attribute',
attribute_display_type: 'text'
}
}
end
it 'returns error for conflicting key' do
post "/api/v1/accounts/#{account.id}/custom_attribute_definitions",
headers: user.create_new_auth_token,
params: conflicting_payload
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['message']).to include('The provided key is not allowed as it might conflict with default attributes.')
end
end
end
end
@@ -0,0 +1,35 @@
require 'rails_helper'
RSpec.describe Facebook::ConfirmController do
describe 'GET #show' do
let(:user_id) { '12345' }
context 'when deletion is in progress' do
before do
redis_key = format(Redis::Alfred::META_DELETE_PROCESSING, id: user_id)
allow(Redis::Alfred).to receive(:get).with(redis_key).and_return('true')
end
it 'returns processing status' do
get :show, params: { id: user_id }
expect(response).to have_http_status(:ok)
expect(response.body).to eq('Processing')
end
end
context 'when deletion is completed' do
before do
redis_key = format(Redis::Alfred::META_DELETE_PROCESSING, id: user_id)
allow(Redis::Alfred).to receive(:get).with(redis_key).and_return(nil)
end
it 'returns success message' do
get :show, params: { id: user_id }
expect(response).to have_http_status(:ok)
expect(response.body).to eq('Data Deleted Successfully')
end
end
end
end
@@ -0,0 +1,59 @@
require 'rails_helper'
RSpec.describe Facebook::DeleteController do
describe 'POST #create' do
let(:user_id) { '12345' }
let(:app_secret) { 'test_app_secret' }
let(:frontend_url) { ENV.fetch('FRONTEND_URL', nil) }
let(:redis_key) { format(Redis::Alfred::META_DELETE_PROCESSING, id: user_id) }
before do
allow(GlobalConfigService).to receive(:load).with('FB_APP_SECRET', '').and_return(app_secret)
end
context 'with valid signed request' do
let(:payload) { { 'user_id' => user_id }.to_json }
let(:encoded_payload) { Base64.urlsafe_encode64(payload) }
let(:signature) { OpenSSL::HMAC.digest('sha256', app_secret, encoded_payload) }
let(:encoded_signature) { Base64.urlsafe_encode64(signature) }
let(:signed_request) { "#{encoded_signature}.#{encoded_payload}" }
before do
allow(Redis::Alfred).to receive(:set)
allow(Channels::Facebook::RedactContactDataJob).to receive(:perform_later)
end
it 'processes the delete request and returns status URL' do
post :create, params: { signed_request: signed_request }
expect(Redis::Alfred).to have_received(:set).with(redis_key, true)
expect(Channels::Facebook::RedactContactDataJob).to have_received(:perform_later).with(user_id)
expect(response).to have_http_status(:ok)
response_json = response.parsed_body
# NOTE: this is an important condition since this is exactly the format facebook expects
expect(response_json['url']).to eq("#{frontend_url}/facebook/confirm/#{user_id}")
expect(response_json['confirmation_code']).to eq(user_id)
end
end
context 'with invalid signed request' do
let(:invalid_signed_request) { 'invalid_signature.invalid_payload' }
before do
# Mock the Base64.urlsafe_decode64 to return valid values for testing
allow(Base64).to receive(:urlsafe_decode64).with('invalid_signature').and_return('decoded_signature')
allow(Base64).to receive(:urlsafe_decode64).with('invalid_payload').and_return('{"user_id":"12345"}')
allow(JSON).to receive(:parse).with('{"user_id":"12345"}').and_return({ 'user_id' => user_id })
allow(OpenSSL::HMAC).to receive(:digest).and_return('different_signature')
end
it 'returns an error for invalid signature' do
post :create, params: { signed_request: invalid_signed_request }
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
end
@@ -2,11 +2,19 @@ require 'rails_helper'
RSpec.describe Inboxes::FetchImapEmailInboxesJob do
let(:account) { create(:account) }
let(:suspended_account) { create(:account, status: 'suspended') }
let(:imap_email_channel) do
create(:channel_email, imap_enabled: true, imap_address: 'imap.gmail.com', imap_port: 993, imap_login: 'imap@gmail.com',
imap_password: 'password', account: account)
create(:channel_email, imap_enabled: true, account: account)
end
let(:imap_email_channel_suspended) do
create(:channel_email, imap_enabled: true, account: suspended_account)
end
let(:disabled_imap_channel) do
create(:channel_email, imap_enabled: false, account: account)
end
let(:email_inbox) { create(:inbox, channel: imap_email_channel, account: account) }
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
@@ -14,9 +22,26 @@ RSpec.describe Inboxes::FetchImapEmailInboxesJob do
end
context 'when called' do
it 'fetch all the email channels' do
it 'fetches emails only for active accounts with imap enabled' do
# Should call perform_later only once for the active, imap-enabled inbox
expect(Inboxes::FetchImapEmailsJob).to receive(:perform_later).with(imap_email_channel).once
# Should not call for suspended account or disabled IMAP channels
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(imap_email_channel_suspended)
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(disabled_imap_channel)
described_class.perform_now
end
it 'skips suspended accounts' do
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(imap_email_channel_suspended)
described_class.perform_now
end
it 'skips disabled imap channels' do
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(disabled_imap_channel)
described_class.perform_now
end
end
@@ -77,6 +77,63 @@ describe Conversations::FilterService do
expect(result[:count][:all_count]).to be conversations.count
end
it 'filter conversations by priority' do
conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :high)
params[:payload] = [
{
attribute_key: 'priority',
filter_operator: 'equal_to',
values: ['high'],
query_operator: nil,
custom_attribute_type: ''
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
expect(result[:conversations].length).to eq 1
expect(result[:conversations][0][:id]).to eq conversation.id
end
it 'filter conversations by multiple priority values' do
high_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :high)
urgent_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :urgent)
create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :low)
params[:payload] = [
{
attribute_key: 'priority',
filter_operator: 'equal_to',
values: %w[high urgent],
query_operator: nil,
custom_attribute_type: ''
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
expect(result[:conversations].length).to eq 2
expect(result[:conversations].pluck(:id)).to include(high_priority.id, urgent_priority.id)
end
it 'filter conversations with not_equal_to priority operator' do
create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :high)
create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :urgent)
low_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :low)
medium_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :medium)
params[:payload] = [
{
attribute_key: 'priority',
filter_operator: 'not_equal_to',
values: %w[high urgent],
query_operator: nil,
custom_attribute_type: ''
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
# Only include conversations with medium and low priority, excluding high and urgent
expect(result[:conversations].length).to eq 2
expect(result[:conversations].pluck(:id)).to include(low_priority.id, medium_priority.id)
end
it 'filter conversations by additional_attributes and status with pagination' do
params[:payload] = payload
params[:page] = 2