mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-11-03 21:43:35 +00:00 
			
		
		
		
	Compare commits
	
		
			123 Commits
		
	
	
		
			v0.7.3-alp
			...
			feat/live-
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					7217086f3f | ||
| 
						 | 
					98b3b54b6f | ||
| 
						 | 
					aae8d1f793 | ||
| 
						 | 
					7858a9492d | ||
| 
						 | 
					95ae55dabd | ||
| 
						 | 
					0dedc0b68e | ||
| 
						 | 
					0f207a0cd8 | ||
| 
						 | 
					6840f73be4 | ||
| 
						 | 
					bf1cf025e0 | ||
| 
						 | 
					4a1e7af2fa | ||
| 
						 | 
					9c43b8858c | ||
| 
						 | 
					a4b5340a61 | ||
| 
						 | 
					f7e243f3fc | ||
| 
						 | 
					3d76cce66a | ||
| 
						 | 
					4b8f30184a | ||
| 
						 | 
					16ca6b6df7 | ||
| 
						 | 
					e4018ddab8 | ||
| 
						 | 
					02e8a43587 | ||
| 
						 | 
					f3acc37405 | ||
| 
						 | 
					562babf222 | ||
| 
						 | 
					93e94432f5 | ||
| 
						 | 
					ec63604163 | ||
| 
						 | 
					f06da2a861 | ||
| 
						 | 
					98f16854c8 | ||
| 
						 | 
					1de54fe110 | ||
| 
						 | 
					54e614422d | ||
| 
						 | 
					1deeaf6df3 | ||
| 
						 | 
					3a5990174b | ||
| 
						 | 
					c7291b1d1a | ||
| 
						 | 
					5de870c446 | ||
| 
						 | 
					d7067bce7d | ||
| 
						 | 
					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 | ||
| 
						 | 
					ed448055ed | ||
| 
						 | 
					78b8607d8f | ||
| 
						 | 
					0dec822c1c | ||
| 
						 | 
					c721d19b81 | ||
| 
						 | 
					77111835cc | ||
| 
						 | 
					45a77b1422 | ||
| 
						 | 
					9a77c8953c | ||
| 
						 | 
					18d4a8fe3b | ||
| 
						 | 
					a2234e908f | ||
| 
						 | 
					d7fe6153bb | ||
| 
						 | 
					68c2708464 | ||
| 
						 | 
					4f9fc029c0 | ||
| 
						 | 
					6cfa93838a | ||
| 
						 | 
					f72f158cf0 | ||
| 
						 | 
					1962abdc16 | ||
| 
						 | 
					081a5c615a | ||
| 
						 | 
					c35ab42b47 | ||
| 
						 | 
					f05014f412 | ||
| 
						 | 
					e2bba04669 | ||
| 
						 | 
					4beab72a11 | ||
| 
						 | 
					26b3b30fca | ||
| 
						 | 
					11fd57adb0 | ||
| 
						 | 
					d4f644c531 | ||
| 
						 | 
					646bbc7efe | ||
| 
						 | 
					3c3709557e | ||
| 
						 | 
					74732bfe91 | ||
| 
						 | 
					8ee81c2d64 | ||
| 
						 | 
					282dc83439 | ||
| 
						 | 
					61a70f6b52 | ||
| 
						 | 
					5b6a58fba0 | 
							
								
								
									
										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
 | 
					 | 
				
			||||||
							
								
								
									
										43
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										43
									
								
								Makefile
									
									
									
									
									
								
							@@ -15,7 +15,7 @@ GOPATH ?= $(HOME)/go
 | 
				
			|||||||
STUFFBIN ?= $(GOPATH)/bin/stuffbin
 | 
					STUFFBIN ?= $(GOPATH)/bin/stuffbin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# The default target to run when `make` is executed.
 | 
					# The default target to run when `make` is executed.
 | 
				
			||||||
.DEFAULT_GOAL := build  
 | 
					.DEFAULT_GOAL := build 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Install stuffbin if it doesn't exist.
 | 
					# Install stuffbin if it doesn't exist.
 | 
				
			||||||
$(STUFFBIN):
 | 
					$(STUFFBIN):
 | 
				
			||||||
@@ -28,11 +28,24 @@ install-deps: $(STUFFBIN)
 | 
				
			|||||||
	@echo "→ Installing frontend dependencies..."
 | 
						@echo "→ Installing frontend dependencies..."
 | 
				
			||||||
	@cd ${FRONTEND_DIR} && pnpm install
 | 
						@cd ${FRONTEND_DIR} && pnpm install
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Build the frontend for production.
 | 
					# Build the frontend for production (both apps).
 | 
				
			||||||
.PHONY: frontend-build
 | 
					.PHONY: frontend-build
 | 
				
			||||||
frontend-build: install-deps
 | 
					frontend-build: install-deps
 | 
				
			||||||
	@echo "→ Building frontend for production..."
 | 
						@echo "→ Building frontend for production - main app & widget..."
 | 
				
			||||||
	@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build
 | 
						@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:main
 | 
				
			||||||
 | 
						@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:widget
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Build only the main frontend app.
 | 
				
			||||||
 | 
					.PHONY: frontend-build-main
 | 
				
			||||||
 | 
					frontend-build-main: install-deps
 | 
				
			||||||
 | 
						@echo "→ Building main frontend app for production..."
 | 
				
			||||||
 | 
						@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Build only the widget frontend app.
 | 
				
			||||||
 | 
					.PHONY: frontend-build-widget
 | 
				
			||||||
 | 
					frontend-build-widget: install-deps
 | 
				
			||||||
 | 
						@echo "→ Building widget frontend app for production..."
 | 
				
			||||||
 | 
						@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:widget
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Run the Go backend server in development mode.
 | 
					# Run the Go backend server in development mode.
 | 
				
			||||||
.PHONY: run-backend
 | 
					.PHONY: run-backend
 | 
				
			||||||
@@ -40,13 +53,29 @@ run-backend:
 | 
				
			|||||||
	@echo "→ Running backend..."
 | 
						@echo "→ Running backend..."
 | 
				
			||||||
	CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
 | 
						CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Run the JS frontend server in development mode.
 | 
					# Run the JS frontend server in development mode (main app only).
 | 
				
			||||||
.PHONY: run-frontend
 | 
					.PHONY: run-frontend
 | 
				
			||||||
run-frontend:
 | 
					run-frontend:
 | 
				
			||||||
	@echo "→ Installing frontend dependencies (if not already installed)..."
 | 
						@echo "→ Installing frontend dependencies (if not already installed)..."
 | 
				
			||||||
	@cd ${FRONTEND_DIR} && pnpm install
 | 
						@cd ${FRONTEND_DIR} && pnpm install
 | 
				
			||||||
	@echo "→ Running frontend..."
 | 
						@echo "→ Running main frontend app..."
 | 
				
			||||||
	@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev
 | 
						@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Run the main frontend app in development mode.
 | 
				
			||||||
 | 
					.PHONY: run-frontend-main
 | 
				
			||||||
 | 
					run-frontend-main:
 | 
				
			||||||
 | 
						@echo "→ Installing frontend dependencies (if not already installed)..."
 | 
				
			||||||
 | 
						@cd ${FRONTEND_DIR} && pnpm install
 | 
				
			||||||
 | 
						@echo "→ Running main frontend app..."
 | 
				
			||||||
 | 
						@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Run the widget frontend app in development mode.
 | 
				
			||||||
 | 
					.PHONY: run-frontend-widget
 | 
				
			||||||
 | 
					run-frontend-widget:
 | 
				
			||||||
 | 
						@echo "→ Installing frontend dependencies (if not already installed)..."
 | 
				
			||||||
 | 
						@cd ${FRONTEND_DIR} && pnpm install
 | 
				
			||||||
 | 
						@echo "→ Running widget frontend app..."
 | 
				
			||||||
 | 
						@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:widget
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Build the backend binary.
 | 
					# Build the backend binary.
 | 
				
			||||||
.PHONY: build-backend
 | 
					.PHONY: build-backend
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										17
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								README.md
									
									
									
									
									
								
							@@ -3,15 +3,13 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# Libredesk
 | 
					# 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/).
 | 
					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
 | 
					## Features
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- **Multi Shared Inbox**  
 | 
					- **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.
 | 
					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 --set-system-user-password` to set the password for the System user.
 | 
				
			||||||
- Run `./libredesk` and visit `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
 | 
					- Run `./libredesk` and visit `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
See [installation docs](https://libredesk.io/docs/installation)
 | 
					See [installation docs](https://docs.libredesk.io/getting-started/installation)
 | 
				
			||||||
__________________
 | 
					__________________
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Developers
 | 
					## Developers
 | 
				
			||||||
If you are interested in contributing, refer to the [developer setup](https://libredesk.io/docs/developer-setup/). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
 | 
					If you are interested in contributing, refer to the [developer setup](https://docs.libredesk.io/contributing/developer-setup). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
 | 
				
			||||||
 | 
					 | 
				
			||||||
## Development Status
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
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)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Translators
 | 
					## Translators
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										1138
									
								
								cmd/chat.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1138
									
								
								cmd/chat.go
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										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 {
 | 
						if v, ok := form.Value["phone_number"]; ok && len(v) > 0 {
 | 
				
			||||||
		phoneNumber = string(v[0])
 | 
							phoneNumber = string(v[0])
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	phoneNumberCallingCode := ""
 | 
						phoneNumberCountryCode := ""
 | 
				
			||||||
	if v, ok := form.Value["phone_number_calling_code"]; ok && len(v) > 0 {
 | 
						if v, ok := form.Value["phone_number_country_code"]; ok && len(v) > 0 {
 | 
				
			||||||
		phoneNumberCallingCode = string(v[0])
 | 
							phoneNumberCountryCode = string(v[0])
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	avatarURL := ""
 | 
						avatarURL := ""
 | 
				
			||||||
	if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 {
 | 
						if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 {
 | 
				
			||||||
@@ -116,8 +116,8 @@ func handleUpdateContact(r *fastglue.Request) error {
 | 
				
			|||||||
	if avatarURL == "null" {
 | 
						if avatarURL == "null" {
 | 
				
			||||||
		avatarURL = ""
 | 
							avatarURL = ""
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if phoneNumberCallingCode == "null" {
 | 
						if phoneNumberCountryCode == "null" {
 | 
				
			||||||
		phoneNumberCallingCode = ""
 | 
							phoneNumberCountryCode = ""
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if phoneNumber == "null" {
 | 
						if phoneNumber == "null" {
 | 
				
			||||||
		phoneNumber = ""
 | 
							phoneNumber = ""
 | 
				
			||||||
@@ -146,7 +146,7 @@ func handleUpdateContact(r *fastglue.Request) error {
 | 
				
			|||||||
		Email:                  null.StringFrom(email),
 | 
							Email:                  null.StringFrom(email),
 | 
				
			||||||
		AvatarURL:              null.NewString(avatarURL, avatarURL != ""),
 | 
							AvatarURL:              null.NewString(avatarURL, avatarURL != ""),
 | 
				
			||||||
		PhoneNumber:            null.NewString(phoneNumber, phoneNumber != ""),
 | 
							PhoneNumber:            null.NewString(phoneNumber, phoneNumber != ""),
 | 
				
			||||||
		PhoneNumberCallingCode: null.NewString(phoneNumberCallingCode, phoneNumberCallingCode != ""),
 | 
							PhoneNumberCountryCode: null.NewString(phoneNumberCountryCode, phoneNumberCountryCode != ""),
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := app.user.UpdateContact(id, contactToUpdate); err != nil {
 | 
						if err := app.user.UpdateContact(id, contactToUpdate); err != nil {
 | 
				
			||||||
@@ -164,11 +164,17 @@ func handleUpdateContact(r *fastglue.Request) error {
 | 
				
			|||||||
	// Upload avatar?
 | 
						// Upload avatar?
 | 
				
			||||||
	files, ok := form.File["files"]
 | 
						files, ok := form.File["files"]
 | 
				
			||||||
	if ok && len(files) > 0 {
 | 
						if ok && len(files) > 0 {
 | 
				
			||||||
		if err := uploadUserAvatar(r, &contact, files); err != nil {
 | 
							if err := uploadUserAvatar(r, contact, files); err != nil {
 | 
				
			||||||
			return sendErrorEnvelope(r, err)
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
					
 | 
				
			||||||
 | 
						// Refetch contact and return it
 | 
				
			||||||
 | 
						contact, err = app.user.GetContact(id, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return r.SendEnvelope(contact)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleGetContactNotes returns all notes for a contact.
 | 
					// handleGetContactNotes returns all notes for a contact.
 | 
				
			||||||
@@ -195,18 +201,21 @@ func handleCreateContactNote(r *fastglue.Request) error {
 | 
				
			|||||||
		auser        = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser        = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		req          = createContactNoteReq{}
 | 
							req          = createContactNoteReq{}
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					 | 
				
			||||||
	if err := r.Decode(&req, "json"); err != nil {
 | 
						if err := r.Decode(&req, "json"); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					 | 
				
			||||||
	if len(req.Note) == 0 {
 | 
						if len(req.Note) == 0 {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "note"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "note"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if err := app.user.CreateNote(contactID, auser.ID, req.Note); err != nil {
 | 
						n, err := app.user.CreateNote(contactID, auser.ID, req.Note)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						n, err = app.user.GetNote(n.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return r.SendEnvelope(n)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleDeleteContactNote deletes a note for a contact.
 | 
					// handleDeleteContactNote deletes a note for a contact.
 | 
				
			||||||
@@ -240,6 +249,8 @@ func handleDeleteContactNote(r *fastglue.Request) error {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						app.lo.Info("deleting contact note", "note_id", noteID, "contact_id", contactID, "actor_id", auser.ID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := app.user.DeleteNote(noteID, contactID); err != nil {
 | 
						if err := app.user.DeleteNote(noteID, contactID); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -251,6 +262,7 @@ func handleBlockContact(r *fastglue.Request) error {
 | 
				
			|||||||
	var (
 | 
						var (
 | 
				
			||||||
		app          = r.Context.(*App)
 | 
							app          = r.Context.(*App)
 | 
				
			||||||
		contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
							contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
 | 
							auser        = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		req          = blockContactReq{}
 | 
							req          = blockContactReq{}
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -262,8 +274,20 @@ func handleBlockContact(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, req.Enabled); err != nil {
 | 
						app.lo.Info("setting contact block status", "contact_id", contactID, "enabled", req.Enabled, "actor_id", auser.ID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						contact, err := app.user.GetContact(contactID, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
					
 | 
				
			||||||
 | 
						if err := app.user.ToggleEnabled(contactID, contact.Type, req.Enabled); err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						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"`
 | 
						Subject         string `json:"subject"`
 | 
				
			||||||
	Content         string `json:"content"`
 | 
						Content         string `json:"content"`
 | 
				
			||||||
	Attachments     []int  `json:"attachments"`
 | 
						Attachments     []int  `json:"attachments"`
 | 
				
			||||||
 | 
						Initiator       string `json:"initiator"` // "contact" | "agent"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleGetAllConversations retrieves all conversations.
 | 
					// handleGetAllConversations retrieves all conversations.
 | 
				
			||||||
