Compare commits

...

59 Commits

Author SHA1 Message Date
Abhinav Raut
36d91de8f7 fix: remove email validation from SMTP username field in email notification form schema 2025-03-06 15:11:16 +05:30
Abhinav Raut
57c1948379 fix[OOM]: fix read buffer size configuration in server settings, the readbuffer was set to the max body size making the binary go OOM. 2025-03-06 15:10:23 +05:30
Abhinav Raut
772152c40c fix: filter out empty email message ids for setting email references headers.
chore: adds debug logs.
2025-03-06 12:24:18 +05:30
Abhinav Raut
8e15d733ea fix: regression in sso login caused due to attempting in hiding client secret in the API response. Resolves #21 2025-03-05 16:13:13 +05:30
Abhinav Raut
fc47e65fcb chore: update screenshot in README 2025-03-05 04:33:11 +05:30
Abhinav Raut
760be37eda chore: update libredesk screenshot in documentation 2025-03-05 04:32:38 +05:30
Abhinav Raut
d1f08ce035 fix: handle null user last active time when marking agents offline. 2025-03-05 04:24:06 +05:30
Abhinav Raut
8551b65a27 fix: set references header in all outgoing emails, set the last 20 messages.
feat: set conversation reference number in the subject of conversation for better thread matching.
fix: hide CSAT link from conversation last message.
2025-03-05 03:49:22 +05:30
Abhinav Raut
eb499f64d0 chore: adds v0.4.0 to migration list. 2025-03-05 02:33:03 +05:30
Abhinav Raut
494bc15b0a feat: Enable agents to create conversations from the UI
Before this feature the only way to create a conversation was by adding inbox and sending an email.

Agents first search contacts by email, see a dropdown select an existing contact or fill a new email for new contact.

The backend creates contact if it does not exist, creates a conversation, sends a reply to the conversation.
Optinally assigns conversation to a user / team.

fix: Replies to emails create a new conversation instead of attaching to the previous one.

Was not happening in gmail, as gmail was sending the references headers in all replies and I missed this completely. So when libredesk searches a conversation by references headers it worked!

Instead the right way is to generate the outgoing email message id and saving it in DB. This commit fixes that.

There could be more backup strategies like putting reference number in the subject but that can be explored later.

chore: new role `conversatons:write` that enables the create conversations feature for an agent.

chore: migrations for v0.4.0.
2025-03-05 01:17:42 +05:30
Abhinav Raut
360557c58f fix: remove client_id and client_secret from get-all-oidc query 2025-03-04 22:02:42 +05:30
Abhinav Raut
8d8f08e1d2 chore add comments to command box component 2025-03-02 20:58:03 +05:30
Abhinav Raut
10b4f9d08c feat: show app version in admin tab
fix: view form validations and issues with reactivity
feat: save team inbox and view inbox dropdown state in localstorage.
fix: view inbox dropdown icon alignment.
2025-03-02 20:49:19 +05:30
Abhinav Raut
79f74363da fix: hide status dropdown in conversation list as views are prefiltered. 2025-03-02 20:44:05 +05:30
Abhinav Raut
8f6295542e fix: destroy user session when user account is disabled. 2025-03-02 19:17:42 +05:30
Abhinav Raut
8e286e2273 fix: /account navigation from sidebar. 2025-03-02 18:37:05 +05:30
Abhinav Raut
3aad69fc52 fix: update sample database credentials in config file
Matched it with default docker compose password.
2025-03-02 16:31:35 +05:30
Abhinav Raut
58825c3de9 fix: handle invalid sessions by destroying them and redirecting to login 2025-03-02 16:31:00 +05:30
Abhinav Raut
03c68afc4c fix: max age not working for cookies
Switch from expires to max age for setting cookie expiry
Set default max age to 9 hours
2025-03-02 16:28:26 +05:30
Abhinav Raut
15b9caaaed fix: prevent zap logo shrinking and ensure text wraps correctly in command bar
chore: increase command bar size.
2025-03-02 03:31:34 +05:30
Abhinav Raut
b0d3dcb5dd fix: Reply box layout for fullscreen mode 2025-03-02 03:05:51 +05:30
Abhinav Raut
96ef62b509 fix: reduce pagination sizes for conversation and message lists 2025-03-02 03:03:33 +05:30
Abhinav Raut
79c3f5a60c fix: do not clear editor state on API errors.
fix: handle macro errors silently, clear editor state on macro errors as most likely they are permission errors.
2025-03-02 03:02:46 +05:30
Abhinav Raut
70bef7b3ab fix: use explicit v-model binding to match defineModel name for action builder. 2025-03-02 02:55:20 +05:30
Abhinav Raut
b1e1dff3eb feat: replace quill editor with tiptap editor, removes the stupid hack as both editors handle new lines and empty content differently.
Quill adds <p><br></p> for new lines, while Tiptap uses <br> for Shift + Enter and <p> for Enter.

This commit fixes this hack I had added, now all editors in Libredesk are tiptap editors.

fix: Typography for agent and contact message bubbles and macro preview, as tailwind removes browser defaults. Introduces new class `native-html` for this.

fix: removes hardcoded classes in tiptap starter kit configuration as the new class `native-html` takes care of it and has to be just applied.

fix: Form validation for automations and macro form.

fix: automation list padding between items.

feat: adds bullet list and ordered list menu options to tiptap editor.
2025-03-02 01:42:17 +05:30
Abhinav Raut
9b34c2737d feat: multi-tab sync for user availability status and last activity 2025-03-01 20:33:40 +05:30
Abhinav Raut
1b63f03bb1 feat: include recipient details in email templates
With this the admin can simply add
```
Dear {{.Recipient.FirstName}},
```

To the default outgoing template and all outgoing emails will have the receipient name.
2025-03-01 20:04:49 +05:30
Abhinav Raut
26d76c966f feat: allow setting OpenAI API KEY from the UI.
feat: new `ai:manage` permission for the same
Migrations for new role.
2025-03-01 19:40:18 +05:30
Abhinav Raut
1ff335f772 fix: improve welcome email template styling and content
fix: extra large app logo in base template.
refactor: standardize template variables, explicitly pass variables for rendering into template
2025-03-01 19:10:50 +05:30
Abhinav Raut
5836ee8d90 fix: annoying scroll bar when there's a single message in a conversation
adjusts padding around single message in a conversation.
2025-02-28 22:22:13 +05:30
Abhinav Raut
98534f3c5a fix: reduce update check interval and initial sleep duration
As Libredesk is Alpha I will be pushing quick updates and fixes
2025-02-28 22:12:01 +05:30
Abhinav Raut
59951f0829 fix: private message sent as reply 2025-02-28 21:44:02 +05:30
Abhinav Raut
461ae3cf22 fix: sla badge not visible in conversation info sidebar. 2025-02-28 21:32:08 +05:30
Abhinav Raut
da5dfdbcde fix: prevent email enumeration in reset password flow. 2025-02-28 20:57:47 +05:30
Abhinav Raut
9c67c02b08 fix: ensure navigation to SSO list only after creating SSO provider and not while updating SSO provider. 2025-02-27 23:46:31 +05:30
Abhinav Raut
15b200b0db fix: add descriptions for notification settings SMTP config for better clarity 2025-02-27 23:02:14 +05:30
Abhinav Raut
f4617c599c fix: correct Zod schema for email address validation 2025-02-27 23:01:49 +05:30
Abhinav Raut
341d0b7e47 Update README.md 2025-02-27 21:37:07 +05:30
Abhinav Raut
78b8c508d8 fix: message bubble styling for better text wrapping 2025-02-27 03:01:05 +05:30
Abhinav Raut
f17d96f96f rafactor: move full screen editor and non-fullscreen editor to a common component.
feat: add typography plugin and improve DOM purifying in conversation messages
fix: sooner not working in outer app.
fix: macro actions getting deleted when macro is remove from the text editor preview.
fix: square user avatar image in sidebar,made it rounded-lg
refactor: visual fixes and improvements to macro previews for consistency with attachment preview.
2025-02-27 02:47:23 +05:30
Abhinav Raut
c75c117a4d fix: improve password handling and error reporting during password reset 2025-02-27 01:58:08 +05:30
Abhinav Raut
873d26ccb2 fix: ensure deep copy of macros, as removing macro from editor was deleting the macro action from the macro store.
- fix: conversation macro cmds visible when conversation is not open.
2025-02-26 23:19:37 +05:30
Abhinav Raut
71601364ae fix: mark conversation as read when messages are already cached 2025-02-26 17:41:25 +05:30
Abhinav Raut
44723fb70d fix: update command to start backend dev server in documentation 2025-02-26 12:11:15 +05:30
Abhinav Raut
67e1230485 feat: agent availability status
New columns in users table to store user availability status.

Websocket pings sets the last active at timestamp, once user stops sending pings (on disconnect) after 5 minutes the user availalbility status changes to offline.

Detects auto away by checking for mouse, keyboard events and sets user status to away.

User can also set their status to away manually from the sidebar.

Migrations for v0.3.0

Minor visual fixes.

Bump version in package.json
2025-02-26 04:34:30 +05:30
Abhinav Raut
d58898c60f fix: update DockerHub image path and branch reference in installation documentation 2025-02-26 00:52:42 +05:30
Abhinav Raut
a8dc0a6242 fix: correct DockerHub image path in installation documentation 2025-02-26 00:50:30 +05:30
Abhinav Raut
3aa144f703 feat: display app update component only for admin routes. 2025-02-25 18:27:21 +05:30
Abhinav Raut
fcbd16f042 feat: implement app update checker and UI notification 2025-02-25 02:49:09 +05:30
Abhinav Raut
e8f3f24422 feat: update build configuration and versioning system 2025-02-25 01:35:52 +05:30
Abhinav Raut
425bb4ed04 feat: add database upgrade functionality (adapted from listmonk) 2025-02-25 01:35:27 +05:30
Abhinav Raut
0c3da82250 fix: remove redundant margin class from Actions AccordionItem 2025-02-25 00:34:28 +05:30
Abhinav Raut
8649826a89 Update .gitattributes 2025-02-25 00:19:06 +05:30
Abhinav Raut
d427dfd20c Update .gitattributes 2025-02-25 00:18:42 +05:30
Abhinav Raut
afb54c371b Update .gitattributes 2025-02-25 00:16:28 +05:30
Abhinav Raut
46459599c7 Update .gitattributes 2025-02-25 00:10:14 +05:30
Abhinav Raut
63a6aedfd0 chore: update .gitattributes to specify Go language for all files 2025-02-25 00:08:04 +05:30
Abhinav Raut
ffbf613e68 chore: add .gitattributes to mark frontend files as vendored 2025-02-25 00:06:28 +05:30
Abhinav Raut
88f82fe80b fix: typos in docker image and curl request. 2025-02-24 22:28:01 +05:30
113 changed files with 3107 additions and 1298 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
VERSION export-subst

View File

@@ -10,7 +10,7 @@ before:
- make frontend-build
builds:
- id: "standard"
- id: "universal"
main: ./cmd
env:
- CGO_ENABLED=0
@@ -24,29 +24,13 @@ builds:
goarch:
- amd64
- arm64
binary: 'libredesk{{ if eq .Os "windows" }}.exe{{ end }}'
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
hooks:
post: make stuff BIN={{ .Path }}
- id: "arm"
main: ./cmd
env:
- CGO_ENABLED=0
goos:
- freebsd
- linux
- netbsd
- openbsd
goarch:
- arm
goarm:
- 6
- 7
binary: 'libredesk{{ if eq .Os "windows" }}.exe{{ end }}'
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
- -s -w -X "main.buildString={{ .Tag }} ({{ .ShortCommit }} {{ .Date }}, {{ .Os }}/{{ .Arch }})" -X "main.versionString={{ .Tag }}"
hooks:
post: make stuff BIN={{ .Path }}
@@ -70,7 +54,7 @@ dockers:
goos: linux
goarch: amd64
ids:
- standard
- universal
image_templates:
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
@@ -94,7 +78,7 @@ dockers:
goos: linux
goarch: arm64
ids:
- standard
- universal
image_templates:
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64"
@@ -119,7 +103,7 @@ dockers:
goarch: arm
goarm: 6
ids:
- arm
- universal
image_templates:
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
@@ -144,7 +128,7 @@ dockers:
goarch: arm
goarm: 7
ids:
- arm
- universal
image_templates:
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
@@ -195,4 +179,4 @@ release:
owner: abhinavxd
name: libredesk
prerelease: auto
draft: true
draft: true

View File

