mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-04 22:13:25 +00:00
Compare commits
28 Commits
refactor-a
...
v0.7.4-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d63302843b | ||
|
|
a652f380b2 | ||
|
|
a4a9a9ccd3 | ||
|
|
71865e389e | ||
|
|
ae470be4c8 | ||
|
|
636742c34b | ||
|
|
de77c03f66 | ||
|
|
b7092744fd | ||
|
|
6f300bb073 | ||
|
|
a8ca12fb9a | ||
|
|
e4bec993e6 | ||
|
|
efc01be7d3 | ||
|
|
ec72c5af90 | ||
|
|
490417cf9d | ||
|
|
4f54db3d1b | ||
|
|
210b8bb53b | ||
|
|
a0e1ccf117 | ||
|
|
faf2082561 | ||
|
|
50baa8491b | ||
|
|
8e89e4e0d4 | ||
|
|
b15413b7ca | ||
|
|
701e5b2580 | ||
|
|
dbd4e97f7e | ||
|
|
007c332a7d | ||
|
|
4fcad4fd81 | ||
|
|
bece58bdec | ||
|
|
6d2d8f78d4 | ||
|
|
074d147bb6 |
31
.github/workflows/github-pages.yml
vendored
31
.github/workflows/github-pages.yml
vendored
@@ -1,31 +0,0 @@
|
||||
name: Deploy MkDocs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.x
|
||||
|
||||
- run: pip install mkdocs-material
|
||||
|
||||
- run: |
|
||||
if [ -f requirements.txt ]; then
|
||||
pip install -r requirements.txt;
|
||||
fi
|
||||
|
||||
- run: cd docs && mkdocs build
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./docs/site
|
||||
10
README.md
10
README.md
@@ -3,9 +3,9 @@
|
||||
|
||||
# Libredesk
|
||||
|
||||
Open source, self-hosted customer support desk. Single binary app.
|
||||
Modern, open source, self-hosted customer support desk. Single binary app.
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
|
||||
@@ -67,7 +67,7 @@ docker exec -it libredesk_app ./libredesk --set-system-user-password
|
||||
|
||||
Go to `http://localhost:9000` and login with username `System` and the password you set using the `--set-system-user-password` command.
|
||||
|
||||
See [installation docs](https://libredesk.io/docs/installation/)
|
||||
See [installation docs](https://docs.libredesk.io/getting-started/installation)
|
||||
|
||||
__________________
|
||||
|
||||
@@ -78,12 +78,12 @@ __________________
|
||||
- Run `./libredesk --set-system-user-password` to set the password for the System user.
|
||||
- Run `./libredesk` and visit `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
|
||||
|
||||
See [installation docs](https://libredesk.io/docs/installation)
|
||||
See [installation docs](https://docs.libredesk.io/getting-started/installation)
|
||||
__________________
|
||||
|
||||
|
||||
## Developers
|
||||
If you are interested in contributing, refer to the [developer setup](https://libredesk.io/docs/developer-setup/). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
|
||||
If you are interested in contributing, refer to the [developer setup](https://docs.libredesk.io/contributing/developer-setup). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
|
||||
|
||||
## Development Status
|
||||
|
||||
|
||||
@@ -103,9 +103,9 @@ func handleUpdateContact(r *fastglue.Request) error {
|
||||
if v, ok := form.Value["phone_number"]; ok && len(v) > 0 {
|
||||
phoneNumber = string(v[0])
|
||||
}
|
||||
phoneNumberCallingCode := ""
|
||||
if v, ok := form.Value["phone_number_calling_code"]; ok && len(v) > 0 {
|
||||
phoneNumberCallingCode = string(v[0])
|
||||
phoneNumberCountryCode := ""
|
||||
if v, ok := form.Value["phone_number_country_code"]; ok && len(v) > 0 {
|
||||
phoneNumberCountryCode = string(v[0])
|
||||
}
|
||||
avatarURL := ""
|
||||
if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 {
|
||||
@@ -116,8 +116,8 @@ func handleUpdateContact(r *fastglue.Request) error {
|
||||
if avatarURL == "null" {
|
||||
avatarURL = ""
|
||||
}
|
||||
if phoneNumberCallingCode == "null" {
|
||||
phoneNumberCallingCode = ""
|
||||
if phoneNumberCountryCode == "null" {
|
||||
phoneNumberCountryCode = ""
|
||||
}
|
||||
if phoneNumber == "null" {
|
||||
phoneNumber = ""
|
||||
@@ -146,7 +146,7 @@ func handleUpdateContact(r *fastglue.Request) error {
|
||||
Email: null.StringFrom(email),
|
||||
AvatarURL: null.NewString(avatarURL, avatarURL != ""),
|
||||
PhoneNumber: null.NewString(phoneNumber, phoneNumber != ""),
|
||||
PhoneNumberCallingCode: null.NewString(phoneNumberCallingCode, phoneNumberCallingCode != ""),
|
||||
PhoneNumberCountryCode: null.NewString(phoneNumberCountryCode, phoneNumberCountryCode != ""),
|
||||
}
|
||||
|
||||
if err := app.user.UpdateContact(id, contactToUpdate); err != nil {
|
||||
|
||||
@@ -734,7 +734,7 @@ func handleCreateConversation(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
|
||||
}
|
||||
case umodels.UserTypeContact:
|
||||
// Create message on behalf of contact.
|
||||
// Create contact message.
|
||||
if _, err := app.conversation.CreateContactMessage(media, contact.ID, conversationUUID, req.Content, cmodels.ContentTypeHTML); err != nil {
|
||||
// Delete the conversation if message creation fails.
|
||||
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
|
||||
|
||||
@@ -6,6 +6,10 @@ import (
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
const (
|
||||
maxCsatFeedbackLength = 1000
|
||||
)
|
||||
|
||||
// handleShowCSAT renders the CSAT page for a given csat.
|
||||
func handleShowCSAT(r *fastglue.Request) error {
|
||||
var (
|
||||
@@ -88,6 +92,11 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Trim feedback if it exceeds max length
|
||||
if len(feedback) > maxCsatFeedbackLength {
|
||||
feedback = feedback[:maxCsatFeedbackLength]
|
||||
}
|
||||
|
||||
if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
|
||||
@@ -155,7 +155,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
|
||||
|
||||
// Roles.
|
||||
g.GET("/api/v1/roles", perm(handleGetRoles, "roles:manage"))
|
||||
g.GET("/api/v1/roles", auth(handleGetRoles))
|
||||
g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage"))
|
||||
g.POST("/api/v1/roles", perm(handleCreateRole, "roles:manage"))
|
||||
g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage"))
|
||||
|
||||
@@ -2,10 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
authzModels "github.com/abhinavxd/libredesk/internal/authz/models"
|
||||
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
medModels "github.com/abhinavxd/libredesk/internal/media/models"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
@@ -17,6 +21,7 @@ type messageReq struct {
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc"`
|
||||
SenderType string `json:"sender_type"`
|
||||
}
|
||||
|
||||
// handleGetMessages returns messages for a conversation.
|
||||
@@ -150,7 +155,31 @@ func handleSendMessage(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Prepare attachments.
|
||||
if req.SenderType != umodels.UserTypeAgent && req.SenderType != umodels.UserTypeContact {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`sender_type`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Contacts cannot send private messages
|
||||
if req.SenderType == umodels.UserTypeContact && req.Private {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Check if user has permission to send messages as contact
|
||||
if req.SenderType == umodels.UserTypeContact {
|
||||
parts := strings.Split(authzModels.PermMessagesWriteAsContact, ":")
|
||||
if len(parts) != 2 {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil))
|
||||
}
|
||||
ok, err := app.authz.Enforce(user, parts[0], parts[1])
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil))
|
||||
}
|
||||
if !ok {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
|
||||
}
|
||||
}
|
||||
|
||||
// Get media for all attachments.
|
||||
var media = make([]medModels.Media, 0, len(req.Attachments))
|
||||
for _, id := range req.Attachments {
|
||||
m, err := app.media.Get(id, "")
|
||||
@@ -161,6 +190,16 @@ func handleSendMessage(r *fastglue.Request) error {
|
||||
media = append(media, m)
|
||||
}
|
||||
|
||||
// Create contact message.
|
||||
if req.SenderType == umodels.UserTypeContact {
|
||||
message, err := app.conversation.CreateContactMessage(media, int(conv.ContactID), cuuid, req.Message, cmodels.ContentTypeHTML)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(message)
|
||||
}
|
||||
|
||||
// Send private note.
|
||||
if req.Private {
|
||||
message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message)
|
||||
if err != nil {
|
||||
@@ -168,6 +207,8 @@ func handleSendMessage(r *fastglue.Request) error {
|
||||
}
|
||||
return r.SendEnvelope(message)
|
||||
}
|
||||
|
||||
// Queue reply.
|
||||
message, err := app.conversation.QueueReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
|
||||
@@ -35,6 +35,7 @@ var migList = []migFunc{
|
||||
{"v0.5.0", migrations.V0_5_0},
|
||||
{"v0.6.0", migrations.V0_6_0},
|
||||
{"v0.7.0", migrations.V0_7_0},
|
||||
{"v0.7.4", migrations.V0_7_4},
|
||||
}
|
||||
|
||||
// upgrade upgrades the database to the current version by running SQL migration files
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# API getting started
|
||||
|
||||
You can access the Libredesk API to interact with your instance programmatically.
|
||||
|
||||
## Generating API keys
|
||||
|
||||
1. **Edit agent**: Go to Admin → Teammate → Agent → Edit
|
||||
2. **Generate new API key**: An API Key and API Secret will be generated for the agent
|
||||
3. **Save the credentials**: Keep both the API Key and API Secret secure
|
||||
4. **Key management**: You can revoke / regenerate API keys at any time from the same page
|
||||
|
||||
## Using the API
|
||||
|
||||
LibreDesk supports two authentication schemes:
|
||||
|
||||
### Basic authentication
|
||||
```bash
|
||||
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
|
||||
-H "Authorization: Basic <base64_encoded_key:secret>"
|
||||
```
|
||||
|
||||
### Token authentication
|
||||
```bash
|
||||
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
|
||||
-H "Authorization: token your_api_key:your_api_secret"
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
Complete API documentation with available endpoints and examples coming soon.
|
||||
@@ -1,32 +0,0 @@
|
||||
# Developer Setup
|
||||
|
||||
Libredesk is a monorepo with a Go backend and a Vue.js frontend. The frontend uses Shadcn for UI components.
|
||||
|
||||
### Pre-requisites
|
||||
|
||||
- go
|
||||
- nodejs (if you are working on the frontend) and `pnpm`
|
||||
- redis
|
||||
- postgres database (>= 13)
|
||||
|
||||
### First time setup
|
||||
|
||||
Clone the repository:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/abhinavxd/libredesk.git
|
||||
```
|
||||
|
||||
1. Copy `config.toml.sample` as `config.toml` and add your config.
|
||||
2. Run `make` to build the libredesk binary. Once the binary is built, run `./libredesk --install` to run the DB setup and set the System user password.
|
||||
|
||||
### Running the Dev Environment
|
||||
|
||||
1. Run `make run-backend` to start the libredesk backend dev server on `:9000`.
|
||||
2. Run `make run-frontend` to start the Vue frontend in dev mode using pnpm on `:8000`. Requests are proxied to the backend running on `:9000` check `vite.config.js` for the proxy config.
|
||||
|
||||
---
|
||||
|
||||
# Production Build
|
||||
|
||||
Run `make` to build the Go binary, build the Javascript frontend, and embed the static assets producing a single self-contained binary, `libredesk`.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 298 KiB |
@@ -1,17 +0,0 @@
|
||||
# Introduction
|
||||
|
||||
Libredesk is an open-source, self-hosted customer support desk — single binary app.
|
||||
|
||||
<div style="border: 1px solid #ccc; padding: 2px; border-radius: 6px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); background-color: #f9f9f9;">
|
||||
<a href="https://libredesk.io">
|
||||
<img src="images/hero.png" alt="libredesk UI screenshot" style="display: block; margin: 0 auto; max-width: 100%; border-radius: 4px;" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Developers
|
||||
|
||||
Libredesk is licensed under AGPLv3. Contributions are welcome.
|
||||
|
||||
- Source code: [GitHub](https://github.com/abhinavxd/libredesk)
|
||||
- Setup guide: [Developer setup](developer-setup.md)
|
||||
- Stack: Go backend, Vue 3 frontend (Shadcn UI)
|
||||
@@ -1,65 +0,0 @@
|
||||
# Installation
|
||||
|
||||
Libredesk is a single binary application that requires postgres and redis to run. You can install it using the binary or docker.
|
||||
|
||||
## Binary
|
||||
|
||||
1. Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary.
|
||||
2. `./libredesk --install` to install the tables in the Postgres DB (⩾ 13) and set the System user password.
|
||||
3. Run `./libredesk` and visit `http://localhost:9000` and login with the email `System` and the password you set during installation.
|
||||
|
||||
!!! Tip
|
||||
To set the System user password during installation, set the environment variables:
|
||||
`LIBREDESK_SYSTEM_USER_PASSWORD=xxxxxxxxxxx ./libredesk --install`
|
||||
|
||||
|
||||
## Docker
|
||||
|
||||
The latest image is available on DockerHub at `libredesk/libredesk:latest`
|
||||
|
||||
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/main/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
|
||||
|
||||
```shell
|
||||
# Download the compose file and the sample config file in the current directory.
|
||||
curl -LO https://github.com/abhinavxd/libredesk/raw/main/docker-compose.yml
|
||||
curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
|
||||
|
||||
# Copy the config.sample.toml to config.toml and edit it as needed.
|
||||
cp config.sample.toml config.toml
|
||||
|
||||
# Run the services in the background.
|
||||
docker compose up -d
|
||||
|
||||
# Setting System user password.
|
||||
docker exec -it libredesk_app ./libredesk --set-system-user-password
|
||||
```
|
||||
|
||||
Go to `http://localhost:9000` and login with the email `System` and the password you set using the `--set-system-user-password` command.
|
||||
|
||||
|
||||
## Compiling from source
|
||||
|
||||
To compile the latest unreleased version (`main` branch):
|
||||
|
||||
1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system.
|
||||
2. `git clone git@github.com:abhinavxd/libredesk.git`
|
||||
3. `cd libredesk && make`. This will generate the `libredesk` binary.
|
||||
|
||||
|
||||
## Nginx
|
||||
|
||||
Libredesk uses websockets for real-time updates. If you are using Nginx, you need to add the following (or similar) configuration to your Nginx configuration file.
|
||||
|
||||
```nginx
|
||||
client_max_body_size 100M;
|
||||
location / {
|
||||
proxy_pass http://localhost:9000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
```
|
||||
@@ -1,57 +0,0 @@
|
||||
# Setting up SSO
|
||||
|
||||
Libredesk supports external OpenID Connect providers (e.g., Google, Keycloak) for signing in users.
|
||||
|
||||
!!! note
|
||||
User accounts must be created in Libredesk manually; signup is not supported.
|
||||
|
||||
## Generic Configuration Steps
|
||||
|
||||
Since each provider’s configuration might differ, consult your provider’s documentation for any additional or divergent settings.
|
||||
|
||||
1. Provider setup:
|
||||
In your provider’s admin console, create a new OpenID Connect application/client. Retrieve:
|
||||
- Client ID
|
||||
- Client Secret
|
||||
|
||||
2. Libredesk configuration:
|
||||
In Libredesk, navigate to Security > SSO and click New SSO and enter the following details:
|
||||
- Provider URL (e.g., the URL of your OpenID provider)
|
||||
- Client ID
|
||||
- Client Secret
|
||||
- A descriptive name for the connection
|
||||
|
||||
3. Redirect URL:
|
||||
After saving, copy the generated Callback URL from Libredesk and add it as a valid redirect URI in your provider’s client settings.
|
||||
|
||||
## Provider Examples
|
||||
|
||||
#### Keycloak
|
||||
|
||||
1. Log in to your Keycloak Admin Console.
|
||||
|
||||
2. In Keycloak, navigate to Clients and click Create:
|
||||
|
||||
- Client ID (e.g., `libredesk-app`)
|
||||
- Client Protocol: `openid-connect`
|
||||
- Root URL and Web Origins: your app domain (e.g., `https://ticket.example.com`)
|
||||
- Under Authentication flow, uncheck everything except the standard flow
|
||||
- Click save
|
||||
|
||||
3. Go to the credentials tab:
|
||||
- Ensure client authenticator is set to `Client Id and Secret`
|
||||
- Note down the generated client secret
|
||||
|
||||
4. In Libredesk, go to Admin > Security > SSO and click New SSO:
|
||||
- Provider URL (e.g., `https://keycloak.example.com/realms/yourrealm`)
|
||||
- Name (e.g., `Keycloak`)
|
||||
- Client ID
|
||||
- Client secret
|
||||
- Click save
|
||||
|
||||
5. After saving, click on the three dots and choose Edit to open the new SSO entry.
|
||||
|
||||
6. Copy the generated Callback URL from Libredesk.
|
||||
|
||||
7. Back in Keycloak, edit the client and add the Callback URL to Valid Redirect URIs:
|
||||
- e.g., `https://ticket.example.com/api/v1/oidc/1/finish`
|
||||
@@ -1,60 +0,0 @@
|
||||
# Templating
|
||||
|
||||
Templating in outgoing emails allows you to personalize content by embedding dynamic expressions like `{{ .Recipient.FullName }}`. These expressions reference fields from the conversation, contact, recipient, and author objects.
|
||||
|
||||
## Outgoing Email Template Expressions
|
||||
|
||||
If you want to customize the look of outgoing emails, you can do so in the Admin > Templates -> Outgoing Email Templates section. This template will be used for all outgoing emails including replies to conversations, notifications, and other system-generated emails.
|
||||
|
||||
### Conversation Variables
|
||||
|
||||
| Variable | Value |
|
||||
|---------------------------------|--------------------------------------------------------|
|
||||
| {{ .Conversation.ReferenceNumber }} | The unique reference number of the conversation |
|
||||
| {{ .Conversation.Subject }} | The subject of the conversation |
|
||||
| {{ .Conversation.Priority }} | The priority level of the conversation |
|
||||
| {{ .Conversation.UUID }} | The unique identifier of the conversation |
|
||||
|
||||
### Contact Variables
|
||||
|
||||
| Variable | Value |
|
||||
|------------------------------|------------------------------------|
|
||||
| {{ .Contact.FirstName }} | First name of the contact/customer |
|
||||
| {{ .Contact.LastName }} | Last name of the contact/customer |
|
||||
| {{ .Contact.FullName }} | Full name of the contact/customer |
|
||||
| {{ .Contact.Email }} | Email address of the contact/customer |
|
||||
|
||||
### Recipient Variables
|
||||
|
||||
| Variable | Value |
|
||||
|--------------------------------|-----------------------------------|
|
||||
| {{ .Recipient.FirstName }} | First name of the recipient |
|
||||
| {{ .Recipient.LastName }} | Last name of the recipient |
|
||||
| {{ .Recipient.FullName }} | Full name of the recipient |
|
||||
| {{ .Recipient.Email }} | Email address of the recipient |
|
||||
|
||||
### Author Variables
|
||||
|
||||
| Variable | Value |
|
||||
|------------------------------|-----------------------------------|
|
||||
| {{ .Author.FirstName }} | First name of the message author |
|
||||
| {{ .Author.LastName }} | Last name of the message author |
|
||||
| {{ .Author.FullName }} | Full name of the message author |
|
||||
| {{ .Author.Email }} | Email address of the message author |
|
||||
|
||||
### Example outgoing email template
|
||||
|
||||
```html
|
||||
Dear {{ .Recipient.FirstName }},
|
||||
|
||||
{{ template "content" . }}
|
||||
|
||||
Best regards,
|
||||
{{ .Author.FullName }}
|
||||
---
|
||||
Reference: {{ .Conversation.ReferenceNumber }}
|
||||
```
|
||||
|
||||
Here, the `{{ template "content" . }}` serves as a placeholder for the body of the outgoing email. It will be replaced with the actual email content at the time of sending.
|
||||
|
||||
Similarly, the `{{ .Recipient.FirstName }}` expression will dynamically insert the recipient's first name when the email is sent.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Translations / Internationalization
|
||||
|
||||
You can help translate libreDesk into different languages by contributing here: [Libredesk Translation Project](https://crowdin.com/project/libredesk)
|
||||
@@ -1,18 +0,0 @@
|
||||
# Upgrade
|
||||
|
||||
!!! warning "Warning"
|
||||
Always take a backup of the Postgres database before upgrading Libredesk.
|
||||
|
||||
## Binary
|
||||
- Stop running libredesk binary.
|
||||
- Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary and overwrite the previous version.
|
||||
- `./libredesk --upgrade` to upgrade an existing database schema. Upgrades are idempotent and running them multiple times have no side effects.
|
||||
- Run `./libredesk` again.
|
||||
|
||||
## Docker
|
||||
|
||||
```shell
|
||||
docker compose down app
|
||||
docker compose pull
|
||||
docker compose up app -d
|
||||
```
|
||||
@@ -1,222 +0,0 @@
|
||||
# Webhooks
|
||||
|
||||
Webhooks allow you to receive real-time HTTP notifications when specific events occur in your Libredesk instance. This enables you to integrate Libredesk with external systems and automate workflows based on conversation and message events.
|
||||
|
||||
## Overview
|
||||
|
||||
When a configured event occurs in Libredesk, a HTTP POST request is sent to the webhook URL you specify. The request contains a JSON payload with event details and relevant data.
|
||||
|
||||
## Webhook Configuration
|
||||
|
||||
1. Navigate to **Admin > Integrations > Webhooks** in your Libredesk dashboard
|
||||
2. Click **Create Webhook**
|
||||
3. Configure the following:
|
||||
- **Name**: A descriptive name for your webhook
|
||||
- **URL**: The endpoint URL where webhook payloads will be sent
|
||||
- **Events**: Select which events you want to subscribe to
|
||||
- **Secret**: Optional secret key for signature verification
|
||||
- **Status**: Enable or disable the webhook
|
||||
|
||||
## Security
|
||||
|
||||
### Signature Verification
|
||||
|
||||
If you provide a secret key, webhook payloads will be signed using HMAC-SHA256. The signature is included in the `X-Signature-256` header in the format `sha256=<signature>`.
|
||||
|
||||
To verify the signature:
|
||||
|
||||
```python
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
def verify_signature(payload, signature, secret):
|
||||
expected_signature = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
payload,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(f"sha256={expected_signature}", signature)
|
||||
```
|
||||
|
||||
### Headers
|
||||
|
||||
Each webhook request includes the following headers:
|
||||
|
||||
- `Content-Type`: `application/json`
|
||||
- `User-Agent`: `Libredesk-Webhook/<libredesk_version_here>`
|
||||
- `X-Signature-256`: HMAC signature (if secret is configured)
|
||||
|
||||
## Available Events
|
||||
|
||||
### Conversation Events
|
||||
|
||||
#### `conversation.created`
|
||||
Triggered when a new conversation is created.
|
||||
|
||||
**Sample Payload:**
|
||||
```json
|
||||
{
|
||||
"event": "conversation.created",
|
||||
"timestamp": "2025-06-15T10:30:00Z",
|
||||
"payload": {
|
||||
"id": 123,
|
||||
"created_at": "2025-06-15T10:30:00Z",
|
||||
"updated_at": "2025-06-15T10:30:00Z",
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"contact_id": 456,
|
||||
"inbox_id": 1,
|
||||
"reference_number": "100",
|
||||
"priority": "Medium",
|
||||
"priority_id": 2,
|
||||
"status": "Open",
|
||||
"status_id": 1,
|
||||
"subject": "Help with account setup",
|
||||
"inbox_name": "Support",
|
||||
"inbox_channel": "email",
|
||||
"contact": {
|
||||
"id": 456,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"email": "john.doe@example.com",
|
||||
"type": "contact"
|
||||
},
|
||||
"custom_attributes": {},
|
||||
"tags": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `conversation.status_changed`
|
||||
Triggered when a conversation's status is updated.
|
||||
|
||||
**Sample Payload:**
|
||||
```json
|
||||
{
|
||||
"event": "conversation.status_changed",
|
||||
"timestamp": "2025-06-15T10:35:00Z",
|
||||
"payload": {
|
||||
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"previous_status": "Open",
|
||||
"new_status": "Resolved",
|
||||
"snooze_until": "",
|
||||
"actor_id": 789
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `conversation.assigned`
|
||||
Triggered when a conversation is assigned to a user.
|
||||
|
||||
**Sample Payload:**
|
||||
```json
|
||||
{
|
||||
"event": "conversation.assigned",
|
||||
"timestamp": "2025-06-15T10:32:00Z",
|
||||
"payload": {
|
||||
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"assigned_to": 789,
|
||||
"actor_id": 789
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `conversation.unassigned`
|
||||
Triggered when a conversation is unassigned from a user.
|
||||
|
||||
**Sample Payload:**
|
||||
```json
|
||||
{
|
||||
"event": "conversation.unassigned",
|
||||
"timestamp": "2025-06-15T10:40:00Z",
|
||||
"payload": {
|
||||
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"actor_id": 789
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `conversation.tags_changed`
|
||||
Triggered when tags are added or removed from a conversation.
|
||||
|
||||
**Sample Payload:**
|
||||
```json
|
||||
{
|
||||
"event": "conversation.tags_changed",
|
||||
"timestamp": "2025-06-15T10:45:00Z",
|
||||
"payload": {
|
||||
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"previous_tags": ["bug", "priority"],
|
||||
"new_tags": ["bug", "priority", "resolved"],
|
||||
"actor_id": 789
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Message Events
|
||||
|
||||
#### `message.created`
|
||||
Triggered when a new message is created in a conversation.
|
||||
|
||||
**Sample Payload:**
|
||||
```json
|
||||
{
|
||||
"event": "message.created",
|
||||
"timestamp": "2025-06-15T10:33:00Z",
|
||||
"payload": {
|
||||
"id": 987,
|
||||
"created_at": "2025-06-15T10:33:00Z",
|
||||
"updated_at": "2025-06-15T10:33:00Z",
|
||||
"uuid": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"type": "outgoing",
|
||||
"status": "sent",
|
||||
"conversation_id": 123,
|
||||
"content": "<p>Hello! How can I help you today?</p>",
|
||||
"text_content": "Hello! How can I help you today?",
|
||||
"content_type": "html",
|
||||
"private": false,
|
||||
"sender_id": 789,
|
||||
"sender_type": "agent",
|
||||
"attachments": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `message.updated`
|
||||
Triggered when an existing message is updated.
|
||||
|
||||
**Sample Payload:**
|
||||
```json
|
||||
{
|
||||
"event": "message.updated",
|
||||
"timestamp": "2025-06-15T10:34:00Z",
|
||||
"payload": {
|
||||
"id": 987,
|
||||
"created_at": "2025-06-15T10:33:00Z",
|
||||
"updated_at": "2025-06-15T10:34:00Z",
|
||||
"uuid": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"type": "outgoing",
|
||||
"status": "sent",
|
||||
"conversation_id": 123,
|
||||
"content": "<p>Hello! How can I help you today? (Updated)</p>",
|
||||
"text_content": "Hello! How can I help you today? (Updated)",
|
||||
"content_type": "html",
|
||||
"private": false,
|
||||
"sender_id": 789,
|
||||
"sender_type": "agent",
|
||||
"attachments": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Delivery and Retries
|
||||
|
||||
- Webhooks requests timeout can be configured in the `config.toml` file
|
||||
- Failed deliveries are not automatically retried
|
||||
- Webhook delivery runs in a background worker pool for better performance
|
||||
- If the webhook queue is full (configurable in config.toml file), new events may be dropped
|
||||
|
||||
## Testing Webhooks
|
||||
|
||||
You can test your webhook configuration using tools like:
|
||||
|
||||
- [Webhook.site](https://webhook.site) - Generate a temporary URL to inspect webhook payloads
|
||||
@@ -1,38 +0,0 @@
|
||||
site_name: Libredesk Docs
|
||||
theme:
|
||||
name: material
|
||||
language: en
|
||||
font:
|
||||
text: Source Sans Pro
|
||||
code: Roboto Mono
|
||||
weights: [400, 700]
|
||||
direction: ltr
|
||||
palette:
|
||||
primary: white
|
||||
accent: red
|
||||
features:
|
||||
- navigation.indexes
|
||||
- navigation.sections
|
||||
- content.code.copy
|
||||
extra:
|
||||
search:
|
||||
language: en
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- codehilite
|
||||
- toc:
|
||||
permalink: true
|
||||
|
||||
nav:
|
||||
- Introduction: index.md
|
||||
- Getting Started:
|
||||
- Installation: installation.md
|
||||
- Upgrade Guide: upgrade.md
|
||||
- Email Templates: templating.md
|
||||
- SSO Setup: sso.md
|
||||
- Webhooks: webhooks.md
|
||||
- API Getting Started: api-getting-started.md
|
||||
- Contributions:
|
||||
- Developer Setup: developer-setup.md
|
||||
- Translate Libredesk: translations.md
|
||||
@@ -137,10 +137,10 @@
|
||||
--background: 240 5.9% 10%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 240 10% 3.9%;
|
||||
--card: 240 5.9% 10%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover: 240 5.9% 10%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
@@ -184,6 +184,10 @@
|
||||
@apply border shadow rounded;
|
||||
}
|
||||
|
||||
.loading-fade {
|
||||
@apply opacity-50 transition-opacity duration-300
|
||||
}
|
||||
|
||||
// Scrollbar start
|
||||
::-webkit-scrollbar {
|
||||
width: 8px; /* Adjust width */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click.prevent="onClose"
|
||||
@click.stop="onClose"
|
||||
size="xs"
|
||||
class="text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 w-6 h-6 p-0"
|
||||
>
|
||||
|
||||
@@ -52,8 +52,15 @@
|
||||
<div class="flex-1">
|
||||
<div v-if="modelFilter.field && modelFilter.operator">
|
||||
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
|
||||
<SelectTag
|
||||
v-if="getFieldType(modelFilter) === FIELD_TYPE.MULTI_SELECT"
|
||||
v-model="modelFilter.value"
|
||||
:items="getFieldOptions(modelFilter)"
|
||||
:placeholder="t('globals.messages.select', { name: t('globals.terms.tag', 2) })"
|
||||
/>
|
||||
|
||||
<SelectComboBox
|
||||
v-if="
|
||||
v-else-if="
|
||||
getFieldOptions(modelFilter).length > 0 &&
|
||||
modelFilter.field === 'assigned_user_id'
|
||||
"
|
||||
@@ -94,8 +101,9 @@
|
||||
<CloseButton :onClose="() => removeFilter(index)" />
|
||||
</div>
|
||||
|
||||
<!-- Button Container -->
|
||||
<div class="flex items-center justify-between pt-3">
|
||||
<Button variant="ghost" size="sm" @click="addFilter" class="text-slate-600">
|
||||
<Button variant="ghost" size="sm" @click.stop="addFilter" class="text-slate-600">
|
||||
<Plus class="w-3 h-3 mr-1" />
|
||||
{{
|
||||
$t('globals.messages.add', {
|
||||
@@ -104,15 +112,17 @@
|
||||
}}
|
||||
</Button>
|
||||
<div class="flex gap-2" v-if="showButtons">
|
||||
<Button variant="ghost" @click="clearFilters">{{ $t('globals.messages.reset') }}</Button>
|
||||
<Button @click="applyFilters">{{ $t('globals.messages.apply') }}</Button>
|
||||
<Button variant="ghost" @click.stop="clearFilters">
|
||||
{{ $t('globals.messages.reset') }}
|
||||
</Button>
|
||||
<Button @click.stop="applyFilters">{{ $t('globals.messages.apply') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -125,8 +135,10 @@ import { Plus } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { FIELD_TYPE } from '@/constants/filterConfig'
|
||||
import CloseButton from '@/components/button/CloseButton.vue'
|
||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
|
||||
import SelectTag from '@/components/ui/select/SelectTag.vue'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
@@ -150,12 +162,17 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// On unmounted set valid filters
|
||||
modelValue.value = validFilters.value
|
||||
})
|
||||
|
||||
const getModel = (field) => {
|
||||
const fieldConfig = props.fields.find((f) => f.field === field)
|
||||
return fieldConfig?.model || ''
|
||||
}
|
||||
|
||||
// Set model for each filter
|
||||
// Set model for each filter and the default value
|
||||
watch(
|
||||
() => modelValue.value,
|
||||
(filters) => {
|
||||
@@ -163,6 +180,15 @@ watch(
|
||||
if (filter.field && !filter.model) {
|
||||
filter.model = getModel(filter.field)
|
||||
}
|
||||
|
||||
// Multi select need arrays as their default value
|
||||
if (
|
||||
filter.field &&
|
||||
getFieldType(filter) === FIELD_TYPE.MULTI_SELECT &&
|
||||
!Array.isArray(filter.value)
|
||||
) {
|
||||
filter.value = []
|
||||
}
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
@@ -170,15 +196,20 @@ watch(
|
||||
|
||||
// Reset operator and value when field changes for a filter at a given index
|
||||
watch(
|
||||
() => modelValue.value.map((f) => f.field),
|
||||
(newFields, oldFields) => {
|
||||
newFields.forEach((field, index) => {
|
||||
if (field !== oldFields[index]) {
|
||||
modelValue.value[index].operator = ''
|
||||
modelValue.value[index].value = ''
|
||||
modelValue,
|
||||
(newFilters, oldFilters) => {
|
||||
// Skip first run
|
||||
if (!oldFilters) return
|
||||
|
||||
newFilters.forEach((filter, index) => {
|
||||
const oldFilter = oldFilters[index]
|
||||
if (oldFilter && filter.field !== oldFilter.field) {
|
||||
filter.operator = ''
|
||||
filter.value = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const addFilter = () => {
|
||||
@@ -197,7 +228,17 @@ const clearFilters = () => {
|
||||
}
|
||||
|
||||
const validFilters = computed(() => {
|
||||
return modelValue.value.filter((filter) => filter.field && filter.operator && filter.value)
|
||||
return modelValue.value.filter((filter) => {
|
||||
// For multi-select field type, allow empty array as a valid value
|
||||
const field = props.fields.find((f) => f.field === filter.field)
|
||||
const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT
|
||||
|
||||
if (isMultiSelectField) {
|
||||
return filter.field && filter.operator && filter.value !== undefined && filter.value !== null
|
||||
}
|
||||
|
||||
return filter.field && filter.operator && filter.value
|
||||
})
|
||||
})
|
||||
|
||||
const getFieldOptions = (fieldValue) => {
|
||||
@@ -209,4 +250,9 @@ const getFieldOperators = (modelFilter) => {
|
||||
const field = props.fields.find((f) => f.field === modelFilter.field)
|
||||
return field?.operators || []
|
||||
}
|
||||
|
||||
const getFieldType = (modelFilter) => {
|
||||
const field = props.fields.find((f) => f.field === modelFilter.field)
|
||||
return field?.type || ''
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -38,6 +38,16 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { filterNavItems } from '@/utils/nav-permissions'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
@@ -73,8 +83,17 @@ const editView = (view) => {
|
||||
emit('editView', view)
|
||||
}
|
||||
|
||||
const deleteView = (view) => {
|
||||
emit('deleteView', view)
|
||||
const openDeleteConfirmation = (view) => {
|
||||
viewToDelete.value = view
|
||||
isDeleteOpen.value = true
|
||||
}
|
||||
|
||||
const handleDeleteView = () => {
|
||||
if (viewToDelete.value) {
|
||||
emit('deleteView', viewToDelete.value)
|
||||
isDeleteOpen.value = false
|
||||
viewToDelete.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation methods with conversation retention
|
||||
@@ -157,6 +176,13 @@ watch(
|
||||
const sidebarOpen = useStorage('mainSidebarOpen', true)
|
||||
const teamInboxOpen = useStorage('teamInboxOpen', true)
|
||||
const viewInboxOpen = useStorage('viewInboxOpen', true)
|
||||
|
||||
// Track which view is being hovered for ellipsis menu visibility
|
||||
const hoveredViewId = ref(null)
|
||||
|
||||
// Track delete confirmation dialog state
|
||||
const isDeleteOpen = ref(false)
|
||||
const viewToDelete = ref(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -472,24 +498,35 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
|
||||
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub v-for="view in userViews" :key="view.id">
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubItem
|
||||
@mouseenter="hoveredViewId = view.id"
|
||||
@mouseleave="hoveredViewId = null"
|
||||
>
|
||||
<SidebarMenuButton
|
||||
size="sm"
|
||||
:isActive="route.params.viewID == view.id"
|
||||
asChild
|
||||
>
|
||||
<a href="#" @click.prevent="navigateToViewInbox(view.id)">
|
||||
<span class="break-words w-32 truncate">{{ view.name }}</span>
|
||||
<SidebarMenuAction :showOnHover="true" class="mr-3">
|
||||
<span class="break-words w-32 truncate" :title="view.name">{{ view.name }}</span>
|
||||
<SidebarMenuAction
|
||||
@click.stop
|
||||
:class="[
|
||||
'mr-3',
|
||||
'md:opacity-0',
|
||||
'data-[state=open]:opacity-100',
|
||||
{ 'md:opacity-100': hoveredViewId === view.id }
|
||||
]"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<DropdownMenuTrigger asChild @click.prevent>
|
||||
<EllipsisVertical />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem @click="() => editView(view)">
|
||||
<span>{{ t('globals.messages.edit') }}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="() => deleteView(view)">
|
||||
<DropdownMenuItem @click="() => openDeleteConfirmation(view)">
|
||||
<span>{{ t('globals.messages.delete') }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -513,4 +550,22 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
|
||||
<slot></slot>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
<!-- View Delete Confirmation Dialog -->
|
||||
<AlertDialog v-model:open="isDeleteOpen">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{{ t('globals.messages.deletionConfirmation', { name: t('globals.terms.view') }) }}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{{ t('globals.messages.cancel') }}</AlertDialogCancel>
|
||||
<AlertDialogAction @click="handleDeleteView">
|
||||
{{ t('globals.messages.delete') }}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:class="['w-full justify-between', buttonClass]"
|
||||
>
|
||||
<slot name="selected" :selected="selectedItem">{{ selectedLabel }}</slot>
|
||||
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CaretSortIcon class="h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<template>
|
||||
<!-- idk why I named this select tag, should be named multi-select -->
|
||||
<TagsInput v-model="tags" class="px-0 gap-0" :displayValue="getLabel">
|
||||
<!-- Tags visible to the user -->
|
||||
<div class="flex gap-2 flex-wrap items-center px-3">
|
||||
@@ -24,6 +25,7 @@
|
||||
@keydown.enter.prevent
|
||||
@blur="handleBlur"
|
||||
@click="open = true"
|
||||
@input.stop
|
||||
/>
|
||||
</ComboboxInput>
|
||||
</ComboboxAnchor>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useUsersStore } from '@/stores/users'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { useSlaStore } from '@/stores/sla'
|
||||
import { useCustomAttributeStore } from '@/stores/customAttributes'
|
||||
import { useTagStore } from '@/stores/tag'
|
||||
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -15,6 +16,7 @@ export function useConversationFilters () {
|
||||
const tStore = useTeamStore()
|
||||
const slaStore = useSlaStore()
|
||||
const customAttributeStore = useCustomAttributeStore()
|
||||
const tagStore = useTagStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const customAttributeDataTypeToFieldType = {
|
||||
@@ -69,6 +71,12 @@ export function useConversationFilters () {
|
||||
type: FIELD_TYPE.SELECT,
|
||||
operators: FIELD_OPERATORS.SELECT,
|
||||
options: iStore.options
|
||||
},
|
||||
tags: {
|
||||
label: t('globals.terms.tag', 2),
|
||||
type: FIELD_TYPE.MULTI_SELECT,
|
||||
operators: FIELD_OPERATORS.MULTI_SELECT,
|
||||
options: tagStore.tagOptions
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const FIELD_TYPE = {
|
||||
SELECT: 'select',
|
||||
TAG: 'tag',
|
||||
MULTI_SELECT: 'multi-select',
|
||||
TEXT: 'text',
|
||||
NUMBER: 'number',
|
||||
RICHTEXT: 'richtext',
|
||||
@@ -39,4 +40,5 @@ export const FIELD_OPERATORS = {
|
||||
OPERATOR.LESS_THAN
|
||||
],
|
||||
NUMBER: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.GREATER_THAN, OPERATOR.LESS_THAN],
|
||||
MULTI_SELECT: [OPERATOR.CONTAINS, OPERATOR.NOT_CONTAINS, OPERATOR.SET, OPERATOR.NOT_SET]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export const permissions = {
|
||||
CONVERSATIONS_UPDATE_TAGS: 'conversations:update_tags',
|
||||
MESSAGES_READ: 'messages:read',
|
||||
MESSAGES_WRITE: 'messages:write',
|
||||
MESSAGES_WRITE_AS_CONTACT: 'messages:write_as_contact',
|
||||
VIEW_MANAGE: 'view:manage',
|
||||
GENERAL_SETTINGS_MANAGE: 'general_settings:manage',
|
||||
NOTIFICATION_SETTINGS_MANAGE: 'notification_settings:manage',
|
||||
|
||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.firstName'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('first_name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('first_name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.lastName'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('last_name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('last_name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.enabled'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -36,7 +36,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.email'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('email'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('email'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -47,7 +47,7 @@ export const createColumns = (t) => [
|
||||
cell: function ({ row }) {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'text-center font-medium' },
|
||||
{ class: 'text-center' },
|
||||
format(row.getValue('created_at'), 'PPpp')
|
||||
)
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export const createColumns = (t) => [
|
||||
cell: function ({ row }) {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'text-center font-medium' },
|
||||
{ class: 'text-center' },
|
||||
format(row.getValue('updated_at'), 'PPpp')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
|
||||
return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.key'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('key'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('key'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.type'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('data_type'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('data_type'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -36,7 +36,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.appliesTo'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('applies_to'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('applies_to'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -47,7 +47,7 @@ export const createColumns = (t) => [
|
||||
cell: function ({ row }) {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'text-center font-medium' },
|
||||
{ class: 'text-center' },
|
||||
format(row.getValue('created_at'), 'PPpp')
|
||||
)
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export const createColumns = (t) => [
|
||||
cell: function ({ row }) {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'text-center font-medium' },
|
||||
{ class: 'text-center' },
|
||||
format(row.getValue('updated_at'), 'PPpp')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.provider'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('provider'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('provider'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -140,6 +140,7 @@ const permissions = ref([
|
||||
{ name: perms.CONVERSATIONS_UPDATE_TAGS, label: t('admin.role.conversations.updateTags') },
|
||||
{ name: perms.MESSAGES_READ, label: t('admin.role.messages.read') },
|
||||
{ name: perms.MESSAGES_WRITE, label: t('admin.role.messages.write') },
|
||||
{ name: perms.MESSAGES_WRITE_AS_CONTACT, label: t('admin.role.messages.writeAsContact') },
|
||||
{ name: perms.VIEW_MANAGE, label: t('admin.role.view.manage') }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -17,7 +17,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.description'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('description'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('description'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
|
||||
return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ export const columns = [
|
||||
return h('div', { class: 'text-center' }, 'Name')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -20,7 +20,7 @@ export const columns = [
|
||||
cell: function ({ row }) {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'text-center font-medium' },
|
||||
{ class: 'text-center' },
|
||||
format(row.getValue('created_at'), 'PPpp')
|
||||
)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export const columns = [
|
||||
cell: function ({ row }) {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'text-center font-medium' },
|
||||
{ class: 'text-center' },
|
||||
format(row.getValue('updated_at'), 'PPpp')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export const createOutgoingEmailTableColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -60,7 +60,7 @@ export const createEmailNotificationTableColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex items-end">
|
||||
<FormField v-slot="{ componentField }" name="phone_number_calling_code">
|
||||
<FormItem class="w-20">
|
||||
<FormField v-slot="{ componentField }" name="phone_number_country_code">
|
||||
<FormItem class="w-max">
|
||||
<FormLabel class="flex items-center whitespace-nowrap">
|
||||
{{ t('globals.terms.phoneNumber') }}
|
||||
</FormLabel>
|
||||
@@ -58,13 +58,18 @@
|
||||
<div class="w-7 h-7 flex items-center justify-center">
|
||||
<span v-if="item.emoji">{{ item.emoji }}</span>
|
||||
</div>
|
||||
<span class="text-sm">{{ item.label }} ({{ item.value }})</span>
|
||||
<span class="text-sm">{{ item.label }} ({{ item.calling_code }})</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #selected="{ selected }">
|
||||
<div class="flex items-center mb-1">
|
||||
<span v-if="selected" class="text-xl leading-none">{{ selected.emoji }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span v-if="selected" class="text-lg">{{ selected.emoji }}</span>
|
||||
<span
|
||||
v-if="selected && selected.calling_code"
|
||||
class="text-xs text-muted-foreground"
|
||||
>({{ selected.calling_code }})</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
@@ -116,7 +121,8 @@ const userStore = useUserStore()
|
||||
|
||||
const allCountries = countries.map((country) => ({
|
||||
label: country.name,
|
||||
value: country.calling_code,
|
||||
emoji: country.emoji
|
||||
value: country.iso_2,
|
||||
emoji: country.emoji,
|
||||
calling_code: country.calling_code
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -33,13 +33,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="cancelAddNote"
|
||||
class="transition-all hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline" @click="cancelAddNote"> Cancel </Button>
|
||||
<Button type="submit" :disabled="!newNote.trim()">
|
||||
{{ $t('globals.messages.save') + ' ' + $t('globals.terms.note').toLowerCase() }}
|
||||
</Button>
|
||||
@@ -53,13 +47,13 @@
|
||||
<Card
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="overflow-hidden border-gray-2 hover:border-gray-300 transition-all duration-200 box hover:shadow"
|
||||
class="overflow-hidden border-gray-2 dark:hover:border-gray-700 hover:border-gray-300 transition-all duration-200 box hover:shadow"
|
||||
>
|
||||
<!-- Header -->
|
||||
<CardHeader class="bg-gray-50/50 dark:bg-secondary border-b p-2">
|
||||
<CardHeader class="bg-background border-b p-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<Avatar class="border border-gray-200 shadow-sm">
|
||||
<Avatar class="border shadow-sm">
|
||||
<AvatarImage :src="note.avatar_url" />
|
||||
<AvatarFallback>
|
||||
{{ getInitials(note.first_name, note.last_name) }}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const createFormSchema = (t) => z.object({
|
||||
})
|
||||
})
|
||||
.nullable(),
|
||||
phone_number_calling_code: z.string().optional().nullable(),
|
||||
phone_number_country_code: z.string().optional().nullable(),
|
||||
avatar_url: z.string().optional().nullable(),
|
||||
email: z
|
||||
.string({
|
||||
|
||||
@@ -122,6 +122,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { useFileUpload } from '@/composables/useFileUpload'
|
||||
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
|
||||
import { UserTypeAgent } from '@/constants/user'
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
@@ -252,6 +253,7 @@ const processSend = async () => {
|
||||
if (hasTextContent.value > 0 || mediaFiles.value.length > 0) {
|
||||
const message = htmlContent.value
|
||||
await api.sendMessage(conversationStore.current.uuid, {
|
||||
sender_type: UserTypeAgent,
|
||||
private: messageType.value === 'private_note',
|
||||
message: message,
|
||||
attachments: mediaFiles.value.map((file) => file.id),
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
<SelectComboBox
|
||||
v-model="conversationStore.current.assigned_user_id"
|
||||
:items="[{ value: 'none', label: 'None' }, ...usersStore.options]"
|
||||
:placeholder="t('globals.messages.select', { name: t('globals.terms.agent').toLowerCase() })"
|
||||
:placeholder="
|
||||
t('globals.messages.select', { name: t('globals.terms.agent').toLowerCase() })
|
||||
"
|
||||
@select="selectAgent"
|
||||
type="user"
|
||||
/>
|
||||
@@ -22,7 +24,9 @@
|
||||
<SelectComboBox
|
||||
v-model="conversationStore.current.assigned_team_id"
|
||||
:items="[{ value: 'none', label: 'None' }, ...teamsStore.options]"
|
||||
:placeholder="t('globals.messages.select', { name: t('globals.terms.team').toLowerCase() })"
|
||||
:placeholder="
|
||||
t('globals.messages.select', { name: t('globals.terms.team').toLowerCase() })
|
||||
"
|
||||
@select="selectTeam"
|
||||
type="team"
|
||||
/>
|
||||
@@ -31,7 +35,9 @@
|
||||
<SelectComboBox
|
||||
v-model="conversationStore.current.priority_id"
|
||||
:items="priorityOptions"
|
||||
:placeholder="t('globals.messages.select', { name: t('globals.terms.priority').toLowerCase() })"
|
||||
:placeholder="
|
||||
t('globals.messages.select', { name: t('globals.terms.priority').toLowerCase() })
|
||||
"
|
||||
@select="selectPriority"
|
||||
type="priority"
|
||||
/>
|
||||
@@ -41,7 +47,9 @@
|
||||
v-if="conversationStore.current"
|
||||
v-model="conversationStore.current.tags"
|
||||
:items="tags.map((tag) => ({ label: tag, value: tag }))"
|
||||
:placeholder="t('globals.messages.select', { name: t('globals.terms.tag', 2).toLowerCase() })"
|
||||
:placeholder="
|
||||
t('globals.messages.select', { name: t('globals.terms.tag', 2).toLowerCase() })
|
||||
"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -93,6 +101,7 @@ import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { useTagStore } from '@/stores/tag'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -118,6 +127,7 @@ const emitter = useEmitter()
|
||||
const conversationStore = useConversationStore()
|
||||
const usersStore = useUsersStore()
|
||||
const teamsStore = useTeamStore()
|
||||
const tagStore = useTagStore()
|
||||
const tags = ref([])
|
||||
// Save the accordion state in local storage
|
||||
const accordionState = useStorage('conversation-sidebar-accordion', [])
|
||||
@@ -171,15 +181,8 @@ watch(
|
||||
const priorityOptions = computed(() => conversationStore.priorityOptions)
|
||||
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const resp = await api.getTags()
|
||||
tags.value = resp.data.data.map((item) => item.name)
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
await tagStore.fetchTags()
|
||||
tags.value = tagStore.tags.map((item) => item.name)
|
||||
}
|
||||
|
||||
const handleAssignedUserChange = (id) => {
|
||||
|
||||
@@ -58,6 +58,7 @@ import { ViewVerticalIcon } from '@radix-icons/vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Mail, Phone, ExternalLink } from 'lucide-vue-next'
|
||||
import countries from '@/constants/countries.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
@@ -72,8 +73,13 @@ const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const phoneNumber = computed(() => {
|
||||
const callingCode = conversation.value?.contact?.phone_number_calling_code || ''
|
||||
const countryCodeValue = conversation.value?.contact?.phone_number_country_code || ''
|
||||
const number = conversation.value?.contact?.phone_number || t('conversation.sidebar.notAvailable')
|
||||
return callingCode ? `${callingCode} ${number}` : number
|
||||
if (!countryCodeValue) return number
|
||||
|
||||
// Lookup calling code
|
||||
const country = countries.find((c) => c.iso_2 === countryCodeValue)
|
||||
const callingCode = country ? country.calling_code : countryCodeValue
|
||||
return `${callingCode} ${number}`
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
{{ $t('conversation.sidebar.noPreviousConvo') }}
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-else class="space-y-1">
|
||||
<router-link
|
||||
v-for="conversation in conversationStore.current.previous_conversations"
|
||||
:key="conversation.uuid"
|
||||
@@ -30,9 +30,31 @@
|
||||
{{ conversation.last_message }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground" v-if="conversation.last_message_at">
|
||||
{{ format(new Date(conversation.last_message_at), 'h') + ' h' }}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div class="flex gap-1 items-center text-xs text-muted-foreground">
|
||||
<span v-if="conversation.created_at">
|
||||
{{ getRelativeTime(new Date(conversation.created_at)) }}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span v-if="conversation.last_message_at">
|
||||
{{ getRelativeTime(new Date(conversation.last_message_at)) }}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div class="space-y-1 text-xs">
|
||||
<p>
|
||||
{{ $t('globals.terms.createdAt') }}:
|
||||
{{ formatFullTimestamp(new Date(conversation.created_at)) }}
|
||||
</p>
|
||||
<p v-if="conversation.last_message_at">
|
||||
{{ $t('globals.terms.lastMessageAt') }}:
|
||||
{{ formatFullTimestamp(new Date(conversation.last_message_at)) }}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -40,7 +62,8 @@
|
||||
|
||||
<script setup>
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { format } from 'date-fns'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { formatFullTimestamp, getRelativeTime } from '@/utils/datetime'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
</script>
|
||||
|
||||
@@ -83,6 +83,7 @@ import { handleHTTPError } from '@/utils/http'
|
||||
import { OPERATOR } from '@/constants/filterConfig.js'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { z } from 'zod'
|
||||
import { FIELD_TYPE } from '@/constants/filterConfig'
|
||||
import api from '@/api'
|
||||
|
||||
const emitter = useEmitter()
|
||||
@@ -106,68 +107,88 @@ const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
name: z
|
||||
.string()
|
||||
.string({
|
||||
required_error: t('globals.messages.required')
|
||||
})
|
||||
.min(2, { message: t('view.form.name.length') })
|
||||
.max(30, { message: t('view.form.name.length') }),
|
||||
filters: z
|
||||
.array(
|
||||
z.object({
|
||||
model: z.string({
|
||||
required_error: t('globals.messages.required', {
|
||||
name: t('globals.terms.filter').toLowerCase()
|
||||
})
|
||||
}),
|
||||
field: z.string({
|
||||
required_error: t('globals.messages.required', {
|
||||
name: t('globals.terms.field').toLowerCase()
|
||||
})
|
||||
}),
|
||||
operator: z.string({
|
||||
required_error: t('globals.messages.required', {
|
||||
name: t('globals.terms.operator').toLowerCase()
|
||||
})
|
||||
}),
|
||||
value: z.union([z.string(), z.number(), z.boolean()]).optional()
|
||||
model: z.string().optional(),
|
||||
field: z.string().optional(),
|
||||
operator: z.string().optional(),
|
||||
value: z
|
||||
.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.array(z.union([z.string(), z.number()]))
|
||||
])
|
||||
.optional()
|
||||
})
|
||||
)
|
||||
.default([])
|
||||
.refine((filters) => filters.length > 0, { message: t('view.form.filter.selectAtLeastOne') })
|
||||
.refine(
|
||||
(filters) =>
|
||||
filters.every(
|
||||
(f) =>
|
||||
f.model &&
|
||||
f.field &&
|
||||
f.operator &&
|
||||
([OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) || f.value)
|
||||
),
|
||||
{
|
||||
message: t('view.form.filter.partiallyFilled')
|
||||
}
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
validateOnMount: false,
|
||||
validateOnInput: false,
|
||||
validateOnBlur: false
|
||||
validationSchema: formSchema
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
const validationResult = await form.validate()
|
||||
if (!validationResult.valid) return
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
if (isSubmitting.value) return
|
||||
|
||||
// Make sure at least one filter is selected
|
||||
if (!values.filters || values.filters.length === 0) {
|
||||
form.setFieldError('filters', t('view.form.filter.selectAtLeastOne'))
|
||||
return
|
||||
}
|
||||
|
||||
// Check for partial filters
|
||||
const hasPartialFilters = values.filters.some(
|
||||
(f) =>
|
||||
!f.field ||
|
||||
!f.operator ||
|
||||
(![OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) && !f.value)
|
||||
)
|
||||
if (hasPartialFilters) {
|
||||
form.setFieldError('filters', t('view.form.filter.partiallyFilled'))
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
const values = form.values
|
||||
// Serialize array values to JSON strings for backend
|
||||
if (values.filters) {
|
||||
values.filters = values.filters.map((filter) => {
|
||||
if (Array.isArray(filter.value)) {
|
||||
// Convert string IDs to numbers for backend (tags use string IDs in frontend)
|
||||
const numericValues = filter.value.map((v) => {
|
||||
const num = Number(v)
|
||||
return isNaN(num) ? v : num
|
||||
})
|
||||
return { ...filter, value: JSON.stringify(numericValues) }
|
||||
}
|
||||
return filter
|
||||
})
|
||||
}
|
||||
|
||||
if (values.id) {
|
||||
await api.updateView(values.id, values)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
description: t('globals.messages.updatedSuccessfully', {
|
||||
name: t('globals.terms.view')
|
||||
})
|
||||
})
|
||||
} else {
|
||||
await api.createView(values)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
description: t('globals.messages.createdSuccessfully', {
|
||||
name: t('globals.terms.view')
|
||||
})
|
||||
})
|
||||
}
|
||||
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
|
||||
openDialog.value = false
|
||||
@@ -180,14 +201,36 @@ const onSubmit = async () => {
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Set form values when view prop changes
|
||||
watch(
|
||||
() => view.value,
|
||||
(newVal) => {
|
||||
if (newVal && Object.keys(newVal).length) {
|
||||
form.setValues(newVal)
|
||||
// Deserialize multi-select filter values from JSON strings to arrays
|
||||
const processedVal = { ...newVal }
|
||||
if (processedVal.filters) {
|
||||
processedVal.filters = processedVal.filters.map((filter) => {
|
||||
// Multi-select fields need to be deserialized from JSON strings
|
||||
const field = filterFields.value.find((f) => f.field === filter.field)
|
||||
const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT
|
||||
|
||||
if (isMultiSelectField && typeof filter.value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(filter.value)
|
||||
// Convert numbers back to strings (frontend uses string IDs)
|
||||
const stringValues = Array.isArray(parsed) ? parsed.map((v) => String(v)) : parsed
|
||||
return { ...filter, value: stringValues }
|
||||
} catch (e) {
|
||||
// If parsing fails, return as-is
|
||||
return filter
|
||||
}
|
||||
}
|
||||
return filter
|
||||
})
|
||||
}
|
||||
form.setValues(processedVal)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { format, differenceInMinutes, differenceInHours, differenceInDays } from 'date-fns'
|
||||
import { format, differenceInMinutes, differenceInHours, differenceInDays, differenceInYears } from 'date-fns'
|
||||
|
||||
export function getRelativeTime (timestamp, now = new Date()) {
|
||||
try {
|
||||
const mins = differenceInMinutes(now, timestamp)
|
||||
const hours = differenceInHours(now, timestamp)
|
||||
const days = differenceInDays(now, timestamp)
|
||||
const years = differenceInYears(now, timestamp)
|
||||
|
||||
if (mins === 0) return 'Just now'
|
||||
if (mins < 60) return `${mins} mins ago`
|
||||
if (hours < 24) return `${hours} hrs ago`
|
||||
if (days < 7) return `${days} days ago`
|
||||
return format(timestamp, 'MMMM d, yyyy h:mm a')
|
||||
if (mins === 0) return 'now'
|
||||
if (mins < 60) return `${mins}m`
|
||||
if (hours < 24) return `${hours}h`
|
||||
if (days < 365) return `${days}d`
|
||||
return `${years}y`
|
||||
} catch (error) {
|
||||
console.error('Error parsing time', error, 'timestamp', timestamp)
|
||||
return ''
|
||||
|
||||
@@ -69,7 +69,7 @@ const columns = [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -78,7 +78,7 @@ const columns = [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.channel'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('channel'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('channel'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<template #help>
|
||||
<p>Configure single sign-on with one or more OpenID Connect providers.</p>
|
||||
<a
|
||||
href="https://libredesk.io/docs/sso/"
|
||||
href="https://docs.libredesk.io/configuration/sso"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="link-style"
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<p>Design templates for customer communications and responses.</p>
|
||||
<p>Modify content for internal and external emails.</p>
|
||||
<a
|
||||
href="https://libredesk.io/docs/templating/"
|
||||
href="https://docs.libredesk.io/configuration/email-templates"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="link-style"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<p>Configure webhooks to receive real-time notifications when events occur in your Libredesk workspace.</p>
|
||||
<p>Webhooks allow you to integrate Libredesk with external services by sending HTTP POST requests when specific events happen.</p>
|
||||
<a
|
||||
href="https://libredesk.io/docs/webhooks/"
|
||||
href="https://docs.libredesk.io/configuration/webhooks"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="link-style"
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
<CustomBreadcrumb :links="breadcrumbLinks" />
|
||||
</div>
|
||||
|
||||
<div v-if="contact" class="flex justify-center space-y-4 w-full">
|
||||
<div
|
||||
v-if="contact"
|
||||
class="flex justify-center space-y-4 w-full"
|
||||
:class="{ 'loading-fade': formLoading }"
|
||||
>
|
||||
<div class="flex flex-col w-full mt-12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<AvatarUpload
|
||||
@@ -189,7 +193,7 @@ async function onUpload(file) {
|
||||
formData.append('last_name', form.values.last_name)
|
||||
formData.append('email', form.values.email)
|
||||
formData.append('phone_number', form.values.phone_number)
|
||||
formData.append('phone_number_calling_code', form.values.phone_number_calling_code)
|
||||
formData.append('phone_number_country_code', form.values.phone_number_country_code)
|
||||
formData.append('enabled', form.values.enabled)
|
||||
const { data } = await api.updateContact(contact.value.id, formData)
|
||||
contact.value.avatar_url = data.avatar_url
|
||||
|
||||
1
go.mod
1
go.mod
@@ -70,6 +70,7 @@ require (
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
golang.org/x/net v0.40.0 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -157,6 +157,8 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
|
||||
@@ -177,6 +177,7 @@
|
||||
"globals.terms.usage": "Usage",
|
||||
"globals.terms.createdAt": "Created At",
|
||||
"globals.terms.updatedAt": "Updated At",
|
||||
"globals.terms.lastMessageAt": "Last message at",
|
||||
"globals.terms.pickDate": "Pick a date",
|
||||
"globals.terms.time": "Time",
|
||||
"globals.terms.listValues": "List values",
|
||||
@@ -412,7 +413,7 @@
|
||||
"admin.general.maxAllowedFileUploadSize.description": "Max allowed file upload size in MB.",
|
||||
"admin.general.maxAllowedFileUploadSize.valid": "Max allowed file upload size should be between 1 and 500 MB",
|
||||
"admin.general.allowedFileUploadExtensions": "Allowed File Upload Extensions",
|
||||
"admin.general.allowedFileUploadExtensions.description": "Allowed file upload extensions. Use `*` to allow all file types.",
|
||||
"admin.general.allowedFileUploadExtensions.description": "Use `*` to permit all file types. For example: `jpg, png, pdf`",
|
||||
"admin.businessHours.unauthorized": "You do not have permission to view business hours.",
|
||||
"admin.businessHours.setBusinessHours": "Set business hours",
|
||||
"admin.businessHours.alwaysOpen24x7": "Always open (24/7)",
|
||||
@@ -500,6 +501,7 @@
|
||||
"admin.role.conversations.updateTags": "Add or remove conversation tags",
|
||||
"admin.role.messages.read": "View conversation messages",
|
||||
"admin.role.messages.write": "Send messages in conversations",
|
||||
"admin.role.messages.writeAsContact": "Send messages as contact",
|
||||
"admin.role.view.manage": "Create and manage conversation views",
|
||||
"admin.role.generalSettings.manage": "Manage General Settings",
|
||||
"admin.role.notificationSettings.manage": "Manage Notification Settings",
|
||||
@@ -531,7 +533,7 @@
|
||||
"admin.automation.conversationUpdate": "Conversation Update",
|
||||
"admin.automation.conversationUpdate.description": "Rules that run when a conversation is updated.",
|
||||
"admin.automation.timeTriggers": "Time Triggers",
|
||||
"admin.automation.timeTriggers.description": "Rules that once an hour",
|
||||
"admin.automation.timeTriggers.description": "Rules that run once an hour",
|
||||
"admin.automation.match": "Match",
|
||||
"admin.automation.any": "ANY",
|
||||
"admin.automation.all": "ALL",
|
||||
|
||||
@@ -15,6 +15,7 @@ const (
|
||||
PermConversationWrite = "conversations:write"
|
||||
PermMessagesRead = "messages:read"
|
||||
PermMessagesWrite = "messages:write"
|
||||
PermMessagesWriteAsContact = "messages:write_as_contact"
|
||||
|
||||
// View
|
||||
PermViewManage = "view:manage"
|
||||
@@ -102,6 +103,7 @@ var validPermissions = map[string]struct{}{
|
||||
PermConversationWrite: {},
|
||||
PermMessagesRead: {},
|
||||
PermMessagesWrite: {},
|
||||
PermMessagesWriteAsContact: {},
|
||||
PermViewManage: {},
|
||||
PermStatusManage: {},
|
||||
PermTagsManage: {},
|
||||
|
||||
@@ -232,6 +232,14 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
|
||||
for _, ruleValue := range ruleValues {
|
||||
// Normalize rule value by collapsing multiple spaces
|
||||
normalizedRuleValue := strings.Join(strings.Fields(ruleValue), " ")
|
||||
|
||||
// Respect CaseSensitiveMatch flag
|
||||
if rule.CaseSensitiveMatch {
|
||||
if strings.Contains(normalizedInputText, normalizedRuleValue) {
|
||||
conditionMet = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if strings.Contains(
|
||||
strings.ToLower(normalizedInputText),
|
||||
strings.ToLower(normalizedRuleValue),
|
||||
@@ -240,6 +248,7 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
case models.RuleOperatorNotContains:
|
||||
// Normalize input text by collapsing multiple spaces
|
||||
normalizedInputText := strings.Join(strings.Fields(valueToCompare), " ")
|
||||
@@ -249,6 +258,14 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
|
||||
for _, ruleValue := range ruleValues {
|
||||
// Normalize rule value by collapsing multiple spaces
|
||||
normalizedRuleValue := strings.Join(strings.Fields(ruleValue), " ")
|
||||
|
||||
// Respect CaseSensitiveMatch flag
|
||||
if rule.CaseSensitiveMatch {
|
||||
if strings.Contains(normalizedInputText, normalizedRuleValue) {
|
||||
conditionMet = false
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if strings.Contains(
|
||||
strings.ToLower(normalizedInputText),
|
||||
strings.ToLower(normalizedRuleValue),
|
||||
@@ -257,6 +274,7 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
case models.RuleOperatorSet:
|
||||
conditionMet = len(valueToCompare) > 0
|
||||
case models.RuleOperatorNotSet:
|
||||
|
||||
1201
internal/automation/evaluator_test.go
Normal file
1201
internal/automation/evaluator_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -541,6 +541,10 @@ func (c *Manager) UpdateConversationTeamAssignee(uuid string, teamID int, actor
|
||||
|
||||
// Team changed?
|
||||
if previousAssignedTeamID != teamID {
|
||||
// Remove assigned user if team has changed.
|
||||
c.RemoveConversationAssignee(uuid, models.AssigneeTypeUser, actor)
|
||||
|
||||
// Apply SLA policy if this new team has a SLA policy.
|
||||
team, err := c.teamStore.Get(teamID)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -960,7 +964,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveConversationAssignee removes the assignee from the conversation.
|
||||
// RemoveConversationAssignee removes assigned user from a conversation.
|
||||
func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.User) error {
|
||||
if _, err := m.q.RemoveConversationAssignee.Exec(uuid, typ); err != nil {
|
||||
m.lo.Error("error removing conversation assignee", "error", err)
|
||||
@@ -975,6 +979,14 @@ func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.Use
|
||||
})
|
||||
}
|
||||
|
||||
// Broadcast ws update.
|
||||
switch typ {
|
||||
case models.AssigneeTypeUser:
|
||||
m.BroadcastConversationUpdate(uuid, "assigned_user_id", nil)
|
||||
case models.AssigneeTypeTeam:
|
||||
m.BroadcastConversationUpdate(uuid, "assigned_team_id", nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1081,6 +1093,35 @@ func (c *Manager) makeConversationsListQuery(userID int, teamIDs []int, listType
|
||||
return "", nil, fmt.Errorf("no conversation list types specified")
|
||||
}
|
||||
|
||||
// Parse filters to extract tag filters
|
||||
var (
|
||||
filters []dbutil.Filter
|
||||
tagFilters []dbutil.Filter
|
||||
remainingFilters []dbutil.Filter
|
||||
)
|
||||
if filtersJSON != "" && filtersJSON != "[]" {
|
||||
if err := json.Unmarshal([]byte(filtersJSON), &filters); err != nil {
|
||||
return "", nil, fmt.Errorf("invalid filters JSON: %w", err)
|
||||
}
|
||||
|
||||
// Separate tag filters from other filters
|
||||
for _, f := range filters {
|
||||
if f.Field == "tags" && (f.Operator == "contains" || f.Operator == "not contains" || f.Operator == "set" || f.Operator == "not set") {
|
||||
tagFilters = append(tagFilters, f)
|
||||
} else {
|
||||
remainingFilters = append(remainingFilters, f)
|
||||
}
|
||||
}
|
||||
|
||||
// Update filtersJSON with remaining filters for the generic builder
|
||||
if len(remainingFilters) > 0 {
|
||||
b, _ := json.Marshal(remainingFilters)
|
||||
filtersJSON = string(b)
|
||||
} else {
|
||||
filtersJSON = "[]"
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the conditions based on the list types.
|
||||
conditions := []string{}
|
||||
for _, lt := range listTypes {
|
||||
@@ -1106,13 +1147,60 @@ func (c *Manager) makeConversationsListQuery(userID int, teamIDs []int, listType
|
||||
}
|
||||
}
|
||||
|
||||
// Build the base query with list type conditions
|
||||
var whereClause string
|
||||
if len(conditions) > 0 {
|
||||
baseQuery = fmt.Sprintf(baseQuery, "AND ("+strings.Join(conditions, " OR ")+")")
|
||||
} else {
|
||||
// Replace the `%s` in the base query with an empty string.
|
||||
baseQuery = fmt.Sprintf(baseQuery, "")
|
||||
whereClause = "AND (" + strings.Join(conditions, " OR ") + ")"
|
||||
}
|
||||
|
||||
// Add tag filter conditions
|
||||
// TODO: Evaluate - https://github.com/Masterminds/squirrel when required.
|
||||
for _, tf := range tagFilters {
|
||||
switch tf.Operator {
|
||||
case "contains", "not contains":
|
||||
var tagIDs []int
|
||||
if err := json.Unmarshal([]byte(tf.Value), &tagIDs); err != nil {
|
||||
return "", nil, fmt.Errorf("invalid tag IDs in filter: %w", err)
|
||||
}
|
||||
if len(tagIDs) > 0 {
|
||||
paramIdx := len(qArgs) + 1
|
||||
switch tf.Operator {
|
||||
case "contains":
|
||||
// Has any of the tags
|
||||
tagCondition := fmt.Sprintf(` AND conversations.id IN (
|
||||
SELECT DISTINCT conversation_id
|
||||
FROM conversation_tags
|
||||
WHERE tag_id = ANY($%d::int[])
|
||||
)`, paramIdx)
|
||||
whereClause += tagCondition
|
||||
case "not contains":
|
||||
// Doesn't have any of the tags
|
||||
tagCondition := fmt.Sprintf(` AND conversations.id NOT IN (
|
||||
SELECT DISTINCT conversation_id
|
||||
FROM conversation_tags
|
||||
WHERE tag_id = ANY($%d::int[])
|
||||
)`, paramIdx)
|
||||
whereClause += tagCondition
|
||||
}
|
||||
qArgs = append(qArgs, pq.Array(tagIDs))
|
||||
}
|
||||
case "set":
|
||||
// Has any tags at all
|
||||
whereClause += ` AND EXISTS (
|
||||
SELECT 1 FROM conversation_tags
|
||||
WHERE conversation_id = conversations.id
|
||||
)`
|
||||
case "not set":
|
||||
// Has no tags at all
|
||||
whereClause += ` AND NOT EXISTS (
|
||||
SELECT 1 FROM conversation_tags
|
||||
WHERE conversation_id = conversations.id
|
||||
)`
|
||||
}
|
||||
}
|
||||
|
||||
baseQuery = fmt.Sprintf(baseQuery, whereClause)
|
||||
|
||||
return dbutil.BuildPaginatedQuery(baseQuery, qArgs, dbutil.PaginationOptions{
|
||||
Order: order,
|
||||
OrderBy: orderBy,
|
||||
|
||||
@@ -462,6 +462,11 @@ func (m *Manager) InsertMessage(message *models.Message) error {
|
||||
message.Meta = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
// Handle empty content type enum, default to text.
|
||||
if message.ContentType == "" {
|
||||
message.ContentType = models.ContentTypeText
|
||||
}
|
||||
|
||||
// Convert HTML content to text for search.
|
||||
message.TextContent = stringutil.HTML2Text(message.Content)
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ type ConversationContact struct {
|
||||
AvailabilityStatus string `db:"availability_status" json:"availability_status"`
|
||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||
PhoneNumber null.String `db:"phone_number" json:"phone_number"`
|
||||
PhoneNumberCallingCode null.String `db:"phone_number_calling_code" json:"phone_number_calling_code"`
|
||||
PhoneNumberCountryCode null.String `db:"phone_number_country_code" json:"phone_number_country_code"`
|
||||
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"`
|
||||
@@ -173,7 +173,7 @@ type PreviousConversationContact struct {
|
||||
}
|
||||
|
||||
type ConversationParticipant struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
ID int `db:"id" json:"id"`
|
||||
FirstName string `db:"first_name" json:"first_name"`
|
||||
LastName string `db:"last_name" json:"last_name"`
|
||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||
|
||||
@@ -140,7 +140,7 @@ SELECT
|
||||
ct.availability_status as "contact.availability_status",
|
||||
ct.avatar_url as "contact.avatar_url",
|
||||
ct.phone_number as "contact.phone_number",
|
||||
ct.phone_number_calling_code as "contact.phone_number_calling_code",
|
||||
ct.phone_number_country_code as "contact.phone_number_country_code",
|
||||
ct.custom_attributes as "contact.custom_attributes",
|
||||
ct.enabled as "contact.enabled",
|
||||
ct.last_active_at as "contact.last_active_at",
|
||||
@@ -353,6 +353,7 @@ WHERE uuid = $1;
|
||||
UPDATE conversations
|
||||
SET
|
||||
assigned_user_id = CASE WHEN $2 = 'user' THEN NULL ELSE assigned_user_id END,
|
||||
assignee_last_seen_at = CASE WHEN $2 = 'user' THEN NULL ELSE assignee_last_seen_at END,
|
||||
assigned_team_id = CASE WHEN $2 = 'team' THEN NULL ELSE assigned_team_id END,
|
||||
updated_at = NOW()
|
||||
WHERE uuid = $1;
|
||||
|
||||
53
internal/migrations/v0.7.4.go
Normal file
53
internal/migrations/v0.7.4.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V0_7_4 updates the database schema to v0.7.4.
|
||||
func V0_7_4(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
// Rename phone_number_calling_code to phone_number_country_code
|
||||
// This column will now store country codes (US, CA, GB) instead of calling codes (+1, +44)
|
||||
_, err := db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name = 'phone_number_country_code'
|
||||
) AND EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name = 'phone_number_calling_code'
|
||||
) THEN
|
||||
ALTER TABLE users
|
||||
RENAME COLUMN phone_number_calling_code TO phone_number_country_code;
|
||||
END IF;
|
||||
END $$;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Rename the constraint to match the new column name
|
||||
_, err = db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.constraint_column_usage
|
||||
WHERE constraint_name = 'constraint_users_on_phone_number_country_code'
|
||||
) AND EXISTS (
|
||||
SELECT 1 FROM information_schema.constraint_column_usage
|
||||
WHERE constraint_name = 'constraint_users_on_phone_number_calling_code'
|
||||
) THEN
|
||||
ALTER TABLE users
|
||||
RENAME CONSTRAINT constraint_users_on_phone_number_calling_code TO constraint_users_on_phone_number_country_code;
|
||||
END IF;
|
||||
END $$;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -29,7 +29,7 @@ func (u *Manager) CreateContact(user *models.User) error {
|
||||
|
||||
// UpdateContact updates a contact in the database.
|
||||
func (u *Manager) UpdateContact(id int, user models.User) error {
|
||||
if _, err := u.q.UpdateContact.Exec(id, user.FirstName, user.LastName, user.Email, user.AvatarURL, user.PhoneNumber, user.PhoneNumberCallingCode); err != nil {
|
||||
if _, err := u.q.UpdateContact.Exec(id, user.FirstName, user.LastName, user.Email, user.AvatarURL, user.PhoneNumber, user.PhoneNumberCountryCode); err != nil {
|
||||
u.lo.Error("error updating user", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.contact}"), nil)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ type UserCompact struct {
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
Total int `db:"total" json:"total"`
|
||||
Total int `db:"total" json:"-"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
@@ -53,7 +53,7 @@ type User struct {
|
||||
Email null.String `db:"email" json:"email"`
|
||||
Type string `db:"type" json:"type"`
|
||||
AvailabilityStatus string `db:"availability_status" json:"availability_status"`
|
||||
PhoneNumberCallingCode null.String `db:"phone_number_calling_code" json:"phone_number_calling_code"`
|
||||
PhoneNumberCountryCode null.String `db:"phone_number_country_code" json:"phone_number_country_code"`
|
||||
PhoneNumber null.String `db:"phone_number" json:"phone_number"`
|
||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
|
||||
@@ -39,7 +39,7 @@ SELECT
|
||||
u.availability_status,
|
||||
u.last_active_at,
|
||||
u.last_login_at,
|
||||
u.phone_number_calling_code,
|
||||
u.phone_number_country_code,
|
||||
u.phone_number,
|
||||
u.api_key,
|
||||
u.api_key_last_used_at,
|
||||
@@ -174,7 +174,7 @@ SET first_name = COALESCE($2, first_name),
|
||||
email = COALESCE($4, email),
|
||||
avatar_url = $5,
|
||||
phone_number = $6,
|
||||
phone_number_calling_code = $7,
|
||||
phone_number_country_code = $7,
|
||||
updated_at = now()
|
||||
WHERE id = $1 and type = 'contact';
|
||||
|
||||
@@ -233,7 +233,7 @@ SELECT
|
||||
u.availability_status,
|
||||
u.last_active_at,
|
||||
u.last_login_at,
|
||||
u.phone_number_calling_code,
|
||||
u.phone_number_country_code,
|
||||
u.phone_number,
|
||||
u.api_key,
|
||||
u.api_key_last_used_at,
|
||||
|
||||
@@ -129,7 +129,7 @@ CREATE TABLE users (
|
||||
email TEXT NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NULL,
|
||||
phone_number_calling_code TEXT NULL,
|
||||
phone_number_country_code TEXT NULL,
|
||||
phone_number TEXT NULL,
|
||||
country TEXT NULL,
|
||||
"password" VARCHAR(150) NULL,
|
||||
@@ -146,7 +146,7 @@ CREATE TABLE users (
|
||||
api_key_last_used_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT constraint_users_on_country CHECK (LENGTH(country) <= 140),
|
||||
CONSTRAINT constraint_users_on_phone_number CHECK (LENGTH(phone_number) <= 20),
|
||||
CONSTRAINT constraint_users_on_phone_number_calling_code CHECK (LENGTH(phone_number_calling_code) <= 10),
|
||||
CONSTRAINT constraint_users_on_phone_number_country_code CHECK (LENGTH(phone_number_country_code) <= 10),
|
||||
CONSTRAINT constraint_users_on_email_length CHECK (LENGTH(email) <= 320),
|
||||
CONSTRAINT constraint_users_on_first_name CHECK (LENGTH(first_name) <= 140),
|
||||
CONSTRAINT constraint_users_on_last_name CHECK (LENGTH(last_name) <= 140)
|
||||
@@ -619,7 +619,7 @@ VALUES
|
||||
('app.lang', '"en"'::jsonb),
|
||||
('app.root_url', '"http://localhost:9000"'::jsonb),
|
||||
('app.logo_url', '"http://localhost:9000/logo.png"'::jsonb),
|
||||
('app.site_name', '"Libredesk"'::jsonb),
|
||||
('app.site_name', '"LIBREDESK"'::jsonb),
|
||||
('app.favicon_url', '"http://localhost:9000/favicon.ico"'::jsonb),
|
||||
('app.max_file_upload_size', '20'::jsonb),
|
||||
('app.allowed_file_upload_extensions', '["*"]'::jsonb),
|
||||
|
||||
Reference in New Issue
Block a user