@@ -273,8 +274,8 @@ func handleGetConversation(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	prev, _ := app.conversation.GetContactConversations(conv.ContactID)
 | 
						prev, _ := app.conversation.GetContactPreviousConversations(conv.ContactID, 10)
 | 
				
			||||||
	conv.PreviousConversations = filterCurrentConv(prev, conv.UUID)
 | 
						conv.PreviousConversations = filterCurrentPreviousConv(prev, conv.UUID)
 | 
				
			||||||
	return r.SendEnvelope(conv)
 | 
						return r.SendEnvelope(conv)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -474,11 +475,6 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Make sure a user is assigned before resolving conversation.
 | 
					 | 
				
			||||||
	if status == cmodels.StatusResolved && conversation.AssignedUserID.Int == 0 {
 | 
					 | 
				
			||||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("conversation.resolveWithoutAssignee"), nil))
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Update conversation status.
 | 
						// Update conversation status.
 | 
				
			||||||
	if err := app.conversation.UpdateConversationStatus(uuid, 0 /**status_id**/, status, snoozedUntil, user); err != nil {
 | 
						if err := app.conversation.UpdateConversationStatus(uuid, 0 /**status_id**/, status, snoozedUntil, user); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
@@ -583,7 +579,7 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if err := app.user.UpdateCustomAttributes(conversation.ContactID, attributes); err != nil {
 | 
						if err := app.user.SaveCustomAttributes(conversation.ContactID, attributes, false); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	// Broadcast update.
 | 
						// Broadcast update.
 | 
				
			||||||
@@ -649,14 +645,14 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
 | 
				
			|||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// filterCurrentConv removes the current conversation from the list of conversations.
 | 
					// filterCurrentPreviousConv removes the current conversation from the list of previous conversations.
 | 
				
			||||||
func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conversation {
 | 
					func filterCurrentPreviousConv(convs []cmodels.PreviousConversation, uuid string) []cmodels.PreviousConversation {
 | 
				
			||||||
	for i, c := range convs {
 | 
						for i, c := range convs {
 | 
				
			||||||
		if c.UUID == uuid {
 | 
							if c.UUID == uuid {
 | 
				
			||||||
			return append(convs[:i], convs[i+1:]...)
 | 
								return append(convs[:i], convs[i+1:]...)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return []cmodels.Conversation{}
 | 
						return []cmodels.PreviousConversation{}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleCreateConversation creates a new conversation and sends a message to it.
 | 
					// handleCreateConversation creates a new conversation and sends a message to it.
 | 
				
			||||||
@@ -672,67 +668,42 @@ func handleCreateConversation(r *fastglue.Request) error {
 | 
				
			|||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Validate the request
 | 
				
			||||||
 | 
						if err := validateCreateConversationRequest(req, app); err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	to := []string{req.Email}
 | 
						to := []string{req.Email}
 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Validate required fields
 | 
					 | 
				
			||||||
	if req.InboxID <= 0 {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	if req.Content == "" {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	if req.Email == "" {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	if req.FirstName == "" {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	if !stringutil.ValidEmail(req.Email) {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	user, err := app.user.GetAgent(auser.ID, "")
 | 
						user, err := app.user.GetAgent(auser.ID, "")
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Check if inbox exists and is enabled.
 | 
					 | 
				
			||||||
	inbox, err := app.inbox.GetDBRecord(req.InboxID)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	if !inbox.Enabled {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Find or create contact.
 | 
						// Find or create contact.
 | 
				
			||||||
	contact := umodels.User{
 | 
						contact := umodels.User{
 | 
				
			||||||
		Email:           null.StringFrom(req.Email),
 | 
							Email:     null.StringFrom(req.Email),
 | 
				
			||||||
		SourceChannelID: null.StringFrom(req.Email),
 | 
							FirstName: req.FirstName,
 | 
				
			||||||
		FirstName:       req.FirstName,
 | 
							LastName:  req.LastName,
 | 
				
			||||||
		LastName:        req.LastName,
 | 
					 | 
				
			||||||
		InboxID:         req.InboxID,
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if err := app.user.CreateContact(&contact); err != nil {
 | 
						if err := app.user.CreateContact(&contact); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Create conversation
 | 
						// Create conversation first.
 | 
				
			||||||
	conversationID, conversationUUID, err := app.conversation.CreateConversation(
 | 
						conversationID, conversationUUID, err := app.conversation.CreateConversation(
 | 
				
			||||||
		contact.ID,
 | 
							contact.ID,
 | 
				
			||||||
		contact.ContactChannelID,
 | 
					 | 
				
			||||||
		req.InboxID,
 | 
							req.InboxID,
 | 
				
			||||||
		"",         /** last_message **/
 | 
							"",         /** last_message **/
 | 
				
			||||||
		time.Now(), /** last_message_at **/
 | 
							time.Now(), /** last_message_at **/
 | 
				
			||||||
		req.Subject,
 | 
							req.Subject,
 | 
				
			||||||
		true, /** append reference number to subject **/
 | 
							true, /** append reference number to subject? **/
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		app.lo.Error("error creating conversation", "error", err)
 | 
							app.lo.Error("error creating conversation", "error", err)
 | 
				
			||||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Prepare attachments.
 | 
						// Get media for the attachment ids.
 | 
				
			||||||
	var media = make([]medModels.Media, 0, len(req.Attachments))
 | 
						var media = make([]medModels.Media, 0, len(req.Attachments))
 | 
				
			||||||
	for _, id := range req.Attachments {
 | 
						for _, id := range req.Attachments {
 | 
				
			||||||
		m, err := app.media.Get(id, "")
 | 
							m, err := app.media.Get(id, "")
 | 
				
			||||||
@@ -743,13 +714,29 @@ func handleCreateConversation(r *fastglue.Request) error {
 | 
				
			|||||||
		media = append(media, m)
 | 
							media = append(media, m)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Send reply to the created conversation.
 | 
						// Send initial message based on the initiator of conversation.
 | 
				
			||||||
	if _, err := app.conversation.SendReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
 | 
						switch req.Initiator {
 | 
				
			||||||
		// Delete the conversation if reply fails.
 | 
						case umodels.UserTypeAgent:
 | 
				
			||||||
		if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
 | 
							// Queue reply.
 | 
				
			||||||
			app.lo.Error("error deleting conversation", "error", err)
 | 
							if _, err := app.conversation.QueueReply(media, req.InboxID, auser.ID /**sender_id**/, contact.ID, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
 | 
				
			||||||
 | 
								// Delete the conversation if msg queue fails.
 | 
				
			||||||
 | 
								if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
 | 
				
			||||||
 | 
									app.lo.Error("error deleting conversation", "error", err)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
 | 
						case umodels.UserTypeContact:
 | 
				
			||||||
 | 
							// Create contact message.
 | 
				
			||||||
 | 
							if _, err := app.conversation.CreateContactMessage(media, contact.ID, conversationUUID, req.Content, cmodels.ContentTypeHTML); err != nil {
 | 
				
			||||||
 | 
								// Delete the conversation if message creation fails.
 | 
				
			||||||
 | 
								if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
 | 
				
			||||||
 | 
									app.lo.Error("error deleting conversation", "error", err)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						default:
 | 
				
			||||||
 | 
							// Guard anyway.
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Assign the conversation to the agent or team.
 | 
						// Assign the conversation to the agent or team.
 | 
				
			||||||
@@ -768,3 +755,36 @@ func handleCreateConversation(r *fastglue.Request) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(conversation)
 | 
						return r.SendEnvelope(conversation)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// validateCreateConversationRequest validates the create conversation request fields.
 | 
				
			||||||
 | 
					func validateCreateConversationRequest(req createConversationRequest, app *App) error {
 | 
				
			||||||
 | 
						if req.InboxID <= 0 {
 | 
				
			||||||
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if req.Content == "" {
 | 
				
			||||||
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if req.Email == "" {
 | 
				
			||||||
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if req.FirstName == "" {
 | 
				
			||||||
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if !stringutil.ValidEmail(req.Email) {
 | 
				
			||||||
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if req.Initiator != umodels.UserTypeContact && req.Initiator != umodels.UserTypeAgent {
 | 
				
			||||||
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Check if inbox exists and is enabled.
 | 
				
			||||||
 | 
						inbox, err := app.inbox.GetDBRecord(req.InboxID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if !inbox.Enabled {
 | 
				
			||||||
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										70
									
								
								cmd/csat.go
									
									
									
									
									
								
							
							
						
						
									
										70
									
								
								cmd/csat.go
									
									
									
									
									
								
							@@ -3,9 +3,19 @@ package main
 | 
				
			|||||||
import (
 | 
					import (
 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/envelope"
 | 
				
			||||||
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type csatResponse struct {
 | 
				
			||||||
 | 
						Rating   int    `json:"rating"`
 | 
				
			||||||
 | 
						Feedback string `json:"feedback"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					const (
 | 
				
			||||||
 | 
						maxCsatFeedbackLength = 1000
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleShowCSAT renders the CSAT page for a given csat.
 | 
					// handleShowCSAT renders the CSAT page for a given csat.
 | 
				
			||||||
func handleShowCSAT(r *fastglue.Request) error {
 | 
					func handleShowCSAT(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
@@ -17,7 +27,7 @@ func handleShowCSAT(r *fastglue.Request) error {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
				
			||||||
			"Data": map[string]interface{}{
 | 
								"Data": map[string]interface{}{
 | 
				
			||||||
				"ErrorMessage": "Page not found",
 | 
									"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -25,8 +35,8 @@ func handleShowCSAT(r *fastglue.Request) error {
 | 
				
			|||||||
	if csat.ResponseTimestamp.Valid {
 | 
						if csat.ResponseTimestamp.Valid {
 | 
				
			||||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
 | 
				
			||||||
			"Data": map[string]interface{}{
 | 
								"Data": map[string]interface{}{
 | 
				
			||||||
				"Title":   "Thank you!",
 | 
									"Title":   app.i18n.T("globals.messages.thankYou"),
 | 
				
			||||||
				"Message": "We appreciate you taking the time to submit your feedback.",
 | 
									"Message": app.i18n.T("csat.thankYouMessage"),
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -35,14 +45,14 @@ func handleShowCSAT(r *fastglue.Request) error {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
				
			||||||
			"Data": map[string]interface{}{
 | 
								"Data": map[string]interface{}{
 | 
				
			||||||
				"ErrorMessage": "Page not found",
 | 
									"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
 | 
						return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
 | 
				
			||||||
		"Data": map[string]interface{}{
 | 
							"Data": map[string]interface{}{
 | 
				
			||||||
			"Title":    "Rate your interaction with us",
 | 
								"Title": app.i18n.T("csat.pageTitle"),
 | 
				
			||||||
			"CSAT": map[string]interface{}{
 | 
								"CSAT": map[string]interface{}{
 | 
				
			||||||
				"UUID": csat.UUID,
 | 
									"UUID": csat.UUID,
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
@@ -67,15 +77,15 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
				
			||||||
			"Data": map[string]interface{}{
 | 
								"Data": map[string]interface{}{
 | 
				
			||||||
				"ErrorMessage": "Invalid `rating`",
 | 
									"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if ratingI < 1 || ratingI > 5 {
 | 
						if ratingI < 0 || ratingI > 5 {
 | 
				
			||||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
				
			||||||
			"Data": map[string]interface{}{
 | 
								"Data": map[string]interface{}{
 | 
				
			||||||
				"ErrorMessage": "Invalid `rating`",
 | 
									"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -83,11 +93,16 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
 | 
				
			|||||||
	if uuid == "" {
 | 
						if uuid == "" {
 | 
				
			||||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
				
			||||||
			"Data": map[string]interface{}{
 | 
								"Data": map[string]interface{}{
 | 
				
			||||||
				"ErrorMessage": "Invalid `uuid`",
 | 
									"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Trim feedback if it exceeds max length
 | 
				
			||||||
 | 
						if len(feedback) > maxCsatFeedbackLength {
 | 
				
			||||||
 | 
							feedback = feedback[:maxCsatFeedbackLength]
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil {
 | 
						if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil {
 | 
				
			||||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
				
			||||||
			"Data": map[string]interface{}{
 | 
								"Data": map[string]interface{}{
 | 
				
			||||||
@@ -98,8 +113,41 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
 | 
						return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
 | 
				
			||||||
		"Data": map[string]interface{}{
 | 
							"Data": map[string]interface{}{
 | 
				
			||||||
			"Title":   "Thank you!",
 | 
								"Title":   app.i18n.T("globals.messages.thankYou"),
 | 
				
			||||||
			"Message": "We appreciate you taking the time to submit your feedback.",
 | 
								"Message": app.i18n.T("csat.thankYouMessage"),
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleSubmitCSATResponse handles CSAT response submission from the widget API.
 | 
				
			||||||
 | 
					func handleSubmitCSATResponse(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app  = r.Context.(*App)
 | 
				
			||||||
 | 
							uuid = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
 | 
							req  = csatResponse{}
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := r.Decode(&req, "json"); err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid JSON", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if req.Rating < 0 || req.Rating > 5 {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Rating must be between 0 and 5 (0 means no rating)", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// At least one of rating or feedback must be provided
 | 
				
			||||||
 | 
						if req.Rating == 0 && req.Feedback == "" {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Either rating or feedback must be provided", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if uuid == "" {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid UUID", nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Update CSAT response
 | 
				
			||||||
 | 
						if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback); err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										147
									
								
								cmd/handlers.go
									
									
									
									
									
								
							
							
						
						
									
										147
									
								
								cmd/handlers.go
									
									
									
									
									
								
							@@ -1,12 +1,16 @@
 | 
				
			|||||||
package main
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
 | 
						"encoding/json"
 | 
				
			||||||
	"mime"
 | 
						"mime"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
	"path"
 | 
						"path"
 | 
				
			||||||
	"path/filepath"
 | 
						"path/filepath"
 | 
				
			||||||
 | 
						"strings"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
						"github.com/abhinavxd/libredesk/internal/envelope"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/httputil"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/ws"
 | 
						"github.com/abhinavxd/libredesk/internal/ws"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
@@ -23,18 +27,20 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
				
			|||||||
	// i18n.
 | 
						// i18n.
 | 
				
			||||||
	g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
 | 
						g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Public config for app initialization.
 | 
				
			||||||
 | 
						g.GET("/api/v1/config", handleGetConfig)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Media.
 | 
						// Media.
 | 
				
			||||||
	g.GET("/uploads/{uuid}", auth(handleServeMedia))
 | 
						g.GET("/uploads/{uuid}", auth(handleServeMedia))
 | 
				
			||||||
	g.POST("/api/v1/media", auth(handleMediaUpload))
 | 
						g.POST("/api/v1/media", auth(handleMediaUpload))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Settings.
 | 
						// Settings.
 | 
				
			||||||
	g.GET("/api/v1/settings/general", handleGetGeneralSettings)
 | 
						g.GET("/api/v1/settings/general", auth(handleGetGeneralSettings))
 | 
				
			||||||
	g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "general_settings:manage"))
 | 
						g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "general_settings:manage"))
 | 
				
			||||||
	g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_settings:manage"))
 | 
						g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_settings:manage"))
 | 
				
			||||||
	g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage"))
 | 
						g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// OpenID connect single sign-on.
 | 
						// OpenID connect single sign-on.
 | 
				
			||||||
	g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
 | 
					 | 
				
			||||||
	g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
 | 
						g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
 | 
				
			||||||
	g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
 | 
						g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
 | 
				
			||||||
	g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
 | 
						g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
 | 
				
			||||||
@@ -153,7 +159,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
				
			|||||||
	g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
 | 
						g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Roles.
 | 
						// Roles.
 | 
				
			||||||
	g.GET("/api/v1/roles", perm(handleGetRoles, "roles:manage"))
 | 
						g.GET("/api/v1/roles", auth(handleGetRoles))
 | 
				
			||||||
	g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage"))
 | 
						g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage"))
 | 
				
			||||||
	g.POST("/api/v1/roles", perm(handleCreateRole, "roles:manage"))
 | 
						g.POST("/api/v1/roles", perm(handleCreateRole, "roles:manage"))
 | 
				
			||||||
	g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage"))
 | 
						g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage"))
 | 
				
			||||||
@@ -209,13 +215,30 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
				
			|||||||
	// Actvity logs.
 | 
						// Actvity logs.
 | 
				
			||||||
	g.GET("/api/v1/activity-logs", perm(handleGetActivityLogs, "activity_logs:manage"))
 | 
						g.GET("/api/v1/activity-logs", perm(handleGetActivityLogs, "activity_logs:manage"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// CSAT.
 | 
				
			||||||
 | 
						g.POST("/api/v1/csat/{uuid}/response", handleSubmitCSATResponse)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// WebSocket.
 | 
						// WebSocket.
 | 
				
			||||||
	g.GET("/ws", auth(func(r *fastglue.Request) error {
 | 
						g.GET("/ws", auth(func(r *fastglue.Request) error {
 | 
				
			||||||
		return handleWS(r, hub)
 | 
							return handleWS(r, hub)
 | 
				
			||||||
	}))
 | 
						}))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Live chat widget websocket.
 | 
				
			||||||
 | 
						g.GET("/widget/ws", handleWidgetWS)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Widget APIs.
 | 
				
			||||||
 | 
						g.GET("/api/v1/widget/chat/settings/launcher", handleGetChatLauncherSettings)
 | 
				
			||||||
 | 
						g.GET("/api/v1/widget/chat/settings", handleGetChatSettings)
 | 
				
			||||||
 | 
						g.POST("/api/v1/widget/chat/conversations/init", rateLimitWidget(widgetAuth(handleChatInit)))
 | 
				
			||||||
 | 
						g.GET("/api/v1/widget/chat/conversations", rateLimitWidget(widgetAuth(handleGetConversations)))
 | 
				
			||||||
 | 
						g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", rateLimitWidget(widgetAuth(handleChatUpdateLastSeen)))
 | 
				
			||||||
 | 
						g.GET("/api/v1/widget/chat/conversations/{uuid}", rateLimitWidget(widgetAuth(handleChatGetConversation)))
 | 
				
			||||||
 | 
						g.POST("/api/v1/widget/chat/conversations/{uuid}/message", rateLimitWidget(widgetAuth(handleChatSendMessage)))
 | 
				
			||||||
 | 
						g.POST("/api/v1/widget/media/upload", rateLimitWidget(widgetAuth(handleWidgetMediaUpload)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Frontend pages.
 | 
						// Frontend pages.
 | 
				
			||||||
	g.GET("/", notAuthPage(serveIndexPage))
 | 
						g.GET("/", notAuthPage(serveIndexPage))
 | 
				
			||||||
 | 
						g.GET("/widget", serveWidgetIndexPage)
 | 
				
			||||||
	g.GET("/inboxes/{all:*}", authPage(serveIndexPage))
 | 
						g.GET("/inboxes/{all:*}", authPage(serveIndexPage))
 | 
				
			||||||
	g.GET("/teams/{all:*}", authPage(serveIndexPage))
 | 
						g.GET("/teams/{all:*}", authPage(serveIndexPage))
 | 
				
			||||||
	g.GET("/views/{all:*}", authPage(serveIndexPage))
 | 
						g.GET("/views/{all:*}", authPage(serveIndexPage))
 | 
				
			||||||
@@ -225,8 +248,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
				
			|||||||
	g.GET("/account/{all:*}", authPage(serveIndexPage))
 | 
						g.GET("/account/{all:*}", authPage(serveIndexPage))
 | 
				
			||||||
	g.GET("/reset-password", notAuthPage(serveIndexPage))
 | 
						g.GET("/reset-password", notAuthPage(serveIndexPage))
 | 
				
			||||||
	g.GET("/set-password", notAuthPage(serveIndexPage))
 | 
						g.GET("/set-password", notAuthPage(serveIndexPage))
 | 
				
			||||||
	// FIXME: Don't need three separate routes for the same thing.
 | 
					
 | 
				
			||||||
 | 
						// Assets and static files.
 | 
				
			||||||
 | 
						// FIXME: Reduce the number of routes.
 | 
				
			||||||
 | 
						g.GET("/widget.js", serveWidgetJS)
 | 
				
			||||||
	g.GET("/assets/{all:*}", serveFrontendStaticFiles)
 | 
						g.GET("/assets/{all:*}", serveFrontendStaticFiles)
 | 
				
			||||||
 | 
						g.GET("/widget/assets/{all:*}", serveWidgetStaticFiles)
 | 
				
			||||||
	g.GET("/images/{all:*}", serveFrontendStaticFiles)
 | 
						g.GET("/images/{all:*}", serveFrontendStaticFiles)
 | 
				
			||||||
	g.GET("/static/public/{all:*}", serveStaticFiles)
 | 
						g.GET("/static/public/{all:*}", serveStaticFiles)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -263,6 +290,77 @@ func serveIndexPage(r *fastglue.Request) error {
 | 
				
			|||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// validateWidgetReferer validates the Referer header against trusted domains configured in the live chat inbox settings.
 | 
				
			||||||
 | 
					func validateWidgetReferer(app *App, r *fastglue.Request, inboxID int) error {
 | 
				
			||||||
 | 
						// Get the Referer header from the request
 | 
				
			||||||
 | 
						referer := string(r.RequestCtx.Request.Header.Peek("Referer"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// If no referer header is present, allow direct access.
 | 
				
			||||||
 | 
						if referer == "" {
 | 
				
			||||||
 | 
							return nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Get inbox configuration
 | 
				
			||||||
 | 
						inbox, err := app.inbox.GetDBRecord(inboxID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							app.lo.Error("error fetching inbox for referer check", "inbox_id", inboxID, "error", err)
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.NotFoundError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if !inbox.Enabled {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(http.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Parse the live chat config
 | 
				
			||||||
 | 
						var config livechat.Config
 | 
				
			||||||
 | 
						if err := json.Unmarshal(inbox.Config, &config); err != nil {
 | 
				
			||||||
 | 
							app.lo.Error("error parsing live chat config for referer check", "error", err)
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(http.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// If trusted domains list is empty, allow all referers
 | 
				
			||||||
 | 
						if len(config.TrustedDomains) == 0 {
 | 
				
			||||||
 | 
							return nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Check if the referer matches any of the trusted domains
 | 
				
			||||||
 | 
						if !httputil.IsOriginTrusted(referer, config.TrustedDomains) {
 | 
				
			||||||
 | 
							app.lo.Warn("widget request from untrusted referer blocked",
 | 
				
			||||||
 | 
								"referer", referer,
 | 
				
			||||||
 | 
								"inbox_id", inboxID,
 | 
				
			||||||
 | 
								"trusted_domains", config.TrustedDomains)
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(http.StatusForbidden, "Widget not allowed from this origin: "+referer, nil, envelope.PermissionError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						app.lo.Debug("widget request from trusted referer allowed", "referer", referer, "inbox_id", inboxID)
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// serveWidgetIndexPage serves the widget index page of the application.
 | 
				
			||||||
 | 
					func serveWidgetIndexPage(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						app := r.Context.(*App)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Extract inbox ID and validate trusted domains if present
 | 
				
			||||||
 | 
						inboxID := r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id")
 | 
				
			||||||
 | 
						if err := validateWidgetReferer(app, r, inboxID); err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Prevent caching of the index page.
 | 
				
			||||||
 | 
						r.RequestCtx.Response.Header.Add("Cache-Control", "no-store, no-cache, must-revalidate, post-check=0, pre-check=0")
 | 
				
			||||||
 | 
						r.RequestCtx.Response.Header.Add("Pragma", "no-cache")
 | 
				
			||||||
 | 
						r.RequestCtx.Response.Header.Add("Expires", "-1")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Serve the index.html file from the embedded filesystem.
 | 
				
			||||||
 | 
						file, err := app.fs.Get(path.Join(widgetDir, "index.html"))
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						r.RequestCtx.Response.Header.Set("Content-Type", "text/html")
 | 
				
			||||||
 | 
						r.RequestCtx.SetBody(file.ReadBytes())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// serveStaticFiles serves static assets from the embedded filesystem.
 | 
					// serveStaticFiles serves static assets from the embedded filesystem.
 | 
				
			||||||
func serveStaticFiles(r *fastglue.Request) error {
 | 
					func serveStaticFiles(r *fastglue.Request) error {
 | 
				
			||||||
	app := r.Context.(*App)
 | 
						app := r.Context.(*App)
 | 
				
			||||||
@@ -311,6 +409,47 @@ func serveFrontendStaticFiles(r *fastglue.Request) error {
 | 
				
			|||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// serveWidgetStaticFiles serves widget static assets from the embedded filesystem.
 | 
				
			||||||
 | 
					func serveWidgetStaticFiles(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						app := r.Context.(*App)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						filePath := string(r.RequestCtx.Path())
 | 
				
			||||||
 | 
						finalPath := filepath.Join(widgetDir, strings.TrimPrefix(filePath, "/widget"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						file, err := app.fs.Get(finalPath)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Set the appropriate Content-Type based on the file extension.
 | 
				
			||||||
 | 
						ext := filepath.Ext(filePath)
 | 
				
			||||||
 | 
						contentType := mime.TypeByExtension(ext)
 | 
				
			||||||
 | 
						if contentType == "" {
 | 
				
			||||||
 | 
							contentType = http.DetectContentType(file.ReadBytes())
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						r.RequestCtx.Response.Header.Set("Content-Type", contentType)
 | 
				
			||||||
 | 
						r.RequestCtx.SetBody(file.ReadBytes())
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// serveWidgetJS serves the widget JavaScript file.
 | 
				
			||||||
 | 
					func serveWidgetJS(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						app := r.Context.(*App)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Set appropriate headers for JavaScript
 | 
				
			||||||
 | 
						r.RequestCtx.Response.Header.Set("Content-Type", "application/javascript")
 | 
				
			||||||
 | 
						r.RequestCtx.Response.Header.Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Serve the widget.js file from the embedded filesystem.
 | 
				
			||||||
 | 
						file, err := app.fs.Get("static/widget.js")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						r.RequestCtx.SetBody(file.ReadBytes())
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// sendErrorEnvelope sends a standardized error response to the client.
 | 
					// sendErrorEnvelope sends a standardized error response to the client.
 | 
				
			||||||
func sendErrorEnvelope(r *fastglue.Request, err error) error {
 | 
					func sendErrorEnvelope(r *fastglue.Request, err error) error {
 | 
				
			||||||
	e, ok := err.(envelope.Error)
 | 
						e, ok := err.(envelope.Error)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,12 @@
 | 
				
			|||||||
package main
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
 | 
						"encoding/json"
 | 
				
			||||||
	"net/mail"
 | 
						"net/mail"
 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
						"github.com/abhinavxd/libredesk/internal/envelope"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
 | 
				
			||||||
	imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
 | 
						imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
@@ -17,6 +19,12 @@ func handleGetInboxes(r *fastglue.Request) error {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						for i := range inboxes {
 | 
				
			||||||
 | 
							if err := inboxes[i].ClearPasswords(); err != nil {
 | 
				
			||||||
 | 
								app.lo.Error("error clearing inbox passwords from response", "error", err)
 | 
				
			||||||
 | 
								return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(inboxes)
 | 
						return r.SendEnvelope(inboxes)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -154,9 +162,11 @@ func handleDeleteInbox(r *fastglue.Request) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// validateInbox validates the inbox
 | 
					// validateInbox validates the inbox
 | 
				
			||||||
func validateInbox(app *App, inbox imodels.Inbox) error {
 | 
					func validateInbox(app *App, inbox imodels.Inbox) error {
 | 
				
			||||||
	// Validate from address.
 | 
						// Validate from address only for email channels.
 | 
				
			||||||
	if _, err := mail.ParseAddress(inbox.From); err != nil {
 | 
						if inbox.Channel == "email" {
 | 
				
			||||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil)
 | 
							if _, err := mail.ParseAddress(inbox.From); err != nil {
 | 
				
			||||||
 | 
								return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if len(inbox.Config) == 0 {
 | 
						if len(inbox.Config) == 0 {
 | 
				
			||||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "config"), nil)
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "config"), nil)
 | 
				
			||||||
@@ -167,5 +177,33 @@ func validateInbox(app *App, inbox imodels.Inbox) error {
 | 
				
			|||||||
	if inbox.Channel == "" {
 | 
						if inbox.Channel == "" {
 | 
				
			||||||
		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "channel"), nil)
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "channel"), nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Validate livechat-specific configuration
 | 
				
			||||||
 | 
						if inbox.Channel == livechat.ChannelLiveChat {
 | 
				
			||||||
 | 
							var config livechat.Config
 | 
				
			||||||
 | 
							if err := json.Unmarshal(inbox.Config, &config); err == nil {
 | 
				
			||||||
 | 
								// ShowOfficeHoursAfterAssignment cannot be enabled if ShowOfficeHoursInChat is disabled
 | 
				
			||||||
 | 
								if config.ShowOfficeHoursAfterAssignment && !config.ShowOfficeHoursInChat {
 | 
				
			||||||
 | 
									return envelope.NewError(envelope.InputError, "`show_office_hours_after_assignment` cannot be enabled when `show_office_hours_in_chat` is disabled", nil)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Validate linked email inbox if specified
 | 
				
			||||||
 | 
							if inbox.LinkedEmailInboxID.Valid {
 | 
				
			||||||
 | 
								linkedInbox, err := app.inbox.GetDBRecord(int(inbox.LinkedEmailInboxID.Int))
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "linked_email_inbox_id"), nil)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								// Ensure linked inbox is an email channel
 | 
				
			||||||
 | 
								if linkedInbox.Channel != "email" {
 | 
				
			||||||
 | 
									return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "linked_email_inbox_id"), nil)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								// Ensure linked inbox is enabled
 | 
				
			||||||
 | 
								if !linkedInbox.Enabled {
 | 
				
			||||||
 | 
									return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "linked_email_inbox_id"), nil)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										86
									
								
								cmd/init.go
									
									
									
									
									
								
							
							
						
						
									
										86
									
								
								cmd/init.go
									
									
									
									
									
								
							@@ -27,6 +27,7 @@ import (
 | 
				
			|||||||
	customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
 | 
						customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/inbox"
 | 
						"github.com/abhinavxd/libredesk/internal/inbox"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/inbox/channel/email"
 | 
						"github.com/abhinavxd/libredesk/internal/inbox/channel/email"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
 | 
				
			||||||
	imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
 | 
						imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/macro"
 | 
						"github.com/abhinavxd/libredesk/internal/macro"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/media"
 | 
						"github.com/abhinavxd/libredesk/internal/media"
 | 
				
			||||||
@@ -35,6 +36,7 @@ import (
 | 
				
			|||||||
	notifier "github.com/abhinavxd/libredesk/internal/notification"
 | 
						notifier "github.com/abhinavxd/libredesk/internal/notification"
 | 
				
			||||||
	emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
 | 
						emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/oidc"
 | 
						"github.com/abhinavxd/libredesk/internal/oidc"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/ratelimit"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/report"
 | 
						"github.com/abhinavxd/libredesk/internal/report"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/role"
 | 
						"github.com/abhinavxd/libredesk/internal/role"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/search"
 | 
						"github.com/abhinavxd/libredesk/internal/search"
 | 
				
			||||||
@@ -132,7 +134,8 @@ func initConstants() *constants {
 | 
				
			|||||||
// initFS initializes the stuffbin FileSystem.
 | 
					// initFS initializes the stuffbin FileSystem.
 | 
				
			||||||
func initFS() stuffbin.FileSystem {
 | 
					func initFS() stuffbin.FileSystem {
 | 
				
			||||||
	var files = []string{
 | 
						var files = []string{
 | 
				
			||||||
		"frontend/dist",
 | 
							"frontend/dist/main",
 | 
				
			||||||
 | 
							"frontend/dist/widget",
 | 
				
			||||||
		"i18n",
 | 
							"i18n",
 | 
				
			||||||
		"static",
 | 
							"static",
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -223,11 +226,30 @@ func initConversations(
 | 
				
			|||||||
	template *tmpl.Manager,
 | 
						template *tmpl.Manager,
 | 
				
			||||||
	webhook *webhook.Manager,
 | 
						webhook *webhook.Manager,
 | 
				
			||||||
) *conversation.Manager {
 | 
					) *conversation.Manager {
 | 
				
			||||||
 | 
						continuityConfig := &conversation.ContinuityConfig{}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if ko.Exists("conversation.continuity.batch_check_interval") {
 | 
				
			||||||
 | 
							continuityConfig.BatchCheckInterval = ko.MustDuration("conversation.continuity.batch_check_interval")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if ko.Exists("conversation.continuity.offline_threshold") {
 | 
				
			||||||
 | 
							continuityConfig.OfflineThreshold = ko.MustDuration("conversation.continuity.offline_threshold")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if ko.Exists("conversation.continuity.min_email_interval") {
 | 
				
			||||||
 | 
							continuityConfig.MinEmailInterval = ko.MustDuration("conversation.continuity.min_email_interval")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if ko.Exists("conversation.continuity.max_messages_per_email") {
 | 
				
			||||||
 | 
							continuityConfig.MaxMessagesPerEmail = ko.MustInt("conversation.continuity.max_messages_per_email")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, webhook, conversation.Opts{
 | 
						c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, webhook, conversation.Opts{
 | 
				
			||||||
		DB:                       db,
 | 
							DB:                       db,
 | 
				
			||||||
		Lo:                       initLogger("conversation_manager"),
 | 
							Lo:                       initLogger("conversation_manager"),
 | 
				
			||||||
		OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
 | 
							OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
 | 
				
			||||||
		IncomingMessageQueueSize: ko.MustInt("message.incoming_queue_size"),
 | 
							IncomingMessageQueueSize: ko.MustInt("message.incoming_queue_size"),
 | 
				
			||||||
 | 
							ContinuityConfig:         continuityConfig,
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		log.Fatalf("error initializing conversation manager: %v", err)
 | 
							log.Fatalf("error initializing conversation manager: %v", err)
 | 
				
			||||||
@@ -250,11 +272,12 @@ func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// initViews inits view manager.
 | 
					// initViews inits view manager.
 | 
				
			||||||
func initView(db *sqlx.DB) *view.Manager {
 | 
					func initView(db *sqlx.DB, i18n *i18n.I18n) *view.Manager {
 | 
				
			||||||
	var lo = initLogger("view_manager")
 | 
						var lo = initLogger("view_manager")
 | 
				
			||||||
	m, err := view.New(view.Opts{
 | 
						m, err := view.New(view.Opts{
 | 
				
			||||||
		DB: db,
 | 
							DB:   db,
 | 
				
			||||||
		Lo: lo,
 | 
							Lo:   lo,
 | 
				
			||||||
 | 
							I18n: i18n,
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		log.Fatalf("error initializing view manager: %v", err)
 | 
							log.Fatalf("error initializing view manager: %v", err)
 | 
				
			||||||
@@ -327,7 +350,7 @@ func initWS(user *user.Manager) *ws.Hub {
 | 
				
			|||||||
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager {
 | 
					func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		lo      = initLogger("template")
 | 
							lo      = initLogger("template")
 | 
				
			||||||
		funcMap = getTmplFuncs(consts)
 | 
							funcMap = getTmplFuncs(consts, i18n)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
 | 
						tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
@@ -345,7 +368,7 @@ func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// getTmplFuncs returns the template functions.
 | 
					// getTmplFuncs returns the template functions.
 | 
				
			||||||
func getTmplFuncs(consts *constants) template.FuncMap {
 | 
					func getTmplFuncs(consts *constants, i18n *i18n.I18n) template.FuncMap {
 | 
				
			||||||
	return template.FuncMap{
 | 
						return template.FuncMap{
 | 
				
			||||||
		"RootURL": func() string {
 | 
							"RootURL": func() string {
 | 
				
			||||||
			return consts.AppBaseURL
 | 
								return consts.AppBaseURL
 | 
				
			||||||
@@ -365,6 +388,9 @@ func getTmplFuncs(consts *constants) template.FuncMap {
 | 
				
			|||||||
		"SiteName": func() string {
 | 
							"SiteName": func() string {
 | 
				
			||||||
			return consts.SiteName
 | 
								return consts.SiteName
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
 | 
							"L": func() interface{} {
 | 
				
			||||||
 | 
								return i18n
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -381,7 +407,10 @@ func reloadSettings(app *App) error {
 | 
				
			|||||||
		app.lo.Error("error unmarshalling settings from DB", "error", err)
 | 
							app.lo.Error("error unmarshalling settings from DB", "error", err)
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if err := ko.Load(confmap.Provider(out, "."), nil); err != nil {
 | 
						app.Lock()
 | 
				
			||||||
 | 
						err = ko.Load(confmap.Provider(out, "."), nil)
 | 
				
			||||||
 | 
						app.Unlock()
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
		app.lo.Error("error loading settings into koanf", "error", err)
 | 
							app.lo.Error("error loading settings into koanf", "error", err)
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -393,7 +422,7 @@ func reloadSettings(app *App) error {
 | 
				
			|||||||
// reloadTemplates reloads the templates from the filesystem.
 | 
					// reloadTemplates reloads the templates from the filesystem.
 | 
				
			||||||
func reloadTemplates(app *App) error {
 | 
					func reloadTemplates(app *App) error {
 | 
				
			||||||
	app.lo.Info("reloading templates")
 | 
						app.lo.Info("reloading templates")
 | 
				
			||||||
	funcMap := getTmplFuncs(app.consts.Load().(*constants))
 | 
						funcMap := getTmplFuncs(app.consts.Load().(*constants), app.i18n)
 | 
				
			||||||
	tpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/email-templates/*.html")
 | 
						tpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/email-templates/*.html")
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		app.lo.Error("error parsing email templates", "error", err)
 | 
							app.lo.Error("error parsing email templates", "error", err)
 | 
				
			||||||
@@ -451,6 +480,8 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager {
 | 
				
			|||||||
			UploadURI:  "/uploads",
 | 
								UploadURI:  "/uploads",
 | 
				
			||||||
			UploadPath: filepath.Clean(ko.String("upload.fs.upload_path")),
 | 
								UploadPath: filepath.Clean(ko.String("upload.fs.upload_path")),
 | 
				
			||||||
			RootURL:    appRootURL,
 | 
								RootURL:    appRootURL,
 | 
				
			||||||
 | 
								Expiry:     ko.Duration("upload.fs.expiry"),
 | 
				
			||||||
 | 
								Secret:     ko.String("upload.fs.secret"),
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			log.Fatalf("error initializing fs media store: %v", err)
 | 
								log.Fatalf("error initializing fs media store: %v", err)
 | 
				
			||||||
@@ -572,11 +603,41 @@ func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrS
 | 
				
			|||||||
	return inbox, nil
 | 
						return inbox, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// initLiveChatInbox initializes the live chat inbox.
 | 
				
			||||||
 | 
					func initLiveChatInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
 | 
				
			||||||
 | 
						var config livechat.Config
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Load JSON data into Koanf.
 | 
				
			||||||
 | 
						if err := ko.Load(rawbytes.Provider([]byte(inboxRecord.Config)), kjson.Parser()); err != nil {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("loading config: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := ko.UnmarshalWithConf("", &config, koanf.UnmarshalConf{Tag: "json"}); err != nil {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("unmarshalling `%s` %s config: %w", inboxRecord.Channel, inboxRecord.Name, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						inbox, err := livechat.New(msgStore, usrStore, livechat.Opts{
 | 
				
			||||||
 | 
							ID:     inboxRecord.ID,
 | 
				
			||||||
 | 
							Config: config,
 | 
				
			||||||
 | 
							Lo:     initLogger("livechat_inbox"),
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						log.Printf("`%s` inbox successfully initialized", inboxRecord.Name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return inbox, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// initializeInboxes handles inbox initialization.
 | 
					// initializeInboxes handles inbox initialization.
 | 
				
			||||||
func initializeInboxes(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
 | 
					func initializeInboxes(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
 | 
				
			||||||
	switch inboxR.Channel {
 | 
						switch inboxR.Channel {
 | 
				
			||||||
	case "email":
 | 
						case "email":
 | 
				
			||||||
		return initEmailInbox(inboxR, msgStore, usrStore)
 | 
							return initEmailInbox(inboxR, msgStore, usrStore)
 | 
				
			||||||
 | 
						case "livechat":
 | 
				
			||||||
 | 
							return initLiveChatInbox(inboxR, msgStore, usrStore)
 | 
				
			||||||
	default:
 | 
						default:
 | 
				
			||||||
		return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel)
 | 
							return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -894,3 +955,12 @@ func getLogLevel(lvl string) logf.Level {
 | 
				
			|||||||
		return logf.InfoLevel
 | 
							return logf.InfoLevel
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// initRateLimit initializes the rate limiter.
 | 
				
			||||||
 | 
					func initRateLimit(redisClient *redis.Client) *ratelimit.Limiter {
 | 
				
			||||||
 | 
						var config ratelimit.Config
 | 
				
			||||||
 | 
						if err := ko.UnmarshalWithConf("rate_limit", &config, koanf.UnmarshalConf{Tag: "toml"}); err != nil {
 | 
				
			||||||
 | 
							log.Fatalf("error unmarshalling rate limit config: %v", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return ratelimit.New(redisClient, config)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										16
									
								
								cmd/main.go
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								cmd/main.go
									
									
									
									
									
								
							@@ -35,6 +35,7 @@ import (
 | 
				
			|||||||
	"github.com/abhinavxd/libredesk/internal/inbox"
 | 
						"github.com/abhinavxd/libredesk/internal/inbox"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/media"
 | 
						"github.com/abhinavxd/libredesk/internal/media"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/oidc"
 | 
						"github.com/abhinavxd/libredesk/internal/oidc"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/ratelimit"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/role"
 | 
						"github.com/abhinavxd/libredesk/internal/role"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/setting"
 | 
						"github.com/abhinavxd/libredesk/internal/setting"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/tag"
 | 
						"github.com/abhinavxd/libredesk/internal/tag"
 | 
				
			||||||
@@ -54,7 +55,8 @@ var (
 | 
				
			|||||||
	ko          = koanf.New(".")
 | 
						ko          = koanf.New(".")
 | 
				
			||||||
	ctx         = context.Background()
 | 
						ctx         = context.Background()
 | 
				
			||||||
	appName     = "libredesk"
 | 
						appName     = "libredesk"
 | 
				
			||||||
	frontendDir = "frontend/dist"
 | 
						frontendDir = "frontend/dist/main"
 | 
				
			||||||
 | 
						widgetDir   = "frontend/dist/widget"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Injected at build time.
 | 
						// Injected at build time.
 | 
				
			||||||
	buildString   string
 | 
						buildString   string
 | 
				
			||||||
@@ -94,9 +96,12 @@ type App struct {
 | 
				
			|||||||
	customAttribute *customAttribute.Manager
 | 
						customAttribute *customAttribute.Manager
 | 
				
			||||||
	report          *report.Manager
 | 
						report          *report.Manager
 | 
				
			||||||
	webhook         *webhook.Manager
 | 
						webhook         *webhook.Manager
 | 
				
			||||||
 | 
						rateLimit       *ratelimit.Limiter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Global state that stores data on an available app update.
 | 
						// Global state that stores data on an available app update.
 | 
				
			||||||
	update *AppUpdate
 | 
						update *AppUpdate
 | 
				
			||||||
 | 
						// Flag to indicate if app restart is required for settings to take effect.
 | 
				
			||||||
 | 
						restartRequired bool
 | 
				
			||||||
	sync.Mutex
 | 
						sync.Mutex
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -201,14 +206,20 @@ func main() {
 | 
				
			|||||||
		sla                         = initSLA(db, team, settings, businessHours, notifier, template, user, i18n)
 | 
							sla                         = initSLA(db, team, settings, businessHours, notifier, template, user, i18n)
 | 
				
			||||||
		conversation                = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template, webhook)
 | 
							conversation                = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template, webhook)
 | 
				
			||||||
		autoassigner                = initAutoAssigner(team, user, conversation)
 | 
							autoassigner                = initAutoAssigner(team, user, conversation)
 | 
				
			||||||
 | 
							rateLimiter                 = initRateLimit(rdb)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						wsHub.SetConversationStore(conversation)
 | 
				
			||||||
	automation.SetConversationStore(conversation)
 | 
						automation.SetConversationStore(conversation)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Start inboxes.
 | 
				
			||||||
	startInboxes(ctx, inbox, conversation, user)
 | 
						startInboxes(ctx, inbox, conversation, user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	go automation.Run(ctx, automationWorkers)
 | 
						go automation.Run(ctx, automationWorkers)
 | 
				
			||||||
	go autoassigner.Run(ctx, autoAssignInterval)
 | 
						go autoassigner.Run(ctx, autoAssignInterval)
 | 
				
			||||||
	go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval)
 | 
						go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval)
 | 
				
			||||||
	go conversation.RunUnsnoozer(ctx, unsnoozeInterval)
 | 
						go conversation.RunUnsnoozer(ctx, unsnoozeInterval)
 | 
				
			||||||
 | 
						go conversation.RunContinuity(ctx)
 | 
				
			||||||
	go webhook.Run(ctx)
 | 
						go webhook.Run(ctx)
 | 
				
			||||||
	go notifier.Run(ctx)
 | 
						go notifier.Run(ctx)
 | 
				
			||||||
	go sla.Run(ctx, slaEvaluationInterval)
 | 
						go sla.Run(ctx, slaEvaluationInterval)
 | 
				
			||||||
@@ -239,7 +250,7 @@ func main() {
 | 
				
			|||||||
		activityLog:     initActivityLog(db, i18n),
 | 
							activityLog:     initActivityLog(db, i18n),
 | 
				
			||||||
		customAttribute: initCustomAttribute(db, i18n),
 | 
							customAttribute: initCustomAttribute(db, i18n),
 | 
				
			||||||
		authz:           initAuthz(i18n),
 | 
							authz:           initAuthz(i18n),
 | 
				
			||||||
		view:            initView(db),
 | 
							view:            initView(db, i18n),
 | 
				
			||||||
		report:          initReport(db, i18n),
 | 
							report:          initReport(db, i18n),
 | 
				
			||||||
		csat:            initCSAT(db, i18n),
 | 
							csat:            initCSAT(db, i18n),
 | 
				
			||||||
		search:          initSearch(db, i18n),
 | 
							search:          initSearch(db, i18n),
 | 
				
			||||||
@@ -248,6 +259,7 @@ func main() {
 | 
				
			|||||||
		macro:           initMacro(db, i18n),
 | 
							macro:           initMacro(db, i18n),
 | 
				
			||||||
		ai:              initAI(db, i18n),
 | 
							ai:              initAI(db, i18n),
 | 
				
			||||||
		webhook:         webhook,
 | 
							webhook:         webhook,
 | 
				
			||||||
 | 
							rateLimit:       rateLimiter,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	app.consts.Store(constants)
 | 
						app.consts.Store(constants)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										60
									
								
								cmd/media.go
									
									
									
									
									
								
							
							
						
						
									
										60
									
								
								cmd/media.go
									
									
									
									
									
								
							@@ -143,45 +143,51 @@ func handleMediaUpload(r *fastglue.Request) error {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleServeMedia serves uploaded media.
 | 
					// handleServeMedia serves uploaded media.
 | 
				
			||||||
 | 
					// Supports both authenticated agent access and unauthenticated access via signed URLs.
 | 
				
			||||||
func handleServeMedia(r *fastglue.Request) error {
 | 
					func handleServeMedia(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app  = r.Context.(*App)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							uuid = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
					 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	user, err := app.user.GetAgent(auser.ID, "")
 | 
						// Check if user is authenticated (agent access)
 | 
				
			||||||
	if err != nil {
 | 
						auser := r.RequestCtx.UserValue("user")
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
						if auser != nil {
 | 
				
			||||||
	}
 | 
							// Authenticated.
 | 
				
			||||||
 | 
							user, err := app.user.GetAgent(auser.(amodels.User).ID, "")
 | 
				
			||||||
	// Fetch media from DB.
 | 
					 | 
				
			||||||
	media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix))
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Check if the user has permission to access the linked model.
 | 
					 | 
				
			||||||
	allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// For messages, check access to the conversation this message is part of.
 | 
					 | 
				
			||||||
	if media.Model.String == "messages" {
 | 
					 | 
				
			||||||
		conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			return sendErrorEnvelope(r, err)
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		allowed, err = app.authz.EnforceConversationAccess(user, conversation)
 | 
					
 | 
				
			||||||
 | 
							// Fetch media from DB.
 | 
				
			||||||
 | 
							media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix))
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			return sendErrorEnvelope(r, err)
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if !allowed {
 | 
							// Check if the user has permission to access the linked model.
 | 
				
			||||||
		return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError)
 | 
							allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// For messages, check access to the conversation this message is part of.
 | 
				
			||||||
 | 
							if media.Model.String == "messages" {
 | 
				
			||||||
 | 
								conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int)
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								allowed, err = app.authz.EnforceConversationAccess(user, conversation)
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if !allowed {
 | 
				
			||||||
 | 
								return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						// If no authenticated user, the middleware has already verified the request signature serve the file.
 | 
				
			||||||
	consts := app.consts.Load().(*constants)
 | 
						consts := app.consts.Load().(*constants)
 | 
				
			||||||
	switch consts.UploadProvider {
 | 
						switch consts.UploadProvider {
 | 
				
			||||||
	case "fs":
 | 
						case "fs":
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,10 +2,14 @@ package main
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
 | 
						"strings"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
						amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
				
			||||||
 | 
						authzModels "github.com/abhinavxd/libredesk/internal/authz/models"
 | 
				
			||||||
 | 
						cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
						"github.com/abhinavxd/libredesk/internal/envelope"
 | 
				
			||||||
	medModels "github.com/abhinavxd/libredesk/internal/media/models"
 | 
						medModels "github.com/abhinavxd/libredesk/internal/media/models"
 | 
				
			||||||
 | 
						umodels "github.com/abhinavxd/libredesk/internal/user/models"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -17,6 +21,7 @@ type messageReq struct {
 | 
				
			|||||||
	To          []string `json:"to"`
 | 
						To          []string `json:"to"`
 | 
				
			||||||
	CC          []string `json:"cc"`
 | 
						CC          []string `json:"cc"`
 | 
				
			||||||
	BCC         []string `json:"bcc"`
 | 
						BCC         []string `json:"bcc"`
 | 
				
			||||||
 | 
						SenderType  string   `json:"sender_type"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleGetMessages returns messages for a conversation.
 | 
					// handleGetMessages returns messages for a conversation.
 | 
				
			||||||
@@ -41,7 +46,7 @@ func handleGetMessages(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	messages, pageSize, err := app.conversation.GetConversationMessages(uuid, page, pageSize)
 | 
						messages, pageSize, err := app.conversation.GetConversationMessages(uuid, []string{cmodels.MessageIncoming, cmodels.MessageOutgoing, cmodels.MessageActivity}, nil, page, pageSize)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -52,10 +57,11 @@ func handleGetMessages(r *fastglue.Request) error {
 | 
				
			|||||||
		for j := range messages[i].Attachments {
 | 
							for j := range messages[i].Attachments {
 | 
				
			||||||
			messages[i].Attachments[j].URL = app.media.GetURL(messages[i].Attachments[j].UUID)
 | 
								messages[i].Attachments[j].URL = app.media.GetURL(messages[i].Attachments[j].UUID)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		// Redact CSAT survey link
 | 
					 | 
				
			||||||
		messages[i].CensorCSATContent()
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Process CSAT status for all messages (will only affect CSAT messages)
 | 
				
			||||||
 | 
						app.conversation.ProcessCSATStatus(messages)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(envelope.PageResults{
 | 
						return r.SendEnvelope(envelope.PageResults{
 | 
				
			||||||
		Total:      total,
 | 
							Total:      total,
 | 
				
			||||||
		Results:    messages,
 | 
							Results:    messages,
 | 
				
			||||||
@@ -89,8 +95,10 @@ func handleGetMessage(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Redact CSAT survey link
 | 
						// Process CSAT status for the message (will only affect CSAT messages)
 | 
				
			||||||
	message.CensorCSATContent()
 | 
						messages := []cmodels.Message{message}
 | 
				
			||||||
 | 
						app.conversation.ProcessCSATStatus(messages)
 | 
				
			||||||
 | 
						message = messages[0]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	for j := range message.Attachments {
 | 
						for j := range message.Attachments {
 | 
				
			||||||
		message.Attachments[j].URL = app.media.GetURL(message.Attachments[j].UUID)
 | 
							message.Attachments[j].URL = app.media.GetURL(message.Attachments[j].UUID)
 | 
				
			||||||
@@ -99,7 +107,7 @@ func handleGetMessage(r *fastglue.Request) error {
 | 
				
			|||||||
	return r.SendEnvelope(message)
 | 
						return r.SendEnvelope(message)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleRetryMessage changes message status so it can be retried for sending.
 | 
					// handleRetryMessage changes message status to `pending`, so it's enqueued for sending.
 | 
				
			||||||
func handleRetryMessage(r *fastglue.Request) error {
 | 
					func handleRetryMessage(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
@@ -150,7 +158,41 @@ func handleSendMessage(r *fastglue.Request) error {
 | 
				
			|||||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Make sure the inbox is enabled.
 | 
				
			||||||
 | 
						inbox, err := app.inbox.GetDBRecord(conv.InboxID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if !inbox.Enabled {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Prepare attachments.
 | 
						// Prepare attachments.
 | 
				
			||||||
 | 
						if req.SenderType != umodels.UserTypeAgent && req.SenderType != umodels.UserTypeContact {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`sender_type`"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Contacts cannot send private messages
 | 
				
			||||||
 | 
						if req.SenderType == umodels.UserTypeContact && req.Private {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Check if user has permission to send messages as contact
 | 
				
			||||||
 | 
						if req.SenderType == umodels.UserTypeContact {
 | 
				
			||||||
 | 
							parts := strings.Split(authzModels.PermMessagesWriteAsContact, ":")
 | 
				
			||||||
 | 
							if len(parts) != 2 {
 | 
				
			||||||
 | 
								return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil))
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							ok, err := app.authz.Enforce(user, parts[0], parts[1])
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil))
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if !ok {
 | 
				
			||||||
 | 
								return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Get media for all attachments.
 | 
				
			||||||
	var media = make([]medModels.Media, 0, len(req.Attachments))
 | 
						var media = make([]medModels.Media, 0, len(req.Attachments))
 | 
				
			||||||
	for _, id := range req.Attachments {
 | 
						for _, id := range req.Attachments {
 | 
				
			||||||
		m, err := app.media.Get(id, "")
 | 
							m, err := app.media.Get(id, "")
 | 
				
			||||||
@@ -161,6 +203,16 @@ func handleSendMessage(r *fastglue.Request) error {
 | 
				
			|||||||
		media = append(media, m)
 | 
							media = append(media, m)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Create contact message.
 | 
				
			||||||
 | 
						if req.SenderType == umodels.UserTypeContact {
 | 
				
			||||||
 | 
							message, err := app.conversation.CreateContactMessage(media, int(conv.ContactID), cuuid, req.Message, cmodels.ContentTypeHTML)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return r.SendEnvelope(message)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Send private note.
 | 
				
			||||||
	if req.Private {
 | 
						if req.Private {
 | 
				
			||||||
		message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message)
 | 
							message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
@@ -168,7 +220,8 @@ func handleSendMessage(r *fastglue.Request) error {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
		return r.SendEnvelope(message)
 | 
							return r.SendEnvelope(message)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	message, err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
 | 
					
 | 
				
			||||||
 | 
						message, err := app.conversation.QueueReply(media, conv.InboxID, user.ID, conv.ContactID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -97,6 +97,23 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
 | 
				
			|||||||
	return func(r *fastglue.Request) error {
 | 
						return func(r *fastglue.Request) error {
 | 
				
			||||||
		var app = r.Context.(*App)
 | 
							var app = r.Context.(*App)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// For media uploads, check if signature is provided in the query parameters, if so, verify it.
 | 
				
			||||||
 | 
							path := string(r.RequestCtx.Path())
 | 
				
			||||||
 | 
							if strings.HasPrefix(path, "/uploads/") {
 | 
				
			||||||
 | 
								signature := string(r.RequestCtx.QueryArgs().Peek("signature"))
 | 
				
			||||||
 | 
								expires := string(r.RequestCtx.QueryArgs().Peek("expires"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if signature != "" && expires != "" {
 | 
				
			||||||
 | 
									if err := app.media.VerifySignature(r); err != nil {
 | 
				
			||||||
 | 
										app.lo.Error("error verifying media signature", "error",
 | 
				
			||||||
 | 
											err, "path", string(r.RequestCtx.Path()), "query", string(r.RequestCtx.QueryArgs().QueryString()))
 | 
				
			||||||
 | 
										return r.SendErrorEnvelope(http.StatusUnauthorized, "signature verification failed", nil, envelope.PermissionError)
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									return handler(r)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								// If no signature, continue with normal authentication.
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Authenticate user using shared authentication logic
 | 
							// Authenticate user using shared authentication logic
 | 
				
			||||||
		user, err := authenticateUser(r, app)
 | 
							user, err := authenticateUser(r, app)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										18
									
								
								cmd/oidc.go
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								cmd/oidc.go
									
									
									
									
									
								
							@@ -11,16 +11,6 @@ import (
 | 
				
			|||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleGetAllEnabledOIDC returns all enabled OIDC records
 | 
					 | 
				
			||||||
func handleGetAllEnabledOIDC(r *fastglue.Request) error {
 | 
					 | 
				
			||||||
	app := r.Context.(*App)
 | 
					 | 
				
			||||||
	out, err := app.oidc.GetAllEnabled()
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return r.SendEnvelope(out)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// handleGetAllOIDC returns all OIDC records
 | 
					// handleGetAllOIDC returns all OIDC records
 | 
				
			||||||
func handleGetAllOIDC(r *fastglue.Request) error {
 | 
					func handleGetAllOIDC(r *fastglue.Request) error {
 | 
				
			||||||
	app := r.Context.(*App)
 | 
						app := r.Context.(*App)
 | 
				
			||||||
@@ -74,10 +64,10 @@ func handleCreateOIDC(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := reloadAuth(app); err != nil {
 | 
						if err := reloadAuth(app); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	
 | 
					
 | 
				
			||||||
	// Clear client secret before returning
 | 
						// Clear client secret before returning
 | 
				
			||||||
	createdOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
 | 
						createdOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
 | 
				
			||||||
	
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(createdOIDC)
 | 
						return r.SendEnvelope(createdOIDC)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -110,10 +100,10 @@ func handleUpdateOIDC(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := reloadAuth(app); err != nil {
 | 
						if err := reloadAuth(app); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	
 | 
					
 | 
				
			||||||
	// Clear client secret before returning
 | 
						// Clear client secret before returning
 | 
				
			||||||
	updatedOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
 | 
						updatedOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
 | 
				
			||||||
	
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(updatedOIDC)
 | 
						return r.SendEnvelope(updatedOIDC)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -31,6 +31,8 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
 | 
				
			|||||||
	settings["app.update"] = app.update
 | 
						settings["app.update"] = app.update
 | 
				
			||||||
	// Set app version.
 | 
						// Set app version.
 | 
				
			||||||
	settings["app.version"] = versionString
 | 
						settings["app.version"] = versionString
 | 
				
			||||||
 | 
						// Set restart required flag.
 | 
				
			||||||
 | 
						settings["app.restart_required"] = app.restartRequired
 | 
				
			||||||
	return r.SendEnvelope(settings)
 | 
						return r.SendEnvelope(settings)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -45,6 +47,11 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
 | 
				
			|||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Get current language before update.
 | 
				
			||||||
 | 
						app.Lock()
 | 
				
			||||||
 | 
						oldLang := ko.String("app.lang")
 | 
				
			||||||
 | 
						app.Unlock()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Remove any trailing slash `/` from the root url.
 | 
						// Remove any trailing slash `/` from the root url.
 | 
				
			||||||
	req.RootURL = strings.TrimRight(req.RootURL, "/")
 | 
						req.RootURL = strings.TrimRight(req.RootURL, "/")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -55,6 +62,17 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := reloadSettings(app); err != nil {
 | 
						if err := reloadSettings(app); err != nil {
 | 
				
			||||||
		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
 | 
							return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Check if language changed and reload i18n if needed.
 | 
				
			||||||
 | 
						app.Lock()
 | 
				
			||||||
 | 
						newLang := ko.String("app.lang")
 | 
				
			||||||
 | 
						if oldLang != newLang {
 | 
				
			||||||
 | 
							app.lo.Info("language changed, reloading i18n", "old_lang", oldLang, "new_lang", newLang)
 | 
				
			||||||
 | 
							app.i18n = initI18n(app.fs)
 | 
				
			||||||
 | 
							app.lo.Info("reloaded i18n", "old_lang", oldLang, "new_lang", newLang)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						app.Unlock()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := reloadTemplates(app); err != nil {
 | 
						if err := reloadTemplates(app); err != nil {
 | 
				
			||||||
		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
 | 
							return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -109,6 +127,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
 | 
				
			|||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.invalidFromAddress"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.invalidFromAddress"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// If empty then retain previous password.
 | 
				
			||||||
	if req.Password == "" {
 | 
						if req.Password == "" {
 | 
				
			||||||
		req.Password = cur.Password
 | 
							req.Password = cur.Password
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -117,6 +136,10 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// No reload implemented, so user has to restart the app.
 | 
						// Email notification settings require app restart to take effect.
 | 
				
			||||||
 | 
						app.Lock()
 | 
				
			||||||
 | 
						app.restartRequired = true
 | 
				
			||||||
 | 
						app.Unlock()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -83,7 +83,7 @@ func handleUpdateTeam(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	updatedTeam, err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations);
 | 
						updatedTeam, err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -35,6 +35,8 @@ var migList = []migFunc{
 | 
				
			|||||||
	{"v0.5.0", migrations.V0_5_0},
 | 
						{"v0.5.0", migrations.V0_5_0},
 | 
				
			||||||
	{"v0.6.0", migrations.V0_6_0},
 | 
						{"v0.6.0", migrations.V0_6_0},
 | 
				
			||||||
	{"v0.7.0", migrations.V0_7_0},
 | 
						{"v0.7.0", migrations.V0_7_0},
 | 
				
			||||||
 | 
						{"v0.7.4", migrations.V0_7_4},
 | 
				
			||||||
 | 
						{"v0.9.0", migrations.V0_9_0},
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// upgrade upgrades the database to the current version by running SQL migration files
 | 
					// upgrade upgrades the database to the current version by running SQL migration files
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										222
									
								
								cmd/users.go
									
									
									
									
									
								
							
							
						
						
									
										222
									
								
								cmd/users.go
									
									
									
									
									
								
							@@ -26,34 +26,34 @@ const (
 | 
				
			|||||||
	maxAvatarSizeMB = 2
 | 
						maxAvatarSizeMB = 2
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Request structs for user-related endpoints
 | 
					type resetPasswordRequest struct {
 | 
				
			||||||
 | 
					 | 
				
			||||||
// UpdateAvailabilityRequest represents the request to update user availability
 | 
					 | 
				
			||||||
type UpdateAvailabilityRequest struct {
 | 
					 | 
				
			||||||
	Status string `json:"status"`
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// ResetPasswordRequest represents the password reset request
 | 
					 | 
				
			||||||
type ResetPasswordRequest struct {
 | 
					 | 
				
			||||||
	Email string `json:"email"`
 | 
						Email string `json:"email"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// SetPasswordRequest represents the set password request
 | 
					type setPasswordRequest struct {
 | 
				
			||||||
type SetPasswordRequest struct {
 | 
					 | 
				
			||||||
	Token    string `json:"token"`
 | 
						Token    string `json:"token"`
 | 
				
			||||||
	Password string `json:"password"`
 | 
						Password string `json:"password"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// AvailabilityRequest represents the request to update agent availability
 | 
					type availabilityRequest struct {
 | 
				
			||||||
type AvailabilityRequest struct {
 | 
					 | 
				
			||||||
	Status string `json:"status"`
 | 
						Status string `json:"status"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type agentReq struct {
 | 
				
			||||||
 | 
						FirstName          string   `json:"first_name"`
 | 
				
			||||||
 | 
						LastName           string   `json:"last_name"`
 | 
				
			||||||
 | 
						Email              string   `json:"email"`
 | 
				
			||||||
 | 
						SendWelcomeEmail   bool     `json:"send_welcome_email"`
 | 
				
			||||||
 | 
						Teams              []string `json:"teams"`
 | 
				
			||||||
 | 
						Roles              []string `json:"roles"`
 | 
				
			||||||
 | 
						Enabled            bool     `json:"enabled"`
 | 
				
			||||||
 | 
						AvailabilityStatus string   `json:"availability_status"`
 | 
				
			||||||
 | 
						NewPassword        string   `json:"new_password,omitempty"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleGetAgents returns all agents.
 | 
					// handleGetAgents returns all agents.
 | 
				
			||||||
func handleGetAgents(r *fastglue.Request) error {
 | 
					func handleGetAgents(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var app = r.Context.(*App)
 | 
				
			||||||
		app = r.Context.(*App)
 | 
					 | 
				
			||||||
	)
 | 
					 | 
				
			||||||
	agents, err := app.user.GetAgents()
 | 
						agents, err := app.user.GetAgents()
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
@@ -73,9 +73,7 @@ func handleGetAgentsCompact(r *fastglue.Request) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// handleGetAgent returns an agent.
 | 
					// handleGetAgent returns an agent.
 | 
				
			||||||
func handleGetAgent(r *fastglue.Request) error {
 | 
					func handleGetAgent(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var app = r.Context.(*App)
 | 
				
			||||||
		app = r.Context.(*App)
 | 
					 | 
				
			||||||
	)
 | 
					 | 
				
			||||||
	id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
						id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
	if err != nil || id <= 0 {
 | 
						if err != nil || id <= 0 {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
 | 
				
			||||||
@@ -93,7 +91,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
 | 
				
			|||||||
		app      = r.Context.(*App)
 | 
							app      = r.Context.(*App)
 | 
				
			||||||
		auser    = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser    = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		ip       = realip.FromRequest(r.RequestCtx)
 | 
							ip       = realip.FromRequest(r.RequestCtx)
 | 
				
			||||||
		availReq AvailabilityRequest
 | 
							availReq availabilityRequest
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Decode JSON request
 | 
						// Decode JSON request
 | 
				
			||||||
@@ -101,6 +99,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
 | 
				
			|||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Fetch entire agent
 | 
				
			||||||
	agent, err := app.user.GetAgent(auser.ID, "")
 | 
						agent, err := app.user.GetAgent(auser.ID, "")
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
@@ -108,10 +107,10 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Same status?
 | 
						// Same status?
 | 
				
			||||||
	if agent.AvailabilityStatus == availReq.Status {
 | 
						if agent.AvailabilityStatus == availReq.Status {
 | 
				
			||||||
		return r.SendEnvelope(true)
 | 
							return r.SendEnvelope(agent)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Update availability status.
 | 
						// Update availability status
 | 
				
			||||||
	if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil {
 | 
						if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -123,21 +122,22 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						// Fetch updated agent and return
 | 
				
			||||||
 | 
						agent, err = app.user.GetAgent(auser.ID, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(agent)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleGetCurrentAgentTeams returns the teams of an agent.
 | 
					// handleGetCurrentAgentTeams returns the teams of current agent.
 | 
				
			||||||
func handleGetCurrentAgentTeams(r *fastglue.Request) error {
 | 
					func handleGetCurrentAgentTeams(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	agent, err := app.user.GetAgent(auser.ID, "")
 | 
						teams, err := app.team.GetUserTeams(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	teams, err := app.team.GetUserTeams(agent.ID)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -150,11 +150,6 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
 | 
				
			|||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	agent, err := app.user.GetAgent(auser.ID, "")
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	form, err := r.RequestCtx.MultipartForm()
 | 
						form, err := r.RequestCtx.MultipartForm()
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		app.lo.Error("error parsing form data", "error", err)
 | 
							app.lo.Error("error parsing form data", "error", err)
 | 
				
			||||||
@@ -165,54 +160,53 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Upload avatar?
 | 
						// Upload avatar?
 | 
				
			||||||
	if ok && len(files) > 0 {
 | 
						if ok && len(files) > 0 {
 | 
				
			||||||
		if err := uploadUserAvatar(r, &agent, files); err != nil {
 | 
							agent, err := app.user.GetAgent(auser.ID, "")
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if err := uploadUserAvatar(r, agent, files); err != nil {
 | 
				
			||||||
			return sendErrorEnvelope(r, err)
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
					
 | 
				
			||||||
 | 
						// Fetch updated agent and return.
 | 
				
			||||||
 | 
						agent, err := app.user.GetAgent(auser.ID, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(agent)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleCreateAgent creates a new agent.
 | 
					// handleCreateAgent creates a new agent.
 | 
				
			||||||
func handleCreateAgent(r *fastglue.Request) error {
 | 
					func handleCreateAgent(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app  = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
		user = models.User{}
 | 
							req = agentReq{}
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	if err := r.Decode(&user, "json"); err != nil {
 | 
					
 | 
				
			||||||
 | 
						if err := r.Decode(&req, "json"); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if user.Email.String == "" {
 | 
						// Validate agent request
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
 | 
						if err := validateAgentRequest(r, &req); err != nil {
 | 
				
			||||||
	}
 | 
							return err
 | 
				
			||||||
	user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if !stringutil.ValidEmail(user.Email.String) {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if user.Roles == nil {
 | 
						agent, err := app.user.CreateAgent(req.FirstName, req.LastName, req.Email, req.Roles)
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
 | 
						if err != nil {
 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if user.FirstName == "" {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if err := app.user.CreateAgent(&user); err != nil {
 | 
					 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Upsert user teams.
 | 
						// Upsert user teams.
 | 
				
			||||||
	if len(user.Teams) > 0 {
 | 
						if len(req.Teams) > 0 {
 | 
				
			||||||
		if err := app.team.UpsertUserTeams(user.ID, user.Teams.Names()); err != nil {
 | 
							app.team.UpsertUserTeams(agent.ID, req.Teams)
 | 
				
			||||||
			return sendErrorEnvelope(r, err)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if user.SendWelcomeEmail {
 | 
						if req.SendWelcomeEmail {
 | 
				
			||||||
		// Generate reset token.
 | 
							// Generate reset token.
 | 
				
			||||||
		resetToken, err := app.user.SetResetPasswordToken(user.ID)
 | 
							resetToken, err := app.user.SetResetPasswordToken(agent.ID)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			return sendErrorEnvelope(r, err)
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
@@ -220,31 +214,36 @@ func handleCreateAgent(r *fastglue.Request) error {
 | 
				
			|||||||
		// Render template and send email.
 | 
							// Render template and send email.
 | 
				
			||||||
		content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
 | 
							content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
 | 
				
			||||||
			"ResetToken": resetToken,
 | 
								"ResetToken": resetToken,
 | 
				
			||||||
			"Email":      user.Email.String,
 | 
								"Email":      req.Email,
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			app.lo.Error("error rendering template", "error", err)
 | 
								app.lo.Error("error rendering template", "error", err)
 | 
				
			||||||
			return r.SendEnvelope(true)
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if err := app.notifier.Send(notifier.Message{
 | 
							if err := app.notifier.Send(notifier.Message{
 | 
				
			||||||
			RecipientEmails: []string{user.Email.String},
 | 
								RecipientEmails: []string{req.Email},
 | 
				
			||||||
			Subject:         "Welcome to Libredesk",
 | 
								Subject:         app.i18n.T("globals.messages.welcomeToLibredesk"),
 | 
				
			||||||
			Content:         content,
 | 
								Content:         content,
 | 
				
			||||||
			Provider:        notifier.ProviderEmail,
 | 
								Provider:        notifier.ProviderEmail,
 | 
				
			||||||
		}); err != nil {
 | 
							}); err != nil {
 | 
				
			||||||
			app.lo.Error("error sending notification message", "error", err)
 | 
								app.lo.Error("error sending notification message", "error", err)
 | 
				
			||||||
			return r.SendEnvelope(true)
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
					
 | 
				
			||||||
 | 
						// Refetch agent as other details might've changed.
 | 
				
			||||||
 | 
						agent, err = app.user.GetAgent(agent.ID, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(agent)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleUpdateAgent updates an agent.
 | 
					// handleUpdateAgent updates an agent.
 | 
				
			||||||
func handleUpdateAgent(r *fastglue.Request) error {
 | 
					func handleUpdateAgent(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		user  = models.User{}
 | 
							req   = agentReq{}
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		ip    = realip.FromRequest(r.RequestCtx)
 | 
							ip    = realip.FromRequest(r.RequestCtx)
 | 
				
			||||||
		id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
							id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
@@ -253,25 +252,13 @@ func handleUpdateAgent(r *fastglue.Request) error {
 | 
				
			|||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := r.Decode(&user, "json"); err != nil {
 | 
						if err := r.Decode(&req, "json"); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if user.Email.String == "" {
 | 
						// Validate agent request
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
 | 
						if err := validateAgentRequest(r, &req); err != nil {
 | 
				
			||||||
	}
 | 
							return err
 | 
				
			||||||
	user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if !stringutil.ValidEmail(user.Email.String) {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if user.Roles == nil {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if user.FirstName == "" {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	agent, err := app.user.GetAgent(id, "")
 | 
						agent, err := app.user.GetAgent(id, "")
 | 
				
			||||||
@@ -280,8 +267,8 @@ func handleUpdateAgent(r *fastglue.Request) error {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
	oldAvailabilityStatus := agent.AvailabilityStatus
 | 
						oldAvailabilityStatus := agent.AvailabilityStatus
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Update agent.
 | 
						// Update agent with individual fields
 | 
				
			||||||
	if err = app.user.UpdateAgent(id, user); err != nil {
 | 
						if err = app.user.UpdateAgent(id, req.FirstName, req.LastName, req.Email, req.Roles, req.Enabled, req.AvailabilityStatus, req.NewPassword); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -289,18 +276,24 @@ func handleUpdateAgent(r *fastglue.Request) error {
 | 
				
			|||||||
	defer app.authz.InvalidateUserCache(id)
 | 
						defer app.authz.InvalidateUserCache(id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Create activity log if user availability status changed.
 | 
						// Create activity log if user availability status changed.
 | 
				
			||||||
	if oldAvailabilityStatus != user.AvailabilityStatus {
 | 
						if oldAvailabilityStatus != req.AvailabilityStatus {
 | 
				
			||||||
		if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil {
 | 
							if err := app.activityLog.UserAvailability(auser.ID, auser.Email, req.AvailabilityStatus, ip, req.Email, id); err != nil {
 | 
				
			||||||
			app.lo.Error("error creating activity log", "error", err)
 | 
								app.lo.Error("error creating activity log", "error", err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Upsert agent teams.
 | 
						// Upsert agent teams.
 | 
				
			||||||
	if err := app.team.UpsertUserTeams(id, user.Teams.Names()); err != nil {
 | 
						if err := app.team.UpsertUserTeams(id, req.Teams); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						// Refetch agent and return.
 | 
				
			||||||
 | 
						agent, err = app.user.GetAgent(id, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(agent)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleDeleteAgent soft deletes an agent.
 | 
					// handleDeleteAgent soft deletes an agent.
 | 
				
			||||||
@@ -381,7 +374,7 @@ func handleResetPassword(r *fastglue.Request) error {
 | 
				
			|||||||
	var (
 | 
						var (
 | 
				
			||||||
		app       = r.Context.(*App)
 | 
							app       = r.Context.(*App)
 | 
				
			||||||
		auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		resetReq  ResetPasswordRequest
 | 
							resetReq  resetPasswordRequest
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	if ok && auser.ID > 0 {
 | 
						if ok && auser.ID > 0 {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
 | 
				
			||||||
@@ -399,7 +392,7 @@ func handleResetPassword(r *fastglue.Request) error {
 | 
				
			|||||||
	agent, err := app.user.GetAgent(0, resetReq.Email)
 | 
						agent, err := app.user.GetAgent(0, resetReq.Email)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		// Send 200 even if user not found, to prevent email enumeration.
 | 
							// Send 200 even if user not found, to prevent email enumeration.
 | 
				
			||||||
		return r.SendEnvelope("Reset password email sent successfully.")
 | 
							return r.SendEnvelope(true)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	token, err := app.user.SetResetPasswordToken(agent.ID)
 | 
						token, err := app.user.SetResetPasswordToken(agent.ID)
 | 
				
			||||||
@@ -434,7 +427,7 @@ func handleSetPassword(r *fastglue.Request) error {
 | 
				
			|||||||
	var (
 | 
						var (
 | 
				
			||||||
		app       = r.Context.(*App)
 | 
							app       = r.Context.(*App)
 | 
				
			||||||
		agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		req       = SetPasswordRequest{}
 | 
							req       setPasswordRequest
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if ok && agent.ID > 0 {
 | 
						if ok && agent.ID > 0 {
 | 
				
			||||||
@@ -457,13 +450,13 @@ func handleSetPassword(r *fastglue.Request) error {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// uploadUserAvatar uploads the user avatar.
 | 
					// uploadUserAvatar uploads the user avatar.
 | 
				
			||||||
func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart.FileHeader) error {
 | 
					func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart.FileHeader) error {
 | 
				
			||||||
	var app = r.Context.(*App)
 | 
						var app = r.Context.(*App)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	fileHeader := files[0]
 | 
						fileHeader := files[0]
 | 
				
			||||||
	file, err := fileHeader.Open()
 | 
						file, err := fileHeader.Open()
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		app.lo.Error("error opening uploaded file", "error", err)
 | 
							app.lo.Error("error opening uploaded file", "user_id", user.ID, "error", err)
 | 
				
			||||||
		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil)
 | 
							return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	defer file.Close()
 | 
						defer file.Close()
 | 
				
			||||||
@@ -480,7 +473,7 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Check file size
 | 
						// Check file size
 | 
				
			||||||
	if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB {
 | 
						if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB {
 | 
				
			||||||
		app.lo.Error("error uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
 | 
							app.lo.Error("error uploaded file size is larger than max allowed", "user_id", user.ID, "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
 | 
				
			||||||
		return envelope.NewError(
 | 
							return envelope.NewError(
 | 
				
			||||||
			envelope.InputError,
 | 
								envelope.InputError,
 | 
				
			||||||
			app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)),
 | 
								app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)),
 | 
				
			||||||
@@ -497,23 +490,25 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
 | 
				
			|||||||
	meta := []byte("{}")
 | 
						meta := []byte("{}")
 | 
				
			||||||
	media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
 | 
						media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		app.lo.Error("error uploading file", "error", err)
 | 
							app.lo.Error("error uploading file", "user_id", user.ID, "error", err)
 | 
				
			||||||
		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
 | 
							return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Delete current avatar.
 | 
						// Delete current avatar.
 | 
				
			||||||
	if user.AvatarURL.Valid {
 | 
						if user.AvatarURL.Valid {
 | 
				
			||||||
		fileName := filepath.Base(user.AvatarURL.String)
 | 
							fileName := filepath.Base(user.AvatarURL.String)
 | 
				
			||||||
		app.media.Delete(fileName)
 | 
							if err := app.media.Delete(fileName); err != nil {
 | 
				
			||||||
 | 
								app.lo.Error("error deleting user avatar", "user_id", user.ID, "error", err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Save file path.
 | 
						// Save file path.
 | 
				
			||||||
	path, err := stringutil.GetPathFromURL(media.URL)
 | 
						path, err := stringutil.GetPathFromURL(media.URL)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		app.lo.Debug("error getting path from URL", "url", media.URL, "error", err)
 | 
							app.lo.Debug("error getting path from URL", "user_id", user.ID, "url", media.URL, "error", err)
 | 
				
			||||||
		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
 | 
							return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	fmt.Println("path", path)
 | 
					
 | 
				
			||||||
	if err := app.user.UpdateAvatar(user.ID, path); err != nil {
 | 
						if err := app.user.UpdateAvatar(user.ID, path); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -577,3 +572,28 @@ func handleRevokeAPIKey(r *fastglue.Request) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// validateAgentRequest validates common agent request fields and normalizes the email
 | 
				
			||||||
 | 
					func validateAgentRequest(r *fastglue.Request, req *agentReq) error {
 | 
				
			||||||
 | 
						var app = r.Context.(*App)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Normalize email
 | 
				
			||||||
 | 
						req.Email = strings.TrimSpace(strings.ToLower(req.Email))
 | 
				
			||||||
 | 
						if req.Email == "" {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if !stringutil.ValidEmail(req.Email) {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if req.Roles == nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if req.FirstName == "" {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										167
									
								
								cmd/widget_middleware.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								cmd/widget_middleware.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,167 @@
 | 
				
			|||||||
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
						"strconv"
 | 
				
			||||||
 | 
						"strings"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/envelope"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
 | 
				
			||||||
 | 
						imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
 | 
				
			||||||
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const (
 | 
				
			||||||
 | 
						// Context keys for storing authenticated widget data
 | 
				
			||||||
 | 
						ctxWidgetClaims    = "widget_claims"
 | 
				
			||||||
 | 
						ctxWidgetInboxID   = "widget_inbox_id"
 | 
				
			||||||
 | 
						ctxWidgetContactID = "widget_contact_id"
 | 
				
			||||||
 | 
						ctxWidgetInbox     = "widget_inbox"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Header sent in every widget request to identify the inbox
 | 
				
			||||||
 | 
						hdrWidgetInboxID = "X-Libredesk-Inbox-ID"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// widgetAuth middleware authenticates widget requests using JWT and inbox validation.
 | 
				
			||||||
 | 
					// It always validates the inbox from X-Libredesk-Inbox-ID header, and conditionally validates JWT.
 | 
				
			||||||
 | 
					// For /conversations/init without JWT, it allows visitor creation while still validating inbox.
 | 
				
			||||||
 | 
					func widgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) error {
 | 
				
			||||||
 | 
						return func(r *fastglue.Request) error {
 | 
				
			||||||
 | 
							var (
 | 
				
			||||||
 | 
								app = r.Context.(*App)
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Always extract and validate inbox_id from custom header
 | 
				
			||||||
 | 
							inboxIDHeader := string(r.RequestCtx.Request.Header.Peek(hdrWidgetInboxID))
 | 
				
			||||||
 | 
							if inboxIDHeader == "" {
 | 
				
			||||||
 | 
								return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							inboxID, err := strconv.Atoi(inboxIDHeader)
 | 
				
			||||||
 | 
							if err != nil || inboxID <= 0 {
 | 
				
			||||||
 | 
								return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Always fetch and validate inbox
 | 
				
			||||||
 | 
							inbox, err := app.inbox.GetDBRecord(inboxID)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								app.lo.Error("error fetching inbox", "inbox_id", inboxID, "error", err)
 | 
				
			||||||
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if !inbox.Enabled {
 | 
				
			||||||
 | 
								return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Check if inbox is the correct type for widget requests
 | 
				
			||||||
 | 
							if inbox.Channel != livechat.ChannelLiveChat {
 | 
				
			||||||
 | 
								return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Always store inbox data in context
 | 
				
			||||||
 | 
							r.RequestCtx.SetUserValue(ctxWidgetInboxID, inboxID)
 | 
				
			||||||
 | 
							r.RequestCtx.SetUserValue(ctxWidgetInbox, inbox)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Extract JWT from Authorization header (Bearer token)
 | 
				
			||||||
 | 
							authHeader := string(r.RequestCtx.Request.Header.Peek("Authorization"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// For init endpoint, allow requests without JWT (visitor creation)
 | 
				
			||||||
 | 
							if authHeader == "" && strings.Contains(string(r.RequestCtx.Path()), "/conversations/init") {
 | 
				
			||||||
 | 
								return next(r)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// For all other requests, require JWT
 | 
				
			||||||
 | 
							if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
 | 
				
			||||||
 | 
								return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							jwtToken := strings.TrimPrefix(authHeader, "Bearer ")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Verify JWT using inbox secret
 | 
				
			||||||
 | 
							claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								app.lo.Error("invalid JWT", "jwt", jwtToken, "error", err)
 | 
				
			||||||
 | 
								return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Resolve user/contact ID from JWT claims
 | 
				
			||||||
 | 
							contactID, err := resolveUserIDFromClaims(app, claims)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								envErr, ok := err.(envelope.Error)
 | 
				
			||||||
 | 
								if ok && envErr.ErrorType != envelope.NotFoundError {
 | 
				
			||||||
 | 
									app.lo.Error("error resolving user ID from JWT claims", "error", err)
 | 
				
			||||||
 | 
									return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Store authenticated data in request context for downstream handlers
 | 
				
			||||||
 | 
							r.RequestCtx.SetUserValue(ctxWidgetClaims, claims)
 | 
				
			||||||
 | 
							r.RequestCtx.SetUserValue(ctxWidgetContactID, contactID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							return next(r)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Helper functions to extract authenticated data from request context
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// getWidgetInboxID extracts inbox ID from request context
 | 
				
			||||||
 | 
					func getWidgetInboxID(r *fastglue.Request) (int, error) {
 | 
				
			||||||
 | 
						val := r.RequestCtx.UserValue(ctxWidgetInboxID)
 | 
				
			||||||
 | 
						if val == nil {
 | 
				
			||||||
 | 
							return 0, fmt.Errorf("widget middleware not applied: missing inbox ID in context")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						inboxID, ok := val.(int)
 | 
				
			||||||
 | 
						if !ok {
 | 
				
			||||||
 | 
							return 0, fmt.Errorf("invalid inbox ID type in context")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return inboxID, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// getWidgetContactID extracts contact ID from request context
 | 
				
			||||||
 | 
					func getWidgetContactID(r *fastglue.Request) (int, error) {
 | 
				
			||||||
 | 
						val := r.RequestCtx.UserValue(ctxWidgetContactID)
 | 
				
			||||||
 | 
						if val == nil {
 | 
				
			||||||
 | 
							return 0, fmt.Errorf("widget middleware not applied: missing contact ID in context")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						contactID, ok := val.(int)
 | 
				
			||||||
 | 
						if !ok {
 | 
				
			||||||
 | 
							return 0, fmt.Errorf("invalid contact ID type in context")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return contactID, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// getWidgetInbox extracts inbox model from request context
 | 
				
			||||||
 | 
					func getWidgetInbox(r *fastglue.Request) (imodels.Inbox, error) {
 | 
				
			||||||
 | 
						val := r.RequestCtx.UserValue(ctxWidgetInbox)
 | 
				
			||||||
 | 
						if val == nil {
 | 
				
			||||||
 | 
							return imodels.Inbox{}, fmt.Errorf("widget middleware not applied: missing inbox in context")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						inbox, ok := val.(imodels.Inbox)
 | 
				
			||||||
 | 
						if !ok {
 | 
				
			||||||
 | 
							return imodels.Inbox{}, fmt.Errorf("invalid inbox type in context")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return inbox, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// getWidgetClaimsOptional extracts JWT claims from request context, returns nil if not set
 | 
				
			||||||
 | 
					func getWidgetClaimsOptional(r *fastglue.Request) *Claims {
 | 
				
			||||||
 | 
						val := r.RequestCtx.UserValue(ctxWidgetClaims)
 | 
				
			||||||
 | 
						if val == nil {
 | 
				
			||||||
 | 
							return nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if claims, ok := val.(Claims); ok {
 | 
				
			||||||
 | 
							return &claims
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// rateLimitWidget applies rate limiting to widget endpoints.
 | 
				
			||||||
 | 
					func rateLimitWidget(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
 | 
				
			||||||
 | 
						return func(r *fastglue.Request) error {
 | 
				
			||||||
 | 
							app := r.Context.(*App)
 | 
				
			||||||
 | 
							if err := app.rateLimit.CheckWidgetLimit(r.RequestCtx); err != nil {
 | 
				
			||||||
 | 
								return err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return handler(r)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										288
									
								
								cmd/widget_ws.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										288
									
								
								cmd/widget_ws.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,288 @@
 | 
				
			|||||||
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"encoding/json"
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
 | 
				
			||||||
 | 
						"github.com/fasthttp/websocket"
 | 
				
			||||||
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Widget WebSocket message types
 | 
				
			||||||
 | 
					const (
 | 
				
			||||||
 | 
						WidgetMsgTypeJoin    = "join"
 | 
				
			||||||
 | 
						WidgetMsgTypeMessage = "message"
 | 
				
			||||||
 | 
						WidgetMsgTypeTyping  = "typing"
 | 
				
			||||||
 | 
						WidgetMsgTypePing    = "ping"
 | 
				
			||||||
 | 
						WidgetMsgTypePong    = "pong"
 | 
				
			||||||
 | 
						WidgetMsgTypeError   = "error"
 | 
				
			||||||
 | 
						WidgetMsgTypeNewMsg  = "new_message"
 | 
				
			||||||
 | 
						WidgetMsgTypeStatus  = "status"
 | 
				
			||||||
 | 
						WidgetMsgTypeJoined  = "joined"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// WidgetMessage represents a message sent through the widget WebSocket
 | 
				
			||||||
 | 
					type WidgetMessage struct {
 | 
				
			||||||
 | 
						Type string `json:"type"`
 | 
				
			||||||
 | 
						JWT  string `json:"jwt,omitempty"`
 | 
				
			||||||
 | 
						Data any    `json:"data"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type WidgetInboxJoinRequest struct {
 | 
				
			||||||
 | 
						InboxID int `json:"inbox_id"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// WidgetMessageData represents a chat message through the widget
 | 
				
			||||||
 | 
					type WidgetMessageData struct {
 | 
				
			||||||
 | 
						ConversationUUID string `json:"conversation_uuid"`
 | 
				
			||||||
 | 
						Content          string `json:"content"`
 | 
				
			||||||
 | 
						SenderName       string `json:"sender_name,omitempty"`
 | 
				
			||||||
 | 
						SenderType       string `json:"sender_type"`
 | 
				
			||||||
 | 
						Timestamp        int64  `json:"timestamp"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// WidgetTypingData represents typing indicator data
 | 
				
			||||||
 | 
					type WidgetTypingData struct {
 | 
				
			||||||
 | 
						ConversationUUID string `json:"conversation_uuid"`
 | 
				
			||||||
 | 
						IsTyping         bool   `json:"is_typing"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleWidgetWS handles the widget WebSocket connection for live chat.
 | 
				
			||||||
 | 
					func handleWidgetWS(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var app = r.Context.(*App)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) {
 | 
				
			||||||
 | 
							// To store client and live chat references for cleanup.
 | 
				
			||||||
 | 
							var client *livechat.Client
 | 
				
			||||||
 | 
							var liveChat *livechat.LiveChat
 | 
				
			||||||
 | 
							var inboxID int
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Clean up client when connection closes.
 | 
				
			||||||
 | 
							defer func() {
 | 
				
			||||||
 | 
								conn.Close()
 | 
				
			||||||
 | 
								if client != nil && liveChat != nil {
 | 
				
			||||||
 | 
									liveChat.RemoveClient(client)
 | 
				
			||||||
 | 
									close(client.Channel)
 | 
				
			||||||
 | 
									app.lo.Debug("cleaned up client on websocket disconnect", "client_id", client.ID)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Read messages from the WebSocket connection.
 | 
				
			||||||
 | 
							for {
 | 
				
			||||||
 | 
								var msg WidgetMessage
 | 
				
			||||||
 | 
								if err := conn.ReadJSON(&msg); err != nil {
 | 
				
			||||||
 | 
									app.lo.Debug("widget websocket connection closed", "error", err)
 | 
				
			||||||
 | 
									break
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								switch msg.Type {
 | 
				
			||||||
 | 
								// Inbox join request.
 | 
				
			||||||
 | 
								case WidgetMsgTypeJoin:
 | 
				
			||||||
 | 
									var joinedClient *livechat.Client
 | 
				
			||||||
 | 
									var joinedLiveChat *livechat.LiveChat
 | 
				
			||||||
 | 
									var joinedInboxID int
 | 
				
			||||||
 | 
									var err error
 | 
				
			||||||
 | 
									if joinedClient, joinedLiveChat, joinedInboxID, err = handleInboxJoin(app, conn, &msg); err != nil {
 | 
				
			||||||
 | 
										app.lo.Error("error handling widget join", "error", err)
 | 
				
			||||||
 | 
										sendWidgetError(conn, "Failed to join conversation")
 | 
				
			||||||
 | 
										continue
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									// Store the client, livechat, and inbox ID for cleanup and future use.
 | 
				
			||||||
 | 
									client = joinedClient
 | 
				
			||||||
 | 
									liveChat = joinedLiveChat
 | 
				
			||||||
 | 
									inboxID = joinedInboxID
 | 
				
			||||||
 | 
								// Typing.
 | 
				
			||||||
 | 
								case WidgetMsgTypeTyping:
 | 
				
			||||||
 | 
									if err := handleWidgetTyping(app, &msg); err != nil {
 | 
				
			||||||
 | 
										app.lo.Error("error handling widget typing", "error", err)
 | 
				
			||||||
 | 
										continue
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								// Ping.
 | 
				
			||||||
 | 
								case WidgetMsgTypePing:
 | 
				
			||||||
 | 
									// Update user's last active timestamp if JWT is provided and client has joined
 | 
				
			||||||
 | 
									if msg.JWT != "" && inboxID != 0 {
 | 
				
			||||||
 | 
										if claims, err := validateWidgetMessageJWT(app, msg.JWT, inboxID); err == nil {
 | 
				
			||||||
 | 
											if userID, err := resolveUserIDFromClaims(app, claims); err == nil {
 | 
				
			||||||
 | 
												if err := app.user.UpdateLastActive(userID); err != nil {
 | 
				
			||||||
 | 
													app.lo.Error("error updating user last active timestamp", "user_id", userID, "error", err)
 | 
				
			||||||
 | 
												} else {
 | 
				
			||||||
 | 
													app.lo.Debug("updated user last active timestamp", "user_id", userID)
 | 
				
			||||||
 | 
												}
 | 
				
			||||||
 | 
											}
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									if err := conn.WriteJSON(WidgetMessage{
 | 
				
			||||||
 | 
										Type: WidgetMsgTypePong,
 | 
				
			||||||
 | 
									}); err != nil {
 | 
				
			||||||
 | 
										app.lo.Error("error writing pong to widget client", "error", err)
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}); err != nil {
 | 
				
			||||||
 | 
							app.lo.Error("error upgrading widget websocket connection", "error", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleInboxJoin handles a websocket join request for a live chat inbox.
 | 
				
			||||||
 | 
					func handleInboxJoin(app *App, conn *websocket.Conn, msg *WidgetMessage) (*livechat.Client, *livechat.LiveChat, int, error) {
 | 
				
			||||||
 | 
						joinDataBytes, err := json.Marshal(msg.Data)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, nil, 0, fmt.Errorf("invalid join data: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var joinData WidgetInboxJoinRequest
 | 
				
			||||||
 | 
						if err := json.Unmarshal(joinDataBytes, &joinData); err != nil {
 | 
				
			||||||
 | 
							return nil, nil, 0, fmt.Errorf("invalid join data format: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Validate JWT with inbox secret
 | 
				
			||||||
 | 
						claims, err := validateWidgetMessageJWT(app, msg.JWT, joinData.InboxID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, nil, 0, fmt.Errorf("JWT validation failed: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Resolve user ID.
 | 
				
			||||||
 | 
						userID, err := resolveUserIDFromClaims(app, claims)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, nil, 0, fmt.Errorf("failed to resolve user ID from claims: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Make sure inbox is active.
 | 
				
			||||||
 | 
						inbox, err := app.inbox.GetDBRecord(joinData.InboxID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, nil, 0, fmt.Errorf("inbox not found: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if !inbox.Enabled {
 | 
				
			||||||
 | 
							return nil, nil, 0, fmt.Errorf("inbox is not enabled")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Get live chat inbox
 | 
				
			||||||
 | 
						lcInbox, err := app.inbox.Get(inbox.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, nil, 0, fmt.Errorf("live chat inbox not found: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Assert type.
 | 
				
			||||||
 | 
						liveChat, ok := lcInbox.(*livechat.LiveChat)
 | 
				
			||||||
 | 
						if !ok {
 | 
				
			||||||
 | 
							return nil, nil, 0, fmt.Errorf("inbox is not a live chat inbox")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Add client to live chat session
 | 
				
			||||||
 | 
						userIDStr := fmt.Sprintf("%d", userID)
 | 
				
			||||||
 | 
						client, err := liveChat.AddClient(userIDStr)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							app.lo.Error("error adding client to live chat", "error", err, "user_id", userIDStr)
 | 
				
			||||||
 | 
							return nil, nil, 0, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Start listening for messages from the live chat channel.
 | 
				
			||||||
 | 
						go func() {
 | 
				
			||||||
 | 
							for msgData := range client.Channel {
 | 
				
			||||||
 | 
								if err := conn.WriteMessage(websocket.TextMessage, msgData); err != nil {
 | 
				
			||||||
 | 
									app.lo.Error("error forwarding message to widget client", "error", err)
 | 
				
			||||||
 | 
									return
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Send join confirmation
 | 
				
			||||||
 | 
						joinResp := WidgetMessage{
 | 
				
			||||||
 | 
							Type: WidgetMsgTypeJoined,
 | 
				
			||||||
 | 
							Data: map[string]string{
 | 
				
			||||||
 | 
								"message": "namaste!",
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := conn.WriteJSON(joinResp); err != nil {
 | 
				
			||||||
 | 
							return nil, nil, 0, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						app.lo.Debug("widget client joined live chat", "user_id", userIDStr, "inbox_id", joinData.InboxID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return client, liveChat, joinData.InboxID, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleWidgetTyping handles typing indicators
 | 
				
			||||||
 | 
					func handleWidgetTyping(app *App, msg *WidgetMessage) error {
 | 
				
			||||||
 | 
						typingDataBytes, err := json.Marshal(msg.Data)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							app.lo.Error("error marshalling typing data", "error", err)
 | 
				
			||||||
 | 
							return fmt.Errorf("invalid typing data: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var typingData WidgetTypingData
 | 
				
			||||||
 | 
						if err := json.Unmarshal(typingDataBytes, &typingData); err != nil {
 | 
				
			||||||
 | 
							app.lo.Error("error unmarshalling typing data", "error", err)
 | 
				
			||||||
 | 
							return fmt.Errorf("invalid typing data format: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Get conversation to retrieve inbox ID for JWT validation
 | 
				
			||||||
 | 
						if typingData.ConversationUUID == "" {
 | 
				
			||||||
 | 
							return fmt.Errorf("conversation UUID is required for typing messages")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						conversation, err := app.conversation.GetConversation(0, typingData.ConversationUUID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							app.lo.Error("error fetching conversation for typing", "conversation_uuid", typingData.ConversationUUID, "error", err)
 | 
				
			||||||
 | 
							return fmt.Errorf("conversation not found: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Validate JWT with inbox secret
 | 
				
			||||||
 | 
						claims, err := validateWidgetMessageJWT(app, msg.JWT, conversation.InboxID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return fmt.Errorf("JWT validation failed: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						userID := claims.UserID
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Broadcast typing status to agents via conversation manager
 | 
				
			||||||
 | 
						// Set broadcastToWidgets=false to avoid echoing back to widget clients
 | 
				
			||||||
 | 
						app.conversation.BroadcastTypingToConversation(typingData.ConversationUUID, typingData.IsTyping, false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						app.lo.Debug("Broadcasted typing data from widget user to agents", "user_id", userID, "is_typing", typingData.IsTyping, "conversation_uuid", typingData.ConversationUUID)
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// validateWidgetMessageJWT validates the incoming widget message JWT using inbox secret
 | 
				
			||||||
 | 
					func validateWidgetMessageJWT(app *App, jwtToken string, inboxID int) (Claims, error) {
 | 
				
			||||||
 | 
						if jwtToken == "" {
 | 
				
			||||||
 | 
							return Claims{}, fmt.Errorf("JWT token is empty")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if inboxID <= 0 {
 | 
				
			||||||
 | 
							return Claims{}, fmt.Errorf("inbox ID is required for JWT validation")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Get inbox to retrieve secret for JWT verification
 | 
				
			||||||
 | 
						inbox, err := app.inbox.GetDBRecord(inboxID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return Claims{}, fmt.Errorf("inbox not found: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if !inbox.Secret.Valid {
 | 
				
			||||||
 | 
							return Claims{}, fmt.Errorf("inbox secret not configured for JWT verification")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Use the existing verifyStandardJWT function which properly validates with inbox secret
 | 
				
			||||||
 | 
						claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return Claims{}, fmt.Errorf("JWT validation failed: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return claims, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// sendWidgetError sends an error message to the widget client
 | 
				
			||||||
 | 
					func sendWidgetError(conn *websocket.Conn, message string) {
 | 
				
			||||||
 | 
						errorMsg := WidgetMessage{
 | 
				
			||||||
 | 
							Type: WidgetMsgTypeError,
 | 
				
			||||||
 | 
							Data: map[string]string{
 | 
				
			||||||
 | 
								"message": message,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						conn.WriteJSON(errorMsg)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -119,6 +119,17 @@ timeout = "15s"
 | 
				
			|||||||
# How often to check for conversations to unsnooze
 | 
					# How often to check for conversations to unsnooze
 | 
				
			||||||
unsnooze_interval = "5m"
 | 
					unsnooze_interval = "5m"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[conversation.continuity]
 | 
				
			||||||
 | 
					offline_threshold = "10m"
 | 
				
			||||||
 | 
					batch_check_interval = "5m"
 | 
				
			||||||
 | 
					max_messages_per_email = 10
 | 
				
			||||||
 | 
					min_email_interval = "15m"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[sla]
 | 
					[sla]
 | 
				
			||||||
# How often to evaluate SLA compliance for conversations
 | 
					# How often to evaluate SLA compliance for conversations
 | 
				
			||||||
evaluation_interval = "5m"
 | 
					evaluation_interval = "5m"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[rate_limit]
 | 
				
			||||||
 | 
					  [rate_limit.widget]
 | 
				
			||||||
 | 
					    enabled = true
 | 
				
			||||||
 | 
					    requests_per_minute = 100
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
					 | 
				
			||||||
							
								
								
									
										59
									
								
								frontend/README-SETUP.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								frontend/README-SETUP.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,59 @@
 | 
				
			|||||||
 | 
					# Libredesk Frontend - Multi-App Setup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This frontend supports both the main Libredesk application and a chat widget as separate Vue applications sharing common UI components.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Project Structure
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					frontend/
 | 
				
			||||||
 | 
					├── apps/
 | 
				
			||||||
 | 
					│   ├── main/          # Main Libredesk application
 | 
				
			||||||
 | 
					│   │   ├── src/
 | 
				
			||||||
 | 
					│   │   └── index.html
 | 
				
			||||||
 | 
					│   └── widget/        # Chat widget application
 | 
				
			||||||
 | 
					│       ├── src/
 | 
				
			||||||
 | 
					│       └── index.html
 | 
				
			||||||
 | 
					├── shared-ui/         # Shared UI components (shadcn/ui)
 | 
				
			||||||
 | 
					│   ├── components/
 | 
				
			||||||
 | 
					│   │   └── ui/        # shadcn/ui components
 | 
				
			||||||
 | 
					│   ├── lib/           # Utility functions
 | 
				
			||||||
 | 
					│   └── assets/        # Shared styles
 | 
				
			||||||
 | 
					└── package.json
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Development
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Check Makefile for available commands.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Shared UI Components
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The `shared-ui` directory contains all the shadcn/ui components that can be used in both apps.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Using Shared Components
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```vue
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { Button } from '@shared-ui/components/ui/button'
 | 
				
			||||||
 | 
					import { Card, CardContent, CardHeader, CardTitle } from '@shared-ui/components/ui/card'
 | 
				
			||||||
 | 
					import { Input } from '@shared-ui/components/ui/input'
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <Card>
 | 
				
			||||||
 | 
					    <CardHeader>
 | 
				
			||||||
 | 
					      <CardTitle>Example Card</CardTitle>
 | 
				
			||||||
 | 
					    </CardHeader>
 | 
				
			||||||
 | 
					    <CardContent>
 | 
				
			||||||
 | 
					      <Input placeholder="Type something..." />
 | 
				
			||||||
 | 
					      <Button>Submit</Button>
 | 
				
			||||||
 | 
					    </CardContent>
 | 
				
			||||||
 | 
					  </Card>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Path Aliases
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `@shared-ui` - Points to the shared-ui directory
 | 
				
			||||||
 | 
					- `@main` - Points to apps/main/src
 | 
				
			||||||
 | 
					- `@widget` - Points to apps/widget/src
 | 
				
			||||||
 | 
					- `@` - Points to the current app's src directory (context-dependent)
 | 
				
			||||||
@@ -88,8 +88,8 @@
 | 
				
			|||||||
        @create-conversation="() => (openCreateConversationDialog = true)"
 | 
					        @create-conversation="() => (openCreateConversationDialog = true)"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <div class="flex flex-col h-screen">
 | 
					        <div class="flex flex-col h-screen">
 | 
				
			||||||
          <!-- Show app update only in admin routes -->
 | 
					          <!-- Show admin banner only in admin routes -->
 | 
				
			||||||
          <AppUpdate v-if="route.path.startsWith('/admin')" />
 | 
					          <AdminBanner v-if="route.path.startsWith('/admin')" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <!-- Common header for all pages -->
 | 
					          <!-- Common header for all pages -->
 | 
				
			||||||
          <PageHeader />
 | 
					          <PageHeader />
 | 
				
			||||||
@@ -112,26 +112,25 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { onMounted, ref } from 'vue'
 | 
					import { onMounted, ref } from 'vue'
 | 
				
			||||||
import { RouterView } from 'vue-router'
 | 
					import { RouterView } from 'vue-router'
 | 
				
			||||||
import { useUserStore } from '@/stores/user'
 | 
					import { useUserStore } from './stores/user'
 | 
				
			||||||
import { initWS } from '@/websocket.js'
 | 
					import { initWS } from './websocket.js'
 | 
				
			||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from './constants/emitterEvents.js'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from './composables/useEmitter'
 | 
				
			||||||
import { handleHTTPError } from '@/utils/http'
 | 
					import { handleHTTPError } from './utils/http'
 | 
				
			||||||
import { useConversationStore } from './stores/conversation'
 | 
					import { useConversationStore } from './stores/conversation'
 | 
				
			||||||
import { useInboxStore } from '@/stores/inbox'
 | 
					import { useInboxStore } from './stores/inbox'
 | 
				
			||||||
import { useUsersStore } from '@/stores/users'
 | 
					import { useUsersStore } from './stores/users'
 | 
				
			||||||
import { useTeamStore } from '@/stores/team'
 | 
					import { useTeamStore } from './stores/team'
 | 
				
			||||||
import { useSlaStore } from '@/stores/sla'
 | 
					import { useSlaStore } from './stores/sla'
 | 
				
			||||||
import { useMacroStore } from '@/stores/macro'
 | 
					import { useMacroStore } from './stores/macro'
 | 
				
			||||||
import { useTagStore } from '@/stores/tag'
 | 
					import { useTagStore } from './stores/tag'
 | 
				
			||||||
import { useCustomAttributeStore } from '@/stores/customAttributes'
 | 
					import { useCustomAttributeStore } from './stores/customAttributes'
 | 
				
			||||||
import { useIdleDetection } from '@/composables/useIdleDetection'
 | 
					import { useIdleDetection } from './composables/useIdleDetection'
 | 
				
			||||||
import PageHeader from './components/layout/PageHeader.vue'
 | 
					import PageHeader from './components/layout/PageHeader.vue'
 | 
				
			||||||
import ViewForm from '@/features/view/ViewForm.vue'
 | 
					import ViewForm from '@/features/view/ViewForm.vue'
 | 
				
			||||||
import AppUpdate from '@/components/update/AppUpdate.vue'
 | 
					import AdminBanner from '@/components/banner/AdminBanner.vue'
 | 
				
			||||||
import api from '@/api'
 | 
					 | 
				
			||||||
import { toast as sooner } from 'vue-sonner'
 | 
					import { toast as sooner } from 'vue-sonner'
 | 
				
			||||||
import Sidebar from '@/components/sidebar/Sidebar.vue'
 | 
					import Sidebar from '@main/components/sidebar/Sidebar.vue'
 | 
				
			||||||
import Command from '@/features/command/CommandBox.vue'
 | 
					import Command from '@/features/command/CommandBox.vue'
 | 
				
			||||||
import CreateConversation from '@/features/conversation/CreateConversation.vue'
 | 
					import CreateConversation from '@/features/conversation/CreateConversation.vue'
 | 
				
			||||||
import { Inbox, Shield, FileLineChart, BookUser } from 'lucide-vue-next'
 | 
					import { Inbox, Shield, FileLineChart, BookUser } from 'lucide-vue-next'
 | 
				
			||||||
@@ -147,9 +146,10 @@ import {
 | 
				
			|||||||
  SidebarMenuButton,
 | 
					  SidebarMenuButton,
 | 
				
			||||||
  SidebarMenuItem,
 | 
					  SidebarMenuItem,
 | 
				
			||||||
  SidebarProvider
 | 
					  SidebarProvider
 | 
				
			||||||
} from '@/components/ui/sidebar'
 | 
					} from '@shared-ui/components/ui/sidebar'
 | 
				
			||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
 | 
					import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
 | 
				
			||||||
import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue'
 | 
					import SidebarNavUser from '@main/components/sidebar/SidebarNavUser.vue'
 | 
				
			||||||
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const route = useRoute()
 | 
					const route = useRoute()
 | 
				
			||||||
const emitter = useEmitter()
 | 
					const emitter = useEmitter()
 | 
				
			||||||
@@ -5,8 +5,8 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { onMounted } from 'vue'
 | 
					import { onMounted } from 'vue'
 | 
				
			||||||
import { RouterView } from 'vue-router'
 | 
					import { RouterView } from 'vue-router'
 | 
				
			||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from './constants/emitterEvents.js'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from './composables/useEmitter'
 | 
				
			||||||
import { toast as sooner } from 'vue-sonner'
 | 
					import { toast as sooner } from 'vue-sonner'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emitter = useEmitter()
 | 
					const emitter = useEmitter()
 | 
				
			||||||
@@ -7,6 +7,6 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { RouterView } from 'vue-router'
 | 
					import { RouterView } from 'vue-router'
 | 
				
			||||||
import { Toaster } from '@/components/ui/sonner'
 | 
					import { Toaster } from '@shared-ui/components/ui/sonner'
 | 
				
			||||||
import { TooltipProvider } from '@/components/ui/tooltip'
 | 
					import { TooltipProvider } from '@shared-ui/components/ui/tooltip'
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
@@ -122,7 +122,7 @@ const createOIDC = (data) =>
 | 
				
			|||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled')
 | 
					const getConfig = () => http.get('/api/v1/config')
 | 
				
			||||||
const getAllOIDC = () => http.get('/api/v1/oidc')
 | 
					const getAllOIDC = () => http.get('/api/v1/oidc')
 | 
				
			||||||
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
 | 
					const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
 | 
				
			||||||
const updateOIDC = (id, data) =>
 | 
					const updateOIDC = (id, data) =>
 | 
				
			||||||
@@ -514,7 +514,7 @@ export default {
 | 
				
			|||||||
  updateSettings,
 | 
					  updateSettings,
 | 
				
			||||||
  createOIDC,
 | 
					  createOIDC,
 | 
				
			||||||
  getAllOIDC,
 | 
					  getAllOIDC,
 | 
				
			||||||
  getAllEnabledOIDC,
 | 
					  getConfig,
 | 
				
			||||||
  getOIDC,
 | 
					  getOIDC,
 | 
				
			||||||
  updateOIDC,
 | 
					  updateOIDC,
 | 
				
			||||||
  deleteOIDC,
 | 
					  deleteOIDC,
 | 
				
			||||||
							
								
								
									
										63
									
								
								frontend/apps/main/src/components/banner/AdminBanner.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								frontend/apps/main/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>
 | 
					<template>
 | 
				
			||||||
  <Button
 | 
					  <Button
 | 
				
			||||||
    variant="ghost"
 | 
					    variant="ghost"
 | 
				
			||||||
    @click.prevent="onClose"
 | 
					    @click.stop="onClose"
 | 
				
			||||||
    size="xs"
 | 
					    size="xs"
 | 
				
			||||||
    class="text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 w-6 h-6 p-0"
 | 
					    class="text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 w-6 h-6 p-0"
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
@@ -12,7 +12,7 @@
 | 
				
			|||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button'
 | 
				
			||||||
import { X } from 'lucide-vue-next'
 | 
					import { X } from 'lucide-vue-next'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
defineProps({
 | 
					defineProps({
 | 
				
			||||||
							
								
								
									
										73
									
								
								frontend/apps/main/src/components/button/CopyButton.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								frontend/apps/main/src/components/button/CopyButton.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,73 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <Button :variant="variant" :size="size" type="button" @click="handleCopy" :class="buttonClass">
 | 
				
			||||||
 | 
					    <Copy v-if="!copied" class="w-4 h-4" />
 | 
				
			||||||
 | 
					    <Check v-else class="w-4 h-4 text-green-500" />
 | 
				
			||||||
 | 
					    <span v-if="showText">
 | 
				
			||||||
 | 
					      {{ copied ? copiedText : copyText }}
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
 | 
					  </Button>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { ref, computed } from 'vue'
 | 
				
			||||||
 | 
					import { Button } from '@shared-ui/components/ui/button'
 | 
				
			||||||
 | 
					import { Copy, Check } from 'lucide-vue-next'
 | 
				
			||||||
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps({
 | 
				
			||||||
 | 
					  textToCopy: {
 | 
				
			||||||
 | 
					    type: String,
 | 
				
			||||||
 | 
					    required: true
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  variant: {
 | 
				
			||||||
 | 
					    type: String,
 | 
				
			||||||
 | 
					    default: 'secondary'
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  size: {
 | 
				
			||||||
 | 
					    type: String,
 | 
				
			||||||
 | 
					    default: 'sm'
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  showText: {
 | 
				
			||||||
 | 
					    type: Boolean,
 | 
				
			||||||
 | 
					    default: true
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  copyText: {
 | 
				
			||||||
 | 
					    type: String,
 | 
				
			||||||
 | 
					    default: null
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  copiedText: {
 | 
				
			||||||
 | 
					    type: String,
 | 
				
			||||||
 | 
					    default: null
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  resetDelay: {
 | 
				
			||||||
 | 
					    type: Number,
 | 
				
			||||||
 | 
					    default: 2000
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  class: {
 | 
				
			||||||
 | 
					    type: String,
 | 
				
			||||||
 | 
					    default: ''
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const { t } = useI18n()
 | 
				
			||||||
 | 
					const copied = ref(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const buttonClass = computed(() => props.class)
 | 
				
			||||||
 | 
					const copyText = computed(() => props.copyText || t('globals.terms.copy'))
 | 
				
			||||||
 | 
					const copiedText = computed(() => props.copiedText || t('globals.terms.copied'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleCopy = async () => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    await navigator.clipboard.writeText(props.textToCopy)
 | 
				
			||||||
 | 
					    copied.value = true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (props.resetDelay > 0) {
 | 
				
			||||||
 | 
					      setTimeout(() => {
 | 
				
			||||||
 | 
					        copied.value = false
 | 
				
			||||||
 | 
					      }, props.resetDelay)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  } catch (err) {
 | 
				
			||||||
 | 
					    console.error('Failed to copy:', err)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
@@ -42,8 +42,8 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { computed } from 'vue'
 | 
					import { computed } from 'vue'
 | 
				
			||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
 | 
					import { Avatar, AvatarImage, AvatarFallback } from '@shared-ui/components/ui/avatar'
 | 
				
			||||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
 | 
					import ComboBox from '@shared-ui/components/ui/combobox/ComboBox.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  modelValue: [String, Number, Object],
 | 
					  modelValue: [String, Number, Object],
 | 
				
			||||||
@@ -51,7 +51,7 @@ import {
 | 
				
			|||||||
  TableHead,
 | 
					  TableHead,
 | 
				
			||||||
  TableHeader,
 | 
					  TableHeader,
 | 
				
			||||||
  TableRow
 | 
					  TableRow
 | 
				
			||||||
} from '@/components/ui/table'
 | 
					} from '@shared-ui/components/ui/table'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const { t } = useI18n()
 | 
					const { t } = useI18n()
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
@@ -1,18 +1,20 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
    <div ref="codeEditor" @click="editorView?.focus()" class="w-full h-[28rem] border rounded-md" />
 | 
					    <div ref="codeEditor" @click="editorView?.focus()" :class="readOnly ? 'w-full border rounded-md' : 'w-full h-[28rem] border rounded-md'" />
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref, onMounted, watch, nextTick, useTemplateRef } from 'vue'
 | 
					import { ref, onMounted, watch, nextTick, useTemplateRef } from 'vue'
 | 
				
			||||||
import { EditorView, basicSetup } from 'codemirror'
 | 
					import { EditorView, basicSetup } from 'codemirror'
 | 
				
			||||||
import { html } from '@codemirror/lang-html'
 | 
					import { html } from '@codemirror/lang-html'
 | 
				
			||||||
 | 
					import { javascript } from '@codemirror/lang-javascript'
 | 
				
			||||||
import { oneDark } from '@codemirror/theme-one-dark'
 | 
					import { oneDark } from '@codemirror/theme-one-dark'
 | 
				
			||||||
import { useColorMode } from '@vueuse/core'
 | 
					import { useColorMode } from '@vueuse/core'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
    modelValue: { type: String, default: '' },
 | 
					    modelValue: { type: String, default: '' },
 | 
				
			||||||
    language: { type: String, default: 'html' },
 | 
					    language: { type: String, default: 'html' },
 | 
				
			||||||
    disabled: Boolean
 | 
					    disabled: Boolean,
 | 
				
			||||||
 | 
					    readOnly: Boolean
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emit = defineEmits(['update:modelValue'])
 | 
					const emit = defineEmits(['update:modelValue'])
 | 
				
			||||||
@@ -22,33 +24,37 @@ const codeEditor = useTemplateRef('codeEditor')
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const initCodeEditor = (body) => {
 | 
					const initCodeEditor = (body) => {
 | 
				
			||||||
    const isDark = useColorMode().value === 'dark'
 | 
					    const isDark = useColorMode().value === 'dark'
 | 
				
			||||||
 | 
					    const langExtension = props.language === 'javascript' ? javascript() : html()
 | 
				
			||||||
 | 
					    const isEditable = !props.disabled && !props.readOnly
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    editorView = new EditorView({
 | 
					    editorView = new EditorView({
 | 
				
			||||||
        doc: body,
 | 
					        doc: body,
 | 
				
			||||||
        extensions: [
 | 
					        extensions: [
 | 
				
			||||||
            basicSetup,
 | 
					            basicSetup,
 | 
				
			||||||
            html(),
 | 
					            langExtension,
 | 
				
			||||||
            ...(isDark ? [oneDark] : []),
 | 
					            ...(isDark ? [oneDark] : []),
 | 
				
			||||||
            EditorView.editable.of(!props.disabled),
 | 
					            EditorView.editable.of(isEditable),
 | 
				
			||||||
            EditorView.theme({
 | 
					            EditorView.theme({
 | 
				
			||||||
                '&': { height: '100%' },
 | 
					                '&': { height: props.readOnly ? 'auto' : '100%' },
 | 
				
			||||||
                '.cm-editor': { height: '100%' },
 | 
					                '.cm-editor': { height: props.readOnly ? 'auto' : '100%' },
 | 
				
			||||||
                '.cm-scroller': { overflow: 'auto' }
 | 
					                '.cm-scroller': { overflow: 'auto' }
 | 
				
			||||||
            }),
 | 
					            }),
 | 
				
			||||||
            EditorView.updateListener.of((update) => {
 | 
					            EditorView.updateListener.of((update) => {
 | 
				
			||||||
                if (!update.docChanged) return
 | 
					                if (!update.docChanged || props.readOnly) return
 | 
				
			||||||
                const v = update.state.doc.toString()
 | 
					                const v = update.state.doc.toString()
 | 
				
			||||||
                emit('update:modelValue', v)
 | 
					                emit('update:modelValue', v)
 | 
				
			||||||
                data.value = v
 | 
					                data.value = v
 | 
				
			||||||
                
 | 
					
 | 
				
			||||||
            })
 | 
					            })
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
        parent: codeEditor.value
 | 
					        parent: codeEditor.value
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    nextTick(() => {
 | 
					    if (!props.readOnly) {
 | 
				
			||||||
        editorView?.focus()
 | 
					        nextTick(() => {
 | 
				
			||||||
    })
 | 
					            editorView?.focus()
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
onMounted(() => {
 | 
					onMounted(() => {
 | 
				
			||||||
@@ -102,14 +102,14 @@ import {
 | 
				
			|||||||
  Check,
 | 
					  Check,
 | 
				
			||||||
  X
 | 
					  X
 | 
				
			||||||
} from 'lucide-vue-next'
 | 
					} from 'lucide-vue-next'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  DropdownMenu,
 | 
					  DropdownMenu,
 | 
				
			||||||
  DropdownMenuContent,
 | 
					  DropdownMenuContent,
 | 
				
			||||||
  DropdownMenuItem,
 | 
					  DropdownMenuItem,
 | 
				
			||||||
  DropdownMenuTrigger
 | 
					  DropdownMenuTrigger
 | 
				
			||||||
} from '@/components/ui/dropdown-menu'
 | 
					} from '@shared-ui/components/ui/dropdown-menu'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@shared-ui/components/ui/input'
 | 
				
			||||||
import Placeholder from '@tiptap/extension-placeholder'
 | 
					import Placeholder from '@tiptap/extension-placeholder'
 | 
				
			||||||
import Image from '@tiptap/extension-image'
 | 
					import Image from '@tiptap/extension-image'
 | 
				
			||||||
import StarterKit from '@tiptap/starter-kit'
 | 
					import StarterKit from '@tiptap/starter-kit'
 | 
				
			||||||
@@ -118,6 +118,8 @@ import Table from '@tiptap/extension-table'
 | 
				
			|||||||
import TableRow from '@tiptap/extension-table-row'
 | 
					import TableRow from '@tiptap/extension-table-row'
 | 
				
			||||||
import TableCell from '@tiptap/extension-table-cell'
 | 
					import TableCell from '@tiptap/extension-table-cell'
 | 
				
			||||||
import TableHeader from '@tiptap/extension-table-header'
 | 
					import TableHeader from '@tiptap/extension-table-header'
 | 
				
			||||||
 | 
					import { useTypingIndicator } from '@shared-ui/composables'
 | 
				
			||||||
 | 
					import { useConversationStore } from '@main/stores/conversation'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const textContent = defineModel('textContent', { default: '' })
 | 
					const textContent = defineModel('textContent', { default: '' })
 | 
				
			||||||
const htmlContent = defineModel('htmlContent', { default: '' })
 | 
					const htmlContent = defineModel('htmlContent', { default: '' })
 | 
				
			||||||
@@ -127,6 +129,7 @@ const linkUrl = ref('')
 | 
				
			|||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  placeholder: String,
 | 
					  placeholder: String,
 | 
				
			||||||
  insertContent: String,
 | 
					  insertContent: String,
 | 
				
			||||||
 | 
					  messageType: String,
 | 
				
			||||||
  autoFocus: {
 | 
					  autoFocus: {
 | 
				
			||||||
    type: Boolean,
 | 
					    type: Boolean,
 | 
				
			||||||
    default: true
 | 
					    default: true
 | 
				
			||||||
@@ -134,13 +137,19 @@ const props = defineProps({
 | 
				
			|||||||
  aiPrompts: {
 | 
					  aiPrompts: {
 | 
				
			||||||
    type: Array,
 | 
					    type: Array,
 | 
				
			||||||
    default: () => []
 | 
					    default: () => []
 | 
				
			||||||
  }
 | 
					  },
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emit = defineEmits(['send', 'aiPromptSelected'])
 | 
					const emit = defineEmits(['send', 'aiPromptSelected'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emitPrompt = (key) => emit('aiPromptSelected', key)
 | 
					const emitPrompt = (key) => emit('aiPromptSelected', key)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Set up typing indicator
 | 
				
			||||||
 | 
					const conversationStore = useConversationStore()
 | 
				
			||||||
 | 
					const { startTyping, stopTyping } = useTypingIndicator(conversationStore.sendTyping, {
 | 
				
			||||||
 | 
					  get isPrivateMessage() { return props.messageType === 'private_note' }
 | 
				
			||||||
 | 
					}) 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// To preseve the table styling in emails, need to set the table style inline.
 | 
					// To preseve the table styling in emails, need to set the table style inline.
 | 
				
			||||||
// Created these custom extensions to set the table style inline.
 | 
					// Created these custom extensions to set the table style inline.
 | 
				
			||||||
const CustomTable = Table.extend({
 | 
					const CustomTable = Table.extend({
 | 
				
			||||||
@@ -201,6 +210,8 @@ const editor = useEditor({
 | 
				
			|||||||
    handleKeyDown: (view, event) => {
 | 
					    handleKeyDown: (view, event) => {
 | 
				
			||||||
      if (event.ctrlKey && event.key === 'Enter') {
 | 
					      if (event.ctrlKey && event.key === 'Enter') {
 | 
				
			||||||
        emit('send')
 | 
					        emit('send')
 | 
				
			||||||
 | 
					        // Stop typing when sending
 | 
				
			||||||
 | 
					        stopTyping()
 | 
				
			||||||
        return true
 | 
					        return true
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -211,6 +222,13 @@ const editor = useEditor({
 | 
				
			|||||||
    htmlContent.value = editor.getHTML()
 | 
					    htmlContent.value = editor.getHTML()
 | 
				
			||||||
    textContent.value = editor.getText()
 | 
					    textContent.value = editor.getText()
 | 
				
			||||||
    isInternalUpdate.value = false
 | 
					    isInternalUpdate.value = false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Trigger typing indicator when user types
 | 
				
			||||||
 | 
					    startTyping()
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  onBlur: () => {
 | 
				
			||||||
 | 
					    // Stop typing when editor loses focus
 | 
				
			||||||
 | 
					    stopTyping()
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -52,8 +52,15 @@
 | 
				
			|||||||
        <div class="flex-1">
 | 
					        <div class="flex-1">
 | 
				
			||||||
          <div v-if="modelFilter.field && modelFilter.operator">
 | 
					          <div v-if="modelFilter.field && modelFilter.operator">
 | 
				
			||||||
            <template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
 | 
					            <template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
 | 
				
			||||||
 | 
					              <SelectTag
 | 
				
			||||||
 | 
					                v-if="getFieldType(modelFilter) === FIELD_TYPE.MULTI_SELECT"
 | 
				
			||||||
 | 
					                v-model="modelFilter.value"
 | 
				
			||||||
 | 
					                :items="getFieldOptions(modelFilter)"
 | 
				
			||||||
 | 
					                :placeholder="t('globals.messages.select', { name: t('globals.terms.tag', 2) })"
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              <SelectComboBox
 | 
					              <SelectComboBox
 | 
				
			||||||
                v-if="
 | 
					                v-else-if="
 | 
				
			||||||
                  getFieldOptions(modelFilter).length > 0 &&
 | 
					                  getFieldOptions(modelFilter).length > 0 &&
 | 
				
			||||||
                  modelFilter.field === 'assigned_user_id'
 | 
					                  modelFilter.field === 'assigned_user_id'
 | 
				
			||||||
                "
 | 
					                "
 | 
				
			||||||
@@ -94,8 +101,9 @@
 | 
				
			|||||||
      <CloseButton :onClose="() => removeFilter(index)" />
 | 
					      <CloseButton :onClose="() => removeFilter(index)" />
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Button Container -->
 | 
				
			||||||
    <div class="flex items-center justify-between pt-3">
 | 
					    <div class="flex items-center justify-between pt-3">
 | 
				
			||||||
      <Button variant="ghost" size="sm" @click="addFilter" class="text-slate-600">
 | 
					      <Button variant="ghost" size="sm" @click.stop="addFilter" class="text-slate-600">
 | 
				
			||||||
        <Plus class="w-3 h-3 mr-1" />
 | 
					        <Plus class="w-3 h-3 mr-1" />
 | 
				
			||||||
        {{
 | 
					        {{
 | 
				
			||||||
          $t('globals.messages.add', {
 | 
					          $t('globals.messages.add', {
 | 
				
			||||||
@@ -104,15 +112,17 @@
 | 
				
			|||||||
        }}
 | 
					        }}
 | 
				
			||||||
      </Button>
 | 
					      </Button>
 | 
				
			||||||
      <div class="flex gap-2" v-if="showButtons">
 | 
					      <div class="flex gap-2" v-if="showButtons">
 | 
				
			||||||
        <Button variant="ghost" @click="clearFilters">{{ $t('globals.messages.reset') }}</Button>
 | 
					        <Button variant="ghost" @click.stop="clearFilters">
 | 
				
			||||||
        <Button @click="applyFilters">{{ $t('globals.messages.apply') }}</Button>
 | 
					          {{ $t('globals.messages.reset') }}
 | 
				
			||||||
 | 
					        </Button>
 | 
				
			||||||
 | 
					        <Button @click.stop="applyFilters">{{ $t('globals.messages.apply') }}</Button>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { computed, onMounted, watch } from 'vue'
 | 
					import { computed, onMounted, onUnmounted, watch } from 'vue'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
@@ -120,13 +130,15 @@ import {
 | 
				
			|||||||
  SelectItem,
 | 
					  SelectItem,
 | 
				
			||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@shared-ui/components/ui/select'
 | 
				
			||||||
import { Plus } from 'lucide-vue-next'
 | 
					import { Plus } from 'lucide-vue-next'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@shared-ui/components/ui/input'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
 | 
					import { FIELD_TYPE } from '@/constants/filterConfig'
 | 
				
			||||||
import CloseButton from '@/components/button/CloseButton.vue'
 | 
					import CloseButton from '@/components/button/CloseButton.vue'
 | 
				
			||||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
					import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
				
			||||||
 | 
					import SelectTag from '@shared-ui/components/ui/select/SelectTag.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  fields: {
 | 
					  fields: {
 | 
				
			||||||
@@ -150,12 +162,17 @@ onMounted(() => {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onUnmounted(() => {
 | 
				
			||||||
 | 
					  // On unmounted set valid filters
 | 
				
			||||||
 | 
					  modelValue.value = validFilters.value
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getModel = (field) => {
 | 
					const getModel = (field) => {
 | 
				
			||||||
  const fieldConfig = props.fields.find((f) => f.field === field)
 | 
					  const fieldConfig = props.fields.find((f) => f.field === field)
 | 
				
			||||||
  return fieldConfig?.model || ''
 | 
					  return fieldConfig?.model || ''
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Set model for each filter
 | 
					// Set model for each filter and the default value
 | 
				
			||||||
watch(
 | 
					watch(
 | 
				
			||||||
  () => modelValue.value,
 | 
					  () => modelValue.value,
 | 
				
			||||||
  (filters) => {
 | 
					  (filters) => {
 | 
				
			||||||
@@ -163,6 +180,15 @@ watch(
 | 
				
			|||||||
      if (filter.field && !filter.model) {
 | 
					      if (filter.field && !filter.model) {
 | 
				
			||||||
        filter.model = getModel(filter.field)
 | 
					        filter.model = getModel(filter.field)
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Multi select need arrays as their default value
 | 
				
			||||||
 | 
					      if (
 | 
				
			||||||
 | 
					        filter.field &&
 | 
				
			||||||
 | 
					        getFieldType(filter) === FIELD_TYPE.MULTI_SELECT &&
 | 
				
			||||||
 | 
					        !Array.isArray(filter.value)
 | 
				
			||||||
 | 
					      ) {
 | 
				
			||||||
 | 
					        filter.value = []
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  { deep: true }
 | 
					  { deep: true }
 | 
				
			||||||
@@ -170,15 +196,20 @@ watch(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// Reset operator and value when field changes for a filter at a given index
 | 
					// Reset operator and value when field changes for a filter at a given index
 | 
				
			||||||
watch(
 | 
					watch(
 | 
				
			||||||
  () => modelValue.value.map((f) => f.field),
 | 
					  modelValue,
 | 
				
			||||||
  (newFields, oldFields) => {
 | 
					  (newFilters, oldFilters) => {
 | 
				
			||||||
    newFields.forEach((field, index) => {
 | 
					    // Skip first run
 | 
				
			||||||
      if (field !== oldFields[index]) {
 | 
					    if (!oldFilters) return
 | 
				
			||||||
        modelValue.value[index].operator = ''
 | 
					
 | 
				
			||||||
        modelValue.value[index].value = ''
 | 
					    newFilters.forEach((filter, index) => {
 | 
				
			||||||
 | 
					      const oldFilter = oldFilters[index]
 | 
				
			||||||
 | 
					      if (oldFilter && filter.field !== oldFilter.field) {
 | 
				
			||||||
 | 
					        filter.operator = ''
 | 
				
			||||||
 | 
					        filter.value = ''
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  }
 | 
					  },
 | 
				
			||||||
 | 
					  { deep: true }
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const addFilter = () => {
 | 
					const addFilter = () => {
 | 
				
			||||||
@@ -197,7 +228,17 @@ const clearFilters = () => {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const validFilters = computed(() => {
 | 
					const validFilters = computed(() => {
 | 
				
			||||||
  return modelValue.value.filter((filter) => filter.field && filter.operator && filter.value)
 | 
					  return modelValue.value.filter((filter) => {
 | 
				
			||||||
 | 
					    // For multi-select field type, allow empty array as a valid value
 | 
				
			||||||
 | 
					    const field = props.fields.find((f) => f.field === filter.field)
 | 
				
			||||||
 | 
					    const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (isMultiSelectField) {
 | 
				
			||||||
 | 
					      return filter.field && filter.operator && filter.value !== undefined && filter.value !== null
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return filter.field && filter.operator && filter.value
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getFieldOptions = (fieldValue) => {
 | 
					const getFieldOptions = (fieldValue) => {
 | 
				
			||||||
@@ -209,4 +250,9 @@ const getFieldOperators = (modelFilter) => {
 | 
				
			|||||||
  const field = props.fields.find((f) => f.field === modelFilter.field)
 | 
					  const field = props.fields.find((f) => f.field === modelFilter.field)
 | 
				
			||||||
  return field?.operators || []
 | 
					  return field?.operators || []
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getFieldType = (modelFilter) => {
 | 
				
			||||||
 | 
					  const field = props.fields.find((f) => f.field === modelFilter.field)
 | 
				
			||||||
 | 
					  return field?.type || ''
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
@@ -4,9 +4,9 @@
 | 
				
			|||||||
    @click="handleClick">
 | 
					    @click="handleClick">
 | 
				
			||||||
    <div class="flex items-center mb-2">
 | 
					    <div class="flex items-center mb-2">
 | 
				
			||||||
      <component :is="icon" size="24" class="mr-2 text-primary" />
 | 
					      <component :is="icon" size="24" class="mr-2 text-primary" />
 | 
				
			||||||
      <h3 class="text-lg font-medium text-gray-800">{{ title }}</h3>
 | 
					      <h3 class="text-lg font-medium">{{ title }}</h3>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <p class="text-sm text-gray-600">{{ subTitle }}</p>
 | 
					    <p class="text-sm text-gray-600 dark:text-gray-400">{{ subTitle }}</p>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -12,8 +12,8 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { computed } from 'vue'
 | 
					import { computed } from 'vue'
 | 
				
			||||||
import { Separator } from '@/components/ui/separator'
 | 
					import { Separator } from '@shared-ui/components/ui/separator'
 | 
				
			||||||
import { SidebarTrigger } from '@/components/ui/sidebar'
 | 
					import { SidebarTrigger } from '@shared-ui/components/ui/sidebar'
 | 
				
			||||||
import { useRoute } from 'vue-router'
 | 
					import { useRoute } from 'vue-router'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const route = useRoute()
 | 
					const route = useRoute()
 | 
				
			||||||
@@ -4,9 +4,9 @@ import {
 | 
				
			|||||||
  reportsNavItems,
 | 
					  reportsNavItems,
 | 
				
			||||||
  accountNavItems,
 | 
					  accountNavItems,
 | 
				
			||||||
  contactNavItems
 | 
					  contactNavItems
 | 
				
			||||||
} from '@/constants/navigation'
 | 
					} from '../../constants/navigation'
 | 
				
			||||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
 | 
					import { RouterLink, useRoute, useRouter } from 'vue-router'
 | 
				
			||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
 | 
					import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@shared-ui/components/ui/collapsible'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Sidebar,
 | 
					  Sidebar,
 | 
				
			||||||
  SidebarContent,
 | 
					  SidebarContent,
 | 
				
			||||||
@@ -21,8 +21,8 @@ import {
 | 
				
			|||||||
  SidebarMenuSubItem,
 | 
					  SidebarMenuSubItem,
 | 
				
			||||||
  SidebarProvider,
 | 
					  SidebarProvider,
 | 
				
			||||||
  SidebarRail
 | 
					  SidebarRail
 | 
				
			||||||
} from '@/components/ui/sidebar'
 | 
					} from '@shared-ui/components/ui/sidebar'
 | 
				
			||||||
import { useAppSettingsStore } from '@/stores/appSettings'
 | 
					import { useAppSettingsStore } from '../../stores/appSettings'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  ChevronRight,
 | 
					  ChevronRight,
 | 
				
			||||||
  EllipsisVertical,
 | 
					  EllipsisVertical,
 | 
				
			||||||
@@ -37,13 +37,23 @@ import {
 | 
				
			|||||||
  DropdownMenuContent,
 | 
					  DropdownMenuContent,
 | 
				
			||||||
  DropdownMenuItem,
 | 
					  DropdownMenuItem,
 | 
				
			||||||
  DropdownMenuTrigger
 | 
					  DropdownMenuTrigger
 | 
				
			||||||
} from '@/components/ui/dropdown-menu'
 | 
					} from '@shared-ui/components/ui/dropdown-menu'
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  AlertDialog,
 | 
				
			||||||
 | 
					  AlertDialogAction,
 | 
				
			||||||
 | 
					  AlertDialogCancel,
 | 
				
			||||||
 | 
					  AlertDialogContent,
 | 
				
			||||||
 | 
					  AlertDialogDescription,
 | 
				
			||||||
 | 
					  AlertDialogFooter,
 | 
				
			||||||
 | 
					  AlertDialogHeader,
 | 
				
			||||||
 | 
					  AlertDialogTitle
 | 
				
			||||||
 | 
					} from '@shared-ui/components/ui/alert-dialog'
 | 
				
			||||||
import { filterNavItems } from '@/utils/nav-permissions'
 | 
					import { filterNavItems } from '@/utils/nav-permissions'
 | 
				
			||||||
import { useStorage } from '@vueuse/core'
 | 
					import { useStorage } from '@vueuse/core'
 | 
				
			||||||
import { computed, ref, watch } from 'vue'
 | 
					import { computed, ref, watch } from 'vue'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import { useUserStore } from '@/stores/user'
 | 
					import { useUserStore } from '../../stores/user'
 | 
				
			||||||
import { useConversationStore } from '@/stores/conversation'
 | 
					import { useConversationStore } from '../../stores/conversation'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
defineProps({
 | 
					defineProps({
 | 
				
			||||||
  userTeams: { type: Array, default: () => [] },
 | 
					  userTeams: { type: Array, default: () => [] },
 | 
				
			||||||
@@ -73,8 +83,17 @@ const editView = (view) => {
 | 
				
			|||||||
  emit('editView', view)
 | 
					  emit('editView', view)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const deleteView = (view) => {
 | 
					const openDeleteConfirmation = (view) => {
 | 
				
			||||||
  emit('deleteView', view)
 | 
					  viewToDelete.value = view
 | 
				
			||||||
 | 
					  isDeleteOpen.value = true
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleDeleteView = () => {
 | 
				
			||||||
 | 
					  if (viewToDelete.value) {
 | 
				
			||||||
 | 
					    emit('deleteView', viewToDelete.value)
 | 
				
			||||||
 | 
					    isDeleteOpen.value = false
 | 
				
			||||||
 | 
					    viewToDelete.value = null
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Navigation methods with conversation retention
 | 
					// Navigation methods with conversation retention
 | 
				
			||||||
@@ -157,6 +176,13 @@ watch(
 | 
				
			|||||||
const sidebarOpen = useStorage('mainSidebarOpen', true)
 | 
					const sidebarOpen = useStorage('mainSidebarOpen', true)
 | 
				
			||||||
const teamInboxOpen = useStorage('teamInboxOpen', true)
 | 
					const teamInboxOpen = useStorage('teamInboxOpen', true)
 | 
				
			||||||
const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
					const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Track which view is being hovered for ellipsis menu visibility
 | 
				
			||||||
 | 
					const hoveredViewId = ref(null)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Track delete confirmation dialog state
 | 
				
			||||||
 | 
					const isDeleteOpen = ref(false)
 | 
				
			||||||
 | 
					const viewToDelete = ref(null)
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
@@ -472,24 +498,35 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
                  <CollapsibleContent>
 | 
					                  <CollapsibleContent>
 | 
				
			||||||
                    <SidebarMenuSub v-for="view in userViews" :key="view.id">
 | 
					                    <SidebarMenuSub v-for="view in userViews" :key="view.id">
 | 
				
			||||||
                      <SidebarMenuSubItem>
 | 
					                      <SidebarMenuSubItem
 | 
				
			||||||
 | 
					                        @mouseenter="hoveredViewId = view.id"
 | 
				
			||||||
 | 
					                        @mouseleave="hoveredViewId = null"
 | 
				
			||||||
 | 
					                      >
 | 
				
			||||||
                        <SidebarMenuButton
 | 
					                        <SidebarMenuButton
 | 
				
			||||||
                          size="sm"
 | 
					                          size="sm"
 | 
				
			||||||
                          :isActive="route.params.viewID == view.id"
 | 
					                          :isActive="route.params.viewID == view.id"
 | 
				
			||||||
                          asChild
 | 
					                          asChild
 | 
				
			||||||
                        >
 | 
					                        >
 | 
				
			||||||
                          <a href="#" @click.prevent="navigateToViewInbox(view.id)">
 | 
					                          <a href="#" @click.prevent="navigateToViewInbox(view.id)">
 | 
				
			||||||
                            <span class="break-words w-32 truncate">{{ view.name }}</span>
 | 
					                            <span class="break-words w-32 truncate" :title="view.name">{{ view.name }}</span>
 | 
				
			||||||
                            <SidebarMenuAction :showOnHover="true" class="mr-3">
 | 
					                            <SidebarMenuAction
 | 
				
			||||||
 | 
					                              @click.stop
 | 
				
			||||||
 | 
					                              :class="[
 | 
				
			||||||
 | 
					                                'mr-3',
 | 
				
			||||||
 | 
					                                'md:opacity-0',
 | 
				
			||||||
 | 
					                                'data-[state=open]:opacity-100',
 | 
				
			||||||
 | 
					                                { 'md:opacity-100': hoveredViewId === view.id }
 | 
				
			||||||
 | 
					                              ]"
 | 
				
			||||||
 | 
					                            >
 | 
				
			||||||
                              <DropdownMenu>
 | 
					                              <DropdownMenu>
 | 
				
			||||||
                                <DropdownMenuTrigger asChild>
 | 
					                                <DropdownMenuTrigger asChild @click.prevent>
 | 
				
			||||||
                                  <EllipsisVertical />
 | 
					                                  <EllipsisVertical />
 | 
				
			||||||
                                </DropdownMenuTrigger>
 | 
					                                </DropdownMenuTrigger>
 | 
				
			||||||
                                <DropdownMenuContent>
 | 
					                                <DropdownMenuContent>
 | 
				
			||||||
                                  <DropdownMenuItem @click="() => editView(view)">
 | 
					                                  <DropdownMenuItem @click="() => editView(view)">
 | 
				
			||||||
                                    <span>{{ t('globals.messages.edit') }}</span>
 | 
					                                    <span>{{ t('globals.messages.edit') }}</span>
 | 
				
			||||||
                                  </DropdownMenuItem>
 | 
					                                  </DropdownMenuItem>
 | 
				
			||||||
                                  <DropdownMenuItem @click="() => deleteView(view)">
 | 
					                                  <DropdownMenuItem @click="() => openDeleteConfirmation(view)">
 | 
				
			||||||
                                    <span>{{ t('globals.messages.delete') }}</span>
 | 
					                                    <span>{{ t('globals.messages.delete') }}</span>
 | 
				
			||||||
                                  </DropdownMenuItem>
 | 
					                                  </DropdownMenuItem>
 | 
				
			||||||
                                </DropdownMenuContent>
 | 
					                                </DropdownMenuContent>
 | 
				
			||||||
@@ -513,4 +550,22 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
      <slot></slot>
 | 
					      <slot></slot>
 | 
				
			||||||
    </SidebarInset>
 | 
					    </SidebarInset>
 | 
				
			||||||
  </SidebarProvider>
 | 
					  </SidebarProvider>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <!-- View Delete Confirmation Dialog -->
 | 
				
			||||||
 | 
					  <AlertDialog v-model:open="isDeleteOpen">
 | 
				
			||||||
 | 
					    <AlertDialogContent>
 | 
				
			||||||
 | 
					      <AlertDialogHeader>
 | 
				
			||||||
 | 
					        <AlertDialogTitle>{{ t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
 | 
				
			||||||
 | 
					        <AlertDialogDescription>
 | 
				
			||||||
 | 
					          {{ t('globals.messages.deletionConfirmation', { name: t('globals.terms.view') }) }}
 | 
				
			||||||
 | 
					        </AlertDialogDescription>
 | 
				
			||||||
 | 
					      </AlertDialogHeader>
 | 
				
			||||||
 | 
					      <AlertDialogFooter>
 | 
				
			||||||
 | 
					        <AlertDialogCancel>{{ t('globals.messages.cancel') }}</AlertDialogCancel>
 | 
				
			||||||
 | 
					        <AlertDialogAction @click="handleDeleteView">
 | 
				
			||||||
 | 
					          {{ t('globals.messages.delete') }}
 | 
				
			||||||
 | 
					        </AlertDialogAction>
 | 
				
			||||||
 | 
					      </AlertDialogFooter>
 | 
				
			||||||
 | 
					    </AlertDialogContent>
 | 
				
			||||||
 | 
					  </AlertDialog>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
@@ -118,12 +118,12 @@ import {
 | 
				
			|||||||
  DropdownMenuLabel,
 | 
					  DropdownMenuLabel,
 | 
				
			||||||
  DropdownMenuSeparator,
 | 
					  DropdownMenuSeparator,
 | 
				
			||||||
  DropdownMenuTrigger
 | 
					  DropdownMenuTrigger
 | 
				
			||||||
} from '@/components/ui/dropdown-menu'
 | 
					} from '@shared-ui/components/ui/dropdown-menu'
 | 
				
			||||||
import { SidebarMenuButton } from '@/components/ui/sidebar'
 | 
					import { SidebarMenuButton } from '@shared-ui/components/ui/sidebar'
 | 
				
			||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
					import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
 | 
				
			||||||
import { Switch } from '@/components/ui/switch'
 | 
					import { Switch } from '@shared-ui/components/ui/switch'
 | 
				
			||||||
import { ChevronsUpDown, CircleUserRound, LogOut, Moon, Sun } from 'lucide-vue-next'
 | 
					import { ChevronsUpDown, CircleUserRound, LogOut, Moon, Sun } from 'lucide-vue-next'
 | 
				
			||||||
import { useUserStore } from '@/stores/user'
 | 
					import { useUserStore } from '../../stores/user'
 | 
				
			||||||
import { useRouter } from 'vue-router'
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { useColorMode } from '@vueuse/core'
 | 
					import { useColorMode } from '@vueuse/core'
 | 
				
			||||||
@@ -71,8 +71,8 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { Trash2 } from 'lucide-vue-next'
 | 
					import { Trash2 } from 'lucide-vue-next'
 | 
				
			||||||
import { defineEmits } from 'vue'
 | 
					import { defineEmits } from 'vue'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button'
 | 
				
			||||||
import { Skeleton } from '@/components/ui/skeleton'
 | 
					import { Skeleton } from '@shared-ui/components/ui/skeleton'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
defineProps({
 | 
					defineProps({
 | 
				
			||||||
  headers: {
 | 
					  headers: {
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
import { computed } from 'vue'
 | 
					import { computed } from 'vue'
 | 
				
			||||||
import { useUsersStore } from '@/stores/users'
 | 
					import { useUsersStore } from '../stores/users'
 | 
				
			||||||
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
 | 
					import { FIELD_TYPE, FIELD_OPERATORS } from '../constants/filterConfig'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function useActivityLogFilters () {
 | 
					export function useActivityLogFilters () {
 | 
				
			||||||
@@ -5,6 +5,7 @@ import { useUsersStore } from '@/stores/users'
 | 
				
			|||||||
import { useTeamStore } from '@/stores/team'
 | 
					import { useTeamStore } from '@/stores/team'
 | 
				
			||||||
import { useSlaStore } from '@/stores/sla'
 | 
					import { useSlaStore } from '@/stores/sla'
 | 
				
			||||||
import { useCustomAttributeStore } from '@/stores/customAttributes'
 | 
					import { useCustomAttributeStore } from '@/stores/customAttributes'
 | 
				
			||||||
 | 
					import { useTagStore } from '@/stores/tag'
 | 
				
			||||||
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
 | 
					import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -15,6 +16,7 @@ export function useConversationFilters () {
 | 
				
			|||||||
    const tStore = useTeamStore()
 | 
					    const tStore = useTeamStore()
 | 
				
			||||||
    const slaStore = useSlaStore()
 | 
					    const slaStore = useSlaStore()
 | 
				
			||||||
    const customAttributeStore = useCustomAttributeStore()
 | 
					    const customAttributeStore = useCustomAttributeStore()
 | 
				
			||||||
 | 
					    const tagStore = useTagStore()
 | 
				
			||||||
    const { t } = useI18n()
 | 
					    const { t } = useI18n()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const customAttributeDataTypeToFieldType = {
 | 
					    const customAttributeDataTypeToFieldType = {
 | 
				
			||||||
@@ -69,6 +71,12 @@ export function useConversationFilters () {
 | 
				
			|||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: iStore.options
 | 
					            options: iStore.options
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        tags: {
 | 
				
			||||||
 | 
					            label: t('globals.terms.tag', 2),
 | 
				
			||||||
 | 
					            type: FIELD_TYPE.MULTI_SELECT,
 | 
				
			||||||
 | 
					            operators: FIELD_OPERATORS.MULTI_SELECT,
 | 
				
			||||||
 | 
					            options: tagStore.tagOptions
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }))
 | 
					    }))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1,8 +1,8 @@
 | 
				
			|||||||
import { ref, readonly } from 'vue'
 | 
					import { ref, readonly } from 'vue'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from './useEmitter'
 | 
				
			||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from '../constants/emitterEvents.js'
 | 
				
			||||||
import { handleHTTPError } from '@/utils/http'
 | 
					import { handleHTTPError } from '../utils/http'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '../api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Composable for handling file uploads
 | 
					 * Composable for handling file uploads
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
 | 
					import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
 | 
				
			||||||
import { useUserStore } from '@/stores/user'
 | 
					import { useUserStore } from '../stores/user'
 | 
				
			||||||
import { debounce } from '@/utils/debounce'
 | 
					import { debounce } from '../utils/debounce'
 | 
				
			||||||
import { useStorage } from '@vueuse/core'
 | 
					import { useStorage } from '@vueuse/core'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function useIdleDetection () {
 | 
					export function useIdleDetection () {
 | 
				
			||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
import { ref, onMounted, onUnmounted } from 'vue'
 | 
					import { ref, onMounted, onUnmounted } from 'vue'
 | 
				
			||||||
import { calculateSla } from '@/utils/sla'
 | 
					import { calculateSla } from '../utils/sla'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function useSla (dueAt, actualAt) {
 | 
					export function useSla (dueAt, actualAt) {
 | 
				
			||||||
    const sla = ref(null)
 | 
					    const sla = ref(null)
 | 
				
			||||||
@@ -1,6 +1,7 @@
 | 
				
			|||||||
export const FIELD_TYPE = {
 | 
					export const FIELD_TYPE = {
 | 
				
			||||||
    SELECT: 'select',
 | 
					    SELECT: 'select',
 | 
				
			||||||
    TAG: 'tag',
 | 
					    TAG: 'tag',
 | 
				
			||||||
 | 
					    MULTI_SELECT: 'multi-select',
 | 
				
			||||||
    TEXT: 'text',
 | 
					    TEXT: 'text',
 | 
				
			||||||
    NUMBER: 'number',
 | 
					    NUMBER: 'number',
 | 
				
			||||||
    RICHTEXT: 'richtext',
 | 
					    RICHTEXT: 'richtext',
 | 
				
			||||||
@@ -39,4 +40,5 @@ export const FIELD_OPERATORS = {
 | 
				
			|||||||
        OPERATOR.LESS_THAN
 | 
					        OPERATOR.LESS_THAN
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
    NUMBER: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.GREATER_THAN, OPERATOR.LESS_THAN],
 | 
					    NUMBER: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.GREATER_THAN, OPERATOR.LESS_THAN],
 | 
				
			||||||
 | 
					    MULTI_SELECT: [OPERATOR.CONTAINS, OPERATOR.NOT_CONTAINS, OPERATOR.SET, OPERATOR.NOT_SET]
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -12,6 +12,7 @@ export const permissions = {
 | 
				
			|||||||
  CONVERSATIONS_UPDATE_TAGS: 'conversations:update_tags',
 | 
					  CONVERSATIONS_UPDATE_TAGS: 'conversations:update_tags',
 | 
				
			||||||
  MESSAGES_READ: 'messages:read',
 | 
					  MESSAGES_READ: 'messages:read',
 | 
				
			||||||
  MESSAGES_WRITE: 'messages:write',
 | 
					  MESSAGES_WRITE: 'messages:write',
 | 
				
			||||||
 | 
					  MESSAGES_WRITE_AS_CONTACT: 'messages:write_as_contact',
 | 
				
			||||||
  VIEW_MANAGE: 'view:manage',
 | 
					  VIEW_MANAGE: 'view:manage',
 | 
				
			||||||
  GENERAL_SETTINGS_MANAGE: 'general_settings:manage',
 | 
					  GENERAL_SETTINGS_MANAGE: 'general_settings:manage',
 | 
				
			||||||
  NOTIFICATION_SETTINGS_MANAGE: 'notification_settings:manage',
 | 
					  NOTIFICATION_SETTINGS_MANAGE: 'notification_settings:manage',
 | 
				
			||||||
							
								
								
									
										3
									
								
								frontend/apps/main/src/constants/user.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								frontend/apps/main/src/constants/user.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					export const Roles = ["Admin", "Agent"]
 | 
				
			||||||
 | 
					export const UserTypeAgent = "agent"
 | 
				
			||||||
 | 
					export const UserTypeContact = "contact"
 | 
				
			||||||
							
								
								
									
										13
									
								
								frontend/apps/main/src/constants/websocket.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								frontend/apps/main/src/constants/websocket.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
				
			|||||||
 | 
					export const WS_EVENT = {
 | 
				
			||||||
 | 
					    NEW_MESSAGE: 'new_message',
 | 
				
			||||||
 | 
					    MESSAGE_PROP_UPDATE: 'message_prop_update',
 | 
				
			||||||
 | 
					    CONVERSATION_PROP_UPDATE: 'conversation_prop_update',
 | 
				
			||||||
 | 
					    CONVERSATION_SUBSCRIBE: 'conversation_subscribe',
 | 
				
			||||||
 | 
					    CONVERSATION_SUBSCRIBED: 'conversation_subscribed',
 | 
				
			||||||
 | 
					    TYPING: 'typing',
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Message types that should not be queued because they become stale quickly
 | 
				
			||||||
 | 
					export const WS_EPHEMERAL_TYPES = [
 | 
				
			||||||
 | 
					    WS_EVENT.TYPING,
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
@@ -148,7 +148,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref, computed, onMounted, watch } from 'vue'
 | 
					import { ref, computed, onMounted, watch } from 'vue'
 | 
				
			||||||
import SimpleTable from '@/components/table/SimpleTable.vue'
 | 
					import SimpleTable from '@main/components/table/SimpleTable.vue'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Pagination,
 | 
					  Pagination,
 | 
				
			||||||
  PaginationEllipsis,
 | 
					  PaginationEllipsis,
 | 
				
			||||||
@@ -158,23 +158,23 @@ import {
 | 
				
			|||||||
  PaginationListItem,
 | 
					  PaginationListItem,
 | 
				
			||||||
  PaginationNext,
 | 
					  PaginationNext,
 | 
				
			||||||
  PaginationPrev
 | 
					  PaginationPrev
 | 
				
			||||||
} from '@/components/ui/pagination'
 | 
					} from '@shared-ui/components/ui/pagination'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
  SelectItem,
 | 
					  SelectItem,
 | 
				
			||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@shared-ui/components/ui/select'
 | 
				
			||||||
import FilterBuilder from '@/components/filter/FilterBuilder.vue'
 | 
					import FilterBuilder from '@main/components/filter/FilterBuilder.vue'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button'
 | 
				
			||||||
import { ListFilter, ArrowDownWideNarrow } from 'lucide-vue-next'
 | 
					import { ListFilter, ArrowDownWideNarrow } from 'lucide-vue-next'
 | 
				
			||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 | 
					import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover'
 | 
				
			||||||
import { useActivityLogFilters } from '@/composables/useActivityLogFilters'
 | 
					import { useActivityLogFilters } from '../../../composables/useActivityLogFilters'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import { format } from 'date-fns'
 | 
					import { format } from 'date-fns'
 | 
				
			||||||
import { getVisiblePages } from '@/utils/pagination'
 | 
					import { getVisiblePages } from '../../../utils/pagination'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '../../../api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const activityLogs = ref([])
 | 
					const activityLogs = ref([])
 | 
				
			||||||
const { t } = useI18n()
 | 
					const { t } = useI18n()
 | 
				
			||||||
@@ -132,28 +132,24 @@
 | 
				
			|||||||
            <Label class="text-sm font-medium">{{ $t('globals.terms.apiKey') }}</Label>
 | 
					            <Label class="text-sm font-medium">{{ $t('globals.terms.apiKey') }}</Label>
 | 
				
			||||||
            <div class="flex items-center gap-2 mt-1">
 | 
					            <div class="flex items-center gap-2 mt-1">
 | 
				
			||||||
              <Input v-model="newAPIKeyData.api_key" readonly class="font-mono text-sm" />
 | 
					              <Input v-model="newAPIKeyData.api_key" readonly class="font-mono text-sm" />
 | 
				
			||||||
              <Button
 | 
					              <CopyButton
 | 
				
			||||||
                type="button"
 | 
					                :text-to-copy="newAPIKeyData.api_key"
 | 
				
			||||||
                variant="outline"
 | 
					                variant="outline"
 | 
				
			||||||
                size="sm"
 | 
					                size="sm"
 | 
				
			||||||
                @click="copyToClipboard(newAPIKeyData.api_key)"
 | 
					                :show-text="false"
 | 
				
			||||||
              >
 | 
					              />
 | 
				
			||||||
                <Copy class="w-4 h-4" />
 | 
					 | 
				
			||||||
              </Button>
 | 
					 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <div>
 | 
					          <div>
 | 
				
			||||||
            <Label class="text-sm font-medium">{{ $t('globals.terms.secret') }}</Label>
 | 
					            <Label class="text-sm font-medium">{{ $t('globals.terms.secret') }}</Label>
 | 
				
			||||||
            <div class="flex items-center gap-2 mt-1">
 | 
					            <div class="flex items-center gap-2 mt-1">
 | 
				
			||||||
              <Input v-model="newAPIKeyData.api_secret" readonly class="font-mono text-sm" />
 | 
					              <Input v-model="newAPIKeyData.api_secret" readonly class="font-mono text-sm" />
 | 
				
			||||||
              <Button
 | 
					              <CopyButton
 | 
				
			||||||
                type="button"
 | 
					                :text-to-copy="newAPIKeyData.api_secret"
 | 
				
			||||||
                variant="outline"
 | 
					                variant="outline"
 | 
				
			||||||
                size="sm"
 | 
					                size="sm"
 | 
				
			||||||
                @click="copyToClipboard(newAPIKeyData.api_secret)"
 | 
					                :show-text="false"
 | 
				
			||||||
              >
 | 
					              />
 | 
				
			||||||
                <Copy class="w-4 h-4" />
 | 
					 | 
				
			||||||
              </Button>
 | 
					 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <Alert>
 | 
					          <Alert>
 | 
				
			||||||
@@ -304,17 +300,24 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { watch, onMounted, ref, computed } from 'vue'
 | 
					import { watch, onMounted, ref, computed } from 'vue'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button/index.js'
 | 
				
			||||||
import { useForm } from 'vee-validate'
 | 
					import { useForm } from 'vee-validate'
 | 
				
			||||||
import { toTypedSchema } from '@vee-validate/zod'
 | 
					import { toTypedSchema } from '@vee-validate/zod'
 | 
				
			||||||
import { createFormSchema } from './formSchema.js'
 | 
					import { createFormSchema } from './formSchema.js'
 | 
				
			||||||
import { Checkbox } from '@/components/ui/checkbox'
 | 
					import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js'
 | 
				
			||||||
import { Label } from '@/components/ui/label'
 | 
					import { Label } from '@shared-ui/components/ui/label/index.js'
 | 
				
			||||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
 | 
					import { vAutoAnimate } from '@formkit/auto-animate/vue'
 | 
				
			||||||
import { Badge } from '@/components/ui/badge'
 | 
					import { Badge } from '@shared-ui/components/ui/badge/index.js'
 | 
				
			||||||
import { Clock, LogIn, Key, RotateCcw, Trash2, Plus, Copy, AlertTriangle } from 'lucide-vue-next'
 | 
					import { Clock, LogIn, Key, RotateCcw, Trash2, Plus, AlertTriangle } from 'lucide-vue-next'
 | 
				
			||||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
 | 
					import {
 | 
				
			||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
					  FormControl,
 | 
				
			||||||
 | 
					  FormField,
 | 
				
			||||||
 | 
					  FormItem,
 | 
				
			||||||
 | 
					  FormLabel,
 | 
				
			||||||
 | 
					  FormMessage
 | 
				
			||||||
 | 
					} from '@shared-ui/components/ui/form/index.js'
 | 
				
			||||||
 | 
					import CopyButton from '@/components/button/CopyButton.vue'
 | 
				
			||||||
 | 
					import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar/index.js'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
@@ -322,9 +325,9 @@ import {
 | 
				
			|||||||
  SelectItem,
 | 
					  SelectItem,
 | 
				
			||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@shared-ui/components/ui/select/index.js'
 | 
				
			||||||
import { SelectTag } from '@/components/ui/select'
 | 
					import { SelectTag } from '@shared-ui/components/ui/select/index.js'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@shared-ui/components/ui/input/index.js'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Dialog,
 | 
					  Dialog,
 | 
				
			||||||
  DialogContent,
 | 
					  DialogContent,
 | 
				
			||||||
@@ -332,13 +335,13 @@ import {
 | 
				
			|||||||
  DialogFooter,
 | 
					  DialogFooter,
 | 
				
			||||||
  DialogHeader,
 | 
					  DialogHeader,
 | 
				
			||||||
  DialogTitle
 | 
					  DialogTitle
 | 
				
			||||||
} from '@/components/ui/dialog'
 | 
					} from '@shared-ui/components/ui/dialog/index.js'
 | 
				
			||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
 | 
					import { Alert, AlertDescription, AlertTitle } from '@shared-ui/components/ui/alert/index.js'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '../../../composables/useEmitter.js'
 | 
				
			||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
 | 
				
			||||||
import { format } from 'date-fns'
 | 
					import { format } from 'date-fns'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '../../../api/index.js'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  initialValues: {
 | 
					  initialValues: {
 | 
				
			||||||
@@ -418,7 +421,6 @@ const onSubmit = form.handleSubmit((values) => {
 | 
				
			|||||||
  if (values.availability_status === 'active_group') {
 | 
					  if (values.availability_status === 'active_group') {
 | 
				
			||||||
    values.availability_status = 'online'
 | 
					    values.availability_status = 'online'
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  values.teams = values.teams.map((team) => ({ name: team }))
 | 
					 | 
				
			||||||
  props.submitForm(values)
 | 
					  props.submitForm(values)
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -494,17 +496,6 @@ const revokeAPIKey = async () => {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const copyToClipboard = async (text) => {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    await navigator.clipboard.writeText(text)
 | 
					 | 
				
			||||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					 | 
				
			||||||
      description: t('globals.messages.copied')
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    console.error('Error copying to clipboard:', error)
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const closeAPIKeyModal = () => {
 | 
					const closeAPIKeyModal = () => {
 | 
				
			||||||
  showAPIKeyDialog.value = false
 | 
					  showAPIKeyDialog.value = false
 | 
				
			||||||
  newAPIKeyData.value = { api_key: '', api_secret: '' }
 | 
					  newAPIKeyData.value = { api_key: '', api_secret: '' }
 | 
				
			||||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
      return h('div', { class: 'text-center' }, t('globals.terms.firstName'))
 | 
					      return h('div', { class: 'text-center' }, t('globals.terms.firstName'))
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    cell: function ({ row }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('first_name'))
 | 
					      return h('div', { class: 'text-center' }, row.getValue('first_name'))
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
      return h('div', { class: 'text-center' }, t('globals.terms.lastName'))
 | 
					      return h('div', { class: 'text-center' }, t('globals.terms.lastName'))
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    cell: function ({ row }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('last_name'))
 | 
					      return h('div', { class: 'text-center' }, row.getValue('last_name'))
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
      return h('div', { class: 'text-center' }, t('globals.terms.enabled'))
 | 
					      return h('div', { class: 'text-center' }, t('globals.terms.enabled'))
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    cell: function ({ row }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
 | 
					      return h('div', { class: 'text-center' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
@@ -36,7 +36,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
      return h('div', { class: 'text-center' }, t('globals.terms.email'))
 | 
					      return h('div', { class: 'text-center' }, t('globals.terms.email'))
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    cell: function ({ row }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('email'))
 | 
					      return h('div', { class: 'text-center' }, row.getValue('email'))
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
@@ -47,7 +47,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
    cell: function ({ row }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h(
 | 
					      return h(
 | 
				
			||||||
        'div',
 | 
					        'div',
 | 
				
			||||||
        { class: 'text-center font-medium' },
 | 
					        { class: 'text-center' },
 | 
				
			||||||
        format(row.getValue('created_at'), 'PPpp')
 | 
					        format(row.getValue('created_at'), 'PPpp')
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -60,7 +60,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
    cell: function ({ row }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h(
 | 
					      return h(
 | 
				
			||||||
        'div',
 | 
					        'div',
 | 
				
			||||||
        { class: 'text-center font-medium' },
 | 
					        { class: 'text-center' },
 | 
				
			||||||
        format(row.getValue('updated_at'), 'PPpp')
 | 
					        format(row.getValue('updated_at'), 'PPpp')
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -40,7 +40,7 @@ import {
 | 
				
			|||||||
  DropdownMenuContent,
 | 
					  DropdownMenuContent,
 | 
				
			||||||
  DropdownMenuItem,
 | 
					  DropdownMenuItem,
 | 
				
			||||||
  DropdownMenuTrigger
 | 
					  DropdownMenuTrigger
 | 
				
			||||||
} from '@/components/ui/dropdown-menu'
 | 
					} from '@shared-ui/components/ui/dropdown-menu'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  AlertDialog,
 | 
					  AlertDialog,
 | 
				
			||||||
  AlertDialogAction,
 | 
					  AlertDialogAction,
 | 
				
			||||||
@@ -50,13 +50,13 @@ import {
 | 
				
			|||||||
  AlertDialogFooter,
 | 
					  AlertDialogFooter,
 | 
				
			||||||
  AlertDialogHeader,
 | 
					  AlertDialogHeader,
 | 
				
			||||||
  AlertDialogTitle
 | 
					  AlertDialogTitle
 | 
				
			||||||
} from '@/components/ui/alert-dialog'
 | 
					} from '@shared-ui/components/ui/alert-dialog'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button'
 | 
				
			||||||
import { useRouter } from 'vue-router'
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '../../../composables/useEmitter'
 | 
				
			||||||
import { handleHTTPError } from '@/utils/http'
 | 
					import { handleHTTPError } from '../../../utils/http'
 | 
				
			||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '../../../api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const alertOpen = ref(false)
 | 
					const alertOpen = ref(false)
 | 
				
			||||||
const emit = useEmitter()
 | 
					const emit = useEmitter()
 | 
				
			||||||
@@ -87,9 +87,9 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { toRefs } from 'vue'
 | 
					import { toRefs } from 'vue'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button'
 | 
				
			||||||
import CloseButton from '@/components/button/CloseButton.vue'
 | 
					import CloseButton from '@main/components/button/CloseButton.vue'
 | 
				
			||||||
import { useTagStore } from '@/stores/tag'
 | 
					import { useTagStore } from '../../../stores/tag'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
@@ -97,13 +97,13 @@ import {
 | 
				
			|||||||
  SelectItem,
 | 
					  SelectItem,
 | 
				
			||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@shared-ui/components/ui/select'
 | 
				
			||||||
import { SelectTag } from '@/components/ui/select'
 | 
					import { SelectTag } from '@shared-ui/components/ui/select'
 | 
				
			||||||
import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
					import { useConversationFilters } from '../../../composables/useConversationFilters'
 | 
				
			||||||
import { getTextFromHTML } from '@/utils/strings.js'
 | 
					import { getTextFromHTML } from '@shared-ui/utils/string'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import Editor from '@/components/editor/TextEditor.vue'
 | 
					import Editor from '@main/components/editor/TextEditor.vue'
 | 
				
			||||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
					import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  actions: {
 | 
					  actions: {
 | 
				
			||||||
@@ -34,7 +34,7 @@
 | 
				
			|||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
 | 
					import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shared-ui/components/ui/tabs'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import RuleTab from './RuleTab.vue'
 | 
					import RuleTab from './RuleTab.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -190,10 +190,10 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { toRefs, computed, watch } from 'vue'
 | 
					import { toRefs, computed, watch } from 'vue'
 | 
				
			||||||
import { Checkbox } from '@/components/ui/checkbox'
 | 
					import { Checkbox } from '@shared-ui/components/ui/checkbox'
 | 
				
			||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
 | 
					import { RadioGroup, RadioGroupItem } from '@shared-ui/components/ui/radio-group'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button'
 | 
				
			||||||
import CloseButton from '@/components/button/CloseButton.vue'
 | 
					import CloseButton from '@main/components/button/CloseButton.vue'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
@@ -202,19 +202,19 @@ import {
 | 
				
			|||||||
  SelectLabel,
 | 
					  SelectLabel,
 | 
				
			||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@shared-ui/components/ui/select'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  TagsInput,
 | 
					  TagsInput,
 | 
				
			||||||
  TagsInputInput,
 | 
					  TagsInputInput,
 | 
				
			||||||
  TagsInputItem,
 | 
					  TagsInputItem,
 | 
				
			||||||
  TagsInputItemDelete,
 | 
					  TagsInputItemDelete,
 | 
				
			||||||
  TagsInputItemText
 | 
					  TagsInputItemText
 | 
				
			||||||
} from '@/components/ui/tags-input'
 | 
					} from '@shared-ui/components/ui/tags-input'
 | 
				
			||||||
import { Label } from '@/components/ui/label'
 | 
					import { Label } from '@shared-ui/components/ui/label'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@shared-ui/components/ui/input'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
					import { useConversationFilters } from '../../../composables/useConversationFilters'
 | 
				
			||||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
					import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  ruleGroup: {
 | 
					  ruleGroup: {
 | 
				
			||||||
@@ -68,7 +68,7 @@ import {
 | 
				
			|||||||
  DropdownMenuContent,
 | 
					  DropdownMenuContent,
 | 
				
			||||||
  DropdownMenuItem,
 | 
					  DropdownMenuItem,
 | 
				
			||||||
  DropdownMenuTrigger
 | 
					  DropdownMenuTrigger
 | 
				
			||||||
} from '@/components/ui/dropdown-menu'
 | 
					} from '@shared-ui/components/ui/dropdown-menu'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  AlertDialog,
 | 
					  AlertDialog,
 | 
				
			||||||
  AlertDialogAction,
 | 
					  AlertDialogAction,
 | 
				
			||||||
@@ -78,10 +78,10 @@ import {
 | 
				
			|||||||
  AlertDialogFooter,
 | 
					  AlertDialogFooter,
 | 
				
			||||||
  AlertDialogHeader,
 | 
					  AlertDialogHeader,
 | 
				
			||||||
  AlertDialogTitle
 | 
					  AlertDialogTitle
 | 
				
			||||||
} from '@/components/ui/alert-dialog'
 | 
					} from '@shared-ui/components/ui/alert-dialog'
 | 
				
			||||||
import { EllipsisVertical } from 'lucide-vue-next'
 | 
					import { EllipsisVertical } from 'lucide-vue-next'
 | 
				
			||||||
import { useRouter } from 'vue-router'
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
import { Badge } from '@/components/ui/badge'
 | 
					import { Badge } from '@shared-ui/components/ui/badge'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const router = useRouter()
 | 
					const router = useRouter()
 | 
				
			||||||
const alertOpen = ref(false)
 | 
					const alertOpen = ref(false)
 | 
				
			||||||
@@ -64,17 +64,17 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref, onMounted, watch } from 'vue'
 | 
					import { ref, onMounted, watch } from 'vue'
 | 
				
			||||||
import RuleList from './RuleList.vue'
 | 
					import RuleList from './RuleList.vue'
 | 
				
			||||||
import { Spinner } from '@/components/ui/spinner'
 | 
					import { Spinner } from '@shared-ui/components/ui/spinner'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
  SelectItem,
 | 
					  SelectItem,
 | 
				
			||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@shared-ui/components/ui/select'
 | 
				
			||||||
import { Settings } from 'lucide-vue-next'
 | 
					import { Settings } from 'lucide-vue-next'
 | 
				
			||||||
import draggable from 'vuedraggable'
 | 
					import draggable from 'vuedraggable'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '../../../api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const isLoading = ref(false)
 | 
					const isLoading = ref(false)
 | 
				
			||||||
const rules = ref([])
 | 
					const rules = ref([])
 | 
				
			||||||
@@ -167,23 +167,23 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref, watch, reactive, computed } from 'vue'
 | 
					import { ref, watch, reactive, computed } from 'vue'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button/index.js'
 | 
				
			||||||
import { useForm } from 'vee-validate'
 | 
					import { useForm } from 'vee-validate'
 | 
				
			||||||
import { toTypedSchema } from '@vee-validate/zod'
 | 
					import { toTypedSchema } from '@vee-validate/zod'
 | 
				
			||||||
import { createFormSchema } from './formSchema.js'
 | 
					import { createFormSchema } from './formSchema.js'
 | 
				
			||||||
import { Checkbox } from '@/components/ui/checkbox'
 | 
					import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js'
 | 
				
			||||||
import { Label } from '@/components/ui/label'
 | 
					import { Label } from '@shared-ui/components/ui/label/index.js'
 | 
				
			||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
 | 
					import { RadioGroup, RadioGroupItem } from '@shared-ui/components/ui/radio-group/index.js'
 | 
				
			||||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
 | 
					import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js'
 | 
				
			||||||
import { Calendar } from '@/components/ui/calendar'
 | 
					import { Calendar } from '@shared-ui/components/ui/calendar/index.js'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@shared-ui/components/ui/input/index.js'
 | 
				
			||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 | 
					import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover/index.js'
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { cn } from '@shared-ui/lib/utils.js'
 | 
				
			||||||
import { format } from 'date-fns'
 | 
					import { format } from 'date-fns'
 | 
				
			||||||
import { WEEKDAYS } from '@/constants/date'
 | 
					import { WEEKDAYS } from '../../../constants/date.js'
 | 
				
			||||||
import { Calendar as CalendarIcon } from 'lucide-vue-next'
 | 
					import { Calendar as CalendarIcon } from 'lucide-vue-next'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import SimpleTable from '@/components/table/SimpleTable.vue'
 | 
					import SimpleTable from '@main/components/table/SimpleTable.vue'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Dialog,
 | 
					  Dialog,
 | 
				
			||||||
  DialogContent,
 | 
					  DialogContent,
 | 
				
			||||||
@@ -192,7 +192,7 @@ import {
 | 
				
			|||||||
  DialogHeader,
 | 
					  DialogHeader,
 | 
				
			||||||
  DialogTitle,
 | 
					  DialogTitle,
 | 
				
			||||||
  DialogTrigger
 | 
					  DialogTrigger
 | 
				
			||||||
} from '@/components/ui/dialog'
 | 
					} from '@shared-ui/components/ui/dialog/index.js'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  initialValues: {
 | 
					  initialValues: {
 | 
				
			||||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
            return h('div', { class: 'text-center' }, t('globals.terms.name'))
 | 
					            return h('div', { class: 'text-center' }, t('globals.terms.name'))
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        cell: function ({ row }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
					            return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
            return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
 | 
					            return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        cell: function ({ row }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
 | 
					            return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
            return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
 | 
					            return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        cell: function ({ row }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
					            return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -50,7 +50,7 @@ import {
 | 
				
			|||||||
  DropdownMenuContent,
 | 
					  DropdownMenuContent,
 | 
				
			||||||
  DropdownMenuItem,
 | 
					  DropdownMenuItem,
 | 
				
			||||||
  DropdownMenuTrigger
 | 
					  DropdownMenuTrigger
 | 
				
			||||||
} from '@/components/ui/dropdown-menu'
 | 
					} from '@shared-ui/components/ui/dropdown-menu'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  AlertDialog,
 | 
					  AlertDialog,
 | 
				
			||||||
  AlertDialogAction,
 | 
					  AlertDialogAction,
 | 
				
			||||||
@@ -60,13 +60,13 @@ import {
 | 
				
			|||||||
  AlertDialogFooter,
 | 
					  AlertDialogFooter,
 | 
				
			||||||
  AlertDialogHeader,
 | 
					  AlertDialogHeader,
 | 
				
			||||||
  AlertDialogTitle
 | 
					  AlertDialogTitle
 | 
				
			||||||
} from '@/components/ui/alert-dialog'
 | 
					} from '@shared-ui/components/ui/alert-dialog'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button'
 | 
				
			||||||
import { useRouter } from 'vue-router'
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '../../../api'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '../../../composables/useEmitter'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const { t } = useI18n()
 | 
					const { t } = useI18n()
 | 
				
			||||||
const router = useRouter()
 | 
					const router = useRouter()
 | 
				
			||||||
@@ -150,14 +150,14 @@ import {
 | 
				
			|||||||
  FormItem,
 | 
					  FormItem,
 | 
				
			||||||
  FormLabel,
 | 
					  FormLabel,
 | 
				
			||||||
  FormMessage
 | 
					  FormMessage
 | 
				
			||||||
} from '@/components/ui/form'
 | 
					} from '@shared-ui/components/ui/form'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  TagsInput,
 | 
					  TagsInput,
 | 
				
			||||||
  TagsInputInput,
 | 
					  TagsInputInput,
 | 
				
			||||||
  TagsInputItem,
 | 
					  TagsInputItem,
 | 
				
			||||||
  TagsInputItemDelete,
 | 
					  TagsInputItemDelete,
 | 
				
			||||||
  TagsInputItemText
 | 
					  TagsInputItemText
 | 
				
			||||||
} from '@/components/ui/tags-input'
 | 
					} from '@shared-ui/components/ui/tags-input'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
@@ -165,8 +165,8 @@ import {
 | 
				
			|||||||
  SelectItem,
 | 
					  SelectItem,
 | 
				
			||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@shared-ui/components/ui/select'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@shared-ui/components/ui/input'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  form: {
 | 
					  form: {
 | 
				
			||||||
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
            return h('div', { class: 'text-center' }, t('globals.terms.name'))
 | 
					            return h('div', { class: 'text-center' }, t('globals.terms.name'))
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        cell: function ({ row }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
					            return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
            return h('div', { class: 'text-center' }, t('globals.terms.key'))
 | 
					            return h('div', { class: 'text-center' }, t('globals.terms.key'))
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        cell: function ({ row }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h('div', { class: 'text-center font-medium' }, row.getValue('key'))
 | 
					            return h('div', { class: 'text-center' }, row.getValue('key'))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
            return h('div', { class: 'text-center' }, t('globals.terms.type'))
 | 
					            return h('div', { class: 'text-center' }, t('globals.terms.type'))
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        cell: function ({ row }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h('div', { class: 'text-center font-medium' }, row.getValue('data_type'))
 | 
					            return h('div', { class: 'text-center' }, row.getValue('data_type'))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -36,7 +36,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
            return h('div', { class: 'text-center' }, t('globals.terms.appliesTo'))
 | 
					            return h('div', { class: 'text-center' }, t('globals.terms.appliesTo'))
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        cell: function ({ row }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h('div', { class: 'text-center font-medium' }, row.getValue('applies_to'))
 | 
					            return h('div', { class: 'text-center' }, row.getValue('applies_to'))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -47,7 +47,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
        cell: function ({ row }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h(
 | 
					            return h(
 | 
				
			||||||
                'div',
 | 
					                'div',
 | 
				
			||||||
                { class: 'text-center font-medium' },
 | 
					                { class: 'text-center' },
 | 
				
			||||||
                format(row.getValue('created_at'), 'PPpp')
 | 
					                format(row.getValue('created_at'), 'PPpp')
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -60,7 +60,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
        cell: function ({ row }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h(
 | 
					            return h(
 | 
				
			||||||
                'div',
 | 
					                'div',
 | 
				
			||||||
                { class: 'text-center font-medium' },
 | 
					                { class: 'text-center' },
 | 
				
			||||||
                format(row.getValue('updated_at'), 'PPpp')
 | 
					                format(row.getValue('updated_at'), 'PPpp')
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -44,7 +44,7 @@ import {
 | 
				
			|||||||
  DropdownMenuContent,
 | 
					  DropdownMenuContent,
 | 
				
			||||||
  DropdownMenuItem,
 | 
					  DropdownMenuItem,
 | 
				
			||||||
  DropdownMenuTrigger
 | 
					  DropdownMenuTrigger
 | 
				
			||||||
} from '@/components/ui/dropdown-menu'
 | 
					} from '@shared-ui/components/ui/dropdown-menu'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  AlertDialog,
 | 
					  AlertDialog,
 | 
				
			||||||
  AlertDialogAction,
 | 
					  AlertDialogAction,
 | 
				
			||||||
@@ -54,12 +54,12 @@ import {
 | 
				
			|||||||
  AlertDialogFooter,
 | 
					  AlertDialogFooter,
 | 
				
			||||||
  AlertDialogHeader,
 | 
					  AlertDialogHeader,
 | 
				
			||||||
  AlertDialogTitle
 | 
					  AlertDialogTitle
 | 
				
			||||||
} from '@/components/ui/alert-dialog'
 | 
					} from '@shared-ui/components/ui/alert-dialog'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '../../../composables/useEmitter'
 | 
				
			||||||
import { handleHTTPError } from '@/utils/http'
 | 
					import { handleHTTPError } from '../../../utils/http'
 | 
				
			||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '../../../api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const alertOpen = ref(false)
 | 
					const alertOpen = ref(false)
 | 
				
			||||||
const emit = useEmitter()
 | 
					const emit = useEmitter()
 | 
				
			||||||
@@ -171,7 +171,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { watch, ref, onMounted } from 'vue'
 | 
					import { watch, ref, onMounted } from 'vue'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button/index.js'
 | 
				
			||||||
import { useForm } from 'vee-validate'
 | 
					import { useForm } from 'vee-validate'
 | 
				
			||||||
import { toTypedSchema } from '@vee-validate/zod'
 | 
					import { toTypedSchema } from '@vee-validate/zod'
 | 
				
			||||||
import { createFormSchema } from './formSchema.js'
 | 
					import { createFormSchema } from './formSchema.js'
 | 
				
			||||||
@@ -182,7 +182,7 @@ import {
 | 
				
			|||||||
  FormLabel,
 | 
					  FormLabel,
 | 
				
			||||||
  FormMessage,
 | 
					  FormMessage,
 | 
				
			||||||
  FormDescription
 | 
					  FormDescription
 | 
				
			||||||
} from '@/components/ui/form'
 | 
					} from '@shared-ui/components/ui/form/index.js'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
@@ -190,21 +190,21 @@ import {
 | 
				
			|||||||
  SelectItem,
 | 
					  SelectItem,
 | 
				
			||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@shared-ui/components/ui/select/index.js'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  TagsInput,
 | 
					  TagsInput,
 | 
				
			||||||
  TagsInputInput,
 | 
					  TagsInputInput,
 | 
				
			||||||
  TagsInputItem,
 | 
					  TagsInputItem,
 | 
				
			||||||
  TagsInputItemDelete,
 | 
					  TagsInputItemDelete,
 | 
				
			||||||
  TagsInputItemText
 | 
					  TagsInputItemText
 | 
				
			||||||
} from '@/components/ui/tags-input'
 | 
					} from '@shared-ui/components/ui/tags-input/index.js'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@shared-ui/components/ui/input/index.js'
 | 
				
			||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '../../../composables/useEmitter.js'
 | 
				
			||||||
import { handleHTTPError } from '@/utils/http'
 | 
					import { handleHTTPError } from '../../../utils/http.js'
 | 
				
			||||||
import { timeZones } from '@/constants/timezones.js'
 | 
					import { timeZones } from '../../../constants/timezones.js'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '../../../api/index.js'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emitter = useEmitter()
 | 
					const emitter = useEmitter()
 | 
				
			||||||
const { t } = useI18n()
 | 
					const { t } = useI18n()
 | 
				
			||||||
@@ -360,17 +360,17 @@ import {
 | 
				
			|||||||
  FormLabel,
 | 
					  FormLabel,
 | 
				
			||||||
  FormMessage,
 | 
					  FormMessage,
 | 
				
			||||||
  FormDescription
 | 
					  FormDescription
 | 
				
			||||||
} from '@/components/ui/form'
 | 
					} from '@shared-ui/components/ui/form/index.js'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@shared-ui/components/ui/input/index.js'
 | 
				
			||||||
import { Switch } from '@/components/ui/switch'
 | 
					import { Switch } from '@shared-ui/components/ui/switch/index.js'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button/index.js'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
  SelectItem,
 | 
					  SelectItem,
 | 
				
			||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@shared-ui/components/ui/select/index.js'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
@@ -48,7 +48,7 @@ import {
 | 
				
			|||||||
  DropdownMenuContent,
 | 
					  DropdownMenuContent,
 | 
				
			||||||
  DropdownMenuItem,
 | 
					  DropdownMenuItem,
 | 
				
			||||||
  DropdownMenuTrigger
 | 
					  DropdownMenuTrigger
 | 
				
			||||||
} from '@/components/ui/dropdown-menu'
 | 
					} from '@shared-ui/components/ui/dropdown-menu'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  AlertDialog,
 | 
					  AlertDialog,
 | 
				
			||||||
  AlertDialogAction,
 | 
					  AlertDialogAction,
 | 
				
			||||||
@@ -58,8 +58,8 @@ import {
 | 
				
			|||||||
  AlertDialogFooter,
 | 
					  AlertDialogFooter,
 | 
				
			||||||
  AlertDialogHeader,
 | 
					  AlertDialogHeader,
 | 
				
			||||||
  AlertDialogTitle
 | 
					  AlertDialogTitle
 | 
				
			||||||
} from '@/components/ui/alert-dialog'
 | 
					} from '@shared-ui/components/ui/alert-dialog'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const alertOpen = ref(false)
 | 
					const alertOpen = ref(false)
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
							
								
								
									
										1017
									
								
								frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1017
									
								
								frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -0,0 +1,278 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="space-y-6">
 | 
				
			||||||
 | 
					    <!-- Master Toggle -->
 | 
				
			||||||
 | 
					    <div class="flex flex-row items-center justify-between box p-4">
 | 
				
			||||||
 | 
					      <div class="space-y-0.5">
 | 
				
			||||||
 | 
					        <label class="text-base font-medium">
 | 
				
			||||||
 | 
					          {{ $t('admin.inbox.livechat.prechatForm.enabled') }}
 | 
				
			||||||
 | 
					        </label>
 | 
				
			||||||
 | 
					        <p class="text-sm text-muted-foreground">
 | 
				
			||||||
 | 
					          {{ $t('admin.inbox.livechat.prechatForm.enabled.description') }}
 | 
				
			||||||
 | 
					        </p>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <Switch v-model:checked="prechatConfig.enabled" />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Form Configuration -->
 | 
				
			||||||
 | 
					    <div v-if="prechatConfig.enabled" class="space-y-6">
 | 
				
			||||||
 | 
					      <!-- Form Title -->
 | 
				
			||||||
 | 
					      <div>
 | 
				
			||||||
 | 
					        <label class="text-sm font-medium">
 | 
				
			||||||
 | 
					          {{ $t('admin.inbox.livechat.prechatForm.title') }}
 | 
				
			||||||
 | 
					        </label>
 | 
				
			||||||
 | 
					        <Input
 | 
				
			||||||
 | 
					          type="text"
 | 
				
			||||||
 | 
					          v-model="prechatConfig.title"
 | 
				
			||||||
 | 
					          placeholder="Tell us about yourself"
 | 
				
			||||||
 | 
					          class="mt-1"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					        <p class="text-sm text-muted-foreground mt-1">
 | 
				
			||||||
 | 
					          {{ $t('admin.inbox.livechat.prechatForm.title.description') }}
 | 
				
			||||||
 | 
					        </p>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <!-- Fields Configuration -->
 | 
				
			||||||
 | 
					      <div class="space-y-4">
 | 
				
			||||||
 | 
					        <div class="flex justify-between items-center">
 | 
				
			||||||
 | 
					          <h4 class="font-medium text-foreground">
 | 
				
			||||||
 | 
					            {{ $t('admin.inbox.livechat.prechatForm.fields') }}
 | 
				
			||||||
 | 
					          </h4>
 | 
				
			||||||
 | 
					          <Button
 | 
				
			||||||
 | 
					            variant="outline"
 | 
				
			||||||
 | 
					            size="sm"
 | 
				
			||||||
 | 
					            @click="fetchCustomAttributes"
 | 
				
			||||||
 | 
					            :disabled="availableCustomAttributes.length === 0"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <Plus class="w-4 h-4 mr-2" />
 | 
				
			||||||
 | 
					            {{ $t('admin.inbox.livechat.prechatForm.addField') }}
 | 
				
			||||||
 | 
					          </Button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <!-- Field List -->
 | 
				
			||||||
 | 
					        <div class="space-y-3">
 | 
				
			||||||
 | 
					          <Draggable
 | 
				
			||||||
 | 
					            v-model="draggableFields"
 | 
				
			||||||
 | 
					            :item-key="(field) => field.key || `field_${field.custom_attribute_id || 'unknown'}`"
 | 
				
			||||||
 | 
					            :animation="200"
 | 
				
			||||||
 | 
					            class="space-y-3"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <template #item="{ element: field, index }">
 | 
				
			||||||
 | 
					              <div :key="field.key || `field-${index}`" class="border rounded-lg p-4 space-y-4">
 | 
				
			||||||
 | 
					                <!-- Field Header -->
 | 
				
			||||||
 | 
					                <div class="flex items-center justify-between">
 | 
				
			||||||
 | 
					                  <div class="flex items-center space-x-3">
 | 
				
			||||||
 | 
					                    <div class="cursor-move text-muted-foreground">
 | 
				
			||||||
 | 
					                      <GripVertical class="w-4 h-4" />
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                    <div>
 | 
				
			||||||
 | 
					                      <div class="font-medium">{{ field.label }}</div>
 | 
				
			||||||
 | 
					                      <div class="text-sm text-muted-foreground">
 | 
				
			||||||
 | 
					                        {{ field.type }} {{ field.is_default ? '(Default)' : '(Custom)' }}
 | 
				
			||||||
 | 
					                      </div>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                  <div class="flex items-center space-x-2">
 | 
				
			||||||
 | 
					                    <Switch v-model:checked="field.enabled" />
 | 
				
			||||||
 | 
					                    <Button
 | 
				
			||||||
 | 
					                      v-if="!field.is_default"
 | 
				
			||||||
 | 
					                      variant="ghost"
 | 
				
			||||||
 | 
					                      size="sm"
 | 
				
			||||||
 | 
					                      @click="removeField(index)"
 | 
				
			||||||
 | 
					                    >
 | 
				
			||||||
 | 
					                      <X class="w-4 h-4" />
 | 
				
			||||||
 | 
					                    </Button>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <!-- Field Configuration -->
 | 
				
			||||||
 | 
					                <div v-if="field.enabled" class="space-y-4">
 | 
				
			||||||
 | 
					                  <div class="grid grid-cols-2 gap-4">
 | 
				
			||||||
 | 
					                    <!-- Label -->
 | 
				
			||||||
 | 
					                    <div>
 | 
				
			||||||
 | 
					                      <label class="text-sm font-medium">{{ $t('globals.terms.label') }}</label>
 | 
				
			||||||
 | 
					                      <Input v-model="field.label" placeholder="Field label" class="mt-1" />
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    <!-- Placeholder -->
 | 
				
			||||||
 | 
					                    <div>
 | 
				
			||||||
 | 
					                      <label class="text-sm font-medium">
 | 
				
			||||||
 | 
					                        {{ $t('globals.terms.placeholder') }}
 | 
				
			||||||
 | 
					                      </label>
 | 
				
			||||||
 | 
					                      <Input
 | 
				
			||||||
 | 
					                        v-model="field.placeholder"
 | 
				
			||||||
 | 
					                        placeholder="Field placeholder"
 | 
				
			||||||
 | 
					                        class="mt-1"
 | 
				
			||||||
 | 
					                      />
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                  <!-- Required -->
 | 
				
			||||||
 | 
					                  <div class="flex items-center space-x-2">
 | 
				
			||||||
 | 
					                    <Checkbox v-model:checked="field.required" />
 | 
				
			||||||
 | 
					                    <label class="text-sm">{{ $t('globals.terms.required') }}</label>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </template>
 | 
				
			||||||
 | 
					          </Draggable>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <!-- Empty State -->
 | 
				
			||||||
 | 
					          <div v-if="formFields.length === 0" class="text-center py-8 text-muted-foreground">
 | 
				
			||||||
 | 
					            {{ $t('admin.inbox.livechat.prechatForm.noFields') }}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <!-- Custom Attributes Selection -->
 | 
				
			||||||
 | 
					        <div v-if="availableCustomAttributes.length > 0" class="space-y-3">
 | 
				
			||||||
 | 
					          <h5 class="font-medium text-sm">
 | 
				
			||||||
 | 
					            {{ $t('admin.inbox.livechat.prechatForm.availableFields') }}
 | 
				
			||||||
 | 
					          </h5>
 | 
				
			||||||
 | 
					          <div class="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto">
 | 
				
			||||||
 | 
					            <div
 | 
				
			||||||
 | 
					              v-for="attr in availableCustomAttributes"
 | 
				
			||||||
 | 
					              :key="attr.id"
 | 
				
			||||||
 | 
					              class="flex items-center space-x-2 p-2 border rounded cursor-pointer hover:bg-accent"
 | 
				
			||||||
 | 
					              @click="addCustomAttributeToForm(attr)"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <div class="flex-1">
 | 
				
			||||||
 | 
					                <div class="font-medium text-sm">{{ attr.name }}</div>
 | 
				
			||||||
 | 
					                <div class="text-xs text-muted-foreground">{{ attr.data_type }}</div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					              <Plus class="w-4 h-4 text-muted-foreground" />
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { computed, onMounted, ref } from 'vue'
 | 
				
			||||||
 | 
					import { Input } from '@shared-ui/components/ui/input'
 | 
				
			||||||
 | 
					import { Button } from '@shared-ui/components/ui/button'
 | 
				
			||||||
 | 
					import { Switch } from '@shared-ui/components/ui/switch'
 | 
				
			||||||
 | 
					import { Checkbox } from '@shared-ui/components/ui/checkbox'
 | 
				
			||||||
 | 
					import { Plus, X, GripVertical } from 'lucide-vue-next'
 | 
				
			||||||
 | 
					import Draggable from 'vuedraggable'
 | 
				
			||||||
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const prechatConfig = defineModel({
 | 
				
			||||||
 | 
					  default: () => ({
 | 
				
			||||||
 | 
					    enabled: false,
 | 
				
			||||||
 | 
					    title: '',
 | 
				
			||||||
 | 
					    fields: [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        key: 'name',
 | 
				
			||||||
 | 
					        type: 'text',
 | 
				
			||||||
 | 
					        label: 'Full name',
 | 
				
			||||||
 | 
					        placeholder: 'Enter your name',
 | 
				
			||||||
 | 
					        required: true,
 | 
				
			||||||
 | 
					        enabled: true,
 | 
				
			||||||
 | 
					        order: 1,
 | 
				
			||||||
 | 
					        is_default: true
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        key: 'email',
 | 
				
			||||||
 | 
					        type: 'email',
 | 
				
			||||||
 | 
					        label: 'Email address',
 | 
				
			||||||
 | 
					        placeholder: 'your@email.com',
 | 
				
			||||||
 | 
					        required: true,
 | 
				
			||||||
 | 
					        enabled: true,
 | 
				
			||||||
 | 
					        order: 2,
 | 
				
			||||||
 | 
					        is_default: true
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const customAttributes = ref([])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const formFields = computed(() => {
 | 
				
			||||||
 | 
					  return prechatConfig.value.fields || []
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const availableCustomAttributes = computed(() => {
 | 
				
			||||||
 | 
					  const usedIds = formFields.value
 | 
				
			||||||
 | 
					    .filter((field) => field.custom_attribute_id)
 | 
				
			||||||
 | 
					    .map((field) => field.custom_attribute_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return customAttributes.value.filter((attr) => !usedIds.includes(attr.id))
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const draggableFields = computed({
 | 
				
			||||||
 | 
					  get() {
 | 
				
			||||||
 | 
					    return prechatConfig.value.fields || []
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  set(newValue) {
 | 
				
			||||||
 | 
					    const fieldsWithUpdatedOrder = newValue.map((field, index) => ({
 | 
				
			||||||
 | 
					      ...field,
 | 
				
			||||||
 | 
					      order: index + 1
 | 
				
			||||||
 | 
					    }))
 | 
				
			||||||
 | 
					    prechatConfig.value.fields = fieldsWithUpdatedOrder
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const removeField = (index) => {
 | 
				
			||||||
 | 
					  const fields = formFields.value.filter((_, i) => i !== index)
 | 
				
			||||||
 | 
					  prechatConfig.value.fields = fields
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const addCustomAttributeToForm = (attribute) => {
 | 
				
			||||||
 | 
					  const newField = {
 | 
				
			||||||
 | 
					    key: attribute.key || `custom_attr_${attribute.id || Date.now()}`,
 | 
				
			||||||
 | 
					    type: attribute.data_type,
 | 
				
			||||||
 | 
					    label: attribute.name,
 | 
				
			||||||
 | 
					    placeholder: '',
 | 
				
			||||||
 | 
					    required: false,
 | 
				
			||||||
 | 
					    enabled: false,
 | 
				
			||||||
 | 
					    order: formFields.value.length + 1,
 | 
				
			||||||
 | 
					    is_default: false,
 | 
				
			||||||
 | 
					    custom_attribute_id: attribute.id
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const fields = [...formFields.value, newField]
 | 
				
			||||||
 | 
					  prechatConfig.value.fields = fields
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const fetchCustomAttributes = async () => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    // Fetch both contact and conversation custom attributes
 | 
				
			||||||
 | 
					    const [contactAttrs, conversationAttrs] = await Promise.all([
 | 
				
			||||||
 | 
					      api.getCustomAttributes('contact'),
 | 
				
			||||||
 | 
					      api.getCustomAttributes('conversation')
 | 
				
			||||||
 | 
					    ])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    customAttributes.value = [
 | 
				
			||||||
 | 
					      ...(contactAttrs.data?.data || []),
 | 
				
			||||||
 | 
					      ...(conversationAttrs.data?.data || [])
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Clean up orphaned custom attribute fields
 | 
				
			||||||
 | 
					    const availableCustomAttrIds = customAttributes.value.map((attr) => attr.id)
 | 
				
			||||||
 | 
					    const cleanedFields = (prechatConfig.value.fields || []).filter((field) => {
 | 
				
			||||||
 | 
					      // Keep default fields
 | 
				
			||||||
 | 
					      if (field.is_default) return true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Keep custom fields that still exist
 | 
				
			||||||
 | 
					      if (field.custom_attribute_id && availableCustomAttrIds.includes(field.custom_attribute_id))
 | 
				
			||||||
 | 
					        return true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Remove orphaned custom fields
 | 
				
			||||||
 | 
					      return false
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Update fields if any were removed
 | 
				
			||||||
 | 
					    if (cleanedFields.length !== (prechatConfig.value.fields || []).length) {
 | 
				
			||||||
 | 
					      prechatConfig.value.fields = cleanedFields
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Error fetching custom attributes:', error)
 | 
				
			||||||
 | 
					    customAttributes.value = []
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					  fetchCustomAttributes()
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
import * as z from 'zod'
 | 
					import * as z from 'zod'
 | 
				
			||||||
import { isGoDuration } from '@/utils/strings'
 | 
					import { isGoDuration } from '@shared-ui/utils/string'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const createFormSchema = (t) => z.object({
 | 
					export const createFormSchema = (t) => z.object({
 | 
				
			||||||
  name: z.string().min(1, t('globals.messages.required')),
 | 
					  name: z.string().min(1, t('globals.messages.required')),
 | 
				
			||||||
@@ -0,0 +1,86 @@
 | 
				
			|||||||
 | 
					import { z } from 'zod'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const createFormSchema = (t) => z.object({
 | 
				
			||||||
 | 
					  name: z.string().min(1, { message: t('globals.messages.required') }),
 | 
				
			||||||
 | 
					  enabled: z.boolean(),
 | 
				
			||||||
 | 
					  csat_enabled: z.boolean(),
 | 
				
			||||||
 | 
					  secret: z.string(),
 | 
				
			||||||
 | 
					  linked_email_inbox_id: z.number().nullable().optional(),
 | 
				
			||||||
 | 
					  config: z.object({
 | 
				
			||||||
 | 
					    brand_name: z.string().min(1, { message: t('globals.messages.required') }),
 | 
				
			||||||
 | 
					    dark_mode: z.boolean(),
 | 
				
			||||||
 | 
					    show_powered_by: z.boolean(),
 | 
				
			||||||
 | 
					    language: z.string().min(1, { message: t('globals.messages.required') }),
 | 
				
			||||||
 | 
					    logo_url: z.string().url({
 | 
				
			||||||
 | 
					      message: t('globals.messages.invalid', {
 | 
				
			||||||
 | 
					        name: t('globals.terms.url').toLowerCase()
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    }).optional().or(z.literal('')),
 | 
				
			||||||
 | 
					    launcher: z.object({
 | 
				
			||||||
 | 
					      position: z.enum(['left', 'right']),
 | 
				
			||||||
 | 
					      logo_url: z.string().url({
 | 
				
			||||||
 | 
					        message: t('globals.messages.invalid', {
 | 
				
			||||||
 | 
					          name: t('globals.terms.url').toLowerCase()
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      }).optional().or(z.literal('')),
 | 
				
			||||||
 | 
					      spacing: z.object({
 | 
				
			||||||
 | 
					        side: z.number().min(0),
 | 
				
			||||||
 | 
					        bottom: z.number().min(0),
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    }),
 | 
				
			||||||
 | 
					    greeting_message: z.string().optional(),
 | 
				
			||||||
 | 
					    introduction_message: z.string().optional(),
 | 
				
			||||||
 | 
					    chat_introduction: z.string(),
 | 
				
			||||||
 | 
					    show_office_hours_in_chat: z.boolean(),
 | 
				
			||||||
 | 
					    show_office_hours_after_assignment: z.boolean(),
 | 
				
			||||||
 | 
					    notice_banner: z.object({
 | 
				
			||||||
 | 
					      enabled: z.boolean(),
 | 
				
			||||||
 | 
					      text: z.string().optional()
 | 
				
			||||||
 | 
					    }),
 | 
				
			||||||
 | 
					    colors: z.object({
 | 
				
			||||||
 | 
					      primary: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, {
 | 
				
			||||||
 | 
					        message: t('globals.messages.invalid', {
 | 
				
			||||||
 | 
					          name: t('admin.inbox.livechat.colors').toLowerCase()
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      }),
 | 
				
			||||||
 | 
					    }),
 | 
				
			||||||
 | 
					    features: z.object({
 | 
				
			||||||
 | 
					      file_upload: z.boolean(),
 | 
				
			||||||
 | 
					      emoji: z.boolean(),
 | 
				
			||||||
 | 
					    }),
 | 
				
			||||||
 | 
					    trusted_domains: z.string().optional(),
 | 
				
			||||||
 | 
					    external_links: z.array(z.object({
 | 
				
			||||||
 | 
					      text: z.string().min(1),
 | 
				
			||||||
 | 
					      url: z.string().url({
 | 
				
			||||||
 | 
					        message: t('globals.messages.invalid', {
 | 
				
			||||||
 | 
					          name: t('globals.terms.url').toLowerCase()
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    })),
 | 
				
			||||||
 | 
					    visitors: z.object({
 | 
				
			||||||
 | 
					      start_conversation_button_text: z.string(),
 | 
				
			||||||
 | 
					      allow_start_conversation: z.boolean(),
 | 
				
			||||||
 | 
					      prevent_multiple_conversations: z.boolean(),
 | 
				
			||||||
 | 
					    }),
 | 
				
			||||||
 | 
					    users: z.object({
 | 
				
			||||||
 | 
					      start_conversation_button_text: z.string(),
 | 
				
			||||||
 | 
					      allow_start_conversation: z.boolean(),
 | 
				
			||||||
 | 
					      prevent_multiple_conversations: z.boolean(),
 | 
				
			||||||
 | 
					    }),
 | 
				
			||||||
 | 
					    prechat_form: z.object({
 | 
				
			||||||
 | 
					      enabled: z.boolean(),
 | 
				
			||||||
 | 
					      title: z.string().optional(),
 | 
				
			||||||
 | 
					      fields: z.array(z.object({
 | 
				
			||||||
 | 
					        key: z.string().min(1),
 | 
				
			||||||
 | 
					        type: z.enum(['text', 'email', 'number', 'checkbox', 'date', 'link', 'list']),
 | 
				
			||||||
 | 
					        label: z.string().min(1, { message: t('globals.messages.required') }),
 | 
				
			||||||
 | 
					        placeholder: z.string().optional(),
 | 
				
			||||||
 | 
					        required: z.boolean(),
 | 
				
			||||||
 | 
					        enabled: z.boolean(),
 | 
				
			||||||
 | 
					        order: z.number().min(1),
 | 
				
			||||||
 | 
					        is_default: z.boolean(),
 | 
				
			||||||
 | 
					        custom_attribute_id: z.number().optional()
 | 
				
			||||||
 | 
					      }))
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
@@ -129,7 +129,7 @@
 | 
				
			|||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button'
 | 
				
			||||||
import { Plus } from 'lucide-vue-next'
 | 
					import { Plus } from 'lucide-vue-next'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
@@ -138,11 +138,11 @@ import {
 | 
				
			|||||||
  SelectItem,
 | 
					  SelectItem,
 | 
				
			||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@shared-ui/components/ui/select'
 | 
				
			||||||
import CloseButton from '@/components/button/CloseButton.vue'
 | 
					import CloseButton from '@main/components/button/CloseButton.vue'
 | 
				
			||||||
import { SelectTag } from '@/components/ui/select'
 | 
					import { SelectTag } from '@shared-ui/components/ui/select'
 | 
				
			||||||
import { useTagStore } from '@/stores/tag'
 | 
					import { useTagStore } from '../../../stores/tag'
 | 
				
			||||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
					import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const model = defineModel('actions', {
 | 
					const model = defineModel('actions', {
 | 
				
			||||||
  type: Array,
 | 
					  type: Array,
 | 
				
			||||||
@@ -150,17 +150,17 @@
 | 
				
			|||||||
import { ref, watch, computed } from 'vue'
 | 
					import { ref, watch, computed } from 'vue'
 | 
				
			||||||
import { useForm } from 'vee-validate'
 | 
					import { useForm } from 'vee-validate'
 | 
				
			||||||
import { toTypedSchema } from '@vee-validate/zod'
 | 
					import { toTypedSchema } from '@vee-validate/zod'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@shared-ui/components/ui/button/index.js'
 | 
				
			||||||
import { Spinner } from '@/components/ui/spinner'
 | 
					import { Spinner } from '@shared-ui/components/ui/spinner/index.js'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@shared-ui/components/ui/input/index.js'
 | 
				
			||||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
 | 
					import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js'
 | 
				
			||||||
import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
 | 
					import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
 | 
				
			||||||
import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
					import { useConversationFilters } from '../../../composables/useConversationFilters.js'
 | 
				
			||||||
import { useUsersStore } from '@/stores/users'
 | 
					import { useUsersStore } from '../../../stores/users.js'
 | 
				
			||||||
import { useTeamStore } from '@/stores/team'
 | 
					import { useTeamStore } from '../../../stores/team.js'
 | 
				
			||||||
import { getTextFromHTML } from '@/utils/strings.js'
 | 
					import { getTextFromHTML } from '@shared-ui/utils/string'
 | 
				
			||||||
import { createFormSchema } from './formSchema.js'
 | 
					import { createFormSchema } from './formSchema.js'
 | 
				
			||||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
					import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
@@ -169,9 +169,9 @@ import {
 | 
				
			|||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue,
 | 
					  SelectValue,
 | 
				
			||||||
  SelectTag
 | 
					  SelectTag
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@shared-ui/components/ui/select/index.js'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import Editor from '@/components/editor/TextEditor.vue'
 | 
					import Editor from '@main/components/editor/TextEditor.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const { macroActions } = useConversationFilters()
 | 
					const { macroActions } = useConversationFilters()
 | 
				
			||||||
const { t } = useI18n()
 | 
					const { t } = useI18n()
 | 
				
			||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user