mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-11-04 05:53:30 +00:00 
			
		
		
		
	Compare commits
	
		
			81 Commits
		
	
	
		
			v0.7.2-alp
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					879c626fb3 | ||
| 
						 | 
					16fbfa7b7c | ||
| 
						 | 
					b8da96c1d1 | ||
| 
						 | 
					3d76cce66a | ||
| 
						 | 
					4b8f30184a | ||
| 
						 | 
					e4018ddab8 | ||
| 
						 | 
					02e8a43587 | ||
| 
						 | 
					f3acc37405 | ||
| 
						 | 
					562babf222 | ||
| 
						 | 
					93e94432f5 | ||
| 
						 | 
					ec63604163 | ||
| 
						 | 
					f06da2a861 | ||
| 
						 | 
					98f16854c8 | ||
| 
						 | 
					cc36ef5a3a | ||
| 
						 | 
					969d6ea4f9 | ||
| 
						 | 
					326ccdf9d4 | ||
| 
						 | 
					d6a8e76472 | ||
| 
						 | 
					f95b374b74 | ||
| 
						 | 
					a1db6ccb31 | ||
| 
						 | 
					267a6027ee | ||
| 
						 | 
					3471263710 | ||
| 
						 | 
					7469e296d2 | ||
| 
						 | 
					44ffc77c4e | ||
| 
						 | 
					3ec061d8f1 | ||
| 
						 | 
					48b8d14f8f | ||
| 
						 | 
					6231a9e131 | ||
| 
						 | 
					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 | ||
| 
						 | 
					78b8607d8f | ||
| 
						 | 
					0dec822c1c | ||
| 
						 | 
					958f5e38c0 | ||
| 
						 | 
					550a3fa801 | ||
| 
						 | 
					6bbfbe8cf6 | ||
| 
						 | 
					f9ed326d72 | 
							
								
								
									
										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
 | 
			
		||||
							
								
								
									
										17
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								README.md
									
									
									
									
									
								
							@@ -3,15 +3,13 @@
 | 
			
		||||
 | 
			
		||||
# Libredesk
 | 
			
		||||
 | 
			
		||||
Open source, self-hosted customer support desk. Single binary app.
 | 
			
		||||
Modern, open source, self-hosted customer support desk. Single binary app. 
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
 | 
			
		||||
 | 
			
		||||
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
 | 
			
		||||
 | 
			
		||||
## Features
 | 
			
		||||
 | 
			
		||||
- **Multi Shared Inbox**  
 | 
			
		||||
@@ -67,7 +65,7 @@ docker exec -it libredesk_app ./libredesk --set-system-user-password
 | 
			
		||||
 | 
			
		||||
Go to `http://localhost:9000` and login with username `System` and the password you set using the `--set-system-user-password` command.
 | 
			
		||||
 | 
			
		||||
