Compare commits

...

28 Commits

Author SHA1 Message Date
Abhinav Raut
d63302843b Refactor: assigned user removal when changing assigned team 2025-09-16 21:31:53 +05:30
Abhinav Raut
a652f380b2 fix: set default content type to text if empty
Ref #137
2025-09-16 21:10:20 +05:30
Abhinav Raut
a4a9a9ccd3 trim feedback length to 1000 chars 2025-09-15 23:29:57 +05:30
Abhinav Raut
71865e389e fix: Change id type to int in ConversationParticipant
- hide Total field in UserCompact JSON
2025-09-15 01:01:51 +05:30
Abhinav Raut
ae470be4c8 remove mkdocs docs as docs are moved to docs.libredesk.io repository 2025-09-14 22:48:32 +05:30
Abhinav Raut
636742c34b Update docs link to point to new docs domain docs.libredesk.io 2025-09-14 22:47:05 +05:30
Abhinav Raut
de77c03f66 Merge pull request #135 from abhinavxd/fix/contact-form-calling-code
Fix: Contact form displays countries with the same calling code incorrectly
2025-09-14 22:27:06 +05:30
Abhinav Raut
b7092744fd feat: add loading fade effect to ContactDetailView and adjust FormItem width in ContactForm 2025-09-14 21:43:40 +05:30
Abhinav Raut
6f300bb073 Fix: Contact form displays countries with the same calling code incorrectly.
For example, when a user selects the USA, the form also shows Canada, as both share the +1 calling code.

Rename column from `phone_number_calling_code` to `phone_number_country_code`.

Feat: Show the calling code alongside the country flag in the contact form for the selected country. Previously, only the flag was displayed.
2025-09-14 19:36:30 +05:30
Abhinav Raut
a8ca12fb9a Update README.md 2025-09-13 22:27:21 +05:30
Abhinav Raut
e4bec993e6 Update README.md 2025-09-13 22:12:20 +05:30
Abhinav Raut
efc01be7d3 Update hero image link in README.md 2025-09-13 22:07:04 +05:30
Abhinav Raut
ec72c5af90 style: update dark mode colors for card and popover in main.scss; Update colors in ContactNotes.vue 2025-09-13 21:55:30 +05:30
Abhinav Raut
490417cf9d clarify file upload extension form input in general setting s 2025-09-13 21:46:35 +05:30
Abhinav Raut
4f54db3d1b Conversation sidebar: Show last message and conversation created timestamps in the previous converastions accordion, with tooltip for full timestamps.
- Update util getRelativeTime to return shorter relative time strings instead of full date strings.
2025-09-13 21:33:28 +05:30
Abhinav Raut
210b8bb53b feat: add support for sending messages as contact
- introduce new permission `messages:write_as_contact` that needs to be set to allow this.
2025-09-13 20:55:04 +05:30
Abhinav Raut
a0e1ccf117 clear assigned user ID and last seen timestamp when updating conversation assigned team 2025-09-11 22:09:06 +05:30
Abhinav Raut
faf2082561 fix view form validation happening while filters are being added, delay validation on form submit
Set missing .stop modified on click events
2025-09-11 00:16:58 +05:30
Abhinav Raut
50baa8491b add title attribute to view name in sidebar 2025-09-11 00:13:08 +05:30
Abhinav Raut
8e89e4e0d4 fix(multi-select): add @input.stop to prevent event bubbling and corrupting state when it's wrapped e.g. like it's done in the Filterbuilder component 2025-09-10 03:06:32 +05:30
Abhinav Raut
b15413b7ca Add support for filter conversations from views using tags assigned.
Add multi select support to FilterBuilder component.

