Compare commits

...

71 Commits

Author SHA1 Message Date
Abhinav Raut
6f62a77783 fix(ai): compute email recipients for AI and automated replies
- Add SendAutoReply method that automatically determines to/cc/bcc based on conversation history. Fixes AI assistant replies failing for email conversations while maintaining livechat compatibility.
2025-08-24 15:47:03 +05:30
Abhinav Raut
af1373272e fix(ai): assistants now reply on first msg
- previously replies started from 2nd msg
- enqueue completions on assignment + new msg
- correct get-latest-message query
2025-08-24 14:20:36 +05:30
Abhinav Raut
61e343de5b Merge branch 'feat/live-chat-channel' into help-articles-and-ai-responses 2025-08-24 02:27:42 +05:30
Abhinav Raut
c721d19b81 fix migration 2025-08-24 02:27:17 +05:30
Abhinav Raut
2ff5a945e2 Merge branch 'feat/live-chat-channel' into help-articles-and-ai-responses 2025-08-24 02:17:55 +05:30
Abhinav Raut
77111835cc fix component import 2025-08-24 02:17:28 +05:30
Abhinav Raut
5284b2ee15 fix build 2025-08-24 02:14:10 +05:30
Abhinav Raut
b1f8231f7d Merge branch 'feat/live-chat-channel' into help-articles-and-ai-responses 2025-08-24 02:12:32 +05:30
Abhinav Raut
45a77b1422 fix build 2025-08-24 02:01:21 +05:30
Abhinav Raut
9a77c8953c Merge branch 'main' into feat/live-chat-channel 2025-08-24 01:52:12 +05:30
Abhinav Raut
18d4a8fe3b feat: auto-remove pending outgoing widget messages after 10 seconds if they have a temporary ID 2025-08-23 19:24:14 +05:30
Abhinav Raut
a2234e908f make widget expand to full viewport height
update shadows for iframe and widget
2025-08-22 02:24:23 +05:30
Abhinav Raut
d7fe6153bb Center pre chat form title 2025-08-22 02:00:53 +05:30
Abhinav Raut
f786c4d962 tidy go mod 2025-08-22 01:43:41 +05:30
Abhinav Raut
cff5a6dfc2 disable file uploader for ai assisants 2025-08-22 01:43:18 +05:30
Abhinav Raut
d0df6f9322 feat: Implement rate limiting for AI conversation completion requests 2025-08-22 01:14:09 +05:30
Abhinav Raut
30902310dc feat: Add Markdown to HTML conversion and clean JSON response utility
- Implemented MarkdownToHTML function using goldmark for converting markdown content to HTML.
- Added CleanJSONResponse function to remove markdown code blocks from LLM responses.
- Updated stringutil tests to remove unnecessary test cases for empty strings and special characters.

refactor: Update SQL schema for knowledge base and help center

- Introduced ai_knowledge_type enum for knowledge base categorization.
- Added help_center_id reference in inboxes table.
- Enhanced help_centers table with default_locale column.
- Changed data types from INTEGER to INT for consistency across tables.
- Renamed ai_custom_answers table to ai_knowledge_base and adjusted its schema.

fix: Remove unnecessary CSS filter from default icon in widget

