mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-10-31 12:03:33 +00:00 
			
		
		
		
	Compare commits
	
		
			28 Commits
		
	
	
		
			refactor-a
			...
			v0.7.4-alp
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | d63302843b | ||
|  | a652f380b2 | ||
|  | a4a9a9ccd3 | ||
|  | 71865e389e | ||
|  | ae470be4c8 | ||
|  | 636742c34b | ||
|  | de77c03f66 | ||
|  | b7092744fd | ||
|  | 6f300bb073 | ||
|  | a8ca12fb9a | ||
|  | e4bec993e6 | ||
|  | efc01be7d3 | ||
|  | ec72c5af90 | ||
|  | 490417cf9d | ||
|  | 4f54db3d1b | ||
|  | 210b8bb53b | ||
|  | a0e1ccf117 | ||
|  | faf2082561 | ||
|  | 50baa8491b | ||
|  | 8e89e4e0d4 | ||
|  | b15413b7ca | ||
|  | 701e5b2580 | ||
|  | dbd4e97f7e | ||
|  | 007c332a7d | ||
|  | 4fcad4fd81 | ||
|  | bece58bdec | ||
|  | 6d2d8f78d4 | ||
|  | 074d147bb6 | 
							
								
								
									
										31
									
								
								.github/workflows/github-pages.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										31
									
								
								.github/workflows/github-pages.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,31 +0,0 @@ | ||||
| name: Deploy MkDocs | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - main | ||||
|  | ||||
| jobs: | ||||
|   deploy: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|  | ||||
|       - uses: actions/setup-python@v4 | ||||
|         with: | ||||
|           python-version: 3.x | ||||
|  | ||||
|       - run: pip install mkdocs-material | ||||
|  | ||||
|       - run: | | ||||
|           if [ -f requirements.txt ]; then | ||||
|             pip install -r requirements.txt; | ||||
|           fi | ||||
|  | ||||
|       - run: cd docs && mkdocs build | ||||
|  | ||||
|       - name: Deploy to GitHub Pages | ||||
|         uses: peaceiris/actions-gh-pages@v3 | ||||
|         with: | ||||
|           github_token: ${{ secrets.GITHUB_TOKEN }} | ||||
|           publish_dir: ./docs/site | ||||
							
								
								
									
										10
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								README.md
									
									
									
									
									
								
							| @@ -3,9 +3,9 @@ | ||||
