mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-11-04 05:53:30 +00:00 
			
		
		
		
	Compare commits
	
		
			62 Commits
		
	
	
		
			v0.7.0-alp
			...
			v0.7.4-alp
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					d63302843b | ||
| 
						 | 
					a652f380b2 | ||
| 
						 | 
					a4a9a9ccd3 | ||
| 
						 | 
					71865e389e | ||
| 
						 | 
					ae470be4c8 | ||
| 
						 | 
					636742c34b | ||
| 
						 | 
					de77c03f66 | ||
| 
						 | 
					b7092744fd | ||
| 
						 | 
					6f300bb073 | ||
| 
						 | 
					a8ca12fb9a | ||
| 
						 | 
					e4bec993e6 | ||
| 
						 | 
					efc01be7d3 | ||
| 
						 | 
					ec72c5af90 | ||
| 
						 | 
					490417cf9d | ||
| 
						 | 
					4f54db3d1b | ||
| 
						 | 
					210b8bb53b | ||
| 
						 | 
					a0e1ccf117 | ||
| 
						 | 
					faf2082561 | ||
| 
						 | 
					50baa8491b | ||
| 
						 | 
					8e89e4e0d4 | ||
| 
						 | 
					b15413b7ca | ||
| 
						 | 
					701e5b2580 | ||
| 
						 | 
					dbd4e97f7e | ||
| 
						 | 
					007c332a7d | ||
| 
						 | 
					4fcad4fd81 | ||
| 
						 | 
					bece58bdec | ||
| 
						 | 
					6d2d8f78d4 | ||
| 
						 | 
					98492a1869 | ||
| 
						 | 
					18b50b11c8 | ||
| 
						 | 
					5a1628f710 | ||
| 
						 | 
					12ebe32ba3 | ||
| 
						 | 
					fce2587a9d | ||
| 
						 | 
					7d92ac9cce | ||
| 
						 | 
					3ce3c5e0ee | ||
| 
						 | 
					35ad00ec51 | ||
| 
						 | 
					9ec96be959 | ||
| 
						 | 
					6ca36d611f | ||
| 
						 | 
					5a87d24d72 | ||
| 
						 | 
					7d4e7e68c3 | ||
| 
						 | 
					5b941fd993 | ||
| 
						 | 
					63e348e512 | ||
| 
						 | 
					10a845dc81 | ||
| 
						 | 
					0228989202 | ||
| 
						 | 
					3f7d151d33 | ||
| 
						 | 
					a516773b14 | ||
| 
						 | 
					f6d3bd543f | ||
| 
						 | 
					074d147bb6 | ||
| 
						 | 
					c1c14f7f54 | ||
| 
						 | 
					634fc66e9f | ||
| 
						 | 
					0dec822c1c | ||
| 
						 | 
					958f5e38c0 | ||
| 
						 | 
					550a3fa801 | ||
| 
						 | 
					6bbfbe8cf6 | ||
| 
						 | 
					f9ed326d72 | ||
| 
						 | 
					e0dc0285a4 | ||
| 
						 | 
					b971619ea6 | ||
| 
						 | 
					69accaebef | ||
| 
						 | 
					27de73536e | ||
| 
						 | 
					df108a3363 | ||
| 
						 | 
					266c3dab72 | ||
| 
						 | 
					bf2c1fff6f | ||
| 
						 | 
					2930af0c4f | 
							
								
								
									
										16
									
								
								.github/ISSUE_TEMPLATE/confirmed-bug.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.github/ISSUE_TEMPLATE/confirmed-bug.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
---
 | 
			
		||||
name: Confirmed Bug Report
 | 
			
		||||
about: Report a confirmed bug in Libredesk
 | 
			
		||||
title: "[Bug] <brief summary>"
 | 
			
		||||
labels: bug
 | 
			
		||||
assignees: ""
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
**Version:**
 | 
			
		||||
- libredesk: [eg: v0.7.0]
 | 
			
		||||
 | 
			
		||||
**Description of the bug and steps to reproduce:**
 | 
			
		||||
A clear and concise description of what the bug is.
 | 
			
		||||
 | 
			
		||||
**Logs / Screenshots:**
 | 
			
		||||
Attach any relevant logs or screenshots to help diagnose the issue.
 | 
			
		||||
							
								
								
									
										16
									
								
								.github/ISSUE_TEMPLATE/possible-bug.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.github/ISSUE_TEMPLATE/possible-bug.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
---
 | 
			
		||||
name: Possible Bug Report
 | 
			
		||||
about: Something in Libredesk might be broken but needs confirmation
 | 
			
		||||
title: "[Possible Bug] <brief summary>"
 | 
			
		||||
labels: bug, needs-investigation
 | 
			
		||||
assignees: ""
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
**Version:**
 | 
			
		||||
 - libredesk: [eg: v0.7.0]
 | 
			
		||||
 
 | 
			
		||||
**Description of the bug and steps to reproduce:**
 | 
			
		||||
A clear and concise description of what the bug is.
 | 
			
		||||
 | 
			
		||||
**Logs / Screenshots:**
 | 
			
		||||
Attach any relevant logs or screenshots to help diagnose the issue.
 | 
			
		||||
							
								
								
									
										31
									
								
								.github/workflows/github-pages.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										31
									
								
								.github/workflows/github-pages.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,31 +0,0 @@
 | 
			
		||||
name: Deploy MkDocs
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches:
 | 
			
		||||
      - main
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  deploy:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
 | 
			
		||||
      - uses: actions/setup-python@v4
 | 
			
		||||
        with:
 | 
			
		||||
          python-version: 3.x
 | 
			
		||||
 | 
			
		||||
      - run: pip install mkdocs-material
 | 
			
		||||
 | 
			
		||||
      - run: |
 | 
			
		||||
          if [ -f requirements.txt ]; then
 | 
			
		||||
            pip install -r requirements.txt;
 | 
			
		||||
          fi
 | 
			
		||||
 | 
			
		||||
      - run: cd docs && mkdocs build
 | 
			
		||||
 | 
			
		||||
      - name: Deploy to GitHub Pages
 | 
			
		||||
        uses: peaceiris/actions-gh-pages@v3
 | 
			
		||||
        with:
 | 
			
		||||
          github_token: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          publish_dir: ./docs/site
 | 
			
		||||
							
								
								
									
										10
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								README.md
									
									
									
									
									
								
							@@ -3,9 +3,9 @@
 | 
			
		||||
 | 
			
		||||
# Libredesk
 | 
			
		||||
 | 
			
		||||
Open source, self-hosted customer support desk. Single binary app.
 | 
			
		||||
Modern, open source, self-hosted customer support desk. Single binary app. 
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
 | 
			
		||||
@@ -67,7 +67,7 @@ docker exec -it libredesk_app ./libredesk --set-system-user-password
 | 
			
		||||
 | 
			
		||||
Go to `http://localhost:9000` and login with username `System` and the password you set using the `--set-system-user-password` command.
 | 
			
		||||
 | 
			
		||||
