fix: preserve custom files during upstream sync
This commit is contained in:
parent
cf7338a6e2
commit
db9b451eeb
229
META-WEBHOOK-PROXY.md
Normal file
229
META-WEBHOOK-PROXY.md
Normal file
@ -0,0 +1,229 @@
|
||||
# Meta Webhook Proxy
|
||||
|
||||
## Problem
|
||||
|
||||
Some VPS providers silently drop inbound TCP connections from Meta's webhook servers (AS32934) due to overzealous DDoS protection. This causes 15–20% WhatsApp message loss. A reverse proxy on a clean provider (e.g., DigitalOcean) eliminates the drops completely.
|
||||
|
||||
```
|
||||
Meta (WhatsApp) → proxy.example.com (clean provider) → your Chatwoot instance
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The proxy is a single nginx server that routes requests based on the first path segment:
|
||||
|
||||
```
|
||||
https://proxy.example.com/<upstream_host>/webhooks/whatsapp/%2B<phone>
|
||||
```
|
||||
|
||||
This is the URL you configure in Meta's App Dashboard as the webhook callback URL. The proxy extracts `<upstream_host>`, checks it against an allowlist, and forwards the request to `https://<upstream_host>/webhooks/whatsapp/%2B<phone>`.
|
||||
|
||||
### Multi-tenant
|
||||
|
||||
One proxy serves multiple Chatwoot instances. Each upstream is identified by its domain in the URL path — no separate config per tenant beyond adding the host to the allowlist.
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A server (Ubuntu 22.04/24.04) on a provider with clean Meta connectivity (DigitalOcean, AWS, etc.)
|
||||
- A DNS A record pointing your proxy domain to the server IP (e.g., `proxy.example.com → 1.2.3.4`)
|
||||
- SSH root access to the server
|
||||
|
||||
### 1. Install nginx and certbot
|
||||
|
||||
```bash
|
||||
ssh root@proxy.example.com
|
||||
|
||||
apt-get update
|
||||
apt-get install -y nginx certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
### 2. Create a temporary HTTP-only config
|
||||
|
||||
Certbot needs nginx running to perform the ACME challenge, but the full config references SSL certs that don't exist yet. Start with an HTTP-only config:
|
||||
|
||||
```bash
|
||||
cat > /etc/nginx/sites-available/cw-proxy << 'EOF'
|
||||
server {
|
||||
listen 80;
|
||||
server_name proxy.example.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Enable the site and reload:
|
||||
|
||||
```bash
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
ln -sf /etc/nginx/sites-available/cw-proxy /etc/nginx/sites-enabled/cw-proxy
|
||||
nginx -t && systemctl reload nginx
|
||||
```
|
||||
|
||||
### 3. Obtain the SSL certificate
|
||||
|
||||
```bash
|
||||
certbot certonly --webroot -w /var/www/html -d proxy.example.com \
|
||||
--non-interactive --agree-tos -m your-email@example.com
|
||||
```
|
||||
|
||||
Certbot installs a systemd timer that auto-renews the certificate before it expires.
|
||||
|
||||
### 4. Deploy the full proxy config
|
||||
|
||||
Replace the temporary config with the full proxy configuration:
|
||||
|
||||
```bash
|
||||
cat > /etc/nginx/sites-available/cw-proxy << 'EOF'
|
||||
# Allowlist of upstream Chatwoot hosts
|
||||
# Add new hosts here to enable proxying
|
||||
map $upstream_host $upstream_allowed {
|
||||
default 0;
|
||||
chatwoot.example.com 1;
|
||||
# chatwoot.other.com 1;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name proxy.example.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name proxy.example.com;
|
||||
|
||||
resolver 1.1.1.1 8.8.8.8 valid=300s;
|
||||
resolver_timeout 5s;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/proxy.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/proxy.example.com/privkey.pem;
|
||||
|
||||
# Extract upstream host from first path segment, proxy the rest
|
||||
location ~ ^/([^/]+)(/.*)$ {
|
||||
set $upstream_host $1;
|
||||
set $upstream_path $2;
|
||||
|
||||
# Reject hosts not in the allowlist
|
||||
if ($upstream_allowed = 0) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
proxy_pass https://$upstream_host$upstream_path$is_args$args;
|
||||
proxy_set_header Host $upstream_host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_ssl_server_name on;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Test and reload:
|
||||
|
||||
```bash
|
||||
nginx -t && systemctl reload nginx
|
||||
```
|
||||
|
||||
### 5. Verify
|
||||
|
||||
```bash
|
||||
# Root path → 404
|
||||
curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/
|
||||
# Expected: 404
|
||||
|
||||
# Unknown host → 403
|
||||
curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/unknown.host/webhooks/whatsapp/test
|
||||
# Expected: 403
|
||||
|
||||
# Allowed host → proxied (502 if upstream is unreachable, 200 if live)
|
||||
curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/chatwoot.example.com/webhooks/whatsapp/test
|
||||
# Expected: 502 or 200
|
||||
```
|
||||
|
||||
### 6. Configure Meta webhook URL
|
||||
|
||||
In the Meta App Dashboard, set the webhook callback URL to:
|
||||
|
||||
```
|
||||
https://proxy.example.com/<your-chatwoot-domain>/webhooks/whatsapp/%2B<phone>
|
||||
```
|
||||
|
||||
For example, if your Chatwoot is at `chatwoot.example.com` and the phone number is `+5511999999999`:
|
||||
|
||||
```
|
||||
https://proxy.example.com/chatwoot.example.com/webhooks/whatsapp/%2B5511999999999
|
||||
```
|
||||
|
||||
Meta will send both verification (GET) and delivery (POST) requests to this URL. The proxy passes them through transparently.
|
||||
|
||||
## Adding a new upstream
|
||||
|
||||
1. SSH into the proxy server
|
||||
2. Edit `/etc/nginx/sites-available/cw-proxy`
|
||||
3. Add the new host to the `map` block:
|
||||
```nginx
|
||||
map $upstream_host $upstream_allowed {
|
||||
default 0;
|
||||
chatwoot.example.com 1;
|
||||
chatwoot.newclient.com 1; # ← add this line
|
||||
}
|
||||
```
|
||||
4. Test and reload:
|
||||
```bash
|
||||
nginx -t && systemctl reload nginx
|
||||
```
|
||||
5. Set the Meta webhook callback URL for the new instance to:
|
||||
```
|
||||
https://proxy.example.com/chatwoot.newclient.com/webhooks/whatsapp/%2B<phone>
|
||||
```
|
||||
|
||||
## Removing an upstream
|
||||
|
||||
1. Remove or comment out the host from the `map` block
|
||||
2. `nginx -t && systemctl reload nginx`
|
||||
3. Update the Meta webhook callback URL to point directly at the Chatwoot instance (or to a different proxy)
|
||||
|
||||
## Key nginx directives
|
||||
|
||||
| Directive | Purpose |
|
||||
|-----------|---------|
|
||||
| `map $upstream_host $upstream_allowed` | Allowlist of permitted upstream hosts. Only hosts set to `1` are proxied; all others get 403. |
|
||||
| `proxy_ssl_server_name on` | Enables SNI so the TLS handshake uses the correct hostname for the upstream's certificate. |
|
||||
| `resolver 1.1.1.1 8.8.8.8 valid=300s` | Required because `proxy_pass` uses a variable (`$upstream_host`), so nginx cannot resolve DNS at config load time. Uses Cloudflare and Google DNS. |
|
||||
| `proxy_set_header Host $upstream_host` | Sets the Host header to the upstream domain so reverse proxies (Traefik, etc.) route correctly. |
|
||||
|
||||
## Failure modes
|
||||
|
||||
All recoverable — Meta retries with exponential backoff for up to 36 hours:
|
||||
|
||||
| Failure | What happens | Recovery |
|
||||
|---------|-------------|----------|
|
||||
| Proxy down | Connection refused | Meta retries |
|
||||
| Upstream down | 502 Bad Gateway | Meta retries |
|
||||
| SSL expired | TLS handshake error | Meta retries |
|
||||
|
||||
## Important notes
|
||||
|
||||
- **Do not rate-limit.** Meta sends webhook deliveries from many IPs in AS32934. Bursts of 10+ requests per second are normal.
|
||||
- **SSL auto-renewal** is handled by the certbot systemd timer. Verify with `systemctl status certbot.timer`.
|
||||
- The `%2B` in the URL is the URL-encoded `+` sign for the phone number's country code.
|
||||
273
app/javascript/dashboard/assets/scss/plugins/_multiselect.scss
Normal file
273
app/javascript/dashboard/assets/scss/plugins/_multiselect.scss
Normal file
@ -0,0 +1,273 @@
|
||||
@mixin label-multiselect-hover {
|
||||
&::after {
|
||||
@apply text-n-brand;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply bg-n-slate-3;
|
||||
|
||||
&::after {
|
||||
@apply text-n-blue-11;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect {
|
||||
&:not(.no-margin) {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
&.invalid .multiselect__tags {
|
||||
@apply border-0 outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8;
|
||||
}
|
||||
|
||||
&.multiselect--disabled {
|
||||
@apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto;
|
||||
|
||||
.multiselect__select {
|
||||
@apply cursor-not-allowed bg-transparent rounded-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect--active {
|
||||
> .multiselect__tags {
|
||||
@apply outline-n-blue-border;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__select {
|
||||
@apply min-h-[2.875rem] p-0 right-0 top-0;
|
||||
|
||||
&::before {
|
||||
@apply right-0;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__content-wrapper {
|
||||
@apply bg-n-alpha-black2 text-n-slate-12 backdrop-blur-[100px] border-0 border-none outline outline-1 outline-n-weak rounded-b-lg;
|
||||
}
|
||||
|
||||
.multiselect__content {
|
||||
@apply max-w-full;
|
||||
|
||||
.multiselect__option {
|
||||
@apply text-sm font-normal flex justify-between items-center;
|
||||
|
||||
span {
|
||||
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight;
|
||||
}
|
||||
|
||||
&.multiselect__option--highlight {
|
||||
@apply bg-n-alpha-black2 text-n-slate-12;
|
||||
}
|
||||
|
||||
&.multiselect__option--highlight:hover {
|
||||
@apply bg-n-brand/10 text-n-slate-12;
|
||||
|
||||
&::after {
|
||||
@apply bg-transparent text-center text-n-slate-12;
|
||||
}
|
||||
}
|
||||
|
||||
&.multiselect__option--highlight::after {
|
||||
@apply bg-transparent text-n-slate-12;
|
||||
}
|
||||
|
||||
&.multiselect__option--selected {
|
||||
@apply bg-n-brand/20 text-n-slate-12;
|
||||
|
||||
&::after {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
&.multiselect__option--highlight:hover {
|
||||
@apply bg-transparent;
|
||||
|
||||
&::after:hover {
|
||||
@apply text-n-slate-12 bg-transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__tags {
|
||||
@apply bg-n-alpha-black2 border-0 grid items-center w-full border-none outline-1 outline outline-n-weak hover:outline-n-slate-6 m-0 min-h-[2.875rem] rounded-lg pt-0;
|
||||
|
||||
input {
|
||||
@apply border-0 border-none bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-10;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__spinner {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.multiselect__tags-wrap {
|
||||
@apply inline-block leading-none mt-1;
|
||||
}
|
||||
|
||||
.multiselect__placeholder {
|
||||
@apply text-n-slate-10 font-normal pt-3;
|
||||
}
|
||||
|
||||
.multiselect__tag {
|
||||
@apply bg-n-alpha-white mt-1 text-n-slate-12 pr-6 pl-2.5 py-1.5;
|
||||
}
|
||||
|
||||
.multiselect__tag-icon {
|
||||
@include label-multiselect-hover;
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
@apply text-sm h-[2.875rem] mb-0 p-0 shadow-none border-transparent hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none active:border-transparent active:shadow-none;
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
@apply bg-transparent text-n-slate-12 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-labels-wrap {
|
||||
&.has-edited,
|
||||
&:hover {
|
||||
.multiselect {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect {
|
||||
> .multiselect__select {
|
||||
@apply invisible;
|
||||
}
|
||||
|
||||
> .multiselect__tags {
|
||||
@apply outline-transparent;
|
||||
}
|
||||
|
||||
&.multiselect--active > .multiselect__tags {
|
||||
@apply outline-n-blue-border;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect-wrap--small {
|
||||
// To be removed one SLA reports date picker is created
|
||||
&.tiny {
|
||||
.multiselect.no-margin {
|
||||
@apply min-h-[32px];
|
||||
}
|
||||
|
||||
.multiselect__select {
|
||||
@apply min-h-[32px] h-8;
|
||||
|
||||
&::before {
|
||||
@apply top-[60%];
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__tags {
|
||||
@apply min-h-[32px] max-h-[32px];
|
||||
|
||||
.multiselect__single {
|
||||
@apply pt-1 pb-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__tags,
|
||||
.multiselect__input,
|
||||
.multiselect {
|
||||
@apply text-n-slate-12 rounded-lg text-sm min-h-[2.5rem];
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
@apply h-[2.375rem] min-h-[2.375rem];
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
@apply items-center flex m-0 text-sm max-h-[2.375rem] bg-transparent text-n-slate-12 py-3 px-0.5;
|
||||
}
|
||||
|
||||
.multiselect__placeholder {
|
||||
@apply m-0 py-2 px-0.5;
|
||||
}
|
||||
|
||||
.multiselect__tag {
|
||||
@apply py-[6px] my-[1px];
|
||||
}
|
||||
|
||||
.multiselect__select {
|
||||
@apply min-h-[2.5rem];
|
||||
}
|
||||
|
||||
.multiselect--disabled .multiselect__current,
|
||||
.multiselect--disabled .multiselect__select {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect--disabled {
|
||||
background-color: rgba(var(--black-alpha-2)) !important;
|
||||
|
||||
.multiselect__tags {
|
||||
@apply hover:outline-n-weak;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect--active {
|
||||
.multiselect__select::before {
|
||||
@apply top-[62%];
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__select::before {
|
||||
top: 60% !important;
|
||||
}
|
||||
|
||||
.multiselect-wrap--medium {
|
||||
.multiselect__tags,
|
||||
.multiselect__input {
|
||||
@apply items-center flex;
|
||||
}
|
||||
|
||||
.multiselect__tags,
|
||||
.multiselect__input,
|
||||
.multiselect {
|
||||
@apply bg-n-alpha-black2 text-n-slate-12 text-sm h-12 min-h-[3rem];
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
@apply h-[2.875rem] min-h-[2.875rem];
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
@apply items-center flex m-0 text-sm py-1 px-0.5 bg-transparent text-n-slate-12;
|
||||
}
|
||||
|
||||
.multiselect__placeholder {
|
||||
@apply m-0 py-1 px-0.5;
|
||||
}
|
||||
|
||||
.multiselect__select {
|
||||
@apply min-h-[3rem];
|
||||
}
|
||||
|
||||
.multiselect--disabled .multiselect__current,
|
||||
.multiselect--disabled .multiselect__select {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
.multiselect__tags-wrap {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabledMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleChange = () => {
|
||||
if (!props.isActive && !props.disabled) {
|
||||
emit('select', props.id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
|
||||
:class="[
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
|
||||
!disabled && !isActive ? 'hover:outline-n-strong' : '',
|
||||
]"
|
||||
@click="handleChange"
|
||||
>
|
||||
<div class="absolute top-4 right-4">
|
||||
<input
|
||||
:id="`${id}`"
|
||||
:checked="isActive"
|
||||
:value="id"
|
||||
:name="id"
|
||||
:disabled="disabled"
|
||||
type="radio"
|
||||
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex flex-col gap-3 items-start">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ label }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="disabled"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-n-yellow-3 text-n-yellow-11"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ disabled && disabledMessage ? disabledMessage : description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
31
app/javascript/dashboard/components/FormSection.vue
Normal file
31
app/javascript/dashboard/components/FormSection.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start w-full gap-6">
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<h4 v-if="title" class="text-lg font-medium text-n-slate-12">
|
||||
{{ title }}
|
||||
</h4>
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<div class="flex-grow h-px bg-n-weak" />
|
||||
</div>
|
||||
<p v-if="description" class="mb-0 text-sm font-normal text-n-slate-12">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-6">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,54 @@
|
||||
<script>
|
||||
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
|
||||
|
||||
export default {
|
||||
name: 'PriorityMark',
|
||||
props: {
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
validate: value =>
|
||||
[...Object.values(CONVERSATION_PRIORITY), ''].includes(value),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
CONVERSATION_PRIORITY,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
tooltipText() {
|
||||
return this.$t(
|
||||
`CONVERSATION.PRIORITY.OPTIONS.${this.priority.toUpperCase()}`
|
||||
);
|
||||
},
|
||||
isUrgent() {
|
||||
return this.priority === CONVERSATION_PRIORITY.URGENT;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<span
|
||||
v-if="priority"
|
||||
v-tooltip="{
|
||||
content: tooltipText,
|
||||
delay: { show: 1500, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
class="shrink-0 rounded-sm inline-flex items-center justify-center w-3.5 h-3.5"
|
||||
:class="{
|
||||
'bg-n-ruby-4 text-n-ruby-10': isUrgent,
|
||||
'bg-n-slate-4 text-n-slate-11': !isUrgent,
|
||||
}"
|
||||
>
|
||||
<fluent-icon
|
||||
:icon="`priority-${priority.toLowerCase()}`"
|
||||
:size="isUrgent ? 12 : 14"
|
||||
class="flex-shrink-0"
|
||||
view-box="0 0 14 14"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
@ -6,7 +6,6 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Multiselect from 'vue-multiselect';
|
||||
import Auth from '../../../../api/auth';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
@ -253,7 +252,7 @@ const resetPassword = async () => {
|
||||
</label>
|
||||
|
||||
<div v-if="!alertAllInboxes">
|
||||
<Multiselect
|
||||
<multiselect
|
||||
v-model="selectedAlertInboxes"
|
||||
:options="inboxes || []"
|
||||
track-by="id"
|
||||
|
||||
@ -0,0 +1,443 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
|
||||
import InputRadioGroup from './components/InputRadioGroup.vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Widget,
|
||||
InputRadioGroup,
|
||||
NextButton,
|
||||
Editor,
|
||||
Avatar,
|
||||
},
|
||||
props: {
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isWidgetPreview: true,
|
||||
color: '#1f93ff',
|
||||
websiteName: '',
|
||||
welcomeHeading: '',
|
||||
welcomeTagline: '',
|
||||
replyTime: 'in_a_few_minutes',
|
||||
avatarFile: null,
|
||||
avatarUrl: '',
|
||||
widgetBubblePosition: 'right',
|
||||
widgetBubbleLauncherTitle: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_LAUNCHER_TITLE.DEFAULT'
|
||||
),
|
||||
widgetBubbleType: 'standard',
|
||||
widgetBubblePositions: [
|
||||
{
|
||||
id: 'left',
|
||||
title: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_POSITION.LEFT'
|
||||
),
|
||||
checked: false,
|
||||
},
|
||||
{
|
||||
id: 'right',
|
||||
title: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_POSITION.RIGHT'
|
||||
),
|
||||
checked: true,
|
||||
},
|
||||
],
|
||||
widgetBubbleTypes: [
|
||||
{
|
||||
id: 'standard',
|
||||
title: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_TYPE.STANDARD'
|
||||
),
|
||||
checked: true,
|
||||
},
|
||||
{
|
||||
id: 'expanded_bubble',
|
||||
title: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_TYPE.EXPANDED_BUBBLE'
|
||||
),
|
||||
checked: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'inboxes/getUIFlags',
|
||||
}),
|
||||
storageKey() {
|
||||
return `${LOCAL_STORAGE_KEYS.WIDGET_BUILDER}${this.inbox.id}`;
|
||||
},
|
||||
widgetScript() {
|
||||
let options = {
|
||||
position: this.widgetBubblePosition,
|
||||
type: this.widgetBubbleType,
|
||||
launcherTitle: this.widgetBubbleLauncherTitle,
|
||||
};
|
||||
let script = this.inbox.web_widget_script;
|
||||
return (
|
||||
script.substring(0, 13) +
|
||||
this.$t('INBOX_MGMT.WIDGET_BUILDER.SCRIPT_SETTINGS', {
|
||||
options: JSON.stringify(options),
|
||||
}) +
|
||||
script.substring(13)
|
||||
);
|
||||
},
|
||||
getWidgetViewOptions() {
|
||||
return [
|
||||
{
|
||||
id: 'preview',
|
||||
title: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_VIEW_OPTION.PREVIEW'
|
||||
),
|
||||
checked: true,
|
||||
},
|
||||
{
|
||||
id: 'script',
|
||||
title: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_VIEW_OPTION.SCRIPT'
|
||||
),
|
||||
checked: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
getReplyTimeOptions() {
|
||||
return [
|
||||
{
|
||||
key: 'in_a_few_minutes',
|
||||
value: 'in_a_few_minutes',
|
||||
text: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.REPLY_TIME.IN_A_FEW_MINUTES'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'in_a_few_hours',
|
||||
value: 'in_a_few_hours',
|
||||
text: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.REPLY_TIME.IN_A_FEW_HOURS'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'in_a_day',
|
||||
value: 'in_a_day',
|
||||
text: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.REPLY_TIME.IN_A_DAY'
|
||||
),
|
||||
},
|
||||
];
|
||||
},
|
||||
websiteNameValidationErrorMsg() {
|
||||
return this.v$.websiteName.$error
|
||||
? this.$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WEBSITE_NAME.ERROR')
|
||||
: '';
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setDefaults();
|
||||
},
|
||||
validations: {
|
||||
websiteName: { required },
|
||||
},
|
||||
methods: {
|
||||
setDefaults() {
|
||||
// Widget Settings
|
||||
const {
|
||||
name,
|
||||
welcome_title,
|
||||
welcome_tagline,
|
||||
widget_color,
|
||||
reply_time,
|
||||
avatar_url,
|
||||
} = this.inbox;
|
||||
this.websiteName = name;
|
||||
this.welcomeHeading = welcome_title;
|
||||
this.welcomeTagline = welcome_tagline;
|
||||
this.color = widget_color;
|
||||
this.replyTime = reply_time;
|
||||
this.avatarUrl = avatar_url;
|
||||
|
||||
const savedInformation = this.getSavedInboxInformation();
|
||||
if (savedInformation) {
|
||||
this.widgetBubblePositions = this.widgetBubblePositions.map(item => {
|
||||
if (item.id === savedInformation.position) {
|
||||
item.checked = true;
|
||||
this.widgetBubblePosition = item.id;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
this.widgetBubbleTypes = this.widgetBubbleTypes.map(item => {
|
||||
if (item.id === savedInformation.type) {
|
||||
item.checked = true;
|
||||
this.widgetBubbleType = item.id;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
this.widgetBubbleLauncherTitle =
|
||||
savedInformation.launcherTitle || 'Chat with us';
|
||||
}
|
||||
},
|
||||
handleWidgetBubblePositionChange(item) {
|
||||
this.widgetBubblePosition = item.id;
|
||||
},
|
||||
handleWidgetBubbleTypeChange(item) {
|
||||
this.widgetBubbleType = item.id;
|
||||
},
|
||||
handleWidgetViewChange(item) {
|
||||
this.isWidgetPreview = item.id === 'preview';
|
||||
},
|
||||
handleImageUpload({ file, url }) {
|
||||
this.avatarFile = file;
|
||||
this.avatarUrl = url;
|
||||
},
|
||||
async handleAvatarDelete() {
|
||||
try {
|
||||
await this.$store.dispatch('inboxes/deleteInboxAvatar', this.inbox.id);
|
||||
this.avatarFile = null;
|
||||
this.avatarUrl = '';
|
||||
useAlert(
|
||||
this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.AVATAR.DELETE.API.SUCCESS_MESSAGE'
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message
|
||||
? error.message
|
||||
: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.AVATAR.DELETE.API.ERROR_MESSAGE'
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
async updateWidget() {
|
||||
const bubbleSettings = {
|
||||
position: this.widgetBubblePosition,
|
||||
launcherTitle: this.widgetBubbleLauncherTitle,
|
||||
type: this.widgetBubbleType,
|
||||
};
|
||||
|
||||
LocalStorage.set(this.storageKey, bubbleSettings);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
id: this.inbox.id,
|
||||
name: this.websiteName,
|
||||
channel: {
|
||||
widget_color: this.color,
|
||||
welcome_title: this.welcomeHeading,
|
||||
welcome_tagline: this.welcomeTagline,
|
||||
reply_time: this.replyTime,
|
||||
},
|
||||
};
|
||||
if (this.avatarFile) {
|
||||
payload.avatar = this.avatarFile;
|
||||
}
|
||||
await this.$store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(
|
||||
this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.UPDATE.API.SUCCESS_MESSAGE'
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message ||
|
||||
this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.UPDATE.API.ERROR_MESSAGE'
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
getSavedInboxInformation() {
|
||||
return LocalStorage.get(this.storageKey);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-8">
|
||||
<div class="flex p-2.5">
|
||||
<div class="w-100 lg:w-[40%]">
|
||||
<div class="min-h-full py-4 overflow-y-scroll px-px">
|
||||
<form @submit.prevent="updateWidget">
|
||||
<div class="flex flex-col mb-4 items-start gap-1 w-full">
|
||||
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.AVATAR.LABEL')
|
||||
}}
|
||||
</label>
|
||||
<Avatar
|
||||
:src="avatarUrl"
|
||||
:size="72"
|
||||
icon-name="i-ri-global-fill"
|
||||
name=""
|
||||
allow-upload
|
||||
rounded-full
|
||||
@upload="handleImageUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
/>
|
||||
</div>
|
||||
<woot-input
|
||||
v-model="websiteName"
|
||||
:class="{ error: v$.websiteName.$error }"
|
||||
:label="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WEBSITE_NAME.LABEL'
|
||||
)
|
||||
"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WEBSITE_NAME.PLACE_HOLDER'
|
||||
)
|
||||
"
|
||||
:error="websiteNameValidationErrorMsg"
|
||||
@blur="v$.websiteName.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="welcomeHeading"
|
||||
:label="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_HEADING.LABEL'
|
||||
)
|
||||
"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_HEADING.PLACE_HOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<Editor
|
||||
v-model="welcomeTagline"
|
||||
:label="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_TAGLINE.LABEL'
|
||||
)
|
||||
"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_TAGLINE.PLACE_HOLDER'
|
||||
)
|
||||
"
|
||||
:max-length="255"
|
||||
channel-type="Context::InboxSettings"
|
||||
class="mb-4"
|
||||
/>
|
||||
<label>
|
||||
{{
|
||||
$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.REPLY_TIME.LABEL')
|
||||
}}
|
||||
<select v-model="replyTime">
|
||||
<option
|
||||
v-for="option in getReplyTimeOptions"
|
||||
:key="option.key"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.text }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_COLOR_LABEL'
|
||||
)
|
||||
}}
|
||||
<woot-color-picker v-model="color" />
|
||||
</label>
|
||||
<InputRadioGroup
|
||||
name="widget-bubble-position"
|
||||
:label="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_POSITION_LABEL'
|
||||
)
|
||||
"
|
||||
:items="widgetBubblePositions"
|
||||
:action="handleWidgetBubblePositionChange"
|
||||
/>
|
||||
<InputRadioGroup
|
||||
name="widget-bubble-type"
|
||||
:label="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_TYPE_LABEL'
|
||||
)
|
||||
"
|
||||
:items="widgetBubbleTypes"
|
||||
:action="handleWidgetBubbleTypeChange"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="widgetBubbleLauncherTitle"
|
||||
:label="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_LAUNCHER_TITLE.LABEL'
|
||||
)
|
||||
"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_LAUNCHER_TITLE.PLACE_HOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<NextButton
|
||||
type="submit"
|
||||
class="mt-4"
|
||||
:label="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.UPDATE.BUTTON_TEXT'
|
||||
)
|
||||
"
|
||||
:is-loading="uiFlags.isUpdating"
|
||||
:disabled="v$.$invalid || uiFlags.isUpdating"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-100 lg:w-3/5">
|
||||
<InputRadioGroup
|
||||
name="widget-view-options"
|
||||
class="text-center"
|
||||
:items="getWidgetViewOptions"
|
||||
:action="handleWidgetViewChange"
|
||||
/>
|
||||
<div
|
||||
v-if="isWidgetPreview"
|
||||
class="flex flex-col items-center justify-end min-h-[40.625rem] mx-5 mb-5 p-2.5 bg-n-slate-3 rounded-lg"
|
||||
>
|
||||
<Widget
|
||||
:welcome-heading="welcomeHeading"
|
||||
:welcome-tagline="welcomeTagline"
|
||||
:website-name="websiteName"
|
||||
:logo="avatarUrl"
|
||||
is-online
|
||||
:reply-time="replyTime"
|
||||
:color="color"
|
||||
:widget-bubble-position="widgetBubblePosition"
|
||||
:widget-bubble-launcher-title="widgetBubbleLauncherTitle"
|
||||
:widget-bubble-type="widgetBubbleType"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="mx-5 p-2.5 bg-n-slate-3 rounded-lg dark:bg-n-solid-3"
|
||||
>
|
||||
<woot-code :script="widgetScript" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import SLAListItem from './SLAListItem.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full min-h-[12rem] relative">
|
||||
<div class="w-full space-y-3">
|
||||
<SLAListItem
|
||||
class="opacity-25 dark:opacity-20"
|
||||
:sla-name="$t('SLA.LIST.EMPTY.TITLE_1')"
|
||||
:description="$t('SLA.LIST.EMPTY.DESC_1')"
|
||||
first-response="20m"
|
||||
next-response="1h"
|
||||
resolution-time="24h"
|
||||
has-business-hours
|
||||
/>
|
||||
<SLAListItem
|
||||
class="opacity-25 dark:opacity-20"
|
||||
:sla-name="$t('SLA.LIST.EMPTY.TITLE_2')"
|
||||
:description="$t('SLA.LIST.EMPTY.DESC_2')"
|
||||
first-response="2h"
|
||||
next-response="4h"
|
||||
resolution-time="4d"
|
||||
has-business-hours
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center w-full h-full bg-gradient-to-t from-white dark:from-slate-900 to-transparent"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
hasBusinessHours: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center min-w-0 gap-1 px-1.5 sm:px-2 py-1 border border-solid rounded-lg border-n-weak"
|
||||
>
|
||||
<fluent-icon
|
||||
size="14"
|
||||
:icon="hasBusinessHours ? 'alarm-on' : 'alarm-off'"
|
||||
type="outline"
|
||||
class="flex-shrink-0"
|
||||
:class="hasBusinessHours ? 'text-n-slate-11' : 'text-n-slate-10'"
|
||||
/>
|
||||
<span
|
||||
class="hidden text-xs tracking-[0.2%] font-normal truncate sm:block"
|
||||
:class="hasBusinessHours ? 'text-n-slate-11' : 'text-n-slate-10'"
|
||||
>
|
||||
{{
|
||||
hasBusinessHours
|
||||
? $t('SLA.LIST.BUSINESS_HOURS_ON')
|
||||
: $t('SLA.LIST.BUSINESS_HOURS_OFF')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import BaseEmptyState from './BaseEmptyState.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const emit = defineEmits(['primaryAction']);
|
||||
const primaryAction = () => emit('primaryAction');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseEmptyState>
|
||||
<p class="max-w-xs text-sm font-medium text-center">
|
||||
{{ $t('SLA.LIST.404') }}
|
||||
</p>
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
class="mt-4"
|
||||
:label="$t('SLA.ADD_ACTION_LONG')"
|
||||
@click="primaryAction"
|
||||
/>
|
||||
</BaseEmptyState>
|
||||
</template>
|
||||
@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
|
||||
defineProps({
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['add']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseSettingsHeader
|
||||
:title="$t('SLA.HEADER')"
|
||||
:description="$t('SLA.DESCRIPTION')"
|
||||
:link-text="$t('SLA.LEARN_MORE')"
|
||||
feature-name="sla"
|
||||
>
|
||||
<template v-if="showActions" #actions>
|
||||
<Button
|
||||
:label="$t('SLA.ADD_ACTION')"
|
||||
icon="i-lucide-circle-plus"
|
||||
@click="$emit('add')"
|
||||
/>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
import BaseSettingsListItem from '../../components/BaseSettingsListItem.vue';
|
||||
import SLAResponseTime from './SLAResponseTime.vue';
|
||||
import SLABusinessHoursLabel from './SLABusinessHoursLabel.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
slaName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
firstResponse: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
nextResponse: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
resolutionTime: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
hasBusinessHours: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseSettingsListItem
|
||||
class="sm:divide-x sm:divide-n-weak"
|
||||
:title="slaName"
|
||||
:description="description"
|
||||
>
|
||||
<template #label>
|
||||
<SLABusinessHoursLabel :has-business-hours="hasBusinessHours" />
|
||||
</template>
|
||||
<template #rightSection>
|
||||
<div
|
||||
class="flex items-center divide-x rtl:divide-x-reverse sm:rtl:!border-l-0 sm:rtl:!border-r sm:rtl:border-solid sm:rtl:border-n-weak gap-1.5 w-fit sm:w-full sm:gap-0 sm:justify-between divide-n-weak"
|
||||
>
|
||||
<SLAResponseTime response-type="FRT" :response-time="firstResponse" />
|
||||
<SLAResponseTime response-type="NRT" :response-time="nextResponse" />
|
||||
<SLAResponseTime response-type="RT" :response-time="resolutionTime" />
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-tooltip.top="$t('SLA.FORM.DELETE')"
|
||||
faded
|
||||
ruby
|
||||
xs
|
||||
icon="i-lucide-trash-2"
|
||||
:is-loading="isLoading"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</template>
|
||||
</BaseSettingsListItem>
|
||||
</template>
|
||||
@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import BaseSettingsListItem from '../../components/BaseSettingsListItem.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseSettingsListItem class="opacity-50">
|
||||
<template #title>
|
||||
<div
|
||||
class="w-24 h-[26px] rounded-md bg-n-slate-4 dark:bg-n-slate-6 animate-pulse"
|
||||
/>
|
||||
</template>
|
||||
<template #description>
|
||||
<div
|
||||
class="w-64 h-4 mb-0.5 rounded-md bg-n-slate-4 dark:bg-n-slate-6 animate-pulse"
|
||||
/>
|
||||
<div
|
||||
class="w-48 h-4 rounded-md bg-n-slate-4 dark:bg-n-slate-6 animate-pulse"
|
||||
/>
|
||||
</template>
|
||||
<template #label>
|
||||
<div
|
||||
class="w-32 h-[26px] bg-n-slate-4 dark:bg-n-slate-6 animate-pulse rounded-md"
|
||||
/>
|
||||
</template>
|
||||
<template #rightSection>
|
||||
<div
|
||||
class="flex items-center sm:rtl:!border-l-0 sm:rtl:!border-r border-n-weak gap-1.5 w-fit sm:w-full sm:gap-0 sm:justify-between"
|
||||
>
|
||||
<div
|
||||
v-for="ii in 3"
|
||||
:key="ii"
|
||||
class="flex justify-end w-1/3 h-full px-4"
|
||||
>
|
||||
<div
|
||||
class="w-32 h-full rounded-md bg-n-slate-4 dark:bg-n-slate-6 animate-pulse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BaseSettingsListItem>
|
||||
</template>
|
||||
@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
responseType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
responseTime: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-row items-start w-full h-full gap-1 sm:items-end sm:px-6 sm:py-2 sm:gap-2 sm:flex-col"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 tracking-[-0.6%] text-sm ltr:pl-1.5 sm:ltr:pl-0 rtl:pr-1.5 sm:rtl:pr-0 text-n-slate-11"
|
||||
>
|
||||
<fluent-icon
|
||||
v-tooltip.left="$t(`SLA.LIST.RESPONSE_TYPES.${responseType}`)"
|
||||
size="14"
|
||||
icon="information"
|
||||
type="outline"
|
||||
class="flex-shrink-0 hidden text-sm font-normal sm:flex sm:font-medium text-n-slate-11"
|
||||
/>
|
||||
{{ $t(`SLA.LIST.RESPONSE_TYPES.SHORT_HAND.${responseType}`) }}
|
||||
<span class="flex sm:hidden">:</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-sm sm:text-2xl font-medium tracking-[-1.5%] text-n-slate-12"
|
||||
>
|
||||
{{ responseTime }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
47
app/jobs/inboxes/bulk_auto_assignment_job.rb
Normal file
47
app/jobs/inboxes/bulk_auto_assignment_job.rb
Normal file
@ -0,0 +1,47 @@
|
||||
class Inboxes::BulkAutoAssignmentJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
include BillingHelper
|
||||
|
||||
def perform
|
||||
Account.feature_assignment_v2.find_each do |account|
|
||||
if should_skip_auto_assignment?(account)
|
||||
Rails.logger.info("Skipping auto assignment for account #{account.id}")
|
||||
next
|
||||
end
|
||||
|
||||
account.inboxes.where(enable_auto_assignment: true).find_each do |inbox|
|
||||
process_assignment(inbox)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_assignment(inbox)
|
||||
allowed_agent_ids = inbox.member_ids_with_assignment_capacity
|
||||
|
||||
if allowed_agent_ids.blank?
|
||||
Rails.logger.info("No agents available to assign conversation to inbox #{inbox.id}")
|
||||
return
|
||||
end
|
||||
|
||||
assign_conversations(inbox, allowed_agent_ids)
|
||||
end
|
||||
|
||||
def assign_conversations(inbox, allowed_agent_ids)
|
||||
unassigned_conversations = inbox.conversations.unassigned.open.limit(Limits::AUTO_ASSIGNMENT_BULK_LIMIT)
|
||||
unassigned_conversations.find_each do |conversation|
|
||||
::AutoAssignment::AgentAssignmentService.new(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: allowed_agent_ids
|
||||
).perform
|
||||
Rails.logger.info("Assigned conversation #{conversation.id} to agent #{allowed_agent_ids.first}")
|
||||
end
|
||||
end
|
||||
|
||||
def should_skip_auto_assignment?(account)
|
||||
return false unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
default_plan?(account)
|
||||
end
|
||||
end
|
||||
19
app/services/whatsapp/message_dedup_lock.rb
Normal file
19
app/services/whatsapp/message_dedup_lock.rb
Normal file
@ -0,0 +1,19 @@
|
||||
# Atomic dedup lock for WhatsApp incoming messages.
|
||||
#
|
||||
# Meta can deliver the same webhook event multiple times. This lock uses
|
||||
# Redis SET NX EX to ensure only one worker processes a given source_id.
|
||||
class Whatsapp::MessageDedupLock
|
||||
KEY_PREFIX = Redis::RedisKeys::MESSAGE_SOURCE_KEY
|
||||
DEFAULT_TTL = 1.day.to_i
|
||||
|
||||
def initialize(source_id, ttl: DEFAULT_TTL)
|
||||
@key = format(KEY_PREFIX, id: source_id)
|
||||
@ttl = ttl
|
||||
end
|
||||
|
||||
# Returns true when the lock is acquired (caller should proceed).
|
||||
# Returns false when another worker already holds the lock.
|
||||
def acquire!
|
||||
::Redis::Alfred.set(@key, true, nx: true, ex: @ttl)
|
||||
end
|
||||
end
|
||||
170
docs/BAILEYS_VPN_SETUP.md
Normal file
170
docs/BAILEYS_VPN_SETUP.md
Normal file
@ -0,0 +1,170 @@
|
||||
# VPN Setup for Baileys API (Gluetun + Mullvad)
|
||||
|
||||
## Problem
|
||||
|
||||
Hosting providers like Hostinger have their IP ranges mass-blocked by Meta. This causes WhatsApp connections through Baileys to fail. The solution is to route **only** Baileys traffic through a VPN, keeping everything else (Rails, Sidekiq, Postgres, Redis) on the regular network.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Docker Network (coolify) │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ Rails │ │ Gluetun (VPN tunnel) │ │
|
||||
│ │ Sidekiq │───▶│ :3025 ──▶ Baileys API │ │
|
||||
│ └──────────┘ │ (network_mode: service) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ VPN (WireGuard) │ │
|
||||
│ │ │ ▼ │ │
|
||||
│ │ │ Mullvad SP (Brazil) │ │
|
||||
│ │ └──────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Redis │◀───────│ Baileys │ │
|
||||
│ │ Postgres│ │ (via VPN)│ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Gluetun** creates a WireGuard VPN tunnel. Baileys shares Gluetun's network via `network_mode: "service:gluetun"`, so all WhatsApp traffic exits through the VPN IP. Internal Docker traffic (Redis, etc.) is exempted via firewall subnet rules.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Get Mullvad Credentials
|
||||
|
||||
1. Go to <https://mullvad.net/en/account/create> and generate an account (no email needed — save the 16-digit account number).
|
||||
2. Add credit (€5/month, accepts card, PayPal, crypto).
|
||||
3. Go to <https://mullvad.net/en/account/wireguard-config>, log in, select **Linux**, click **Generate key**, choose **Brazil > São Paulo**, and download the `.conf` file.
|
||||
4. From the downloaded file, note:
|
||||
- `PrivateKey` — e.g. `KLaIt4oAaI6Iz4iQhSS9/0UBlbvfmG1LC/NWGXW/DH4=`
|
||||
- `Address` — use **only the IPv4** address, e.g. `10.67.152.85/32` (discard the IPv6 address)
|
||||
|
||||
> **Important:** Gluetun does not support IPv6. Only use the IPv4 address from the `.conf` file.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Docker Compose Changes
|
||||
|
||||
### 2.1 Add the `gluetun` service (baileys-api compose)
|
||||
|
||||
```yaml
|
||||
gluetun:
|
||||
image: qmcgaw/gluetun
|
||||
restart: always
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
ports:
|
||||
- '3025:3025'
|
||||
environment:
|
||||
- VPN_SERVICE_PROVIDER=mullvad
|
||||
- VPN_TYPE=wireguard
|
||||
- WIREGUARD_PRIVATE_KEY=<your-private-key>
|
||||
- WIREGUARD_ADDRESSES=<your-ipv4-address>/32
|
||||
- SERVER_COUNTRIES=Brazil
|
||||
- SERVER_CITIES=Sao Paulo
|
||||
- FIREWALL_OUTBOUND_SUBNETS=172.16.0.0/12,10.0.0.0/8,192.168.0.0/16
|
||||
- DNS_KEEP_NAMESERVER=on
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- 'wget -qO- https://ipinfo.io/ip'
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
networks:
|
||||
- coolify
|
||||
```
|
||||
|
||||
Key environment variables:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `FIREWALL_OUTBOUND_SUBNETS` | Allows internal Docker traffic (Redis, etc.) to bypass the VPN. Must cover all private subnets used by Docker. |
|
||||
| `DNS_KEEP_NAMESERVER` | Keeps Docker's internal DNS so containers can resolve hostnames like `redis`. Without this, you get `getaddrinfo ENOTFOUND` errors. |
|
||||
|
||||
> **Do NOT set `OWNED_ONLY=yes`** — Mullvad does not have owned servers in São Paulo, only rented ones. This filter would match zero servers.
|
||||
|
||||
### 2.2 Modify the `baileys-api` service
|
||||
|
||||
Apply these changes to the existing baileys-api service:
|
||||
|
||||
```yaml
|
||||
baileys-api:
|
||||
# ... existing config ...
|
||||
network_mode: 'service:gluetun' # Route all traffic through Gluetun
|
||||
depends_on:
|
||||
gluetun:
|
||||
condition: service_healthy # Wait for VPN to be up
|
||||
# REMOVE any 'ports' section — port 3025 is now exposed by gluetun
|
||||
# REMOVE any 'networks' section — network_mode is incompatible with networks
|
||||
```
|
||||
|
||||
> **`condition: service_healthy`** is critical. Without it, baileys-api starts before the VPN tunnel is established, causing Redis connection timeouts.
|
||||
|
||||
### 2.3 Update `BAILEYS_PROVIDER_DEFAULT_URL` in Chatwoot
|
||||
|
||||
In the Rails and Sidekiq services (or Coolify environment variables), change:
|
||||
|
||||
```
|
||||
# Before
|
||||
BAILEYS_PROVIDER_DEFAULT_URL=http://baileys-api:3025
|
||||
|
||||
# After
|
||||
BAILEYS_PROVIDER_DEFAULT_URL=http://gluetun:3025
|
||||
```
|
||||
|
||||
Since baileys-api now shares Gluetun's network, external services must address it via `gluetun` hostname.
|
||||
|
||||
### 2.4 Declare the shared network
|
||||
|
||||
If the baileys-api compose is a separate stack, declare the shared network:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
coolify:
|
||||
external: true
|
||||
name: coolify
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Verify
|
||||
|
||||
After deploying, run from the server's SSH terminal:
|
||||
|
||||
```bash
|
||||
# Check container health
|
||||
docker ps | grep -E "gluetun|baileys"
|
||||
|
||||
# Get the VPN exit IP (replace with your actual gluetun container name)
|
||||
docker exec <GLUETUN_CONTAINER> wget -qO- https://ipinfo.io/ip
|
||||
|
||||
# Compare with the server's real IP
|
||||
curl -s https://ipinfo.io/ip
|
||||
```
|
||||
|
||||
If the two IPs are **different**, the VPN is working correctly.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `Redis client error` / connection timeout on Redis | Baileys starts before VPN is ready | Add `depends_on` with `condition: service_healthy` |
|
||||
| `Redis client error` / connection refused | Internal Docker traffic blocked by VPN firewall | Add `192.168.0.0/16` to `FIREWALL_OUTBOUND_SUBNETS` |
|
||||
| `getaddrinfo ENOTFOUND redis` | Docker DNS not working inside VPN | Set `DNS_KEEP_NAMESERVER=on` in gluetun |
|
||||
| `no server found: ... city sao paulo; owned servers only` | Mullvad has no owned servers in São Paulo | Remove `OWNED_ONLY=yes` from gluetun env |
|
||||
| `interface address is IPv6 but IPv6 is not supported` | IPv6 address in `WIREGUARD_ADDRESSES` | Use only the IPv4 address (remove the `fc00:...` part) |
|
||||
| `REDIS_URL` with hardcoded IP (e.g. `172.19.0.2`) | Docker internal IPs change on restart | Always use hostnames (e.g. `redis://redis:6379`) |
|
||||
|
||||
---
|
||||
|
||||
## VPN Expiration
|
||||
|
||||
If the Mullvad subscription expires:
|
||||
- **WhatsApp stops working** (Baileys can't connect through the VPN).
|
||||
- **Everything else keeps running** normally (Rails, Sidekiq, Redis, Postgres are not affected).
|
||||
- To restore: renew Mullvad, or revert the docker-compose changes to bypass the VPN entirely.
|
||||
9
lib/tasks/auto_annotate_models.rake
Normal file
9
lib/tasks/auto_annotate_models.rake
Normal file
@ -0,0 +1,9 @@
|
||||
# NOTE: only doing this in development as some production environments (Heroku)
|
||||
# NOTE: are sensitive to local FS writes, and besides -- it's just not proper
|
||||
# NOTE: to have a dev-mode tool do its thing in production.
|
||||
if Rails.env.development?
|
||||
require 'annotate_rb'
|
||||
|
||||
# Configuration is in .annotaterb.yml
|
||||
AnnotateRb::Core.load_rake_tasks
|
||||
end
|
||||
12
lib/tasks/companies.rake
Normal file
12
lib/tasks/companies.rake
Normal file
@ -0,0 +1,12 @@
|
||||
namespace :companies do
|
||||
desc 'Backfill companies from existing contact email domains'
|
||||
task backfill: :environment do
|
||||
puts 'Starting company backfill migration...'
|
||||
puts 'This will process all accounts and create companies from contact email domains.'
|
||||
puts 'The job will run in the background via Sidekiq'
|
||||
puts ''
|
||||
Migration::CompanyBackfillJob.perform_later
|
||||
puts 'Company backfill job has been enqueued.'
|
||||
puts 'Monitor progress in logs or Sidekiq dashboard.'
|
||||
end
|
||||
end
|
||||
6
public/assets/images/dashboard/priority/high.svg
Normal file
6
public/assets/images/dashboard/priority/high.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" fill="#F1F5F8"/>
|
||||
<path d="M9.7642 8L9.62358 14.1619H8.25142L8.11506 8H9.7642ZM8.9375 16.821C8.67898 16.821 8.45739 16.7301 8.27273 16.5483C8.09091 16.3665 8 16.1449 8 15.8835C8 15.6278 8.09091 15.4091 8.27273 15.2273C8.45739 15.0455 8.67898 14.9545 8.9375 14.9545C9.19034 14.9545 9.40909 15.0455 9.59375 15.2273C9.78125 15.4091 9.875 15.6278 9.875 15.8835C9.875 16.0568 9.83097 16.2145 9.7429 16.3565C9.65767 16.4986 9.54403 16.6122 9.40199 16.6974C9.26278 16.7798 9.10795 16.821 8.9375 16.821Z" fill="#446888"/>
|
||||
<path d="M13.1073 8L12.9667 14.1619H11.5945L11.4582 8H13.1073ZM12.2806 16.821C12.0221 16.821 11.8005 16.7301 11.6159 16.5483C11.434 16.3665 11.3431 16.1449 11.3431 15.8835C11.3431 15.6278 11.434 15.4091 11.6159 15.2273C11.8005 15.0455 12.0221 14.9545 12.2806 14.9545C12.5335 14.9545 12.7522 15.0455 12.9369 15.2273C13.1244 15.4091 13.2181 15.6278 13.2181 15.8835C13.2181 16.0568 13.1741 16.2145 13.086 16.3565C13.0008 16.4986 12.8872 16.6122 12.7451 16.6974C12.6059 16.7798 12.4511 16.821 12.2806 16.821Z" fill="#446888"/>
|
||||
<path d="M16.4505 8L16.3098 14.1619H14.9377L14.8013 8H16.4505ZM15.6237 16.821C15.3652 16.821 15.1436 16.7301 14.959 16.5483C14.7772 16.3665 14.6862 16.1449 14.6862 15.8835C14.6862 15.6278 14.7772 15.4091 14.959 15.2273C15.1436 15.0455 15.3652 14.9545 15.6237 14.9545C15.8766 14.9545 16.0953 15.0455 16.28 15.2273C16.4675 15.4091 16.5612 15.6278 16.5612 15.8835C16.5612 16.0568 16.5172 16.2145 16.4291 16.3565C16.3439 16.4986 16.2303 16.6122 16.0882 16.6974C15.949 16.7798 15.7942 16.821 15.6237 16.821Z" fill="#446888"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
4
public/assets/images/dashboard/priority/low.svg
Normal file
4
public/assets/images/dashboard/priority/low.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" fill="#F1F5F8"/>
|
||||
<path d="M12.7642 8L12.6236 14.1619H11.2514L11.1151 8H12.7642ZM11.9375 16.821C11.679 16.821 11.4574 16.7301 11.2727 16.5483C11.0909 16.3665 11 16.1449 11 15.8835C11 15.6278 11.0909 15.4091 11.2727 15.2273C11.4574 15.0455 11.679 14.9545 11.9375 14.9545C12.1903 14.9545 12.4091 15.0455 12.5938 15.2273C12.7812 15.4091 12.875 15.6278 12.875 15.8835C12.875 16.0568 12.831 16.2145 12.7429 16.3565C12.6577 16.4986 12.544 16.6122 12.402 16.6974C12.2628 16.7798 12.108 16.821 11.9375 16.821Z" fill="#446888"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 651 B |
5
public/assets/images/dashboard/priority/medium.svg
Normal file
5
public/assets/images/dashboard/priority/medium.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" fill="#F1F5F8"/>
|
||||
<path d="M10.7642 8L10.6236 14.1619H9.25142L9.11506 8H10.7642ZM9.9375 16.821C9.67898 16.821 9.45739 16.7301 9.27273 16.5483C9.09091 16.3665 9 16.1449 9 15.8835C9 15.6278 9.09091 15.4091 9.27273 15.2273C9.45739 15.0455 9.67898 14.9545 9.9375 14.9545C10.1903 14.9545 10.4091 15.0455 10.5938 15.2273C10.7812 15.4091 10.875 15.6278 10.875 15.8835C10.875 16.0568 10.831 16.2145 10.7429 16.3565C10.6577 16.4986 10.544 16.6122 10.402 16.6974C10.2628 16.7798 10.108 16.821 9.9375 16.821Z" fill="#446888"/>
|
||||
<path d="M14.1073 8L13.9667 14.1619H12.5945L12.4582 8H14.1073ZM13.2806 16.821C13.0221 16.821 12.8005 16.7301 12.6159 16.5483C12.434 16.3665 12.3431 16.1449 12.3431 15.8835C12.3431 15.6278 12.434 15.4091 12.6159 15.2273C12.8005 15.0455 13.0221 14.9545 13.2806 14.9545C13.5335 14.9545 13.7522 15.0455 13.9369 15.2273C14.1244 15.4091 14.2181 15.6278 14.2181 15.8835C14.2181 16.0568 14.1741 16.2145 14.086 16.3565C14.0008 16.4986 13.8872 16.6122 13.7451 16.6974C13.6059 16.7798 13.4511 16.821 13.2806 16.821Z" fill="#446888"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
public/assets/images/dashboard/priority/none.svg
Normal file
4
public/assets/images/dashboard/priority/none.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" fill="#F1F5F8"/>
|
||||
<path d="M13.5686 8L11.1579 16.9562H10L12.4107 8H13.5686Z" fill="#446888"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 225 B |
9
public/assets/images/dashboard/priority/urgent.svg
Normal file
9
public/assets/images/dashboard/priority/urgent.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" fill="#FFEBEE"/>
|
||||
<path d="M8 8.5C8 7.94772 8.44772 7.5 9 7.5C9.55228 7.5 10 7.94772 10 8.5V13C10 13.5523 9.55228 14 9 14C8.44772 14 8 13.5523 8 13V8.5Z" fill="#FF382D"/>
|
||||
<path d="M8 15.5C8 14.9477 8.44772 14.5 9 14.5C9.55228 14.5 10 14.9477 10 15.5C10 16.0523 9.55228 16.5 9 16.5C8.44772 16.5 8 16.0523 8 15.5Z" fill="#FF382D"/>
|
||||
<path d="M11 8.5C11 7.94772 11.4477 7.5 12 7.5C12.5523 7.5 13 7.94772 13 8.5V13C13 13.5523 12.5523 14 12 14C11.4477 14 11 13.5523 11 13V8.5Z" fill="#FF382D"/>
|
||||
<path d="M11 15.5C11 14.9477 11.4477 14.5 12 14.5C12.5523 14.5 13 14.9477 13 15.5C13 16.0523 12.5523 16.5 12 16.5C11.4477 16.5 11 16.0523 11 15.5Z" fill="#FF382D"/>
|
||||
<path d="M14 8.5C14 7.94772 14.4477 7.5 15 7.5C15.5523 7.5 16 7.94772 16 8.5V13C16 13.5523 15.5523 14 15 14C14.4477 14 14 13.5523 14 13V8.5Z" fill="#FF382D"/>
|
||||
<path d="M14 15.5C14 14.9477 14.4477 14.5 15 14.5C15.5523 14.5 16 14.9477 16 15.5C16 16.0523 15.5523 16.5 15 16.5C14.4477 16.5 14 16.0523 14 15.5Z" fill="#FF382D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
44
public/manifest.json
Normal file
44
public/manifest.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "Chatwoot",
|
||||
"short_name": "Chatwoot",
|
||||
"icons": [{
|
||||
"src": "\/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}],
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1f93ff",
|
||||
"theme_color": "#1f93ff"
|
||||
}
|
||||
93
spec/jobs/inboxes/bulk_auto_assignment_job_spec.rb
Normal file
93
spec/jobs/inboxes/bulk_auto_assignment_job_spec.rb
Normal file
@ -0,0 +1,93 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inboxes::BulkAutoAssignmentJob do
|
||||
let(:account) { create(:account, custom_attributes: { 'plan_name' => 'Startups' }) }
|
||||
let(:agent) { create(:user, account: account, role: :agent, auto_offline: false) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: nil, status: :open) }
|
||||
let(:assignment_service) { double }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
allow(assignment_service).to receive(:perform)
|
||||
end
|
||||
|
||||
context 'when inbox has inbox members' do
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
account.enable_features!('assignment_v2')
|
||||
inbox.update!(enable_auto_assignment: true)
|
||||
end
|
||||
|
||||
it 'assigns unassigned conversations in enabled inboxes' do
|
||||
allow(AutoAssignment::AgentAssignmentService).to receive(:new).with(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: [agent.id]
|
||||
).and_return(assignment_service)
|
||||
|
||||
described_class.perform_now
|
||||
expect(AutoAssignment::AgentAssignmentService).to have_received(:new).with(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: [agent.id]
|
||||
)
|
||||
end
|
||||
|
||||
it 'skips inboxes with auto assignment disabled' do
|
||||
inbox.update!(enable_auto_assignment: false)
|
||||
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
|
||||
|
||||
described_class.perform_now
|
||||
|
||||
expect(AutoAssignment::AgentAssignmentService).not_to have_received(:new).with(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: [agent.id]
|
||||
)
|
||||
end
|
||||
|
||||
context 'when account is on default plan in chatwoot cloud' do
|
||||
before do
|
||||
account.update!(custom_attributes: {})
|
||||
InstallationConfig.create!(name: 'CHATWOOT_CLOUD_PLANS', value: [{ 'name' => 'default' }])
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
end
|
||||
|
||||
it 'skips auto assignment' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
expect(Rails.logger).to receive(:info).with("Skipping auto assignment for account #{account.id}")
|
||||
|
||||
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
|
||||
expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox has no members' do
|
||||
before do
|
||||
account.enable_features!('assignment_v2')
|
||||
inbox.update!(enable_auto_assignment: true)
|
||||
end
|
||||
|
||||
it 'does not assign conversations' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
expect(Rails.logger).to receive(:info).with("No agents available to assign conversation to inbox #{inbox.id}")
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assignment_v2 feature is disabled' do
|
||||
before do
|
||||
account.disable_features!('assignment_v2')
|
||||
end
|
||||
|
||||
it 'skips auto assignment' do
|
||||
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
|
||||
expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
43
spec/services/whatsapp/message_dedup_lock_spec.rb
Normal file
43
spec/services/whatsapp/message_dedup_lock_spec.rb
Normal file
@ -0,0 +1,43 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::MessageDedupLock do
|
||||
let(:source_id) { "wamid.test_#{SecureRandom.hex(8)}" }
|
||||
let(:lock) { described_class.new(source_id) }
|
||||
let(:redis_key) { format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: source_id) }
|
||||
|
||||
after { Redis::Alfred.delete(redis_key) }
|
||||
|
||||
describe '#acquire!' do
|
||||
it 'returns truthy on first acquire' do
|
||||
expect(lock.acquire!).to be_truthy
|
||||
end
|
||||
|
||||
it 'returns falsy on second acquire for the same source_id' do
|
||||
lock.acquire!
|
||||
expect(described_class.new(source_id).acquire!).to be_falsy
|
||||
end
|
||||
|
||||
it 'allows different source_ids to acquire independently' do
|
||||
lock.acquire!
|
||||
other = described_class.new("wamid.other_#{SecureRandom.hex(8)}")
|
||||
expect(other.acquire!).to be_truthy
|
||||
end
|
||||
|
||||
it 'lets exactly one thread win when two race for the same source_id' do
|
||||
results = Concurrent::Array.new
|
||||
barrier = Concurrent::CyclicBarrier.new(2)
|
||||
|
||||
threads = Array.new(2) do
|
||||
Thread.new do
|
||||
barrier.wait
|
||||
results << described_class.new(source_id).acquire!
|
||||
end
|
||||
end
|
||||
|
||||
threads.each(&:join)
|
||||
|
||||
wins = results.count { |r| r }
|
||||
expect(wins).to eq(1), "Expected exactly 1 winner but got #{wins}. Results: #{results.inspect}"
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user