|  | ||||
| # Libredesk | ||||
|  | ||||
| Open source, self-hosted customer support desk. Single binary app. | ||||
| Modern, open source, self-hosted customer support desk. Single binary app.  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/). | ||||
| @@ -67,7 +67,7 @@ docker exec -it libredesk_app ./libredesk --set-system-user-password | ||||
|  | ||||
| Go to `http://localhost:9000` and login with username `System` and the password you set using the `--set-system-user-password` command. | ||||
|  | ||||
| See [installation docs](https://libredesk.io/docs/installation/) | ||||
| See [installation docs](https://docs.libredesk.io/getting-started/installation) | ||||
|  | ||||
| __________________ | ||||
|  | ||||
| @@ -78,12 +78,12 @@ __________________ | ||||
| - Run `./libredesk --set-system-user-password` to set the password for the System user. | ||||
| - Run `./libredesk` and visit `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command. | ||||
|  | ||||
| See [installation docs](https://libredesk.io/docs/installation) | ||||
| See [installation docs](https://docs.libredesk.io/getting-started/installation) | ||||
| __________________ | ||||
|  | ||||
|  | ||||
| ## Developers | ||||
| If you are interested in contributing, refer to the [developer setup](https://libredesk.io/docs/developer-setup/). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components. | ||||
| If you are interested in contributing, refer to the [developer setup](https://docs.libredesk.io/contributing/developer-setup). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components. | ||||
|  | ||||
| ## Development Status | ||||
|  | ||||
|   | ||||
| @@ -103,9 +103,9 @@ func handleUpdateContact(r *fastglue.Request) error { | ||||
| 	if v, ok := form.Value["phone_number"]; ok && len(v) > 0 { | ||||
| 		phoneNumber = string(v[0]) | ||||
| 	} | ||||
| 	phoneNumberCallingCode := "" | ||||
| 	if v, ok := form.Value["phone_number_calling_code"]; ok && len(v) > 0 { | ||||
| 		phoneNumberCallingCode = string(v[0]) | ||||
| 	phoneNumberCountryCode := "" | ||||
| 	if v, ok := form.Value["phone_number_country_code"]; ok && len(v) > 0 { | ||||
| 		phoneNumberCountryCode = string(v[0]) | ||||
| 	} | ||||
| 	avatarURL := "" | ||||
| 	if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 { | ||||
| @@ -116,8 +116,8 @@ func handleUpdateContact(r *fastglue.Request) error { | ||||
| 	if avatarURL == "null" { | ||||
| 		avatarURL = "" | ||||
| 	} | ||||
| 	if phoneNumberCallingCode == "null" { | ||||
| 		phoneNumberCallingCode = "" | ||||
| 	if phoneNumberCountryCode == "null" { | ||||
| 		phoneNumberCountryCode = "" | ||||
| 	} | ||||
| 	if phoneNumber == "null" { | ||||
| 		phoneNumber = "" | ||||
| @@ -146,7 +146,7 @@ func handleUpdateContact(r *fastglue.Request) error { | ||||
| 		Email:                  null.StringFrom(email), | ||||
| 		AvatarURL:              null.NewString(avatarURL, avatarURL != ""), | ||||
| 		PhoneNumber:            null.NewString(phoneNumber, phoneNumber != ""), | ||||
| 		PhoneNumberCallingCode: null.NewString(phoneNumberCallingCode, phoneNumberCallingCode != ""), | ||||
| 		PhoneNumberCountryCode: null.NewString(phoneNumberCountryCode, phoneNumberCountryCode != ""), | ||||
| 	} | ||||
|  | ||||
| 	if err := app.user.UpdateContact(id, contactToUpdate); err != nil { | ||||
|   | ||||
| @@ -734,7 +734,7 @@ func handleCreateConversation(r *fastglue.Request) error { | ||||
| 			return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil)) | ||||
| 		} | ||||
| 	case umodels.UserTypeContact: | ||||
| 		// Create message on behalf of contact. | ||||
| 		// 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 { | ||||
|   | ||||
| @@ -6,6 +6,10 @@ import ( | ||||
| 	"github.com/zerodha/fastglue" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	maxCsatFeedbackLength = 1000 | ||||
| ) | ||||
|  | ||||
| // handleShowCSAT renders the CSAT page for a given csat. | ||||
| func handleShowCSAT(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| @@ -88,6 +92,11 @@ func handleUpdateCSATResponse(r *fastglue.Request) error { | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	// Trim feedback if it exceeds max length | ||||
| 	if len(feedback) > maxCsatFeedbackLength { | ||||
| 		feedback = feedback[:maxCsatFeedbackLength] | ||||
| 	} | ||||
|  | ||||
| 	if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil { | ||||
| 		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ | ||||
| 			"Data": map[string]interface{}{ | ||||
|   | ||||
| @@ -155,7 +155,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { | ||||
| 	g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage")) | ||||
|  | ||||
| 	// Roles. | ||||
| 	g.GET("/api/v1/roles", perm(handleGetRoles, "roles:manage")) | ||||
| 	g.GET("/api/v1/roles", auth(handleGetRoles)) | ||||
| 	g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage")) | ||||
| 	g.POST("/api/v1/roles", perm(handleCreateRole, "roles:manage")) | ||||
| 	g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage")) | ||||
|   | ||||
| @@ -2,10 +2,14 @@ package main | ||||
|  | ||||
| import ( | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
|  | ||||
| 	amodels "github.com/abhinavxd/libredesk/internal/auth/models" | ||||
| 	authzModels "github.com/abhinavxd/libredesk/internal/authz/models" | ||||
| 	cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" | ||||
| 	"github.com/abhinavxd/libredesk/internal/envelope" | ||||
| 	medModels "github.com/abhinavxd/libredesk/internal/media/models" | ||||
| 	umodels "github.com/abhinavxd/libredesk/internal/user/models" | ||||
| 	"github.com/valyala/fasthttp" | ||||
| 	"github.com/zerodha/fastglue" | ||||
| ) | ||||
| @@ -17,6 +21,7 @@ type messageReq struct { | ||||
| 	To          []string `json:"to"` | ||||
| 	CC          []string `json:"cc"` | ||||
| 	BCC         []string `json:"bcc"` | ||||
| 	SenderType  string   `json:"sender_type"` | ||||
| } | ||||
|  | ||||
| // handleGetMessages returns messages for a conversation. | ||||
| @@ -150,7 +155,31 @@ func handleSendMessage(r *fastglue.Request) error { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	// Prepare attachments. | ||||
| 	if req.SenderType != umodels.UserTypeAgent && req.SenderType != umodels.UserTypeContact { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`sender_type`"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	// Contacts cannot send private messages | ||||
| 	if req.SenderType == umodels.UserTypeContact && req.Private { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	// Check if user has permission to send messages as contact | ||||
| 	if req.SenderType == umodels.UserTypeContact { | ||||
| 		parts := strings.Split(authzModels.PermMessagesWriteAsContact, ":") | ||||
| 		if len(parts) != 2 { | ||||
| 			return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil)) | ||||
| 		} | ||||
| 		ok, err := app.authz.Enforce(user, parts[0], parts[1]) | ||||
| 		if err != nil { | ||||
| 			return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil)) | ||||
| 		} | ||||
| 		if !ok { | ||||
| 			return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Get media for all attachments. | ||||
| 	var media = make([]medModels.Media, 0, len(req.Attachments)) | ||||
| 	for _, id := range req.Attachments { | ||||
| 		m, err := app.media.Get(id, "") | ||||
| @@ -161,6 +190,16 @@ func handleSendMessage(r *fastglue.Request) error { | ||||
| 		media = append(media, m) | ||||
| 	} | ||||
|  | ||||
| 	// Create contact message. | ||||
| 	if req.SenderType == umodels.UserTypeContact { | ||||
| 		message, err := app.conversation.CreateContactMessage(media, int(conv.ContactID), cuuid, req.Message, cmodels.ContentTypeHTML) | ||||
| 		if err != nil { | ||||
| 			return sendErrorEnvelope(r, err) | ||||
| 		} | ||||
| 		return r.SendEnvelope(message) | ||||
| 	} | ||||
|  | ||||
| 	// Send private note. | ||||
| 	if req.Private { | ||||
| 		message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message) | ||||
| 		if err != nil { | ||||
| @@ -168,6 +207,8 @@ func handleSendMessage(r *fastglue.Request) error { | ||||
| 		} | ||||
| 		return r.SendEnvelope(message) | ||||
| 	} | ||||
|  | ||||
| 	// Queue reply. | ||||
| 	message, err := app.conversation.QueueReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
|   | ||||
| @@ -35,6 +35,7 @@ var migList = []migFunc{ | ||||
| 	{"v0.5.0", migrations.V0_5_0}, | ||||
| 	{"v0.6.0", migrations.V0_6_0}, | ||||
| 	{"v0.7.0", migrations.V0_7_0}, | ||||
| 	{"v0.7.4", migrations.V0_7_4}, | ||||
| } | ||||
|  | ||||
| // upgrade upgrades the database to the current version by running SQL migration files | ||||
|   | ||||
| @@ -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 | ||||
| @@ -137,10 +137,10 @@ | ||||
|     --background: 240 5.9% 10%; | ||||
|     --foreground: 0 0% 98%; | ||||
|  | ||||
|     --card: 240 10% 3.9%; | ||||
|     --card: 240 5.9% 10%; | ||||
|     --card-foreground: 0 0% 98%; | ||||
|  | ||||
|     --popover: 240 10% 3.9%; | ||||
|     --popover: 240 5.9% 10%; | ||||
|     --popover-foreground: 0 0% 98%; | ||||
|  | ||||
|     --primary: 0 0% 98%; | ||||
| @@ -184,6 +184,10 @@ | ||||
|   @apply border shadow rounded; | ||||
| } | ||||
|  | ||||
| .loading-fade { | ||||
|   @apply opacity-50 transition-opacity duration-300 | ||||
| } | ||||
|  | ||||
| // Scrollbar start | ||||
| ::-webkit-scrollbar { | ||||
|   width: 8px; /* Adjust width */ | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <template> | ||||
|   <Button | ||||
|     variant="ghost" | ||||
|     @click.prevent="onClose" | ||||
|     @click.stop="onClose" | ||||
|     size="xs" | ||||
|     class="text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 w-6 h-6 p-0" | ||||
|   > | ||||
|   | ||||
| @@ -52,8 +52,15 @@ | ||||
|         <div class="flex-1"> | ||||
|           <div v-if="modelFilter.field && modelFilter.operator"> | ||||
|             <template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'"> | ||||
|               <SelectTag | ||||
|                 v-if="getFieldType(modelFilter) === FIELD_TYPE.MULTI_SELECT" | ||||
|                 v-model="modelFilter.value" | ||||
|                 :items="getFieldOptions(modelFilter)" | ||||
|                 :placeholder="t('globals.messages.select', { name: t('globals.terms.tag', 2) })" | ||||
|               /> | ||||
|  | ||||
|               <SelectComboBox | ||||
|                 v-if=" | ||||
|                 v-else-if=" | ||||
|                   getFieldOptions(modelFilter).length > 0 && | ||||
|                   modelFilter.field === 'assigned_user_id' | ||||
|                 " | ||||
| @@ -94,8 +101,9 @@ | ||||
|       <CloseButton :onClose="() => removeFilter(index)" /> | ||||
|     </div> | ||||
|  | ||||
|     <!-- Button Container --> | ||||
|     <div class="flex items-center justify-between pt-3"> | ||||
|       <Button variant="ghost" size="sm" @click="addFilter" class="text-slate-600"> | ||||
|       <Button variant="ghost" size="sm" @click.stop="addFilter" class="text-slate-600"> | ||||
|         <Plus class="w-3 h-3 mr-1" /> | ||||
|         {{ | ||||
|           $t('globals.messages.add', { | ||||
| @@ -104,15 +112,17 @@ | ||||
|         }} | ||||
|       </Button> | ||||
|       <div class="flex gap-2" v-if="showButtons"> | ||||
|         <Button variant="ghost" @click="clearFilters">{{ $t('globals.messages.reset') }}</Button> | ||||
|         <Button @click="applyFilters">{{ $t('globals.messages.apply') }}</Button> | ||||
|         <Button variant="ghost" @click.stop="clearFilters"> | ||||
|           {{ $t('globals.messages.reset') }} | ||||
|         </Button> | ||||
|         <Button @click.stop="applyFilters">{{ $t('globals.messages.apply') }}</Button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { computed, onMounted, watch } from 'vue' | ||||
| import { computed, onMounted, onUnmounted, watch } from 'vue' | ||||
| import { | ||||
|   Select, | ||||
|   SelectContent, | ||||
| @@ -125,8 +135,10 @@ import { Plus } from 'lucide-vue-next' | ||||
| import { Button } from '@/components/ui/button' | ||||
| import { Input } from '@/components/ui/input' | ||||
| import { useI18n } from 'vue-i18n' | ||||
| import { FIELD_TYPE } from '@/constants/filterConfig' | ||||
| import CloseButton from '@/components/button/CloseButton.vue' | ||||
| import SelectComboBox from '@/components/combobox/SelectCombobox.vue' | ||||
| import SelectTag from '@/components/ui/select/SelectTag.vue' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   fields: { | ||||
| @@ -150,12 +162,17 @@ onMounted(() => { | ||||
|   } | ||||
| }) | ||||
|  | ||||
| onUnmounted(() => { | ||||
|   // On unmounted set valid filters | ||||
|   modelValue.value = validFilters.value | ||||
| }) | ||||
|  | ||||
| const getModel = (field) => { | ||||
|   const fieldConfig = props.fields.find((f) => f.field === field) | ||||
|   return fieldConfig?.model || '' | ||||
| } | ||||
|  | ||||
| // Set model for each filter | ||||
| // Set model for each filter and the default value | ||||
| watch( | ||||
|   () => modelValue.value, | ||||
|   (filters) => { | ||||
| @@ -163,6 +180,15 @@ watch( | ||||
|       if (filter.field && !filter.model) { | ||||
|         filter.model = getModel(filter.field) | ||||
|       } | ||||
|  | ||||
|       // Multi select need arrays as their default value | ||||
|       if ( | ||||
|         filter.field && | ||||
|         getFieldType(filter) === FIELD_TYPE.MULTI_SELECT && | ||||
|         !Array.isArray(filter.value) | ||||
|       ) { | ||||
|         filter.value = [] | ||||
|       } | ||||
|     }) | ||||
|   }, | ||||
|   { deep: true } | ||||
| @@ -170,15 +196,20 @@ watch( | ||||
|  | ||||
| // Reset operator and value when field changes for a filter at a given index | ||||
| watch( | ||||
|   () => modelValue.value.map((f) => f.field), | ||||
|   (newFields, oldFields) => { | ||||
|     newFields.forEach((field, index) => { | ||||
|       if (field !== oldFields[index]) { | ||||
|         modelValue.value[index].operator = '' | ||||
|         modelValue.value[index].value = '' | ||||
|   modelValue, | ||||
|   (newFilters, oldFilters) => { | ||||
|     // Skip first run | ||||
|     if (!oldFilters) return | ||||
|  | ||||
|     newFilters.forEach((filter, index) => { | ||||
|       const oldFilter = oldFilters[index] | ||||
|       if (oldFilter && filter.field !== oldFilter.field) { | ||||
|         filter.operator = '' | ||||
|         filter.value = '' | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
|   }, | ||||
|   { deep: true } | ||||
| ) | ||||
|  | ||||
| const addFilter = () => { | ||||
| @@ -197,7 +228,17 @@ const clearFilters = () => { | ||||
| } | ||||
|  | ||||
| const validFilters = computed(() => { | ||||
|   return modelValue.value.filter((filter) => filter.field && filter.operator && filter.value) | ||||
|   return modelValue.value.filter((filter) => { | ||||
|     // For multi-select field type, allow empty array as a valid value | ||||
|     const field = props.fields.find((f) => f.field === filter.field) | ||||
|     const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT | ||||
|  | ||||
|     if (isMultiSelectField) { | ||||
|       return filter.field && filter.operator && filter.value !== undefined && filter.value !== null | ||||
|     } | ||||
|  | ||||
|     return filter.field && filter.operator && filter.value | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| const getFieldOptions = (fieldValue) => { | ||||
| @@ -209,4 +250,9 @@ const getFieldOperators = (modelFilter) => { | ||||
|   const field = props.fields.find((f) => f.field === modelFilter.field) | ||||
|   return field?.operators || [] | ||||
| } | ||||
|  | ||||
| const getFieldType = (modelFilter) => { | ||||
|   const field = props.fields.find((f) => f.field === modelFilter.field) | ||||
|   return field?.type || '' | ||||
| } | ||||
| </script> | ||||
|   | ||||
| @@ -38,6 +38,16 @@ import { | ||||
|   DropdownMenuItem, | ||||
|   DropdownMenuTrigger | ||||
| } from '@/components/ui/dropdown-menu' | ||||
| import { | ||||
|   AlertDialog, | ||||
|   AlertDialogAction, | ||||
|   AlertDialogCancel, | ||||
|   AlertDialogContent, | ||||
|   AlertDialogDescription, | ||||
|   AlertDialogFooter, | ||||
|   AlertDialogHeader, | ||||
|   AlertDialogTitle | ||||
| } from '@/components/ui/alert-dialog' | ||||
| import { filterNavItems } from '@/utils/nav-permissions' | ||||
| import { useStorage } from '@vueuse/core' | ||||
| import { computed, ref, watch } from 'vue' | ||||
| @@ -73,8 +83,17 @@ const editView = (view) => { | ||||
|   emit('editView', view) | ||||
| } | ||||
|  | ||||
| const deleteView = (view) => { | ||||
|   emit('deleteView', view) | ||||
| const openDeleteConfirmation = (view) => { | ||||
|   viewToDelete.value = view | ||||
|   isDeleteOpen.value = true | ||||
| } | ||||
|  | ||||
| const handleDeleteView = () => { | ||||
|   if (viewToDelete.value) { | ||||
|     emit('deleteView', viewToDelete.value) | ||||
|     isDeleteOpen.value = false | ||||
|     viewToDelete.value = null | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Navigation methods with conversation retention | ||||
| @@ -157,6 +176,13 @@ watch( | ||||
| const sidebarOpen = useStorage('mainSidebarOpen', true) | ||||
| const teamInboxOpen = useStorage('teamInboxOpen', true) | ||||
| const viewInboxOpen = useStorage('viewInboxOpen', true) | ||||
|  | ||||
| // Track which view is being hovered for ellipsis menu visibility | ||||
| const hoveredViewId = ref(null) | ||||
|  | ||||
| // Track delete confirmation dialog state | ||||
| const isDeleteOpen = ref(false) | ||||
| const viewToDelete = ref(null) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| @@ -472,24 +498,35 @@ const viewInboxOpen = useStorage('viewInboxOpen', true) | ||||
|  | ||||
|                   <CollapsibleContent> | ||||
|                     <SidebarMenuSub v-for="view in userViews" :key="view.id"> | ||||
|                       <SidebarMenuSubItem> | ||||
|                       <SidebarMenuSubItem | ||||
|                         @mouseenter="hoveredViewId = view.id" | ||||
|                         @mouseleave="hoveredViewId = null" | ||||
|                       > | ||||
|                         <SidebarMenuButton | ||||
|                           size="sm" | ||||
|                           :isActive="route.params.viewID == view.id" | ||||
|                           asChild | ||||
|                         > | ||||
|                           <a href="#" @click.prevent="navigateToViewInbox(view.id)"> | ||||
|                             <span class="break-words w-32 truncate">{{ view.name }}</span> | ||||
|                             <SidebarMenuAction :showOnHover="true" class="mr-3"> | ||||
|                             <span class="break-words w-32 truncate" :title="view.name">{{ view.name }}</span> | ||||
|                             <SidebarMenuAction | ||||
|                               @click.stop | ||||
|                               :class="[ | ||||
|                                 'mr-3', | ||||
|                                 'md:opacity-0', | ||||
|                                 'data-[state=open]:opacity-100', | ||||
|                                 { 'md:opacity-100': hoveredViewId === view.id } | ||||
|                               ]" | ||||
|                             > | ||||
|                               <DropdownMenu> | ||||
|                                 <DropdownMenuTrigger asChild> | ||||
|                                 <DropdownMenuTrigger asChild @click.prevent> | ||||
|                                   <EllipsisVertical /> | ||||
|                                 </DropdownMenuTrigger> | ||||
|                                 <DropdownMenuContent> | ||||
|                                   <DropdownMenuItem @click="() => editView(view)"> | ||||
|                                     <span>{{ t('globals.messages.edit') }}</span> | ||||
|                                   </DropdownMenuItem> | ||||
|                                   <DropdownMenuItem @click="() => deleteView(view)"> | ||||
|                                   <DropdownMenuItem @click="() => openDeleteConfirmation(view)"> | ||||
|                                     <span>{{ t('globals.messages.delete') }}</span> | ||||
|                                   </DropdownMenuItem> | ||||
|                                 </DropdownMenuContent> | ||||
| @@ -513,4 +550,22 @@ const viewInboxOpen = useStorage('viewInboxOpen', true) | ||||
|       <slot></slot> | ||||
|     </SidebarInset> | ||||
|   </SidebarProvider> | ||||
|  | ||||
|   <!-- View Delete Confirmation Dialog --> | ||||
|   <AlertDialog v-model:open="isDeleteOpen"> | ||||
|     <AlertDialogContent> | ||||
|       <AlertDialogHeader> | ||||
|         <AlertDialogTitle>{{ t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle> | ||||
|         <AlertDialogDescription> | ||||
|           {{ t('globals.messages.deletionConfirmation', { name: t('globals.terms.view') }) }} | ||||
|         </AlertDialogDescription> | ||||
|       </AlertDialogHeader> | ||||
|       <AlertDialogFooter> | ||||
|         <AlertDialogCancel>{{ t('globals.messages.cancel') }}</AlertDialogCancel> | ||||
|         <AlertDialogAction @click="handleDeleteView"> | ||||
|           {{ t('globals.messages.delete') }} | ||||
|         </AlertDialogAction> | ||||
|       </AlertDialogFooter> | ||||
|     </AlertDialogContent> | ||||
|   </AlertDialog> | ||||
| </template> | ||||
|   | ||||
| @@ -8,7 +8,7 @@ | ||||
|         :class="['w-full justify-between', buttonClass]" | ||||
|       > | ||||
|         <slot name="selected" :selected="selectedItem">{{ selectedLabel }}</slot> | ||||
|         <CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" /> | ||||
|         <CaretSortIcon class="h-4 w-4 shrink-0 opacity-50" /> | ||||
|       </Button> | ||||
|     </PopoverTrigger> | ||||
|     <PopoverContent class="p-0"> | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| <template> | ||||
|   <!-- idk why I named this select tag, should be named multi-select --> | ||||
|   <TagsInput v-model="tags" class="px-0 gap-0" :displayValue="getLabel"> | ||||
|     <!-- Tags visible to the user --> | ||||
|     <div class="flex gap-2 flex-wrap items-center px-3"> | ||||
| @@ -24,6 +25,7 @@ | ||||
|             @keydown.enter.prevent | ||||
|             @blur="handleBlur" | ||||
|             @click="open = true" | ||||
|             @input.stop | ||||
|           /> | ||||
|         </ComboboxInput> | ||||
|       </ComboboxAnchor> | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import { useUsersStore } from '@/stores/users' | ||||
| import { useTeamStore } from '@/stores/team' | ||||
| import { useSlaStore } from '@/stores/sla' | ||||
| import { useCustomAttributeStore } from '@/stores/customAttributes' | ||||
| import { useTagStore } from '@/stores/tag' | ||||
| import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig' | ||||
| import { useI18n } from 'vue-i18n' | ||||
|  | ||||
| @@ -15,6 +16,7 @@ export function useConversationFilters () { | ||||
|     const tStore = useTeamStore() | ||||
|     const slaStore = useSlaStore() | ||||
|     const customAttributeStore = useCustomAttributeStore() | ||||
|     const tagStore = useTagStore() | ||||
|     const { t } = useI18n() | ||||
|  | ||||
|     const customAttributeDataTypeToFieldType = { | ||||
| @@ -69,6 +71,12 @@ export function useConversationFilters () { | ||||
|             type: FIELD_TYPE.SELECT, | ||||
|             operators: FIELD_OPERATORS.SELECT, | ||||
|             options: iStore.options | ||||
|         }, | ||||
|         tags: { | ||||
|             label: t('globals.terms.tag', 2), | ||||
|             type: FIELD_TYPE.MULTI_SELECT, | ||||
|             operators: FIELD_OPERATORS.MULTI_SELECT, | ||||
|             options: tagStore.tagOptions | ||||
|         } | ||||
|     })) | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| export const FIELD_TYPE = { | ||||
|     SELECT: 'select', | ||||
|     TAG: 'tag', | ||||
|     MULTI_SELECT: 'multi-select', | ||||
|     TEXT: 'text', | ||||
|     NUMBER: 'number', | ||||
|     RICHTEXT: 'richtext', | ||||
| @@ -39,4 +40,5 @@ export const FIELD_OPERATORS = { | ||||
|         OPERATOR.LESS_THAN | ||||
|     ], | ||||
|     NUMBER: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.GREATER_THAN, OPERATOR.LESS_THAN], | ||||
|     MULTI_SELECT: [OPERATOR.CONTAINS, OPERATOR.NOT_CONTAINS, OPERATOR.SET, OPERATOR.NOT_SET] | ||||
| } | ||||
|   | ||||
| @@ -12,6 +12,7 @@ export const permissions = { | ||||
|   CONVERSATIONS_UPDATE_TAGS: 'conversations:update_tags', | ||||
|   MESSAGES_READ: 'messages:read', | ||||
|   MESSAGES_WRITE: 'messages:write', | ||||
|   MESSAGES_WRITE_AS_CONTACT: 'messages:write_as_contact', | ||||
|   VIEW_MANAGE: 'view:manage', | ||||
|   GENERAL_SETTINGS_MANAGE: 'general_settings:manage', | ||||
|   NOTIFICATION_SETTINGS_MANAGE: 'notification_settings:manage', | ||||
|   | ||||
| @@ -9,7 +9,7 @@ export const createColumns = (t) => [ | ||||
|       return h('div', { class: 'text-center' }, t('globals.terms.firstName')) | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('first_name')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('first_name')) | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
| @@ -18,7 +18,7 @@ export const createColumns = (t) => [ | ||||
|       return h('div', { class: 'text-center' }, t('globals.terms.lastName')) | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('last_name')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('last_name')) | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
| @@ -27,7 +27,7 @@ export const createColumns = (t) => [ | ||||
|       return h('div', { class: 'text-center' }, t('globals.terms.enabled')) | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no')) | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
| @@ -36,7 +36,7 @@ export const createColumns = (t) => [ | ||||
|       return h('div', { class: 'text-center' }, t('globals.terms.email')) | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('email')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('email')) | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
| @@ -47,7 +47,7 @@ export const createColumns = (t) => [ | ||||
|     cell: function ({ row }) { | ||||
|       return h( | ||||
|         'div', | ||||
|         { class: 'text-center font-medium' }, | ||||
|         { class: 'text-center' }, | ||||
|         format(row.getValue('created_at'), 'PPpp') | ||||
|       ) | ||||
|     } | ||||
| @@ -60,7 +60,7 @@ export const createColumns = (t) => [ | ||||
|     cell: function ({ row }) { | ||||
|       return h( | ||||
|         'div', | ||||
|         { class: 'text-center font-medium' }, | ||||
|         { class: 'text-center' }, | ||||
|         format(row.getValue('updated_at'), 'PPpp') | ||||
|       ) | ||||
|     } | ||||
|   | ||||
| @@ -9,7 +9,7 @@ export const createColumns = (t) => [ | ||||
|             return h('div', { class: 'text-center' }, t('globals.terms.name')) | ||||
|         }, | ||||
|         cell: function ({ row }) { | ||||
|             return h('div', { class: 'text-center font-medium' }, row.getValue('name')) | ||||
|             return h('div', { class: 'text-center' }, row.getValue('name')) | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
| @@ -18,7 +18,7 @@ export const createColumns = (t) => [ | ||||
|             return h('div', { class: 'text-center' }, t('globals.terms.createdAt')) | ||||
|         }, | ||||
|         cell: function ({ row }) { | ||||
|             return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp')) | ||||
|             return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp')) | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
| @@ -27,7 +27,7 @@ export const createColumns = (t) => [ | ||||
|             return h('div', { class: 'text-center' }, t('globals.terms.updatedAt')) | ||||
|         }, | ||||
|         cell: function ({ row }) { | ||||
|             return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp')) | ||||
|             return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp')) | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
|   | ||||
| @@ -9,7 +9,7 @@ export const createColumns = (t) => [ | ||||
|             return h('div', { class: 'text-center' }, t('globals.terms.name')) | ||||
|         }, | ||||
|         cell: function ({ row }) { | ||||
|             return h('div', { class: 'text-center font-medium' }, row.getValue('name')) | ||||
|             return h('div', { class: 'text-center' }, row.getValue('name')) | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
| @@ -18,7 +18,7 @@ export const createColumns = (t) => [ | ||||
|             return h('div', { class: 'text-center' }, t('globals.terms.key')) | ||||
|         }, | ||||
|         cell: function ({ row }) { | ||||
|             return h('div', { class: 'text-center font-medium' }, row.getValue('key')) | ||||
|             return h('div', { class: 'text-center' }, row.getValue('key')) | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
| @@ -27,7 +27,7 @@ export const createColumns = (t) => [ | ||||
|             return h('div', { class: 'text-center' }, t('globals.terms.type')) | ||||
|         }, | ||||
|         cell: function ({ row }) { | ||||
|             return h('div', { class: 'text-center font-medium' }, row.getValue('data_type')) | ||||
|             return h('div', { class: 'text-center' }, row.getValue('data_type')) | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
| @@ -36,7 +36,7 @@ export const createColumns = (t) => [ | ||||
|             return h('div', { class: 'text-center' }, t('globals.terms.appliesTo')) | ||||
|         }, | ||||
|         cell: function ({ row }) { | ||||
|             return h('div', { class: 'text-center font-medium' }, row.getValue('applies_to')) | ||||
|             return h('div', { class: 'text-center' }, row.getValue('applies_to')) | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
| @@ -47,7 +47,7 @@ export const createColumns = (t) => [ | ||||
|         cell: function ({ row }) { | ||||
|             return h( | ||||
|                 'div', | ||||
|                 { class: 'text-center font-medium' }, | ||||
|                 { class: 'text-center' }, | ||||
|                 format(row.getValue('created_at'), 'PPpp') | ||||
|             ) | ||||
|         } | ||||
| @@ -60,7 +60,7 @@ export const createColumns = (t) => [ | ||||
|         cell: function ({ row }) { | ||||
|             return h( | ||||
|                 'div', | ||||
|                 { class: 'text-center font-medium' }, | ||||
|                 { class: 'text-center' }, | ||||
|                 format(row.getValue('updated_at'), 'PPpp') | ||||
|             ) | ||||
|         } | ||||
|   | ||||
| @@ -9,7 +9,7 @@ export const createColumns = (t) => [ | ||||
|       return h('div', { class: 'text-center' }, t('globals.terms.name')) | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('name')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('name')) | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|   | ||||
| @@ -9,7 +9,7 @@ export const createColumns = (t) => [ | ||||
|       return h('div', { class: 'text-center' }, t('globals.terms.name')) | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('name')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('name')) | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
| @@ -18,7 +18,7 @@ export const createColumns = (t) => [ | ||||
|       return h('div', { class: 'text-center' }, t('globals.terms.provider')) | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('provider')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('provider')) | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|   | ||||
| @@ -140,6 +140,7 @@ const permissions = ref([ | ||||
|       { name: perms.CONVERSATIONS_UPDATE_TAGS, label: t('admin.role.conversations.updateTags') }, | ||||
|       { name: perms.MESSAGES_READ, label: t('admin.role.messages.read') }, | ||||
|       { name: perms.MESSAGES_WRITE, label: t('admin.role.messages.write') }, | ||||
|       { name: perms.MESSAGES_WRITE_AS_CONTACT, label: t('admin.role.messages.writeAsContact') }, | ||||
|       { name: perms.VIEW_MANAGE, label: t('admin.role.view.manage') } | ||||
|     ] | ||||
|   }, | ||||
|   | ||||
| @@ -8,7 +8,7 @@ export const createColumns = (t) => [ | ||||
|       return h('div', { class: 'text-center' }, t('globals.terms.name')) | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('name')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('name')) | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
| @@ -17,7 +17,7 @@ export const createColumns = (t) => [ | ||||
|       return h('div', { class: 'text-center' }, t('globals.terms.description')) | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('description')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('description')) | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|   | ||||
| @@ -9,7 +9,7 @@ export const createColumns = (t) => [ | ||||
|             return h('div', { class: 'text-center' }, t('globals.terms.name')) | ||||
|         }, | ||||
|         cell: function ({ row }) { | ||||
|             return h('div', { class: 'text-center font-medium' }, row.getValue('name')) | ||||
|             return h('div', { class: 'text-center' }, row.getValue('name')) | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
| @@ -18,7 +18,7 @@ export const createColumns = (t) => [ | ||||
|             return h('div', { class: 'text-center' }, t('globals.terms.createdAt')) | ||||
|         }, | ||||
|         cell: function ({ row }) { | ||||
|             return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp')) | ||||
|             return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp')) | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
| @@ -27,7 +27,7 @@ export const createColumns = (t) => [ | ||||
|             return h('div', { class: 'text-center' }, t('globals.terms.updatedAt')) | ||||
|         }, | ||||
|         cell: function ({ row }) { | ||||
|             return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp')) | ||||
|             return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp')) | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
|   | ||||
| @@ -9,7 +9,7 @@ export const createColumns = (t) => [ | ||||
|       return h('div', { class: 'text-center' }, t('globals.terms.name')) | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('name')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('name')) | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|   | ||||
| @@ -9,7 +9,7 @@ export const createColumns = (t) => [ | ||||
|       return h('div', { class: 'text-center' }, t('globals.terms.name')) | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('name')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('name')) | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|   | ||||
| @@ -9,7 +9,7 @@ export const columns = [ | ||||
|       return h('div', { class: 'text-center' }, 'Name') | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('name')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('name')) | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
| @@ -20,7 +20,7 @@ export const columns = [ | ||||
|     cell: function ({ row }) { | ||||
|       return h( | ||||
|         'div', | ||||
|         { class: 'text-center font-medium' }, | ||||
|         { class: 'text-center' }, | ||||
|         format(row.getValue('created_at'), 'PPpp') | ||||
|       ) | ||||
|     } | ||||
| @@ -33,7 +33,7 @@ export const columns = [ | ||||
|     cell: function ({ row }) { | ||||
|       return h( | ||||
|         'div', | ||||
|         { class: 'text-center font-medium' }, | ||||
|         { class: 'text-center' }, | ||||
|         format(row.getValue('updated_at'), 'PPpp') | ||||
|       ) | ||||
|     } | ||||
|   | ||||
| @@ -9,7 +9,7 @@ export const createOutgoingEmailTableColumns = (t) => [ | ||||
|       return h('div', { class: 'text-center' }, t('globals.terms.name')) | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('name')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('name')) | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
| @@ -60,7 +60,7 @@ export const createEmailNotificationTableColumns = (t) => [ | ||||
|       return h('div', { class: 'text-center' }, t('globals.terms.name')) | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('name')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('name')) | ||||
|     } | ||||
|   }, | ||||
|  | ||||
|   | ||||
| @@ -10,7 +10,7 @@ export const createColumns = (t) => [ | ||||
|       return h('div', { class: 'text-center' }, t('globals.terms.name')) | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('name')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('name')) | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|   | ||||
| @@ -41,8 +41,8 @@ | ||||
|  | ||||
|       <div class="flex flex-col flex-1"> | ||||
|         <div class="flex items-end"> | ||||
|           <FormField v-slot="{ componentField }" name="phone_number_calling_code"> | ||||
|             <FormItem class="w-20"> | ||||
|           <FormField v-slot="{ componentField }" name="phone_number_country_code"> | ||||
|             <FormItem class="w-max"> | ||||
|               <FormLabel class="flex items-center whitespace-nowrap"> | ||||
|                 {{ t('globals.terms.phoneNumber') }} | ||||
|               </FormLabel> | ||||
| @@ -58,13 +58,18 @@ | ||||
|                       <div class="w-7 h-7 flex items-center justify-center"> | ||||
|                         <span v-if="item.emoji">{{ item.emoji }}</span> | ||||
|                       </div> | ||||
|                       <span class="text-sm">{{ item.label }} ({{ item.value }})</span> | ||||
|                       <span class="text-sm">{{ item.label }} ({{ item.calling_code }})</span> | ||||
|                     </div> | ||||
|                   </template> | ||||
|  | ||||
|                   <template #selected="{ selected }"> | ||||
|                     <div class="flex items-center mb-1"> | ||||
|                       <span v-if="selected" class="text-xl leading-none">{{ selected.emoji }}</span> | ||||
|                     <div class="flex items-center gap-1"> | ||||
|                       <span v-if="selected" class="text-lg">{{ selected.emoji }}</span> | ||||
|                       <span | ||||
|                         v-if="selected && selected.calling_code" | ||||
|                         class="text-xs text-muted-foreground" | ||||
|                         >({{ selected.calling_code }})</span | ||||
|                       > | ||||
|                     </div> | ||||
|                   </template> | ||||
|                 </ComboBox> | ||||
| @@ -116,7 +121,8 @@ const userStore = useUserStore() | ||||
|  | ||||
| const allCountries = countries.map((country) => ({ | ||||
|   label: country.name, | ||||
|   value: country.calling_code, | ||||
|   emoji: country.emoji | ||||
|   value: country.iso_2, | ||||
|   emoji: country.emoji, | ||||
|   calling_code: country.calling_code | ||||
| })) | ||||
| </script> | ||||
|   | ||||
| @@ -33,13 +33,7 @@ | ||||
|             /> | ||||
|           </div> | ||||
|           <div class="flex justify-end space-x-3 pt-2"> | ||||
|             <Button | ||||
|               variant="outline" | ||||
|               @click="cancelAddNote" | ||||
|               class="transition-all hover:bg-gray-100" | ||||
|             > | ||||
|               Cancel | ||||
|             </Button> | ||||
|             <Button variant="outline" @click="cancelAddNote"> Cancel </Button> | ||||
|             <Button type="submit" :disabled="!newNote.trim()"> | ||||
|               {{ $t('globals.messages.save') + ' ' + $t('globals.terms.note').toLowerCase() }} | ||||
|             </Button> | ||||
| @@ -53,13 +47,13 @@ | ||||
|       <Card | ||||
|         v-for="note in notes" | ||||
|         :key="note.id" | ||||
|         class="overflow-hidden border-gray-2 hover:border-gray-300 transition-all duration-200 box hover:shadow" | ||||
|         class="overflow-hidden border-gray-2 dark:hover:border-gray-700 hover:border-gray-300 transition-all duration-200 box hover:shadow" | ||||
|       > | ||||
|         <!-- Header --> | ||||
|         <CardHeader class="bg-gray-50/50 dark:bg-secondary border-b p-2"> | ||||
|         <CardHeader class="bg-background border-b p-2"> | ||||
|           <div class="flex items-center justify-between"> | ||||
|             <div class="flex items-center space-x-3"> | ||||
|               <Avatar class="border border-gray-200 shadow-sm"> | ||||
|               <Avatar class="border shadow-sm"> | ||||
|                 <AvatarImage :src="note.avatar_url" /> | ||||
|                 <AvatarFallback> | ||||
|                   {{ getInitials(note.first_name, note.last_name) }} | ||||
|   | ||||
| @@ -29,7 +29,7 @@ export const createFormSchema = (t) => z.object({ | ||||
|             }) | ||||
|         }) | ||||
|         .nullable(), | ||||
|     phone_number_calling_code: z.string().optional().nullable(), | ||||
|     phone_number_country_code: z.string().optional().nullable(), | ||||
|     avatar_url: z.string().optional().nullable(), | ||||
|     email: z | ||||
|         .string({ | ||||
|   | ||||
| @@ -122,6 +122,7 @@ import { Input } from '@/components/ui/input' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import { useFileUpload } from '@/composables/useFileUpload' | ||||
| import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue' | ||||
| import { UserTypeAgent } from '@/constants/user' | ||||
| import { | ||||
|   Form, | ||||
|   FormField, | ||||
| @@ -252,6 +253,7 @@ const processSend = async () => { | ||||
|     if (hasTextContent.value > 0 || mediaFiles.value.length > 0) { | ||||
|       const message = htmlContent.value | ||||
|       await api.sendMessage(conversationStore.current.uuid, { | ||||
|         sender_type: UserTypeAgent, | ||||
|         private: messageType.value === 'private_note', | ||||
|         message: message, | ||||
|         attachments: mediaFiles.value.map((file) => file.id), | ||||
|   | ||||
| @@ -13,7 +13,9 @@ | ||||
|           <SelectComboBox | ||||
|             v-model="conversationStore.current.assigned_user_id" | ||||
|             :items="[{ value: 'none', label: 'None' }, ...usersStore.options]" | ||||
|             :placeholder="t('globals.messages.select', { name: t('globals.terms.agent').toLowerCase() })" | ||||
|             :placeholder=" | ||||
|               t('globals.messages.select', { name: t('globals.terms.agent').toLowerCase() }) | ||||
|             " | ||||
|             @select="selectAgent" | ||||
|             type="user" | ||||
|           /> | ||||
| @@ -22,7 +24,9 @@ | ||||
|           <SelectComboBox | ||||
|             v-model="conversationStore.current.assigned_team_id" | ||||
|             :items="[{ value: 'none', label: 'None' }, ...teamsStore.options]" | ||||
|             :placeholder="t('globals.messages.select', { name: t('globals.terms.team').toLowerCase() })" | ||||
|             :placeholder=" | ||||
|               t('globals.messages.select', { name: t('globals.terms.team').toLowerCase() }) | ||||
|             " | ||||
|             @select="selectTeam" | ||||
|             type="team" | ||||
|           /> | ||||
| @@ -31,7 +35,9 @@ | ||||
|           <SelectComboBox | ||||
|             v-model="conversationStore.current.priority_id" | ||||
|             :items="priorityOptions" | ||||
|             :placeholder="t('globals.messages.select', { name: t('globals.terms.priority').toLowerCase() })" | ||||
|             :placeholder=" | ||||
|               t('globals.messages.select', { name: t('globals.terms.priority').toLowerCase() }) | ||||
|             " | ||||
|             @select="selectPriority" | ||||
|             type="priority" | ||||
|           /> | ||||
| @@ -41,7 +47,9 @@ | ||||
|             v-if="conversationStore.current" | ||||
|             v-model="conversationStore.current.tags" | ||||
|             :items="tags.map((tag) => ({ label: tag, value: tag }))" | ||||
|             :placeholder="t('globals.messages.select', { name: t('globals.terms.tag', 2).toLowerCase() })" | ||||
|             :placeholder=" | ||||
|               t('globals.messages.select', { name: t('globals.terms.tag', 2).toLowerCase() }) | ||||
|             " | ||||
|           /> | ||||
|         </AccordionContent> | ||||
|       </AccordionItem> | ||||
| @@ -93,6 +101,7 @@ import { ref, onMounted, watch, computed } from 'vue' | ||||
| import { useConversationStore } from '@/stores/conversation' | ||||
| import { useUsersStore } from '@/stores/users' | ||||
| import { useTeamStore } from '@/stores/team' | ||||
| import { useTagStore } from '@/stores/tag' | ||||
| import { | ||||
|   Accordion, | ||||
|   AccordionContent, | ||||
| @@ -118,6 +127,7 @@ const emitter = useEmitter() | ||||
| const conversationStore = useConversationStore() | ||||
| const usersStore = useUsersStore() | ||||
| const teamsStore = useTeamStore() | ||||
| const tagStore = useTagStore() | ||||
| const tags = ref([]) | ||||
| // Save the accordion state in local storage | ||||
| const accordionState = useStorage('conversation-sidebar-accordion', []) | ||||
| @@ -171,15 +181,8 @@ watch( | ||||
| const priorityOptions = computed(() => conversationStore.priorityOptions) | ||||
|  | ||||
| const fetchTags = async () => { | ||||
|   try { | ||||
|     const resp = await api.getTags() | ||||
|     tags.value = resp.data.data.map((item) => item.name) | ||||
|   } catch (error) { | ||||
|     emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|       variant: 'destructive', | ||||
|       description: handleHTTPError(error).message | ||||
|     }) | ||||
|   } | ||||
|   await tagStore.fetchTags() | ||||
|   tags.value = tagStore.tags.map((item) => item.name) | ||||
| } | ||||
|  | ||||
| const handleAssignedUserChange = (id) => { | ||||
|   | ||||
| @@ -58,6 +58,7 @@ import { ViewVerticalIcon } from '@radix-icons/vue' | ||||
| import { Button } from '@/components/ui/button' | ||||
| import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' | ||||
| import { Mail, Phone, ExternalLink } from 'lucide-vue-next' | ||||
| import countries from '@/constants/countries.js' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' | ||||
| import { useConversationStore } from '@/stores/conversation' | ||||
| @@ -72,8 +73,13 @@ const { t } = useI18n() | ||||
| const userStore = useUserStore() | ||||
|  | ||||
| const phoneNumber = computed(() => { | ||||
|   const callingCode = conversation.value?.contact?.phone_number_calling_code || '' | ||||
|   const countryCodeValue = conversation.value?.contact?.phone_number_country_code || '' | ||||
|   const number = conversation.value?.contact?.phone_number || t('conversation.sidebar.notAvailable') | ||||
|   return callingCode ? `${callingCode} ${number}` : number | ||||
|   if (!countryCodeValue) return number | ||||
|  | ||||
|   // Lookup calling code | ||||
|   const country = countries.find((c) => c.iso_2 === countryCodeValue) | ||||
|   const callingCode = country ? country.calling_code : countryCodeValue | ||||
|   return `${callingCode} ${number}` | ||||
| }) | ||||
| </script> | ||||
|   | ||||
| @@ -8,7 +8,7 @@ | ||||
|   > | ||||
|     {{ $t('conversation.sidebar.noPreviousConvo') }} | ||||
|   </div> | ||||
|   <div v-else class="space-y-3"> | ||||
|   <div v-else class="space-y-1"> | ||||
|     <router-link | ||||
|       v-for="conversation in conversationStore.current.previous_conversations" | ||||
|       :key="conversation.uuid" | ||||
| @@ -30,9 +30,31 @@ | ||||
|             {{ conversation.last_message }} | ||||
|           </span> | ||||
|         </div> | ||||
|         <span class="text-xs text-muted-foreground" v-if="conversation.last_message_at"> | ||||
|           {{ format(new Date(conversation.last_message_at), 'h') + ' h' }} | ||||
|         </span> | ||||
|         <Tooltip> | ||||
|           <TooltipTrigger asChild> | ||||
|             <div class="flex gap-1 items-center text-xs text-muted-foreground"> | ||||
|               <span v-if="conversation.created_at"> | ||||
|                 {{ getRelativeTime(new Date(conversation.created_at)) }} | ||||
|               </span> | ||||
|               <span>•</span> | ||||
|               <span v-if="conversation.last_message_at"> | ||||
|                 {{ getRelativeTime(new Date(conversation.last_message_at)) }} | ||||
|               </span> | ||||
|             </div> | ||||
|           </TooltipTrigger> | ||||
|           <TooltipContent> | ||||
|             <div class="space-y-1 text-xs"> | ||||
|               <p> | ||||
|                 {{ $t('globals.terms.createdAt') }}: | ||||
|                 {{ formatFullTimestamp(new Date(conversation.created_at)) }} | ||||
|               </p> | ||||
|               <p v-if="conversation.last_message_at"> | ||||
|                 {{ $t('globals.terms.lastMessageAt') }}: | ||||
|                 {{ formatFullTimestamp(new Date(conversation.last_message_at)) }} | ||||
|               </p> | ||||
|             </div> | ||||
|           </TooltipContent> | ||||
|         </Tooltip> | ||||
|       </div> | ||||
|     </router-link> | ||||
|   </div> | ||||
| @@ -40,7 +62,8 @@ | ||||
|  | ||||
| <script setup> | ||||
| import { useConversationStore } from '@/stores/conversation' | ||||
| import { format } from 'date-fns' | ||||
| import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' | ||||
| import { formatFullTimestamp, getRelativeTime } from '@/utils/datetime' | ||||
|  | ||||
| const conversationStore = useConversationStore() | ||||
| </script> | ||||
|   | ||||
| @@ -83,6 +83,7 @@ import { handleHTTPError } from '@/utils/http' | ||||
| import { OPERATOR } from '@/constants/filterConfig.js' | ||||
| import { useI18n } from 'vue-i18n' | ||||
| import { z } from 'zod' | ||||
| import { FIELD_TYPE } from '@/constants/filterConfig' | ||||
| import api from '@/api' | ||||
|  | ||||
| const emitter = useEmitter() | ||||
| @@ -106,68 +107,88 @@ const formSchema = toTypedSchema( | ||||
|   z.object({ | ||||
|     id: z.number().optional(), | ||||
|     name: z | ||||
|       .string() | ||||
|       .string({ | ||||
|         required_error: t('globals.messages.required') | ||||
|       }) | ||||
|       .min(2, { message: t('view.form.name.length') }) | ||||
|       .max(30, { message: t('view.form.name.length') }), | ||||
|     filters: z | ||||
|       .array( | ||||
|         z.object({ | ||||
|           model: z.string({ | ||||
|             required_error: t('globals.messages.required', { | ||||
|               name: t('globals.terms.filter').toLowerCase() | ||||
|             }) | ||||
|           }), | ||||
|           field: z.string({ | ||||
|             required_error: t('globals.messages.required', { | ||||
|               name: t('globals.terms.field').toLowerCase() | ||||
|             }) | ||||
|           }), | ||||
|           operator: z.string({ | ||||
|             required_error: t('globals.messages.required', { | ||||
|               name: t('globals.terms.operator').toLowerCase() | ||||
|             }) | ||||
|           }), | ||||
|           value: z.union([z.string(), z.number(), z.boolean()]).optional() | ||||
|           model: z.string().optional(), | ||||
|           field: z.string().optional(), | ||||
|           operator: z.string().optional(), | ||||
|           value: z | ||||
|             .union([ | ||||
|               z.string(), | ||||
|               z.number(), | ||||
|               z.boolean(), | ||||
|               z.array(z.union([z.string(), z.number()])) | ||||
|             ]) | ||||
|             .optional() | ||||
|         }) | ||||
|       ) | ||||
|       .default([]) | ||||
|       .refine((filters) => filters.length > 0, { message: t('view.form.filter.selectAtLeastOne') }) | ||||
|       .refine( | ||||
|         (filters) => | ||||
|           filters.every( | ||||
|             (f) => | ||||
|               f.model && | ||||
|               f.field && | ||||
|               f.operator && | ||||
|               ([OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) || f.value) | ||||
|           ), | ||||
|         { | ||||
|           message: t('view.form.filter.partiallyFilled') | ||||
|         } | ||||
|       ) | ||||
|   }) | ||||
| ) | ||||
|  | ||||
| const form = useForm({ | ||||
|   validationSchema: formSchema, | ||||
|   validateOnMount: false, | ||||
|   validateOnInput: false, | ||||
|   validateOnBlur: false | ||||
|   validationSchema: formSchema | ||||
| }) | ||||
|  | ||||
| const onSubmit = async () => { | ||||
|   const validationResult = await form.validate() | ||||
|   if (!validationResult.valid) return | ||||
|  | ||||
| const onSubmit = form.handleSubmit(async (values) => { | ||||
|   if (isSubmitting.value) return | ||||
|  | ||||
|   // Make sure at least one filter is selected | ||||
|   if (!values.filters || values.filters.length === 0) { | ||||
|     form.setFieldError('filters', t('view.form.filter.selectAtLeastOne')) | ||||
|     return | ||||
|   } | ||||
|  | ||||
|   // Check for partial filters | ||||
|   const hasPartialFilters = values.filters.some( | ||||
|     (f) => | ||||
|       !f.field || | ||||
|       !f.operator || | ||||
|       (![OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) && !f.value) | ||||
|   ) | ||||
|   if (hasPartialFilters) { | ||||
|     form.setFieldError('filters', t('view.form.filter.partiallyFilled')) | ||||
|     return | ||||
|   } | ||||
|  | ||||
|   isSubmitting.value = true | ||||
|  | ||||
|   try { | ||||
|     const values = form.values | ||||
|     // Serialize array values to JSON strings for backend | ||||
|     if (values.filters) { | ||||
|       values.filters = values.filters.map((filter) => { | ||||
|         if (Array.isArray(filter.value)) { | ||||
|           // Convert string IDs to numbers for backend (tags use string IDs in frontend) | ||||
|           const numericValues = filter.value.map((v) => { | ||||
|             const num = Number(v) | ||||
|             return isNaN(num) ? v : num | ||||
|           }) | ||||
|           return { ...filter, value: JSON.stringify(numericValues) } | ||||
|         } | ||||
|         return filter | ||||
|       }) | ||||
|     } | ||||
|  | ||||
|     if (values.id) { | ||||
|       await api.updateView(values.id, values) | ||||
|       emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|         description: t('globals.messages.updatedSuccessfully', { | ||||
|           name: t('globals.terms.view') | ||||
|         }) | ||||
|       }) | ||||
|     } else { | ||||
|       await api.createView(values) | ||||
|       emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|         description: t('globals.messages.createdSuccessfully', { | ||||
|           name: t('globals.terms.view') | ||||
|         }) | ||||
|       }) | ||||
|     } | ||||
|     emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' }) | ||||
|     openDialog.value = false | ||||
| @@ -180,14 +201,36 @@ const onSubmit = async () => { | ||||
|   } finally { | ||||
|     isSubmitting.value = false | ||||
|   } | ||||
| } | ||||
| }) | ||||
|  | ||||
| // Set form values when view prop changes | ||||
| watch( | ||||
|   () => view.value, | ||||
|   (newVal) => { | ||||
|     if (newVal && Object.keys(newVal).length) { | ||||
|       form.setValues(newVal) | ||||
|       // Deserialize multi-select filter values from JSON strings to arrays | ||||
|       const processedVal = { ...newVal } | ||||
|       if (processedVal.filters) { | ||||
|         processedVal.filters = processedVal.filters.map((filter) => { | ||||
|           // Multi-select fields need to be deserialized from JSON strings | ||||
|           const field = filterFields.value.find((f) => f.field === filter.field) | ||||
|           const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT | ||||
|  | ||||
|           if (isMultiSelectField && typeof filter.value === 'string') { | ||||
|             try { | ||||
|               const parsed = JSON.parse(filter.value) | ||||
|               // Convert numbers back to strings (frontend uses string IDs) | ||||
|               const stringValues = Array.isArray(parsed) ? parsed.map((v) => String(v)) : parsed | ||||
|               return { ...filter, value: stringValues } | ||||
|             } catch (e) { | ||||
|               // If parsing fails, return as-is | ||||
|               return filter | ||||
|             } | ||||
|           } | ||||
|           return filter | ||||
|         }) | ||||
|       } | ||||
|       form.setValues(processedVal) | ||||
|     } | ||||
|   }, | ||||
|   { immediate: true } | ||||
|   | ||||
| @@ -1,16 +1,17 @@ | ||||
| import { format, differenceInMinutes, differenceInHours, differenceInDays } from 'date-fns' | ||||
| import { format, differenceInMinutes, differenceInHours, differenceInDays, differenceInYears } from 'date-fns' | ||||
|  | ||||
| export function getRelativeTime (timestamp, now = new Date()) { | ||||
|   try { | ||||
|     const mins = differenceInMinutes(now, timestamp) | ||||
|     const hours = differenceInHours(now, timestamp) | ||||
|     const days = differenceInDays(now, timestamp) | ||||
|     const years = differenceInYears(now, timestamp) | ||||
|  | ||||
|     if (mins === 0) return 'Just now' | ||||
|     if (mins < 60) return `${mins} mins ago` | ||||
|     if (hours < 24) return `${hours} hrs ago` | ||||
|     if (days < 7) return `${days} days ago` | ||||
|     return format(timestamp, 'MMMM d, yyyy h:mm a') | ||||
|     if (mins === 0) return 'now' | ||||
|     if (mins < 60) return `${mins}m` | ||||
|     if (hours < 24) return `${hours}h` | ||||
|     if (days < 365) return `${days}d` | ||||
|     return `${years}y` | ||||
|   } catch (error) { | ||||
|     console.error('Error parsing time', error, 'timestamp', timestamp) | ||||
|     return '' | ||||
|   | ||||
| @@ -69,7 +69,7 @@ const columns = [ | ||||
|       return h('div', { class: 'text-center' }, t('globals.terms.name')) | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('name')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('name')) | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
| @@ -78,7 +78,7 @@ const columns = [ | ||||
|       return h('div', { class: 'text-center' }, t('globals.terms.channel')) | ||||
|     }, | ||||
|     cell: function ({ row }) { | ||||
|       return h('div', { class: 'text-center font-medium' }, row.getValue('channel')) | ||||
|       return h('div', { class: 'text-center' }, row.getValue('channel')) | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|   | ||||
| @@ -7,7 +7,7 @@ | ||||
|     <template #help> | ||||
|       <p>Configure single sign-on with one or more OpenID Connect providers.</p> | ||||
|       <a | ||||
|         href="https://libredesk.io/docs/sso/" | ||||
|         href="https://docs.libredesk.io/configuration/sso" | ||||
|         target="_blank" | ||||
|         rel="noopener noreferrer" | ||||
|         class="link-style" | ||||
|   | ||||
| @@ -49,7 +49,7 @@ | ||||
|         <p>Design templates for customer communications and responses.</p> | ||||
|         <p>Modify content for internal and external emails.</p> | ||||
|         <a | ||||
|           href="https://libredesk.io/docs/templating/" | ||||
|           href="https://docs.libredesk.io/configuration/email-templates" | ||||
|           target="_blank" | ||||
|           rel="noopener noreferrer" | ||||
|           class="link-style" | ||||
|   | ||||
| @@ -8,7 +8,7 @@ | ||||
|       <p>Configure webhooks to receive real-time notifications when events occur in your Libredesk workspace.</p> | ||||
|       <p>Webhooks allow you to integrate Libredesk with external services by sending HTTP POST requests when specific events happen.</p> | ||||
|       <a | ||||
|         href="https://libredesk.io/docs/webhooks/" | ||||
|         href="https://docs.libredesk.io/configuration/webhooks" | ||||
|         target="_blank" | ||||
|         rel="noopener noreferrer" | ||||
|         class="link-style" | ||||
|   | ||||
| @@ -5,7 +5,11 @@ | ||||
|         <CustomBreadcrumb :links="breadcrumbLinks" /> | ||||
|       </div> | ||||
|  | ||||
|       <div v-if="contact" class="flex justify-center space-y-4 w-full"> | ||||
|       <div | ||||
|         v-if="contact" | ||||
|         class="flex justify-center space-y-4 w-full" | ||||
|         :class="{ 'loading-fade': formLoading }" | ||||
|       > | ||||
|         <div class="flex flex-col w-full mt-12"> | ||||
|           <div class="flex flex-col space-y-2"> | ||||
|             <AvatarUpload | ||||
| @@ -189,7 +193,7 @@ async function onUpload(file) { | ||||
|     formData.append('last_name', form.values.last_name) | ||||
|     formData.append('email', form.values.email) | ||||
|     formData.append('phone_number', form.values.phone_number) | ||||
|     formData.append('phone_number_calling_code', form.values.phone_number_calling_code) | ||||
|     formData.append('phone_number_country_code', form.values.phone_number_country_code) | ||||
|     formData.append('enabled', form.values.enabled) | ||||
|     const { data } = await api.updateContact(contact.value.id, formData) | ||||
|     contact.value.avatar_url = data.avatar_url | ||||
|   | ||||
							
								
								
									
										1
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								go.mod
									
									
									
									
									
								
							| @@ -70,6 +70,7 @@ require ( | ||||
| 	github.com/rivo/uniseg v0.4.4 // indirect | ||||
| 	github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect | ||||
| 	github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect | ||||
| 	github.com/stretchr/objx v0.5.2 // indirect | ||||
| 	github.com/valyala/bytebufferpool v1.0.0 // indirect | ||||
| 	golang.org/x/image v0.18.0 // indirect | ||||
| 	golang.org/x/net v0.40.0 // indirect | ||||
|   | ||||
							
								
								
									
										2
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								go.sum
									
									
									
									
									
								
							| @@ -157,6 +157,8 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An | ||||
| github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= | ||||
| github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= | ||||
| github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||||
| github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= | ||||
| github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= | ||||
| github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||||
| github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= | ||||
| github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= | ||||
|   | ||||
| @@ -177,6 +177,7 @@ | ||||
|   "globals.terms.usage": "Usage", | ||||
|   "globals.terms.createdAt": "Created At", | ||||
|   "globals.terms.updatedAt": "Updated At", | ||||
|   "globals.terms.lastMessageAt": "Last message at", | ||||
|   "globals.terms.pickDate": "Pick a date", | ||||
|   "globals.terms.time": "Time", | ||||
|   "globals.terms.listValues": "List values", | ||||
| @@ -412,7 +413,7 @@ | ||||
|   "admin.general.maxAllowedFileUploadSize.description": "Max allowed file upload size in MB.", | ||||
|   "admin.general.maxAllowedFileUploadSize.valid": "Max allowed file upload size should be between 1 and 500 MB", | ||||
|   "admin.general.allowedFileUploadExtensions": "Allowed File Upload Extensions", | ||||
|   "admin.general.allowedFileUploadExtensions.description": "Allowed file upload extensions. Use `*` to allow all file types.", | ||||
|   "admin.general.allowedFileUploadExtensions.description": "Use `*` to permit all file types. For example: `jpg, png, pdf`", | ||||
|   "admin.businessHours.unauthorized": "You do not have permission to view business hours.", | ||||
|   "admin.businessHours.setBusinessHours": "Set business hours", | ||||
|   "admin.businessHours.alwaysOpen24x7": "Always open (24/7)", | ||||
| @@ -500,6 +501,7 @@ | ||||
|   "admin.role.conversations.updateTags": "Add or remove conversation tags", | ||||
|   "admin.role.messages.read": "View conversation messages", | ||||
|   "admin.role.messages.write": "Send messages in conversations", | ||||
|   "admin.role.messages.writeAsContact": "Send messages as contact", | ||||
|   "admin.role.view.manage": "Create and manage conversation views", | ||||
|   "admin.role.generalSettings.manage": "Manage General Settings", | ||||
|   "admin.role.notificationSettings.manage": "Manage Notification Settings", | ||||
| @@ -531,7 +533,7 @@ | ||||
|   "admin.automation.conversationUpdate": "Conversation Update", | ||||
|   "admin.automation.conversationUpdate.description": "Rules that run when a conversation is updated.", | ||||
|   "admin.automation.timeTriggers": "Time Triggers", | ||||
|   "admin.automation.timeTriggers.description": "Rules that once an hour", | ||||
|   "admin.automation.timeTriggers.description": "Rules that run once an hour", | ||||
|   "admin.automation.match": "Match", | ||||
|   "admin.automation.any": "ANY", | ||||
|   "admin.automation.all": "ALL", | ||||
|   | ||||
| @@ -15,6 +15,7 @@ const ( | ||||
| 	PermConversationWrite               = "conversations:write" | ||||
| 	PermMessagesRead                    = "messages:read" | ||||
| 	PermMessagesWrite                   = "messages:write" | ||||
| 	PermMessagesWriteAsContact          = "messages:write_as_contact" | ||||
|  | ||||
| 	// View | ||||
| 	PermViewManage = "view:manage" | ||||
| @@ -102,6 +103,7 @@ var validPermissions = map[string]struct{}{ | ||||
| 	PermConversationWrite:               {}, | ||||
| 	PermMessagesRead:                    {}, | ||||
| 	PermMessagesWrite:                   {}, | ||||
| 	PermMessagesWriteAsContact:          {}, | ||||
| 	PermViewManage:                      {}, | ||||
| 	PermStatusManage:                    {}, | ||||
| 	PermTagsManage:                      {}, | ||||
|   | ||||
| @@ -232,12 +232,21 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve | ||||
| 		for _, ruleValue := range ruleValues { | ||||
| 			// Normalize rule value by collapsing multiple spaces | ||||
| 			normalizedRuleValue := strings.Join(strings.Fields(ruleValue), " ") | ||||
| 			if strings.Contains( | ||||
| 				strings.ToLower(normalizedInputText), | ||||
| 				strings.ToLower(normalizedRuleValue), | ||||
| 			) { | ||||
| 				conditionMet = true | ||||
| 				break | ||||
| 			 | ||||
| 			// Respect CaseSensitiveMatch flag | ||||
| 			if rule.CaseSensitiveMatch { | ||||
| 				if strings.Contains(normalizedInputText, normalizedRuleValue) { | ||||
| 					conditionMet = true | ||||
| 					break | ||||
| 				} | ||||
| 			} else { | ||||
| 				if strings.Contains( | ||||
| 					strings.ToLower(normalizedInputText), | ||||
| 					strings.ToLower(normalizedRuleValue), | ||||
| 				) { | ||||
| 					conditionMet = true | ||||
| 					break | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	case models.RuleOperatorNotContains: | ||||
| @@ -249,12 +258,21 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve | ||||
| 		for _, ruleValue := range ruleValues { | ||||
| 			// Normalize rule value by collapsing multiple spaces | ||||
| 			normalizedRuleValue := strings.Join(strings.Fields(ruleValue), " ") | ||||
| 			if strings.Contains( | ||||
| 				strings.ToLower(normalizedInputText), | ||||
| 				strings.ToLower(normalizedRuleValue), | ||||
| 			) { | ||||
| 				conditionMet = false | ||||
| 				break | ||||
| 			 | ||||
| 			// Respect CaseSensitiveMatch flag | ||||
| 			if rule.CaseSensitiveMatch { | ||||
| 				if strings.Contains(normalizedInputText, normalizedRuleValue) { | ||||
| 					conditionMet = false | ||||
| 					break | ||||
| 				} | ||||
| 			} else { | ||||
| 				if strings.Contains( | ||||
| 					strings.ToLower(normalizedInputText), | ||||
| 					strings.ToLower(normalizedRuleValue), | ||||
| 				) { | ||||
| 					conditionMet = false | ||||
| 					break | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	case models.RuleOperatorSet: | ||||
|   | ||||
							
								
								
									
										1201
									
								
								internal/automation/evaluator_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1201
									
								
								internal/automation/evaluator_test.go
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -541,6 +541,10 @@ func (c *Manager) UpdateConversationTeamAssignee(uuid string, teamID int, actor | ||||
|  | ||||
| 	// Team changed? | ||||
| 	if previousAssignedTeamID != teamID { | ||||
| 		// Remove assigned user if team has changed. | ||||
| 		c.RemoveConversationAssignee(uuid, models.AssigneeTypeUser, actor) | ||||
|  | ||||
| 		// Apply SLA policy if this new team has a SLA policy. | ||||
| 		team, err := c.teamStore.Get(teamID) | ||||
| 		if err != nil { | ||||
| 			return nil | ||||
| @@ -960,7 +964,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // RemoveConversationAssignee removes the assignee from the conversation. | ||||
| // RemoveConversationAssignee removes assigned user from a conversation. | ||||
| func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.User) error { | ||||
| 	if _, err := m.q.RemoveConversationAssignee.Exec(uuid, typ); err != nil { | ||||
| 		m.lo.Error("error removing conversation assignee", "error", err) | ||||
| @@ -975,6 +979,14 @@ func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.Use | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	// Broadcast ws update. | ||||
| 	switch typ { | ||||
| 	case models.AssigneeTypeUser: | ||||
| 		m.BroadcastConversationUpdate(uuid, "assigned_user_id", nil) | ||||
| 	case models.AssigneeTypeTeam: | ||||
| 		m.BroadcastConversationUpdate(uuid, "assigned_team_id", nil) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| @@ -1081,6 +1093,35 @@ func (c *Manager) makeConversationsListQuery(userID int, teamIDs []int, listType | ||||
| 		return "", nil, fmt.Errorf("no conversation list types specified") | ||||
| 	} | ||||
|  | ||||
| 	// Parse filters to extract tag filters | ||||
| 	var ( | ||||
| 		filters          []dbutil.Filter | ||||
| 		tagFilters       []dbutil.Filter | ||||
| 		remainingFilters []dbutil.Filter | ||||
| 	) | ||||
| 	if filtersJSON != "" && filtersJSON != "[]" { | ||||
| 		if err := json.Unmarshal([]byte(filtersJSON), &filters); err != nil { | ||||
| 			return "", nil, fmt.Errorf("invalid filters JSON: %w", err) | ||||
| 		} | ||||
|  | ||||
| 		// Separate tag filters from other filters | ||||
| 		for _, f := range filters { | ||||
| 			if f.Field == "tags" && (f.Operator == "contains" || f.Operator == "not contains" || f.Operator == "set" || f.Operator == "not set") { | ||||
| 				tagFilters = append(tagFilters, f) | ||||
| 			} else { | ||||
| 				remainingFilters = append(remainingFilters, f) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// Update filtersJSON with remaining filters for the generic builder | ||||
| 		if len(remainingFilters) > 0 { | ||||
| 			b, _ := json.Marshal(remainingFilters) | ||||
| 			filtersJSON = string(b) | ||||
| 		} else { | ||||
| 			filtersJSON = "[]" | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Prepare the conditions based on the list types. | ||||
| 	conditions := []string{} | ||||
| 	for _, lt := range listTypes { | ||||
| @@ -1106,13 +1147,60 @@ func (c *Manager) makeConversationsListQuery(userID int, teamIDs []int, listType | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Build the base query with list type conditions | ||||
| 	var whereClause string | ||||
| 	if len(conditions) > 0 { | ||||
| 		baseQuery = fmt.Sprintf(baseQuery, "AND ("+strings.Join(conditions, " OR ")+")") | ||||
| 	} else { | ||||
| 		// Replace the `%s` in the base query with an empty string. | ||||
| 		baseQuery = fmt.Sprintf(baseQuery, "") | ||||
| 		whereClause = "AND (" + strings.Join(conditions, " OR ") + ")" | ||||
| 	} | ||||
|  | ||||
| 	// Add tag filter conditions | ||||
| 	// TODO: Evaluate - https://github.com/Masterminds/squirrel when required. | ||||
| 	for _, tf := range tagFilters { | ||||
| 		switch tf.Operator { | ||||
| 		case "contains", "not contains": | ||||
| 			var tagIDs []int | ||||
| 			if err := json.Unmarshal([]byte(tf.Value), &tagIDs); err != nil { | ||||
| 				return "", nil, fmt.Errorf("invalid tag IDs in filter: %w", err) | ||||
| 			} | ||||
| 			if len(tagIDs) > 0 { | ||||
| 				paramIdx := len(qArgs) + 1 | ||||
| 				switch tf.Operator { | ||||
| 				case "contains": | ||||
| 					// Has any of the tags | ||||
| 					tagCondition := fmt.Sprintf(` AND conversations.id IN ( | ||||
| 						SELECT DISTINCT conversation_id  | ||||
| 						FROM conversation_tags  | ||||
| 						WHERE tag_id = ANY($%d::int[]) | ||||
| 					)`, paramIdx) | ||||
| 					whereClause += tagCondition | ||||
| 				case "not contains": | ||||
| 					// Doesn't have any of the tags | ||||
| 					tagCondition := fmt.Sprintf(` AND conversations.id NOT IN ( | ||||
| 						SELECT DISTINCT conversation_id  | ||||
| 						FROM conversation_tags  | ||||
| 						WHERE tag_id = ANY($%d::int[]) | ||||
| 					)`, paramIdx) | ||||
| 					whereClause += tagCondition | ||||
| 				} | ||||
| 				qArgs = append(qArgs, pq.Array(tagIDs)) | ||||
| 			} | ||||
| 		case "set": | ||||
| 			// Has any tags at all | ||||
| 			whereClause += ` AND EXISTS ( | ||||
| 				SELECT 1 FROM conversation_tags  | ||||
| 				WHERE conversation_id = conversations.id | ||||
| 			)` | ||||
| 		case "not set": | ||||
| 			// Has no tags at all | ||||
| 			whereClause += ` AND NOT EXISTS ( | ||||
| 				SELECT 1 FROM conversation_tags  | ||||
| 				WHERE conversation_id = conversations.id | ||||
| 			)` | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	baseQuery = fmt.Sprintf(baseQuery, whereClause) | ||||
|  | ||||
| 	return dbutil.BuildPaginatedQuery(baseQuery, qArgs, dbutil.PaginationOptions{ | ||||
| 		Order:    order, | ||||
| 		OrderBy:  orderBy, | ||||
|   | ||||
| @@ -462,6 +462,11 @@ func (m *Manager) InsertMessage(message *models.Message) error { | ||||
| 		message.Meta = json.RawMessage(`{}`) | ||||
| 	} | ||||
|  | ||||
| 	// Handle empty content type enum, default to text. | ||||
| 	if message.ContentType == "" { | ||||
| 		message.ContentType = models.ContentTypeText | ||||
| 	} | ||||
|  | ||||
| 	// Convert HTML content to text for search. | ||||
| 	message.TextContent = stringutil.HTML2Text(message.Content) | ||||
|  | ||||
|   | ||||
| @@ -145,7 +145,7 @@ type ConversationContact struct { | ||||
| 	AvailabilityStatus     string          `db:"availability_status" json:"availability_status"` | ||||
| 	AvatarURL              null.String     `db:"avatar_url" json:"avatar_url"` | ||||
| 	PhoneNumber            null.String     `db:"phone_number" json:"phone_number"` | ||||
| 	PhoneNumberCallingCode null.String     `db:"phone_number_calling_code" json:"phone_number_calling_code"` | ||||
| 	PhoneNumberCountryCode null.String     `db:"phone_number_country_code" json:"phone_number_country_code"` | ||||
| 	CustomAttributes       json.RawMessage `db:"custom_attributes" json:"custom_attributes"` | ||||
| 	Enabled                bool            `db:"enabled" json:"enabled"` | ||||
| 	LastActiveAt           null.Time       `db:"last_active_at" json:"last_active_at"` | ||||
| @@ -173,7 +173,7 @@ type PreviousConversationContact struct { | ||||
| } | ||||
|  | ||||
| type ConversationParticipant struct { | ||||
| 	ID        string      `db:"id" json:"id"` | ||||
| 	ID        int         `db:"id" json:"id"` | ||||
| 	FirstName string      `db:"first_name" json:"first_name"` | ||||
| 	LastName  string      `db:"last_name" json:"last_name"` | ||||
| 	AvatarURL null.String `db:"avatar_url" json:"avatar_url"` | ||||
|   | ||||
| @@ -140,7 +140,7 @@ SELECT | ||||
|    ct.availability_status as "contact.availability_status", | ||||
|    ct.avatar_url as "contact.avatar_url", | ||||
|    ct.phone_number as "contact.phone_number", | ||||
|    ct.phone_number_calling_code as "contact.phone_number_calling_code", | ||||
|    ct.phone_number_country_code as "contact.phone_number_country_code", | ||||
|    ct.custom_attributes as "contact.custom_attributes", | ||||
|    ct.enabled as "contact.enabled", | ||||
|    ct.last_active_at as "contact.last_active_at", | ||||
| @@ -353,6 +353,7 @@ WHERE uuid = $1; | ||||
| UPDATE conversations | ||||
| SET  | ||||
|     assigned_user_id = CASE WHEN $2 = 'user' THEN NULL ELSE assigned_user_id END, | ||||
|     assignee_last_seen_at = CASE WHEN $2 = 'user' THEN NULL ELSE assignee_last_seen_at END, | ||||
|     assigned_team_id = CASE WHEN $2 = 'team' THEN NULL ELSE assigned_team_id END, | ||||
|     updated_at = NOW() | ||||
| WHERE uuid = $1; | ||||
|   | ||||
							
								
								
									
										53
									
								
								internal/migrations/v0.7.4.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								internal/migrations/v0.7.4.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | ||||
| package migrations | ||||
|  | ||||
| import ( | ||||
| 	"github.com/jmoiron/sqlx" | ||||
| 	"github.com/knadh/koanf/v2" | ||||
| 	"github.com/knadh/stuffbin" | ||||
| ) | ||||
|  | ||||
| // V0_7_4 updates the database schema to v0.7.4. | ||||
| func V0_7_4(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { | ||||
| 	// Rename phone_number_calling_code to phone_number_country_code | ||||
| 	// This column will now store country codes (US, CA, GB) instead of calling codes (+1, +44) | ||||
| 	_, err := db.Exec(` | ||||
| 		DO $$ | ||||
| 		BEGIN | ||||
| 			IF NOT EXISTS ( | ||||
| 				SELECT 1 FROM information_schema.columns | ||||
| 				WHERE table_name = 'users' AND column_name = 'phone_number_country_code' | ||||
| 			) AND EXISTS ( | ||||
| 				SELECT 1 FROM information_schema.columns | ||||
| 				WHERE table_name = 'users' AND column_name = 'phone_number_calling_code' | ||||
| 			) THEN | ||||
| 				ALTER TABLE users | ||||
| 				RENAME COLUMN phone_number_calling_code TO phone_number_country_code; | ||||
| 			END IF; | ||||
| 		END $$; | ||||
| 	`) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	// Rename the constraint to match the new column name | ||||
| 	_, err = db.Exec(` | ||||
| 		DO $$ | ||||
| 		BEGIN | ||||
| 			IF NOT EXISTS ( | ||||
| 				SELECT 1 FROM information_schema.constraint_column_usage | ||||
| 				WHERE constraint_name = 'constraint_users_on_phone_number_country_code' | ||||
| 			) AND EXISTS ( | ||||
| 				SELECT 1 FROM information_schema.constraint_column_usage | ||||
| 				WHERE constraint_name = 'constraint_users_on_phone_number_calling_code' | ||||
| 			) THEN | ||||
| 				ALTER TABLE users | ||||
| 				RENAME CONSTRAINT constraint_users_on_phone_number_calling_code TO constraint_users_on_phone_number_country_code; | ||||
| 			END IF; | ||||
| 		END $$; | ||||
| 	`) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
| @@ -29,7 +29,7 @@ func (u *Manager) CreateContact(user *models.User) error { | ||||
|  | ||||
| // UpdateContact updates a contact in the database. | ||||
| func (u *Manager) UpdateContact(id int, user models.User) error { | ||||
| 	if _, err := u.q.UpdateContact.Exec(id, user.FirstName, user.LastName, user.Email, user.AvatarURL, user.PhoneNumber, user.PhoneNumberCallingCode); err != nil { | ||||
| 	if _, err := u.q.UpdateContact.Exec(id, user.FirstName, user.LastName, user.Email, user.AvatarURL, user.PhoneNumber, user.PhoneNumberCountryCode); err != nil { | ||||
| 		u.lo.Error("error updating user", "error", err) | ||||
| 		return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.contact}"), nil) | ||||
| 	} | ||||
|   | ||||
| @@ -41,7 +41,7 @@ type UserCompact struct { | ||||
| 	CreatedAt time.Time   `db:"created_at" json:"created_at"` | ||||
| 	UpdatedAt time.Time   `db:"updated_at" json:"updated_at"` | ||||
|  | ||||
| 	Total int `db:"total" json:"total"` | ||||
| 	Total int `db:"total" json:"-"` | ||||
| } | ||||
|  | ||||
| type User struct { | ||||
| @@ -53,7 +53,7 @@ type User struct { | ||||
| 	Email                  null.String          `db:"email" json:"email"` | ||||
| 	Type                   string               `db:"type" json:"type"` | ||||
| 	AvailabilityStatus     string               `db:"availability_status" json:"availability_status"` | ||||
| 	PhoneNumberCallingCode null.String          `db:"phone_number_calling_code" json:"phone_number_calling_code"` | ||||
| 	PhoneNumberCountryCode null.String          `db:"phone_number_country_code" json:"phone_number_country_code"` | ||||
| 	PhoneNumber            null.String          `db:"phone_number" json:"phone_number"` | ||||
| 	AvatarURL              null.String          `db:"avatar_url" json:"avatar_url"` | ||||
| 	Enabled                bool                 `db:"enabled" json:"enabled"` | ||||
|   | ||||
| @@ -39,7 +39,7 @@ SELECT | ||||
|     u.availability_status, | ||||
|     u.last_active_at, | ||||
|     u.last_login_at, | ||||
|     u.phone_number_calling_code, | ||||
|     u.phone_number_country_code, | ||||
|     u.phone_number, | ||||
|     u.api_key, | ||||
|     u.api_key_last_used_at, | ||||
| @@ -174,7 +174,7 @@ SET first_name = COALESCE($2, first_name), | ||||
|     email = COALESCE($4, email), | ||||
|     avatar_url = $5, | ||||
|     phone_number = $6, | ||||
|     phone_number_calling_code = $7, | ||||
|     phone_number_country_code = $7, | ||||
|     updated_at = now() | ||||
| WHERE id = $1 and type = 'contact'; | ||||
|  | ||||
| @@ -233,7 +233,7 @@ SELECT | ||||
|     u.availability_status, | ||||
|     u.last_active_at, | ||||
|     u.last_login_at, | ||||
|     u.phone_number_calling_code, | ||||
|     u.phone_number_country_code, | ||||
|     u.phone_number, | ||||
|     u.api_key, | ||||
|     u.api_key_last_used_at, | ||||
|   | ||||
| @@ -129,7 +129,7 @@ CREATE TABLE users ( | ||||
|     email TEXT NULL, | ||||
|     first_name TEXT NOT NULL, | ||||
|     last_name TEXT NULL, | ||||
| 	phone_number_calling_code TEXT NULL, | ||||
| 	phone_number_country_code TEXT NULL, | ||||
|     phone_number TEXT NULL, | ||||
|     country TEXT NULL, | ||||
|     "password" VARCHAR(150) NULL, | ||||
| @@ -146,7 +146,7 @@ CREATE TABLE users ( | ||||
| 	api_key_last_used_at TIMESTAMPTZ NULL, | ||||
|     CONSTRAINT constraint_users_on_country CHECK (LENGTH(country) <= 140), | ||||
|     CONSTRAINT constraint_users_on_phone_number CHECK (LENGTH(phone_number) <= 20), | ||||
| 	CONSTRAINT constraint_users_on_phone_number_calling_code CHECK (LENGTH(phone_number_calling_code) <= 10), | ||||
| 	CONSTRAINT constraint_users_on_phone_number_country_code CHECK (LENGTH(phone_number_country_code) <= 10), | ||||
|     CONSTRAINT constraint_users_on_email_length CHECK (LENGTH(email) <= 320), | ||||
|     CONSTRAINT constraint_users_on_first_name CHECK (LENGTH(first_name) <= 140), | ||||
|     CONSTRAINT constraint_users_on_last_name CHECK (LENGTH(last_name) <= 140) | ||||
| @@ -619,7 +619,7 @@ VALUES | ||||
|     ('app.lang', '"en"'::jsonb), | ||||
|     ('app.root_url', '"http://localhost:9000"'::jsonb), | ||||
|     ('app.logo_url', '"http://localhost:9000/logo.png"'::jsonb), | ||||
|     ('app.site_name', '"Libredesk"'::jsonb), | ||||
|     ('app.site_name', '"LIBREDESK"'::jsonb), | ||||
|     ('app.favicon_url', '"http://localhost:9000/favicon.ico"'::jsonb), | ||||
|     ('app.max_file_upload_size', '20'::jsonb), | ||||
|     ('app.allowed_file_upload_extensions', '["*"]'::jsonb), | ||||
|   | ||||
		Reference in New Issue
	
	Block a user