See [installation docs](https://libredesk.io/docs/installation/)
 | 
			
		||||
See [installation docs](https://docs.libredesk.io/getting-started/installation)
 | 
			
		||||
 | 
			
		||||
__________________
 | 
			
		||||
 | 
			
		||||
@@ -78,12 +78,12 @@ __________________
 | 
			
		||||
- Run `./libredesk --set-system-user-password` to set the password for the System user.
 | 
			
		||||
- Run `./libredesk` and visit `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
 | 
			
		||||
 | 
			
		||||
See [installation docs](https://libredesk.io/docs/installation)
 | 
			
		||||
See [installation docs](https://docs.libredesk.io/getting-started/installation)
 | 
			
		||||
__________________
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Developers
 | 
			
		||||
If you are interested in contributing, refer to the [developer setup](https://libredesk.io/docs/developer-setup/). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
 | 
			
		||||
If you are interested in contributing, refer to the [developer setup](https://docs.libredesk.io/contributing/developer-setup). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
 | 
			
		||||
 | 
			
		||||
## Development Status
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										63
									
								
								cmd/config.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								cmd/config.go
									
									
									
									
									
										Normal 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)
 | 
			
		||||
}
 | 
			
		||||
@@ -103,9 +103,9 @@ func handleUpdateContact(r *fastglue.Request) error {
 | 
			
		||||
	if v, ok := form.Value["phone_number"]; ok && len(v) > 0 {
 | 
			
		||||
		phoneNumber = string(v[0])
 | 
			
		||||
	}
 | 
			
		||||
	phoneNumberCallingCode := ""
 | 
			
		||||
	if v, ok := form.Value["phone_number_calling_code"]; ok && len(v) > 0 {
 | 
			
		||||
		phoneNumberCallingCode = string(v[0])
 | 
			
		||||
	phoneNumberCountryCode := ""
 | 
			
		||||
	if v, ok := form.Value["phone_number_country_code"]; ok && len(v) > 0 {
 | 
			
		||||
		phoneNumberCountryCode = string(v[0])
 | 
			
		||||
	}
 | 
			
		||||
	avatarURL := ""
 | 
			
		||||
	if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 {
 | 
			
		||||
@@ -116,8 +116,8 @@ func handleUpdateContact(r *fastglue.Request) error {
 | 
			
		||||
	if avatarURL == "null" {
 | 
			
		||||
		avatarURL = ""
 | 
			
		||||
	}
 | 
			
		||||
	if phoneNumberCallingCode == "null" {
 | 
			
		||||
		phoneNumberCallingCode = ""
 | 
			
		||||
	if phoneNumberCountryCode == "null" {
 | 
			
		||||
		phoneNumberCountryCode = ""
 | 
			
		||||
	}
 | 
			
		||||
	if phoneNumber == "null" {
 | 
			
		||||
		phoneNumber = ""
 | 
			
		||||
@@ -146,7 +146,7 @@ func handleUpdateContact(r *fastglue.Request) error {
 | 
			
		||||
		Email:                  null.StringFrom(email),
 | 
			
		||||
		AvatarURL:              null.NewString(avatarURL, avatarURL != ""),
 | 
			
		||||
		PhoneNumber:            null.NewString(phoneNumber, phoneNumber != ""),
 | 
			
		||||
		PhoneNumberCallingCode: null.NewString(phoneNumberCallingCode, phoneNumberCallingCode != ""),
 | 
			
		||||
		PhoneNumberCountryCode: null.NewString(phoneNumberCountryCode, phoneNumberCountryCode != ""),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := app.user.UpdateContact(id, contactToUpdate); err != nil {
 | 
			
		||||
@@ -164,11 +164,17 @@ func handleUpdateContact(r *fastglue.Request) error {
 | 
			
		||||
	// Upload avatar?
 | 
			
		||||
	files, ok := form.File["files"]
 | 
			
		||||
	if ok && len(files) > 0 {
 | 
			
		||||
		if err := uploadUserAvatar(r, &contact, files); err != nil {
 | 
			
		||||
		if err := uploadUserAvatar(r, contact, files); err != nil {
 | 
			
		||||
			return sendErrorEnvelope(r, err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(true)
 | 
			
		||||
 | 
			
		||||
	// Refetch contact and return it
 | 
			
		||||
	contact, err = app.user.GetContact(id, "")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(contact)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleGetContactNotes returns all notes for a contact.
 | 
			
		||||
@@ -195,18 +201,21 @@ func handleCreateContactNote(r *fastglue.Request) error {
 | 
			
		||||
		auser        = r.RequestCtx.UserValue("user").(amodels.User)
 | 
			
		||||
		req          = createContactNoteReq{}
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	if err := r.Decode(&req, "json"); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(req.Note) == 0 {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "note"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
	if err := app.user.CreateNote(contactID, auser.ID, req.Note); err != nil {
 | 
			
		||||
	n, err := app.user.CreateNote(contactID, auser.ID, req.Note)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(true)
 | 
			
		||||
	n, err = app.user.GetNote(n.ID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(n)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleDeleteContactNote deletes a note for a contact.
 | 
			
		||||
@@ -240,6 +249,8 @@ func handleDeleteContactNote(r *fastglue.Request) error {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	app.lo.Info("deleting contact note", "note_id", noteID, "contact_id", contactID, "actor_id", auser.ID)
 | 
			
		||||
 | 
			
		||||
	if err := app.user.DeleteNote(noteID, contactID); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
@@ -251,6 +262,7 @@ func handleBlockContact(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app          = r.Context.(*App)
 | 
			
		||||
		contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
			
		||||
		auser        = r.RequestCtx.UserValue("user").(amodels.User)
 | 
			
		||||
		req          = blockContactReq{}
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
@@ -262,8 +274,15 @@ func handleBlockContact(r *fastglue.Request) error {
 | 
			
		||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	app.lo.Info("setting contact block status", "contact_id", contactID, "enabled", req.Enabled, "actor_id", auser.ID)
 | 
			
		||||
 | 
			
		||||
	if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, req.Enabled); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(true)
 | 
			
		||||
 | 
			
		||||
	contact, err := app.user.GetContact(contactID, "")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(contact)
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -49,6 +49,7 @@ type createConversationRequest struct {
 | 
			
		||||
	Subject         string `json:"subject"`
 | 
			
		||||
	Content         string `json:"content"`
 | 
			
		||||
	Attachments     []int  `json:"attachments"`
 | 
			
		||||
	Initiator       string `json:"initiator"` // "contact" | "agent"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleGetAllConversations retrieves all conversations.
 | 
			
		||||
@@ -273,8 +274,8 @@ func handleGetConversation(r *fastglue.Request) error {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	prev, _ := app.conversation.GetContactConversations(conv.ContactID)
 | 
			
		||||
	conv.PreviousConversations = filterCurrentConv(prev, conv.UUID)
 | 
			
		||||
	prev, _ := app.conversation.GetContactPreviousConversations(conv.ContactID, 10)
 | 
			
		||||
	conv.PreviousConversations = filterCurrentPreviousConv(prev, conv.UUID)
 | 
			
		||||
	return r.SendEnvelope(conv)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -649,14 +650,14 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
 | 
			
		||||
	return r.SendEnvelope(true)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// filterCurrentConv removes the current conversation from the list of conversations.
 | 
			
		||||
func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conversation {
 | 
			
		||||
// filterCurrentPreviousConv removes the current conversation from the list of previous conversations.
 | 
			
		||||
func filterCurrentPreviousConv(convs []cmodels.PreviousConversation, uuid string) []cmodels.PreviousConversation {
 | 
			
		||||
	for i, c := range convs {
 | 
			
		||||
		if c.UUID == uuid {
 | 
			
		||||
			return append(convs[:i], convs[i+1:]...)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return []cmodels.Conversation{}
 | 
			
		||||
	return []cmodels.PreviousConversation{}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleCreateConversation creates a new conversation and sends a message to it.
 | 
			
		||||
@@ -672,39 +673,17 @@ func handleCreateConversation(r *fastglue.Request) error {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Validate the request
 | 
			
		||||
	if err := validateCreateConversationRequest(req, app); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	to := []string{req.Email}
 | 
			
		||||
 | 
			
		||||
	// Validate required fields
 | 
			
		||||
	if req.InboxID <= 0 {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
	if req.Content == "" {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
	if req.Email == "" {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
	if req.FirstName == "" {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
	if !stringutil.ValidEmail(req.Email) {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	user, err := app.user.GetAgent(auser.ID, "")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if inbox exists and is enabled.
 | 
			
		||||
	inbox, err := app.inbox.GetDBRecord(req.InboxID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
	if !inbox.Enabled {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Find or create contact.
 | 
			
		||||
	contact := umodels.User{
 | 
			
		||||
		Email:           null.StringFrom(req.Email),
 | 
			
		||||
@@ -717,7 +696,7 @@ func handleCreateConversation(r *fastglue.Request) error {
 | 
			
		||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create conversation
 | 
			
		||||
	// Create conversation first.
 | 
			
		||||
	conversationID, conversationUUID, err := app.conversation.CreateConversation(
 | 
			
		||||
		contact.ID,
 | 
			
		||||
		contact.ContactChannelID,
 | 
			
		||||
@@ -725,14 +704,14 @@ func handleCreateConversation(r *fastglue.Request) error {
 | 
			
		||||
		"",         /** last_message **/
 | 
			
		||||
		time.Now(), /** last_message_at **/
 | 
			
		||||
		req.Subject,
 | 
			
		||||
		true, /** append reference number to subject **/
 | 
			
		||||
		true, /** append reference number to subject? **/
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.lo.Error("error creating conversation", "error", err)
 | 
			
		||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Prepare attachments.
 | 
			
		||||
	// Get media for the attachment ids.
 | 
			
		||||
	var media = make([]medModels.Media, 0, len(req.Attachments))
 | 
			
		||||
	for _, id := range req.Attachments {
 | 
			
		||||
		m, err := app.media.Get(id, "")
 | 
			
		||||
@@ -743,14 +722,30 @@ func handleCreateConversation(r *fastglue.Request) error {
 | 
			
		||||
		media = append(media, m)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Send reply to the created conversation.
 | 
			
		||||
	if _, err := app.conversation.SendReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
 | 
			
		||||
		// Delete the conversation if reply fails.
 | 
			
		||||
	// Send initial message based on the initiator of conversation.
 | 
			
		||||
	switch req.Initiator {
 | 
			
		||||
	case umodels.UserTypeAgent:
 | 
			
		||||
		// Queue reply.
 | 
			
		||||
		if _, err := app.conversation.QueueReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
 | 
			
		||||
			// Delete the conversation if msg queue fails.
 | 
			
		||||
			if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
 | 
			
		||||
				app.lo.Error("error deleting conversation", "error", err)
 | 
			
		||||
			}
 | 
			
		||||
			return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
 | 
			
		||||
		}
 | 
			
		||||
	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.
 | 
			
		||||
	if req.AssignedAgentID > 0 {
 | 
			
		||||
@@ -768,3 +763,36 @@ func handleCreateConversation(r *fastglue.Request) error {
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(conversation)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// validateCreateConversationRequest validates the create conversation request fields.
 | 
			
		||||
func validateCreateConversationRequest(req createConversationRequest, app *App) error {
 | 
			
		||||
	if req.InboxID <= 0 {
 | 
			
		||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil)
 | 
			
		||||
	}
 | 
			
		||||
	if req.Content == "" {
 | 
			
		||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil)
 | 
			
		||||
	}
 | 
			
		||||
	if req.Email == "" {
 | 
			
		||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil)
 | 
			
		||||
	}
 | 
			
		||||
	if req.FirstName == "" {
 | 
			
		||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil)
 | 
			
		||||
	}
 | 
			
		||||
	if !stringutil.ValidEmail(req.Email) {
 | 
			
		||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil)
 | 
			
		||||
	}
 | 
			
		||||
	if req.Initiator != umodels.UserTypeContact && req.Initiator != umodels.UserTypeAgent {
 | 
			
		||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if inbox exists and is enabled.
 | 
			
		||||
	inbox, err := app.inbox.GetDBRecord(req.InboxID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	if !inbox.Enabled {
 | 
			
		||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										29
									
								
								cmd/csat.go
									
									
									
									
									
								
							
							
						
						
									
										29
									
								
								cmd/csat.go
									
									
									
									
									
								
							@@ -6,6 +6,10 @@ import (
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	maxCsatFeedbackLength = 1000
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// handleShowCSAT renders the CSAT page for a given csat.
 | 
			
		||||
func handleShowCSAT(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
@@ -17,7 +21,7 @@ func handleShowCSAT(r *fastglue.Request) error {
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
			
		||||
			"Data": map[string]interface{}{
 | 
			
		||||
				"ErrorMessage": "Page not found",
 | 
			
		||||
				"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
 | 
			
		||||
			},
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
@@ -25,8 +29,8 @@ func handleShowCSAT(r *fastglue.Request) error {
 | 
			
		||||
	if csat.ResponseTimestamp.Valid {
 | 
			
		||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
 | 
			
		||||
			"Data": map[string]interface{}{
 | 
			
		||||
				"Title":   "Thank you!",
 | 
			
		||||
				"Message": "We appreciate you taking the time to submit your feedback.",
 | 
			
		||||
				"Title":   app.i18n.T("globals.messages.thankYou"),
 | 
			
		||||
				"Message": app.i18n.T("csat.thankYouMessage"),
 | 
			
		||||
			},
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
@@ -35,14 +39,14 @@ func handleShowCSAT(r *fastglue.Request) error {
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
			
		||||
			"Data": map[string]interface{}{
 | 
			
		||||
				"ErrorMessage": "Page not found",
 | 
			
		||||
				"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
 | 
			
		||||
			},
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
 | 
			
		||||
		"Data": map[string]interface{}{
 | 
			
		||||
			"Title":    "Rate your interaction with us",
 | 
			
		||||
			"Title": app.i18n.T("csat.pageTitle"),
 | 
			
		||||
			"CSAT": map[string]interface{}{
 | 
			
		||||
				"UUID": csat.UUID,
 | 
			
		||||
			},
 | 
			
		||||
@@ -67,7 +71,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
			
		||||
			"Data": map[string]interface{}{
 | 
			
		||||
				"ErrorMessage": "Invalid `rating`",
 | 
			
		||||
				"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
 | 
			
		||||
			},
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
@@ -75,7 +79,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
 | 
			
		||||
	if ratingI < 1 || ratingI > 5 {
 | 
			
		||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
			
		||||
			"Data": map[string]interface{}{
 | 
			
		||||
				"ErrorMessage": "Invalid `rating`",
 | 
			
		||||
				"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
 | 
			
		||||
			},
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
@@ -83,11 +87,16 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
 | 
			
		||||
	if uuid == "" {
 | 
			
		||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
			
		||||
			"Data": map[string]interface{}{
 | 
			
		||||
				"ErrorMessage": "Invalid `uuid`",
 | 
			
		||||
				"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
 | 
			
		||||
			},
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Trim feedback if it exceeds max length
 | 
			
		||||
	if len(feedback) > maxCsatFeedbackLength {
 | 
			
		||||
		feedback = feedback[:maxCsatFeedbackLength]
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil {
 | 
			
		||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
			
		||||
			"Data": map[string]interface{}{
 | 
			
		||||
@@ -98,8 +107,8 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
 | 
			
		||||
 | 
			
		||||
	return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
 | 
			
		||||
		"Data": map[string]interface{}{
 | 
			
		||||
			"Title":   "Thank you!",
 | 
			
		||||
			"Message": "We appreciate you taking the time to submit your feedback.",
 | 
			
		||||
			"Title":   app.i18n.T("globals.messages.thankYou"),
 | 
			
		||||
			"Message": app.i18n.T("csat.thankYouMessage"),
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -23,18 +23,20 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
			
		||||
	// i18n.
 | 
			
		||||
	g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
 | 
			
		||||
 | 
			
		||||
	// Public config for app initialization.
 | 
			
		||||
	g.GET("/api/v1/config", handleGetConfig)
 | 
			
		||||
 | 
			
		||||
	// Media.
 | 
			
		||||
	g.GET("/uploads/{uuid}", auth(handleServeMedia))
 | 
			
		||||
	g.POST("/api/v1/media", auth(handleMediaUpload))
 | 
			
		||||
 | 
			
		||||
	// Settings.
 | 
			
		||||
	g.GET("/api/v1/settings/general", handleGetGeneralSettings)
 | 
			
		||||
	g.GET("/api/v1/settings/general", auth(handleGetGeneralSettings))
 | 
			
		||||
	g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "general_settings:manage"))
 | 
			
		||||
	g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_settings:manage"))
 | 
			
		||||
	g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage"))
 | 
			
		||||
 | 
			
		||||
	// OpenID connect single sign-on.
 | 
			
		||||
	g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
 | 
			
		||||
	g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
 | 
			
		||||
	g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
 | 
			
		||||
	g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
 | 
			
		||||
@@ -153,7 +155,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
			
		||||
	g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
 | 
			
		||||
 | 
			
		||||
	// Roles.
 | 
			
		||||
	g.GET("/api/v1/roles", perm(handleGetRoles, "roles:manage"))
 | 
			
		||||
	g.GET("/api/v1/roles", auth(handleGetRoles))
 | 
			
		||||
	g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage"))
 | 
			
		||||
	g.POST("/api/v1/roles", perm(handleCreateRole, "roles:manage"))
 | 
			
		||||
	g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage"))
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,12 @@ func handleGetInboxes(r *fastglue.Request) error {
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
	for i := range inboxes {
 | 
			
		||||
		if err := inboxes[i].ClearPasswords(); err != nil {
 | 
			
		||||
			app.lo.Error("error clearing inbox passwords from response", "error", err)
 | 
			
		||||
			return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(inboxes)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										17
									
								
								cmd/init.go
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								cmd/init.go
									
									
									
									
									
								
							@@ -250,11 +250,12 @@ func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// initViews inits view manager.
 | 
			
		||||
func initView(db *sqlx.DB) *view.Manager {
 | 
			
		||||
func initView(db *sqlx.DB, i18n *i18n.I18n) *view.Manager {
 | 
			
		||||
	var lo = initLogger("view_manager")
 | 
			
		||||
	m, err := view.New(view.Opts{
 | 
			
		||||
		DB:   db,
 | 
			
		||||
		Lo:   lo,
 | 
			
		||||
		I18n: i18n,
 | 
			
		||||
	})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatalf("error initializing view manager: %v", err)
 | 
			
		||||
@@ -327,7 +328,7 @@ func initWS(user *user.Manager) *ws.Hub {
 | 
			
		||||
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager {
 | 
			
		||||
	var (
 | 
			
		||||
		lo      = initLogger("template")
 | 
			
		||||
		funcMap = getTmplFuncs(consts)
 | 
			
		||||
		funcMap = getTmplFuncs(consts, i18n)
 | 
			
		||||
	)
 | 
			
		||||
	tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -345,7 +346,7 @@ func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// getTmplFuncs returns the template functions.
 | 
			
		||||
func getTmplFuncs(consts *constants) template.FuncMap {
 | 
			
		||||
func getTmplFuncs(consts *constants, i18n *i18n.I18n) template.FuncMap {
 | 
			
		||||
	return template.FuncMap{
 | 
			
		||||
		"RootURL": func() string {
 | 
			
		||||
			return consts.AppBaseURL
 | 
			
		||||
@@ -365,6 +366,9 @@ func getTmplFuncs(consts *constants) template.FuncMap {
 | 
			
		||||
		"SiteName": func() string {
 | 
			
		||||
			return consts.SiteName
 | 
			
		||||
		},
 | 
			
		||||
		"L": func() interface{} {
 | 
			
		||||
			return i18n
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -381,7 +385,10 @@ func reloadSettings(app *App) error {
 | 
			
		||||
		app.lo.Error("error unmarshalling settings from DB", "error", err)
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	if err := ko.Load(confmap.Provider(out, "."), nil); err != nil {
 | 
			
		||||
	app.Lock()
 | 
			
		||||
	err = ko.Load(confmap.Provider(out, "."), nil)
 | 
			
		||||
	app.Unlock()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.lo.Error("error loading settings into koanf", "error", err)
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
@@ -393,7 +400,7 @@ func reloadSettings(app *App) error {
 | 
			
		||||
// reloadTemplates reloads the templates from the filesystem.
 | 
			
		||||
func reloadTemplates(app *App) error {
 | 
			
		||||
	app.lo.Info("reloading templates")
 | 
			
		||||
	funcMap := getTmplFuncs(app.consts.Load().(*constants))
 | 
			
		||||
	funcMap := getTmplFuncs(app.consts.Load().(*constants), app.i18n)
 | 
			
		||||
	tpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/email-templates/*.html")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.lo.Error("error parsing email templates", "error", err)
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,6 @@ package main
 | 
			
		||||
import (
 | 
			
		||||
	amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
			
		||||
	umodels "github.com/abhinavxd/libredesk/internal/user/models"
 | 
			
		||||
	realip "github.com/ferluci/fast-realip"
 | 
			
		||||
	"github.com/valyala/fasthttp"
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
@@ -42,12 +41,6 @@ func handleLogin(r *fastglue.Request) error {
 | 
			
		||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.accountDisabled"), nil))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set user availability status to online.
 | 
			
		||||
	if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
	user.AvailabilityStatus = umodels.Online
 | 
			
		||||
 | 
			
		||||
	if err := app.auth.SaveSession(amodels.User{
 | 
			
		||||
		ID:        user.ID,
 | 
			
		||||
		Email:     user.Email.String,
 | 
			
		||||
 
 | 
			
		||||
@@ -97,6 +97,8 @@ type App struct {
 | 
			
		||||
 | 
			
		||||
	// Global state that stores data on an available app update.
 | 
			
		||||
	update *AppUpdate
 | 
			
		||||
	// Flag to indicate if app restart is required for settings to take effect.
 | 
			
		||||
	restartRequired bool
 | 
			
		||||
	sync.Mutex
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -239,7 +241,7 @@ func main() {
 | 
			
		||||
		activityLog:     initActivityLog(db, i18n),
 | 
			
		||||
		customAttribute: initCustomAttribute(db, i18n),
 | 
			
		||||
		authz:           initAuthz(i18n),
 | 
			
		||||
		view:            initView(db),
 | 
			
		||||
		view:            initView(db, i18n),
 | 
			
		||||
		report:          initReport(db, i18n),
 | 
			
		||||
		csat:            initCSAT(db, i18n),
 | 
			
		||||
		search:          initSearch(db, i18n),
 | 
			
		||||
 
 | 
			
		||||
@@ -2,10 +2,14 @@ package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
			
		||||
	authzModels "github.com/abhinavxd/libredesk/internal/authz/models"
 | 
			
		||||
	cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
			
		||||
	medModels "github.com/abhinavxd/libredesk/internal/media/models"
 | 
			
		||||
	umodels "github.com/abhinavxd/libredesk/internal/user/models"
 | 
			
		||||
	"github.com/valyala/fasthttp"
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
@@ -17,6 +21,7 @@ type messageReq struct {
 | 
			
		||||
	To          []string `json:"to"`
 | 
			
		||||
	CC          []string `json:"cc"`
 | 
			
		||||
	BCC         []string `json:"bcc"`
 | 
			
		||||
	SenderType  string   `json:"sender_type"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleGetMessages returns messages for a conversation.
 | 
			
		||||
@@ -99,7 +104,7 @@ func handleGetMessage(r *fastglue.Request) error {
 | 
			
		||||
	return r.SendEnvelope(message)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleRetryMessage changes message status so it can be retried for sending.
 | 
			
		||||
// handleRetryMessage changes message status to `pending`, so it's enqueued for sending.
 | 
			
		||||
func handleRetryMessage(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app   = r.Context.(*App)
 | 
			
		||||
@@ -150,7 +155,31 @@ func handleSendMessage(r *fastglue.Request) error {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Prepare attachments.
 | 
			
		||||
	if req.SenderType != umodels.UserTypeAgent && req.SenderType != umodels.UserTypeContact {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`sender_type`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Contacts cannot send private messages
 | 
			
		||||
	if req.SenderType == umodels.UserTypeContact && req.Private {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if user has permission to send messages as contact
 | 
			
		||||
	if req.SenderType == umodels.UserTypeContact {
 | 
			
		||||
		parts := strings.Split(authzModels.PermMessagesWriteAsContact, ":")
 | 
			
		||||
		if len(parts) != 2 {
 | 
			
		||||
			return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil))
 | 
			
		||||
		}
 | 
			
		||||
		ok, err := app.authz.Enforce(user, parts[0], parts[1])
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil))
 | 
			
		||||
		}
 | 
			
		||||
		if !ok {
 | 
			
		||||
			return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get media for all attachments.
 | 
			
		||||
	var media = make([]medModels.Media, 0, len(req.Attachments))
 | 
			
		||||
	for _, id := range req.Attachments {
 | 
			
		||||
		m, err := app.media.Get(id, "")
 | 
			
		||||
@@ -161,6 +190,16 @@ func handleSendMessage(r *fastglue.Request) error {
 | 
			
		||||
		media = append(media, m)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create contact message.
 | 
			
		||||
	if req.SenderType == umodels.UserTypeContact {
 | 
			
		||||
		message, err := app.conversation.CreateContactMessage(media, int(conv.ContactID), cuuid, req.Message, cmodels.ContentTypeHTML)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return sendErrorEnvelope(r, err)
 | 
			
		||||
		}
 | 
			
		||||
		return r.SendEnvelope(message)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Send private note.
 | 
			
		||||
	if req.Private {
 | 
			
		||||
		message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
@@ -168,7 +207,9 @@ func handleSendMessage(r *fastglue.Request) error {
 | 
			
		||||
		}
 | 
			
		||||
		return r.SendEnvelope(message)
 | 
			
		||||
	}
 | 
			
		||||
	message, err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
 | 
			
		||||
 | 
			
		||||
	// Queue reply.
 | 
			
		||||
	message, err := app.conversation.QueueReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10
									
								
								cmd/oidc.go
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								cmd/oidc.go
									
									
									
									
									
								
							@@ -11,16 +11,6 @@ import (
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// handleGetAllEnabledOIDC returns all enabled OIDC records
 | 
			
		||||
func handleGetAllEnabledOIDC(r *fastglue.Request) error {
 | 
			
		||||
	app := r.Context.(*App)
 | 
			
		||||
	out, err := app.oidc.GetAllEnabled()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(out)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleGetAllOIDC returns all OIDC records
 | 
			
		||||
func handleGetAllOIDC(r *fastglue.Request) error {
 | 
			
		||||
	app := r.Context.(*App)
 | 
			
		||||
 
 | 
			
		||||
@@ -31,6 +31,8 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
 | 
			
		||||
	settings["app.update"] = app.update
 | 
			
		||||
	// Set app version.
 | 
			
		||||
	settings["app.version"] = versionString
 | 
			
		||||
	// Set restart required flag.
 | 
			
		||||
	settings["app.restart_required"] = app.restartRequired
 | 
			
		||||
	return r.SendEnvelope(settings)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -45,6 +47,11 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get current language before update.
 | 
			
		||||
	app.Lock()
 | 
			
		||||
	oldLang := ko.String("app.lang")
 | 
			
		||||
	app.Unlock()
 | 
			
		||||
 | 
			
		||||
	// Remove any trailing slash `/` from the root url.
 | 
			
		||||
	req.RootURL = strings.TrimRight(req.RootURL, "/")
 | 
			
		||||
 | 
			
		||||
@@ -55,6 +62,17 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
 | 
			
		||||
	if err := reloadSettings(app); err != nil {
 | 
			
		||||
		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if language changed and reload i18n if needed.
 | 
			
		||||
	app.Lock()
 | 
			
		||||
	newLang := ko.String("app.lang")
 | 
			
		||||
	if oldLang != newLang {
 | 
			
		||||
		app.lo.Info("language changed, reloading i18n", "old_lang", oldLang, "new_lang", newLang)
 | 
			
		||||
		app.i18n = initI18n(app.fs)
 | 
			
		||||
		app.lo.Info("reloaded i18n", "old_lang", oldLang, "new_lang", newLang)
 | 
			
		||||
	}
 | 
			
		||||
	app.Unlock()
 | 
			
		||||
 | 
			
		||||
	if err := reloadTemplates(app); err != nil {
 | 
			
		||||
		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
 | 
			
		||||
	}
 | 
			
		||||
@@ -109,6 +127,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.invalidFromAddress"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// If empty then retain previous password.
 | 
			
		||||
	if req.Password == "" {
 | 
			
		||||
		req.Password = cur.Password
 | 
			
		||||
	}
 | 
			
		||||
@@ -117,6 +136,10 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// No reload implemented, so user has to restart the app.
 | 
			
		||||
	// Email notification settings require app restart to take effect.
 | 
			
		||||
	app.Lock()
 | 
			
		||||
	app.restartRequired = true
 | 
			
		||||
	app.Unlock()
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(true)
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -83,7 +83,7 @@ func handleUpdateTeam(r *fastglue.Request) error {
 | 
			
		||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	updatedTeam, err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations);
 | 
			
		||||
	updatedTeam, err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
@@ -35,6 +35,7 @@ var migList = []migFunc{
 | 
			
		||||
	{"v0.5.0", migrations.V0_5_0},
 | 
			
		||||
	{"v0.6.0", migrations.V0_6_0},
 | 
			
		||||
	{"v0.7.0", migrations.V0_7_0},
 | 
			
		||||
	{"v0.7.4", migrations.V0_7_4},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// upgrade upgrades the database to the current version by running SQL migration files
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										218
									
								
								cmd/users.go
									
									
									
									
									
								
							
							
						
						
									
										218
									
								
								cmd/users.go
									
									
									
									
									
								
							@@ -26,34 +26,38 @@ const (
 | 
			
		||||
	maxAvatarSizeMB = 2
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Request structs for user-related endpoints
 | 
			
		||||
 | 
			
		||||
// UpdateAvailabilityRequest represents the request to update user availability
 | 
			
		||||
type UpdateAvailabilityRequest struct {
 | 
			
		||||
type updateAvailabilityRequest struct {
 | 
			
		||||
	Status string `json:"status"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ResetPasswordRequest represents the password reset request
 | 
			
		||||
type ResetPasswordRequest struct {
 | 
			
		||||
type resetPasswordRequest struct {
 | 
			
		||||
	Email string `json:"email"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SetPasswordRequest represents the set password request
 | 
			
		||||
type SetPasswordRequest struct {
 | 
			
		||||
type setPasswordRequest struct {
 | 
			
		||||
	Token    string `json:"token"`
 | 
			
		||||
	Password string `json:"password"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// AvailabilityRequest represents the request to update agent availability
 | 
			
		||||
type AvailabilityRequest struct {
 | 
			
		||||
type availabilityRequest struct {
 | 
			
		||||
	Status string `json:"status"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type agentReq struct {
 | 
			
		||||
	FirstName          string   `json:"first_name"`
 | 
			
		||||
	LastName           string   `json:"last_name"`
 | 
			
		||||
	Email              string   `json:"email"`
 | 
			
		||||
	SendWelcomeEmail   bool     `json:"send_welcome_email"`
 | 
			
		||||
	Teams              []string `json:"teams"`
 | 
			
		||||
	Roles              []string `json:"roles"`
 | 
			
		||||
	Enabled            bool     `json:"enabled"`
 | 
			
		||||
	AvailabilityStatus string   `json:"availability_status"`
 | 
			
		||||
	NewPassword        string   `json:"new_password,omitempty"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleGetAgents returns all agents.
 | 
			
		||||
func handleGetAgents(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app = r.Context.(*App)
 | 
			
		||||
	)
 | 
			
		||||
	var app = r.Context.(*App)
 | 
			
		||||
	agents, err := app.user.GetAgents()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
@@ -73,9 +77,7 @@ func handleGetAgentsCompact(r *fastglue.Request) error {
 | 
			
		||||
 | 
			
		||||
// handleGetAgent returns an agent.
 | 
			
		||||
func handleGetAgent(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app = r.Context.(*App)
 | 
			
		||||
	)
 | 
			
		||||
	var app = r.Context.(*App)
 | 
			
		||||
	id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
			
		||||
	if err != nil || id <= 0 {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
 | 
			
		||||
@@ -93,7 +95,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
 | 
			
		||||
		app      = r.Context.(*App)
 | 
			
		||||
		auser    = r.RequestCtx.UserValue("user").(amodels.User)
 | 
			
		||||
		ip       = realip.FromRequest(r.RequestCtx)
 | 
			
		||||
		availReq AvailabilityRequest
 | 
			
		||||
		availReq availabilityRequest
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	// Decode JSON request
 | 
			
		||||
@@ -101,6 +103,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Fetch entire agent
 | 
			
		||||
	agent, err := app.user.GetAgent(auser.ID, "")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
@@ -108,10 +111,10 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
 | 
			
		||||
 | 
			
		||||
	// Same status?
 | 
			
		||||
	if agent.AvailabilityStatus == availReq.Status {
 | 
			
		||||
		return r.SendEnvelope(true)
 | 
			
		||||
		return r.SendEnvelope(agent)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Update availability status.
 | 
			
		||||
	// Update availability status
 | 
			
		||||
	if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
@@ -123,21 +126,22 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(true)
 | 
			
		||||
	// Fetch updated agent and return
 | 
			
		||||
	agent, err = app.user.GetAgent(auser.ID, "")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
// handleGetCurrentAgentTeams returns the teams of an agent.
 | 
			
		||||
	return r.SendEnvelope(agent)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleGetCurrentAgentTeams returns the teams of current agent.
 | 
			
		||||
func handleGetCurrentAgentTeams(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app   = r.Context.(*App)
 | 
			
		||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
			
		||||
	)
 | 
			
		||||
	agent, err := app.user.GetAgent(auser.ID, "")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	teams, err := app.team.GetUserTeams(agent.ID)
 | 
			
		||||
	teams, err := app.team.GetUserTeams(auser.ID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
@@ -150,11 +154,6 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
 | 
			
		||||
		app   = r.Context.(*App)
 | 
			
		||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
			
		||||
	)
 | 
			
		||||
	agent, err := app.user.GetAgent(auser.ID, "")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	form, err := r.RequestCtx.MultipartForm()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.lo.Error("error parsing form data", "error", err)
 | 
			
		||||
@@ -165,54 +164,53 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
 | 
			
		||||
 | 
			
		||||
	// Upload avatar?
 | 
			
		||||
	if ok && len(files) > 0 {
 | 
			
		||||
		if err := uploadUserAvatar(r, &agent, files); err != nil {
 | 
			
		||||
		agent, err := app.user.GetAgent(auser.ID, "")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return sendErrorEnvelope(r, err)
 | 
			
		||||
		}
 | 
			
		||||
		if err := uploadUserAvatar(r, agent, files); err != nil {
 | 
			
		||||
			return sendErrorEnvelope(r, err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(true)
 | 
			
		||||
 | 
			
		||||
	// Fetch updated agent and return.
 | 
			
		||||
	agent, err := app.user.GetAgent(auser.ID, "")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(agent)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleCreateAgent creates a new agent.
 | 
			
		||||
func handleCreateAgent(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app = r.Context.(*App)
 | 
			
		||||
		user = models.User{}
 | 
			
		||||
		req = agentReq{}
 | 
			
		||||
	)
 | 
			
		||||
	if err := r.Decode(&user, "json"); err != nil {
 | 
			
		||||
 | 
			
		||||
	if err := r.Decode(&req, "json"); err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if user.Email.String == "" {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
	user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
 | 
			
		||||
 | 
			
		||||
	if !stringutil.ValidEmail(user.Email.String) {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
 | 
			
		||||
	// Validate agent request
 | 
			
		||||
	if err := validateAgentRequest(r, &req); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if user.Roles == nil {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if user.FirstName == "" {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := app.user.CreateAgent(&user); err != nil {
 | 
			
		||||
	agent, err := app.user.CreateAgent(req.FirstName, req.LastName, req.Email, req.Roles)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Upsert user teams.
 | 
			
		||||
	if len(user.Teams) > 0 {
 | 
			
		||||
		if err := app.team.UpsertUserTeams(user.ID, user.Teams.Names()); err != nil {
 | 
			
		||||
			return sendErrorEnvelope(r, err)
 | 
			
		||||
		}
 | 
			
		||||
	if len(req.Teams) > 0 {
 | 
			
		||||
		app.team.UpsertUserTeams(agent.ID, req.Teams)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if user.SendWelcomeEmail {
 | 
			
		||||
	if req.SendWelcomeEmail {
 | 
			
		||||
		// Generate reset token.
 | 
			
		||||
		resetToken, err := app.user.SetResetPasswordToken(user.ID)
 | 
			
		||||
		resetToken, err := app.user.SetResetPasswordToken(agent.ID)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return sendErrorEnvelope(r, err)
 | 
			
		||||
		}
 | 
			
		||||
@@ -220,31 +218,36 @@ func handleCreateAgent(r *fastglue.Request) error {
 | 
			
		||||
		// Render template and send email.
 | 
			
		||||
		content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
 | 
			
		||||
			"ResetToken": resetToken,
 | 
			
		||||
			"Email":      user.Email.String,
 | 
			
		||||
			"Email":      req.Email,
 | 
			
		||||
		})
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			app.lo.Error("error rendering template", "error", err)
 | 
			
		||||
			return r.SendEnvelope(true)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := app.notifier.Send(notifier.Message{
 | 
			
		||||
			RecipientEmails: []string{user.Email.String},
 | 
			
		||||
			Subject:         "Welcome to Libredesk",
 | 
			
		||||
			RecipientEmails: []string{req.Email},
 | 
			
		||||
			Subject:         app.i18n.T("globals.messages.welcomeToLibredesk"),
 | 
			
		||||
			Content:         content,
 | 
			
		||||
			Provider:        notifier.ProviderEmail,
 | 
			
		||||
		}); err != nil {
 | 
			
		||||
			app.lo.Error("error sending notification message", "error", err)
 | 
			
		||||
			return r.SendEnvelope(true)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(true)
 | 
			
		||||
 | 
			
		||||
	// Refetch agent as other details might've changed.
 | 
			
		||||
	agent, err = app.user.GetAgent(agent.ID, "")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(agent)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleUpdateAgent updates an agent.
 | 
			
		||||
func handleUpdateAgent(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app   = r.Context.(*App)
 | 
			
		||||
		user  = models.User{}
 | 
			
		||||
		req   = agentReq{}
 | 
			
		||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
			
		||||
		ip    = realip.FromRequest(r.RequestCtx)
 | 
			
		||||
		id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
			
		||||
@@ -253,25 +256,13 @@ func handleUpdateAgent(r *fastglue.Request) error {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := r.Decode(&user, "json"); err != nil {
 | 
			
		||||
	if err := r.Decode(&req, "json"); err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if user.Email.String == "" {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
	user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
 | 
			
		||||
 | 
			
		||||
	if !stringutil.ValidEmail(user.Email.String) {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if user.Roles == nil {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if user.FirstName == "" {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
 | 
			
		||||
	// Validate agent request
 | 
			
		||||
	if err := validateAgentRequest(r, &req); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	agent, err := app.user.GetAgent(id, "")
 | 
			
		||||
@@ -280,8 +271,8 @@ func handleUpdateAgent(r *fastglue.Request) error {
 | 
			
		||||
	}
 | 
			
		||||
	oldAvailabilityStatus := agent.AvailabilityStatus
 | 
			
		||||
 | 
			
		||||
	// Update agent.
 | 
			
		||||
	if err = app.user.UpdateAgent(id, user); err != nil {
 | 
			
		||||
	// Update agent with individual fields
 | 
			
		||||
	if err = app.user.UpdateAgent(id, req.FirstName, req.LastName, req.Email, req.Roles, req.Enabled, req.AvailabilityStatus, req.NewPassword); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -289,18 +280,24 @@ func handleUpdateAgent(r *fastglue.Request) error {
 | 
			
		||||
	defer app.authz.InvalidateUserCache(id)
 | 
			
		||||
 | 
			
		||||
	// Create activity log if user availability status changed.
 | 
			
		||||
	if oldAvailabilityStatus != user.AvailabilityStatus {
 | 
			
		||||
		if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil {
 | 
			
		||||
	if oldAvailabilityStatus != req.AvailabilityStatus {
 | 
			
		||||
		if err := app.activityLog.UserAvailability(auser.ID, auser.Email, req.AvailabilityStatus, ip, req.Email, id); err != nil {
 | 
			
		||||
			app.lo.Error("error creating activity log", "error", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Upsert agent teams.
 | 
			
		||||
	if err := app.team.UpsertUserTeams(id, user.Teams.Names()); err != nil {
 | 
			
		||||
	if err := app.team.UpsertUserTeams(id, req.Teams); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(true)
 | 
			
		||||
	// Refetch agent and return.
 | 
			
		||||
	agent, err = app.user.GetAgent(id, "")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(agent)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleDeleteAgent soft deletes an agent.
 | 
			
		||||
@@ -381,7 +378,7 @@ func handleResetPassword(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app       = r.Context.(*App)
 | 
			
		||||
		auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
 | 
			
		||||
		resetReq  ResetPasswordRequest
 | 
			
		||||
		resetReq  resetPasswordRequest
 | 
			
		||||
	)
 | 
			
		||||
	if ok && auser.ID > 0 {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
 | 
			
		||||
@@ -399,7 +396,7 @@ func handleResetPassword(r *fastglue.Request) error {
 | 
			
		||||
	agent, err := app.user.GetAgent(0, resetReq.Email)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		// Send 200 even if user not found, to prevent email enumeration.
 | 
			
		||||
		return r.SendEnvelope("Reset password email sent successfully.")
 | 
			
		||||
		return r.SendEnvelope(true)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	token, err := app.user.SetResetPasswordToken(agent.ID)
 | 
			
		||||
@@ -434,7 +431,7 @@ func handleSetPassword(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app       = r.Context.(*App)
 | 
			
		||||
		agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
 | 
			
		||||
		req       = SetPasswordRequest{}
 | 
			
		||||
		req       setPasswordRequest
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	if ok && agent.ID > 0 {
 | 
			
		||||
@@ -457,13 +454,13 @@ func handleSetPassword(r *fastglue.Request) error {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// uploadUserAvatar uploads the user avatar.
 | 
			
		||||
func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart.FileHeader) error {
 | 
			
		||||
func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart.FileHeader) error {
 | 
			
		||||
	var app = r.Context.(*App)
 | 
			
		||||
 | 
			
		||||
	fileHeader := files[0]
 | 
			
		||||
	file, err := fileHeader.Open()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.lo.Error("error opening uploaded file", "error", err)
 | 
			
		||||
		app.lo.Error("error opening uploaded file", "user_id", user.ID, "error", err)
 | 
			
		||||
		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil)
 | 
			
		||||
	}
 | 
			
		||||
	defer file.Close()
 | 
			
		||||
@@ -480,7 +477,7 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
 | 
			
		||||
 | 
			
		||||
	// Check file size
 | 
			
		||||
	if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB {
 | 
			
		||||
		app.lo.Error("error uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
 | 
			
		||||
		app.lo.Error("error uploaded file size is larger than max allowed", "user_id", user.ID, "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
 | 
			
		||||
		return envelope.NewError(
 | 
			
		||||
			envelope.InputError,
 | 
			
		||||
			app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)),
 | 
			
		||||
@@ -497,23 +494,25 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
 | 
			
		||||
	meta := []byte("{}")
 | 
			
		||||
	media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.lo.Error("error uploading file", "error", err)
 | 
			
		||||
		app.lo.Error("error uploading file", "user_id", user.ID, "error", err)
 | 
			
		||||
		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Delete current avatar.
 | 
			
		||||
	if user.AvatarURL.Valid {
 | 
			
		||||
		fileName := filepath.Base(user.AvatarURL.String)
 | 
			
		||||
		app.media.Delete(fileName)
 | 
			
		||||
		if err := app.media.Delete(fileName); err != nil {
 | 
			
		||||
			app.lo.Error("error deleting user avatar", "user_id", user.ID, "error", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Save file path.
 | 
			
		||||
	path, err := stringutil.GetPathFromURL(media.URL)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.lo.Debug("error getting path from URL", "url", media.URL, "error", err)
 | 
			
		||||
		app.lo.Debug("error getting path from URL", "user_id", user.ID, "url", media.URL, "error", err)
 | 
			
		||||
		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
 | 
			
		||||
	}
 | 
			
		||||
	fmt.Println("path", path)
 | 
			
		||||
 | 
			
		||||
	if err := app.user.UpdateAvatar(user.ID, path); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
@@ -577,3 +576,28 @@ func handleRevokeAPIKey(r *fastglue.Request) error {
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(true)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// validateAgentRequest validates common agent request fields and normalizes the email
 | 
			
		||||
func validateAgentRequest(r *fastglue.Request, req *agentReq) error {
 | 
			
		||||
	var app = r.Context.(*App)
 | 
			
		||||
 | 
			
		||||
	// Normalize email
 | 
			
		||||
	req.Email = strings.TrimSpace(strings.ToLower(req.Email))
 | 
			
		||||
	if req.Email == "" {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !stringutil.ValidEmail(req.Email) {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if req.Roles == nil {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if req.FirstName == "" {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,32 +0,0 @@
 | 
			
		||||
# Developer Setup
 | 
			
		||||
 | 
			
		||||
Libredesk is a monorepo with a Go backend and a Vue.js frontend. The frontend uses Shadcn for UI components.
 | 
			
		||||
 | 
			
		||||
### Pre-requisites
 | 
			
		||||
 | 
			
		||||
- go
 | 
			
		||||
- nodejs (if you are working on the frontend) and `pnpm`
 | 
			
		||||
- redis
 | 
			
		||||
- postgres database (>= 13)
 | 
			
		||||
 | 
			
		||||
### First time setup
 | 
			
		||||
 | 
			
		||||
Clone the repository:
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
git clone https://github.com/abhinavxd/libredesk.git
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
1. Copy `config.toml.sample` as `config.toml` and add your config.
 | 
			
		||||
2. Run `make` to build the libredesk binary. Once the binary is built, run `./libredesk --install` to run the DB setup and set the System user password.
 | 
			
		||||
 | 
			
		||||
### Running the Dev Environment
 | 
			
		||||
 | 
			
		||||
1. Run `make run-backend` to start the libredesk backend dev server on `:9000`.
 | 
			
		||||
2. Run `make run-frontend` to start the Vue frontend in dev mode using pnpm on `:8000`. Requests are proxied to the backend running on `:9000` check `vite.config.js` for the proxy config.
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
# Production Build
 | 
			
		||||
 | 
			
		||||
Run `make` to build the Go binary, build the Javascript frontend, and embed the static assets producing a single self-contained binary, `libredesk`.
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 298 KiB  | 
@@ -1,17 +0,0 @@
 | 
			
		||||
# Introduction
 | 
			
		||||
 | 
			
		||||
Libredesk is an open-source, self-hosted customer support desk — single binary app.
 | 
			
		||||
 | 
			
		||||
<div style="border: 1px solid #ccc; padding: 2px; border-radius: 6px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); background-color: #f9f9f9;">
 | 
			
		||||
  <a href="https://libredesk.io">
 | 
			
		||||
    <img src="images/hero.png" alt="libredesk UI screenshot" style="display: block; margin: 0 auto; max-width: 100%; border-radius: 4px;" />
 | 
			
		||||
  </a>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
## Developers
 | 
			
		||||
 | 
			
		||||
Libredesk is licensed under AGPLv3. Contributions are welcome.
 | 
			
		||||
 | 
			
		||||
- Source code: [GitHub](https://github.com/abhinavxd/libredesk)
 | 
			
		||||
- Setup guide: [Developer setup](developer-setup.md)
 | 
			
		||||
- Stack: Go backend, Vue 3 frontend (Shadcn UI)
 | 
			
		||||
@@ -1,65 +0,0 @@
 | 
			
		||||
# Installation
 | 
			
		||||
 | 
			
		||||
Libredesk is a single binary application that requires postgres and redis to run. You can install it using the binary or docker.
 | 
			
		||||
 | 
			
		||||
## Binary
 | 
			
		||||
 | 
			
		||||
1. Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary.
 | 
			
		||||
2. `./libredesk --install` to install the tables in the Postgres DB (⩾ 13) and set the System user password.
 | 
			
		||||
3. Run `./libredesk` and visit `http://localhost:9000` and login with the email `System` and the password you set during installation.
 | 
			
		||||
 | 
			
		||||
!!! Tip
 | 
			
		||||
    To set the System user password during installation, set the environment variables:
 | 
			
		||||
    `LIBREDESK_SYSTEM_USER_PASSWORD=xxxxxxxxxxx ./libredesk --install`
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Docker
 | 
			
		||||
 | 
			
		||||
The latest image is available on DockerHub at `libredesk/libredesk:latest`
 | 
			
		||||
 | 
			
		||||
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/main/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
 | 
			
		||||
 | 
			
		||||
```shell
 | 
			
		||||
# Download the compose file and the sample config file in the current directory.
 | 
			
		||||
curl -LO https://github.com/abhinavxd/libredesk/raw/main/docker-compose.yml
 | 
			
		||||
curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
 | 
			
		||||
 | 
			
		||||
# Copy the config.sample.toml to config.toml and edit it as needed.
 | 
			
		||||
cp config.sample.toml config.toml
 | 
			
		||||
 | 
			
		||||
# Run the services in the background.
 | 
			
		||||
docker compose up -d
 | 
			
		||||
 | 
			
		||||
# Setting System user password.
 | 
			
		||||
docker exec -it libredesk_app ./libredesk --set-system-user-password
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Go to `http://localhost:9000` and login with the email `System` and the password you set using the `--set-system-user-password` command.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Compiling from source
 | 
			
		||||
 | 
			
		||||
To compile the latest unreleased version (`main` branch):
 | 
			
		||||
 | 
			
		||||
1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system.
 | 
			
		||||
2. `git clone git@github.com:abhinavxd/libredesk.git`
 | 
			
		||||
3. `cd libredesk && make`. This will generate the `libredesk` binary.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Nginx
 | 
			
		||||
 | 
			
		||||
Libredesk uses websockets for real-time updates. If you are using Nginx, you need to add the following (or similar) configuration to your Nginx configuration file.
 | 
			
		||||
 | 
			
		||||
```nginx
 | 
			
		||||
client_max_body_size 100M;
 | 
			
		||||
location / {
 | 
			
		||||
    proxy_pass http://localhost:9000;
 | 
			
		||||
    proxy_http_version 1.1;
 | 
			
		||||
    proxy_set_header Upgrade $http_upgrade;
 | 
			
		||||
    proxy_set_header Connection 'upgrade';
 | 
			
		||||
    proxy_set_header Host $host;
 | 
			
		||||
    proxy_set_header X-Real-IP $remote_addr;
 | 
			
		||||
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 | 
			
		||||
    proxy_cache_bypass $http_upgrade;
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
@@ -1,57 +0,0 @@
 | 
			
		||||
# Setting up SSO
 | 
			
		||||
 | 
			
		||||
Libredesk supports external OpenID Connect providers (e.g., Google, Keycloak) for signing in users.
 | 
			
		||||
 | 
			
		||||
!!! note
 | 
			
		||||
    User accounts must be created in Libredesk manually; signup is not supported.
 | 
			
		||||
 | 
			
		||||
## Generic Configuration Steps
 | 
			
		||||
 | 
			
		||||
Since each provider’s configuration might differ, consult your provider’s documentation for any additional or divergent settings.
 | 
			
		||||
 | 
			
		||||
1. Provider setup:  
 | 
			
		||||
   In your provider’s admin console, create a new OpenID Connect application/client. Retrieve:
 | 
			
		||||
      - Client ID
 | 
			
		||||
      - Client Secret
 | 
			
		||||
 | 
			
		||||
2. Libredesk configuration: 
 | 
			
		||||
   In Libredesk, navigate to Security > SSO and click New SSO and enter the following details:
 | 
			
		||||
      - Provider URL (e.g., the URL of your OpenID provider)
 | 
			
		||||
      - Client ID
 | 
			
		||||
      - Client Secret
 | 
			
		||||
      - A descriptive name for the connection
 | 
			
		||||
 | 
			
		||||
3. Redirect URL:  
 | 
			
		||||
   After saving, copy the generated Callback URL from Libredesk and add it as a valid redirect URI in your provider’s client settings.
 | 
			
		||||
   
 | 
			
		||||
## Provider Examples
 | 
			
		||||
 | 
			
		||||
#### Keycloak
 | 
			
		||||
 | 
			
		||||
1. Log in to your Keycloak Admin Console.
 | 
			
		||||
 | 
			
		||||
2. In Keycloak, navigate to Clients and click Create:
 | 
			
		||||
 | 
			
		||||
      - Client ID (e.g., `libredesk-app`)
 | 
			
		||||
      - Client Protocol: `openid-connect`
 | 
			
		||||
      - Root URL and Web Origins: your app domain (e.g., `https://ticket.example.com`)
 | 
			
		||||
      - Under Authentication flow, uncheck everything except the standard flow
 | 
			
		||||
      - Click save
 | 
			
		||||
 | 
			
		||||
3. Go to the credentials tab:
 | 
			
		||||
      - Ensure client authenticator is set to `Client Id and Secret`
 | 
			
		||||
      - Note down the generated client secret
 | 
			
		||||
 | 
			
		||||
4. In Libredesk, go to Admin > Security > SSO and click New SSO:
 | 
			
		||||
      - Provider URL (e.g., `https://keycloak.example.com/realms/yourrealm`)
 | 
			
		||||
      - Name (e.g., `Keycloak`)
 | 
			
		||||
      - Client ID
 | 
			
		||||
      - Client secret
 | 
			
		||||
      - Click save
 | 
			
		||||
 | 
			
		||||
5. After saving, click on the three dots and choose Edit to open the new SSO entry.
 | 
			
		||||
 | 
			
		||||
6. Copy the generated Callback URL from Libredesk.
 | 
			
		||||
 | 
			
		||||
7. Back in Keycloak, edit the client and add the Callback URL to Valid Redirect URIs:
 | 
			
		||||
      - e.g., `https://ticket.example.com/api/v1/oidc/1/finish`
 | 
			
		||||
@@ -1,60 +0,0 @@
 | 
			
		||||
# Templating
 | 
			
		||||
 | 
			
		||||
Templating in outgoing emails allows you to personalize content by embedding dynamic expressions like `{{ .Recipient.FullName }}`. These expressions reference fields from the conversation, contact, recipient, and author objects.
 | 
			
		||||
 | 
			
		||||
## Outgoing Email Template Expressions
 | 
			
		||||
 | 
			
		||||
If you want to customize the look of outgoing emails, you can do so in the Admin > Templates -> Outgoing Email Templates section. This template will be used for all outgoing emails including replies to conversations, notifications, and other system-generated emails.
 | 
			
		||||
 | 
			
		||||
### Conversation Variables
 | 
			
		||||
 | 
			
		||||
| Variable | Value |
 | 
			
		||||
|---------------------------------|--------------------------------------------------------|
 | 
			
		||||
| {{ .Conversation.ReferenceNumber }} | The unique reference number of the conversation |
 | 
			
		||||
| {{ .Conversation.Subject }} | The subject of the conversation |
 | 
			
		||||
| {{ .Conversation.Priority }} | The priority level of the conversation |
 | 
			
		||||
| {{ .Conversation.UUID }} | The unique identifier of the conversation |
 | 
			
		||||
 | 
			
		||||
### Contact Variables
 | 
			
		||||
 | 
			
		||||
| Variable | Value |
 | 
			
		||||
|------------------------------|------------------------------------|
 | 
			
		||||
| {{ .Contact.FirstName }} | First name of the contact/customer |
 | 
			
		||||
| {{ .Contact.LastName }} | Last name of the contact/customer |
 | 
			
		||||
| {{ .Contact.FullName }} | Full name of the contact/customer |
 | 
			
		||||
| {{ .Contact.Email }} | Email address of the contact/customer |
 | 
			
		||||
 | 
			
		||||
### Recipient Variables
 | 
			
		||||
 | 
			
		||||
| Variable | Value |
 | 
			
		||||
|--------------------------------|-----------------------------------|
 | 
			
		||||
| {{ .Recipient.FirstName }} | First name of the recipient |
 | 
			
		||||
| {{ .Recipient.LastName }} | Last name of the recipient |
 | 
			
		||||
| {{ .Recipient.FullName }} | Full name of the recipient |
 | 
			
		||||
| {{ .Recipient.Email }} | Email address of the recipient |
 | 
			
		||||
 | 
			
		||||
### Author Variables
 | 
			
		||||
 | 
			
		||||
| Variable | Value |
 | 
			
		||||
|------------------------------|-----------------------------------|
 | 
			
		||||
| {{ .Author.FirstName }} | First name of the message author |
 | 
			
		||||
| {{ .Author.LastName }} | Last name of the message author |
 | 
			
		||||
| {{ .Author.FullName }} | Full name of the message author |
 | 
			
		||||
| {{ .Author.Email }} | Email address of the message author |
 | 
			
		||||
 | 
			
		||||
### Example outgoing email template
 | 
			
		||||
 | 
			
		||||
```html
 | 
			
		||||
Dear {{ .Recipient.FirstName }},
 | 
			
		||||
 | 
			
		||||
{{ template "content" . }}
 | 
			
		||||
 | 
			
		||||
Best regards,
 | 
			
		||||
{{ .Author.FullName }}
 | 
			
		||||
---
 | 
			
		||||
Reference: {{ .Conversation.ReferenceNumber }}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Here, the `{{ template "content" . }}` serves as a placeholder for the body of the outgoing email. It will be replaced with the actual email content at the time of sending.
 | 
			
		||||
 | 
			
		||||
Similarly, the `{{ .Recipient.FirstName }}` expression will dynamically insert the recipient's first name when the email is sent.
 | 
			
		||||
@@ -1,3 +0,0 @@
 | 
			
		||||
# Translations / Internationalization
 | 
			
		||||
 | 
			
		||||
You can help translate libreDesk into different languages by contributing here: [Libredesk Translation Project](https://crowdin.com/project/libredesk)
 | 
			
		||||
@@ -1,18 +0,0 @@
 | 
			
		||||
# Upgrade
 | 
			
		||||
 | 
			
		||||
!!! warning "Warning"
 | 
			
		||||
    Always take a backup of the Postgres database before upgrading Libredesk.
 | 
			
		||||
 | 
			
		||||
## Binary
 | 
			
		||||
- Stop running libredesk binary.
 | 
			
		||||
- Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary and overwrite the previous version.
 | 
			
		||||
- `./libredesk --upgrade` to upgrade an existing database schema. Upgrades are idempotent and running them multiple times have no side effects.
 | 
			
		||||
- Run `./libredesk` again.
 | 
			
		||||
 | 
			
		||||
## Docker
 | 
			
		||||
 | 
			
		||||
```shell
 | 
			
		||||
docker compose down app
 | 
			
		||||
docker compose pull
 | 
			
		||||
docker compose up app -d
 | 
			
		||||
```
 | 
			
		||||
@@ -1,222 +0,0 @@
 | 
			
		||||
# Webhooks
 | 
			
		||||
 | 
			
		||||
Webhooks allow you to receive real-time HTTP notifications when specific events occur in your Libredesk instance. This enables you to integrate Libredesk with external systems and automate workflows based on conversation and message events.
 | 
			
		||||
 | 
			
		||||
## Overview
 | 
			
		||||
 | 
			
		||||
When a configured event occurs in Libredesk, a HTTP POST request is sent to the webhook URL you specify. The request contains a JSON payload with event details and relevant data.
 | 
			
		||||
 | 
			
		||||
## Webhook Configuration
 | 
			
		||||
 | 
			
		||||
1. Navigate to **Admin > Integrations > Webhooks** in your Libredesk dashboard
 | 
			
		||||
2. Click **Create Webhook**
 | 
			
		||||
3. Configure the following:
 | 
			
		||||
   - **Name**: A descriptive name for your webhook
 | 
			
		||||
   - **URL**: The endpoint URL where webhook payloads will be sent
 | 
			
		||||
   - **Events**: Select which events you want to subscribe to
 | 
			
		||||
   - **Secret**: Optional secret key for signature verification
 | 
			
		||||
   - **Status**: Enable or disable the webhook
 | 
			
		||||
 | 
			
		||||
## Security
 | 
			
		||||
 | 
			
		||||
### Signature Verification
 | 
			
		||||
 | 
			
		||||
If you provide a secret key, webhook payloads will be signed using HMAC-SHA256. The signature is included in the `X-Signature-256` header in the format `sha256=<signature>`.
 | 
			
		||||
 | 
			
		||||
To verify the signature:
 | 
			
		||||
 | 
			
		||||
```python
 | 
			
		||||
import hmac
 | 
			
		||||
import hashlib
 | 
			
		||||
 | 
			
		||||
def verify_signature(payload, signature, secret):
 | 
			
		||||
    expected_signature = hmac.new(
 | 
			
		||||
        secret.encode('utf-8'),
 | 
			
		||||
        payload,
 | 
			
		||||
        hashlib.sha256
 | 
			
		||||
    ).hexdigest()
 | 
			
		||||
    return hmac.compare_digest(f"sha256={expected_signature}", signature)
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Headers
 | 
			
		||||
 | 
			
		||||
Each webhook request includes the following headers:
 | 
			
		||||
 | 
			
		||||
- `Content-Type`: `application/json`
 | 
			
		||||
- `User-Agent`: `Libredesk-Webhook/<libredesk_version_here>`
 | 
			
		||||
- `X-Signature-256`: HMAC signature (if secret is configured)
 | 
			
		||||
 | 
			
		||||
## Available Events
 | 
			
		||||
 | 
			
		||||
### Conversation Events
 | 
			
		||||
 | 
			
		||||
#### `conversation.created`
 | 
			
		||||
Triggered when a new conversation is created.
 | 
			
		||||
 | 
			
		||||
**Sample Payload:**
 | 
			
		||||
```json
 | 
			
		||||
{
 | 
			
		||||
  "event": "conversation.created",
 | 
			
		||||
  "timestamp": "2025-06-15T10:30:00Z",
 | 
			
		||||
  "payload": {
 | 
			
		||||
    "id": 123,
 | 
			
		||||
    "created_at": "2025-06-15T10:30:00Z",
 | 
			
		||||
    "updated_at": "2025-06-15T10:30:00Z",
 | 
			
		||||
    "uuid": "550e8400-e29b-41d4-a716-446655440000",
 | 
			
		||||
    "contact_id": 456,
 | 
			
		||||
    "inbox_id": 1,
 | 
			
		||||
    "reference_number": "100",
 | 
			
		||||
    "priority": "Medium",
 | 
			
		||||
    "priority_id": 2,
 | 
			
		||||
    "status": "Open",
 | 
			
		||||
    "status_id": 1,
 | 
			
		||||
    "subject": "Help with account setup",
 | 
			
		||||
    "inbox_name": "Support",
 | 
			
		||||
    "inbox_channel": "email",
 | 
			
		||||
    "contact": {
 | 
			
		||||
      "id": 456,
 | 
			
		||||
      "first_name": "John",
 | 
			
		||||
      "last_name": "Doe",
 | 
			
		||||
      "email": "john.doe@example.com",
 | 
			
		||||
      "type": "contact"
 | 
			
		||||
    },
 | 
			
		||||
    "custom_attributes": {},
 | 
			
		||||
    "tags": []
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### `conversation.status_changed`
 | 
			
		||||
Triggered when a conversation's status is updated.
 | 
			
		||||
 | 
			
		||||
**Sample Payload:**
 | 
			
		||||
```json
 | 
			
		||||
{
 | 
			
		||||
  "event": "conversation.status_changed",
 | 
			
		||||
  "timestamp": "2025-06-15T10:35:00Z",
 | 
			
		||||
  "payload": {
 | 
			
		||||
    "conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
 | 
			
		||||
    "previous_status": "Open",
 | 
			
		||||
    "new_status": "Resolved",
 | 
			
		||||
    "snooze_until": "",
 | 
			
		||||
    "actor_id": 789
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### `conversation.assigned`
 | 
			
		||||
Triggered when a conversation is assigned to a user.
 | 
			
		||||
 | 
			
		||||
**Sample Payload:**
 | 
			
		||||
```json
 | 
			
		||||
{
 | 
			
		||||
  "event": "conversation.assigned",
 | 
			
		||||
  "timestamp": "2025-06-15T10:32:00Z",
 | 
			
		||||
  "payload": {
 | 
			
		||||
    "conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
 | 
			
		||||
    "assigned_to": 789,
 | 
			
		||||
    "actor_id": 789
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### `conversation.unassigned`
 | 
			
		||||
Triggered when a conversation is unassigned from a user.
 | 
			
		||||
 | 
			
		||||
**Sample Payload:**
 | 
			
		||||
```json
 | 
			
		||||
{
 | 
			
		||||
  "event": "conversation.unassigned",
 | 
			
		||||
  "timestamp": "2025-06-15T10:40:00Z",
 | 
			
		||||
  "payload": {
 | 
			
		||||
    "conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
 | 
			
		||||
    "actor_id": 789
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### `conversation.tags_changed`
 | 
			
		||||
Triggered when tags are added or removed from a conversation.
 | 
			
		||||
 | 
			
		||||
**Sample Payload:**
 | 
			
		||||
```json
 | 
			
		||||
{
 | 
			
		||||
  "event": "conversation.tags_changed",
 | 
			
		||||
  "timestamp": "2025-06-15T10:45:00Z",
 | 
			
		||||
  "payload": {
 | 
			
		||||
    "conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
 | 
			
		||||
    "previous_tags": ["bug", "priority"],
 | 
			
		||||
    "new_tags": ["bug", "priority", "resolved"],
 | 
			
		||||
    "actor_id": 789
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Message Events
 | 
			
		||||
 | 
			
		||||
#### `message.created`
 | 
			
		||||
Triggered when a new message is created in a conversation.
 | 
			
		||||
 | 
			
		||||
**Sample Payload:**
 | 
			
		||||
```json
 | 
			
		||||
{
 | 
			
		||||
  "event": "message.created",
 | 
			
		||||
  "timestamp": "2025-06-15T10:33:00Z",
 | 
			
		||||
  "payload": {
 | 
			
		||||
    "id": 987,
 | 
			
		||||
    "created_at": "2025-06-15T10:33:00Z",
 | 
			
		||||
    "updated_at": "2025-06-15T10:33:00Z",
 | 
			
		||||
    "uuid": "123e4567-e89b-12d3-a456-426614174000",
 | 
			
		||||
    "type": "outgoing",
 | 
			
		||||
    "status": "sent",
 | 
			
		||||
    "conversation_id": 123,
 | 
			
		||||
    "content": "<p>Hello! How can I help you today?</p>",
 | 
			
		||||
    "text_content": "Hello! How can I help you today?",
 | 
			
		||||
    "content_type": "html",
 | 
			
		||||
    "private": false,
 | 
			
		||||
    "sender_id": 789,
 | 
			
		||||
    "sender_type": "agent",
 | 
			
		||||
    "attachments": []
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### `message.updated`
 | 
			
		||||
Triggered when an existing message is updated.
 | 
			
		||||
 | 
			
		||||
**Sample Payload:**
 | 
			
		||||
```json
 | 
			
		||||
{
 | 
			
		||||
  "event": "message.updated",
 | 
			
		||||
  "timestamp": "2025-06-15T10:34:00Z",
 | 
			
		||||
  "payload": {
 | 
			
		||||
    "id": 987,
 | 
			
		||||
    "created_at": "2025-06-15T10:33:00Z",
 | 
			
		||||
    "updated_at": "2025-06-15T10:34:00Z",
 | 
			
		||||
    "uuid": "123e4567-e89b-12d3-a456-426614174000",
 | 
			
		||||
    "type": "outgoing",
 | 
			
		||||
    "status": "sent",
 | 
			
		||||
    "conversation_id": 123,
 | 
			
		||||
    "content": "<p>Hello! How can I help you today? (Updated)</p>",
 | 
			
		||||
    "text_content": "Hello! How can I help you today? (Updated)",
 | 
			
		||||
    "content_type": "html",
 | 
			
		||||
    "private": false,
 | 
			
		||||
    "sender_id": 789,
 | 
			
		||||
    "sender_type": "agent",
 | 
			
		||||
    "attachments": []
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Delivery and Retries
 | 
			
		||||
 | 
			
		||||
- Webhooks requests timeout can be configured in the `config.toml` file
 | 
			
		||||
- Failed deliveries are not automatically retried
 | 
			
		||||
- Webhook delivery runs in a background worker pool for better performance
 | 
			
		||||
- If the webhook queue is full (configurable in config.toml file), new events may be dropped
 | 
			
		||||
 | 
			
		||||
## Testing Webhooks
 | 
			
		||||
 | 
			
		||||
You can test your webhook configuration using tools like:
 | 
			
		||||
 | 
			
		||||
- [Webhook.site](https://webhook.site) - Generate a temporary URL to inspect webhook payloads
 | 
			
		||||
@@ -1,37 +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
 | 
			
		||||
  - Contributions:
 | 
			
		||||
      - Developer Setup: developer-setup.md
 | 
			
		||||
      - Translate Libredesk: translations.md
 | 
			
		||||
@@ -2,23 +2,33 @@
 | 
			
		||||
 | 
			
		||||
describe('Login Component', () => {
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
        // Visit the login page
 | 
			
		||||
        cy.visit('/')
 | 
			
		||||
 | 
			
		||||
        // Mock the API response for OIDC providers
 | 
			
		||||
        cy.intercept('GET', '**/api/v1/oidc/enabled', {
 | 
			
		||||
        cy.intercept('GET', '**/api/v1/config', {
 | 
			
		||||
            statusCode: 200,
 | 
			
		||||
            body: {
 | 
			
		||||
                data: [
 | 
			
		||||
                data: {
 | 
			
		||||
                    "app.favicon_url": "http://localhost:9000/favicon.ico",
 | 
			
		||||
                    "app.lang": "en",
 | 
			
		||||
                    "app.logo_url": "http://localhost:9000/logo.png",
 | 
			
		||||
                    "app.site_name": "Libredesk",
 | 
			
		||||
                    "app.sso_providers": [
 | 
			
		||||
                        {
 | 
			
		||||
                        id: 1,
 | 
			
		||||
                        name: 'Google',
 | 
			
		||||
                        logo_url: 'https://example.com/google-logo.png',
 | 
			
		||||
                        disabled: false
 | 
			
		||||
                            "client_id": "xx",
 | 
			
		||||
                            "enabled": true,
 | 
			
		||||
                            "id": 1,
 | 
			
		||||
                            "logo_url": "/images/google-logo.png",
 | 
			
		||||
                            "name": "Google",
 | 
			
		||||
                            "provider": "Google",
 | 
			
		||||
                            "provider_url": "https://accounts.google.com",
 | 
			
		||||
                            "redirect_uri": "http://localhost:9000/api/v1/oidc/1/finish"
 | 
			
		||||
                        }
 | 
			
		||||
                    ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }).as('getOIDCProviders')
 | 
			
		||||
 | 
			
		||||
        // Visit the login page
 | 
			
		||||
        cy.visit('/')
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('should display login form', () => {
 | 
			
		||||
 
 | 
			
		||||
@@ -88,8 +88,8 @@
 | 
			
		||||
        @create-conversation="() => (openCreateConversationDialog = true)"
 | 
			
		||||
      >
 | 
			
		||||
        <div class="flex flex-col h-screen">
 | 
			
		||||
          <!-- Show app update only in admin routes -->
 | 
			
		||||
          <AppUpdate v-if="route.path.startsWith('/admin')" />
 | 
			
		||||
          <!-- Show admin banner only in admin routes -->
 | 
			
		||||
          <AdminBanner v-if="route.path.startsWith('/admin')" />
 | 
			
		||||
 | 
			
		||||
          <!-- Common header for all pages -->
 | 
			
		||||
          <PageHeader />
 | 
			
		||||
@@ -128,7 +128,7 @@ import { useCustomAttributeStore } from '@/stores/customAttributes'
 | 
			
		||||
import { useIdleDetection } from '@/composables/useIdleDetection'
 | 
			
		||||
import PageHeader from './components/layout/PageHeader.vue'
 | 
			
		||||
import ViewForm from '@/features/view/ViewForm.vue'
 | 
			
		||||
import AppUpdate from '@/components/update/AppUpdate.vue'
 | 
			
		||||
import AdminBanner from '@/components/banner/AdminBanner.vue'
 | 
			
		||||
import api from '@/api'
 | 
			
		||||
import { toast as sooner } from 'vue-sonner'
 | 
			
		||||
import Sidebar from '@/components/sidebar/Sidebar.vue'
 | 
			
		||||
 
 | 
			
		||||
@@ -122,7 +122,7 @@ const createOIDC = (data) =>
 | 
			
		||||
      'Content-Type': 'application/json'
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled')
 | 
			
		||||
const getConfig = () => http.get('/api/v1/config')
 | 
			
		||||
const getAllOIDC = () => http.get('/api/v1/oidc')
 | 
			
		||||
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
 | 
			
		||||
const updateOIDC = (id, data) =>
 | 
			
		||||
@@ -514,7 +514,7 @@ export default {
 | 
			
		||||
  updateSettings,
 | 
			
		||||
  createOIDC,
 | 
			
		||||
  getAllOIDC,
 | 
			
		||||
  getAllEnabledOIDC,
 | 
			
		||||
  getConfig,
 | 
			
		||||
  getOIDC,
 | 
			
		||||
  updateOIDC,
 | 
			
		||||
  deleteOIDC,
 | 
			
		||||
 
 | 
			
		||||
@@ -137,10 +137,10 @@
 | 
			
		||||
    --background: 240 5.9% 10%;
 | 
			
		||||
    --foreground: 0 0% 98%;
 | 
			
		||||
 | 
			
		||||
    --card: 240 10% 3.9%;
 | 
			
		||||
    --card: 240 5.9% 10%;
 | 
			
		||||
    --card-foreground: 0 0% 98%;
 | 
			
		||||
 | 
			
		||||
    --popover: 240 10% 3.9%;
 | 
			
		||||
    --popover: 240 5.9% 10%;
 | 
			
		||||
    --popover-foreground: 0 0% 98%;
 | 
			
		||||
 | 
			
		||||
    --primary: 0 0% 98%;
 | 
			
		||||
@@ -184,6 +184,10 @@
 | 
			
		||||
  @apply border shadow rounded;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.loading-fade {
 | 
			
		||||
  @apply opacity-50 transition-opacity duration-300
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Scrollbar start
 | 
			
		||||
::-webkit-scrollbar {
 | 
			
		||||
  width: 8px; /* Adjust width */
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										63
									
								
								frontend/src/components/banner/AdminBanner.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								frontend/src/components/banner/AdminBanner.vue
									
									
									
									
									
										Normal 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>
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <Button
 | 
			
		||||
    variant="ghost"
 | 
			
		||||
    @click.prevent="onClose"
 | 
			
		||||
    @click.stop="onClose"
 | 
			
		||||
    size="xs"
 | 
			
		||||
    class="text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 w-6 h-6 p-0"
 | 
			
		||||
  >
 | 
			
		||||
 
 | 
			
		||||
@@ -52,8 +52,15 @@
 | 
			
		||||
        <div class="flex-1">
 | 
			
		||||
          <div v-if="modelFilter.field && modelFilter.operator">
 | 
			
		||||
            <template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
 | 
			
		||||
              <SelectTag
 | 
			
		||||
                v-if="getFieldType(modelFilter) === FIELD_TYPE.MULTI_SELECT"
 | 
			
		||||
                v-model="modelFilter.value"
 | 
			
		||||
                :items="getFieldOptions(modelFilter)"
 | 
			
		||||
                :placeholder="t('globals.messages.select', { name: t('globals.terms.tag', 2) })"
 | 
			
		||||
              />
 | 
			
		||||
 | 
			
		||||
              <SelectComboBox
 | 
			
		||||
                v-if="
 | 
			
		||||
                v-else-if="
 | 
			
		||||
                  getFieldOptions(modelFilter).length > 0 &&
 | 
			
		||||
                  modelFilter.field === 'assigned_user_id'
 | 
			
		||||
                "
 | 
			
		||||
@@ -94,8 +101,9 @@
 | 
			
		||||
      <CloseButton :onClose="() => removeFilter(index)" />
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Button Container -->
 | 
			
		||||
    <div class="flex items-center justify-between pt-3">
 | 
			
		||||
      <Button variant="ghost" size="sm" @click="addFilter" class="text-slate-600">
 | 
			
		||||
      <Button variant="ghost" size="sm" @click.stop="addFilter" class="text-slate-600">
 | 
			
		||||
        <Plus class="w-3 h-3 mr-1" />
 | 
			
		||||
        {{
 | 
			
		||||
          $t('globals.messages.add', {
 | 
			
		||||
@@ -104,15 +112,17 @@
 | 
			
		||||
        }}
 | 
			
		||||
      </Button>
 | 
			
		||||
      <div class="flex gap-2" v-if="showButtons">
 | 
			
		||||
        <Button variant="ghost" @click="clearFilters">{{ $t('globals.messages.reset') }}</Button>
 | 
			
		||||
        <Button @click="applyFilters">{{ $t('globals.messages.apply') }}</Button>
 | 
			
		||||
        <Button variant="ghost" @click.stop="clearFilters">
 | 
			
		||||
          {{ $t('globals.messages.reset') }}
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button @click.stop="applyFilters">{{ $t('globals.messages.apply') }}</Button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed, onMounted, watch } from 'vue'
 | 
			
		||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
 | 
			
		||||
import {
 | 
			
		||||
  Select,
 | 
			
		||||
  SelectContent,
 | 
			
		||||
@@ -125,8 +135,10 @@ import { Plus } from 'lucide-vue-next'
 | 
			
		||||
import { Button } from '@/components/ui/button'
 | 
			
		||||
import { Input } from '@/components/ui/input'
 | 
			
		||||
import { useI18n } from 'vue-i18n'
 | 
			
		||||
import { FIELD_TYPE } from '@/constants/filterConfig'
 | 
			
		||||
import CloseButton from '@/components/button/CloseButton.vue'
 | 
			
		||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
			
		||||
import SelectTag from '@/components/ui/select/SelectTag.vue'
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  fields: {
 | 
			
		||||
@@ -150,12 +162,17 @@ onMounted(() => {
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
onUnmounted(() => {
 | 
			
		||||
  // On unmounted set valid filters
 | 
			
		||||
  modelValue.value = validFilters.value
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const getModel = (field) => {
 | 
			
		||||
  const fieldConfig = props.fields.find((f) => f.field === field)
 | 
			
		||||
  return fieldConfig?.model || ''
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Set model for each filter
 | 
			
		||||
// Set model for each filter and the default value
 | 
			
		||||
watch(
 | 
			
		||||
  () => modelValue.value,
 | 
			
		||||
  (filters) => {
 | 
			
		||||
@@ -163,6 +180,15 @@ watch(
 | 
			
		||||
      if (filter.field && !filter.model) {
 | 
			
		||||
        filter.model = getModel(filter.field)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Multi select need arrays as their default value
 | 
			
		||||
      if (
 | 
			
		||||
        filter.field &&
 | 
			
		||||
        getFieldType(filter) === FIELD_TYPE.MULTI_SELECT &&
 | 
			
		||||
        !Array.isArray(filter.value)
 | 
			
		||||
      ) {
 | 
			
		||||
        filter.value = []
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  },
 | 
			
		||||
  { deep: true }
 | 
			
		||||
@@ -170,15 +196,20 @@ watch(
 | 
			
		||||
 | 
			
		||||
// Reset operator and value when field changes for a filter at a given index
 | 
			
		||||
watch(
 | 
			
		||||
  () => modelValue.value.map((f) => f.field),
 | 
			
		||||
  (newFields, oldFields) => {
 | 
			
		||||
    newFields.forEach((field, index) => {
 | 
			
		||||
      if (field !== oldFields[index]) {
 | 
			
		||||
        modelValue.value[index].operator = ''
 | 
			
		||||
        modelValue.value[index].value = ''
 | 
			
		||||
  modelValue,
 | 
			
		||||
  (newFilters, oldFilters) => {
 | 
			
		||||
    // Skip first run
 | 
			
		||||
    if (!oldFilters) return
 | 
			
		||||
 | 
			
		||||
    newFilters.forEach((filter, index) => {
 | 
			
		||||
      const oldFilter = oldFilters[index]
 | 
			
		||||
      if (oldFilter && filter.field !== oldFilter.field) {
 | 
			
		||||
        filter.operator = ''
 | 
			
		||||
        filter.value = ''
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
  },
 | 
			
		||||
  { deep: true }
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const addFilter = () => {
 | 
			
		||||
@@ -197,7 +228,17 @@ const clearFilters = () => {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const validFilters = computed(() => {
 | 
			
		||||
  return modelValue.value.filter((filter) => filter.field && filter.operator && filter.value)
 | 
			
		||||
  return modelValue.value.filter((filter) => {
 | 
			
		||||
    // For multi-select field type, allow empty array as a valid value
 | 
			
		||||
    const field = props.fields.find((f) => f.field === filter.field)
 | 
			
		||||
    const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT
 | 
			
		||||
 | 
			
		||||
    if (isMultiSelectField) {
 | 
			
		||||
      return filter.field && filter.operator && filter.value !== undefined && filter.value !== null
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return filter.field && filter.operator && filter.value
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const getFieldOptions = (fieldValue) => {
 | 
			
		||||
@@ -209,4 +250,9 @@ const getFieldOperators = (modelFilter) => {
 | 
			
		||||
  const field = props.fields.find((f) => f.field === modelFilter.field)
 | 
			
		||||
  return field?.operators || []
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const getFieldType = (modelFilter) => {
 | 
			
		||||
  const field = props.fields.find((f) => f.field === modelFilter.field)
 | 
			
		||||
  return field?.type || ''
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -4,9 +4,9 @@
 | 
			
		||||
    @click="handleClick">
 | 
			
		||||
    <div class="flex items-center mb-2">
 | 
			
		||||
      <component :is="icon" size="24" class="mr-2 text-primary" />
 | 
			
		||||
      <h3 class="text-lg font-medium text-gray-800">{{ title }}</h3>
 | 
			
		||||
      <h3 class="text-lg font-medium">{{ title }}</h3>
 | 
			
		||||
    </div>
 | 
			
		||||
    <p class="text-sm text-gray-600">{{ subTitle }}</p>
 | 
			
		||||
    <p class="text-sm text-gray-600 dark:text-gray-400">{{ subTitle }}</p>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -38,6 +38,16 @@ import {
 | 
			
		||||
  DropdownMenuItem,
 | 
			
		||||
  DropdownMenuTrigger
 | 
			
		||||
} from '@/components/ui/dropdown-menu'
 | 
			
		||||
import {
 | 
			
		||||
  AlertDialog,
 | 
			
		||||
  AlertDialogAction,
 | 
			
		||||
  AlertDialogCancel,
 | 
			
		||||
  AlertDialogContent,
 | 
			
		||||
  AlertDialogDescription,
 | 
			
		||||
  AlertDialogFooter,
 | 
			
		||||
  AlertDialogHeader,
 | 
			
		||||
  AlertDialogTitle
 | 
			
		||||
} from '@/components/ui/alert-dialog'
 | 
			
		||||
import { filterNavItems } from '@/utils/nav-permissions'
 | 
			
		||||
import { useStorage } from '@vueuse/core'
 | 
			
		||||
import { computed, ref, watch } from 'vue'
 | 
			
		||||
@@ -73,8 +83,17 @@ const editView = (view) => {
 | 
			
		||||
  emit('editView', view)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const deleteView = (view) => {
 | 
			
		||||
  emit('deleteView', view)
 | 
			
		||||
const openDeleteConfirmation = (view) => {
 | 
			
		||||
  viewToDelete.value = view
 | 
			
		||||
  isDeleteOpen.value = true
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const handleDeleteView = () => {
 | 
			
		||||
  if (viewToDelete.value) {
 | 
			
		||||
    emit('deleteView', viewToDelete.value)
 | 
			
		||||
    isDeleteOpen.value = false
 | 
			
		||||
    viewToDelete.value = null
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Navigation methods with conversation retention
 | 
			
		||||
@@ -157,6 +176,13 @@ watch(
 | 
			
		||||
const sidebarOpen = useStorage('mainSidebarOpen', true)
 | 
			
		||||
const teamInboxOpen = useStorage('teamInboxOpen', true)
 | 
			
		||||
const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
			
		||||
 | 
			
		||||
// Track which view is being hovered for ellipsis menu visibility
 | 
			
		||||
const hoveredViewId = ref(null)
 | 
			
		||||
 | 
			
		||||
// Track delete confirmation dialog state
 | 
			
		||||
const isDeleteOpen = ref(false)
 | 
			
		||||
const viewToDelete = ref(null)
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
@@ -472,24 +498,35 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
			
		||||
 | 
			
		||||
                  <CollapsibleContent>
 | 
			
		||||
                    <SidebarMenuSub v-for="view in userViews" :key="view.id">
 | 
			
		||||
                      <SidebarMenuSubItem>
 | 
			
		||||
                      <SidebarMenuSubItem
 | 
			
		||||
                        @mouseenter="hoveredViewId = view.id"
 | 
			
		||||
                        @mouseleave="hoveredViewId = null"
 | 
			
		||||
                      >
 | 
			
		||||
                        <SidebarMenuButton
 | 
			
		||||
                          size="sm"
 | 
			
		||||
                          :isActive="route.params.viewID == view.id"
 | 
			
		||||
                          asChild
 | 
			
		||||
                        >
 | 
			
		||||
                          <a href="#" @click.prevent="navigateToViewInbox(view.id)">
 | 
			
		||||
                            <span class="break-words w-32 truncate">{{ view.name }}</span>
 | 
			
		||||
                            <SidebarMenuAction :showOnHover="true" class="mr-3">
 | 
			
		||||
                            <span class="break-words w-32 truncate" :title="view.name">{{ view.name }}</span>
 | 
			
		||||
                            <SidebarMenuAction
 | 
			
		||||
                              @click.stop
 | 
			
		||||
                              :class="[
 | 
			
		||||
                                'mr-3',
 | 
			
		||||
                                'md:opacity-0',
 | 
			
		||||
                                'data-[state=open]:opacity-100',
 | 
			
		||||
                                { 'md:opacity-100': hoveredViewId === view.id }
 | 
			
		||||
                              ]"
 | 
			
		||||
                            >
 | 
			
		||||
                              <DropdownMenu>
 | 
			
		||||
                                <DropdownMenuTrigger asChild>
 | 
			
		||||
                                <DropdownMenuTrigger asChild @click.prevent>
 | 
			
		||||
                                  <EllipsisVertical />
 | 
			
		||||
                                </DropdownMenuTrigger>
 | 
			
		||||
                                <DropdownMenuContent>
 | 
			
		||||
                                  <DropdownMenuItem @click="() => editView(view)">
 | 
			
		||||
                                    <span>{{ t('globals.messages.edit') }}</span>
 | 
			
		||||
                                  </DropdownMenuItem>
 | 
			
		||||
                                  <DropdownMenuItem @click="() => deleteView(view)">
 | 
			
		||||
                                  <DropdownMenuItem @click="() => openDeleteConfirmation(view)">
 | 
			
		||||
                                    <span>{{ t('globals.messages.delete') }}</span>
 | 
			
		||||
                                  </DropdownMenuItem>
 | 
			
		||||
                                </DropdownMenuContent>
 | 
			
		||||
@@ -513,4 +550,22 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
			
		||||
      <slot></slot>
 | 
			
		||||
    </SidebarInset>
 | 
			
		||||
  </SidebarProvider>
 | 
			
		||||
 | 
			
		||||
  <!-- View Delete Confirmation Dialog -->
 | 
			
		||||
  <AlertDialog v-model:open="isDeleteOpen">
 | 
			
		||||
    <AlertDialogContent>
 | 
			
		||||
      <AlertDialogHeader>
 | 
			
		||||
        <AlertDialogTitle>{{ t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
 | 
			
		||||
        <AlertDialogDescription>
 | 
			
		||||
          {{ t('globals.messages.deletionConfirmation', { name: t('globals.terms.view') }) }}
 | 
			
		||||
        </AlertDialogDescription>
 | 
			
		||||
      </AlertDialogHeader>
 | 
			
		||||
      <AlertDialogFooter>
 | 
			
		||||
        <AlertDialogCancel>{{ t('globals.messages.cancel') }}</AlertDialogCancel>
 | 
			
		||||
        <AlertDialogAction @click="handleDeleteView">
 | 
			
		||||
          {{ t('globals.messages.delete') }}
 | 
			
		||||
        </AlertDialogAction>
 | 
			
		||||
      </AlertDialogFooter>
 | 
			
		||||
    </AlertDialogContent>
 | 
			
		||||
  </AlertDialog>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@
 | 
			
		||||
        :class="['w-full justify-between', buttonClass]"
 | 
			
		||||
      >
 | 
			
		||||
        <slot name="selected" :selected="selectedItem">{{ selectedLabel }}</slot>
 | 
			
		||||
        <CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
 | 
			
		||||
        <CaretSortIcon class="h-4 w-4 shrink-0 opacity-50" />
 | 
			
		||||
      </Button>
 | 
			
		||||
    </PopoverTrigger>
 | 
			
		||||
    <PopoverContent class="p-0">
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <!-- idk why I named this select tag, should be named multi-select -->
 | 
			
		||||
  <TagsInput v-model="tags" class="px-0 gap-0" :displayValue="getLabel">
 | 
			
		||||
    <!-- Tags visible to the user -->
 | 
			
		||||
    <div class="flex gap-2 flex-wrap items-center px-3">
 | 
			
		||||
@@ -24,6 +25,7 @@
 | 
			
		||||
            @keydown.enter.prevent
 | 
			
		||||
            @blur="handleBlur"
 | 
			
		||||
            @click="open = true"
 | 
			
		||||
            @input.stop
 | 
			
		||||
          />
 | 
			
		||||
        </ComboboxInput>
 | 
			
		||||
      </ComboboxAnchor>
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -5,6 +5,7 @@ import { useUsersStore } from '@/stores/users'
 | 
			
		||||
import { useTeamStore } from '@/stores/team'
 | 
			
		||||
import { useSlaStore } from '@/stores/sla'
 | 
			
		||||
import { useCustomAttributeStore } from '@/stores/customAttributes'
 | 
			
		||||
import { useTagStore } from '@/stores/tag'
 | 
			
		||||
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
 | 
			
		||||
import { useI18n } from 'vue-i18n'
 | 
			
		||||
 | 
			
		||||
@@ -15,6 +16,7 @@ export function useConversationFilters () {
 | 
			
		||||
    const tStore = useTeamStore()
 | 
			
		||||
    const slaStore = useSlaStore()
 | 
			
		||||
    const customAttributeStore = useCustomAttributeStore()
 | 
			
		||||
    const tagStore = useTagStore()
 | 
			
		||||
    const { t } = useI18n()
 | 
			
		||||
 | 
			
		||||
    const customAttributeDataTypeToFieldType = {
 | 
			
		||||
@@ -69,6 +71,12 @@ export function useConversationFilters () {
 | 
			
		||||
            type: FIELD_TYPE.SELECT,
 | 
			
		||||
            operators: FIELD_OPERATORS.SELECT,
 | 
			
		||||
            options: iStore.options
 | 
			
		||||
        },
 | 
			
		||||
        tags: {
 | 
			
		||||
            label: t('globals.terms.tag', 2),
 | 
			
		||||
            type: FIELD_TYPE.MULTI_SELECT,
 | 
			
		||||
            operators: FIELD_OPERATORS.MULTI_SELECT,
 | 
			
		||||
            options: tagStore.tagOptions
 | 
			
		||||
        }
 | 
			
		||||
    }))
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
export const FIELD_TYPE = {
 | 
			
		||||
    SELECT: 'select',
 | 
			
		||||
    TAG: 'tag',
 | 
			
		||||
    MULTI_SELECT: 'multi-select',
 | 
			
		||||
    TEXT: 'text',
 | 
			
		||||
    NUMBER: 'number',
 | 
			
		||||
    RICHTEXT: 'richtext',
 | 
			
		||||
@@ -39,4 +40,5 @@ export const FIELD_OPERATORS = {
 | 
			
		||||
        OPERATOR.LESS_THAN
 | 
			
		||||
    ],
 | 
			
		||||
    NUMBER: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.GREATER_THAN, OPERATOR.LESS_THAN],
 | 
			
		||||
    MULTI_SELECT: [OPERATOR.CONTAINS, OPERATOR.NOT_CONTAINS, OPERATOR.SET, OPERATOR.NOT_SET]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,7 @@ export const permissions = {
 | 
			
		||||
  CONVERSATIONS_UPDATE_TAGS: 'conversations:update_tags',
 | 
			
		||||
  MESSAGES_READ: 'messages:read',
 | 
			
		||||
  MESSAGES_WRITE: 'messages:write',
 | 
			
		||||
  MESSAGES_WRITE_AS_CONTACT: 'messages:write_as_contact',
 | 
			
		||||
  VIEW_MANAGE: 'view:manage',
 | 
			
		||||
  GENERAL_SETTINGS_MANAGE: 'general_settings:manage',
 | 
			
		||||
  NOTIFICATION_SETTINGS_MANAGE: 'notification_settings:manage',
 | 
			
		||||
 
 | 
			
		||||
@@ -1 +1,3 @@
 | 
			
		||||
export const Roles = ["Admin", "Agent"]
 | 
			
		||||
export const UserTypeAgent = "agent"
 | 
			
		||||
export const UserTypeContact = "contact"
 | 
			
		||||
@@ -418,7 +418,6 @@ const onSubmit = form.handleSubmit((values) => {
 | 
			
		||||
  if (values.availability_status === 'active_group') {
 | 
			
		||||
    values.availability_status = 'online'
 | 
			
		||||
  }
 | 
			
		||||
  values.teams = values.teams.map((team) => ({ name: team }))
 | 
			
		||||
  props.submitForm(values)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
 | 
			
		||||
      return h('div', { class: 'text-center' }, t('globals.terms.firstName'))
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('first_name'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('first_name'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
 | 
			
		||||
      return h('div', { class: 'text-center' }, t('globals.terms.lastName'))
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('last_name'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('last_name'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
 | 
			
		||||
      return h('div', { class: 'text-center' }, t('globals.terms.enabled'))
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
@@ -36,7 +36,7 @@ export const createColumns = (t) => [
 | 
			
		||||
      return h('div', { class: 'text-center' }, t('globals.terms.email'))
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('email'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('email'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
@@ -47,7 +47,7 @@ export const createColumns = (t) => [
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h(
 | 
			
		||||
        'div',
 | 
			
		||||
        { class: 'text-center font-medium' },
 | 
			
		||||
        { class: 'text-center' },
 | 
			
		||||
        format(row.getValue('created_at'), 'PPpp')
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
@@ -60,7 +60,7 @@ export const createColumns = (t) => [
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h(
 | 
			
		||||
        'div',
 | 
			
		||||
        { class: 'text-center font-medium' },
 | 
			
		||||
        { class: 'text-center' },
 | 
			
		||||
        format(row.getValue('updated_at'), 'PPpp')
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
 | 
			
		||||
            return h('div', { class: 'text-center' }, t('globals.terms.name'))
 | 
			
		||||
        },
 | 
			
		||||
        cell: function ({ row }) {
 | 
			
		||||
            return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
			
		||||
            return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
 | 
			
		||||
            return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
 | 
			
		||||
        },
 | 
			
		||||
        cell: function ({ row }) {
 | 
			
		||||
            return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
 | 
			
		||||
            return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
 | 
			
		||||
            return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
 | 
			
		||||
        },
 | 
			
		||||
        cell: function ({ row }) {
 | 
			
		||||
            return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
			
		||||
            return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
 | 
			
		||||
            return h('div', { class: 'text-center' }, t('globals.terms.name'))
 | 
			
		||||
        },
 | 
			
		||||
        cell: function ({ row }) {
 | 
			
		||||
            return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
			
		||||
            return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
 | 
			
		||||
            return h('div', { class: 'text-center' }, t('globals.terms.key'))
 | 
			
		||||
        },
 | 
			
		||||
        cell: function ({ row }) {
 | 
			
		||||
            return h('div', { class: 'text-center font-medium' }, row.getValue('key'))
 | 
			
		||||
            return h('div', { class: 'text-center' }, row.getValue('key'))
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
 | 
			
		||||
            return h('div', { class: 'text-center' }, t('globals.terms.type'))
 | 
			
		||||
        },
 | 
			
		||||
        cell: function ({ row }) {
 | 
			
		||||
            return h('div', { class: 'text-center font-medium' }, row.getValue('data_type'))
 | 
			
		||||
            return h('div', { class: 'text-center' }, row.getValue('data_type'))
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
@@ -36,7 +36,7 @@ export const createColumns = (t) => [
 | 
			
		||||
            return h('div', { class: 'text-center' }, t('globals.terms.appliesTo'))
 | 
			
		||||
        },
 | 
			
		||||
        cell: function ({ row }) {
 | 
			
		||||
            return h('div', { class: 'text-center font-medium' }, row.getValue('applies_to'))
 | 
			
		||||
            return h('div', { class: 'text-center' }, row.getValue('applies_to'))
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
@@ -47,7 +47,7 @@ export const createColumns = (t) => [
 | 
			
		||||
        cell: function ({ row }) {
 | 
			
		||||
            return h(
 | 
			
		||||
                'div',
 | 
			
		||||
                { class: 'text-center font-medium' },
 | 
			
		||||
                { class: 'text-center' },
 | 
			
		||||
                format(row.getValue('created_at'), 'PPpp')
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
@@ -60,7 +60,7 @@ export const createColumns = (t) => [
 | 
			
		||||
        cell: function ({ row }) {
 | 
			
		||||
            return h(
 | 
			
		||||
                'div',
 | 
			
		||||
                { class: 'text-center font-medium' },
 | 
			
		||||
                { class: 'text-center' },
 | 
			
		||||
                format(row.getValue('updated_at'), 'PPpp')
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
 | 
			
		||||
      return h('div', { class: 'text-center' }, t('globals.terms.name'))
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
 | 
			
		||||
      return h('div', { class: 'text-center' }, t('globals.terms.name'))
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
 | 
			
		||||
      return h('div', { class: 'text-center' }, t('globals.terms.provider'))
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('provider'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('provider'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
 
 | 
			
		||||
@@ -140,6 +140,7 @@ const permissions = ref([
 | 
			
		||||
      { name: perms.CONVERSATIONS_UPDATE_TAGS, label: t('admin.role.conversations.updateTags') },
 | 
			
		||||
      { name: perms.MESSAGES_READ, label: t('admin.role.messages.read') },
 | 
			
		||||
      { name: perms.MESSAGES_WRITE, label: t('admin.role.messages.write') },
 | 
			
		||||
      { name: perms.MESSAGES_WRITE_AS_CONTACT, label: t('admin.role.messages.writeAsContact') },
 | 
			
		||||
      { name: perms.VIEW_MANAGE, label: t('admin.role.view.manage') }
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@ export const createColumns = (t) => [
 | 
			
		||||
      return h('div', { class: 'text-center' }, t('globals.terms.name'))
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
@@ -17,7 +17,7 @@ export const createColumns = (t) => [
 | 
			
		||||
      return h('div', { class: 'text-center' }, t('globals.terms.description'))
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('description'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('description'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
 | 
			
		||||
            return h('div', { class: 'text-center' }, t('globals.terms.name'))
 | 
			
		||||
        },
 | 
			
		||||
        cell: function ({ row }) {
 | 
			
		||||
            return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
			
		||||
            return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
 | 
			
		||||
            return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
 | 
			
		||||
        },
 | 
			
		||||
        cell: function ({ row }) {
 | 
			
		||||
            return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
 | 
			
		||||
            return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
 | 
			
		||||
            return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
 | 
			
		||||
        },
 | 
			
		||||
        cell: function ({ row }) {
 | 
			
		||||
            return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
			
		||||
            return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
 | 
			
		||||
      return h('div', { class: 'text-center' }, t('globals.terms.name'))
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
 | 
			
		||||
      return h('div', { class: 'text-center' }, t('globals.terms.name'))
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ export const columns = [
 | 
			
		||||
      return h('div', { class: 'text-center' }, 'Name')
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
@@ -20,7 +20,7 @@ export const columns = [
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h(
 | 
			
		||||
        'div',
 | 
			
		||||
        { class: 'text-center font-medium' },
 | 
			
		||||
        { class: 'text-center' },
 | 
			
		||||
        format(row.getValue('created_at'), 'PPpp')
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
@@ -33,7 +33,7 @@ export const columns = [
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h(
 | 
			
		||||
        'div',
 | 
			
		||||
        { class: 'text-center font-medium' },
 | 
			
		||||
        { class: 'text-center' },
 | 
			
		||||
        format(row.getValue('updated_at'), 'PPpp')
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ export const createOutgoingEmailTableColumns = (t) => [
 | 
			
		||||
      return h('div', { class: 'text-center' }, t('globals.terms.name'))
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
@@ -60,7 +60,7 @@ export const createEmailNotificationTableColumns = (t) => [
 | 
			
		||||
      return h('div', { class: 'text-center' }, t('globals.terms.name'))
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,7 @@ export const createColumns = (t) => [
 | 
			
		||||
      return h('div', { class: 'text-center' }, t('globals.terms.name'))
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
 
 | 
			
		||||
@@ -41,8 +41,8 @@
 | 
			
		||||
 | 
			
		||||
      <div class="flex flex-col flex-1">
 | 
			
		||||
        <div class="flex items-end">
 | 
			
		||||
          <FormField v-slot="{ componentField }" name="phone_number_calling_code">
 | 
			
		||||
            <FormItem class="w-20">
 | 
			
		||||
          <FormField v-slot="{ componentField }" name="phone_number_country_code">
 | 
			
		||||
            <FormItem class="w-max">
 | 
			
		||||
              <FormLabel class="flex items-center whitespace-nowrap">
 | 
			
		||||
                {{ t('globals.terms.phoneNumber') }}
 | 
			
		||||
              </FormLabel>
 | 
			
		||||
@@ -58,13 +58,18 @@
 | 
			
		||||
                      <div class="w-7 h-7 flex items-center justify-center">
 | 
			
		||||
                        <span v-if="item.emoji">{{ item.emoji }}</span>
 | 
			
		||||
                      </div>
 | 
			
		||||
                      <span class="text-sm">{{ item.label }} ({{ item.value }})</span>
 | 
			
		||||
                      <span class="text-sm">{{ item.label }} ({{ item.calling_code }})</span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </template>
 | 
			
		||||
 | 
			
		||||
                  <template #selected="{ selected }">
 | 
			
		||||
                    <div class="flex items-center mb-1">
 | 
			
		||||
                      <span v-if="selected" class="text-xl leading-none">{{ selected.emoji }}</span>
 | 
			
		||||
                    <div class="flex items-center gap-1">
 | 
			
		||||
                      <span v-if="selected" class="text-lg">{{ selected.emoji }}</span>
 | 
			
		||||
                      <span
 | 
			
		||||
                        v-if="selected && selected.calling_code"
 | 
			
		||||
                        class="text-xs text-muted-foreground"
 | 
			
		||||
                        >({{ selected.calling_code }})</span
 | 
			
		||||
                      >
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </template>
 | 
			
		||||
                </ComboBox>
 | 
			
		||||
@@ -116,7 +121,8 @@ const userStore = useUserStore()
 | 
			
		||||
 | 
			
		||||
const allCountries = countries.map((country) => ({
 | 
			
		||||
  label: country.name,
 | 
			
		||||
  value: country.calling_code,
 | 
			
		||||
  emoji: country.emoji
 | 
			
		||||
  value: country.iso_2,
 | 
			
		||||
  emoji: country.emoji,
 | 
			
		||||
  calling_code: country.calling_code
 | 
			
		||||
}))
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -33,13 +33,7 @@
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="flex justify-end space-x-3 pt-2">
 | 
			
		||||
            <Button
 | 
			
		||||
              variant="outline"
 | 
			
		||||
              @click="cancelAddNote"
 | 
			
		||||
              class="transition-all hover:bg-gray-100"
 | 
			
		||||
            >
 | 
			
		||||
              Cancel
 | 
			
		||||
            </Button>
 | 
			
		||||
            <Button variant="outline" @click="cancelAddNote"> Cancel </Button>
 | 
			
		||||
            <Button type="submit" :disabled="!newNote.trim()">
 | 
			
		||||
              {{ $t('globals.messages.save') + ' ' + $t('globals.terms.note').toLowerCase() }}
 | 
			
		||||
            </Button>
 | 
			
		||||
@@ -53,13 +47,13 @@
 | 
			
		||||
      <Card
 | 
			
		||||
        v-for="note in notes"
 | 
			
		||||
        :key="note.id"
 | 
			
		||||
        class="overflow-hidden border-gray-2 hover:border-gray-300 transition-all duration-200 box hover:shadow"
 | 
			
		||||
        class="overflow-hidden border-gray-2 dark:hover:border-gray-700 hover:border-gray-300 transition-all duration-200 box hover:shadow"
 | 
			
		||||
      >
 | 
			
		||||
        <!-- Header -->
 | 
			
		||||
        <CardHeader class="bg-gray-50/50 dark:bg-secondary border-b p-2">
 | 
			
		||||
        <CardHeader class="bg-background border-b p-2">
 | 
			
		||||
          <div class="flex items-center justify-between">
 | 
			
		||||
            <div class="flex items-center space-x-3">
 | 
			
		||||
              <Avatar class="border border-gray-200 shadow-sm">
 | 
			
		||||
              <Avatar class="border shadow-sm">
 | 
			
		||||
                <AvatarImage :src="note.avatar_url" />
 | 
			
		||||
                <AvatarFallback>
 | 
			
		||||
                  {{ getInitials(note.first_name, note.last_name) }}
 | 
			
		||||
 
 | 
			
		||||
@@ -29,7 +29,7 @@ export const createFormSchema = (t) => z.object({
 | 
			
		||||
            })
 | 
			
		||||
        })
 | 
			
		||||
        .nullable(),
 | 
			
		||||
    phone_number_calling_code: z.string().optional().nullable(),
 | 
			
		||||
    phone_number_country_code: z.string().optional().nullable(),
 | 
			
		||||
    avatar_url: z.string().optional().nullable(),
 | 
			
		||||
    email: z
 | 
			
		||||
        .string({
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,106 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="h-screen w-full flex items-center justify-center min-w-[400px]">
 | 
			
		||||
    <p>{{ $t('conversation.placeholder') }}</p>
 | 
			
		||||
  <div class="placeholder-container">
 | 
			
		||||
    <Spinner v-if="isLoading" />
 | 
			
		||||
    <template v-else>
 | 
			
		||||
      <div v-if="showGettingStarted" class="getting-started-wrapper">
 | 
			
		||||
        <div class="text-center">
 | 
			
		||||
          <h2 class="text-2xl font-semibold text-foreground mb-6">
 | 
			
		||||
            {{ $t('setup.completeYourSetup') }}
 | 
			
		||||
          </h2>
 | 
			
		||||
 | 
			
		||||
          <div class="space-y-4 mb-6">
 | 
			
		||||
            <div class="checklist-item" :class="{ completed: hasInboxes }">
 | 
			
		||||
              <CheckCircle v-if="hasInboxes" class="check-icon completed" />
 | 
			
		||||
              <Circle v-else class="w-5 h-5 text-muted-foreground" />
 | 
			
		||||
              <span class="flex-1 text-left ml-3 text-foreground">
 | 
			
		||||
                {{ $t('setup.createFirstInbox') }}
 | 
			
		||||
              </span>
 | 
			
		||||
              <Button
 | 
			
		||||
                v-if="!hasInboxes"
 | 
			
		||||
                variant="ghost"
 | 
			
		||||
                size="sm"
 | 
			
		||||
                @click="router.push({ name: 'inbox-list' })"
 | 
			
		||||
                class="ml-auto"
 | 
			
		||||
              >
 | 
			
		||||
                {{ $t('globals.messages.setUp') }}
 | 
			
		||||
              </Button>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="checklist-item" :class="{ completed: hasAgents, disabled: !hasInboxes }">
 | 
			
		||||
              <CheckCircle v-if="hasAgents" class="check-icon completed" />
 | 
			
		||||
              <Circle v-else class="w-5 h-5 text-muted-foreground" />
 | 
			
		||||
              <span class="flex-1 text-left ml-3 text-foreground">
 | 
			
		||||
                {{ $t('setup.inviteTeammates') }}
 | 
			
		||||
              </span>
 | 
			
		||||
              <Button
 | 
			
		||||
                v-if="!hasAgents && hasInboxes"
 | 
			
		||||
                variant="ghost"
 | 
			
		||||
                size="sm"
 | 
			
		||||
                @click="router.push({ name: 'agent-list' })"
 | 
			
		||||
                class="ml-auto"
 | 
			
		||||
              >
 | 
			
		||||
                {{ $t('globals.messages.invite') }}
 | 
			
		||||
              </Button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div v-else>
 | 
			
		||||
        <p class="placeholder-text">{{ $t('conversation.placeholder') }}</p>
 | 
			
		||||
      </div>
 | 
			
		||||
    </template>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
import { ref, computed, onMounted } from 'vue'
 | 
			
		||||
import { useRouter } from 'vue-router'
 | 
			
		||||
import { CheckCircle, Circle } from 'lucide-vue-next'
 | 
			
		||||
import { Button } from '@/components/ui/button'
 | 
			
		||||
import { Spinner } from '@/components/ui/spinner'
 | 
			
		||||
import { useInboxStore } from '@/stores/inbox'
 | 
			
		||||
import { useUsersStore } from '@/stores/users'
 | 
			
		||||
 | 
			
		||||
const router = useRouter()
 | 
			
		||||
const inboxStore = useInboxStore()
 | 
			
		||||
const usersStore = useUsersStore()
 | 
			
		||||
const isLoading = ref(true)
 | 
			
		||||
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  try {
 | 
			
		||||
    await Promise.all([inboxStore.fetchInboxes(), usersStore.fetchUsers()])
 | 
			
		||||
  } finally {
 | 
			
		||||
    isLoading.value = false
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const hasInboxes = computed(() => inboxStore.inboxes.length > 0)
 | 
			
		||||
const hasAgents = computed(() => usersStore.users.length > 0)
 | 
			
		||||
const showGettingStarted = computed(() => !hasInboxes.value || !hasAgents.value)
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.placeholder-container {
 | 
			
		||||
  @apply h-screen w-full flex items-center justify-center min-w-[400px] relative;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.getting-started-wrapper {
 | 
			
		||||
  @apply w-full max-w-md mx-auto px-4;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checklist-item {
 | 
			
		||||
  @apply flex items-center justify-between py-3 px-4 rounded-lg border border-border;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checklist-item.completed {
 | 
			
		||||
  @apply bg-muted/50;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checklist-item.disabled {
 | 
			
		||||
  @apply opacity-50;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.check-icon.completed {
 | 
			
		||||
  @apply w-5 h-5 text-primary;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 
 | 
			
		||||
@@ -263,6 +263,7 @@ import { useFileUpload } from '@/composables/useFileUpload'
 | 
			
		||||
import Editor from '@/components/editor/TextEditor.vue'
 | 
			
		||||
import { useMacroStore } from '@/stores/macro'
 | 
			
		||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
			
		||||
import { UserTypeAgent } from '@/constants/user'
 | 
			
		||||
import api from '@/api'
 | 
			
		||||
 | 
			
		||||
const dialogOpen = defineModel({
 | 
			
		||||
@@ -393,12 +394,14 @@ const selectContact = (contact) => {
 | 
			
		||||
const createConversation = form.handleSubmit(async (values) => {
 | 
			
		||||
  loading.value = true
 | 
			
		||||
  try {
 | 
			
		||||
    // convert ids to numbers if they are not already
 | 
			
		||||
    // Convert ids to numbers if they are not already
 | 
			
		||||
    values.inbox_id = Number(values.inbox_id)
 | 
			
		||||
    values.team_id = values.team_id ? Number(values.team_id) : null
 | 
			
		||||
    values.agent_id = values.agent_id ? Number(values.agent_id) : null
 | 
			
		||||
    // array of attachment ids.
 | 
			
		||||
    // Array of attachment ids.
 | 
			
		||||
    values.attachments = mediaFiles.value.map((file) => file.id)
 | 
			
		||||
    // Initiator of this conversation is always agent
 | 
			
		||||
    values.initiator = UserTypeAgent
 | 
			
		||||
    const conversation = await api.createConversation(values)
 | 
			
		||||
    const conversationUUID = conversation.data.data.uuid
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -122,6 +122,7 @@ import { Input } from '@/components/ui/input'
 | 
			
		||||
import { useEmitter } from '@/composables/useEmitter'
 | 
			
		||||
import { useFileUpload } from '@/composables/useFileUpload'
 | 
			
		||||
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
 | 
			
		||||
import { UserTypeAgent } from '@/constants/user'
 | 
			
		||||
import {
 | 
			
		||||
  Form,
 | 
			
		||||
  FormField,
 | 
			
		||||
@@ -252,6 +253,7 @@ const processSend = async () => {
 | 
			
		||||
    if (hasTextContent.value > 0 || mediaFiles.value.length > 0) {
 | 
			
		||||
      const message = htmlContent.value
 | 
			
		||||
      await api.sendMessage(conversationStore.current.uuid, {
 | 
			
		||||
        sender_type: UserTypeAgent,
 | 
			
		||||
        private: messageType.value === 'private_note',
 | 
			
		||||
        message: message,
 | 
			
		||||
        attachments: mediaFiles.value.map((file) => file.id),
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,9 @@
 | 
			
		||||
          <SelectComboBox
 | 
			
		||||
            v-model="conversationStore.current.assigned_user_id"
 | 
			
		||||
            :items="[{ value: 'none', label: 'None' }, ...usersStore.options]"
 | 
			
		||||
            :placeholder="t('globals.messages.select', { name: t('globals.terms.agent').toLowerCase() })"
 | 
			
		||||
            :placeholder="
 | 
			
		||||
              t('globals.messages.select', { name: t('globals.terms.agent').toLowerCase() })
 | 
			
		||||
            "
 | 
			
		||||
            @select="selectAgent"
 | 
			
		||||
            type="user"
 | 
			
		||||
          />
 | 
			
		||||
@@ -22,7 +24,9 @@
 | 
			
		||||
          <SelectComboBox
 | 
			
		||||
            v-model="conversationStore.current.assigned_team_id"
 | 
			
		||||
            :items="[{ value: 'none', label: 'None' }, ...teamsStore.options]"
 | 
			
		||||
            :placeholder="t('globals.messages.select', { name: t('globals.terms.team').toLowerCase() })"
 | 
			
		||||
            :placeholder="
 | 
			
		||||
              t('globals.messages.select', { name: t('globals.terms.team').toLowerCase() })
 | 
			
		||||
            "
 | 
			
		||||
            @select="selectTeam"
 | 
			
		||||
            type="team"
 | 
			
		||||
          />
 | 
			
		||||
@@ -31,7 +35,9 @@
 | 
			
		||||
          <SelectComboBox
 | 
			
		||||
            v-model="conversationStore.current.priority_id"
 | 
			
		||||
            :items="priorityOptions"
 | 
			
		||||
            :placeholder="t('globals.messages.select', { name: t('globals.terms.priority').toLowerCase() })"
 | 
			
		||||
            :placeholder="
 | 
			
		||||
              t('globals.messages.select', { name: t('globals.terms.priority').toLowerCase() })
 | 
			
		||||
            "
 | 
			
		||||
            @select="selectPriority"
 | 
			
		||||
            type="priority"
 | 
			
		||||
          />
 | 
			
		||||
@@ -41,7 +47,9 @@
 | 
			
		||||
            v-if="conversationStore.current"
 | 
			
		||||
            v-model="conversationStore.current.tags"
 | 
			
		||||
            :items="tags.map((tag) => ({ label: tag, value: tag }))"
 | 
			
		||||
            :placeholder="t('globals.messages.select', { name: t('globals.terms.tag', 2).toLowerCase() })"
 | 
			
		||||
            :placeholder="
 | 
			
		||||
              t('globals.messages.select', { name: t('globals.terms.tag', 2).toLowerCase() })
 | 
			
		||||
            "
 | 
			
		||||
          />
 | 
			
		||||
        </AccordionContent>
 | 
			
		||||
      </AccordionItem>
 | 
			
		||||
@@ -93,6 +101,7 @@ import { ref, onMounted, watch, computed } from 'vue'
 | 
			
		||||
import { useConversationStore } from '@/stores/conversation'
 | 
			
		||||
import { useUsersStore } from '@/stores/users'
 | 
			
		||||
import { useTeamStore } from '@/stores/team'
 | 
			
		||||
import { useTagStore } from '@/stores/tag'
 | 
			
		||||
import {
 | 
			
		||||
  Accordion,
 | 
			
		||||
  AccordionContent,
 | 
			
		||||
@@ -118,6 +127,7 @@ const emitter = useEmitter()
 | 
			
		||||
const conversationStore = useConversationStore()
 | 
			
		||||
const usersStore = useUsersStore()
 | 
			
		||||
const teamsStore = useTeamStore()
 | 
			
		||||
const tagStore = useTagStore()
 | 
			
		||||
const tags = ref([])
 | 
			
		||||
// Save the accordion state in local storage
 | 
			
		||||
const accordionState = useStorage('conversation-sidebar-accordion', [])
 | 
			
		||||
@@ -171,15 +181,8 @@ watch(
 | 
			
		||||
const priorityOptions = computed(() => conversationStore.priorityOptions)
 | 
			
		||||
 | 
			
		||||
const fetchTags = async () => {
 | 
			
		||||
  try {
 | 
			
		||||
    const resp = await api.getTags()
 | 
			
		||||
    tags.value = resp.data.data.map((item) => item.name)
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
			
		||||
      variant: 'destructive',
 | 
			
		||||
      description: handleHTTPError(error).message
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
  await tagStore.fetchTags()
 | 
			
		||||
  tags.value = tagStore.tags.map((item) => item.name)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const handleAssignedUserChange = (id) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -58,6 +58,7 @@ import { ViewVerticalIcon } from '@radix-icons/vue'
 | 
			
		||||
import { Button } from '@/components/ui/button'
 | 
			
		||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
			
		||||
import { Mail, Phone, ExternalLink } from 'lucide-vue-next'
 | 
			
		||||
import countries from '@/constants/countries.js'
 | 
			
		||||
import { useEmitter } from '@/composables/useEmitter'
 | 
			
		||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
			
		||||
import { useConversationStore } from '@/stores/conversation'
 | 
			
		||||
@@ -72,8 +73,13 @@ const { t } = useI18n()
 | 
			
		||||
const userStore = useUserStore()
 | 
			
		||||
 | 
			
		||||
const phoneNumber = computed(() => {
 | 
			
		||||
  const callingCode = conversation.value?.contact?.phone_number_calling_code || ''
 | 
			
		||||
  const countryCodeValue = conversation.value?.contact?.phone_number_country_code || ''
 | 
			
		||||
  const number = conversation.value?.contact?.phone_number || t('conversation.sidebar.notAvailable')
 | 
			
		||||
  return callingCode ? `${callingCode} ${number}` : number
 | 
			
		||||
  if (!countryCodeValue) return number
 | 
			
		||||
 | 
			
		||||
  // Lookup calling code
 | 
			
		||||
  const country = countries.find((c) => c.iso_2 === countryCodeValue)
 | 
			
		||||
  const callingCode = country ? country.calling_code : countryCodeValue
 | 
			
		||||
  return `${callingCode} ${number}`
 | 
			
		||||
})
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@
 | 
			
		||||
  >
 | 
			
		||||
    {{ $t('conversation.sidebar.noPreviousConvo') }}
 | 
			
		||||
  </div>
 | 
			
		||||
  <div v-else class="space-y-3">
 | 
			
		||||
  <div v-else class="space-y-1">
 | 
			
		||||
    <router-link
 | 
			
		||||
      v-for="conversation in conversationStore.current.previous_conversations"
 | 
			
		||||
      :key="conversation.uuid"
 | 
			
		||||
@@ -30,9 +30,31 @@
 | 
			
		||||
            {{ conversation.last_message }}
 | 
			
		||||
          </span>
 | 
			
		||||
        </div>
 | 
			
		||||
        <span class="text-xs text-muted-foreground" v-if="conversation.last_message_at">
 | 
			
		||||
          {{ format(new Date(conversation.last_message_at), 'h') + ' h' }}
 | 
			
		||||
        <Tooltip>
 | 
			
		||||
          <TooltipTrigger asChild>
 | 
			
		||||
            <div class="flex gap-1 items-center text-xs text-muted-foreground">
 | 
			
		||||
              <span v-if="conversation.created_at">
 | 
			
		||||
                {{ getRelativeTime(new Date(conversation.created_at)) }}
 | 
			
		||||
              </span>
 | 
			
		||||
              <span>•</span>
 | 
			
		||||
              <span v-if="conversation.last_message_at">
 | 
			
		||||
                {{ getRelativeTime(new Date(conversation.last_message_at)) }}
 | 
			
		||||
              </span>
 | 
			
		||||
            </div>
 | 
			
		||||
          </TooltipTrigger>
 | 
			
		||||
          <TooltipContent>
 | 
			
		||||
            <div class="space-y-1 text-xs">
 | 
			
		||||
              <p>
 | 
			
		||||
                {{ $t('globals.terms.createdAt') }}:
 | 
			
		||||
                {{ formatFullTimestamp(new Date(conversation.created_at)) }}
 | 
			
		||||
              </p>
 | 
			
		||||
              <p v-if="conversation.last_message_at">
 | 
			
		||||
                {{ $t('globals.terms.lastMessageAt') }}:
 | 
			
		||||
                {{ formatFullTimestamp(new Date(conversation.last_message_at)) }}
 | 
			
		||||
              </p>
 | 
			
		||||
            </div>
 | 
			
		||||
          </TooltipContent>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
      </div>
 | 
			
		||||
    </router-link>
 | 
			
		||||
  </div>
 | 
			
		||||
@@ -40,7 +62,8 @@
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
import { useConversationStore } from '@/stores/conversation'
 | 
			
		||||
import { format } from 'date-fns'
 | 
			
		||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
 | 
			
		||||
import { formatFullTimestamp, getRelativeTime } from '@/utils/datetime'
 | 
			
		||||
 | 
			
		||||
const conversationStore = useConversationStore()
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,33 +1,32 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="max-w-5xl mx-auto p-6 min-h-screen">
 | 
			
		||||
    <div class="space-y-8">
 | 
			
		||||
      <div
 | 
			
		||||
        v-for="(items, type) in results"
 | 
			
		||||
        :key="type"
 | 
			
		||||
        class="bg-card rounded shadow overflow-hidden"
 | 
			
		||||
      >
 | 
			
		||||
        <!-- Header for each section -->
 | 
			
		||||
        <h2
 | 
			
		||||
          class="bg-primary dark:bg-primary text-lg font-bold text-white dark:text-primary-foreground py-2 px-6 capitalize"
 | 
			
		||||
        >
 | 
			
		||||
          {{ type }}
 | 
			
		||||
        </h2>
 | 
			
		||||
    <Tabs :default-value="defaultTab" v-model="activeTab">
 | 
			
		||||
      <TabsList class="grid w-full mb-6" :class="tabsGridClass">
 | 
			
		||||
        <TabsTrigger v-for="(items, type) in results" :key="type" :value="type" class="capitalize">
 | 
			
		||||
          {{ type }} ({{ items.length }})
 | 
			
		||||
        </TabsTrigger>
 | 
			
		||||
      </TabsList>
 | 
			
		||||
 | 
			
		||||
      <TabsContent v-for="(items, type) in results" :key="type" :value="type" class="mt-0">
 | 
			
		||||
        <div class="bg-background rounded border overflow-hidden">
 | 
			
		||||
          <!-- No results message -->
 | 
			
		||||
        <div v-if="items.length === 0" class="p-6 text-gray-500 dark:text-muted-foreground">
 | 
			
		||||
          <div v-if="items.length === 0" class="p-8 text-center text-muted-foreground">
 | 
			
		||||
            <div class="text-lg font-medium mb-2">
 | 
			
		||||
              {{
 | 
			
		||||
                $t('globals.messages.noResults', {
 | 
			
		||||
                  name: type
 | 
			
		||||
                })
 | 
			
		||||
              }}
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="text-sm">{{ $t('search.adjustSearchTerms') }}</div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Results list -->
 | 
			
		||||
        <div class="divide-y divide-gray-200 dark:divide-border">
 | 
			
		||||
          <div v-else class="divide-y divide-border">
 | 
			
		||||
            <div
 | 
			
		||||
              v-for="item in items"
 | 
			
		||||
              :key="item.id || item.uuid"
 | 
			
		||||
            class="p-6 hover:bg-gray-100 dark:hover:bg-accent transition duration-300 ease-in-out group"
 | 
			
		||||
              class="p-6 hover:bg-accent/50 transition duration-200 ease-in-out group"
 | 
			
		||||
            >
 | 
			
		||||
              <router-link
 | 
			
		||||
                :to="{
 | 
			
		||||
@@ -43,7 +42,7 @@
 | 
			
		||||
                  <div class="flex-grow">
 | 
			
		||||
                    <!-- Reference number -->
 | 
			
		||||
                    <div
 | 
			
		||||
                    class="text-sm font-semibold mb-2 group-hover:text-primary dark:group-hover:text-primary transition duration-300"
 | 
			
		||||
                      class="text-sm font-semibold mb-2 text-muted-foreground group-hover:text-primary transition duration-200"
 | 
			
		||||
                    >
 | 
			
		||||
                      #{{
 | 
			
		||||
                        type === 'conversations'
 | 
			
		||||
@@ -54,15 +53,18 @@
 | 
			
		||||
 | 
			
		||||
                    <!-- Content -->
 | 
			
		||||
                    <div
 | 
			
		||||
                    class="text-gray-900 dark:text-card-foreground font-medium mb-2 text-lg group-hover:text-gray-950 dark:group-hover:text-foreground transition duration-300"
 | 
			
		||||
                      class="text-foreground font-medium mb-2 text-lg group-hover:text-primary transition duration-200"
 | 
			
		||||
                    >
 | 
			
		||||
                      {{
 | 
			
		||||
                      truncateText(type === 'conversations' ? item.subject : item.text_content, 100)
 | 
			
		||||
                        truncateText(
 | 
			
		||||
                          type === 'conversations' ? item.subject : item.text_content,
 | 
			
		||||
                          100
 | 
			
		||||
                        )
 | 
			
		||||
                      }}
 | 
			
		||||
                    </div>
 | 
			
		||||
 | 
			
		||||
                    <!-- Timestamp -->
 | 
			
		||||
                  <div class="text-sm text-gray-500 dark:text-muted-foreground flex items-center">
 | 
			
		||||
                    <div class="text-sm text-muted-foreground flex items-center">
 | 
			
		||||
                      <ClockIcon class="h-4 w-4 mr-1" />
 | 
			
		||||
                      {{
 | 
			
		||||
                        formatDate(
 | 
			
		||||
@@ -74,10 +76,10 @@
 | 
			
		||||
 | 
			
		||||
                  <!-- Right arrow icon -->
 | 
			
		||||
                  <div
 | 
			
		||||
                  class="bg-gray-200 dark:bg-secondary rounded-full p-2 group-hover:bg-primary dark:group-hover:bg-primary transition duration-300"
 | 
			
		||||
                    class="bg-secondary rounded-full p-2 group-hover:bg-primary transition duration-200"
 | 
			
		||||
                  >
 | 
			
		||||
                    <ChevronRightIcon
 | 
			
		||||
                    class="h-5 w-5 text-gray-700 dark:text-secondary-foreground group-hover:text-white dark:group-hover:text-primary-foreground"
 | 
			
		||||
                      class="h-5 w-5 text-secondary-foreground group-hover:text-primary-foreground"
 | 
			
		||||
                      aria-hidden="true"
 | 
			
		||||
                    />
 | 
			
		||||
                  </div>
 | 
			
		||||
@@ -86,20 +88,52 @@
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
      </TabsContent>
 | 
			
		||||
    </Tabs>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed, ref, watch } from 'vue'
 | 
			
		||||
import { ChevronRightIcon, ClockIcon } from 'lucide-vue-next'
 | 
			
		||||
import { format, parseISO } from 'date-fns'
 | 
			
		||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
 | 
			
		||||
 | 
			
		||||
defineProps({
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  results: {
 | 
			
		||||
    type: Object,
 | 
			
		||||
    required: true
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
// Get the first available tab as default
 | 
			
		||||
const defaultTab = computed(() => {
 | 
			
		||||
  const types = Object.keys(props.results)
 | 
			
		||||
  return types.length > 0 ? types[0] : ''
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const activeTab = ref('')
 | 
			
		||||
 | 
			
		||||
// Watch for changes in results and set the first tab as active
 | 
			
		||||
watch(
 | 
			
		||||
  () => props.results,
 | 
			
		||||
  (newResults) => {
 | 
			
		||||
    const types = Object.keys(newResults)
 | 
			
		||||
    if (types.length > 0 && !activeTab.value) {
 | 
			
		||||
      activeTab.value = types[0]
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  { immediate: true }
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Dynamic grid class based on number of tabs
 | 
			
		||||
const tabsGridClass = computed(() => {
 | 
			
		||||
  const tabCount = Object.keys(props.results).length
 | 
			
		||||
  if (tabCount <= 2) return 'grid-cols-2'
 | 
			
		||||
  if (tabCount <= 3) return 'grid-cols-3'
 | 
			
		||||
  if (tabCount <= 4) return 'grid-cols-4'
 | 
			
		||||
  return 'grid-cols-5'
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const formatDate = (dateString) => {
 | 
			
		||||
  const date = parseISO(dateString)
 | 
			
		||||
  return format(date, 'MMM d, yyyy HH:mm')
 | 
			
		||||
 
 | 
			
		||||
@@ -83,6 +83,7 @@ import { handleHTTPError } from '@/utils/http'
 | 
			
		||||
import { OPERATOR } from '@/constants/filterConfig.js'
 | 
			
		||||
import { useI18n } from 'vue-i18n'
 | 
			
		||||
import { z } from 'zod'
 | 
			
		||||
import { FIELD_TYPE } from '@/constants/filterConfig'
 | 
			
		||||
import api from '@/api'
 | 
			
		||||
 | 
			
		||||
const emitter = useEmitter()
 | 
			
		||||
@@ -106,68 +107,88 @@ const formSchema = toTypedSchema(
 | 
			
		||||
  z.object({
 | 
			
		||||
    id: z.number().optional(),
 | 
			
		||||
    name: z
 | 
			
		||||
      .string()
 | 
			
		||||
      .string({
 | 
			
		||||
        required_error: t('globals.messages.required')
 | 
			
		||||
      })
 | 
			
		||||
      .min(2, { message: t('view.form.name.length') })
 | 
			
		||||
      .max(30, { message: t('view.form.name.length') }),
 | 
			
		||||
    filters: z
 | 
			
		||||
      .array(
 | 
			
		||||
        z.object({
 | 
			
		||||
          model: z.string({
 | 
			
		||||
            required_error: t('globals.messages.required', {
 | 
			
		||||
              name: t('globals.terms.filter').toLowerCase()
 | 
			
		||||
            })
 | 
			
		||||
          }),
 | 
			
		||||
          field: z.string({
 | 
			
		||||
            required_error: t('globals.messages.required', {
 | 
			
		||||
              name: t('globals.terms.field').toLowerCase()
 | 
			
		||||
            })
 | 
			
		||||
          }),
 | 
			
		||||
          operator: z.string({
 | 
			
		||||
            required_error: t('globals.messages.required', {
 | 
			
		||||
              name: t('globals.terms.operator').toLowerCase()
 | 
			
		||||
            })
 | 
			
		||||
          }),
 | 
			
		||||
          value: z.union([z.string(), z.number(), z.boolean()]).optional()
 | 
			
		||||
          model: z.string().optional(),
 | 
			
		||||
          field: z.string().optional(),
 | 
			
		||||
          operator: z.string().optional(),
 | 
			
		||||
          value: z
 | 
			
		||||
            .union([
 | 
			
		||||
              z.string(),
 | 
			
		||||
              z.number(),
 | 
			
		||||
              z.boolean(),
 | 
			
		||||
              z.array(z.union([z.string(), z.number()]))
 | 
			
		||||
            ])
 | 
			
		||||
            .optional()
 | 
			
		||||
        })
 | 
			
		||||
      )
 | 
			
		||||
      .default([])
 | 
			
		||||
      .refine((filters) => filters.length > 0, { message: t('view.form.filter.selectAtLeastOne') })
 | 
			
		||||
      .refine(
 | 
			
		||||
        (filters) =>
 | 
			
		||||
          filters.every(
 | 
			
		||||
            (f) =>
 | 
			
		||||
              f.model &&
 | 
			
		||||
              f.field &&
 | 
			
		||||
              f.operator &&
 | 
			
		||||
              ([OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) || f.value)
 | 
			
		||||
          ),
 | 
			
		||||
        {
 | 
			
		||||
          message: t('view.form.filter.partiallyFilled')
 | 
			
		||||
        }
 | 
			
		||||
      )
 | 
			
		||||
  })
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const form = useForm({
 | 
			
		||||
  validationSchema: formSchema,
 | 
			
		||||
  validateOnMount: false,
 | 
			
		||||
  validateOnInput: false,
 | 
			
		||||
  validateOnBlur: false
 | 
			
		||||
  validationSchema: formSchema
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const onSubmit = async () => {
 | 
			
		||||
  const validationResult = await form.validate()
 | 
			
		||||
  if (!validationResult.valid) return
 | 
			
		||||
 | 
			
		||||
const onSubmit = form.handleSubmit(async (values) => {
 | 
			
		||||
  if (isSubmitting.value) return
 | 
			
		||||
 | 
			
		||||
  // Make sure at least one filter is selected
 | 
			
		||||
  if (!values.filters || values.filters.length === 0) {
 | 
			
		||||
    form.setFieldError('filters', t('view.form.filter.selectAtLeastOne'))
 | 
			
		||||
    return
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Check for partial filters
 | 
			
		||||
  const hasPartialFilters = values.filters.some(
 | 
			
		||||
    (f) =>
 | 
			
		||||
      !f.field ||
 | 
			
		||||
      !f.operator ||
 | 
			
		||||
      (![OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) && !f.value)
 | 
			
		||||
  )
 | 
			
		||||
  if (hasPartialFilters) {
 | 
			
		||||
    form.setFieldError('filters', t('view.form.filter.partiallyFilled'))
 | 
			
		||||
    return
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isSubmitting.value = true
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const values = form.values
 | 
			
		||||
    // Serialize array values to JSON strings for backend
 | 
			
		||||
    if (values.filters) {
 | 
			
		||||
      values.filters = values.filters.map((filter) => {
 | 
			
		||||
        if (Array.isArray(filter.value)) {
 | 
			
		||||
          // Convert string IDs to numbers for backend (tags use string IDs in frontend)
 | 
			
		||||
          const numericValues = filter.value.map((v) => {
 | 
			
		||||
            const num = Number(v)
 | 
			
		||||
            return isNaN(num) ? v : num
 | 
			
		||||
          })
 | 
			
		||||
          return { ...filter, value: JSON.stringify(numericValues) }
 | 
			
		||||
        }
 | 
			
		||||
        return filter
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (values.id) {
 | 
			
		||||
      await api.updateView(values.id, values)
 | 
			
		||||
      emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
			
		||||
        description: t('globals.messages.updatedSuccessfully', {
 | 
			
		||||
          name: t('globals.terms.view')
 | 
			
		||||
        })
 | 
			
		||||
      })
 | 
			
		||||
    } else {
 | 
			
		||||
      await api.createView(values)
 | 
			
		||||
      emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
			
		||||
        description: t('globals.messages.createdSuccessfully', {
 | 
			
		||||
          name: t('globals.terms.view')
 | 
			
		||||
        })
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
    emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
 | 
			
		||||
    openDialog.value = false
 | 
			
		||||
@@ -180,14 +201,36 @@ const onSubmit = async () => {
 | 
			
		||||
  } finally {
 | 
			
		||||
    isSubmitting.value = false
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
// Set form values when view prop changes
 | 
			
		||||
watch(
 | 
			
		||||
  () => view.value,
 | 
			
		||||
  (newVal) => {
 | 
			
		||||
    if (newVal && Object.keys(newVal).length) {
 | 
			
		||||
      form.setValues(newVal)
 | 
			
		||||
      // Deserialize multi-select filter values from JSON strings to arrays
 | 
			
		||||
      const processedVal = { ...newVal }
 | 
			
		||||
      if (processedVal.filters) {
 | 
			
		||||
        processedVal.filters = processedVal.filters.map((filter) => {
 | 
			
		||||
          // Multi-select fields need to be deserialized from JSON strings
 | 
			
		||||
          const field = filterFields.value.find((f) => f.field === filter.field)
 | 
			
		||||
          const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT
 | 
			
		||||
 | 
			
		||||
          if (isMultiSelectField && typeof filter.value === 'string') {
 | 
			
		||||
            try {
 | 
			
		||||
              const parsed = JSON.parse(filter.value)
 | 
			
		||||
              // Convert numbers back to strings (frontend uses string IDs)
 | 
			
		||||
              const stringValues = Array.isArray(parsed) ? parsed.map((v) => String(v)) : parsed
 | 
			
		||||
              return { ...filter, value: stringValues }
 | 
			
		||||
            } catch (e) {
 | 
			
		||||
              // If parsing fails, return as-is
 | 
			
		||||
              return filter
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
          return filter
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      form.setValues(processedVal)
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  { immediate: true }
 | 
			
		||||
 
 | 
			
		||||
@@ -18,14 +18,14 @@ const setFavicon = (url) => {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function initApp () {
 | 
			
		||||
  const settings = (await api.getSettings('general')).data.data
 | 
			
		||||
  const config = (await api.getConfig()).data.data
 | 
			
		||||
  const emitter = mitt()
 | 
			
		||||
  const lang = settings['app.lang'] || 'en'
 | 
			
		||||
  const lang = config['app.lang'] || 'en'
 | 
			
		||||
  const langMessages = await api.getLanguage(lang)
 | 
			
		||||
 | 
			
		||||
  // Set favicon.
 | 
			
		||||
  if (settings['app.favicon_url'])
 | 
			
		||||
    setFavicon(settings['app.favicon_url'])
 | 
			
		||||
  if (config['app.favicon_url'])
 | 
			
		||||
    setFavicon(config['app.favicon_url'])
 | 
			
		||||
 | 
			
		||||
  // Initialize i18n.
 | 
			
		||||
  const i18nConfig = {
 | 
			
		||||
@@ -42,9 +42,17 @@ async function initApp () {
 | 
			
		||||
  const pinia = createPinia()
 | 
			
		||||
  app.use(pinia)
 | 
			
		||||
 | 
			
		||||
  // Store app settings in Pinia
 | 
			
		||||
  // Fetch and store app settings in store (after pinia is initialized)
 | 
			
		||||
  const settingsStore = useAppSettingsStore()
 | 
			
		||||
  settingsStore.setSettings(settings)
 | 
			
		||||
 | 
			
		||||
  // Store the public config in the store
 | 
			
		||||
  settingsStore.setPublicConfig(config)
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    await settingsStore.fetchSettings('general')
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    // Pass
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Add emitter to global properties.
 | 
			
		||||
  app.config.globalProperties.emitter = emitter
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,35 @@
 | 
			
		||||
import { defineStore } from 'pinia'
 | 
			
		||||
import api from '@/api'
 | 
			
		||||
 | 
			
		||||
export const useAppSettingsStore = defineStore('settings', {
 | 
			
		||||
    state: () => ({
 | 
			
		||||
        settings: {}
 | 
			
		||||
        settings: {},
 | 
			
		||||
        public_config: {}
 | 
			
		||||
    }),
 | 
			
		||||
    actions: {
 | 
			
		||||
        async fetchSettings (key = 'general') {
 | 
			
		||||
            try {
 | 
			
		||||
                const response = await api.getSettings(key)
 | 
			
		||||
                this.settings = response?.data?.data || {}
 | 
			
		||||
                return this.settings
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                // Pass
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        async fetchPublicConfig () {
 | 
			
		||||
            try {
 | 
			
		||||
                const response = await api.getConfig()
 | 
			
		||||
                this.public_config = response?.data?.data || {}
 | 
			
		||||
                return this.public_config
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                // Pass
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        setSettings (newSettings) {
 | 
			
		||||
            this.settings = newSettings
 | 
			
		||||
        },
 | 
			
		||||
        setPublicConfig (newPublicConfig) {
 | 
			
		||||
            this.public_config = newPublicConfig
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -12,14 +12,13 @@ export const useInboxStore = defineStore('inbox', () => {
 | 
			
		||||
    label: inb.name,
 | 
			
		||||
    value: String(inb.id)
 | 
			
		||||
  })))
 | 
			
		||||
  const fetchInboxes = async () => {
 | 
			
		||||
    if (inboxes.value.length) return
 | 
			
		||||
  const fetchInboxes = async (force = false) => {
 | 
			
		||||
    if (!force && inboxes.value.length) return
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await api.getInboxes()
 | 
			
		||||
      inboxes.value = response?.data?.data || []
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
			
		||||
        title: 'Error',
 | 
			
		||||
        variant: 'destructive',
 | 
			
		||||
        description: handleHTTPError(error).message
 | 
			
		||||
      })
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@ import { useEmitter } from '@/composables/useEmitter'
 | 
			
		||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
 | 
			
		||||
import api from '@/api'
 | 
			
		||||
 | 
			
		||||
// TODO: rename this store to agents
 | 
			
		||||
export const useUsersStore = defineStore('users', () => {
 | 
			
		||||
    const users = ref([])
 | 
			
		||||
    const emitter = useEmitter()
 | 
			
		||||
@@ -13,8 +14,8 @@ export const useUsersStore = defineStore('users', () => {
 | 
			
		||||
        value: String(user.id),
 | 
			
		||||
        avatar_url: user.avatar_url,
 | 
			
		||||
    })))
 | 
			
		||||
    const fetchUsers = async () => {
 | 
			
		||||
        if (users.value.length) return
 | 
			
		||||
    const fetchUsers = async (force = false) => {
 | 
			
		||||
        if (!force && users.value.length) return
 | 
			
		||||
        try {
 | 
			
		||||
            const response = await api.getUsersCompact()
 | 
			
		||||
            users.value = response?.data?.data || []
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,17 @@
 | 
			
		||||
import { format, differenceInMinutes, differenceInHours, differenceInDays } from 'date-fns'
 | 
			
		||||
import { format, differenceInMinutes, differenceInHours, differenceInDays, differenceInYears } from 'date-fns'
 | 
			
		||||
 | 
			
		||||
export function getRelativeTime (timestamp, now = new Date()) {
 | 
			
		||||
  try {
 | 
			
		||||
    const mins = differenceInMinutes(now, timestamp)
 | 
			
		||||
    const hours = differenceInHours(now, timestamp)
 | 
			
		||||
    const days = differenceInDays(now, timestamp)
 | 
			
		||||
    const years = differenceInYears(now, timestamp)
 | 
			
		||||
 | 
			
		||||
    if (mins === 0) return 'Just now'
 | 
			
		||||
    if (mins < 60) return `${mins} mins ago`
 | 
			
		||||
    if (hours < 24) return `${hours} hrs ago`
 | 
			
		||||
    if (days < 7) return `${days} days ago`
 | 
			
		||||
    return format(timestamp, 'MMMM d, yyyy h:mm a')
 | 
			
		||||
    if (mins === 0) return 'now'
 | 
			
		||||
    if (mins < 60) return `${mins}m`
 | 
			
		||||
    if (hours < 24) return `${hours}h`
 | 
			
		||||
    if (days < 365) return `${days}d`
 | 
			
		||||
    return `${years}y`
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Error parsing time', error, 'timestamp', timestamp)
 | 
			
		||||
    return ''
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
import { onMounted, ref } from 'vue'
 | 
			
		||||
import { onMounted, onUnmounted, ref } from 'vue'
 | 
			
		||||
import { createColumns } from '@/features/admin/agents/dataTableColumns.js'
 | 
			
		||||
import { Button } from '@/components/ui/button'
 | 
			
		||||
import DataTable from '@/components/datatable/DataTable.vue'
 | 
			
		||||
@@ -25,10 +25,11 @@ import { handleHTTPError } from '@/utils/http'
 | 
			
		||||
import { Spinner } from '@/components/ui/spinner'
 | 
			
		||||
import { useEmitter } from '@/composables/useEmitter'
 | 
			
		||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
			
		||||
import api from '@/api'
 | 
			
		||||
import { useUsersStore } from '@/stores/users'
 | 
			
		||||
import { useI18n } from 'vue-i18n'
 | 
			
		||||
 | 
			
		||||
const isLoading = ref(false)
 | 
			
		||||
const usersStore = useUsersStore()
 | 
			
		||||
const { t } = useI18n()
 | 
			
		||||
const data = ref([])
 | 
			
		||||
const emitter = useEmitter()
 | 
			
		||||
@@ -40,11 +41,15 @@ onMounted(async () => {
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
onUnmounted(() => {
 | 
			
		||||
  emitter.off(EMITTER_EVENTS.REFRESH_LIST)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const getData = async () => {
 | 
			
		||||
  try {
 | 
			
		||||
    isLoading.value = true
 | 
			
		||||
    const response = await api.getUsers()
 | 
			
		||||
    data.value = response.data.data
 | 
			
		||||
    await usersStore.fetchUsers(true)
 | 
			
		||||
    data.value = usersStore.users
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
			
		||||
      variant: 'destructive',
 | 
			
		||||
 
 | 
			
		||||
@@ -20,15 +20,17 @@ import { ref, onMounted } from 'vue'
 | 
			
		||||
import { Spinner } from '@/components/ui/spinner'
 | 
			
		||||
import GeneralSettingForm from '@/features/admin/general/GeneralSettingForm.vue'
 | 
			
		||||
import AdminPageWithHelp from '@/layouts/admin/AdminPageWithHelp.vue'
 | 
			
		||||
import { useAppSettingsStore } from '@/stores/appSettings'
 | 
			
		||||
import api from '@/api'
 | 
			
		||||
 | 
			
		||||
const initialValues = ref({})
 | 
			
		||||
const isLoading = ref(false)
 | 
			
		||||
const settingsStore = useAppSettingsStore()
 | 
			
		||||
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  isLoading.value = true
 | 
			
		||||
  const response = await api.getSettings('general')
 | 
			
		||||
  const data = response.data.data
 | 
			
		||||
  await settingsStore.fetchSettings('general')
 | 
			
		||||
  const data = settingsStore.settings
 | 
			
		||||
  isLoading.value = false
 | 
			
		||||
  initialValues.value = Object.keys(data).reduce((acc, key) => {
 | 
			
		||||
    // Remove 'app.' prefix
 | 
			
		||||
 
 | 
			
		||||
@@ -32,11 +32,13 @@ import { useRouter } from 'vue-router'
 | 
			
		||||
import { useI18n } from 'vue-i18n'
 | 
			
		||||
import { format } from 'date-fns'
 | 
			
		||||
import { Spinner } from '@/components/ui/spinner'
 | 
			
		||||
import { useInboxStore } from '@/stores/inbox'
 | 
			
		||||
import api from '@/api'
 | 
			
		||||
 | 
			
		||||
const { t } = useI18n()
 | 
			
		||||
const router = useRouter()
 | 
			
		||||
const emitter = useEmitter()
 | 
			
		||||
const inboxStore = useInboxStore()
 | 
			
		||||
const isLoading = ref(false)
 | 
			
		||||
const data = ref([])
 | 
			
		||||
 | 
			
		||||
@@ -47,8 +49,8 @@ onMounted(async () => {
 | 
			
		||||
const getInboxes = async () => {
 | 
			
		||||
  try {
 | 
			
		||||
    isLoading.value = true
 | 
			
		||||
    const response = await api.getInboxes()
 | 
			
		||||
    data.value = response.data.data
 | 
			
		||||
    await inboxStore.fetchInboxes(true)
 | 
			
		||||
    data.value = inboxStore.inboxes
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
			
		||||
      variant: 'destructive',
 | 
			
		||||
@@ -67,7 +69,7 @@ const columns = [
 | 
			
		||||
      return h('div', { class: 'text-center' }, t('globals.terms.name'))
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
@@ -76,7 +78,7 @@ const columns = [
 | 
			
		||||
      return h('div', { class: 'text-center' }, t('globals.terms.channel'))
 | 
			
		||||
    },
 | 
			
		||||
    cell: function ({ row }) {
 | 
			
		||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('channel'))
 | 
			
		||||
      return h('div', { class: 'text-center' }, row.getValue('channel'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@
 | 
			
		||||
    <template #help>
 | 
			
		||||
      <p>Configure single sign-on with one or more OpenID Connect providers.</p>
 | 
			
		||||
      <a
 | 
			
		||||
        href="https://libredesk.io/docs/sso/"
 | 
			
		||||
        href="https://docs.libredesk.io/configuration/sso"
 | 
			
		||||
        target="_blank"
 | 
			
		||||
        rel="noopener noreferrer"
 | 
			
		||||
        class="link-style"
 | 
			
		||||
 
 | 
			
		||||
@@ -49,7 +49,7 @@
 | 
			
		||||
        <p>Design templates for customer communications and responses.</p>
 | 
			
		||||
        <p>Modify content for internal and external emails.</p>
 | 
			
		||||
        <a
 | 
			
		||||
          href="https://libredesk.io/docs/templating/"
 | 
			
		||||
          href="https://docs.libredesk.io/configuration/email-templates"
 | 
			
		||||
          target="_blank"
 | 
			
		||||
          rel="noopener noreferrer"
 | 
			
		||||
          class="link-style"
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@
 | 
			
		||||
      <p>Configure webhooks to receive real-time notifications when events occur in your Libredesk workspace.</p>
 | 
			
		||||
      <p>Webhooks allow you to integrate Libredesk with external services by sending HTTP POST requests when specific events happen.</p>
 | 
			
		||||
      <a
 | 
			
		||||
        href="https://libredesk.io/docs/webhooks/"
 | 
			
		||||
        href="https://docs.libredesk.io/configuration/webhooks"
 | 
			
		||||
        target="_blank"
 | 
			
		||||
        rel="noopener noreferrer"
 | 
			
		||||
        class="link-style"
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@
 | 
			
		||||
      <CardContent class="p-6 space-y-6">
 | 
			
		||||
        <div class="space-y-2 text-center">
 | 
			
		||||
          <CardTitle class="text-3xl font-bold text-foreground">
 | 
			
		||||
            {{ appSettingsStore.settings?.['app.site_name'] || 'Libredesk' }}
 | 
			
		||||
            {{ appSettingsStore.public_config?.['app.site_name'] || 'LIBREDESK' }}
 | 
			
		||||
          </CardTitle>
 | 
			
		||||
          <p class="text-muted-foreground">{{ t('auth.signIn') }}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
@@ -25,9 +25,8 @@
 | 
			
		||||
          >
 | 
			
		||||
            <img
 | 
			
		||||
              :src="oidcProvider.logo_url"
 | 
			
		||||
              :alt="oidcProvider.name"
 | 
			
		||||
              width="20"
 | 
			
		||||
              class="mr-2"
 | 
			
		||||
              alt=""
 | 
			
		||||
              v-if="oidcProvider.logo_url"
 | 
			
		||||
            />
 | 
			
		||||
            {{ oidcProvider.name }}
 | 
			
		||||
@@ -89,7 +88,9 @@
 | 
			
		||||
            type="submit"
 | 
			
		||||
          >
 | 
			
		||||
            <span v-if="isLoading" class="flex items-center justify-center">
 | 
			
		||||
              <div class="w-5 h-5 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin mr-3"></div>
 | 
			
		||||
              <div
 | 
			
		||||
                class="w-5 h-5 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin mr-3"
 | 
			
		||||
              ></div>
 | 
			
		||||
              {{ t('auth.loggingIn') }}
 | 
			
		||||
            </span>
 | 
			
		||||
            <span v-else>{{ t('auth.signInButton') }}</span>
 | 
			
		||||
@@ -159,8 +160,10 @@ onMounted(async () => {
 | 
			
		||||
 | 
			
		||||
const fetchOIDCProviders = async () => {
 | 
			
		||||
  try {
 | 
			
		||||
    const resp = await api.getAllEnabledOIDC()
 | 
			
		||||
    oidcProviders.value = resp.data.data
 | 
			
		||||
    const config = appSettingsStore.public_config
 | 
			
		||||
    if (config && config['app.sso_providers']) {
 | 
			
		||||
      oidcProviders.value = config['app.sso_providers'] || []
 | 
			
		||||
    }
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
			
		||||
      variant: 'destructive',
 | 
			
		||||
@@ -204,6 +207,9 @@ const loginAction = () => {
 | 
			
		||||
      if (resp?.data?.data) {
 | 
			
		||||
        userStore.setCurrentUser(resp.data.data)
 | 
			
		||||
      }
 | 
			
		||||
      // Also fetch general setting as user's logged in.
 | 
			
		||||
      appSettingsStore.fetchSettings('general')
 | 
			
		||||
      // Navigate to inboxes
 | 
			
		||||
      router.push({ name: 'inboxes' })
 | 
			
		||||
    })
 | 
			
		||||
    .catch((error) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,11 @@
 | 
			
		||||
        <CustomBreadcrumb :links="breadcrumbLinks" />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div v-if="contact" class="flex justify-center space-y-4 w-full">
 | 
			
		||||
      <div
 | 
			
		||||
        v-if="contact"
 | 
			
		||||
        class="flex justify-center space-y-4 w-full"
 | 
			
		||||
        :class="{ 'loading-fade': formLoading }"
 | 
			
		||||
      >
 | 
			
		||||
        <div class="flex flex-col w-full mt-12">
 | 
			
		||||
          <div class="flex flex-col space-y-2">
 | 
			
		||||
            <AvatarUpload
 | 
			
		||||
@@ -189,7 +193,7 @@ async function onUpload(file) {
 | 
			
		||||
    formData.append('last_name', form.values.last_name)
 | 
			
		||||
    formData.append('email', form.values.email)
 | 
			
		||||
    formData.append('phone_number', form.values.phone_number)
 | 
			
		||||
    formData.append('phone_number_calling_code', form.values.phone_number_calling_code)
 | 
			
		||||
    formData.append('phone_number_country_code', form.values.phone_number_country_code)
 | 
			
		||||
    formData.append('enabled', form.values.enabled)
 | 
			
		||||
    const { data } = await api.updateContact(contact.value.id, formData)
 | 
			
		||||
    contact.value.avatar_url = data.avatar_url
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										5
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										5
									
								
								go.mod
									
									
									
									
									
								
							@@ -7,6 +7,7 @@ require (
 | 
			
		||||
	github.com/coreos/go-oidc/v3 v3.11.0
 | 
			
		||||
	github.com/disintegration/imaging v1.6.2
 | 
			
		||||
	github.com/emersion/go-imap/v2 v2.0.0-beta.3
 | 
			
		||||
	github.com/emersion/go-message v0.18.1
 | 
			
		||||
	github.com/fasthttp/websocket v1.5.9
 | 
			
		||||
	github.com/ferluci/fast-realip v1.0.1
 | 
			
		||||
	github.com/google/uuid v1.6.0
 | 
			
		||||
@@ -38,7 +39,7 @@ require (
 | 
			
		||||
	github.com/zerodha/simplesessions/v3 v3.0.0
 | 
			
		||||
	golang.org/x/crypto v0.38.0
 | 
			
		||||
	golang.org/x/mod v0.17.0
 | 
			
		||||
	golang.org/x/oauth2 v0.21.0
 | 
			
		||||
	golang.org/x/oauth2 v0.27.0
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
@@ -49,7 +50,6 @@ require (
 | 
			
		||||
	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 | 
			
		||||
	github.com/davecgh/go-spew v1.1.1 // indirect
 | 
			
		||||
	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 | 
			
		||||
	github.com/emersion/go-message v0.18.1 // indirect
 | 
			
		||||
	github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
 | 
			
		||||
	github.com/fasthttp/router v1.5.0 // indirect
 | 
			
		||||
	github.com/fsnotify/fsnotify v1.6.0 // indirect
 | 
			
		||||
@@ -70,6 +70,7 @@ require (
 | 
			
		||||
	github.com/rivo/uniseg v0.4.4 // indirect
 | 
			
		||||
	github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect
 | 
			
		||||
	github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
 | 
			
		||||
	github.com/stretchr/objx v0.5.2 // indirect
 | 
			
		||||
	github.com/valyala/bytebufferpool v1.0.0 // indirect
 | 
			
		||||
	golang.org/x/image v0.18.0 // indirect
 | 
			
		||||
	golang.org/x/net v0.40.0 // indirect
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										8
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										8
									
								
								go.sum
									
									
									
									
									
								
							@@ -140,8 +140,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 | 
			
		||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 | 
			
		||||
github.com/redis/go-redis/v9 v9.5.5 h1:51VEyMF8eOO+NUHFm8fpg+IOc1xFuFOhxs3R+kPu1FM=
 | 
			
		||||
github.com/redis/go-redis/v9 v9.5.5/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
 | 
			
		||||
github.com/rhnvrm/simples3 v0.9.0 h1:It6/glyqRTRooRzXcYOuqpKwjGg3lsXgNmeGgxpBtjA=
 | 
			
		||||
github.com/rhnvrm/simples3 v0.9.0/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
 | 
			
		||||
github.com/rhnvrm/simples3 v0.9.1 h1:pYfEe2wTjx8B2zFzUdy4kZn3I3Otd9ZvzIhHkFR85kE=
 | 
			
		||||
github.com/rhnvrm/simples3 v0.9.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
 | 
			
		||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 | 
			
		||||
@@ -159,6 +157,8 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
 | 
			
		||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
 | 
			
		||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
 | 
			
		||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 | 
			
		||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
 | 
			
		||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 | 
			
		||||
github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 | 
			
		||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 | 
			
		||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 | 
			
		||||
@@ -211,8 +211,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
 | 
			
		||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 | 
			
		||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
 | 
			
		||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
 | 
			
		||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
 | 
			
		||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
 | 
			
		||||
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
 | 
			
		||||
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
 | 
			
		||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										31
									
								
								i18n/en.json
									
									
									
									
									
								
							
							
						
						
									
										31
									
								
								i18n/en.json
									
									
									
									
									
								
							@@ -177,6 +177,7 @@
 | 
			
		||||
  "globals.terms.usage": "Usage",
 | 
			
		||||
  "globals.terms.createdAt": "Created At",
 | 
			
		||||
  "globals.terms.updatedAt": "Updated At",
 | 
			
		||||
  "globals.terms.lastMessageAt": "Last message at",
 | 
			
		||||
  "globals.terms.pickDate": "Pick a date",
 | 
			
		||||
  "globals.terms.time": "Time",
 | 
			
		||||
  "globals.terms.listValues": "List values",
 | 
			
		||||
@@ -188,6 +189,7 @@
 | 
			
		||||
  "globals.terms.recipient": "Recipient | Recipients",
 | 
			
		||||
  "globals.terms.tls": "TLS | TLSs",
 | 
			
		||||
  "globals.terms.credential": "Credential | Credentials",
 | 
			
		||||
  "globals.messages.welcomeToLibredesk": "Welcome to Libredesk",
 | 
			
		||||
  "globals.messages.invalid": "Invalid {name}",
 | 
			
		||||
  "globals.messages.custom": "Custom {name}",
 | 
			
		||||
  "globals.messages.replying": "Replying",
 | 
			
		||||
@@ -294,6 +296,8 @@
 | 
			
		||||
  "globals.messages.submit": "Submit",
 | 
			
		||||
  "globals.messages.send": "Send {name}",
 | 
			
		||||
  "globals.messages.update": "Update {name}",
 | 
			
		||||
  "globals.messages.setUp": "Set up",
 | 
			
		||||
  "globals.messages.invite": "Invite",
 | 
			
		||||
  "globals.messages.enable": "Enable",
 | 
			
		||||
  "globals.messages.disable": "Disable",
 | 
			
		||||
  "globals.messages.block": "Block {name}",
 | 
			
		||||
@@ -306,6 +310,12 @@
 | 
			
		||||
  "globals.messages.reset": "Reset {name}",
 | 
			
		||||
  "globals.messages.lastNItems": "Last {n} {name} | Last {n} {name}",
 | 
			
		||||
  "globals.messages.correctEmailErrors": "Please correct the email errors",
 | 
			
		||||
  "globals.messages.additionalFeedback": "Additional feedback (optional)",
 | 
			
		||||
  "globals.messages.pleaseSelect": "Please select {name} before submitting",
 | 
			
		||||
  "globals.messages.poweredBy": "Powered by",
 | 
			
		||||
  "globals.messages.thankYou": "Thank you!",
 | 
			
		||||
  "globals.messages.pageNotFound": "Page not found",
 | 
			
		||||
  "globals.messages.somethingWentWrong": "Something went wrong",
 | 
			
		||||
  "form.error.min": "Must be at least {min} characters",
 | 
			
		||||
  "form.error.max": "Must be at most {max} characters",
 | 
			
		||||
  "form.error.minmax": "Must be between {min} and {max} characters",
 | 
			
		||||
@@ -339,6 +349,14 @@
 | 
			
		||||
  "conversationStatus.alreadyInUse": "Cannot delete status as it is in use, Please remove this status from all conversations before deleting",
 | 
			
		||||
  "conversationStatus.cannotUpdateDefault": "Cannot update default conversation status",
 | 
			
		||||
  "csat.alreadySubmitted": "CSAT already submitted",
 | 
			
		||||
  "csat.rateYourInteraction": "Rate your recent interaction",
 | 
			
		||||
  "csat.rating.poor": "Poor",
 | 
			
		||||
  "csat.rating.fair": "Fair",
 | 
			
		||||
  "csat.rating.good": "Good",
 | 
			
		||||
  "csat.rating.great": "Great",
 | 
			
		||||
  "csat.rating.excellent": "Excellent",
 | 
			
		||||
  "csat.pageTitle": "Rate your interaction with us",
 | 
			
		||||
  "csat.thankYouMessage": "We appreciate you taking the time to submit your feedback.",
 | 
			
		||||
  "auth.csrfTokenMismatch": "CSRF token mismatch",
 | 
			
		||||
  "auth.invalidOrExpiredSession": "Invalid or expired session",
 | 
			
		||||
  "auth.invalidOrExpiredSessionClearCookie": "Invalid or expired session. Please clear your cookies and try again.",
 | 
			
		||||
@@ -395,7 +413,7 @@
 | 
			
		||||
  "admin.general.maxAllowedFileUploadSize.description": "Max allowed file upload size in MB.",
 | 
			
		||||
  "admin.general.maxAllowedFileUploadSize.valid": "Max allowed file upload size should be between 1 and 500 MB",
 | 
			
		||||
  "admin.general.allowedFileUploadExtensions": "Allowed File Upload Extensions",
 | 
			
		||||
  "admin.general.allowedFileUploadExtensions.description": "Allowed file upload extensions. Use `*` to allow all file types.",
 | 
			
		||||
  "admin.general.allowedFileUploadExtensions.description": "Use `*` to permit all file types. For example: `jpg, png, pdf`",
 | 
			
		||||
  "admin.businessHours.unauthorized": "You do not have permission to view business hours.",
 | 
			
		||||
  "admin.businessHours.setBusinessHours": "Set business hours",
 | 
			
		||||
  "admin.businessHours.alwaysOpen24x7": "Always open (24/7)",
 | 
			
		||||
@@ -483,6 +501,7 @@
 | 
			
		||||
  "admin.role.conversations.updateTags": "Add or remove conversation tags",
 | 
			
		||||
  "admin.role.messages.read": "View conversation messages",
 | 
			
		||||
  "admin.role.messages.write": "Send messages in conversations",
 | 
			
		||||
  "admin.role.messages.writeAsContact": "Send messages as contact",
 | 
			
		||||
  "admin.role.view.manage": "Create and manage conversation views",
 | 
			
		||||
  "admin.role.generalSettings.manage": "Manage General Settings",
 | 
			
		||||
  "admin.role.notificationSettings.manage": "Manage Notification Settings",
 | 
			
		||||
@@ -508,12 +527,13 @@
 | 
			
		||||
  "admin.role.contactNotes.write": "Add Contact Notes",
 | 
			
		||||
  "admin.role.contactNotes.delete": "Delete Contact Notes",
 | 
			
		||||
  "admin.role.customAttributes.manage": "Manage Custom Attributes",
 | 
			
		||||
  "admin.role.webhooks.manage": "Manage Webhooks",
 | 
			
		||||
  "admin.role.activityLog.manage": "Manage Activity Log",
 | 
			
		||||
  "admin.automation.newConversation.description": "Rules that run when a new conversation is created, drag and drop to reorder rules.",
 | 
			
		||||
  "admin.automation.conversationUpdate": "Conversation Update",
 | 
			
		||||
  "admin.automation.conversationUpdate.description": "Rules that run when a conversation is updated.",
 | 
			
		||||
  "admin.automation.timeTriggers": "Time Triggers",
 | 
			
		||||
  "admin.automation.timeTriggers.description": "Rules that once an hour",
 | 
			
		||||
  "admin.automation.timeTriggers.description": "Rules that run once an hour",
 | 
			
		||||
  "admin.automation.match": "Match",
 | 
			
		||||
  "admin.automation.any": "ANY",
 | 
			
		||||
  "admin.automation.all": "ALL",
 | 
			
		||||
@@ -533,6 +553,7 @@
 | 
			
		||||
  "admin.automation.event.message.incoming": "Incoming message",
 | 
			
		||||
  "admin.automation.invalid": "Make sure you have atleast one action and one rule and their values are not empty.",
 | 
			
		||||
  "admin.notification.restartApp": "Settings updated successfully, Please restart the app for changes to take effect.",
 | 
			
		||||
  "admin.banner.restartMessage": "Some settings have been changed that require an application restart to take effect.",
 | 
			
		||||
  "admin.template.outgoingEmailTemplates": "Outgoing email templates",
 | 
			
		||||
  "admin.template.emailNotificationTemplates": "Email notification templates",
 | 
			
		||||
  "admin.template.makeSureTemplateHasContent": "Make sure the template has {content} only once.",
 | 
			
		||||
@@ -568,6 +589,7 @@
 | 
			
		||||
  "search.noResultsForQuery": "No results found for query `{query}`. Try a different search term.",
 | 
			
		||||
  "search.minQueryLength": " Please enter at least {length} characters to search.",
 | 
			
		||||
  "search.searchBy": "Search by reference number, contact email address or messages in conversations.",
 | 
			
		||||
  "search.adjustSearchTerms": "Try adjusting your search terms or filters.",
 | 
			
		||||
  "sla.overdueBy": "Overdue by",
 | 
			
		||||
  "sla.met": "SLA met",
 | 
			
		||||
  "view.form.description": "Create and save custom filter views for quick access to your conversations.",
 | 
			
		||||
@@ -621,5 +643,8 @@
 | 
			
		||||
  "contact.unblockConfirm": "Are you sure you want to unblock this contact? They will be able to interact with you again.",
 | 
			
		||||
  "contact.alreadyExistsWithEmail": "Another contact with same email already exists",
 | 
			
		||||
  "contact.notes.empty": "No notes yet",
 | 
			
		||||
  "contact.notes.help": "Add note for this contact to keep track of important information and conversations."
 | 
			
		||||
  "contact.notes.help": "Add note for this contact to keep track of important information and conversations.",
 | 
			
		||||
  "setup.completeYourSetup": "Complete your setup",
 | 
			
		||||
  "setup.createFirstInbox": "Create your first inbox",
 | 
			
		||||
  "setup.inviteTeammates": "Invite teammates"
 | 
			
		||||
}
 | 
			
		||||
@@ -82,18 +82,9 @@ func (m *Manager) GetAll(order, orderBy, filtersJSON string, page, pageSize int)
 | 
			
		||||
	return activityLogs, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Create adds a new activity log.
 | 
			
		||||
func (m *Manager) Create(activityType, activityDescription string, actorID int, targetModelType string, targetModelID int, ip string) error {
 | 
			
		||||
	if _, err := m.q.InsertActivity.Exec(activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil {
 | 
			
		||||
		m.lo.Error("error inserting activity", "error", err)
 | 
			
		||||
		return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.activityLog}"), nil)
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Login records a login event for the given user.
 | 
			
		||||
func (al *Manager) Login(userID int, email, ip string) error {
 | 
			
		||||
	return al.Create(
 | 
			
		||||
	return al.create(
 | 
			
		||||
		models.AgentLogin,
 | 
			
		||||
		fmt.Sprintf("%s (#%d) logged in", email, userID),
 | 
			
		||||
		userID,
 | 
			
		||||
@@ -105,7 +96,7 @@ func (al *Manager) Login(userID int, email, ip string) error {
 | 
			
		||||
 | 
			
		||||
// Logout records a logout event for the given user.
 | 
			
		||||
func (al *Manager) Logout(userID int, email, ip string) error {
 | 
			
		||||
	return al.Create(
 | 
			
		||||
	return al.create(
 | 
			
		||||
		models.AgentLogout,
 | 
			
		||||
		fmt.Sprintf("%s (#%d) logged out", email, userID),
 | 
			
		||||
		userID,
 | 
			
		||||
@@ -123,7 +114,7 @@ func (al *Manager) Away(actorID int, actorEmail, ip string, targetID int, target
 | 
			
		||||
	} else {
 | 
			
		||||
		description = fmt.Sprintf("%s (#%d) is away", actorEmail, actorID)
 | 
			
		||||
	}
 | 
			
		||||
	return al.Create(
 | 
			
		||||
	return al.create(
 | 
			
		||||
		models.AgentAway, /* activity type*/
 | 
			
		||||
		description,
 | 
			
		||||
		actorID,           /*actor_id*/
 | 
			
		||||
@@ -141,7 +132,7 @@ func (al *Manager) AwayReassigned(actorID int, actorEmail, ip string, targetID i
 | 
			
		||||
	} else {
 | 
			
		||||
		description = fmt.Sprintf("%s (#%d) is away and reassigning", actorEmail, actorID)
 | 
			
		||||
	}
 | 
			
		||||
	return al.Create(
 | 
			
		||||
	return al.create(
 | 
			
		||||
		models.AgentAwayReassigned, /* activity type*/
 | 
			
		||||
		description,
 | 
			
		||||
		actorID,           /*actor_id*/
 | 
			
		||||
@@ -159,7 +150,7 @@ func (al *Manager) Online(actorID int, actorEmail, ip string, targetID int, targ
 | 
			
		||||
	} else {
 | 
			
		||||
		description = fmt.Sprintf("%s (#%d) is online", actorEmail, actorID)
 | 
			
		||||
	}
 | 
			
		||||
	return al.Create(
 | 
			
		||||
	return al.create(
 | 
			
		||||
		models.AgentOnline, /* activity type*/
 | 
			
		||||
		description,
 | 
			
		||||
		actorID,           /*actor_id*/
 | 
			
		||||
@@ -190,6 +181,16 @@ func (al *Manager) UserAvailability(actorID int, actorEmail, status, ip, targetE
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// create creates a new activity log in DB.
 | 
			
		||||
func (m *Manager) create(activityType, activityDescription string, actorID int, targetModelType string, targetModelID int, ip string) error {
 | 
			
		||||
	var activityLog models.ActivityLog
 | 
			
		||||
	if err := m.q.InsertActivity.Get(&activityLog, activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil {
 | 
			
		||||
		m.lo.Error("error inserting activity log", "error", err)
 | 
			
		||||
		return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.activityLog}"), nil)
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// makeQuery constructs the SQL query for fetching activity logs with filters and pagination.
 | 
			
		||||
func (m *Manager) makeQuery(page, pageSize int, order, orderBy, filtersJSON string) (string, []any, error) {
 | 
			
		||||
	var (
 | 
			
		||||
 
 | 
			
		||||
@@ -2,10 +2,10 @@
 | 
			
		||||
SELECT id, name, provider, config, is_default FROM ai_providers where is_default is true;
 | 
			
		||||
 | 
			
		||||
-- name: get-prompt
 | 
			
		||||
SELECT id, key, title, content FROM ai_prompts where key = $1;
 | 
			
		||||
SELECT id, created_at, updated_at, key, title, content FROM ai_prompts where key = $1;
 | 
			
		||||
 | 
			
		||||
-- name: get-prompts
 | 
			
		||||
SELECT id, key, title FROM ai_prompts order by title;
 | 
			
		||||
SELECT id, created_at, updated_at, key, title FROM ai_prompts order by title;
 | 
			
		||||
 | 
			
		||||
-- name: set-openai-key
 | 
			
		||||
UPDATE ai_providers 
 | 
			
		||||
 
 | 
			
		||||
@@ -183,6 +183,7 @@ func (e *Enforcer) EnforceConversationAccess(user umodels.User, conversation cmo
 | 
			
		||||
// EnforceMediaAccess checks for read access on linked model to media.
 | 
			
		||||
func (e *Enforcer) EnforceMediaAccess(user umodels.User, model string) (bool, error) {
 | 
			
		||||
	switch model {
 | 
			
		||||
	// TODO: Pick this table / model name from the package/models/models.go
 | 
			
		||||
	case "messages":
 | 
			
		||||
		allowed, err := e.Enforce(user, model, "read")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,7 @@ const (
 | 
			
		||||
	PermConversationWrite               = "conversations:write"
 | 
			
		||||
	PermMessagesRead                    = "messages:read"
 | 
			
		||||
	PermMessagesWrite                   = "messages:write"
 | 
			
		||||
	PermMessagesWriteAsContact          = "messages:write_as_contact"
 | 
			
		||||
 | 
			
		||||
	// View
 | 
			
		||||
	PermViewManage = "view:manage"
 | 
			
		||||
@@ -102,6 +103,7 @@ var validPermissions = map[string]struct{}{
 | 
			
		||||
	PermConversationWrite:               {},
 | 
			
		||||
	PermMessagesRead:                    {},
 | 
			
		||||
	PermMessagesWrite:                   {},
 | 
			
		||||
	PermMessagesWriteAsContact:          {},
 | 
			
		||||
	PermViewManage:                      {},
 | 
			
		||||
	PermStatusManage:                    {},
 | 
			
		||||
	PermTagsManage:                      {},
 | 
			
		||||
 
 | 
			
		||||
@@ -33,7 +33,7 @@ type conversationStore interface {
 | 
			
		||||
 | 
			
		||||
type teamStore interface {
 | 
			
		||||
	GetAll() ([]tmodels.Team, error)
 | 
			
		||||
	GetMembers(teamID int) ([]umodels.User, error)
 | 
			
		||||
	GetMembers(teamID int) ([]tmodels.TeamMember, error)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Engine represents a manager for assigning unassigned conversations
 | 
			
		||||
 
 | 
			
		||||
@@ -232,6 +232,14 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
 | 
			
		||||
		for _, ruleValue := range ruleValues {
 | 
			
		||||
			// Normalize rule value by collapsing multiple spaces
 | 
			
		||||
			normalizedRuleValue := strings.Join(strings.Fields(ruleValue), " ")
 | 
			
		||||
			
 | 
			
		||||
			// Respect CaseSensitiveMatch flag
 | 
			
		||||
			if rule.CaseSensitiveMatch {
 | 
			
		||||
				if strings.Contains(normalizedInputText, normalizedRuleValue) {
 | 
			
		||||
					conditionMet = true
 | 
			
		||||
					break
 | 
			
		||||
				}
 | 
			
		||||
			} else {
 | 
			
		||||
				if strings.Contains(
 | 
			
		||||
					strings.ToLower(normalizedInputText),
 | 
			
		||||
					strings.ToLower(normalizedRuleValue),
 | 
			
		||||
@@ -240,6 +248,7 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
 | 
			
		||||
					break
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	case models.RuleOperatorNotContains:
 | 
			
		||||
		// Normalize input text by collapsing multiple spaces
 | 
			
		||||
		normalizedInputText := strings.Join(strings.Fields(valueToCompare), " ")
 | 
			
		||||
@@ -249,6 +258,14 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
 | 
			
		||||
		for _, ruleValue := range ruleValues {
 | 
			
		||||
			// Normalize rule value by collapsing multiple spaces
 | 
			
		||||
			normalizedRuleValue := strings.Join(strings.Fields(ruleValue), " ")
 | 
			
		||||
			
 | 
			
		||||
			// Respect CaseSensitiveMatch flag
 | 
			
		||||
			if rule.CaseSensitiveMatch {
 | 
			
		||||
				if strings.Contains(normalizedInputText, normalizedRuleValue) {
 | 
			
		||||
					conditionMet = false
 | 
			
		||||
					break
 | 
			
		||||
				}
 | 
			
		||||
			} else {
 | 
			
		||||
				if strings.Contains(
 | 
			
		||||
					strings.ToLower(normalizedInputText),
 | 
			
		||||
					strings.ToLower(normalizedRuleValue),
 | 
			
		||||
@@ -257,6 +274,7 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
 | 
			
		||||
					break
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	case models.RuleOperatorSet:
 | 
			
		||||
		conditionMet = len(valueToCompare) > 0
 | 
			
		||||
	case models.RuleOperatorNotSet:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1201
									
								
								internal/automation/evaluator_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1201
									
								
								internal/automation/evaluator_test.go
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -7,10 +7,10 @@ select
 | 
			
		||||
from automation_rules where enabled is TRUE ORDER BY weight ASC;
 | 
			
		||||
 | 
			
		||||
-- name: get-all
 | 
			
		||||
SELECT id, created_at, updated_at, enabled, name, description, type, events, rules, execution_mode from automation_rules where type = $1 ORDER BY weight ASC;
 | 
			
		||||
SELECT id, created_at, updated_at, "name", description, "type", rules, events, enabled, weight, execution_mode from automation_rules where type = $1 ORDER BY weight ASC;
 | 
			
		||||
 | 
			
		||||
-- name: get-rule
 | 
			
		||||
SELECT id, created_at, updated_at, enabled, name, description, type, events, rules, execution_mode from automation_rules where id = $1;
 | 
			
		||||
SELECT id, created_at, updated_at, "name", description, "type", rules, events, enabled, weight, execution_mode from automation_rules where id = $1;
 | 
			
		||||
 | 
			
		||||
-- name: update-rule
 | 
			
		||||
INSERT INTO automation_rules(id, name, description, type, events, rules, enabled)
 | 
			
		||||
 
 | 
			
		||||
@@ -15,7 +15,10 @@ SELECT id,
 | 
			
		||||
    created_at,
 | 
			
		||||
    updated_at,
 | 
			
		||||
    "name",
 | 
			
		||||
    description
 | 
			
		||||
    description,
 | 
			
		||||
    is_always_open,
 | 
			
		||||
    hours,
 | 
			
		||||
    holidays
 | 
			
		||||
FROM business_hours
 | 
			
		||||
ORDER BY updated_at DESC;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -200,7 +200,7 @@ type queries struct {
 | 
			
		||||
	GetConversationsCreatedAfter       *sqlx.Stmt `query:"get-conversations-created-after"`
 | 
			
		||||
	GetUnassignedConversations         *sqlx.Stmt `query:"get-unassigned-conversations"`
 | 
			
		||||
	GetConversations                   string     `query:"get-conversations"`
 | 
			
		||||
	GetContactConversations            *sqlx.Stmt `query:"get-contact-conversations"`
 | 
			
		||||
	GetContactPreviousConversations    *sqlx.Stmt `query:"get-contact-previous-conversations"`
 | 
			
		||||
	GetConversationParticipants        *sqlx.Stmt `query:"get-conversation-participants"`
 | 
			
		||||
	GetUserActiveConversationsCount    *sqlx.Stmt `query:"get-user-active-conversations-count"`
 | 
			
		||||
	UpdateConversationFirstReplyAt     *sqlx.Stmt `query:"update-conversation-first-reply-at"`
 | 
			
		||||
@@ -280,11 +280,11 @@ func (c *Manager) GetConversation(id int, uuid string) (models.Conversation, err
 | 
			
		||||
	return conversation, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetContactConversations retrieves conversations for a contact.
 | 
			
		||||
func (c *Manager) GetContactConversations(contactID int) ([]models.Conversation, error) {
 | 
			
		||||
	var conversations = make([]models.Conversation, 0)
 | 
			
		||||
	if err := c.q.GetContactConversations.Select(&conversations, contactID); err != nil {
 | 
			
		||||
		c.lo.Error("error fetching conversations", "error", err)
 | 
			
		||||
// GetContactPreviousConversations retrieves previous conversations for a contact with a configurable limit.
 | 
			
		||||
func (c *Manager) GetContactPreviousConversations(contactID int, limit int) ([]models.PreviousConversation, error) {
 | 
			
		||||
	var conversations = make([]models.PreviousConversation, 0)
 | 
			
		||||
	if err := c.q.GetContactPreviousConversations.Select(&conversations, contactID, limit); err != nil {
 | 
			
		||||
		c.lo.Error("error fetching previous conversations", "error", err)
 | 
			
		||||
		return conversations, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil)
 | 
			
		||||
	}
 | 
			
		||||
	return conversations, nil
 | 
			
		||||
@@ -348,32 +348,32 @@ func (c *Manager) GetConversationUUID(id int) (string, error) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetAllConversationsList retrieves all conversations with optional filtering, ordering, and pagination.
 | 
			
		||||
func (c *Manager) GetAllConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
 | 
			
		||||
func (c *Manager) GetAllConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
 | 
			
		||||
	return c.GetConversations(0, []int{}, []string{models.AllConversations}, order, orderBy, filters, page, pageSize)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetAssignedConversationsList retrieves conversations assigned to a specific user with optional filtering, ordering, and pagination.
 | 
			
		||||
func (c *Manager) GetAssignedConversationsList(userID int, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
 | 
			
		||||
func (c *Manager) GetAssignedConversationsList(userID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
 | 
			
		||||
	return c.GetConversations(userID, []int{}, []string{models.AssignedConversations}, order, orderBy, filters, page, pageSize)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetUnassignedConversationsList retrieves conversations assigned to a team the user is part of with optional filtering, ordering, and pagination.
 | 
			
		||||
func (c *Manager) GetUnassignedConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
 | 
			
		||||
func (c *Manager) GetUnassignedConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
 | 
			
		||||
	return c.GetConversations(0, []int{}, []string{models.UnassignedConversations}, order, orderBy, filters, page, pageSize)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetTeamUnassignedConversationsList retrieves conversations assigned to a team with optional filtering, ordering, and pagination.
 | 
			
		||||
func (c *Manager) GetTeamUnassignedConversationsList(teamID int, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
 | 
			
		||||
func (c *Manager) GetTeamUnassignedConversationsList(teamID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
 | 
			
		||||
	return c.GetConversations(0, []int{teamID}, []string{models.TeamUnassignedConversations}, order, orderBy, filters, page, pageSize)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *Manager) GetViewConversationsList(userID int, teamIDs []int, listType []string, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
 | 
			
		||||
func (c *Manager) GetViewConversationsList(userID int, teamIDs []int, listType []string, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
 | 
			
		||||
	return c.GetConversations(userID, teamIDs, listType, order, orderBy, filters, page, pageSize)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetConversations retrieves conversations list based on user ID, type, and optional filtering, ordering, and pagination.
 | 
			
		||||
func (c *Manager) GetConversations(userID int, teamIDs []int, listTypes []string, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
 | 
			
		||||
	var conversations = make([]models.Conversation, 0)
 | 
			
		||||
func (c *Manager) GetConversations(userID int, teamIDs []int, listTypes []string, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
 | 
			
		||||
	var conversations = make([]models.ConversationListItem, 0)
 | 
			
		||||
 | 
			
		||||
	// Make the query.
 | 
			
		||||
	query, qArgs, err := c.makeConversationsListQuery(userID, teamIDs, listTypes, c.q.GetConversations, order, orderBy, page, pageSize, filters)
 | 
			
		||||
@@ -541,6 +541,10 @@ func (c *Manager) UpdateConversationTeamAssignee(uuid string, teamID int, actor
 | 
			
		||||
 | 
			
		||||
	// Team changed?
 | 
			
		||||
	if previousAssignedTeamID != teamID {
 | 
			
		||||
		// Remove assigned user if team has changed.
 | 
			
		||||
		c.RemoveConversationAssignee(uuid, models.AssigneeTypeUser, actor)
 | 
			
		||||
 | 
			
		||||
		// Apply SLA policy if this new team has a SLA policy.
 | 
			
		||||
		team, err := c.teamStore.Get(teamID)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil
 | 
			
		||||
@@ -930,7 +934,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("making recipients for reply action: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
		_, err = m.SendReply(
 | 
			
		||||
		_, err = m.QueueReply(
 | 
			
		||||
			[]mmodels.Media{},
 | 
			
		||||
			conv.InboxID,
 | 
			
		||||
			user.ID,
 | 
			
		||||
@@ -960,7 +964,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RemoveConversationAssignee removes the assignee from the conversation.
 | 
			
		||||
// RemoveConversationAssignee removes assigned user from a conversation.
 | 
			
		||||
func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.User) error {
 | 
			
		||||
	if _, err := m.q.RemoveConversationAssignee.Exec(uuid, typ); err != nil {
 | 
			
		||||
		m.lo.Error("error removing conversation assignee", "error", err)
 | 
			
		||||
@@ -975,6 +979,14 @@ func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.Use
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Broadcast ws update.
 | 
			
		||||
	switch typ {
 | 
			
		||||
	case models.AssigneeTypeUser:
 | 
			
		||||
		m.BroadcastConversationUpdate(uuid, "assigned_user_id", nil)
 | 
			
		||||
	case models.AssigneeTypeTeam:
 | 
			
		||||
		m.BroadcastConversationUpdate(uuid, "assigned_team_id", nil)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -1001,8 +1013,8 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
 | 
			
		||||
		return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Send CSAT reply.
 | 
			
		||||
	_, err = m.SendReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta)
 | 
			
		||||
	// Queue CSAT reply.
 | 
			
		||||
	_, err = m.QueueReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		m.lo.Error("error sending CSAT reply", "conversation_uuid", conversation.UUID, "error", err)
 | 
			
		||||
		return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil)
 | 
			
		||||
@@ -1081,6 +1093,35 @@ func (c *Manager) makeConversationsListQuery(userID int, teamIDs []int, listType
 | 
			
		||||
		return "", nil, fmt.Errorf("no conversation list types specified")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Parse filters to extract tag filters
 | 
			
		||||
	var (
 | 
			
		||||
		filters          []dbutil.Filter
 | 
			
		||||
		tagFilters       []dbutil.Filter
 | 
			
		||||
		remainingFilters []dbutil.Filter
 | 
			
		||||
	)
 | 
			
		||||
	if filtersJSON != "" && filtersJSON != "[]" {
 | 
			
		||||
		if err := json.Unmarshal([]byte(filtersJSON), &filters); err != nil {
 | 
			
		||||
			return "", nil, fmt.Errorf("invalid filters JSON: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Separate tag filters from other filters
 | 
			
		||||
		for _, f := range filters {
 | 
			
		||||
			if f.Field == "tags" && (f.Operator == "contains" || f.Operator == "not contains" || f.Operator == "set" || f.Operator == "not set") {
 | 
			
		||||
				tagFilters = append(tagFilters, f)
 | 
			
		||||
			} else {
 | 
			
		||||
				remainingFilters = append(remainingFilters, f)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Update filtersJSON with remaining filters for the generic builder
 | 
			
		||||
		if len(remainingFilters) > 0 {
 | 
			
		||||
			b, _ := json.Marshal(remainingFilters)
 | 
			
		||||
			filtersJSON = string(b)
 | 
			
		||||
		} else {
 | 
			
		||||
			filtersJSON = "[]"
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Prepare the conditions based on the list types.
 | 
			
		||||
	conditions := []string{}
 | 
			
		||||
	for _, lt := range listTypes {
 | 
			
		||||
@@ -1106,13 +1147,60 @@ func (c *Manager) makeConversationsListQuery(userID int, teamIDs []int, listType
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Build the base query with list type conditions
 | 
			
		||||
	var whereClause string
 | 
			
		||||
	if len(conditions) > 0 {
 | 
			
		||||
		baseQuery = fmt.Sprintf(baseQuery, "AND ("+strings.Join(conditions, " OR ")+")")
 | 
			
		||||
	} else {
 | 
			
		||||
		// Replace the `%s` in the base query with an empty string.
 | 
			
		||||
		baseQuery = fmt.Sprintf(baseQuery, "")
 | 
			
		||||
		whereClause = "AND (" + strings.Join(conditions, " OR ") + ")"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Add tag filter conditions
 | 
			
		||||
	// TODO: Evaluate - https://github.com/Masterminds/squirrel when required.
 | 
			
		||||
	for _, tf := range tagFilters {
 | 
			
		||||
		switch tf.Operator {
 | 
			
		||||
		case "contains", "not contains":
 | 
			
		||||
			var tagIDs []int
 | 
			
		||||
			if err := json.Unmarshal([]byte(tf.Value), &tagIDs); err != nil {
 | 
			
		||||
				return "", nil, fmt.Errorf("invalid tag IDs in filter: %w", err)
 | 
			
		||||
			}
 | 
			
		||||
			if len(tagIDs) > 0 {
 | 
			
		||||
				paramIdx := len(qArgs) + 1
 | 
			
		||||
				switch tf.Operator {
 | 
			
		||||
				case "contains":
 | 
			
		||||
					// Has any of the tags
 | 
			
		||||
					tagCondition := fmt.Sprintf(` AND conversations.id IN (
 | 
			
		||||
						SELECT DISTINCT conversation_id 
 | 
			
		||||
						FROM conversation_tags 
 | 
			
		||||
						WHERE tag_id = ANY($%d::int[])
 | 
			
		||||
					)`, paramIdx)
 | 
			
		||||
					whereClause += tagCondition
 | 
			
		||||
				case "not contains":
 | 
			
		||||
					// Doesn't have any of the tags
 | 
			
		||||
					tagCondition := fmt.Sprintf(` AND conversations.id NOT IN (
 | 
			
		||||
						SELECT DISTINCT conversation_id 
 | 
			
		||||
						FROM conversation_tags 
 | 
			
		||||
						WHERE tag_id = ANY($%d::int[])
 | 
			
		||||
					)`, paramIdx)
 | 
			
		||||
					whereClause += tagCondition
 | 
			
		||||
				}
 | 
			
		||||
				qArgs = append(qArgs, pq.Array(tagIDs))
 | 
			
		||||
			}
 | 
			
		||||
		case "set":
 | 
			
		||||
			// Has any tags at all
 | 
			
		||||
			whereClause += ` AND EXISTS (
 | 
			
		||||
				SELECT 1 FROM conversation_tags 
 | 
			
		||||
				WHERE conversation_id = conversations.id
 | 
			
		||||
			)`
 | 
			
		||||
		case "not set":
 | 
			
		||||
			// Has no tags at all
 | 
			
		||||
			whereClause += ` AND NOT EXISTS (
 | 
			
		||||
				SELECT 1 FROM conversation_tags 
 | 
			
		||||
				WHERE conversation_id = conversations.id
 | 
			
		||||
			)`
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	baseQuery = fmt.Sprintf(baseQuery, whereClause)
 | 
			
		||||
 | 
			
		||||
	return dbutil.BuildPaginatedQuery(baseQuery, qArgs, dbutil.PaginationOptions{
 | 
			
		||||
		Order:    order,
 | 
			
		||||
		OrderBy:  orderBy,
 | 
			
		||||
 
 | 
			
		||||
@@ -167,7 +167,7 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// References is sorted in DESC i.e newest message first, so reverse it to keep the references in order.
 | 
			
		||||
	stringutil.ReverseSlice(message.References)
 | 
			
		||||
	slices.Reverse(message.References)
 | 
			
		||||
 | 
			
		||||
	// Remove the current message ID from the references.
 | 
			
		||||
	message.References = stringutil.RemoveItemByValue(message.References, message.SourceID.String)
 | 
			
		||||
@@ -347,9 +347,10 @@ func (m *Manager) UpdateMessageStatus(messageUUID string, status string) error {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// MarkMessageAsPending updates message status to `Pending`, so if it's a outgoing message it can be picked up again by a worker.
 | 
			
		||||
// MarkMessageAsPending updates message status to `Pending`, enqueuing it for sending.
 | 
			
		||||
func (m *Manager) MarkMessageAsPending(uuid string) error {
 | 
			
		||||
	if err := m.UpdateMessageStatus(uuid, models.MessageStatusPending); err != nil {
 | 
			
		||||
		m.lo.Error("error marking message as pending", "uuid", uuid, "error", err)
 | 
			
		||||
		return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil)
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
@@ -374,8 +375,27 @@ func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversat
 | 
			
		||||
	return message, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SendReply inserts a reply message in a conversation.
 | 
			
		||||
func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conversationUUID, content string, to, cc, bcc []string, meta map[string]interface{}) (models.Message, error) {
 | 
			
		||||
// CreateContactMessage creates a contact message in a conversation.
 | 
			
		||||
func (m *Manager) CreateContactMessage(media []mmodels.Media, contactID int, conversationUUID, content, contentType string) (models.Message, error) {
 | 
			
		||||
	message := models.Message{
 | 
			
		||||
		ConversationUUID: conversationUUID,
 | 
			
		||||
		SenderID:         contactID,
 | 
			
		||||
		Type:             models.MessageIncoming,
 | 
			
		||||
		SenderType:       models.SenderTypeContact,
 | 
			
		||||
		Status:           models.MessageStatusReceived,
 | 
			
		||||
		Content:          content,
 | 
			
		||||
		ContentType:      contentType,
 | 
			
		||||
		Private:          false,
 | 
			
		||||
		Media:            media,
 | 
			
		||||
	}
 | 
			
		||||
	if err := m.InsertMessage(&message); err != nil {
 | 
			
		||||
		return models.Message{}, err
 | 
			
		||||
	}
 | 
			
		||||
	return message, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// QueueReply queues a reply message in a conversation.
 | 
			
		||||
func (m *Manager) QueueReply(media []mmodels.Media, inboxID, senderID int, conversationUUID, content string, to, cc, bcc []string, meta map[string]interface{}) (models.Message, error) {
 | 
			
		||||
	var (
 | 
			
		||||
		message = models.Message{}
 | 
			
		||||
	)
 | 
			
		||||
@@ -402,7 +422,7 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conver
 | 
			
		||||
		return message, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorMarshalling", "name", "{globals.terms.meta}"), nil)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Generage unique source ID i.e. message-id for email.
 | 
			
		||||
	// Generate unique source ID i.e. message-id for email.
 | 
			
		||||
	inbox, err := m.inboxStore.GetDBRecord(inboxID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return message, err
 | 
			
		||||
@@ -442,6 +462,11 @@ func (m *Manager) InsertMessage(message *models.Message) error {
 | 
			
		||||
		message.Meta = json.RawMessage(`{}`)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Handle empty content type enum, default to text.
 | 
			
		||||
	if message.ContentType == "" {
 | 
			
		||||
		message.ContentType = models.ContentTypeText
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Convert HTML content to text for search.
 | 
			
		||||
	message.TextContent = stringutil.HTML2Text(message.Content)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -52,16 +52,57 @@ var (
 | 
			
		||||
	ContentTypeHTML = "html"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// ConversationListItem represents a conversation in list views
 | 
			
		||||
type ConversationListItem struct {
 | 
			
		||||
	Total              int                     `db:"total" json:"-"`
 | 
			
		||||
	ID                 int                     `db:"id" json:"id"`
 | 
			
		||||
	CreatedAt          time.Time               `db:"created_at" json:"created_at"`
 | 
			
		||||
	UpdatedAt          time.Time               `db:"updated_at" json:"updated_at"`
 | 
			
		||||
	UUID               string                  `db:"uuid" json:"uuid"`
 | 
			
		||||
	WaitingSince       null.Time               `db:"waiting_since" json:"waiting_since"`
 | 
			
		||||
	AssigneeLastSeenAt null.Time               `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
 | 
			
		||||
	Contact            ConversationListContact `db:"contact" json:"contact"`
 | 
			
		||||
	InboxChannel       string                  `db:"inbox_channel" json:"inbox_channel"`
 | 
			
		||||
	InboxName          string                  `db:"inbox_name" json:"inbox_name"`
 | 
			
		||||
	SLAPolicyID        null.Int                `db:"sla_policy_id" json:"sla_policy_id"`
 | 
			
		||||
	FirstReplyAt       null.Time               `db:"first_reply_at" json:"first_reply_at"`
 | 
			
		||||
	LastReplyAt        null.Time               `db:"last_reply_at" json:"last_reply_at"`
 | 
			
		||||
	ResolvedAt         null.Time               `db:"resolved_at" json:"resolved_at"`
 | 
			
		||||
	Subject            null.String             `db:"subject" json:"subject"`
 | 
			
		||||
	LastMessage        null.String             `db:"last_message" json:"last_message"`
 | 
			
		||||
	LastMessageAt      null.Time               `db:"last_message_at" json:"last_message_at"`
 | 
			
		||||
	LastMessageSender  null.String             `db:"last_message_sender" json:"last_message_sender"`
 | 
			
		||||
	NextSLADeadlineAt  null.Time               `db:"next_sla_deadline_at" json:"next_sla_deadline_at"`
 | 
			
		||||
	PriorityID         null.Int                `db:"priority_id" json:"priority_id"`
 | 
			
		||||
	UnreadMessageCount int                     `db:"unread_message_count" json:"unread_message_count"`
 | 
			
		||||
	Status             null.String             `db:"status" json:"status"`
 | 
			
		||||
	Priority           null.String             `db:"priority" json:"priority"`
 | 
			
		||||
	FirstResponseDueAt null.Time               `db:"first_response_deadline_at" json:"first_response_deadline_at"`
 | 
			
		||||
	ResolutionDueAt    null.Time               `db:"resolution_deadline_at" json:"resolution_deadline_at"`
 | 
			
		||||
	AppliedSLAID       null.Int                `db:"applied_sla_id" json:"applied_sla_id"`
 | 
			
		||||
	NextResponseDueAt  null.Time               `db:"next_response_deadline_at" json:"next_response_deadline_at"`
 | 
			
		||||
	NextResponseMetAt  null.Time               `db:"next_response_met_at" json:"next_response_met_at"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ConversationListContact represents contact info in conversation list views
 | 
			
		||||
type ConversationListContact struct {
 | 
			
		||||
	CreatedAt time.Time   `db:"created_at" json:"created_at"`
 | 
			
		||||
	UpdatedAt time.Time   `db:"updated_at" json:"updated_at"`
 | 
			
		||||
	FirstName string      `db:"first_name" json:"first_name"`
 | 
			
		||||
	LastName  string      `db:"last_name" json:"last_name"`
 | 
			
		||||
	AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Conversation struct {
 | 
			
		||||
	ID                    int             `db:"id" json:"id,omitempty"`
 | 
			
		||||
	ID                    int                    `db:"id" json:"id"`
 | 
			
		||||
	CreatedAt             time.Time              `db:"created_at" json:"created_at"`
 | 
			
		||||
	UpdatedAt             time.Time              `db:"updated_at" json:"updated_at"`
 | 
			
		||||
	UUID                  string                 `db:"uuid" json:"uuid"`
 | 
			
		||||
	ContactID             int                    `db:"contact_id" json:"contact_id"`
 | 
			
		||||
	InboxID               int             `db:"inbox_id" json:"inbox_id,omitempty"`
 | 
			
		||||
	ClosedAt              null.Time       `db:"closed_at" json:"closed_at,omitempty"`
 | 
			
		||||
	ResolvedAt            null.Time       `db:"resolved_at" json:"resolved_at,omitempty"`
 | 
			
		||||
	ReferenceNumber       string          `db:"reference_number" json:"reference_number,omitempty"`
 | 
			
		||||
	InboxID               int                    `db:"inbox_id" json:"inbox_id"`
 | 
			
		||||
	ClosedAt              null.Time              `db:"closed_at" json:"closed_at"`
 | 
			
		||||
	ResolvedAt            null.Time              `db:"resolved_at" json:"resolved_at"`
 | 
			
		||||
	ReferenceNumber       string                 `db:"reference_number" json:"reference_number"`
 | 
			
		||||
	Priority              null.String            `db:"priority" json:"priority"`
 | 
			
		||||
	PriorityID            null.Int               `db:"priority_id" json:"priority_id"`
 | 
			
		||||
	Status                null.String            `db:"status" json:"status"`
 | 
			
		||||
@@ -73,7 +114,6 @@ type Conversation struct {
 | 
			
		||||
	AssigneeLastSeenAt    null.Time              `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
 | 
			
		||||
	WaitingSince          null.Time              `db:"waiting_since" json:"waiting_since"`
 | 
			
		||||
	Subject               null.String            `db:"subject" json:"subject"`
 | 
			
		||||
	UnreadMessageCount    int             `db:"unread_message_count" json:"unread_message_count"`
 | 
			
		||||
	InboxMail             string                 `db:"inbox_mail" json:"inbox_mail"`
 | 
			
		||||
	InboxName             string                 `db:"inbox_name" json:"inbox_name"`
 | 
			
		||||
	InboxChannel          string                 `db:"inbox_channel" json:"inbox_channel"`
 | 
			
		||||
@@ -83,21 +123,57 @@ type Conversation struct {
 | 
			
		||||
	LastMessageAt         null.Time              `db:"last_message_at" json:"last_message_at"`
 | 
			
		||||
	LastMessage           null.String            `db:"last_message" json:"last_message"`
 | 
			
		||||
	LastMessageSender     null.String            `db:"last_message_sender" json:"last_message_sender"`
 | 
			
		||||
	Contact               umodels.User    `db:"contact" json:"contact"`
 | 
			
		||||
	Contact               ConversationContact    `db:"contact" json:"contact"`
 | 
			
		||||
	SLAPolicyID           null.Int               `db:"sla_policy_id" json:"sla_policy_id"`
 | 
			
		||||
	SlaPolicyName         null.String            `db:"sla_policy_name" json:"sla_policy_name"`
 | 
			
		||||
	AppliedSLAID          null.Int               `db:"applied_sla_id" json:"applied_sla_id"`
 | 
			
		||||
	NextSLADeadlineAt     null.Time       `db:"next_sla_deadline_at" json:"next_sla_deadline_at"`
 | 
			
		||||
	FirstResponseDueAt    null.Time              `db:"first_response_deadline_at" json:"first_response_deadline_at"`
 | 
			
		||||
	ResolutionDueAt       null.Time              `db:"resolution_deadline_at" json:"resolution_deadline_at"`
 | 
			
		||||
	NextResponseDueAt     null.Time              `db:"next_response_deadline_at" json:"next_response_deadline_at"`
 | 
			
		||||
	NextResponseMetAt     null.Time              `db:"next_response_met_at" json:"next_response_met_at"`
 | 
			
		||||
	PreviousConversations []Conversation  `db:"-" json:"previous_conversations"`
 | 
			
		||||
	Total                 int             `db:"total" json:"-"`
 | 
			
		||||
	PreviousConversations []PreviousConversation `db:"-" json:"previous_conversations"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ConversationContact struct {
 | 
			
		||||
	ID                     int             `db:"id" json:"id"`
 | 
			
		||||
	CreatedAt              time.Time       `db:"created_at" json:"created_at"`
 | 
			
		||||
	UpdatedAt              time.Time       `db:"updated_at" json:"updated_at"`
 | 
			
		||||
	FirstName              string          `db:"first_name" json:"first_name"`
 | 
			
		||||
	LastName               string          `db:"last_name" json:"last_name"`
 | 
			
		||||
	Email                  null.String     `db:"email" json:"email"`
 | 
			
		||||
	Type                   string          `db:"type" json:"type"`
 | 
			
		||||
	AvailabilityStatus     string          `db:"availability_status" json:"availability_status"`
 | 
			
		||||
	AvatarURL              null.String     `db:"avatar_url" json:"avatar_url"`
 | 
			
		||||
	PhoneNumber            null.String     `db:"phone_number" json:"phone_number"`
 | 
			
		||||
	PhoneNumberCountryCode null.String     `db:"phone_number_country_code" json:"phone_number_country_code"`
 | 
			
		||||
	CustomAttributes       json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
 | 
			
		||||
	Enabled                bool            `db:"enabled" json:"enabled"`
 | 
			
		||||
	LastActiveAt           null.Time       `db:"last_active_at" json:"last_active_at"`
 | 
			
		||||
	LastLoginAt            null.Time       `db:"last_login_at" json:"last_login_at"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *ConversationContact) FullName() string {
 | 
			
		||||
	return c.FirstName + " " + c.LastName
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type PreviousConversation struct {
 | 
			
		||||
	ID            int                         `db:"id" json:"id"`
 | 
			
		||||
	CreatedAt     time.Time                   `db:"created_at" json:"created_at"`
 | 
			
		||||
	UpdatedAt     time.Time                   `db:"updated_at" json:"updated_at"`
 | 
			
		||||
	UUID          string                      `db:"uuid" json:"uuid"`
 | 
			
		||||
	Contact       PreviousConversationContact `db:"contact" json:"contact"`
 | 
			
		||||
	LastMessage   null.String                 `db:"last_message" json:"last_message"`
 | 
			
		||||
	LastMessageAt null.Time                   `db:"last_message_at" json:"last_message_at"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type PreviousConversationContact struct {
 | 
			
		||||
	FirstName string      `db:"first_name" json:"first_name"`
 | 
			
		||||
	LastName  string      `db:"last_name" json:"last_name"`
 | 
			
		||||
	AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ConversationParticipant struct {
 | 
			
		||||
	ID        string      `db:"id" json:"id"`
 | 
			
		||||
	ID        int         `db:"id" json:"id"`
 | 
			
		||||
	FirstName string      `db:"first_name" json:"first_name"`
 | 
			
		||||
	LastName  string      `db:"last_name" json:"last_name"`
 | 
			
		||||
	AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
 | 
			
		||||
@@ -117,13 +193,15 @@ type NewConversationsStats struct {
 | 
			
		||||
 | 
			
		||||
// Message represents a message in a conversation
 | 
			
		||||
type Message struct {
 | 
			
		||||
	ID               int                    `db:"id" json:"id,omitempty"`
 | 
			
		||||
	Total            int                    `db:"total" json:"-"`
 | 
			
		||||
	ID               int                    `db:"id" json:"id"`
 | 
			
		||||
	CreatedAt        time.Time              `db:"created_at" json:"created_at"`
 | 
			
		||||
	UpdatedAt        time.Time              `db:"updated_at" json:"updated_at"`
 | 
			
		||||
	UUID             string                 `db:"uuid" json:"uuid"`
 | 
			
		||||
	Type             string                 `db:"type" json:"type"`
 | 
			
		||||
	Status           string                 `db:"status" json:"status"`
 | 
			
		||||
	ConversationID   int                    `db:"conversation_id" json:"conversation_id"`
 | 
			
		||||
	ConversationUUID string                 `db:"conversation_uuid" json:"conversation_uuid"`
 | 
			
		||||
	Content          string                 `db:"content" json:"content"`
 | 
			
		||||
	TextContent      string                 `db:"text_content" json:"text_content"`
 | 
			
		||||
	ContentType      string                 `db:"content_type" json:"content_type"`
 | 
			
		||||
@@ -134,7 +212,6 @@ type Message struct {
 | 
			
		||||
	InboxID          int                    `db:"inbox_id" json:"-"`
 | 
			
		||||
	Meta             json.RawMessage        `db:"meta" json:"meta"`
 | 
			
		||||
	Attachments      attachment.Attachments `db:"attachments" json:"attachments"`
 | 
			
		||||
	ConversationUUID string                 `db:"conversation_uuid" json:"-"`
 | 
			
		||||
	From             string                 `db:"from"  json:"-"`
 | 
			
		||||
	Subject          string                 `db:"subject" json:"-"`
 | 
			
		||||
	Channel          string                 `db:"channel" json:"-"`
 | 
			
		||||
@@ -144,10 +221,9 @@ type Message struct {
 | 
			
		||||
	References       []string               `json:"-"`
 | 
			
		||||
	InReplyTo        string                 `json:"-"`
 | 
			
		||||
	Headers          textproto.MIMEHeader   `json:"-"`
 | 
			
		||||
	AltContent       string                 `db:"-" json:"-"`
 | 
			
		||||
	Media            []mmodels.Media        `db:"-" json:"-"`
 | 
			
		||||
	IsCSAT           bool                   `db:"-" json:"-"`
 | 
			
		||||
	Total            int                    `db:"total" json:"-"`
 | 
			
		||||
	AltContent       string                 `json:"-"`
 | 
			
		||||
	Media            []mmodels.Media        `json:"-"`
 | 
			
		||||
	IsCSAT           bool                   `json:"-"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// CensorCSATContent redacts the content of a CSAT message to prevent leaking the CSAT survey public link.
 | 
			
		||||
 
 | 
			
		||||
@@ -99,6 +99,8 @@ SELECT
 | 
			
		||||
   c.closed_at,
 | 
			
		||||
   c.resolved_at,
 | 
			
		||||
   c.inbox_id,
 | 
			
		||||
   c.assignee_last_seen_at,
 | 
			
		||||
   inb.name as inbox_name,
 | 
			
		||||
   COALESCE(inb.from, '') as inbox_mail,
 | 
			
		||||
   COALESCE(inb.channel::TEXT, '') as inbox_channel,
 | 
			
		||||
   c.status_id,
 | 
			
		||||
@@ -138,9 +140,8 @@ SELECT
 | 
			
		||||
   ct.availability_status as "contact.availability_status",
 | 
			
		||||
   ct.avatar_url as "contact.avatar_url",
 | 
			
		||||
   ct.phone_number as "contact.phone_number",
 | 
			
		||||
   ct.phone_number_calling_code as "contact.phone_number_calling_code",
 | 
			
		||||
   ct.phone_number_country_code as "contact.phone_number_country_code",
 | 
			
		||||
   ct.custom_attributes as "contact.custom_attributes",
 | 
			
		||||
   ct.avatar_url as "contact.avatar_url",
 | 
			
		||||
   ct.enabled as "contact.enabled",
 | 
			
		||||
   ct.last_active_at as "contact.last_active_at",
 | 
			
		||||
   ct.last_login_at as "contact.last_login_at",
 | 
			
		||||
@@ -183,8 +184,11 @@ SELECT
 | 
			
		||||
FROM conversations c
 | 
			
		||||
WHERE c.created_at > $1;
 | 
			
		||||
 | 
			
		||||
-- name: get-contact-conversations
 | 
			
		||||
-- name: get-contact-previous-conversations
 | 
			
		||||
SELECT
 | 
			
		||||
    c.id,
 | 
			
		||||
    c.created_at,
 | 
			
		||||
    c.updated_at,
 | 
			
		||||
    c.uuid,
 | 
			
		||||
    u.first_name AS "contact.first_name",
 | 
			
		||||
    u.last_name AS "contact.last_name",
 | 
			
		||||
@@ -195,7 +199,7 @@ FROM users u
 | 
			
		||||
JOIN conversations c ON c.contact_id = u.id
 | 
			
		||||
WHERE c.contact_id = $1
 | 
			
		||||
ORDER BY c.created_at DESC
 | 
			
		||||
LIMIT 10;
 | 
			
		||||
LIMIT $2;
 | 
			
		||||
 | 
			
		||||
-- name: get-conversation-uuid
 | 
			
		||||
SELECT uuid from conversations where id = $1;
 | 
			
		||||
@@ -349,6 +353,7 @@ WHERE uuid = $1;
 | 
			
		||||
UPDATE conversations
 | 
			
		||||
SET 
 | 
			
		||||
    assigned_user_id = CASE WHEN $2 = 'user' THEN NULL ELSE assigned_user_id END,
 | 
			
		||||
    assignee_last_seen_at = CASE WHEN $2 = 'user' THEN NULL ELSE assignee_last_seen_at END,
 | 
			
		||||
    assigned_team_id = CASE WHEN $2 = 'team' THEN NULL ELSE assigned_team_id END,
 | 
			
		||||
    updated_at = NOW()
 | 
			
		||||
WHERE uuid = $1;
 | 
			
		||||
@@ -400,22 +405,27 @@ LIMIT $2;
 | 
			
		||||
 | 
			
		||||
-- name: get-outgoing-pending-messages
 | 
			
		||||
SELECT
 | 
			
		||||
    m.created_at,
 | 
			
		||||
    m.id,
 | 
			
		||||
    m.uuid,
 | 
			
		||||
    m.sender_id,
 | 
			
		||||
    m.type,
 | 
			
		||||
    m.private,
 | 
			
		||||
    m.created_at,
 | 
			
		||||
    m.updated_at,
 | 
			
		||||
    m.status,
 | 
			
		||||
    m.type,
 | 
			
		||||
    m.content,
 | 
			
		||||
    m.text_content,
 | 
			
		||||
    m.content_type,
 | 
			
		||||
    m.conversation_id,
 | 
			
		||||
    m.uuid,
 | 
			
		||||
    m.private,
 | 
			
		||||
    m.sender_type,
 | 
			
		||||
    m.sender_id,
 | 
			
		||||
    m.meta,
 | 
			
		||||
    c.uuid as conversation_uuid,
 | 
			
		||||
    m.content_type,
 | 
			
		||||
    m.source_id,
 | 
			
		||||
    ARRAY(SELECT jsonb_array_elements_text(m.meta->'cc')) AS cc,
 | 
			
		||||
    ARRAY(SELECT jsonb_array_elements_text(m.meta->'bcc')) AS bcc,
 | 
			
		||||
    ARRAY(SELECT jsonb_array_elements_text(m.meta->'to')) AS to,
 | 
			
		||||
    c.inbox_id,
 | 
			
		||||
    c.uuid as conversation_uuid,
 | 
			
		||||
    c.subject
 | 
			
		||||
FROM conversation_messages m
 | 
			
		||||
INNER JOIN conversations c ON c.id = m.conversation_id
 | 
			
		||||
@@ -438,6 +448,7 @@ SELECT
 | 
			
		||||
    m.sender_type,
 | 
			
		||||
    m.sender_id,
 | 
			
		||||
    m.meta,
 | 
			
		||||
    c.uuid as conversation_uuid,
 | 
			
		||||
    COALESCE(
 | 
			
		||||
        json_agg(
 | 
			
		||||
            json_build_object(
 | 
			
		||||
@@ -452,25 +463,31 @@ SELECT
 | 
			
		||||
        '[]'::json
 | 
			
		||||
    ) AS attachments
 | 
			
		||||
FROM conversation_messages m
 | 
			
		||||
INNER JOIN conversations c ON c.id = m.conversation_id
 | 
			
		||||
LEFT JOIN media ON media.model_type = 'messages' AND media.model_id = m.id
 | 
			
		||||
WHERE m.uuid = $1
 | 
			
		||||
GROUP BY 
 | 
			
		||||
    m.id, m.created_at, m.updated_at, m.status, m.type, m.content, m.uuid, m.private, m.sender_type
 | 
			
		||||
    m.id, m.created_at, m.updated_at, m.status, m.type, m.content, m.uuid, m.private, m.sender_type, c.uuid
 | 
			
		||||
ORDER BY m.created_at;
 | 
			
		||||
 | 
			
		||||
-- name: get-messages
 | 
			
		||||
SELECT
 | 
			
		||||
   COUNT(*) OVER() AS total,
 | 
			
		||||
   m.id,
 | 
			
		||||
   m.created_at,
 | 
			
		||||
   m.updated_at,
 | 
			
		||||
   m.status,
 | 
			
		||||
   m.type, 
 | 
			
		||||
   m.content,
 | 
			
		||||
   m.text_content,
 | 
			
		||||
   m.content_type,
 | 
			
		||||
   m.conversation_id,
 | 
			
		||||
   m.uuid,
 | 
			
		||||
   m.private,
 | 
			
		||||
   m.sender_id,
 | 
			
		||||
   m.sender_type,
 | 
			
		||||
   m.meta,
 | 
			
		||||
   $1::uuid AS conversation_uuid,
 | 
			
		||||
   COALESCE(
 | 
			
		||||
     (SELECT json_agg(
 | 
			
		||||
       json_build_object(
 | 
			
		||||
 
 | 
			
		||||
@@ -93,7 +93,7 @@ func (m *Manager) UpdateResponse(uuid string, score int, feedback string) error
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if csat.Score > 0 || !csat.ResponseTimestamp.IsZero() {
 | 
			
		||||
	if csat.Rating > 0 || !csat.ResponseTimestamp.IsZero() {
 | 
			
		||||
		return envelope.NewError(envelope.InputError, m.i18n.T("csat.alreadySubmitted"), nil)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user