Ref #124
2025-09-10 03:02:55 +05:30
Abhinav Raut
701e5b2580 show sidebar view dropdown Ellipsis only on hover of single view.
add toast messages for update, create, delete of view
add confirmation dialog for view deletion
2025-09-10 02:48:48 +05:30
Abhinav Raut
dbd4e97f7e use tag store to fetch tags in conversation side bar to remove duplicate api call 2025-09-09 23:43:25 +05:30
Abhinav Raut
007c332a7d remove font-medium from data table columns in all data tables
- Remove permissions requirement for GET on roles
2025-09-09 23:22:08 +05:30
Abhinav Raut
4fcad4fd81 fix translation 2025-09-02 03:10:08 +05:30
Abhinav Raut
bece58bdec fix[automation] respect case sensitive flag for contains and not contains operator
- test cases for automation evaluator
2025-09-02 02:31:08 +05:30
Abhinav Raut
6d2d8f78d4 Merge pull request #130 from abhinavxd/refactor-apis
Clean up APIs and fixes
2025-09-02 02:27:28 +05:30
Abhinav Raut
074d147bb6 Update README.md 2025-08-30 03:58:26 +05:30
70 changed files with 1819 additions and 767 deletions

View File

@@ -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

View File

@@ -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.
![image](docs/docs/images/hero.png)
![image](https://libredesk.io/hero_white.png)
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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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{}{

View File

@@ -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"))

View File

@@ -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)

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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)

View File

@@ -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;
}
```

View File

@@ -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 providers configuration might differ, consult your providers documentation for any additional or divergent settings.
1. Provider setup:
In your providers 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 providers 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`

View File

@@ -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.

View File

@@ -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)

View File

@@ -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
```

View File

@@ -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

View File

@@ -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

View File

@@ -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 */

View File

@@ -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"
>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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
}
}))

View File

@@ -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]
}

View File

@@ -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',

View File

@@ -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')
)
}

View File

@@ -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'))
}
},
{

View File

@@ -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')
)
}

View File

@@ -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'))
}
},
{

View File

@@ -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'))
}
},
{

View File

@@ -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') }
]
},

View File

@@ -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'))
}
},
{

View File

@@ -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'))
}
},
{

View File

@@ -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'))
}
},
{

View File

@@ -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'))
}
},
{

View File

@@ -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')
)
}

View File

@@ -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'))
}
},

View File

@@ -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'))
}
},
{

View File

@@ -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>

View File

@@ -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) }}

View File

@@ -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({

View File

@@ -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),

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -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' }}
</span>
<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>

View File

@@ -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 }

View File

@@ -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 ''

View File

@@ -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'))
}
},
{

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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",

View File

@@ -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: {},

View File

@@ -232,12 +232,21 @@ 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), " ")
if strings.Contains(
strings.ToLower(normalizedInputText),
strings.ToLower(normalizedRuleValue),
) {
conditionMet = true
break
// Respect CaseSensitiveMatch flag
if rule.CaseSensitiveMatch {
if strings.Contains(normalizedInputText, normalizedRuleValue) {
conditionMet = true
break
}
} else {
if strings.Contains(
strings.ToLower(normalizedInputText),
strings.ToLower(normalizedRuleValue),
) {
conditionMet = true
break
}
}
}
case models.RuleOperatorNotContains:
@@ -249,12 +258,21 @@ 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), " ")
if strings.Contains(
strings.ToLower(normalizedInputText),
strings.ToLower(normalizedRuleValue),
) {
conditionMet = false
break
// Respect CaseSensitiveMatch flag
if rule.CaseSensitiveMatch {
if strings.Contains(normalizedInputText, normalizedRuleValue) {
conditionMet = false
break
}
} else {
if strings.Contains(
strings.ToLower(normalizedInputText),
strings.ToLower(normalizedRuleValue),
) {
conditionMet = false
break
}
}
}
case models.RuleOperatorSet:

File diff suppressed because it is too large Load Diff

View File

@@ -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,

View File

@@ -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)

View File

@@ -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"`

View File

@@ -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;

View 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
}

View File

@@ -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)
}

View File

@@ -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"`

View File

@@ -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,

View File

@@ -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),