Compare commits

..

50 Commits

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

Rename column from `phone_number_calling_code` to `phone_number_country_code`.

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

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

View File

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

View File

@@ -3,9 +3,9 @@
# Libredesk # Libredesk
Open source, self-hosted customer support desk. Single binary app. Modern, open source, self-hosted customer support desk. Single binary app.
![image](docs/docs/images/hero.png) ![image](https://libredesk.io/hero_white.png)
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/). Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
@@ -67,7 +67,7 @@ docker exec -it libredesk_app ./libredesk --set-system-user-password
Go to `http://localhost:9000` and login with username `System` and the password you set using the `--set-system-user-password` command. Go to `http://localhost:9000` and login with username `System` and the password you set using the `--set-system-user-password` command.
See [installation docs](https://libredesk.io/docs/installation/) See [installation docs](https://docs.libredesk.io/getting-started/installation)
__________________ __________________
@@ -78,12 +78,12 @@ __________________
- Run `./libredesk --set-system-user-password` to set the password for the System user. - Run `./libredesk --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. - 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 ## 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 ## 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 { if v, ok := form.Value["phone_number"]; ok && len(v) > 0 {
phoneNumber = string(v[0]) phoneNumber = string(v[0])
} }
phoneNumberCallingCode := "" phoneNumberCountryCode := ""
if v, ok := form.Value["phone_number_calling_code"]; ok && len(v) > 0 { if v, ok := form.Value["phone_number_country_code"]; ok && len(v) > 0 {
phoneNumberCallingCode = string(v[0]) phoneNumberCountryCode = string(v[0])
} }
avatarURL := "" avatarURL := ""
if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 { if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 {
@@ -116,8 +116,8 @@ func handleUpdateContact(r *fastglue.Request) error {
if avatarURL == "null" { if avatarURL == "null" {
avatarURL = "" avatarURL = ""
} }
if phoneNumberCallingCode == "null" { if phoneNumberCountryCode == "null" {
phoneNumberCallingCode = "" phoneNumberCountryCode = ""
} }
if phoneNumber == "null" { if phoneNumber == "null" {
phoneNumber = "" phoneNumber = ""
@@ -146,7 +146,7 @@ func handleUpdateContact(r *fastglue.Request) error {
Email: null.StringFrom(email), Email: null.StringFrom(email),
AvatarURL: null.NewString(avatarURL, avatarURL != ""), AvatarURL: null.NewString(avatarURL, avatarURL != ""),
PhoneNumber: null.NewString(phoneNumber, phoneNumber != ""), PhoneNumber: null.NewString(phoneNumber, phoneNumber != ""),
PhoneNumberCallingCode: null.NewString(phoneNumberCallingCode, phoneNumberCallingCode != ""), PhoneNumberCountryCode: null.NewString(phoneNumberCountryCode, phoneNumberCountryCode != ""),
} }
if err := app.user.UpdateContact(id, contactToUpdate); err != nil { if err := app.user.UpdateContact(id, contactToUpdate); err != nil {
@@ -164,11 +164,17 @@ func handleUpdateContact(r *fastglue.Request) error {
// Upload avatar? // Upload avatar?
files, ok := form.File["files"] files, ok := form.File["files"]
if ok && len(files) > 0 { 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 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. // handleGetContactNotes returns all notes for a contact.
@@ -195,18 +201,21 @@ func handleCreateContactNote(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
req = createContactNoteReq{} req = createContactNoteReq{}
) )
if err := r.Decode(&req, "json"); err != nil { 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)) return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
} }
if len(req.Note) == 0 { if len(req.Note) == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "note"), nil, envelope.InputError) 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 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. // 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 { if err := app.user.DeleteNote(noteID, contactID); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -251,6 +262,7 @@ func handleBlockContact(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = blockContactReq{} 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)) 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 { if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, req.Enabled); err != nil {
return sendErrorEnvelope(r, err) 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"` Subject string `json:"subject"`
Content string `json:"content"` Content string `json:"content"`
Attachments []int `json:"attachments"` Attachments []int `json:"attachments"`
Initiator string `json:"initiator"` // "contact" | "agent"
} }
// handleGetAllConversations retrieves all conversations. // handleGetAllConversations retrieves all conversations.
@@ -273,8 +274,8 @@ func handleGetConversation(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
prev, _ := app.conversation.GetContactConversations(conv.ContactID) prev, _ := app.conversation.GetContactPreviousConversations(conv.ContactID, 10)
conv.PreviousConversations = filterCurrentConv(prev, conv.UUID) conv.PreviousConversations = filterCurrentPreviousConv(prev, conv.UUID)
return r.SendEnvelope(conv) return r.SendEnvelope(conv)
} }
@@ -649,14 +650,14 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
return r.SendEnvelope(true) return r.SendEnvelope(true)
} }
// filterCurrentConv removes the current conversation from the list of conversations. // filterCurrentPreviousConv removes the current conversation from the list of previous conversations.
func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conversation { func filterCurrentPreviousConv(convs []cmodels.PreviousConversation, uuid string) []cmodels.PreviousConversation {
for i, c := range convs { for i, c := range convs {
if c.UUID == uuid { if c.UUID == uuid {
return append(convs[:i], convs[i+1:]...) return append(convs[:i], convs[i+1:]...)
} }
} }
return []cmodels.Conversation{} return []cmodels.PreviousConversation{}
} }
// handleCreateConversation creates a new conversation and sends a message to it. // 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) 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} 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, "") user, err := app.user.GetAgent(auser.ID, "")
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) 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. // Find or create contact.
contact := umodels.User{ contact := umodels.User{
Email: null.StringFrom(req.Email), 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)) 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( conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID, contact.ID,
contact.ContactChannelID, contact.ContactChannelID,
@@ -725,14 +704,14 @@ func handleCreateConversation(r *fastglue.Request) error {
"", /** last_message **/ "", /** last_message **/
time.Now(), /** last_message_at **/ time.Now(), /** last_message_at **/
req.Subject, req.Subject,
true, /** append reference number to subject **/ true, /** append reference number to subject? **/
) )
if err != nil { if err != nil {
app.lo.Error("error creating conversation", "error", err) 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)) 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)) var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments { for _, id := range req.Attachments {
m, err := app.media.Get(id, "") m, err := app.media.Get(id, "")
@@ -743,13 +722,29 @@ func handleCreateConversation(r *fastglue.Request) error {
media = append(media, m) media = append(media, m)
} }
// Send reply to the created conversation. // Send initial message based on the initiator of 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 { switch req.Initiator {
// Delete the conversation if reply fails. case umodels.UserTypeAgent:
if err := app.conversation.DeleteConversation(conversationUUID); err != nil { // Queue reply.
app.lo.Error("error deleting conversation", "error", err) 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. // Assign the conversation to the agent or team.
@@ -768,3 +763,36 @@ func handleCreateConversation(r *fastglue.Request) error {
return r.SendEnvelope(conversation) 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" "github.com/zerodha/fastglue"
) )
const (
maxCsatFeedbackLength = 1000
)
// handleShowCSAT renders the CSAT page for a given csat. // handleShowCSAT renders the CSAT page for a given csat.
func handleShowCSAT(r *fastglue.Request) error { func handleShowCSAT(r *fastglue.Request) error {
var ( var (
@@ -17,7 +21,7 @@ func handleShowCSAT(r *fastglue.Request) error {
if err != nil { if err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": 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 { if csat.ResponseTimestamp.Valid {
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
"Data": map[string]interface{}{ "Data": map[string]interface{}{
"Title": "Thank you!", "Title": app.i18n.T("globals.messages.thankYou"),
"Message": "We appreciate you taking the time to submit your feedback.", "Message": app.i18n.T("csat.thankYouMessage"),
}, },
}) })
} }
@@ -35,14 +39,14 @@ func handleShowCSAT(r *fastglue.Request) error {
if err != nil { if err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": 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{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
"Data": map[string]interface{}{ "Data": map[string]interface{}{
"Title": "Rate your interaction with us", "Title": app.i18n.T("csat.pageTitle"),
"CSAT": map[string]interface{}{ "CSAT": map[string]interface{}{
"UUID": csat.UUID, "UUID": csat.UUID,
}, },
@@ -67,7 +71,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
if err != nil { if err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": 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 { if ratingI < 1 || ratingI > 5 {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": 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 == "" { if uuid == "" {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": 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 { if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": 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{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
"Data": map[string]interface{}{ "Data": map[string]interface{}{
"Title": "Thank you!", "Title": app.i18n.T("globals.messages.thankYou"),
"Message": "We appreciate you taking the time to submit your feedback.", "Message": app.i18n.T("csat.thankYouMessage"),
}, },
}) })
} }

View File

@@ -23,18 +23,20 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// i18n. // i18n.
g.GET("/api/v1/lang/{lang}", handleGetI18nLang) g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
// Public config for app initialization.
g.GET("/api/v1/config", handleGetConfig)
// Media. // Media.
g.GET("/uploads/{uuid}", auth(handleServeMedia)) g.GET("/uploads/{uuid}", auth(handleServeMedia))
g.POST("/api/v1/media", auth(handleMediaUpload)) g.POST("/api/v1/media", auth(handleMediaUpload))
// Settings. // 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.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "general_settings:manage"))
g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_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")) g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage"))
// OpenID connect single sign-on. // OpenID connect single sign-on.
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage")) g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage")) g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "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")) g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
// Roles. // 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.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage"))
g.POST("/api/v1/roles", perm(handleCreateRole, "roles:manage")) g.POST("/api/v1/roles", perm(handleCreateRole, "roles:manage"))
g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "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 { if err != nil {
return sendErrorEnvelope(r, err) 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) return r.SendEnvelope(inboxes)
} }

View File