@@ -1,8 +1,10 @@
# Build variables
LAST_COMMIT := $(shell git rev-parse --short HEAD)
LAST_COMMIT_DATE := $(shell git show -s --format=%ci ${LAST_COMMIT})
VERSION := $(shell git describe --tags)
BUILDSTR := ${VERSION} (Commit: ${LAST_COMMIT_DATE} (${LAST_COMMIT}), Build: $(shell date +"%Y-%m-%d %H:%M:%S %z"))
# Try to get the commit hash from 1) git 2) the VERSION file 3) fallback.
LAST_COMMIT := $(or $(shell git rev-parse --short HEAD 2> /dev/null),$(shell head -n 1 VERSION | grep -oP -m 1 "^[a-z0-9]+$$"), "")
# Try to get the semver from 1) git 2) the VERSION file 3) fallback.
VERSION := $(or $(LIBREDESK_VERSION),$(shell git describe --tags --abbrev=0 2> /dev/null),$(shell grep -oP 'tag: \Kv\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?' VERSION),"v0.0.0")
BUILDSTR := ${VERSION} (\#${LAST_COMMIT} $(shell date -u +"%Y-%m-%dT%H:%M:%S%z"))
# Binary names and paths
BIN := libredesk
@@ -30,13 +32,13 @@ install-deps: $(STUFFBIN)
.PHONY: frontend-build
frontend-build: install-deps
@echo "→ Building frontend for production..."
@cd ${FRONTEND_DIR} && pnpm build
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build
# Run the Go backend server in development mode.
.PHONY: run-backend
run-backend:
@echo "→ Running backend..."
CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
# Run the JS frontend server in development mode.
.PHONY: run-frontend
@@ -44,19 +46,19 @@ run-frontend:
@echo "→ Installing frontend dependencies (if not already installed)..."
@cd ${FRONTEND_DIR} && pnpm install
@echo "→ Running frontend..."
@export VUE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev
# Build the backend binary.
.PHONY: backend-build
backend-build: $(STUFFBIN)
.PHONY: build-backend
build-backend: $(STUFFBIN)
@echo "→ Building backend..."
@CGO_ENABLED=0 go build -a\
-ldflags="-X 'main.buildString=${BUILDSTR}' -s -w" \
-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -s -w" \
-o ${BIN} cmd/*.go
# Main build target: builds both frontend and backend, then stuffs static assets into the binary.
.PHONY: build
build: frontend-build backend-build stuff
build: frontend-build build-backend stuff
@echo "→ Build successful. Current version: $(VERSION)"
# Stuff static assets into the binary using stuffbin.

View File

@@ -7,7 +7,7 @@ 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/).
![Screenshot_20250220_231723](https://github.com/user-attachments/assets/55e0ec68-b624-4442-8387-6157742da253)
![Screenshot_20250220_231723](https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-HvmxvOkalQSLp4qVezdTXaCd3dB4Rm.png)
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
@@ -47,8 +47,9 @@ And more checkout - [libredesk.io](https://libredesk.io)
The latest image is available on DockerHub at [`libredesk/libredesk:latest`](https://hub.docker.com/r/libredesk/libredesk/tags?page=1&ordering=last_updated&name=latest)
```shell
# Download the compose file to the current directory.
curl -LO https://github.com/abhinavxd/libredesk/raw/master/docker-compose.yml
# Download the compose file and 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
@@ -73,7 +74,7 @@ __________________
- 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.app/docs/installation)
See [installation docs](https://libredesk.io/docs/installation)
__________________

2
VERSION Normal file
View File

@@ -0,0 +1,2 @@
$Format:%h$
$Format:%D$

View File

@@ -1,6 +1,14 @@
package main
import "github.com/zerodha/fastglue"
import (
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/zerodha/fastglue"
)
type providerUpdateReq struct {
Provider string `json:"provider"`
APIKey string `json:"api_key"`
}
// handleAICompletion handles AI completion requests
func handleAICompletion(r *fastglue.Request) error {
@@ -27,3 +35,18 @@ func handleGetAIPrompts(r *fastglue.Request) error {
}
return r.SendEnvelope(resp)
}
// handleUpdateAIProvider updates the AI provider
func handleUpdateAIProvider(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req providerUpdateReq
)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Error unmarshalling request", nil))
}
if err := app.ai.UpdateProvider(req.Provider, req.APIKey); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Provider updated successfully")
}

View File

@@ -75,7 +75,7 @@ func handleOIDCCallback(r *fastglue.Request) error {
}
// Lookup the user by email and set the session.
user, err := app.user.GetByEmail(claims.Email)
user, err := app.user.GetAgentByEmail(claims.Email)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"strconv"
"strings"
"time"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
@@ -42,7 +43,6 @@ func handleGetAllConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -79,7 +79,6 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -116,7 +115,6 @@ func handleGetUnassignedConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -153,7 +151,7 @@ func handleGetViewConversations(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You don't have access to this view.", nil, envelope.PermissionError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -195,7 +193,6 @@ func handleGetViewConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -248,7 +245,6 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -268,7 +264,7 @@ func handleGetConversation(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -284,7 +280,6 @@ func handleGetConversation(r *fastglue.Request) error {
prev, _ := app.conversation.GetContactConversations(conv.ContactID)
conv.PreviousConversations = filterCurrentConv(prev, conv.UUID)
conv.ID = 0
return r.SendEnvelope(conv)
}
@@ -295,7 +290,7 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -316,7 +311,7 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -343,7 +338,7 @@ func handleUpdateUserAssignee(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `assignee_id`", nil, envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -375,7 +370,7 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -426,7 +421,7 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -471,7 +466,7 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
}
// Enforce conversation access.
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -528,7 +523,7 @@ func handleUpdateConversationtags(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -607,7 +602,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -628,7 +623,7 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -651,3 +646,99 @@ func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conv
}
return []cmodels.Conversation{}
}
// handleCreateConversation creates a new conversation and sends a message to it.
func handleCreateConversation(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
inboxID = r.RequestCtx.PostArgs().GetUintOrZero("inbox_id")
assignedAgentID = r.RequestCtx.PostArgs().GetUintOrZero("agent_id")
assignedTeamID = r.RequestCtx.PostArgs().GetUintOrZero("team_id")
email = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("contact_email")))
firstName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("first_name")))
lastName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("last_name")))
subject = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("subject")))
content = string(r.RequestCtx.PostArgs().Peek("content"))
)
// Validate required fields
if inboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "inbox_id is required", nil, envelope.InputError)
}
if subject == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "subject is required", nil, envelope.InputError)
}
if content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "content is required", nil, envelope.InputError)
}
if email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Contact email is required", nil, envelope.InputError)
}
if firstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "First name is required when creating a new contact", nil, envelope.InputError)
}
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check if inbox exists and is enabled.
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "The chosen inbox is disabled", nil, envelope.InputError)
}
// Find or create contact.
contact := umodels.User{
Email: null.StringFrom(email),
SourceChannelID: null.StringFrom(email),
FirstName: firstName,
LastName: lastName,
InboxID: inboxID,
}
if err := app.user.CreateContact(&contact); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error creating contact", nil))
}
// Create conversation
conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID,
contact.ContactChannelID,
inboxID,
"", /** last_message **/
time.Now(),
subject,
true, /** append reference number to subject **/
)
if err != nil {
app.lo.Error("error creating conversation", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error creating conversation", nil))
}
// Send reply to the created conversation.
if err := app.conversation.SendReply(nil /**media**/, inboxID, auser.ID, conversationUUID, content, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error sending message", nil))
}
// Assign the conversation to the agent or team.
if assignedAgentID > 0 {
app.conversation.UpdateConversationUserAssignee(conversationUUID, assignedAgentID, user)
}
if assignedTeamID > 0 {
app.conversation.UpdateConversationTeamAssignee(conversationUUID, assignedTeamID, user)
}
// Send the created conversation back to the client.
conversation, err := app.conversation.GetConversation(conversationID, "")
if err != nil {
app.lo.Error("error fetching created conversation", "error", err)
}
return r.SendEnvelope(conversation)
}

View File

@@ -63,10 +63,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write"))
g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages:write"))
g.POST("/api/v1/conversations", perm(handleCreateConversation, "conversations:write"))
// Search.
g.GET("/api/v1/conversations/search", perm(handleSearchConversations, "conversations:read"))
g.GET("/api/v1/messages/search", perm(handleSearchMessages, "messages:read"))
g.GET("/api/v1/contacts/search", perm(handleSearchContacts, "conversations:write"))
// Views.
g.GET("/api/v1/views/me", perm(handleGetUserViews, "view:manage"))
@@ -99,6 +101,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/users/me", auth(handleGetCurrentUser))
g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams))
g.PUT("/api/v1/users/me/availability", auth(handleUpdateUserAvailability))
g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar))
g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
g.GET("/api/v1/users", perm(handleGetUsers, "users:manage"))
@@ -173,6 +176,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// AI completion.
g.GET("/api/v1/ai/prompts", auth(handleGetAIPrompts))
g.POST("/api/v1/ai/completion", auth(handleAICompletion))
g.PUT("/api/v1/ai/provider", perm(handleUpdateAIProvider, "ai:manage"))
// WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error {

View File

@@ -308,6 +308,11 @@ func initCSAT(db *sqlx.DB) *csat.Manager {
return m
}
// initWS inits websocket hub.
func initWS(user *user.Manager) *ws.Hub {
return ws.NewHub(user)
}
// initTemplates inits template manager.
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager {
var (
@@ -549,7 +554,7 @@ func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.
return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err)
}
log.Printf("`%s` inbox successfully initialized. %d SMTP servers. %d IMAP clients.", inboxRecord.Name, len(config.SMTP), len(config.IMAP))
log.Printf("`%s` inbox successfully initialized", inboxRecord.Name)
return inbox, nil
}

View File

@@ -9,10 +9,10 @@ import (
"time"
"github.com/abhinavxd/libredesk/internal/colorlog"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/user"
"github.com/jmoiron/sqlx"
"github.com/knadh/stuffbin"
"github.com/lib/pq"
)
// Install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed.
@@ -24,7 +24,7 @@ func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem, idempoten
}
// Make sure the system user password is strong enough.
password := strings.TrimSpace(os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD"))
password := os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD")
if password != "" && !user.IsStrongSystemUserPassword(password) && !schemaInstalled {
log.Fatalf("system user password is not strong, %s", user.SystemUserPasswordHint)
}
@@ -76,7 +76,7 @@ func setSystemUserPass(ctx context.Context, db *sqlx.DB) {
// checkSchema verifies if the DB schema is already installed by querying a table.
func checkSchema(db *sqlx.DB) (bool, error) {
if _, err := db.Exec(`SELECT * FROM settings LIMIT 1`); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "42P01" {
if dbutil.IsTableNotExistError(err) {
return false, nil
}
return false, err

View File

@@ -3,6 +3,7 @@ package main
import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -11,14 +12,24 @@ import (
func handleLogin(r *fastglue.Request) error {
var (
app = r.Context.(*App)
p = r.RequestCtx.PostArgs()
email = string(p.Peek("email"))
password = p.Peek("password")
email = string(r.RequestCtx.PostArgs().Peek("email"))
password = r.RequestCtx.PostArgs().Peek("password")
)
user, err := app.user.VerifyPassword(email, password)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !user.Enabled {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Your account is disabled, please contact administrator", nil))
}
// Set user availability status to online.
if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
return sendErrorEnvelope(r, err)
}
user.AvailabilityStatus = umodels.Online
if err := app.auth.SaveSession(amodels.User{
ID: user.ID,
Email: user.Email.String,

View File

@@ -145,7 +145,7 @@ func handleApplyMacro(r *fastglue.Request) error {
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
incomingActions = []autoModels.RuleAction{}
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -239,7 +239,7 @@ func setDisplayValues(app *App, actions []autoModels.RuleAction) error {
return t.Name, nil
},
autoModels.ActionAssignUser: func(id int) (string, error) {
u, err := app.user.Get(id)
u, err := app.user.GetAgent(id)
if err != nil {
app.lo.Warn("user not found for macro action", "user_id", id)
return "", err

View File

@@ -6,8 +6,10 @@ import (
"log"
"os"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/abhinavxd/libredesk/internal/ai"
auth_ "github.com/abhinavxd/libredesk/internal/auth"
@@ -34,7 +36,6 @@ import (
"github.com/abhinavxd/libredesk/internal/team"
"github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/user"
"github.com/abhinavxd/libredesk/internal/ws"
"github.com/knadh/go-i18n"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
@@ -50,7 +51,8 @@ var (
frontendDir = "frontend/dist"
// Injected at build time.
buildString = ""
buildString string
versionString string
)
// App is the global app context which is passed and injected in the http handlers.
@@ -82,6 +84,10 @@ type App struct {
ai *ai.Manager
search *search.Manager
notifier *notifier.Service
// Global state that stores data on an available app update.
update *AppUpdate
sync.Mutex
}
func main() {
@@ -99,9 +105,8 @@ func main() {
}
// Build string injected at build time.
if buildString != "" {
colorlog.Green("Build: %s", buildString)
}
colorlog.Green("Build: %s", buildString)
colorlog.Green("Version: %s", versionString)
// Load the config files into Koanf.
initConfig(ko)
@@ -136,10 +141,13 @@ func main() {
// Upgrade.
if ko.Bool("upgrade") {
log.Println("no upgrades available")
upgrade(db, fs, !ko.Bool("yes"))
os.Exit(0)
}
// Check for pending upgrade.
checkPendingUpgrade(db)
// Load app settings from DB into the Koanf instance.
settings := initSettings(db)
loadSettings(settings)
@@ -153,7 +161,6 @@ func main() {
messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
lo = initLogger(appName)
wsHub = ws.NewHub()
rdb = initRedis()
constants = initConstants()
i18n = initI18n(fs)
@@ -168,6 +175,7 @@ func main() {
team = initTeam(db)
businessHours = initBusinessHours(db)
user = initUser(i18n, db)
wsHub = initWS(user)
notifier = initNotifier(user)
automation = initAutomationEngine(db)
sla = initSLA(db, team, settings, businessHours)
@@ -184,6 +192,7 @@ func main() {
go notifier.Run(ctx)
go sla.Run(ctx, slaEvaluationInterval)
go media.DeleteUnlinkedMedia(ctx)
go user.MonitorAgentAvailability(ctx)
var app = &App{
lo: lo,
@@ -226,7 +235,7 @@ func main() {
WriteTimeout: ko.MustDuration("app.server.write_timeout"),
MaxRequestBodySize: ko.MustInt("app.server.max_body_size"),
MaxKeepaliveDuration: ko.MustDuration("app.server.keepalive_timeout"),
ReadBufferSize: ko.MustInt("app.server.max_body_size"),
ReadBufferSize: ko.Int("app.server.read_buffer_size"),
}
go func() {
@@ -239,6 +248,11 @@ func main() {
}
}()
// Start the app update checker.
if ko.Bool("app.check_updates") {
go checkUpdates(versionString, time.Hour*1, app)
}
// Wait for shutdown signal.
<-ctx.Done()
colorlog.Red("Shutting down HTTP server...")

View File

@@ -150,7 +150,7 @@ func handleServeMedia(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -30,7 +30,7 @@ func handleGetMessages(r *fastglue.Request) error {
total = 0
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -70,7 +70,7 @@ func handleGetMessage(r *fastglue.Request) error {
cuuid = r.RequestCtx.UserValue("cuuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -105,7 +105,7 @@ func handleRetryMessage(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -133,13 +133,13 @@ func handleSendMessage(r *fastglue.Request) error {
req = messageReq{}
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check permission
_, err = enforceConversationAccess(app, cuuid, user)
conv, err := enforceConversationAccess(app, cuuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -163,7 +163,7 @@ func handleSendMessage(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
} else {
if err := app.conversation.SendReply(media, user.ID, cuuid, req.Message, req.CC, req.BCC, map[string]interface{}{}); err != nil {
if err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.CC, req.BCC, map[string]any{} /**meta**/); err != nil {
return sendErrorEnvelope(r, err)
}
// Evaluate automation rules.

View File

@@ -8,6 +8,7 @@ import (
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
"github.com/zerodha/simplesessions/v3"
)
// tryAuth is a middleware that attempts to authenticate the user and add them to the context
@@ -23,7 +24,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
// Try to get user.
user, err := app.user.Get(userSession.ID)
user, err := app.user.GetAgent(userSession.ID)
if err != nil {
return handler(r)
}
@@ -43,9 +44,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
// auth makes sure the user is logged in.
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
var app = r.Context.(*App)
// Validate session and fetch user.
userSession, err := app.auth.ValidateSession(r)
@@ -55,7 +54,7 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
// Set user in the request context.
user, err := app.user.Get(userSession.ID)
user, err := app.user.GetAgent(userSession.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -92,11 +91,19 @@ func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequest
}
// Get user from DB.
user, err := app.user.Get(sessUser.ID)
user, err := app.user.GetAgent(sessUser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Destroy session if user is disabled.
if !user.Enabled {
if err := app.auth.DestroySession(r); err != nil {
app.lo.Error("error destroying session", "error", err)
}
return r.SendErrorEnvelope(http.StatusUnauthorized, "User account disabled", nil, envelope.PermissionError)
}
// Split the permission string into object and action and enforce it.
parts := strings.Split(perm, ":")
if len(parts) != 2 {
@@ -131,9 +138,17 @@ func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
// Validate session.
user, err := app.auth.ValidateSession(r)
if err != nil {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
// Session is not valid, destroy it and redirect to login.
if err != simplesessions.ErrInvalidSession {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "Error validating session", nil, envelope.PermissionError)
}
if err := app.auth.DestroySession(r); err != nil {
app.lo.Error("error destroying session", "error", err)
}
}
// User is authenticated.
if user.ID > 0 {
return handler(r)
}
@@ -142,7 +157,7 @@ func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
if len(nextURI) == 0 {
nextURI = r.RequestCtx.RequestURI()
}
return r.RedirectURI("/", fasthttp.StatusFound, map[string]interface{}{
return r.RedirectURI("/", fasthttp.StatusFound, map[string]any{
"next": string(nextURI),
}, "")
}

View File

@@ -2,9 +2,11 @@ package main
import (
"strconv"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/oidc/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -26,6 +28,10 @@ func handleGetAllOIDC(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
// Replace secrets with dummy values.
for i := range out {
out[i].ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
}
return r.SendEnvelope(out)
}

View File

@@ -44,3 +44,19 @@ func handleSearchMessages(r *fastglue.Request) error {
}
return r.SendEnvelope(messages)
}
// handleSearchContacts searches contacts based on the query.
func handleSearchContacts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
q = string(r.RequestCtx.QueryArgs().Peek("query"))
)
if len(q) < minSearchQueryLength {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Query length should be at least 3 characters", nil))
}
contacts, err := app.search.Contacts(q)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(contacts)
}

View File

@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"net/mail"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
@@ -20,7 +21,17 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(out)
// Unmarshal to set the app.update to the settings, so the frontend can show that an update is available.
var settings map[string]interface{}
if err := json.Unmarshal(out, &settings); err != nil {
app.lo.Error("error unmarshalling settings", "err", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error fetching settings", nil))
}
// Set the app.update to the settings, adding `app` prefix to the key to match the settings structure in db.
settings["app.update"] = app.update
// Set app version.
settings["app.version"] = versionString
return r.SendEnvelope(settings)
}
// handleUpdateGeneralSettings updates general settings.
@@ -90,6 +101,11 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error updating settings", nil))
}
// Make sure it's a valid from email address.
if _, err := mail.ParseAddress(req.EmailAddress); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid from email address format", nil, envelope.InputError)
}
if req.Password == "" {
req.Password = cur.Password
}
@@ -97,5 +113,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
if err := app.setting.Update(req); err != nil {
return sendErrorEnvelope(r, err)
}
// No reload implemented, so user has to restart the app.
return r.SendEnvelope("Settings updated successfully, Please restart the app for changes to take effect.")
}

98
cmd/updates.go Normal file
View File

@@ -0,0 +1,98 @@
// Copyright Kailash Nadh (https://github.com/knadh/listmonk)
// SPDX-License-Identifier: AGPL-3.0
// Adapted from listmonk for Libredesk.
package main
import (
"encoding/json"
"io"
"net/http"
"regexp"
"time"
"golang.org/x/mod/semver"
)
const updateCheckURL = "https://updates.libredesk.io/updates.json"
type AppUpdate struct {
Update struct {
ReleaseVersion string `json:"release_version"`
ReleaseDate string `json:"release_date"`
URL string `json:"url"`
Description string `json:"description"`
// This is computed and set locally based on the local version.
IsNew bool `json:"is_new"`
} `json:"update"`
Messages []struct {
Date string `json:"date"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
Priority string `json:"priority"`
} `json:"messages"`
}
var reSemver = regexp.MustCompile(`-(.*)`)
// checkUpdates is a blocking function that checks for updates to the app
// at the given intervals. On detecting a new update (new semver), it
// sets the global update status that renders a prompt on the UI.
func checkUpdates(curVersion string, interval time.Duration, app *App) {
// Strip -* suffix.
curVersion = reSemver.ReplaceAllString(curVersion, "")
fnCheck := func() {
resp, err := http.Get(updateCheckURL)
if err != nil {
app.lo.Error("error checking for app updates", "err", err)
return
}
if resp.StatusCode != 200 {
app.lo.Error("non-ok status code checking for app updates", "status", resp.StatusCode)
return
}
b, err := io.ReadAll(resp.Body)
if err != nil {
app.lo.Error("error reading response body", "err", err)
return
}
resp.Body.Close()
var out AppUpdate
if err := json.Unmarshal(b, &out); err != nil {
app.lo.Error("error unmarshalling response body", "err", err)
return
}
// There is an update. Set it on the global app state.
if semver.IsValid(out.Update.ReleaseVersion) {
v := reSemver.ReplaceAllString(out.Update.ReleaseVersion, "")
if semver.Compare(v, curVersion) > 0 {
out.Update.IsNew = true
app.lo.Info("new update available", "version", out.Update.ReleaseVersion)
}
}
app.Lock()
app.update = &out
app.Unlock()
}
// Give a 5 minute buffer after app start in case the admin wants to disable
// update checks entirely and not make a request to upstream.
time.Sleep(time.Minute * 5)
fnCheck()
// Thereafter, check every $interval.
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
fnCheck()
}
}

149
cmd/upgrade.go Normal file
View File

@@ -0,0 +1,149 @@
// Copyright Kailash Nadh (https://github.com/knadh/listmonk)
// SPDX-License-Identifier: AGPL-3.0
// Adapted from listmonk for Libredesk.
package main
import (
"fmt"
"log"
"strings"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/migrations"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
"golang.org/x/mod/semver"
)
// migFunc represents a migration function for a particular version.
// fn (generally) executes database migrations and additionally
// takes the filesystem and config objects in case there are additional bits
// of logic to be performed before executing upgrades. fn is idempotent.
type migFunc struct {
version string
fn func(*sqlx.DB, stuffbin.FileSystem, *koanf.Koanf) error
}
// migList is the list of available migList ordered by the semver.
// Each migration is a Go file in internal/migrations named after the semver.
// The functions are named as: v0.7.0 => migrations.V0_7_0() and are idempotent.
var migList = []migFunc{
{"v0.3.0", migrations.V0_3_0},
{"v0.4.0", migrations.V0_4_0},
}
// upgrade upgrades the database to the current version by running SQL migration files
// for all version from the last known version to the current one.
func upgrade(db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) {
if prompt {
var ok string
fmt.Printf("** IMPORTANT: Take a backup of the database before upgrading.\n")
fmt.Print("continue (y/n)? ")
if _, err := fmt.Scanf("%s", &ok); err != nil {
log.Fatalf("error reading value from terminal: %v", err)
}
if !strings.EqualFold(ok, "y") {
fmt.Println("upgrade cancelled")
return
}
}
_, toRun, err := getPendingMigrations(db)
if err != nil {
log.Fatalf("error checking migrations: %v", err)
}
// No migrations to run.
if len(toRun) == 0 {
log.Printf("no upgrades to run. Database is up to date.")
return
}
// Execute migrations in succession.
for _, m := range toRun {
log.Printf("running migration %s", m.version)
if err := m.fn(db, fs, ko); err != nil {
log.Fatalf("error running migration %s: %v", m.version, err)
}
// Record the migration version in the settings table. There was no
// settings table until v0.7.0, so ignore the no-table errors.
if err := recordMigrationVersion(m.version, db); err != nil {
if dbutil.IsTableNotExistError(err) {
continue
}
log.Fatalf("error recording migration version %s: %v", m.version, err)
}
}
log.Printf("upgrade complete")
}
// getPendingMigrations gets the pending migrations by comparing the last
// recorded migration in the DB against all migrations listed in `migrations`.
func getPendingMigrations(db *sqlx.DB) (string, []migFunc, error) {
lastVer, err := getLastMigrationVersion(db)
if err != nil {
return "", nil, err
}
// Iterate through the migration versions and get everything above the last
// upgraded semver.
var toRun []migFunc
for i, m := range migList {
if semver.Compare(m.version, lastVer) > 0 {
toRun = migList[i:]
break
}
}
return lastVer, toRun, nil
}
// getLastMigrationVersion returns the last migration semver recorded in the DB.
// If there isn't any, `v0.0.0` is returned.
func getLastMigrationVersion(db *sqlx.DB) (string, error) {
var v string
if err := db.Get(&v, `
SELECT COALESCE(
(SELECT value->>-1 FROM settings WHERE key='migrations'),
'v0.0.0')`); err != nil {
if dbutil.IsTableNotExistError(err) {
return "v0.0.0", nil
}
return v, err
}
return v, nil
}
// recordMigrationVersion inserts the given version (of DB migration) into the
// `migrations` array in the settings table.
func recordMigrationVersion(ver string, db *sqlx.DB) error {
_, err := db.Exec(fmt.Sprintf(`INSERT INTO settings (key, value)
VALUES('migrations', '["%s"]'::JSONB)
ON CONFLICT (key) DO UPDATE SET value = settings.value || EXCLUDED.value`, ver))
return err
}
// checkPendingUpgrade checks if the current database schema matches the expected binary version.
func checkPendingUpgrade(db *sqlx.DB) {
lastVer, toRun, err := getPendingMigrations(db)
if err != nil {
log.Fatalf("error checking migrations: %v", err)
}
// No migrations to run.
if len(toRun) == 0 {
return
}
var vers []string
for _, m := range toRun {
vers = append(vers, m.version)
}
log.Fatalf(`there are %d pending database upgrade(s): %v. The last upgrade was %s. Backup the database and run libredesk --upgrade`,
len(toRun), vers, lastVer)
}

View File

