Compare commits

...

70 Commits

Author SHA1 Message Date
Abhinav Raut
f3acc37405 update version to 0.8.0-beta in package.json
remove caution note about alpha status from README.md
2025-09-30 14:35:53 +05:30
Abhinav Raut
562babf222 Merge pull request #150 from csr4422/fix/convert-select-values
Fix: convert select values to number or null before submit
2025-09-29 23:01:36 +05:30
csr4422
93e94432f5 feat: add None option to SLA and Business Hours select boxes 2025-09-29 21:20:20 +05:30
csr4422
ec63604163 Fix: convert select values to number or null before submit 2025-09-29 17:18:36 +05:30
Abhinav Raut
f06da2a861 Merge pull request #149 from csr4422/add-password-toggle-btn
Add password toggle to login page
2025-09-26 17:01:35 +05:30
csr4422
98f16854c8 Add password toggle to login page 2025-09-26 16:50:43 +05:30
Abhinav Raut
cc36ef5a3a bump simple s3 2025-09-18 00:44:27 +05:30
Abhinav Raut
969d6ea4f9 Merge pull request #129 from abhinavxd/dependabot/go_modules/github.com/go-viper/mapstructure/v2-2.4.0
chore(deps): bump github.com/go-viper/mapstructure/v2 from 2.0.0-alpha.1 to 2.4.0
2025-09-17 19:23:14 +05:30
Abhinav Raut
326ccdf9d4 Merge pull request #138 from abhinavxd/dependabot/npm_and_yarn/frontend/axios-1.12.0
chore(deps): bump axios from 1.8.2 to 1.12.0 in /frontend
2025-09-17 19:22:03 +05:30
Abhinav Raut
d6a8e76472 Merge pull request #140 from abhinavxd/dependabot/npm_and_yarn/frontend/vite-5.4.20
chore(deps-dev): bump vite from 5.4.19 to 5.4.20 in /frontend
2025-09-17 19:21:41 +05:30
Abhinav Raut
f95b374b74 Merge pull request #139 from abhinavxd/dependabot/npm_and_yarn/frontend/vue-i18n-9.14.5
chore(deps): bump vue-i18n from 9.14.3 to 9.14.5 in /frontend
2025-09-17 19:21:28 +05:30
dependabot[bot]
a1db6ccb31 chore(deps-dev): bump vite from 5.4.19 to 5.4.20 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.19 to 5.4.20.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.20/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.20/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.20
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-17 13:51:25 +00:00
dependabot[bot]
267a6027ee chore(deps): bump vue-i18n from 9.14.3 to 9.14.5 in /frontend
Bumps [vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n) from 9.14.3 to 9.14.5.
- [Release notes](https://github.com/intlify/vue-i18n/releases)
- [Changelog](https://github.com/intlify/vue-i18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/intlify/vue-i18n/commits/v9.14.5/packages/vue-i18n)

---
updated-dependencies:
- dependency-name: vue-i18n
  dependency-version: 9.14.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-17 13:50:50 +00:00
dependabot[bot]
3471263710 chore(deps): bump axios from 1.8.2 to 1.12.0 in /frontend
Bumps [axios](https://github.com/axios/axios) from 1.8.2 to 1.12.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.8.2...v1.12.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-17 13:50:40 +00:00
Abhinav Raut
7469e296d2 fix: allow message processing to continue on thumbnail generation errors 2025-09-17 12:51:56 +05:30
Abhinav Raut
44ffc77c4e log conversation deletion 2025-09-17 02:47:06 +05:30
Abhinav Raut
3ec061d8f1 fix: improve error handling for message attachment uploads delete newly created conversation if media upload fails so this message is tried again 2025-09-17 02:42:07 +05:30
Abhinav Raut
48b8d14f8f Update README.md 2025-09-16 23:16:26 +05:30
Abhinav Raut
6231a9e131 update logger to log time value instead of null time 2025-09-16 23:15:09 +05:30
Abhinav Raut
d63302843b Refactor: assigned user removal when changing assigned team 2025-09-16 21:31:53 +05:30
Abhinav Raut
a652f380b2 fix: set default content type to text if empty
Ref #137
2025-09-16 21:10:20 +05:30
Abhinav Raut
a4a9a9ccd3 trim feedback length to 1000 chars 2025-09-15 23:29:57 +05:30
Abhinav Raut
71865e389e fix: Change id type to int in ConversationParticipant
- hide Total field in UserCompact JSON
2025-09-15 01:01:51 +05:30
Abhinav Raut
ae470be4c8 remove mkdocs docs as docs are moved to docs.libredesk.io repository 2025-09-14 22:48:32 +05:30
Abhinav Raut
636742c34b Update docs link to point to new docs domain docs.libredesk.io 2025-09-14 22:47:05 +05:30
Abhinav Raut
de77c03f66 Merge pull request #135 from abhinavxd/fix/contact-form-calling-code
Fix: Contact form displays countries with the same calling code incorrectly
2025-09-14 22:27:06 +05:30
Abhinav Raut
b7092744fd feat: add loading fade effect to ContactDetailView and adjust FormItem width in ContactForm 2025-09-14 21:43:40 +05:30
Abhinav Raut
6f300bb073 Fix: Contact form displays countries with the same calling code incorrectly.
For example, when a user selects the USA, the form also shows Canada, as both share the +1 calling code.

Rename column from `phone_number_calling_code` to `phone_number_country_code`.

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

Ref #124
2025-09-10 03:02:55 +05:30
Abhinav Raut
701e5b2580 show sidebar view dropdown Ellipsis only on hover of single view.
add toast messages for update, create, delete of view
add confirmation dialog for view deletion
2025-09-10 02:48:48 +05:30
Abhinav Raut
dbd4e97f7e use tag store to fetch tags in conversation side bar to remove duplicate api call 2025-09-09 23:43:25 +05:30
Abhinav Raut
007c332a7d remove font-medium from data table columns in all data tables
- Remove permissions requirement for GET on roles
2025-09-09 23:22:08 +05:30
Abhinav Raut
4fcad4fd81 fix translation 2025-09-02 03:10:08 +05:30
Abhinav Raut
bece58bdec fix[automation] respect case sensitive flag for contains and not contains operator
- test cases for automation evaluator
2025-09-02 02:31:08 +05:30
Abhinav Raut
6d2d8f78d4 Merge pull request #130 from abhinavxd/refactor-apis
Clean up APIs and fixes
2025-09-02 02:27:28 +05:30
Abhinav Raut
98492a1869 refactor: use team compact struct for user teams list
- Check for existing email before agent update and raise proper error
2025-09-02 02:00:35 +05:30
Abhinav Raut
18b50b11c8 reduce footer font size for webtemplates 2025-09-02 01:10:03 +05:30
Abhinav Raut
5a1628f710 fix fetch general settings after user logs in 2025-09-02 00:57:05 +05:30
Abhinav Raut
12ebe32ba3 return complete contact note by refetching it using GetNote 2025-09-02 00:32:29 +05:30
Abhinav Raut
fce2587a9d remove unncessary margin from oidc provider logo
add alt attribute
2025-09-01 03:47:10 +05:30
Abhinav Raut
7d92ac9cce fix cypress test 2025-09-01 03:43:13 +05:30
Abhinav Raut
3ce3c5e0ee store public config in pinia store 2025-09-01 03:20:09 +05:30
Abhinav Raut
35ad00ec51 Add loading spinner to ConversationPlaceholder
Add missing i18n translation
2025-09-01 02:41:47 +05:30
Abhinav Raut
9ec96be959 rename AppUpdate component with AdminBanner
- show banner when app restart is required.
- UI changes to admin banner
2025-08-31 20:08:38 +05:30
Abhinav Raut
6ca36d611f add missing i18n key 2025-08-31 18:57:08 +05:30
Abhinav Raut
5a87d24d72 update var name 2025-08-31 18:55:31 +05:30
Abhinav Raut
7d4e7e68c3 update user avatar upload function to accept user by value and improve error logging by logging the user id 2025-08-31 18:48:02 +05:30
Abhinav Raut
5b941fd993 Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-31 18:37:05 +05:30
Abhinav Raut
63e348e512 remove subject from csat page 2025-08-30 22:27:48 +05:30
Abhinav Raut
10a845dc81 fix cypress test 2025-08-30 22:27:13 +05:30
Abhinav Raut
0228989202 fix cypress test 2025-08-30 22:18:00 +05:30
Abhinav Raut
3f7d151d33 - add getting started flow for new users
- Translate web template pass i18n dependency
- Fix colors in menu card
- Show update description if avaialble in AppUpdate component
- Remvoe i18n from settings as i18n and settings depend on each other to load initial lang.
- Clear inbox password as the update SQL query now returns the config.
- Fetch agents and inboxes from the store instead of directly fetching using axios instance.
2025-08-30 21:30:24 +05:30
Abhinav Raut
a516773b14 feat: add i18n support to web templates
- Add i18n object to template funcMap for direct access
  - Translate all hardcoded strings in CSAT and footer templates
  - Add reusable translation keys to globals.messages
2025-08-30 19:35:00 +05:30
Abhinav Raut
f6d3bd543f refactor: consolidate public config into single endpoint, move settings behind auth
- remove OIDC enabled endpoint
2025-08-30 18:46:37 +05:30
Abhinav Raut
074d147bb6 Update README.md 2025-08-30 03:58:26 +05:30
Abhinav Raut
c1c14f7f54 refactor: split converstion list item and conversation into different structs
- Add missing columns in message queries
2025-08-29 00:15:22 +05:30
Abhinav Raut
634fc66e9f Translate welcome to libredesk email subject
- Update all SQL queries to add missing columns

- Update the create conversation API to allow setting the initiator of a conversation. For example, we might want to use this API to create a conversation on behalf of a customer, with the first message coming from the customer instead of the agent. This param allows this.

- Minor refactors and clean up

- Tidy go.mod

- Rename structs to reflect purpose

- Create focus structs for scanning JSON payloads for clarity.
2025-08-28 00:34:56 +05:30
dependabot[bot]
78b8607d8f chore(deps): bump github.com/go-viper/mapstructure/v2
Bumps [github.com/go-viper/mapstructure/v2](https://github.com/go-viper/mapstructure) from 2.0.0-alpha.1 to 2.4.0.
- [Release notes](https://github.com/go-viper/mapstructure/releases)
- [Changelog](https://github.com/go-viper/mapstructure/blob/main/CHANGELOG.md)
- [Commits](https://github.com/go-viper/mapstructure/compare/v2.0.0-alpha.1...v2.4.0)

---
updated-dependencies:
- dependency-name: github.com/go-viper/mapstructure/v2
  dependency-version: 2.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-25 20:35:13 +00:00
Abhinav Raut
0dec822c1c fix panic due to missing i18n dependency 2025-08-26 01:12:30 +05:30
138 changed files with 3257 additions and 1710 deletions

View File

@@ -1,31 +0,0 @@
name: Deploy MkDocs
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: 3.x
- run: pip install mkdocs-material
- run: |
if [ -f requirements.txt ]; then
pip install -r requirements.txt;
fi
- run: cd docs && mkdocs build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/site

View File

@@ -3,15 +3,13 @@
# Libredesk
Open source, self-hosted customer support desk. Single binary app.
Modern, open source, self-hosted customer support desk. Single binary app.
![image](docs/docs/images/hero.png)
![image](https://libredesk.io/hero.png)
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
## Features
- **Multi Shared Inbox**
@@ -67,7 +65,7 @@ docker exec -it libredesk_app ./libredesk --set-system-user-password
Go to `http://localhost:9000` and login with username `System` and the password you set using the `--set-system-user-password` command.
See [installation docs](https://libredesk.io/docs/installation/)
See [installation docs](https://docs.libredesk.io/getting-started/installation)
__________________
@@ -78,12 +76,12 @@ __________________
- Run `./libredesk --set-system-user-password` to set the password for the System user.
- Run `./libredesk` and visit `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
See [installation docs](https://libredesk.io/docs/installation)
See [installation docs](https://docs.libredesk.io/getting-started/installation)
__________________
## Developers
If you are interested in contributing, refer to the [developer setup](https://libredesk.io/docs/developer-setup/). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
If you are interested in contributing, refer to the [developer setup](https://docs.libredesk.io/contributing/developer-setup). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
## Development Status

63
cmd/config.go Normal file
View File

@@ -0,0 +1,63 @@
package main
import (
"encoding/json"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/zerodha/fastglue"
)
// handleGetConfig returns the public configuration needed for app initialization, this includes minimal app settings and enabled SSO providers (without secrets).
func handleGetConfig(r *fastglue.Request) error {
var app = r.Context.(*App)
// Get app settings
settingsJSON, err := app.setting.GetByPrefix("app")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Unmarshal settings
var settings map[string]any
if err := json.Unmarshal(settingsJSON, &settings); err != nil {
app.lo.Error("error unmarshalling settings", "err", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", app.i18n.T("globals.terms.setting")), nil))
}
// Filter to only include public fields needed for initial app load
publicSettings := map[string]any{
"app.lang": settings["app.lang"],
"app.favicon_url": settings["app.favicon_url"],
"app.logo_url": settings["app.logo_url"],
"app.site_name": settings["app.site_name"],
}
// Get all OIDC providers
oidcProviders, err := app.oidc.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
// Filter for enabled providers and remove client_secret
enabledProviders := make([]map[string]any, 0)
for _, provider := range oidcProviders {
if provider.Enabled {
providerMap := map[string]any{
"id": provider.ID,
"name": provider.Name,
"provider": provider.Provider,
"provider_url": provider.ProviderURL,
"client_id": provider.ClientID,
"logo_url": provider.ProviderLogoURL,
"enabled": provider.Enabled,
"redirect_uri": provider.RedirectURI,
}
enabledProviders = append(enabledProviders, providerMap)
}
}
// Add SSO providers to the response
publicSettings["app.sso_providers"] = enabledProviders
return r.SendEnvelope(publicSettings)
}

View File

@@ -103,9 +103,9 @@ func handleUpdateContact(r *fastglue.Request) error {
if v, ok := form.Value["phone_number"]; ok && len(v) > 0 {
phoneNumber = string(v[0])
}
phoneNumberCallingCode := ""
if v, ok := form.Value["phone_number_calling_code"]; ok && len(v) > 0 {
phoneNumberCallingCode = string(v[0])
phoneNumberCountryCode := ""
if v, ok := form.Value["phone_number_country_code"]; ok && len(v) > 0 {
phoneNumberCountryCode = string(v[0])
}
avatarURL := ""
if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 {
@@ -116,8 +116,8 @@ func handleUpdateContact(r *fastglue.Request) error {
if avatarURL == "null" {
avatarURL = ""
}
if phoneNumberCallingCode == "null" {
phoneNumberCallingCode = ""
if phoneNumberCountryCode == "null" {
phoneNumberCountryCode = ""
}
if phoneNumber == "null" {
phoneNumber = ""
@@ -146,7 +146,7 @@ func handleUpdateContact(r *fastglue.Request) error {
Email: null.StringFrom(email),
AvatarURL: null.NewString(avatarURL, avatarURL != ""),
PhoneNumber: null.NewString(phoneNumber, phoneNumber != ""),
PhoneNumberCallingCode: null.NewString(phoneNumberCallingCode, phoneNumberCallingCode != ""),
PhoneNumberCountryCode: null.NewString(phoneNumberCountryCode, phoneNumberCountryCode != ""),
}
if err := app.user.UpdateContact(id, contactToUpdate); err != nil {
@@ -164,11 +164,17 @@ func handleUpdateContact(r *fastglue.Request) error {
// Upload avatar?
files, ok := form.File["files"]
if ok && len(files) > 0 {
if err := uploadUserAvatar(r, &contact, files); err != nil {
if err := uploadUserAvatar(r, contact, files); err != nil {
return sendErrorEnvelope(r, err)
}
}
return r.SendEnvelope(true)
// Refetch contact and return it
contact, err = app.user.GetContact(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(contact)
}
// handleGetContactNotes returns all notes for a contact.
@@ -195,18 +201,21 @@ func handleCreateContactNote(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = createContactNoteReq{}
)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if len(req.Note) == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "note"), nil, envelope.InputError)
}
if err := app.user.CreateNote(contactID, auser.ID, req.Note); err != nil {
n, err := app.user.CreateNote(contactID, auser.ID, req.Note)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
n, err = app.user.GetNote(n.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(n)
}
// handleDeleteContactNote deletes a note for a contact.
@@ -240,6 +249,8 @@ func handleDeleteContactNote(r *fastglue.Request) error {
}
}
app.lo.Info("deleting contact note", "note_id", noteID, "contact_id", contactID, "actor_id", auser.ID)
if err := app.user.DeleteNote(noteID, contactID); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -251,6 +262,7 @@ func handleBlockContact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = blockContactReq{}
)
@@ -262,8 +274,15 @@ func handleBlockContact(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
app.lo.Info("setting contact block status", "contact_id", contactID, "enabled", req.Enabled, "actor_id", auser.ID)
if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, req.Enabled); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
contact, err := app.user.GetContact(contactID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(contact)
}

View File

@@ -49,6 +49,7 @@ type createConversationRequest struct {
Subject string `json:"subject"`
Content string `json:"content"`
Attachments []int `json:"attachments"`
Initiator string `json:"initiator"` // "contact" | "agent"
}
// handleGetAllConversations retrieves all conversations.
@@ -273,8 +274,8 @@ func handleGetConversation(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
prev, _ := app.conversation.GetContactConversations(conv.ContactID)
conv.PreviousConversations = filterCurrentConv(prev, conv.UUID)
prev, _ := app.conversation.GetContactPreviousConversations(conv.ContactID, 10)
conv.PreviousConversations = filterCurrentPreviousConv(prev, conv.UUID)
return r.SendEnvelope(conv)
}
@@ -649,14 +650,14 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
return r.SendEnvelope(true)
}
// filterCurrentConv removes the current conversation from the list of conversations.
func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conversation {
// filterCurrentPreviousConv removes the current conversation from the list of previous conversations.
func filterCurrentPreviousConv(convs []cmodels.PreviousConversation, uuid string) []cmodels.PreviousConversation {
for i, c := range convs {
if c.UUID == uuid {
return append(convs[:i], convs[i+1:]...)
}
}
return []cmodels.Conversation{}
return []cmodels.PreviousConversation{}
}
// handleCreateConversation creates a new conversation and sends a message to it.
@@ -672,39 +673,17 @@ func handleCreateConversation(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Validate the request
if err := validateCreateConversationRequest(req, app); err != nil {
return sendErrorEnvelope(r, err)
}
to := []string{req.Email}
// Validate required fields
if req.InboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil, envelope.InputError)
}
if req.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil, envelope.InputError)
}
if req.Email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil, envelope.InputError)
}
if req.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(req.Email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), 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(req.InboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil, envelope.InputError)
}
// Find or create contact.
contact := umodels.User{
Email: null.StringFrom(req.Email),
@@ -717,7 +696,7 @@ func handleCreateConversation(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
}
// Create conversation
// Create conversation first.
conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID,
contact.ContactChannelID,
@@ -725,14 +704,14 @@ func handleCreateConversation(r *fastglue.Request) error {
"", /** last_message **/
time.Now(), /** last_message_at **/
req.Subject,
true, /** append reference number to 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, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
}
// Prepare attachments.
// Get media for the attachment ids.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments {
m, err := app.media.Get(id, "")
@@ -743,13 +722,29 @@ func handleCreateConversation(r *fastglue.Request) error {
media = append(media, m)
}
// Send reply to the created conversation.
if _, err := app.conversation.SendReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if reply fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
// Send initial message based on the initiator of conversation.
switch req.Initiator {
case umodels.UserTypeAgent:
// Queue reply.
if _, err := app.conversation.QueueReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if msg queue fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
case umodels.UserTypeContact:
// Create contact message.
if _, err := app.conversation.CreateContactMessage(media, contact.ID, conversationUUID, req.Content, cmodels.ContentTypeHTML); err != nil {
// Delete the conversation if message creation fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
}
default:
// Guard anyway.
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil, envelope.InputError)
}
// Assign the conversation to the agent or team.
@@ -768,3 +763,36 @@ func handleCreateConversation(r *fastglue.Request) error {
return r.SendEnvelope(conversation)
}
// validateCreateConversationRequest validates the create conversation request fields.
func validateCreateConversationRequest(req createConversationRequest, app *App) error {
if req.InboxID <= 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil)
}
if req.Content == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil)
}
if req.Email == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil)
}
if req.FirstName == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil)
}
if !stringutil.ValidEmail(req.Email) {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil)
}
if req.Initiator != umodels.UserTypeContact && req.Initiator != umodels.UserTypeAgent {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil)
}
// Check if inbox exists and is enabled.
inbox, err := app.inbox.GetDBRecord(req.InboxID)
if err != nil {
return err
}
if !inbox.Enabled {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil)
}
return nil
}

View File

@@ -6,6 +6,10 @@ import (
"github.com/zerodha/fastglue"
)
const (
maxCsatFeedbackLength = 1000
)
// handleShowCSAT renders the CSAT page for a given csat.
func handleShowCSAT(r *fastglue.Request) error {
var (
@@ -17,7 +21,7 @@ func handleShowCSAT(r *fastglue.Request) error {
if err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": "Page not found",
"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
},
})
}
@@ -25,8 +29,8 @@ func handleShowCSAT(r *fastglue.Request) error {
if csat.ResponseTimestamp.Valid {
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
"Data": map[string]interface{}{
"Title": "Thank you!",
"Message": "We appreciate you taking the time to submit your feedback.",
"Title": app.i18n.T("globals.messages.thankYou"),
"Message": app.i18n.T("csat.thankYouMessage"),
},
})
}
@@ -35,14 +39,14 @@ func handleShowCSAT(r *fastglue.Request) error {
if err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": "Page not found",
"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
},
})
}
return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
"Data": map[string]interface{}{
"Title": "Rate your interaction with us",
"Title": app.i18n.T("csat.pageTitle"),
"CSAT": map[string]interface{}{
"UUID": csat.UUID,
},
@@ -67,7 +71,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
if err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": "Invalid `rating`",
"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
},
})
}
@@ -75,7 +79,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
if ratingI < 1 || ratingI > 5 {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": "Invalid `rating`",
"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
},
})
}
@@ -83,11 +87,16 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
if uuid == "" {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": "Invalid `uuid`",
"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
},
})
}
// Trim feedback if it exceeds max length
if len(feedback) > maxCsatFeedbackLength {
feedback = feedback[:maxCsatFeedbackLength]
}
if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
@@ -98,8 +107,8 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
"Data": map[string]interface{}{
"Title": "Thank you!",
"Message": "We appreciate you taking the time to submit your feedback.",
"Title": app.i18n.T("globals.messages.thankYou"),
"Message": app.i18n.T("csat.thankYouMessage"),
},
})
}

View File

@@ -23,18 +23,20 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// i18n.
g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
// Public config for app initialization.
g.GET("/api/v1/config", handleGetConfig)
// Media.
g.GET("/uploads/{uuid}", auth(handleServeMedia))
g.POST("/api/v1/media", auth(handleMediaUpload))
// Settings.
g.GET("/api/v1/settings/general", handleGetGeneralSettings)
g.GET("/api/v1/settings/general", auth(handleGetGeneralSettings))
g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "general_settings:manage"))
g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_settings:manage"))
g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage"))
// OpenID connect single sign-on.
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
@@ -153,7 +155,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
// Roles.
g.GET("/api/v1/roles", perm(handleGetRoles, "roles:manage"))
g.GET("/api/v1/roles", auth(handleGetRoles))
g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage"))
g.POST("/api/v1/roles", perm(handleCreateRole, "roles:manage"))
g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage"))

View File

@@ -17,6 +17,12 @@ func handleGetInboxes(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
for i := range inboxes {
if err := inboxes[i].ClearPasswords(); err != nil {
app.lo.Error("error clearing inbox passwords from response", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil)
}
}
return r.SendEnvelope(inboxes)
}

View File

@@ -250,11 +250,12 @@ func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager {
}
// initViews inits view manager.
func initView(db *sqlx.DB) *view.Manager {
func initView(db *sqlx.DB, i18n *i18n.I18n) *view.Manager {
var lo = initLogger("view_manager")
m, err := view.New(view.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing view manager: %v", err)
@@ -327,7 +328,7 @@ func initWS(user *user.Manager) *ws.Hub {
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager {
var (
lo = initLogger("template")
funcMap = getTmplFuncs(consts)
funcMap = getTmplFuncs(consts, i18n)
)
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
if err != nil {
@@ -345,7 +346,7 @@ func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *
}
// getTmplFuncs returns the template functions.
func getTmplFuncs(consts *constants) template.FuncMap {
func getTmplFuncs(consts *constants, i18n *i18n.I18n) template.FuncMap {
return template.FuncMap{
"RootURL": func() string {
return consts.AppBaseURL
@@ -365,6 +366,9 @@ func getTmplFuncs(consts *constants) template.FuncMap {
"SiteName": func() string {
return consts.SiteName
},
"L": func() interface{} {
return i18n
},
}
}
@@ -381,7 +385,10 @@ func reloadSettings(app *App) error {
app.lo.Error("error unmarshalling settings from DB", "error", err)
return err
}
if err := ko.Load(confmap.Provider(out, "."), nil); err != nil {
app.Lock()
err = ko.Load(confmap.Provider(out, "."), nil)
app.Unlock()
if err != nil {
app.lo.Error("error loading settings into koanf", "error", err)
return err
}
@@ -393,7 +400,7 @@ func reloadSettings(app *App) error {
// reloadTemplates reloads the templates from the filesystem.
func reloadTemplates(app *App) error {
app.lo.Info("reloading templates")
funcMap := getTmplFuncs(app.consts.Load().(*constants))
funcMap := getTmplFuncs(app.consts.Load().(*constants), app.i18n)
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/email-templates/*.html")
if err != nil {
app.lo.Error("error parsing email templates", "error", err)

View File

@@ -97,6 +97,8 @@ type App struct {
// Global state that stores data on an available app update.
update *AppUpdate
// Flag to indicate if app restart is required for settings to take effect.
restartRequired bool
sync.Mutex
}
@@ -239,7 +241,7 @@ func main() {
activityLog: initActivityLog(db, i18n),
customAttribute: initCustomAttribute(db, i18n),
authz: initAuthz(i18n),
view: initView(db),
view: initView(db, i18n),
report: initReport(db, i18n),
csat: initCSAT(db, i18n),
search: initSearch(db, i18n),

View File

@@ -2,10 +2,14 @@ package main
import (
"strconv"
"strings"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
authzModels "github.com/abhinavxd/libredesk/internal/authz/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
medModels "github.com/abhinavxd/libredesk/internal/media/models"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -17,6 +21,7 @@ type messageReq struct {
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc"`
SenderType string `json:"sender_type"`
}
// handleGetMessages returns messages for a conversation.
@@ -99,7 +104,7 @@ func handleGetMessage(r *fastglue.Request) error {
return r.SendEnvelope(message)
}
// handleRetryMessage changes message status so it can be retried for sending.
// handleRetryMessage changes message status to `pending`, so it's enqueued for sending.
func handleRetryMessage(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -150,7 +155,31 @@ func handleSendMessage(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Prepare attachments.
if req.SenderType != umodels.UserTypeAgent && req.SenderType != umodels.UserTypeContact {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`sender_type`"), nil, envelope.InputError)
}
// Contacts cannot send private messages
if req.SenderType == umodels.UserTypeContact && req.Private {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
}
// Check if user has permission to send messages as contact
if req.SenderType == umodels.UserTypeContact {
parts := strings.Split(authzModels.PermMessagesWriteAsContact, ":")
if len(parts) != 2 {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil))
}
ok, err := app.authz.Enforce(user, parts[0], parts[1])
if err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil))
}
if !ok {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
}
// Get media for all attachments.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments {
m, err := app.media.Get(id, "")
@@ -161,6 +190,16 @@ func handleSendMessage(r *fastglue.Request) error {
media = append(media, m)
}
// Create contact message.
if req.SenderType == umodels.UserTypeContact {
message, err := app.conversation.CreateContactMessage(media, int(conv.ContactID), cuuid, req.Message, cmodels.ContentTypeHTML)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(message)
}
// Send private note.
if req.Private {
message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message)
if err != nil {
@@ -168,7 +207,9 @@ func handleSendMessage(r *fastglue.Request) error {
}
return r.SendEnvelope(message)
}
message, err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
// Queue reply.
message, err := app.conversation.QueueReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -11,16 +11,6 @@ import (
"github.com/zerodha/fastglue"
)
// handleGetAllEnabledOIDC returns all enabled OIDC records
func handleGetAllEnabledOIDC(r *fastglue.Request) error {
app := r.Context.(*App)
out, err := app.oidc.GetAllEnabled()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(out)
}
// handleGetAllOIDC returns all OIDC records
func handleGetAllOIDC(r *fastglue.Request) error {
app := r.Context.(*App)
@@ -74,10 +64,10 @@ func handleCreateOIDC(r *fastglue.Request) error {
if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
}
// Clear client secret before returning
createdOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(createdOIDC)
}
@@ -110,10 +100,10 @@ func handleUpdateOIDC(r *fastglue.Request) error {
if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
}
// Clear client secret before returning
updatedOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(updatedOIDC)
}

View File

@@ -31,6 +31,8 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
settings["app.update"] = app.update
// Set app version.
settings["app.version"] = versionString
// Set restart required flag.
settings["app.restart_required"] = app.restartRequired
return r.SendEnvelope(settings)
}
@@ -45,6 +47,11 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
}
// Get current language before update.
app.Lock()
oldLang := ko.String("app.lang")
app.Unlock()
// Remove any trailing slash `/` from the root url.
req.RootURL = strings.TrimRight(req.RootURL, "/")
@@ -55,6 +62,17 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
if err := reloadSettings(app); err != nil {
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
}
// Check if language changed and reload i18n if needed.
app.Lock()
newLang := ko.String("app.lang")
if oldLang != newLang {
app.lo.Info("language changed, reloading i18n", "old_lang", oldLang, "new_lang", newLang)
app.i18n = initI18n(app.fs)
app.lo.Info("reloaded i18n", "old_lang", oldLang, "new_lang", newLang)
}
app.Unlock()
if err := reloadTemplates(app); err != nil {
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
}
@@ -109,6 +127,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.invalidFromAddress"), nil, envelope.InputError)
}
// If empty then retain previous password.
if req.Password == "" {
req.Password = cur.Password
}
@@ -117,6 +136,10 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
// No reload implemented, so user has to restart the app.
// Email notification settings require app restart to take effect.
app.Lock()
app.restartRequired = true
app.Unlock()
return r.SendEnvelope(true)
}

View File

@@ -83,7 +83,7 @@ func handleUpdateTeam(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
updatedTeam, err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations);
updatedTeam, err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -35,6 +35,7 @@ var migList = []migFunc{
{"v0.5.0", migrations.V0_5_0},
{"v0.6.0", migrations.V0_6_0},
{"v0.7.0", migrations.V0_7_0},
{"v0.7.4", migrations.V0_7_4},
}
// upgrade upgrades the database to the current version by running SQL migration files

View File

@@ -26,34 +26,38 @@ const (
maxAvatarSizeMB = 2
)
// Request structs for user-related endpoints
// UpdateAvailabilityRequest represents the request to update user availability
type UpdateAvailabilityRequest struct {
type updateAvailabilityRequest struct {
Status string `json:"status"`
}
// ResetPasswordRequest represents the password reset request
type ResetPasswordRequest struct {
type resetPasswordRequest struct {
Email string `json:"email"`
}
// SetPasswordRequest represents the set password request
type SetPasswordRequest struct {
type setPasswordRequest struct {
Token string `json:"token"`
Password string `json:"password"`
}
// AvailabilityRequest represents the request to update agent availability
type AvailabilityRequest struct {
type availabilityRequest struct {
Status string `json:"status"`
}
type agentReq struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
SendWelcomeEmail bool `json:"send_welcome_email"`
Teams []string `json:"teams"`
Roles []string `json:"roles"`
Enabled bool `json:"enabled"`
AvailabilityStatus string `json:"availability_status"`
NewPassword string `json:"new_password,omitempty"`
}
// handleGetAgents returns all agents.
func handleGetAgents(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
var app = r.Context.(*App)
agents, err := app.user.GetAgents()
if err != nil {
return sendErrorEnvelope(r, err)
@@ -73,9 +77,7 @@ func handleGetAgentsCompact(r *fastglue.Request) error {
// handleGetAgent returns an agent.
func handleGetAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
var app = r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
@@ -93,7 +95,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
availReq AvailabilityRequest
availReq availabilityRequest
)
// Decode JSON request
@@ -101,6 +103,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Fetch entire agent
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
@@ -108,10 +111,10 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
// Same status?
if agent.AvailabilityStatus == availReq.Status {
return r.SendEnvelope(true)
return r.SendEnvelope(agent)
}
// Update availability status.
// Update availability status
if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -123,21 +126,22 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
}
}
return r.SendEnvelope(true)
// Fetch updated agent and return
agent, err = app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
}
// handleGetCurrentAgentTeams returns the teams of an agent.
// handleGetCurrentAgentTeams returns the teams of current agent.
func handleGetCurrentAgentTeams(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
teams, err := app.team.GetUserTeams(agent.ID)
teams, err := app.team.GetUserTeams(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -150,11 +154,6 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
form, err := r.RequestCtx.MultipartForm()
if err != nil {
app.lo.Error("error parsing form data", "error", err)
@@ -165,54 +164,53 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
// Upload avatar?
if ok && len(files) > 0 {
if err := uploadUserAvatar(r, &agent, files); err != nil {
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := uploadUserAvatar(r, agent, files); err != nil {
return sendErrorEnvelope(r, err)
}
}
return r.SendEnvelope(true)
// Fetch updated agent and return.
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
}
// handleCreateAgent creates a new agent.
func handleCreateAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = models.User{}
app = r.Context.(*App)
req = agentReq{}
)
if err := r.Decode(&user, "json"); err != nil {
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if user.Email.String == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
if !stringutil.ValidEmail(user.Email.String) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
// Validate agent request
if err := validateAgentRequest(r, &req); err != nil {
return err
}
if user.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
}
if user.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
}
if err := app.user.CreateAgent(&user); err != nil {
agent, err := app.user.CreateAgent(req.FirstName, req.LastName, req.Email, req.Roles)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Upsert user teams.
if len(user.Teams) > 0 {
if err := app.team.UpsertUserTeams(user.ID, user.Teams.Names()); err != nil {
return sendErrorEnvelope(r, err)
}
if len(req.Teams) > 0 {
app.team.UpsertUserTeams(agent.ID, req.Teams)
}
if user.SendWelcomeEmail {
if req.SendWelcomeEmail {
// Generate reset token.
resetToken, err := app.user.SetResetPasswordToken(user.ID)
resetToken, err := app.user.SetResetPasswordToken(agent.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -220,31 +218,36 @@ func handleCreateAgent(r *fastglue.Request) error {
// Render template and send email.
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
"ResetToken": resetToken,
"Email": user.Email.String,
"Email": req.Email,
})
if err != nil {
app.lo.Error("error rendering template", "error", err)
return r.SendEnvelope(true)
}
if err := app.notifier.Send(notifier.Message{
RecipientEmails: []string{user.Email.String},
Subject: "Welcome to Libredesk",
RecipientEmails: []string{req.Email},
Subject: app.i18n.T("globals.messages.welcomeToLibredesk"),
Content: content,
Provider: notifier.ProviderEmail,
}); err != nil {
app.lo.Error("error sending notification message", "error", err)
return r.SendEnvelope(true)
}
}
return r.SendEnvelope(true)
// Refetch agent as other details might've changed.
agent, err = app.user.GetAgent(agent.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
}
// handleUpdateAgent updates an agent.
func handleUpdateAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = models.User{}
req = agentReq{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
@@ -253,25 +256,13 @@ func handleUpdateAgent(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&user, "json"); err != nil {
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if user.Email.String == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
if !stringutil.ValidEmail(user.Email.String) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
}
if user.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
}
if user.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
// Validate agent request
if err := validateAgentRequest(r, &req); err != nil {
return err
}
agent, err := app.user.GetAgent(id, "")
@@ -280,8 +271,8 @@ func handleUpdateAgent(r *fastglue.Request) error {
}
oldAvailabilityStatus := agent.AvailabilityStatus
// Update agent.
if err = app.user.UpdateAgent(id, user); err != nil {
// Update agent with individual fields
if err = app.user.UpdateAgent(id, req.FirstName, req.LastName, req.Email, req.Roles, req.Enabled, req.AvailabilityStatus, req.NewPassword); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -289,18 +280,24 @@ func handleUpdateAgent(r *fastglue.Request) error {
defer app.authz.InvalidateUserCache(id)
// Create activity log if user availability status changed.
if oldAvailabilityStatus != user.AvailabilityStatus {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil {
if oldAvailabilityStatus != req.AvailabilityStatus {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, req.AvailabilityStatus, ip, req.Email, id); err != nil {
app.lo.Error("error creating activity log", "error", err)
}
}
// Upsert agent teams.
if err := app.team.UpsertUserTeams(id, user.Teams.Names()); err != nil {
if err := app.team.UpsertUserTeams(id, req.Teams); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
// Refetch agent and return.
agent, err = app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
}
// handleDeleteAgent soft deletes an agent.
@@ -381,7 +378,7 @@ func handleResetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
resetReq ResetPasswordRequest
resetReq resetPasswordRequest
)
if ok && auser.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
@@ -399,7 +396,7 @@ func handleResetPassword(r *fastglue.Request) error {
agent, err := app.user.GetAgent(0, resetReq.Email)
if err != nil {
// Send 200 even if user not found, to prevent email enumeration.
return r.SendEnvelope("Reset password email sent successfully.")
return r.SendEnvelope(true)
}
token, err := app.user.SetResetPasswordToken(agent.ID)
@@ -434,7 +431,7 @@ func handleSetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
req = SetPasswordRequest{}
req setPasswordRequest
)
if ok && agent.ID > 0 {
@@ -457,13 +454,13 @@ func handleSetPassword(r *fastglue.Request) error {
}
// uploadUserAvatar uploads the user avatar.
func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart.FileHeader) error {
func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart.FileHeader) error {
var app = r.Context.(*App)
fileHeader := files[0]
file, err := fileHeader.Open()
if err != nil {
app.lo.Error("error opening uploaded file", "error", err)
app.lo.Error("error opening uploaded file", "user_id", user.ID, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil)
}
defer file.Close()
@@ -480,7 +477,7 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
// Check file size
if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB {
app.lo.Error("error uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
app.lo.Error("error uploaded file size is larger than max allowed", "user_id", user.ID, "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
return envelope.NewError(
envelope.InputError,
app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)),
@@ -497,23 +494,25 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
meta := []byte("{}")
media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
if err != nil {
app.lo.Error("error uploading file", "error", err)
app.lo.Error("error uploading file", "user_id", user.ID, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
}
// Delete current avatar.
if user.AvatarURL.Valid {
fileName := filepath.Base(user.AvatarURL.String)
app.media.Delete(fileName)
if err := app.media.Delete(fileName); err != nil {
app.lo.Error("error deleting user avatar", "user_id", user.ID, "error", err)
}
}
// Save file path.
path, err := stringutil.GetPathFromURL(media.URL)
if err != nil {
app.lo.Debug("error getting path from URL", "url", media.URL, "error", err)
app.lo.Debug("error getting path from URL", "user_id", user.ID, "url", media.URL, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
}
fmt.Println("path", path)
if err := app.user.UpdateAvatar(user.ID, path); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -577,3 +576,28 @@ func handleRevokeAPIKey(r *fastglue.Request) error {
return r.SendEnvelope(true)
}
// validateAgentRequest validates common agent request fields and normalizes the email
func validateAgentRequest(r *fastglue.Request, req *agentReq) error {
var app = r.Context.(*App)
// Normalize email
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(req.Email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
}
if req.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
}
if req.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
}
return nil
}

View File

@@ -1,30 +0,0 @@
# API getting started
You can access the Libredesk API to interact with your instance programmatically.
## Generating API keys
1. **Edit agent**: Go to Admin → Teammate → Agent → Edit
2. **Generate new API key**: An API Key and API Secret will be generated for the agent
3. **Save the credentials**: Keep both the API Key and API Secret secure
4. **Key management**: You can revoke / regenerate API keys at any time from the same page
## Using the API
LibreDesk supports two authentication schemes:
### Basic authentication
```bash
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
-H "Authorization: Basic <base64_encoded_key:secret>"
```
### Token authentication
```bash
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
-H "Authorization: token your_api_key:your_api_secret"
```
## API Documentation
Complete API documentation with available endpoints and examples coming soon.

View File

@@ -1,32 +0,0 @@
# Developer Setup
Libredesk is a monorepo with a Go backend and a Vue.js frontend. The frontend uses Shadcn for UI components.
### Pre-requisites
- go
- nodejs (if you are working on the frontend) and `pnpm`
- redis
- postgres database (>= 13)
### First time setup
Clone the repository:
```sh
git clone https://github.com/abhinavxd/libredesk.git
```
1. Copy `config.toml.sample` as `config.toml` and add your config.
2. Run `make` to build the libredesk binary. Once the binary is built, run `./libredesk --install` to run the DB setup and set the System user password.
### Running the Dev Environment
1. Run `make run-backend` to start the libredesk backend dev server on `:9000`.
2. Run `make run-frontend` to start the Vue frontend in dev mode using pnpm on `:8000`. Requests are proxied to the backend running on `:9000` check `vite.config.js` for the proxy config.
---
# Production Build
Run `make` to build the Go binary, build the Javascript frontend, and embed the static assets producing a single self-contained binary, `libredesk`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

View File

@@ -1,17 +0,0 @@
# Introduction
Libredesk is an open-source, self-hosted customer support desk — single binary app.
<div style="border: 1px solid #ccc; padding: 2px; border-radius: 6px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); background-color: #f9f9f9;">
<a href="https://libredesk.io">
<img src="images/hero.png" alt="libredesk UI screenshot" style="display: block; margin: 0 auto; max-width: 100%; border-radius: 4px;" />
</a>
</div>
## Developers
Libredesk is licensed under AGPLv3. Contributions are welcome.
- Source code: [GitHub](https://github.com/abhinavxd/libredesk)
- Setup guide: [Developer setup](developer-setup.md)
- Stack: Go backend, Vue 3 frontend (Shadcn UI)

View File

@@ -1,65 +0,0 @@
# Installation
Libredesk is a single binary application that requires postgres and redis to run. You can install it using the binary or docker.
## Binary
1. Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary.
2. `./libredesk --install` to install the tables in the Postgres DB (⩾ 13) and set the System user password.
3. Run `./libredesk` and visit `http://localhost:9000` and login with the email `System` and the password you set during installation.
!!! Tip
To set the System user password during installation, set the environment variables:
`LIBREDESK_SYSTEM_USER_PASSWORD=xxxxxxxxxxx ./libredesk --install`
## Docker
The latest image is available on DockerHub at `libredesk/libredesk:latest`
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/main/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
```shell
# Download the compose file and the sample config file in the current directory.
curl -LO https://github.com/abhinavxd/libredesk/raw/main/docker-compose.yml
curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
# Copy the config.sample.toml to config.toml and edit it as needed.
cp config.sample.toml config.toml
# Run the services in the background.
docker compose up -d
# Setting System user password.
docker exec -it libredesk_app ./libredesk --set-system-user-password
```
Go to `http://localhost:9000` and login with the email `System` and the password you set using the `--set-system-user-password` command.
## Compiling from source
To compile the latest unreleased version (`main` branch):
1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system.
2. `git clone git@github.com:abhinavxd/libredesk.git`
3. `cd libredesk && make`. This will generate the `libredesk` binary.
## Nginx
Libredesk uses websockets for real-time updates. If you are using Nginx, you need to add the following (or similar) configuration to your Nginx configuration file.
```nginx
client_max_body_size 100M;
location / {
proxy_pass http://localhost:9000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
```

View File

@@ -1,57 +0,0 @@
# Setting up SSO
Libredesk supports external OpenID Connect providers (e.g., Google, Keycloak) for signing in users.
!!! note
User accounts must be created in Libredesk manually; signup is not supported.
## Generic Configuration Steps
Since each providers configuration might differ, consult your providers documentation for any additional or divergent settings.
1. Provider setup:
In your providers admin console, create a new OpenID Connect application/client. Retrieve:
- Client ID
- Client Secret
2. Libredesk configuration:
In Libredesk, navigate to Security > SSO and click New SSO and enter the following details:
- Provider URL (e.g., the URL of your OpenID provider)
- Client ID
- Client Secret
- A descriptive name for the connection
3. Redirect URL:
After saving, copy the generated Callback URL from Libredesk and add it as a valid redirect URI in your providers client settings.
## Provider Examples
#### Keycloak
1. Log in to your Keycloak Admin Console.
2. In Keycloak, navigate to Clients and click Create:
- Client ID (e.g., `libredesk-app`)
- Client Protocol: `openid-connect`
- Root URL and Web Origins: your app domain (e.g., `https://ticket.example.com`)
- Under Authentication flow, uncheck everything except the standard flow
- Click save
3. Go to the credentials tab:
- Ensure client authenticator is set to `Client Id and Secret`
- Note down the generated client secret
4. In Libredesk, go to Admin > Security > SSO and click New SSO:
- Provider URL (e.g., `https://keycloak.example.com/realms/yourrealm`)
- Name (e.g., `Keycloak`)
- Client ID
- Client secret
- Click save
5. After saving, click on the three dots and choose Edit to open the new SSO entry.
6. Copy the generated Callback URL from Libredesk.
7. Back in Keycloak, edit the client and add the Callback URL to Valid Redirect URIs:
- e.g., `https://ticket.example.com/api/v1/oidc/1/finish`

View File

@@ -1,60 +0,0 @@
# Templating
Templating in outgoing emails allows you to personalize content by embedding dynamic expressions like `{{ .Recipient.FullName }}`. These expressions reference fields from the conversation, contact, recipient, and author objects.
## Outgoing Email Template Expressions
If you want to customize the look of outgoing emails, you can do so in the Admin > Templates -> Outgoing Email Templates section. This template will be used for all outgoing emails including replies to conversations, notifications, and other system-generated emails.
### Conversation Variables
| Variable | Value |
|---------------------------------|--------------------------------------------------------|
| {{ .Conversation.ReferenceNumber }} | The unique reference number of the conversation |
| {{ .Conversation.Subject }} | The subject of the conversation |
| {{ .Conversation.Priority }} | The priority level of the conversation |
| {{ .Conversation.UUID }} | The unique identifier of the conversation |
### Contact Variables
| Variable | Value |
|------------------------------|------------------------------------|
| {{ .Contact.FirstName }} | First name of the contact/customer |
| {{ .Contact.LastName }} | Last name of the contact/customer |
| {{ .Contact.FullName }} | Full name of the contact/customer |
| {{ .Contact.Email }} | Email address of the contact/customer |
### Recipient Variables
| Variable | Value |
|--------------------------------|-----------------------------------|
| {{ .Recipient.FirstName }} | First name of the recipient |
| {{ .Recipient.LastName }} | Last name of the recipient |
| {{ .Recipient.FullName }} | Full name of the recipient |
| {{ .Recipient.Email }} | Email address of the recipient |
### Author Variables
| Variable | Value |
|------------------------------|-----------------------------------|
| {{ .Author.FirstName }} | First name of the message author |
| {{ .Author.LastName }} | Last name of the message author |
| {{ .Author.FullName }} | Full name of the message author |
| {{ .Author.Email }} | Email address of the message author |
### Example outgoing email template
```html
Dear {{ .Recipient.FirstName }},
{{ template "content" . }}
Best regards,
{{ .Author.FullName }}
---
Reference: {{ .Conversation.ReferenceNumber }}
```
Here, the `{{ template "content" . }}` serves as a placeholder for the body of the outgoing email. It will be replaced with the actual email content at the time of sending.
Similarly, the `{{ .Recipient.FirstName }}` expression will dynamically insert the recipient's first name when the email is sent.

View File

@@ -1,3 +0,0 @@
# Translations / Internationalization
You can help translate libreDesk into different languages by contributing here: [Libredesk Translation Project](https://crowdin.com/project/libredesk)

View File

@@ -1,18 +0,0 @@
# Upgrade
!!! warning "Warning"
Always take a backup of the Postgres database before upgrading Libredesk.
## Binary
- Stop running libredesk binary.
- Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary and overwrite the previous version.
- `./libredesk --upgrade` to upgrade an existing database schema. Upgrades are idempotent and running them multiple times have no side effects.
- Run `./libredesk` again.
## Docker
```shell
docker compose down app
docker compose pull
docker compose up app -d
```

View File

@@ -1,222 +0,0 @@
# Webhooks
Webhooks allow you to receive real-time HTTP notifications when specific events occur in your Libredesk instance. This enables you to integrate Libredesk with external systems and automate workflows based on conversation and message events.
## Overview
When a configured event occurs in Libredesk, a HTTP POST request is sent to the webhook URL you specify. The request contains a JSON payload with event details and relevant data.
## Webhook Configuration
1. Navigate to **Admin > Integrations > Webhooks** in your Libredesk dashboard
2. Click **Create Webhook**
3. Configure the following:
- **Name**: A descriptive name for your webhook
- **URL**: The endpoint URL where webhook payloads will be sent
- **Events**: Select which events you want to subscribe to
- **Secret**: Optional secret key for signature verification
- **Status**: Enable or disable the webhook
## Security
### Signature Verification
If you provide a secret key, webhook payloads will be signed using HMAC-SHA256. The signature is included in the `X-Signature-256` header in the format `sha256=<signature>`.
To verify the signature:
```python
import hmac
import hashlib
def verify_signature(payload, signature, secret):
expected_signature = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected_signature}", signature)
```
### Headers
Each webhook request includes the following headers:
- `Content-Type`: `application/json`
- `User-Agent`: `Libredesk-Webhook/<libredesk_version_here>`
- `X-Signature-256`: HMAC signature (if secret is configured)
## Available Events
### Conversation Events
#### `conversation.created`
Triggered when a new conversation is created.
**Sample Payload:**
```json
{
"event": "conversation.created",
"timestamp": "2025-06-15T10:30:00Z",
"payload": {
"id": 123,
"created_at": "2025-06-15T10:30:00Z",
"updated_at": "2025-06-15T10:30:00Z",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"contact_id": 456,
"inbox_id": 1,
"reference_number": "100",
"priority": "Medium",
"priority_id": 2,
"status": "Open",
"status_id": 1,
"subject": "Help with account setup",
"inbox_name": "Support",
"inbox_channel": "email",
"contact": {
"id": 456,
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
"type": "contact"
},
"custom_attributes": {},
"tags": []
}
}
```
#### `conversation.status_changed`
Triggered when a conversation's status is updated.
**Sample Payload:**
```json
{
"event": "conversation.status_changed",
"timestamp": "2025-06-15T10:35:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"previous_status": "Open",
"new_status": "Resolved",
"snooze_until": "",
"actor_id": 789
}
}
```
#### `conversation.assigned`
Triggered when a conversation is assigned to a user.
**Sample Payload:**
```json
{
"event": "conversation.assigned",
"timestamp": "2025-06-15T10:32:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"assigned_to": 789,
"actor_id": 789
}
}
```
#### `conversation.unassigned`
Triggered when a conversation is unassigned from a user.
**Sample Payload:**
```json
{
"event": "conversation.unassigned",
"timestamp": "2025-06-15T10:40:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"actor_id": 789
}
}
```
#### `conversation.tags_changed`
Triggered when tags are added or removed from a conversation.
**Sample Payload:**
```json
{
"event": "conversation.tags_changed",
"timestamp": "2025-06-15T10:45:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"previous_tags": ["bug", "priority"],
"new_tags": ["bug", "priority", "resolved"],
"actor_id": 789
}
}
```
### Message Events
#### `message.created`
Triggered when a new message is created in a conversation.
**Sample Payload:**
```json
{
"event": "message.created",
"timestamp": "2025-06-15T10:33:00Z",
"payload": {
"id": 987,
"created_at": "2025-06-15T10:33:00Z",
"updated_at": "2025-06-15T10:33:00Z",
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"type": "outgoing",
"status": "sent",
"conversation_id": 123,
"content": "<p>Hello! How can I help you today?</p>",
"text_content": "Hello! How can I help you today?",
"content_type": "html",
"private": false,
"sender_id": 789,
"sender_type": "agent",
"attachments": []
}
}
```
#### `message.updated`
Triggered when an existing message is updated.
**Sample Payload:**
```json
{
"event": "message.updated",
"timestamp": "2025-06-15T10:34:00Z",
"payload": {
"id": 987,
"created_at": "2025-06-15T10:33:00Z",
"updated_at": "2025-06-15T10:34:00Z",
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"type": "outgoing",
"status": "sent",
"conversation_id": 123,
"content": "<p>Hello! How can I help you today? (Updated)</p>",
"text_content": "Hello! How can I help you today? (Updated)",
"content_type": "html",
"private": false,
"sender_id": 789,
"sender_type": "agent",
"attachments": []
}
}
```
## Delivery and Retries
- Webhooks requests timeout can be configured in the `config.toml` file
- Failed deliveries are not automatically retried
- Webhook delivery runs in a background worker pool for better performance
- If the webhook queue is full (configurable in config.toml file), new events may be dropped
## Testing Webhooks
You can test your webhook configuration using tools like:
- [Webhook.site](https://webhook.site) - Generate a temporary URL to inspect webhook payloads

View File

@@ -1,38 +0,0 @@
site_name: Libredesk Docs
theme:
name: material
language: en
font:
text: Source Sans Pro
code: Roboto Mono
weights: [400, 700]
direction: ltr
palette:
primary: white
accent: red
features:
- navigation.indexes
- navigation.sections
- content.code.copy
extra:
search:
language: en
markdown_extensions:
- admonition
- codehilite
- toc:
permalink: true
nav:
- Introduction: index.md
- Getting Started:
- Installation: installation.md
- Upgrade Guide: upgrade.md
- Email Templates: templating.md
- SSO Setup: sso.md
- Webhooks: webhooks.md
- API Getting Started: api-getting-started.md
- Contributions:
- Developer Setup: developer-setup.md
- Translate Libredesk: translations.md

View File

@@ -2,23 +2,33 @@
describe('Login Component', () => {
beforeEach(() => {
// Visit the login page
cy.visit('/')
// Mock the API response for OIDC providers
cy.intercept('GET', '**/api/v1/oidc/enabled', {
cy.intercept('GET', '**/api/v1/config', {
statusCode: 200,
body: {
data: [
{
id: 1,
name: 'Google',
logo_url: 'https://example.com/google-logo.png',
disabled: false
}
]
data: {
"app.favicon_url": "http://localhost:9000/favicon.ico",
"app.lang": "en",
"app.logo_url": "http://localhost:9000/logo.png",
"app.site_name": "Libredesk",
"app.sso_providers": [
{
"client_id": "xx",
"enabled": true,
"id": 1,
"logo_url": "/images/google-logo.png",
"name": "Google",
"provider": "Google",
"provider_url": "https://accounts.google.com",
"redirect_uri": "http://localhost:9000/api/v1/oidc/1/finish"
}
]
}
}
}).as('getOIDCProviders')
// Visit the login page
cy.visit('/')
})
it('should display login form', () => {

View File

@@ -1,6 +1,6 @@
{
"name": "libredesk",
"version": "0.6.0-alpha",
"version": "0.8.0-beta",
"private": true,
"type": "module",
"scripts": {
@@ -39,7 +39,7 @@
"@unovis/vue": "^1.4.4",
"@vee-validate/zod": "^4.15.0",
"@vueuse/core": "^12.4.0",
"axios": "^1.8.2",
"axios": "^1.12.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"codemirror": "^6.0.2",
@@ -78,7 +78,7 @@
"start-server-and-test": "^2.0.3",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"vite": "^5.4.19",
"vite": "^5.4.20",
"vitest": "^3.2.2"
},
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"

306
frontend/pnpm-lock.yaml generated
View File

@@ -72,8 +72,8 @@ importers:
specifier: ^12.4.0
version: 12.4.0(typescript@5.7.3)
axios:
specifier: ^1.8.2
version: 1.8.2(debug@4.4.0)
specifier: ^1.12.0
version: 1.12.0(debug@4.4.0)
class-variance-authority:
specifier: ^0.7.0
version: 0.7.1
@@ -118,7 +118,7 @@ importers:
version: 5.2.0(vue@3.5.13(typescript@5.7.3))
vue-i18n:
specifier: '9'
version: 9.14.3(vue@3.5.13(typescript@5.7.3))
version: 9.14.5(vue@3.5.13(typescript@5.7.3))
vue-letter:
specifier: ^0.2.0
version: 0.2.0
@@ -146,7 +146,7 @@ importers:
version: 1.10.5
'@vitejs/plugin-vue':
specifier: ^5.0.3
version: 5.2.1(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))
version: 5.2.1(vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))
'@vue/eslint-config-prettier':
specifier: ^8.0.0
version: 8.0.0(eslint@8.57.1)(prettier@3.4.2)
@@ -184,8 +184,8 @@ importers:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.17)
vite:
specifier: ^5.4.19
version: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
specifier: ^5.4.20
version: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
vitest:
specifier: ^3.2.2
version: 3.2.2(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
@@ -510,16 +510,16 @@ packages:
'@internationalized/number@3.6.0':
resolution: {integrity: sha512-PtrRcJVy7nw++wn4W2OuePQQfTqDzfusSuY1QTtui4wa7r+rGVtR75pO8CyKvHvzyQYi3Q1uO5sY0AsB4e65Bw==}
'@intlify/core-base@9.14.3':
resolution: {integrity: sha512-nbJ7pKTlXFnaXPblyfiH6awAx1C0PWNNuqXAR74yRwgi5A/Re/8/5fErLY0pv4R8+EHj3ZaThMHdnuC/5OBa6g==}
'@intlify/core-base@9.14.5':
resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==}
engines: {node: '>= 16'}
'@intlify/message-compiler@9.14.3':
resolution: {integrity: sha512-ANwC226BQdd+MpJ36rOYkChSESfPwu3Ss2Faw0RHTOknYLoHTX6V6e/JjIKVDMbzs0/H/df/rO6yU0SPiWHqNg==}
'@intlify/message-compiler@9.14.5':
resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==}
engines: {node: '>= 16'}
'@intlify/shared@9.14.3':
resolution: {integrity: sha512-hJXz9LA5VG7qNE00t50bdzDv8Z4q9fpcL81wj4y4duKavrv0KM8YNLTwXNEFINHjTsfrG9TXvPuEjVaAvZ7yWg==}
'@intlify/shared@9.14.5':
resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==}
engines: {node: '>= 16'}
'@isaacs/cliui@8.0.2':
@@ -708,103 +708,108 @@ packages:
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
'@rollup/rollup-android-arm-eabi@4.41.1':
resolution: {integrity: sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==}
'@rollup/rollup-android-arm-eabi@4.50.2':
resolution: {integrity: sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.41.1':
resolution: {integrity: sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA==}
'@rollup/rollup-android-arm64@4.50.2':
resolution: {integrity: sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.41.1':
resolution: {integrity: sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w==}
'@rollup/rollup-darwin-arm64@4.50.2':
resolution: {integrity: sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.41.1':
resolution: {integrity: sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg==}
'@rollup/rollup-darwin-x64@4.50.2':
resolution: {integrity: sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.41.1':
resolution: {integrity: sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg==}
'@rollup/rollup-freebsd-arm64@4.50.2':
resolution: {integrity: sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.41.1':
resolution: {integrity: sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA==}
'@rollup/rollup-freebsd-x64@4.50.2':
resolution: {integrity: sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.41.1':
resolution: {integrity: sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==}
'@rollup/rollup-linux-arm-gnueabihf@4.50.2':
resolution: {integrity: sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.41.1':
resolution: {integrity: sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==}
'@rollup/rollup-linux-arm-musleabihf@4.50.2':
resolution: {integrity: sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.41.1':
resolution: {integrity: sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==}
'@rollup/rollup-linux-arm64-gnu@4.50.2':
resolution: {integrity: sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.41.1':
resolution: {integrity: sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==}
'@rollup/rollup-linux-arm64-musl@4.50.2':
resolution: {integrity: sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-loongarch64-gnu@4.41.1':
resolution: {integrity: sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==}
'@rollup/rollup-linux-loong64-gnu@4.50.2':
resolution: {integrity: sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-powerpc64le-gnu@4.41.1':
resolution: {integrity: sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==}
'@rollup/rollup-linux-ppc64-gnu@4.50.2':
resolution: {integrity: sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.41.1':
resolution: {integrity: sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==}
'@rollup/rollup-linux-riscv64-gnu@4.50.2':
resolution: {integrity: sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.41.1':
resolution: {integrity: sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==}
'@rollup/rollup-linux-riscv64-musl@4.50.2':
resolution: {integrity: sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.41.1':
resolution: {integrity: sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==}
'@rollup/rollup-linux-s390x-gnu@4.50.2':
resolution: {integrity: sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.41.1':
resolution: {integrity: sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==}
'@rollup/rollup-linux-x64-gnu@4.50.2':
resolution: {integrity: sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.41.1':
resolution: {integrity: sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==}
'@rollup/rollup-linux-x64-musl@4.50.2':
resolution: {integrity: sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==}
cpu: [x64]
os: [linux]
'@rollup/rollup-win32-arm64-msvc@4.41.1':
resolution: {integrity: sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==}
'@rollup/rollup-openharmony-arm64@4.50.2':
resolution: {integrity: sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.50.2':
resolution: {integrity: sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.41.1':
resolution: {integrity: sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg==}
'@rollup/rollup-win32-ia32-msvc@4.50.2':
resolution: {integrity: sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.41.1':
resolution: {integrity: sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw==}
'@rollup/rollup-win32-x64-msvc@4.50.2':
resolution: {integrity: sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA==}
cpu: [x64]
os: [win32]
@@ -1136,8 +1141,8 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/geojson@7946.0.15':
resolution: {integrity: sha512-9oSxFzDCT2Rj6DfcHF8G++jxBKS7mBqXl5xrRW+Kbvjry6Uduya2iiwqHPhVXpasAVMBYKkEPGgKhd3+/HZ6xA==}
@@ -1451,8 +1456,8 @@ packages:
aws4@1.13.2:
resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==}
axios@1.8.2:
resolution: {integrity: sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==}
axios@1.12.0:
resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==}
babel-plugin-macros@3.1.0:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
@@ -1857,6 +1862,15 @@ packages:
supports-color:
optional: true
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
@@ -2139,8 +2153,8 @@ packages:
flatted@3.3.2:
resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==}
follow-redirects@1.15.9:
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
@@ -2155,8 +2169,8 @@ packages:
forever-agent@0.6.1:
resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==}
form-data@4.0.2:
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
form-data@4.0.4:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
fraction.js@4.3.7:
@@ -2992,8 +3006,8 @@ packages:
robust-predicates@3.0.2:
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
rollup@4.41.1:
resolution: {integrity: sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==}
rollup@4.50.2:
resolution: {integrity: sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@@ -3089,9 +3103,9 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
source-map@0.7.4:
resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==}
engines: {node: '>= 8'}
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
@@ -3355,8 +3369,8 @@ packages:
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite@5.4.19:
resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==}
vite@5.4.20:
resolution: {integrity: sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -3439,8 +3453,8 @@ packages:
peerDependencies:
eslint: '>=6.0.0'
vue-i18n@9.14.3:
resolution: {integrity: sha512-C+E0KE8ihKjdYCQx8oUkXX+8tBItrYNMnGJuzEPevBARQFUN2tKez6ZVOvBrWH0+KT5wEk3vOWjNk7ygb2u9ig==}
vue-i18n@9.14.5:
resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==}
engines: {node: '>= 16'}
peerDependencies:
vue: ^3.0.0
@@ -3700,7 +3714,7 @@ snapshots:
combined-stream: 1.0.8
extend: 3.0.2
forever-agent: 0.6.1
form-data: 4.0.2
form-data: 4.0.4
http-signature: 1.4.0
is-typedarray: 1.0.0
isstream: 0.1.2
@@ -3914,17 +3928,17 @@ snapshots:
dependencies:
'@swc/helpers': 0.5.15
'@intlify/core-base@9.14.3':
'@intlify/core-base@9.14.5':
dependencies:
'@intlify/message-compiler': 9.14.3
'@intlify/shared': 9.14.3
'@intlify/message-compiler': 9.14.5
'@intlify/shared': 9.14.5
'@intlify/message-compiler@9.14.3':
'@intlify/message-compiler@9.14.5':
dependencies:
'@intlify/shared': 9.14.3
'@intlify/shared': 9.14.5
source-map-js: 1.2.1
'@intlify/shared@9.14.3': {}
'@intlify/shared@9.14.5': {}
'@isaacs/cliui@8.0.2':
dependencies:
@@ -4091,64 +4105,67 @@ snapshots:
'@remirror/core-constants@3.0.0': {}
'@rollup/rollup-android-arm-eabi@4.41.1':
'@rollup/rollup-android-arm-eabi@4.50.2':
optional: true
'@rollup/rollup-android-arm64@4.41.1':
'@rollup/rollup-android-arm64@4.50.2':
optional: true
'@rollup/rollup-darwin-arm64@4.41.1':
'@rollup/rollup-darwin-arm64@4.50.2':
optional: true
'@rollup/rollup-darwin-x64@4.41.1':
'@rollup/rollup-darwin-x64@4.50.2':
optional: true
'@rollup/rollup-freebsd-arm64@4.41.1':
'@rollup/rollup-freebsd-arm64@4.50.2':
optional: true
'@rollup/rollup-freebsd-x64@4.41.1':
'@rollup/rollup-freebsd-x64@4.50.2':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.41.1':
'@rollup/rollup-linux-arm-gnueabihf@4.50.2':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.41.1':
'@rollup/rollup-linux-arm-musleabihf@4.50.2':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.41.1':
'@rollup/rollup-linux-arm64-gnu@4.50.2':
optional: true
'@rollup/rollup-linux-arm64-musl@4.41.1':
'@rollup/rollup-linux-arm64-musl@4.50.2':
optional: true
'@rollup/rollup-linux-loongarch64-gnu@4.41.1':
'@rollup/rollup-linux-loong64-gnu@4.50.2':
optional: true
'@rollup/rollup-linux-powerpc64le-gnu@4.41.1':
'@rollup/rollup-linux-ppc64-gnu@4.50.2':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.41.1':
'@rollup/rollup-linux-riscv64-gnu@4.50.2':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.41.1':
'@rollup/rollup-linux-riscv64-musl@4.50.2':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.41.1':
'@rollup/rollup-linux-s390x-gnu@4.50.2':
optional: true
'@rollup/rollup-linux-x64-gnu@4.41.1':
'@rollup/rollup-linux-x64-gnu@4.50.2':
optional: true
'@rollup/rollup-linux-x64-musl@4.41.1':
'@rollup/rollup-linux-x64-musl@4.50.2':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.41.1':
'@rollup/rollup-openharmony-arm64@4.50.2':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.41.1':
'@rollup/rollup-win32-arm64-msvc@4.50.2':
optional: true
'@rollup/rollup-win32-x64-msvc@4.41.1':
'@rollup/rollup-win32-ia32-msvc@4.50.2':
optional: true
'@rollup/rollup-win32-x64-msvc@4.50.2':
optional: true
'@rushstack/eslint-patch@1.10.5': {}
@@ -4513,7 +4530,7 @@ snapshots:
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.7': {}
'@types/estree@1.0.8': {}
'@types/geojson@7946.0.15': {}
@@ -4659,9 +4676,9 @@ snapshots:
transitivePeerDependencies:
- vue
'@vitejs/plugin-vue@5.2.1(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))':
'@vitejs/plugin-vue@5.2.1(vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))':
dependencies:
vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
vite: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
vue: 3.5.13(typescript@5.7.3)
'@vitest/expect@3.2.2':
@@ -4672,13 +4689,13 @@ snapshots:
chai: 5.2.0
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.2(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))':
'@vitest/mocker@3.2.2(vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))':
dependencies:
'@vitest/spy': 3.2.2
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
vite: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
'@vitest/pretty-format@3.2.2':
dependencies:
@@ -4929,10 +4946,10 @@ snapshots:
aws4@1.13.2: {}
axios@1.8.2(debug@4.4.0):
axios@1.12.0(debug@4.4.0):
dependencies:
follow-redirects: 1.15.9(debug@4.4.0)
form-data: 4.0.2
follow-redirects: 1.15.11(debug@4.4.0)
form-data: 4.0.4
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
@@ -5393,6 +5410,11 @@ snapshots:
dependencies:
ms: 2.1.3
debug@4.4.3:
dependencies:
ms: 2.1.3
optional: true
decode-uri-component@0.2.2:
optional: true
@@ -5618,7 +5640,7 @@ snapshots:
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.7
'@types/estree': 1.0.8
esutils@2.0.3: {}
@@ -5733,7 +5755,7 @@ snapshots:
flatted@3.3.2: {}
follow-redirects@1.15.9(debug@4.4.0):
follow-redirects@1.15.11(debug@4.4.0):
optionalDependencies:
debug: 4.4.0(supports-color@8.1.1)
@@ -5744,11 +5766,12 @@ snapshots:
forever-agent@0.6.1: {}
form-data@4.0.2:
form-data@4.0.4:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
fraction.js@4.3.7: {}
@@ -6581,30 +6604,31 @@ snapshots:
robust-predicates@3.0.2: {}
rollup@4.41.1:
rollup@4.50.2:
dependencies:
'@types/estree': 1.0.7
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.41.1
'@rollup/rollup-android-arm64': 4.41.1
'@rollup/rollup-darwin-arm64': 4.41.1
'@rollup/rollup-darwin-x64': 4.41.1
'@rollup/rollup-freebsd-arm64': 4.41.1
'@rollup/rollup-freebsd-x64': 4.41.1
'@rollup/rollup-linux-arm-gnueabihf': 4.41.1
'@rollup/rollup-linux-arm-musleabihf': 4.41.1
'@rollup/rollup-linux-arm64-gnu': 4.41.1
'@rollup/rollup-linux-arm64-musl': 4.41.1
'@rollup/rollup-linux-loongarch64-gnu': 4.41.1
'@rollup/rollup-linux-powerpc64le-gnu': 4.41.1
'@rollup/rollup-linux-riscv64-gnu': 4.41.1
'@rollup/rollup-linux-riscv64-musl': 4.41.1
'@rollup/rollup-linux-s390x-gnu': 4.41.1
'@rollup/rollup-linux-x64-gnu': 4.41.1
'@rollup/rollup-linux-x64-musl': 4.41.1
'@rollup/rollup-win32-arm64-msvc': 4.41.1
'@rollup/rollup-win32-ia32-msvc': 4.41.1
'@rollup/rollup-win32-x64-msvc': 4.41.1
'@rollup/rollup-android-arm-eabi': 4.50.2
'@rollup/rollup-android-arm64': 4.50.2
'@rollup/rollup-darwin-arm64': 4.50.2
'@rollup/rollup-darwin-x64': 4.50.2
'@rollup/rollup-freebsd-arm64': 4.50.2
'@rollup/rollup-freebsd-x64': 4.50.2
'@rollup/rollup-linux-arm-gnueabihf': 4.50.2
'@rollup/rollup-linux-arm-musleabihf': 4.50.2
'@rollup/rollup-linux-arm64-gnu': 4.50.2
'@rollup/rollup-linux-arm64-musl': 4.50.2
'@rollup/rollup-linux-loong64-gnu': 4.50.2
'@rollup/rollup-linux-ppc64-gnu': 4.50.2
'@rollup/rollup-linux-riscv64-gnu': 4.50.2
'@rollup/rollup-linux-riscv64-musl': 4.50.2
'@rollup/rollup-linux-s390x-gnu': 4.50.2
'@rollup/rollup-linux-x64-gnu': 4.50.2
'@rollup/rollup-linux-x64-musl': 4.50.2
'@rollup/rollup-openharmony-arm64': 4.50.2
'@rollup/rollup-win32-arm64-msvc': 4.50.2
'@rollup/rollup-win32-ia32-msvc': 4.50.2
'@rollup/rollup-win32-x64-msvc': 4.50.2
fsevents: 2.3.3
rope-sequence@1.3.4: {}
@@ -6703,7 +6727,7 @@ snapshots:
source-map@0.6.1:
optional: true
source-map@0.7.4:
source-map@0.7.6:
optional: true
speakingurl@14.0.1: {}
@@ -6778,11 +6802,11 @@ snapshots:
stylus@0.57.0:
dependencies:
css: 3.0.0
debug: 4.4.1
debug: 4.4.3
glob: 7.2.3
safer-buffer: 2.1.2
sax: 1.2.4
source-map: 0.7.4
source-map: 0.7.6
transitivePeerDependencies:
- supports-color
optional: true
@@ -6982,7 +7006,7 @@ snapshots:
debug: 4.4.1
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
vite: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
transitivePeerDependencies:
- '@types/node'
- less
@@ -6994,11 +7018,11 @@ snapshots:
- supports-color
- terser
vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0):
vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0):
dependencies:
esbuild: 0.21.5
postcss: 8.4.49
rollup: 4.41.1
rollup: 4.50.2
optionalDependencies:
'@types/node': 22.10.5
fsevents: 2.3.3
@@ -7009,7 +7033,7 @@ snapshots:
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.2
'@vitest/mocker': 3.2.2(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))
'@vitest/mocker': 3.2.2(vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))
'@vitest/pretty-format': 3.2.2
'@vitest/runner': 3.2.2
'@vitest/snapshot': 3.2.2
@@ -7027,7 +7051,7 @@ snapshots:
tinyglobby: 0.2.14
tinypool: 1.1.0
tinyrainbow: 2.0.0
vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
vite: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
vite-node: 3.2.2(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
why-is-node-running: 2.3.0
optionalDependencies:
@@ -7071,10 +7095,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-i18n@9.14.3(vue@3.5.13(typescript@5.7.3)):
vue-i18n@9.14.5(vue@3.5.13(typescript@5.7.3)):
dependencies:
'@intlify/core-base': 9.14.3
'@intlify/shared': 9.14.3
'@intlify/core-base': 9.14.5
'@intlify/shared': 9.14.5
'@vue/devtools-api': 6.6.4
vue: 3.5.13(typescript@5.7.3)
@@ -7122,7 +7146,7 @@ snapshots:
wait-on@8.0.1(debug@4.4.0):
dependencies:
axios: 1.8.2(debug@4.4.0)
axios: 1.12.0(debug@4.4.0)
joi: 17.13.3
lodash: 4.17.21
minimist: 1.2.8

View File

@@ -88,8 +88,8 @@
@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')" />
<!-- Show admin banner only in admin routes -->
<AdminBanner v-if="route.path.startsWith('/admin')" />
<!-- Common header for all pages -->
<PageHeader />
@@ -128,7 +128,7 @@ import { useCustomAttributeStore } from '@/stores/customAttributes'
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 AdminBanner from '@/components/banner/AdminBanner.vue'
import api from '@/api'
import { toast as sooner } from 'vue-sonner'
import Sidebar from '@/components/sidebar/Sidebar.vue'

View File

@@ -122,7 +122,7 @@ const createOIDC = (data) =>
'Content-Type': 'application/json'
}
})
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled')
const getConfig = () => http.get('/api/v1/config')
const getAllOIDC = () => http.get('/api/v1/oidc')
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
const updateOIDC = (id, data) =>
@@ -514,7 +514,7 @@ export default {
updateSettings,
createOIDC,
getAllOIDC,
getAllEnabledOIDC,
getConfig,
getOIDC,
updateOIDC,
deleteOIDC,

View File

@@ -137,10 +137,10 @@
--background: 240 5.9% 10%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card: 240 5.9% 10%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover: 240 5.9% 10%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
@@ -184,6 +184,10 @@
@apply border shadow rounded;
}
.loading-fade {
@apply opacity-50 transition-opacity duration-300
}
// Scrollbar start
::-webkit-scrollbar {
width: 8px; /* Adjust width */

View File

@@ -0,0 +1,63 @@
<template>
<div class="border-b">
<!-- Update notification -->
<div
v-if="appSettingsStore.settings['app.update']?.update?.is_new"
class="px-4 py-2.5 border-b border-border/50 last:border-b-0"
>
<div class="flex items-center gap-3">
<div class="flex-shrink-0">
<Download class="w-5 h-5 text-primary" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-sm text-foreground">
<span>{{ $t('update.newUpdateAvailable') }}</span>
<a
:href="appSettingsStore.settings['app.update'].update.url"
target="_blank"
rel="nofollow noreferrer"
class="font-semibold text-primary hover:text-primary/80 underline transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
>
{{ appSettingsStore.settings['app.update'].update.release_version }}
</a>
<span class="text-muted-foreground"></span>
<span class="text-muted-foreground">
{{ appSettingsStore.settings['app.update'].update.release_date }}
</span>
</div>
<!-- Update description -->
<div
v-if="appSettingsStore.settings['app.update'].update.description"
class="mt-2 text-xs text-muted-foreground"
>
{{ appSettingsStore.settings['app.update'].update.description }}
</div>
</div>
</div>
</div>
<!-- Restart required notification -->
<div
v-if="appSettingsStore.settings['app.restart_required']"
class="px-4 py-2.5 border-b border-border/50 last:border-b-0"
>
<div class="flex items-center gap-3">
<div class="flex-shrink-0">
<Info class="w-5 h-5 text-primary" />
</div>
<div class="min-w-0 flex-1">
<div class="text-sm text-foreground">
{{ $t('admin.banner.restartMessage') }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { Download, Info } from 'lucide-vue-next'
import { useAppSettingsStore } from '@/stores/appSettings'
const appSettingsStore = useAppSettingsStore()
</script>

View File

@@ -1,7 +1,7 @@
<template>
<Button
variant="ghost"
@click.prevent="onClose"
@click.stop="onClose"
size="xs"
class="text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 w-6 h-6 p-0"
>

View File

@@ -52,8 +52,15 @@
<div class="flex-1">
<div v-if="modelFilter.field && modelFilter.operator">
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
<SelectTag
v-if="getFieldType(modelFilter) === FIELD_TYPE.MULTI_SELECT"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
:placeholder="t('globals.messages.select', { name: t('globals.terms.tag', 2) })"
/>
<SelectComboBox
v-if="
v-else-if="
getFieldOptions(modelFilter).length > 0 &&
modelFilter.field === 'assigned_user_id'
"
@@ -94,8 +101,9 @@
<CloseButton :onClose="() => removeFilter(index)" />
</div>
<!-- Button Container -->
<div class="flex items-center justify-between pt-3">
<Button variant="ghost" size="sm" @click="addFilter" class="text-slate-600">
<Button variant="ghost" size="sm" @click.stop="addFilter" class="text-slate-600">
<Plus class="w-3 h-3 mr-1" />
{{
$t('globals.messages.add', {
@@ -104,15 +112,17 @@
}}
</Button>
<div class="flex gap-2" v-if="showButtons">
<Button variant="ghost" @click="clearFilters">{{ $t('globals.messages.reset') }}</Button>
<Button @click="applyFilters">{{ $t('globals.messages.apply') }}</Button>
<Button variant="ghost" @click.stop="clearFilters">
{{ $t('globals.messages.reset') }}
</Button>
<Button @click.stop="applyFilters">{{ $t('globals.messages.apply') }}</Button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import {
Select,
SelectContent,
@@ -125,8 +135,10 @@ import { Plus } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useI18n } from 'vue-i18n'
import { FIELD_TYPE } from '@/constants/filterConfig'
import CloseButton from '@/components/button/CloseButton.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import SelectTag from '@/components/ui/select/SelectTag.vue'
const props = defineProps({
fields: {
@@ -150,12 +162,17 @@ onMounted(() => {
}
})
onUnmounted(() => {
// On unmounted set valid filters
modelValue.value = validFilters.value
})
const getModel = (field) => {
const fieldConfig = props.fields.find((f) => f.field === field)
return fieldConfig?.model || ''
}
// Set model for each filter
// Set model for each filter and the default value
watch(
() => modelValue.value,
(filters) => {
@@ -163,6 +180,15 @@ watch(
if (filter.field && !filter.model) {
filter.model = getModel(filter.field)
}
// Multi select need arrays as their default value
if (
filter.field &&
getFieldType(filter) === FIELD_TYPE.MULTI_SELECT &&
!Array.isArray(filter.value)
) {
filter.value = []
}
})
},
{ deep: true }
@@ -170,15 +196,20 @@ watch(
// Reset operator and value when field changes for a filter at a given index
watch(
() => modelValue.value.map((f) => f.field),
(newFields, oldFields) => {
newFields.forEach((field, index) => {
if (field !== oldFields[index]) {
modelValue.value[index].operator = ''
modelValue.value[index].value = ''
modelValue,
(newFilters, oldFilters) => {
// Skip first run
if (!oldFilters) return
newFilters.forEach((filter, index) => {
const oldFilter = oldFilters[index]
if (oldFilter && filter.field !== oldFilter.field) {
filter.operator = ''
filter.value = ''
}
})
}
},
{ deep: true }
)
const addFilter = () => {
@@ -197,7 +228,17 @@ const clearFilters = () => {
}
const validFilters = computed(() => {
return modelValue.value.filter((filter) => filter.field && filter.operator && filter.value)
return modelValue.value.filter((filter) => {
// For multi-select field type, allow empty array as a valid value
const field = props.fields.find((f) => f.field === filter.field)
const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT
if (isMultiSelectField) {
return filter.field && filter.operator && filter.value !== undefined && filter.value !== null
}
return filter.field && filter.operator && filter.value
})
})
const getFieldOptions = (fieldValue) => {
@@ -209,4 +250,9 @@ const getFieldOperators = (modelFilter) => {
const field = props.fields.find((f) => f.field === modelFilter.field)
return field?.operators || []
}
const getFieldType = (modelFilter) => {
const field = props.fields.find((f) => f.field === modelFilter.field)
return field?.type || ''
}
</script>

View File

@@ -4,9 +4,9 @@
@click="handleClick">
<div class="flex items-center mb-2">
<component :is="icon" size="24" class="mr-2 text-primary" />
<h3 class="text-lg font-medium text-gray-800">{{ title }}</h3>
<h3 class="text-lg font-medium">{{ title }}</h3>
</div>
<p class="text-sm text-gray-600">{{ subTitle }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ subTitle }}</p>
</div>
</template>

View File

@@ -38,6 +38,16 @@ import {
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { filterNavItems } from '@/utils/nav-permissions'
import { useStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
@@ -73,8 +83,17 @@ const editView = (view) => {
emit('editView', view)
}
const deleteView = (view) => {
emit('deleteView', view)
const openDeleteConfirmation = (view) => {
viewToDelete.value = view
isDeleteOpen.value = true
}
const handleDeleteView = () => {
if (viewToDelete.value) {
emit('deleteView', viewToDelete.value)
isDeleteOpen.value = false
viewToDelete.value = null
}
}
// Navigation methods with conversation retention
@@ -157,6 +176,13 @@ watch(
const sidebarOpen = useStorage('mainSidebarOpen', true)
const teamInboxOpen = useStorage('teamInboxOpen', true)
const viewInboxOpen = useStorage('viewInboxOpen', true)
// Track which view is being hovered for ellipsis menu visibility
const hoveredViewId = ref(null)
// Track delete confirmation dialog state
const isDeleteOpen = ref(false)
const viewToDelete = ref(null)
</script>
<template>
@@ -472,24 +498,35 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<CollapsibleContent>
<SidebarMenuSub v-for="view in userViews" :key="view.id">
<SidebarMenuSubItem>
<SidebarMenuSubItem
@mouseenter="hoveredViewId = view.id"
@mouseleave="hoveredViewId = null"
>
<SidebarMenuButton
size="sm"
:isActive="route.params.viewID == view.id"
asChild
>
<a href="#" @click.prevent="navigateToViewInbox(view.id)">
<span class="break-words w-32 truncate">{{ view.name }}</span>
<SidebarMenuAction :showOnHover="true" class="mr-3">
<span class="break-words w-32 truncate" :title="view.name">{{ view.name }}</span>
<SidebarMenuAction
@click.stop
:class="[
'mr-3',
'md:opacity-0',
'data-[state=open]:opacity-100',
{ 'md:opacity-100': hoveredViewId === view.id }
]"
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild @click.prevent>
<EllipsisVertical />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="() => editView(view)">
<span>{{ t('globals.messages.edit') }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="() => deleteView(view)">
<DropdownMenuItem @click="() => openDeleteConfirmation(view)">
<span>{{ t('globals.messages.delete') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -513,4 +550,22 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<slot></slot>
</SidebarInset>
</SidebarProvider>
<!-- View Delete Confirmation Dialog -->
<AlertDialog v-model:open="isDeleteOpen">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>
{{ t('globals.messages.deletionConfirmation', { name: t('globals.terms.view') }) }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ t('globals.messages.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleDeleteView">
{{ t('globals.messages.delete') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View File

@@ -8,7 +8,7 @@
:class="['w-full justify-between', buttonClass]"
>
<slot name="selected" :selected="selectedItem">{{ selectedLabel }}</slot>
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
<CaretSortIcon class="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="p-0">

View File

@@ -1,4 +1,5 @@
<template>
<!-- idk why I named this select tag, should be named multi-select -->
<TagsInput v-model="tags" class="px-0 gap-0" :displayValue="getLabel">
<!-- Tags visible to the user -->
<div class="flex gap-2 flex-wrap items-center px-3">
@@ -24,6 +25,7 @@
@keydown.enter.prevent
@blur="handleBlur"
@click="open = true"
@input.stop
/>
</ComboboxInput>
</ComboboxAnchor>

View File

@@ -1,25 +0,0 @@
<template>
<div
v-if="appSettingsStore.settings['app.update']?.update?.is_new"
class="p-2 mb-2 border-b bg-secondary text-secondary-foreground"
>
{{ $t('update.newUpdateAvailable') }}:
{{ 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"
>
{{ $t('globals.messages.viewDetails') }}
</a>
</div>
</template>
<script setup>
import { useAppSettingsStore } from '@/stores/appSettings'
const appSettingsStore = useAppSettingsStore()
</script>

View File

@@ -5,6 +5,7 @@ import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla'
import { useCustomAttributeStore } from '@/stores/customAttributes'
import { useTagStore } from '@/stores/tag'
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
import { useI18n } from 'vue-i18n'
@@ -15,6 +16,7 @@ export function useConversationFilters () {
const tStore = useTeamStore()
const slaStore = useSlaStore()
const customAttributeStore = useCustomAttributeStore()
const tagStore = useTagStore()
const { t } = useI18n()
const customAttributeDataTypeToFieldType = {
@@ -69,6 +71,12 @@ export function useConversationFilters () {
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: iStore.options
},
tags: {
label: t('globals.terms.tag', 2),
type: FIELD_TYPE.MULTI_SELECT,
operators: FIELD_OPERATORS.MULTI_SELECT,
options: tagStore.tagOptions
}
}))

View File

@@ -1,6 +1,7 @@
export const FIELD_TYPE = {
SELECT: 'select',
TAG: 'tag',
MULTI_SELECT: 'multi-select',
TEXT: 'text',
NUMBER: 'number',
RICHTEXT: 'richtext',
@@ -39,4 +40,5 @@ export const FIELD_OPERATORS = {
OPERATOR.LESS_THAN
],
NUMBER: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.GREATER_THAN, OPERATOR.LESS_THAN],
MULTI_SELECT: [OPERATOR.CONTAINS, OPERATOR.NOT_CONTAINS, OPERATOR.SET, OPERATOR.NOT_SET]
}

View File

@@ -12,6 +12,7 @@ export const permissions = {
CONVERSATIONS_UPDATE_TAGS: 'conversations:update_tags',
MESSAGES_READ: 'messages:read',
MESSAGES_WRITE: 'messages:write',
MESSAGES_WRITE_AS_CONTACT: 'messages:write_as_contact',
VIEW_MANAGE: 'view:manage',
GENERAL_SETTINGS_MANAGE: 'general_settings:manage',
NOTIFICATION_SETTINGS_MANAGE: 'notification_settings:manage',

View File

@@ -1 +1,3 @@
export const Roles = ["Admin", "Agent"]
export const Roles = ["Admin", "Agent"]
export const UserTypeAgent = "agent"
export const UserTypeContact = "contact"

View File

@@ -418,7 +418,6 @@ const onSubmit = form.handleSubmit((values) => {
if (values.availability_status === 'active_group') {
values.availability_status = 'online'
}
values.teams = values.teams.map((team) => ({ name: team }))
props.submitForm(values)
})

View File

@@ -9,7 +9,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.firstName'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('first_name'))
return h('div', { class: 'text-center' }, row.getValue('first_name'))
}
},
{
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.lastName'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('last_name'))
return h('div', { class: 'text-center' }, row.getValue('last_name'))
}
},
{
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.enabled'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
return h('div', { class: 'text-center' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
}
},
{
@@ -36,7 +36,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.email'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('email'))
return h('div', { class: 'text-center' }, row.getValue('email'))
}
},
{
@@ -47,7 +47,7 @@ export const createColumns = (t) => [
cell: function ({ row }) {
return h(
'div',
{ class: 'text-center font-medium' },
{ class: 'text-center' },
format(row.getValue('created_at'), 'PPpp')
)
}
@@ -60,7 +60,7 @@ export const createColumns = (t) => [
cell: function ({ row }) {
return h(
'div',
{ class: 'text-center font-medium' },
{ class: 'text-center' },
format(row.getValue('updated_at'), 'PPpp')
)
}

View File

@@ -9,7 +9,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
return h('div', { class: 'text-center' }, row.getValue('name'))
}
},
{
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
}
},
{
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
}
},
{

View File

@@ -9,7 +9,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
return h('div', { class: 'text-center' }, row.getValue('name'))
}
},
{
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.key'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('key'))
return h('div', { class: 'text-center' }, row.getValue('key'))
}
},
{
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.type'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('data_type'))
return h('div', { class: 'text-center' }, row.getValue('data_type'))
}
},
{
@@ -36,7 +36,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.appliesTo'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('applies_to'))
return h('div', { class: 'text-center' }, row.getValue('applies_to'))
}
},
{
@@ -47,7 +47,7 @@ export const createColumns = (t) => [
cell: function ({ row }) {
return h(
'div',
{ class: 'text-center font-medium' },
{ class: 'text-center' },
format(row.getValue('created_at'), 'PPpp')
)
}
@@ -60,7 +60,7 @@ export const createColumns = (t) => [
cell: function ({ row }) {
return h(
'div',
{ class: 'text-center font-medium' },
{ class: 'text-center' },
format(row.getValue('updated_at'), 'PPpp')
)
}

View File

@@ -9,7 +9,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
return h('div', { class: 'text-center' }, row.getValue('name'))
}
},
{

View File

@@ -9,7 +9,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
return h('div', { class: 'text-center' }, row.getValue('name'))
}
},
{
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.provider'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('provider'))
return h('div', { class: 'text-center' }, row.getValue('provider'))
}
},
{

View File

@@ -140,6 +140,7 @@ const permissions = ref([
{ name: perms.CONVERSATIONS_UPDATE_TAGS, label: t('admin.role.conversations.updateTags') },
{ name: perms.MESSAGES_READ, label: t('admin.role.messages.read') },
{ name: perms.MESSAGES_WRITE, label: t('admin.role.messages.write') },
{ name: perms.MESSAGES_WRITE_AS_CONTACT, label: t('admin.role.messages.writeAsContact') },
{ name: perms.VIEW_MANAGE, label: t('admin.role.view.manage') }
]
},

View File

@@ -8,7 +8,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
return h('div', { class: 'text-center' }, row.getValue('name'))
}
},
{
@@ -17,7 +17,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.description'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('description'))
return h('div', { class: 'text-center' }, row.getValue('description'))
}
},
{

View File

@@ -9,7 +9,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
return h('div', { class: 'text-center' }, row.getValue('name'))
}
},
{
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
}
},
{
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
}
},
{

View File

@@ -9,7 +9,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
return h('div', { class: 'text-center' }, row.getValue('name'))
}
},
{

View File

@@ -9,7 +9,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
return h('div', { class: 'text-center' }, row.getValue('name'))
}
},
{

View File

@@ -57,9 +57,8 @@
<Input type="number" placeholder="0" v-bind="componentField" />
</FormControl>
<FormDescription>
Maximum number of conversations that can be auto-assigned to an agent,
conversations in "Resolved" or "Closed" states do not count toward this limit. Set to 0
for unlimited.
Maximum number of conversations that can be auto-assigned to an agent, conversations in
"Resolved" or "Closed" states do not count toward this limit. Set to 0 for unlimited.
</FormDescription>
<FormMessage />
</FormItem>
@@ -97,6 +96,7 @@
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem :value = 0>None</SelectItem>
<SelectItem v-for="bh in businessHours" :key="bh.id" :value="bh.id">
{{ bh.name }}
</SelectItem>
@@ -121,6 +121,7 @@
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem :value= 0>None</SelectItem>
<SelectItem
v-for="sla in slaStore.options"
:key="sla.value"
@@ -226,7 +227,11 @@ const fetchBusinessHours = async () => {
}
const onSubmit = form.handleSubmit((values) => {
props.submitForm(values)
props.submitForm({
...values,
business_hours_id: values.business_hours_id > 0 ? values.business_hours_id : null,
sla_policy_id: values.sla_policy_id > 0 ? values.sla_policy_id: null
})
})
watch(

View File

@@ -9,7 +9,7 @@ export const columns = [
return h('div', { class: 'text-center' }, 'Name')
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
return h('div', { class: 'text-center' }, row.getValue('name'))
}
},
{
@@ -20,7 +20,7 @@ export const columns = [
cell: function ({ row }) {
return h(
'div',
{ class: 'text-center font-medium' },
{ class: 'text-center' },
format(row.getValue('created_at'), 'PPpp')
)
}
@@ -33,7 +33,7 @@ export const columns = [
cell: function ({ row }) {
return h(
'div',
{ class: 'text-center font-medium' },
{ class: 'text-center' },
format(row.getValue('updated_at'), 'PPpp')
)
}

View File

@@ -9,7 +9,7 @@ export const createOutgoingEmailTableColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
return h('div', { class: 'text-center' }, row.getValue('name'))
}
},
{
@@ -60,7 +60,7 @@ export const createEmailNotificationTableColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
return h('div', { class: 'text-center' }, row.getValue('name'))
}
},

View File

@@ -10,7 +10,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
return h('div', { class: 'text-center' }, row.getValue('name'))
}
},
{

View File

@@ -41,8 +41,8 @@
<div class="flex flex-col flex-1">
<div class="flex items-end">
<FormField v-slot="{ componentField }" name="phone_number_calling_code">
<FormItem class="w-20">
<FormField v-slot="{ componentField }" name="phone_number_country_code">
<FormItem class="w-max">
<FormLabel class="flex items-center whitespace-nowrap">
{{ t('globals.terms.phoneNumber') }}
</FormLabel>
@@ -58,13 +58,18 @@
<div class="w-7 h-7 flex items-center justify-center">
<span v-if="item.emoji">{{ item.emoji }}</span>
</div>
<span class="text-sm">{{ item.label }} ({{ item.value }})</span>
<span class="text-sm">{{ item.label }} ({{ item.calling_code }})</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center mb-1">
<span v-if="selected" class="text-xl leading-none">{{ selected.emoji }}</span>
<div class="flex items-center gap-1">
<span v-if="selected" class="text-lg">{{ selected.emoji }}</span>
<span
v-if="selected && selected.calling_code"
class="text-xs text-muted-foreground"
>({{ selected.calling_code }})</span
>
</div>
</template>
</ComboBox>
@@ -116,7 +121,8 @@ const userStore = useUserStore()
const allCountries = countries.map((country) => ({
label: country.name,
value: country.calling_code,
emoji: country.emoji
value: country.iso_2,
emoji: country.emoji,
calling_code: country.calling_code
}))
</script>

View File

@@ -33,13 +33,7 @@
/>
</div>
<div class="flex justify-end space-x-3 pt-2">
<Button
variant="outline"
@click="cancelAddNote"
class="transition-all hover:bg-gray-100"
>
Cancel
</Button>
<Button variant="outline" @click="cancelAddNote"> Cancel </Button>
<Button type="submit" :disabled="!newNote.trim()">
{{ $t('globals.messages.save') + ' ' + $t('globals.terms.note').toLowerCase() }}
</Button>
@@ -53,13 +47,13 @@
<Card
v-for="note in notes"
:key="note.id"
class="overflow-hidden border-gray-2 hover:border-gray-300 transition-all duration-200 box hover:shadow"
class="overflow-hidden border-gray-2 dark:hover:border-gray-700 hover:border-gray-300 transition-all duration-200 box hover:shadow"
>
<!-- Header -->
<CardHeader class="bg-gray-50/50 dark:bg-secondary border-b p-2">
<CardHeader class="bg-background border-b p-2">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<Avatar class="border border-gray-200 shadow-sm">
<Avatar class="border shadow-sm">
<AvatarImage :src="note.avatar_url" />
<AvatarFallback>
{{ getInitials(note.first_name, note.last_name) }}

View File

@@ -29,7 +29,7 @@ export const createFormSchema = (t) => z.object({
})
})
.nullable(),
phone_number_calling_code: z.string().optional().nullable(),
phone_number_country_code: z.string().optional().nullable(),
avatar_url: z.string().optional().nullable(),
email: z
.string({

View File

@@ -1,5 +1,106 @@
<template>
<div class="h-screen w-full flex items-center justify-center min-w-[400px]">
<p>{{ $t('conversation.placeholder') }}</p>
<div class="placeholder-container">
<Spinner v-if="isLoading" />
<template v-else>
<div v-if="showGettingStarted" class="getting-started-wrapper">
<div class="text-center">
<h2 class="text-2xl font-semibold text-foreground mb-6">
{{ $t('setup.completeYourSetup') }}
</h2>
<div class="space-y-4 mb-6">
<div class="checklist-item" :class="{ completed: hasInboxes }">
<CheckCircle v-if="hasInboxes" class="check-icon completed" />
<Circle v-else class="w-5 h-5 text-muted-foreground" />
<span class="flex-1 text-left ml-3 text-foreground">
{{ $t('setup.createFirstInbox') }}
</span>
<Button
v-if="!hasInboxes"
variant="ghost"
size="sm"
@click="router.push({ name: 'inbox-list' })"
class="ml-auto"
>
{{ $t('globals.messages.setUp') }}
</Button>
</div>
<div class="checklist-item" :class="{ completed: hasAgents, disabled: !hasInboxes }">
<CheckCircle v-if="hasAgents" class="check-icon completed" />
<Circle v-else class="w-5 h-5 text-muted-foreground" />
<span class="flex-1 text-left ml-3 text-foreground">
{{ $t('setup.inviteTeammates') }}
</span>
<Button
v-if="!hasAgents && hasInboxes"
variant="ghost"
size="sm"
@click="router.push({ name: 'agent-list' })"
class="ml-auto"
>
{{ $t('globals.messages.invite') }}
</Button>
</div>
</div>
</div>
</div>
<div v-else>
<p class="placeholder-text">{{ $t('conversation.placeholder') }}</p>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { CheckCircle, Circle } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { useInboxStore } from '@/stores/inbox'
import { useUsersStore } from '@/stores/users'
const router = useRouter()
const inboxStore = useInboxStore()
const usersStore = useUsersStore()
const isLoading = ref(true)
onMounted(async () => {
try {
await Promise.all([inboxStore.fetchInboxes(), usersStore.fetchUsers()])
} finally {
isLoading.value = false
}
})
const hasInboxes = computed(() => inboxStore.inboxes.length > 0)
const hasAgents = computed(() => usersStore.users.length > 0)
const showGettingStarted = computed(() => !hasInboxes.value || !hasAgents.value)
</script>
<style scoped>
.placeholder-container {
@apply h-screen w-full flex items-center justify-center min-w-[400px] relative;
}
.getting-started-wrapper {
@apply w-full max-w-md mx-auto px-4;
}
.checklist-item {
@apply flex items-center justify-between py-3 px-4 rounded-lg border border-border;
}
.checklist-item.completed {
@apply bg-muted/50;
}
.checklist-item.disabled {
@apply opacity-50;
}
.check-icon.completed {
@apply w-5 h-5 text-primary;
}
</style>

View File

@@ -10,7 +10,7 @@
})
}}
</DialogTitle>
<DialogDescription/>
<DialogDescription />
</DialogHeader>
<form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden">
<!-- Form Fields Section -->
@@ -263,6 +263,7 @@ import { useFileUpload } from '@/composables/useFileUpload'
import Editor from '@/components/editor/TextEditor.vue'
import { useMacroStore } from '@/stores/macro'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import { UserTypeAgent } from '@/constants/user'
import api from '@/api'
const dialogOpen = defineModel({
@@ -393,12 +394,14 @@ const selectContact = (contact) => {
const createConversation = form.handleSubmit(async (values) => {
loading.value = true
try {
// convert ids to numbers if they are not already
// Convert ids to numbers if they are not already
values.inbox_id = Number(values.inbox_id)
values.team_id = values.team_id ? Number(values.team_id) : null
values.agent_id = values.agent_id ? Number(values.agent_id) : null
// array of attachment ids.
// Array of attachment ids.
values.attachments = mediaFiles.value.map((file) => file.id)
// Initiator of this conversation is always agent
values.initiator = UserTypeAgent
const conversation = await api.createConversation(values)
const conversationUUID = conversation.data.data.uuid

View File

@@ -122,6 +122,7 @@ import { Input } from '@/components/ui/input'
import { useEmitter } from '@/composables/useEmitter'
import { useFileUpload } from '@/composables/useFileUpload'
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
import { UserTypeAgent } from '@/constants/user'
import {
Form,
FormField,
@@ -252,6 +253,7 @@ const processSend = async () => {
if (hasTextContent.value > 0 || mediaFiles.value.length > 0) {
const message = htmlContent.value
await api.sendMessage(conversationStore.current.uuid, {
sender_type: UserTypeAgent,
private: messageType.value === 'private_note',
message: message,
attachments: mediaFiles.value.map((file) => file.id),

View File

@@ -13,7 +13,9 @@
<SelectComboBox
v-model="conversationStore.current.assigned_user_id"
:items="[{ value: 'none', label: 'None' }, ...usersStore.options]"
:placeholder="t('globals.messages.select', { name: t('globals.terms.agent').toLowerCase() })"
:placeholder="
t('globals.messages.select', { name: t('globals.terms.agent').toLowerCase() })
"
@select="selectAgent"
type="user"
/>
@@ -22,7 +24,9 @@
<SelectComboBox
v-model="conversationStore.current.assigned_team_id"
:items="[{ value: 'none', label: 'None' }, ...teamsStore.options]"
:placeholder="t('globals.messages.select', { name: t('globals.terms.team').toLowerCase() })"
:placeholder="
t('globals.messages.select', { name: t('globals.terms.team').toLowerCase() })
"
@select="selectTeam"
type="team"
/>
@@ -31,7 +35,9 @@
<SelectComboBox
v-model="conversationStore.current.priority_id"
:items="priorityOptions"
:placeholder="t('globals.messages.select', { name: t('globals.terms.priority').toLowerCase() })"
:placeholder="
t('globals.messages.select', { name: t('globals.terms.priority').toLowerCase() })
"
@select="selectPriority"
type="priority"
/>
@@ -41,7 +47,9 @@
v-if="conversationStore.current"
v-model="conversationStore.current.tags"
:items="tags.map((tag) => ({ label: tag, value: tag }))"
:placeholder="t('globals.messages.select', { name: t('globals.terms.tag', 2).toLowerCase() })"
:placeholder="
t('globals.messages.select', { name: t('globals.terms.tag', 2).toLowerCase() })
"
/>
</AccordionContent>
</AccordionItem>
@@ -93,6 +101,7 @@ import { ref, onMounted, watch, computed } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { useTagStore } from '@/stores/tag'
import {
Accordion,
AccordionContent,
@@ -118,6 +127,7 @@ const emitter = useEmitter()
const conversationStore = useConversationStore()
const usersStore = useUsersStore()
const teamsStore = useTeamStore()
const tagStore = useTagStore()
const tags = ref([])
// Save the accordion state in local storage
const accordionState = useStorage('conversation-sidebar-accordion', [])
@@ -171,15 +181,8 @@ watch(
const priorityOptions = computed(() => conversationStore.priorityOptions)
const fetchTags = async () => {
try {
const resp = await api.getTags()
tags.value = resp.data.data.map((item) => item.name)
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
await tagStore.fetchTags()
tags.value = tagStore.tags.map((item) => item.name)
}
const handleAssignedUserChange = (id) => {

View File

@@ -58,6 +58,7 @@ import { ViewVerticalIcon } from '@radix-icons/vue'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Mail, Phone, ExternalLink } from 'lucide-vue-next'
import countries from '@/constants/countries.js'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useConversationStore } from '@/stores/conversation'
@@ -72,8 +73,13 @@ const { t } = useI18n()
const userStore = useUserStore()
const phoneNumber = computed(() => {
const callingCode = conversation.value?.contact?.phone_number_calling_code || ''
const countryCodeValue = conversation.value?.contact?.phone_number_country_code || ''
const number = conversation.value?.contact?.phone_number || t('conversation.sidebar.notAvailable')
return callingCode ? `${callingCode} ${number}` : number
if (!countryCodeValue) return number
// Lookup calling code
const country = countries.find((c) => c.iso_2 === countryCodeValue)
const callingCode = country ? country.calling_code : countryCodeValue
return `${callingCode} ${number}`
})
</script>

View File

@@ -8,7 +8,7 @@
>
{{ $t('conversation.sidebar.noPreviousConvo') }}
</div>
<div v-else class="space-y-3">
<div v-else class="space-y-1">
<router-link
v-for="conversation in conversationStore.current.previous_conversations"
:key="conversation.uuid"
@@ -30,9 +30,31 @@
{{ conversation.last_message }}
</span>
</div>
<span class="text-xs text-muted-foreground" v-if="conversation.last_message_at">
{{ format(new Date(conversation.last_message_at), 'h') + ' h' }}
</span>
<Tooltip>
<TooltipTrigger asChild>
<div class="flex gap-1 items-center text-xs text-muted-foreground">
<span v-if="conversation.created_at">
{{ getRelativeTime(new Date(conversation.created_at)) }}
</span>
<span>•</span>
<span v-if="conversation.last_message_at">
{{ getRelativeTime(new Date(conversation.last_message_at)) }}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<div class="space-y-1 text-xs">
<p>
{{ $t('globals.terms.createdAt') }}:
{{ formatFullTimestamp(new Date(conversation.created_at)) }}
</p>
<p v-if="conversation.last_message_at">
{{ $t('globals.terms.lastMessageAt') }}:
{{ formatFullTimestamp(new Date(conversation.last_message_at)) }}
</p>
</div>
</TooltipContent>
</Tooltip>
</div>
</router-link>
</div>
@@ -40,7 +62,8 @@
<script setup>
import { useConversationStore } from '@/stores/conversation'
import { format } from 'date-fns'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { formatFullTimestamp, getRelativeTime } from '@/utils/datetime'
const conversationStore = useConversationStore()
</script>

View File

@@ -83,6 +83,7 @@ import { handleHTTPError } from '@/utils/http'
import { OPERATOR } from '@/constants/filterConfig.js'
import { useI18n } from 'vue-i18n'
import { z } from 'zod'
import { FIELD_TYPE } from '@/constants/filterConfig'
import api from '@/api'
const emitter = useEmitter()
@@ -106,68 +107,88 @@ const formSchema = toTypedSchema(
z.object({
id: z.number().optional(),
name: z
.string()
.string({
required_error: t('globals.messages.required')
})
.min(2, { message: t('view.form.name.length') })
.max(30, { message: t('view.form.name.length') }),
filters: z
.array(
z.object({
model: z.string({
required_error: t('globals.messages.required', {
name: t('globals.terms.filter').toLowerCase()
})
}),
field: z.string({
required_error: t('globals.messages.required', {
name: t('globals.terms.field').toLowerCase()
})
}),
operator: z.string({
required_error: t('globals.messages.required', {
name: t('globals.terms.operator').toLowerCase()
})
}),
value: z.union([z.string(), z.number(), z.boolean()]).optional()
model: z.string().optional(),
field: z.string().optional(),
operator: z.string().optional(),
value: z
.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.union([z.string(), z.number()]))
])
.optional()
})
)
.default([])
.refine((filters) => filters.length > 0, { message: t('view.form.filter.selectAtLeastOne') })
.refine(
(filters) =>
filters.every(
(f) =>
f.model &&
f.field &&
f.operator &&
([OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) || f.value)
),
{
message: t('view.form.filter.partiallyFilled')
}
)
})
)
const form = useForm({
validationSchema: formSchema,
validateOnMount: false,
validateOnInput: false,
validateOnBlur: false
validationSchema: formSchema
})
const onSubmit = async () => {
const validationResult = await form.validate()
if (!validationResult.valid) return
const onSubmit = form.handleSubmit(async (values) => {
if (isSubmitting.value) return
// Make sure at least one filter is selected
if (!values.filters || values.filters.length === 0) {
form.setFieldError('filters', t('view.form.filter.selectAtLeastOne'))
return
}
// Check for partial filters
const hasPartialFilters = values.filters.some(
(f) =>
!f.field ||
!f.operator ||
(![OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) && !f.value)
)
if (hasPartialFilters) {
form.setFieldError('filters', t('view.form.filter.partiallyFilled'))
return
}
isSubmitting.value = true
try {
const values = form.values
// Serialize array values to JSON strings for backend
if (values.filters) {
values.filters = values.filters.map((filter) => {
if (Array.isArray(filter.value)) {
// Convert string IDs to numbers for backend (tags use string IDs in frontend)
const numericValues = filter.value.map((v) => {
const num = Number(v)
return isNaN(num) ? v : num
})
return { ...filter, value: JSON.stringify(numericValues) }
}
return filter
})
}
if (values.id) {
await api.updateView(values.id, values)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.updatedSuccessfully', {
name: t('globals.terms.view')
})
})
} else {
await api.createView(values)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.createdSuccessfully', {
name: t('globals.terms.view')
})
})
}
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
openDialog.value = false
@@ -180,14 +201,36 @@ const onSubmit = async () => {
} finally {
isSubmitting.value = false
}
}
})
// Set form values when view prop changes
watch(
() => view.value,
(newVal) => {
if (newVal && Object.keys(newVal).length) {
form.setValues(newVal)
// Deserialize multi-select filter values from JSON strings to arrays
const processedVal = { ...newVal }
if (processedVal.filters) {
processedVal.filters = processedVal.filters.map((filter) => {
// Multi-select fields need to be deserialized from JSON strings
const field = filterFields.value.find((f) => f.field === filter.field)
const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT
if (isMultiSelectField && typeof filter.value === 'string') {
try {
const parsed = JSON.parse(filter.value)
// Convert numbers back to strings (frontend uses string IDs)
const stringValues = Array.isArray(parsed) ? parsed.map((v) => String(v)) : parsed
return { ...filter, value: stringValues }
} catch (e) {
// If parsing fails, return as-is
return filter
}
}
return filter
})
}
form.setValues(processedVal)
}
},
{ immediate: true }

View File

@@ -18,14 +18,14 @@ const setFavicon = (url) => {
}
async function initApp () {
const settings = (await api.getSettings('general')).data.data
const config = (await api.getConfig()).data.data
const emitter = mitt()
const lang = settings['app.lang'] || 'en'
const lang = config['app.lang'] || 'en'
const langMessages = await api.getLanguage(lang)
// Set favicon.
if (settings['app.favicon_url'])
setFavicon(settings['app.favicon_url'])
if (config['app.favicon_url'])
setFavicon(config['app.favicon_url'])
// Initialize i18n.
const i18nConfig = {
@@ -42,9 +42,17 @@ async function initApp () {
const pinia = createPinia()
app.use(pinia)
// Store app settings in Pinia
// Fetch and store app settings in store (after pinia is initialized)
const settingsStore = useAppSettingsStore()
settingsStore.setSettings(settings)
// Store the public config in the store
settingsStore.setPublicConfig(config)
try {
await settingsStore.fetchSettings('general')
} catch (error) {
// Pass
}
// Add emitter to global properties.
app.config.globalProperties.emitter = emitter

View File

@@ -1,12 +1,35 @@
import { defineStore } from 'pinia'
import api from '@/api'
export const useAppSettingsStore = defineStore('settings', {
state: () => ({
settings: {}
settings: {},
public_config: {}
}),
actions: {
async fetchSettings (key = 'general') {
try {
const response = await api.getSettings(key)
this.settings = response?.data?.data || {}
return this.settings
} catch (error) {
// Pass
}
},
async fetchPublicConfig () {
try {
const response = await api.getConfig()
this.public_config = response?.data?.data || {}
return this.public_config
} catch (error) {
// Pass
}
},
setSettings (newSettings) {
this.settings = newSettings
},
setPublicConfig (newPublicConfig) {
this.public_config = newPublicConfig
}
}
})

View File

@@ -12,14 +12,13 @@ export const useInboxStore = defineStore('inbox', () => {
label: inb.name,
value: String(inb.id)
})))
const fetchInboxes = async () => {
if (inboxes.value.length) return
const fetchInboxes = async (force = false) => {
if (!force && inboxes.value.length) return
try {
const response = await api.getInboxes()
inboxes.value = response?.data?.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})

View File

@@ -5,6 +5,7 @@ import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import api from '@/api'
// TODO: rename this store to agents
export const useUsersStore = defineStore('users', () => {
const users = ref([])
const emitter = useEmitter()
@@ -13,8 +14,8 @@ export const useUsersStore = defineStore('users', () => {
value: String(user.id),
avatar_url: user.avatar_url,
})))
const fetchUsers = async () => {
if (users.value.length) return
const fetchUsers = async (force = false) => {
if (!force && users.value.length) return
try {
const response = await api.getUsersCompact()
users.value = response?.data?.data || []

View File

@@ -1,16 +1,17 @@
import { format, differenceInMinutes, differenceInHours, differenceInDays } from 'date-fns'
import { format, differenceInMinutes, differenceInHours, differenceInDays, differenceInYears } from 'date-fns'
export function getRelativeTime (timestamp, now = new Date()) {
try {
const mins = differenceInMinutes(now, timestamp)
const hours = differenceInHours(now, timestamp)
const days = differenceInDays(now, timestamp)
const years = differenceInYears(now, timestamp)
if (mins === 0) return 'Just now'
if (mins < 60) return `${mins} mins ago`
if (hours < 24) return `${hours} hrs ago`
if (days < 7) return `${days} days ago`
return format(timestamp, 'MMMM d, yyyy h:mm a')
if (mins === 0) return 'now'
if (mins < 60) return `${mins}m`
if (hours < 24) return `${hours}h`
if (days < 365) return `${days}d`
return `${years}y`
} catch (error) {
console.error('Error parsing time', error, 'timestamp', timestamp)
return ''

View File

@@ -17,7 +17,7 @@
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { createColumns } from '@/features/admin/agents/dataTableColumns.js'
import { Button } from '@/components/ui/button'
import DataTable from '@/components/datatable/DataTable.vue'
@@ -25,10 +25,11 @@ import { handleHTTPError } from '@/utils/http'
import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api'
import { useUsersStore } from '@/stores/users'
import { useI18n } from 'vue-i18n'
const isLoading = ref(false)
const usersStore = useUsersStore()
const { t } = useI18n()
const data = ref([])
const emitter = useEmitter()
@@ -40,11 +41,15 @@ onMounted(async () => {
})
})
onUnmounted(() => {
emitter.off(EMITTER_EVENTS.REFRESH_LIST)
})
const getData = async () => {
try {
isLoading.value = true
const response = await api.getUsers()
data.value = response.data.data
await usersStore.fetchUsers(true)
data.value = usersStore.users
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',

View File

@@ -20,15 +20,17 @@ import { ref, onMounted } from 'vue'
import { Spinner } from '@/components/ui/spinner'
import GeneralSettingForm from '@/features/admin/general/GeneralSettingForm.vue'
import AdminPageWithHelp from '@/layouts/admin/AdminPageWithHelp.vue'
import { useAppSettingsStore } from '@/stores/appSettings'
import api from '@/api'
const initialValues = ref({})
const isLoading = ref(false)
const settingsStore = useAppSettingsStore()
onMounted(async () => {
isLoading.value = true
const response = await api.getSettings('general')
const data = response.data.data
await settingsStore.fetchSettings('general')
const data = settingsStore.settings
isLoading.value = false
initialValues.value = Object.keys(data).reduce((acc, key) => {
// Remove 'app.' prefix

View File

@@ -32,11 +32,13 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { Spinner } from '@/components/ui/spinner'
import { useInboxStore } from '@/stores/inbox'
import api from '@/api'
const { t } = useI18n()
const router = useRouter()
const emitter = useEmitter()
const inboxStore = useInboxStore()
const isLoading = ref(false)
const data = ref([])
@@ -47,8 +49,8 @@ onMounted(async () => {
const getInboxes = async () => {
try {
isLoading.value = true
const response = await api.getInboxes()
data.value = response.data.data
await inboxStore.fetchInboxes(true)
data.value = inboxStore.inboxes
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
@@ -67,7 +69,7 @@ const columns = [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
return h('div', { class: 'text-center' }, row.getValue('name'))
}
},
{
@@ -76,7 +78,7 @@ const columns = [
return h('div', { class: 'text-center' }, t('globals.terms.channel'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('channel'))
return h('div', { class: 'text-center' }, row.getValue('channel'))
}
},
{

View File

@@ -7,7 +7,7 @@
<template #help>
<p>Configure single sign-on with one or more OpenID Connect providers.</p>
<a
href="https://libredesk.io/docs/sso/"
href="https://docs.libredesk.io/configuration/sso"
target="_blank"
rel="noopener noreferrer"
class="link-style"

View File

@@ -49,7 +49,7 @@
<p>Design templates for customer communications and responses.</p>
<p>Modify content for internal and external emails.</p>
<a
href="https://libredesk.io/docs/templating/"
href="https://docs.libredesk.io/configuration/email-templates"
target="_blank"
rel="noopener noreferrer"
class="link-style"

View File

@@ -8,7 +8,7 @@
<p>Configure webhooks to receive real-time notifications when events occur in your Libredesk workspace.</p>
<p>Webhooks allow you to integrate Libredesk with external services by sending HTTP POST requests when specific events happen.</p>
<a
href="https://libredesk.io/docs/webhooks/"
href="https://docs.libredesk.io/configuration/webhooks"
target="_blank"
rel="noopener noreferrer"
class="link-style"

View File

@@ -9,7 +9,7 @@
<CardContent class="p-6 space-y-6">
<div class="space-y-2 text-center">
<CardTitle class="text-3xl font-bold text-foreground">
{{ appSettingsStore.settings?.['app.site_name'] || 'Libredesk' }}
{{ appSettingsStore.public_config?.['app.site_name'] || 'LIBREDESK' }}
</CardTitle>
<p class="text-muted-foreground">{{ t('auth.signIn') }}</p>
</div>
@@ -25,9 +25,8 @@
>
<img
:src="oidcProvider.logo_url"
:alt="oidcProvider.name"
width="20"
class="mr-2"
alt=""
v-if="oidcProvider.logo_url"
/>
{{ oidcProvider.name }}
@@ -60,18 +59,28 @@
</div>
<div class="space-y-2">
<Label for="password" class="text-sm font-medium text-foreground">{{
t('globals.terms.password')
}}</Label>
<Input
id="password"
type="password"
autocomplete="current-password"
:placeholder="t('auth.enterPassword')"
v-model="loginForm.password"
:class="{ 'border-destructive': passwordHasError }"
class="w-full bg-card border-border text-foreground placeholder:text-muted-foreground rounded py-2 px-3 focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-200 ease-in-out"
/>
<Label for="password" class="text-sm font-medium text-foreground">
{{ t('globals.terms.password') }}
</Label>
<div class="relative">
<Input
id="password"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
:placeholder="t('auth.enterPassword')"
v-model="loginForm.password"
:class="{ 'border-destructive': passwordHasError }"
class="w-full bg-card border-border text-foreground placeholder:text-muted-foreground rounded py-2 px-3 pr-10 focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-200 ease-in-out"
/>
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
@click="showPassword = !showPassword"
>
<Eye v-if="!showPassword" class="w-5 h-5" />
<EyeOff v-else class="w-5 h-5" />
</button>
</div>
</div>
<div class="flex items-center justify-between">
@@ -89,7 +98,9 @@
type="submit"
>
<span v-if="isLoading" class="flex items-center justify-center">
<div class="w-5 h-5 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin mr-3"></div>
<div
class="w-5 h-5 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin mr-3"
></div>
{{ t('auth.loggingIn') }}
</span>
<span v-else>{{ t('auth.signInButton') }}</span>
@@ -125,6 +136,7 @@ import { useI18n } from 'vue-i18n'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useAppSettingsStore } from '@/stores/appSettings'
import AuthLayout from '@/layouts/auth/AuthLayout.vue'
import { Eye, EyeOff } from 'lucide-vue-next'
const emitter = useEmitter()
const { t } = useI18n()
@@ -133,6 +145,7 @@ const isLoading = ref(false)
const router = useRouter()
const userStore = useUserStore()
const shakeCard = ref(false)
const showPassword = ref(false)
const loginForm = ref({
email: '',
password: ''
@@ -159,8 +172,10 @@ onMounted(async () => {
const fetchOIDCProviders = async () => {
try {
const resp = await api.getAllEnabledOIDC()
oidcProviders.value = resp.data.data
const config = appSettingsStore.public_config
if (config && config['app.sso_providers']) {
oidcProviders.value = config['app.sso_providers'] || []
}
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
@@ -204,6 +219,9 @@ const loginAction = () => {
if (resp?.data?.data) {
userStore.setCurrentUser(resp.data.data)
}
// Also fetch general setting as user's logged in.
appSettingsStore.fetchSettings('general')
// Navigate to inboxes
router.push({ name: 'inboxes' })
})
.catch((error) => {

View File

@@ -5,7 +5,11 @@
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<div v-if="contact" class="flex justify-center space-y-4 w-full">
<div
v-if="contact"
class="flex justify-center space-y-4 w-full"
:class="{ 'loading-fade': formLoading }"
>
<div class="flex flex-col w-full mt-12">
<div class="flex flex-col space-y-2">
<AvatarUpload
@@ -189,7 +193,7 @@ async function onUpload(file) {
formData.append('last_name', form.values.last_name)
formData.append('email', form.values.email)
formData.append('phone_number', form.values.phone_number)
formData.append('phone_number_calling_code', form.values.phone_number_calling_code)
formData.append('phone_number_country_code', form.values.phone_number_country_code)
formData.append('enabled', form.values.enabled)
const { data } = await api.updateContact(contact.value.id, formData)
contact.value.avatar_url = data.avatar_url

7
go.mod
View File

@@ -7,6 +7,7 @@ require (
github.com/coreos/go-oidc/v3 v3.11.0
github.com/disintegration/imaging v1.6.2
github.com/emersion/go-imap/v2 v2.0.0-beta.3
github.com/emersion/go-message v0.18.1
github.com/fasthttp/websocket v1.5.9
github.com/ferluci/fast-realip v1.0.1
github.com/google/uuid v1.6.0
@@ -27,7 +28,7 @@ require (
github.com/lib/pq v1.10.9
github.com/mr-karan/balance v0.0.0-20250317053523-d32c6ade6cf1
github.com/redis/go-redis/v9 v9.5.5
github.com/rhnvrm/simples3 v0.9.1
github.com/rhnvrm/simples3 v0.9.2
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.10.0
github.com/valyala/fasthttp v1.62.0
@@ -49,12 +50,11 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/emersion/go-message v0.18.1 // indirect
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
github.com/fasthttp/router v1.5.0 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
github.com/klauspost/compress v1.18.0 // indirect
@@ -70,6 +70,7 @@ require (
github.com/rivo/uniseg v0.4.4 // indirect
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/net v0.40.0 // indirect

8
go.sum
View File

@@ -54,8 +54,8 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c=
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
@@ -142,6 +142,8 @@ github.com/redis/go-redis/v9 v9.5.5 h1:51VEyMF8eOO+NUHFm8fpg+IOc1xFuFOhxs3R+kPu1
github.com/redis/go-redis/v9 v9.5.5/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/rhnvrm/simples3 v0.9.1 h1:pYfEe2wTjx8B2zFzUdy4kZn3I3Otd9ZvzIhHkFR85kE=
github.com/rhnvrm/simples3 v0.9.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
github.com/rhnvrm/simples3 v0.9.2 h1:XrwsiMnwWf7t/kskvhMYXW6keqp5u3u6t5Va3ltzCQI=
github.com/rhnvrm/simples3 v0.9.2/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -157,6 +159,8 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=

View File

@@ -177,6 +177,7 @@
"globals.terms.usage": "Usage",
"globals.terms.createdAt": "Created At",
"globals.terms.updatedAt": "Updated At",
"globals.terms.lastMessageAt": "Last message at",
"globals.terms.pickDate": "Pick a date",
"globals.terms.time": "Time",
"globals.terms.listValues": "List values",
@@ -188,6 +189,7 @@
"globals.terms.recipient": "Recipient | Recipients",
"globals.terms.tls": "TLS | TLSs",
"globals.terms.credential": "Credential | Credentials",
"globals.messages.welcomeToLibredesk": "Welcome to Libredesk",
"globals.messages.invalid": "Invalid {name}",
"globals.messages.custom": "Custom {name}",
"globals.messages.replying": "Replying",
@@ -294,6 +296,8 @@
"globals.messages.submit": "Submit",
"globals.messages.send": "Send {name}",
"globals.messages.update": "Update {name}",
"globals.messages.setUp": "Set up",
"globals.messages.invite": "Invite",
"globals.messages.enable": "Enable",
"globals.messages.disable": "Disable",
"globals.messages.block": "Block {name}",
@@ -306,6 +310,12 @@
"globals.messages.reset": "Reset {name}",
"globals.messages.lastNItems": "Last {n} {name} | Last {n} {name}",
"globals.messages.correctEmailErrors": "Please correct the email errors",
"globals.messages.additionalFeedback": "Additional feedback (optional)",
"globals.messages.pleaseSelect": "Please select {name} before submitting",
"globals.messages.poweredBy": "Powered by",
"globals.messages.thankYou": "Thank you!",
"globals.messages.pageNotFound": "Page not found",
"globals.messages.somethingWentWrong": "Something went wrong",
"form.error.min": "Must be at least {min} characters",
"form.error.max": "Must be at most {max} characters",
"form.error.minmax": "Must be between {min} and {max} characters",
@@ -339,6 +349,14 @@
"conversationStatus.alreadyInUse": "Cannot delete status as it is in use, Please remove this status from all conversations before deleting",
"conversationStatus.cannotUpdateDefault": "Cannot update default conversation status",
"csat.alreadySubmitted": "CSAT already submitted",
"csat.rateYourInteraction": "Rate your recent interaction",
"csat.rating.poor": "Poor",
"csat.rating.fair": "Fair",
"csat.rating.good": "Good",
"csat.rating.great": "Great",
"csat.rating.excellent": "Excellent",
"csat.pageTitle": "Rate your interaction with us",
"csat.thankYouMessage": "We appreciate you taking the time to submit your feedback.",
"auth.csrfTokenMismatch": "CSRF token mismatch",
"auth.invalidOrExpiredSession": "Invalid or expired session",
"auth.invalidOrExpiredSessionClearCookie": "Invalid or expired session. Please clear your cookies and try again.",
@@ -395,7 +413,7 @@
"admin.general.maxAllowedFileUploadSize.description": "Max allowed file upload size in MB.",
"admin.general.maxAllowedFileUploadSize.valid": "Max allowed file upload size should be between 1 and 500 MB",
"admin.general.allowedFileUploadExtensions": "Allowed File Upload Extensions",
"admin.general.allowedFileUploadExtensions.description": "Allowed file upload extensions. Use `*` to allow all file types.",
"admin.general.allowedFileUploadExtensions.description": "Use `*` to permit all file types. For example: `jpg, png, pdf`",
"admin.businessHours.unauthorized": "You do not have permission to view business hours.",
"admin.businessHours.setBusinessHours": "Set business hours",
"admin.businessHours.alwaysOpen24x7": "Always open (24/7)",
@@ -483,6 +501,7 @@
"admin.role.conversations.updateTags": "Add or remove conversation tags",
"admin.role.messages.read": "View conversation messages",
"admin.role.messages.write": "Send messages in conversations",
"admin.role.messages.writeAsContact": "Send messages as contact",
"admin.role.view.manage": "Create and manage conversation views",
"admin.role.generalSettings.manage": "Manage General Settings",
"admin.role.notificationSettings.manage": "Manage Notification Settings",
@@ -508,12 +527,13 @@
"admin.role.contactNotes.write": "Add Contact Notes",
"admin.role.contactNotes.delete": "Delete Contact Notes",
"admin.role.customAttributes.manage": "Manage Custom Attributes",
"admin.role.webhooks.manage": "Manage Webhooks",
"admin.role.activityLog.manage": "Manage Activity Log",
"admin.automation.newConversation.description": "Rules that run when a new conversation is created, drag and drop to reorder rules.",
"admin.automation.conversationUpdate": "Conversation Update",
"admin.automation.conversationUpdate.description": "Rules that run when a conversation is updated.",
"admin.automation.timeTriggers": "Time Triggers",
"admin.automation.timeTriggers.description": "Rules that once an hour",
"admin.automation.timeTriggers.description": "Rules that run once an hour",
"admin.automation.match": "Match",
"admin.automation.any": "ANY",
"admin.automation.all": "ALL",
@@ -533,6 +553,7 @@
"admin.automation.event.message.incoming": "Incoming message",
"admin.automation.invalid": "Make sure you have atleast one action and one rule and their values are not empty.",
"admin.notification.restartApp": "Settings updated successfully, Please restart the app for changes to take effect.",
"admin.banner.restartMessage": "Some settings have been changed that require an application restart to take effect.",
"admin.template.outgoingEmailTemplates": "Outgoing email templates",
"admin.template.emailNotificationTemplates": "Email notification templates",
"admin.template.makeSureTemplateHasContent": "Make sure the template has {content} only once.",
@@ -622,5 +643,8 @@
"contact.unblockConfirm": "Are you sure you want to unblock this contact? They will be able to interact with you again.",
"contact.alreadyExistsWithEmail": "Another contact with same email already exists",
"contact.notes.empty": "No notes yet",
"contact.notes.help": "Add note for this contact to keep track of important information and conversations."
"contact.notes.help": "Add note for this contact to keep track of important information and conversations.",
"setup.completeYourSetup": "Complete your setup",
"setup.createFirstInbox": "Create your first inbox",
"setup.inviteTeammates": "Invite teammates"
}

View File

@@ -82,18 +82,9 @@ func (m *Manager) GetAll(order, orderBy, filtersJSON string, page, pageSize int)
return activityLogs, nil
}
// Create adds a new activity log.
func (m *Manager) Create(activityType, activityDescription string, actorID int, targetModelType string, targetModelID int, ip string) error {
if _, err := m.q.InsertActivity.Exec(activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil {
m.lo.Error("error inserting activity", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.activityLog}"), nil)
}
return nil
}
// Login records a login event for the given user.
func (al *Manager) Login(userID int, email, ip string) error {
return al.Create(
return al.create(
models.AgentLogin,
fmt.Sprintf("%s (#%d) logged in", email, userID),
userID,
@@ -105,7 +96,7 @@ func (al *Manager) Login(userID int, email, ip string) error {
// Logout records a logout event for the given user.
func (al *Manager) Logout(userID int, email, ip string) error {
return al.Create(
return al.create(
models.AgentLogout,
fmt.Sprintf("%s (#%d) logged out", email, userID),
userID,
@@ -123,7 +114,7 @@ func (al *Manager) Away(actorID int, actorEmail, ip string, targetID int, target
} else {
description = fmt.Sprintf("%s (#%d) is away", actorEmail, actorID)
}
return al.Create(
return al.create(
models.AgentAway, /* activity type*/
description,
actorID, /*actor_id*/
@@ -141,7 +132,7 @@ func (al *Manager) AwayReassigned(actorID int, actorEmail, ip string, targetID i
} else {
description = fmt.Sprintf("%s (#%d) is away and reassigning", actorEmail, actorID)
}
return al.Create(
return al.create(
models.AgentAwayReassigned, /* activity type*/
description,
actorID, /*actor_id*/
@@ -159,7 +150,7 @@ func (al *Manager) Online(actorID int, actorEmail, ip string, targetID int, targ
} else {
description = fmt.Sprintf("%s (#%d) is online", actorEmail, actorID)
}
return al.Create(
return al.create(
models.AgentOnline, /* activity type*/
description,
actorID, /*actor_id*/
@@ -190,6 +181,16 @@ func (al *Manager) UserAvailability(actorID int, actorEmail, status, ip, targetE
return nil
}
// create creates a new activity log in DB.
func (m *Manager) create(activityType, activityDescription string, actorID int, targetModelType string, targetModelID int, ip string) error {
var activityLog models.ActivityLog
if err := m.q.InsertActivity.Get(&activityLog, activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil {
m.lo.Error("error inserting activity log", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.activityLog}"), nil)
}
return nil
}
// makeQuery constructs the SQL query for fetching activity logs with filters and pagination.
func (m *Manager) makeQuery(page, pageSize int, order, orderBy, filtersJSON string) (string, []any, error) {
var (

View File

@@ -2,10 +2,10 @@
SELECT id, name, provider, config, is_default FROM ai_providers where is_default is true;
-- name: get-prompt
SELECT id, key, title, content FROM ai_prompts where key = $1;
SELECT id, created_at, updated_at, key, title, content FROM ai_prompts where key = $1;
-- name: get-prompts
SELECT id, key, title FROM ai_prompts order by title;
SELECT id, created_at, updated_at, key, title FROM ai_prompts order by title;
-- name: set-openai-key
UPDATE ai_providers

View File

@@ -183,6 +183,7 @@ func (e *Enforcer) EnforceConversationAccess(user umodels.User, conversation cmo
// EnforceMediaAccess checks for read access on linked model to media.
func (e *Enforcer) EnforceMediaAccess(user umodels.User, model string) (bool, error) {
switch model {
// TODO: Pick this table / model name from the package/models/models.go
case "messages":
allowed, err := e.Enforce(user, model, "read")
if err != nil {

View File

@@ -15,6 +15,7 @@ const (
PermConversationWrite = "conversations:write"
PermMessagesRead = "messages:read"
PermMessagesWrite = "messages:write"
PermMessagesWriteAsContact = "messages:write_as_contact"
// View
PermViewManage = "view:manage"
@@ -102,6 +103,7 @@ var validPermissions = map[string]struct{}{
PermConversationWrite: {},
PermMessagesRead: {},
PermMessagesWrite: {},
PermMessagesWriteAsContact: {},
PermViewManage: {},
PermStatusManage: {},
PermTagsManage: {},

View File

@@ -33,7 +33,7 @@ type conversationStore interface {
type teamStore interface {
GetAll() ([]tmodels.Team, error)
GetMembers(teamID int) ([]umodels.User, error)
GetMembers(teamID int) ([]tmodels.TeamMember, error)
}
// Engine represents a manager for assigning unassigned conversations

View File

@@ -232,12 +232,21 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
for _, ruleValue := range ruleValues {
// Normalize rule value by collapsing multiple spaces
normalizedRuleValue := strings.Join(strings.Fields(ruleValue), " ")
if strings.Contains(
strings.ToLower(normalizedInputText),
strings.ToLower(normalizedRuleValue),
) {
conditionMet = true
break
// Respect CaseSensitiveMatch flag
if rule.CaseSensitiveMatch {
if strings.Contains(normalizedInputText, normalizedRuleValue) {
conditionMet = true
break
}
} else {
if strings.Contains(
strings.ToLower(normalizedInputText),
strings.ToLower(normalizedRuleValue),
) {
conditionMet = true
break
}
}
}
case models.RuleOperatorNotContains:
@@ -249,12 +258,21 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
for _, ruleValue := range ruleValues {
// Normalize rule value by collapsing multiple spaces
normalizedRuleValue := strings.Join(strings.Fields(ruleValue), " ")
if strings.Contains(
strings.ToLower(normalizedInputText),
strings.ToLower(normalizedRuleValue),
) {
conditionMet = false
break
// Respect CaseSensitiveMatch flag
if rule.CaseSensitiveMatch {
if strings.Contains(normalizedInputText, normalizedRuleValue) {
conditionMet = false
break
}
} else {
if strings.Contains(
strings.ToLower(normalizedInputText),
strings.ToLower(normalizedRuleValue),
) {
conditionMet = false
break
}
}
}
case models.RuleOperatorSet:

File diff suppressed because it is too large Load Diff

View File

@@ -7,10 +7,10 @@ select
from automation_rules where enabled is TRUE ORDER BY weight ASC;
-- name: get-all
SELECT id, created_at, updated_at, enabled, name, description, type, events, rules, execution_mode from automation_rules where type = $1 ORDER BY weight ASC;
SELECT id, created_at, updated_at, "name", description, "type", rules, events, enabled, weight, execution_mode from automation_rules where type = $1 ORDER BY weight ASC;
-- name: get-rule
SELECT id, created_at, updated_at, enabled, name, description, type, events, rules, execution_mode from automation_rules where id = $1;
SELECT id, created_at, updated_at, "name", description, "type", rules, events, enabled, weight, execution_mode from automation_rules where id = $1;
-- name: update-rule
INSERT INTO automation_rules(id, name, description, type, events, rules, enabled)

View File

@@ -15,7 +15,10 @@ SELECT id,
created_at,
updated_at,
"name",
description
description,
is_always_open,
hours,
holidays
FROM business_hours
ORDER BY updated_at DESC;

View File

@@ -200,7 +200,7 @@ type queries struct {
GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"`
GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"`
GetConversations string `query:"get-conversations"`
GetContactConversations *sqlx.Stmt `query:"get-contact-conversations"`
GetContactPreviousConversations *sqlx.Stmt `query:"get-contact-previous-conversations"`
GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
GetUserActiveConversationsCount *sqlx.Stmt `query:"get-user-active-conversations-count"`
UpdateConversationFirstReplyAt *sqlx.Stmt `query:"update-conversation-first-reply-at"`
@@ -280,11 +280,11 @@ func (c *Manager) GetConversation(id int, uuid string) (models.Conversation, err
return conversation, nil
}
// GetContactConversations retrieves conversations for a contact.
func (c *Manager) GetContactConversations(contactID int) ([]models.Conversation, error) {
var conversations = make([]models.Conversation, 0)
if err := c.q.GetContactConversations.Select(&conversations, contactID); err != nil {
c.lo.Error("error fetching conversations", "error", err)
// GetContactPreviousConversations retrieves previous conversations for a contact with a configurable limit.
func (c *Manager) GetContactPreviousConversations(contactID int, limit int) ([]models.PreviousConversation, error) {
var conversations = make([]models.PreviousConversation, 0)
if err := c.q.GetContactPreviousConversations.Select(&conversations, contactID, limit); err != nil {
c.lo.Error("error fetching previous conversations", "error", err)
return conversations, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil)
}
return conversations, nil
@@ -348,32 +348,32 @@ func (c *Manager) GetConversationUUID(id int) (string, error) {
}
// GetAllConversationsList retrieves all conversations with optional filtering, ordering, and pagination.
func (c *Manager) GetAllConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
func (c *Manager) GetAllConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
return c.GetConversations(0, []int{}, []string{models.AllConversations}, order, orderBy, filters, page, pageSize)
}
// GetAssignedConversationsList retrieves conversations assigned to a specific user with optional filtering, ordering, and pagination.
func (c *Manager) GetAssignedConversationsList(userID int, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
func (c *Manager) GetAssignedConversationsList(userID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
return c.GetConversations(userID, []int{}, []string{models.AssignedConversations}, order, orderBy, filters, page, pageSize)
}
// GetUnassignedConversationsList retrieves conversations assigned to a team the user is part of with optional filtering, ordering, and pagination.
func (c *Manager) GetUnassignedConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
func (c *Manager) GetUnassignedConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
return c.GetConversations(0, []int{}, []string{models.UnassignedConversations}, order, orderBy, filters, page, pageSize)
}
// GetTeamUnassignedConversationsList retrieves conversations assigned to a team with optional filtering, ordering, and pagination.
func (c *Manager) GetTeamUnassignedConversationsList(teamID int, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
func (c *Manager) GetTeamUnassignedConversationsList(teamID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
return c.GetConversations(0, []int{teamID}, []string{models.TeamUnassignedConversations}, order, orderBy, filters, page, pageSize)
}
func (c *Manager) GetViewConversationsList(userID int, teamIDs []int, listType []string, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
func (c *Manager) GetViewConversationsList(userID int, teamIDs []int, listType []string, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
return c.GetConversations(userID, teamIDs, listType, order, orderBy, filters, page, pageSize)
}
// GetConversations retrieves conversations list based on user ID, type, and optional filtering, ordering, and pagination.
func (c *Manager) GetConversations(userID int, teamIDs []int, listTypes []string, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
var conversations = make([]models.Conversation, 0)
func (c *Manager) GetConversations(userID int, teamIDs []int, listTypes []string, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
var conversations = make([]models.ConversationListItem, 0)
// Make the query.
query, qArgs, err := c.makeConversationsListQuery(userID, teamIDs, listTypes, c.q.GetConversations, order, orderBy, page, pageSize, filters)
@@ -541,6 +541,10 @@ func (c *Manager) UpdateConversationTeamAssignee(uuid string, teamID int, actor
// Team changed?
if previousAssignedTeamID != teamID {
// Remove assigned user if team has changed.
c.RemoveConversationAssignee(uuid, models.AssigneeTypeUser, actor)
// Apply SLA policy if this new team has a SLA policy.
team, err := c.teamStore.Get(teamID)
if err != nil {
return nil
@@ -930,7 +934,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
if err != nil {
return fmt.Errorf("making recipients for reply action: %w", err)
}
_, err = m.SendReply(
_, err = m.QueueReply(
[]mmodels.Media{},
conv.InboxID,
user.ID,
@@ -960,7 +964,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
return nil
}
// RemoveConversationAssignee removes the assignee from the conversation.
// RemoveConversationAssignee removes assigned user from a conversation.
func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.User) error {
if _, err := m.q.RemoveConversationAssignee.Exec(uuid, typ); err != nil {
m.lo.Error("error removing conversation assignee", "error", err)
@@ -975,6 +979,14 @@ func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.Use
})
}
// Broadcast ws update.
switch typ {
case models.AssigneeTypeUser:
m.BroadcastConversationUpdate(uuid, "assigned_user_id", nil)
case models.AssigneeTypeTeam:
m.BroadcastConversationUpdate(uuid, "assigned_team_id", nil)
}
return nil
}
@@ -1001,8 +1013,8 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil)
}
// Send CSAT reply.
_, err = m.SendReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta)
// Queue CSAT reply.
_, err = m.QueueReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta)
if err != nil {
m.lo.Error("error sending CSAT reply", "conversation_uuid", conversation.UUID, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil)
@@ -1012,6 +1024,7 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
// DeleteConversation deletes a conversation.
func (m *Manager) DeleteConversation(uuid string) error {
m.lo.Info("deleting conversation", "uuid", uuid)
if _, err := m.q.DeleteConversation.Exec(uuid); err != nil {
m.lo.Error("error deleting conversation", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorDeleting", "name", m.i18n.Ts("globals.terms.conversation")), nil)
@@ -1081,6 +1094,35 @@ func (c *Manager) makeConversationsListQuery(userID int, teamIDs []int, listType
return "", nil, fmt.Errorf("no conversation list types specified")
}
// Parse filters to extract tag filters
var (
filters []dbutil.Filter
tagFilters []dbutil.Filter
remainingFilters []dbutil.Filter
)
if filtersJSON != "" && filtersJSON != "[]" {
if err := json.Unmarshal([]byte(filtersJSON), &filters); err != nil {
return "", nil, fmt.Errorf("invalid filters JSON: %w", err)
}
// Separate tag filters from other filters
for _, f := range filters {
if f.Field == "tags" && (f.Operator == "contains" || f.Operator == "not contains" || f.Operator == "set" || f.Operator == "not set") {
tagFilters = append(tagFilters, f)
} else {
remainingFilters = append(remainingFilters, f)
}
}
// Update filtersJSON with remaining filters for the generic builder
if len(remainingFilters) > 0 {
b, _ := json.Marshal(remainingFilters)
filtersJSON = string(b)
} else {
filtersJSON = "[]"
}
}
// Prepare the conditions based on the list types.
conditions := []string{}
for _, lt := range listTypes {
@@ -1106,13 +1148,60 @@ func (c *Manager) makeConversationsListQuery(userID int, teamIDs []int, listType
}
}
// Build the base query with list type conditions
var whereClause string
if len(conditions) > 0 {
baseQuery = fmt.Sprintf(baseQuery, "AND ("+strings.Join(conditions, " OR ")+")")
} else {
// Replace the `%s` in the base query with an empty string.
baseQuery = fmt.Sprintf(baseQuery, "")
whereClause = "AND (" + strings.Join(conditions, " OR ") + ")"
}
// Add tag filter conditions
// TODO: Evaluate - https://github.com/Masterminds/squirrel when required.
for _, tf := range tagFilters {
switch tf.Operator {
case "contains", "not contains":
var tagIDs []int
if err := json.Unmarshal([]byte(tf.Value), &tagIDs); err != nil {
return "", nil, fmt.Errorf("invalid tag IDs in filter: %w", err)
}
if len(tagIDs) > 0 {
paramIdx := len(qArgs) + 1
switch tf.Operator {
case "contains":
// Has any of the tags
tagCondition := fmt.Sprintf(` AND conversations.id IN (
SELECT DISTINCT conversation_id
FROM conversation_tags
WHERE tag_id = ANY($%d::int[])
)`, paramIdx)
whereClause += tagCondition
case "not contains":
// Doesn't have any of the tags
tagCondition := fmt.Sprintf(` AND conversations.id NOT IN (
SELECT DISTINCT conversation_id
FROM conversation_tags
WHERE tag_id = ANY($%d::int[])
)`, paramIdx)
whereClause += tagCondition
}
qArgs = append(qArgs, pq.Array(tagIDs))
}
case "set":
// Has any tags at all
whereClause += ` AND EXISTS (
SELECT 1 FROM conversation_tags
WHERE conversation_id = conversations.id
)`
case "not set":
// Has no tags at all
whereClause += ` AND NOT EXISTS (
SELECT 1 FROM conversation_tags
WHERE conversation_id = conversations.id
)`
}
}
baseQuery = fmt.Sprintf(baseQuery, whereClause)
return dbutil.BuildPaginatedQuery(baseQuery, qArgs, dbutil.PaginationOptions{
Order: order,
OrderBy: orderBy,

View File

@@ -167,7 +167,7 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
}
// References is sorted in DESC i.e newest message first, so reverse it to keep the references in order.
stringutil.ReverseSlice(message.References)
slices.Reverse(message.References)
// Remove the current message ID from the references.
message.References = stringutil.RemoveItemByValue(message.References, message.SourceID.String)
@@ -347,9 +347,10 @@ func (m *Manager) UpdateMessageStatus(messageUUID string, status string) error {
return nil
}
// MarkMessageAsPending updates message status to `Pending`, so if it's a outgoing message it can be picked up again by a worker.
// MarkMessageAsPending updates message status to `Pending`, enqueuing it for sending.
func (m *Manager) MarkMessageAsPending(uuid string) error {
if err := m.UpdateMessageStatus(uuid, models.MessageStatusPending); err != nil {
m.lo.Error("error marking message as pending", "uuid", uuid, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil)
}
return nil
@@ -374,8 +375,27 @@ func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversat
return message, nil
}
// SendReply inserts a reply message in a conversation.
func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conversationUUID, content string, to, cc, bcc []string, meta map[string]interface{}) (models.Message, error) {
// CreateContactMessage creates a contact message in a conversation.
func (m *Manager) CreateContactMessage(media []mmodels.Media, contactID int, conversationUUID, content, contentType string) (models.Message, error) {
message := models.Message{
ConversationUUID: conversationUUID,
SenderID: contactID,
Type: models.MessageIncoming,
SenderType: models.SenderTypeContact,
Status: models.MessageStatusReceived,
Content: content,
ContentType: contentType,
Private: false,
Media: media,
}
if err := m.InsertMessage(&message); err != nil {
return models.Message{}, err
}
return message, nil
}
// QueueReply queues a reply message in a conversation.
func (m *Manager) QueueReply(media []mmodels.Media, inboxID, senderID int, conversationUUID, content string, to, cc, bcc []string, meta map[string]interface{}) (models.Message, error) {
var (
message = models.Message{}
)
@@ -402,7 +422,7 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conver
return message, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorMarshalling", "name", "{globals.terms.meta}"), nil)
}
// Generage unique source ID i.e. message-id for email.
// Generate unique source ID i.e. message-id for email.
inbox, err := m.inboxStore.GetDBRecord(inboxID)
if err != nil {
return message, err
@@ -442,6 +462,11 @@ func (m *Manager) InsertMessage(message *models.Message) error {
message.Meta = json.RawMessage(`{}`)
}
// Handle empty content type enum, default to text.
if message.ContentType == "" {
message.ContentType = models.ContentTypeText
}
// Convert HTML content to text for search.
message.TextContent = stringutil.HTML2Text(message.Content)
@@ -614,7 +639,7 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
}
in.Message.SenderID = in.Contact.ID
// Conversations exists for this message?
// Conversation already exists for this message? Skip if it does.
conversationID, err := m.findConversationID([]string{in.Message.SourceID.String})
if err != nil && err != errConversationNotFound {
return err
@@ -629,10 +654,16 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
return err
}
// Upload message attachments.
if err := m.uploadMessageAttachments(&in.Message); err != nil {
// Log error but continue processing.
m.lo.Error("error uploading message attachments", "message_source_id", in.Message.SourceID, "error", err)
// Upload message attachments, on failure delete the conversation if it was just created for this message.
if upErr := m.uploadMessageAttachments(&in.Message); upErr != nil {
m.lo.Error("error uploading message attachments", "message_source_id", in.Message.SourceID, "error", upErr)
if isNewConversation && in.Message.ConversationUUID != "" {
m.lo.Info("deleting conversation as message attachment upload failed", "conversation_uuid", in.Message.ConversationUUID, "message_source_id", in.Message.SourceID)
if err := m.DeleteConversation(in.Message.ConversationUUID); err != nil {
return fmt.Errorf("error deleting conversation after message attachment upload failure: %w", err)
}
}
return fmt.Errorf("error uploading message attachments: %w", upErr)
}
// Insert message.
@@ -756,12 +787,11 @@ func (c *Manager) generateMessagesQuery(baseQuery string, qArgs []interface{}, p
}
// uploadMessageAttachments uploads all attachments for a message.
func (m *Manager) uploadMessageAttachments(message *models.Message) []error {
func (m *Manager) uploadMessageAttachments(message *models.Message) error {
if len(message.Attachments) == 0 {
return nil
}
var uploadErr []error
for _, attachment := range message.Attachments {
// Check if this attachment already exists by the content ID, as inline images can be repeated across conversations.
contentID := attachment.ContentID
@@ -808,21 +838,20 @@ func (m *Manager) uploadMessageAttachments(message *models.Message) []error {
[]byte("{}"), /** meta **/
)
if err != nil {
uploadErr = append(uploadErr, err)
m.lo.Error("failed to upload attachment", "name", attachment.Name, "error", err)
return fmt.Errorf("failed to upload media %s: %w", attachment.Name, err)
}
// If the attachment is an image, generate and upload thumbnail.
// If the attachment is an image, generate and upload a thumbnail. Log any errors and continue, as thumbnail generation failure should not block message processing.
attachmentExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(attachment.Name)), ".")
if slices.Contains(image.Exts, attachmentExt) {
if err := m.uploadThumbnailForMedia(media, attachment.Content); err != nil {
uploadErr = append(uploadErr, err)
m.lo.Error("error uploading thumbnail", "error", err)
}
}
message.Media = append(message.Media, media)
}
return uploadErr
return nil
}
// findOrCreateConversation finds or creates a conversation for the given message.

View File

@@ -52,52 +52,128 @@ var (
ContentTypeHTML = "html"
)
// ConversationListItem represents a conversation in list views
type ConversationListItem struct {
Total int `db:"total" json:"-"`
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"`
WaitingSince null.Time `db:"waiting_since" json:"waiting_since"`
AssigneeLastSeenAt null.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
Contact ConversationListContact `db:"contact" json:"contact"`
InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
InboxName string `db:"inbox_name" json:"inbox_name"`
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"`
LastReplyAt null.Time `db:"last_reply_at" json:"last_reply_at"`
ResolvedAt null.Time `db:"resolved_at" json:"resolved_at"`
Subject null.String `db:"subject" json:"subject"`
LastMessage null.String `db:"last_message" json:"last_message"`
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
LastMessageSender null.String `db:"last_message_sender" json:"last_message_sender"`
NextSLADeadlineAt null.Time `db:"next_sla_deadline_at" json:"next_sla_deadline_at"`
PriorityID null.Int `db:"priority_id" json:"priority_id"`
UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"`
Status null.String `db:"status" json:"status"`
Priority null.String `db:"priority" json:"priority"`
FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"`
ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"`
AppliedSLAID null.Int `db:"applied_sla_id" json:"applied_sla_id"`
NextResponseDueAt null.Time `db:"next_response_deadline_at" json:"next_response_deadline_at"`
NextResponseMetAt null.Time `db:"next_response_met_at" json:"next_response_met_at"`
}
// ConversationListContact represents contact info in conversation list views
type ConversationListContact struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
}
type Conversation struct {
ID int `db:"id" json:"id,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"`
ContactID int `db:"contact_id" json:"contact_id"`
InboxID int `db:"inbox_id" json:"inbox_id,omitempty"`
ClosedAt null.Time `db:"closed_at" json:"closed_at,omitempty"`
ResolvedAt null.Time `db:"resolved_at" json:"resolved_at,omitempty"`
ReferenceNumber string `db:"reference_number" json:"reference_number,omitempty"`
Priority null.String `db:"priority" json:"priority"`
PriorityID null.Int `db:"priority_id" json:"priority_id"`
Status null.String `db:"status" json:"status"`
StatusID null.Int `db:"status_id" json:"status_id"`
FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"`
LastReplyAt null.Time `db:"last_reply_at" json:"last_reply_at"`
AssignedUserID null.Int `db:"assigned_user_id" json:"assigned_user_id"`
AssignedTeamID null.Int `db:"assigned_team_id" json:"assigned_team_id"`
AssigneeLastSeenAt null.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
WaitingSince null.Time `db:"waiting_since" json:"waiting_since"`
Subject null.String `db:"subject" json:"subject"`
UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"`
InboxMail string `db:"inbox_mail" json:"inbox_mail"`
InboxName string `db:"inbox_name" json:"inbox_name"`
InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
Tags null.JSON `db:"tags" json:"tags"`
Meta pq.StringArray `db:"meta" json:"meta"`
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
LastMessage null.String `db:"last_message" json:"last_message"`
LastMessageSender null.String `db:"last_message_sender" json:"last_message_sender"`
Contact umodels.User `db:"contact" json:"contact"`
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"`
AppliedSLAID null.Int `db:"applied_sla_id" json:"applied_sla_id"`
NextSLADeadlineAt null.Time `db:"next_sla_deadline_at" json:"next_sla_deadline_at"`
FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"`
ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"`
NextResponseDueAt null.Time `db:"next_response_deadline_at" json:"next_response_deadline_at"`
NextResponseMetAt null.Time `db:"next_response_met_at" json:"next_response_met_at"`
PreviousConversations []Conversation `db:"-" json:"previous_conversations"`
Total int `db:"total" json:"-"`
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"`
ContactID int `db:"contact_id" json:"contact_id"`
InboxID int `db:"inbox_id" json:"inbox_id"`
ClosedAt null.Time `db:"closed_at" json:"closed_at"`
ResolvedAt null.Time `db:"resolved_at" json:"resolved_at"`
ReferenceNumber string `db:"reference_number" json:"reference_number"`
Priority null.String `db:"priority" json:"priority"`
PriorityID null.Int `db:"priority_id" json:"priority_id"`
Status null.String `db:"status" json:"status"`
StatusID null.Int `db:"status_id" json:"status_id"`
FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"`
LastReplyAt null.Time `db:"last_reply_at" json:"last_reply_at"`
AssignedUserID null.Int `db:"assigned_user_id" json:"assigned_user_id"`
AssignedTeamID null.Int `db:"assigned_team_id" json:"assigned_team_id"`
AssigneeLastSeenAt null.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
WaitingSince null.Time `db:"waiting_since" json:"waiting_since"`
Subject null.String `db:"subject" json:"subject"`
InboxMail string `db:"inbox_mail" json:"inbox_mail"`
InboxName string `db:"inbox_name" json:"inbox_name"`
InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
Tags null.JSON `db:"tags" json:"tags"`
Meta pq.StringArray `db:"meta" json:"meta"`
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
LastMessage null.String `db:"last_message" json:"last_message"`
LastMessageSender null.String `db:"last_message_sender" json:"last_message_sender"`
Contact ConversationContact `db:"contact" json:"contact"`
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"`
AppliedSLAID null.Int `db:"applied_sla_id" json:"applied_sla_id"`
FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"`
ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"`
NextResponseDueAt null.Time `db:"next_response_deadline_at" json:"next_response_deadline_at"`
NextResponseMetAt null.Time `db:"next_response_met_at" json:"next_response_met_at"`
PreviousConversations []PreviousConversation `db:"-" json:"previous_conversations"`
}
type ConversationContact struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
Email null.String `db:"email" json:"email"`
Type string `db:"type" json:"type"`
AvailabilityStatus string `db:"availability_status" json:"availability_status"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
PhoneNumber null.String `db:"phone_number" json:"phone_number"`
PhoneNumberCountryCode null.String `db:"phone_number_country_code" json:"phone_number_country_code"`
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
Enabled bool `db:"enabled" json:"enabled"`
LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"`
LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"`
}
func (c *ConversationContact) FullName() string {
return c.FirstName + " " + c.LastName
}
type PreviousConversation struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"`
Contact PreviousConversationContact `db:"contact" json:"contact"`
LastMessage null.String `db:"last_message" json:"last_message"`
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
}
type PreviousConversationContact struct {
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
}
type ConversationParticipant struct {
ID string `db:"id" json:"id"`
ID int `db:"id" json:"id"`
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
@@ -117,13 +193,15 @@ type NewConversationsStats struct {
// Message represents a message in a conversation
type Message struct {
ID int `db:"id" json:"id,omitempty"`
Total int `db:"total" json:"-"`
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"`
Type string `db:"type" json:"type"`
Status string `db:"status" json:"status"`
ConversationID int `db:"conversation_id" json:"conversation_id"`
ConversationUUID string `db:"conversation_uuid" json:"conversation_uuid"`
Content string `db:"content" json:"content"`
TextContent string `db:"text_content" json:"text_content"`
ContentType string `db:"content_type" json:"content_type"`
@@ -134,7 +212,6 @@ type Message struct {
InboxID int `db:"inbox_id" json:"-"`
Meta json.RawMessage `db:"meta" json:"meta"`
Attachments attachment.Attachments `db:"attachments" json:"attachments"`
ConversationUUID string `db:"conversation_uuid" json:"-"`
From string `db:"from" json:"-"`
Subject string `db:"subject" json:"-"`
Channel string `db:"channel" json:"-"`
@@ -144,10 +221,9 @@ type Message struct {
References []string `json:"-"`
InReplyTo string `json:"-"`
Headers textproto.MIMEHeader `json:"-"`
AltContent string `db:"-" json:"-"`
Media []mmodels.Media `db:"-" json:"-"`
IsCSAT bool `db:"-" json:"-"`
Total int `db:"total" json:"-"`
AltContent string `json:"-"`
Media []mmodels.Media `json:"-"`
IsCSAT bool `json:"-"`
}
// CensorCSATContent redacts the content of a CSAT message to prevent leaking the CSAT survey public link.

View File

@@ -99,6 +99,8 @@ SELECT
c.closed_at,
c.resolved_at,
c.inbox_id,
c.assignee_last_seen_at,
inb.name as inbox_name,
COALESCE(inb.from, '') as inbox_mail,
COALESCE(inb.channel::TEXT, '') as inbox_channel,
c.status_id,
@@ -138,9 +140,8 @@ SELECT
ct.availability_status as "contact.availability_status",
ct.avatar_url as "contact.avatar_url",
ct.phone_number as "contact.phone_number",
ct.phone_number_calling_code as "contact.phone_number_calling_code",
ct.phone_number_country_code as "contact.phone_number_country_code",
ct.custom_attributes as "contact.custom_attributes",
ct.avatar_url as "contact.avatar_url",
ct.enabled as "contact.enabled",
ct.last_active_at as "contact.last_active_at",
ct.last_login_at as "contact.last_login_at",
@@ -183,8 +184,11 @@ SELECT
FROM conversations c
WHERE c.created_at > $1;
-- name: get-contact-conversations
-- name: get-contact-previous-conversations
SELECT
c.id,
c.created_at,
c.updated_at,
c.uuid,
u.first_name AS "contact.first_name",
u.last_name AS "contact.last_name",
@@ -195,7 +199,7 @@ FROM users u
JOIN conversations c ON c.contact_id = u.id
WHERE c.contact_id = $1
ORDER BY c.created_at DESC
LIMIT 10;
LIMIT $2;
-- name: get-conversation-uuid
SELECT uuid from conversations where id = $1;
@@ -349,6 +353,7 @@ WHERE uuid = $1;
UPDATE conversations
SET
assigned_user_id = CASE WHEN $2 = 'user' THEN NULL ELSE assigned_user_id END,
assignee_last_seen_at = CASE WHEN $2 = 'user' THEN NULL ELSE assignee_last_seen_at END,
assigned_team_id = CASE WHEN $2 = 'team' THEN NULL ELSE assigned_team_id END,
updated_at = NOW()
WHERE uuid = $1;
@@ -400,22 +405,27 @@ LIMIT $2;
-- name: get-outgoing-pending-messages
SELECT
m.created_at,
m.id,
m.uuid,
m.sender_id,
m.type,
m.private,
m.created_at,
m.updated_at,
m.status,
m.type,
m.content,
m.text_content,
m.content_type,
m.conversation_id,
m.uuid,
m.private,
m.sender_type,
m.sender_id,
m.meta,
c.uuid as conversation_uuid,
m.content_type,
m.source_id,
ARRAY(SELECT jsonb_array_elements_text(m.meta->'cc')) AS cc,
ARRAY(SELECT jsonb_array_elements_text(m.meta->'bcc')) AS bcc,
ARRAY(SELECT jsonb_array_elements_text(m.meta->'to')) AS to,
c.inbox_id,
c.uuid as conversation_uuid,
c.subject
FROM conversation_messages m
INNER JOIN conversations c ON c.id = m.conversation_id
@@ -463,16 +473,21 @@ ORDER BY m.created_at;
-- name: get-messages
SELECT
COUNT(*) OVER() AS total,
m.id,
m.created_at,
m.updated_at,
m.status,
m.type,
m.content,
m.text_content,
m.content_type,
m.conversation_id,
m.uuid,
m.private,
m.sender_id,
m.sender_type,
m.meta,
$1::uuid AS conversation_uuid,
COALESCE(
(SELECT json_agg(
json_build_object(

View File

@@ -93,7 +93,7 @@ func (m *Manager) UpdateResponse(uuid string, score int, feedback string) error
return err
}
if csat.Score > 0 || !csat.ResponseTimestamp.IsZero() {
if csat.Rating > 0 || !csat.ResponseTimestamp.IsZero() {
return envelope.NewError(envelope.InputError, m.i18n.T("csat.alreadySubmitted"), nil)
}

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