@@ -250,11 +250,12 @@ func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager {
} }
// initViews inits view 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") var lo = initLogger("view_manager")
m, err := view.New(view.Opts{ m, err := view.New(view.Opts{
DB: db, DB: db,
Lo: lo, Lo: lo,
I18n: i18n,
}) })
if err != nil { if err != nil {
log.Fatalf("error initializing view manager: %v", err) 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 { func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager {
var ( var (
lo = initLogger("template") lo = initLogger("template")
funcMap = getTmplFuncs(consts) funcMap = getTmplFuncs(consts, i18n)
) )
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html") tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
if err != nil { if err != nil {
@@ -345,7 +346,7 @@ func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *
} }
// getTmplFuncs returns the template functions. // getTmplFuncs returns the template functions.
func getTmplFuncs(consts *constants) template.FuncMap { func getTmplFuncs(consts *constants, i18n *i18n.I18n) template.FuncMap {
return template.FuncMap{ return template.FuncMap{
"RootURL": func() string { "RootURL": func() string {
return consts.AppBaseURL return consts.AppBaseURL
@@ -365,6 +366,9 @@ func getTmplFuncs(consts *constants) template.FuncMap {
"SiteName": func() string { "SiteName": func() string {
return consts.SiteName 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) app.lo.Error("error unmarshalling settings from DB", "error", err)
return 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) app.lo.Error("error loading settings into koanf", "error", err)
return err return err
} }
@@ -393,7 +400,7 @@ func reloadSettings(app *App) error {
// reloadTemplates reloads the templates from the filesystem. // reloadTemplates reloads the templates from the filesystem.
func reloadTemplates(app *App) error { func reloadTemplates(app *App) error {
app.lo.Info("reloading templates") 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") tpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/email-templates/*.html")
if err != nil { if err != nil {
app.lo.Error("error parsing email templates", "error", err) 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. // Global state that stores data on an available app update.
update *AppUpdate update *AppUpdate
// Flag to indicate if app restart is required for settings to take effect.
restartRequired bool
sync.Mutex sync.Mutex
} }
@@ -239,7 +241,7 @@ func main() {
activityLog: initActivityLog(db, i18n), activityLog: initActivityLog(db, i18n),
customAttribute: initCustomAttribute(db, i18n), customAttribute: initCustomAttribute(db, i18n),
authz: initAuthz(i18n), authz: initAuthz(i18n),
view: initView(db), view: initView(db, i18n),
report: initReport(db, i18n), report: initReport(db, i18n),
csat: initCSAT(db, i18n), csat: initCSAT(db, i18n),
search: initSearch(db, i18n), search: initSearch(db, i18n),

View File

@@ -2,10 +2,14 @@ package main
import ( import (
"strconv" "strconv"
"strings"
amodels "github.com/abhinavxd/libredesk/internal/auth/models" 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" "github.com/abhinavxd/libredesk/internal/envelope"
medModels "github.com/abhinavxd/libredesk/internal/media/models" medModels "github.com/abhinavxd/libredesk/internal/media/models"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
@@ -17,6 +21,7 @@ type messageReq struct {
To []string `json:"to"` To []string `json:"to"`
CC []string `json:"cc"` CC []string `json:"cc"`
BCC []string `json:"bcc"` BCC []string `json:"bcc"`
SenderType string `json:"sender_type"`
} }
// handleGetMessages returns messages for a conversation. // handleGetMessages returns messages for a conversation.
@@ -99,7 +104,7 @@ func handleGetMessage(r *fastglue.Request) error {
return r.SendEnvelope(message) 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 { func handleRetryMessage(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) 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) 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)) var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments { for _, id := range req.Attachments {
m, err := app.media.Get(id, "") m, err := app.media.Get(id, "")
@@ -161,6 +190,16 @@ func handleSendMessage(r *fastglue.Request) error {
media = append(media, m) 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 { if req.Private {
message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message) message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message)
if err != nil { if err != nil {
@@ -168,7 +207,9 @@ func handleSendMessage(r *fastglue.Request) error {
} }
return r.SendEnvelope(message) 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 { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }

View File

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

View File

@@ -31,6 +31,8 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
settings["app.update"] = app.update settings["app.update"] = app.update
// Set app version. // Set app version.
settings["app.version"] = versionString settings["app.version"] = versionString
// Set restart required flag.
settings["app.restart_required"] = app.restartRequired
return r.SendEnvelope(settings) 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) 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. // Remove any trailing slash `/` from the root url.
req.RootURL = strings.TrimRight(req.RootURL, "/") req.RootURL = strings.TrimRight(req.RootURL, "/")
@@ -55,6 +62,17 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
if err := reloadSettings(app); err != nil { 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) 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 { 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) 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) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.invalidFromAddress"), nil, envelope.InputError)
} }
// If empty then retain previous password.
if req.Password == "" { if req.Password == "" {
req.Password = cur.Password req.Password = cur.Password
} }
@@ -117,6 +136,10 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) 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) 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)) 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 { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }

View File

@@ -35,6 +35,7 @@ var migList = []migFunc{
{"v0.5.0", migrations.V0_5_0}, {"v0.5.0", migrations.V0_5_0},
{"v0.6.0", migrations.V0_6_0}, {"v0.6.0", migrations.V0_6_0},
{"v0.7.0", migrations.V0_7_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 // upgrade upgrades the database to the current version by running SQL migration files

View File

@@ -26,34 +26,38 @@ const (
maxAvatarSizeMB = 2 maxAvatarSizeMB = 2
) )
// Request structs for user-related endpoints type updateAvailabilityRequest struct {
// UpdateAvailabilityRequest represents the request to update user availability
type UpdateAvailabilityRequest struct {
Status string `json:"status"` Status string `json:"status"`
} }
// ResetPasswordRequest represents the password reset request type resetPasswordRequest struct {
type ResetPasswordRequest struct {
Email string `json:"email"` Email string `json:"email"`
} }
// SetPasswordRequest represents the set password request type setPasswordRequest struct {
type SetPasswordRequest struct {
Token string `json:"token"` Token string `json:"token"`
Password string `json:"password"` Password string `json:"password"`
} }
// AvailabilityRequest represents the request to update agent availability type availabilityRequest struct {
type AvailabilityRequest struct {
Status string `json:"status"` 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. // handleGetAgents returns all agents.
func handleGetAgents(r *fastglue.Request) error { func handleGetAgents(r *fastglue.Request) error {
var ( var app = r.Context.(*App)
app = r.Context.(*App)
)
agents, err := app.user.GetAgents() agents, err := app.user.GetAgents()
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
@@ -73,9 +77,7 @@ func handleGetAgentsCompact(r *fastglue.Request) error {
// handleGetAgent returns an agent. // handleGetAgent returns an agent.
func handleGetAgent(r *fastglue.Request) error { func handleGetAgent(r *fastglue.Request) error {
var ( var app = r.Context.(*App)
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 { if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) 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) app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx) ip = realip.FromRequest(r.RequestCtx)
availReq AvailabilityRequest availReq availabilityRequest
) )
// Decode JSON request // 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) 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, "") agent, err := app.user.GetAgent(auser.ID, "")
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
@@ -108,10 +111,10 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
// Same status? // Same status?
if agent.AvailabilityStatus == availReq.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 { if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil {
return sendErrorEnvelope(r, err) 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 { func handleGetCurrentAgentTeams(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
) )
agent, err := app.user.GetAgent(auser.ID, "") teams, err := app.team.GetUserTeams(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
teams, err := app.team.GetUserTeams(agent.ID)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -150,11 +154,6 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
app = r.Context.(*App) app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User) 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() form, err := r.RequestCtx.MultipartForm()
if err != nil { if err != nil {
app.lo.Error("error parsing form data", "error", err) app.lo.Error("error parsing form data", "error", err)
@@ -165,54 +164,53 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
// Upload avatar? // Upload avatar?
if ok && len(files) > 0 { 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 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. // handleCreateAgent creates a new agent.
func handleCreateAgent(r *fastglue.Request) error { func handleCreateAgent(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
user = models.User{} 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) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
} }
if user.Email.String == "" { // Validate agent request
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError) if err := validateAgentRequest(r, &req); err != nil {
} return err
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 { agent, err := app.user.CreateAgent(req.FirstName, req.LastName, req.Email, req.Roles)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError) if err != nil {
}
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 {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Upsert user teams. // Upsert user teams.
if len(user.Teams) > 0 { if len(req.Teams) > 0 {
if err := app.team.UpsertUserTeams(user.ID, user.Teams.Names()); err != nil { app.team.UpsertUserTeams(agent.ID, req.Teams)
return sendErrorEnvelope(r, err)
}
} }
if user.SendWelcomeEmail { if req.SendWelcomeEmail {
// Generate reset token. // Generate reset token.
resetToken, err := app.user.SetResetPasswordToken(user.ID) resetToken, err := app.user.SetResetPasswordToken(agent.ID)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -220,31 +218,36 @@ func handleCreateAgent(r *fastglue.Request) error {
// Render template and send email. // Render template and send email.
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{ content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
"ResetToken": resetToken, "ResetToken": resetToken,
"Email": user.Email.String, "Email": req.Email,
}) })
if err != nil { if err != nil {
app.lo.Error("error rendering template", "error", err) app.lo.Error("error rendering template", "error", err)
return r.SendEnvelope(true)
} }
if err := app.notifier.Send(notifier.Message{ if err := app.notifier.Send(notifier.Message{
RecipientEmails: []string{user.Email.String}, RecipientEmails: []string{req.Email},
Subject: "Welcome to Libredesk", Subject: app.i18n.T("globals.messages.welcomeToLibredesk"),
Content: content, Content: content,
Provider: notifier.ProviderEmail, Provider: notifier.ProviderEmail,
}); err != nil { }); err != nil {
app.lo.Error("error sending notification message", "error", err) 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. // handleUpdateAgent updates an agent.
func handleUpdateAgent(r *fastglue.Request) error { func handleUpdateAgent(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
user = models.User{} req = agentReq{}
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx) ip = realip.FromRequest(r.RequestCtx)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) 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) 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) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
} }
if user.Email.String == "" { // Validate agent request
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError) if err := validateAgentRequest(r, &req); err != nil {
} return err
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)
} }
agent, err := app.user.GetAgent(id, "") agent, err := app.user.GetAgent(id, "")
@@ -280,8 +271,8 @@ func handleUpdateAgent(r *fastglue.Request) error {
} }
oldAvailabilityStatus := agent.AvailabilityStatus oldAvailabilityStatus := agent.AvailabilityStatus
// Update agent. // Update agent with individual fields
if err = app.user.UpdateAgent(id, user); err != nil { 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) return sendErrorEnvelope(r, err)
} }
@@ -289,18 +280,24 @@ func handleUpdateAgent(r *fastglue.Request) error {
defer app.authz.InvalidateUserCache(id) defer app.authz.InvalidateUserCache(id)
// Create activity log if user availability status changed. // Create activity log if user availability status changed.
if oldAvailabilityStatus != user.AvailabilityStatus { if oldAvailabilityStatus != req.AvailabilityStatus {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil { 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) app.lo.Error("error creating activity log", "error", err)
} }
} }
// Upsert agent teams. // 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 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. // handleDeleteAgent soft deletes an agent.
@@ -381,7 +378,7 @@ func handleResetPassword(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
auser, ok = r.RequestCtx.UserValue("user").(amodels.User) auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
resetReq ResetPasswordRequest resetReq resetPasswordRequest
) )
if ok && auser.ID > 0 { if ok && auser.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError) 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) agent, err := app.user.GetAgent(0, resetReq.Email)
if err != nil { if err != nil {
// Send 200 even if user not found, to prevent email enumeration. // 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) token, err := app.user.SetResetPasswordToken(agent.ID)
@@ -434,7 +431,7 @@ func handleSetPassword(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
agent, ok = r.RequestCtx.UserValue("user").(amodels.User) agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
req = SetPasswordRequest{} req setPasswordRequest
) )
if ok && agent.ID > 0 { if ok && agent.ID > 0 {
@@ -457,13 +454,13 @@ func handleSetPassword(r *fastglue.Request) error {
} }
// uploadUserAvatar uploads the user avatar. // 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) var app = r.Context.(*App)
fileHeader := files[0] fileHeader := files[0]
file, err := fileHeader.Open() file, err := fileHeader.Open()
if err != nil { 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) return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil)
} }
defer file.Close() defer file.Close()
@@ -480,7 +477,7 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
// Check file size // Check file size
if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB { 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( return envelope.NewError(
envelope.InputError, envelope.InputError,
app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)), 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("{}") meta := []byte("{}")
media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta) media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
if err != nil { 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) return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
} }
// Delete current avatar. // Delete current avatar.
if user.AvatarURL.Valid { if user.AvatarURL.Valid {
fileName := filepath.Base(user.AvatarURL.String) 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. // Save file path.
path, err := stringutil.GetPathFromURL(media.URL) path, err := stringutil.GetPathFromURL(media.URL)
if err != nil { 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) 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 { if err := app.user.UpdateAvatar(user.ID, path); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -577,3 +576,28 @@ func handleRevokeAPIKey(r *fastglue.Request) error {
return r.SendEnvelope(true) 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', () => { describe('Login Component', () => {
beforeEach(() => { beforeEach(() => {
// Visit the login page
cy.visit('/')
// Mock the API response for OIDC providers // Mock the API response for OIDC providers
cy.intercept('GET', '**/api/v1/oidc/enabled', { cy.intercept('GET', '**/api/v1/config', {
statusCode: 200, statusCode: 200,
body: { body: {
data: [ data: {
{ "app.favicon_url": "http://localhost:9000/favicon.ico",
id: 1, "app.lang": "en",
name: 'Google', "app.logo_url": "http://localhost:9000/logo.png",
logo_url: 'https://example.com/google-logo.png', "app.site_name": "Libredesk",
disabled: false "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') }).as('getOIDCProviders')
// Visit the login page
cy.visit('/')
}) })
it('should display login form', () => { it('should display login form', () => {

View File

@@ -88,8 +88,8 @@
@create-conversation="() => (openCreateConversationDialog = true)" @create-conversation="() => (openCreateConversationDialog = true)"
> >
<div class="flex flex-col h-screen"> <div class="flex flex-col h-screen">
<!-- Show app update only in admin routes --> <!-- Show admin banner only in admin routes -->
<AppUpdate v-if="route.path.startsWith('/admin')" /> <AdminBanner v-if="route.path.startsWith('/admin')" />
<!-- Common header for all pages --> <!-- Common header for all pages -->
<PageHeader /> <PageHeader />
@@ -128,7 +128,7 @@ import { useCustomAttributeStore } from '@/stores/customAttributes'
import { useIdleDetection } from '@/composables/useIdleDetection' import { useIdleDetection } from '@/composables/useIdleDetection'
import PageHeader from './components/layout/PageHeader.vue' import PageHeader from './components/layout/PageHeader.vue'
import ViewForm from '@/features/view/ViewForm.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 api from '@/api'
import { toast as sooner } from 'vue-sonner' import { toast as sooner } from 'vue-sonner'
import Sidebar from '@/components/sidebar/Sidebar.vue' import Sidebar from '@/components/sidebar/Sidebar.vue'

View File

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

View File

@@ -137,10 +137,10 @@
--background: 240 5.9% 10%; --background: 240 5.9% 10%;
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
--card: 240 10% 3.9%; --card: 240 5.9% 10%;
--card-foreground: 0 0% 98%; --card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%; --popover: 240 5.9% 10%;
--popover-foreground: 0 0% 98%; --popover-foreground: 0 0% 98%;
--primary: 0 0% 98%; --primary: 0 0% 98%;
@@ -184,6 +184,10 @@
@apply border shadow rounded; @apply border shadow rounded;
} }
.loading-fade {
@apply opacity-50 transition-opacity duration-300
}
// Scrollbar start // Scrollbar start
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; /* Adjust width */ 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> <template>
<Button <Button
variant="ghost" variant="ghost"
@click.prevent="onClose" @click.stop="onClose"
size="xs" 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" 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 class="flex-1">
<div v-if="modelFilter.field && modelFilter.operator"> <div v-if="modelFilter.field && modelFilter.operator">
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'"> <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 <SelectComboBox
v-if=" v-else-if="
getFieldOptions(modelFilter).length > 0 && getFieldOptions(modelFilter).length > 0 &&
modelFilter.field === 'assigned_user_id' modelFilter.field === 'assigned_user_id'
" "
@@ -94,8 +101,9 @@
<CloseButton :onClose="() => removeFilter(index)" /> <CloseButton :onClose="() => removeFilter(index)" />
</div> </div>
<!-- Button Container -->
<div class="flex items-center justify-between pt-3"> <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" /> <Plus class="w-3 h-3 mr-1" />
{{ {{
$t('globals.messages.add', { $t('globals.messages.add', {
@@ -104,15 +112,17 @@
}} }}
</Button> </Button>
<div class="flex gap-2" v-if="showButtons"> <div class="flex gap-2" v-if="showButtons">
<Button variant="ghost" @click="clearFilters">{{ $t('globals.messages.reset') }}</Button> <Button variant="ghost" @click.stop="clearFilters">
<Button @click="applyFilters">{{ $t('globals.messages.apply') }}</Button> {{ $t('globals.messages.reset') }}
</Button>
<Button @click.stop="applyFilters">{{ $t('globals.messages.apply') }}</Button>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, onUnmounted, watch } from 'vue'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -125,8 +135,10 @@ import { Plus } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { FIELD_TYPE } from '@/constants/filterConfig'
import CloseButton from '@/components/button/CloseButton.vue' import CloseButton from '@/components/button/CloseButton.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue' import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import SelectTag from '@/components/ui/select/SelectTag.vue'
const props = defineProps({ const props = defineProps({
fields: { fields: {
@@ -150,12 +162,17 @@ onMounted(() => {
} }
}) })
onUnmounted(() => {
// On unmounted set valid filters
modelValue.value = validFilters.value
})
const getModel = (field) => { const getModel = (field) => {
const fieldConfig = props.fields.find((f) => f.field === field) const fieldConfig = props.fields.find((f) => f.field === field)
return fieldConfig?.model || '' return fieldConfig?.model || ''
} }
// Set model for each filter // Set model for each filter and the default value
watch( watch(
() => modelValue.value, () => modelValue.value,
(filters) => { (filters) => {
@@ -163,6 +180,15 @@ watch(
if (filter.field && !filter.model) { if (filter.field && !filter.model) {
filter.model = getModel(filter.field) 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 } { deep: true }
@@ -170,15 +196,20 @@ watch(
// Reset operator and value when field changes for a filter at a given index // Reset operator and value when field changes for a filter at a given index
watch( watch(
() => modelValue.value.map((f) => f.field), modelValue,
(newFields, oldFields) => { (newFilters, oldFilters) => {
newFields.forEach((field, index) => { // Skip first run
if (field !== oldFields[index]) { if (!oldFilters) return
modelValue.value[index].operator = ''
modelValue.value[index].value = '' newFilters.forEach((filter, index) => {
const oldFilter = oldFilters[index]
if (oldFilter && filter.field !== oldFilter.field) {
filter.operator = ''
filter.value = ''
} }
}) })
} },
{ deep: true }
) )
const addFilter = () => { const addFilter = () => {
@@ -197,7 +228,17 @@ const clearFilters = () => {
} }
const validFilters = computed(() => { 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) => { const getFieldOptions = (fieldValue) => {
@@ -209,4 +250,9 @@ const getFieldOperators = (modelFilter) => {
const field = props.fields.find((f) => f.field === modelFilter.field) const field = props.fields.find((f) => f.field === modelFilter.field)
return field?.operators || [] return field?.operators || []
} }
const getFieldType = (modelFilter) => {
const field = props.fields.find((f) => f.field === modelFilter.field)
return field?.type || ''
}
</script> </script>

View File

@@ -4,9 +4,9 @@
@click="handleClick"> @click="handleClick">
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<component :is="icon" size="24" class="mr-2 text-primary" /> <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> </div>
<p class="text-sm text-gray-600">{{ subTitle }}</p> <p class="text-sm text-gray-600 dark:text-gray-400">{{ subTitle }}</p>
</div> </div>
</template> </template>

View File

@@ -38,6 +38,16 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } 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 { filterNavItems } from '@/utils/nav-permissions'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
@@ -73,8 +83,17 @@ const editView = (view) => {
emit('editView', view) emit('editView', view)
} }
const deleteView = (view) => { const openDeleteConfirmation = (view) => {
emit('deleteView', 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 // Navigation methods with conversation retention
@@ -157,6 +176,13 @@ watch(
const sidebarOpen = useStorage('mainSidebarOpen', true) const sidebarOpen = useStorage('mainSidebarOpen', true)
const teamInboxOpen = useStorage('teamInboxOpen', true) const teamInboxOpen = useStorage('teamInboxOpen', true)
const viewInboxOpen = useStorage('viewInboxOpen', 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> </script>
<template> <template>
@@ -472,24 +498,35 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<CollapsibleContent> <CollapsibleContent>
<SidebarMenuSub v-for="view in userViews" :key="view.id"> <SidebarMenuSub v-for="view in userViews" :key="view.id">
<SidebarMenuSubItem> <SidebarMenuSubItem
@mouseenter="hoveredViewId = view.id"
@mouseleave="hoveredViewId = null"
>
<SidebarMenuButton <SidebarMenuButton
size="sm" size="sm"
:isActive="route.params.viewID == view.id" :isActive="route.params.viewID == view.id"
asChild asChild
> >
<a href="#" @click.prevent="navigateToViewInbox(view.id)"> <a href="#" @click.prevent="navigateToViewInbox(view.id)">
<span class="break-words w-32 truncate">{{ view.name }}</span> <span class="break-words w-32 truncate" :title="view.name">{{ view.name }}</span>
<SidebarMenuAction :showOnHover="true" class="mr-3"> <SidebarMenuAction
@click.stop
:class="[
'mr-3',
'md:opacity-0',
'data-[state=open]:opacity-100',
{ 'md:opacity-100': hoveredViewId === view.id }
]"
>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild @click.prevent>
<EllipsisVertical /> <EllipsisVertical />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem @click="() => editView(view)"> <DropdownMenuItem @click="() => editView(view)">
<span>{{ t('globals.messages.edit') }}</span> <span>{{ t('globals.messages.edit') }}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem @click="() => deleteView(view)"> <DropdownMenuItem @click="() => openDeleteConfirmation(view)">
<span>{{ t('globals.messages.delete') }}</span> <span>{{ t('globals.messages.delete') }}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -513,4 +550,22 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<slot></slot> <slot></slot>
</SidebarInset> </SidebarInset>
</SidebarProvider> </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> </template>

View File

@@ -8,7 +8,7 @@
:class="['w-full justify-between', buttonClass]" :class="['w-full justify-between', buttonClass]"
> >
<slot name="selected" :selected="selectedItem">{{ selectedLabel }}</slot> <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> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent class="p-0"> <PopoverContent class="p-0">

View File

@@ -1,4 +1,5 @@
<template> <template>
<!-- idk why I named this select tag, should be named multi-select -->
<TagsInput v-model="tags" class="px-0 gap-0" :displayValue="getLabel"> <TagsInput v-model="tags" class="px-0 gap-0" :displayValue="getLabel">
<!-- Tags visible to the user --> <!-- Tags visible to the user -->
<div class="flex gap-2 flex-wrap items-center px-3"> <div class="flex gap-2 flex-wrap items-center px-3">
@@ -24,6 +25,7 @@
@keydown.enter.prevent @keydown.enter.prevent
@blur="handleBlur" @blur="handleBlur"
@click="open = true" @click="open = true"
@input.stop
/> />
</ComboboxInput> </ComboboxInput>
</ComboboxAnchor> </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 { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla' import { useSlaStore } from '@/stores/sla'
import { useCustomAttributeStore } from '@/stores/customAttributes' import { useCustomAttributeStore } from '@/stores/customAttributes'
import { useTagStore } from '@/stores/tag'
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig' import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -15,6 +16,7 @@ export function useConversationFilters () {
const tStore = useTeamStore() const tStore = useTeamStore()
const slaStore = useSlaStore() const slaStore = useSlaStore()
const customAttributeStore = useCustomAttributeStore() const customAttributeStore = useCustomAttributeStore()
const tagStore = useTagStore()
const { t } = useI18n() const { t } = useI18n()
const customAttributeDataTypeToFieldType = { const customAttributeDataTypeToFieldType = {
@@ -69,6 +71,12 @@ export function useConversationFilters () {
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: iStore.options 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 = { export const FIELD_TYPE = {
SELECT: 'select', SELECT: 'select',
TAG: 'tag', TAG: 'tag',
MULTI_SELECT: 'multi-select',
TEXT: 'text', TEXT: 'text',
NUMBER: 'number', NUMBER: 'number',
RICHTEXT: 'richtext', RICHTEXT: 'richtext',
@@ -39,4 +40,5 @@ export const FIELD_OPERATORS = {
OPERATOR.LESS_THAN OPERATOR.LESS_THAN
], ],
NUMBER: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.GREATER_THAN, 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', CONVERSATIONS_UPDATE_TAGS: 'conversations:update_tags',
MESSAGES_READ: 'messages:read', MESSAGES_READ: 'messages:read',
MESSAGES_WRITE: 'messages:write', MESSAGES_WRITE: 'messages:write',
MESSAGES_WRITE_AS_CONTACT: 'messages:write_as_contact',
VIEW_MANAGE: 'view:manage', VIEW_MANAGE: 'view:manage',
GENERAL_SETTINGS_MANAGE: 'general_settings:manage', GENERAL_SETTINGS_MANAGE: 'general_settings:manage',
NOTIFICATION_SETTINGS_MANAGE: 'notification_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') { if (values.availability_status === 'active_group') {
values.availability_status = 'online' values.availability_status = 'online'
} }
values.teams = values.teams.map((team) => ({ name: team }))
props.submitForm(values) props.submitForm(values)
}) })

View File

@@ -9,7 +9,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.firstName')) return h('div', { class: 'text-center' }, t('globals.terms.firstName'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.lastName'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.enabled'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.email'))
}, },
cell: function ({ row }) { 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 }) { cell: function ({ row }) {
return h( return h(
'div', 'div',
{ class: 'text-center font-medium' }, { class: 'text-center' },
format(row.getValue('created_at'), 'PPpp') format(row.getValue('created_at'), 'PPpp')
) )
} }
@@ -60,7 +60,7 @@ export const createColumns = (t) => [
cell: function ({ row }) { cell: function ({ row }) {
return h( return h(
'div', 'div',
{ class: 'text-center font-medium' }, { class: 'text-center' },
format(row.getValue('updated_at'), 'PPpp') 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')) return h('div', { class: 'text-center' }, t('globals.terms.name'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.name'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.key'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.type'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.appliesTo'))
}, },
cell: function ({ row }) { 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 }) { cell: function ({ row }) {
return h( return h(
'div', 'div',
{ class: 'text-center font-medium' }, { class: 'text-center' },
format(row.getValue('created_at'), 'PPpp') format(row.getValue('created_at'), 'PPpp')
) )
} }
@@ -60,7 +60,7 @@ export const createColumns = (t) => [
cell: function ({ row }) { cell: function ({ row }) {
return h( return h(
'div', 'div',
{ class: 'text-center font-medium' }, { class: 'text-center' },
format(row.getValue('updated_at'), 'PPpp') 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')) return h('div', { class: 'text-center' }, t('globals.terms.name'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.name'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.provider'))
}, },
cell: function ({ row }) { 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.CONVERSATIONS_UPDATE_TAGS, label: t('admin.role.conversations.updateTags') },
{ name: perms.MESSAGES_READ, label: t('admin.role.messages.read') }, { name: perms.MESSAGES_READ, label: t('admin.role.messages.read') },
{ name: perms.MESSAGES_WRITE, label: t('admin.role.messages.write') }, { 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') } { 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')) return h('div', { class: 'text-center' }, t('globals.terms.name'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.description'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.name'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.name'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.name'))
}, },
cell: function ({ row }) { cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name')) return h('div', { class: 'text-center' }, row.getValue('name'))
} }
}, },
{ {

View File

@@ -9,7 +9,7 @@ export const columns = [
return h('div', { class: 'text-center' }, 'Name') return h('div', { class: 'text-center' }, 'Name')
}, },
cell: function ({ row }) { 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 }) { cell: function ({ row }) {
return h( return h(
'div', 'div',
{ class: 'text-center font-medium' }, { class: 'text-center' },
format(row.getValue('created_at'), 'PPpp') format(row.getValue('created_at'), 'PPpp')
) )
} }
@@ -33,7 +33,7 @@ export const columns = [
cell: function ({ row }) { cell: function ({ row }) {
return h( return h(
'div', 'div',
{ class: 'text-center font-medium' }, { class: 'text-center' },
format(row.getValue('updated_at'), 'PPpp') 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')) return h('div', { class: 'text-center' }, t('globals.terms.name'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.name'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.name'))
}, },
cell: function ({ row }) { 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 flex-col flex-1">
<div class="flex items-end"> <div class="flex items-end">
<FormField v-slot="{ componentField }" name="phone_number_calling_code"> <FormField v-slot="{ componentField }" name="phone_number_country_code">
<FormItem class="w-20"> <FormItem class="w-max">
<FormLabel class="flex items-center whitespace-nowrap"> <FormLabel class="flex items-center whitespace-nowrap">
{{ t('globals.terms.phoneNumber') }} {{ t('globals.terms.phoneNumber') }}
</FormLabel> </FormLabel>
@@ -58,13 +58,18 @@
<div class="w-7 h-7 flex items-center justify-center"> <div class="w-7 h-7 flex items-center justify-center">
<span v-if="item.emoji">{{ item.emoji }}</span> <span v-if="item.emoji">{{ item.emoji }}</span>
</div> </div>
<span class="text-sm">{{ item.label }} ({{ item.value }})</span> <span class="text-sm">{{ item.label }} ({{ item.calling_code }})</span>
</div> </div>
</template> </template>
<template #selected="{ selected }"> <template #selected="{ selected }">
<div class="flex items-center mb-1"> <div class="flex items-center gap-1">
<span v-if="selected" class="text-xl leading-none">{{ selected.emoji }}</span> <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> </div>
</template> </template>
</ComboBox> </ComboBox>
@@ -116,7 +121,8 @@ const userStore = useUserStore()
const allCountries = countries.map((country) => ({ const allCountries = countries.map((country) => ({
label: country.name, label: country.name,
value: country.calling_code, value: country.iso_2,
emoji: country.emoji emoji: country.emoji,
calling_code: country.calling_code
})) }))
</script> </script>

View File

@@ -33,13 +33,7 @@
/> />
</div> </div>
<div class="flex justify-end space-x-3 pt-2"> <div class="flex justify-end space-x-3 pt-2">
<Button <Button variant="outline" @click="cancelAddNote"> Cancel </Button>
variant="outline"
@click="cancelAddNote"
class="transition-all hover:bg-gray-100"
>
Cancel
</Button>
<Button type="submit" :disabled="!newNote.trim()"> <Button type="submit" :disabled="!newNote.trim()">
{{ $t('globals.messages.save') + ' ' + $t('globals.terms.note').toLowerCase() }} {{ $t('globals.messages.save') + ' ' + $t('globals.terms.note').toLowerCase() }}
</Button> </Button>
@@ -53,13 +47,13 @@
<Card <Card
v-for="note in notes" v-for="note in notes"
:key="note.id" :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 --> <!-- 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 justify-between">
<div class="flex items-center space-x-3"> <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" /> <AvatarImage :src="note.avatar_url" />
<AvatarFallback> <AvatarFallback>
{{ getInitials(note.first_name, note.last_name) }} {{ getInitials(note.first_name, note.last_name) }}

View File

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

View File

@@ -1,5 +1,106 @@
<template> <template>
<div class="h-screen w-full flex items-center justify-center min-w-[400px]"> <div class="placeholder-container">
<p>{{ $t('conversation.placeholder') }}</p> <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> </div>
</template> </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> </DialogTitle>
<DialogDescription/> <DialogDescription />
</DialogHeader> </DialogHeader>
<form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden"> <form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden">
<!-- Form Fields Section --> <!-- Form Fields Section -->
@@ -263,6 +263,7 @@ import { useFileUpload } from '@/composables/useFileUpload'
import Editor from '@/components/editor/TextEditor.vue' import Editor from '@/components/editor/TextEditor.vue'
import { useMacroStore } from '@/stores/macro' import { useMacroStore } from '@/stores/macro'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue' import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import { UserTypeAgent } from '@/constants/user'
import api from '@/api' import api from '@/api'
const dialogOpen = defineModel({ const dialogOpen = defineModel({
@@ -393,12 +394,14 @@ const selectContact = (contact) => {
const createConversation = form.handleSubmit(async (values) => { const createConversation = form.handleSubmit(async (values) => {
loading.value = true loading.value = true
try { 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.inbox_id = Number(values.inbox_id)
values.team_id = values.team_id ? Number(values.team_id) : null values.team_id = values.team_id ? Number(values.team_id) : null
values.agent_id = values.agent_id ? Number(values.agent_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) 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 conversation = await api.createConversation(values)
const conversationUUID = conversation.data.data.uuid const conversationUUID = conversation.data.data.uuid

View File

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

View File

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

View File

@@ -58,6 +58,7 @@ import { ViewVerticalIcon } from '@radix-icons/vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Mail, Phone, ExternalLink } from 'lucide-vue-next' import { Mail, Phone, ExternalLink } from 'lucide-vue-next'
import countries from '@/constants/countries.js'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
@@ -72,8 +73,13 @@ const { t } = useI18n()
const userStore = useUserStore() const userStore = useUserStore()
const phoneNumber = computed(() => { 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') 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> </script>

View File

@@ -8,7 +8,7 @@
> >
{{ $t('conversation.sidebar.noPreviousConvo') }} {{ $t('conversation.sidebar.noPreviousConvo') }}
</div> </div>
<div v-else class="space-y-3"> <div v-else class="space-y-1">
<router-link <router-link
v-for="conversation in conversationStore.current.previous_conversations" v-for="conversation in conversationStore.current.previous_conversations"
:key="conversation.uuid" :key="conversation.uuid"
@@ -30,9 +30,31 @@
{{ conversation.last_message }} {{ conversation.last_message }}
</span> </span>
</div> </div>
<span class="text-xs text-muted-foreground" v-if="conversation.last_message_at"> <Tooltip>
{{ format(new Date(conversation.last_message_at), 'h') + ' h' }} <TooltipTrigger asChild>
</span> <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> </div>
</router-link> </router-link>
</div> </div>
@@ -40,7 +62,8 @@
<script setup> <script setup>
import { useConversationStore } from '@/stores/conversation' 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() const conversationStore = useConversationStore()
</script> </script>

View File

@@ -83,6 +83,7 @@ import { handleHTTPError } from '@/utils/http'
import { OPERATOR } from '@/constants/filterConfig.js' import { OPERATOR } from '@/constants/filterConfig.js'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { z } from 'zod' import { z } from 'zod'
import { FIELD_TYPE } from '@/constants/filterConfig'
import api from '@/api' import api from '@/api'
const emitter = useEmitter() const emitter = useEmitter()
@@ -106,68 +107,88 @@ const formSchema = toTypedSchema(
z.object({ z.object({
id: z.number().optional(), id: z.number().optional(),
name: z name: z
.string() .string({
required_error: t('globals.messages.required')
})
.min(2, { message: t('view.form.name.length') }) .min(2, { message: t('view.form.name.length') })
.max(30, { message: t('view.form.name.length') }), .max(30, { message: t('view.form.name.length') }),
filters: z filters: z
.array( .array(
z.object({ z.object({
model: z.string({ model: z.string().optional(),
required_error: t('globals.messages.required', { field: z.string().optional(),
name: t('globals.terms.filter').toLowerCase() operator: z.string().optional(),
}) value: z
}), .union([
field: z.string({ z.string(),
required_error: t('globals.messages.required', { z.number(),
name: t('globals.terms.field').toLowerCase() z.boolean(),
}) z.array(z.union([z.string(), z.number()]))
}), ])
operator: z.string({ .optional()
required_error: t('globals.messages.required', {
name: t('globals.terms.operator').toLowerCase()
})
}),
value: z.union([z.string(), z.number(), z.boolean()]).optional()
}) })
) )
.default([]) .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({ const form = useForm({
validationSchema: formSchema, validationSchema: formSchema
validateOnMount: false,
validateOnInput: false,
validateOnBlur: false
}) })
const onSubmit = async () => { const onSubmit = form.handleSubmit(async (values) => {
const validationResult = await form.validate()
if (!validationResult.valid) return
if (isSubmitting.value) return 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 isSubmitting.value = true
try { 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) { if (values.id) {
await api.updateView(values.id, values) await api.updateView(values.id, values)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.updatedSuccessfully', {
name: t('globals.terms.view')
})
})
} else { } else {
await api.createView(values) 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' }) emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
openDialog.value = false openDialog.value = false
@@ -180,14 +201,36 @@ const onSubmit = async () => {
} finally { } finally {
isSubmitting.value = false isSubmitting.value = false
} }
} })
// Set form values when view prop changes // Set form values when view prop changes
watch( watch(
() => view.value, () => view.value,
(newVal) => { (newVal) => {
if (newVal && Object.keys(newVal).length) { 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 } { immediate: true }

View File

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

View File

@@ -1,12 +1,35 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import api from '@/api'
export const useAppSettingsStore = defineStore('settings', { export const useAppSettingsStore = defineStore('settings', {
state: () => ({ state: () => ({
settings: {} settings: {},
public_config: {}
}), }),
actions: { 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) { setSettings (newSettings) {
this.settings = newSettings this.settings = newSettings
},
setPublicConfig (newPublicConfig) {
this.public_config = newPublicConfig
} }
} }
}) })

View File

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

View File

@@ -5,6 +5,7 @@ import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents' import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import api from '@/api' import api from '@/api'
// TODO: rename this store to agents
export const useUsersStore = defineStore('users', () => { export const useUsersStore = defineStore('users', () => {
const users = ref([]) const users = ref([])
const emitter = useEmitter() const emitter = useEmitter()
@@ -13,8 +14,8 @@ export const useUsersStore = defineStore('users', () => {
value: String(user.id), value: String(user.id),
avatar_url: user.avatar_url, avatar_url: user.avatar_url,
}))) })))
const fetchUsers = async () => { const fetchUsers = async (force = false) => {
if (users.value.length) return if (!force && users.value.length) return
try { try {
const response = await api.getUsersCompact() const response = await api.getUsersCompact()
users.value = response?.data?.data || [] 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()) { export function getRelativeTime (timestamp, now = new Date()) {
try { try {
const mins = differenceInMinutes(now, timestamp) const mins = differenceInMinutes(now, timestamp)
const hours = differenceInHours(now, timestamp) const hours = differenceInHours(now, timestamp)
const days = differenceInDays(now, timestamp) const days = differenceInDays(now, timestamp)
const years = differenceInYears(now, timestamp)
if (mins === 0) return 'Just now' if (mins === 0) return 'now'
if (mins < 60) return `${mins} mins ago` if (mins < 60) return `${mins}m`
if (hours < 24) return `${hours} hrs ago` if (hours < 24) return `${hours}h`
if (days < 7) return `${days} days ago` if (days < 365) return `${days}d`
return format(timestamp, 'MMMM d, yyyy h:mm a') return `${years}y`
} catch (error) { } catch (error) {
console.error('Error parsing time', error, 'timestamp', timestamp) console.error('Error parsing time', error, 'timestamp', timestamp)
return '' return ''

View File

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

View File

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

View File

@@ -32,11 +32,13 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { format } from 'date-fns' import { format } from 'date-fns'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { useInboxStore } from '@/stores/inbox'
import api from '@/api' import api from '@/api'
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
const emitter = useEmitter() const emitter = useEmitter()
const inboxStore = useInboxStore()
const isLoading = ref(false) const isLoading = ref(false)
const data = ref([]) const data = ref([])
@@ -47,8 +49,8 @@ onMounted(async () => {
const getInboxes = async () => { const getInboxes = async () => {
try { try {
isLoading.value = true isLoading.value = true
const response = await api.getInboxes() await inboxStore.fetchInboxes(true)
data.value = response.data.data data.value = inboxStore.inboxes
} catch (error) { } catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive', variant: 'destructive',
@@ -67,7 +69,7 @@ const columns = [
return h('div', { class: 'text-center' }, t('globals.terms.name')) return h('div', { class: 'text-center' }, t('globals.terms.name'))
}, },
cell: function ({ row }) { 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')) return h('div', { class: 'text-center' }, t('globals.terms.channel'))
}, },
cell: function ({ row }) { 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> <template #help>
<p>Configure single sign-on with one or more OpenID Connect providers.</p> <p>Configure single sign-on with one or more OpenID Connect providers.</p>
<a <a
href="https://libredesk.io/docs/sso/" href="https://docs.libredesk.io/configuration/sso"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="link-style" class="link-style"

View File

@@ -49,7 +49,7 @@
<p>Design templates for customer communications and responses.</p> <p>Design templates for customer communications and responses.</p>
<p>Modify content for internal and external emails.</p> <p>Modify content for internal and external emails.</p>
<a <a
href="https://libredesk.io/docs/templating/" href="https://docs.libredesk.io/configuration/email-templates"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="link-style" 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>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> <p>Webhooks allow you to integrate Libredesk with external services by sending HTTP POST requests when specific events happen.</p>
<a <a
href="https://libredesk.io/docs/webhooks/" href="https://docs.libredesk.io/configuration/webhooks"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="link-style" class="link-style"

View File

@@ -9,7 +9,7 @@
<CardContent class="p-6 space-y-6"> <CardContent class="p-6 space-y-6">
<div class="space-y-2 text-center"> <div class="space-y-2 text-center">
<CardTitle class="text-3xl font-bold text-foreground"> <CardTitle class="text-3xl font-bold text-foreground">
{{ appSettingsStore.settings?.['app.site_name'] || 'Libredesk' }} {{ appSettingsStore.public_config?.['app.site_name'] || 'LIBREDESK' }}
</CardTitle> </CardTitle>
<p class="text-muted-foreground">{{ t('auth.signIn') }}</p> <p class="text-muted-foreground">{{ t('auth.signIn') }}</p>
</div> </div>
@@ -25,9 +25,8 @@
> >
<img <img
:src="oidcProvider.logo_url" :src="oidcProvider.logo_url"
:alt="oidcProvider.name"
width="20" width="20"
class="mr-2"
alt=""
v-if="oidcProvider.logo_url" v-if="oidcProvider.logo_url"
/> />
{{ oidcProvider.name }} {{ oidcProvider.name }}
@@ -89,7 +88,9 @@
type="submit" type="submit"
> >
<span v-if="isLoading" class="flex items-center justify-center"> <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') }} {{ t('auth.loggingIn') }}
</span> </span>
<span v-else>{{ t('auth.signInButton') }}</span> <span v-else>{{ t('auth.signInButton') }}</span>
@@ -159,8 +160,10 @@ onMounted(async () => {
const fetchOIDCProviders = async () => { const fetchOIDCProviders = async () => {
try { try {
const resp = await api.getAllEnabledOIDC() const config = appSettingsStore.public_config
oidcProviders.value = resp.data.data if (config && config['app.sso_providers']) {
oidcProviders.value = config['app.sso_providers'] || []
}
} catch (error) { } catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive', variant: 'destructive',
@@ -204,6 +207,9 @@ const loginAction = () => {
if (resp?.data?.data) { if (resp?.data?.data) {
userStore.setCurrentUser(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' }) router.push({ name: 'inboxes' })
}) })
.catch((error) => { .catch((error) => {

View File

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

3
go.mod
View File

@@ -7,6 +7,7 @@ require (
github.com/coreos/go-oidc/v3 v3.11.0 github.com/coreos/go-oidc/v3 v3.11.0
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/emersion/go-imap/v2 v2.0.0-beta.3 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/fasthttp/websocket v1.5.9
github.com/ferluci/fast-realip v1.0.1 github.com/ferluci/fast-realip v1.0.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
@@ -49,7 +50,6 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
github.com/fasthttp/router v1.5.0 // indirect github.com/fasthttp/router v1.5.0 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect
@@ -70,6 +70,7 @@ require (
github.com/rivo/uniseg v0.4.4 // indirect github.com/rivo/uniseg v0.4.4 // indirect
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // 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 github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/image v0.18.0 // indirect golang.org/x/image v0.18.0 // indirect
golang.org/x/net v0.40.0 // indirect golang.org/x/net v0.40.0 // indirect

2
go.sum
View File

@@ -157,6 +157,8 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= 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.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.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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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.usage": "Usage",
"globals.terms.createdAt": "Created At", "globals.terms.createdAt": "Created At",
"globals.terms.updatedAt": "Updated At", "globals.terms.updatedAt": "Updated At",
"globals.terms.lastMessageAt": "Last message at",
"globals.terms.pickDate": "Pick a date", "globals.terms.pickDate": "Pick a date",
"globals.terms.time": "Time", "globals.terms.time": "Time",
"globals.terms.listValues": "List values", "globals.terms.listValues": "List values",
@@ -188,6 +189,7 @@
"globals.terms.recipient": "Recipient | Recipients", "globals.terms.recipient": "Recipient | Recipients",
"globals.terms.tls": "TLS | TLSs", "globals.terms.tls": "TLS | TLSs",
"globals.terms.credential": "Credential | Credentials", "globals.terms.credential": "Credential | Credentials",
"globals.messages.welcomeToLibredesk": "Welcome to Libredesk",
"globals.messages.invalid": "Invalid {name}", "globals.messages.invalid": "Invalid {name}",
"globals.messages.custom": "Custom {name}", "globals.messages.custom": "Custom {name}",
"globals.messages.replying": "Replying", "globals.messages.replying": "Replying",
@@ -294,6 +296,8 @@
"globals.messages.submit": "Submit", "globals.messages.submit": "Submit",
"globals.messages.send": "Send {name}", "globals.messages.send": "Send {name}",
"globals.messages.update": "Update {name}", "globals.messages.update": "Update {name}",
"globals.messages.setUp": "Set up",
"globals.messages.invite": "Invite",
"globals.messages.enable": "Enable", "globals.messages.enable": "Enable",
"globals.messages.disable": "Disable", "globals.messages.disable": "Disable",
"globals.messages.block": "Block {name}", "globals.messages.block": "Block {name}",
@@ -306,6 +310,12 @@
"globals.messages.reset": "Reset {name}", "globals.messages.reset": "Reset {name}",
"globals.messages.lastNItems": "Last {n} {name} | Last {n} {name}", "globals.messages.lastNItems": "Last {n} {name} | Last {n} {name}",
"globals.messages.correctEmailErrors": "Please correct the email errors", "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.min": "Must be at least {min} characters",
"form.error.max": "Must be at most {max} characters", "form.error.max": "Must be at most {max} characters",
"form.error.minmax": "Must be between {min} and {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.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", "conversationStatus.cannotUpdateDefault": "Cannot update default conversation status",
"csat.alreadySubmitted": "CSAT already submitted", "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.csrfTokenMismatch": "CSRF token mismatch",
"auth.invalidOrExpiredSession": "Invalid or expired session", "auth.invalidOrExpiredSession": "Invalid or expired session",
"auth.invalidOrExpiredSessionClearCookie": "Invalid or expired session. Please clear your cookies and try again.", "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.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.maxAllowedFileUploadSize.valid": "Max allowed file upload size should be between 1 and 500 MB",
"admin.general.allowedFileUploadExtensions": "Allowed File Upload Extensions", "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.unauthorized": "You do not have permission to view business hours.",
"admin.businessHours.setBusinessHours": "Set business hours", "admin.businessHours.setBusinessHours": "Set business hours",
"admin.businessHours.alwaysOpen24x7": "Always open (24/7)", "admin.businessHours.alwaysOpen24x7": "Always open (24/7)",
@@ -483,6 +501,7 @@
"admin.role.conversations.updateTags": "Add or remove conversation tags", "admin.role.conversations.updateTags": "Add or remove conversation tags",
"admin.role.messages.read": "View conversation messages", "admin.role.messages.read": "View conversation messages",
"admin.role.messages.write": "Send messages in conversations", "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.view.manage": "Create and manage conversation views",
"admin.role.generalSettings.manage": "Manage General Settings", "admin.role.generalSettings.manage": "Manage General Settings",
"admin.role.notificationSettings.manage": "Manage Notification Settings", "admin.role.notificationSettings.manage": "Manage Notification Settings",
@@ -508,12 +527,13 @@
"admin.role.contactNotes.write": "Add Contact Notes", "admin.role.contactNotes.write": "Add Contact Notes",
"admin.role.contactNotes.delete": "Delete Contact Notes", "admin.role.contactNotes.delete": "Delete Contact Notes",
"admin.role.customAttributes.manage": "Manage Custom Attributes", "admin.role.customAttributes.manage": "Manage Custom Attributes",
"admin.role.webhooks.manage": "Manage Webhooks",
"admin.role.activityLog.manage": "Manage Activity Log", "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.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": "Conversation Update",
"admin.automation.conversationUpdate.description": "Rules that run when a conversation is updated.", "admin.automation.conversationUpdate.description": "Rules that run when a conversation is updated.",
"admin.automation.timeTriggers": "Time Triggers", "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.match": "Match",
"admin.automation.any": "ANY", "admin.automation.any": "ANY",
"admin.automation.all": "ALL", "admin.automation.all": "ALL",
@@ -533,6 +553,7 @@
"admin.automation.event.message.incoming": "Incoming message", "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.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.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.outgoingEmailTemplates": "Outgoing email templates",
"admin.template.emailNotificationTemplates": "Email notification templates", "admin.template.emailNotificationTemplates": "Email notification templates",
"admin.template.makeSureTemplateHasContent": "Make sure the template has {content} only once.", "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.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.alreadyExistsWithEmail": "Another contact with same email already exists",
"contact.notes.empty": "No notes yet", "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 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. // Login records a login event for the given user.
func (al *Manager) Login(userID int, email, ip string) error { func (al *Manager) Login(userID int, email, ip string) error {
return al.Create( return al.create(
models.AgentLogin, models.AgentLogin,
fmt.Sprintf("%s (#%d) logged in", email, userID), fmt.Sprintf("%s (#%d) logged in", email, userID),
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. // Logout records a logout event for the given user.
func (al *Manager) Logout(userID int, email, ip string) error { func (al *Manager) Logout(userID int, email, ip string) error {
return al.Create( return al.create(
models.AgentLogout, models.AgentLogout,
fmt.Sprintf("%s (#%d) logged out", email, userID), fmt.Sprintf("%s (#%d) logged out", email, userID),
userID, userID,
@@ -123,7 +114,7 @@ func (al *Manager) Away(actorID int, actorEmail, ip string, targetID int, target
} else { } else {
description = fmt.Sprintf("%s (#%d) is away", actorEmail, actorID) description = fmt.Sprintf("%s (#%d) is away", actorEmail, actorID)
} }
return al.Create( return al.create(
models.AgentAway, /* activity type*/ models.AgentAway, /* activity type*/
description, description,
actorID, /*actor_id*/ actorID, /*actor_id*/
@@ -141,7 +132,7 @@ func (al *Manager) AwayReassigned(actorID int, actorEmail, ip string, targetID i
} else { } else {
description = fmt.Sprintf("%s (#%d) is away and reassigning", actorEmail, actorID) description = fmt.Sprintf("%s (#%d) is away and reassigning", actorEmail, actorID)
} }
return al.Create( return al.create(
models.AgentAwayReassigned, /* activity type*/ models.AgentAwayReassigned, /* activity type*/
description, description,
actorID, /*actor_id*/ actorID, /*actor_id*/
@@ -159,7 +150,7 @@ func (al *Manager) Online(actorID int, actorEmail, ip string, targetID int, targ
} else { } else {
description = fmt.Sprintf("%s (#%d) is online", actorEmail, actorID) description = fmt.Sprintf("%s (#%d) is online", actorEmail, actorID)
} }
return al.Create( return al.create(
models.AgentOnline, /* activity type*/ models.AgentOnline, /* activity type*/
description, description,
actorID, /*actor_id*/ actorID, /*actor_id*/
@@ -190,6 +181,16 @@ func (al *Manager) UserAvailability(actorID int, actorEmail, status, ip, targetE
return nil 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. // 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) { func (m *Manager) makeQuery(page, pageSize int, order, orderBy, filtersJSON string) (string, []any, error) {
var ( var (

View File

@@ -2,10 +2,10 @@
SELECT id, name, provider, config, is_default FROM ai_providers where is_default is true; SELECT id, name, provider, config, is_default FROM ai_providers where is_default is true;
-- name: get-prompt -- 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 -- 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 -- name: set-openai-key
UPDATE ai_providers 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. // EnforceMediaAccess checks for read access on linked model to media.
func (e *Enforcer) EnforceMediaAccess(user umodels.User, model string) (bool, error) { func (e *Enforcer) EnforceMediaAccess(user umodels.User, model string) (bool, error) {
switch model { switch model {
// TODO: Pick this table / model name from the package/models/models.go
case "messages": case "messages":
allowed, err := e.Enforce(user, model, "read") allowed, err := e.Enforce(user, model, "read")
if err != nil { if err != nil {

View File

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

View File

@@ -33,7 +33,7 @@ type conversationStore interface {
type teamStore interface { type teamStore interface {
GetAll() ([]tmodels.Team, error) GetAll() ([]tmodels.Team, error)
GetMembers(teamID int) ([]umodels.User, error) GetMembers(teamID int) ([]tmodels.TeamMember, error)
} }
// Engine represents a manager for assigning unassigned conversations // 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 { for _, ruleValue := range ruleValues {
// Normalize rule value by collapsing multiple spaces // Normalize rule value by collapsing multiple spaces
normalizedRuleValue := strings.Join(strings.Fields(ruleValue), " ") normalizedRuleValue := strings.Join(strings.Fields(ruleValue), " ")
if strings.Contains(
strings.ToLower(normalizedInputText), // Respect CaseSensitiveMatch flag
strings.ToLower(normalizedRuleValue), if rule.CaseSensitiveMatch {
) { if strings.Contains(normalizedInputText, normalizedRuleValue) {
conditionMet = true conditionMet = true
break break
}
} else {
if strings.Contains(
strings.ToLower(normalizedInputText),
strings.ToLower(normalizedRuleValue),
) {
conditionMet = true
break
}
} }
} }
case models.RuleOperatorNotContains: case models.RuleOperatorNotContains:
@@ -249,12 +258,21 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
for _, ruleValue := range ruleValues { for _, ruleValue := range ruleValues {
// Normalize rule value by collapsing multiple spaces // Normalize rule value by collapsing multiple spaces
normalizedRuleValue := strings.Join(strings.Fields(ruleValue), " ") normalizedRuleValue := strings.Join(strings.Fields(ruleValue), " ")
if strings.Contains(
strings.ToLower(normalizedInputText), // Respect CaseSensitiveMatch flag
strings.ToLower(normalizedRuleValue), if rule.CaseSensitiveMatch {
) { if strings.Contains(normalizedInputText, normalizedRuleValue) {
conditionMet = false conditionMet = false
break break
}
} else {
if strings.Contains(
strings.ToLower(normalizedInputText),
strings.ToLower(normalizedRuleValue),
) {
conditionMet = false
break
}
} }
} }
case models.RuleOperatorSet: 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; from automation_rules where enabled is TRUE ORDER BY weight ASC;
-- name: get-all -- 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 -- 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 -- name: update-rule
INSERT INTO automation_rules(id, name, description, type, events, rules, enabled) INSERT INTO automation_rules(id, name, description, type, events, rules, enabled)

View File

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

View File

@@ -200,7 +200,7 @@ type queries struct {
GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"` GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"`
GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"` GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"`
GetConversations string `query:"get-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"` GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
GetUserActiveConversationsCount *sqlx.Stmt `query:"get-user-active-conversations-count"` GetUserActiveConversationsCount *sqlx.Stmt `query:"get-user-active-conversations-count"`
UpdateConversationFirstReplyAt *sqlx.Stmt `query:"update-conversation-first-reply-at"` 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 return conversation, nil
} }
// GetContactConversations retrieves conversations for a contact. // GetContactPreviousConversations retrieves previous conversations for a contact with a configurable limit.
func (c *Manager) GetContactConversations(contactID int) ([]models.Conversation, error) { func (c *Manager) GetContactPreviousConversations(contactID int, limit int) ([]models.PreviousConversation, error) {
var conversations = make([]models.Conversation, 0) var conversations = make([]models.PreviousConversation, 0)
if err := c.q.GetContactConversations.Select(&conversations, contactID); err != nil { if err := c.q.GetContactPreviousConversations.Select(&conversations, contactID, limit); err != nil {
c.lo.Error("error fetching conversations", "error", err) 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, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil)
} }
return conversations, 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. // 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) 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. // 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) 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. // 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) 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. // 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) 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) 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. // 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) { func (c *Manager) GetConversations(userID int, teamIDs []int, listTypes []string, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
var conversations = make([]models.Conversation, 0) var conversations = make([]models.ConversationListItem, 0)
// Make the query. // Make the query.
query, qArgs, err := c.makeConversationsListQuery(userID, teamIDs, listTypes, c.q.GetConversations, order, orderBy, page, pageSize, filters) 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? // Team changed?
if previousAssignedTeamID != teamID { 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) team, err := c.teamStore.Get(teamID)
if err != nil { if err != nil {
return nil return nil
@@ -930,7 +934,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
if err != nil { if err != nil {
return fmt.Errorf("making recipients for reply action: %w", err) return fmt.Errorf("making recipients for reply action: %w", err)
} }
_, err = m.SendReply( _, err = m.QueueReply(
[]mmodels.Media{}, []mmodels.Media{},
conv.InboxID, conv.InboxID,
user.ID, user.ID,
@@ -960,7 +964,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
return nil 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 { func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.User) error {
if _, err := m.q.RemoveConversationAssignee.Exec(uuid, typ); err != nil { if _, err := m.q.RemoveConversationAssignee.Exec(uuid, typ); err != nil {
m.lo.Error("error removing conversation assignee", "error", err) 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 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) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil)
} }
// Send CSAT reply. // Queue CSAT reply.
_, err = m.SendReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta) _, err = m.QueueReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta)
if err != nil { if err != nil {
m.lo.Error("error sending CSAT reply", "conversation_uuid", conversation.UUID, "error", err) 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) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil)
@@ -1081,6 +1093,35 @@ func (c *Manager) makeConversationsListQuery(userID int, teamIDs []int, listType
return "", nil, fmt.Errorf("no conversation list types specified") 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. // Prepare the conditions based on the list types.
conditions := []string{} conditions := []string{}
for _, lt := range listTypes { for _, lt := range listTypes {
@@ -1106,13 +1147,60 @@ func (c *Manager) makeConversationsListQuery(userID int, teamIDs []int, listType
} }
} }
// Build the base query with list type conditions
var whereClause string
if len(conditions) > 0 { if len(conditions) > 0 {
baseQuery = fmt.Sprintf(baseQuery, "AND ("+strings.Join(conditions, " OR ")+")") whereClause = "AND (" + strings.Join(conditions, " OR ") + ")"
} else {
// Replace the `%s` in the base query with an empty string.
baseQuery = fmt.Sprintf(baseQuery, "")
} }
// 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{ return dbutil.BuildPaginatedQuery(baseQuery, qArgs, dbutil.PaginationOptions{
Order: order, Order: order,
OrderBy: orderBy, 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. // 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. // Remove the current message ID from the references.
message.References = stringutil.RemoveItemByValue(message.References, message.SourceID.String) message.References = stringutil.RemoveItemByValue(message.References, message.SourceID.String)
@@ -347,9 +347,10 @@ func (m *Manager) UpdateMessageStatus(messageUUID string, status string) error {
return nil 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 { func (m *Manager) MarkMessageAsPending(uuid string) error {
if err := m.UpdateMessageStatus(uuid, models.MessageStatusPending); err != nil { 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 envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil)
} }
return nil return nil
@@ -374,8 +375,27 @@ func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversat
return message, nil return message, nil
} }
// SendReply inserts a reply message in a conversation. // CreateContactMessage creates a contact 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) { 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 ( var (
message = models.Message{} 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) 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) inbox, err := m.inboxStore.GetDBRecord(inboxID)
if err != nil { if err != nil {
return message, err return message, err
@@ -442,6 +462,11 @@ func (m *Manager) InsertMessage(message *models.Message) error {
message.Meta = json.RawMessage(`{}`) 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. // Convert HTML content to text for search.
message.TextContent = stringutil.HTML2Text(message.Content) message.TextContent = stringutil.HTML2Text(message.Content)

View File

@@ -52,52 +52,128 @@ var (
ContentTypeHTML = "html" 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 { type Conversation struct {
ID int `db:"id" json:"id,omitempty"` ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"` UUID string `db:"uuid" json:"uuid"`
ContactID int `db:"contact_id" json:"contact_id"` ContactID int `db:"contact_id" json:"contact_id"`
InboxID int `db:"inbox_id" json:"inbox_id,omitempty"` InboxID int `db:"inbox_id" json:"inbox_id"`
ClosedAt null.Time `db:"closed_at" json:"closed_at,omitempty"` ClosedAt null.Time `db:"closed_at" json:"closed_at"`
ResolvedAt null.Time `db:"resolved_at" json:"resolved_at,omitempty"` ResolvedAt null.Time `db:"resolved_at" json:"resolved_at"`
ReferenceNumber string `db:"reference_number" json:"reference_number,omitempty"` ReferenceNumber string `db:"reference_number" json:"reference_number"`
Priority null.String `db:"priority" json:"priority"` Priority null.String `db:"priority" json:"priority"`
PriorityID null.Int `db:"priority_id" json:"priority_id"` PriorityID null.Int `db:"priority_id" json:"priority_id"`
Status null.String `db:"status" json:"status"` Status null.String `db:"status" json:"status"`
StatusID null.Int `db:"status_id" json:"status_id"` StatusID null.Int `db:"status_id" json:"status_id"`
FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"` FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"`
LastReplyAt null.Time `db:"last_reply_at" json:"last_reply_at"` LastReplyAt null.Time `db:"last_reply_at" json:"last_reply_at"`
AssignedUserID null.Int `db:"assigned_user_id" json:"assigned_user_id"` AssignedUserID null.Int `db:"assigned_user_id" json:"assigned_user_id"`
AssignedTeamID null.Int `db:"assigned_team_id" json:"assigned_team_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"` AssigneeLastSeenAt null.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
WaitingSince null.Time `db:"waiting_since" json:"waiting_since"` WaitingSince null.Time `db:"waiting_since" json:"waiting_since"`
Subject null.String `db:"subject" json:"subject"` 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"`
InboxMail string `db:"inbox_mail" json:"inbox_mail"` InboxName string `db:"inbox_name" json:"inbox_name"`
InboxName string `db:"inbox_name" json:"inbox_name"` InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
InboxChannel string `db:"inbox_channel" json:"inbox_channel"` Tags null.JSON `db:"tags" json:"tags"`
Tags null.JSON `db:"tags" json:"tags"` Meta pq.StringArray `db:"meta" json:"meta"`
Meta pq.StringArray `db:"meta" json:"meta"` CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"` LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"` LastMessage null.String `db:"last_message" json:"last_message"`
LastMessage null.String `db:"last_message" json:"last_message"` LastMessageSender null.String `db:"last_message_sender" json:"last_message_sender"`
LastMessageSender null.String `db:"last_message_sender" json:"last_message_sender"` Contact ConversationContact `db:"contact" json:"contact"`
Contact umodels.User `db:"contact" json:"contact"` SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"` SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"`
SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"` AppliedSLAID null.Int `db:"applied_sla_id" json:"applied_sla_id"`
AppliedSLAID null.Int `db:"applied_sla_id" json:"applied_sla_id"` FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"`
NextSLADeadlineAt null.Time `db:"next_sla_deadline_at" json:"next_sla_deadline_at"` ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"`
FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"` NextResponseDueAt null.Time `db:"next_response_deadline_at" json:"next_response_deadline_at"`
ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"` NextResponseMetAt null.Time `db:"next_response_met_at" json:"next_response_met_at"`
NextResponseDueAt null.Time `db:"next_response_deadline_at" json:"next_response_deadline_at"` PreviousConversations []PreviousConversation `db:"-" json:"previous_conversations"`
NextResponseMetAt null.Time `db:"next_response_met_at" json:"next_response_met_at"` }
PreviousConversations []Conversation `db:"-" json:"previous_conversations"`
Total int `db:"total" json:"-"` 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 { type ConversationParticipant struct {
ID string `db:"id" json:"id"` ID int `db:"id" json:"id"`
FirstName string `db:"first_name" json:"first_name"` FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"` LastName string `db:"last_name" json:"last_name"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"` AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
@@ -117,13 +193,15 @@ type NewConversationsStats struct {
// Message represents a message in a conversation // Message represents a message in a conversation
type Message struct { 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"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"` UUID string `db:"uuid" json:"uuid"`
Type string `db:"type" json:"type"` Type string `db:"type" json:"type"`
Status string `db:"status" json:"status"` Status string `db:"status" json:"status"`
ConversationID int `db:"conversation_id" json:"conversation_id"` ConversationID int `db:"conversation_id" json:"conversation_id"`
ConversationUUID string `db:"conversation_uuid" json:"conversation_uuid"`
Content string `db:"content" json:"content"` Content string `db:"content" json:"content"`
TextContent string `db:"text_content" json:"text_content"` TextContent string `db:"text_content" json:"text_content"`
ContentType string `db:"content_type" json:"content_type"` ContentType string `db:"content_type" json:"content_type"`
@@ -134,7 +212,6 @@ type Message struct {
InboxID int `db:"inbox_id" json:"-"` InboxID int `db:"inbox_id" json:"-"`
Meta json.RawMessage `db:"meta" json:"meta"` Meta json.RawMessage `db:"meta" json:"meta"`
Attachments attachment.Attachments `db:"attachments" json:"attachments"` Attachments attachment.Attachments `db:"attachments" json:"attachments"`
ConversationUUID string `db:"conversation_uuid" json:"-"`
From string `db:"from" json:"-"` From string `db:"from" json:"-"`
Subject string `db:"subject" json:"-"` Subject string `db:"subject" json:"-"`
Channel string `db:"channel" json:"-"` Channel string `db:"channel" json:"-"`
@@ -144,10 +221,9 @@ type Message struct {
References []string `json:"-"` References []string `json:"-"`
InReplyTo string `json:"-"` InReplyTo string `json:"-"`
Headers textproto.MIMEHeader `json:"-"` Headers textproto.MIMEHeader `json:"-"`
AltContent string `db:"-" json:"-"` AltContent string `json:"-"`
Media []mmodels.Media `db:"-" json:"-"` Media []mmodels.Media `json:"-"`
IsCSAT bool `db:"-" json:"-"` IsCSAT bool `json:"-"`
Total int `db:"total" json:"-"`
} }
// CensorCSATContent redacts the content of a CSAT message to prevent leaking the CSAT survey public link. // 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.closed_at,
c.resolved_at, c.resolved_at,
c.inbox_id, c.inbox_id,
c.assignee_last_seen_at,
inb.name as inbox_name,
COALESCE(inb.from, '') as inbox_mail, COALESCE(inb.from, '') as inbox_mail,
COALESCE(inb.channel::TEXT, '') as inbox_channel, COALESCE(inb.channel::TEXT, '') as inbox_channel,
c.status_id, c.status_id,
@@ -138,9 +140,8 @@ SELECT
ct.availability_status as "contact.availability_status", ct.availability_status as "contact.availability_status",
ct.avatar_url as "contact.avatar_url", ct.avatar_url as "contact.avatar_url",
ct.phone_number as "contact.phone_number", 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.custom_attributes as "contact.custom_attributes",
ct.avatar_url as "contact.avatar_url",
ct.enabled as "contact.enabled", ct.enabled as "contact.enabled",
ct.last_active_at as "contact.last_active_at", ct.last_active_at as "contact.last_active_at",
ct.last_login_at as "contact.last_login_at", ct.last_login_at as "contact.last_login_at",
@@ -183,8 +184,11 @@ SELECT
FROM conversations c FROM conversations c
WHERE c.created_at > $1; WHERE c.created_at > $1;
-- name: get-contact-conversations -- name: get-contact-previous-conversations
SELECT SELECT
c.id,
c.created_at,
c.updated_at,
c.uuid, c.uuid,
u.first_name AS "contact.first_name", u.first_name AS "contact.first_name",
u.last_name AS "contact.last_name", u.last_name AS "contact.last_name",
@@ -195,7 +199,7 @@ FROM users u
JOIN conversations c ON c.contact_id = u.id JOIN conversations c ON c.contact_id = u.id
WHERE c.contact_id = $1 WHERE c.contact_id = $1
ORDER BY c.created_at DESC ORDER BY c.created_at DESC
LIMIT 10; LIMIT $2;
-- name: get-conversation-uuid -- name: get-conversation-uuid
SELECT uuid from conversations where id = $1; SELECT uuid from conversations where id = $1;
@@ -349,6 +353,7 @@ WHERE uuid = $1;
UPDATE conversations UPDATE conversations
SET SET
assigned_user_id = CASE WHEN $2 = 'user' THEN NULL ELSE assigned_user_id END, 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, assigned_team_id = CASE WHEN $2 = 'team' THEN NULL ELSE assigned_team_id END,
updated_at = NOW() updated_at = NOW()
WHERE uuid = $1; WHERE uuid = $1;
@@ -400,22 +405,27 @@ LIMIT $2;
-- name: get-outgoing-pending-messages -- name: get-outgoing-pending-messages
SELECT SELECT
m.created_at,
m.id, m.id,
m.uuid, m.created_at,
m.sender_id, m.updated_at,
m.type,
m.private,
m.status, m.status,
m.type,
m.content, m.content,
m.text_content,
m.content_type,
m.conversation_id, m.conversation_id,
m.uuid,
m.private,
m.sender_type,
m.sender_id,
m.meta,
c.uuid as conversation_uuid,
m.content_type, m.content_type,
m.source_id, m.source_id,
ARRAY(SELECT jsonb_array_elements_text(m.meta->'cc')) AS cc, 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->'bcc')) AS bcc,
ARRAY(SELECT jsonb_array_elements_text(m.meta->'to')) AS to, ARRAY(SELECT jsonb_array_elements_text(m.meta->'to')) AS to,
c.inbox_id, c.inbox_id,
c.uuid as conversation_uuid,
c.subject c.subject
FROM conversation_messages m FROM conversation_messages m
INNER JOIN conversations c ON c.id = m.conversation_id INNER JOIN conversations c ON c.id = m.conversation_id
@@ -463,16 +473,21 @@ ORDER BY m.created_at;
-- name: get-messages -- name: get-messages
SELECT SELECT
COUNT(*) OVER() AS total, COUNT(*) OVER() AS total,
m.id,
m.created_at, m.created_at,
m.updated_at, m.updated_at,
m.status, m.status,
m.type, m.type,
m.content, m.content,
m.text_content,
m.content_type,
m.conversation_id,
m.uuid, m.uuid,
m.private, m.private,
m.sender_id, m.sender_id,
m.sender_type, m.sender_type,
m.meta, m.meta,
$1::uuid AS conversation_uuid,
COALESCE( COALESCE(
(SELECT json_agg( (SELECT json_agg(
json_build_object( json_build_object(

View File

@@ -93,7 +93,7 @@ func (m *Manager) UpdateResponse(uuid string, score int, feedback string) error
return err 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) return envelope.NewError(envelope.InputError, m.i18n.T("csat.alreadySubmitted"), nil)
} }

View File

@@ -10,11 +10,11 @@ import (
// CSATResponse represents a customer satisfaction survey response. // CSATResponse represents a customer satisfaction survey response.
type CSATResponse struct { type CSATResponse struct {
ID int `db:"id"` ID int `db:"id"`
UUID string `db:"uuid"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
UUID string `db:"uuid"`
ConversationID int `db:"conversation_id"` ConversationID int `db:"conversation_id"`
Score int `db:"rating"` Rating int `db:"rating"`
Feedback null.String `db:"feedback"` Feedback null.String `db:"feedback"`
ResponseTimestamp null.Time `db:"response_timestamp"` ResponseTimestamp null.Time `db:"response_timestamp"`
} }

View File

@@ -10,9 +10,9 @@ type CustomAttribute struct {
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
AppliesTo string `db:"applies_to" json:"applies_to"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"` Description string `db:"description" json:"description"`
AppliesTo string `db:"applies_to" json:"applies_to"`
Key string `db:"key" json:"key"` Key string `db:"key" json:"key"`
Values pq.StringArray `db:"values" json:"values"` Values pq.StringArray `db:"values" json:"values"`
DataType string `db:"data_type" json:"data_type"` DataType string `db:"data_type" json:"data_type"`

View File

@@ -3,9 +3,9 @@ SELECT
id, id,
created_at, created_at,
updated_at, updated_at,
applies_to,
name, name,
description, description,
applies_to,
key, key,
values, values,
data_type, data_type,
@@ -25,9 +25,9 @@ SELECT
id, id,
created_at, created_at,
updated_at, updated_at,
applies_to,
name, name,
description, description,
applies_to,
key, key,
values, values,
data_type, data_type,

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