See [installation docs](https://libredesk.io/docs/installation/)
 | 
			
		||||
See [installation docs](https://docs.libredesk.io/getting-started/installation)
 | 
			
		||||
 | 
			
		||||
__________________
 | 
			
		||||
 | 
			
		||||
@@ -78,17 +76,12 @@ __________________
 | 
			
		||||
- Run `./libredesk --set-system-user-password` to set the password for the System user.
 | 
			
		||||
- Run `./libredesk` and visit `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
 | 
			
		||||
 | 
			
		||||
See [installation docs](https://libredesk.io/docs/installation)
 | 
			
		||||
See [installation docs](https://docs.libredesk.io/getting-started/installation)
 | 
			
		||||
__________________
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Developers
 | 
			
		||||
If you are interested in contributing, refer to the [developer setup](https://libredesk.io/docs/developer-setup/). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
 | 
			
		||||
 | 
			
		||||
## Development Status
 | 
			
		||||
 | 
			
		||||
Libredesk is under active development.  
 | 
			
		||||
Track roadmap and progress on the GitHub Project Board:   [https://github.com/users/abhinavxd/projects/1](https://github.com/users/abhinavxd/projects/1)
 | 
			
		||||
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.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Translators
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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),
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12
									
								
								cmd/media.go
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								cmd/media.go
									
									
									
									
									
								
							@@ -185,6 +185,18 @@ func handleServeMedia(r *fastglue.Request) error {
 | 
			
		||||
	consts := app.consts.Load().(*constants)
 | 
			
		||||
	switch consts.UploadProvider {
 | 
			
		||||
	case "fs":
 | 
			
		||||
		disposition := "attachment"
 | 
			
		||||
 | 
			
		||||
		// Keep certain content types inline.
 | 
			
		||||
		if strings.HasPrefix(media.ContentType, "image/") ||
 | 
			
		||||
			strings.HasPrefix(media.ContentType, "video/") ||
 | 
			
		||||
			media.ContentType == "application/pdf" {
 | 
			
		||||
			disposition = "inline"
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		r.RequestCtx.Response.Header.Set("Content-Type", media.ContentType)
 | 
			
		||||
		r.RequestCtx.Response.Header.Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, media.Filename))
 | 
			
		||||
 | 
			
		||||
		fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid))
 | 
			
		||||
	case "s3":
 | 
			
		||||
		r.RequestCtx.Redirect(app.media.GetURL(uuid), http.StatusFound)
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(agent)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleGetCurrentAgentTeams returns the teams of an agent.
 | 
			
		||||
// handleGetCurrentAgentTeams returns the teams of current agent.
 | 
			
		||||
func handleGetCurrentAgentTeams(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app   = r.Context.(*App)
 | 
			
		||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
			
		||||
	)
 | 
			
		||||
	agent, err := app.user.GetAgent(auser.ID, "")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	teams, err := app.team.GetUserTeams(agent.ID)
 | 
			
		||||
	teams, err := app.team.GetUserTeams(auser.ID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
@@ -150,11 +154,6 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
 | 
			
		||||
		app   = r.Context.(*App)
 | 
			
		||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
			
		||||
	)
 | 
			
		||||
	agent, err := app.user.GetAgent(auser.ID, "")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	form, err := r.RequestCtx.MultipartForm()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.lo.Error("error parsing form data", "error", err)
 | 
			
		||||
@@ -165,54 +164,53 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
 | 
			
		||||
 | 
			
		||||
	// Upload avatar?
 | 
			
		||||
	if ok && len(files) > 0 {
 | 
			
		||||
		if err := uploadUserAvatar(r, &agent, files); err != nil {
 | 
			
		||||
		agent, err := app.user.GetAgent(auser.ID, "")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return sendErrorEnvelope(r, err)
 | 
			
		||||
		}
 | 
			
		||||
		if err := uploadUserAvatar(r, agent, files); err != nil {
 | 
			
		||||
			return sendErrorEnvelope(r, err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(true)
 | 
			
		||||
 | 
			
		||||
	// Fetch updated agent and return.
 | 
			
		||||
	agent, err := app.user.GetAgent(auser.ID, "")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(agent)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleCreateAgent creates a new agent.
 | 
			
		||||
func handleCreateAgent(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app = r.Context.(*App)
 | 
			
		||||
		user = models.User{}
 | 
			
		||||
		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,30 +0,0 @@
 | 
			
		||||
# API getting started
 | 
			
		||||
 | 
			
		||||
You can access the Libredesk API to interact with your instance programmatically.
 | 
			
		||||
 | 
			
		||||
## Generating API keys
 | 
			
		||||
 | 
			
		||||
1. **Edit agent**: Go to Admin → Teammate → Agent → Edit
 | 
			
		||||
2. **Generate new API key**: An API Key and API Secret will be generated for the agent
 | 
			
		||||
3. **Save the credentials**: Keep both the API Key and API Secret secure
 | 
			
		||||
4. **Key management**: You can revoke / regenerate API keys at any time from the same page
 | 
			
		||||
 | 
			
		||||
## Using the API
 | 
			
		||||
 | 
			
		||||
LibreDesk supports two authentication schemes:
 | 
			
		||||
 | 
			
		||||
### Basic authentication
 | 
			
		||||
```bash
 | 
			
		||||
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
 | 
			
		||||
  -H "Authorization: Basic <base64_encoded_key:secret>"
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Token authentication
 | 
			
		||||
```bash
 | 
			
		||||
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
 | 
			
		||||
  -H "Authorization: token your_api_key:your_api_secret"
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## API Documentation
 | 
			
		||||
 | 
			
		||||
Complete API documentation with available endpoints and examples coming soon.
 | 
			
		||||
@@ -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,38 +0,0 @@
 | 
			
		||||
site_name: Libredesk Docs
 | 
			
		||||
theme:
 | 
			
		||||
  name: material
 | 
			
		||||
  language: en
 | 
			
		||||
  font:
 | 
			
		||||
    text: Source Sans Pro
 | 
			
		||||
    code: Roboto Mono
 | 
			
		||||
    weights: [400, 700]
 | 
			
		||||
  direction: ltr
 | 
			
		||||
  palette:
 | 
			
		||||
    primary: white
 | 
			
		||||
    accent: red
 | 
			
		||||
  features:
 | 
			
		||||
    - navigation.indexes
 | 
			
		||||
    - navigation.sections
 | 
			
		||||
    - content.code.copy
 | 
			
		||||
extra:
 | 
			
		||||
  search:
 | 
			
		||||
    language: en
 | 
			
		||||
 | 
			
		||||
markdown_extensions:
 | 
			
		||||
  - admonition
 | 
			
		||||
  - codehilite
 | 
			
		||||
  - toc:
 | 
			
		||||
      permalink: true
 | 
			
		||||
 | 
			
		||||
nav:
 | 
			
		||||
  - Introduction: index.md
 | 
			
		||||
  - Getting Started:
 | 
			
		||||
      - Installation: installation.md
 | 
			
		||||
      - Upgrade Guide: upgrade.md
 | 
			
		||||
      - Email Templates: templating.md
 | 
			
		||||
      - SSO Setup: sso.md
 | 
			
		||||
      - Webhooks: webhooks.md
 | 
			
		||||
      - API Getting Started: api-getting-started.md
 | 
			
		||||
  - Contributions:
 | 
			
		||||
      - Developer Setup: developer-setup.md
 | 
			
		||||
      - Translate Libredesk: translations.md
 | 
			
		||||
@@ -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', () => {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "libredesk",
 | 
			
		||||
  "version": "0.6.0-alpha",
 | 
			
		||||
  "version": "0.8.0-beta",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
@@ -39,7 +39,7 @@
 | 
			
		||||
    "@unovis/vue": "^1.4.4",
 | 
			
		||||
    "@vee-validate/zod": "^4.15.0",
 | 
			
		||||
    "@vueuse/core": "^12.4.0",
 | 
			
		||||
    "axios": "^1.8.2",
 | 
			
		||||
    "axios": "^1.12.0",
 | 
			
		||||
    "class-variance-authority": "^0.7.0",
 | 
			
		||||
    "clsx": "^2.1.1",
 | 
			
		||||
    "codemirror": "^6.0.2",
 | 
			
		||||
@@ -78,7 +78,7 @@
 | 
			
		||||
    "start-server-and-test": "^2.0.3",
 | 
			
		||||
    "tailwindcss": "^3.4.17",
 | 
			
		||||
    "tailwindcss-animate": "^1.0.7",
 | 
			
		||||
    "vite": "^5.4.19",
 | 
			
		||||
    "vite": "^5.4.20",
 | 
			
		||||
    "vitest": "^3.2.2"
 | 
			
		||||
  },
 | 
			
		||||
  "packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										306
									
								
								frontend/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										306
									
								
								frontend/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							@@ -72,8 +72,8 @@ importers:
 | 
			
		||||
        specifier: ^12.4.0
 | 
			
		||||
        version: 12.4.0(typescript@5.7.3)
 | 
			
		||||
      axios:
 | 
			
		||||
        specifier: ^1.8.2
 | 
			
		||||
        version: 1.8.2(debug@4.4.0)
 | 
			
		||||
        specifier: ^1.12.0
 | 
			
		||||
        version: 1.12.0(debug@4.4.0)
 | 
			
		||||
      class-variance-authority:
 | 
			
		||||
        specifier: ^0.7.0
 | 
			
		||||
        version: 0.7.1
 | 
			
		||||
@@ -118,7 +118,7 @@ importers:
 | 
			
		||||
        version: 5.2.0(vue@3.5.13(typescript@5.7.3))
 | 
			
		||||
      vue-i18n:
 | 
			
		||||
        specifier: '9'
 | 
			
		||||
        version: 9.14.3(vue@3.5.13(typescript@5.7.3))
 | 
			
		||||
        version: 9.14.5(vue@3.5.13(typescript@5.7.3))
 | 
			
		||||
      vue-letter:
 | 
			
		||||
        specifier: ^0.2.0
 | 
			
		||||
        version: 0.2.0
 | 
			
		||||
@@ -146,7 +146,7 @@ importers:
 | 
			
		||||
        version: 1.10.5
 | 
			
		||||
      '@vitejs/plugin-vue':
 | 
			
		||||
        specifier: ^5.0.3
 | 
			
		||||
        version: 5.2.1(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))
 | 
			
		||||
        version: 5.2.1(vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))
 | 
			
		||||
      '@vue/eslint-config-prettier':
 | 
			
		||||
        specifier: ^8.0.0
 | 
			
		||||
        version: 8.0.0(eslint@8.57.1)(prettier@3.4.2)
 | 
			
		||||
@@ -184,8 +184,8 @@ importers:
 | 
			
		||||
        specifier: ^1.0.7
 | 
			
		||||
        version: 1.0.7(tailwindcss@3.4.17)
 | 
			
		||||
      vite:
 | 
			
		||||
        specifier: ^5.4.19
 | 
			
		||||
        version: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
 | 
			
		||||
        specifier: ^5.4.20
 | 
			
		||||
        version: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
 | 
			
		||||
      vitest:
 | 
			
		||||
        specifier: ^3.2.2
 | 
			
		||||
        version: 3.2.2(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
 | 
			
		||||
@@ -510,16 +510,16 @@ packages:
 | 
			
		||||
  '@internationalized/number@3.6.0':
 | 
			
		||||
    resolution: {integrity: sha512-PtrRcJVy7nw++wn4W2OuePQQfTqDzfusSuY1QTtui4wa7r+rGVtR75pO8CyKvHvzyQYi3Q1uO5sY0AsB4e65Bw==}
 | 
			
		||||
 | 
			
		||||
  '@intlify/core-base@9.14.3':
 | 
			
		||||
    resolution: {integrity: sha512-nbJ7pKTlXFnaXPblyfiH6awAx1C0PWNNuqXAR74yRwgi5A/Re/8/5fErLY0pv4R8+EHj3ZaThMHdnuC/5OBa6g==}
 | 
			
		||||
  '@intlify/core-base@9.14.5':
 | 
			
		||||
    resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==}
 | 
			
		||||
    engines: {node: '>= 16'}
 | 
			
		||||
 | 
			
		||||
  '@intlify/message-compiler@9.14.3':
 | 
			
		||||
    resolution: {integrity: sha512-ANwC226BQdd+MpJ36rOYkChSESfPwu3Ss2Faw0RHTOknYLoHTX6V6e/JjIKVDMbzs0/H/df/rO6yU0SPiWHqNg==}
 | 
			
		||||
  '@intlify/message-compiler@9.14.5':
 | 
			
		||||
    resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==}
 | 
			
		||||
    engines: {node: '>= 16'}
 | 
			
		||||
 | 
			
		||||
  '@intlify/shared@9.14.3':
 | 
			
		||||
    resolution: {integrity: sha512-hJXz9LA5VG7qNE00t50bdzDv8Z4q9fpcL81wj4y4duKavrv0KM8YNLTwXNEFINHjTsfrG9TXvPuEjVaAvZ7yWg==}
 | 
			
		||||
  '@intlify/shared@9.14.5':
 | 
			
		||||
    resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==}
 | 
			
		||||
    engines: {node: '>= 16'}
 | 
			
		||||
 | 
			
		||||
  '@isaacs/cliui@8.0.2':
 | 
			
		||||
@@ -708,103 +708,108 @@ packages:
 | 
			
		||||
  '@remirror/core-constants@3.0.0':
 | 
			
		||||
    resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-android-arm-eabi@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==}
 | 
			
		||||
  '@rollup/rollup-android-arm-eabi@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A==}
 | 
			
		||||
    cpu: [arm]
 | 
			
		||||
    os: [android]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-android-arm64@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA==}
 | 
			
		||||
  '@rollup/rollup-android-arm64@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g==}
 | 
			
		||||
    cpu: [arm64]
 | 
			
		||||
    os: [android]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-darwin-arm64@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w==}
 | 
			
		||||
  '@rollup/rollup-darwin-arm64@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q==}
 | 
			
		||||
    cpu: [arm64]
 | 
			
		||||
    os: [darwin]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-darwin-x64@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg==}
 | 
			
		||||
  '@rollup/rollup-darwin-x64@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A==}
 | 
			
		||||
    cpu: [x64]
 | 
			
		||||
    os: [darwin]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-freebsd-arm64@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg==}
 | 
			
		||||
  '@rollup/rollup-freebsd-arm64@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow==}
 | 
			
		||||
    cpu: [arm64]
 | 
			
		||||
    os: [freebsd]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-freebsd-x64@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA==}
 | 
			
		||||
  '@rollup/rollup-freebsd-x64@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog==}
 | 
			
		||||
    cpu: [x64]
 | 
			
		||||
    os: [freebsd]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-arm-gnueabihf@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==}
 | 
			
		||||
  '@rollup/rollup-linux-arm-gnueabihf@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==}
 | 
			
		||||
    cpu: [arm]
 | 
			
		||||
    os: [linux]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-arm-musleabihf@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==}
 | 
			
		||||
  '@rollup/rollup-linux-arm-musleabihf@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==}
 | 
			
		||||
    cpu: [arm]
 | 
			
		||||
    os: [linux]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-arm64-gnu@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==}
 | 
			
		||||
  '@rollup/rollup-linux-arm64-gnu@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==}
 | 
			
		||||
    cpu: [arm64]
 | 
			
		||||
    os: [linux]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-arm64-musl@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==}
 | 
			
		||||
  '@rollup/rollup-linux-arm64-musl@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==}
 | 
			
		||||
    cpu: [arm64]
 | 
			
		||||
    os: [linux]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-loongarch64-gnu@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==}
 | 
			
		||||
  '@rollup/rollup-linux-loong64-gnu@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==}
 | 
			
		||||
    cpu: [loong64]
 | 
			
		||||
    os: [linux]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-powerpc64le-gnu@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==}
 | 
			
		||||
  '@rollup/rollup-linux-ppc64-gnu@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==}
 | 
			
		||||
    cpu: [ppc64]
 | 
			
		||||
    os: [linux]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-riscv64-gnu@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==}
 | 
			
		||||
  '@rollup/rollup-linux-riscv64-gnu@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==}
 | 
			
		||||
    cpu: [riscv64]
 | 
			
		||||
    os: [linux]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-riscv64-musl@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==}
 | 
			
		||||
  '@rollup/rollup-linux-riscv64-musl@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==}
 | 
			
		||||
    cpu: [riscv64]
 | 
			
		||||
    os: [linux]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-s390x-gnu@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==}
 | 
			
		||||
  '@rollup/rollup-linux-s390x-gnu@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==}
 | 
			
		||||
    cpu: [s390x]
 | 
			
		||||
    os: [linux]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-x64-gnu@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==}
 | 
			
		||||
  '@rollup/rollup-linux-x64-gnu@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==}
 | 
			
		||||
    cpu: [x64]
 | 
			
		||||
    os: [linux]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-x64-musl@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==}
 | 
			
		||||
  '@rollup/rollup-linux-x64-musl@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==}
 | 
			
		||||
    cpu: [x64]
 | 
			
		||||
    os: [linux]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-win32-arm64-msvc@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==}
 | 
			
		||||
  '@rollup/rollup-openharmony-arm64@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==}
 | 
			
		||||
    cpu: [arm64]
 | 
			
		||||
    os: [openharmony]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-win32-arm64-msvc@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA==}
 | 
			
		||||
    cpu: [arm64]
 | 
			
		||||
    os: [win32]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-win32-ia32-msvc@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg==}
 | 
			
		||||
  '@rollup/rollup-win32-ia32-msvc@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA==}
 | 
			
		||||
    cpu: [ia32]
 | 
			
		||||
    os: [win32]
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-win32-x64-msvc@4.41.1':
 | 
			
		||||
    resolution: {integrity: sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw==}
 | 
			
		||||
  '@rollup/rollup-win32-x64-msvc@4.50.2':
 | 
			
		||||
    resolution: {integrity: sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA==}
 | 
			
		||||
    cpu: [x64]
 | 
			
		||||
    os: [win32]
 | 
			
		||||
 | 
			
		||||
@@ -1136,8 +1141,8 @@ packages:
 | 
			
		||||
  '@types/deep-eql@4.0.2':
 | 
			
		||||
    resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
 | 
			
		||||
 | 
			
		||||
  '@types/estree@1.0.7':
 | 
			
		||||
    resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
 | 
			
		||||
  '@types/estree@1.0.8':
 | 
			
		||||
    resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
 | 
			
		||||
 | 
			
		||||
  '@types/geojson@7946.0.15':
 | 
			
		||||
    resolution: {integrity: sha512-9oSxFzDCT2Rj6DfcHF8G++jxBKS7mBqXl5xrRW+Kbvjry6Uduya2iiwqHPhVXpasAVMBYKkEPGgKhd3+/HZ6xA==}
 | 
			
		||||
@@ -1451,8 +1456,8 @@ packages:
 | 
			
		||||
  aws4@1.13.2:
 | 
			
		||||
    resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==}
 | 
			
		||||
 | 
			
		||||
  axios@1.8.2:
 | 
			
		||||
    resolution: {integrity: sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==}
 | 
			
		||||
  axios@1.12.0:
 | 
			
		||||
    resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==}
 | 
			
		||||
 | 
			
		||||
  babel-plugin-macros@3.1.0:
 | 
			
		||||
    resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
 | 
			
		||||
@@ -1857,6 +1862,15 @@ packages:
 | 
			
		||||
      supports-color:
 | 
			
		||||
        optional: true
 | 
			
		||||
 | 
			
		||||
  debug@4.4.3:
 | 
			
		||||
    resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
 | 
			
		||||
    engines: {node: '>=6.0'}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      supports-color: '*'
 | 
			
		||||
    peerDependenciesMeta:
 | 
			
		||||
      supports-color:
 | 
			
		||||
        optional: true
 | 
			
		||||
 | 
			
		||||
  decode-uri-component@0.2.2:
 | 
			
		||||
    resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
 | 
			
		||||
    engines: {node: '>=0.10'}
 | 
			
		||||
@@ -2139,8 +2153,8 @@ packages:
 | 
			
		||||
  flatted@3.3.2:
 | 
			
		||||
    resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==}
 | 
			
		||||
 | 
			
		||||
  follow-redirects@1.15.9:
 | 
			
		||||
    resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
 | 
			
		||||
  follow-redirects@1.15.11:
 | 
			
		||||
    resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
 | 
			
		||||
    engines: {node: '>=4.0'}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      debug: '*'
 | 
			
		||||
@@ -2155,8 +2169,8 @@ packages:
 | 
			
		||||
  forever-agent@0.6.1:
 | 
			
		||||
    resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==}
 | 
			
		||||
 | 
			
		||||
  form-data@4.0.2:
 | 
			
		||||
    resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
 | 
			
		||||
  form-data@4.0.4:
 | 
			
		||||
    resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
 | 
			
		||||
    engines: {node: '>= 6'}
 | 
			
		||||
 | 
			
		||||
  fraction.js@4.3.7:
 | 
			
		||||
@@ -2992,8 +3006,8 @@ packages:
 | 
			
		||||
  robust-predicates@3.0.2:
 | 
			
		||||
    resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
 | 
			
		||||
 | 
			
		||||
  rollup@4.41.1:
 | 
			
		||||
    resolution: {integrity: sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==}
 | 
			
		||||
  rollup@4.50.2:
 | 
			
		||||
    resolution: {integrity: sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w==}
 | 
			
		||||
    engines: {node: '>=18.0.0', npm: '>=8.0.0'}
 | 
			
		||||
    hasBin: true
 | 
			
		||||
 | 
			
		||||
@@ -3089,9 +3103,9 @@ packages:
 | 
			
		||||
    resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
 | 
			
		||||
    engines: {node: '>=0.10.0'}
 | 
			
		||||
 | 
			
		||||
  source-map@0.7.4:
 | 
			
		||||
    resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==}
 | 
			
		||||
    engines: {node: '>= 8'}
 | 
			
		||||
  source-map@0.7.6:
 | 
			
		||||
    resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
 | 
			
		||||
    engines: {node: '>= 12'}
 | 
			
		||||
 | 
			
		||||
  speakingurl@14.0.1:
 | 
			
		||||
    resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
 | 
			
		||||
@@ -3355,8 +3369,8 @@ packages:
 | 
			
		||||
    engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
 | 
			
		||||
    hasBin: true
 | 
			
		||||
 | 
			
		||||
  vite@5.4.19:
 | 
			
		||||
    resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==}
 | 
			
		||||
  vite@5.4.20:
 | 
			
		||||
    resolution: {integrity: sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==}
 | 
			
		||||
    engines: {node: ^18.0.0 || >=20.0.0}
 | 
			
		||||
    hasBin: true
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
@@ -3439,8 +3453,8 @@ packages:
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      eslint: '>=6.0.0'
 | 
			
		||||
 | 
			
		||||
  vue-i18n@9.14.3:
 | 
			
		||||
    resolution: {integrity: sha512-C+E0KE8ihKjdYCQx8oUkXX+8tBItrYNMnGJuzEPevBARQFUN2tKez6ZVOvBrWH0+KT5wEk3vOWjNk7ygb2u9ig==}
 | 
			
		||||
  vue-i18n@9.14.5:
 | 
			
		||||
    resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==}
 | 
			
		||||
    engines: {node: '>= 16'}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      vue: ^3.0.0
 | 
			
		||||
@@ -3700,7 +3714,7 @@ snapshots:
 | 
			
		||||
      combined-stream: 1.0.8
 | 
			
		||||
      extend: 3.0.2
 | 
			
		||||
      forever-agent: 0.6.1
 | 
			
		||||
      form-data: 4.0.2
 | 
			
		||||
      form-data: 4.0.4
 | 
			
		||||
      http-signature: 1.4.0
 | 
			
		||||
      is-typedarray: 1.0.0
 | 
			
		||||
      isstream: 0.1.2
 | 
			
		||||
@@ -3914,17 +3928,17 @@ snapshots:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@swc/helpers': 0.5.15
 | 
			
		||||
 | 
			
		||||
  '@intlify/core-base@9.14.3':
 | 
			
		||||
  '@intlify/core-base@9.14.5':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@intlify/message-compiler': 9.14.3
 | 
			
		||||
      '@intlify/shared': 9.14.3
 | 
			
		||||
      '@intlify/message-compiler': 9.14.5
 | 
			
		||||
      '@intlify/shared': 9.14.5
 | 
			
		||||
 | 
			
		||||
  '@intlify/message-compiler@9.14.3':
 | 
			
		||||
  '@intlify/message-compiler@9.14.5':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@intlify/shared': 9.14.3
 | 
			
		||||
      '@intlify/shared': 9.14.5
 | 
			
		||||
      source-map-js: 1.2.1
 | 
			
		||||
 | 
			
		||||
  '@intlify/shared@9.14.3': {}
 | 
			
		||||
  '@intlify/shared@9.14.5': {}
 | 
			
		||||
 | 
			
		||||
  '@isaacs/cliui@8.0.2':
 | 
			
		||||
    dependencies:
 | 
			
		||||
@@ -4091,64 +4105,67 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  '@remirror/core-constants@3.0.0': {}
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-android-arm-eabi@4.41.1':
 | 
			
		||||
  '@rollup/rollup-android-arm-eabi@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-android-arm64@4.41.1':
 | 
			
		||||
  '@rollup/rollup-android-arm64@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-darwin-arm64@4.41.1':
 | 
			
		||||
  '@rollup/rollup-darwin-arm64@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-darwin-x64@4.41.1':
 | 
			
		||||
  '@rollup/rollup-darwin-x64@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-freebsd-arm64@4.41.1':
 | 
			
		||||
  '@rollup/rollup-freebsd-arm64@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-freebsd-x64@4.41.1':
 | 
			
		||||
  '@rollup/rollup-freebsd-x64@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-arm-gnueabihf@4.41.1':
 | 
			
		||||
  '@rollup/rollup-linux-arm-gnueabihf@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-arm-musleabihf@4.41.1':
 | 
			
		||||
  '@rollup/rollup-linux-arm-musleabihf@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-arm64-gnu@4.41.1':
 | 
			
		||||
  '@rollup/rollup-linux-arm64-gnu@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-arm64-musl@4.41.1':
 | 
			
		||||
  '@rollup/rollup-linux-arm64-musl@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-loongarch64-gnu@4.41.1':
 | 
			
		||||
  '@rollup/rollup-linux-loong64-gnu@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-powerpc64le-gnu@4.41.1':
 | 
			
		||||
  '@rollup/rollup-linux-ppc64-gnu@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-riscv64-gnu@4.41.1':
 | 
			
		||||
  '@rollup/rollup-linux-riscv64-gnu@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-riscv64-musl@4.41.1':
 | 
			
		||||
  '@rollup/rollup-linux-riscv64-musl@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-s390x-gnu@4.41.1':
 | 
			
		||||
  '@rollup/rollup-linux-s390x-gnu@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-x64-gnu@4.41.1':
 | 
			
		||||
  '@rollup/rollup-linux-x64-gnu@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-linux-x64-musl@4.41.1':
 | 
			
		||||
  '@rollup/rollup-linux-x64-musl@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-win32-arm64-msvc@4.41.1':
 | 
			
		||||
  '@rollup/rollup-openharmony-arm64@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-win32-ia32-msvc@4.41.1':
 | 
			
		||||
  '@rollup/rollup-win32-arm64-msvc@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-win32-x64-msvc@4.41.1':
 | 
			
		||||
  '@rollup/rollup-win32-ia32-msvc@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rollup/rollup-win32-x64-msvc@4.50.2':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@rushstack/eslint-patch@1.10.5': {}
 | 
			
		||||
@@ -4513,7 +4530,7 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  '@types/deep-eql@4.0.2': {}
 | 
			
		||||
 | 
			
		||||
  '@types/estree@1.0.7': {}
 | 
			
		||||
  '@types/estree@1.0.8': {}
 | 
			
		||||
 | 
			
		||||
  '@types/geojson@7946.0.15': {}
 | 
			
		||||
 | 
			
		||||
@@ -4659,9 +4676,9 @@ snapshots:
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - vue
 | 
			
		||||
 | 
			
		||||
  '@vitejs/plugin-vue@5.2.1(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))':
 | 
			
		||||
  '@vitejs/plugin-vue@5.2.1(vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
 | 
			
		||||
      vite: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
 | 
			
		||||
      vue: 3.5.13(typescript@5.7.3)
 | 
			
		||||
 | 
			
		||||
  '@vitest/expect@3.2.2':
 | 
			
		||||
@@ -4672,13 +4689,13 @@ snapshots:
 | 
			
		||||
      chai: 5.2.0
 | 
			
		||||
      tinyrainbow: 2.0.0
 | 
			
		||||
 | 
			
		||||
  '@vitest/mocker@3.2.2(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))':
 | 
			
		||||
  '@vitest/mocker@3.2.2(vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@vitest/spy': 3.2.2
 | 
			
		||||
      estree-walker: 3.0.3
 | 
			
		||||
      magic-string: 0.30.17
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
 | 
			
		||||
      vite: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
 | 
			
		||||
 | 
			
		||||
  '@vitest/pretty-format@3.2.2':
 | 
			
		||||
    dependencies:
 | 
			
		||||
@@ -4929,10 +4946,10 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  aws4@1.13.2: {}
 | 
			
		||||
 | 
			
		||||
  axios@1.8.2(debug@4.4.0):
 | 
			
		||||
  axios@1.12.0(debug@4.4.0):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      follow-redirects: 1.15.9(debug@4.4.0)
 | 
			
		||||
      form-data: 4.0.2
 | 
			
		||||
      follow-redirects: 1.15.11(debug@4.4.0)
 | 
			
		||||
      form-data: 4.0.4
 | 
			
		||||
      proxy-from-env: 1.1.0
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - debug
 | 
			
		||||
@@ -5393,6 +5410,11 @@ snapshots:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      ms: 2.1.3
 | 
			
		||||
 | 
			
		||||
  debug@4.4.3:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      ms: 2.1.3
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  decode-uri-component@0.2.2:
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
@@ -5618,7 +5640,7 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  estree-walker@3.0.3:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@types/estree': 1.0.7
 | 
			
		||||
      '@types/estree': 1.0.8
 | 
			
		||||
 | 
			
		||||
  esutils@2.0.3: {}
 | 
			
		||||
 | 
			
		||||
@@ -5733,7 +5755,7 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  flatted@3.3.2: {}
 | 
			
		||||
 | 
			
		||||
  follow-redirects@1.15.9(debug@4.4.0):
 | 
			
		||||
  follow-redirects@1.15.11(debug@4.4.0):
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      debug: 4.4.0(supports-color@8.1.1)
 | 
			
		||||
 | 
			
		||||
@@ -5744,11 +5766,12 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  forever-agent@0.6.1: {}
 | 
			
		||||
 | 
			
		||||
  form-data@4.0.2:
 | 
			
		||||
  form-data@4.0.4:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      asynckit: 0.4.0
 | 
			
		||||
      combined-stream: 1.0.8
 | 
			
		||||
      es-set-tostringtag: 2.1.0
 | 
			
		||||
      hasown: 2.0.2
 | 
			
		||||
      mime-types: 2.1.35
 | 
			
		||||
 | 
			
		||||
  fraction.js@4.3.7: {}
 | 
			
		||||
@@ -6581,30 +6604,31 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  robust-predicates@3.0.2: {}
 | 
			
		||||
 | 
			
		||||
  rollup@4.41.1:
 | 
			
		||||
  rollup@4.50.2:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@types/estree': 1.0.7
 | 
			
		||||
      '@types/estree': 1.0.8
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      '@rollup/rollup-android-arm-eabi': 4.41.1
 | 
			
		||||
      '@rollup/rollup-android-arm64': 4.41.1
 | 
			
		||||
      '@rollup/rollup-darwin-arm64': 4.41.1
 | 
			
		||||
      '@rollup/rollup-darwin-x64': 4.41.1
 | 
			
		||||
      '@rollup/rollup-freebsd-arm64': 4.41.1
 | 
			
		||||
      '@rollup/rollup-freebsd-x64': 4.41.1
 | 
			
		||||
      '@rollup/rollup-linux-arm-gnueabihf': 4.41.1
 | 
			
		||||
      '@rollup/rollup-linux-arm-musleabihf': 4.41.1
 | 
			
		||||
      '@rollup/rollup-linux-arm64-gnu': 4.41.1
 | 
			
		||||
      '@rollup/rollup-linux-arm64-musl': 4.41.1
 | 
			
		||||
      '@rollup/rollup-linux-loongarch64-gnu': 4.41.1
 | 
			
		||||
      '@rollup/rollup-linux-powerpc64le-gnu': 4.41.1
 | 
			
		||||
      '@rollup/rollup-linux-riscv64-gnu': 4.41.1
 | 
			
		||||
      '@rollup/rollup-linux-riscv64-musl': 4.41.1
 | 
			
		||||
      '@rollup/rollup-linux-s390x-gnu': 4.41.1
 | 
			
		||||
      '@rollup/rollup-linux-x64-gnu': 4.41.1
 | 
			
		||||
      '@rollup/rollup-linux-x64-musl': 4.41.1
 | 
			
		||||
      '@rollup/rollup-win32-arm64-msvc': 4.41.1
 | 
			
		||||
      '@rollup/rollup-win32-ia32-msvc': 4.41.1
 | 
			
		||||
      '@rollup/rollup-win32-x64-msvc': 4.41.1
 | 
			
		||||
      '@rollup/rollup-android-arm-eabi': 4.50.2
 | 
			
		||||
      '@rollup/rollup-android-arm64': 4.50.2
 | 
			
		||||
      '@rollup/rollup-darwin-arm64': 4.50.2
 | 
			
		||||
      '@rollup/rollup-darwin-x64': 4.50.2
 | 
			
		||||
      '@rollup/rollup-freebsd-arm64': 4.50.2
 | 
			
		||||
      '@rollup/rollup-freebsd-x64': 4.50.2
 | 
			
		||||
      '@rollup/rollup-linux-arm-gnueabihf': 4.50.2
 | 
			
		||||
      '@rollup/rollup-linux-arm-musleabihf': 4.50.2
 | 
			
		||||
      '@rollup/rollup-linux-arm64-gnu': 4.50.2
 | 
			
		||||
      '@rollup/rollup-linux-arm64-musl': 4.50.2
 | 
			
		||||
      '@rollup/rollup-linux-loong64-gnu': 4.50.2
 | 
			
		||||
      '@rollup/rollup-linux-ppc64-gnu': 4.50.2
 | 
			
		||||
      '@rollup/rollup-linux-riscv64-gnu': 4.50.2
 | 
			
		||||
      '@rollup/rollup-linux-riscv64-musl': 4.50.2
 | 
			
		||||
      '@rollup/rollup-linux-s390x-gnu': 4.50.2
 | 
			
		||||
      '@rollup/rollup-linux-x64-gnu': 4.50.2
 | 
			
		||||
      '@rollup/rollup-linux-x64-musl': 4.50.2
 | 
			
		||||
      '@rollup/rollup-openharmony-arm64': 4.50.2
 | 
			
		||||
      '@rollup/rollup-win32-arm64-msvc': 4.50.2
 | 
			
		||||
      '@rollup/rollup-win32-ia32-msvc': 4.50.2
 | 
			
		||||
      '@rollup/rollup-win32-x64-msvc': 4.50.2
 | 
			
		||||
      fsevents: 2.3.3
 | 
			
		||||
 | 
			
		||||
  rope-sequence@1.3.4: {}
 | 
			
		||||
@@ -6703,7 +6727,7 @@ snapshots:
 | 
			
		||||
  source-map@0.6.1:
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  source-map@0.7.4:
 | 
			
		||||
  source-map@0.7.6:
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  speakingurl@14.0.1: {}
 | 
			
		||||
@@ -6778,11 +6802,11 @@ snapshots:
 | 
			
		||||
  stylus@0.57.0:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      css: 3.0.0
 | 
			
		||||
      debug: 4.4.1
 | 
			
		||||
      debug: 4.4.3
 | 
			
		||||
      glob: 7.2.3
 | 
			
		||||
      safer-buffer: 2.1.2
 | 
			
		||||
      sax: 1.2.4
 | 
			
		||||
      source-map: 0.7.4
 | 
			
		||||
      source-map: 0.7.6
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - supports-color
 | 
			
		||||
    optional: true
 | 
			
		||||
@@ -6982,7 +7006,7 @@ snapshots:
 | 
			
		||||
      debug: 4.4.1
 | 
			
		||||
      es-module-lexer: 1.7.0
 | 
			
		||||
      pathe: 2.0.3
 | 
			
		||||
      vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
 | 
			
		||||
      vite: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - '@types/node'
 | 
			
		||||
      - less
 | 
			
		||||
@@ -6994,11 +7018,11 @@ snapshots:
 | 
			
		||||
      - supports-color
 | 
			
		||||
      - terser
 | 
			
		||||
 | 
			
		||||
  vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0):
 | 
			
		||||
  vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      esbuild: 0.21.5
 | 
			
		||||
      postcss: 8.4.49
 | 
			
		||||
      rollup: 4.41.1
 | 
			
		||||
      rollup: 4.50.2
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      '@types/node': 22.10.5
 | 
			
		||||
      fsevents: 2.3.3
 | 
			
		||||
@@ -7009,7 +7033,7 @@ snapshots:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@types/chai': 5.2.2
 | 
			
		||||
      '@vitest/expect': 3.2.2
 | 
			
		||||
      '@vitest/mocker': 3.2.2(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))
 | 
			
		||||
      '@vitest/mocker': 3.2.2(vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))
 | 
			
		||||
      '@vitest/pretty-format': 3.2.2
 | 
			
		||||
      '@vitest/runner': 3.2.2
 | 
			
		||||
      '@vitest/snapshot': 3.2.2
 | 
			
		||||
@@ -7027,7 +7051,7 @@ snapshots:
 | 
			
		||||
      tinyglobby: 0.2.14
 | 
			
		||||
      tinypool: 1.1.0
 | 
			
		||||
      tinyrainbow: 2.0.0
 | 
			
		||||
      vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
 | 
			
		||||
      vite: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
 | 
			
		||||
      vite-node: 3.2.2(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
 | 
			
		||||
      why-is-node-running: 2.3.0
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
@@ -7071,10 +7095,10 @@ snapshots:
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - supports-color
 | 
			
		||||
 | 
			
		||||
  vue-i18n@9.14.3(vue@3.5.13(typescript@5.7.3)):
 | 
			
		||||
  vue-i18n@9.14.5(vue@3.5.13(typescript@5.7.3)):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@intlify/core-base': 9.14.3
 | 
			
		||||
      '@intlify/shared': 9.14.3
 | 
			
		||||
      '@intlify/core-base': 9.14.5
 | 
			
		||||
      '@intlify/shared': 9.14.5
 | 
			
		||||
      '@vue/devtools-api': 6.6.4
 | 
			
		||||
      vue: 3.5.13(typescript@5.7.3)
 | 
			
		||||
 | 
			
		||||
@@ -7122,7 +7146,7 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  wait-on@8.0.1(debug@4.4.0):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      axios: 1.8.2(debug@4.4.0)
 | 
			
		||||
      axios: 1.12.0(debug@4.4.0)
 | 
			
		||||
      joi: 17.13.3
 | 
			
		||||
      lodash: 4.17.21
 | 
			
		||||
      minimist: 1.2.8
 | 
			
		||||
 
 | 
			
		||||
@@ -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'))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
 
 | 
			
		||||
@@ -57,9 +57,8 @@
 | 
			
		||||
          <Input type="number" placeholder="0" v-bind="componentField" />
 | 
			
		||||
        </FormControl>
 | 
			
		||||
        <FormDescription>
 | 
			
		||||
          Maximum number of conversations that can be auto-assigned to an agent,
 | 
			
		||||
          conversations in "Resolved" or "Closed" states do not count toward this limit. Set to 0
 | 
			
		||||
          for unlimited.
 | 
			
		||||
          Maximum number of conversations that can be auto-assigned to an agent, conversations in
 | 
			
		||||
          "Resolved" or "Closed" states do not count toward this limit. Set to 0 for unlimited.
 | 
			
		||||
        </FormDescription>
 | 
			
		||||
        <FormMessage />
 | 
			
		||||
      </FormItem>
 | 
			
		||||
@@ -97,6 +96,7 @@
 | 
			
		||||
            </SelectTrigger>
 | 
			
		||||
            <SelectContent>
 | 
			
		||||
              <SelectGroup>
 | 
			
		||||
                <SelectItem :value = 0>None</SelectItem>
 | 
			
		||||
                <SelectItem v-for="bh in businessHours" :key="bh.id" :value="bh.id">
 | 
			
		||||
                  {{ bh.name }}
 | 
			
		||||
                </SelectItem>
 | 
			
		||||
@@ -121,6 +121,7 @@
 | 
			
		||||
            </SelectTrigger>
 | 
			
		||||
            <SelectContent>
 | 
			
		||||
              <SelectGroup>
 | 
			
		||||
                <SelectItem :value= 0>None</SelectItem>
 | 
			
		||||
                <SelectItem
 | 
			
		||||
                  v-for="sla in slaStore.options"
 | 
			
		||||
                  :key="sla.value"
 | 
			
		||||
@@ -226,7 +227,11 @@ const fetchBusinessHours = async () => {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const onSubmit = form.handleSubmit((values) => {
 | 
			
		||||
  props.submitForm(values)
 | 
			
		||||
  props.submitForm({
 | 
			
		||||
    ...values,
 | 
			
		||||
    business_hours_id: values.business_hours_id > 0 ? values.business_hours_id : null,
 | 
			
		||||
    sla_policy_id: values.sla_policy_id > 0 ? values.sla_policy_id: null
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
watch(
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,7 @@
 | 
			
		||||
              })
 | 
			
		||||
            }}
 | 
			
		||||
          </DialogTitle>
 | 
			
		||||
          <DialogDescription/>
 | 
			
		||||
          <DialogDescription />
 | 
			
		||||
        </DialogHeader>
 | 
			
		||||
        <form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden">
 | 
			
		||||
          <!-- Form Fields Section -->
 | 
			
		||||
@@ -263,6 +263,7 @@ import { useFileUpload } from '@/composables/useFileUpload'
 | 
			
		||||
import Editor from '@/components/editor/TextEditor.vue'
 | 
			
		||||
import { useMacroStore } from '@/stores/macro'
 | 
			
		||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
			
		||||
import { UserTypeAgent } from '@/constants/user'
 | 
			
		||||
import api from '@/api'
 | 
			
		||||
 | 
			
		||||
const dialogOpen = defineModel({
 | 
			
		||||
@@ -393,12 +394,14 @@ const selectContact = (contact) => {
 | 
			
		||||
const createConversation = form.handleSubmit(async (values) => {
 | 
			
		||||
  loading.value = true
 | 
			
		||||
  try {
 | 
			
		||||
    // convert ids to numbers if they are not already
 | 
			
		||||
    // Convert ids to numbers if they are not already
 | 
			
		||||
    values.inbox_id = Number(values.inbox_id)
 | 
			
		||||
    values.team_id = values.team_id ? Number(values.team_id) : null
 | 
			
		||||
    values.agent_id = values.agent_id ? Number(values.agent_id) : null
 | 
			
		||||
    // array of attachment ids.
 | 
			
		||||
    // Array of attachment ids.
 | 
			
		||||
    values.attachments = mediaFiles.value.map((file) => file.id)
 | 
			
		||||
    // Initiator of this conversation is always agent
 | 
			
		||||
    values.initiator = UserTypeAgent
 | 
			
		||||
    const conversation = await api.createConversation(values)
 | 
			
		||||
    const conversationUUID = conversation.data.data.uuid
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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),
 | 
			
		||||
 
 | 
			
		||||
@@ -31,7 +31,15 @@
 | 
			
		||||
          <hr class="mb-2" v-if="showEnvelope" />
 | 
			
		||||
 | 
			
		||||
          <!-- Message Text -->
 | 
			
		||||
          <div
 | 
			
		||||
            v-if="message.content_type === 'text'"
 | 
			
		||||
            class="mb-1 native-html break-all whitespace-pre-wrap"
 | 
			
		||||
            :class="{ 'mb-3': message.attachments.length > 0 }"
 | 
			
		||||
          >
 | 
			
		||||
            {{ sanitizedMessageContent }}
 | 
			
		||||
          </div>
 | 
			
		||||
          <Letter
 | 
			
		||||
            v-else
 | 
			
		||||
            :html="sanitizedMessageContent"
 | 
			
		||||
            :allowedSchemas="['cid', 'https', 'http', 'mailto']"
 | 
			
		||||
            class="mb-1 native-html break-all"
 | 
			
		||||
@@ -94,8 +102,12 @@ const settingsStore = useAppSettingsStore()
 | 
			
		||||
const showQuotedText = ref(false)
 | 
			
		||||
const { t } = useI18n()
 | 
			
		||||
 | 
			
		||||
const participant = computed(() => {
 | 
			
		||||
  return convStore.conversation?.participants?.[props.message.sender_id] ?? {}
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const getAvatar = computed(() => {
 | 
			
		||||
  return convStore.current?.contact?.avatar_url || ''
 | 
			
		||||
  return participant.value?.avatar_url || ''
 | 
			
		||||
})
 | 
			
		||||
const sanitizedMessageContent = computed(() => {
 | 
			
		||||
  let content = props.message.content || ''
 | 
			
		||||
@@ -124,13 +136,14 @@ const nonInlineAttachments = computed(() =>
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const getFullName = computed(() => {
 | 
			
		||||
  const contact = convStore.current?.contact || {}
 | 
			
		||||
  return `${contact.first_name || ''} ${contact.last_name || ''}`.trim()
 | 
			
		||||
  const firstName = participant.value?.first_name ?? 'User'
 | 
			
		||||
  const lastName = participant.value?.last_name ?? ''
 | 
			
		||||
  return `${firstName} ${lastName}`
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const avatarFallback = computed(() => {
 | 
			
		||||
  const contact = convStore.current?.contact || {}
 | 
			
		||||
  return (contact.first_name || '').toUpperCase().substring(0, 2)
 | 
			
		||||
  const firstName = participant.value?.first_name ?? 'U'
 | 
			
		||||
  return firstName.toUpperCase().substring(0, 2)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const showEnvelope = computed(() => {
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
@@ -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,19 @@
 | 
			
		||||
import { format, differenceInMinutes, differenceInHours, differenceInDays } from 'date-fns'
 | 
			
		||||
import { format, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, 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 months = differenceInMonths(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 < 31) return `${days}d`
 | 
			
		||||
    if (months < 12) return `${months}mo`
 | 
			
		||||
    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 }}
 | 
			
		||||
@@ -60,18 +59,28 @@
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="space-y-2">
 | 
			
		||||
            <Label for="password" class="text-sm font-medium text-foreground">{{
 | 
			
		||||
              t('globals.terms.password')
 | 
			
		||||
            }}</Label>
 | 
			
		||||
            <Label for="password" class="text-sm font-medium text-foreground">
 | 
			
		||||
              {{ t('globals.terms.password') }}
 | 
			
		||||
            </Label>
 | 
			
		||||
            <div class="relative">
 | 
			
		||||
              <Input
 | 
			
		||||
                id="password"
 | 
			
		||||
              type="password"
 | 
			
		||||
                :type="showPassword ? 'text' : 'password'"
 | 
			
		||||
                autocomplete="current-password"
 | 
			
		||||
                :placeholder="t('auth.enterPassword')"
 | 
			
		||||
                v-model="loginForm.password"
 | 
			
		||||
                :class="{ 'border-destructive': passwordHasError }"
 | 
			
		||||
              class="w-full bg-card border-border text-foreground placeholder:text-muted-foreground rounded py-2 px-3 focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-200 ease-in-out"
 | 
			
		||||
                class="w-full bg-card border-border text-foreground placeholder:text-muted-foreground rounded py-2 px-3 pr-10 focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-200 ease-in-out"
 | 
			
		||||
              />
 | 
			
		||||
              <button
 | 
			
		||||
                type="button"
 | 
			
		||||
                class="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
 | 
			
		||||
                @click="showPassword = !showPassword"
 | 
			
		||||
              >
 | 
			
		||||
                <Eye v-if="!showPassword" class="w-5 h-5" />
 | 
			
		||||
                <EyeOff v-else class="w-5 h-5" />
 | 
			
		||||
              </button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="flex items-center justify-between">
 | 
			
		||||
@@ -89,7 +98,9 @@
 | 
			
		||||
            type="submit"
 | 
			
		||||
          >
 | 
			
		||||
            <span v-if="isLoading" class="flex items-center justify-center">
 | 
			
		||||
              <div class="w-5 h-5 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin mr-3"></div>
 | 
			
		||||
              <div
 | 
			
		||||
                class="w-5 h-5 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin mr-3"
 | 
			
		||||
              ></div>
 | 
			
		||||
              {{ t('auth.loggingIn') }}
 | 
			
		||||
            </span>
 | 
			
		||||
            <span v-else>{{ t('auth.signInButton') }}</span>
 | 
			
		||||
@@ -125,6 +136,7 @@ import { useI18n } from 'vue-i18n'
 | 
			
		||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
			
		||||
import { useAppSettingsStore } from '@/stores/appSettings'
 | 
			
		||||
import AuthLayout from '@/layouts/auth/AuthLayout.vue'
 | 
			
		||||
import { Eye, EyeOff } from 'lucide-vue-next'
 | 
			
		||||
 | 
			
		||||
const emitter = useEmitter()
 | 
			
		||||
const { t } = useI18n()
 | 
			
		||||
@@ -133,6 +145,7 @@ const isLoading = ref(false)
 | 
			
		||||
const router = useRouter()
 | 
			
		||||
const userStore = useUserStore()
 | 
			
		||||
const shakeCard = ref(false)
 | 
			
		||||
const showPassword = ref(false)
 | 
			
		||||
const loginForm = ref({
 | 
			
		||||
  email: '',
 | 
			
		||||
  password: ''
 | 
			
		||||
@@ -159,8 +172,10 @@ onMounted(async () => {
 | 
			
		||||
 | 
			
		||||
const fetchOIDCProviders = async () => {
 | 
			
		||||
  try {
 | 
			
		||||
    const resp = await api.getAllEnabledOIDC()
 | 
			
		||||
    oidcProviders.value = resp.data.data
 | 
			
		||||
    const config = appSettingsStore.public_config
 | 
			
		||||
    if (config && config['app.sso_providers']) {
 | 
			
		||||
      oidcProviders.value = config['app.sso_providers'] || []
 | 
			
		||||
    }
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
			
		||||
      variant: 'destructive',
 | 
			
		||||
@@ -204,6 +219,9 @@ const loginAction = () => {
 | 
			
		||||
      if (resp?.data?.data) {
 | 
			
		||||
        userStore.setCurrentUser(resp.data.data)
 | 
			
		||||
      }
 | 
			
		||||
      // Also fetch general setting as user's logged in.
 | 
			
		||||
      appSettingsStore.fetchSettings('general')
 | 
			
		||||
      // Navigate to inboxes
 | 
			
		||||
      router.push({ name: 'inboxes' })
 | 
			
		||||
    })
 | 
			
		||||
    .catch((error) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,11 @@
 | 
			
		||||
        <CustomBreadcrumb :links="breadcrumbLinks" />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div v-if="contact" class="flex justify-center space-y-4 w-full">
 | 
			
		||||
      <div
 | 
			
		||||
        v-if="contact"
 | 
			
		||||
        class="flex justify-center space-y-4 w-full"
 | 
			
		||||
        :class="{ 'loading-fade': formLoading }"
 | 
			
		||||
      >
 | 
			
		||||
        <div class="flex flex-col w-full mt-12">
 | 
			
		||||
          <div class="flex flex-col space-y-2">
 | 
			
		||||
            <AvatarUpload
 | 
			
		||||
@@ -189,7 +193,7 @@ async function onUpload(file) {
 | 
			
		||||
    formData.append('last_name', form.values.last_name)
 | 
			
		||||
    formData.append('email', form.values.email)
 | 
			
		||||
    formData.append('phone_number', form.values.phone_number)
 | 
			
		||||
    formData.append('phone_number_calling_code', form.values.phone_number_calling_code)
 | 
			
		||||
    formData.append('phone_number_country_code', form.values.phone_number_country_code)
 | 
			
		||||
    formData.append('enabled', form.values.enabled)
 | 
			
		||||
    const { data } = await api.updateContact(contact.value.id, formData)
 | 
			
		||||
    contact.value.avatar_url = data.avatar_url
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										7
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										7
									
								
								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
 | 
			
		||||
@@ -27,7 +28,7 @@ require (
 | 
			
		||||
	github.com/lib/pq v1.10.9
 | 
			
		||||
	github.com/mr-karan/balance v0.0.0-20250317053523-d32c6ade6cf1
 | 
			
		||||
	github.com/redis/go-redis/v9 v9.5.5
 | 
			
		||||
	github.com/rhnvrm/simples3 v0.9.1
 | 
			
		||||
	github.com/rhnvrm/simples3 v0.9.2
 | 
			
		||||
	github.com/spf13/pflag v1.0.5
 | 
			
		||||
	github.com/stretchr/testify v1.10.0
 | 
			
		||||
	github.com/valyala/fasthttp v1.62.0
 | 
			
		||||
@@ -49,12 +50,11 @@ require (
 | 
			
		||||
	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 | 
			
		||||
	github.com/davecgh/go-spew v1.1.1 // indirect
 | 
			
		||||
	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 | 
			
		||||
	github.com/emersion/go-message v0.18.1 // indirect
 | 
			
		||||
	github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
 | 
			
		||||
	github.com/fasthttp/router v1.5.0 // indirect
 | 
			
		||||
	github.com/fsnotify/fsnotify v1.6.0 // indirect
 | 
			
		||||
	github.com/go-jose/go-jose/v4 v4.0.5 // indirect
 | 
			
		||||
	github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect
 | 
			
		||||
	github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
 | 
			
		||||
	github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
 | 
			
		||||
	github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
 | 
			
		||||
	github.com/klauspost/compress v1.18.0 // indirect
 | 
			
		||||
@@ -70,6 +70,7 @@ require (
 | 
			
		||||
	github.com/rivo/uniseg v0.4.4 // indirect
 | 
			
		||||
	github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect
 | 
			
		||||
	github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
 | 
			
		||||
	github.com/stretchr/objx v0.5.2 // indirect
 | 
			
		||||
	github.com/valyala/bytebufferpool v1.0.0 // indirect
 | 
			
		||||
	golang.org/x/image v0.18.0 // indirect
 | 
			
		||||
	golang.org/x/net v0.40.0 // indirect
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										8
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										8
									
								
								go.sum
									
									
									
									
									
								
							@@ -54,8 +54,8 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv
 | 
			
		||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
 | 
			
		||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
 | 
			
		||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
 | 
			
		||||
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c=
 | 
			
		||||
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 | 
			
		||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
 | 
			
		||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 | 
			
		||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
 | 
			
		||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
 | 
			
		||||
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
 | 
			
		||||
@@ -142,6 +142,8 @@ github.com/redis/go-redis/v9 v9.5.5 h1:51VEyMF8eOO+NUHFm8fpg+IOc1xFuFOhxs3R+kPu1
 | 
			
		||||
github.com/redis/go-redis/v9 v9.5.5/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
 | 
			
		||||
github.com/rhnvrm/simples3 v0.9.1 h1:pYfEe2wTjx8B2zFzUdy4kZn3I3Otd9ZvzIhHkFR85kE=
 | 
			
		||||
github.com/rhnvrm/simples3 v0.9.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
 | 
			
		||||
github.com/rhnvrm/simples3 v0.9.2 h1:XrwsiMnwWf7t/kskvhMYXW6keqp5u3u6t5Va3ltzCQI=
 | 
			
		||||
github.com/rhnvrm/simples3 v0.9.2/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
 | 
			
		||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 | 
			
		||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
 | 
			
		||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 | 
			
		||||
@@ -157,6 +159,8 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
 | 
			
		||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
 | 
			
		||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
 | 
			
		||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 | 
			
		||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
 | 
			
		||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 | 
			
		||||
github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 | 
			
		||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 | 
			
		||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										30
									
								
								i18n/en.json
									
									
									
									
									
								
							
							
						
						
									
										30
									
								
								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.",
 | 
			
		||||
@@ -622,5 +643,8 @@
 | 
			
		||||
  "contact.unblockConfirm": "Are you sure you want to unblock this contact? They will be able to interact with you again.",
 | 
			
		||||
  "contact.alreadyExistsWithEmail": "Another contact with same email already exists",
 | 
			
		||||
  "contact.notes.empty": "No notes yet",
 | 
			
		||||
  "contact.notes.help": "Add note for this contact to keep track of important information and conversations."
 | 
			
		||||
  "contact.notes.help": "Add note for this contact to keep track of important information and conversations.",
 | 
			
		||||
  "setup.completeYourSetup": "Complete your setup",
 | 
			
		||||
  "setup.createFirstInbox": "Create your first inbox",
 | 
			
		||||
  "setup.inviteTeammates": "Invite teammates"
 | 
			
		||||
}
 | 
			
		||||
@@ -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)
 | 
			
		||||
@@ -1012,6 +1024,7 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
 | 
			
		||||
 | 
			
		||||
// DeleteConversation deletes a conversation.
 | 
			
		||||
func (m *Manager) DeleteConversation(uuid string) error {
 | 
			
		||||
	m.lo.Info("deleting conversation", "uuid", uuid)
 | 
			
		||||
	if _, err := m.q.DeleteConversation.Exec(uuid); err != nil {
 | 
			
		||||
		m.lo.Error("error deleting conversation", "error", err)
 | 
			
		||||
		return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorDeleting", "name", m.i18n.Ts("globals.terms.conversation")), nil)
 | 
			
		||||
@@ -1081,6 +1094,35 @@ func (c *Manager) makeConversationsListQuery(userID int, teamIDs []int, listType
 | 
			
		||||
		return "", nil, fmt.Errorf("no conversation list types specified")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Parse filters to extract tag filters
 | 
			
		||||
	var (
 | 
			
		||||
		filters          []dbutil.Filter
 | 
			
		||||
		tagFilters       []dbutil.Filter
 | 
			
		||||
		remainingFilters []dbutil.Filter
 | 
			
		||||
	)
 | 
			
		||||
	if filtersJSON != "" && filtersJSON != "[]" {
 | 
			
		||||
		if err := json.Unmarshal([]byte(filtersJSON), &filters); err != nil {
 | 
			
		||||
			return "", nil, fmt.Errorf("invalid filters JSON: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Separate tag filters from other filters
 | 
			
		||||
		for _, f := range filters {
 | 
			
		||||
			if f.Field == "tags" && (f.Operator == "contains" || f.Operator == "not contains" || f.Operator == "set" || f.Operator == "not set") {
 | 
			
		||||
				tagFilters = append(tagFilters, f)
 | 
			
		||||
			} else {
 | 
			
		||||
				remainingFilters = append(remainingFilters, f)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Update filtersJSON with remaining filters for the generic builder
 | 
			
		||||
		if len(remainingFilters) > 0 {
 | 
			
		||||
			b, _ := json.Marshal(remainingFilters)
 | 
			
		||||
			filtersJSON = string(b)
 | 
			
		||||
		} else {
 | 
			
		||||
			filtersJSON = "[]"
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Prepare the conditions based on the list types.
 | 
			
		||||
	conditions := []string{}
 | 
			
		||||
	for _, lt := range listTypes {
 | 
			
		||||
@@ -1106,13 +1148,60 @@ func (c *Manager) makeConversationsListQuery(userID int, teamIDs []int, listType
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Build the base query with list type conditions
 | 
			
		||||
	var whereClause string
 | 
			
		||||
	if len(conditions) > 0 {
 | 
			
		||||
		baseQuery = fmt.Sprintf(baseQuery, "AND ("+strings.Join(conditions, " OR ")+")")
 | 
			
		||||
	} else {
 | 
			
		||||
		// Replace the `%s` in the base query with an empty string.
 | 
			
		||||
		baseQuery = fmt.Sprintf(baseQuery, "")
 | 
			
		||||
		whereClause = "AND (" + strings.Join(conditions, " OR ") + ")"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Add tag filter conditions
 | 
			
		||||
	// TODO: Evaluate - https://github.com/Masterminds/squirrel when required.
 | 
			
		||||
	for _, tf := range tagFilters {
 | 
			
		||||
		switch tf.Operator {
 | 
			
		||||
		case "contains", "not contains":
 | 
			
		||||
			var tagIDs []int
 | 
			
		||||
			if err := json.Unmarshal([]byte(tf.Value), &tagIDs); err != nil {
 | 
			
		||||
				return "", nil, fmt.Errorf("invalid tag IDs in filter: %w", err)
 | 
			
		||||
			}
 | 
			
		||||
			if len(tagIDs) > 0 {
 | 
			
		||||
				paramIdx := len(qArgs) + 1
 | 
			
		||||
				switch tf.Operator {
 | 
			
		||||
				case "contains":
 | 
			
		||||
					// Has any of the tags
 | 
			
		||||
					tagCondition := fmt.Sprintf(` AND conversations.id IN (
 | 
			
		||||
						SELECT DISTINCT conversation_id 
 | 
			
		||||
						FROM conversation_tags 
 | 
			
		||||
						WHERE tag_id = ANY($%d::int[])
 | 
			
		||||
					)`, paramIdx)
 | 
			
		||||
					whereClause += tagCondition
 | 
			
		||||
				case "not contains":
 | 
			
		||||
					// Doesn't have any of the tags
 | 
			
		||||
					tagCondition := fmt.Sprintf(` AND conversations.id NOT IN (
 | 
			
		||||
						SELECT DISTINCT conversation_id 
 | 
			
		||||
						FROM conversation_tags 
 | 
			
		||||
						WHERE tag_id = ANY($%d::int[])
 | 
			
		||||
					)`, paramIdx)
 | 
			
		||||
					whereClause += tagCondition
 | 
			
		||||
				}
 | 
			
		||||
				qArgs = append(qArgs, pq.Array(tagIDs))
 | 
			
		||||
			}
 | 
			
		||||
		case "set":
 | 
			
		||||
			// Has any tags at all
 | 
			
		||||
			whereClause += ` AND EXISTS (
 | 
			
		||||
				SELECT 1 FROM conversation_tags 
 | 
			
		||||
				WHERE conversation_id = conversations.id
 | 
			
		||||
			)`
 | 
			
		||||
		case "not set":
 | 
			
		||||
			// Has no tags at all
 | 
			
		||||
			whereClause += ` AND NOT EXISTS (
 | 
			
		||||
				SELECT 1 FROM conversation_tags 
 | 
			
		||||
				WHERE conversation_id = conversations.id
 | 
			
		||||
			)`
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	baseQuery = fmt.Sprintf(baseQuery, whereClause)
 | 
			
		||||
 | 
			
		||||
	return dbutil.BuildPaginatedQuery(baseQuery, qArgs, dbutil.PaginationOptions{
 | 
			
		||||
		Order:    order,
 | 
			
		||||
		OrderBy:  orderBy,
 | 
			
		||||
 
 | 
			
		||||
@@ -167,7 +167,7 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// References is sorted in DESC i.e newest message first, so reverse it to keep the references in order.
 | 
			
		||||
	stringutil.ReverseSlice(message.References)
 | 
			
		||||
	slices.Reverse(message.References)
 | 
			
		||||
 | 
			
		||||
	// Remove the current message ID from the references.
 | 
			
		||||
	message.References = stringutil.RemoveItemByValue(message.References, message.SourceID.String)
 | 
			
		||||
@@ -347,9 +347,10 @@ func (m *Manager) UpdateMessageStatus(messageUUID string, status string) error {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// MarkMessageAsPending updates message status to `Pending`, so if it's a outgoing message it can be picked up again by a worker.
 | 
			
		||||
// MarkMessageAsPending updates message status to `Pending`, enqueuing it for sending.
 | 
			
		||||
func (m *Manager) MarkMessageAsPending(uuid string) error {
 | 
			
		||||
	if err := m.UpdateMessageStatus(uuid, models.MessageStatusPending); err != nil {
 | 
			
		||||
		m.lo.Error("error marking message as pending", "uuid", uuid, "error", err)
 | 
			
		||||
		return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil)
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
@@ -374,8 +375,27 @@ func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversat
 | 
			
		||||
	return message, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SendReply inserts a reply message in a conversation.
 | 
			
		||||
func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conversationUUID, content string, to, cc, bcc []string, meta map[string]interface{}) (models.Message, error) {
 | 
			
		||||
// CreateContactMessage creates a contact message in a conversation.
 | 
			
		||||
func (m *Manager) CreateContactMessage(media []mmodels.Media, contactID int, conversationUUID, content, contentType string) (models.Message, error) {
 | 
			
		||||
	message := models.Message{
 | 
			
		||||
		ConversationUUID: conversationUUID,
 | 
			
		||||
		SenderID:         contactID,
 | 
			
		||||
		Type:             models.MessageIncoming,
 | 
			
		||||
		SenderType:       models.SenderTypeContact,
 | 
			
		||||
		Status:           models.MessageStatusReceived,
 | 
			
		||||
		Content:          content,
 | 
			
		||||
		ContentType:      contentType,
 | 
			
		||||
		Private:          false,
 | 
			
		||||
		Media:            media,
 | 
			
		||||
	}
 | 
			
		||||
	if err := m.InsertMessage(&message); err != nil {
 | 
			
		||||
		return models.Message{}, err
 | 
			
		||||
	}
 | 
			
		||||
	return message, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// QueueReply queues a reply message in a conversation.
 | 
			
		||||
func (m *Manager) QueueReply(media []mmodels.Media, inboxID, senderID int, conversationUUID, content string, to, cc, bcc []string, meta map[string]interface{}) (models.Message, error) {
 | 
			
		||||
	var (
 | 
			
		||||
		message = models.Message{}
 | 
			
		||||
	)
 | 
			
		||||
@@ -402,7 +422,7 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conver
 | 
			
		||||
		return message, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorMarshalling", "name", "{globals.terms.meta}"), nil)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Generage unique source ID i.e. message-id for email.
 | 
			
		||||
	// Generate unique source ID i.e. message-id for email.
 | 
			
		||||
	inbox, err := m.inboxStore.GetDBRecord(inboxID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return message, err
 | 
			
		||||
@@ -442,6 +462,11 @@ func (m *Manager) InsertMessage(message *models.Message) error {
 | 
			
		||||
		message.Meta = json.RawMessage(`{}`)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Handle empty content type enum, default to text.
 | 
			
		||||
	if message.ContentType == "" {
 | 
			
		||||
		message.ContentType = models.ContentTypeText
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Convert HTML content to text for search.
 | 
			
		||||
	message.TextContent = stringutil.HTML2Text(message.Content)
 | 
			
		||||
 | 
			
		||||
@@ -614,7 +639,7 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
 | 
			
		||||
	}
 | 
			
		||||
	in.Message.SenderID = in.Contact.ID
 | 
			
		||||
 | 
			
		||||
	// Conversations exists for this message?
 | 
			
		||||
	// Conversation already exists for this message? Skip if it does.
 | 
			
		||||
	conversationID, err := m.findConversationID([]string{in.Message.SourceID.String})
 | 
			
		||||
	if err != nil && err != errConversationNotFound {
 | 
			
		||||
		return err
 | 
			
		||||
@@ -629,10 +654,16 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Upload message attachments.
 | 
			
		||||
	if err := m.uploadMessageAttachments(&in.Message); err != nil {
 | 
			
		||||
		// Log error but continue processing.
 | 
			
		||||
		m.lo.Error("error uploading message attachments", "message_source_id", in.Message.SourceID, "error", err)
 | 
			
		||||
	// Upload message attachments, on failure delete the conversation if it was just created for this message.
 | 
			
		||||
	if upErr := m.uploadMessageAttachments(&in.Message); upErr != nil {
 | 
			
		||||
		m.lo.Error("error uploading message attachments", "message_source_id", in.Message.SourceID, "error", upErr)
 | 
			
		||||
		if isNewConversation && in.Message.ConversationUUID != "" {
 | 
			
		||||
			m.lo.Info("deleting conversation as message attachment upload failed", "conversation_uuid", in.Message.ConversationUUID, "message_source_id", in.Message.SourceID)
 | 
			
		||||
			if err := m.DeleteConversation(in.Message.ConversationUUID); err != nil {
 | 
			
		||||
				return fmt.Errorf("error deleting conversation after message attachment upload failure: %w", err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return fmt.Errorf("error uploading message attachments: %w", upErr)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Insert message.
 | 
			
		||||
@@ -756,12 +787,11 @@ func (c *Manager) generateMessagesQuery(baseQuery string, qArgs []interface{}, p
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// uploadMessageAttachments uploads all attachments for a message.
 | 
			
		||||
func (m *Manager) uploadMessageAttachments(message *models.Message) []error {
 | 
			
		||||
func (m *Manager) uploadMessageAttachments(message *models.Message) error {
 | 
			
		||||
	if len(message.Attachments) == 0 {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var uploadErr []error
 | 
			
		||||
	for _, attachment := range message.Attachments {
 | 
			
		||||
		// Check if this attachment already exists by the content ID, as inline images can be repeated across conversations.
 | 
			
		||||
		contentID := attachment.ContentID
 | 
			
		||||
@@ -808,21 +838,20 @@ func (m *Manager) uploadMessageAttachments(message *models.Message) []error {
 | 
			
		||||
			[]byte("{}"), /** meta **/
 | 
			
		||||
		)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			uploadErr = append(uploadErr, err)
 | 
			
		||||
			m.lo.Error("failed to upload attachment", "name", attachment.Name, "error", err)
 | 
			
		||||
			return fmt.Errorf("failed to upload media %s: %w", attachment.Name, err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// If the attachment is an image, generate and upload thumbnail.
 | 
			
		||||
		// If the attachment is an image, generate and upload a thumbnail. Log any errors and continue, as thumbnail generation failure should not block message processing.
 | 
			
		||||
		attachmentExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(attachment.Name)), ".")
 | 
			
		||||
		if slices.Contains(image.Exts, attachmentExt) {
 | 
			
		||||
			if err := m.uploadThumbnailForMedia(media, attachment.Content); err != nil {
 | 
			
		||||
				uploadErr = append(uploadErr, err)
 | 
			
		||||
				m.lo.Error("error uploading thumbnail", "error", err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		message.Media = append(message.Media, media)
 | 
			
		||||
	}
 | 
			
		||||
	return uploadErr
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// findOrCreateConversation finds or creates a conversation for the given message.
 | 
			
		||||
 
 | 
			
		||||
@@ -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.
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user