- Cleaned up widget.js by removing the brightness filter from the default icon styling.
2025-08-22 01:14:08 +05:30
Abhinav Raut
8bf0255b61 fix: conversation messages order in completion request 2025-08-22 01:12:52 +05:30
Abhinav Raut
f337f79f96 wip: AI responses and help articles 2025-08-22 01:12:52 +05:30
Abhinav Raut
68c2708464 feat: remove VisitorInfoForm component and integrate customizable pre-chat form.
- Deleted the VisitorInfoForm.vue component and its associated schema.
- Introduced a new preChatFormSchema.js to handle dynamic form validation.
- Updated ChatView.vue to conditionally display the PreChatForm based on user session and conversation state.
- Enhanced chat store to manage current conversation updates.
- Implemented WebSocket event handling for conversation updates.
- Updated localization files to include new terms related to the pre-chat form.
- Modified conversation management logic to support broadcasting updates to widget clients.
- Updated SQL queries to accommodate custom attributes for visitors.
2025-08-22 00:42:12 +05:30
Abhinav Raut
e0dc0285a4 fix: agents availability status changing to online after doing an email password login even after being Away or in Reassinging Replies status.
This was not affecting OIDC login just email password login
2025-08-19 16:34:15 +05:30
Abhinav Raut
4f9fc029c0 show uploading state when file is being uploaded from widget 2025-08-19 03:22:12 +05:30
Abhinav Raut
6cfa93838a fix: remove unnecessary filter from default icon styling in widget 2025-08-19 03:01:28 +05:30
Abhinav Raut
f72f158cf0 - show thumbnail image in widget thread instead of the entire image
- update file imports to use shared-ui utils and remove redundant file.js
- Implement SignedURLStore interface for fs store
2025-08-19 03:01:21 +05:30
Abhinav Raut
1962abdc16 feat: implement rate limiting for public widget endpoints with Redis support 2025-08-19 01:58:13 +05:30
Abhinav Raut
b971619ea6 use tabs for search results seperation also looks better now. 2025-08-14 16:12:29 +05:30
Abhinav Raut
69accaebef fix: conversations not reopening on reply from contacts (only when there's an attachment in the reply) else the convo would reopen, the conversation_uuid field was empty as it wasn't part of the get-messages query. 2025-08-14 16:11:27 +05:30
Abhinav Raut
081a5c615a fix: update main.js to import styles from shared-ui instead 2025-08-03 17:35:52 +05:30
Abhinav Raut
27de73536e Update confirmed-bug.md 2025-07-23 00:18:36 +05:30
Abhinav Raut
df108a3363 feat: add confirmed and possible bug report templates 2025-07-23 00:14:34 +05:30
Abhinav Raut
c35ab42b47 feat: configurable visitor information collection with a form before starting chat.
fix: Chat initialization failing due to the JWT authenticated user doesn't exist in the DB yet.

fix: Always upsert custom attribues instead of replacing.
2025-07-21 01:58:30 +05:30
Abhinav Raut
f05014f412 refactor: implement widget authentication middleware with standard HTTP headers
- Add widgetAuth middleware to handle JWT and inbox validation consistently
  - Move authentication logic from request body to standard HTTP headers:
    * JWT: Authorization: Bearer <token>
    * Inbox ID: X-Libredesk-Inbox-ID: <id>
  - Refactor all widget handlers to use middleware context instead of duplicate auth code
  - Frontend now sends auth headers via HTTP interceptor for all widget requests
2025-07-20 17:44:36 +05:30
Abhinav Raut
e2bba04669 Fix: Trusted domain validation for live chat widget, check the referrer header instead of origin.
- Removed the widgetOrigin middleware as it would have same origin as the iFrame URL, changed this to use `Referrer` header on initial iFrame load.
- Feat(agent-view): Added external_user_id display in the conversation sidebar.
2025-07-20 16:44:33 +05:30
Abhinav Raut
4beab72a11 feat: add external user ID support and secret field for inboxes.
Update user and inbox models, queries, and migrations
2025-07-20 16:42:03 +05:30
Abhinav Raut
26b3b30fca feat: add authenticated user support by passing JWT from parent to widget iframe.
feat: more methods to toggle wiget visibility
2025-07-20 16:40:44 +05:30
Abhinav Raut
11fd57adb0 update lucide-vue-next to version 0.525.0 2025-07-20 16:20:26 +05:30
Abhinav Raut
266c3dab72 Merge pull request #121 from abhinavxd/dependabot/go_modules/golang.org/x/oauth2-0.27.0
chore(deps): bump golang.org/x/oauth2 from 0.21.0 to 0.27.0
2025-07-20 14:42:29 +05:30
dependabot[bot]
bf2c1fff6f chore(deps): bump golang.org/x/oauth2 from 0.21.0 to 0.27.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.21.0 to 0.27.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.21.0...v0.27.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.27.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-20 09:11:03 +00:00
Abhinav Raut
d4f644c531 feat translate widget app 2025-07-17 02:56:32 +05:30
Abhinav Raut
646bbc7efe wait for widget vue app to be ready before showing the widget icon
- show arrow down when when widget is open
2025-07-17 02:37:03 +05:30
Abhinav Raut
3c3709557e feat: Add loading indicators to chat components and improve spinner UI 2025-07-17 02:29:05 +05:30
Abhinav Raut
74732bfe91 feat: Add expand/collapse functionality to chat view 2025-07-17 01:49:22 +05:30
Abhinav Raut
8ee81c2d64 feat: Widget dark mode and chat reply expectation message in chat title.
feat: Add HTTP utility functions for trusted origin checks

feat: Implement typing status broadcasting for live chat clients and agents.

feat: Add support for signed URLs in media manager

fix: Update database migration to handle duplicate visitors with same email address.

feat: Add conversation subscription and typing message models for WebSocket communication

feat: Implement conversation subscription management in WebSocket hub this is used for broadcasting typing indicator.

feat: Revamp widget JavaScript to improve mobile responsiveness and show unread messages if any.
2025-07-17 01:06:54 +05:30
Abhinav Raut
2930af0c4f feat: add API getting started guide and update navigation 2025-07-07 01:06:28 +05:30
Abhinav Raut
389c4e3dd3 fix: allow configurable webhook request timeout from config.toml 2025-07-07 00:26:31 +05:30
Abhinav Raut
9a119e6dc3 change log level from Warn to Info for zero rules 2025-07-07 00:18:33 +05:30
Abhinav Raut
ee178d383d fix: remove hardcoded color for weekday in business hrs form
- change holiday form action label to `add`
2025-07-07 00:00:30 +05:30
Abhinav Raut
fc4db676d9 fix: correct capitalization for "Business hour" in English translation 2025-07-06 23:59:47 +05:30
Abhinav Raut
70cb3d0f80 fix: make code mirror editor fill remaining space 2025-07-06 23:59:35 +05:30
Abhinav Raut
c9920c3377 fix: set chunkSizeWarningLimit to 600 kb in build config as code mirror's ~550 kb 2025-07-06 21:44:26 +05:30
Abhinav Raut
6d62c3a4ba fix: update code mirror dark mode detection to use VueUse's useColorMode 2025-07-06 21:10:09 +05:30
Abhinav Raut
d9b5fb8f0f fix: adjust margin for URL display in webhook list data table 2025-07-06 20:39:57 +05:30
Abhinav Raut
3de320f1fb fix: correct capitalization in english translation 2025-07-06 20:34:06 +05:30
Abhinav Raut
be977dcff2 feat: show date and month below each message bubble
Use `created_at` timestamp instead of `updated_at` timestamp in message bubble.

Fixes #117
2025-07-06 20:14:18 +05:30
Abhinav Raut
5e19f13e18 fix: make vue-letter break all words for contact messages 2025-07-06 19:52:46 +05:30
Abhinav Raut
ccc5940dd9 return created message in message fetch API 2025-07-06 19:51:44 +05:30
Abhinav Raut
282dc83439 fix set correct var name 2025-07-06 18:47:19 +05:30
Abhinav Raut
61a70f6b52 clean up live chat
move last message details in the `meta` JSONB column of conversations
2025-07-06 18:46:54 +05:30
Abhinav Raut
5b6a58fba0 wip: intercom like live chat with chat widget
- new vue app for serving live chat widget, created subdirectories inside frontend dir `main` and `widget`
- vite changes for both main app and widget app.
- new backend live chat channel
- apis for live chat widget
2025-06-29 04:59:55 +05:30
Abhinav Raut
4203b82e90 Update README.md 2025-06-28 23:34:13 +05:30
Abhinav Raut
ba07e224c2 Update README.md 2025-06-21 22:11:44 +05:30
Abhinav Raut
3fff65150f Merge pull request #109 from ketan-10/migrate-codeflask-to-codemirror
Migrate codeflask to codemirror
2025-06-21 17:55:18 +05:30
ketan
c4fcf6bd91 feat: integrate CodeMirror for code editing and update styles. 2025-06-21 16:28:34 +05:30
Abhinav Raut
5ea1b9e84c fix: retain conversation view when converstion list type is changed 2025-06-21 14:44:36 +05:30
Abhinav Raut
5b522888bc Merge pull request #108 from abhinavxd/fix/post-put-handlers-return-objects
fix: Return created/updated objects in POST/PUT responses
2025-06-21 11:31:45 +05:30
Abhinav Raut
dc2250ce50 remove console log 2025-06-21 11:27:53 +05:30
Abhinav Raut
839a06f0d2 fixes to business hrs form 2025-06-20 19:35:09 +05:30
Abhinav Raut
d2e5d85e3a fix: return created/updated objects in POST/PUT responses with masked secrets
All POST/PUT handlers now return actual database objects instead of `true`
2025-06-20 19:35:09 +05:30
Abhinav Raut
0737d22374 Merge pull request #107 from ketan-10/fix/disable-crowdin-workflows-on-forks
stop crowdin workflow on forks
2025-06-19 11:52:06 +05:30
ketan
d6af9d10ea stop crowdin workflow on forks 2025-06-19 02:22:26 +05:30
Abhinav Raut
6381fc23c2 Merge pull request #105 from abhinavxd/feat/api-user
feat: API key management for agents
2025-06-19 02:05:00 +05:30
686 changed files with 23264 additions and 2503 deletions

16
.github/ISSUE_TEMPLATE/confirmed-bug.md vendored Normal file
View File

@@ -0,0 +1,16 @@
---
name: Confirmed Bug Report
about: Report a confirmed bug in Libredesk
title: "[Bug] <brief summary>"
labels: bug
assignees: ""
---
**Version:**
- libredesk: [eg: v0.7.0]
**Description of the bug and steps to reproduce:**
A clear and concise description of what the bug is.
**Logs / Screenshots:**
Attach any relevant logs or screenshots to help diagnose the issue.

16
.github/ISSUE_TEMPLATE/possible-bug.md vendored Normal file
View File

@@ -0,0 +1,16 @@
---
name: Possible Bug Report
about: Something in Libredesk might be broken but needs confirmation
title: "[Possible Bug] <brief summary>"
labels: bug, needs-investigation
assignees: ""
---
**Version:**
- libredesk: [eg: v0.7.0]
**Description of the bug and steps to reproduce:**
A clear and concise description of what the bug is.
**Logs / Screenshots:**
Attach any relevant logs or screenshots to help diagnose the issue.

View File

@@ -12,6 +12,8 @@ on:
jobs:
crowdin:
runs-on: ubuntu-latest
# Only run on the original repository, not forks
if: github.event.repository.fork == false
steps:
- name: Checkout
uses: actions/checkout@v4

View File

@@ -15,7 +15,7 @@ GOPATH ?= $(HOME)/go
STUFFBIN ?= $(GOPATH)/bin/stuffbin
# The default target to run when `make` is executed.
.DEFAULT_GOAL := build
.DEFAULT_GOAL := build
# Install stuffbin if it doesn't exist.
$(STUFFBIN):
@@ -28,11 +28,24 @@ install-deps: $(STUFFBIN)
@echo "→ Installing frontend dependencies..."
@cd ${FRONTEND_DIR} && pnpm install
# Build the frontend for production.
# Build the frontend for production (both apps).
.PHONY: frontend-build
frontend-build: install-deps
@echo "→ Building frontend for production..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build
@echo "→ Building frontend for production - main app & widget..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:main
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:widget
# Build only the main frontend app.
.PHONY: frontend-build-main
frontend-build-main: install-deps
@echo "→ Building main frontend app for production..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:main
# Build only the widget frontend app.
.PHONY: frontend-build-widget
frontend-build-widget: install-deps
@echo "→ Building widget frontend app for production..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:widget
# Run the Go backend server in development mode.
.PHONY: run-backend
@@ -40,13 +53,29 @@ run-backend:
@echo "→ Running backend..."
CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
# Run the JS frontend server in development mode.
# Run the JS frontend server in development mode (main app only).
.PHONY: run-frontend
run-frontend:
@echo "→ Installing frontend dependencies (if not already installed)..."
@cd ${FRONTEND_DIR} && pnpm install
@echo "→ Running frontend..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev
@echo "→ Running main frontend app..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:main
# Run the main frontend app in development mode.
.PHONY: run-frontend-main
run-frontend-main:
@echo "→ Installing frontend dependencies (if not already installed)..."
@cd ${FRONTEND_DIR} && pnpm install
@echo "→ Running main frontend app..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:main
# Run the widget frontend app in development mode.
.PHONY: run-frontend-widget
run-frontend-widget:
@echo "→ Installing frontend dependencies (if not already installed)..."
@cd ${FRONTEND_DIR} && pnpm install
@echo "→ Running widget frontend app..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:widget
# Build the backend binary.
.PHONY: build-backend

View File

@@ -15,7 +15,7 @@ Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live
## Features
- **Multi Shared Inbox**
Libredesk supports multiple shares inboxes, letting you manage conversations across teams effortlessly.
Libredesk supports multiple shared inboxes, letting you manage conversations across teams effortlessly.
- **Granular Permissions**
Create custom roles with granular permissions for teams and individual agents.
- **Smart Automation**
@@ -85,6 +85,11 @@ __________________
## Developers
If you are interested in contributing, refer to the [developer setup](https://libredesk.io/docs/developer-setup/). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
## Development Status
Libredesk is under active development.
Track roadmap and progress on the GitHub Project Board: [https://github.com/users/abhinavxd/projects/1](https://github.com/users/abhinavxd/projects/1)
## Translators
You can help translate Libredesk into your language on [Crowdin](https://crowdin.com/project/libredesk).

193
cmd/ai_assistants.go Normal file
View File

@@ -0,0 +1,193 @@
package main
import (
"encoding/json"
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
type aiAssisantRequest struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
ProductName string `json:"product_name"`
ProductDescription string `json:"product_description"`
AnswerLength string `json:"answer_length"`
AnswerTone string `json:"answer_tone"`
HandOff bool `json:"hand_off"`
HandOffTeam int `json:"hand_off_team"`
Enabled bool `json:"enabled"`
}
// handleGetAIAssistants returns all AI assistants from the database.
func handleGetAIAssistants(r *fastglue.Request) error {
var app = r.Context.(*App)
assistants, err := app.user.GetAIAssistants()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(assistants)
}
// handleGetAIAssistant returns a single AI assistant by ID.
func handleGetAIAssistant(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
assistant, err := app.user.GetAIAssistant(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(assistant)
}
// handleCreateAIAssistant creates a new AI assistant in the database.
func handleCreateAIAssistant(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = aiAssisantRequest{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateAIAssistantRequest(req, app); err != nil {
return sendErrorEnvelope(r, err)
}
// Prepare meta data
meta := umodels.AIAssistantMeta{
ProductName: req.ProductName,
ProductDescription: req.ProductDescription,
AnswerLength: req.AnswerLength,
AnswerTone: req.AnswerTone,
HandOff: req.HandOff,
HandOffTeam: req.HandOffTeam,
}
metaBytes, err := json.Marshal(meta)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorMarshalling", "name", "{globals.terms.meta}"), err.Error(), envelope.GeneralError)
}
// Create AI assistant in the database
assistant := &umodels.User{
FirstName: req.FirstName,
LastName: req.LastName,
Email: null.NewString(req.Email, req.Email != ""),
AvatarURL: null.NewString(req.AvatarURL, req.AvatarURL != ""),
Type: umodels.UserTypeAIAssistant,
Enabled: true,
Meta: metaBytes,
}
if err := app.user.CreateAIAssistant(assistant); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(assistant)
}
// handleUpdateAIAssistant updates an existing AI assistant in the database.
func handleUpdateAIAssistant(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = aiAssisantRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateAIAssistantRequest(req, app); err != nil {
return sendErrorEnvelope(r, err)
}
// Prepare meta data
meta := umodels.AIAssistantMeta{
ProductName: req.ProductName,
ProductDescription: req.ProductDescription,
AnswerLength: req.AnswerLength,
AnswerTone: req.AnswerTone,
HandOff: req.HandOff,
HandOffTeam: req.HandOffTeam,
}
metaBytes, err := json.Marshal(meta)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error encoding meta data", err.Error(), envelope.GeneralError)
}
// Update AI assistant in the database
assistant := umodels.User{
FirstName: req.FirstName,
LastName: req.LastName,
Email: null.NewString(req.Email, req.Email != ""),
AvatarURL: null.NewString(req.AvatarURL, req.AvatarURL != ""),
Enabled: req.Enabled,
Meta: metaBytes,
}
if err := app.user.UpdateAIAssistant(id, assistant); err != nil {
return sendErrorEnvelope(r, err)
}
// Return the updated assistant
updatedAssistant, err := app.user.GetAIAssistant(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(updatedAssistant)
}
// handleDeleteAIAssistant soft deletes an AI assistant from the database.
func handleDeleteAIAssistant(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.user.SoftDeleteAIAssistant(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// validateAIAssistantRequest validates the fields of an aiAssisantRequest.
func validateAIAssistantRequest(req aiAssisantRequest, app *App) error {
if req.FirstName == "" {
return envelope.NewError("validation_error", app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil)
}
if req.ProductName == "" {
return envelope.NewError("validation_error", app.i18n.Ts("globals.messages.empty", "name", "`product_name`"), nil)
}
if req.ProductDescription == "" {
return envelope.NewError("validation_error", app.i18n.Ts("globals.messages.empty", "name", "`product_description`"), nil)
}
if req.AnswerLength == "" {
return envelope.NewError("validation_error", app.i18n.Ts("globals.messages.empty", "name", "`answer_length`"), nil)
}
if req.AnswerTone == "" {
return envelope.NewError("validation_error", app.i18n.Ts("globals.messages.empty", "name", "`answer_tone`"), nil)
}
return nil
}

View File

@@ -45,10 +45,11 @@ func handleToggleAutomationRule(r *fastglue.Request) error {
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if err := app.automation.ToggleRule(id); err != nil {
toggledRule, err := app.automation.ToggleRule(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(toggledRule)
}
// handleUpdateAutomationRule updates an automation rule
@@ -66,10 +67,11 @@ func handleUpdateAutomationRule(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err = app.automation.UpdateRule(id, rule); err != nil {
updatedRule, err := app.automation.UpdateRule(id, rule)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedRule)
}
// handleCreateAutomationRule creates a new automation rule
@@ -81,10 +83,11 @@ func handleCreateAutomationRule(r *fastglue.Request) error {
if err := r.Decode(&rule, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := app.automation.CreateRule(rule); err != nil {
createdRule, err := app.automation.CreateRule(rule)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdRule)
}
// handleDeleteAutomationRule deletes an automation rule

View File

@@ -55,11 +55,12 @@ func handleCreateBusinessHours(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if err := app.businessHours.Create(businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays); err != nil {
createdBusinessHours, err := app.businessHours.Create(businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdBusinessHours)
}
// handleDeleteBusinessHour deletes the business hour with the given id.
@@ -93,8 +94,9 @@ func handleUpdateBusinessHours(r *fastglue.Request) error {
if businessHours.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`name`"), nil, envelope.InputError)
}
if err := app.businessHours.Update(id, businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays); err != nil {
updatedBusinessHours, err := app.businessHours.Update(id, businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedBusinessHours)
}

1088
cmd/chat.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -469,34 +469,16 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
conversation, err := enforceConversationAccess(app, uuid, user)
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Make sure a user is assigned before resolving conversation.
if status == cmodels.StatusResolved && conversation.AssignedUserID.Int == 0 {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("conversation.resolveWithoutAssignee"), nil))
}
// Update conversation status.
if err := app.conversation.UpdateConversationStatus(uuid, 0 /**status_id**/, status, snoozedUntil, user); err != nil {
return sendErrorEnvelope(r, err)
}
// If status is `Resolved`, send CSAT survey if enabled on inbox.
if status == cmodels.StatusResolved {
// Check if CSAT is enabled on the inbox and send CSAT survey message.
inbox, err := app.inbox.GetDBRecord(conversation.InboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if inbox.CSATEnabled {
if err := app.conversation.SendCSATReply(user.ID, *conversation); err != nil {
return sendErrorEnvelope(r, err)
}
}
}
return r.SendEnvelope(true)
}
@@ -583,7 +565,7 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.user.UpdateCustomAttributes(conversation.ContactID, attributes); err != nil {
if err := app.user.SaveCustomAttributes(conversation.ContactID, attributes, false); err != nil {
return sendErrorEnvelope(r, err)
}
// Broadcast update.
@@ -707,11 +689,9 @@ func handleCreateConversation(r *fastglue.Request) error {
// Find or create contact.
contact := umodels.User{
Email: null.StringFrom(req.Email),
SourceChannelID: null.StringFrom(req.Email),
FirstName: req.FirstName,
LastName: req.LastName,
InboxID: req.InboxID,
Email: null.StringFrom(req.Email),
FirstName: req.FirstName,
LastName: req.LastName,
}
if err := app.user.CreateContact(&contact); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
@@ -720,7 +700,6 @@ func handleCreateConversation(r *fastglue.Request) error {
// Create conversation
conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID,
contact.ContactChannelID,
req.InboxID,
"", /** last_message **/
time.Now(), /** last_message_at **/
@@ -744,7 +723,7 @@ func handleCreateConversation(r *fastglue.Request) error {
}
// Send reply to the created conversation.
if err := app.conversation.SendReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
if _, err := app.conversation.SendReply(media, req.InboxID, auser.ID, contact.ID, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if reply fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)

View File

@@ -3,9 +3,16 @@ package main
import (
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
type csatResponse struct {
Rating int `json:"rating"`
Feedback string `json:"feedback"`
}
// handleShowCSAT renders the CSAT page for a given csat.
func handleShowCSAT(r *fastglue.Request) error {
var (
@@ -42,7 +49,7 @@ func handleShowCSAT(r *fastglue.Request) error {
return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
"Data": map[string]interface{}{
"Title": "Rate your interaction with us",
"Title": "Rate your interaction with us",
"CSAT": map[string]interface{}{
"UUID": csat.UUID,
},
@@ -72,7 +79,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
})
}
if ratingI < 1 || ratingI > 5 {
if ratingI < 0 || ratingI > 5 {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": "Invalid `rating`",
@@ -103,3 +110,36 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
},
})
}
// handleSubmitCSATResponse handles CSAT response submission from the widget API.
func handleSubmitCSATResponse(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
req = csatResponse{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid JSON", nil, envelope.InputError)
}
if req.Rating < 0 || req.Rating > 5 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Rating must be between 0 and 5 (0 means no rating)", nil, envelope.InputError)
}
// At least one of rating or feedback must be provided
if req.Rating == 0 && req.Feedback == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Either rating or feedback must be provided", nil, envelope.InputError)
}
if uuid == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid UUID", nil, envelope.InputError)
}
// Update CSAT response
if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

View File

@@ -28,22 +28,6 @@ var (
}
)
// handleGetCustomAttribute retrieves a custom attribute by its ID.
func handleGetCustomAttribute(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
attribute, err := app.customAttribute.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(attribute)
}
// handleGetCustomAttributes retrieves all custom attributes from the database.
func handleGetCustomAttributes(r *fastglue.Request) error {
@@ -70,10 +54,11 @@ func handleCreateCustomAttribute(r *fastglue.Request) error {
if err := validateCustomAttribute(app, attribute); err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.customAttribute.Create(attribute); err != nil {
createdAttr, err := app.customAttribute.Create(attribute)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdAttr)
}
// handleUpdateCustomAttribute updates an existing custom attribute in the database.
@@ -92,10 +77,11 @@ func handleUpdateCustomAttribute(r *fastglue.Request) error {
if err := validateCustomAttribute(app, attribute); err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.customAttribute.Update(id, attribute); err != nil {
updatedAttr, err := app.customAttribute.Update(id, attribute)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedAttr)
}
// handleDeleteCustomAttribute deletes a custom attribute from the database.

View File

@@ -1,12 +1,16 @@
package main
import (
"encoding/json"
"mime"
"net/http"
"path"
"path/filepath"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/httputil"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
"github.com/abhinavxd/libredesk/internal/ws"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
@@ -89,6 +93,20 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/tags/{id}", perm(handleUpdateTag, "tags:manage"))
g.DELETE("/api/v1/tags/{id}", perm(handleDeleteTag, "tags:manage"))
// AI Assistants.
g.GET("/api/v1/ai-assistants", perm(handleGetAIAssistants, "ai:manage"))
g.GET("/api/v1/ai-assistants/{id}", perm(handleGetAIAssistant, "ai:manage"))
g.POST("/api/v1/ai-assistants", perm(handleCreateAIAssistant, "ai:manage"))
g.PUT("/api/v1/ai-assistants/{id}", perm(handleUpdateAIAssistant, "ai:manage"))
g.DELETE("/api/v1/ai-assistants/{id}", perm(handleDeleteAIAssistant, "ai:manage"))
// AI Snippets.
g.GET("/api/v1/ai-snippets", perm(handleGetAISnippets, "ai:manage"))
g.GET("/api/v1/ai-snippets/{id}", perm(handleGetAISnippet, "ai:manage"))
g.POST("/api/v1/ai-snippets", perm(handleCreateAISnippet, "ai:manage"))
g.PUT("/api/v1/ai-snippets/{id}", perm(handleUpdateAISnippet, "ai:manage"))
g.DELETE("/api/v1/ai-snippets/{id}", perm(handleDeleteAISnippet, "ai:manage"))
// Macros.
g.GET("/api/v1/macros", auth(handleGetMacros))
g.GET("/api/v1/macros/{id}", perm(handleGetMacro, "macros:manage"))
@@ -202,20 +220,61 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Custom attributes.
g.GET("/api/v1/custom-attributes", auth(handleGetCustomAttributes))
g.POST("/api/v1/custom-attributes", perm(handleCreateCustomAttribute, "custom_attributes:manage"))
g.GET("/api/v1/custom-attributes/{id}", perm(handleGetCustomAttribute, "custom_attributes:manage"))
g.PUT("/api/v1/custom-attributes/{id}", perm(handleUpdateCustomAttribute, "custom_attributes:manage"))
g.DELETE("/api/v1/custom-attributes/{id}", perm(handleDeleteCustomAttribute, "custom_attributes:manage"))
// Actvity logs.
g.GET("/api/v1/activity-logs", perm(handleGetActivityLogs, "activity_logs:manage"))
// Help Centers.
g.GET("/api/v1/help-centers", auth(handleGetHelpCenters))
g.GET("/api/v1/help-centers/{id}", auth(handleGetHelpCenter))
g.GET("/api/v1/help-centers/{id}/tree", auth(handleGetHelpCenterTree))
g.POST("/api/v1/help-centers", perm(handleCreateHelpCenter, "help_center:manage"))
g.PUT("/api/v1/help-centers/{id}", perm(handleUpdateHelpCenter, "help_center:manage"))
g.DELETE("/api/v1/help-centers/{id}", perm(handleDeleteHelpCenter, "help_center:manage"))
// Collections.
g.GET("/api/v1/help-centers/{hc_id}/collections", auth(handleGetCollections))
g.GET("/api/v1/help-centers/{hc_id}/collections/{id}", auth(handleGetCollection))
g.POST("/api/v1/help-centers/{hc_id}/collections", perm(handleCreateCollection, "help_center:manage"))
g.PUT("/api/v1/help-centers/{hc_id}/collections/{id}", perm(handleUpdateCollection, "help_center:manage"))
g.DELETE("/api/v1/help-centers/{hc_id}/collections/{id}", perm(handleDeleteCollection, "help_center:manage"))
g.PUT("/api/v1/collections/{id}/toggle", perm(handleToggleCollection, "help_center:manage"))
// Articles.
g.GET("/api/v1/collections/{col_id}/articles", auth(handleGetArticles))
g.GET("/api/v1/collections/{col_id}/articles/{id}", auth(handleGetArticle))
g.POST("/api/v1/collections/{col_id}/articles", perm(handleCreateArticle, "help_center:manage"))
g.PUT("/api/v1/collections/{col_id}/articles/{id}", perm(handleUpdateArticle, "help_center:manage"))
g.PUT("/api/v1/articles/{id}", perm(handleUpdateArticleByID, "help_center:manage"))
g.DELETE("/api/v1/collections/{col_id}/articles/{id}", perm(handleDeleteArticle, "help_center:manage"))
g.PUT("/api/v1/articles/{id}/status", perm(handleUpdateArticleStatus, "help_center:manage"))
// CSAT.
g.POST("/api/v1/csat/{uuid}/response", handleSubmitCSATResponse)
// WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error {
return handleWS(r, hub)
}))
// Live chat widget websocket.
g.GET("/widget/ws", handleWidgetWS)
// Widget APIs.
g.GET("/api/v1/widget/chat/settings/launcher", handleGetChatLauncherSettings)
g.GET("/api/v1/widget/chat/settings", handleGetChatSettings)
g.POST("/api/v1/widget/chat/conversations/init", rateLimitWidget(widgetAuth(handleChatInit)))
g.GET("/api/v1/widget/chat/conversations", rateLimitWidget(widgetAuth(handleGetConversations)))
g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", rateLimitWidget(widgetAuth(handleChatUpdateLastSeen)))
g.GET("/api/v1/widget/chat/conversations/{uuid}", rateLimitWidget(widgetAuth(handleChatGetConversation)))
g.POST("/api/v1/widget/chat/conversations/{uuid}/message", rateLimitWidget(widgetAuth(handleChatSendMessage)))
g.POST("/api/v1/widget/media/upload", rateLimitWidget(widgetAuth(handleWidgetMediaUpload)))
// Frontend pages.
g.GET("/", notAuthPage(serveIndexPage))
g.GET("/widget", serveWidgetIndexPage)
g.GET("/inboxes/{all:*}", authPage(serveIndexPage))
g.GET("/teams/{all:*}", authPage(serveIndexPage))
g.GET("/views/{all:*}", authPage(serveIndexPage))
@@ -225,8 +284,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/account/{all:*}", authPage(serveIndexPage))
g.GET("/reset-password", notAuthPage(serveIndexPage))
g.GET("/set-password", notAuthPage(serveIndexPage))
// FIXME: Don't need three separate routes for the same thing.
// Assets and static files.
// FIXME: Reduce the number of routes.
g.GET("/widget.js", serveWidgetJS)
g.GET("/assets/{all:*}", serveFrontendStaticFiles)
g.GET("/widget/assets/{all:*}", serveWidgetStaticFiles)
g.GET("/images/{all:*}", serveFrontendStaticFiles)
g.GET("/static/public/{all:*}", serveStaticFiles)
@@ -263,6 +326,77 @@ func serveIndexPage(r *fastglue.Request) error {
return nil
}
// validateWidgetReferer validates the Referer header against trusted domains configured in the live chat inbox settings.
func validateWidgetReferer(app *App, r *fastglue.Request, inboxID int) error {
// Get the Referer header from the request
referer := string(r.RequestCtx.Request.Header.Peek("Referer"))
// If no referer header is present, allow direct access.
if referer == "" {
return nil
}
// Get inbox configuration
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
app.lo.Error("error fetching inbox for referer check", "inbox_id", inboxID, "error", err)
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.NotFoundError)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(http.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Parse the live chat config
var config livechat.Config
if err := json.Unmarshal(inbox.Config, &config); err != nil {
app.lo.Error("error parsing live chat config for referer check", "error", err)
return r.SendErrorEnvelope(http.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
// If trusted domains list is empty, allow all referers
if len(config.TrustedDomains) == 0 {
return nil
}
// Check if the referer matches any of the trusted domains
if !httputil.IsOriginTrusted(referer, config.TrustedDomains) {
app.lo.Warn("widget request from untrusted referer blocked",
"referer", referer,
"inbox_id", inboxID,
"trusted_domains", config.TrustedDomains)
return r.SendErrorEnvelope(http.StatusForbidden, "Widget not allowed from this origin: "+referer, nil, envelope.PermissionError)
}
app.lo.Debug("widget request from trusted referer allowed", "referer", referer, "inbox_id", inboxID)
return nil
}
// serveWidgetIndexPage serves the widget index page of the application.
func serveWidgetIndexPage(r *fastglue.Request) error {
app := r.Context.(*App)
// Extract inbox ID and validate trusted domains if present
inboxID := r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id")
if err := validateWidgetReferer(app, r, inboxID); err != nil {
return err
}
// Prevent caching of the index page.
r.RequestCtx.Response.Header.Add("Cache-Control", "no-store, no-cache, must-revalidate, post-check=0, pre-check=0")
r.RequestCtx.Response.Header.Add("Pragma", "no-cache")
r.RequestCtx.Response.Header.Add("Expires", "-1")
// Serve the index.html file from the embedded filesystem.
file, err := app.fs.Get(path.Join(widgetDir, "index.html"))
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
r.RequestCtx.Response.Header.Set("Content-Type", "text/html")
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// serveStaticFiles serves static assets from the embedded filesystem.
func serveStaticFiles(r *fastglue.Request) error {
app := r.Context.(*App)
@@ -311,6 +445,47 @@ func serveFrontendStaticFiles(r *fastglue.Request) error {
return nil
}
// serveWidgetStaticFiles serves widget static assets from the embedded filesystem.
func serveWidgetStaticFiles(r *fastglue.Request) error {
app := r.Context.(*App)
filePath := string(r.RequestCtx.Path())
finalPath := filepath.Join(widgetDir, strings.TrimPrefix(filePath, "/widget"))
file, err := app.fs.Get(finalPath)
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
// Set the appropriate Content-Type based on the file extension.
ext := filepath.Ext(filePath)
contentType := mime.TypeByExtension(ext)
if contentType == "" {
contentType = http.DetectContentType(file.ReadBytes())
}
r.RequestCtx.Response.Header.Set("Content-Type", contentType)
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// serveWidgetJS serves the widget JavaScript file.
func serveWidgetJS(r *fastglue.Request) error {
app := r.Context.(*App)
// Set appropriate headers for JavaScript
r.RequestCtx.Response.Header.Set("Content-Type", "application/javascript")
r.RequestCtx.Response.Header.Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
// Serve the widget.js file from the embedded filesystem.
file, err := app.fs.Get("static/widget.js")
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// sendErrorEnvelope sends a standardized error response to the client.
func sendErrorEnvelope(r *fastglue.Request, err error) error {
e, ok := err.(envelope.Error)

548
cmd/helpcenter.go Normal file
View File

@@ -0,0 +1,548 @@
package main
import (
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/helpcenter"
hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// Help Centers
// handleGetHelpCenters returns all help centers from the database.
func handleGetHelpCenters(r *fastglue.Request) error {
app := r.Context.(*App)
helpCenters, err := app.helpcenter.GetAllHelpCenters()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(helpCenters)
}
// handleGetHelpCenter returns a specific help center by ID.
func handleGetHelpCenter(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
helpCenter, err := app.helpcenter.GetHelpCenterByID(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(helpCenter)
}
// handleCreateHelpCenter creates a new help center.
func handleCreateHelpCenter(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.HelpCenterCreateRequest{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateHelpCenter(r, &req); err != nil {
return err
}
helpCenter, err := app.helpcenter.CreateHelpCenter(req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(helpCenter)
}
// handleUpdateHelpCenter updates an existing help center.
func handleUpdateHelpCenter(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.HelpCenterUpdateRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateHelpCenter(r, &req); err != nil {
return err
}
helpCenter, err := app.helpcenter.UpdateHelpCenter(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(helpCenter)
}
// handleDeleteHelpCenter deletes a help center.
func handleDeleteHelpCenter(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.helpcenter.DeleteHelpCenter(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// Collections
// handleGetCollections returns all collections for a help center.
func handleGetCollections(r *fastglue.Request) error {
var (
app = r.Context.(*App)
helpCenterID, _ = strconv.Atoi(r.RequestCtx.UserValue("hc_id").(string))
err error
)
if helpCenterID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`help_center_id`"), nil, envelope.InputError)
}
// Check for locale filter
locale := string(r.RequestCtx.QueryArgs().Peek("locale"))
var collections []hcmodels.Collection
if locale != "" {
collections, err = app.helpcenter.GetCollectionsByHelpCenterAndLocale(helpCenterID, locale)
} else {
collections, err = app.helpcenter.GetCollectionsByHelpCenter(helpCenterID)
}
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(collections)
}
// handleGetCollection returns a specific collection by ID.
func handleGetCollection(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
collection, err := app.helpcenter.GetCollectionByID(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(collection)
}
// handleCreateCollection creates a new collection.
func handleCreateCollection(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.CollectionCreateRequest{}
helpCenterID, err = strconv.Atoi(r.RequestCtx.UserValue("hc_id").(string))
)
if helpCenterID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`help_center_id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateCollection(r, &req); err != nil {
return err
}
// Generate slug.
req.Slug = stringutil.GenerateSlug(req.Name, true)
collection, err := app.helpcenter.CreateCollection(helpCenterID, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(collection)
}
// handleUpdateCollection updates an existing collection.
func handleUpdateCollection(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.CollectionUpdateRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateCollection(r, &req); err != nil {
return err
}
// Generate slug
req.Slug = stringutil.GenerateSlug(req.Name, true)
collection, err := app.helpcenter.UpdateCollection(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(collection)
}
// handleDeleteCollection deletes a collection.
func handleDeleteCollection(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.helpcenter.DeleteCollection(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleToggleCollection toggles the published status of a collection.
func handleToggleCollection(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
collection, err := app.helpcenter.ToggleCollectionPublished(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(collection)
}
// Articles
// handleGetArticles returns all articles for a collection.
func handleGetArticles(r *fastglue.Request) error {
var (
app = r.Context.(*App)
collectionID, _ = strconv.Atoi(r.RequestCtx.UserValue("col_id").(string))
err error
)
if collectionID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`collection_id`"), nil, envelope.InputError)
}
// Check for locale filter
locale := string(r.RequestCtx.QueryArgs().Peek("locale"))
var articles []hcmodels.Article
if locale != "" {
articles, err = app.helpcenter.GetArticlesByCollectionAndLocale(collectionID, locale)
} else {
articles, err = app.helpcenter.GetArticlesByCollection(collectionID)
}
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(articles)
}
// handleGetArticle returns a specific article by ID.
func handleGetArticle(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
article, err := app.helpcenter.GetArticleByID(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleCreateArticle creates a new article.
func handleCreateArticle(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.ArticleCreateRequest{}
collectionID, _ = strconv.Atoi(r.RequestCtx.UserValue("col_id").(string))
)
if collectionID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`collection_id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateArticle(r, &req); err != nil {
return err
}
// Generate slug
req.Slug = stringutil.GenerateSlug(req.Title, true)
if req.Status == "" {
req.Status = hcmodels.ArticleStatusDraft
}
article, err := app.helpcenter.CreateArticle(collectionID, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleUpdateArticle updates an existing article.
func handleUpdateArticle(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.ArticleUpdateRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateArticle(r, &req); err != nil {
return err
}
// Generate slug
req.Slug = stringutil.GenerateSlug(req.Title, true)
if req.Status == "" {
req.Status = hcmodels.ArticleStatusDraft
}
article, err := app.helpcenter.UpdateArticle(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleUpdateArticleByID updates an existing article by its ID (allows collection changes).
func handleUpdateArticleByID(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.ArticleUpdateRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateArticle(r, &req); err != nil {
return err
}
// Generate slug
req.Slug = stringutil.GenerateSlug(req.Title, true)
if req.Status == "" {
req.Status = hcmodels.ArticleStatusDraft
}
article, err := app.helpcenter.UpdateArticle(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleDeleteArticle deletes an article.
func handleDeleteArticle(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.helpcenter.DeleteArticle(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateArticleStatus updates the status of an article.
func handleUpdateArticleStatus(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.UpdateStatusRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if req.Status == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`status`"), nil, envelope.InputError)
}
article, err := app.helpcenter.UpdateArticleStatus(id, req.Status)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleGetHelpCenterTree returns the complete tree structure for a help center.
func handleGetHelpCenterTree(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
// Get locale from query parameter (optional)
locale := string(r.RequestCtx.QueryArgs().Peek("locale"))
tree, err := app.helpcenter.GetHelpCenterTree(id, locale)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(tree)
}
func validateHelpCenter(r *fastglue.Request, req any) error {
app := r.Context.(*App)
switch v := req.(type) {
case *helpcenter.HelpCenterCreateRequest:
if v.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if v.Slug == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`slug`"), nil, envelope.InputError)
}
if v.PageTitle == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`page_title`"), nil, envelope.InputError)
}
if v.DefaultLocale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`default_locale`"), nil, envelope.InputError)
}
case *helpcenter.HelpCenterUpdateRequest:
if v.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if v.Slug == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`slug`"), nil, envelope.InputError)
}
if v.PageTitle == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`page_title`"), nil, envelope.InputError)
}
if v.DefaultLocale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`default_locale`"), nil, envelope.InputError)
}
}
return nil
}
func validateCollection(r *fastglue.Request, req any) error {
app := r.Context.(*App)
switch v := req.(type) {
case *helpcenter.CollectionCreateRequest:
if v.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if v.Locale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`locale`"), nil, envelope.InputError)
}
case *helpcenter.CollectionUpdateRequest:
if v.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if v.Locale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`locale`"), nil, envelope.InputError)
}
}
return nil
}
func validateArticle(r *fastglue.Request, req any) error {
app := r.Context.(*App)
switch v := req.(type) {
case *helpcenter.ArticleCreateRequest:
if v.Title == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`title`"), nil, envelope.InputError)
}
if v.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`content`"), nil, envelope.InputError)
}
if v.Locale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`locale`"), nil, envelope.InputError)
}
case *helpcenter.ArticleUpdateRequest:
if v.Title == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`title`"), nil, envelope.InputError)
}
if v.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`content`"), nil, envelope.InputError)
}
if v.Locale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`locale`"), nil, envelope.InputError)
}
}
return nil
}

View File

@@ -1,10 +1,12 @@
package main
import (
"encoding/json"
"net/mail"
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
@@ -47,11 +49,12 @@ func handleCreateInbox(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if err := app.inbox.Create(inbox); err != nil {
createdInbox, err := app.inbox.Create(inbox)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := validateInbox(app, inbox); err != nil {
if err := validateInbox(app, createdInbox); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -59,7 +62,13 @@ func handleCreateInbox(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(true)
// Clear passwords before returning.
if err := createdInbox.ClearPasswords(); err != nil {
app.lo.Error("error clearing inbox passwords from response", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil)
}
return r.SendEnvelope(createdInbox)
}
// handleUpdateInbox updates an inbox
@@ -82,7 +91,7 @@ func handleUpdateInbox(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
err = app.inbox.Update(id, inbox)
updatedInbox, err := app.inbox.Update(id, inbox)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -91,7 +100,13 @@ func handleUpdateInbox(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(inbox)
// Clear passwords before returning.
if err := updatedInbox.ClearPasswords(); err != nil {
app.lo.Error("error clearing inbox passwords from response", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil)
}
return r.SendEnvelope(updatedInbox)
}
// handleToggleInbox toggles an inbox
@@ -105,7 +120,8 @@ func handleToggleInbox(r *fastglue.Request) error {
app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err = app.inbox.Toggle(id); err != nil {
toggledInbox, err := app.inbox.Toggle(id)
if err != nil {
return err
}
@@ -113,7 +129,13 @@ func handleToggleInbox(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(true)
// Clear passwords before returning
if err := toggledInbox.ClearPasswords(); err != nil {
app.lo.Error("error clearing inbox passwords from response", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil)
}
return r.SendEnvelope(toggledInbox)
}
// handleDeleteInbox deletes an inbox
@@ -134,9 +156,11 @@ func handleDeleteInbox(r *fastglue.Request) error {
// validateInbox validates the inbox
func validateInbox(app *App, inbox imodels.Inbox) error {
// Validate from address.
if _, err := mail.ParseAddress(inbox.From); err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil)
// Validate from address only for email channels.
if inbox.Channel == "email" {
if _, err := mail.ParseAddress(inbox.From); err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil)
}
}
if len(inbox.Config) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "config"), nil)
@@ -147,5 +171,17 @@ func validateInbox(app *App, inbox imodels.Inbox) error {
if inbox.Channel == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "channel"), nil)
}
// Validate livechat-specific configuration
if inbox.Channel == livechat.ChannelLiveChat {
var config livechat.Config
if err := json.Unmarshal(inbox.Config, &config); err == nil {
// ShowOfficeHoursAfterAssignment cannot be enabled if ShowOfficeHoursInChat is disabled
if config.ShowOfficeHoursAfterAssignment && !config.ShowOfficeHoursInChat {
return envelope.NewError(envelope.InputError, "`show_office_hours_after_assignment` cannot be enabled when `show_office_hours_in_chat` is disabled", nil)
}
}
}
return nil
}

View File

@@ -25,8 +25,10 @@ import (
"github.com/abhinavxd/libredesk/internal/conversation/status"
"github.com/abhinavxd/libredesk/internal/csat"
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/helpcenter"
"github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/inbox/channel/email"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/abhinavxd/libredesk/internal/macro"
"github.com/abhinavxd/libredesk/internal/media"
@@ -35,6 +37,7 @@ import (
notifier "github.com/abhinavxd/libredesk/internal/notification"
emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
"github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/ratelimit"
"github.com/abhinavxd/libredesk/internal/report"
"github.com/abhinavxd/libredesk/internal/role"
"github.com/abhinavxd/libredesk/internal/search"
@@ -132,7 +135,8 @@ func initConstants() *constants {
// initFS initializes the stuffbin FileSystem.
func initFS() stuffbin.FileSystem {
var files = []string{
"frontend/dist",
"frontend/dist/main",
"frontend/dist/widget",
"i18n",
"static",
}
@@ -249,6 +253,20 @@ func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager {
return mgr
}
// initHelpCenter inits helpcenter manager.
func initHelpCenter(db *sqlx.DB, i18n *i18n.I18n) *helpcenter.Manager {
var lo = initLogger("helpcenter_manager")
mgr, err := helpcenter.New(helpcenter.Opts{
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing helpcenter: %v", err)
}
return mgr
}
// initViews inits view manager.
func initView(db *sqlx.DB) *view.Manager {
var lo = initLogger("view_manager")
@@ -460,10 +478,11 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager {
}
media, err := media.New(media.Opts{
Store: store,
Lo: lo,
DB: db,
I18n: i18n,
Store: store,
Lo: lo,
DB: db,
I18n: i18n,
Secret: ko.String("upload.secret"),
})
if err != nil {
log.Fatalf("error initializing media: %v", err)
@@ -572,11 +591,41 @@ func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrS
return inbox, nil
}
// initLiveChatInbox initializes the live chat inbox.
func initLiveChatInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
var config livechat.Config
// Load JSON data into Koanf.
if err := ko.Load(rawbytes.Provider([]byte(inboxRecord.Config)), kjson.Parser()); err != nil {
return nil, fmt.Errorf("loading config: %w", err)
}
if err := ko.UnmarshalWithConf("", &config, koanf.UnmarshalConf{Tag: "json"}); err != nil {
return nil, fmt.Errorf("unmarshalling `%s` %s config: %w", inboxRecord.Channel, inboxRecord.Name, err)
}
inbox, err := livechat.New(msgStore, usrStore, livechat.Opts{
ID: inboxRecord.ID,
Config: config,
Lo: initLogger("livechat_inbox"),
})
if err != nil {
return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err)
}
log.Printf("`%s` inbox successfully initialized", inboxRecord.Name)
return inbox, nil
}
// initializeInboxes handles inbox initialization.
func initializeInboxes(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
switch inboxR.Channel {
case "email":
return initEmailInbox(inboxR, msgStore, usrStore)
case "livechat":
return initLiveChatInbox(inboxR, msgStore, usrStore)
default:
return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel)
}
@@ -771,9 +820,39 @@ func initPriority(db *sqlx.DB, i18n *i18n.I18n) *priority.Manager {
}
// initAI inits AI manager.
func initAI(db *sqlx.DB, i18n *i18n.I18n) *ai.Manager {
func initAI(db *sqlx.DB, i18n *i18n.I18n, conversationStore *conversation.Manager, helpCenterStore *helpcenter.Manager) *ai.Manager {
lo := initLogger("ai")
m, err := ai.New(ai.Opts{
embeddingCfg := ai.EmbeddingConfig{
Provider: ko.String("ai.embedding.provider"),
URL: ko.String("ai.embedding.url"),
APIKey: ko.String("ai.embedding.api_key"),
Model: ko.String("ai.embedding.model"),
Timeout: ko.Duration("ai.embedding.timeout"),
}
chunkingCfg := ai.ChunkingConfig{
MaxTokens: ko.Int("ai.embedding.chunking.max_tokens"),
MinTokens: ko.Int("ai.embedding.chunking.min_tokens"),
OverlapTokens: ko.Int("ai.embedding.chunking.overlap_tokens"),
}
completionCfg := ai.CompletionConfig{
Provider: ko.String("ai.completion.provider"),
URL: ko.String("ai.completion.url"),
APIKey: ko.String("ai.completion.api_key"),
Model: ko.String("ai.completion.model"),
Timeout: ko.Duration("ai.completion.timeout"),
MaxTokens: ko.Int("ai.completion.max_tokens"),
Temperature: ko.Float64("ai.completion.temperature"),
}
workerCfg := ai.WorkerConfig{
Workers: ko.Int("ai.worker.workers"),
Capacity: ko.Int("ai.worker.capacity"),
}
m, err := ai.New(embeddingCfg, chunkingCfg, completionCfg, workerCfg, conversationStore, helpCenterStore, ai.Opts{
DB: db,
Lo: lo,
I18n: i18n,
@@ -894,3 +973,12 @@ func getLogLevel(lvl string) logf.Level {
return logf.InfoLevel
}
}
// initRateLimit initializes the rate limiter.
func initRateLimit(redisClient *redis.Client) *ratelimit.Limiter {
var config ratelimit.Config
if err := ko.UnmarshalWithConf("rate_limit", &config, koanf.UnmarshalConf{Tag: "toml"}); err != nil {
log.Fatalf("error unmarshalling rate limit config: %v", err)
}
return ratelimit.New(redisClient, config)
}

View File

@@ -3,7 +3,6 @@ package main
import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
realip "github.com/ferluci/fast-realip"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
@@ -42,12 +41,6 @@ func handleLogin(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.accountDisabled"), nil))
}
// Set user availability status to online.
if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
return sendErrorEnvelope(r, err)
}
user.AvailabilityStatus = umodels.Online
if err := app.auth.SaveSession(amodels.User{
ID: user.ID,
Email: user.Email.String,

View File

@@ -81,11 +81,12 @@ func handleCreateMacro(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil {
createdMacro, err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(macro)
return r.SendEnvelope(createdMacro)
}
// handleUpdateMacro updates a macro.
@@ -109,11 +110,12 @@ func handleUpdateMacro(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil {
updatedMacro, err := app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(macro)
return r.SendEnvelope(updatedMacro)
}
// handleDeleteMacro deletes macro.

View File

@@ -13,6 +13,8 @@ import (
_ "time/tzdata"
_ "github.com/pgvector/pgvector-go"
activitylog "github.com/abhinavxd/libredesk/internal/activity_log"
"github.com/abhinavxd/libredesk/internal/ai"
auth_ "github.com/abhinavxd/libredesk/internal/auth"
@@ -21,6 +23,7 @@ import (
"github.com/abhinavxd/libredesk/internal/colorlog"
"github.com/abhinavxd/libredesk/internal/csat"
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/helpcenter"
"github.com/abhinavxd/libredesk/internal/macro"
notifier "github.com/abhinavxd/libredesk/internal/notification"
"github.com/abhinavxd/libredesk/internal/report"
@@ -35,6 +38,7 @@ import (
"github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/media"
"github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/ratelimit"
"github.com/abhinavxd/libredesk/internal/role"
"github.com/abhinavxd/libredesk/internal/setting"
"github.com/abhinavxd/libredesk/internal/tag"
@@ -54,7 +58,8 @@ var (
ko = koanf.New(".")
ctx = context.Background()
appName = "libredesk"
frontendDir = "frontend/dist"
frontendDir = "frontend/dist/main"
widgetDir = "frontend/dist/widget"
// Injected at build time.
buildString string
@@ -94,6 +99,8 @@ type App struct {
customAttribute *customAttribute.Manager
report *report.Manager
webhook *webhook.Manager
rateLimit *ratelimit.Limiter
helpcenter *helpcenter.Manager
// Global state that stores data on an available app update.
update *AppUpdate
@@ -201,10 +208,19 @@ func main() {
sla = initSLA(db, team, settings, businessHours, notifier, template, user, i18n)
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template, webhook)
autoassigner = initAutoAssigner(team, user, conversation)
rateLimiter = initRateLimit(rdb)
helpcenter = initHelpCenter(db, i18n)
ai = initAI(db, i18n, conversation, helpcenter)
)
automation.SetConversationStore(conversation)
wsHub.SetConversationStore(conversation)
automation.SetConversationStore(conversation)
conversation.SetAIStore(ai)
helpcenter.SetAIStore(ai)
// Start inboxes.
startInboxes(ctx, inbox, conversation, user)
go automation.Run(ctx, automationWorkers)
go autoassigner.Run(ctx, autoAssignInterval)
go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval)
@@ -215,6 +231,7 @@ func main() {
go sla.SendNotifications(ctx)
go media.DeleteUnlinkedMedia(ctx)
go user.MonitorAgentAvailability(ctx)
go ai.StartConversationCompletions()
var app = &App{
lo: lo,
@@ -246,8 +263,10 @@ func main() {
role: initRole(db, i18n),
tag: initTag(db, i18n),
macro: initMacro(db, i18n),
ai: initAI(db, i18n),
ai: ai,
webhook: webhook,
rateLimit: rateLimiter,
helpcenter: helpcenter,
}
app.consts.Store(constants)
@@ -295,6 +314,8 @@ func main() {
webhook.Close()
colorlog.Red("Shutting down conversation...")
conversation.Close()
colorlog.Red("Shutting down AI...")
app.ai.StopConversationCompletions()
colorlog.Red("Shutting down SLA...")
sla.Close()
colorlog.Red("Shutting down database...")

View File

@@ -143,45 +143,51 @@ func handleMediaUpload(r *fastglue.Request) error {
}
// handleServeMedia serves uploaded media.
// Supports both authenticated agent access and unauthenticated access via signed URLs.
func handleServeMedia(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Fetch media from DB.
media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix))
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check if the user has permission to access the linked model.
allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String)
if err != nil {
return sendErrorEnvelope(r, err)
}
// For messages, check access to the conversation this message is part of.
if media.Model.String == "messages" {
conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int)
// Check if user is authenticated (agent access)
auser := r.RequestCtx.UserValue("user")
if auser != nil {
// Authenticated.
user, err := app.user.GetAgent(auser.(amodels.User).ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
allowed, err = app.authz.EnforceConversationAccess(user, conversation)
// Fetch media from DB.
media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix))
if err != nil {
return sendErrorEnvelope(r, err)
}
}
if !allowed {
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError)
// Check if the user has permission to access the linked model.
allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String)
if err != nil {
return sendErrorEnvelope(r, err)
}
// For messages, check access to the conversation this message is part of.
if media.Model.String == "messages" {
conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int)
if err != nil {
return sendErrorEnvelope(r, err)
}
allowed, err = app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return sendErrorEnvelope(r, err)
}
}
if !allowed {
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError)
}
}
// If no authenticated user, the middleware has already verified the request signature serve the file.
consts := app.consts.Load().(*constants)
switch consts.UploadProvider {
case "fs":

View File

@@ -4,6 +4,7 @@ import (
"strconv"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
medModels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/valyala/fasthttp"
@@ -41,7 +42,7 @@ func handleGetMessages(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
messages, pageSize, err := app.conversation.GetConversationMessages(uuid, page, pageSize)
messages, pageSize, err := app.conversation.GetConversationMessages(uuid, []string{cmodels.MessageIncoming, cmodels.MessageOutgoing, cmodels.MessageActivity}, nil, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -52,10 +53,11 @@ func handleGetMessages(r *fastglue.Request) error {
for j := range messages[i].Attachments {
messages[i].Attachments[j].URL = app.media.GetURL(messages[i].Attachments[j].UUID)
}
// Redact CSAT survey link
messages[i].CensorCSATContent()
}
// Process CSAT status for all messages (will only affect CSAT messages)
app.conversation.ProcessCSATStatus(messages)
return r.SendEnvelope(envelope.PageResults{
Total: total,
Results: messages,
@@ -89,8 +91,10 @@ func handleGetMessage(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
// Redact CSAT survey link
message.CensorCSATContent()
// Process CSAT status for the message (will only affect CSAT messages)
messages := []cmodels.Message{message}
app.conversation.ProcessCSATStatus(messages)
message = messages[0]
for j := range message.Attachments {
message.Attachments[j].URL = app.media.GetURL(message.Attachments[j].UUID)
@@ -150,6 +154,15 @@ func handleSendMessage(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Make sure the inbox is enabled.
inbox, err := app.inbox.GetDBRecord(conv.InboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Prepare attachments.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments {
@@ -162,13 +175,16 @@ func handleSendMessage(r *fastglue.Request) error {
}
if req.Private {
if err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message); err != nil {
return sendErrorEnvelope(r, err)
}
} else {
if err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/); err != nil {
message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(message)
}
return r.SendEnvelope(true)
message, err := app.conversation.SendReply(media, conv.InboxID, user.ID, conv.ContactID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(message)
}

View File

@@ -97,6 +97,23 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var app = r.Context.(*App)
// For media uploads, check if signature is provided in the query parameters, if so, verify it.
path := string(r.RequestCtx.Path())
if strings.HasPrefix(path, "/uploads/") {
signature := string(r.RequestCtx.QueryArgs().Peek("signature"))
expires := string(r.RequestCtx.QueryArgs().Peek("expires"))
if signature != "" && expires != "" {
if err := app.media.VerifySignature(r); err != nil {
app.lo.Error("error verifying media signature", "error",
err, "path", string(r.RequestCtx.Path()), "query", string(r.RequestCtx.QueryArgs().QueryString()))
return r.SendErrorEnvelope(http.StatusUnauthorized, "signature verification failed", nil, envelope.PermissionError)
}
return handler(r)
}
// If no signature, continue with normal authentication.
}
// Authenticate user using shared authentication logic
user, err := authenticateUser(r, app)
if err != nil {

View File

@@ -65,7 +65,8 @@ func handleCreateOIDC(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if err := app.oidc.Create(req); err != nil {
createdOIDC, err := app.oidc.Create(req)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -73,7 +74,11 @@ func handleCreateOIDC(r *fastglue.Request) error {
if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
}
return r.SendEnvelope("OIDC created successfully")
// Clear client secret before returning
createdOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(createdOIDC)
}
// handleUpdateOIDC updates an OIDC record.
@@ -96,7 +101,8 @@ func handleUpdateOIDC(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if err = app.oidc.Update(id, req); err != nil {
updatedOIDC, err := app.oidc.Update(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -104,7 +110,11 @@ func handleUpdateOIDC(r *fastglue.Request) error {
if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
}
return r.SendEnvelope(true)
// Clear client secret before returning
updatedOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(updatedOIDC)
}
// handleDeleteOIDC deletes an OIDC record.

View File

@@ -55,10 +55,11 @@ func handleCreateRole(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := app.role.Create(req); err != nil {
createdRole, err := app.role.Create(req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdRole)
}
// handleUpdateRole updates a role
@@ -71,8 +72,9 @@ func handleUpdateRole(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := app.role.Update(id, req); err != nil {
updatedRole, err := app.role.Update(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedRole)
}

View File

@@ -54,11 +54,12 @@ func handleCreateSLA(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications); err != nil {
createdSLA, err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("SLA created successfully.")
return r.SendEnvelope(createdSLA)
}
// handleUpdateSLA updates the SLA with the given ID.
@@ -81,11 +82,12 @@ func handleUpdateSLA(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications); err != nil {
updatedSLA, err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedSLA)
}
// handleDeleteSLA deletes the SLA with the given ID.

108
cmd/snippets.go Normal file
View File

@@ -0,0 +1,108 @@
package main
import (
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// snippetReq represents the request payload for snippets creation and updates.
type snippetReq struct {
Content string `json:"content"`
Enabled bool `json:"enabled"`
}
// validateSnippetReq validates the snippet request payload.
func validateSnippetReq(r *fastglue.Request, snippetData *snippetReq) error {
var app = r.Context.(*App)
if snippetData.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`content`"), nil, envelope.InputError)
}
return nil
}
// handleGetAISnippets returns all AI snippets from the database.
func handleGetAISnippets(r *fastglue.Request) error {
var app = r.Context.(*App)
snippets, err := app.ai.GetKnowledgeBaseItems()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(snippets)
}
// handleGetAISnippet returns a single AI snippet by ID.
func handleGetAISnippet(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
snippet, err := app.ai.GetKnowledgeBaseItem(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(snippet)
}
// handleCreateAISnippet creates a new AI snippet in the database.
func handleCreateAISnippet(r *fastglue.Request) error {
var (
app = r.Context.(*App)
snippetData snippetReq
)
if err := r.Decode(&snippetData, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateSnippetReq(r, &snippetData); err != nil {
return err
}
snippet, err := app.ai.CreateKnowledgeBaseItem("snippet", snippetData.Content, snippetData.Enabled)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(snippet)
}
// handleUpdateAISnippet updates an existing AI snippet in the database.
func handleUpdateAISnippet(r *fastglue.Request) error {
var (
app = r.Context.(*App)
snippetData snippetReq
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&snippetData, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateSnippetReq(r, &snippetData); err != nil {
return err
}
snippet, err := app.ai.UpdateKnowledgeBaseItem(id, "snippet", snippetData.Content, snippetData.Enabled)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(snippet)
}
// handleDeleteAISnippet deletes an AI snippet from the database.
func handleDeleteAISnippet(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.ai.DeleteKnowledgeBaseItem(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

View File

@@ -33,12 +33,12 @@ func handleCreateStatus(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
err := app.status.Create(status.Name)
createdStatus, err := app.status.Create(status.Name)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdStatus)
}
func handleDeleteStatus(r *fastglue.Request) error {
@@ -74,10 +74,10 @@ func handleUpdateStatus(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
err = app.status.Update(id, status.Name)
updatedStatus, err := app.status.Update(id, status.Name)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedStatus)
}

View File

@@ -35,11 +35,12 @@ func handleCreateTag(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if err := app.tag.Create(tag.Name); err != nil {
createdTag, err := app.tag.Create(tag.Name)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdTag)
}
// handleDeleteTag deletes a tag from the database.
@@ -78,9 +79,10 @@ func handleUpdateTag(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if err = app.tag.Update(id, tag.Name); err != nil {
updatedTag, err := app.tag.Update(id, tag.Name)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedTag)
}

View File

@@ -60,10 +60,11 @@ func handleCreateTeam(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if err := app.team.Create(req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations); err != nil {
createdTeam, err := app.team.Create(req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdTeam)
}
// handleUpdateTeam updates an existing team.
@@ -82,10 +83,11 @@ func handleUpdateTeam(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations); err != nil {
updatedTeam, err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations);
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedTeam)
}
// handleDeleteTeam deletes a team

View File

@@ -53,10 +53,11 @@ func handleCreateTemplate(r *fastglue.Request) error {
if req.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if err := app.tmpl.Create(req); err != nil {
template, err := app.tmpl.Create(req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(template)
}
// handleUpdateTemplate updates a template.
@@ -76,10 +77,11 @@ func handleUpdateTemplate(r *fastglue.Request) error {
if req.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if err = app.tmpl.Update(id, req); err != nil {
updatedTemplate, err := app.tmpl.Update(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedTemplate)
}
// handleDeleteTemplate deletes a template.

View File

@@ -35,6 +35,8 @@ 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.8.0", migrations.V0_8_0},
{"v0.9.0", migrations.V0_9_0},
}
// upgrade upgrades the database to the current version by running SQL migration files

View File

@@ -47,10 +47,11 @@ func handleCreateUserView(r *fastglue.Request) error {
if string(view.Filters) == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`Filters`"), nil, envelope.InputError)
}
if err := app.view.Create(view.Name, view.Filters, user.ID); err != nil {
createdView, err := app.view.Create(view.Name, view.Filters, user.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdView)
}
// handleDeleteUserView deletes a view for a user.
@@ -111,8 +112,9 @@ func handleUpdateUserView(r *fastglue.Request) error {
if v.UserID != user.ID {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
if err = app.view.Update(id, view.Name, view.Filters); err != nil {
updatedView, err := app.view.Update(id, view.Name, view.Filters)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedView)
}

View File

@@ -67,12 +67,15 @@ func handleCreateWebhook(r *fastglue.Request) error {
return r.SendEnvelope(err)
}
_, err := app.webhook.Create(webhook)
webhook, err := app.webhook.Create(webhook)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
// Clear secret before returning
webhook.Secret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(webhook)
}
// handleUpdateWebhook updates an existing webhook in the database.
@@ -105,11 +108,15 @@ func handleUpdateWebhook(r *fastglue.Request) error {
webhook.Secret = existingWebhook.Secret
}
if err := app.webhook.Update(id, webhook); err != nil {
updatedWebhook, err := app.webhook.Update(id, webhook)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
// Clear secret before returning
updatedWebhook.Secret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(updatedWebhook)
}
// handleDeleteWebhook deletes a webhook from the database.
@@ -140,11 +147,15 @@ func handleToggleWebhook(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.webhook.Toggle(id); err != nil {
toggledWebhook, err := app.webhook.Toggle(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
// Clear secret before returning
toggledWebhook.Secret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(toggledWebhook)
}
// handleTestWebhook sends a test payload to a webhook.

167
cmd/widget_middleware.go Normal file
View File

@@ -0,0 +1,167 @@
package main
import (
"fmt"
"strconv"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
const (
// Context keys for storing authenticated widget data
ctxWidgetClaims = "widget_claims"
ctxWidgetInboxID = "widget_inbox_id"
ctxWidgetContactID = "widget_contact_id"
ctxWidgetInbox = "widget_inbox"
// Header sent in every widget request to identify the inbox
hdrWidgetInboxID = "X-Libredesk-Inbox-ID"
)
// widgetAuth middleware authenticates widget requests using JWT and inbox validation.
// It always validates the inbox from X-Libredesk-Inbox-ID header, and conditionally validates JWT.
// For /conversations/init without JWT, it allows visitor creation while still validating inbox.
func widgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) error {
return func(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
// Always extract and validate inbox_id from custom header
inboxIDHeader := string(r.RequestCtx.Request.Header.Peek(hdrWidgetInboxID))
if inboxIDHeader == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
inboxID, err := strconv.Atoi(inboxIDHeader)
if err != nil || inboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Always fetch and validate inbox
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
app.lo.Error("error fetching inbox", "inbox_id", inboxID, "error", err)
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Check if inbox is the correct type for widget requests
if inbox.Channel != livechat.ChannelLiveChat {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Always store inbox data in context
r.RequestCtx.SetUserValue(ctxWidgetInboxID, inboxID)
r.RequestCtx.SetUserValue(ctxWidgetInbox, inbox)
// Extract JWT from Authorization header (Bearer token)
authHeader := string(r.RequestCtx.Request.Header.Peek("Authorization"))
// For init endpoint, allow requests without JWT (visitor creation)
if authHeader == "" && strings.Contains(string(r.RequestCtx.Path()), "/conversations/init") {
return next(r)
}
// For all other requests, require JWT
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
jwtToken := strings.TrimPrefix(authHeader, "Bearer ")
// Verify JWT using inbox secret
claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String)
if err != nil {
app.lo.Error("invalid JWT", "jwt", jwtToken, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
// Resolve user/contact ID from JWT claims
contactID, err := resolveUserIDFromClaims(app, claims)
if err != nil {
envErr, ok := err.(envelope.Error)
if ok && envErr.ErrorType != envelope.NotFoundError {
app.lo.Error("error resolving user ID from JWT claims", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
}
}
// Store authenticated data in request context for downstream handlers
r.RequestCtx.SetUserValue(ctxWidgetClaims, claims)
r.RequestCtx.SetUserValue(ctxWidgetContactID, contactID)
return next(r)
}
}
// Helper functions to extract authenticated data from request context
// getWidgetInboxID extracts inbox ID from request context
func getWidgetInboxID(r *fastglue.Request) (int, error) {
val := r.RequestCtx.UserValue(ctxWidgetInboxID)
if val == nil {
return 0, fmt.Errorf("widget middleware not applied: missing inbox ID in context")
}
inboxID, ok := val.(int)
if !ok {
return 0, fmt.Errorf("invalid inbox ID type in context")
}
return inboxID, nil
}
// getWidgetContactID extracts contact ID from request context
func getWidgetContactID(r *fastglue.Request) (int, error) {
val := r.RequestCtx.UserValue(ctxWidgetContactID)
if val == nil {
return 0, fmt.Errorf("widget middleware not applied: missing contact ID in context")
}
contactID, ok := val.(int)
if !ok {
return 0, fmt.Errorf("invalid contact ID type in context")
}
return contactID, nil
}
// getWidgetInbox extracts inbox model from request context
func getWidgetInbox(r *fastglue.Request) (imodels.Inbox, error) {
val := r.RequestCtx.UserValue(ctxWidgetInbox)
if val == nil {
return imodels.Inbox{}, fmt.Errorf("widget middleware not applied: missing inbox in context")
}
inbox, ok := val.(imodels.Inbox)
if !ok {
return imodels.Inbox{}, fmt.Errorf("invalid inbox type in context")
}
return inbox, nil
}
// getWidgetClaimsOptional extracts JWT claims from request context, returns nil if not set
func getWidgetClaimsOptional(r *fastglue.Request) *Claims {
val := r.RequestCtx.UserValue(ctxWidgetClaims)
if val == nil {
return nil
}
if claims, ok := val.(Claims); ok {
return &claims
}
return nil
}
// rateLimitWidget applies rate limiting to widget endpoints.
func rateLimitWidget(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
app := r.Context.(*App)
if err := app.rateLimit.CheckWidgetLimit(r.RequestCtx); err != nil {
return err
}
return handler(r)
}
}

272
cmd/widget_ws.go Normal file
View File

@@ -0,0 +1,272 @@
package main
import (
"encoding/json"
"fmt"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
"github.com/fasthttp/websocket"
"github.com/zerodha/fastglue"
)
// Widget WebSocket message types
const (
WidgetMsgTypeJoin = "join"
WidgetMsgTypeMessage = "message"
WidgetMsgTypeTyping = "typing"
WidgetMsgTypePing = "ping"
WidgetMsgTypePong = "pong"
WidgetMsgTypeError = "error"
WidgetMsgTypeNewMsg = "new_message"
WidgetMsgTypeStatus = "status"
WidgetMsgTypeJoined = "joined"
)
// WidgetMessage represents a message sent through the widget WebSocket
type WidgetMessage struct {
Type string `json:"type"`
JWT string `json:"jwt,omitempty"`
Data any `json:"data"`
}
type WidgetInboxJoinRequest struct {
InboxID int `json:"inbox_id"`
}
// WidgetMessageData represents a chat message through the widget
type WidgetMessageData struct {
ConversationUUID string `json:"conversation_uuid"`
Content string `json:"content"`
SenderName string `json:"sender_name,omitempty"`
SenderType string `json:"sender_type"`
Timestamp int64 `json:"timestamp"`
}
// WidgetTypingData represents typing indicator data
type WidgetTypingData struct {
ConversationUUID string `json:"conversation_uuid"`
IsTyping bool `json:"is_typing"`
}
// handleWidgetWS handles the widget WebSocket connection for live chat.
func handleWidgetWS(r *fastglue.Request) error {
var app = r.Context.(*App)
if err := upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) {
// To store client and live chat references for cleanup.
var client *livechat.Client
var liveChat *livechat.LiveChat
// Clean up client when connection closes.
defer func() {
conn.Close()
if client != nil && liveChat != nil {
liveChat.RemoveClient(client)
close(client.Channel)
app.lo.Debug("cleaned up client on websocket disconnect", "client_id", client.ID)
}
}()
// Read messages from the WebSocket connection.
for {
var msg WidgetMessage
if err := conn.ReadJSON(&msg); err != nil {
app.lo.Debug("widget websocket connection closed", "error", err)
break
}
switch msg.Type {
// Inbox join request.
case WidgetMsgTypeJoin:
var joinedClient *livechat.Client
var joinedLiveChat *livechat.LiveChat
var err error
if joinedClient, joinedLiveChat, err = handleInboxJoin(app, conn, &msg); err != nil {
app.lo.Error("error handling widget join", "error", err)
sendWidgetError(conn, "Failed to join conversation")
continue
}
// Store the client and livechat reference for cleanup.
client = joinedClient
liveChat = joinedLiveChat
// Typing.
case WidgetMsgTypeTyping:
if err := handleWidgetTyping(app, &msg); err != nil {
app.lo.Error("error handling widget typing", "error", err)
continue
}
// Ping.
case WidgetMsgTypePing:
if err := conn.WriteJSON(WidgetMessage{
Type: WidgetMsgTypePong,
}); err != nil {
app.lo.Error("error writing pong to widget client", "error", err)
}
}
}
}); err != nil {
app.lo.Error("error upgrading widget websocket connection", "error", err)
}
return nil
}
// handleInboxJoin handles a websocket join request for a live chat inbox.
func handleInboxJoin(app *App, conn *websocket.Conn, msg *WidgetMessage) (*livechat.Client, *livechat.LiveChat, error) {
joinDataBytes, err := json.Marshal(msg.Data)
if err != nil {
return nil, nil, fmt.Errorf("invalid join data: %w", err)
}
var joinData WidgetInboxJoinRequest
if err := json.Unmarshal(joinDataBytes, &joinData); err != nil {
return nil, nil, fmt.Errorf("invalid join data format: %w", err)
}
// Validate JWT with inbox secret
claims, err := validateWidgetMessageJWT(app, msg.JWT, joinData.InboxID)
if err != nil {
return nil, nil, fmt.Errorf("JWT validation failed: %w", err)
}
// Resolve user ID.
userID, err := resolveUserIDFromClaims(app, claims)
if err != nil {
return nil, nil, fmt.Errorf("failed to resolve user ID from claims: %w", err)
}
// Make sure inbox is active.
inbox, err := app.inbox.GetDBRecord(joinData.InboxID)
if err != nil {
return nil, nil, fmt.Errorf("inbox not found: %w", err)
}
if !inbox.Enabled {
return nil, nil, fmt.Errorf("inbox is not enabled")
}
// Get live chat inbox
lcInbox, err := app.inbox.Get(inbox.ID)
if err != nil {
return nil, nil, fmt.Errorf("live chat inbox not found: %w", err)
}
// Assert type.
liveChat, ok := lcInbox.(*livechat.LiveChat)
if !ok {
return nil, nil, fmt.Errorf("inbox is not a live chat inbox")
}
// Add client to live chat session
userIDStr := fmt.Sprintf("%d", userID)
client, err := liveChat.AddClient(userIDStr)
if err != nil {
app.lo.Error("error adding client to live chat", "error", err, "user_id", userIDStr)
return nil, nil, err
}
// Start listening for messages from the live chat channel.
go func() {
for msgData := range client.Channel {
if err := conn.WriteMessage(websocket.TextMessage, msgData); err != nil {
app.lo.Error("error forwarding message to widget client", "error", err)
return
}
}
}()
// Send join confirmation
joinResp := WidgetMessage{
Type: WidgetMsgTypeJoined,
Data: map[string]string{
"message": "namaste!",
},
}
if err := conn.WriteJSON(joinResp); err != nil {
return nil, nil, err
}
app.lo.Debug("widget client joined live chat", "user_id", userIDStr, "inbox_id", joinData.InboxID)
return client, liveChat, nil
}
// handleWidgetTyping handles typing indicators
func handleWidgetTyping(app *App, msg *WidgetMessage) error {
typingDataBytes, err := json.Marshal(msg.Data)
if err != nil {
app.lo.Error("error marshalling typing data", "error", err)
return fmt.Errorf("invalid typing data: %w", err)
}
var typingData WidgetTypingData
if err := json.Unmarshal(typingDataBytes, &typingData); err != nil {
app.lo.Error("error unmarshalling typing data", "error", err)
return fmt.Errorf("invalid typing data format: %w", err)
}
// Get conversation to retrieve inbox ID for JWT validation
if typingData.ConversationUUID == "" {
return fmt.Errorf("conversation UUID is required for typing messages")
}
conversation, err := app.conversation.GetConversation(0, typingData.ConversationUUID)
if err != nil {
app.lo.Error("error fetching conversation for typing", "conversation_uuid", typingData.ConversationUUID, "error", err)
return fmt.Errorf("conversation not found: %w", err)
}
// Validate JWT with inbox secret
claims, err := validateWidgetMessageJWT(app, msg.JWT, conversation.InboxID)
if err != nil {
return fmt.Errorf("JWT validation failed: %w", err)
}
userID := claims.UserID
// Broadcast typing status to agents via conversation manager
// Set broadcastToWidgets=false to avoid echoing back to widget clients
app.conversation.BroadcastTypingToConversation(typingData.ConversationUUID, typingData.IsTyping, false)
app.lo.Debug("Broadcasted typing data from widget user to agents", "user_id", userID, "is_typing", typingData.IsTyping, "conversation_uuid", typingData.ConversationUUID)
return nil
}
// validateWidgetMessageJWT validates the incoming widget message JWT using inbox secret
func validateWidgetMessageJWT(app *App, jwtToken string, inboxID int) (Claims, error) {
if jwtToken == "" {
return Claims{}, fmt.Errorf("JWT token is empty")
}
if inboxID <= 0 {
return Claims{}, fmt.Errorf("inbox ID is required for JWT validation")
}
// Get inbox to retrieve secret for JWT verification
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
return Claims{}, fmt.Errorf("inbox not found: %w", err)
}
if !inbox.Secret.Valid || inbox.Secret.String == "" {
return Claims{}, fmt.Errorf("inbox secret not configured for JWT verification")
}
// Use the existing verifyStandardJWT function which properly validates with inbox secret
claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String)
if err != nil {
return Claims{}, fmt.Errorf("JWT validation failed: %w", err)
}
return claims, nil
}
// sendWidgetError sends an error message to the widget client
func sendWidgetError(conn *websocket.Conn, message string) {
errorMsg := WidgetMessage{
Type: WidgetMsgTypeError,
Data: map[string]string{
"message": message,
},
}
conn.WriteJSON(errorMsg)
}

View File

@@ -122,3 +122,37 @@ unsnooze_interval = "5m"
[sla]
# How often to evaluate SLA compliance for conversations
evaluation_interval = "5m"
[rate_limit]
[rate_limit.widget]
enabled = true
requests_per_minute = 100
[ai]
[ai.embedding]
provider = "openai"
url = "https://api.openai.com/v1/embeddings"
api_key = "secret"
model = "text-embedding-3-small"
timeout = "20s"
[ai.embedding.chunking]
# Maximum tokens per chunk (increase for larger context models)
max_tokens = 2000
# Minimum tokens per chunk (smaller chunks may lack context)
min_tokens = 400
# Overlap tokens between chunks for context continuity
overlap_tokens = 150
[ai.completion]
provider = "openai"
url = "https://api.openai.com/v1/chat/completions"
api_key = "secret"
model = "gpt-oss:20b"
temperature = 0.2
max_tokens = 1000
timeout = "30s"
[ai.worker]
workers = 50
capacity = 10000

View File

@@ -0,0 +1,30 @@
# API getting started
You can access the Libredesk API to interact with your instance programmatically.
## Generating API keys
1. **Edit agent**: Go to Admin → Teammate → Agent → Edit
2. **Generate new API key**: An API Key and API Secret will be generated for the agent
3. **Save the credentials**: Keep both the API Key and API Secret secure
4. **Key management**: You can revoke / regenerate API keys at any time from the same page
## Using the API
LibreDesk supports two authentication schemes:
### Basic authentication
```bash
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
-H "Authorization: Basic <base64_encoded_key:secret>"
```
### Token authentication
```bash
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
-H "Authorization: token your_api_key:your_api_secret"
```
## API Documentation
Complete API documentation with available endpoints and examples coming soon.

View File

@@ -210,7 +210,7 @@ Triggered when an existing message is updated.
## Delivery and Retries
- Webhooks are delivered with a 10-second timeout
- 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

View File

@@ -32,6 +32,7 @@ nav:
- Email Templates: templating.md
- SSO Setup: sso.md
- Webhooks: webhooks.md
- API Getting Started: api-getting-started.md
- Contributions:
- Developer Setup: developer-setup.md
- Translate Libredesk: translations.md

59
frontend/README-SETUP.md Normal file
View File

@@ -0,0 +1,59 @@
# Libredesk Frontend - Multi-App Setup
This frontend supports both the main Libredesk application and a chat widget as separate Vue applications sharing common UI components.
## Project Structure
```
frontend/
├── apps/
│ ├── main/ # Main Libredesk application
│ │ ├── src/
│ │ └── index.html
│ └── widget/ # Chat widget application
│ ├── src/
│ └── index.html
├── shared-ui/ # Shared UI components (shadcn/ui)
│ ├── components/
│ │ └── ui/ # shadcn/ui components
│ ├── lib/ # Utility functions
│ └── assets/ # Shared styles
└── package.json
```
## Development
Check Makefile for available commands.
## Shared UI Components
The `shared-ui` directory contains all the shadcn/ui components that can be used in both apps.
### Using Shared Components
```vue
<script setup>
import { Button } from '@shared-ui/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@shared-ui/components/ui/card'
import { Input } from '@shared-ui/components/ui/input'
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Example Card</CardTitle>
</CardHeader>
<CardContent>
<Input placeholder="Type something..." />
<Button>Submit</Button>
</CardContent>
</Card>
</template>
```
### Path Aliases
- `@shared-ui` - Points to the shared-ui directory
- `@main` - Points to apps/main/src
- `@widget` - Points to apps/widget/src
- `@` - Points to the current app's src directory (context-dependent)

View File

@@ -112,26 +112,26 @@
<script setup>
import { onMounted, ref } from 'vue'
import { RouterView } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { initWS } from '@/websocket.js'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { useUserStore } from './stores/user'
import { initWS } from './websocket.js'
import { EMITTER_EVENTS } from './constants/emitterEvents.js'
import { useEmitter } from './composables/useEmitter'
import { handleHTTPError } from './utils/http'
import { useConversationStore } from './stores/conversation'
import { useInboxStore } from '@/stores/inbox'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla'
import { useMacroStore } from '@/stores/macro'
import { useTagStore } from '@/stores/tag'
import { useCustomAttributeStore } from '@/stores/customAttributes'
import { useIdleDetection } from '@/composables/useIdleDetection'
import { useInboxStore } from './stores/inbox'
import { useUsersStore } from './stores/users'
import { useTeamStore } from './stores/team'
import { useSlaStore } from './stores/sla'
import { useMacroStore } from './stores/macro'
import { useTagStore } from './stores/tag'
import { useCustomAttributeStore } from './stores/customAttributes'
import { useIdleDetection } from './composables/useIdleDetection'
import PageHeader from './components/layout/PageHeader.vue'
import ViewForm from '@/features/view/ViewForm.vue'
import AppUpdate from '@/components/update/AppUpdate.vue'
import api from '@/api'
import AppUpdate from '@main/components/update/AppUpdate.vue'
import api from './api'
import { toast as sooner } from 'vue-sonner'
import Sidebar from '@/components/sidebar/Sidebar.vue'
import Sidebar from '@main/components/sidebar/Sidebar.vue'
import Command from '@/features/command/CommandBox.vue'
import CreateConversation from '@/features/conversation/CreateConversation.vue'
import { Inbox, Shield, FileLineChart, BookUser } from 'lucide-vue-next'
@@ -147,9 +147,9 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider
} from '@/components/ui/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue'
} from '@shared-ui/components/ui/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
import SidebarNavUser from '@main/components/sidebar/SidebarNavUser.vue'
const route = useRoute()
const emitter = useEmitter()

View File

@@ -5,8 +5,8 @@
<script setup>
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from './constants/emitterEvents.js'
import { useEmitter } from './composables/useEmitter'
import { toast as sooner } from 'vue-sonner'
const emitter = useEmitter()

View File

@@ -7,6 +7,6 @@
<script setup>
import { RouterView } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
import { Toaster } from '@shared-ui/components/ui/sonner'
import { TooltipProvider } from '@shared-ui/components/ui/tooltip'
</script>

View File

@@ -47,7 +47,6 @@ const createCustomAttribute = (data) =>
'Content-Type': 'application/json'
}
})
const getCustomAttribute = (id) => http.get(`/api/v1/custom-attributes/${id}`)
const updateCustomAttribute = (id, data) =>
http.put(`/api/v1/custom-attributes/${id}`, data, {
headers: {
@@ -431,6 +430,96 @@ const generateAPIKey = (id) =>
const revokeAPIKey = (id) => http.delete(`/api/v1/agents/${id}/api-key`)
// Help center.
const getHelpCenters = () => http.get('/api/v1/help-centers')
const getHelpCenter = (id) => http.get(`/api/v1/help-centers/${id}`)
const createHelpCenter = (data) => http.post('/api/v1/help-centers', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateHelpCenter = (id, data) => http.put(`/api/v1/help-centers/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteHelpCenter = (id) => http.delete(`/api/v1/help-centers/${id}`)
const getHelpCenterTree = (id, params) => http.get(`/api/v1/help-centers/${id}/tree`, { params })
const getCollections = (helpCenterId, params) => http.get(`/api/v1/help-centers/${helpCenterId}/collections`, { params })
const getCollection = (id) => http.get(`/api/v1/help-centers/*/collections/${id}`)
const createCollection = (helpCenterId, data) => http.post(`/api/v1/help-centers/${helpCenterId}/collections`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateCollection = (helpCenterId, id, data) => http.put(`/api/v1/help-centers/${helpCenterId}/collections/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteCollection = (helpCenterId, id) => http.delete(`/api/v1/help-centers/${helpCenterId}/collections/${id}`)
const toggleCollection = (id) => http.put(`/api/v1/collections/${id}/toggle`)
const getArticles = (collectionId, params) => http.get(`/api/v1/collections/${collectionId}/articles`, { params })
const getArticle = (id) => http.get(`/api/v1/collections/*/articles/${id}`)
const createArticle = (collectionId, data) => http.post(`/api/v1/collections/${collectionId}/articles`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateArticle = (collectionId, id, data) => http.put(`/api/v1/collections/${collectionId}/articles/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateArticleByID = (id, data) => http.put(`/api/v1/articles/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteArticle = (collectionId, id) => http.delete(`/api/v1/collections/${collectionId}/articles/${id}`)
const updateArticleStatus = (id, data) => http.put(`/api/v1/articles/${id}/status`, data, {
headers: {
'Content-Type': 'application/json'
}
})
// AI Assistants
const getAIAssistants = () => http.get('/api/v1/ai-assistants')
const getAIAssistant = (id) => http.get(`/api/v1/ai-assistants/${id}`)
const createAIAssistant = (data) =>
http.post('/api/v1/ai-assistants', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAIAssistant = (id, data) =>
http.put(`/api/v1/ai-assistants/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteAIAssistant = (id) => http.delete(`/api/v1/ai-assistants/${id}`)
// AI Snippets
const getAISnippets = () => http.get('/api/v1/ai-snippets')
const getAISnippet = (id) => http.get(`/api/v1/ai-snippets/${id}`)
const createAISnippet = (data) =>
http.post('/api/v1/ai-snippets', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAISnippet = (id, data) =>
http.put(`/api/v1/ai-snippets/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteAISnippet = (id) => http.delete(`/api/v1/ai-snippets/${id}`)
export default {
login,
deleteUser,
@@ -504,6 +593,18 @@ export default {
sendMessage,
retryMessage,
createUser,
// AI Assistants
getAIAssistants,
getAIAssistant,
createAIAssistant,
updateAIAssistant,
deleteAIAssistant,
// AI Snippets
getAISnippets,
getAISnippet,
createAISnippet,
updateAISnippet,
deleteAISnippet,
createInbox,
updateInbox,
deleteInbox,
@@ -554,7 +655,6 @@ export default {
createCustomAttribute,
updateCustomAttribute,
deleteCustomAttribute,
getCustomAttribute,
getContactNotes,
createContactNote,
deleteContactNote,
@@ -567,5 +667,25 @@ export default {
toggleWebhook,
testWebhook,
generateAPIKey,
revokeAPIKey
revokeAPIKey,
// Help Center
getHelpCenters,
getHelpCenter,
createHelpCenter,
updateHelpCenter,
deleteHelpCenter,
getHelpCenterTree,
getCollections,
getCollection,
createCollection,
updateCollection,
deleteCollection,
toggleCollection,
getArticles,
getArticle,
createArticle,
updateArticle,
updateArticleByID,
deleteArticle,
updateArticleStatus,
}

View File

@@ -12,7 +12,7 @@
</template>
<script setup>
import { Button } from '@/components/ui/button'
import { Button } from '@shared-ui/components/ui/button'
import { X } from 'lucide-vue-next'
defineProps({

View File

@@ -42,8 +42,8 @@
<script setup>
import { computed } from 'vue'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { Avatar, AvatarImage, AvatarFallback } from '@shared-ui/components/ui/avatar'
import ComboBox from '@shared-ui/components/ui/combobox/ComboBox.vue'
const props = defineProps({
modelValue: [String, Number, Object],

View File

@@ -51,7 +51,7 @@ import {
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
} from '@shared-ui/components/ui/table'
const { t } = useI18n()
const props = defineProps({

View File

@@ -0,0 +1,65 @@
<template>
<div ref="codeEditor" @click="editorView?.focus()" class="w-full h-[28rem] border rounded-md" />
</template>
<script setup>
import { ref, onMounted, watch, nextTick, useTemplateRef } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { html } from '@codemirror/lang-html'
import { oneDark } from '@codemirror/theme-one-dark'
import { useColorMode } from '@vueuse/core'
const props = defineProps({
modelValue: { type: String, default: '' },
language: { type: String, default: 'html' },
disabled: Boolean
})
const emit = defineEmits(['update:modelValue'])
const data = ref('')
let editorView = null
const codeEditor = useTemplateRef('codeEditor')
const initCodeEditor = (body) => {
const isDark = useColorMode().value === 'dark'
editorView = new EditorView({
doc: body,
extensions: [
basicSetup,
html(),
...(isDark ? [oneDark] : []),
EditorView.editable.of(!props.disabled),
EditorView.theme({
'&': { height: '100%' },
'.cm-editor': { height: '100%' },
'.cm-scroller': { overflow: 'auto' }
}),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return
const v = update.state.doc.toString()
emit('update:modelValue', v)
data.value = v
})
],
parent: codeEditor.value
})
nextTick(() => {
editorView?.focus()
})
}
onMounted(() => {
initCodeEditor(props.modelValue || '')
})
watch(() => props.modelValue, (newVal) => {
if (newVal !== data.value) {
editorView?.dispatch({
changes: { from: 0, to: editorView.state.doc.length, insert: newVal }
})
}
})
</script>

View File

@@ -4,12 +4,12 @@
:editor="editor"
:tippy-options="{ duration: 100 }"
v-if="editor"
class="bg-background p-1 box will-change-transform"
class="bg-background p-2 box will-change-transform max-w-fit"
>
<div class="flex space-x-1 items-center">
<div class="flex gap-1 items-center justify-start whitespace-nowrap">
<DropdownMenu v-if="aiPrompts.length > 0">
<DropdownMenuTrigger>
<Button size="sm" variant="ghost" class="flex items-center justify-center">
<Button size="sm" variant="ghost" class="flex items-center justify-center" title="AI Prompts">
<span class="flex items-center">
<span class="text-medium">AI</span>
<Bot size="14" class="ml-1" />
@@ -27,11 +27,43 @@
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<!-- Heading Dropdown for Article Mode -->
<DropdownMenu v-if="editorType === 'article'">
<DropdownMenuTrigger>
<Button size="sm" variant="ghost" class="flex items-center justify-center" title="Heading Options">
<span class="flex items-center">
<Type size="14" />
<span class="ml-1 text-xs font-medium">{{ getCurrentHeadingText() }}</span>
<ChevronDown class="w-3 h-3 ml-1" />
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @select="setParagraph" title="Set Paragraph">
<span class="font-normal">Paragraph</span>
</DropdownMenuItem>
<DropdownMenuItem @select="() => setHeading(1)" title="Set Heading 1">
<span class="text-xl font-bold">Heading 1</span>
</DropdownMenuItem>
<DropdownMenuItem @select="() => setHeading(2)" title="Set Heading 2">
<span class="text-lg font-bold">Heading 2</span>
</DropdownMenuItem>
<DropdownMenuItem @select="() => setHeading(3)" title="Set Heading 3">
<span class="text-base font-semibold">Heading 3</span>
</DropdownMenuItem>
<DropdownMenuItem @select="() => setHeading(4)" title="Set Heading 4">
<span class="text-sm font-semibold">Heading 4</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
size="sm"
variant="ghost"
@click.prevent="editor?.chain().focus().toggleBold().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bold') }"
title="Bold"
>
<Bold size="14" />
</Button>
@@ -40,6 +72,7 @@
variant="ghost"
@click.prevent="editor?.chain().focus().toggleItalic().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('italic') }"
title="Italic"
>
<Italic size="14" />
</Button>
@@ -48,6 +81,7 @@
variant="ghost"
@click.prevent="editor?.chain().focus().toggleBulletList().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bulletList') }"
title="Bullet List"
>
<List size="14" />
</Button>
@@ -57,6 +91,7 @@
variant="ghost"
@click.prevent="editor?.chain().focus().toggleOrderedList().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('orderedList') }"
title="Ordered List"
>
<ListOrdered size="14" />
</Button>
@@ -65,9 +100,32 @@
variant="ghost"
@click.prevent="openLinkModal"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('link') }"
title="Insert Link"
>
<LinkIcon size="14" />
</Button>
<!-- Additional tools for Article Mode -->
<template v-if="editorType === 'article'">
<Button
size="sm"
variant="ghost"
@click.prevent="editor?.chain().focus().toggleCodeBlock().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('codeBlock') }"
title="Code Block"
>
<Code size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="editor?.chain().focus().toggleBlockquote().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('blockquote') }"
title="Blockquote"
>
<Quote size="14" />
</Button>
</template>
<div v-if="showLinkInput" class="flex space-x-2 p-2 bg-background border rounded">
<Input
v-model="linkUrl"
@@ -75,10 +133,10 @@
placeholder="Enter link URL"
class="border p-1 text-sm w-[200px]"
/>
<Button size="sm" @click="setLink">
<Button size="sm" @click="setLink" title="Set Link">
<Check size="14" />
</Button>
<Button size="sm" @click="unsetLink">
<Button size="sm" @click="unsetLink" title="Unset Link">
<X size="14" />
</Button>
</div>
@@ -100,16 +158,19 @@ import {
ListOrdered,
Link as LinkIcon,
Check,
X
X,
Type,
Code,
Quote
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Button } from '@shared-ui/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
} from '@shared-ui/components/ui/dropdown-menu'
import { Input } from '@shared-ui/components/ui/input'
import Placeholder from '@tiptap/extension-placeholder'
import Image from '@tiptap/extension-image'
import StarterKit from '@tiptap/starter-kit'
@@ -118,6 +179,8 @@ import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
import { useTypingIndicator } from '@shared-ui/composables'
import { useConversationStore } from '@main/stores/conversation'
const textContent = defineModel('textContent', { default: '' })
const htmlContent = defineModel('htmlContent', { default: '' })
@@ -134,6 +197,11 @@ const props = defineProps({
aiPrompts: {
type: Array,
default: () => []
},
editorType: {
type: String,
default: 'conversation',
validator: (value) => ['conversation', 'article'].includes(value)
}
})
@@ -141,6 +209,10 @@ const emit = defineEmits(['send', 'aiPromptSelected'])
const emitPrompt = (key) => emit('aiPromptSelected', key)
// Set up typing indicator
const conversationStore = useConversationStore()
const { startTyping, stopTyping } = useTypingIndicator(conversationStore.sendTyping)
// To preseve the table styling in emails, need to set the table style inline.
// Created these custom extensions to set the table style inline.
const CustomTable = Table.extend({
@@ -183,17 +255,39 @@ const CustomTableHeader = TableHeader.extend({
const isInternalUpdate = ref(false)
const editor = useEditor({
extensions: [
StarterKit.configure(),
// Configure extensions based on editor type
const getExtensions = () => {
const baseExtensions = [
StarterKit.configure({
heading: props.editorType === 'article' ? { levels: [1, 2, 3, 4] } : false
}),
Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
Placeholder.configure({ placeholder: () => props.placeholder }),
Link,
CustomTable.configure({ resizable: false }),
TableRow,
CustomTableCell,
CustomTableHeader
],
Link
]
// Add table extensions
if (props.editorType === 'article') {
baseExtensions.push(
CustomTable.configure({ resizable: true }),
TableRow,
CustomTableCell,
CustomTableHeader
)
} else {
baseExtensions.push(
CustomTable.configure({ resizable: false }),
TableRow,
CustomTableCell,
CustomTableHeader
)
}
return baseExtensions
}
const editor = useEditor({
extensions: getExtensions(),
autofocus: props.autoFocus,
content: htmlContent.value,
editorProps: {
@@ -201,6 +295,8 @@ const editor = useEditor({
handleKeyDown: (view, event) => {
if (event.ctrlKey && event.key === 'Enter') {
emit('send')
// Stop typing when sending
stopTyping()
return true
}
}
@@ -211,6 +307,13 @@ const editor = useEditor({
htmlContent.value = editor.getHTML()
textContent.value = editor.getText()
isInternalUpdate.value = false
// Trigger typing indicator when user types
startTyping()
},
onBlur: () => {
// Stop typing when editor loses focus
stopTyping()
}
})
@@ -258,6 +361,32 @@ const unsetLink = () => {
editor.value?.chain().focus().unsetLink().run()
showLinkInput.value = false
}
// Heading functions for article mode
const setHeading = (level) => {
editor.value?.chain().focus().toggleHeading({ level }).run()
}
const setParagraph = () => {
editor.value?.chain().focus().setParagraph().run()
}
const getCurrentHeadingLevel = () => {
if (!editor.value) return null
for (let level = 1; level <= 4; level++) {
if (editor.value.isActive('heading', { level })) {
return level
}
}
return null
}
const getCurrentHeadingText = () => {
const level = getCurrentHeadingLevel()
if (level) return `H${level}`
if (editor.value?.isActive('paragraph')) return 'P'
return 'T'
}
</script>
<style lang="scss">

View File

@@ -120,13 +120,13 @@ import {
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
} from '@shared-ui/components/ui/select'
import { Plus } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import { useI18n } from 'vue-i18n'
import CloseButton from '@/components/button/CloseButton.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import CloseButton from '@main/components/button/CloseButton.vue'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
const props = defineProps({
fields: {

View File

@@ -12,8 +12,8 @@
<script setup>
import { computed } from 'vue'
import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { Separator } from '@shared-ui/components/ui/separator'
import { SidebarTrigger } from '@shared-ui/components/ui/sidebar'
import { useRoute } from 'vue-router'
const route = useRoute()

View File

@@ -4,9 +4,9 @@ import {
reportsNavItems,
accountNavItems,
contactNavItems
} from '@/constants/navigation'
import { RouterLink, useRoute } from 'vue-router'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
} from '../../constants/navigation'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@shared-ui/components/ui/collapsible'
import {
Sidebar,
SidebarContent,
@@ -21,8 +21,8 @@ import {
SidebarMenuSubItem,
SidebarProvider,
SidebarRail
} from '@/components/ui/sidebar'
import { useAppSettingsStore } from '@/stores/appSettings'
} from '@shared-ui/components/ui/sidebar'
import { useAppSettingsStore } from '../../stores/appSettings'
import {
ChevronRight,
EllipsisVertical,
@@ -37,20 +37,23 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { filterNavItems } from '@/utils/nav-permissions'
} from '@shared-ui/components/ui/dropdown-menu'
import { filterNavItems } from '../../utils/nav-permissions'
import { useStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
import { useUserStore } from '../../stores/user'
import { useConversationStore } from '../../stores/conversation'
defineProps({
userTeams: { type: Array, default: () => [] },
userViews: { type: Array, default: () => [] }
})
const userStore = useUserStore()
const conversationStore = useConversationStore()
const settingsStore = useAppSettingsStore()
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation'])
@@ -74,6 +77,58 @@ const deleteView = (view) => {
emit('deleteView', view)
}
// Navigation methods with conversation retention
const navigateToInbox = (type) => {
if (conversationStore.hasConversationOpen && conversationStore.conversation.data?.uuid) {
router.push({
name: 'inbox-conversation',
params: {
type,
uuid: conversationStore.conversation.data.uuid
}
})
} else {
router.push({
name: 'inbox',
params: { type }
})
}
}
const navigateToTeamInbox = (teamID) => {
if (conversationStore.hasConversationOpen && conversationStore.conversation.data?.uuid) {
router.push({
name: 'team-inbox-conversation',
params: {
teamID,
uuid: conversationStore.conversation.data.uuid
}
})
} else {
router.push({
name: 'team-inbox',
params: { teamID }
})
}
}
const navigateToViewInbox = (viewID) => {
if (conversationStore.hasConversationOpen && conversationStore.conversation.data?.uuid) {
router.push({
name: 'view-inbox-conversation',
params: {
viewID,
uuid: conversationStore.conversation.data.uuid
}
})
} else {
router.push({
name: 'view-inbox',
params: { viewID }
})
}
}
const filteredAdminNavItems = computed(() => filterNavItems(adminNavItems, userStore.can))
const filteredReportsNavItems = computed(() => filterNavItems(reportsNavItems, userStore.can))
const filteredContactsNavItems = computed(() => filterNavItems(contactNavItems, userStore.can))
@@ -234,7 +289,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarMenuSubItem v-for="child in item.children" :key="child.titleKey">
<SidebarMenuButton size="sm" :isActive="isActiveParent(child.href)" asChild>
<router-link :to="child.href">
<span>{{ t(child.titleKey) }}</span>
<span>{{ t(child.titleKey, child.isTitleKeyPlural === true ? 2 : 1) }}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuSubItem>
@@ -322,32 +377,32 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/assigned')">
<router-link :to="{ name: 'inbox', params: { type: 'assigned' } }">
<a href="#" @click.prevent="navigateToInbox('assigned')">
<User />
<span>{{ t('globals.terms.myInbox') }}</span>
</router-link>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/unassigned')">
<router-link :to="{ name: 'inbox', params: { type: 'unassigned' } }">
<a href="#" @click.prevent="navigateToInbox('unassigned')">
<CircleDashed />
<span>
{{ t('globals.terms.unassigned') }}
</span>
</router-link>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/all')">
<router-link :to="{ name: 'inbox', params: { type: 'all' } }">
<a href="#" @click.prevent="navigateToInbox('all')">
<List />
<span>
{{ t('globals.messages.all') }}
</span>
</router-link>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -380,9 +435,9 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
:is-active="route.params.teamID == team.id"
asChild
>
<router-link :to="{ name: 'team-inbox', params: { teamID: team.id } }">
<a href="#" @click.prevent="navigateToTeamInbox(team.id)">
{{ team.emoji }}<span>{{ team.name }}</span>
</router-link>
</a>
</SidebarMenuButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
@@ -423,7 +478,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
:isActive="route.params.viewID == view.id"
asChild
>
<router-link :to="{ name: 'view-inbox', params: { viewID: view.id } }">
<a href="#" @click.prevent="navigateToViewInbox(view.id)">
<span class="break-words w-32 truncate">{{ view.name }}</span>
<SidebarMenuAction :showOnHover="true" class="mr-3">
<DropdownMenu>
@@ -440,7 +495,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuAction>
</router-link>
</a>
</SidebarMenuButton>
</SidebarMenuSubItem>
</SidebarMenuSub>

View File

@@ -118,12 +118,12 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { SidebarMenuButton } from '@/components/ui/sidebar'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Switch } from '@/components/ui/switch'
} from '@shared-ui/components/ui/dropdown-menu'
import { SidebarMenuButton } from '@shared-ui/components/ui/sidebar'
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
import { Switch } from '@shared-ui/components/ui/switch'
import { ChevronsUpDown, CircleUserRound, LogOut, Moon, Sun } from 'lucide-vue-next'
import { useUserStore } from '@/stores/user'
import { useUserStore } from '../../stores/user'
import { useRouter } from 'vue-router'
import { useColorMode } from '@vueuse/core'

View File

@@ -71,8 +71,8 @@
<script setup>
import { Trash2 } from 'lucide-vue-next'
import { defineEmits } from 'vue'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@shared-ui/components/ui/button'
import { Skeleton } from '@shared-ui/components/ui/skeleton'
defineProps({
headers: {

View File

@@ -20,6 +20,6 @@
</template>
<script setup>
import { useAppSettingsStore } from '@/stores/appSettings'
import { useAppSettingsStore } from '../../stores/appSettings'
const appSettingsStore = useAppSettingsStore()
</script>

View File

@@ -1,6 +1,6 @@
import { computed } from 'vue'
import { useUsersStore } from '@/stores/users'
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
import { useUsersStore } from '../stores/users'
import { FIELD_TYPE, FIELD_OPERATORS } from '../constants/filterConfig'
import { useI18n } from 'vue-i18n'
export function useActivityLogFilters () {

View File

@@ -1,11 +1,11 @@
import { computed } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { useInboxStore } from '@/stores/inbox'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla'
import { useCustomAttributeStore } from '@/stores/customAttributes'
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
import { useConversationStore } from '../stores/conversation'
import { useInboxStore } from '../stores/inbox'
import { useUsersStore } from '../stores/users'
import { useTeamStore } from '../stores/team'
import { useSlaStore } from '../stores/sla'
import { useCustomAttributeStore } from '../stores/customAttributes'
import { FIELD_TYPE, FIELD_OPERATORS } from '../constants/filterConfig'
import { useI18n } from 'vue-i18n'
export function useConversationFilters () {

View File

@@ -1,8 +1,8 @@
import { ref, readonly } from 'vue'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
import { useEmitter } from './useEmitter'
import { EMITTER_EVENTS } from '../constants/emitterEvents.js'
import { handleHTTPError } from '../utils/http'
import api from '../api'
/**
* Composable for handling file uploads

View File

@@ -1,6 +1,6 @@
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useUserStore } from '@/stores/user'
import { debounce } from '@/utils/debounce'
import { useUserStore } from '../stores/user'
import { debounce } from '../utils/debounce'
import { useStorage } from '@vueuse/core'
export function useIdleDetection () {

View File

@@ -1,5 +1,5 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { calculateSla } from '@/utils/sla'
import { calculateSla } from '../utils/sla'
export function useSla (dueAt, actualAt) {
const sla = ref(null)

View File

@@ -82,6 +82,23 @@ export const adminNavItems = [
}
]
},
{
titleKey: 'globals.terms.ai',
children: [
{
titleKey: 'globals.terms.aiAssistant',
isTitleKeyPlural: true,
href: '/admin/ai/assistants',
permission: 'ai:manage'
},
{
titleKey: 'globals.terms.snippet',
isTitleKeyPlural: true,
href: '/admin/ai/snippets',
permission: 'ai:manage'
},
]
},
{
titleKey: 'globals.terms.automation',
children: [
@@ -142,7 +159,17 @@ export const adminNavItems = [
permission: 'webhooks:manage'
}
]
}
},
{
titleKey: 'globals.terms.helpCenter',
children: [
{
titleKey: 'globals.terms.helpCenter',
href: '/admin/help-center',
permission: 'help_center:manage'
}
]
},
]
export const accountNavItems = [

View File

@@ -0,0 +1,13 @@
export const WS_EVENT = {
NEW_MESSAGE: 'new_message',
MESSAGE_PROP_UPDATE: 'message_prop_update',
CONVERSATION_PROP_UPDATE: 'conversation_prop_update',
CONVERSATION_SUBSCRIBE: 'conversation_subscribe',
CONVERSATION_SUBSCRIBED: 'conversation_subscribed',
TYPING: 'typing',
}
// Message types that should not be queued because they become stale quickly
export const WS_EPHEMERAL_TYPES = [
WS_EVENT.TYPING,
]

View File

@@ -148,7 +148,7 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import SimpleTable from '@/components/table/SimpleTable.vue'
import SimpleTable from '@main/components/table/SimpleTable.vue'
import {
Pagination,
PaginationEllipsis,
@@ -158,23 +158,23 @@ import {
PaginationListItem,
PaginationNext,
PaginationPrev
} from '@/components/ui/pagination'
} from '@shared-ui/components/ui/pagination'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import FilterBuilder from '@/components/filter/FilterBuilder.vue'
import { Button } from '@/components/ui/button'
} from '@shared-ui/components/ui/select'
import FilterBuilder from '@main/components/filter/FilterBuilder.vue'
import { Button } from '@shared-ui/components/ui/button'
import { ListFilter, ArrowDownWideNarrow } from 'lucide-vue-next'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useActivityLogFilters } from '@/composables/useActivityLogFilters'
import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover'
import { useActivityLogFilters } from '../../../composables/useActivityLogFilters'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { getVisiblePages } from '@/utils/pagination'
import api from '@/api'
import { getVisiblePages } from '../../../utils/pagination'
import api from '../../../api'
const activityLogs = ref([])
const { t } = useI18n()

View File

@@ -304,17 +304,17 @@
<script setup>
import { watch, onMounted, ref, computed } from 'vue'
import { Button } from '@/components/ui/button'
import { Button } from '@shared-ui/components/ui/button/index.js'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from './formSchema.js'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js'
import { Label } from '@shared-ui/components/ui/label/index.js'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { Badge } from '@/components/ui/badge'
import { Badge } from '@shared-ui/components/ui/badge/index.js'
import { Clock, LogIn, Key, RotateCcw, Trash2, Plus, Copy, AlertTriangle } from 'lucide-vue-next'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js'
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar/index.js'
import {
Select,
SelectContent,
@@ -322,9 +322,9 @@ import {
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { SelectTag } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
} from '@shared-ui/components/ui/select/index.js'
import { SelectTag } from '@shared-ui/components/ui/select/index.js'
import { Input } from '@shared-ui/components/ui/input/index.js'
import {
Dialog,
DialogContent,
@@ -332,13 +332,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
} from '@shared-ui/components/ui/dialog/index.js'
import { Alert, AlertDescription, AlertTitle } from '@shared-ui/components/ui/alert/index.js'
import { useI18n } from 'vue-i18n'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '../../../composables/useEmitter.js'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { format } from 'date-fns'
import api from '@/api'
import api from '../../../api/index.js'
const props = defineProps({
initialValues: {

View File

@@ -40,7 +40,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
} from '@shared-ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
@@ -50,13 +50,13 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
} from '@shared-ui/components/ui/alert-dialog'
import { Button } from '@shared-ui/components/ui/button'
import { useRouter } from 'vue-router'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api'
import { useEmitter } from '../../../composables/useEmitter'
import { handleHTTPError } from '../../../utils/http'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import api from '../../../api'
const alertOpen = ref(false)
const emit = useEmitter()

View File

@@ -0,0 +1,305 @@
<template>
<Spinner v-if="formLoading"></Spinner>
<form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50': formLoading }">
<!-- Enabled Field -->
<FormField v-slot="{ componentField, handleChange }" name="enabled" v-if="!isNewForm">
<FormItem class="flex flex-row items-center justify-between rounded-lg border p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ t('globals.terms.enabled') }}</FormLabel>
<FormDescription>{{ t('ai.assistant.enabledDescription') }}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<!-- Name Field -->
<FormField v-slot="{ componentField }" name="first_name">
<FormItem>
<FormLabel>{{ t('globals.terms.name') }} <span class="text-red-500">*</span></FormLabel>
<FormControl>
<Input
type="text"
:placeholder="t('ai.assistant.namePlaceholder')"
v-bind="componentField"
/>
</FormControl>
<FormDescription>{{ t('ai.assistant.nameDescription') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Avatar url -->
<FormField v-slot="{ componentField }" name="avatar_url">
<FormItem>
<FormLabel>{{ t('globals.terms.avatar') }} {{ t('globals.terms.url') }}</FormLabel>
<FormControl>
<Input
type="url"
v-bind="componentField"
/>
</FormControl>
<FormMessage></FormMessage>
</FormItem>
</FormField>
<!-- Product Name Field -->
<FormField v-slot="{ componentField }" name="product_name">
<FormItem>
<FormLabel
>{{ t('ai.assistant.productName') }} <span class="text-red-500">*</span></FormLabel
>
<FormControl>
<Input
type="text"
:placeholder="t('ai.assistant.productNamePlaceholder')"
v-bind="componentField"
/>
</FormControl>
<FormDescription>{{ t('ai.assistant.productNameDescription') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Product Description Field -->
<FormField v-slot="{ componentField }" name="product_description">
<FormItem>
<FormLabel
>{{ t('ai.assistant.productDescription') }} <span class="text-red-500">*</span></FormLabel
>
<FormControl>
<Textarea
:placeholder="t('ai.assistant.productDescriptionPlaceholder')"
v-bind="componentField"
rows="4"
/>
</FormControl>
<FormDescription>{{ t('ai.assistant.productDescriptionDescription') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Answer Length Field -->
<FormField v-slot="{ componentField }" name="answer_length">
<FormItem>
<FormLabel
>{{ t('ai.assistant.answerLength') }} <span class="text-red-500">*</span></FormLabel
>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger>
<SelectValue :placeholder="t('ai.assistant.selectAnswerLength')" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="concise">{{ t('ai.assistant.answerLengthConcise') }}</SelectItem>
<SelectItem value="medium">{{ t('ai.assistant.answerLengthMedium') }}</SelectItem>
<SelectItem value="long">{{ t('ai.assistant.answerLengthLong') }}</SelectItem>
</SelectContent>
</Select>
<FormDescription>{{ t('ai.assistant.answerLengthDescription') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Answer Tone Field -->
<FormField v-slot="{ componentField }" name="answer_tone">
<FormItem>
<FormLabel
>{{ t('ai.assistant.answerTone') }} <span class="text-red-500">*</span></FormLabel
>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger>
<SelectValue :placeholder="t('ai.assistant.selectAnswerTone')" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="neutral">{{ t('ai.assistant.answerToneNeutral') }}</SelectItem>
<SelectItem value="friendly">{{ t('ai.assistant.answerToneFriendly') }}</SelectItem>
<SelectItem value="professional">{{
t('ai.assistant.answerToneProfessional')
}}</SelectItem>
<SelectItem value="humorous">{{ t('ai.assistant.answerToneHumorous') }}</SelectItem>
</SelectContent>
</Select>
<FormDescription>{{ t('ai.assistant.answerToneDescription') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Enable Handoff Checkbox -->
<FormField v-slot="{ componentField, handleChange }" name="hand_off">
<FormItem class="flex flex-row items-center justify-between rounded-lg border p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ t('ai.assistant.enableHandoff') }}</FormLabel>
<FormDescription>{{ t('ai.assistant.enableHandoffDescription') }}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<!-- Hand off team (conditional) -->
<FormField v-slot="{ componentField }" name="hand_off_team" v-if="form.values.hand_off">
<FormItem>
<FormLabel>{{ t('ai.assistant.conversationHandoffTeam') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger>
<SelectValue
:placeholder="
t('globals.messages.select', { name: t('globals.terms.team').toLowerCase() })
"
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
v-for="opt in teamStore.options"
:key="opt.value"
:value="parseInt(opt.value)"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Submit Button -->
<div class="flex justify-end">
<Button type="submit" :disabled="formLoading">
<template v-if="formLoading">
<LoaderCircle class="w-4 h-4 mr-2 animate-spin" />
</template>
{{ isNewForm ? t('globals.messages.create') : t('globals.messages.update') }}
</Button>
</div>
</form>
</template>
<script setup>
import { computed, onMounted, watch } from 'vue'
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { useI18n } from 'vue-i18n'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import { Textarea } from '@shared-ui/components/ui/textarea'
import { Switch } from '@shared-ui/components/ui/switch'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@shared-ui/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import { Spinner } from '@shared-ui/components/ui/spinner'
import { LoaderCircle } from 'lucide-vue-next'
import { createFormSchema } from './formSchema.js'
import { useTeamStore } from '@/stores/team'
const { t } = useI18n()
const teamStore = useTeamStore()
const props = defineProps({
initialValues: {
type: Object,
default: () => ({})
},
submitForm: {
type: Function,
required: true
},
isNewForm: {
type: Boolean,
default: false
},
isLoading: {
type: Boolean,
default: false
}
})
const formLoading = computed(() => props.isLoading)
const formSchema = toTypedSchema(createFormSchema(t))
const form = useForm({
validationSchema: formSchema,
initialValues: {
first_name: '',
last_name: '',
avatar_url: '',
product_name: '',
product_description: '',
answer_length: 'medium',
answer_tone: 'friendly',
hand_off: false,
hand_off_team: null,
enabled: true,
...props.initialValues
}
})
const onSubmit = form.handleSubmit((values) => {
props.submitForm(values)
})
// Parse meta fields if editing an existing assistant
onMounted(() => {
if (!props.isNewForm && props.initialValues?.meta) {
try {
const meta =
typeof props.initialValues.meta === 'string'
? JSON.parse(props.initialValues.meta)
: props.initialValues.meta
if (meta) {
form.setFieldValue('product_name', meta.product_name || '')
form.setFieldValue('product_description', meta.product_description || '')
form.setFieldValue('answer_length', meta.answer_length || 'medium')
form.setFieldValue('answer_tone', meta.answer_tone || 'friendly')
form.setFieldValue('hand_off', meta.hand_off || false)
form.setFieldValue('hand_off_team', meta.hand_off_team || null)
}
} catch (e) {
console.warn('Failed to parse AI assistant meta:', e)
}
}
})
// Watch for changes in initialValues (for edit mode)
watch(
() => props.initialValues,
(newValues) => {
if (newValues && Object.keys(newValues).length > 0) {
form.resetForm({
values: {
first_name: newValues.first_name || '',
last_name: newValues.last_name || '',
avatar_url: newValues.avatar_url || '',
hand_off: newValues.hand_off ?? false,
hand_off_team: newValues.hand_off_team || null,
enabled: newValues.enabled ?? true,
...newValues
}
})
}
},
{ deep: true, immediate: true }
)
</script>

View File

@@ -0,0 +1,81 @@
import { h } from 'vue'
import AIAssistantDataTableDropDown from '@/features/admin/ai-assistants/dataTableDropdown.vue'
import { format } from 'date-fns'
export const createColumns = (t) => [
{
accessorKey: 'first_name',
header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('first_name'))
}
},
{
accessorKey: 'meta',
header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.product'))
},
cell: function ({ row }) {
const meta = row.getValue('meta')
let productName = ''
try {
const parsedMeta = typeof meta === 'string' ? JSON.parse(meta) : meta
productName = parsedMeta?.product_name || ''
} catch (e) {
productName = ''
}
return h('div', { class: 'text-center font-medium' }, productName)
}
},
{
accessorKey: 'enabled',
header: function () {
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'))
}
},
{
accessorKey: 'created_at',
header: function () {
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')
)
}
},
{
accessorKey: 'updated_at',
header: function () {
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')
)
}
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const assistant = row.original
return h(
'div',
{ class: 'relative' },
h(AIAssistantDataTableDropDown, {
assistant
})
)
}
}
]

View File

@@ -0,0 +1,97 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only"></span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editAIAssistant(props.assistant.id)">{{
$t('globals.messages.edit')
}}</DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)">{{
$t('globals.messages.delete')
}}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog :open="alertOpen" @update:open="alertOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>{{ $t('ai.assistant.deleteConfirmation') }}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ $t('globals.messages.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleDelete">{{
$t('globals.messages.delete')
}}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
<script setup>
import { ref } from 'vue'
import { MoreHorizontal } from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@shared-ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@shared-ui/components/ui/alert-dialog'
import { Button } from '@shared-ui/components/ui/button'
import { useRouter } from 'vue-router'
import { useEmitter } from '../../../composables/useEmitter'
import { handleHTTPError } from '../../../utils/http'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import api from '../../../api'
const alertOpen = ref(false)
const emit = useEmitter()
const router = useRouter()
const props = defineProps({
assistant: {
type: Object,
required: true,
default: () => ({
id: ''
})
}
})
function editAIAssistant(id) {
router.push({ path: `/admin/ai/assistants/${id}/edit` })
}
async function handleDelete() {
try {
await api.deleteAIAssistant(props.assistant.id)
alertOpen.value = false
emitRefreshAssistantList()
} catch (error) {
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const emitRefreshAssistantList = () => {
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
model: 'ai_assistant'
})
}
</script>

View File

@@ -0,0 +1,89 @@
import * as z from 'zod'
export const createFormSchema = (t) => z.object({
first_name: z
.string({
required_error: t('globals.messages.required'),
})
.min(2, {
message: t('form.error.minmax', {
min: 2,
max: 100,
})
})
.max(100, {
message: t('form.error.minmax', {
min: 2,
max: 100,
})
}),
last_name: z.string().optional(),
avatar_url: z
.string()
.url({
message: t('globals.messages.invalidUrl'),
})
.optional()
.or(z.literal('')),
product_name: z
.string({
required_error: t('globals.messages.required'),
})
.min(2, {
message: t('form.error.minmax', {
min: 2,
max: 255,
})
})
.max(255, {
message: t('form.error.minmax', {
min: 2,
max: 255,
})
}),
product_description: z
.string({
required_error: t('globals.messages.required'),
})
.min(10, {
message: t('form.error.minmax', {
min: 10,
max: 1000,
})
})
.max(1000, {
message: t('form.error.minmax', {
min: 10,
max: 1000,
})
}),
answer_length: z
.enum(['concise', 'medium', 'long'], {
required_error: t('globals.messages.required'),
invalid_type_error: t('globals.messages.invalid', { name: t('ai.assistant.answerLength') })
}),
answer_tone: z
.enum(['neutral', 'friendly', 'professional', 'humorous'], {
required_error: t('globals.messages.required'),
invalid_type_error: t('globals.messages.invalid', { name: t('ai.assistant.answerTone') })
}),
enabled: z.boolean().optional().default(true),
hand_off: z.boolean().optional().default(false),
hand_off_team: z
.number()
.int({
message: t('globals.messages.invalid', { name: t('globals.terms.team') })
})
.optional()
.nullable()
.default(null),
})

View File

@@ -87,9 +87,9 @@
<script setup>
import { toRefs } from 'vue'
import { Button } from '@/components/ui/button'
import CloseButton from '@/components/button/CloseButton.vue'
import { useTagStore } from '@/stores/tag'
import { Button } from '@shared-ui/components/ui/button'
import CloseButton from '@main/components/button/CloseButton.vue'
import { useTagStore } from '../../../stores/tag'
import {
Select,
SelectContent,
@@ -97,13 +97,13 @@ import {
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { SelectTag } from '@/components/ui/select'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { getTextFromHTML } from '@/utils/strings.js'
} from '@shared-ui/components/ui/select'
import { SelectTag } from '@shared-ui/components/ui/select'
import { useConversationFilters } from '../../../composables/useConversationFilters'
import { getTextFromHTML } from '../../../utils/strings.js'
import { useI18n } from 'vue-i18n'
import Editor from '@/components/editor/TextEditor.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import Editor from '@main/components/editor/TextEditor.vue'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
const props = defineProps({
actions: {

View File

@@ -34,7 +34,7 @@
</template>
<script setup>
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shared-ui/components/ui/tabs'
import { useI18n } from 'vue-i18n'
import RuleTab from './RuleTab.vue'

View File

@@ -190,10 +190,10 @@
<script setup>
import { toRefs, computed, watch } from 'vue'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Button } from '@/components/ui/button'
import CloseButton from '@/components/button/CloseButton.vue'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@shared-ui/components/ui/radio-group'
import { Button } from '@shared-ui/components/ui/button'
import CloseButton from '@main/components/button/CloseButton.vue'
import {
Select,
SelectContent,
@@ -202,19 +202,19 @@ import {
SelectLabel,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
} from '@shared-ui/components/ui/select'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
} from '@/components/ui/tags-input'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
} from '@shared-ui/components/ui/tags-input'
import { Label } from '@shared-ui/components/ui/label'
import { Input } from '@shared-ui/components/ui/input'
import { useI18n } from 'vue-i18n'
import { useConversationFilters } from '@/composables/useConversationFilters'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import { useConversationFilters } from '../../../composables/useConversationFilters'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
const props = defineProps({
ruleGroup: {

View File

@@ -68,7 +68,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
} from '@shared-ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
@@ -78,10 +78,10 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
} from '@shared-ui/components/ui/alert-dialog'
import { EllipsisVertical } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { Badge } from '@/components/ui/badge'
import { Badge } from '@shared-ui/components/ui/badge'
const router = useRouter()
const alertOpen = ref(false)

View File

@@ -64,17 +64,17 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import RuleList from './RuleList.vue'
import { Spinner } from '@/components/ui/spinner'
import { Spinner } from '@shared-ui/components/ui/spinner'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
} from '@shared-ui/components/ui/select'
import { Settings } from 'lucide-vue-next'
import draggable from 'vuedraggable'
import api from '@/api'
import api from '../../../api'
const isLoading = ref(false)
const rules = ref([])

View File

@@ -62,7 +62,7 @@
:checked="!!selectedDays[day]"
@update:checked="handleDayToggle(day, $event)"
/>
<Label :for="day" class="font-medium text-gray-800">{{ day }}</Label>
<Label :for="day" class="font-medium">{{ day }}</Label>
</div>
<div class="flex space-x-2 items-center">
<div class="flex flex-col items-start">
@@ -156,7 +156,7 @@
</div>
<DialogFooter>
<Button :disabled="!holidayName || !holidayDate" @click="saveHoliday">
{{ t('globals.messages.saveChanges') }}
{{ t('globals.messages.add') }}
</Button>
</DialogFooter>
</DialogContent>
@@ -167,23 +167,23 @@
<script setup>
import { ref, watch, reactive, computed } from 'vue'
import { Button } from '@/components/ui/button'
import { Button } from '@shared-ui/components/ui/button/index.js'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from './formSchema.js'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js'
import { Label } from '@shared-ui/components/ui/label/index.js'
import { RadioGroup, RadioGroupItem } from '@shared-ui/components/ui/radio-group/index.js'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js'
import { Calendar } from '@shared-ui/components/ui/calendar/index.js'
import { Input } from '@shared-ui/components/ui/input/index.js'
import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover/index.js'
import { cn } from '@shared-ui/lib/utils.js'
import { format } from 'date-fns'
import { WEEKDAYS } from '@/constants/date'
import { WEEKDAYS } from '../../../constants/date.js'
import { Calendar as CalendarIcon } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import SimpleTable from '@/components/table/SimpleTable.vue'
import SimpleTable from '@main/components/table/SimpleTable.vue'
import {
Dialog,
DialogContent,
@@ -192,7 +192,7 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
} from '@shared-ui/components/ui/dialog/index.js'
const props = defineProps({
initialValues: {
@@ -231,9 +231,16 @@ const { t } = useI18n()
const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t)),
initialValues: props.initialValues
initialValues: {
is_always_open: true
}
})
// Sync form field with local state
const syncHoursToForm = () => {
form.setFieldValue('hours', { ...hours.value })
}
const saveHoliday = () => {
holidays.push({
name: holidayName.value,
@@ -252,21 +259,15 @@ const deleteHoliday = (item) => {
}
const handleDayToggle = (day, checked) => {
selectedDays.value = {
...selectedDays.value,
[day]: checked
selectedDays.value[day] = checked
if (checked) {
hours.value[day] = hours.value[day] || { open: '09:00', close: '17:00' }
} else {
delete hours.value[day]
}
if (checked && !hours.value[day]) {
hours.value[day] = { open: '09:00', close: '17:00' }
} else if (!checked) {
const newHours = { ...hours.value }
delete newHours[day]
hours.value = newHours
}
// Sync with form values
form.setFieldValue('hours', { ...hours.value })
syncHoursToForm()
}
const updateHours = (day, type, value) => {
@@ -274,50 +275,48 @@ const updateHours = (day, type, value) => {
hours.value[day] = { open: '09:00', close: '17:00' }
}
hours.value[day][type] = value
// Sync with form values
form.setFieldValue('hours', { ...hours.value })
syncHoursToForm()
}
const onSubmit = form.handleSubmit((values) => {
const businessHours =
values.is_always_open === true
? {}
: Object.keys(selectedDays.value)
.filter((day) => selectedDays.value[day])
.reduce((acc, day) => {
acc[day] = hours.value[day]
return acc
}, {})
const businessHours = values.is_always_open === true ? {} : { ...hours.value }
const finalValues = {
...values,
is_always_open: values.is_always_open,
hours: businessHours,
holidays: holidays
holidays: [...holidays]
}
props.submitForm(finalValues)
})
// Initialize state from props
const initializeFromValues = (values) => {
if (!values) return
// Reset state
hours.value = {}
selectedDays.value = {}
holidays.length = 0
// Set hours and selected days
if (values.hours && typeof values.hours === 'object') {
hours.value = { ...values.hours }
selectedDays.value = Object.keys(values.hours).reduce((acc, day) => {
acc[day] = true
return acc
}, {})
}
// Set holidays
if (values.holidays) {
holidays.push(...values.holidays)
}
// Update form
form.setValues(values)
syncHoursToForm()
}
// Watch for initial values
watch(
() => props.initialValues,
(newValues) => {
if (!newValues || Object.keys(newValues).length === 0) {
return
}
// Set business hours if provided
if (newValues.is_always_open === false) {
hours.value = newValues.hours || {}
selectedDays.value = Object.keys(hours.value).reduce((acc, day) => {
acc[day] = true
return acc
}, {})
}
// Set other form values
form.setValues(newValues)
holidays.length = 0
holidays.push(...(newValues.holidays || []))
},
{ deep: true }
)
watch(() => props.initialValues, initializeFromValues, { immediate: true, deep: true })
</script>

View File

@@ -50,7 +50,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
} from '@shared-ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
@@ -60,13 +60,13 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
} from '@shared-ui/components/ui/alert-dialog'
import { Button } from '@shared-ui/components/ui/button'
import { useRouter } from 'vue-router'
import api from '@/api'
import { useEmitter } from '@/composables/useEmitter'
import api from '../../../api'
import { useEmitter } from '../../../composables/useEmitter'
import { useI18n } from 'vue-i18n'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
const { t } = useI18n()
const router = useRouter()

View File

@@ -5,7 +5,7 @@ const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/
export const createFormSchema = (t) => z.object({
name: z.string().min(1, t('globals.messages.required')),
description: z.string().min(1, t('globals.messages.required')),
is_always_open: z.boolean().default(true),
is_always_open: z.boolean(),
hours: z.record(
z.object({
open: z.string().regex(timeRegex, t('form.error.time.invalid')),

View File

@@ -150,14 +150,14 @@ import {
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
} from '@shared-ui/components/ui/form'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
} from '@/components/ui/tags-input'
} from '@shared-ui/components/ui/tags-input'
import {
Select,
SelectContent,
@@ -165,8 +165,8 @@ import {
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
} from '@shared-ui/components/ui/select'
import { Input } from '@shared-ui/components/ui/input'
const props = defineProps({
form: {

View File

@@ -44,7 +44,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
} from '@shared-ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
@@ -54,12 +54,12 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api'
} from '@shared-ui/components/ui/alert-dialog'
import { Button } from '@shared-ui/components/ui/button'
import { useEmitter } from '../../../composables/useEmitter'
import { handleHTTPError } from '../../../utils/http'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import api from '../../../api'
const alertOpen = ref(false)
const emit = useEmitter()

View File

@@ -171,7 +171,7 @@
<script setup>
import { watch, ref, onMounted } from 'vue'
import { Button } from '@/components/ui/button'
import { Button } from '@shared-ui/components/ui/button/index.js'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from './formSchema.js'
@@ -182,7 +182,7 @@ import {
FormLabel,
FormMessage,
FormDescription
} from '@/components/ui/form'
} from '@shared-ui/components/ui/form/index.js'
import {
Select,
SelectContent,
@@ -190,21 +190,21 @@ import {
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
} from '@shared-ui/components/ui/select/index.js'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
} from '@/components/ui/tags-input'
import { Input } from '@/components/ui/input'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { timeZones } from '@/constants/timezones.js'
} from '@shared-ui/components/ui/tags-input/index.js'
import { Input } from '@shared-ui/components/ui/input/index.js'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { useEmitter } from '../../../composables/useEmitter.js'
import { handleHTTPError } from '../../../utils/http.js'
import { timeZones } from '../../../constants/timezones.js'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import api from '../../../api/index.js'
const emitter = useEmitter()
const { t } = useI18n()

View File

@@ -0,0 +1,376 @@
<template>
<Sheet :open="isOpen" @update:open="$emit('update:open', $event)">
<SheetContent class="!max-w-[80vw] sm:!max-w-[80vw] h-full p-0 flex flex-col">
<div class="flex-1 flex flex-col min-h-0">
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b bg-card/50">
<div>
<h2 class="text-lg font-semibold">
{{ article ? 'Edit Article' : 'Create Article' }}
</h2>
<p class="text-sm text-muted-foreground mt-1">
{{ article ? `Last updated ${formatDatetime(new Date(article.updated_at))}` : 'Create a new help article' }}
</p>
</div>
</div>
<!-- Content -->
<div class="flex-1 flex min-h-0">
<!-- Main Content Area (75%) -->
<div class="flex-1 flex flex-col p-6 space-y-6 overflow-y-auto">
<Spinner v-if="formLoading" />
<form v-else @submit="onSubmit" class="space-y-6 flex-1 flex flex-col">
<!-- Title -->
<FormField v-slot="{ componentField }" name="title">
<FormItem>
<FormControl>
<Input
type="text"
placeholder="Enter article title..."
v-bind="componentField"
class="text-xl font-semibold border-0 px-0 py-3 shadow-none focus-visible:ring-0 placeholder:text-muted-foreground/60"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Content Editor -->
<FormField v-slot="{ componentField }" name="content">
<FormItem class="flex-1 flex flex-col">
<FormControl class="flex-1">
<div class="flex-1 flex flex-col">
<Editor
v-model:htmlContent="componentField.modelValue"
@update:htmlContent="(value) => componentField.onChange(value)"
:placeholder="t('editor.newLine')"
editorType="article"
class="min-h-[400px] border-0 px-0 shadow-none focus-visible:ring-0"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Submit Button (Hidden - controlled by sidebar) -->
<button type="submit" class="hidden" ref="submitButton"></button>
</form>
</div>
<!-- Sidebar (25%) -->
<div class="w-80 border-l bg-muted/20 p-6 overflow-y-auto">
<div class="space-y-6">
<!-- Publish Actions -->
<div class="space-y-4">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Actions
</h3>
<div class="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
@click="$emit('cancel')"
class="flex-1"
>
Cancel
</Button>
<Button
type="button"
size="sm"
@click="handleSubmit"
:disabled="isLoading"
class="flex-1"
>
<Loader2Icon v-if="isLoading" class="h-4 w-4 mr-2 animate-spin" />
{{ submitLabel }}
</Button>
</div>
</div>
<!-- Status -->
<div class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Status
</h3>
<FormField v-slot="{ componentField }" name="status">
<FormItem>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Published</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription class="text-xs">
Only published articles are visible to users
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Collection -->
<div v-if="availableCollections.length > 0" class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Collection
</h3>
<FormField v-slot="{ componentField }" name="collection_id">
<FormItem>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select collection" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="collection in availableCollections"
:key="collection.id"
:value="collection.id"
>
{{ collection.name }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription class="text-xs">
Move this article to a different collection
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- AI Settings -->
<div class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
AI Settings
</h3>
<FormField v-slot="{ componentField }" name="ai_enabled">
<FormItem class="flex flex-row items-start space-x-3 space-y-0 border rounded-lg p-3">
<FormControl>
<Checkbox
:checked="componentField.modelValue"
@update:checked="componentField.onChange"
/>
</FormControl>
<div class="space-y-1 leading-none flex-1">
<FormLabel class="text-sm font-medium">
Allow AI assistants to use this article
</FormLabel>
<FormDescription class="text-xs">
Article must be published for this to take effect
</FormDescription>
</div>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Metadata -->
<div v-if="article" class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Metadata
</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between py-2 border-b border-border/50">
<span class="text-muted-foreground">Created</span>
<span>{{ formatDatetime(new Date(article.created_at)) }}</span>
</div>
<div class="flex justify-between py-2 border-b border-border/50">
<span class="text-muted-foreground">Updated</span>
<span>{{ formatDatetime(new Date(article.updated_at)) }}</span>
</div>
<div v-if="article.view_count !== undefined" class="flex justify-between py-2 border-b border-border/50">
<span class="text-muted-foreground">Views</span>
<span>{{ article.view_count.toLocaleString() }}</span>
</div>
<div class="flex justify-between py-2">
<span class="text-muted-foreground">ID</span>
<span class="font-mono text-xs">#{{ article.id }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</SheetContent>
</Sheet>
</template>
<script setup>
import { ref, watch, onMounted, computed } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import {
Sheet,
SheetContent,
} from '@shared-ui/components/ui/sheet'
import { Spinner } from '@shared-ui/components/ui/spinner'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from '@shared-ui/components/ui/form/index.js'
import { Loader2 as Loader2Icon } from 'lucide-vue-next'
import { createArticleFormSchema } from './articleFormSchema.js'
import { useI18n } from 'vue-i18n'
import { getTextFromHTML } from '../../../utils/strings.js'
import Editor from '@main/components/editor/TextEditor.vue'
import api from '../../../api'
import { handleHTTPError } from '../../../utils/http'
import { useEmitter } from '../../../composables/useEmitter'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { formatDatetime } from '@shared-ui/utils/datetime.js'
const { t } = useI18n()
const props = defineProps({
isOpen: {
type: Boolean,
default: false
},
article: {
type: Object,
default: null
},
collectionId: {
type: Number,
default: null
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
default: ''
},
isLoading: {
type: Boolean,
default: false
},
locale: {
type: String,
default: 'en'
}
})
defineEmits(['update:open', 'cancel'])
const emitter = useEmitter()
const formLoading = ref(false)
const availableCollections = ref([])
const submitButton = ref(null)
const submitLabel = computed(() => {
return (
props.submitLabel ||
(props.article ? t('globals.messages.update') : t('globals.messages.create'))
)
})
const form = useForm({
validationSchema: toTypedSchema(createArticleFormSchema(t)),
initialValues: {
title: props.article?.title || '',
content: props.article?.content || '',
status: props.article?.status || 'draft',
collection_id: props.article?.collection_id || props.collectionId || null,
sort_order: props.article?.sort_order || 0,
ai_enabled: props.article?.ai_enabled || false
}
})
onMounted(async () => {
await fetchAvailableCollections()
})
watch(
() => [props.article, props.collectionId, props.locale],
async (newValues) => {
const [newArticle, newCollectionId] = newValues
// Re-fetch available collections when article, collectionId, or locale changes
await fetchAvailableCollections()
if (newArticle && Object.keys(newArticle).length > 0) {
form.setValues({
title: newArticle.title || '',
content: newArticle.content || '',
status: newArticle.status || 'draft',
collection_id: newArticle.collection_id || newCollectionId || null,
sort_order: newArticle.sort_order || 0,
ai_enabled: newArticle.ai_enabled || false
})
}
},
{ immediate: true }
)
const fetchAvailableCollections = async () => {
try {
let helpCenterId = null
if (props.article?.collection_id) {
// Editing existing article - get its collection first to find help center
const { data: collection } = await api.getCollection(props.article.collection_id)
helpCenterId = collection.data.help_center_id
} else if (props.collectionId) {
// Creating new article - get help center from provided collection
const { data: collection } = await api.getCollection(props.collectionId)
helpCenterId = collection.data.help_center_id
}
if (helpCenterId) {
// Filter collections by current locale
const { data: collections } = await api.getCollections(helpCenterId, { locale: props.locale })
// Allow selecting all published collections for the current locale
availableCollections.value = collections.data.filter((c) => c.is_published)
}
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const onSubmit = form.handleSubmit(async (values) => {
const textContent = getTextFromHTML(values.content)
if (textContent.length === 0) {
values.content = ''
}
props.submitForm(values)
})
const handleSubmit = () => {
if (submitButton.value) {
submitButton.value.click()
}
}
</script>

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