@@ -22,7 +22,7 @@ import (
)
const (
maxAvatarSizeMB = 5
maxAvatarSizeMB = 20
)
// handleGetUsers returns all users.
@@ -39,9 +39,7 @@ func handleGetUsers(r *fastglue.Request) error {
// handleGetUsersCompact returns all users in a compact format.
func handleGetUsersCompact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
var app = r.Context.(*App)
agents, err := app.user.GetAllCompact()
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
@@ -59,20 +57,33 @@ func handleGetUser(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid user `id`.", nil, envelope.InputError)
}
user, err := app.user.Get(id)
user, err := app.user.GetAgent(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(user)
}
// handleUpdateUserAvailability updates the current user availability.
func handleUpdateUserAvailability(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
status = string(r.RequestCtx.PostArgs().Peek("status"))
)
if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("User availability updated successfully.")
}
// handleGetCurrentUserTeams returns the teams of a user.
func handleGetCurrentUserTeams(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -90,13 +101,7 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Get current user.
currentUser, err := app.user.Get(user.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -154,8 +159,8 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
}
// Delete current avatar.
if currentUser.AvatarURL.Valid {
fileName := filepath.Base(currentUser.AvatarURL.String)
if user.AvatarURL.Valid {
fileName := filepath.Base(user.AvatarURL.String)
app.media.Delete(fileName)
}
@@ -212,9 +217,9 @@ func handleCreateUser(r *fastglue.Request) error {
}
// Render template and send email.
content, err := app.tmpl.RenderTemplate(tmpl.TmplWelcome, map[string]interface{}{
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
"ResetToken": resetToken,
"Email": user.Email,
"Email": user.Email.String,
})
if err != nil {
app.lo.Error("error rendering template", "error", err)
@@ -228,7 +233,7 @@ func handleCreateUser(r *fastglue.Request) error {
Provider: notifier.ProviderEmail,
}); err != nil {
app.lo.Error("error sending notification message", "error", err)
return r.SendEnvelope("User created successfully, but error sending welcome email.")
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "User created successfully, but could not send welcome email.", nil))
}
}
return r.SendEnvelope("User created successfully.")
@@ -305,7 +310,7 @@ func handleGetCurrentUser(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
u, err := app.user.Get(auser.ID)
u, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -320,14 +325,14 @@ func handleDeleteAvatar(r *fastglue.Request) error {
)
// Get user
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Valid str?
if user.AvatarURL.String == "" {
return r.SendEnvelope(true)
return r.SendEnvelope("Avatar deleted successfully.")
}
fileName := filepath.Base(user.AvatarURL.String)
@@ -336,8 +341,8 @@ func handleDeleteAvatar(r *fastglue.Request) error {
if err := app.media.Delete(fileName); err != nil {
return sendErrorEnvelope(r, err)
}
err = app.user.UpdateAvatar(user.ID, "")
if err != nil {
if err = app.user.UpdateAvatar(user.ID, ""); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Avatar deleted successfully.")
@@ -352,16 +357,17 @@ func handleResetPassword(r *fastglue.Request) error {
email = string(p.Peek("email"))
)
if ok && auser.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "User is already logged in", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "User is already logged in, Please logout to reset password.", nil, envelope.InputError)
}
if email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
}
user, err := app.user.GetByEmail(email)
user, err := app.user.GetAgentByEmail(email)
if err != nil {
return sendErrorEnvelope(r, err)
// Send 200 even if user not found, to prevent email enumeration.
return r.SendEnvelope("Reset password email sent successfully.")
}
token, err := app.user.SetResetPasswordToken(user.ID)
@@ -370,10 +376,9 @@ func handleResetPassword(r *fastglue.Request) error {
}
// Send email.
content, err := app.tmpl.RenderTemplate(tmpl.TmplResetPassword,
map[string]string{
"ResetToken": token,
})
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplResetPassword, map[string]string{
"ResetToken": token,
})
if err != nil {
app.lo.Error("error rendering template", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error rendering template", nil, envelope.GeneralError)
@@ -385,8 +390,8 @@ func handleResetPassword(r *fastglue.Request) error {
Content: content,
Provider: notifier.ProviderEmail,
}); err != nil {
app.lo.Error("error sending notification message", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error sending notification message", nil, envelope.GeneralError)
app.lo.Error("error sending password reset email", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error sending password reset email", nil, envelope.GeneralError)
}
return r.SendEnvelope("Reset password email sent successfully.")

View File

@@ -16,7 +16,7 @@ func handleGetUserViews(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -37,7 +37,7 @@ func handleCreateUserView(r *fastglue.Request) error {
if err := r.Decode(&view, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -46,7 +46,7 @@ func handleCreateUserView(r *fastglue.Request) error {
}
if string(view.Filters) == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Filter`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Please provide at least one filter", nil, envelope.InputError)
}
if err := app.view.Create(view.Name, view.Filters, user.ID); err != nil {
@@ -71,7 +71,7 @@ func handleDeleteUserView(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `ID`", nil, envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -109,7 +109,7 @@ func handleUpdateUserView(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -2,6 +2,7 @@
[app]
log_level = "debug"
env = "dev"
check_updates = true
# HTTP server.
[app.server]
@@ -10,6 +11,7 @@ socket = ""
read_timeout = "5s"
write_timeout = "5s"
max_body_size = 500000000
read_buffer_size = 4096
keepalive_timeout = "10s"
# File upload provider to use, either `fs` or `s3`.
@@ -35,8 +37,9 @@ expiry = "6h"
# If using docker compose, use the service name as the host. e.g. db
host = "127.0.0.1"
port = 5432
user = "postgres"
password = "postgres"
# Update the following values with your database credentials.
user = "libredesk"
password = "libredesk"
database = "libredesk"
ssl_mode = "disable"
max_open = 30
@@ -45,7 +48,7 @@ max_lifetime = "300s"
# Redis.
[redis]
# If using docker compose, use the service name as the host. e.g. redis
# If using docker compose, use the service name as the host. e.g. redis:6379
address = "127.0.0.1:6379"
password = ""
db = 0
@@ -71,4 +74,4 @@ autoassign_interval = "5m"
unsnooze_interval = "5m"
[sla]
evaluation_interval = "5m"
evaluation_interval = "5m"

View File

@@ -1,7 +1,7 @@
services:
# Libredesk app
app:
image: libredesk:latest
image: libredesk/libredesk:latest
container_name: libredesk_app
restart: unless-stopped
ports:

View File

@@ -21,7 +21,7 @@ git clone https://github.com/abhinavxd/libredesk.git
### Running the Dev Environment
1. Run `make run` to start the libredesk backend dev server on `:9000`.
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.
---

View File

@@ -5,7 +5,7 @@ Libredesk is an open source, self-hosted customer support desk. Single binary ap
<div style="border: 1px solid #ccc; padding: 1px; border-radius:5px; box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1); background-color: #fff;">
<a href="https://libredesk.io">
<img src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/Screenshot_20250220_231723-VxuEQgEiFfI9xhzJDOvgMK0yJ0TwR3.png" alt="libredesk screenshot" style="display: block; margin: 0 auto;">
<img src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-HvmxvOkalQSLp4qVezdTXaCd3dB4Rm.png" alt="libredesk screenshot" style="display: block; margin: 0 auto;">
</a>
</div>

View File

@@ -15,13 +15,14 @@ Libredesk is a single binary application that requires postgres and redis to run
## Docker
The latest image is available on DockerHub at `libredesk/llibredeskistmonk:latest`
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/master/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
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 to the current directory.
curl -LO https://github.com/abhinavxd/libredesk/raw/master/docker-compose.yml
# 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
@@ -40,7 +41,7 @@ Go to `http://localhost:9000` and login with the email `System` and the password
## Compiling from source
To compile the latest unreleased version (`master` branch):
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`

View File

@@ -1,6 +1,6 @@
{
"name": "libredesk",
"version": "0.0.0",
"version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
@@ -18,6 +18,7 @@
"@formkit/auto-animate": "^0.8.2",
"@internationalized/date": "^3.5.5",
"@radix-icons/vue": "^1.0.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/vue-table": "^8.19.2",
"@tiptap/extension-image": "^2.5.9",
"@tiptap/extension-link": "^2.9.1",
@@ -28,7 +29,6 @@
"@unovis/ts": "^1.4.4",
"@unovis/vue": "^1.4.4",
"@vee-validate/zod": "^4.13.2",
"@vueup/vue-quill": "^1.2.0",
"@vueuse/core": "^12.4.0",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.0",
@@ -43,6 +43,7 @@
"tailwind-merge": "^2.3.0",
"vee-validate": "^4.13.2",
"vue": "^3.4.37",
"vue-dompurify-html": "^5.2.0",
"vue-i18n": "9",
"vue-letter": "^0.2.0",
"vue-picture-cropper": "^0.7.0",

287
frontend/pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
'@radix-icons/vue':
specifier: ^1.0.0
version: 1.0.0(vue@3.5.13(typescript@5.7.3))
'@tailwindcss/typography':
specifier: ^0.5.16
version: 0.5.16(tailwindcss@3.4.17)
'@tanstack/vue-table':
specifier: ^8.19.2
version: 8.20.5(vue@3.5.13(typescript@5.7.3))
@@ -47,9 +50,6 @@ importers:
'@vee-validate/zod':
specifier: ^4.13.2
version: 4.15.0(vue@3.5.13(typescript@5.7.3))(zod@3.24.1)
'@vueup/vue-quill':
specifier: ^1.2.0
version: 1.2.0(vue@3.5.13(typescript@5.7.3))
'@vueuse/core':
specifier: ^12.4.0
version: 12.4.0(typescript@5.7.3)
@@ -92,6 +92,9 @@ importers:
vue:
specifier: ^3.4.37
version: 3.5.13(typescript@5.7.3)
vue-dompurify-html:
specifier: ^5.2.0
version: 5.2.0(vue@3.5.13(typescript@5.7.3))
vue-i18n:
specifier: '9'
version: 9.14.2(vue@3.5.13(typescript@5.7.3))
@@ -737,6 +740,11 @@ packages:
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@tailwindcss/typography@0.5.16':
resolution: {integrity: sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@tanstack/table-core@8.20.5':
resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
engines: {node: '>=12'}
@@ -815,8 +823,8 @@ packages:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-hard-break@2.11.2':
resolution: {integrity: sha512-FNcXemfuwkiP4drZ9m90BC6GD4nyikfYHYEUyYuVd74Mm6w5vXpueWXus3mUcdT78xTs1XpQVibDorilLu7X8w==}
'@tiptap/extension-hard-break@2.11.5':
resolution: {integrity: sha512-q9doeN+Yg9F5QNTG8pZGYfNye3tmntOwch683v0CCVCI4ldKaLZ0jG3NbBTq+mosHYdgOH2rNbIORlRRsQ+iYQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
@@ -1076,6 +1084,9 @@ packages:
'@types/topojson@3.2.6':
resolution: {integrity: sha512-ppfdlxjxofWJ66XdLgIlER/85RvpGyfOf8jrWf+3kVIjEatFxEZYD/Ea83jO672Xu1HRzd/ghwlbcZIUNHTskw==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
@@ -1159,11 +1170,6 @@ packages:
'@vue/shared@3.5.13':
resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==}
'@vueup/vue-quill@1.2.0':
resolution: {integrity: sha512-kd5QPSHMDpycklojPXno2Kw2JSiKMYduKYQckTm1RJoVDA557MnyUXgcuuDpry4HY/Rny9nGNcK+m3AHk94wag==}
peerDependencies:
vue: ^3.2.41
'@vueuse/core@10.11.1':
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
@@ -1346,10 +1352,6 @@ packages:
resolution: {integrity: sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==}
engines: {node: '>= 0.4'}
call-bind@1.0.8:
resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
engines: {node: '>= 0.4'}
call-bound@1.0.3:
resolution: {integrity: sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==}
engines: {node: '>= 0.4'}
@@ -1407,10 +1409,6 @@ packages:
resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
engines: {node: '>=8'}
clone@2.1.2:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
engines: {node: '>=0.8'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -1678,21 +1676,9 @@ packages:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
deep-equal@1.1.2:
resolution: {integrity: sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==}
engines: {node: '>= 0.4'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
define-properties@1.2.1:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
@@ -1718,6 +1704,9 @@ packages:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'}
dompurify@3.2.4:
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -1863,9 +1852,6 @@ packages:
eventemitter2@6.4.7:
resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==}
eventemitter3@2.0.3:
resolution: {integrity: sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==}
execa@4.1.0:
resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==}
engines: {node: '>=10'}
@@ -1893,12 +1879,6 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-diff@1.1.2:
resolution: {integrity: sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==}
fast-diff@1.2.0:
resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==}
fast-diff@1.3.0:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
@@ -1985,9 +1965,6 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
geojson-vt@3.2.1:
resolution: {integrity: sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==}
@@ -2066,17 +2043,10 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-property-descriptors@1.0.2:
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@@ -2146,10 +2116,6 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
is-arguments@1.2.0:
resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==}
engines: {node: '>= 0.4'}
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
@@ -2161,10 +2127,6 @@ packages:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
is-date-object@1.1.0:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -2189,10 +2151,6 @@ packages:
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
engines: {node: '>=8'}
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
@@ -2317,11 +2275,11 @@ packages:
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
lodash.castarray@4.4.0:
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -2468,14 +2426,6 @@ packages:
resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
engines: {node: '>= 0.4'}
object-is@1.1.6:
resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==}
engines: {node: '>= 0.4'}
object-keys@1.1.1:
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
engines: {node: '>= 0.4'}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -2508,9 +2458,6 @@ packages:
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
parchment@1.1.4:
resolution: {integrity: sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -2615,6 +2562,10 @@ packages:
peerDependencies:
postcss: ^8.2.14
postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
postcss-selector-parser@6.1.2:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
engines: {node: '>=4'}
@@ -2747,16 +2698,6 @@ packages:
quickselect@2.0.0:
resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==}
quill-delta@3.6.3:
resolution: {integrity: sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==}
engines: {node: '>=0.10'}
quill-delta@4.2.2:
resolution: {integrity: sha512-qjbn82b/yJzOjstBgkhtBjN2TNK+ZHP/BgUQO+j6bRhWQQdmj2lH6hXG7+nwwLF41Xgn//7/83lxs9n2BkTtTg==}
quill@1.3.7:
resolution: {integrity: sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==}
radix-vue@1.9.12:
resolution: {integrity: sha512-zkr66Jqxbej4+oR6O/pZRzyM/VZi66ndbyIBZQjJKAXa1lIoYReZJse6W1EEDZKXknD7rXhpS+jM9Sr23lIqfg==}
peerDependencies:
@@ -2776,10 +2717,6 @@ packages:
regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
regexp.prototype.flags@1.5.4:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
request-progress@3.0.0:
resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==}
@@ -2850,14 +2787,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
set-function-name@2.0.2:
resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
engines: {node: '>= 0.4'}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -3191,6 +3120,11 @@ packages:
'@vue/composition-api':
optional: true
vue-dompurify-html@5.2.0:
resolution: {integrity: sha512-GX+BStkKEJ8wu/+hU1EK2nu/gzXWhb4XzBu6aowpsuU/3nkvXvZ2jx4nZ9M3jtS/Vu7J7MtFXjc7x3cWQ+zbVQ==}
peerDependencies:
vue: ^3.0.0
vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0}
@@ -3802,6 +3736,14 @@ snapshots:
dependencies:
tslib: 2.8.1
'@tailwindcss/typography@0.5.16(tailwindcss@3.4.17)':
dependencies:
lodash.castarray: 4.4.0
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.17
'@tanstack/table-core@8.20.5': {}
'@tanstack/virtual-core@3.11.2': {}
@@ -3867,7 +3809,7 @@ snapshots:
'@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
'@tiptap/pm': 2.11.2
'@tiptap/extension-hard-break@2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))':
'@tiptap/extension-hard-break@2.11.5(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))':
dependencies:
'@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
@@ -3960,7 +3902,7 @@ snapshots:
'@tiptap/extension-document': 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
'@tiptap/extension-dropcursor': 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
'@tiptap/extension-gapcursor': 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
'@tiptap/extension-hard-break': 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
'@tiptap/extension-hard-break': 2.11.5(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
'@tiptap/extension-heading': 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
'@tiptap/extension-history': 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
'@tiptap/extension-horizontal-rule': 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
@@ -4187,6 +4129,9 @@ snapshots:
'@types/topojson-simplify': 3.0.3
'@types/topojson-specification': 1.0.5
'@types/trusted-types@2.0.7':
optional: true
'@types/web-bluetooth@0.0.20': {}
'@types/yauzl@2.10.3':
@@ -4343,12 +4288,6 @@ snapshots:
'@vue/shared@3.5.13': {}
'@vueup/vue-quill@1.2.0(vue@3.5.13(typescript@5.7.3))':
dependencies:
quill: 1.3.7
quill-delta: 4.2.2
vue: 3.5.13(typescript@5.7.3)
'@vueuse/core@10.11.1(vue@3.5.13(typescript@5.7.3))':
dependencies:
'@types/web-bluetooth': 0.0.20
@@ -4537,13 +4476,6 @@ snapshots:
es-errors: 1.3.0
function-bind: 1.1.2
call-bind@1.0.8:
dependencies:
call-bind-apply-helpers: 1.0.1
es-define-property: 1.0.1
get-intrinsic: 1.2.7
set-function-length: 1.2.2
call-bound@1.0.3:
dependencies:
call-bind-apply-helpers: 1.0.1
@@ -4603,8 +4535,6 @@ snapshots:
slice-ansi: 3.0.0
string-width: 4.2.3
clone@2.1.2: {}
clsx@2.1.1: {}
codeflask@1.4.1:
@@ -4921,29 +4851,8 @@ snapshots:
decode-uri-component@0.2.2:
optional: true
deep-equal@1.1.2:
dependencies:
is-arguments: 1.2.0
is-date-object: 1.1.0
is-regex: 1.2.1
object-is: 1.1.6
object-keys: 1.1.1
regexp.prototype.flags: 1.5.4
deep-is@0.1.4: {}
define-data-property@1.1.4:
dependencies:
es-define-property: 1.0.1
es-errors: 1.3.0
gopd: 1.2.0
define-properties@1.2.1:
dependencies:
define-data-property: 1.1.4
has-property-descriptors: 1.0.2
object-keys: 1.1.1
defu@6.1.4: {}
delaunator@5.0.1:
@@ -4963,6 +4872,10 @@ snapshots:
dependencies:
esutils: 2.0.3
dompurify@3.2.4:
optionalDependencies:
'@types/trusted-types': 2.0.7
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.1
@@ -5157,8 +5070,6 @@ snapshots:
eventemitter2@6.4.7: {}
eventemitter3@2.0.3: {}
execa@4.1.0:
dependencies:
cross-spawn: 7.0.6
@@ -5203,10 +5114,6 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-diff@1.1.2: {}
fast-diff@1.2.0: {}
fast-diff@1.3.0: {}
fast-glob@3.3.3:
@@ -5291,8 +5198,6 @@ snapshots:
function-bind@1.1.2: {}
functions-have-names@1.2.3: {}
geojson-vt@3.2.1: {}
geojson@0.5.0: {}
@@ -5381,16 +5286,8 @@ snapshots:
has-flag@4.0.0: {}
has-property-descriptors@1.0.2:
dependencies:
es-define-property: 1.0.1
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -5443,11 +5340,6 @@ snapshots:
internmap@2.0.3: {}
is-arguments@1.2.0:
dependencies:
call-bound: 1.0.3
has-tostringtag: 1.0.2
is-arrayish@0.2.1: {}
is-binary-path@2.1.0:
@@ -5458,11 +5350,6 @@ snapshots:
dependencies:
hasown: 2.0.2
is-date-object@1.1.0:
dependencies:
call-bound: 1.0.3
has-tostringtag: 1.0.2
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -5480,13 +5367,6 @@ snapshots:
is-path-inside@3.0.3: {}
is-regex@1.2.1:
dependencies:
call-bound: 1.0.3
gopd: 1.2.0
has-tostringtag: 1.0.2
hasown: 2.0.2
is-stream@2.0.1: {}
is-typedarray@1.0.0: {}
@@ -5598,9 +5478,9 @@ snapshots:
lodash-es@4.17.21: {}
lodash.clonedeep@4.5.0: {}
lodash.castarray@4.4.0: {}
lodash.isequal@4.5.0: {}
lodash.isplainobject@4.0.6: {}
lodash.merge@4.6.2: {}
@@ -5744,13 +5624,6 @@ snapshots:
object-inspect@1.13.3: {}
object-is@1.1.6:
dependencies:
call-bind: 1.0.8
define-properties: 1.2.1
object-keys@1.1.1: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -5786,8 +5659,6 @@ snapshots:
package-json-from-dist@1.0.1: {}
parchment@1.1.4: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -5873,6 +5744,11 @@ snapshots:
postcss: 8.4.49
postcss-selector-parser: 6.1.2
postcss-selector-parser@6.0.10:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-selector-parser@6.1.2:
dependencies:
cssesc: 3.0.0
@@ -6032,27 +5908,6 @@ snapshots:
quickselect@2.0.0: {}
quill-delta@3.6.3:
dependencies:
deep-equal: 1.1.2
extend: 3.0.2
fast-diff: 1.1.2
quill-delta@4.2.2:
dependencies:
fast-diff: 1.2.0
lodash.clonedeep: 4.5.0
lodash.isequal: 4.5.0
quill@1.3.7:
dependencies:
clone: 2.1.2
deep-equal: 1.1.2
eventemitter3: 2.0.3
extend: 3.0.2
parchment: 1.1.4
quill-delta: 3.6.3
radix-vue@1.9.12(vue@3.5.13(typescript@5.7.3)):
dependencies:
'@floating-ui/dom': 1.6.13
@@ -6082,15 +5937,6 @@ snapshots:
regenerator-runtime@0.14.1: {}
regexp.prototype.flags@1.5.4:
dependencies:
call-bind: 1.0.8
define-properties: 1.2.1
es-errors: 1.3.0
get-proto: 1.0.1
gopd: 1.2.0
set-function-name: 2.0.2
request-progress@3.0.0:
dependencies:
throttleit: 1.0.1
@@ -6176,22 +6022,6 @@ snapshots:
semver@7.6.3: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
es-errors: 1.3.0
function-bind: 1.1.2
get-intrinsic: 1.2.7
gopd: 1.2.0
has-property-descriptors: 1.0.2
set-function-name@2.0.2:
dependencies:
define-data-property: 1.1.4
es-errors: 1.3.0
functions-have-names: 1.2.3
has-property-descriptors: 1.0.2
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -6530,6 +6360,11 @@ snapshots:
dependencies:
vue: 3.5.13(typescript@5.7.3)
vue-dompurify-html@5.2.0(vue@3.5.13(typescript@5.7.3)):
dependencies:
dompurify: 3.2.4
vue: 3.5.13(typescript@5.7.3)
vue-eslint-parser@9.4.3(eslint@8.57.1):
dependencies:
debug: 4.4.0(supports-color@8.1.1)

View File

@@ -46,9 +46,16 @@
@create-view="openCreateViewForm = true"
@edit-view="editView"
@delete-view="deleteView"
@create-conversation="() => openCreateConversationDialog = true"
>
<div class="flex flex-col h-screen">
<!-- Show app update only in admin routes -->
<AppUpdate v-if="route.path.startsWith('/admin')" />
<!-- Common header for all pages -->
<PageHeader />
<!-- Main content -->
<RouterView class="flex-grow" />
</div>
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
@@ -58,6 +65,9 @@
<!-- Command box -->
<Command />
<!-- Create conversation dialog -->
<CreateConversation v-model="openCreateConversationDialog" />
</template>
<script setup>
@@ -75,12 +85,15 @@ import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla'
import { useMacroStore } from '@/stores/macro'
import { useTagStore } from '@/stores/tag'
import { useIdleDetection } from '@/composables/useIdleDetection'
import PageHeader from './components/layout/PageHeader.vue'
import ViewForm from '@/features/view/ViewForm.vue'
import AppUpdate from '@/components/update/AppUpdate.vue'
import api from '@/api'
import { toast as sooner } from 'vue-sonner'
import Sidebar from '@/components/sidebar/Sidebar.vue'
import Command from '@/features/command/CommandBox.vue'
import CreateConversation from '@/features/conversation/CreateConversation.vue'
import { Inbox, Shield, FileLineChart } from 'lucide-vue-next'
import { useRoute } from 'vue-router'
import {
@@ -109,8 +122,11 @@ const tagStore = useTagStore()
const userViews = ref([])
const view = ref({})
const openCreateViewForm = ref(false)
const openCreateConversationDialog = ref(false)
initWS()
useIdleDetection()
onMounted(() => {
initToaster()
listenViewRefresh()
@@ -119,8 +135,10 @@ onMounted(() => {
// initialize data stores
const initStores = async () => {
if (!userStore.userID) {
await userStore.getCurrentUser()
}
await Promise.allSettled([
userStore.getCurrentUser(),
getUserViews(),
conversationStore.fetchStatuses(),
conversationStore.fetchPriorities(),

View File

@@ -1,7 +1,27 @@
<template>
<RouterView />
<RouterView />
</template>
<script setup>
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
</script>
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { toast as sooner } from 'vue-sonner'
const emitter = useEmitter()
onMounted(() => {
initToaster()
})
const initToaster = () => {
emitter.on(EMITTER_EVENTS.SHOW_TOAST, (message) => {
if (message.variant === 'destructive') {
sooner.error(message.description)
} else {
sooner.success(message.description)
}
})
}
</script>

View File

@@ -35,6 +35,7 @@ http.interceptors.request.use((request) => {
const searchConversations = (params) => http.get('/api/v1/conversations/search', { params })
const searchMessages = (params) => http.get('/api/v1/messages/search', { params })
const searchContacts = (params) => http.get('/api/v1/contacts/search', { params })
const resetPassword = (data) => http.post('/api/v1/users/reset-password', data)
const setPassword = (data) => http.post('/api/v1/users/set-password', data)
const deleteUser = (id) => http.delete(`/api/v1/users/${id}`)
@@ -169,10 +170,12 @@ const updateCurrentUser = (data) =>
const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar')
const getCurrentUser = () => http.get('/api/v1/users/me')
const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams')
const updateCurrentUserAvailability = (data) => http.put('/api/v1/users/me/availability', data)
const getTags = () => http.get('/api/v1/tags')
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
const removeAssignee = (uuid, assignee_type) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`)
const createConversation = (data) => http.post('/api/v1/conversations', data)
const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
@@ -264,6 +267,7 @@ const updateView = (id, data) =>
const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
const getAiPrompts = () => http.get('/api/v1/ai/prompts')
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data)
export default {
login,
@@ -323,12 +327,15 @@ export default {
uploadMedia,
updateAssigneeLastSeen,
updateUser,
updateCurrentUserAvailability,
updateAutomationRule,
updateAutomationRuleWeights,
updateAutomationRulesExecutionMode,
updateAIProvider,
createAutomationRule,
toggleAutomationRule,
deleteAutomationRule,
createConversation,
sendMessage,
retryMessage,
createUser,
@@ -373,5 +380,6 @@ export default {
aiCompletion,
searchConversations,
searchMessages,
searchContacts,
removeAssignee,
}

View File

@@ -18,6 +18,49 @@
overflow-x: auto;
}
}
.native-html {
p {
margin-bottom: 0.5rem;
}
ul {
list-style-type: disc;
margin-left: 1.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
ol {
list-style-type: decimal;
margin-left: 1.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
li {
padding-left: 0.25rem;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: 1.25rem;
font-weight: 700;
}
a {
color: #0066cc;
cursor: pointer;
&:hover {
color: #003d7a;
}
}
}
}
// Theme.

View File

@@ -18,6 +18,7 @@ import {
SidebarProvider,
SidebarRail
} from '@/components/ui/sidebar'
import { useAppSettingsStore } from '@/stores/appSettings'
import {
ChevronRight,
EllipsisVertical,
@@ -43,8 +44,9 @@ defineProps({
userViews: { type: Array, default: () => [] }
})
const userStore = useUserStore()
const settingsStore = useAppSettingsStore()
const route = useRoute()
const emit = defineEmits(['createView', 'editView', 'deleteView'])
const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation'])
const openCreateViewDialog = () => {
emit('createView')
@@ -70,6 +72,8 @@ const isInboxRoute = (path) => {
}
const sidebarOpen = useStorage('mainSidebarOpen', true)
const teamInboxOpen = useStorage('teamInboxOpen', true)
const viewInboxOpen = useStorage('viewInboxOpen', true)
</script>
<template>
@@ -122,9 +126,13 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :isActive="isActiveParent('/admin')" asChild>
<div>
<div class="flex items-center justify-between w-full">
<span class="font-semibold text-xl">Admin</span>
</div>
<!-- App version -->
<div class="text-xs text-muted-foreground ml-2">
({{ settingsStore.settings['app.version'] }})
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
@@ -222,15 +230,27 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
<div class="flex items-center justify-between w-full">
<div class="font-semibold text-xl">Inbox</div>
<div class="ml-auto">
<router-link :to="{ name: 'search' }">
<div class="flex items-center bg-accent p-2 rounded-full">
<Search
class="transition-transform duration-200 hover:scale-110 cursor-pointer"
<div class="flex items-center space-x-2">
<div
class="flex items-center bg-accent p-2 rounded-full cursor-pointer"
@click="emit('createConversation')"
>
<Plus
class="transition-transform duration-200 hover:scale-110"
size="15"
stroke-width="2.5"
/>
</div>
</router-link>
<router-link :to="{ name: 'search' }">
<div class="flex items-center bg-accent p-2 rounded-full">
<Search
class="transition-transform duration-200 hover:scale-110 cursor-pointer"
size="15"
stroke-width="2.5"
/>
</div>
</router-link>
</div>
</div>
</div>
</SidebarMenuButton>
@@ -269,7 +289,12 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
</SidebarMenuItem>
<!-- Team Inboxes -->
<Collapsible defaultOpen class="group/collapsible" v-if="userTeams.length">
<Collapsible
defaultOpen
class="group/collapsible"
v-if="userTeams.length"
v-model:open="teamInboxOpen"
>
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton asChild>
@@ -301,7 +326,7 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
</Collapsible>
<!-- Views -->
<Collapsible class="group/collapsible" defaultOpen>
<Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen">
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton asChild>
@@ -315,17 +340,14 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
class="rounded-lg cursor-pointer opacity-0 transition-all duration-200 group-hover:opacity-100 hover:bg-gray-200 hover:shadow-sm text-gray-600 hover:text-gray-800 transform hover:scale-105 active:scale-100 p-1"
/>
</div>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
v-if="userViews.length"
/>
</router-link>
</SidebarMenuButton>
</CollapsibleTrigger>
<SidebarMenuAction>
<ChevronRight
class="transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
v-if="userViews.length"
/>
</SidebarMenuAction>
<CollapsibleContent>
<SidebarMenuSub v-for="view in userViews" :key="view.id">
<SidebarMenuSubItem>
@@ -335,25 +357,24 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
asChild
>
<router-link :to="{ name: 'view-inbox', params: { viewID: view.id } }">
<span class="break-all w-24">{{ view.name }}</span>
<span class="break-words w-32 truncate">{{ view.name }}</span>
<SidebarMenuAction :showOnHover="true" class="mr-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<EllipsisVertical />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="() => editView(view)">
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem @click="() => deleteView(view)">
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuAction>
</router-link>
</SidebarMenuButton>
<SidebarMenuAction>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<EllipsisVertical />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="() => editView(view)">
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem @click="() => deleteView(view)">
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuAction>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>

View File

@@ -1,82 +1,99 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
<AvatarFallback class="rounded-lg">
{{ userStore.getInitials }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs">{{ userStore.email }}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" side="bottom"
:side-offset="4">
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
<AvatarFallback class="rounded-lg">
{{ userStore.getInitials }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs">{{ userStore.email }}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<router-link to="/account" class="flex items-center">
<CircleUserRound size="18" class="mr-2" />
Account
</router-link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout">
<LogOut size="18" class="mr-2" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"
>
<Avatar class="h-8 w-8 rounded-lg relative overflow-visible">
<AvatarImage :src="userStore.avatar" alt="" class="rounded-lg" />
<AvatarFallback class="rounded-lg">
{{ userStore.getInitials }}
</AvatarFallback>
<div
class="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-background"
:class="{
'bg-green-500': userStore.user.availability_status === 'online',
'bg-amber-500':
userStore.user.availability_status === 'away' ||
userStore.user.availability_status === 'away_manual',
'bg-gray-400': userStore.user.availability_status === 'offline'
}"
></div>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs">{{ userStore.email }}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side="bottom"
:side-offset="4"
>
<DropdownMenuLabel class="p-0 font-normal space-y-1">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
<AvatarFallback class="rounded-lg">
{{ userStore.getInitials }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs">{{ userStore.email }}</span>
</div>
</div>
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm justify-between">
<span class="text-muted-foreground">Away</span>
<Switch
:checked="
userStore.user.availability_status === 'away' ||
userStore.user.availability_status === 'away_manual'
"
@update:checked="(val) => userStore.updateUserAvailability(val ? 'away' : 'online')"
/>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem @click.prevent="router.push({ name: 'account' })">
<CircleUserRound size="18" class="mr-2" />
Account
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout">
<LogOut size="18" class="mr-2" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script setup>
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
SidebarMenuButton,
} from '@/components/ui/sidebar'
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar'
import {
ChevronsUpDown,
CircleUserRound,
LogOut,
} from 'lucide-vue-next'
import { SidebarMenuButton } from '@/components/ui/sidebar'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Switch } from '@/components/ui/switch'
import { ChevronsUpDown, CircleUserRound, LogOut } from 'lucide-vue-next'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
const logout = () => {
window.location.href = '/logout'
window.location.href = '/logout'
}
</script>
</script>

View File

@@ -0,0 +1,25 @@
<template>
<div
v-if="appSettingsStore.settings['app.update']?.update?.is_new"
class="p-2 mb-2 border-b bg-secondary text-secondary-foreground"
>
A new update is available:
{{ appSettingsStore.settings['app.update'].update.release_version }} ({{
appSettingsStore.settings['app.update'].update.release_date
}})
<a
:href="appSettingsStore.settings['app.update'].update.url"
target="_blank"
nofollow
noreferrer
class="underline ml-2"
>
View details
</a>
</div>
</template>
<script setup>
import { useAppSettingsStore } from '@/stores/appSettings'
const appSettingsStore = useAppSettingsStore()
</script>

View File

@@ -0,0 +1,59 @@
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useUserStore } from '@/stores/user'
import { debounce } from '@/utils/debounce'
import { useStorage } from '@vueuse/core'
export function useIdleDetection () {
const userStore = useUserStore()
// 4 minutes
const AWAY_THRESHOLD = 4 * 60 * 1000
// 1 minute
const CHECK_INTERVAL = 60 * 1000
// Store last activity time in localStorage to sync across tabs
const lastActivity = useStorage('last_active', Date.now())
const timer = ref(null)
function resetTimer () {
if (userStore.user.availability_status === 'away' || userStore.user.availability_status === 'offline') {
userStore.updateUserAvailability('online', false)
}
const now = Date.now()
if (lastActivity.value < now) {
lastActivity.value = now
}
}
const debouncedResetTimer = debounce(resetTimer, 200)
function checkIdle () {
if (Date.now() - lastActivity.value > AWAY_THRESHOLD &&
userStore.user.availability_status === 'online') {
userStore.updateUserAvailability('away', false)
}
}
onMounted(() => {
window.addEventListener('mousemove', debouncedResetTimer)
window.addEventListener('keypress', debouncedResetTimer)
window.addEventListener('click', debouncedResetTimer)
timer.value = setInterval(checkIdle, CHECK_INTERVAL)
})
onBeforeUnmount(() => {
window.removeEventListener('mousemove', debouncedResetTimer)
window.removeEventListener('keypress', debouncedResetTimer)
window.removeEventListener('click', debouncedResetTimer)
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
})
// Watch for lastActivity changes in localStorage to handle multi-tab sync
watch(lastActivity, (newVal, oldVal) => {
if (newVal > oldVal) {
resetTimer()
}
})
}

View File

@@ -18,5 +18,5 @@ export function useSla (dueAt, actualAt) {
clearInterval(intervalId)
})
})
return { sla, updateSla }
return sla
}

View File

@@ -10,7 +10,6 @@
<div class="flex items-center justify-between">
<div class="flex gap-5">
<div class="w-48">
<!-- Type -->
<Select
v-model="action.type"
@@ -109,15 +108,13 @@
</div>
<div
class="box p-2 h-96 min-h-96"
v-if="action.type && conversationActions[action.type]?.type === 'richtext'"
class="pl-0 shadow"
>
<QuillEditor
theme="snow"
v-model:content="action.value[0]"
contentType="html"
@update:content="(value) => handleValueChange(value, index)"
class="h-32 mb-12"
<Editor
v-model:htmlContent="action.value[0]"
@update:htmlContent="(value) => handleEditorChange(value, index)"
:placeholder="'Shift + Enter to add new line'"
/>
</div>
</div>
@@ -142,12 +139,12 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { SelectTag } from '@/components/ui/select'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { getTextFromHTML } from '@/utils/strings.js'
import Editor from '@/features/conversation/ConversationTextEditor.vue'
const props = defineProps({
actions: {
@@ -175,6 +172,16 @@ const handleValueChange = (value, index) => {
emitUpdate(index)
}
const handleEditorChange = (value, index) => {
// If text is empty, set HTML to empty string
const textContent = getTextFromHTML(value)
if (textContent.length === 0) {
value = ''
}
actions.value[index].value = [value]
emitUpdate(index)
}
const removeAction = (index) => {
emit('remove-action', index)
}

View File

@@ -31,7 +31,7 @@
</template>
</draggable>
</div>
<div v-else>
<div v-else class="space-y-5">
<RuleList
v-for="rule in rules"
:key="rule.id"

View File

@@ -108,19 +108,6 @@
placeholder="Select tag"
/>
</div>
<div
v-if="action.type && config.actions[action.type]?.type === 'richtext'"
class="pl-0 shadow"
>
<QuillEditor
v-model:content="action.value[0]"
theme="snow"
contentType="html"
@update:content="(value) => updateValue(value, index)"
class="h-32 mb-12"
/>
</div>
</div>
</div>
</div>
@@ -139,14 +126,12 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { SelectTag } from '@/components/ui/select'
import { useTagStore } from '@/stores/tag'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
const model = defineModel({
const model = defineModel("actions", {
type: Array,
required: true,
default: () => []

View File

@@ -13,16 +13,25 @@
<FormField v-slot="{ componentField }" name="message_content">
<FormItem>
<FormLabel>Response to be sent when macro is used</FormLabel>
<FormLabel>Response to be sent when macro is used (optional)</FormLabel>
<FormControl>
<QuillEditor
v-model:content="componentField.modelValue"
placeholder="Add a response (optional)"
theme="snow"
contentType="html"
class="h-32 mb-12"
@update:content="(value) => componentField.onChange(value)"
/>
<div class="box p-2 h-96 min-h-96">
<Editor
v-model:htmlContent="componentField.modelValue"
@update:htmlContent="(value) => componentField.onChange(value)"
:placeholder="'Shift + Enter to add new line'"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="actions">
<FormItem>
<FormLabel> Actions (optional)</FormLabel>
<FormControl>
<ActionBuilder v-model:actions="componentField.modelValue" :config="actionConfig" @update:actions="(value) => componentField.onChange(value)" />
</FormControl>
<FormMessage />
</FormItem>
@@ -106,16 +115,6 @@
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="actions">
<FormItem>
<FormLabel> Actions </FormLabel>
<FormControl>
<ActionBuilder v-bind="componentField" :config="actionConfig" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" :isLoading="isLoading">{{ submitLabel }}</Button>
</form>
</template>
@@ -133,9 +132,8 @@ import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { getTextFromHTML } from '@/utils/strings.js'
import { formSchema } from './formSchema.js'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import {
Select,
SelectContent,
@@ -145,6 +143,7 @@ import {
SelectValue
} from '@/components/ui/select'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import Editor from '@/features/conversation/ConversationTextEditor.vue'
const { macroActions } = useConversationFilters()
const formLoading = ref(false)
@@ -181,6 +180,11 @@ const actionConfig = ref({
})
const onSubmit = form.handleSubmit(async (values) => {
// If the text of HTML is empty then set the HTML to empty string
const textContent = getTextFromHTML(values.message_content)
if (textContent.length === 0) {
values.message_content = ''
}
props.submitForm(values)
})

View File

@@ -1,4 +1,5 @@
import * as z from 'zod'
import { getTextFromHTML } from '@/utils/strings.js'
const actionSchema = z.array(
z.object({
@@ -10,8 +11,42 @@ const actionSchema = z.array(
export const formSchema = z.object({
name: z.string().min(1, 'Macro name is required'),
message_content: z.string().optional(),
actions: actionSchema,
actions: actionSchema.optional().default([]), // Default to empty array if not provided
visibility: z.enum(['all', 'team', 'user']),
team_id: z.string().nullable().optional(),
user_id: z.string().nullable().optional(),
})
})
.refine(
(data) => {
// Check if message_content has non-empty text after stripping HTML
const hasMessageContent = getTextFromHTML(data.message_content || '').trim().length > 0
// Check if actions has at least one valid action
const hasValidActions = data.actions && data.actions.length > 0
// Either message content or actions must be valid
return hasMessageContent || hasValidActions
},
{
message: 'Either message content or actions are required',
// Field path to highlight
path: ['message_content'],
}
)
.refine(
(data) => {
// If visibility is 'team', team_id is required
if (data.visibility === 'team' && !data.team_id) {
return false
}
// If visibility is 'user', user_id is required
if (data.visibility === 'user' && !data.user_id) {
return false
}
// Otherwise, validation passes
return true
},
{
message: 'team is required when visibility is "team", and user is required when visibility is "user"',
// Field path to highlight
path: ['visibility'],
}
)

View File

@@ -65,6 +65,7 @@
<Input type="number" placeholder="2" v-bind="componentField" />
</FormControl>
<FormMessage />
<FormDescription> Maximum concurrent connections to the server. </FormDescription>
</FormItem>
</FormField>
@@ -76,6 +77,10 @@
<Input type="text" placeholder="15s" v-bind="componentField" />
</FormControl>
<FormMessage />
<FormDescription>
Time to wait for new activity on a connection before closing it and removing it from the
pool (s for second, m for minute)
</FormDescription>
</FormItem>
</FormField>
@@ -87,6 +92,10 @@
<Input type="text" placeholder="5s" v-bind="componentField" />
</FormControl>
<FormMessage />
<FormDescription>
Time to wait for new activity on a connection before closing it and removing it from the
pool (s for second, m for minute, h for hour).
</FormDescription>
</FormItem>
</FormField>
@@ -139,6 +148,7 @@
<Input type="number" placeholder="2" v-bind="componentField" />
</FormControl>
<FormMessage />
<FormDescription> Number of times to retry when a message fails. </FormDescription>
</FormItem>
</FormField>

View File

@@ -3,7 +3,7 @@ import { isGoDuration } from '@/utils/strings';
export const smtpConfigSchema = z.object({
enabled: z.boolean().describe('Enabled status').default(false),
username: z.string().describe('SMTP username').email().nonempty({
username: z.string().describe('SMTP username').nonempty({
message: "SMTP username is required"
}),
host: z.string().describe('SMTP host').nonempty({
@@ -51,8 +51,8 @@ export const smtpConfigSchema = z.object({
auth_protocol: z
.enum(['plain', 'login', 'cram', 'none'])
.describe('Authentication protocol'),
email_address: z.string().describe('Email address').email().nonempty({
message: "Email address is required"
email_address: z.string().describe('From email address with name (e.g., "Name <email@example.com>")').nonempty({
message: "From email address is required"
}),
max_msg_retries: z
.number({

View File

@@ -13,7 +13,11 @@
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input type="text" placeholder="This role is for all support agents" v-bind="componentField" />
<Input
type="text"
placeholder="This role is for all support agents"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -24,13 +28,19 @@
<div v-for="entity in permissions" :key="entity.name" class="box p-4">
<p class="text-lg mb-5">{{ entity.name }}</p>
<div class="space-y-4">
<FormField v-for="permission in entity.permissions" :key="permission.name" type="checkbox"
:name="permission.name">
<FormField
v-for="permission in entity.permissions"
:key="permission.name"
type="checkbox"
:name="permission.name"
>
<FormItem class="flex flex-col gap-y-5 space-y-0 rounded-lg">
<div class="flex space-x-3">
<FormControl>
<Checkbox :checked="selectedPermissions.includes(permission.name)"
@update:checked="(newValue) => handleChange(newValue, permission.name)" />
<Checkbox
:checked="selectedPermissions.includes(permission.name)"
@update:checked="(newValue) => handleChange(newValue, permission.name)"
/>
<FormLabel>{{ permission.label }}</FormLabel>
</FormControl>
</div>
@@ -69,7 +79,7 @@ const props = defineProps({
},
isLoading: {
type: Boolean,
required: false,
required: false
}
})
@@ -77,7 +87,8 @@ const permissions = ref([
{
name: 'Conversation',
permissions: [
{ name: 'conversations:read', label: 'View conversations' },
{ name: 'conversations:read', label: 'View conversation' },
{ name: 'conversations:write', label: 'Create conversation' },
{ name: 'conversations:read_assigned', label: 'View conversations assigned to me' },
{ name: 'conversations:read_all', label: 'View all conversations' },
{ name: 'conversations:read_unassigned', label: 'View all unassigned conversations' },
@@ -89,7 +100,7 @@ const permissions = ref([
{ name: 'conversations:update_tags', label: 'Add or remove conversation tags' },
{ name: 'messages:read', label: 'View conversation messages' },
{ name: 'messages:write', label: 'Send messages in conversations' },
{ name: 'view:manage', label: 'Create and manage conversation views' },
{ name: 'view:manage', label: 'Create and manage conversation views' }
]
},
{
@@ -110,8 +121,9 @@ const permissions = ref([
{ name: 'reports:manage', label: 'Manage Reports' },
{ name: 'business_hours:manage', label: 'Manage Business Hours' },
{ name: 'sla:manage', label: 'Manage SLA Policies' },
{ name: 'ai:manage', label: 'Manage AI Features' }
]
},
}
])
const selectedPermissions = ref([])

View File

@@ -1,7 +1,11 @@
<template>
<CommandDialog :open="open" @update:open="handleOpenChange" class="z-[51]">
<CommandDialog
:open="open"
@update:open="handleOpenChange"
class="z-[51] !min-w-[50vw] !min-h-[60vh]"
>
<CommandInput placeholder="Type a command or search..." @keydown="onInputKeydown" />
<CommandList class="!min-h-[400px]">
<CommandList class="!min-h-[60vh] !min-w-[50vw]">
<CommandEmpty>
<p class="text-muted-foreground">No command available</p>
</CommandEmpty>
@@ -10,7 +14,7 @@
<CommandGroup
heading="Conversations"
value="conversations"
v-if="nestedCommand === null && conversationStore.current"
v-if="nestedCommand === null && conversationStore.hasConversationOpen"
>
<CommandItem value="conv-snooze" @select="setNestedCommand('snooze')"> Snooze </CommandItem>
<CommandItem value="conv-resolve" @select="resolveConversation"> Resolve </CommandItem>
@@ -32,12 +36,12 @@
</CommandGroup>
<!-- Macros -->
<!-- TODO move to a separate component -->
<div v-if="nestedCommand === 'apply-macro'" class="bg-background">
<CommandGroup heading="Apply macro" class="pb-2">
<div class="min-h-[400px] overflow-auto">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-4 border-r border-border/30 pr-2">
<!-- Left Column: Macro List (30%) -->
<div class="col-span-4 pr-2 border-r">
<CommandItem
v-for="(macro, index) in macroStore.macroOptions"
:key="macro.value"
@@ -45,25 +49,29 @@
:data-index="index"
@select="handleApplyMacro(macro)"
class="px-3 py-2 rounded-md cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:text-primary"
:class="{ 'bg-primary/5 text-primary': selectedMacroIndex === index }"
>
<div class="flex items-center space-x-2 justify-start">
<Zap :size="14" class="text-primary" />
<span class="text-sm overflow">{{ macro.label }}</span>
<div class="flex items-center gap-2">
<Zap size="14" class="text-primary shrink-0" />
<span class="text-sm truncate w-full break-words whitespace-normal">{{
macro.label
}}</span>
</div>
</CommandItem>
</div>
<!-- Right Column: Macro Details (70%) -->
<div class="col-span-8 pl-2">
<div class="space-y-3 text-xs">
<!-- Reply Preview -->
<div v-if="replyContent" class="space-y-1">
<p class="text-xs font-semibold text-primary">Reply Preview</p>
<div
class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm"
v-html="replyContent"
class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm native-html"
v-dompurify-html="replyContent"
/>
</div>
<!-- Actions -->
<div v-if="otherActions.length > 0" class="space-y-1">
<p class="text-xs font-semibold text-primary">Actions</p>
<div class="space-y-1.5 max-w-sm">
@@ -105,6 +113,8 @@
</div>
</div>
</div>
<!-- Empty State -->
<div
v-if="!replyContent && otherActions.length === 0"
class="flex items-center justify-center h-20"
@@ -122,7 +132,6 @@
</CommandList>
<!-- Navigation -->
<!-- TODO: Move to a separate component -->
<div class="mt-2 px-4 py-2 text-xs text-gray-500 flex space-x-4">
<span><kbd>Enter</kbd> select</span>
<span><kbd></kbd>/<kbd></kbd> navigate</span>
@@ -132,7 +141,6 @@
</CommandDialog>
<!-- Date Picker for Custom Snooze -->
<!-- TODO: Move to a separate component -->
<Dialog :open="showDatePicker" @update:open="closeDatePicker">
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
@@ -219,7 +227,9 @@ watch([Meta_K, Ctrl_K], ([mac, win]) => {
const highlightedMacro = ref(null)
function handleApplyMacro(macro) {
conversationStore.setMacro(macro)
// Create a deep copy.
const plainMacro = JSON.parse(JSON.stringify(macro))
conversationStore.setMacro(plainMacro)
handleOpenChange()
}

View File

@@ -38,7 +38,7 @@
<div class="flex flex-col flex-grow overflow-hidden">
<MessageList class="flex-1 overflow-y-auto" />
<div class="sticky bottom-0">
<ReplyBox class="h-max" />
<ReplyBox />
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div class="max-h-[600px] overflow-y-auto">
<div class="editor-wrapper h-full overflow-y-auto">
<BubbleMenu
:editor="editor"
:tippy-options="{ duration: 100 }"
@@ -7,7 +7,7 @@
class="bg-white p-1 box will-change-transform"
>
<div class="flex space-x-1 items-center">
<DropdownMenu>
<DropdownMenu v-if="aiPrompts.length > 0">
<DropdownMenuTrigger>
<Button size="sm" variant="ghost" class="flex items-center justify-center">
<span class="flex items-center">
@@ -30,7 +30,7 @@
<Button
size="sm"
variant="ghost"
@click="isBold = !isBold"
@click.prevent="isBold = !isBold"
:active="isBold"
:class="{ 'bg-gray-200': isBold }"
>
@@ -39,22 +39,39 @@
<Button
size="sm"
variant="ghost"
@click="isItalic = !isItalic"
@click.prevent="isItalic = !isItalic"
:active="isItalic"
:class="{ 'bg-gray-200': isItalic }"
>
<Italic size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="toggleBulletList"
:class="{ 'bg-gray-200': editor?.isActive('bulletList') }"
>
<List size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="toggleOrderedList"
:class="{ 'bg-gray-200': editor?.isActive('orderedList') }"
>
<ListOrdered size="14" />
</Button>
</div>
</BubbleMenu>
<EditorContent :editor="editor" />
<EditorContent :editor="editor" class="native-html" />
</div>
</template>
<script setup>
import { ref, watch, watchEffect, onUnmounted } from 'vue'
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3'
import { ChevronDown, Bold, Italic, Bot } from 'lucide-vue-next'
import { ChevronDown, Bold, Italic, Bot, List, ListOrdered } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -95,28 +112,7 @@ const getSelectionText = (from, to, doc) => doc.textBetween(from, to)
const editorConfig = {
extensions: [
// Lists are unstyled in tailwind, so need to add classes to them.
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: 'list-disc ml-6 my-2'
}
},
orderedList: {
HTMLAttributes: {
class: 'list-decimal ml-6 my-2'
}
},
listItem: {
HTMLAttributes: {
class: 'pl-1'
}
},
heading: {
HTMLAttributes: {
class: 'text-xl font-bold mt-4 mb-2'
}
}
}),
StarterKit.configure(),
Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
Placeholder.configure({ placeholder: () => props.placeholder }),
Link
@@ -179,13 +175,20 @@ watchEffect(() => {
watch(
() => props.contentToSet,
(newContent) => {
if (newContent === '') {
editor.value?.commands.clearContent()
} else {
editor.value?.commands.setContent(newContent, true)
(newContentData) => {
if (!newContentData) return
try {
const parsedData = JSON.parse(newContentData)
const content = parsedData.content
if (content === '') {
editor.value?.commands.clearContent()
} else {
editor.value?.commands.setContent(content, true)
}
editor.value?.commands.focus()
} catch (e) {
console.error('Error parsing content data', e)
}
editor.value?.commands.focus()
}
)
@@ -231,6 +234,18 @@ watch(
onUnmounted(() => {
editor.value?.destroy()
})
const toggleBulletList = () => {
if (editor.value) {
editor.value.chain().focus().toggleBulletList().run()
}
}
const toggleOrderedList = () => {
if (editor.value) {
editor.value.chain().focus().toggleOrderedList().run()
}
}
</script>
<style lang="scss">
@@ -243,22 +258,26 @@ onUnmounted(() => {
height: 0;
}
// Editor height
.ProseMirror {
min-height: 80px !important;
max-height: 60% !important;
overflow-y: scroll !important;
// Ensure the parent div has a proper height
.editor-wrapper div[aria-expanded='false'] {
display: flex;
flex-direction: column;
height: 100%;
}
.fullscreen-tiptap-editor {
@apply p-0;
.ProseMirror {
min-height: 600px !important;
width: 90%;
scrollbar-width: none;
}
// Ensure the editor content has a proper height and breaks words
.tiptap.ProseMirror {
flex: 1;
min-height: 70px;
overflow-y: auto;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
word-break: break-word;
white-space: pre-wrap;
max-width: 100%;
}
// Anchor tag styling
.tiptap {
a {
color: #0066cc;

View File

@@ -0,0 +1,345 @@
<template>
<Dialog :open="dialogOpen" @update:open="dialogOpen = false">
<DialogContent class="max-w-5xl w-full h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
</DialogHeader>
<form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden">
<div class="flex-1 space-y-4 pr-1 overflow-y-auto pb-2">
<FormField name="contact_email">
<FormItem class="relative">
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="Search contact by email or type new email"
v-model="emailQuery"
@input="handleSearchContacts"
autocomplete="off"
/>
</FormControl>
<FormMessage />
<ul
v-if="searchResults.length"
class="border rounded p-2 max-h-60 overflow-y-auto absolute bg-white w-full z-50 shadow-lg"
>
<li
v-for="contact in searchResults"
:key="contact.email"
@click="selectContact(contact)"
class="cursor-pointer p-2 hover:bg-gray-100 rounded"
>
{{ contact.first_name }} {{ contact.last_name }} ({{ contact.email }})
</li>
</ul>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="first_name">
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input type="text" placeholder="First Name" v-bind="componentField" required />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="last_name">
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input type="text" placeholder="Last Name" v-bind="componentField" required />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="subject">
<FormItem>
<FormLabel>Subject</FormLabel>
<FormControl>
<Input type="text" placeholder="Subject" v-bind="componentField" required />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="inbox_id">
<FormItem>
<FormLabel>Inbox</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select an inbox" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="option in inboxStore.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Set assigned team -->
<FormField v-slot="{ componentField }" name="team_id">
<FormItem>
<FormLabel>Assign team (optional)</FormLabel>
<FormControl>
<ComboBox
v-bind="componentField"
:items="[{ value: 'none', label: 'None' }, ...teamStore.options]"
placeholder="Search team"
defaultLabel="Assign team"
>
<template #item="{ item }">
<div class="flex items-center gap-3 py-2">
<div class="w-7 h-7 flex items-center justify-center">
<span v-if="item.emoji">{{ item.emoji }}</span>
<div
v-else
class="text-primary bg-muted rounded-full w-7 h-7 flex items-center justify-center"
>
<Users size="14" />
</div>
</div>
<span class="text-sm">{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-3" v-if="selected">
<div class="w-7 h-7 flex items-center justify-center">
{{ selected?.emoji }}
</div>
<span class="text-sm">{{ selected?.label || 'Select team' }}</span>
</div>
</template>
</ComboBox>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Set assigned agent -->
<FormField v-slot="{ componentField }" name="agent_id">
<FormItem>
<FormLabel>Assign agent (optional)</FormLabel>
<FormControl>
<ComboBox
v-bind="componentField"
:items="[{ value: 'none', label: 'None' }, ...uStore.options]"
placeholder="Search agent"
defaultLabel="Assign agent"
>
<template #item="{ item }">
<div class="flex items-center gap-3 py-2">
<Avatar class="w-8 h-8">
<AvatarImage
:src="item.value === 'none' ? '/default-avatar.png' : item.avatar_url"
:alt="item.value === 'none' ? 'N' : item.label.slice(0, 2)"
/>
<AvatarFallback>
{{ item.value === 'none' ? 'N' : item.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span class="text-sm">{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-3">
<Avatar class="w-7 h-7" v-if="selected">
<AvatarImage
:src="
selected?.value === 'none'
? '/default-avatar.png'
: selected?.avatar_url
"
:alt="selected?.value === 'none' ? 'N' : selected?.label?.slice(0, 2)"
/>
<AvatarFallback>
{{
selected?.value === 'none'
? 'N'
: selected?.label?.slice(0, 2)?.toUpperCase()
}}
</AvatarFallback>
</Avatar>
<span class="text-sm">{{ selected?.label || 'Assign agent' }}</span>
</div>
</template>
</ComboBox>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="content"
class="flex-1 min-h-0 flex flex-col"
>
<FormItem class="flex flex-col flex-1">
<FormLabel>Message</FormLabel>
<FormControl class="flex-1 min-h-0 flex flex-col">
<div class="flex-1 min-h-0 flex flex-col">
<Editor
v-model:htmlContent="componentField.modelValue"
@update:htmlContent="(value) => componentField.onChange(value)"
:placeholder="'Shift + Enter to add new line'"
class="w-full flex-1 overflow-y-auto p-2 min-h-[200px] box"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<DialogFooter class="mt-4 pt-2 border-t shrink-0">
<Button type="submit" :disabled="loading" :isLoading="loading"> Submit </Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
<script setup>
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { z } from 'zod'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { ref, defineModel, watch } from 'vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { handleHTTPError } from '@/utils/http'
import { useInboxStore } from '@/stores/inbox'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import Editor from '@/features/conversation/ConversationTextEditor.vue'
import api from '@/api'
const dialogOpen = defineModel({
required: false,
default: () => false
})
const inboxStore = useInboxStore()
const uStore = useUsersStore()
const teamStore = useTeamStore()
const emitter = useEmitter()
const loading = ref(false)
const searchResults = ref([])
const emailQuery = ref('')
let timeoutId = null
const formSchema = z.object({
subject: z.string().min(3, 'Subject must be at least 3 characters'),
content: z.string().min(1, 'Message cannot be empty'),
inbox_id: z.any().refine((val) => inboxStore.options.some((option) => option.value === val), {
message: 'Inbox is required'
}),
team_id: z.any().optional(),
agent_id: z.any().optional(),
contact_email: z.string().email('Invalid email address'),
first_name: z.string().min(1, 'First name is required'),
last_name: z.string().min(1, 'Last name is required')
})
const form = useForm({
validationSchema: toTypedSchema(formSchema),
initialValues: {
inbox_id: null,
team_id: null,
agent_id: null,
subject: '',
content: '',
contact_email: '',
first_name: '',
last_name: ''
}
})
watch(emailQuery, (newVal) => {
form.setFieldValue('contact_email', newVal)
})
const handleSearchContacts = async () => {
clearTimeout(timeoutId)
timeoutId = setTimeout(async () => {
const query = emailQuery.value.trim()
if (query.length < 3) {
searchResults.value.splice(0)
return
}
try {
const resp = await api.searchContacts({ query })
searchResults.value = [...resp.data.data]
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
searchResults.value.splice(0)
}
}, 300)
}
const selectContact = (contact) => {
emailQuery.value = contact.email
form.setFieldValue('first_name', contact.first_name)
form.setFieldValue('last_name', contact.last_name || '')
searchResults.value.splice(0)
}
const createConversation = form.handleSubmit(async (values) => {
loading.value = true
try {
await api.createConversation(values)
dialogOpen.value = false
form.resetForm()
emailQuery.value = ''
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
loading.value = false
}
})
</script>

View File

@@ -1,16 +1,16 @@
<template>
<div class="flex flex-wrap px-2 py-1">
<div class="flex flex-wrap">
<div class="flex flex-wrap gap-2">
<div
v-for="action in actions"
:key="action.type"
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group"
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group gap-2 py-1"
>
<div class="flex items-center space-x-2 px-3 py-2">
<div class="flex items-center space-x-2 px-2">
<component
:is="getIcon(action.type)"
size="16"
class="text-primary group-hover:text-primary"
class="text-gray-500 text-primary group-hover:text-primary"
/>
<Tooltip>
<TooltipTrigger as-child>
@@ -27,7 +27,7 @@
</div>
<button
@click.stop="onRemove(action)"
class="p-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
class="pr-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
title="Remove action"
>
<X size="14" />

View File

@@ -1,330 +1,202 @@
<template>
<Dialog :open="openAIKeyPrompt" @update:open="openAIKeyPrompt = false">
<DialogContent class="sm:max-w-lg">
<DialogHeader class="space-y-2">
<DialogTitle>Enter OpenAI API Key</DialogTitle>
<DialogDescription>
OpenAI API key is not set or invalid. Please enter a valid API key to use AI features.
</DialogDescription>
</DialogHeader>
<Form v-slot="{ handleSubmit }" as="" keep-values :validation-schema="formSchema">
<form id="apiKeyForm" @submit="handleSubmit($event, updateProvider)">
<FormField v-slot="{ componentField }" name="apiKey">
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input type="text" placeholder="Enter your API key" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</form>
<DialogFooter>
<Button
type="submit"
form="apiKeyForm"
:is-loading="isOpenAIKeyUpdating"
:disabled="isOpenAIKeyUpdating"
>
Save
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
<div class="text-foreground bg-background">
<!-- Fullscreen editor -->
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
<DialogContent
class="max-w-[70%] max-h-[70%] h-[90%] w-full bg-card text-card-foreground px-4 py-4"
class="max-w-[70%] max-h-[70%] h-[70%] bg-card text-card-foreground p-4 flex flex-col"
@escapeKeyDown="isEditorFullscreen = false"
hide-close-button="true"
:hide-close-button="true"
>
<div v-if="isEditorFullscreen" class="h-full flex flex-col">
<!-- Message type toggle -->
<div class="flex justify-between items-center border-b border-border pb-4">
<Tabs v-model="messageType" class="rounded-lg">
<TabsList class="bg-muted p-1 rounded-lg">
<TabsTrigger
value="reply"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
>
Reply
</TabsTrigger>
<TabsTrigger
value="private_note"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
>
Private note
</TabsTrigger>
</TabsList>
</Tabs>
<span
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
variant="ghost"
@click="isEditorFullscreen = false"
>
<Minimize2 size="18" />
</span>
</div>
<!-- CC and BCC fields -->
<div class="space-y-3 p-4 border-b border-border" v-if="messageType === 'reply'">
<div class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="cc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('cc')"
/>
<Button
size="sm"
@click="hideBcc"
class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
{{ showBcc ? 'Remove BCC' : 'BCC' }}
</Button>
</div>
<div v-if="showBcc" class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="bcc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('bcc')"
/>
</div>
</div>
<div
v-if="emailErrors.length > 0"
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
>
<p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
</div>
<!-- Main Editor -->
<div class="flex-grow overflow-y-auto p-2">
<Editor
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
:placeholder="editorPlaceholder"
:aiPrompts="aiPrompts"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
@send="handleSend"
v-model:cursorPosition="cursorPosition"
:clearContent="clearEditorContent"
:setInlineImage="setInlineImage"
:insertContent="insertContent"
class="h-full"
/>
</div>
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
:actions="conversationStore.conversation.macro.actions"
:onRemove="conversationStore.removeMacroAction"
class="mt-4"
/>
<!-- Attachments preview -->
<AttachmentsPreview
:attachments="attachments"
:uploadingFiles="uploadingFiles"
:onDelete="handleOnFileDelete"
v-if="attachments.length > 0 || uploadingFiles.length > 0"
class="mt-4"
/>
<!-- Bottom menu bar -->
<ReplyBoxBottomMenuBar
class="mt-4 pt-4"
:handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
:isSending="isSending"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
:handleSend="handleSend"
@emojiSelect="handleEmojiSelect"
/>
</div>
<ReplyBoxContent
v-if="isEditorFullscreen"
:isFullscreen="true"
:aiPrompts="aiPrompts"
:isSending="isSending"
:uploadingFiles="uploadingFiles"
:clearEditorContent="clearEditorContent"
:htmlContent="htmlContent"
:textContent="textContent"
:selectedText="selectedText"
:isBold="isBold"
:isItalic="isItalic"
:cursorPosition="cursorPosition"
:contentToSet="contentToSet"
:cc="cc"
:bcc="bcc"
:emailErrors="emailErrors"
:messageType="messageType"
:showBcc="showBcc"
@update:htmlContent="htmlContent = $event"
@update:textContent="textContent = $event"
@update:selectedText="selectedText = $event"
@update:isBold="isBold = $event"
@update:isItalic="isItalic = $event"
@update:cursorPosition="cursorPosition = $event"
@toggleFullscreen="isEditorFullscreen = false"
@update:messageType="messageType = $event"
@update:cc="cc = $event"
@update:bcc="bcc = $event"
@update:showBcc="showBcc = $event"
@updateEmailErrors="emailErrors = $event"
@send="processSend"
@fileUpload="handleFileUpload"
@inlineImageUpload="handleInlineImageUpload"
@fileDelete="handleOnFileDelete"
@aiPromptSelected="handleAiPromptSelected"
class="h-full flex-grow"
/>
</DialogContent>
</Dialog>
<!-- Main Editor non-fullscreen -->
<div class="bg-card text-card-foreground box px-2 pt-2 m-2">
<div v-if="!isEditorFullscreen" class="">
<!-- Message type toggle -->
<div class="flex justify-between items-center mb-4">
<Tabs v-model="messageType" class="rounded-lg">
<TabsList class="bg-muted p-1 rounded-lg">
<TabsTrigger
value="reply"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
>
Reply
</TabsTrigger>
<TabsTrigger
value="private_note"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
>
Private note
</TabsTrigger>
</TabsList>
</Tabs>
<span
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer mr-2"
variant="ghost"
@click="isEditorFullscreen = true"
>
<Maximize2 size="15" />
</span>
</div>
<div class="space-y-3 mb-4" v-if="messageType === 'reply'">
<div class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="cc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('cc')"
/>
<Button
size="sm"
@click="hideBcc"
class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
{{ showBcc ? 'Remove BCC' : 'BCC' }}
</Button>
</div>
<div v-if="showBcc" class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="bcc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('bcc')"
/>
</div>
</div>
<div
v-if="emailErrors.length > 0"
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
>
<p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
</div>
<!-- Main Editor -->
<Editor
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
:placeholder="editorPlaceholder"
:aiPrompts="aiPrompts"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
@send="handleSend"
v-model:cursorPosition="cursorPosition"
:clearContent="clearEditorContent"
:setInlineImage="setInlineImage"
:insertContent="insertContent"
/>
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
:actions="conversationStore.conversation.macro.actions"
:onRemove="conversationStore.removeMacroAction"
/>
<!-- Attachments preview -->
<AttachmentsPreview
:attachments="attachments"
:uploadingFiles="uploadingFiles"
:onDelete="handleOnFileDelete"
v-if="attachments.length > 0 || uploadingFiles.length > 0"
class="mt-4"
/>
<!-- Bottom menu bar -->
<ReplyBoxBottomMenuBar
class="mt-1"
:handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
:isSending="isSending"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
:handleSend="handleSend"
@emojiSelect="handleEmojiSelect"
/>
</div>
<div
class="bg-card text-card-foreground box m-2 px-2 pt-2 flex flex-col"
v-if="!isEditorFullscreen"
>
<ReplyBoxContent
:isFullscreen="false"
:aiPrompts="aiPrompts"
:isSending="isSending"
:uploadingFiles="uploadingFiles"
:clearEditorContent="clearEditorContent"
:htmlContent="htmlContent"
:textContent="textContent"
:selectedText="selectedText"
:isBold="isBold"
:isItalic="isItalic"
:cursorPosition="cursorPosition"
:contentToSet="contentToSet"
:cc="cc"
:bcc="bcc"
:emailErrors="emailErrors"
:messageType="messageType"
:showBcc="showBcc"
@update:htmlContent="htmlContent = $event"
@update:textContent="textContent = $event"
@update:selectedText="selectedText = $event"
@update:isBold="isBold = $event"
@update:isItalic="isItalic = $event"
@update:cursorPosition="cursorPosition = $event"
@toggleFullscreen="isEditorFullscreen = true"
@update:messageType="messageType = $event"
@update:cc="cc = $event"
@update:bcc="bcc = $event"
@update:showBcc="showBcc = $event"
@updateEmailErrors="emailErrors = $event"
@send="processSend"
@fileUpload="handleFileUpload"
@inlineImageUpload="handleInlineImageUpload"
@fileDelete="handleOnFileDelete"
@aiPromptSelected="handleAiPromptSelected"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, nextTick, watch } from 'vue'
import { ref, onMounted, nextTick, watch, computed } from 'vue'
import { transformImageSrcToCID } from '@/utils/strings'
import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { Maximize2, Minimize2 } from 'lucide-vue-next'
import { useUserStore } from '@/stores/user'
import api from '@/api'
import Editor from './ConversationTextEditor.vue'
import { useConversationStore } from '@/stores/conversation'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { useEmitter } from '@/composables/useEmitter'
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
import ReplyBoxBottomMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage
} from '@/components/ui/form'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
const formSchema = toTypedSchema(
z.object({
apiKey: z.string().min(1, 'API key is required')
})
)
const conversationStore = useConversationStore()
const emitter = useEmitter()
const insertContent = ref(null)
const setInlineImage = ref(null)
const userStore = useUserStore()
const openAIKeyPrompt = ref(false)
const isOpenAIKeyUpdating = ref(false)
// Shared state between the two editor components.
const clearEditorContent = ref(false)
const isEditorFullscreen = ref(false)
const isSending = ref(false)
const cursorPosition = ref(0)
const selectedText = ref('')
const htmlContent = ref('')
const textContent = ref('')
const contentToSet = ref('')
const isBold = ref(false)
const isItalic = ref(false)
const messageType = ref('reply')
const showBcc = ref(false)
const cc = ref('')
const bcc = ref('')
const showBcc = ref(false)
const emailErrors = ref([])
const aiPrompts = ref([])
const uploadingFiles = ref([])
const editorPlaceholder = 'Press Enter to add a new line; Press Ctrl + Enter to send.'
const htmlContent = ref('')
const textContent = ref('')
const selectedText = ref('')
const isBold = ref(false)
const isItalic = ref(false)
const cursorPosition = ref(0)
const contentToSet = ref('')
onMounted(async () => {
await fetchAiPrompts()
})
const hideBcc = () => {
showBcc.value = !showBcc.value
}
watch(
() => conversationStore.currentCC,
(newVal) => {
cc.value = newVal?.join(', ') || ''
},
{ deep: true, immediate: true }
)
watch(
() => conversationStore.currentBCC,
(newVal) => {
const newBcc = newVal?.join(', ') || ''
bcc.value = newBcc
if (newBcc.length == 0) {
showBcc.value = false
} else {
showBcc.value = true
}
},
{ deep: true, immediate: true }
)
/**
* Fetches AI prompts from the server.
*/
const fetchAiPrompts = async () => {
try {
const resp = await api.getAiPrompts()
@@ -338,14 +210,27 @@ const fetchAiPrompts = async () => {
}
}
/**
* Handles the AI prompt selection event.
* Sends the selected prompt key and the current text content to the server for completion.
* Sets the response as the new content in the editor.
* @param {String} key - The key of the selected AI prompt
*/
const handleAiPromptSelected = async (key) => {
try {
const resp = await api.aiCompletion({
prompt_key: key,
content: selectedText.value
content: textContent.value
})
contentToSet.value = JSON.stringify({
content: resp.data.data.replace(/\n/g, '<br>'),
timestamp: Date.now()
})
contentToSet.value = resp.data.data.replace(/\n/g, '<br>')
} catch (error) {
// Check if user needs to enter OpenAI API key and has permission to do so.
if (error.response?.status === 400 && userStore.can('ai:manage')) {
openAIKeyPrompt.value = true
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
@@ -354,33 +239,35 @@ const handleAiPromptSelected = async (key) => {
}
}
const toggleBold = () => {
isBold.value = !isBold.value
/**
* updateProvider updates the OpenAI API key.
* @param {Object} values - The form values containing the API key
*/
const updateProvider = async (values) => {
try {
isOpenAIKeyUpdating.value = true
await api.updateAIProvider({ api_key: values.apiKey, provider: 'openai' })
openAIKeyPrompt.value = false
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success',
description: 'API key saved successfully.'
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isOpenAIKeyUpdating.value = false
}
}
const toggleItalic = () => {
isItalic.value = !isItalic.value
}
const attachments = computed(() => {
return conversationStore.conversation.mediaFiles.filter(
(upload) => upload.disposition === 'attachment'
)
})
const enableSend = computed(() => {
return (
(textContent.value.trim().length > 0 ||
conversationStore.conversation?.macro?.actions?.length > 0) &&
emailErrors.value.length === 0 &&
!uploadingFiles.value.length
)
})
const hasTextContent = computed(() => {
return textContent.value.trim().length > 0
})
/**
* Handles the file upload process when files are selected.
* Uploads each file to the server and adds them to the conversation's mediaFiles.
* @param {Event} event - The file input change event containing selected files
*/
const handleFileUpload = (event) => {
const files = Array.from(event.target.files)
uploadingFiles.value = files
@@ -407,6 +294,7 @@ const handleFileUpload = (event) => {
}
}
// Inline image upload is not supported yet.
const handleInlineImageUpload = (event) => {
for (const file of event.target.files) {
api
@@ -416,12 +304,13 @@ const handleInlineImageUpload = (event) => {
linked_model: 'messages'
})
.then((resp) => {
setInlineImage.value = {
const imageData = {
src: resp.data.data.url,
alt: resp.data.data.filename,
title: resp.data.data.uuid
}
conversationStore.conversation.mediaFiles.push(resp.data.data)
return imageData
})
.catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
@@ -433,44 +322,24 @@ const handleInlineImageUpload = (event) => {
}
}
const validateEmails = (field) => {
const emails = field === 'cc' ? cc.value : bcc.value
const emailList = emails
.split(',')
.map((e) => e.trim())
.filter((e) => e !== '')
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const invalidEmails = emailList.filter((email) => !emailRegex.test(email))
// Remove any existing errors for this field
emailErrors.value = emailErrors.value.filter(
(error) => !error.startsWith(`Invalid email(s) in ${field.toUpperCase()}`)
)
// Add new error if there are invalid emails
if (invalidEmails.length > 0) {
emailErrors.value.push(
`Invalid email(s) in ${field.toUpperCase()}: ${invalidEmails.join(', ')}`
)
}
}
const handleSend = async () => {
if (emailErrors.value.length > 0) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: 'Please correct the email errors before sending.'
})
return
}
/**
* Returns true if the editor has text content.
*/
const hasTextContent = computed(() => {
return textContent.value.trim().length > 0
})
/**
* Processes the send action.
*/
const processSend = async () => {
let hasAPIErrored = false
isEditorFullscreen.value = false
try {
isSending.value = true
// Send message if there is text content in the editor.
if (hasTextContent.value) {
if (hasTextContent.value > 0) {
// Replace inline image url with cid.
const message = transformImageSrcToCID(htmlContent.value)
@@ -498,7 +367,7 @@ const handleSend = async () => {
.split(',')
.map((email) => email.trim())
.filter((email) => email),
bcc: showBcc.value
bcc: bcc.value
? bcc.value
.split(',')
.map((email) => email.trim())
@@ -507,57 +376,101 @@ const handleSend = async () => {
})
}
// Apply macro if it exists.
// Apply macro actions if any.
// For macros errors just show toast and clear the editor, as most likely it's the permission error.
if (conversationStore.conversation?.macro?.actions?.length > 0) {
await api.applyMacro(
conversationStore.current.uuid,
conversationStore.conversation.macro.id,
conversationStore.conversation.macro.actions
)
try {
await api.applyMacro(
conversationStore.current.uuid,
conversationStore.conversation.macro.id,
conversationStore.conversation.macro.actions
)
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
} catch (error) {
hasAPIErrored = true
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
// If API has NOT errored clear state.
if (hasAPIErrored === false) {
// Clear editor.
clearEditorContent.value = true
// Clear macro.
conversationStore.resetMacro()
// Clear media files.
conversationStore.resetMediaFiles()
// Clear any email errors.
emailErrors.value = []
nextTick(() => {
clearEditorContent.value = false
})
}
isSending.value = false
clearEditorContent.value = true
conversationStore.resetMacro()
conversationStore.resetMediaFiles()
emailErrors.value = []
nextTick(() => {
clearEditorContent.value = false
})
}
// Update assignee last seen timestamp.
api.updateAssigneeLastSeen(conversationStore.current.uuid)
}
/**
* Handles the file delete event.
* Removes the file from the conversation's mediaFiles.
* @param {String} uuid - The UUID of the file to delete
*/
const handleOnFileDelete = (uuid) => {
conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
(item) => item.uuid !== uuid
)
}
const handleEmojiSelect = (emoji) => {
insertContent.value = undefined
// Force reactivity so the user can select the same emoji multiple times
nextTick(() => (insertContent.value = emoji))
}
// Watch for changes in macro content and update editor content.
/**
* Watches for changes in the conversation's macro id and update message content.
*/
watch(
() => conversationStore.conversation.macro,
() => conversationStore.conversation.macro.id,
() => {
// hack: Quill editor adds <p><br></p> replace with <p></p>
if (conversationStore.conversation?.macro?.message_content) {
contentToSet.value = conversationStore.conversation.macro.message_content.replace(
/<p><br><\/p>/g,
'<p></p>'
)
}
// Setting timestamp, so the same macro can be set again.
contentToSet.value = JSON.stringify({
content: conversationStore.conversation.macro.message_content,
timestamp: Date.now()
})
},
{ deep: true }
)
// Initialize cc and bcc from conversation store
watch(
() => conversationStore.currentCC,
(newVal) => {
cc.value = newVal?.join(', ') || ''
},
{ deep: true, immediate: true }
)
watch(
() => conversationStore.currentBCC,
(newVal) => {
const newBcc = newVal?.join(', ') || ''
bcc.value = newBcc
// Only show BCC field if it has content
if (newBcc.length > 0) {
showBcc.value = true
}
},
{ deep: true, immediate: true }
)
</script>

View File

@@ -0,0 +1,307 @@
<template>
<!-- Set fixed width only when not in fullscreen. -->
<div class="flex flex-col h-full" :class="{ 'max-h-[600px]': !isFullscreen }">
<!-- Message type toggle -->
<div
class="flex justify-between items-center"
:class="{ 'mb-4': !isFullscreen, 'border-b border-border pb-4': isFullscreen }"
>
<Tabs v-model="messageType" class="rounded-lg">
<TabsList class="bg-muted p-1 rounded-lg">
<TabsTrigger
value="reply"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
>
Reply
</TabsTrigger>
<TabsTrigger
value="private_note"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
>
Private note
</TabsTrigger>
</TabsList>
</Tabs>
<span
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
variant="ghost"
@click="toggleFullscreen"
>
<component
:is="isFullscreen ? Minimize2 : Maximize2"
:size="isFullscreen ? '18' : '15'"
:class="{ 'mr-2': !isFullscreen }"
/>
</span>
</div>
<!-- CC and BCC fields -->
<div
:class="['space-y-3', isFullscreen ? 'p-4 border-b border-border' : 'mb-4']"
v-if="messageType === 'reply'"
>
<div class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="cc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('cc')"
/>
<Button
size="sm"
@click="toggleBcc"
class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
{{ showBcc ? 'Remove BCC' : 'BCC' }}
</Button>
</div>
<div v-if="showBcc" class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="bcc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('bcc')"
/>
</div>
</div>
<!-- CC and BCC field validation errors -->
<div
v-if="emailErrors.length > 0"
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
>
<p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
</div>
<!-- Main tiptap editor -->
<div class="flex-grow flex flex-col overflow-hidden">
<Editor
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
v-model:cursorPosition="cursorPosition"
:placeholder="editorPlaceholder"
:aiPrompts="aiPrompts"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
@send="handleSend"
:clearContent="clearEditorContent"
:setInlineImage="setInlineImage"
:insertContent="insertContent"
/>
</div>
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
:actions="conversationStore.conversation.macro.actions"
:onRemove="conversationStore.removeMacroAction"
class="mt-2"
/>
<!-- Attachments preview -->
<AttachmentsPreview
:attachments="attachments"
:uploadingFiles="uploadingFiles"
:onDelete="handleOnFileDelete"
v-if="attachments.length > 0 || uploadingFiles.length > 0"
class="mt-2"
/>
<!-- Editor menu bar with send button -->
<ReplyBoxMenuBar
class="mt-1 shrink-0"
:handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
:isSending="isSending"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
:handleSend="handleSend"
@emojiSelect="handleEmojiSelect"
/>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { Maximize2, Minimize2 } from 'lucide-vue-next'
import Editor from './ConversationTextEditor.vue'
import { useConversationStore } from '@/stores/conversation'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useEmitter } from '@/composables/useEmitter'
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
// Define models for two-way binding
const messageType = defineModel('messageType', { default: 'reply' })
const cc = defineModel('cc', { default: '' })
const bcc = defineModel('bcc', { default: '' })
const showBcc = defineModel('showBcc', { default: false })
const emailErrors = defineModel('emailErrors', { default: () => [] })
const htmlContent = defineModel('htmlContent', { default: '' })
const textContent = defineModel('textContent', { default: '' })
const selectedText = defineModel('selectedText', { default: '' })
const isBold = defineModel('isBold', { default: false })
const isItalic = defineModel('isItalic', { default: false })
const cursorPosition = defineModel('cursorPosition', { default: 0 })
const props = defineProps({
isFullscreen: {
type: Boolean,
default: false
},
aiPrompts: {
type: Array,
required: true
},
isSending: {
type: Boolean,
required: true
},
uploadingFiles: {
type: Array,
required: true
},
clearEditorContent: {
type: Boolean,
required: true
},
contentToSet: {
type: String,
default: null
}
})
const emit = defineEmits([
'toggleFullscreen',
'send',
'fileUpload',
'inlineImageUpload',
'fileDelete',
'aiPromptSelected'
])
const conversationStore = useConversationStore()
const emitter = useEmitter()
const insertContent = ref(null)
const setInlineImage = ref(null)
const editorPlaceholder =
'Shift + Enter to add a new line. Cmd + Enter to send. Cmd + K to open command bar.'
const toggleBcc = async () => {
showBcc.value = !showBcc.value
await nextTick()
// If hiding BCC field, clear the content
if (!showBcc.value) {
bcc.value = ''
}
}
const toggleFullscreen = () => {
emit('toggleFullscreen')
}
const toggleBold = () => {
isBold.value = !isBold.value
}
const toggleItalic = () => {
isItalic.value = !isItalic.value
}
const attachments = computed(() => {
return conversationStore.conversation.mediaFiles.filter(
(upload) => upload.disposition === 'attachment'
)
})
const enableSend = computed(() => {
return (
(textContent.value.trim().length > 0 ||
conversationStore.conversation?.macro?.actions?.length > 0) &&
emailErrors.value.length === 0 &&
!props.uploadingFiles.length
)
})
/**
* Validate email addresses in the CC and BCC fields
* @param {string} field - 'cc' or 'bcc'
*/
const validateEmails = (field) => {
const emails = field === 'cc' ? cc.value : bcc.value
const emailList = emails
.split(',')
.map((e) => e.trim())
.filter((e) => e !== '')
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const invalidEmails = emailList.filter((email) => !emailRegex.test(email))
// Remove any existing errors for this field
emailErrors.value = emailErrors.value.filter(
(error) => !error.startsWith(`Invalid email(s) in ${field.toUpperCase()}`)
)
// Add new error if there are invalid emails
if (invalidEmails.length > 0) {
emailErrors.value.push(
`Invalid email(s) in ${field.toUpperCase()}: ${invalidEmails.join(', ')}`
)
}
}
/**
* Send the reply or private note
*/
const handleSend = async () => {
validateEmails('cc')
validateEmails('bcc')
if (emailErrors.value.length > 0) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: 'Please correct the email errors before sending.'
})
return
}
emit('send')
}
const handleFileUpload = (event) => {
emit('fileUpload', event)
}
const handleInlineImageUpload = (event) => {
emit('inlineImageUpload', event)
}
const handleOnFileDelete = (uuid) => {
emit('fileDelete', uuid)
}
const handleEmojiSelect = (emoji) => {
insertContent.value = undefined
// Force reactivity so the user can select the same emoji multiple times
nextTick(() => (insertContent.value = emoji))
}
const handleAiPromptSelected = (key) => {
emit('aiPromptSelected', key)
}
</script>

View File

@@ -35,7 +35,9 @@
<Smile class="h-4 w-4" />
</Toggle>
</div>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending">Send</Button>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending"
>Send</Button
>
</div>
</template>
@@ -52,11 +54,10 @@ const attachmentInput = ref(null)
const inlineImageInput = ref(null)
const isEmojiPickerVisible = ref(false)
const emojiPickerRef = ref(null)
const emit = defineEmits(['toggleBold', 'toggleItalic', 'emojiSelect'])
const emit = defineEmits(['emojiSelect'])
// Using defineProps for props that don't need two-way binding
defineProps({
isBold: Boolean,
isItalic: Boolean,
isSending: Boolean,
enableSend: Boolean,
handleSend: Function,
@@ -69,7 +70,11 @@ onClickOutside(emojiPickerRef, () => {
})
const triggerFileUpload = () => {
attachmentInput.value.click()
if (attachmentInput.value) {
// Clear the value to allow the same file to be uploaded again.
attachmentInput.value.value = ''
attachmentInput.value.click()
}
}
const toggleEmojiPicker = () => {

View File

@@ -8,7 +8,8 @@
<!-- Filters -->
<div class="bg-white p-2 flex justify-between items-center">
<DropdownMenu>
<!-- Status dropdown-menu, hidden when a view is selected as views are pre-filtered -->
<DropdownMenu v-if="!route.params.viewID">
<DropdownMenuTrigger asChild>
<Button variant="ghost" class="w-30">
<div>
@@ -28,6 +29,9 @@
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div v-else></div>
<!-- Sort dropdown-menu -->
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" class="w-30">
@@ -124,7 +128,10 @@
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
{{ isLoading ? 'Loading...' : 'Load more' }}
</Button>
<p class="text-sm text-gray-500" v-else-if="conversationStore.conversationsList.length > 10">
<p
class="text-sm text-gray-500"
v-else-if="conversationStore.conversationsList.length > 10"
>
All conversations loaded
</p>
</div>

View File

@@ -57,16 +57,18 @@
<div class="flex items-center mt-2 space-x-2">
<SlaBadge
v-if="conversation.first_response_due_at"
:dueAt="conversation.first_response_due_at"
:actualAt="conversation.first_reply_at"
:label="'FRD'"
:showSLAMet="false"
:showExtra="false"
/>
<SlaBadge
v-if="conversation.resolution_due_at"
:dueAt="conversation.resolution_due_at"
:actualAt="conversation.resolved_at"
:label="'RD'"
:showSLAMet="false"
:showExtra="false"
/>
</div>
</div>

View File

@@ -19,7 +19,11 @@
}"
>
<!-- Message Content -->
<div v-html="messageContent" :class="{ 'mb-3': message.attachments.length > 0 }"></div>
<div
v-dompurify-html="messageContent"
class="whitespace-pre-wrap break-words overflow-wrap-anywhere native-html"
:class="{ 'mb-3': message.attachments.length > 0 }"
/>
<!-- Attachments -->
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
@@ -125,3 +129,9 @@ const retryMessage = (msg) => {
api.retryMessage(convStore.current.uuid, msg.uuid)
}
</script>
<style scoped>
.overflow-wrap-anywhere {
overflow-wrap: anywhere;
}
</style>

View File

@@ -29,7 +29,7 @@
<Letter
:html="sanitizedMessageContent"
:allowedSchemas="['cid', 'https', 'http']"
class="mb-1"
class="mb-1 native-html"
:class="{ 'mb-3': message.attachments.length > 0 }"
/>

View File

@@ -22,16 +22,14 @@
<MessagesSkeleton :count="10" v-if="conversationStore.messages.loading" />
<TransitionGroup
v-else
enter-active-class="animate-slide-in"
tag="div"
class="space-y-4"
>
<TransitionGroup v-else enter-active-class="animate-slide-in" tag="div" class="space-y-4">
<div
v-for="message in conversationStore.conversationMessages"
v-for="(message, index) in conversationStore.conversationMessages"
:key="message.uuid"
:class="message.type === 'activity' ? 'my-2' : 'my-4'"
:class="{
'my-2': message.type === 'activity',
'pt-4': index === 0
}"
>
<div v-if="!message.private">
<ContactMessageBubble :message="message" v-if="message.type === 'incoming'" />
@@ -57,7 +55,7 @@
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div v-show="!isAtBottom" class="absolute bottom-12 right-6 z-10">
<div v-show="!isAtBottom" class="absolute bottom-5 right-6 z-10">
<button
@click="handleScrollToBottom"
class="w-10 h-10 rounded-full flex items-center justify-center shadow-lg border bg-white text-primary transition-colors duration-200 hover:bg-gray-100"

View File

@@ -27,8 +27,10 @@
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">First reply at</p>
<SlaBadge
v-if="conversation.first_response_due_at"
:dueAt="conversation.first_response_due_at"
:actualAt="conversation.first_reply_at"
:key="conversation.uuid"
/>
</div>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
@@ -43,7 +45,12 @@
<div class="flex flex-col gap-1 mb-5">
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">Resolved at</p>
<SlaBadge :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" />
<SlaBadge
v-if="conversation.resolution_due_at"
:dueAt="conversation.resolution_due_at"
:actualAt="conversation.resolved_at"
:key="conversation.uuid"
/>
</div>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<div v-else>

View File

@@ -6,7 +6,7 @@
collapsible
:default-value="['Actions', 'Information', 'Previous conversations']"
>
<AccordionItem value="Actions" class="border-0 mb-2 mb-2">
<AccordionItem value="Actions" class="border-0 mb-2">
<AccordionTrigger class="bg-muted px-4 py-3 text-sm font-medium rounded-lg mx-2">
Actions
</AccordionTrigger>

View File

@@ -1,25 +1,36 @@
<template>
<div class="flex flex-1 flex-col gap-x-5 box p-5 space-y-5 bg-white">
<div class="flex items-center space-x-2">
<p class="text-2xl">{{ title }}</p>
<p class="text-2xl flex items-center">{{ title }}</p>
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
<span class="blinking-dot"></span>
<p class="uppercase text-xs">Live</p>
</div>
</div>
<div class="flex justify-between pr-32">
<div v-for="(value, key) in counts" :key="key" class="flex flex-col items-center gap-y-2">
<div
v-for="(item, key) in filteredCounts"
:key="key"
class="flex flex-col items-center gap-y-2"
>
<span class="text-muted-foreground">{{ labels[key] }}</span>
<span class="text-2xl font-medium">{{ value }}</span>
<span class="text-2xl font-medium">{{ item }}</span>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
import { computed } from 'vue'
const props = defineProps({
counts: { type: Object, required: true },
labels: { type: Object, required: true },
title: { type: String, required: true }
})
// Filter out counts that don't have a label
const filteredCounts = computed(() => {
return Object.fromEntries(Object.entries(props.counts).filter(([key]) => props.labels[key]))
})
</script>

View File

@@ -1,32 +1,33 @@
<template>
<div v-if="dueAt" class="flex justify-start items-center space-x-2">
<TransitionGroup name="fade">
<!-- Overdue-->
<span v-if="sla?.status === 'overdue'" key="overdue" class="sla-badge box sla-overdue">
<AlertCircle size="10" class="text-red-800" />
<span class="text-xs text-red-800">{{ label }} Overdue</span>
<!-- Overdue-->
<span v-if="sla?.status === 'overdue'" key="overdue" class="sla-badge box sla-overdue">
<AlertCircle size="12" class="text-red-800" />
<span class="sla-text text-red-800"
>{{ label }} Overdue
<span v-if="showExtra">by {{ sla.value }}</span>
</span>
</span>
<!-- SLA Hit -->
<span
v-else-if="sla?.status === 'hit' && showSLAMet"
key="sla-hit"
class="sla-badge box sla-hit"
>
<CheckCircle size="10" />
<span class="sla-text">{{ label }} SLA met</span>
</span>
<!-- SLA Hit -->
<span
v-else-if="sla?.status === 'hit' && showExtra"
key="sla-hit"
class="sla-badge box sla-hit"
>
<CheckCircle size="12" />
<span class="sla-text">{{ label }} SLA met</span>
</span>
<!-- Remaining -->
<span
v-else-if="sla?.status === 'remaining'"
key="remaining"
class="sla-badge box sla-remaining"
>
<Clock size="10" />
<span class="sla-text">{{ label }} {{ sla.value }}</span>
</span>
</TransitionGroup>
<!-- Remaining -->
<span
v-else-if="sla?.status === 'remaining'"
key="remaining"
class="sla-badge box sla-remaining"
>
<Clock size="12" />
<span class="sla-text">{{ label }} {{ sla.value }}</span>
</span>
</div>
</template>
@@ -38,12 +39,16 @@ const props = defineProps({
dueAt: String,
actualAt: String,
label: String,
showSLAMet: {
showExtra: {
type: Boolean,
default: true
}
})
const { sla } = useSla(ref(props.dueAt), ref(props.actualAt))
let sla = null
if (props.dueAt) {
sla = useSla(ref(props.dueAt), ref(props.actualAt))
}
</script>
<style scoped>
@@ -62,4 +67,8 @@ const { sla } = useSla(ref(props.dueAt), ref(props.actualAt))
.sla-remaining {
@apply bg-yellow-100 text-yellow-800;
}
.sla-text {
@apply text-[0.65rem];
}
</style>

View File

@@ -65,6 +65,7 @@
</template>
<template #selected="{ selected }">
<div v-if="!selected">Select value</div>
<div v-if="modelFilter.field === 'assigned_user_id'">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-1">
@@ -76,7 +77,6 @@
</Avatar>
<span>{{ selected.label }}</span>
</div>
<span v-else>Select user</span>
</div>
</div>
<div v-else-if="modelFilter.field === 'assigned_team_id'">
@@ -85,7 +85,6 @@
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</span>
<span v-else>Select team</span>
</div>
</div>
<div v-else-if="selected">
@@ -114,7 +113,7 @@
</div>
<div class="flex items-center justify-between pt-3">
<Button variant="ghost" size="sm" @click.prevent="addFilter" class="text-slate-600">
<Button variant="ghost" size="sm" @click="addFilter" class="text-slate-600">
<Plus class="w-3 h-3 mr-1" /> Add filter
</Button>
<div class="flex gap-2" v-if="showButtons">
@@ -159,7 +158,7 @@ const createFilter = () => ({ field: '', operator: '', value: '' })
onMounted(() => {
if (modelValue.value.length === 0) {
modelValue.value.push(createFilter())
modelValue.value = [createFilter()]
}
})
@@ -171,6 +170,8 @@ const getModel = (field) => {
const fieldConfig = props.fields.find((f) => f.field === field)
return fieldConfig?.model || ''
}
// Set model for each filter
watch(
() => modelValue.value,
(filters) => {
@@ -183,8 +184,25 @@ watch(
{ deep: true }
)
const addFilter = () => modelValue.value.push(createFilter())
const removeFilter = (index) => modelValue.value.splice(index, 1)
// 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 = ''
}
})
}
)
const addFilter = () => {
modelValue.value = [...modelValue.value, createFilter()]
}
const removeFilter = (index) => {
modelValue.value = modelValue.value.filter((_, i) => i !== index)
}
const applyFilters = () => emit('apply', validFilters.value)
const clearFilters = () => {
modelValue.value = []

View File

@@ -1,9 +1,11 @@
<template>
<Dialog :open="openDialog" @update:open="openDialog = false">
<DialogContent>
<DialogContent class="min-w-[40%] min-h-[30%]">
<DialogHeader class="space-y-1">
<DialogTitle>{{ view?.id ? 'Edit' : 'Create' }} view</DialogTitle>
<DialogDescription> Views let you create filters and save them. </DialogDescription>
<DialogDescription>
Create and save custom filter views for quick access to your conversations.
</DialogDescription>
</DialogHeader>
<form @submit.prevent="onSubmit">
<div class="grid gap-4 py-4">
@@ -11,7 +13,13 @@
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input id="name" class="col-span-3" placeholder="Name" v-bind="componentField" />
<Input
id="name"
class="col-span-3"
placeholder="Name"
v-bind="componentField"
@keydown.enter.prevent="onSubmit"
/>
</FormControl>
<FormDescription>Enter an unique name for your view.</FormDescription>
<FormMessage />
@@ -21,9 +29,13 @@
<FormItem>
<FormLabel>Filters</FormLabel>
<FormControl>
<FilterBuilder :fields="filterFields" :showButtons="false" v-bind="componentField" />
<FilterBuilder
:fields="filterFields"
:showButtons="false"
v-bind="componentField"
/>
</FormControl>
<FormDescription>Add multiple filters to customize view.</FormDescription>
<FormDescription> Set one or more filters to customize view.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
@@ -65,6 +77,7 @@ import { toTypedSchema } from '@vee-validate/zod'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { OPERATOR } from '@/constants/filterConfig.js'
import { z } from 'zod'
import api from '@/api'
@@ -91,27 +104,53 @@ const formSchema = toTypedSchema(
name: z
.string()
.min(2, { message: 'Name must be at least 2 characters.' })
.max(250, { message: 'Name cannot exceed 250 characters.' }),
.max(30, { message: 'Name cannot exceed 30 characters.' }),
filters: z
.array(
z.object({
model: z.string({ required_error: 'Filter required' }),
field: z.string({ required_error: 'Filter required' }),
operator: z.string({ required_error: 'Filter required' }),
value: z.union([z.string(), z.number(), z.boolean()])
value: z.union([z.string(), z.number(), z.boolean()]).optional()
})
)
.default([])
.refine(
(filters) => filters.length > 0,
{ message: 'Please add at least one filter.' }
)
.refine(
(filters) =>
filters.every(
(f) =>
f.model &&
f.field &&
f.operator &&
([OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) || f.value)
),
{
message: "Please make sure you've filled the filter fields correctly."
}
)
})
)
const form = useForm({ validationSchema: formSchema })
const form = useForm({
validationSchema: formSchema,
validateOnMount: false,
validateOnInput: false,
validateOnBlur: false
})
const onSubmit = async () => {
const validationResult = await form.validate()
if (!validationResult.valid) return
const onSubmit = form.handleSubmit(async (values) => {
if (isSubmitting.value) return
isSubmitting.value = true
try {
const values = form.values
if (values.id) {
await api.updateView(values.id, values)
} else {
@@ -129,8 +168,9 @@ const onSubmit = form.handleSubmit(async (values) => {
} finally {
isSubmitting.value = false
}
})
}
// Set form values when view prop changes
watch(
() => view.value,
(newVal) => {

View File

@@ -1,11 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import { useAppSettingsStore } from './stores/appSettings'
import router from './router'
import mitt from 'mitt'
import api from './api'
import './assets/styles/main.scss'
import './utils/strings.js'
import VueDOMPurifyHTML from 'vue-dompurify-html'
import Root from './Root.vue'
const setFavicon = (url) => {
@@ -38,13 +40,18 @@ async function initApp () {
const i18n = createI18n(i18nConfig)
const app = createApp(Root)
const pinia = createPinia()
app.use(pinia)
// Store app settings in Pinia
const settingsStore = useAppSettingsStore()
settingsStore.setSettings(settings)
// Add emitter to global properties.
app.config.globalProperties.emitter = emitter
app.use(router)
app.use(pinia)
app.use(i18n)
app.use(VueDOMPurifyHTML)
app.mount('#app')
}

View File

@@ -65,7 +65,6 @@ const routes = [
path: '',
name: 'team-inbox',
component: InboxView,
props: true,
meta: { title: 'Team inbox' }
},
{
@@ -88,7 +87,6 @@ const routes = [
path: '',
name: 'view-inbox',
component: InboxView,
props: true,
meta: { title: 'View inbox' }
},
{
@@ -118,7 +116,6 @@ const routes = [
path: '',
name: 'inbox',
component: InboxView,
props: true,
meta: {
title: 'Inbox',
type: route => route.params.type === 'assigned' ? 'My inbox' : route.params.type

View File

@@ -0,0 +1,12 @@
import { defineStore } from 'pinia'
export const useAppSettingsStore = defineStore('settings', {
state: () => ({
settings: {}
}),
actions: {
setSettings (newSettings) {
this.settings = newSettings
}
}
})

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { computed, reactive, ref } from 'vue'
import { computed, reactive, ref, nextTick } from 'vue'
import { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter'
@@ -8,8 +8,8 @@ import MessageCache from '@/utils/conversation-message-cache'
import api from '@/api'
export const useConversationStore = defineStore('conversation', () => {
const CONV_LIST_PAGE_SIZE = 100
const MESSAGE_LIST_PAGE_SIZE = 100
const CONV_LIST_PAGE_SIZE = 50
const MESSAGE_LIST_PAGE_SIZE = 30
const priorities = ref([])
const statuses = ref([])
@@ -110,8 +110,11 @@ export const useConversationStore = defineStore('conversation', () => {
clearInterval(reRenderInterval)
}
function setMacro (macros) {
conversation.macro = macros
async function setMacro (macro) {
// Clear existing macro.
conversation.macro = {}
await nextTick()
conversation.macro = macro
}
function removeMacroAction (action) {
@@ -231,6 +234,10 @@ export const useConversationStore = defineStore('conversation', () => {
return conversation.data || {}
})
const hasConversationOpen = computed(() => {
return Object.keys(conversation.data || {}).length > 0
})
const currentBCC = computed(() => {
return conversation.data?.bcc || []
})
@@ -282,8 +289,10 @@ export const useConversationStore = defineStore('conversation', () => {
async function fetchMessages (uuid, fetchNextPage = false) {
// Messages are already cached?
let hasMessages = messages.data.getAllPagesMessages(uuid)
if (hasMessages.length > 0 && !fetchNextPage)
if (hasMessages.length > 0 && !fetchNextPage) {
markConversationAsRead(uuid)
return
}
// Fetch messages from server.
messages.loading = true
@@ -293,7 +302,6 @@ export const useConversationStore = defineStore('conversation', () => {
const response = await api.getConversationMessages(uuid, { page: page, page_size: MESSAGE_LIST_PAGE_SIZE })
const result = response.data?.data || {}
const newMessages = result.results || []
// Mark conversation as read
markConversationAsRead(uuid)
// Cache messages
messages.data.addMessages(uuid, newMessages, result.page, result.total_pages)
@@ -608,8 +616,8 @@ export const useConversationStore = defineStore('conversation', () => {
Object.assign(conversation, {
data: null,
participants: {},
macro: {},
mediaFiles: [],
macro: {},
loading: false,
errorMessage: ''
})
@@ -629,6 +637,7 @@ export const useConversationStore = defineStore('conversation', () => {
conversationsList,
conversationMessages,
currentConversationHasMoreMessages,
hasConversationOpen,
current,
currentContactName,
currentBCC,

View File

@@ -1,4 +1,4 @@
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter'
@@ -6,6 +6,7 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import { adminNavItems, reportsNavItems } from '@/constants/navigation'
import { filterNavItems } from '@/utils/nav-permissions'
import api from '@/api'
import { useStorage } from '@vueuse/core'
export const useUserStore = defineStore('user', () => {
const user = ref({
@@ -15,14 +16,15 @@ export const useUserStore = defineStore('user', () => {
avatar_url: '',
email: '',
teams: [],
permissions: []
permissions: [],
availability_status: 'offline'
})
const emitter = useEmitter()
const userID = computed(() => user.value.id)
const firstName = computed(() => user.value.first_name)
const lastName = computed(() => user.value.last_name)
const avatar = computed(() => user.value.avatar_url)
const firstName = computed(() => user.value.first_name || '')
const lastName = computed(() => user.value.last_name || '')
const avatar = computed(() => user.value.avatar_url || '')
const permissions = computed(() => user.value.permissions || [])
const email = computed(() => user.value.email)
const teams = computed(() => user.value.teams || [])
@@ -71,6 +73,10 @@ export const useUserStore = defineStore('user', () => {
}
}
const setCurrentUser = (userData) => {
user.value = userData
}
const setAvatar = (avatarURL) => {
if (typeof avatarURL !== 'string') {
console.warn('Avatar URL must be a string')
@@ -83,6 +89,23 @@ export const useUserStore = defineStore('user', () => {
user.value.avatar_url = ''
}
// Set and watch user availability status in localStorage to sync across tabs
const availabilityStatusStorage = useStorage('user_availability_status', user.value.availability_status)
watch(availabilityStatusStorage, (newVal) => {
user.value.availability_status = newVal
})
const updateUserAvailability = async (status, isManual = true) => {
try {
const apiStatus = status === 'away' && isManual ? 'away_manual' : status
await api.updateCurrentUserAvailability({ status: apiStatus })
user.value.availability_status = apiStatus
availabilityStatusStorage.value = apiStatus
} catch (error) {
if (error?.response?.status === 401) window.location.href = '/'
}
}
return {
user,
userID,
@@ -96,9 +119,11 @@ export const useUserStore = defineStore('user', () => {
getInitials,
hasAdminTabPermissions,
hasReportTabPermissions,
setCurrentUser,
getCurrentUser,
clearAvatar,
setAvatar,
updateUserAvailability,
can
}
})
})

View File

@@ -0,0 +1,7 @@
export function debounce (fn, delay) {
let timeout
return function (...args) {
clearTimeout(timeout)
timeout = setTimeout(() => fn(...args), delay)
}
}

View File

@@ -48,8 +48,13 @@ export const isGoHourMinuteDuration = (value) => {
const template = document.createElement('template')
export function getTextFromHTML(htmlString) {
template.innerHTML = htmlString
const text = template.content.textContent || template.content.innerText || ''
template.innerHTML = ''
return text;
try {
template.innerHTML = htmlString
const text = template.content.textContent || template.content.innerText || ''
template.innerHTML = ''
return text.trim()
} catch (error) {
console.error('Error converting HTML to text:', error)
return ''
}
}

View File

@@ -155,6 +155,7 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { SelectTag } from '@/components/ui/select'
import { OPERATOR } from '@/constants/filterConfig'
import {
Select,
SelectContent,
@@ -315,7 +316,8 @@ const handleSave = async (values) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Invalid rules',
variant: 'destructive',
description: 'Make sure you have atleast one action and one rule.'
description:
'Make sure you have atleast one action and one rule and their values are not empty.'
})
return
}
@@ -347,27 +349,53 @@ const handleSave = async (values) => {
}
}
// TODO: Add some vee-validate validations.
// TODO: Maybe we can do some vee validate magic here.
const areRulesValid = () => {
// Must have groups.
if (rule.value.rules[0].groups.length == 0) {
return false
}
// At least one group should have at least one rule
const group1HasRules = rule.value.rules[0].groups[0].rules.length > 0
const group2HasRules = rule.value.rules[0].groups[1].rules.length > 0
if (!group1HasRules && !group2HasRules) {
return false
}
// For both groups, each rule should have value, operator and field.
for (const group of rule.value.rules[0].groups) {
for (const rule of group.rules) {
if (!rule.field || !rule.operator) {
return false
}
// For 'set' and `not set` operator, value is not required.
if (rule.operator !== OPERATOR.SET && rule.operator !== OPERATOR.NOT_SET && !rule.value) {
return false
}
}
}
// Must have atleast one action.
if (rule.value.rules[0].actions.length == 0) {
return false
}
// Must have atleast 1 group.
if (rule.value.rules[0].groups.length == 0) {
return false
}
// Make sure each action has value.
for (const action of rule.value.rules[0].actions) {
// CSAT action does not require value, set dummy value.
if (action.type === 'send_csat') {
action.value = ['0']
}
// Group should have atleast one rule.
if (rule.value.rules[0].groups[0].rules.length == 0) {
return false
}
// Empty array, no value selected.
if (action.value.length === 0) {
return false
}
// Make sure each rule has all the required fields.
for (const group of rule.value.rules[0].groups) {
for (const rule of group.rules) {
if (!rule.value || !rule.operator || !rule.field) {
// Check if all values are present.
for (const key in action.value) {
if (!action.value[key]) {
return false
}
}

View File

@@ -62,9 +62,9 @@ const submitForm = async (values) => {
}
await api.updateOIDC(props.id, values)
toastDescription = 'Provider updated successfully'
router.push({ name: 'sso-list' })
} else {
await api.createOIDC(values)
router.push({ name: 'sso-list' })
toastDescription = 'Provider created successfully'
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {

View File

@@ -12,7 +12,7 @@
</template>
<script setup>
import { watch, onMounted } from 'vue'
import { watch, onMounted, onUnmounted } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import Conversation from '@/features/conversation/Conversation.vue'
import ConversationSideBarWrapper from '@/features/conversation/sidebar/ConversationSideBarWrapper.vue'
@@ -37,6 +37,10 @@ onMounted(() => {
if (props.uuid) fetchConversation(props.uuid)
})
onUnmounted(() => {
conversationStore.resetCurrentConversation()
})
// Watcher for UUID changes
watch(
() => props.uuid,

View File

@@ -1,5 +1,5 @@
<template>
<ConversationPlaceholder v-if="route.name === 'inbox'" />
<ConversationPlaceholder v-if="['inbox', 'team-inbox', 'view-inbox'].includes(route.name)" />
<router-view />
</template>

View File

@@ -138,12 +138,14 @@ import { Card, CardContent, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useEmitter } from '@/composables/useEmitter'
import { useUserStore } from '@/stores/user'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
const emitter = useEmitter()
const errorMessage = ref('')
const isLoading = ref(false)
const router = useRouter()
const userStore = useUserStore()
const loginForm = ref({
email: '',
password: ''
@@ -207,7 +209,10 @@ const loginAction = () => {
email: loginForm.value.email,
password: loginForm.value.password
})
.then(() => {
.then((resp) => {
if (resp?.data?.data) {
userStore.setCurrentUser(resp.data.data)
}
router.push({ name: 'inboxes' })
})
.catch((error) => {

View File

@@ -76,8 +76,7 @@
</main>
<footer class="p-6 text-center">
<div class="text-sm text-muted-foreground space-x-4">
</div>
<div class="text-sm text-muted-foreground space-x-4"></div>
</footer>
</div>
</template>
@@ -93,10 +92,13 @@ import { Button } from '@/components/ui/button'
import { Error } from '@/components/ui/error'
import { Card, CardContent, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { Label } from '@/components/ui/label'
const errorMessage = ref('')
const isLoading = ref(false)
const emitter = useEmitter()
const router = useRouter()
const resetForm = ref({
email: ''
@@ -121,16 +123,16 @@ const requestResetAction = async () => {
await api.resetPassword({
email: resetForm.value.email
})
toast({
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Reset link sent',
description: 'Please check your email for the reset link.'
})
router.push({ name: 'login' })
} catch (err) {
toast({
title: 'Error',
description: err.response.data.message,
variant: 'destructive'
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Reset link sent',
variant: 'destructive',
description: handleHTTPError(err).message
})
errorMessage.value = handleHTTPError(err).message
useTemporaryClass('reset-password-container', 'animate-shake')

View File

@@ -125,18 +125,16 @@ onMounted(() => {
})
const validateForm = () => {
if (!passwordForm.value.password || passwordForm.value.password.length < 8) {
errorMessage.value = 'Password must be at least 8 characters long.'
if (!passwordForm.value.password) {
errorMessage.value = 'Password is required.'
useTemporaryClass('set-password-container', 'animate-shake')
return false
}
if (passwordForm.value.password !== passwordForm.value.confirmPassword) {
errorMessage.value = 'Passwords do not match.'
useTemporaryClass('set-password-container', 'animate-shake')
return false
}
return true
}
@@ -156,11 +154,6 @@ const setPasswordAction = async () => {
})
router.push({ name: 'login' })
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(err).message
})
errorMessage.value = handleHTTPError(err).message
useTemporaryClass('set-password-container', 'animate-shake')
} finally {

View File

@@ -1,27 +1,29 @@
<template>
<div
class="overflow-y-auto p-4 pr-36"
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
>
<Spinner v-if="isLoading" />
<div class="space-y-4">
<div class="text-sm text-gray-500 text-right">
Last updated: {{ new Date(lastUpdate).toLocaleTimeString() }}
</div>
<div class="mt-7 flex w-full space-x-4">
<Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
<Card
class="w-8/12"
title="Agent status"
:counts="sampleAgentStatusCounts"
:labels="sampleAgentStatusLabels"
/>
</div>
<div class="rounded-lg box w-full p-5 bg-white">
<LineChart :data="chartData.processedData"></LineChart>
</div>
<div class="rounded-lg box w-full p-5 bg-white">
<BarChart :data="chartData.status_summary"></BarChart>
<div class="overflow-y-auto">
<div
class="p-4 w-[calc(100%-3rem)]"
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
>
<Spinner v-if="isLoading" />
<div class="space-y-4">
<div class="text-sm text-gray-500 text-right">
Last updated: {{ new Date(lastUpdate).toLocaleTimeString() }}
</div>
<div class="mt-7 flex w-full space-x-4">
<Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
<Card
class="w-8/12"
title="Agent status"
:counts="agentStatusCounts"
:labels="agentStatusLabels"
/>
</div>
<div class="rounded-lg box w-full p-5 bg-white">
<LineChart :data="chartData.processedData"></LineChart>
</div>
<div class="rounded-lg box w-full p-5 bg-white">
<BarChart :data="chartData.status_summary"></BarChart>
</div>
</div>
</div>
</div>
@@ -52,18 +54,18 @@ const agentCountCardsLabels = {
pending: 'Pending'
}
// TODO: Build agent status feature.
const sampleAgentStatusLabels = {
online: 'Online',
offline: 'Offline',
away: 'Away'
}
const sampleAgentStatusCounts = {
online: 5,
offline: 2,
away: 1
const agentStatusLabels = {
agents_online: 'Online',
agents_offline: 'Offline',
agents_away: 'Away'
}
const agentStatusCounts = ref({
agents_online: 0,
agents_offline: 0,
agents_away: 0
})
onMounted(() => {
getDashboardData()
startRealtimeUpdates()
@@ -96,6 +98,11 @@ const getCardStats = async () => {
.getOverviewCounts()
.then((resp) => {
cardCounts.value = resp.data.data
agentStatusCounts.value = {
agents_online: cardCounts.value.agents_online,
agents_offline: cardCounts.value.agents_offline,
agents_away: cardCounts.value.agents_away
}
})
.catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {

View File

@@ -1,4 +1,5 @@
const animate = require("tailwindcss-animate")
const typography = require("@tailwindcss/typography")
/** @type {import('tailwindcss').Config} */
module.exports = {
@@ -140,5 +141,5 @@ module.exports = {
},
},
},
plugins: [animate],
}
plugins: [animate, typography],
}

1
go.mod
View File

@@ -35,6 +35,7 @@ require (
github.com/zerodha/simplesessions/stores/redis/v3 v3.0.0
github.com/zerodha/simplesessions/v3 v3.0.0
golang.org/x/crypto v0.31.0
golang.org/x/mod v0.17.0
golang.org/x/oauth2 v0.21.0
)

2
go.sum
View File

@@ -187,6 +187,8 @@ golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"embed"
"encoding/json"
"errors"
"github.com/abhinavxd/libredesk/internal/ai/models"
"github.com/abhinavxd/libredesk/internal/dbutil"
@@ -16,6 +17,9 @@ import (
var (
//go:embed queries.sql
efs embed.FS
ErrInvalidAPIKey = errors.New("invalid API Key")
ErrApiKeyNotSet = errors.New("api Key not set")
)
// Manager manages LLM providers.
@@ -35,6 +39,7 @@ type queries struct {
GetDefaultProvider *sqlx.Stmt `query:"get-default-provider"`
GetPrompt *sqlx.Stmt `query:"get-prompt"`
GetPrompts *sqlx.Stmt `query:"get-prompts"`
SetOpenAIKey *sqlx.Stmt `query:"set-openai-key"`
}
// New creates and returns a new instance of the Manager.
@@ -69,6 +74,14 @@ func (m *Manager) Completion(k string, prompt string) (string, error) {
response, err := client.SendPrompt(payload)
if err != nil {
if errors.Is(err, ErrInvalidAPIKey) {
m.lo.Error("error invalid API key", "error", err)
return "", envelope.NewError(envelope.InputError, "OpenAI API Key is invalid, Please ask your administrator to set it up", nil)
}
if errors.Is(err, ErrApiKeyNotSet) {
m.lo.Error("error API key not set", "error", err)
return "", envelope.NewError(envelope.InputError, "OpenAI API Key is not set, Please ask your administrator to set it up", nil)
}
m.lo.Error("error sending prompt to provider", "error", err)
return "", envelope.NewError(envelope.GeneralError, err.Error(), nil)
}
@@ -86,6 +99,26 @@ func (m *Manager) GetPrompts() ([]models.Prompt, error) {
return prompts, nil
}
// UpdateProvider updates a provider.
func (m *Manager) UpdateProvider(provider, apiKey string) error {
switch ProviderType(provider) {
case ProviderOpenAI:
return m.setOpenAIAPIKey(apiKey)
default:
m.lo.Error("unsupported provider type", "provider", provider)
return envelope.NewError(envelope.GeneralError, "Unsupported provider type", nil)
}
}
// setOpenAIAPIKey sets the OpenAI API key in the database.
func (m *Manager) setOpenAIAPIKey(apiKey string) error {
if _, err := m.q.SetOpenAIKey.Exec(apiKey); err != nil {
m.lo.Error("error setting OpenAI API key", "error", err)
return envelope.NewError(envelope.GeneralError, "Error setting OpenAI API key", nil)
}
return nil
}
// getPrompt returns a prompt from the database.
func (m *Manager) getPrompt(k string) (string, error) {
var p models.Prompt

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"time"
"github.com/valyala/fasthttp"
"github.com/zerodha/logf"
)
@@ -28,7 +29,7 @@ func NewOpenAIClient(apiKey string, lo *logf.Logger) *OpenAIClient {
// SendPrompt sends a prompt to the OpenAI API and returns the response text.
func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
if o.apikey == "" {
return "", fmt.Errorf("OpenAI API key is not set, Please ask your administrator to set the key")
return "", ErrApiKeyNotSet
}
apiURL := "https://api.openai.com/v1/chat/completions"
@@ -48,7 +49,7 @@ func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
return "", fmt.Errorf("marshalling request body: %w", err)
}
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(bodyBytes))
req, err := http.NewRequest(fasthttp.MethodPost, apiURL, bytes.NewBuffer(bodyBytes))
if err != nil {
o.lo.Error("error creating request", "error", err)
return "", fmt.Errorf("error creating request: %w", err)
@@ -65,11 +66,12 @@ func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return "", fmt.Errorf("OpenAI API key is invalid, Please ask your administrator to update the key")
return "", ErrInvalidAPIKey
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
o.lo.Error("non-ok response received from openai API", "status", resp.Status, "code", resp.StatusCode, "response_text", body)
return "", fmt.Errorf("API error: %s, body: %s", resp.Status, body)
}

View File

@@ -5,4 +5,13 @@ SELECT id, name, provider, config, is_default FROM ai_providers where is_default
SELECT id, key, title, content FROM ai_prompts where key = $1;
-- name: get-prompts
SELECT id, key, title FROM ai_prompts order by title;
SELECT id, key, title FROM ai_prompts order by title;
-- name: set-openai-key
UPDATE ai_providers
SET config = jsonb_set(
COALESCE(config, '{}'::jsonb),
'{api_key}',
to_jsonb($1::text)
)
WHERE provider = 'openai';

View File

@@ -90,9 +90,10 @@ func New(cfg Config, rd *redis.Client, logger *logf.Logger) (*Auth, error) {
EnableAutoCreate: true,
SessionIDLength: 64,
Cookie: simplesessions.CookieOptions{
Name: "libredesk_session",
IsHTTPOnly: true,
IsSecure: true,
Expires: time.Now().Add(time.Hour * 48),
MaxAge: time.Hour * 9,
},
})
@@ -388,6 +389,7 @@ func simpleSessGetCookieCB(name string, r interface{}) (*http.Cookie, error) {
Path: string(c.Path()),
Domain: string(c.Domain()),
Expires: c.Expire(),
MaxAge: c.MaxAge(),
Secure: c.Secure(),
HttpOnly: c.HTTPOnly(),
SameSite: http.SameSite(c.SameSite()),
@@ -410,6 +412,7 @@ func simpleSessSetCookieCB(c *http.Cookie, w interface{}) error {
fc.SetPath(c.Path)
fc.SetDomain(c.Domain)
fc.SetExpire(c.Expires)
fc.SetMaxAge(int(c.MaxAge))
fc.SetSecure(c.Secure)
fc.SetHTTPOnly(c.HttpOnly)
fc.SetSameSite(fasthttp.CookieSameSite(c.SameSite))

View File

@@ -12,6 +12,7 @@ const (
PermConversationsUpdatePriority = "conversations:update_priority"
PermConversationsUpdateStatus = "conversations:update_status"
PermConversationsUpdateTags = "conversations:update_tags"
PermConversationWrite = "conversations:write"
PermMessagesRead = "messages:read"
PermMessagesWrite = "messages:write"
@@ -62,6 +63,9 @@ const (
// OpenID Connect SSO
PermOIDCManage = "oidc:manage"
// AI
PermAIManage = "ai:manage"
)
var validPermissions = map[string]struct{}{
@@ -75,6 +79,7 @@ var validPermissions = map[string]struct{}{
PermConversationsUpdatePriority: {},
PermConversationsUpdateStatus: {},
PermConversationsUpdateTags: {},
PermConversationWrite: {},
PermMessagesRead: {},
PermMessagesWrite: {},
PermViewManage: {},
@@ -93,6 +98,7 @@ var validPermissions = map[string]struct{}{
PermGeneralSettingsManage: {},
PermNotificationSettingsManage: {},
PermOIDCManage: {},
PermAIManage: {},
}
// IsValidPermission returns true if it's a valid permission.

View File

@@ -24,6 +24,7 @@ import (
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
notifier "github.com/abhinavxd/libredesk/internal/notification"
slaModels "github.com/abhinavxd/libredesk/internal/sla/models"
@@ -96,7 +97,7 @@ type teamStore interface {
}
type userStore interface {
Get(int) (umodels.User, error)
GetAgent(int) (umodels.User, error)
GetSystemUser() (umodels.User, error)
CreateContact(user *umodels.User) error
}
@@ -112,6 +113,7 @@ type mediaStore interface {
type inboxStore interface {
Get(int) (inbox.Inbox, error)
GetDBRecord(int) (imodels.Inbox, error)
}
type settingsStore interface {
@@ -182,7 +184,6 @@ func New(
type queries struct {
// Conversation queries.
GetLatestReceivedMessageSourceID *sqlx.Stmt `query:"get-latest-received-message-source-id"`
GetToAddress *sqlx.Stmt `query:"get-to-address"`
GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"`
GetConversation *sqlx.Stmt `query:"get-conversation"`
@@ -207,6 +208,7 @@ type queries struct {
UnassignOpenConversations *sqlx.Stmt `query:"unassign-open-conversations"`
ReOpenConversation *sqlx.Stmt `query:"re-open-conversation"`
UnsnoozeAll *sqlx.Stmt `query:"unsnooze-all"`
DeleteConversation *sqlx.Stmt `query:"delete-conversation"`
// Dashboard queries.
GetDashboardCharts string `query:"get-dashboard-charts"`
@@ -216,6 +218,7 @@ type queries struct {
GetMessage *sqlx.Stmt `query:"get-message"`
GetMessages string `query:"get-messages"`
GetPendingMessages *sqlx.Stmt `query:"get-pending-messages"`
GetMessageSourceIDs *sqlx.Stmt `query:"get-message-source-ids"`
GetConversationUUIDFromMessageUUID *sqlx.Stmt `query:"get-conversation-uuid-from-message-uuid"`
InsertMessage *sqlx.Stmt `query:"insert-message"`
UpdateMessageStatus *sqlx.Stmt `query:"update-message-status"`
@@ -224,13 +227,13 @@ type queries struct {
}
// CreateConversation creates a new conversation and returns its ID and UUID.
func (c *Manager) CreateConversation(contactID, contactChannelID, inboxID int, lastMessage string, lastMessageAt time.Time, subject string) (int, string, error) {
func (c *Manager) CreateConversation(contactID, contactChannelID, inboxID int, lastMessage string, lastMessageAt time.Time, subject string, appendRefNumToSubject bool) (int, string, error) {
var (
id int
uuid string
prefix string
)
if err := c.q.InsertConversation.QueryRow(contactID, contactChannelID, models.StatusOpen, inboxID, lastMessage, lastMessageAt, subject, prefix).Scan(&id, &uuid); err != nil {
if err := c.q.InsertConversation.QueryRow(contactID, contactChannelID, models.StatusOpen, inboxID, lastMessage, lastMessageAt, subject, prefix, appendRefNumToSubject).Scan(&id, &uuid); err != nil {
c.lo.Error("error inserting new conversation into the DB", "error", err)
return id, uuid, err
}
@@ -738,26 +741,28 @@ func (m *Manager) GetToAddress(conversationID int) ([]string, error) {
return addr, nil
}
// GetLatestReceivedMessageSourceID returns the last received message source ID.
func (m *Manager) GetLatestReceivedMessageSourceID(conversationID int) (string, error) {
var out string
if err := m.q.GetLatestReceivedMessageSourceID.Get(&out, conversationID); err != nil {
m.lo.Error("error fetching message source id", "error", err, "conversation_id", conversationID)
return out, err
// GetMessageSourceIDs retrieves source IDs for messages in a conversation in descending order.
// So the oldest message will be the last in the list.
func (m *Manager) GetMessageSourceIDs(conversationID, limit int) ([]string, error) {
var refs []string
if err := m.q.GetMessageSourceIDs.Select(&refs, conversationID, limit); err != nil {
m.lo.Error("error fetching message source IDs", "conversation_id", conversationID, "error", err)
return refs, err
}
return out, nil
return refs, nil
}
// SendAssignedConversationEmail sends a email for an assigned conversation to the passed user ids.
func (m *Manager) SendAssignedConversationEmail(userIDs []int, conversation models.Conversation) error {
agent, err := m.userStore.Get(userIDs[0])
agent, err := m.userStore.GetAgent(userIDs[0])
if err != nil {
m.lo.Error("error fetching agent", "user_id", userIDs[0], "error", err)
return fmt.Errorf("fetching agent: %w", err)
}
content, subject, err := m.template.RenderNamedTemplate(template.TmplConversationAssigned,
map[string]interface{}{
content, subject, err := m.template.RenderStoredEmailTemplate(template.TmplConversationAssigned,
map[string]any{
// Kept these lower case keys for backward compatibility.
"conversation": map[string]string{
"subject": conversation.Subject.String,
"uuid": conversation.UUID,
@@ -767,6 +772,31 @@ func (m *Manager) SendAssignedConversationEmail(userIDs []int, conversation mode
"agent": map[string]string{
"full_name": agent.FullName(),
},
// Following the new structure.
"Conversation": map[string]any{
"ReferenceNumber": conversation.ReferenceNumber,
"Subject": conversation.Subject.String,
"Priority": conversation.Priority.String,
"UUID": conversation.UUID,
},
"Agent": map[string]any{
"FirstName": agent.FirstName,
"LastName": agent.LastName,
"FullName": agent.FullName(),
"Email": agent.Email,
},
"Contact": map[string]any{
"FirstName": conversation.Contact.FirstName,
"LastName": conversation.Contact.LastName,
"FullName": conversation.Contact.FullName(),
"Email": conversation.Contact.Email,
},
"Recipient": map[string]any{
"FirstName": agent.FirstName,
"LastName": agent.LastName,
"FullName": agent.FullName(),
"Email": agent.Email,
},
})
if err != nil {
m.lo.Error("error rendering template", "template", template.TmplConversationAssigned, "conversation_uuid", conversation.UUID, "error", err)
@@ -849,7 +879,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
case amodels.ActionSendPrivateNote:
return m.SendPrivateNote([]mmodels.Media{}, user.ID, conv.UUID, action.Value[0])
case amodels.ActionReply:
return m.SendReply([]mmodels.Media{}, user.ID, conv.UUID, action.Value[0], nil, nil, nil)
return m.SendReply([]mmodels.Media{}, conv.InboxID, user.ID, conv.UUID, action.Value[0], nil, nil, nil)
case amodels.ActionSetSLA:
slaID, _ := strconv.Atoi(action.Value[0])
return m.ApplySLA(conv, slaID, user)
@@ -887,7 +917,16 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
meta := map[string]interface{}{
"is_csat": true,
}
return m.SendReply([]mmodels.Media{}, actorUserID, conversation.UUID, message, nil, nil, meta)
return m.SendReply([]mmodels.Media{}, conversation.InboxID, actorUserID, conversation.UUID, message, nil, nil, meta)
}
// DeleteConversation deletes a conversation.
func (m *Manager) DeleteConversation(uuid string) error {
if _, err := m.q.DeleteConversation.Exec(uuid); err != nil {
m.lo.Error("error deleting conversation", "error", err)
return envelope.NewError(envelope.GeneralError, "Error deleting conversation", nil)
}
return nil
}
// addConversationParticipant adds a user as participant to a conversation.

View File

@@ -50,7 +50,7 @@ const (
ContentTypeHTML = "html"
maxLastMessageLen = 45
maxMessagesPerPage = 30
maxMessagesPerPage = 100
)
// Run starts a pool of worker goroutines to handle message dispatching via inbox's channel and processes incoming messages. It scans for
@@ -178,10 +178,29 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
return
}
// Set message sender and receiver
// Set from and to addresses
message.From = inbox.FromAddress()
message.To, _ = m.GetToAddress(message.ConversationID)
message.InReplyTo, _ = m.GetLatestReceivedMessageSourceID(message.ConversationID)
message.To, err = m.GetToAddress(message.ConversationID)
if handleError(err, "error fetching `to` address") {
return
}
// Set "In-Reply-To" and "References" headers, logging any errors but continuing to send the message.
// Include only the last 20 messages as references to avoid exceeding header size limits.
message.References, err = m.GetMessageSourceIDs(message.ConversationID, 20)
if err != nil {
m.lo.Error("Error fetching conversation source IDs", "error", err)
}
// References is sorted in DESC i.e newest message first, so reverse it to keep the references in order.
stringutil.ReverseSlice(message.References)
// Remove the current message ID from the references.
message.References = stringutil.RemoveItemByValue(message.References, message.SourceID.String)
if len(message.References) > 0 {
message.InReplyTo = message.References[len(message.References)-1]
}
// Send message
err = inbox.Send(message)
@@ -203,7 +222,27 @@ func (m *Manager) RenderContentInTemplate(channel string, message *models.Messag
m.lo.Error("error fetching conversation", "uuid", message.ConversationUUID, "error", err)
return fmt.Errorf("fetching conversation: %w", err)
}
message.Content, err = m.template.RenderWithBaseTemplate(conversation, message.Content)
// Pass conversation and contact data to the template for rendering any placeholders.
message.Content, err = m.template.RenderEmailWithTemplate(map[string]any{
"Conversation": map[string]any{
"ReferenceNumber": conversation.ReferenceNumber,
"Subject": conversation.Subject.String,
"Priority": conversation.Priority.String,
"UUID": conversation.UUID,
},
"Contact": map[string]any{
"FirstName": conversation.Contact.FirstName,
"LastName": conversation.Contact.LastName,
"FullName": conversation.Contact.FullName(),
"Email": conversation.Contact.Email,
},
"Recipient": map[string]any{
"FirstName": conversation.Contact.FirstName,
"LastName": conversation.Contact.LastName,
"FullName": conversation.Contact.FullName(),
"Email": conversation.Contact.Email,
},
}, message.Content)
if err != nil {
m.lo.Error("could not render email content using template", "id", message.ID, "error", err)
return fmt.Errorf("could not render email content using template: %w", err)
@@ -293,11 +332,10 @@ func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversat
}
// SendReply inserts a reply message in a conversation.
func (m *Manager) SendReply(media []mmodels.Media, senderID int, conversationUUID, content string, cc, bcc []string, meta map[string]interface{}) error {
func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conversationUUID, content string, cc, bcc []string, meta map[string]interface{}) error {
// Save cc and bcc as JSON in meta.
cc = stringutil.RemoveEmpty(cc)
bcc = stringutil.RemoveEmpty(bcc)
// Save cc and bcc as JSON in meta.
if len(cc) > 0 {
meta["cc"] = cc
}
@@ -308,6 +346,19 @@ func (m *Manager) SendReply(media []mmodels.Media, senderID int, conversationUUI
if err != nil {
return envelope.NewError(envelope.GeneralError, "Error marshalling message meta", nil)
}
// Generage unique source ID i.e. message-id for email.
inbox, err := m.inboxStore.GetDBRecord(inboxID)
if err != nil {
return err
}
sourceID, err := stringutil.GenerateEmailMessageID(conversationUUID, inbox.From)
if err != nil {
m.lo.Error("error generating source message id", "error", err)
return envelope.NewError(envelope.GeneralError, "Error generating source message id", nil)
}
// Insert Message.
message := models.Message{
ConversationUUID: conversationUUID,
SenderID: senderID,
@@ -319,6 +370,7 @@ func (m *Manager) SendReply(media []mmodels.Media, senderID int, conversationUUI
Private: false,
Media: media,
Meta: string(metaJSON),
SourceID: null.StringFrom(sourceID),
}
return m.InsertMessage(&message)
}
@@ -355,8 +407,14 @@ func (m *Manager) InsertMessage(message *models.Message) error {
return err
}
// Update conversation last message details in conversation metadata.
m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, message.TextContent, message.SenderType, message.CreatedAt)
// Hide CSAT message content as it contains a public link to the survey.
lastMessage := message.TextContent
if message.HasCSAT() {
lastMessage = "Please rate your experience with us"
}
// Update conversation last message details in conversation.
m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, lastMessage, message.SenderType, message.CreatedAt)
// Broadcast new message.
m.BroadcastNewMessage(message)
@@ -371,7 +429,7 @@ func (m *Manager) RecordAssigneeUserChange(conversationUUID string, assigneeID i
}
// Assignment to another user.
assignee, err := m.userStore.Get(assigneeID)
assignee, err := m.userStore.GetAgent(assigneeID)
if err != nil {
return err
}
@@ -655,11 +713,8 @@ func (m *Manager) findOrCreateConversation(in *models.Message, inboxID, contactC
conversationUUID string
)
// Search for existing conversation.
sourceIDs := in.References
if in.InReplyTo != "" {
sourceIDs = append(sourceIDs, in.InReplyTo)
}
// Search for existing conversation using the in-reply-to and references.
sourceIDs := append([]string{in.InReplyTo}, in.References...)
conversationID, err = m.findConversationID(sourceIDs)
if err != nil && err != errConversationNotFound {
return new, err
@@ -670,7 +725,7 @@ func (m *Manager) findOrCreateConversation(in *models.Message, inboxID, contactC
new = true
lastMessage := stringutil.HTML2Text(in.Content)
lastMessageAt := time.Now()
conversationID, conversationUUID, err = m.CreateConversation(contactID, contactChannelID, inboxID, lastMessage, lastMessageAt, in.Subject)
conversationID, conversationUUID, err = m.CreateConversation(contactID, contactChannelID, inboxID, lastMessage, lastMessageAt, in.Subject, false /**append reference number to subject**/)
if err != nil || conversationID == 0 {
return new, err
}

View File

@@ -119,6 +119,7 @@ type Message struct {
InReplyTo string `json:"-"`
Headers textproto.MIMEHeader `json:"-"`
Media []mmodels.Media `db:"-" json:"-"`
IsCSAT bool `db:"-" json:"-"`
Total int `db:"total" json:"-"`
}
@@ -134,6 +135,16 @@ func (m *Message) CensorCSATContent() {
}
}
// HasCSAT returns true if the message is a CSAT message.
func (m *Message) HasCSAT() bool {
var meta map[string]interface{}
if err := json.Unmarshal([]byte(m.Meta), &meta); err != nil {
return false
}
isCsat, _ := meta["is_csat"].(bool)
return isCsat
}
// IncomingMessage links a message with the contact information and inbox id.
type IncomingMessage struct {
Message Message

View File

@@ -9,7 +9,7 @@ status_id AS (
SELECT id FROM conversation_statuses WHERE name = $3
),
reference_number AS (
SELECT generate_reference_number($8) as reference_number
SELECT generate_reference_number($8) AS reference_number
)
INSERT INTO conversations
(contact_id, contact_channel_id, status_id, inbox_id, last_message, last_message_at, subject, reference_number)
@@ -20,7 +20,10 @@ VALUES(
$4,
$5,
$6,
$7,
CASE
WHEN $9 = TRUE THEN CONCAT($7::text, ' [', (SELECT reference_number FROM reference_number), ']')
ELSE $7::text
END,
(SELECT reference_number FROM reference_number)
)
RETURNING id, uuid;
@@ -234,7 +237,10 @@ SELECT json_build_object(
'open', COUNT(*),
'awaiting_response', COUNT(CASE WHEN c.waiting_since IS NOT NULL THEN 1 END),
'unassigned', COUNT(CASE WHEN c.assigned_user_id IS NULL THEN 1 END),
'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END)
'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END),
'agents_online', (SELECT COUNT(*) FROM users WHERE availability_status = 'online' AND type = 'agent' AND deleted_at is null),
'agents_away', (SELECT COUNT(*) FROM users WHERE availability_status in ('away', 'away_manual') AND type = 'agent' AND deleted_at is null),
'agents_offline', (SELECT COUNT(*) FROM users WHERE availability_status = 'offline' AND type = 'agent' AND deleted_at is null)
)
FROM conversations c
INNER JOIN conversation_statuses s ON c.status_id = s.id
@@ -359,13 +365,17 @@ SET assigned_user_id = NULL,
updated_at = now()
WHERE assigned_user_id = $1 AND status_id in (SELECT id FROM conversation_statuses WHERE name NOT IN ('Resolved', 'Closed'));
-- MESSAGE queries.
-- name: get-latest-received-message-source-id
SELECT source_id
-- name: get-message-source-ids
SELECT
source_id
FROM conversation_messages
WHERE conversation_id = $1 and status = 'received'
WHERE conversation_id = $1
AND type in ('incoming', 'outgoing') and private = false
and source_id > ''
ORDER BY id DESC
LIMIT 1;
LIMIT $2;
-- name: get-pending-messages
SELECT
@@ -517,4 +527,7 @@ SET status_id = (SELECT id FROM conversation_statuses WHERE name = 'Open'), snoo
updated_at = now()
WHERE uuid = $1 and status_id in (
SELECT id FROM conversation_statuses WHERE name IN ('Snoozed', 'Closed', 'Resolved')
)
)
-- name: delete-conversation
DELETE FROM conversations WHERE uuid = $1;

View File

@@ -23,3 +23,14 @@ func IsUniqueViolationError(err error) bool {
}
return false
}
// IsTableNotExistError checks if the given error is a PostgreSQL table does not exist error (error code 42P01)
func IsTableNotExistError(err error) bool {
if err == nil {
return false
}
if pqErr, ok := err.(*pq.Error); ok {
return pqErr.Code == "42P01"
}
return false
}

View File

@@ -112,16 +112,24 @@ func (e *Email) Send(m models.Message) error {
email.Headers.Set(key, value[0])
}
// Set In-Reply-To and References headers
// Set In-Reply-To header
if m.InReplyTo != "" {
email.Headers.Set(headerInReplyTo, "<"+m.InReplyTo+">")
e.lo.Debug("In-Reply-To header set", "message_id", m.InReplyTo)
}
// Set references message ids
// Set message id header
if m.SourceID.String != "" {
email.Headers.Set(headerMessageID, fmt.Sprintf("<%s>", m.SourceID.String))
e.lo.Debug("Message-ID header set", "message_id", m.SourceID.String)
}
// Set references header
var references string
for _, ref := range m.References {
references += "<" + ref + "> "
}
e.lo.Debug("References header set", "references", references)
email.Headers.Set(headerReferences, references)
// Set email content

View File

@@ -0,0 +1,22 @@
package migrations
import (
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V0_3_0 updates the database schema to v0.3.0.
func V0_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
_, err := db.Exec(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_availability_status') THEN
CREATE TYPE user_availability_status AS ENUM ('online', 'away', 'away_manual', 'offline');
END IF;
END$$;
ALTER TABLE users ADD COLUMN IF NOT EXISTS availability_status user_availability_status DEFAULT 'offline' NOT NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ NULL;
`)
return err
}

View File

@@ -0,0 +1,36 @@
package migrations
import (
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V0_4_0 updates the database schema to v0.4.0.
func V0_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
// Admin role gets new permissions.
_, err := db.Exec(`
UPDATE roles
SET permissions = array_append(permissions, 'ai:manage')
WHERE name = 'Admin' AND NOT ('ai:manage' = ANY(permissions));
`)
if err != nil {
return err
}
_, err = db.Exec(`
UPDATE roles
SET permissions = array_append(permissions, 'conversations:write')
WHERE name = 'Admin' AND NOT ('conversations:write' = ANY(permissions));
`)
if err != nil {
return err
}
// Create trigram index on users.email if it doesn't exist.
_, err = db.Exec(`
CREATE INDEX IF NOT EXISTS index_tgrm_users_on_email
ON users USING GIN (email gin_trgm_ops);
`)
return err
}

View File

@@ -1,8 +1,8 @@
-- name: get-all-oidc
SELECT id, created_at, updated_at, name, provider, provider_url, client_id, client_secret, enabled FROM oidc order by updated_at desc;
SELECT id, created_at, updated_at, name, provider, client_id, client_secret, provider_url, enabled FROM oidc order by updated_at desc;
-- name: get-all-enabled
SELECT id, name, enabled, provider, updated_at FROM oidc WHERE enabled = true order by updated_at desc;
SELECT id, name, enabled, provider, client_id, updated_at FROM oidc WHERE enabled = true order by updated_at desc;
-- name: get-oidc
SELECT * FROM oidc WHERE id = $1;

View File

@@ -16,3 +16,10 @@ type Message struct {
ConversationUUID string `db:"conversation_uuid" json:"conversation_uuid"`
ConversationReferenceNumber string `db:"conversation_reference_number" json:"conversation_reference_number"`
}
type Contact struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
Email string `db:"email" json:"email"`
}

Some files were not shown because too many files have changed in this diff Show More