mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-24 19:32:10 +00:00
Merge branch 'mvp'
This commit is contained in:
@@ -1,2 +0,0 @@
|
||||
*.go linguist-detectable=true
|
||||
*.vue linguist-detectable=false
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
config.toml
|
||||
artemis.bin
|
||||
uploads/*
|
||||
libredesk.bin
|
||||
uploads/*
|
||||
.env
|
||||
@@ -1,49 +1,72 @@
|
||||
# Build variables
|
||||
LAST_COMMIT := $(shell git rev-parse --short HEAD)
|
||||
LAST_COMMIT_DATE := $(shell git show -s --format=%ci ${LAST_COMMIT})
|
||||
VERSION := $(shell git describe --tags)
|
||||
VERSION := $(shell git describe --tags)
|
||||
BUILDSTR := ${VERSION} (Commit: ${LAST_COMMIT_DATE} (${LAST_COMMIT}), Build: $(shell date +"%Y-%m-%d %H:%M:%S %z"))
|
||||
|
||||
# Binary names and paths
|
||||
BIN_ARTEMIS := artemis.bin
|
||||
BIN_LIBREDESK := libredesk.bin
|
||||
FRONTEND_DIR := frontend
|
||||
FRONTEND_DIST := ${FRONTEND_DIR}/dist
|
||||
STATIC := ${FRONTEND_DIST} i18n schema.sql static
|
||||
GOPATH ?= $(HOME)/go
|
||||
STUFFBIN ?= $(GOPATH)/bin/stuffbin
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := build
|
||||
# The default target to run when `make` is executed.
|
||||
.DEFAULT_GOAL := build
|
||||
|
||||
# Install stuffbin if it doesn't exist.
|
||||
$(STUFFBIN):
|
||||
@echo "→ Installing stuffbin..."
|
||||
@go install github.com/knadh/stuffbin/...
|
||||
|
||||
# Install dependencies for both backend and frontend.
|
||||
.PHONY: install-deps
|
||||
install-deps: $(STUFFBIN)
|
||||
@echo "→ Installing frontend dependencies..."
|
||||
@cd ${FRONTEND_DIR} && npm install
|
||||
@cd ${FRONTEND_DIR} && pnpm install
|
||||
|
||||
# Frontend builds
|
||||
# Build the frontend for production.
|
||||
.PHONY: frontend-build
|
||||
frontend-build:
|
||||
@echo "→ Building frontend for production..."
|
||||
@cd ${FRONTEND_DIR} && bun run build
|
||||
@cd ${FRONTEND_DIR} && pnpm build
|
||||
|
||||
# Backend builds
|
||||
# Run the Go backend server in development mode.
|
||||
.PHONY: run-backend
|
||||
run-backend:
|
||||
@echo "→ Running backend..."
|
||||
@go run cmd/*.go
|
||||
|
||||
# Run the JS frontend server in development mode.
|
||||
.PHONY: run-frontend
|
||||
run-frontend:
|
||||
@echo "→ Installing frontend dependencies (if not already installed)..."
|
||||
@cd ${FRONTEND_DIR} && pnpm install
|
||||
@echo "→ Running frontend..."
|
||||
@export VUE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev
|
||||
|
||||
# Build the backend binary.
|
||||
.PHONY: backend-build
|
||||
backend-build: $(STUFFBIN)
|
||||
@echo "→ Building backend..."
|
||||
@CGO_ENABLED=0 go build \
|
||||
@CGO_ENABLED=0 go build -a\
|
||||
-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.buildDate=${LAST_COMMIT_DATE}' -s -w" \
|
||||
-o ${BIN_ARTEMIS} cmd/*.go
|
||||
-o ${BIN_LIBREDESK} cmd/*.go
|
||||
|
||||
# Main build targets
|
||||
# Main build target: builds both frontend and backend, then stuffs static assets into the binary.
|
||||
.PHONY: build
|
||||
build: frontend-build backend-build stuff
|
||||
@echo "→ Build successful. Current version: $(VERSION)"
|
||||
|
||||
# Stuff static assets into the binary using stuffbin.
|
||||
.PHONY: stuff
|
||||
stuff: $(STUFFBIN)
|
||||
@echo "→ Stuffing static assets into binary..."
|
||||
@$(STUFFBIN) -a stuff -in ${BIN_ARTEMIS} -out ${BIN_ARTEMIS} ${STATIC}
|
||||
@$(STUFFBIN) -a stuff -in ${BIN_LIBREDESK} -out ${BIN_LIBREDESK} ${STATIC}
|
||||
|
||||
# Build the application in demo mode.
|
||||
.PHONY: demo-build
|
||||
demo-build:
|
||||
@echo "→ Building in demo mode..."
|
||||
@export VITE_DEMO_BUILD="true" && $(MAKE) build
|
||||
@@ -1,2 +1,39 @@
|
||||
# Libredesk
|
||||
|
||||
### I am pushing code on MVP branch, will be merging mvp into main soon.
|
||||
Open-source, self-hosted customer support desk. Single binary app.
|
||||
|
||||
> This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
|
||||
|
||||
## Developer Setup
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- **go**
|
||||
- **pnpm**
|
||||
- **PostgreSQL >= 13**
|
||||
- **Redis**
|
||||
|
||||
1. **Clone the repository**:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/abhinavxd/libredesk.git
|
||||
cd libredesk
|
||||
```
|
||||
|
||||
2. **Configure the Application**:
|
||||
|
||||
- Copy the sample configuration file `config.toml.sample` to `config.toml`:
|
||||
|
||||
```bash
|
||||
cp config.toml.sample config.toml
|
||||
```
|
||||
- Edit the `config.toml` file to configure your database and Redis connection settings.
|
||||
|
||||
3. **Run in Development Mode**:
|
||||
|
||||
- Backend: `make run-backend`
|
||||
- Frontend: `make run-frontend`
|
||||
|
||||
---
|
||||
|
||||
Visit [libredesk.io](https://libredesk.io) for more info.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import "github.com/zerodha/fastglue"
|
||||
|
||||
// handleAICompletion handles AI completion requests
|
||||
func handleAICompletion(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
promptKey = string(r.RequestCtx.PostArgs().Peek("prompt_key"))
|
||||
content = string(r.RequestCtx.PostArgs().Peek("content"))
|
||||
)
|
||||
resp, err := app.ai.Completion(promptKey, content)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(resp)
|
||||
}
|
||||
|
||||
// handleGetAIPrompts returns AI prompts
|
||||
func handleGetAIPrompts(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
resp, err := app.ai.GetPrompts()
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(resp)
|
||||
}
|
||||
+45
-15
@@ -3,28 +3,45 @@ package main
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
var (
|
||||
oidcStateSessKey = "oidc_state"
|
||||
)
|
||||
|
||||
// handleOIDCLogin redirects to the OIDC provider for login.
|
||||
func handleOIDCLogin(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
providerID, err = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
csrfToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
|
||||
)
|
||||
if err != nil {
|
||||
app.lo.Error("error parsing provider id", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error parsing provider id.", nil, envelope.GeneralError)
|
||||
}
|
||||
authURL, err := app.auth.LoginURL(providerID, csrfToken)
|
||||
|
||||
// Set a state and save it in the session, to prevent CSRF attacks.
|
||||
state, err := stringutil.RandomAlphanumeric(32)
|
||||
if err != nil {
|
||||
app.lo.Error("error generating state", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error generating state.", nil, envelope.GeneralError)
|
||||
}
|
||||
if err = app.auth.SetSessionValues(r, map[string]interface{}{
|
||||
oidcStateSessKey: state,
|
||||
}); err != nil {
|
||||
app.lo.Error("error saving state in session", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error saving state in session.", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
authURL, err := app.auth.LoginURL(providerID, state)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.Redirect(authURL, fasthttp.StatusFound, nil, "")
|
||||
}
|
||||
|
||||
@@ -34,30 +51,43 @@ func handleOIDCCallback(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
code = string(r.RequestCtx.QueryArgs().Peek("code"))
|
||||
state = string(r.RequestCtx.QueryArgs().Peek("state"))
|
||||
providerID, err = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("id")))
|
||||
csrfToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
|
||||
providerID, err = strconv.Atoi(string(r.RequestCtx.UserValue("id").(string)))
|
||||
)
|
||||
if err != nil {
|
||||
app.lo.Error("error parsing provider id", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error parsing provider id.", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
_, claims, err := app.auth.ExchangeOIDCToken(r.RequestCtx, providerID, code, csrfToken)
|
||||
// Compare the state from the session with the state from the query.
|
||||
sessionState, err := app.auth.GetSessionValue(r, oidcStateSessKey)
|
||||
if err != nil {
|
||||
app.lo.Error("error getting state from session", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error getting state from session.", nil, envelope.GeneralError)
|
||||
}
|
||||
if state != sessionState {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Invalid state.", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
_, claims, err := app.auth.ExchangeOIDCToken(r.RequestCtx, providerID, code)
|
||||
if err != nil {
|
||||
app.lo.Error("error exchanging oidc token", "error", err)
|
||||
return err
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error exchanging OIDC token.", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
// Get user by e-mail received.
|
||||
// Lookup the user by email and set the session.
|
||||
user, err := app.user.GetByEmail(claims.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Set the session.
|
||||
if err := app.auth.SaveSession(user, r); err != nil {
|
||||
return err
|
||||
if err := app.auth.SaveSession(amodels.User{
|
||||
ID: user.ID,
|
||||
Email: user.Email.String,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
}, r); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error saving session.", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
return r.Redirect(state, fasthttp.StatusFound, nil, "")
|
||||
return r.Redirect("/", fasthttp.StatusFound, nil, "")
|
||||
}
|
||||
|
||||
+46
-10
@@ -3,12 +3,13 @@ package main
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
amodels "github.com/abhinavxd/artemis/internal/automation/models"
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
amodels "github.com/abhinavxd/libredesk/internal/automation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// handleGetAutomationRules gets all automation rules
|
||||
func handleGetAutomationRules(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -21,6 +22,7 @@ func handleGetAutomationRules(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(out)
|
||||
}
|
||||
|
||||
// handleGetAutomationRuleByID gets an automation rule by ID
|
||||
func handleGetAutomationRule(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -33,6 +35,7 @@ func handleGetAutomationRule(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(out)
|
||||
}
|
||||
|
||||
// handleToggleAutomationRule toggles an automation rule
|
||||
func handleToggleAutomationRule(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -41,9 +44,10 @@ func handleToggleAutomationRule(r *fastglue.Request) error {
|
||||
if err := app.automation.ToggleRule(id); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("Rule toggled successfully")
|
||||
}
|
||||
|
||||
// handleUpdateAutomationRule updates an automation rule
|
||||
func handleUpdateAutomationRule(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -59,13 +63,13 @@ func handleUpdateAutomationRule(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
err = app.automation.UpdateRule(id, rule)
|
||||
if err != nil {
|
||||
if err = app.automation.UpdateRule(id, rule);err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("Rule updated successfully")
|
||||
}
|
||||
|
||||
// handleCreateAutomationRule creates a new automation rule
|
||||
func handleCreateAutomationRule(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -74,13 +78,13 @@ func handleCreateAutomationRule(r *fastglue.Request) error {
|
||||
if err := r.Decode(&rule, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", nil, envelope.InputError)
|
||||
}
|
||||
err := app.automation.CreateRule(rule)
|
||||
if err != nil {
|
||||
if err := app.automation.CreateRule(rule); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("Rule created successfully")
|
||||
}
|
||||
|
||||
// handleDeleteAutomationRule deletes an automation rule
|
||||
func handleDeleteAutomationRule(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -96,5 +100,37 @@ func handleDeleteAutomationRule(r *fastglue.Request) error {
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("Rule deleted successfully")
|
||||
}
|
||||
|
||||
// handleUpdateAutomationRuleWeights updates the weights of the automation rules
|
||||
func handleUpdateAutomationRuleWeights(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
weights = make(map[int]int)
|
||||
)
|
||||
if err := r.Decode(&weights, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", nil, envelope.InputError)
|
||||
}
|
||||
err := app.automation.UpdateRuleWeights(weights)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope("Weights updated successfully")
|
||||
}
|
||||
|
||||
// handleUpdateAutomationRuleExecutionMode updates the execution mode of the automation rules for a given type
|
||||
func handleUpdateAutomationRuleExecutionMode(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
mode = string(r.RequestCtx.PostArgs().Peek("mode"))
|
||||
)
|
||||
if mode != amodels.ExecutionModeAll && mode != amodels.ExecutionModeFirstMatch {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid execution mode", nil, envelope.InputError)
|
||||
}
|
||||
// Only new conversation rules can be updated as they are the only ones that have execution mode.
|
||||
if err := app.automation.UpdateRuleExecutionMode(amodels.RuleTypeNewConversation, mode); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope("Execution mode updated successfully")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
businessHours "github.com/abhinavxd/libredesk/internal/business_hours"
|
||||
models "github.com/abhinavxd/libredesk/internal/business_hours/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// handleGetBusinessHours returns all business hours.
|
||||
func handleGetBusinessHours(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
businessHours, err := app.businessHours.GetAll()
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||
}
|
||||
return r.SendEnvelope(businessHours)
|
||||
}
|
||||
|
||||
// handleGetBusinessHour returns the business hour with the given id.
|
||||
func handleGetBusinessHour(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, "Invalid business hour `id`.", nil, envelope.InputError)
|
||||
}
|
||||
businessHour, err := app.businessHours.Get(id)
|
||||
if err != nil {
|
||||
if err == businessHours.ErrBusinessHoursNotFound {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusNotFound, err.Error(), nil, envelope.NotFoundError)
|
||||
}
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error fetching business hour", nil, "")
|
||||
}
|
||||
return r.SendEnvelope(businessHour)
|
||||
}
|
||||
|
||||
// handleCreateBusinessHours creates a new business hour.
|
||||
func handleCreateBusinessHours(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
businessHours = models.BusinessHours{}
|
||||
)
|
||||
if err := r.Decode(&businessHours, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
|
||||
if businessHours.Name == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty business hour `Name`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := app.businessHours.Create(businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleDeleteBusinessHour deletes the business hour with the given id.
|
||||
func handleDeleteBusinessHour(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, "Invalid business hour `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
err = app.businessHours.Delete(id)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleUpdateBusinessHours updates the business hour with the given id.
|
||||
func handleUpdateBusinessHours(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
businessHours = models.BusinessHours{}
|
||||
)
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid business hour `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := r.Decode(&businessHours, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
|
||||
if businessHours.Name == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty business hour `Name`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := app.businessHours.Update(id, businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
cmodels "github.com/abhinavxd/artemis/internal/cannedresp/models"
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
func handleGetCannedResponses(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
c []cmodels.CannedResponse
|
||||
)
|
||||
|
||||
c, err := app.cannedResp.GetAll()
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(c)
|
||||
}
|
||||
|
||||
func handleCreateCannedResponse(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
cannedResponse = cmodels.CannedResponse{}
|
||||
)
|
||||
|
||||
if err := r.Decode(&cannedResponse, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
|
||||
if cannedResponse.Title == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Title`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if cannedResponse.Content == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Content`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
err := app.cannedResp.Create(cannedResponse.Title, cannedResponse.Content)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(cannedResponse)
|
||||
}
|
||||
|
||||
func handleDeleteCannedResponse(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,
|
||||
"Invalid canned response `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := app.cannedResp.Delete(id); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
func handleUpdateCannedResponse(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
cannedResponse = cmodels.CannedResponse{}
|
||||
)
|
||||
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||
"Invalid canned response `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := r.Decode(&cannedResponse, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
|
||||
if cannedResponse.Title == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Title`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if cannedResponse.Content == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Content`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err = app.cannedResp.Update(id, cannedResponse.Title, cannedResponse.Content); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(cannedResponse)
|
||||
}
|
||||
+414
-74
@@ -3,12 +3,16 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/automation/models"
|
||||
cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
umodels "github.com/abhinavxd/artemis/internal/user/models"
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
authzModels "github.com/abhinavxd/libredesk/internal/authz/models"
|
||||
"github.com/abhinavxd/libredesk/internal/automation/models"
|
||||
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -23,13 +27,24 @@ func handleGetAllConversations(r *fastglue.Request) error {
|
||||
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
|
||||
total = 0
|
||||
)
|
||||
conversations, pageSize, err := app.conversation.GetAllConversationsList(order, orderBy, filters, page, pageSize)
|
||||
|
||||
conversations, err := app.conversation.GetAllConversationsList(order, orderBy, filters, page, pageSize)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if len(conversations) > 0 {
|
||||
total = conversations[0].Total
|
||||
}
|
||||
|
||||
// Set deadlines for SLA if conversation has a policy
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
setSLADeadlines(app, &conversations[i])
|
||||
}
|
||||
conversations[i].ID = 0
|
||||
}
|
||||
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Results: conversations,
|
||||
Total: total,
|
||||
@@ -43,21 +58,30 @@ func handleGetAllConversations(r *fastglue.Request) error {
|
||||
func handleGetAssignedConversations(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
user = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
order = string(r.RequestCtx.QueryArgs().Peek("order"))
|
||||
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
|
||||
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
|
||||
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
|
||||
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
|
||||
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
|
||||
total = 0
|
||||
)
|
||||
conversations, pageSize, err := app.conversation.GetAssignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
|
||||
conversations, err := app.conversation.GetAssignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||
}
|
||||
if len(conversations) > 0 {
|
||||
total = conversations[0].Total
|
||||
}
|
||||
|
||||
// Set deadlines for SLA if conversation has a policy
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
setSLADeadlines(app, &conversations[i])
|
||||
}
|
||||
conversations[i].ID = 0
|
||||
}
|
||||
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Results: conversations,
|
||||
Total: total,
|
||||
@@ -71,21 +95,30 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
|
||||
func handleGetUnassignedConversations(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
order = string(r.RequestCtx.QueryArgs().Peek("order"))
|
||||
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
|
||||
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
|
||||
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
|
||||
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
|
||||
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
|
||||
total = 0
|
||||
)
|
||||
conversations, pageSize, err := app.conversation.GetUnassignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
|
||||
|
||||
conversations, err := app.conversation.GetUnassignedConversationsList(order, orderBy, filters, page, pageSize)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||
}
|
||||
if len(conversations) > 0 {
|
||||
total = conversations[0].Total
|
||||
}
|
||||
|
||||
// Set deadlines for SLA if conversation has a policy
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
setSLADeadlines(app, &conversations[i])
|
||||
}
|
||||
conversations[i].ID = 0
|
||||
}
|
||||
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Results: conversations,
|
||||
Total: total,
|
||||
@@ -95,46 +128,199 @@ func handleGetUnassignedConversations(r *fastglue.Request) error {
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetConversation retrieves a single conversation by UUID with permission checks.
|
||||
func handleGetConversation(r *fastglue.Request) error {
|
||||
// handleGetViewConversations retrieves conversations for a view.
|
||||
func handleGetViewConversations(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
viewID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
order = string(r.RequestCtx.QueryArgs().Peek("order"))
|
||||
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
|
||||
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
|
||||
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
|
||||
total = 0
|
||||
)
|
||||
conversation, err := enforceConversationAccess(app, uuid, user)
|
||||
if viewID < 1 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `view_id`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Check if user has access to the view.
|
||||
view, err := app.view.Get(viewID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if view.UserID != auser.ID {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You don't have access to this view.", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(conversation)
|
||||
// Prepare lists user has access to based on user permissions, internally this affects the SQL query.
|
||||
lists := []string{}
|
||||
for _, perm := range user.Permissions {
|
||||
if perm == authzModels.PermConversationsReadAll {
|
||||
// No further lists required as user has access to all conversations.
|
||||
lists = []string{cmodels.AllConversations}
|
||||
break
|
||||
}
|
||||
if perm == authzModels.PermConversationsReadUnassigned {
|
||||
lists = append(lists, cmodels.UnassignedConversations)
|
||||
}
|
||||
if perm == authzModels.PermConversationsReadAssigned {
|
||||
lists = append(lists, cmodels.AssignedConversations)
|
||||
}
|
||||
if perm == authzModels.PermConversationsReadTeamInbox {
|
||||
lists = append(lists, cmodels.TeamUnassignedConversations)
|
||||
}
|
||||
}
|
||||
|
||||
// No lists found, user doesn't have access to any conversations.
|
||||
if len(lists) == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Permission denied", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
conversations, err := app.conversation.GetViewConversationsList(user.ID, user.Teams.IDs(), lists, order, orderBy, string(view.Filters), page, pageSize)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if len(conversations) > 0 {
|
||||
total = conversations[0].Total
|
||||
}
|
||||
|
||||
// Set deadlines for SLA if conversation has a policy
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
setSLADeadlines(app, &conversations[i])
|
||||
}
|
||||
conversations[i].ID = 0
|
||||
}
|
||||
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Results: conversations,
|
||||
Total: total,
|
||||
PerPage: pageSize,
|
||||
TotalPages: (total + pageSize - 1) / pageSize,
|
||||
Page: page,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetTeamUnassignedConversations returns conversations assigned to a team but not to any user.
|
||||
func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
teamIDStr = r.RequestCtx.UserValue("id").(string)
|
||||
order = string(r.RequestCtx.QueryArgs().Peek("order"))
|
||||
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
|
||||
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
|
||||
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
|
||||
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
|
||||
total = 0
|
||||
)
|
||||
teamID, _ := strconv.Atoi(teamIDStr)
|
||||
if teamID < 1 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `team_id`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Check if user belongs to the team.
|
||||
exists, err := app.team.UserBelongsToTeam(teamID, auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "You're not a member of this team, Please refresh the page and try again.", nil))
|
||||
}
|
||||
|
||||
conversations, err := app.conversation.GetTeamUnassignedConversationsList(teamID, order, orderBy, filters, page, pageSize)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if len(conversations) > 0 {
|
||||
total = conversations[0].Total
|
||||
}
|
||||
|
||||
// Set deadlines for SLA if conversation has a policy
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
setSLADeadlines(app, &conversations[i])
|
||||
}
|
||||
conversations[i].ID = 0
|
||||
}
|
||||
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Results: conversations,
|
||||
Total: total,
|
||||
PerPage: pageSize,
|
||||
TotalPages: (total + pageSize - 1) / pageSize,
|
||||
Page: page,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetConversation retrieves a single conversation by it's UUID.
|
||||
func handleGetConversation(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
conv, err := enforceConversationAccess(app, uuid, user)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if conv.SLAPolicyID.Int != 0 {
|
||||
setSLADeadlines(app, conv)
|
||||
}
|
||||
|
||||
prev, _ := app.conversation.GetContactConversations(conv.ContactID)
|
||||
conv.PreviousConversations = filterCurrentConv(prev, conv.UUID)
|
||||
conv.ID = 0
|
||||
return r.SendEnvelope(conv)
|
||||
}
|
||||
|
||||
// handleUpdateConversationAssigneeLastSeen updates the assignee's last seen timestamp for a conversation.
|
||||
func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
_, err := enforceConversationAccess(app, uuid, user)
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
_, err = enforceConversationAccess(app, uuid, user)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if err = app.conversation.UpdateConversationAssigneeLastSeen(uuid); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("Last seen updated successfully")
|
||||
}
|
||||
|
||||
// handleGetConversationParticipants retrieves participants of a conversation.
|
||||
func handleGetConversationParticipants(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
_, err := enforceConversationAccess(app, uuid, user)
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
_, err = enforceConversationAccess(app, uuid, user)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -145,17 +331,21 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(p)
|
||||
}
|
||||
|
||||
// handleUpdateConversationUserAssignee updates the user assigned to a conversation.
|
||||
func handleUpdateConversationUserAssignee(r *fastglue.Request) error {
|
||||
// handleUpdateUserAssignee updates the user assigned to a conversation.
|
||||
func handleUpdateUserAssignee(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
assigneeID = r.RequestCtx.PostArgs().GetUintOrZero("assignee_id")
|
||||
)
|
||||
if assigneeID == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `assignee_id`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id")
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError)
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
_, err = enforceConversationAccess(app, uuid, user)
|
||||
@@ -170,21 +360,32 @@ func handleUpdateConversationUserAssignee(r *fastglue.Request) error {
|
||||
// Evaluate automation rules.
|
||||
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationUserAssigned)
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("User assigned successfully")
|
||||
}
|
||||
|
||||
// handleUpdateTeamAssignee updates the team assigned to a conversation.
|
||||
func handleUpdateTeamAssignee(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id")
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError)
|
||||
}
|
||||
_, err = enforceConversationAccess(app, uuid, user)
|
||||
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
_, err = app.team.Get(assigneeID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
conversation, err := enforceConversationAccess(app, uuid, user)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -192,84 +393,156 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Evaluate automation rules.
|
||||
// Evaluate automation rules on team assignment.
|
||||
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationTeamAssigned)
|
||||
return r.SendEnvelope(true)
|
||||
|
||||
// Apply SLA policy if team has changed and the new team has an SLA policy.
|
||||
if conversation.AssignedTeamID.Int != assigneeID && assigneeID != 0 {
|
||||
team, err := app.team.Get(assigneeID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if team.SLAPolicyID.Int != 0 {
|
||||
if err := app.conversation.ApplySLA(*conversation, team.SLAPolicyID.Int, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return r.SendEnvelope("Team assigned successfully")
|
||||
}
|
||||
|
||||
// handleUpdateConversationPriority updates the priority of a conversation.
|
||||
func handleUpdateConversationPriority(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
p = r.RequestCtx.PostArgs()
|
||||
priority = p.Peek("priority")
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
priority = string(r.RequestCtx.PostArgs().Peek("priority"))
|
||||
)
|
||||
_, err := enforceConversationAccess(app, uuid, user)
|
||||
if priority == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `priority`", nil, envelope.InputError)
|
||||
}
|
||||
conversation, err := app.conversation.GetConversation(0, uuid)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if err := app.conversation.UpdateConversationPriority(uuid, priority, user); err != nil {
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
allowed, err := app.authz.EnforceConversationAccess(user, conversation)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if !allowed {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
|
||||
}
|
||||
if err := app.conversation.UpdateConversationPriority(uuid, 0 /**priority_id**/, priority, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Evaluate automation rules.
|
||||
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationPriorityChange)
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("Priority updated successfully")
|
||||
}
|
||||
|
||||
// handleUpdateConversationStatus updates the status of a conversation.
|
||||
func handleUpdateConversationStatus(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
p = r.RequestCtx.PostArgs()
|
||||
status = p.Peek("status")
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
app = r.Context.(*App)
|
||||
status = string(r.RequestCtx.PostArgs().Peek("status"))
|
||||
snoozedUntil = string(r.RequestCtx.PostArgs().Peek("snoozed_until"))
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
_, err := enforceConversationAccess(app, uuid, user)
|
||||
|
||||
// Validate inputs
|
||||
if status == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `status`", nil, envelope.InputError)
|
||||
}
|
||||
if snoozedUntil == "" && status == cmodels.StatusSnoozed {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `snoozed_until`", nil, envelope.InputError)
|
||||
}
|
||||
if status == cmodels.StatusSnoozed {
|
||||
_, err := time.ParseDuration(snoozedUntil)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `snoozed_until`", nil, envelope.InputError)
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce conversation access.
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if err := app.conversation.UpdateConversationStatus(uuid, status, user); err != nil {
|
||||
conversation, 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, "Cannot resolve the conversation without an assigned user, Please assign a user before attempting to resolve.", nil))
|
||||
}
|
||||
|
||||
// Update conversation status.
|
||||
if err := app.conversation.UpdateConversationStatus(uuid, 0 /**status_id**/, status, snoozedUntil, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Evaluate automation rules.
|
||||
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationStatusChange)
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
// 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("Status updated successfully")
|
||||
}
|
||||
|
||||
// handleAddConversationTags adds tags to a conversation.
|
||||
func handleAddConversationTags(r *fastglue.Request) error {
|
||||
// handleUpdateConversationtags updates conversation tags.
|
||||
func handleUpdateConversationtags(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
p = r.RequestCtx.PostArgs()
|
||||
tagIDs = []int{}
|
||||
tagJSON = p.Peek("tag_ids")
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
app = r.Context.(*App)
|
||||
tagNames = []string{}
|
||||
tagJSON = r.RequestCtx.PostArgs().Peek("tags")
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
)
|
||||
|
||||
// Parse tag IDs from JSON
|
||||
err := json.Unmarshal(tagJSON, &tagIDs)
|
||||
if err != nil {
|
||||
app.lo.Error("unmarshalling tag ids", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "error adding tags", nil, "")
|
||||
if err := json.Unmarshal(tagJSON, &tagNames); err != nil {
|
||||
app.lo.Error("error unmarshalling tags JSON", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling tags JSON", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
_, err = enforceConversationAccess(app, uuid, user)
|
||||
conversation, err := app.conversation.GetConversation(0, uuid)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if err := app.conversation.UpsertConversationTags(uuid, tagIDs); err != nil {
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
|
||||
if allowed, err := app.authz.EnforceConversationAccess(user, conversation); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
} else if !allowed {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
|
||||
}
|
||||
|
||||
if err := app.conversation.UpsertConversationTags(uuid, tagNames, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope("Tags added successfully")
|
||||
}
|
||||
|
||||
// handleDashboardCounts retrieves general dashboard counts for all users.
|
||||
@@ -298,7 +571,7 @@ func handleDashboardCharts(r *fastglue.Request) error {
|
||||
|
||||
// enforceConversationAccess fetches the conversation and checks if the user has access to it.
|
||||
func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmodels.Conversation, error) {
|
||||
conversation, err := app.conversation.GetConversation(uuid)
|
||||
conversation, err := app.conversation.GetConversation(0, uuid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -311,3 +584,70 @@ func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmode
|
||||
}
|
||||
return &conversation, nil
|
||||
}
|
||||
|
||||
// setSLADeadlines gets the latest SLA deadlines for a conversation and sets them.
|
||||
func setSLADeadlines(app *App, conversation *cmodels.Conversation) error {
|
||||
if conversation.ID < 1 {
|
||||
return nil
|
||||
}
|
||||
first, resolution, err := app.sla.GetLatestDeadlines(conversation.ID)
|
||||
if err != nil {
|
||||
app.lo.Error("error getting SLA deadlines", "id", conversation.ID, "error", err)
|
||||
return err
|
||||
}
|
||||
conversation.FirstResponseDueAt = null.NewTime(first, first != time.Time{})
|
||||
conversation.ResolutionDueAt = null.NewTime(resolution, resolution != time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleRemoveUserAssignee removes the user assigned to a conversation.
|
||||
func handleRemoveUserAssignee(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
_, err = enforceConversationAccess(app, uuid, user)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if err = app.conversation.RemoveConversationAssignee(uuid, "user"); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleRemoveTeamAssignee removes the team assigned to a conversation.
|
||||
func handleRemoveTeamAssignee(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
_, err = enforceConversationAccess(app, uuid, user)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if err = app.conversation.RemoveConversationAssignee(uuid, "team"); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// filterCurrentConv removes the current conversation from the list of conversations.
|
||||
func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conversation {
|
||||
for i, c := range convs {
|
||||
if c.UUID == uuid {
|
||||
return append(convs[:i], convs[i+1:]...)
|
||||
}
|
||||
}
|
||||
return []cmodels.Conversation{}
|
||||
}
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// handleShowCSAT renders the CSAT page for a given csat.
|
||||
func handleShowCSAT(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
)
|
||||
|
||||
csat, err := app.csat.Get(uuid)
|
||||
if err != nil {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": "Page not found",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if csat.ResponseTimestamp.Valid {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"Title": "Thank you!",
|
||||
"Message": "We appreciate you taking the time to submit your feedback.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
conversation, err := app.conversation.GetConversation(csat.ConversationID, "")
|
||||
if err != nil {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": "Page not found",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"Title": "Rate your interaction with us",
|
||||
"CSAT": map[string]interface{}{
|
||||
"UUID": csat.UUID,
|
||||
},
|
||||
"Conversation": map[string]interface{}{
|
||||
"Subject": conversation.Subject.String,
|
||||
"ReferenceNumber": conversation.ReferenceNumber,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateCSATResponse updates the CSAT response for a given csat.
|
||||
func handleUpdateCSATResponse(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
rating = r.RequestCtx.FormValue("rating")
|
||||
feedback = string(r.RequestCtx.FormValue("feedback"))
|
||||
)
|
||||
|
||||
ratingI, err := strconv.Atoi(string(rating))
|
||||
if err != nil {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": "Invalid `rating`",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if ratingI < 1 || ratingI > 5 {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": "Invalid `rating`",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if uuid == "" {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": "Invalid `uuid`",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"Title": "Thank you!",
|
||||
"Message": "We appreciate you taking the time to submit your feedback.",
|
||||
},
|
||||
})
|
||||
}
|
||||
+178
-108
@@ -6,135 +6,172 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
"github.com/abhinavxd/artemis/internal/ws"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/ws"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
var (
|
||||
slaReqFields = map[string][2]int{"name": {1, 255}, "description": {1, 255}, "first_response_time": {1, 255}, "resolution_time": {1, 255}}
|
||||
)
|
||||
|
||||
// initHandlers initializes the HTTP routes and handlers for the application.
|
||||
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
// Authentication.
|
||||
g.POST("/api/login", handleLogin)
|
||||
g.POST("/api/v1/login", handleLogin)
|
||||
g.GET("/logout", handleLogout)
|
||||
g.GET("/api/oidc/{id}/login", handleOIDCLogin)
|
||||
g.GET("/api/oidc/finish", handleOIDCCallback)
|
||||
|
||||
// Health check.
|
||||
g.GET("/health", handleHealthCheck)
|
||||
|
||||
// Serve media files.
|
||||
g.GET("/uploads/{uuid}", auth(handleServeMedia))
|
||||
|
||||
// Settings.
|
||||
g.GET("/api/settings/general", handleGetGeneralSettings)
|
||||
g.PUT("/api/settings/general", authPerm(handleUpdateGeneralSettings, "settings_general", "write"))
|
||||
g.GET("/api/settings/notifications/email", authPerm(handleGetEmailNotificationSettings, "settings_notifications", "read"))
|
||||
g.PUT("/api/settings/notifications/email", authPerm(handleUpdateEmailNotificationSettings, "settings_notifications", "write"))
|
||||
|
||||
// OpenID SSO.
|
||||
g.GET("/api/oidc", handleGetAllOIDC)
|
||||
g.GET("/api/oidc/{id}", authPerm(handleGetOIDC, "oidc", "read"))
|
||||
g.POST("/api/oidc", authPerm(handleCreateOIDC, "oidc", "write"))
|
||||
g.PUT("/api/oidc/{id}", authPerm(handleUpdateOIDC, "oidc", "write"))
|
||||
g.DELETE("/api/oidc/{id}", authPerm(handleDeleteOIDC, "oidc", "delete"))
|
||||
|
||||
// Conversation and message.
|
||||
g.GET("/api/conversations/all", authPerm(handleGetAllConversations, "conversations", "read_all"))
|
||||
g.GET("/api/conversations/unassigned", authPerm(handleGetUnassignedConversations, "conversations", "read_unassigned"))
|
||||
g.GET("/api/conversations/assigned", authPerm(handleGetAssignedConversations, "conversations", "read_assigned"))
|
||||
g.GET("/api/conversations/{uuid}", authPerm(handleGetConversation, "conversations", "read"))
|
||||
g.GET("/api/conversations/{uuid}/participants", authPerm(handleGetConversationParticipants, "conversations", "read"))
|
||||
g.PUT("/api/conversations/{uuid}/assignee/user", authPerm(handleUpdateConversationUserAssignee, "conversations", "update_user_assignee"))
|
||||
g.PUT("/api/conversations/{uuid}/assignee/team", authPerm(handleUpdateTeamAssignee, "conversations", "update_team_assignee"))
|
||||
g.PUT("/api/conversations/{uuid}/priority", authPerm(handleUpdateConversationPriority, "conversations", "update_priority"))
|
||||
g.PUT("/api/conversations/{uuid}/status", authPerm(handleUpdateConversationStatus, "conversations", "update_status"))
|
||||
g.PUT("/api/conversations/{uuid}/last-seen", authPerm(handleUpdateConversationAssigneeLastSeen, "conversations", "read"))
|
||||
g.POST("/api/conversations/{uuid}/tags", authPerm(handleAddConversationTags, "conversations", "update_tags"))
|
||||
g.POST("/api/conversations/{cuuid}/messages", authPerm(handleSendMessage, "messages", "write"))
|
||||
g.GET("/api/conversations/{uuid}/messages", authPerm(handleGetMessages, "messages", "read"))
|
||||
g.PUT("/api/conversations/{cuuid}/messages/{uuid}/retry", authPerm(handleRetryMessage, "messages", "write"))
|
||||
g.GET("/api/conversations/{cuuid}/messages/{uuid}", authPerm(handleGetMessage, "messages", "read"))
|
||||
|
||||
// Status and priority.
|
||||
g.GET("/api/statuses", auth(handleGetStatuses))
|
||||
g.POST("/api/statuses", authPerm(handleCreateStatus, "status", "write"))
|
||||
g.PUT("/api/statuses/{id}", authPerm(handleUpdateStatus, "status", "write"))
|
||||
g.DELETE("/api/statuses/{id}", authPerm(handleDeleteStatus, "status", "delete"))
|
||||
g.GET("/api/priorities", auth(handleGetPriorities))
|
||||
|
||||
// Tag.
|
||||
g.GET("/api/tags", auth(handleGetTags))
|
||||
g.POST("/api/tags", authPerm(handleCreateTag, "tags", "write"))
|
||||
g.PUT("/api/tags/{id}", authPerm(handleUpdateTag, "tags", "write"))
|
||||
g.DELETE("/api/tags/{id}", authPerm(handleDeleteTag, "tags", "delete"))
|
||||
g.GET("/api/v1/oidc/{id}/login", handleOIDCLogin)
|
||||
g.GET("/api/v1/oidc/{id}/finish", handleOIDCCallback)
|
||||
|
||||
// Media.
|
||||
g.POST("/api/media", auth(handleMediaUpload))
|
||||
g.GET("/uploads/{uuid}", auth(handleServeMedia))
|
||||
g.POST("/api/v1/media", auth(handleMediaUpload))
|
||||
|
||||
// Canned response.
|
||||
g.GET("/api/canned-responses", auth(handleGetCannedResponses))
|
||||
g.POST("/api/canned-responses", authPerm(handleCreateCannedResponse, "canned_responses", "write"))
|
||||
g.PUT("/api/canned-responses/{id}", authPerm(handleUpdateCannedResponse, "canned_responses", "write"))
|
||||
g.DELETE("/api/canned-responses/{id}", authPerm(handleDeleteCannedResponse, "canned_responses", "delete"))
|
||||
// Settings.
|
||||
g.GET("/api/v1/settings/general", handleGetGeneralSettings)
|
||||
g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "general_settings:manage"))
|
||||
g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_settings:manage"))
|
||||
g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage"))
|
||||
|
||||
// OpenID connect single sign-on.
|
||||
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
|
||||
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
|
||||
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
|
||||
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
|
||||
g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage"))
|
||||
g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage"))
|
||||
|
||||
// Conversations.
|
||||
g.GET("/api/v1/conversations/all", perm(handleGetAllConversations, "conversations:read_all"))
|
||||
g.GET("/api/v1/conversations/unassigned", perm(handleGetUnassignedConversations, "conversations:read_unassigned"))
|
||||
g.GET("/api/v1/conversations/assigned", perm(handleGetAssignedConversations, "conversations:read_assigned"))
|
||||
g.GET("/api/v1/teams/{id}/conversations/unassigned", perm(handleGetTeamUnassignedConversations, "conversations:read_team_inbox"))
|
||||
g.GET("/api/v1/views/{id}/conversations", perm(handleGetViewConversations, "conversations:read"))
|
||||
g.GET("/api/v1/conversations/{uuid}", perm(handleGetConversation, "conversations:read"))
|
||||
g.GET("/api/v1/conversations/{uuid}/participants", perm(handleGetConversationParticipants, "conversations:read"))
|
||||
g.PUT("/api/v1/conversations/{uuid}/assignee/user", perm(handleUpdateUserAssignee, "conversations:update_user_assignee"))
|
||||
g.PUT("/api/v1/conversations/{uuid}/assignee/team", perm(handleUpdateTeamAssignee, "conversations:update_team_assignee"))
|
||||
g.PUT("/api/v1/conversations/{uuid}/assignee/user/remove", perm(handleRemoveUserAssignee, "conversations:update_user_assignee"))
|
||||
g.PUT("/api/v1/conversations/{uuid}/assignee/team/remove", perm(handleRemoveTeamAssignee, "conversations:update_team_assignee"))
|
||||
g.PUT("/api/v1/conversations/{uuid}/priority", perm(handleUpdateConversationPriority, "conversations:update_priority"))
|
||||
g.PUT("/api/v1/conversations/{uuid}/status", perm(handleUpdateConversationStatus, "conversations:update_status"))
|
||||
g.PUT("/api/v1/conversations/{uuid}/last-seen", perm(handleUpdateConversationAssigneeLastSeen, "conversations:read"))
|
||||
g.POST("/api/v1/conversations/{uuid}/tags", perm(handleUpdateConversationtags, "conversations:update_tags"))
|
||||
g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages:read"))
|
||||
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))
|
||||
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write"))
|
||||
g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages:write"))
|
||||
|
||||
// Search.
|
||||
g.GET("/api/v1/conversations/search", perm(handleSearchConversations, "conversations:read"))
|
||||
g.GET("/api/v1/messages/search", perm(handleSearchMessages, "messages:read"))
|
||||
|
||||
// Views.
|
||||
g.GET("/api/v1/views/me", perm(handleGetUserViews, "view:manage"))
|
||||
g.POST("/api/v1/views/me", perm(handleCreateUserView, "view:manage"))
|
||||
g.PUT("/api/v1/views/me/{id}", perm(handleUpdateUserView, "view:manage"))
|
||||
g.DELETE("/api/v1/views/me/{id}", perm(handleDeleteUserView, "view:manage"))
|
||||
|
||||
// Status and priority.
|
||||
g.GET("/api/v1/statuses", auth(handleGetStatuses))
|
||||
g.POST("/api/v1/statuses", perm(handleCreateStatus, "status:manage"))
|
||||
g.PUT("/api/v1/statuses/{id}", perm(handleUpdateStatus, "status:manage"))
|
||||
g.DELETE("/api/v1/statuses/{id}", perm(handleDeleteStatus, "status:manage"))
|
||||
g.GET("/api/v1/priorities", auth(handleGetPriorities))
|
||||
|
||||
// Tag.
|
||||
g.GET("/api/v1/tags", auth(handleGetTags))
|
||||
g.POST("/api/v1/tags", perm(handleCreateTag, "tags:manage"))
|
||||
g.PUT("/api/v1/tags/{id}", perm(handleUpdateTag, "tags:manage"))
|
||||
g.DELETE("/api/v1/tags/{id}", perm(handleDeleteTag, "tags:manage"))
|
||||
|
||||
// Macros.
|
||||
g.GET("/api/v1/macros", auth(handleGetMacros))
|
||||
g.GET("/api/v1/macros/{id}", perm(handleGetMacro, "macros:manage"))
|
||||
g.POST("/api/v1/macros", perm(handleCreateMacro, "macros:manage"))
|
||||
g.PUT("/api/v1/macros/{id}", perm(handleUpdateMacro, "macros:manage"))
|
||||
g.DELETE("/api/v1/macros/{id}", perm(handleDeleteMacro, "macros:manage"))
|
||||
g.POST("/api/v1/conversations/{uuid}/macros/{id}/apply", auth(handleApplyMacro))
|
||||
|
||||
// User.
|
||||
g.GET("/api/users/me", auth(handleGetCurrentUser))
|
||||
g.PUT("/api/users/me", auth(handleUpdateCurrentUser))
|
||||
g.DELETE("/api/users/me/avatar", auth(handleDeleteAvatar))
|
||||
g.GET("/api/users/compact", auth(handleGetUsersCompact))
|
||||
g.GET("/api/users", authPerm(handleGetUsers, "users", "read"))
|
||||
g.GET("/api/users/{id}", authPerm(handleGetUser, "users", "read"))
|
||||
g.POST("/api/users", authPerm(handleCreateUser, "users", "write"))
|
||||
g.PUT("/api/users/{id}", authPerm(handleUpdateUser, "users", "write"))
|
||||
g.DELETE("/api/users/{id}", authPerm(handleDeleteUser, "users", "delete"))
|
||||
g.POST("/api/users/reset-password", tryAuth(handleResetPassword))
|
||||
g.POST("/api/users/set-password", tryAuth(handleSetPassword))
|
||||
g.GET("/api/v1/users/me", auth(handleGetCurrentUser))
|
||||
g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
|
||||
g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams))
|
||||
g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar))
|
||||
g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
|
||||
g.GET("/api/v1/users", perm(handleGetUsers, "users:manage"))
|
||||
g.GET("/api/v1/users/{id}", perm(handleGetUser, "users:manage"))
|
||||
g.POST("/api/v1/users", perm(handleCreateUser, "users:manage"))
|
||||
g.PUT("/api/v1/users/{id}", perm(handleUpdateUser, "users:manage"))
|
||||
g.DELETE("/api/v1/users/{id}", perm(handleDeleteUser, "users:manage"))
|
||||
g.POST("/api/v1/users/reset-password", tryAuth(handleResetPassword))
|
||||
g.POST("/api/v1/users/set-password", tryAuth(handleSetPassword))
|
||||
|
||||
// Team.
|
||||
g.GET("/api/teams/compact", auth(handleGetTeamsCompact))
|
||||
g.GET("/api/teams", authPerm(handleGetTeams, "teams", "read"))
|
||||
g.POST("/api/teams", authPerm(handleCreateTeam, "teams", "write"))
|
||||
g.GET("/api/teams/{id}", authPerm(handleGetTeam, "teams", "read"))
|
||||
g.PUT("/api/teams/{id}", authPerm(handleUpdateTeam, "teams", "write"))
|
||||
g.DELETE("/api/teams/{id}", authPerm(handleDeleteTeam, "teams", "delete"))
|
||||
g.GET("/api/v1/teams/compact", auth(handleGetTeamsCompact))
|
||||
g.GET("/api/v1/teams", perm(handleGetTeams, "teams:manage"))
|
||||
g.GET("/api/v1/teams/{id}", perm(handleGetTeam, "teams:manage"))
|
||||
g.POST("/api/v1/teams", perm(handleCreateTeam, "teams:manage"))
|
||||
g.PUT("/api/v1/teams/{id}", perm(handleUpdateTeam, "teams:manage"))
|
||||
g.DELETE("/api/v1/teams/{id}", perm(handleDeleteTeam, "teams:manage"))
|
||||
|
||||
// i18n.
|
||||
g.GET("/api/lang/{lang}", handleGetI18nLang)
|
||||
g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
|
||||
|
||||
// Automation.
|
||||
g.GET("/api/automation/rules", authPerm(handleGetAutomationRules, "automations", "read"))
|
||||
g.GET("/api/automation/rules/{id}", authPerm(handleGetAutomationRule, "automations", "read"))
|
||||
g.POST("/api/automation/rules", authPerm(handleCreateAutomationRule, "automations", "write"))
|
||||
g.PUT("/api/automation/rules/{id}/toggle", authPerm(handleToggleAutomationRule, "automations", "write"))
|
||||
g.PUT("/api/automation/rules/{id}", authPerm(handleUpdateAutomationRule, "automations", "write"))
|
||||
g.DELETE("/api/automation/rules/{id}", authPerm(handleDeleteAutomationRule, "automations", "delete"))
|
||||
g.GET("/api/v1/automation/rules", perm(handleGetAutomationRules, "automations:manage"))
|
||||
g.GET("/api/v1/automation/rules/{id}", perm(handleGetAutomationRule, "automations:manage"))
|
||||
g.POST("/api/v1/automation/rules", perm(handleCreateAutomationRule, "automations:manage"))
|
||||
g.PUT("/api/v1/automation/rules/{id}/toggle", perm(handleToggleAutomationRule, "automations:manage"))
|
||||
g.PUT("/api/v1/automation/rules/{id}", perm(handleUpdateAutomationRule, "automations:manage"))
|
||||
g.PUT("/api/v1/automation/rules/weights", perm(handleUpdateAutomationRuleWeights, "automations:manage"))
|
||||
g.PUT("/api/v1/automation/rules/execution-mode", perm(handleUpdateAutomationRuleExecutionMode, "automations:manage"))
|
||||
g.DELETE("/api/v1/automation/rules/{id}", perm(handleDeleteAutomationRule, "automations:manage"))
|
||||
|
||||
// Inbox.
|
||||
g.GET("/api/inboxes", authPerm(handleGetInboxes, "inboxes", "read"))
|
||||
g.GET("/api/inboxes/{id}", authPerm(handleGetInbox, "inboxes", "read"))
|
||||
g.POST("/api/inboxes", authPerm(handleCreateInbox, "inboxes", "write"))
|
||||
g.PUT("/api/inboxes/{id}/toggle", authPerm(handleToggleInbox, "inboxes", "write"))
|
||||
g.PUT("/api/inboxes/{id}", authPerm(handleUpdateInbox, "inboxes", "write"))
|
||||
g.DELETE("/api/inboxes/{id}", authPerm(handleDeleteInbox, "inboxes", "delete"))
|
||||
g.GET("/api/v1/inboxes", auth(handleGetInboxes))
|
||||
g.GET("/api/v1/inboxes/{id}", perm(handleGetInbox, "inboxes:manage"))
|
||||
g.POST("/api/v1/inboxes", perm(handleCreateInbox, "inboxes:manage"))
|
||||
g.PUT("/api/v1/inboxes/{id}/toggle", perm(handleToggleInbox, "inboxes:manage"))
|
||||
g.PUT("/api/v1/inboxes/{id}", perm(handleUpdateInbox, "inboxes:manage"))
|
||||
g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
|
||||
|
||||
// Role.
|
||||
g.GET("/api/roles", authPerm(handleGetRoles, "roles", "read"))
|
||||
g.GET("/api/roles/{id}", authPerm(handleGetRole, "roles", "read"))
|
||||
g.POST("/api/roles", authPerm(handleCreateRole, "roles", "write"))
|
||||
g.PUT("/api/roles/{id}", authPerm(handleUpdateRole, "roles", "write"))
|
||||
g.DELETE("/api/roles/{id}", authPerm(handleDeleteRole, "roles", "delete"))
|
||||
g.GET("/api/v1/roles", perm(handleGetRoles, "roles:manage"))
|
||||
g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage"))
|
||||
g.POST("/api/v1/roles", perm(handleCreateRole, "roles:manage"))
|
||||
g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage"))
|
||||
g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "roles:manage"))
|
||||
|
||||
// Dashboard.
|
||||
g.GET("/api/dashboard/global/counts", authPerm(handleDashboardCounts, "dashboard_global", "read"))
|
||||
g.GET("/api/dashboard/global/charts", authPerm(handleDashboardCharts, "dashboard_global", "read"))
|
||||
g.GET("/api/v1/reports/overview/counts", perm(handleDashboardCounts, "reports:manage"))
|
||||
g.GET("/api/v1/reports/overview/charts", perm(handleDashboardCharts, "reports:manage"))
|
||||
|
||||
// Template.
|
||||
g.GET("/api/templates", authPerm(handleGetTemplates, "templates", "read"))
|
||||
g.GET("/api/templates/{id}", authPerm(handleGetTemplate, "templates", "read"))
|
||||
g.POST("/api/templates", authPerm(handleCreateTemplate, "templates", "write"))
|
||||
g.PUT("/api/templates/{id}", authPerm(handleUpdateTemplate, "templates", "write"))
|
||||
g.DELETE("/api/templates/{id}", authPerm(handleDeleteTemplate, "templates", "delete"))
|
||||
g.GET("/api/v1/templates", perm(handleGetTemplates, "templates:manage"))
|
||||
g.GET("/api/v1/templates/{id}", perm(handleGetTemplate, "templates:manage"))
|
||||
g.POST("/api/v1/templates", perm(handleCreateTemplate, "templates:manage"))
|
||||
g.PUT("/api/v1/templates/{id}", perm(handleUpdateTemplate, "templates:manage"))
|
||||
g.DELETE("/api/v1/templates/{id}", perm(handleDeleteTemplate, "templates:manage"))
|
||||
|
||||
// Business hours.
|
||||
g.GET("/api/v1/business-hours", perm(handleGetBusinessHours, "business_hours:manage"))
|
||||
g.GET("/api/v1/business-hours/{id}", perm(handleGetBusinessHour, "business_hours:manage"))
|
||||
g.POST("/api/v1/business-hours", perm(handleCreateBusinessHours, "business_hours:manage"))
|
||||
g.PUT("/api/v1/business-hours/{id}", perm(handleUpdateBusinessHours, "business_hours:manage"))
|
||||
g.DELETE("/api/v1/business-hours/{id}", perm(handleDeleteBusinessHour, "business_hours:manage"))
|
||||
|
||||
// SLA.
|
||||
g.GET("/api/v1/sla", perm(handleGetSLAs, "sla:manage"))
|
||||
g.GET("/api/v1/sla/{id}", perm(handleGetSLA, "sla:manage"))
|
||||
g.POST("/api/v1/sla", perm(fastglue.ReqLenRangeParams(handleCreateSLA, slaReqFields), "sla:manage"))
|
||||
g.PUT("/api/v1/sla/{id}", perm(fastglue.ReqLenRangeParams(handleUpdateSLA, slaReqFields), "sla:manage"))
|
||||
g.DELETE("/api/v1/sla/{id}", perm(handleDeleteSLA, "sla:manage"))
|
||||
|
||||
// AI completion.
|
||||
g.GET("/api/v1/ai/prompts", auth(handleGetAIPrompts))
|
||||
g.POST("/api/v1/ai/completion", auth(handleAICompletion))
|
||||
|
||||
// WebSocket.
|
||||
g.GET("/ws", auth(func(r *fastglue.Request) error {
|
||||
@@ -143,15 +180,25 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
|
||||
// Frontend pages.
|
||||
g.GET("/", notAuthPage(serveIndexPage))
|
||||
g.GET("/dashboard", authPage(serveIndexPage))
|
||||
g.GET("/conversations", authPage(serveIndexPage))
|
||||
g.GET("/conversations/{all:*}", authPage(serveIndexPage))
|
||||
g.GET("/account/profile", authPage(serveIndexPage))
|
||||
g.GET("/inboxes/{all:*}", authPage(serveIndexPage))
|
||||
g.GET("/teams/{all:*}", authPage(serveIndexPage))
|
||||
g.GET("/views/{all:*}", authPage(serveIndexPage))
|
||||
g.GET("/admin/{all:*}", authPage(serveIndexPage))
|
||||
g.GET("/reports/{all:*}", authPage(serveIndexPage))
|
||||
g.GET("/account/{all:*}", authPage(serveIndexPage))
|
||||
g.GET("/reset-password", notAuthPage(serveIndexPage))
|
||||
g.GET("/set-password", notAuthPage(serveIndexPage))
|
||||
g.GET("/assets/{all:*}", serveStaticFiles)
|
||||
g.GET("/images/{all:*}", serveStaticFiles)
|
||||
// FIXME: Don't need three separate routes for the same thing.
|
||||
g.GET("/assets/{all:*}", serveFrontendStaticFiles)
|
||||
g.GET("/images/{all:*}", serveFrontendStaticFiles)
|
||||
g.GET("/static/public/{all:*}", serveStaticFiles)
|
||||
|
||||
// Public pages.
|
||||
g.GET("/csat/{uuid}", handleShowCSAT)
|
||||
g.POST("/csat/{uuid}", handleUpdateCSATResponse)
|
||||
|
||||
// Health check.
|
||||
g.GET("/health", handleHealthCheck)
|
||||
}
|
||||
|
||||
// serveIndexPage serves the main index page of the application.
|
||||
@@ -186,6 +233,29 @@ func serveStaticFiles(r *fastglue.Request) error {
|
||||
// Get the requested file path.
|
||||
filePath := string(r.RequestCtx.Path())
|
||||
|
||||
file, err := app.fs.Get(filePath)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(http.StatusNotFound, "File not found", 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
|
||||
}
|
||||
|
||||
// serveFrontendStaticFiles serves static assets from the embedded filesystem.
|
||||
func serveFrontendStaticFiles(r *fastglue.Request) error {
|
||||
app := r.Context.(*App)
|
||||
|
||||
// Get the requested file path.
|
||||
filePath := string(r.RequestCtx.Path())
|
||||
|
||||
// Fetch and serve the file from the embedded filesystem.
|
||||
finalPath := filepath.Join(frontendDir, filePath)
|
||||
file, err := app.fs.Get(finalPath)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/knadh/go-i18n"
|
||||
"github.com/knadh/stuffbin"
|
||||
"github.com/zerodha/fastglue"
|
||||
|
||||
+5
-4
@@ -3,8 +3,8 @@ package main
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
imodels "github.com/abhinavxd/artemis/internal/inbox/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
@@ -23,7 +23,7 @@ func handleGetInbox(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
)
|
||||
inbox, err := app.inbox.GetByID(id)
|
||||
inbox, err := app.inbox.GetDBRecord(id)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error fetching inbox", nil, envelope.GeneralError)
|
||||
}
|
||||
@@ -54,6 +54,7 @@ func handleCreateInbox(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleUpdateInbox updates an inbox
|
||||
func handleUpdateInbox(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -74,7 +75,7 @@ func handleUpdateInbox(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
if err := reloadInboxes(app); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading inboxes", nil, envelope.GeneralError)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading inboxes, Please restart the app if the issue persists", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(inbox)
|
||||
|
||||
+200
-71
@@ -12,31 +12,37 @@ import (
|
||||
|
||||
"html/template"
|
||||
|
||||
auth_ "github.com/abhinavxd/artemis/internal/auth"
|
||||
"github.com/abhinavxd/artemis/internal/authz"
|
||||
"github.com/abhinavxd/artemis/internal/autoassigner"
|
||||
"github.com/abhinavxd/artemis/internal/automation"
|
||||
"github.com/abhinavxd/artemis/internal/cannedresp"
|
||||
"github.com/abhinavxd/artemis/internal/contact"
|
||||
"github.com/abhinavxd/artemis/internal/conversation"
|
||||
"github.com/abhinavxd/artemis/internal/conversation/priority"
|
||||
"github.com/abhinavxd/artemis/internal/conversation/status"
|
||||
"github.com/abhinavxd/artemis/internal/inbox"
|
||||
"github.com/abhinavxd/artemis/internal/inbox/channel/email"
|
||||
imodels "github.com/abhinavxd/artemis/internal/inbox/models"
|
||||
"github.com/abhinavxd/artemis/internal/media"
|
||||
fs "github.com/abhinavxd/artemis/internal/media/stores/localfs"
|
||||
"github.com/abhinavxd/artemis/internal/media/stores/s3"
|
||||
notifier "github.com/abhinavxd/artemis/internal/notification"
|
||||
emailnotifier "github.com/abhinavxd/artemis/internal/notification/providers/email"
|
||||
"github.com/abhinavxd/artemis/internal/oidc"
|
||||
"github.com/abhinavxd/artemis/internal/role"
|
||||
"github.com/abhinavxd/artemis/internal/setting"
|
||||
"github.com/abhinavxd/artemis/internal/tag"
|
||||
"github.com/abhinavxd/artemis/internal/team"
|
||||
tmpl "github.com/abhinavxd/artemis/internal/template"
|
||||
"github.com/abhinavxd/artemis/internal/user"
|
||||
"github.com/abhinavxd/artemis/internal/ws"
|
||||
"github.com/abhinavxd/libredesk/internal/ai"
|
||||
auth_ "github.com/abhinavxd/libredesk/internal/auth"
|
||||
"github.com/abhinavxd/libredesk/internal/authz"
|
||||
"github.com/abhinavxd/libredesk/internal/autoassigner"
|
||||
"github.com/abhinavxd/libredesk/internal/automation"
|
||||
businesshours "github.com/abhinavxd/libredesk/internal/business_hours"
|
||||
"github.com/abhinavxd/libredesk/internal/colorlog"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation/priority"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation/status"
|
||||
"github.com/abhinavxd/libredesk/internal/csat"
|
||||
"github.com/abhinavxd/libredesk/internal/inbox"
|
||||
"github.com/abhinavxd/libredesk/internal/inbox/channel/email"
|
||||
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
|
||||
"github.com/abhinavxd/libredesk/internal/macro"
|
||||
"github.com/abhinavxd/libredesk/internal/media"
|
||||
fs "github.com/abhinavxd/libredesk/internal/media/stores/localfs"
|
||||
"github.com/abhinavxd/libredesk/internal/media/stores/s3"
|
||||
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/role"
|
||||
"github.com/abhinavxd/libredesk/internal/search"
|
||||
"github.com/abhinavxd/libredesk/internal/setting"
|
||||
"github.com/abhinavxd/libredesk/internal/sla"
|
||||
"github.com/abhinavxd/libredesk/internal/tag"
|
||||
"github.com/abhinavxd/libredesk/internal/team"
|
||||
tmpl "github.com/abhinavxd/libredesk/internal/template"
|
||||
"github.com/abhinavxd/libredesk/internal/user"
|
||||
"github.com/abhinavxd/libredesk/internal/view"
|
||||
"github.com/abhinavxd/libredesk/internal/ws"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/go-i18n"
|
||||
kjson "github.com/knadh/koanf/parsers/json"
|
||||
@@ -56,11 +62,12 @@ import (
|
||||
// constants holds the app constants.
|
||||
type constants struct {
|
||||
AppBaseURL string
|
||||
FaviconURL string
|
||||
LogoURL string
|
||||
SiteName string
|
||||
UploadProvider string
|
||||
AllowedUploadFileExtensions []string
|
||||
MaxFileUploadSizeMB float64
|
||||
MaxFileUploadSizeMB int
|
||||
}
|
||||
|
||||
// Config loads config files into koanf.
|
||||
@@ -103,14 +110,15 @@ func initFlags() {
|
||||
}
|
||||
|
||||
// initConstants initializes the app constants.
|
||||
func initConstants() constants {
|
||||
return constants{
|
||||
func initConstants() *constants {
|
||||
return &constants{
|
||||
AppBaseURL: ko.String("app.root_url"),
|
||||
FaviconURL: ko.String("app.favicon_url"),
|
||||
LogoURL: ko.String("app.logo_url"),
|
||||
SiteName: ko.String("app.site_name"),
|
||||
UploadProvider: ko.MustString("upload.provider"),
|
||||
AllowedUploadFileExtensions: ko.Strings("app.allowed_file_upload_extensions"),
|
||||
MaxFileUploadSizeMB: ko.Float64("app.max_file_upload_size"),
|
||||
MaxFileUploadSizeMB: ko.Int("app.max_file_upload_size"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +127,7 @@ func initFS() stuffbin.FileSystem {
|
||||
var files = []string{
|
||||
"frontend/dist",
|
||||
"i18n",
|
||||
"static",
|
||||
}
|
||||
|
||||
// Get self executable path.
|
||||
@@ -133,7 +142,7 @@ func initFS() stuffbin.FileSystem {
|
||||
if err != nil {
|
||||
if err == stuffbin.ErrNoID {
|
||||
// The embed failed or the binary's already unstuffed or running in local / dev mode, use the local filesystem.
|
||||
log.Println("unstuff failed, using local FS")
|
||||
colorlog.Red("binary unstuff failed, using local filesystem for static files")
|
||||
fs, err = stuffbin.NewLocalFS("/", files...)
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing local FS: %v", err)
|
||||
@@ -189,9 +198,24 @@ func initUser(i18n *i18n.I18n, DB *sqlx.DB) *user.Manager {
|
||||
}
|
||||
|
||||
// initConversations inits conversation manager.
|
||||
func initConversations(i18n *i18n.I18n, hub *ws.Hub, n *notifier.Service, db *sqlx.DB, contactStore *contact.Manager,
|
||||
inboxStore *inbox.Manager, userStore *user.Manager, teamStore *team.Manager, mediaStore *media.Manager, automationEngine *automation.Engine, template *tmpl.Manager) *conversation.Manager {
|
||||
c, err := conversation.New(hub, i18n, n, contactStore, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{
|
||||
func initConversations(
|
||||
i18n *i18n.I18n,
|
||||
sla *sla.Manager,
|
||||
status *status.Manager,
|
||||
priority *priority.Manager,
|
||||
hub *ws.Hub,
|
||||
notif *notifier.Service,
|
||||
db *sqlx.DB,
|
||||
inboxStore *inbox.Manager,
|
||||
userStore *user.Manager,
|
||||
teamStore *team.Manager,
|
||||
mediaStore *media.Manager,
|
||||
settings *setting.Manager,
|
||||
csat *csat.Manager,
|
||||
automationEngine *automation.Engine,
|
||||
template *tmpl.Manager,
|
||||
) *conversation.Manager {
|
||||
c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, conversation.Opts{
|
||||
DB: db,
|
||||
Lo: initLogger("conversation_manager"),
|
||||
OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
|
||||
@@ -203,8 +227,8 @@ func initConversations(i18n *i18n.I18n, hub *ws.Hub, n *notifier.Service, db *sq
|
||||
return c
|
||||
}
|
||||
|
||||
// initTags inits tag manager.
|
||||
func initTags(db *sqlx.DB) *tag.Manager {
|
||||
// initTag inits tag manager.
|
||||
func initTag(db *sqlx.DB) *tag.Manager {
|
||||
var lo = initLogger("tag_manager")
|
||||
mgr, err := tag.New(tag.Opts{
|
||||
DB: db,
|
||||
@@ -216,39 +240,86 @@ func initTags(db *sqlx.DB) *tag.Manager {
|
||||
return mgr
|
||||
}
|
||||
|
||||
// initCannedResponse inits canned response manager.
|
||||
func initCannedResponse(db *sqlx.DB) *cannedresp.Manager {
|
||||
var lo = initLogger("canned-response")
|
||||
c, err := cannedresp.New(cannedresp.Opts{
|
||||
// initViews inits view manager.
|
||||
func initView(db *sqlx.DB) *view.Manager {
|
||||
var lo = initLogger("view_manager")
|
||||
m, err := view.New(view.Opts{
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing canned responses manager: %v", err)
|
||||
log.Fatalf("error initializing view manager: %v", err)
|
||||
}
|
||||
return c
|
||||
return m
|
||||
}
|
||||
|
||||
func initContact(db *sqlx.DB) *contact.Manager {
|
||||
var lo = initLogger("contact-manager")
|
||||
m, err := contact.New(contact.Opts{
|
||||
// initMacro inits macro manager.
|
||||
func initMacro(db *sqlx.DB) *macro.Manager {
|
||||
var lo = initLogger("macro")
|
||||
m, err := macro.New(macro.Opts{
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing contact manager: %v", err)
|
||||
log.Fatalf("error initializing macro manager: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// initBusinessHours inits business hours manager.
|
||||
func initBusinessHours(db *sqlx.DB) *businesshours.Manager {
|
||||
var lo = initLogger("business-hours")
|
||||
m, err := businesshours.New(businesshours.Opts{
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing business hours manager: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// initSLA inits SLA manager.
|
||||
func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager) *sla.Manager {
|
||||
var lo = initLogger("sla")
|
||||
m, err := sla.New(sla.Opts{
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
}, teamManager, settings, businessHours)
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing SLA manager: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// initCSAT inits CSAT manager.
|
||||
func initCSAT(db *sqlx.DB) *csat.Manager {
|
||||
var lo = initLogger("csat")
|
||||
m, err := csat.New(csat.Opts{
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing CSAT manager: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// initTemplates inits template manager.
|
||||
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts constants) *tmpl.Manager {
|
||||
lo := initLogger("template")
|
||||
tpls, err := stuffbin.ParseTemplatesGlob(getTmplFuncs(consts), fs, "/static/email-templates/*.html")
|
||||
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager {
|
||||
var (
|
||||
lo = initLogger("template")
|
||||
funcMap = getTmplFuncs(consts)
|
||||
)
|
||||
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
|
||||
if err != nil {
|
||||
log.Fatalf("error parsing e-mail templates: %v", err)
|
||||
}
|
||||
m, err := tmpl.New(lo, db, tpls)
|
||||
webTpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/public/web-templates/*.html")
|
||||
if err != nil {
|
||||
log.Fatalf("error parsing web templates: %v", err)
|
||||
}
|
||||
m, err := tmpl.New(lo, db, webTpls, tpls, funcMap)
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing template manager: %v", err)
|
||||
}
|
||||
@@ -256,11 +327,14 @@ func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts constants) *tmpl.M
|
||||
}
|
||||
|
||||
// getTmplFuncs returns the template functions.
|
||||
func getTmplFuncs(consts constants) template.FuncMap {
|
||||
func getTmplFuncs(consts *constants) template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"RootURL": func() string {
|
||||
return consts.AppBaseURL
|
||||
},
|
||||
"FaviconURL": func() string {
|
||||
return consts.FaviconURL
|
||||
},
|
||||
"Date": func(layout string) string {
|
||||
if layout == "" {
|
||||
layout = time.ANSIC
|
||||
@@ -276,6 +350,45 @@ func getTmplFuncs(consts constants) template.FuncMap {
|
||||
}
|
||||
}
|
||||
|
||||
// reloadSettings reloads the settings from the database into the Koanf instance.
|
||||
func reloadSettings(app *App) error {
|
||||
app.lo.Info("reloading settings")
|
||||
j, err := app.setting.GetAllJSON()
|
||||
if err != nil {
|
||||
app.lo.Error("error parsing settings from DB", "error", err)
|
||||
return err
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(j, &out); err != nil {
|
||||
app.lo.Error("error unmarshalling settings from DB", "error", err)
|
||||
return err
|
||||
}
|
||||
if err := ko.Load(confmap.Provider(out, "."), nil); err != nil {
|
||||
app.lo.Error("error loading settings into koanf", "error", err)
|
||||
return err
|
||||
}
|
||||
newConsts := initConstants()
|
||||
app.consts.Store(newConsts)
|
||||
return nil
|
||||
}
|
||||
|
||||
// reloadTemplates reloads the templates from the filesystem.
|
||||
func reloadTemplates(app *App) error {
|
||||
app.lo.Info("reloading templates")
|
||||
funcMap := getTmplFuncs(app.consts.Load().(*constants))
|
||||
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/email-templates/*.html")
|
||||
if err != nil {
|
||||
app.lo.Error("error parsing email templates", "error", err)
|
||||
return err
|
||||
}
|
||||
webTpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/public/web-templates/*.html")
|
||||
if err != nil {
|
||||
app.lo.Error("error parsing web templates", "error", err)
|
||||
return err
|
||||
}
|
||||
return app.tmpl.Reload(webTpls, tpls, funcMap)
|
||||
}
|
||||
|
||||
// initTeam inits team manager.
|
||||
func initTeam(db *sqlx.DB) *team.Manager {
|
||||
var lo = initLogger("team-manager")
|
||||
@@ -307,7 +420,8 @@ func initMedia(db *sqlx.DB) *media.Manager {
|
||||
Region: ko.String("upload.s3.region"),
|
||||
Bucket: ko.String("upload.s3.bucket"),
|
||||
BucketPath: ko.String("upload.s3.bucket_path"),
|
||||
BucketType: ko.String("upload.s3.bucket_type"),
|
||||
// All files are private by default.
|
||||
BucketType: "private",
|
||||
Expiry: ko.Duration("upload.s3.expiry"),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -348,15 +462,9 @@ func initInbox(db *sqlx.DB) *inbox.Manager {
|
||||
}
|
||||
|
||||
// initAutomationEngine initializes the automation engine.
|
||||
func initAutomationEngine(db *sqlx.DB, userManager *user.Manager) *automation.Engine {
|
||||
func initAutomationEngine(db *sqlx.DB) *automation.Engine {
|
||||
var lo = initLogger("automation_engine")
|
||||
|
||||
systemUser, err := userManager.GetSystemUser()
|
||||
if err != nil {
|
||||
log.Fatalf("error fetching system user: %v", err)
|
||||
}
|
||||
|
||||
engine, err := automation.New(systemUser, automation.Opts{
|
||||
engine, err := automation.New(automation.Opts{
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
})
|
||||
@@ -407,11 +515,11 @@ func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.
|
||||
|
||||
// Load JSON data into Koanf.
|
||||
if err := ko.Load(rawbytes.Provider([]byte(inboxRecord.Config)), kjson.Parser()); err != nil {
|
||||
log.Fatalf("error loading config: %v", err)
|
||||
return nil, fmt.Errorf("loading config: %w", err)
|
||||
}
|
||||
|
||||
if err := ko.UnmarshalWithConf("", &config, koanf.UnmarshalConf{Tag: "json"}); err != nil {
|
||||
log.Fatalf("error unmarshalling `%s` %s config: %v", inboxRecord.Channel, inboxRecord.Name, err)
|
||||
return nil, fmt.Errorf("unmarshalling `%s` %s config: %w", inboxRecord.Channel, inboxRecord.Name, err)
|
||||
}
|
||||
|
||||
if len(config.SMTP) == 0 {
|
||||
@@ -435,11 +543,10 @@ func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("ERROR: initalizing `%s` inbox: `%s` error : %v", inboxRecord.Channel, inboxRecord.Name, err)
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err)
|
||||
}
|
||||
|
||||
log.Printf("`%s` inbox successfully initalized. %d smtp servers. %d imap clients.", inboxRecord.Name, len(config.SMTP), len(config.IMAP))
|
||||
log.Printf("`%s` inbox successfully initialized. %d SMTP servers. %d IMAP clients.", inboxRecord.Name, len(config.SMTP), len(config.IMAP))
|
||||
|
||||
return inbox, nil
|
||||
}
|
||||
@@ -502,17 +609,14 @@ func initAuth(o *oidc.Manager, rd *redis.Client) *auth_.Auth {
|
||||
// reloadAuth reloads the auth providers.
|
||||
func reloadAuth(app *App) error {
|
||||
app.lo.Info("reloading auth manager")
|
||||
|
||||
providers, err := buildProviders(app.oidc)
|
||||
if err != nil {
|
||||
log.Fatalf("error reloading auth: %v", err)
|
||||
}
|
||||
|
||||
if err := app.auth.Reload(auth_.Config{Providers: providers}); err != nil {
|
||||
app.lo.Error("error reloading auth", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -525,7 +629,7 @@ func buildProviders(o *oidc.Manager) ([]auth_.Provider, error) {
|
||||
|
||||
providers := make([]auth_.Provider, 0, len(oidcConfigs))
|
||||
for _, config := range oidcConfigs {
|
||||
if config.Disabled {
|
||||
if !config.Enabled {
|
||||
continue
|
||||
}
|
||||
providers = append(providers, auth_.Provider{
|
||||
@@ -541,13 +645,12 @@ func buildProviders(o *oidc.Manager) ([]auth_.Provider, error) {
|
||||
}
|
||||
|
||||
// initOIDC initializes open id connect config manager.
|
||||
func initOIDC(db *sqlx.DB) *oidc.Manager {
|
||||
func initOIDC(db *sqlx.DB, settings *setting.Manager) *oidc.Manager {
|
||||
lo := initLogger("oidc")
|
||||
o, err := oidc.New(oidc.Opts{
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
})
|
||||
|
||||
}, settings)
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing oidc: %v", err)
|
||||
}
|
||||
@@ -638,6 +741,32 @@ func initPriority(db *sqlx.DB) *priority.Manager {
|
||||
return manager
|
||||
}
|
||||
|
||||
// initAI inits AI manager.
|
||||
func initAI(db *sqlx.DB) *ai.Manager {
|
||||
lo := initLogger("ai")
|
||||
m, err := ai.New(ai.Opts{
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing AI manager: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// initSearch inits search manager.
|
||||
func initSearch(db *sqlx.DB) *search.Manager {
|
||||
lo := initLogger("search")
|
||||
m, err := search.New(search.Opts{
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing search manager: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// initLogger initializes a logf logger.
|
||||
func initLogger(src string) *logf.Logger {
|
||||
lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")
|
||||
|
||||
+7
-6
@@ -1,24 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/user"
|
||||
"github.com/abhinavxd/libredesk/internal/user"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/stuffbin"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed.
|
||||
func install(db *sqlx.DB, fs stuffbin.FileSystem) error {
|
||||
func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem) error {
|
||||
installed, err := checkSchema(db)
|
||||
if err != nil {
|
||||
log.Fatalf("error checking db schema: %v", err)
|
||||
}
|
||||
if installed {
|
||||
fmt.Printf("\033[31m** WARNING: This will wipe your entire DB - '%s' **\033[0m\n", ko.String("db.database"))
|
||||
fmt.Printf("\033[31m** WARNING: This will wipe your entire database - '%s' **\033[0m\n", ko.String("db.database"))
|
||||
fmt.Print("Continue (y/n)? ")
|
||||
var ok string
|
||||
fmt.Scanf("%s", &ok)
|
||||
@@ -35,15 +36,15 @@ func install(db *sqlx.DB, fs stuffbin.FileSystem) error {
|
||||
log.Println("Schema installed successfully")
|
||||
|
||||
// Create system user.
|
||||
if err := user.CreateSystemUser(db); err != nil {
|
||||
if err := user.CreateSystemUser(ctx, db); err != nil {
|
||||
log.Fatalf("error creating system user: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setSystemUserPass prompts for pass and sets system user password.
|
||||
func setSystemUserPass(db *sqlx.DB) {
|
||||
user.ChangeSystemUserPassword(db)
|
||||
func setSystemUserPass(ctx context.Context, db *sqlx.DB) {
|
||||
user.ChangeSystemUserPassword(ctx, db)
|
||||
}
|
||||
|
||||
// checkSchema verifies if the DB schema is already installed by querying a table.
|
||||
|
||||
+11
-5
@@ -1,12 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// handleLogin logs in the user.
|
||||
// handleLogin logs a user in.
|
||||
func handleLogin(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -14,11 +15,16 @@ func handleLogin(r *fastglue.Request) error {
|
||||
email = string(p.Peek("email"))
|
||||
password = p.Peek("password")
|
||||
)
|
||||
user, err := app.user.Login(email, password)
|
||||
user, err := app.user.VerifyPassword(email, password)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if err := app.auth.SaveSession(user, r); err != nil {
|
||||
if err := app.auth.SaveSession(amodels.User{
|
||||
ID: user.ID,
|
||||
Email: user.Email.String,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
}, r); err != nil {
|
||||
app.lo.Error("error saving session", "error", err)
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
|
||||
}
|
||||
@@ -44,5 +50,5 @@ func handleLogout(r *fastglue.Request) error {
|
||||
"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")
|
||||
return r.RedirectURI("dashboard", fasthttp.StatusFound, nil, "")
|
||||
return r.RedirectURI("/", fasthttp.StatusFound, nil, "")
|
||||
}
|
||||
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
autoModels "github.com/abhinavxd/libredesk/internal/automation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/macro/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// handleGetMacros returns all macros.
|
||||
func handleGetMacros(r *fastglue.Request) error {
|
||||
var app = r.Context.(*App)
|
||||
macros, err := app.macro.GetAll()
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
for i, m := range macros {
|
||||
var actions []autoModels.RuleAction
|
||||
if err := json.Unmarshal(m.Actions, &actions); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling macro actions", nil, envelope.GeneralError)
|
||||
}
|
||||
// Set display values for actions as the value field can contain DB IDs
|
||||
if err := setDisplayValues(app, actions); err != nil {
|
||||
app.lo.Warn("error setting display values", "error", err)
|
||||
}
|
||||
if macros[i].Actions, err = json.Marshal(actions); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "marshal failed", nil, envelope.GeneralError)
|
||||
}
|
||||
}
|
||||
return r.SendEnvelope(macros)
|
||||
}
|
||||
|
||||
// handleGetMacro returns a macro.
|
||||
func handleGetMacro(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,
|
||||
"Invalid macro `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
macro, err := app.macro.Get(id)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
var actions []autoModels.RuleAction
|
||||
if err := json.Unmarshal(macro.Actions, &actions); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling macro actions", nil, envelope.GeneralError)
|
||||
}
|
||||
// Set display values for actions as the value field can contain DB IDs
|
||||
if err := setDisplayValues(app, actions); err != nil {
|
||||
app.lo.Warn("error setting display values", "error", err)
|
||||
}
|
||||
if macro.Actions, err = json.Marshal(actions); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "marshal failed", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(macro)
|
||||
}
|
||||
|
||||
// handleCreateMacro creates new macro.
|
||||
func handleCreateMacro(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
macro = models.Macro{}
|
||||
)
|
||||
|
||||
if err := r.Decode(¯o, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
|
||||
if err := validateMacro(macro); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(macro)
|
||||
}
|
||||
|
||||
// handleUpdateMacro updates a macro.
|
||||
func handleUpdateMacro(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
macro = models.Macro{}
|
||||
)
|
||||
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||
"Invalid macro `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := r.Decode(¯o, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
|
||||
if err := validateMacro(macro); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(macro)
|
||||
}
|
||||
|
||||
// handleDeleteMacro deletes macro.
|
||||
func handleDeleteMacro(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,
|
||||
"Invalid macro `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := app.macro.Delete(id); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope("Macro deleted successfully")
|
||||
}
|
||||
|
||||
// handleApplyMacro applies macro actions to a conversation.
|
||||
func handleApplyMacro(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
conversationUUID = r.RequestCtx.UserValue("uuid").(string)
|
||||
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
incomingActions = []autoModels.RuleAction{}
|
||||
)
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Enforce conversation access.
|
||||
conversation, err := app.conversation.GetConversation(0, conversationUUID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if allowed, err := app.authz.EnforceConversationAccess(user, conversation); err != nil || !allowed {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
|
||||
}
|
||||
|
||||
macro, err := app.macro.Get(id)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Decode incoming actions.
|
||||
if err := r.Decode(&incomingActions, "json"); err != nil {
|
||||
app.lo.Error("error unmashalling incoming actions", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Failed to decode incoming actions", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Make sure no duplicate action types are present.
|
||||
actionTypes := make(map[string]bool, len(incomingActions))
|
||||
for _, act := range incomingActions {
|
||||
if actionTypes[act.Type] {
|
||||
app.lo.Warn("duplicate action types found in macro apply apply request", "action", act.Type, "user_id", user.ID)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Duplicate actions are not allowed", nil, envelope.InputError)
|
||||
}
|
||||
actionTypes[act.Type] = true
|
||||
}
|
||||
|
||||
// Validate action permissions.
|
||||
for _, act := range incomingActions {
|
||||
if !isMacroActionAllowed(act.Type) {
|
||||
app.lo.Warn("action not allowed in macro", "action", act.Type, "user_id", user.ID)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Action not allowed in macro", nil, envelope.PermissionError)
|
||||
}
|
||||
if !hasActionPermission(act.Type, user.Permissions) {
|
||||
app.lo.Warn("no permission to execute macro action", "action", act.Type, "user_id", user.ID)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "No permission to execute this macro", nil, envelope.PermissionError)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply actions.
|
||||
successCount := 0
|
||||
for _, act := range incomingActions {
|
||||
if err := app.conversation.ApplyAction(act, conversation, user); err == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
if successCount == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to apply macro", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
// Increment usage count.
|
||||
app.macro.IncrementUsageCount(macro.ID)
|
||||
|
||||
if successCount < len(incomingActions) {
|
||||
return r.SendJSON(fasthttp.StatusMultiStatus, map[string]interface{}{
|
||||
"message": fmt.Sprintf("Macro executed with errors. %d actions succeeded out of %d", successCount, len(incomingActions)),
|
||||
})
|
||||
}
|
||||
|
||||
return r.SendJSON(fasthttp.StatusOK, map[string]interface{}{
|
||||
"message": "Macro applied successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// hasActionPermission checks user permission for given action
|
||||
func hasActionPermission(action string, userPerms []string) bool {
|
||||
requiredPerm, exists := autoModels.ActionPermissions[action]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(userPerms, requiredPerm)
|
||||
}
|
||||
|
||||
// setDisplayValues sets display values for actions.
|
||||
func setDisplayValues(app *App, actions []autoModels.RuleAction) error {
|
||||
getters := map[string]func(int) (string, error){
|
||||
autoModels.ActionAssignTeam: func(id int) (string, error) {
|
||||
t, err := app.team.Get(id)
|
||||
if err != nil {
|
||||
app.lo.Warn("team not found for macro action", "team_id", id)
|
||||
return "", err
|
||||
}
|
||||
return t.Name, nil
|
||||
},
|
||||
autoModels.ActionAssignUser: func(id int) (string, error) {
|
||||
u, err := app.user.Get(id)
|
||||
if err != nil {
|
||||
app.lo.Warn("user not found for macro action", "user_id", id)
|
||||
return "", err
|
||||
}
|
||||
return u.FullName(), nil
|
||||
},
|
||||
autoModels.ActionSetPriority: func(id int) (string, error) {
|
||||
p, err := app.priority.Get(id)
|
||||
if err != nil {
|
||||
app.lo.Warn("priority not found for macro action", "priority_id", id)
|
||||
return "", err
|
||||
}
|
||||
return p.Name, nil
|
||||
},
|
||||
autoModels.ActionSetStatus: func(id int) (string, error) {
|
||||
s, err := app.status.Get(id)
|
||||
if err != nil {
|
||||
app.lo.Warn("status not found for macro action", "status_id", id)
|
||||
return "", err
|
||||
}
|
||||
return s.Name, nil
|
||||
},
|
||||
}
|
||||
for i := range actions {
|
||||
actions[i].DisplayValue = []string{}
|
||||
if getter, ok := getters[actions[i].Type]; ok {
|
||||
id, _ := strconv.Atoi(actions[i].Value[0])
|
||||
if name, err := getter(id); err == nil {
|
||||
actions[i].DisplayValue = append(actions[i].DisplayValue, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateMacro validates an incoming macro.
|
||||
func validateMacro(macro models.Macro) error {
|
||||
if macro.Name == "" {
|
||||
return envelope.NewError(envelope.InputError, "Empty macro `name`", nil)
|
||||
}
|
||||
|
||||
var act []autoModels.RuleAction
|
||||
if err := json.Unmarshal(macro.Actions, &act); err != nil {
|
||||
return envelope.NewError(envelope.InputError, "Could not parse macro actions", nil)
|
||||
}
|
||||
for _, a := range act {
|
||||
if len(a.Value) == 0 {
|
||||
return envelope.NewError(envelope.InputError, fmt.Sprintf("Empty value for action: %s", a.Type), nil)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isMacroActionAllowed returns true if the action is allowed in a macro.
|
||||
func isMacroActionAllowed(action string) bool {
|
||||
switch action {
|
||||
case autoModels.ActionSendPrivateNote, autoModels.ActionReply:
|
||||
return false
|
||||
case autoModels.ActionAssignTeam, autoModels.ActionAssignUser, autoModels.ActionSetStatus, autoModels.ActionSetPriority, autoModels.ActionSetTags:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
+140
-112
@@ -6,28 +6,35 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
||||
auth_ "github.com/abhinavxd/artemis/internal/auth"
|
||||
"github.com/abhinavxd/artemis/internal/authz"
|
||||
notifier "github.com/abhinavxd/artemis/internal/notification"
|
||||
"github.com/abhinavxd/libredesk/internal/ai"
|
||||
auth_ "github.com/abhinavxd/libredesk/internal/auth"
|
||||
"github.com/abhinavxd/libredesk/internal/authz"
|
||||
businesshours "github.com/abhinavxd/libredesk/internal/business_hours"
|
||||
"github.com/abhinavxd/libredesk/internal/colorlog"
|
||||
"github.com/abhinavxd/libredesk/internal/csat"
|
||||
"github.com/abhinavxd/libredesk/internal/macro"
|
||||
notifier "github.com/abhinavxd/libredesk/internal/notification"
|
||||
"github.com/abhinavxd/libredesk/internal/search"
|
||||
"github.com/abhinavxd/libredesk/internal/sla"
|
||||
"github.com/abhinavxd/libredesk/internal/view"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/automation"
|
||||
"github.com/abhinavxd/artemis/internal/cannedresp"
|
||||
"github.com/abhinavxd/artemis/internal/contact"
|
||||
"github.com/abhinavxd/artemis/internal/conversation"
|
||||
"github.com/abhinavxd/artemis/internal/conversation/priority"
|
||||
"github.com/abhinavxd/artemis/internal/conversation/status"
|
||||
"github.com/abhinavxd/artemis/internal/inbox"
|
||||
"github.com/abhinavxd/artemis/internal/media"
|
||||
"github.com/abhinavxd/artemis/internal/oidc"
|
||||
"github.com/abhinavxd/artemis/internal/role"
|
||||
"github.com/abhinavxd/artemis/internal/setting"
|
||||
"github.com/abhinavxd/artemis/internal/tag"
|
||||
"github.com/abhinavxd/artemis/internal/team"
|
||||
"github.com/abhinavxd/artemis/internal/template"
|
||||
"github.com/abhinavxd/artemis/internal/user"
|
||||
"github.com/abhinavxd/artemis/internal/ws"
|
||||
"github.com/abhinavxd/libredesk/internal/automation"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation/priority"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation/status"
|
||||
"github.com/abhinavxd/libredesk/internal/inbox"
|
||||
"github.com/abhinavxd/libredesk/internal/media"
|
||||
"github.com/abhinavxd/libredesk/internal/oidc"
|
||||
"github.com/abhinavxd/libredesk/internal/role"
|
||||
"github.com/abhinavxd/libredesk/internal/setting"
|
||||
"github.com/abhinavxd/libredesk/internal/tag"
|
||||
"github.com/abhinavxd/libredesk/internal/team"
|
||||
"github.com/abhinavxd/libredesk/internal/template"
|
||||
"github.com/abhinavxd/libredesk/internal/user"
|
||||
"github.com/abhinavxd/libredesk/internal/ws"
|
||||
"github.com/knadh/go-i18n"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
@@ -38,44 +45,64 @@ import (
|
||||
|
||||
var (
|
||||
ko = koanf.New(".")
|
||||
frontendDir = "frontend/dist"
|
||||
ctx = context.Background()
|
||||
buildString string
|
||||
appName = "libredesk"
|
||||
frontendDir = "frontend/dist"
|
||||
|
||||
// Injected at build time.
|
||||
buildString = ""
|
||||
)
|
||||
|
||||
// App is the global app context which is passed and injected in the http handlers.
|
||||
type App struct {
|
||||
consts constants
|
||||
fs stuffbin.FileSystem
|
||||
auth *auth_.Auth
|
||||
authz *authz.Enforcer
|
||||
i18n *i18n.I18n
|
||||
lo *logf.Logger
|
||||
oidc *oidc.Manager
|
||||
media *media.Manager
|
||||
setting *setting.Manager
|
||||
role *role.Manager
|
||||
contact *contact.Manager
|
||||
user *user.Manager
|
||||
team *team.Manager
|
||||
status *status.Manager
|
||||
priority *priority.Manager
|
||||
tag *tag.Manager
|
||||
inbox *inbox.Manager
|
||||
tmpl *template.Manager
|
||||
cannedResp *cannedresp.Manager
|
||||
conversation *conversation.Manager
|
||||
automation *automation.Engine
|
||||
notifier *notifier.Service
|
||||
fs stuffbin.FileSystem
|
||||
consts atomic.Value
|
||||
auth *auth_.Auth
|
||||
authz *authz.Enforcer
|
||||
i18n *i18n.I18n
|
||||
lo *logf.Logger
|
||||
oidc *oidc.Manager
|
||||
media *media.Manager
|
||||
setting *setting.Manager
|
||||
role *role.Manager
|
||||
user *user.Manager
|
||||
team *team.Manager
|
||||
status *status.Manager
|
||||
priority *priority.Manager
|
||||
tag *tag.Manager
|
||||
inbox *inbox.Manager
|
||||
tmpl *template.Manager
|
||||
macro *macro.Manager
|
||||
conversation *conversation.Manager
|
||||
automation *automation.Engine
|
||||
businessHours *businesshours.Manager
|
||||
sla *sla.Manager
|
||||
csat *csat.Manager
|
||||
view *view.Manager
|
||||
ai *ai.Manager
|
||||
search *search.Manager
|
||||
notifier *notifier.Service
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Set up signal handler.
|
||||
ctx, _ = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Load command line flags into Koanf.
|
||||
initFlags()
|
||||
|
||||
// Version flag.
|
||||
if ko.Bool("version") {
|
||||
fmt.Println(buildString)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Build string injected at build time.
|
||||
if buildString != "" {
|
||||
colorlog.Green("Build: %s", buildString)
|
||||
}
|
||||
|
||||
// Load the config files into Koanf.
|
||||
initConfig(ko)
|
||||
|
||||
@@ -85,23 +112,15 @@ func main() {
|
||||
// Init DB.
|
||||
db := initDB()
|
||||
|
||||
// Version flag.
|
||||
if ko.Bool("version") {
|
||||
fmt.Println(buildString)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
log.Printf("Build: %s", buildString)
|
||||
|
||||
// Installer.
|
||||
if ko.Bool("install") {
|
||||
install(db, fs)
|
||||
install(ctx, db, fs)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Set system user password.
|
||||
if ko.Bool("set-system-user-password") {
|
||||
setSystemUserPass(db)
|
||||
setSystemUserPass(ctx, db)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
@@ -120,85 +139,83 @@ func main() {
|
||||
loadSettings(settings)
|
||||
|
||||
var (
|
||||
automationWrk = ko.MustInt("automation.worker_count")
|
||||
messageDispatchWrk = ko.MustInt("message.dispatch_workers")
|
||||
messageDispatchScanInterval = ko.MustDuration("message.dispatch_scan_interval")
|
||||
lo = initLogger("artemis")
|
||||
autoAssignInterval = ko.MustDuration("autoassigner.autoassign_interval")
|
||||
unsnoozeInterval = ko.MustDuration("conversation.unsnooze_interval")
|
||||
automationWorkers = ko.MustInt("automation.worker_count")
|
||||
messageOutgoingQWorkers = ko.MustDuration("message.outgoing_queue_workers")
|
||||
messageIncomingQWorkers = ko.MustDuration("message.incoming_queue_workers")
|
||||
messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
|
||||
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
|
||||
lo = initLogger(appName)
|
||||
wsHub = ws.NewHub()
|
||||
rdb = initRedis()
|
||||
constants = initConstants()
|
||||
i18n = initI18n(fs)
|
||||
oidc = initOIDC(db)
|
||||
csat = initCSAT(db)
|
||||
oidc = initOIDC(db, settings)
|
||||
status = initStatus(db)
|
||||
priority = initPriority(db)
|
||||
auth = initAuth(oidc, rdb)
|
||||
template = initTemplate(db, fs, constants)
|
||||
media = initMedia(db)
|
||||
contact = initContact(db)
|
||||
inbox = initInbox(db)
|
||||
team = initTeam(db)
|
||||
businessHours = initBusinessHours(db)
|
||||
user = initUser(i18n, db)
|
||||
notifier = initNotifier(user)
|
||||
automation = initAutomationEngine(db, user)
|
||||
conversation = initConversations(i18n, wsHub, notifier, db, contact, inbox, user, team, media, automation, template)
|
||||
automation = initAutomationEngine(db)
|
||||
sla = initSLA(db, team, settings, businessHours)
|
||||
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template)
|
||||
autoassigner = initAutoAssigner(team, user, conversation)
|
||||
)
|
||||
|
||||
// Set stores.
|
||||
wsHub.SetConversationStore(conversation)
|
||||
automation.SetConversationStore(conversation)
|
||||
|
||||
// Start inbox receivers.
|
||||
startInboxes(ctx, inbox, conversation)
|
||||
|
||||
// Start evaluating automation rules.
|
||||
go automation.Run(ctx, automationWrk)
|
||||
|
||||
// Start conversation auto assigner.
|
||||
go autoassigner.Run(ctx)
|
||||
|
||||
// Start processing incoming and outgoing messages.
|
||||
go conversation.Run(ctx, messageDispatchWrk, messageDispatchScanInterval)
|
||||
|
||||
// Start notifier.
|
||||
go automation.Run(ctx, automationWorkers)
|
||||
go autoassigner.Run(ctx, autoAssignInterval)
|
||||
go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval)
|
||||
go conversation.RunUnsnoozer(ctx, unsnoozeInterval)
|
||||
go notifier.Run(ctx)
|
||||
go sla.Run(ctx, slaEvaluationInterval)
|
||||
go media.DeleteUnlinkedMedia(ctx)
|
||||
|
||||
// Delete media not linked to any message.
|
||||
go media.DeleteUnlinkedMessageMedia(ctx)
|
||||
|
||||
// Init the app
|
||||
var app = &App{
|
||||
lo: lo,
|
||||
auth: auth,
|
||||
fs: fs,
|
||||
i18n: i18n,
|
||||
media: media,
|
||||
setting: settings,
|
||||
contact: contact,
|
||||
inbox: inbox,
|
||||
user: user,
|
||||
team: team,
|
||||
tmpl: template,
|
||||
conversation: conversation,
|
||||
automation: automation,
|
||||
oidc: oidc,
|
||||
consts: constants,
|
||||
notifier: notifier,
|
||||
authz: initAuthz(),
|
||||
status: initStatus(db),
|
||||
priority: initPriority(db),
|
||||
role: initRole(db),
|
||||
tag: initTags(db),
|
||||
cannedResp: initCannedResponse(db),
|
||||
lo: lo,
|
||||
fs: fs,
|
||||
sla: sla,
|
||||
oidc: oidc,
|
||||
i18n: i18n,
|
||||
auth: auth,
|
||||
media: media,
|
||||
setting: settings,
|
||||
inbox: inbox,
|
||||
user: user,
|
||||
team: team,
|
||||
status: status,
|
||||
priority: priority,
|
||||
tmpl: template,
|
||||
notifier: notifier,
|
||||
consts: atomic.Value{},
|
||||
conversation: conversation,
|
||||
automation: automation,
|
||||
businessHours: businessHours,
|
||||
authz: initAuthz(),
|
||||
view: initView(db),
|
||||
csat: initCSAT(db),
|
||||
search: initSearch(db),
|
||||
role: initRole(db),
|
||||
tag: initTag(db),
|
||||
macro: initMacro(db),
|
||||
ai: initAI(db),
|
||||
}
|
||||
app.consts.Store(constants)
|
||||
|
||||
// Init fastglue and set app in ctx.
|
||||
g := fastglue.NewGlue()
|
||||
g.SetContext(app)
|
||||
|
||||
// Init HTTP handlers.
|
||||
initHandlers(g, wsHub)
|
||||
|
||||
s := &fasthttp.Server{
|
||||
Name: "server",
|
||||
Name: appName,
|
||||
ReadTimeout: ko.MustDuration("app.server.read_timeout"),
|
||||
WriteTimeout: ko.MustDuration("app.server.write_timeout"),
|
||||
MaxRequestBodySize: ko.MustInt("app.server.max_body_size"),
|
||||
@@ -206,24 +223,35 @@ func main() {
|
||||
ReadBufferSize: ko.MustInt("app.server.max_body_size"),
|
||||
}
|
||||
|
||||
log.Printf("%s🚀 server listening on %s %s\x1b[0m", "\x1b[32m", ko.String("app.server.address"), ko.String("app.server.socket"))
|
||||
|
||||
go func() {
|
||||
colorlog.Green("Server started at %s", ko.String("app.server.address"))
|
||||
if ko.String("server.socket") != "" {
|
||||
colorlog.Green("Unix socket created at %s", ko.String("server.socket"))
|
||||
}
|
||||
if err := g.ListenAndServe(ko.String("app.server.address"), ko.String("server.socket"), s); err != nil {
|
||||
log.Fatalf("error starting server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal.
|
||||
<-ctx.Done()
|
||||
log.Printf("%sShutting down the server. Please wait.\x1b[0m", "\x1b[31m")
|
||||
// Shutdown HTTP server.
|
||||
colorlog.Red("Shutting down HTTP server...")
|
||||
s.Shutdown()
|
||||
// Shutdown services.
|
||||
colorlog.Red("Shutting down inboxes...")
|
||||
inbox.Close()
|
||||
colorlog.Red("Shutting down automation...")
|
||||
automation.Close()
|
||||
colorlog.Red("Shutting down autoassigner...")
|
||||
autoassigner.Close()
|
||||
colorlog.Red("Shutting down notifier...")
|
||||
notifier.Close()
|
||||
colorlog.Red("Shutting down conversation...")
|
||||
conversation.Close()
|
||||
colorlog.Red("Shutting down SLA...")
|
||||
sla.Close()
|
||||
colorlog.Red("Shutting down database...")
|
||||
db.Close()
|
||||
colorlog.Red("Shutting down redis...")
|
||||
rdb.Close()
|
||||
colorlog.Green("Shutdown complete.")
|
||||
}
|
||||
|
||||
+28
-23
@@ -9,19 +9,19 @@ import (
|
||||
|
||||
"slices"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/attachment"
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
"github.com/abhinavxd/artemis/internal/image"
|
||||
"github.com/abhinavxd/artemis/internal/stringutil"
|
||||
umodels "github.com/abhinavxd/artemis/internal/user/models"
|
||||
"github.com/abhinavxd/libredesk/internal/attachment"
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/image"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
"github.com/google/uuid"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
const (
|
||||
thumbPrefix = "thumb_"
|
||||
thumbnailSize = 150
|
||||
thumbPrefix = "thumb_"
|
||||
)
|
||||
|
||||
func handleMediaUpload(r *fastglue.Request) error {
|
||||
@@ -50,10 +50,10 @@ func handleMediaUpload(r *fastglue.Request) error {
|
||||
defer file.Close()
|
||||
|
||||
// Inline?
|
||||
var disposition = attachment.DispositionAttachment
|
||||
var disposition = null.StringFrom(attachment.DispositionAttachment)
|
||||
inline, ok := form.Value["inline"]
|
||||
if ok && len(inline) > 0 && inline[0] == "true" {
|
||||
disposition = attachment.DispositionInline
|
||||
disposition = null.StringFrom(attachment.DispositionInline)
|
||||
}
|
||||
|
||||
// Linked model?
|
||||
@@ -70,17 +70,18 @@ func handleMediaUpload(r *fastglue.Request) error {
|
||||
srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".")
|
||||
|
||||
// Check file size
|
||||
if bytesToMegabytes(srcFileSize) > app.consts.MaxFileUploadSizeMB {
|
||||
app.lo.Error("error: uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", app.consts.MaxFileUploadSizeMB)
|
||||
consts := app.consts.Load().(*constants)
|
||||
if bytesToMegabytes(srcFileSize) > float64(consts.MaxFileUploadSizeMB) {
|
||||
app.lo.Error("error: uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", consts.MaxFileUploadSizeMB)
|
||||
return r.SendErrorEnvelope(
|
||||
http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("File size is too large. Please upload file lesser than %f MB", app.consts.MaxFileUploadSizeMB),
|
||||
fmt.Sprintf("File size is too large. Please upload file lesser than %d MB", consts.MaxFileUploadSizeMB),
|
||||
nil,
|
||||
envelope.GeneralError,
|
||||
)
|
||||
}
|
||||
|
||||
if !slices.Contains(app.consts.AllowedUploadFileExtensions, "*") && !slices.Contains(app.consts.AllowedUploadFileExtensions, srcExt) {
|
||||
if !slices.Contains(consts.AllowedUploadFileExtensions, "*") && !slices.Contains(consts.AllowedUploadFileExtensions, srcExt) {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "File type not allowed", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
@@ -94,11 +95,11 @@ func handleMediaUpload(r *fastglue.Request) error {
|
||||
}
|
||||
}()
|
||||
|
||||
// Generate and upload thumbnail and save it's dimensions if it's an image.
|
||||
// Generate and upload thumbnail and store image dimensions in the media meta.
|
||||
var meta = []byte("{}")
|
||||
if slices.Contains(image.Exts, srcExt) {
|
||||
file.Seek(0, 0)
|
||||
thumbFile, err := image.CreateThumb(thumbnailSize, file)
|
||||
thumbFile, err := image.CreateThumb(image.DefThumbSize, file)
|
||||
if err != nil {
|
||||
app.lo.Error("error creating thumb image", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error creating image thumbnail", nil, envelope.GeneralError)
|
||||
@@ -109,7 +110,7 @@ func handleMediaUpload(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Store image dimensions in the media meta.
|
||||
// Store image dimensions in media meta, storing dimensions for image previews in future.
|
||||
file.Seek(0, 0)
|
||||
width, height, err := image.GetDimensions(file)
|
||||
if err != nil {
|
||||
@@ -121,7 +122,6 @@ func handleMediaUpload(r *fastglue.Request) error {
|
||||
"width": width,
|
||||
"height": height,
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
file.Seek(0, 0)
|
||||
@@ -133,7 +133,7 @@ func handleMediaUpload(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Insert in DB.
|
||||
media, err := app.media.Insert(srcFileName, srcContentType, "" /**content_id**/, linkedModel, disposition, uuid.String(), 0, int(srcFileSize), meta)
|
||||
media, err := app.media.Insert(disposition, srcFileName, srcContentType, "" /**content_id**/, null.NewString(linkedModel, linkedModel != ""), uuid.String(), null.Int{} /**model_id**/, int(srcFileSize), meta)
|
||||
if err != nil {
|
||||
cleanUp = true
|
||||
app.lo.Error("error inserting metadata into database", "error", err)
|
||||
@@ -145,11 +145,16 @@ func handleMediaUpload(r *fastglue.Request) error {
|
||||
// handleServeMedia serves uploaded media.
|
||||
func handleServeMedia(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
)
|
||||
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Fetch media from DB.
|
||||
media, err := app.media.GetByUUID(strings.TrimPrefix(uuid, thumbPrefix))
|
||||
if err != nil {
|
||||
@@ -178,8 +183,8 @@ func handleServeMedia(r *fastglue.Request) error {
|
||||
if !allowed {
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "Permission denied", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
switch app.consts.UploadProvider {
|
||||
consts := app.consts.Load().(*constants)
|
||||
switch consts.UploadProvider {
|
||||
case "fs":
|
||||
fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid))
|
||||
case "s3":
|
||||
|
||||
+59
-33
@@ -3,18 +3,20 @@ package main
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/automation/models"
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
medModels "github.com/abhinavxd/artemis/internal/media/models"
|
||||
umodels "github.com/abhinavxd/artemis/internal/user/models"
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
"github.com/abhinavxd/libredesk/internal/automation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
medModels "github.com/abhinavxd/libredesk/internal/media/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
type messageReq struct {
|
||||
Attachments []int `json:"attachments"`
|
||||
Message string `json:"message"`
|
||||
Private bool `json:"private"`
|
||||
Attachments []int `json:"attachments"`
|
||||
Message string `json:"message"`
|
||||
Private bool `json:"private"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc"`
|
||||
}
|
||||
|
||||
// handleGetMessages returns messages for a conversation.
|
||||
@@ -22,14 +24,19 @@ func handleGetMessages(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
|
||||
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
|
||||
total = 0
|
||||
)
|
||||
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Check permission
|
||||
_, err := enforceConversationAccess(app, uuid, user)
|
||||
_, err = enforceConversationAccess(app, uuid, user)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -44,6 +51,7 @@ 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)
|
||||
}
|
||||
messages[i].CensorCSATContent()
|
||||
}
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Total: total,
|
||||
@@ -60,25 +68,32 @@ func handleGetMessage(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
cuuid = r.RequestCtx.UserValue("cuuid").(string)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Check permission
|
||||
_, err := enforceConversationAccess(app, cuuid, user)
|
||||
_, err = enforceConversationAccess(app, cuuid, user)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
messages, err := app.conversation.GetMessage(uuid)
|
||||
message, err := app.conversation.GetMessage(uuid)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
for j := range messages.Attachments {
|
||||
messages.Attachments[j].URL = app.media.GetURL(messages.Attachments[j].UUID)
|
||||
// Redact CSAT survey link
|
||||
message.CensorCSATContent()
|
||||
|
||||
for j := range message.Attachments {
|
||||
message.Attachments[j].URL = app.media.GetURL(message.Attachments[j].UUID)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(messages)
|
||||
return r.SendEnvelope(message)
|
||||
}
|
||||
|
||||
// handleRetryMessage changes message status so it can be retried for sending.
|
||||
@@ -87,11 +102,16 @@ func handleRetryMessage(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
cuuid = r.RequestCtx.UserValue("cuuid").(string)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Check permission
|
||||
_, err := enforceConversationAccess(app, cuuid, user)
|
||||
_, err = enforceConversationAccess(app, cuuid, user)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -106,48 +126,54 @@ func handleRetryMessage(r *fastglue.Request) error {
|
||||
// handleSendMessage sends a message in a conversation.
|
||||
func handleSendMessage(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
cuuid = r.RequestCtx.UserValue("cuuid").(string)
|
||||
req = messageReq{}
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
cuuid = r.RequestCtx.UserValue("cuuid").(string)
|
||||
media = []medModels.Media{}
|
||||
req = messageReq{}
|
||||
)
|
||||
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Check permission
|
||||
_, err := enforceConversationAccess(app, cuuid, user)
|
||||
_, err = enforceConversationAccess(app, cuuid, user)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if err := r.Decode(&req, "json"); err != nil {
|
||||
app.lo.Error("error unmarshalling media ids", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "error decoding request", nil, "")
|
||||
app.lo.Error("error unmarshalling message request", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "error decoding request", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
for _, id := range req.Attachments {
|
||||
m, err := app.media.Get(id)
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching media", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error fetching media", nil, "")
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error fetching media", nil, envelope.GeneralError)
|
||||
}
|
||||
media = append(media, m)
|
||||
}
|
||||
|
||||
// Private note.
|
||||
if req.Private {
|
||||
if err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
} else {
|
||||
if err := app.conversation.SendReply(media, user.ID, cuuid, req.Message, req.CC, req.BCC, map[string]interface{}{}); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
// Evaluate automation rules.
|
||||
app.automation.EvaluateConversationUpdateRules(cuuid, models.EventConversationMessageOutgoing)
|
||||
}
|
||||
|
||||
// Reply.
|
||||
if err := app.conversation.SendReply(media, user.ID, cuuid, req.Message); err != nil {
|
||||
// Reopen if snoozed/closed/resolved regardless of automation rules - this is the default behavior
|
||||
if err := app.conversation.ReOpenConversation(cuuid, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Evaluate automation rules.
|
||||
app.automation.EvaluateConversationUpdateRules(cuuid, models.EventConversationMessageOutgoing)
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("Message sent successfully")
|
||||
}
|
||||
|
||||
+41
-20
@@ -2,8 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
@@ -27,7 +29,12 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
}
|
||||
|
||||
// Set user in context if found.
|
||||
r.RequestCtx.SetUserValue("user", user)
|
||||
r.RequestCtx.SetUserValue("user", amodels.User{
|
||||
ID: user.ID,
|
||||
Email: user.Email.String,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
})
|
||||
|
||||
return handler(r)
|
||||
}
|
||||
@@ -52,14 +59,19 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
r.RequestCtx.SetUserValue("user", user)
|
||||
r.RequestCtx.SetUserValue("user", amodels.User{
|
||||
ID: user.ID,
|
||||
Email: user.Email.String,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
})
|
||||
|
||||
return handler(r)
|
||||
}
|
||||
}
|
||||
|
||||
// authPerm does session validation, CSRF, and permission enforcement.
|
||||
func authPerm(handler fastglue.FastRequestHandler, object, action string) fastglue.FastRequestHandler {
|
||||
// perm does session validation, CSRF, and permission enforcement.
|
||||
func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -73,30 +85,39 @@ func authPerm(handler fastglue.FastRequestHandler, object, action string) fastgl
|
||||
}
|
||||
|
||||
// Validate session and fetch user.
|
||||
userSession, err := app.auth.ValidateSession(r)
|
||||
if err != nil || userSession.ID <= 0 {
|
||||
sessUser, err := app.auth.ValidateSession(r)
|
||||
if err != nil || sessUser.ID <= 0 {
|
||||
app.lo.Error("error validating session", "error", err)
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
user, err := app.user.Get(userSession.ID)
|
||||
// Get user from DB.
|
||||
user, err := app.user.Get(sessUser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Permission enforcement.
|
||||
if object != "" && action != "" {
|
||||
ok, err := app.authz.Enforce(user, object, action)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error checking permissions", nil, envelope.GeneralError)
|
||||
}
|
||||
if !ok {
|
||||
return r.SendErrorEnvelope(http.StatusForbidden, "Permission denied", nil, envelope.PermissionError)
|
||||
}
|
||||
// Split the permission string into object and action and enforce it.
|
||||
parts := strings.Split(perm, ":")
|
||||
if len(parts) != 2 {
|
||||
return r.SendErrorEnvelope(http.StatusInternalServerError, "Invalid permission format", nil, envelope.GeneralError)
|
||||
}
|
||||
object, action := parts[0], parts[1]
|
||||
ok, err := app.authz.Enforce(user, object, action)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error checking permissions", nil, envelope.GeneralError)
|
||||
}
|
||||
if !ok {
|
||||
return r.SendErrorEnvelope(http.StatusForbidden, "Permission denied", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
// Set user in the request context.
|
||||
r.RequestCtx.SetUserValue("user", user)
|
||||
r.RequestCtx.SetUserValue("user", amodels.User{
|
||||
ID: user.ID,
|
||||
Email: user.Email.String,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
})
|
||||
|
||||
return handler(r)
|
||||
}
|
||||
@@ -127,7 +148,7 @@ func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// notAuthPage allows access only if the user is not authenticated; otherwise, redirects to the dashboard.
|
||||
// notAuthPage allows access only if the user is not authenticated; otherwise, redirects to the user inbox.
|
||||
func notAuthPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
app := r.Context.(*App)
|
||||
@@ -142,7 +163,7 @@ func notAuthPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandle
|
||||
if user.ID != 0 {
|
||||
nextURI := string(r.RequestCtx.QueryArgs().Peek("next"))
|
||||
if nextURI == "" {
|
||||
nextURI = "/dashboard"
|
||||
nextURI = "/inboxes/assigned"
|
||||
}
|
||||
return r.RedirectURI(nextURI, fasthttp.StatusFound, nil, "")
|
||||
}
|
||||
|
||||
+18
-18
@@ -1,18 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
"github.com/abhinavxd/artemis/internal/oidc/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/oidc/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
const (
|
||||
redirectURI = "/api/oidc/finish?id=%d"
|
||||
)
|
||||
// handleGetAllEnabledOIDC returns all enabled OIDC records
|
||||
func handleGetAllEnabledOIDC(r *fastglue.Request) error {
|
||||
app := r.Context.(*App)
|
||||
out, err := app.oidc.GetAllEnabled()
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(out)
|
||||
}
|
||||
|
||||
// handleGetAllOIDC returns all OIDC records
|
||||
func handleGetAllOIDC(r *fastglue.Request) error {
|
||||
@@ -26,21 +31,16 @@ func handleGetAllOIDC(r *fastglue.Request) error {
|
||||
|
||||
// handleGetOIDC returns an OIDC record by id.
|
||||
func handleGetOIDC(r *fastglue.Request) error {
|
||||
app := r.Context.(*App)
|
||||
|
||||
var app = r.Context.(*App)
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id <= 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||
"Invalid OIDC `id`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
o, err := app.oidc.Get(id)
|
||||
o, err := app.oidc.Get(id, false)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
o.RedirectURI = fmt.Sprintf("%s%s", app.consts.AppBaseURL, fmt.Sprintf(redirectURI, o.ID))
|
||||
|
||||
return r.SendEnvelope(o)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func handleCreateOIDC(r *fastglue.Request) error {
|
||||
if err := reloadAuth(app); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading auth", nil, envelope.GeneralError)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("OIDC created successfully")
|
||||
}
|
||||
|
||||
func handleUpdateOIDC(r *fastglue.Request) error {
|
||||
@@ -86,9 +86,9 @@ func handleUpdateOIDC(r *fastglue.Request) error {
|
||||
|
||||
// Reload the auth manager to update the OIDC providers.
|
||||
if err := reloadAuth(app); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading auth", nil, envelope.GeneralError)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, envelope.GeneralError)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("OIDC updated successfully")
|
||||
}
|
||||
|
||||
func handleDeleteOIDC(r *fastglue.Request) error {
|
||||
@@ -107,7 +107,7 @@ func handleDeleteOIDC(r *fastglue.Request) error {
|
||||
|
||||
// Reload the auth manager to update the OIDC providers.
|
||||
if err := reloadAuth(app); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading auth", nil, envelope.GeneralError)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, envelope.GeneralError)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("OIDC deleted successfully")
|
||||
}
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@ package main
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
"github.com/abhinavxd/artemis/internal/role/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/role/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
const (
|
||||
minSearchQueryLength = 3
|
||||
)
|
||||
|
||||
// handleSearchConversations searches conversations based on the query.
|
||||
func handleSearchConversations(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
q = string(r.RequestCtx.QueryArgs().Peek("query"))
|
||||
)
|
||||
|
||||
if len(q) < minSearchQueryLength {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Query length should be at least 3 characters", nil))
|
||||
}
|
||||
|
||||
conversations, err := app.search.Conversations(q)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(conversations)
|
||||
}
|
||||
|
||||
// handleSearchMessages searches messages based on the query.
|
||||
func handleSearchMessages(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
q = string(r.RequestCtx.QueryArgs().Peek("query"))
|
||||
)
|
||||
|
||||
if len(q) < minSearchQueryLength {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Query length should be at least 3 characters", nil))
|
||||
}
|
||||
|
||||
messages, err := app.search.Messages(q)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(messages)
|
||||
}
|
||||
+16
-5
@@ -4,13 +4,14 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
"github.com/abhinavxd/artemis/internal/setting/models"
|
||||
"github.com/abhinavxd/artemis/internal/stringutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/setting/models"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// handleGetGeneralSettings fetches general settings.
|
||||
func handleGetGeneralSettings(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -22,6 +23,7 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(out)
|
||||
}
|
||||
|
||||
// handleUpdateGeneralSettings updates general settings.
|
||||
func handleUpdateGeneralSettings(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -35,9 +37,17 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
|
||||
if err := app.setting.Update(req); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
// Reload the settings and templates.
|
||||
if err := reloadSettings(app); err != nil {
|
||||
return envelope.NewError(envelope.GeneralError, "Could not reload settings, Please restart the app.", nil)
|
||||
}
|
||||
if err := reloadTemplates(app); err != nil {
|
||||
return envelope.NewError(envelope.GeneralError, "Could not reload settings, Please restart the app.", nil)
|
||||
}
|
||||
return r.SendEnvelope("Settings updated successfully")
|
||||
}
|
||||
|
||||
// handleGetEmailNotificationSettings fetches email notification settings.
|
||||
func handleGetEmailNotificationSettings(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -59,6 +69,7 @@ func handleGetEmailNotificationSettings(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(notif)
|
||||
}
|
||||
|
||||
// handleUpdateEmailNotificationSettings updates email notification settings.
|
||||
func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -86,5 +97,5 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
|
||||
if err := app.setting.Update(req); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("Settings updated successfully, Please restart the app for changes to take effect.")
|
||||
}
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
func handleGetSLAs(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
slas, err := app.sla.GetAll()
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||
}
|
||||
return r.SendEnvelope(slas)
|
||||
}
|
||||
|
||||
func handleGetSLA(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, "Invalid SLA `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
sla, err := app.sla.Get(id)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||
}
|
||||
return r.SendEnvelope(sla)
|
||||
}
|
||||
|
||||
func handleCreateSLA(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
name = string(r.RequestCtx.PostArgs().Peek("name"))
|
||||
desc = string(r.RequestCtx.PostArgs().Peek("description"))
|
||||
firstRespTime = string(r.RequestCtx.PostArgs().Peek("first_response_time"))
|
||||
resTime = string(r.RequestCtx.PostArgs().Peek("resolution_time"))
|
||||
)
|
||||
// Validate time duration strings
|
||||
if _, err := time.ParseDuration(firstRespTime); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `first_response_time` duration.", nil, envelope.InputError)
|
||||
}
|
||||
if _, err := time.ParseDuration(resTime); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `resolution_time` duration.", nil, envelope.InputError)
|
||||
}
|
||||
if err := app.sla.Create(name, desc, firstRespTime, resTime); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope("SLA created successfully.")
|
||||
}
|
||||
|
||||
func handleDeleteSLA(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, "Invalid SLA `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err = app.sla.Delete(id); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
func handleUpdateSLA(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
name = string(r.RequestCtx.PostArgs().Peek("name"))
|
||||
desc = string(r.RequestCtx.PostArgs().Peek("description"))
|
||||
firstRespTime = string(r.RequestCtx.PostArgs().Peek("first_response_time"))
|
||||
resTime = string(r.RequestCtx.PostArgs().Peek("resolution_time"))
|
||||
)
|
||||
|
||||
// Validate time duration strings
|
||||
if _, err := time.ParseDuration(firstRespTime); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `first_response_time` duration.", nil, envelope.InputError)
|
||||
}
|
||||
if _, err := time.ParseDuration(resTime); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `resolution_time` duration.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid SLA `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := app.sla.Update(id, name, desc, firstRespTime, resTime); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
+4
-5
@@ -3,8 +3,8 @@ package main
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
@@ -20,10 +20,9 @@ func handleGetStatuses(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(out)
|
||||
}
|
||||
|
||||
|
||||
func handleCreateStatus(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
app = r.Context.(*App)
|
||||
status = cmodels.Status{}
|
||||
)
|
||||
if err := r.Decode(&status, "json"); err != nil {
|
||||
@@ -66,7 +65,7 @@ func handleDeleteStatus(r *fastglue.Request) error {
|
||||
|
||||
func handleUpdateStatus(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
app = r.Context.(*App)
|
||||
status = cmodels.Status{}
|
||||
)
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@ package main
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
tmodels "github.com/abhinavxd/artemis/internal/tag/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
tmodels "github.com/abhinavxd/libredesk/internal/tag/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
+38
-36
@@ -1,15 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
"github.com/abhinavxd/artemis/internal/team/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// handleGetTeams returns a list of all teams.
|
||||
func handleGetTeams(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -21,6 +21,7 @@ func handleGetTeams(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(teams)
|
||||
}
|
||||
|
||||
// handleGetTeamsCompact returns a list of all teams in a compact format.
|
||||
func handleGetTeamsCompact(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -32,59 +33,60 @@ func handleGetTeamsCompact(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(teams)
|
||||
}
|
||||
|
||||
// handleGetTeam returns a single team.
|
||||
func handleGetTeam(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
app = r.Context.(*App)
|
||||
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
)
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||
"Invalid team `id`.", nil, envelope.InputError)
|
||||
if id < 1 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid team `id`.", nil, envelope.InputError)
|
||||
}
|
||||
team, err := app.team.GetTeam(id)
|
||||
team, err := app.team.Get(id)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(team)
|
||||
}
|
||||
|
||||
// handleCreateTeam creates a new team.
|
||||
func handleCreateTeam(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
req = models.Team{}
|
||||
app = r.Context.(*App)
|
||||
name = string(r.RequestCtx.PostArgs().Peek("name"))
|
||||
timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
|
||||
emoji = string(r.RequestCtx.PostArgs().Peek("emoji"))
|
||||
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
|
||||
businessHrsID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
|
||||
slaPolicyID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("sla_policy_id")))
|
||||
maxAutoAssignedConversations, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("max_auto_assigned_conversations")))
|
||||
)
|
||||
|
||||
if _, err := fastglue.ScanArgs(r.RequestCtx.PostArgs(), &req, `json`); err != nil {
|
||||
app.lo.Error("error scanning args", "error", err)
|
||||
return envelope.NewError(envelope.InputError,
|
||||
fmt.Sprintf("Invalid request (%s)", err.Error()), nil)
|
||||
}
|
||||
err := app.team.CreateTeam(req)
|
||||
if err != nil {
|
||||
if err := app.team.Create(name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji, maxAutoAssignedConversations); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("Team created successfully.")
|
||||
}
|
||||
|
||||
// handleUpdateTeam updates an existing team.
|
||||
func handleUpdateTeam(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
req = models.Team{}
|
||||
app = r.Context.(*App)
|
||||
name = string(r.RequestCtx.PostArgs().Peek("name"))
|
||||
timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
|
||||
emoji = string(r.RequestCtx.PostArgs().Peek("emoji"))
|
||||
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
|
||||
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
businessHrsID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
|
||||
slaPolicyID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("sla_policy_id")))
|
||||
maxAutoAssignedConversations, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("max_auto_assigned_conversations")))
|
||||
)
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||
"Invalid team `id`.", nil, envelope.InputError)
|
||||
if id < 1 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid team `id`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := r.Decode(&req, "json"); err != nil {
|
||||
return envelope.NewError(envelope.InputError, "Bad request", nil)
|
||||
}
|
||||
err = app.team.UpdateTeam(id, req)
|
||||
if err != nil {
|
||||
if err := app.team.Update(id, name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji, maxAutoAssignedConversations); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("Team updated successfully.")
|
||||
}
|
||||
|
||||
// handleDeleteTeam deletes a team
|
||||
@@ -97,9 +99,9 @@ func handleDeleteTeam(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||
"Invalid team `id`.", nil, envelope.InputError)
|
||||
}
|
||||
err = app.team.DeleteTeam(id)
|
||||
err = app.team.Delete(id)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
return r.SendEnvelope("Team deleted successfully.")
|
||||
}
|
||||
|
||||
+13
-10
@@ -3,23 +3,29 @@ package main
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
"github.com/abhinavxd/artemis/internal/template/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/template/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// handleGetTemplates returns all templates.
|
||||
func handleGetTemplates(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
typ = string(r.RequestCtx.QueryArgs().Peek("type"))
|
||||
)
|
||||
t, err := app.tmpl.GetAll()
|
||||
if typ == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `type`.", nil, envelope.InputError)
|
||||
}
|
||||
t, err := app.tmpl.GetAll(typ)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(t)
|
||||
}
|
||||
|
||||
// handleGetTemplate returns a template by id.
|
||||
func handleGetTemplate(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -36,6 +42,7 @@ func handleGetTemplate(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(t)
|
||||
}
|
||||
|
||||
// handleCreateTemplate creates a new template.
|
||||
func handleCreateTemplate(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -44,14 +51,13 @@ func handleCreateTemplate(r *fastglue.Request) error {
|
||||
if err := r.Decode(&req, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
err := app.tmpl.Create(req)
|
||||
if err != nil {
|
||||
if err := app.tmpl.Create(req); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleUpdateTemplate updates a template.
|
||||
func handleUpdateTemplate(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -62,17 +68,16 @@ func handleUpdateTemplate(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||
"Invalid template `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := r.Decode(&req, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
if err = app.tmpl.Update(id, req); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleDeleteTemplate deletes a template.
|
||||
func handleDeleteTemplate(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -83,11 +88,9 @@ func handleDeleteTemplate(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||
"Invalid template `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := r.Decode(&req, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
if err = app.tmpl.Delete(id); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
+96
-47
@@ -8,14 +8,16 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
"github.com/abhinavxd/artemis/internal/image"
|
||||
mmodels "github.com/abhinavxd/artemis/internal/media/models"
|
||||
notifier "github.com/abhinavxd/artemis/internal/notification"
|
||||
"github.com/abhinavxd/artemis/internal/stringutil"
|
||||
tmpl "github.com/abhinavxd/artemis/internal/template"
|
||||
umodels "github.com/abhinavxd/artemis/internal/user/models"
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/image"
|
||||
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
|
||||
notifier "github.com/abhinavxd/libredesk/internal/notification"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
tmpl "github.com/abhinavxd/libredesk/internal/template"
|
||||
"github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
@@ -23,6 +25,7 @@ const (
|
||||
maxAvatarSizeMB = 5
|
||||
)
|
||||
|
||||
// handleGetUsers returns all users.
|
||||
func handleGetUsers(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -34,6 +37,7 @@ func handleGetUsers(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(agents)
|
||||
}
|
||||
|
||||
// handleGetUsersCompact returns all users in a compact format.
|
||||
func handleGetUsersCompact(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -45,6 +49,7 @@ func handleGetUsersCompact(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(agents)
|
||||
}
|
||||
|
||||
// handleGetUser returns a user.
|
||||
func handleGetUser(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -61,11 +66,34 @@ func handleGetUser(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(user)
|
||||
}
|
||||
|
||||
// handleGetCurrentUserTeams returns the teams of a user.
|
||||
func handleGetCurrentUserTeams(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
teams, err := app.team.GetUserTeams(user.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(teams)
|
||||
}
|
||||
|
||||
// handleUpdateCurrentUser updates the current user.
|
||||
func handleUpdateCurrentUser(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Get current user.
|
||||
currentUser, err := app.user.Get(user.ID)
|
||||
@@ -114,7 +142,12 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
|
||||
|
||||
// Reset ptr.
|
||||
file.Seek(0, 0)
|
||||
media, err := app.media.UploadAndInsert(srcFileName, srcContentType, "", mmodels.ModelUser, user.ID, file, int(srcFileSize), "", []byte("{}"))
|
||||
linkedModel := null.StringFrom(mmodels.ModelUser)
|
||||
linkedID := null.IntFrom(user.ID)
|
||||
disposition := null.NewString("", false)
|
||||
contentID := ""
|
||||
meta := []byte("{}")
|
||||
media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
|
||||
if err != nil {
|
||||
app.lo.Error("error uploading file", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error uploading file", nil, envelope.GeneralError)
|
||||
@@ -136,26 +169,33 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("User updated successfully.")
|
||||
}
|
||||
|
||||
// handleCreateUser creates a new user.
|
||||
func handleCreateUser(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = umodels.User{}
|
||||
user = models.User{}
|
||||
)
|
||||
if err := r.Decode(&user, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
|
||||
if user.Email == "" {
|
||||
if user.Email.String == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
err := app.user.Create(&user)
|
||||
if err != nil {
|
||||
if user.Roles == nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Please select at least one role", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if user.FirstName == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `first_name`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Right now, only agents can be created.
|
||||
if err := app.user.CreateAgent(&user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
@@ -172,13 +212,13 @@ func handleCreateUser(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Render template and send email.
|
||||
content, err := app.tmpl.Render(tmpl.TmplWelcome, map[string]interface{}{
|
||||
content, err := app.tmpl.RenderTemplate(tmpl.TmplWelcome, map[string]interface{}{
|
||||
"ResetToken": resetToken,
|
||||
"Email": user.Email,
|
||||
})
|
||||
if err != nil {
|
||||
app.lo.Error("error rendering template", "error", err)
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("User created successfully, but error rendering welcome email.")
|
||||
}
|
||||
|
||||
if err := app.notifier.Send(notifier.Message{
|
||||
@@ -188,17 +228,17 @@ func handleCreateUser(r *fastglue.Request) error {
|
||||
Provider: notifier.ProviderEmail,
|
||||
}); err != nil {
|
||||
app.lo.Error("error sending notification message", "error", err)
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("User created successfully, but error sending welcome email.")
|
||||
}
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("User created successfully.")
|
||||
}
|
||||
|
||||
// handleUpdateUser updates a user.
|
||||
func handleUpdateUser(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = umodels.User{}
|
||||
user = models.User{}
|
||||
)
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
@@ -210,9 +250,20 @@ func handleUpdateUser(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
|
||||
if user.Email.String == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if user.Roles == nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Please select at least one role", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if user.FirstName == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `first_name`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Update user.
|
||||
err = app.user.Update(id, user)
|
||||
if err != nil {
|
||||
if err = app.user.Update(id, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
@@ -221,23 +272,22 @@ func handleUpdateUser(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("User updated successfully.")
|
||||
}
|
||||
|
||||
// handleDeleteUser deletes a user.
|
||||
// handleDeleteUser soft deletes a user.
|
||||
func handleDeleteUser(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
app = r.Context.(*App)
|
||||
id, err = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
)
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||
"Invalid user `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Soft delete user.
|
||||
err = app.user.SoftDelete(id)
|
||||
if err != nil {
|
||||
if err = app.user.SoftDelete(id); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
@@ -246,16 +296,16 @@ func handleDeleteUser(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("User deleted successfully.")
|
||||
}
|
||||
|
||||
// handleGetCurrentUser returns the current logged in user.
|
||||
func handleGetCurrentUser(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
u, err := app.user.Get(user.ID)
|
||||
u, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -265,12 +315,12 @@ func handleGetCurrentUser(r *fastglue.Request) error {
|
||||
// handleDeleteAvatar deletes a user avatar.
|
||||
func handleDeleteAvatar(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
|
||||
// Get user
|
||||
user, err := app.user.Get(user.ID)
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -290,19 +340,18 @@ func handleDeleteAvatar(r *fastglue.Request) error {
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("Avatar deleted successfully.")
|
||||
}
|
||||
|
||||
// handleResetPassword generates a reset password token and sends an email to the user.
|
||||
func handleResetPassword(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
p = r.RequestCtx.PostArgs()
|
||||
user, ok = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
email = string(p.Peek("email"))
|
||||
app = r.Context.(*App)
|
||||
p = r.RequestCtx.PostArgs()
|
||||
auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
email = string(p.Peek("email"))
|
||||
)
|
||||
|
||||
if ok && user.ID > 0 {
|
||||
if ok && auser.ID > 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "User is already logged in", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
@@ -321,7 +370,7 @@ func handleResetPassword(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Send email.
|
||||
content, err := app.tmpl.Render(tmpl.TmplResetPassword,
|
||||
content, err := app.tmpl.RenderTemplate(tmpl.TmplResetPassword,
|
||||
map[string]string{
|
||||
"ResetToken": token,
|
||||
})
|
||||
@@ -340,14 +389,14 @@ func handleResetPassword(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error sending notification message", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("Reset password email sent successfully.")
|
||||
}
|
||||
|
||||
// handleSetPassword resets the password with the provided token.
|
||||
func handleSetPassword(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user, ok = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
user, ok = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
p = r.RequestCtx.PostArgs()
|
||||
password = string(p.Peek("password"))
|
||||
token = string(p.Peek("token"))
|
||||
@@ -365,5 +414,5 @@ func handleSetPassword(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("Password reset successfully.")
|
||||
}
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
vmodels "github.com/abhinavxd/libredesk/internal/view/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// handleGetUserViews returns all views for a user.
|
||||
func handleGetUserViews(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
v, err := app.view.GetUsersViews(user.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(v)
|
||||
}
|
||||
|
||||
// handleCreateUserView creates a view for a user.
|
||||
func handleCreateUserView(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
view = vmodels.View{}
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
if err := r.Decode(&view, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if view.Name == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Name`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if string(view.Filters) == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Filter`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := app.view.Create(view.Name, view.Filters, user.ID); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope("View created successfully")
|
||||
}
|
||||
|
||||
// handleGetUserView deletes a view for a user.
|
||||
func handleDeleteUserView(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||
"Invalid view `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if id <= 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `ID`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
view, err := app.view.Get(id)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if view.UserID != user.ID {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Forbidden", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
if err = app.view.Delete(id); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope("View deleted successfully")
|
||||
}
|
||||
|
||||
// handleUpdateUserView updates a view for a user.
|
||||
func handleUpdateUserView(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
view = vmodels.View{}
|
||||
)
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||
"Invalid view `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := r.Decode(&view, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if view.Name == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Name`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if string(view.Filters) == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Filter`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
v, err := app.view.Get(id)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if v.UserID != user.ID {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Forbidden", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
if err = app.view.Update(id, view.Name, view.Filters); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
+13
-10
@@ -3,45 +3,48 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
umodels "github.com/abhinavxd/artemis/internal/user/models"
|
||||
"github.com/abhinavxd/artemis/internal/ws"
|
||||
wsmodels "github.com/abhinavxd/artemis/internal/ws/models"
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
"github.com/abhinavxd/libredesk/internal/ws"
|
||||
wsmodels "github.com/abhinavxd/libredesk/internal/ws/models"
|
||||
"github.com/fasthttp/websocket"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// ErrHandler is a custom error handler.
|
||||
func ErrHandler(ctx *fasthttp.RequestCtx, status int, reason error) {
|
||||
fmt.Printf("error status %d: %s", status, reason)
|
||||
}
|
||||
|
||||
// upgrader is a websocket upgrader.
|
||||
var upgrader = websocket.FastHTTPUpgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
ReadBufferSize: 8192,
|
||||
WriteBufferSize: 8192,
|
||||
CheckOrigin: func(ctx *fasthttp.RequestCtx) bool {
|
||||
return true
|
||||
},
|
||||
Error: ErrHandler,
|
||||
}
|
||||
|
||||
// handleWS handles the websocket connection.
|
||||
func handleWS(r *fastglue.Request, hub *ws.Hub) error {
|
||||
var (
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
err := upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) {
|
||||
c := ws.Client{
|
||||
ID: user.ID,
|
||||
ID: auser.ID,
|
||||
Hub: hub,
|
||||
Conn: conn,
|
||||
Send: make(chan wsmodels.WSMessage, 1000),
|
||||
Send: make(chan wsmodels.WSMessage, 10000),
|
||||
}
|
||||
hub.AddClient(&c)
|
||||
go c.Listen()
|
||||
c.Serve()
|
||||
})
|
||||
if err != nil {
|
||||
app.lo.Error("error upgrading tcp connection", "error", err)
|
||||
app.lo.Error("error upgrading tcp connection", "user_id", auser.ID, "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+19
-11
@@ -12,7 +12,7 @@ write_timeout = "5s"
|
||||
max_body_size = 10000000
|
||||
keepalive_timeout = "10s"
|
||||
|
||||
# File upload.
|
||||
# File upload provider.
|
||||
[upload]
|
||||
provider = "fs"
|
||||
|
||||
@@ -23,14 +23,12 @@ upload_path = '/home/ubuntu/uploads'
|
||||
# S3 provider.
|
||||
[upload.s3]
|
||||
url = ""
|
||||
public_url = ""
|
||||
access_key = ""
|
||||
secret_key = ""
|
||||
region = "ap-south-1"
|
||||
bucket = "bucket"
|
||||
bucket_path = ""
|
||||
bucket_type = "private"
|
||||
expiry = "15m"
|
||||
expiry = "6h"
|
||||
|
||||
# Postgres.
|
||||
[db]
|
||||
@@ -38,11 +36,11 @@ host = "127.0.0.1"
|
||||
port = 5432
|
||||
user = "postgres"
|
||||
password = "postgres"
|
||||
database = "database"
|
||||
database = "libredesk"
|
||||
ssl_mode = "disable"
|
||||
max_open = 10
|
||||
max_idle = 10
|
||||
max_lifetime = "10s"
|
||||
max_open = 30
|
||||
max_idle = 30
|
||||
max_lifetime = "300s"
|
||||
|
||||
# Redis.
|
||||
[redis]
|
||||
@@ -51,14 +49,24 @@ password = ""
|
||||
db = 0
|
||||
|
||||
[message]
|
||||
dispatch_workers = 10
|
||||
dispatch_scan_interval = "50ms"
|
||||
outgoing_queue_workers = 10
|
||||
incoming_queue_workers = 10
|
||||
message_outoing_scan_interval = "50ms"
|
||||
incoming_queue_size = 5000
|
||||
outgoing_queue_size = 5000
|
||||
|
||||
[notification]
|
||||
concurrency = 2
|
||||
queue_size = 100
|
||||
queue_size = 2000
|
||||
|
||||
[automation]
|
||||
worker_count = 10
|
||||
|
||||
[autoassigner]
|
||||
autoassign_interval = "5m"
|
||||
|
||||
[conversation]
|
||||
unsnooze_interval = "5m"
|
||||
|
||||
[sla]
|
||||
evaluation_interval = "5m"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# frontend-tailwind
|
||||
# Libredesk frontend
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
|
||||
rel="stylesheet">
|
||||
</head>
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "frontend-tailwind",
|
||||
"name": "libredesk",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bunx --bun vite",
|
||||
"dev": "pnpm exec vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
|
||||
@@ -22,7 +22,6 @@
|
||||
"@tanstack/vue-table": "^8.19.2",
|
||||
"@tiptap/extension-image": "^2.5.9",
|
||||
"@tiptap/extension-link": "^2.9.1",
|
||||
"@tiptap/extension-list-item": "^2.4.0",
|
||||
"@tiptap/extension-ordered-list": "^2.4.0",
|
||||
"@tiptap/extension-placeholder": "^2.4.0",
|
||||
"@tiptap/pm": "^2.4.0",
|
||||
@@ -35,8 +34,9 @@
|
||||
"@vue/reactivity": "^3.4.15",
|
||||
"@vue/runtime-core": "^3.4.15",
|
||||
"@vueup/vue-quill": "^1.2.0",
|
||||
"@vueuse/core": "^11.2.0",
|
||||
"@vueuse/core": "^12.4.0",
|
||||
"add": "^2.0.6",
|
||||
"axios": "^1.7.9",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"codeflask": "^1.4.1",
|
||||
@@ -59,10 +59,12 @@
|
||||
"vue-letter": "^0.2.0",
|
||||
"vue-picture-cropper": "^0.7.0",
|
||||
"vue-router": "^4.2.5",
|
||||
"vue-sonner": "^1.3.0",
|
||||
"vue3-emoji-picker": "^1.1.8",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/vue": "^4.1.2",
|
||||
"@rushstack/eslint-patch": "^1.3.3",
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
@@ -78,5 +80,5 @@
|
||||
"tailwindcss": "latest",
|
||||
"vite": "^5.4.9"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
|
||||
}
|
||||
|
||||
Generated
+8250
File diff suppressed because it is too large
Load Diff
+166
-74
@@ -1,98 +1,190 @@
|
||||
<template>
|
||||
<div class="flex">
|
||||
<NavBar :is-collapsed="isCollapsed" :links="navLinks" :bottom-links="bottomLinks"
|
||||
class="shadow shadow-gray-300 h-screen" />
|
||||
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
|
||||
<ResizableHandle id="resize-handle-1" />
|
||||
<ResizablePanel id="resize-panel-2">
|
||||
<div class="w-full h-screen">
|
||||
<RouterView />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<div class="flex w-full h-screen">
|
||||
<!-- Icon sidebar always visible -->
|
||||
<SidebarProvider style="--sidebar-width: 3rem" class="w-auto z-50">
|
||||
<ShadcnSidebar collapsible="none" class="border-r">
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild :isActive="route.path.startsWith('/inboxes')">
|
||||
<router-link :to="{ name: 'inboxes' }">
|
||||
<Inbox />
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem v-if="userStore.hasAdminTabPermissions">
|
||||
<SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')">
|
||||
<router-link :to="{ name: 'admin' }">
|
||||
<Shield />
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem v-if="userStore.hasReportTabPermissions">
|
||||
<SidebarMenuButton asChild :isActive="route.path.startsWith('/reports')">
|
||||
<router-link :to="{ name: 'reports' }">
|
||||
<FileLineChart />
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarNavUser />
|
||||
</SidebarFooter>
|
||||
</ShadcnSidebar>
|
||||
</SidebarProvider>
|
||||
|
||||
<!-- Main sidebar that collapses -->
|
||||
<div class="flex-1">
|
||||
<Sidebar
|
||||
:userTeams="userStore.teams"
|
||||
:userViews="userViews"
|
||||
@create-view="openCreateViewForm = true"
|
||||
@edit-view="editView"
|
||||
@delete-view="deleteView"
|
||||
>
|
||||
<PageHeader />
|
||||
<RouterView />
|
||||
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Command box -->
|
||||
<Command />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||
import { RouterView, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { initWS } from '@/websocket.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
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 PageHeader from './components/layout/PageHeader.vue'
|
||||
import ViewForm from '@/features/view/ViewForm.vue'
|
||||
import api from '@/api'
|
||||
import { toast as sooner } from 'vue-sonner'
|
||||
import Sidebar from '@/components/sidebar/Sidebar.vue'
|
||||
import Command from '@/features/command/CommandBox.vue'
|
||||
import { Inbox, Shield, FileLineChart } from 'lucide-vue-next'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
Sidebar as ShadcnSidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarMenu,
|
||||
SidebarGroupContent,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider
|
||||
} from '@/components/ui/sidebar'
|
||||
import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { toast } = useToast()
|
||||
const route = useRoute()
|
||||
const emitter = useEmitter()
|
||||
const isCollapsed = ref(true)
|
||||
const allNavLinks = [
|
||||
{
|
||||
title: t('navbar.dashboard'),
|
||||
to: '/dashboard',
|
||||
label: '',
|
||||
icon: 'lucide:layout-dashboard',
|
||||
permission: 'dashboard_global:read',
|
||||
},
|
||||
{
|
||||
title: t('navbar.conversations'),
|
||||
to: '/conversations',
|
||||
label: '',
|
||||
icon: 'lucide:message-circle-more'
|
||||
},
|
||||
{
|
||||
title: t('navbar.account'),
|
||||
to: '/account/profile',
|
||||
label: '',
|
||||
icon: 'lucide:circle-user-round'
|
||||
},
|
||||
{
|
||||
title: t('navbar.admin'),
|
||||
to: '/admin/general',
|
||||
label: '',
|
||||
icon: 'lucide:settings',
|
||||
permission: 'admin:read'
|
||||
}
|
||||
]
|
||||
|
||||
const bottomLinks = [
|
||||
{
|
||||
to: '/logout',
|
||||
icon: 'lucide:log-out',
|
||||
title: 'Logout'
|
||||
}
|
||||
]
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
initWS()
|
||||
const conversationStore = useConversationStore()
|
||||
const usersStore = useUsersStore()
|
||||
const teamStore = useTeamStore()
|
||||
const inboxStore = useInboxStore()
|
||||
const slaStore = useSlaStore()
|
||||
const macroStore = useMacroStore()
|
||||
const tagStore = useTagStore()
|
||||
const userViews = ref([])
|
||||
const view = ref({})
|
||||
const openCreateViewForm = ref(false)
|
||||
|
||||
initWS()
|
||||
onMounted(() => {
|
||||
initToaster()
|
||||
getCurrentUser()
|
||||
listenViewRefresh()
|
||||
initStores()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(EMITTER_EVENTS.SHOW_TOAST, toast)
|
||||
})
|
||||
// initialize data stores
|
||||
const initStores = async () => {
|
||||
await Promise.allSettled([
|
||||
userStore.getCurrentUser(),
|
||||
getUserViews(),
|
||||
conversationStore.fetchStatuses(),
|
||||
conversationStore.fetchPriorities(),
|
||||
usersStore.fetchUsers(),
|
||||
teamStore.fetchTeams(),
|
||||
inboxStore.fetchInboxes(),
|
||||
slaStore.fetchSlas(),
|
||||
macroStore.loadMacros(),
|
||||
tagStore.fetchTags()
|
||||
])
|
||||
}
|
||||
|
||||
const getCurrentUser = () => {
|
||||
userStore.getCurrentUser().catch((err) => {
|
||||
if (err.response && err.response.status === 401) {
|
||||
router.push('/')
|
||||
const editView = (v) => {
|
||||
view.value = { ...v }
|
||||
openCreateViewForm.value = true
|
||||
}
|
||||
|
||||
const deleteView = async (view) => {
|
||||
try {
|
||||
await api.deleteView(view.id)
|
||||
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Success',
|
||||
description: 'View deleted successfully'
|
||||
})
|
||||
} catch (err) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(err).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getUserViews = async () => {
|
||||
try {
|
||||
const response = await api.getCurrentUserViews()
|
||||
userViews.value = response.data.data
|
||||
} catch (err) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(err).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const initToaster = () => {
|
||||
emitter.on(EMITTER_EVENTS.SHOW_TOAST, (message) => {
|
||||
if (message.variant === 'destructive') {
|
||||
sooner.error(message.description)
|
||||
} else {
|
||||
sooner.success(message.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const initToaster = () => {
|
||||
emitter.on(EMITTER_EVENTS.SHOW_TOAST, toast)
|
||||
const listenViewRefresh = () => {
|
||||
emitter.on(EMITTER_EVENTS.REFRESH_LIST, refreshViews)
|
||||
}
|
||||
|
||||
const navLinks = computed(() =>
|
||||
allNavLinks.filter((link) =>
|
||||
!link.permission || (userStore.permissions.includes(link.permission) && link.permission)
|
||||
)
|
||||
)
|
||||
const refreshViews = (data) => {
|
||||
openCreateViewForm.value = false
|
||||
// TODO: move model to constants.
|
||||
if (data?.model === 'view') {
|
||||
getUserViews()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<Toaster />
|
||||
<TooltipProvider :delay-duration="250">
|
||||
<div class="font-inter">
|
||||
<RouterView />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider :delay-duration="150">
|
||||
<div class="!font-jakarta">
|
||||
<Toaster class="pointer-events-auto" position="top-center" richColors />
|
||||
<RouterView />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RouterView } from 'vue-router'
|
||||
import { Toaster } from '@/components/ui/toast'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
</script>
|
||||
</script>
|
||||
|
||||
+182
-97
@@ -33,177 +33,236 @@ http.interceptors.request.use((request) => {
|
||||
return request
|
||||
})
|
||||
|
||||
const resetPassword = (data) => http.post('/api/users/reset-password', data)
|
||||
const setPassword = (data) => http.post('/api/users/set-password', data)
|
||||
const deleteUser = (id) => http.delete(`/api/users/${id}`)
|
||||
const getEmailNotificationSettings = () => http.get('/api/settings/notifications/email')
|
||||
const updateEmailNotificationSettings = (data) => http.put('/api/settings/notifications/email', data)
|
||||
const getPriorities = () => http.get('/api/priorities')
|
||||
const getStatuses = () => http.get('/api/statuses')
|
||||
const createStatus = (data) => http.post('/api/statuses', data)
|
||||
const updateStatus = (id, data) => http.put(`/api/statuses/${id}`, data)
|
||||
const deleteStatus = (id) => http.delete(`/api/statuses/${id}`)
|
||||
const createTag = (data) => http.post('/api/tags', data)
|
||||
const updateTag = (id, data) => http.put(`/api/tags/${id}`, data)
|
||||
const deleteTag = (id) => http.delete(`/api/tags/${id}`)
|
||||
const getTemplate = (id) => http.get(`/api/templates/${id}`)
|
||||
const getTemplates = () => http.get('/api/templates')
|
||||
const searchConversations = (params) => http.get('/api/v1/conversations/search', { params })
|
||||
const searchMessages = (params) => http.get('/api/v1/messages/search', { params })
|
||||
const resetPassword = (data) => http.post('/api/v1/users/reset-password', data)
|
||||
const setPassword = (data) => http.post('/api/v1/users/set-password', data)
|
||||
const deleteUser = (id) => http.delete(`/api/v1/users/${id}`)
|
||||
const getEmailNotificationSettings = () => http.get('/api/v1/settings/notifications/email')
|
||||
const updateEmailNotificationSettings = (data) => http.put('/api/v1/settings/notifications/email', data)
|
||||
const getPriorities = () => http.get('/api/v1/priorities')
|
||||
const getStatuses = () => http.get('/api/v1/statuses')
|
||||
const createStatus = (data) => http.post('/api/v1/statuses', data)
|
||||
const updateStatus = (id, data) => http.put(`/api/v1/statuses/${id}`, data)
|
||||
const deleteStatus = (id) => http.delete(`/api/v1/statuses/${id}`)
|
||||
const createTag = (data) => http.post('/api/v1/tags', data)
|
||||
const updateTag = (id, data) => http.put(`/api/v1/tags/${id}`, data)
|
||||
const deleteTag = (id) => http.delete(`/api/v1/tags/${id}`)
|
||||
const getTemplate = (id) => http.get(`/api/v1/templates/${id}`)
|
||||
const getTemplates = (type) => http.get('/api/v1/templates', { params: { type: type } })
|
||||
const createTemplate = (data) =>
|
||||
http.post('/api/templates', data, {
|
||||
http.post('/api/v1/templates', data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const deleteTemplate = (id) => http.delete(`/api/templates/${id}`)
|
||||
const deleteTemplate = (id) => http.delete(`/api/v1/templates/${id}`)
|
||||
const updateTemplate = (id, data) =>
|
||||
http.put(`/api/templates/${id}`, data, {
|
||||
http.put(`/api/v1/templates/${id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
const getAllBusinessHours = () => http.get('/api/v1/business-hours')
|
||||
const getBusinessHours = (id) => http.get(`/api/v1/business-hours/${id}`)
|
||||
const createBusinessHours = (data) => http.post('/api/v1/business-hours', data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const updateBusinessHours = (id, data) =>
|
||||
http.put(`/api/v1/business-hours/${id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const deleteBusinessHours = (id) => http.delete(`/api/v1/business-hours/${id}`)
|
||||
|
||||
const getAllSLAs = () => http.get('/api/v1/sla')
|
||||
const getSLA = (id) => http.get(`/api/v1/sla/${id}`)
|
||||
const createSLA = (data) => http.post('/api/v1/sla', data)
|
||||
const updateSLA = (id, data) => http.put(`/api/v1/sla/${id}`, data)
|
||||
const deleteSLA = (id) => http.delete(`/api/v1/sla/${id}`)
|
||||
const createOIDC = (data) =>
|
||||
http.post('/api/oidc', data, {
|
||||
http.post('/api/v1/oidc', data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const getAllOIDC = () => http.get('/api/oidc')
|
||||
const getOIDC = (id) => http.get(`/api/oidc/${id}`)
|
||||
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled')
|
||||
const getAllOIDC = () => http.get('/api/v1/oidc')
|
||||
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
|
||||
const updateOIDC = (id, data) =>
|
||||
http.put(`/api/oidc/${id}`, data, {
|
||||
http.put(`/api/v1/oidc/${id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const deleteOIDC = (id) => http.delete(`/api/oidc/${id}`)
|
||||
const deleteOIDC = (id) => http.delete(`/api/v1/oidc/${id}`)
|
||||
const updateSettings = (key, data) =>
|
||||
http.put(`/api/settings/${key}`, data, {
|
||||
http.put(`/api/v1/settings/${key}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const getSettings = (key) => http.get(`/api/settings/${key}`)
|
||||
const login = (data) => http.post(`/api/login`, data)
|
||||
const getSettings = (key) => http.get(`/api/v1/settings/${key}`)
|
||||
const login = (data) => http.post(`/api/v1/login`, data)
|
||||
const getAutomationRules = (type) =>
|
||||
http.get(`/api/automation/rules`, {
|
||||
http.get(`/api/v1/automation/rules`, {
|
||||
params: { type: type }
|
||||
})
|
||||
const toggleAutomationRule = (id) => http.put(`/api/automation/rules/${id}/toggle`)
|
||||
const getAutomationRule = (id) => http.get(`/api/automation/rules/${id}`)
|
||||
const toggleAutomationRule = (id) => http.put(`/api/v1/automation/rules/${id}/toggle`)
|
||||
const getAutomationRule = (id) => http.get(`/api/v1/automation/rules/${id}`)
|
||||
const updateAutomationRule = (id, data) =>
|
||||
http.put(`/api/automation/rules/${id}`, data, {
|
||||
http.put(`/api/v1/automation/rules/${id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const createAutomationRule = (data) =>
|
||||
http.post(`/api/automation/rules`, data, {
|
||||
http.post(`/api/v1/automation/rules`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const getRoles = () => http.get('/api/roles')
|
||||
const getRole = (id) => http.get(`/api/roles/${id}`)
|
||||
const deleteAutomationRule = (id) => http.delete(`/api/v1/automation/rules/${id}`)
|
||||
const updateAutomationRuleWeights = (data) =>
|
||||
http.put(`/api/v1/automation/rules/weights`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const updateAutomationRulesExecutionMode = (data) => http.put(`/api/v1/automation/rules/execution-mode`, data)
|
||||
const getRoles = () => http.get('/api/v1/roles')
|
||||
const getRole = (id) => http.get(`/api/v1/roles/${id}`)
|
||||
const createRole = (data) =>
|
||||
http.post('/api/roles', data, {
|
||||
http.post('/api/v1/roles', data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const updateRole = (id, data) =>
|
||||
http.put(`/api/roles/${id}`, data, {
|
||||
http.put(`/api/v1/roles/${id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const deleteRole = (id) => http.delete(`/api/roles/${id}`)
|
||||
const deleteAutomationRule = (id) => http.delete(`/api/automation/rules/${id}`)
|
||||
const getUser = (id) => http.get(`/api/users/${id}`)
|
||||
const getTeam = (id) => http.get(`/api/teams/${id}`)
|
||||
const getTeams = () => http.get('/api/teams')
|
||||
const getTeamsCompact = () => http.get('/api/teams/compact')
|
||||
const getUsers = () => http.get('/api/users')
|
||||
const getUsersCompact = () => http.get('/api/users/compact')
|
||||
const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`)
|
||||
const getUser = (id) => http.get(`/api/v1/users/${id}`)
|
||||
const getTeam = (id) => http.get(`/api/v1/teams/${id}`)
|
||||
const getTeams = () => http.get('/api/v1/teams')
|
||||
const updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data)
|
||||
const createTeam = (data) => http.post('/api/v1/teams', data)
|
||||
const getTeamsCompact = () => http.get('/api/v1/teams/compact')
|
||||
const deleteTeam = (id) => http.delete(`/api/v1/teams/${id}`)
|
||||
|
||||
const getUsers = () => http.get('/api/v1/users')
|
||||
const getUsersCompact = () => http.get('/api/v1/users/compact')
|
||||
const updateCurrentUser = (data) =>
|
||||
http.put('/api/users/me', data, {
|
||||
http.put('/api/v1/users/me', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
const deleteUserAvatar = () => http.delete('/api/users/me/avatar')
|
||||
const getCurrentUser = () => http.get('/api/users/me')
|
||||
const getTags = () => http.get('/api/tags')
|
||||
const upsertTags = (uuid, data) => http.post(`/api/conversations/${uuid}/tags`, data)
|
||||
const updateAssignee = (uuid, assignee_type, data) =>
|
||||
http.put(`/api/conversations/${uuid}/assignee/${assignee_type}`, data)
|
||||
const updateConversationStatus = (uuid, data) => http.put(`/api/conversations/${uuid}/status`, data)
|
||||
const updateConversationPriority = (uuid, data) => http.put(`/api/conversations/${uuid}/priority`, data)
|
||||
const updateAssigneeLastSeen = (uuid) => http.put(`/api/conversations/${uuid}/last-seen`)
|
||||
const getConversationMessage = (cuuid, uuid) => http.get(`/api/conversations/${cuuid}/messages/${uuid}`)
|
||||
const retryMessage = (cuuid, uuid) => http.put(`/api/conversations/${cuuid}/messages/${uuid}/retry`)
|
||||
const getConversationMessages = (uuid, page) =>
|
||||
http.get(`/api/conversations/${uuid}/messages`, {
|
||||
params: { page: page }
|
||||
})
|
||||
const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar')
|
||||
const getCurrentUser = () => http.get('/api/v1/users/me')
|
||||
const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams')
|
||||
const getTags = () => http.get('/api/v1/tags')
|
||||
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
|
||||
const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
|
||||
const removeAssignee = (uuid, assignee_type) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`)
|
||||
const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
|
||||
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
|
||||
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
|
||||
const getConversationMessage = (cuuid, uuid) => http.get(`/api/v1/conversations/${cuuid}/messages/${uuid}`)
|
||||
const retryMessage = (cuuid, uuid) => http.put(`/api/v1/conversations/${cuuid}/messages/${uuid}/retry`)
|
||||
const getConversationMessages = (uuid, params) => http.get(`/api/v1/conversations/${uuid}/messages`, { params })
|
||||
const sendMessage = (uuid, data) =>
|
||||
http.post(`/api/conversations/${uuid}/messages`, data, {
|
||||
http.post(`/api/v1/conversations/${uuid}/messages`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const getConversation = (uuid) => http.get(`/api/conversations/${uuid}`)
|
||||
const getConversationParticipants = (uuid) => http.get(`/api/conversations/${uuid}/participants`)
|
||||
const getCannedResponses = () => http.get('/api/canned-responses')
|
||||
const createCannedResponse = (data) => http.post('/api/canned-responses', data)
|
||||
const updateCannedResponse = (id, data) => http.put(`/api/canned-responses/${id}`, data)
|
||||
const deleteCannedResponse = (id) => http.delete(`/api/canned-responses/${id}`)
|
||||
const getAssignedConversations = (params) =>
|
||||
http.get('/api/conversations/assigned', { params })
|
||||
const getUnassignedConversations = (params) =>
|
||||
http.get('/api/conversations/unassigned', { params })
|
||||
const getAllConversations = (params) =>
|
||||
http.get('/api/conversations/all', { params })
|
||||
const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`)
|
||||
const getConversationParticipants = (uuid) => http.get(`/api/v1/conversations/${uuid}/participants`)
|
||||
const getAllMacros = () => http.get('/api/v1/macros')
|
||||
const getMacro = (id) => http.get(`/api/v1/macros/${id}`)
|
||||
const createMacro = (data) => http.post('/api/v1/macros', data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const updateMacro = (id, data) => http.put(`/api/v1/macros/${id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const deleteMacro = (id) => http.delete(`/api/v1/macros/${id}`)
|
||||
const applyMacro = (uuid, id, data) => http.post(`/api/v1/conversations/${uuid}/macros/${id}/apply`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const getTeamUnassignedConversations = (teamID, params) =>
|
||||
http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params })
|
||||
const getAssignedConversations = (params) => http.get('/api/v1/conversations/assigned', { params })
|
||||
const getUnassignedConversations = (params) => http.get('/api/v1/conversations/unassigned', { params })
|
||||
const getAllConversations = (params) => http.get('/api/v1/conversations/all', { params })
|
||||
const getViewConversations = (id, params) => http.get(`/api/v1/views/${id}/conversations`, { params })
|
||||
const uploadMedia = (data) =>
|
||||
http.post('/api/media', data, {
|
||||
http.post('/api/v1/media', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
const getGlobalDashboardCounts = () => http.get('/api/dashboard/global/counts')
|
||||
const getGlobalDashboardCharts = () => http.get('/api/dashboard/global/charts')
|
||||
const getUserDashboardCounts = () => http.get(`/api/dashboard/me/counts`)
|
||||
const getUserDashboardCharts = () => http.get(`/api/dashboard/me/charts`)
|
||||
const getLanguage = (lang) => http.get(`/api/lang/${lang}`)
|
||||
const getOverviewCounts = () => http.get('/api/v1/reports/overview/counts')
|
||||
const getOverviewCharts = () => http.get('/api/v1/reports/overview/charts')
|
||||
const getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`)
|
||||
const createUser = (data) =>
|
||||
http.post('/api/users', data, {
|
||||
http.post('/api/v1/users', data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const updateUser = (id, data) =>
|
||||
http.put(`/api/users/${id}`, data, {
|
||||
http.put(`/api/v1/users/${id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const updateTeam = (id, data) => http.put(`/api/teams/${id}`, data)
|
||||
const createTeam = (data) => http.post('/api/teams', data)
|
||||
const createInbox = (data) =>
|
||||
http.post('/api/inboxes', data, {
|
||||
http.post('/api/v1/inboxes', data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const getInboxes = () => http.get('/api/inboxes')
|
||||
const getInbox = (id) => http.get(`/api/inboxes/${id}`)
|
||||
const toggleInbox = (id) => http.put(`/api/inboxes/${id}/toggle`)
|
||||
const getInboxes = () => http.get('/api/v1/inboxes')
|
||||
const getInbox = (id) => http.get(`/api/v1/inboxes/${id}`)
|
||||
const toggleInbox = (id) => http.put(`/api/v1/inboxes/${id}/toggle`)
|
||||
const updateInbox = (id, data) =>
|
||||
http.put(`/api/inboxes/${id}`, data, {
|
||||
http.put(`/api/v1/inboxes/${id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const deleteInbox = (id) => http.delete(`/api/inboxes/${id}`)
|
||||
const deleteInbox = (id) => http.delete(`/api/v1/inboxes/${id}`)
|
||||
const getCurrentUserViews = () => http.get('/api/v1/views/me')
|
||||
const createView = (data) =>
|
||||
http.post('/api/v1/views/me', data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const updateView = (id, data) =>
|
||||
http.put(`/api/v1/views/me/${id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
|
||||
const getAiPrompts = () => http.get('/api/v1/ai/prompts')
|
||||
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
|
||||
|
||||
export default {
|
||||
login,
|
||||
@@ -219,6 +278,7 @@ export default {
|
||||
deleteRole,
|
||||
updateRole,
|
||||
getTeams,
|
||||
deleteTeam,
|
||||
getUsers,
|
||||
getInbox,
|
||||
getInboxes,
|
||||
@@ -226,30 +286,45 @@ export default {
|
||||
getConversation,
|
||||
getAutomationRule,
|
||||
getAutomationRules,
|
||||
getAllBusinessHours,
|
||||
getBusinessHours,
|
||||
createBusinessHours,
|
||||
updateBusinessHours,
|
||||
deleteBusinessHours,
|
||||
getAllSLAs,
|
||||
getSLA,
|
||||
createSLA,
|
||||
updateSLA,
|
||||
deleteSLA,
|
||||
getAssignedConversations,
|
||||
getUnassignedConversations,
|
||||
getAllConversations,
|
||||
getGlobalDashboardCharts,
|
||||
getGlobalDashboardCounts,
|
||||
getUserDashboardCounts,
|
||||
getUserDashboardCharts,
|
||||
getTeamUnassignedConversations,
|
||||
getViewConversations,
|
||||
getOverviewCharts,
|
||||
getOverviewCounts,
|
||||
getConversationParticipants,
|
||||
getConversationMessage,
|
||||
getConversationMessages,
|
||||
getCurrentUser,
|
||||
getCannedResponses,
|
||||
createCannedResponse,
|
||||
updateCannedResponse,
|
||||
deleteCannedResponse,
|
||||
getCurrentUserTeams,
|
||||
getAllMacros,
|
||||
getMacro,
|
||||
createMacro,
|
||||
updateMacro,
|
||||
deleteMacro,
|
||||
applyMacro,
|
||||
updateCurrentUser,
|
||||
updateAssignee,
|
||||
updateConversationStatus,
|
||||
updateConversationPriority,
|
||||
upsertTags,
|
||||
uploadMedia,
|
||||
updateAutomationRule,
|
||||
updateAssigneeLastSeen,
|
||||
updateUser,
|
||||
updateAutomationRule,
|
||||
updateAutomationRuleWeights,
|
||||
updateAutomationRulesExecutionMode,
|
||||
createAutomationRule,
|
||||
toggleAutomationRule,
|
||||
deleteAutomationRule,
|
||||
@@ -266,6 +341,7 @@ export default {
|
||||
updateSettings,
|
||||
createOIDC,
|
||||
getAllOIDC,
|
||||
getAllEnabledOIDC,
|
||||
getOIDC,
|
||||
updateOIDC,
|
||||
deleteOIDC,
|
||||
@@ -287,4 +363,13 @@ export default {
|
||||
getUsersCompact,
|
||||
getEmailNotificationSettings,
|
||||
updateEmailNotificationSettings,
|
||||
getCurrentUserViews,
|
||||
createView,
|
||||
updateView,
|
||||
deleteView,
|
||||
getAiPrompts,
|
||||
aiCompletion,
|
||||
searchConversations,
|
||||
searchMessages,
|
||||
removeAssignee,
|
||||
}
|
||||
|
||||
@@ -2,89 +2,96 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
// App default font-size.
|
||||
// Default: 16px, 15px looks wide.
|
||||
:root {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 1rem 1rem;
|
||||
height: 100%;
|
||||
height: 100vh;
|
||||
overflow-y: scroll;
|
||||
padding-bottom: 100px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Theme.
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
|
||||
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
|
||||
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
|
||||
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
|
||||
--border:240 5.9% 90%;
|
||||
--input:240 5.9% 90%;
|
||||
--ring:240 5.9% 10%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--background:240 10% 3.9%;
|
||||
--foreground:0 0% 98%;
|
||||
|
||||
--card:240 10% 3.9%;
|
||||
--card-foreground:0 0% 98%;
|
||||
|
||||
--popover:240 10% 3.9%;
|
||||
--popover-foreground:0 0% 98%;
|
||||
|
||||
--primary:0 0% 98%;
|
||||
--primary-foreground:240 5.9% 10%;
|
||||
|
||||
--secondary:240 3.7% 15.9%;
|
||||
--secondary-foreground:0 0% 98%;
|
||||
|
||||
--muted:240 3.7% 15.9%;
|
||||
--muted-foreground:240 5% 64.9%;
|
||||
|
||||
--accent:240 3.7% 15.9%;
|
||||
--accent-foreground:0 0% 98%;
|
||||
|
||||
--destructive:0 62.8% 30.6%;
|
||||
--destructive-foreground:0 0% 98%;
|
||||
|
||||
--border:240 3.7% 15.9%;
|
||||
--input:240 3.7% 15.9%;
|
||||
--ring:240 4.9% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--vis-tooltip-background-color: none !important;
|
||||
@@ -148,11 +155,12 @@ body {
|
||||
pb-3
|
||||
min-w-[30%] max-w-[70%]
|
||||
border
|
||||
overflow-x-auto
|
||||
rounded-xl;
|
||||
box-shadow: 1px 1px 1px 0px rgba(0, 0, 0, 0.1);
|
||||
// To make email tables fit.
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
table {
|
||||
width: 100% !important;
|
||||
table-layout: fixed !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,46 +173,42 @@ body {
|
||||
}
|
||||
|
||||
.box {
|
||||
@apply border shadow;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
@apply rounded-lg box w-full;
|
||||
}
|
||||
|
||||
.admin-main-content {
|
||||
@apply p-0;
|
||||
@apply border shadow rounded-lg;
|
||||
}
|
||||
|
||||
// Scrollbar start
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
width: 8px; /* Adjust width */
|
||||
height: 8px; /* Adjust height */
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #888;
|
||||
border-radius: 4px;
|
||||
border-radius: 3px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #555;
|
||||
background-color: #555; /* Hover effect */
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f0f0f0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
// End Scrollbar
|
||||
|
||||
.code-editor {
|
||||
@apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative;
|
||||
}
|
||||
|
||||
.ql-container {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.ql-container .ql-editor {
|
||||
margin-top: 0 !important;
|
||||
height: 200px !important;
|
||||
height: 300px !important;
|
||||
border-radius: var(--radius) !important;
|
||||
@apply rounded-lg rounded-t-none;
|
||||
}
|
||||
@@ -212,3 +216,107 @@ body {
|
||||
.ql-toolbar {
|
||||
@apply rounded-t-lg;
|
||||
}
|
||||
|
||||
.blinking-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: red;
|
||||
border-radius: 50%;
|
||||
animation: blink 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar start
|
||||
@layer base {
|
||||
:root {
|
||||
--sidebar-background: 0 0% 97%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
a[data-active='true'] {
|
||||
background-color: hsl(var(--sidebar-background)) !important;
|
||||
color: hsl(var(--sidebar-accent-foreground)) !important;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
color 0.2s;
|
||||
}
|
||||
a[data-active='false']:hover {
|
||||
background-color: hsl(var(--sidebar-accent)) !important;
|
||||
color: hsl(var(--sidebar-accent-foreground)) !important;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
color 0.2s;
|
||||
}
|
||||
// Sidebar end
|
||||
|
||||
.show-quoted-text {
|
||||
blockquote {
|
||||
@apply block;
|
||||
}
|
||||
}
|
||||
|
||||
.hide-quoted-text {
|
||||
blockquote {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.dot-loader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background-color: currentColor;
|
||||
margin: 0 2px;
|
||||
animation: dot-flashing 1s infinite linear alternate;
|
||||
}
|
||||
|
||||
.dot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.dot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes dot-flashing {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
<script setup>
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
defineProps({
|
||||
isCollapsed: Boolean,
|
||||
links: Array,
|
||||
bottomLinks: Array
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const getFirstSegment = (path) => {
|
||||
return path.split('/')[1]
|
||||
}
|
||||
|
||||
const getButtonVariant = (to) => {
|
||||
const currentSegment = getFirstSegment(route.path.toLowerCase())
|
||||
const targetSegment = getFirstSegment(to.toLowerCase())
|
||||
return currentSegment === targetSegment ? '' : 'ghost'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :data-collapsed="isCollapsed" class="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2 h-full">
|
||||
<nav class="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
|
||||
<template v-for="(link, index) of links">
|
||||
<!-- Collapsed -->
|
||||
<router-link :to="link.to" v-if="isCollapsed" :key="`1-${index}`">
|
||||
<TooltipProvider :delay-duration="10">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span :class="cn(
|
||||
buttonVariants({ variant: getButtonVariant(link.to), size: 'icon' }),
|
||||
'h-9 w-9',
|
||||
link.variant === getButtonVariant(link.to) &&
|
||||
'dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white'
|
||||
)
|
||||
">
|
||||
<Icon :icon="link.icon" class="size-5" />
|
||||
<span class="sr-only">{{ link.title }}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" class="flex items-center gap-4">
|
||||
{{ link.title }}
|
||||
<span v-if="link.label" class="ml-auto text-muted-foreground">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</router-link>
|
||||
|
||||
<!-- Expanded -->
|
||||
<router-link v-else :to="link.to" :key="`2-${index}`" :class="cn(
|
||||
buttonVariants({ variant: getButtonVariant(link.to), size: 'sm' }),
|
||||
link.variant === getButtonVariant(link.to) &&
|
||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
||||
'justify-start'
|
||||
)
|
||||
">
|
||||
<Icon :icon="link.icon" class="mr-2 size-5" />
|
||||
{{ link.title }}
|
||||
<span v-if="link.label" :class="cn(
|
||||
'ml-',
|
||||
link.variant === getButtonVariant(link.to) && 'text-background dark:text-white'
|
||||
)
|
||||
">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</router-link>
|
||||
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Bottom Links -->
|
||||
<div class="mt-auto px-2">
|
||||
<template v-for="(bottomLink, index) in bottomLinks" :key="`bottom-${index}`">
|
||||
<TooltipProvider :delay-duration="10">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<a :href="bottomLink.to" :class="cn(
|
||||
buttonVariants({
|
||||
variant: getButtonVariant(bottomLink.to),
|
||||
size: isCollapsed ? 'icon' : 'sm'
|
||||
}),
|
||||
bottomLink.variant === getButtonVariant(bottomLink.to) &&
|
||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
||||
'justify-start'
|
||||
)
|
||||
">
|
||||
<Icon :icon="bottomLink.icon" class="mr-2 size-5" v-if="!isCollapsed" />
|
||||
<span v-if="!isCollapsed">{{ bottomLink.title }}</span>
|
||||
<Icon :icon="bottomLink.icon" class="size-5 mx-auto" v-else />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" class="flex items-center gap-4">
|
||||
{{ bottomLink.title }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,28 +0,0 @@
|
||||
<script setup>
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import SidebarNav from '@/components/common/SidebarNav.vue'
|
||||
|
||||
const sidebarNavItems = [
|
||||
{
|
||||
title: 'Profile',
|
||||
href: '/account/profile',
|
||||
description: 'Update your profile'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4 md:block page-content">
|
||||
<PageHeader title="Account settings" subTitle="Manage your account settings." />
|
||||
<div class="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
|
||||
<aside class="lg:w-1/6 md:w-1/7 h-[calc(100vh-10rem)] border-r pr-3">
|
||||
<SidebarNav :navItems="sidebarNavItems" />
|
||||
</aside>
|
||||
<div class="flex-1 lg:max-w-3xl admin-main-content min-h-[700px]">
|
||||
<div class="space-y-6">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,79 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import SidebarNav from '@/components/common/SidebarNav.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const allNavItems = [
|
||||
{
|
||||
title: 'General',
|
||||
href: '/admin/general',
|
||||
description: 'Configure general app settings',
|
||||
permission: null,
|
||||
},
|
||||
{
|
||||
title: 'Conversations',
|
||||
href: '/admin/conversations',
|
||||
description: 'Manage tags, canned responses and statuses.',
|
||||
permission: null
|
||||
},
|
||||
{
|
||||
title: 'Inboxes',
|
||||
href: '/admin/inboxes',
|
||||
description: 'Manage your inboxes',
|
||||
permission: null,
|
||||
},
|
||||
{
|
||||
title: 'Teams',
|
||||
href: '/admin/teams',
|
||||
description: 'Manage teams, manage agents and roles',
|
||||
permission: null,
|
||||
},
|
||||
{
|
||||
title: 'Automations',
|
||||
href: '/admin/automations',
|
||||
description: 'Manage automations and time triggers',
|
||||
permission: null,
|
||||
},
|
||||
{
|
||||
title: 'Notification',
|
||||
href: '/admin/notification',
|
||||
description: 'Manage email notification settings',
|
||||
permission: null,
|
||||
},
|
||||
{
|
||||
title: 'Email templates',
|
||||
href: '/admin/templates',
|
||||
description: 'Manage outgoing email templates',
|
||||
permission: null,
|
||||
},
|
||||
{
|
||||
title: 'OpenID Connect SSO',
|
||||
href: '/admin/oidc',
|
||||
description: 'Manage OpenID SSO configurations',
|
||||
permission: null,
|
||||
}
|
||||
]
|
||||
|
||||
const sidebarNavItems = computed(() =>
|
||||
allNavItems.filter((item) => !item.permission || item.permission && userStore.permissions.includes(item.permission))
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4 md:block overflow-y-auto">
|
||||
<PageHeader title="Admin settings" subTitle="Manage your helpdesk settings." />
|
||||
<div class="flex flex-col space-y-8 lg:flex-row lg:space-x-10 lg:space-y-5">
|
||||
<aside class="lg:w-1/6 md:w-1/7 h-[calc(100vh-10rem)] border-r pr-3">
|
||||
<SidebarNav :navItems="sidebarNavItems" />
|
||||
</aside>
|
||||
<div class="flex-1 lg:max-w-5xl admin-main-content min-h-[700px]">
|
||||
<div class="space-y-6">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,63 +0,0 @@
|
||||
<template>
|
||||
<div class="box rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead v-for="header in headerGroup.headers" :key="header.id">
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows?.length">
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
>
|
||||
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
<template v-else>
|
||||
<TableRow>
|
||||
<TableCell :colspan="columns.length" class="h-24 text-center"> No results. </TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table'
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/table'
|
||||
|
||||
const props = defineProps({
|
||||
columns: Array,
|
||||
data: Array
|
||||
})
|
||||
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
return props.data
|
||||
},
|
||||
get columns() {
|
||||
return props.columns
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel()
|
||||
})
|
||||
</script>
|
||||
@@ -1,189 +0,0 @@
|
||||
<template>
|
||||
<div class="box border p-5 space-y-5 rounded-lg">
|
||||
<div class="space-y-5">
|
||||
<div v-for="(action, index) in actions" :key="index" class="space-y-5">
|
||||
<div v-if="index > 0">
|
||||
<hr class="border-t-2 border-dotted border-gray-300" />
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex space-x-5 justify-between">
|
||||
<Select v-model="action.type" @update:modelValue="(value) => handleFieldChange(value, index)">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select action" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Conversation</SelectLabel>
|
||||
<SelectItem v-for="(actionItem, key) in conversationActions" :key="key" :value="key">
|
||||
{{ actionItem.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div v-if="action.type && conversationActions[action.type].inputType === 'select'" class="flex-1">
|
||||
<Select v-model="action.value" @update:modelValue="(value) => handleValueChange(value, index)">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select value" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="(act, index) in getDropdownValues(action.type).value" :key="index"
|
||||
:value="act.value.toString()">
|
||||
{{ act.name }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="cursor-pointer" @click.prevent="removeAction(index)">
|
||||
<X size="16" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="action.type && conversationActions[action.type].inputType === 'richtext'" class="pl-0 shadow">
|
||||
<QuillEditor theme="snow" v-model:content="action.value" contentType="html"
|
||||
@update:content="(value) => handleValueChange(value, index)" class="h-32 mb-12" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="outline" @click.prevent="addAction" size="sm">Add action</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toRefs, ref, onMounted } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { QuillEditor } from '@vueup/vue-quill'
|
||||
import '@vueup/vue-quill/dist/vue-quill.snow.css'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import api from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
actions: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const { actions } = toRefs(props)
|
||||
const emitter = useEmitter()
|
||||
const teams = ref([])
|
||||
const users = ref([])
|
||||
const statuses = ref([])
|
||||
const priorities = ref([])
|
||||
const emit = defineEmits(['update-actions', 'add-action', 'remove-action'])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [teamsResp, usersResp, statusesResp, prioritiesResp] = await Promise.all([
|
||||
api.getTeamsCompact(),
|
||||
api.getUsersCompact(),
|
||||
api.getStatuses(),
|
||||
api.getPriorities(),
|
||||
])
|
||||
|
||||
teams.value = teamsResp.data.data.map(team => ({
|
||||
value: team.id,
|
||||
name: team.name
|
||||
}))
|
||||
|
||||
users.value = usersResp.data.data.map(user => ({
|
||||
value: user.id,
|
||||
name: user.first_name + ' ' + user.last_name
|
||||
}))
|
||||
|
||||
statuses.value = statusesResp.data.data.map(status => ({
|
||||
value: status.name,
|
||||
name: status.name
|
||||
}))
|
||||
|
||||
priorities.value = prioritiesResp.data.data.map(priority => ({
|
||||
value: priority.name,
|
||||
name: priority.name
|
||||
}))
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Something went wrong',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const handleFieldChange = (value, index) => {
|
||||
actions.value[index].value = ''
|
||||
actions.value[index].type = value
|
||||
emitUpdate(index)
|
||||
}
|
||||
|
||||
const handleValueChange = (value, index) => {
|
||||
actions.value[index].value = value
|
||||
emitUpdate(index)
|
||||
}
|
||||
|
||||
const removeAction = (index) => {
|
||||
emit('remove-action', index)
|
||||
}
|
||||
|
||||
const addAction = (index) => {
|
||||
emit('add-action', index)
|
||||
}
|
||||
|
||||
const emitUpdate = (index) => {
|
||||
emit('update-actions', actions, index)
|
||||
}
|
||||
|
||||
const conversationActions = {
|
||||
assign_team: {
|
||||
label: 'Assign to team',
|
||||
inputType: 'select',
|
||||
},
|
||||
assign_user: {
|
||||
label: 'Assign to user',
|
||||
inputType: 'select',
|
||||
},
|
||||
set_status: {
|
||||
label: 'Set status',
|
||||
inputType: 'select',
|
||||
},
|
||||
set_priority: {
|
||||
label: 'Set priority',
|
||||
inputType: 'select',
|
||||
},
|
||||
send_private_note: {
|
||||
label: 'Send private note',
|
||||
inputType: 'richtext',
|
||||
},
|
||||
reply: {
|
||||
label: 'Send reply',
|
||||
inputType: 'richtext',
|
||||
}
|
||||
}
|
||||
|
||||
const actionDropdownValues = {
|
||||
assign_team: teams,
|
||||
assign_user: users,
|
||||
set_status: statuses,
|
||||
set_priority: priorities,
|
||||
}
|
||||
|
||||
const getDropdownValues = (field) => {
|
||||
return actionDropdownValues[field] || []
|
||||
}
|
||||
</script>
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<div class="flex justify-between mb-5">
|
||||
<PageHeader title="Automations" description="Manage automations and time triggers" />
|
||||
<div>
|
||||
<Button size="sm" @click="newRule">New rule</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<AutomationTabs v-model="selectedTab" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import AutomationTabs from '@/components/admin/automation/AutomationTabs.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const selectedTab = ref('new_conversation')
|
||||
|
||||
const newRule = () => {
|
||||
router.push({ path: `/admin/automations/new`, query: { type: selectedTab.value } })
|
||||
}
|
||||
</script>
|
||||
@@ -1,344 +0,0 @@
|
||||
<template>
|
||||
<div class="mb-5">
|
||||
<CustomBreadcrumb :links="breadcrumbLinks" />
|
||||
</div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<span>{{ formTitle }}</span>
|
||||
<div :class="{ 'opacity-50 transition-opacity duration-300': isLoading }">
|
||||
<form @submit="onSubmit">
|
||||
<div class="space-y-5">
|
||||
<div class="space-y-5">
|
||||
|
||||
<FormField v-slot="{ field }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="My new rule" v-bind="field" />
|
||||
</FormControl>
|
||||
<FormDescription>Name for the rule.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ field }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Description for new rule" v-bind="field" />
|
||||
</FormControl>
|
||||
<FormDescription>Description for the rule.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField, handleInput }" name="type">
|
||||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField" @update:modelValue="handleInput">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="new_conversation"> New conversation </SelectItem>
|
||||
<SelectItem value="conversation_update"> Conversation update </SelectItem>
|
||||
<SelectItem value="time_trigger"> Time trigger </SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Type of rule.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="events" v-if="form.values.type === 'conversation_update'">
|
||||
<FormItem>
|
||||
<FormLabel>Events</FormLabel>
|
||||
<FormControl>
|
||||
<SelectTag v-bind="componentField" :items="conversationEvents" placeholder="Select events"></SelectTag>
|
||||
</FormControl>
|
||||
<FormDescription>Evaluate rule on these events.</FormDescription>
|
||||
<FormMessage></FormMessage>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="font-semibold">Match these rules</p>
|
||||
|
||||
<RuleBox :ruleGroup="firstRuleGroup" @update-group="handleUpdateGroup" @add-condition="handleAddCondition"
|
||||
@remove-condition="handleRemoveCondition" :groupIndex="0" />
|
||||
|
||||
<div class="flex justify-center">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button :class="[groupOperator === 'AND' ? 'bg-black' : 'bg-gray-100 text-black']"
|
||||
@click.prevent="toggleGroupOperator('AND')">
|
||||
AND
|
||||
</Button>
|
||||
<Button :class="[groupOperator === 'OR' ? 'bg-black' : 'bg-gray-100 text-black']"
|
||||
@click.prevent="toggleGroupOperator('OR')">
|
||||
OR
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RuleBox :ruleGroup="secondRuleGroup" @update-group="handleUpdateGroup" @add-condition="handleAddCondition"
|
||||
@remove-condition="handleRemoveCondition" :groupIndex="1" />
|
||||
<p class="font-semibold">Perform these actions</p>
|
||||
|
||||
<ActionBox :actions="getActions()" :update-actions="handleUpdateActions" @add-action="handleAddAction"
|
||||
@remove-action="handleRemoveAction" />
|
||||
<Button type="submit" :isLoading="isLoading" size="sm">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import RuleBox from './RuleBox.vue'
|
||||
import ActionBox from './ActionBox.vue'
|
||||
import api from '@/api'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { formSchema } from './formSchema.js'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { SelectTag } from '@/components/ui/select'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from '@/components/ui/form'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const emitter = useEmitter()
|
||||
const rule = ref({
|
||||
id: 0,
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'new_conversation',
|
||||
rules: [
|
||||
{
|
||||
groups: [
|
||||
{
|
||||
rules: [],
|
||||
logical_op: 'OR'
|
||||
},
|
||||
{
|
||||
rules: [],
|
||||
logical_op: 'OR'
|
||||
}
|
||||
],
|
||||
actions: [],
|
||||
group_operator: 'OR'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const conversationEvents = [
|
||||
"conversation.user.assigned",
|
||||
"conversation.team.assigned",
|
||||
"conversation.priority.change",
|
||||
"conversation.status.change",
|
||||
"conversation.message.outgoing",
|
||||
"conversation.message.incoming",
|
||||
]
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: [String, Number],
|
||||
required: false
|
||||
}
|
||||
})
|
||||
|
||||
const breadcrumbPageLabel = () => {
|
||||
if (props.id > 0) return 'Edit rule'
|
||||
return 'New rule'
|
||||
}
|
||||
|
||||
const formTitle = computed(() => {
|
||||
if (props.id > 0) return 'Edit existing rule'
|
||||
return 'Create new rule'
|
||||
})
|
||||
|
||||
const breadcrumbLinks = [
|
||||
{ path: '/admin/automations', label: 'Automations' },
|
||||
{ path: '#', label: breadcrumbPageLabel() }
|
||||
]
|
||||
|
||||
const firstRuleGroup = ref([])
|
||||
const secondRuleGroup = ref([])
|
||||
const groupOperator = ref('')
|
||||
|
||||
const getFirstGroup = () => {
|
||||
if (rule.value.rules?.[0]?.groups?.[0]) {
|
||||
return rule.value.rules[0].groups[0]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const getSecondGroup = () => {
|
||||
if (rule.value.rules?.[0]?.groups?.[1]) {
|
||||
return rule.value.rules[0].groups[1]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const getActions = () => {
|
||||
if (rule.value.rules?.[0]?.actions) {
|
||||
return rule.value.rules[0].actions
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const toggleGroupOperator = (value) => {
|
||||
if (rule.value.rules?.[0]) {
|
||||
rule.value.rules[0].group_operator = value
|
||||
groupOperator.value = value
|
||||
}
|
||||
}
|
||||
|
||||
const getGroupOperator = () => {
|
||||
if (rule.value.rules?.[0]) {
|
||||
return rule.value.rules[0].group_operator
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const handleUpdateGroup = (value, groupIndex) => {
|
||||
rule.value.rules[0].groups[groupIndex] = value.value
|
||||
}
|
||||
|
||||
const handleAddCondition = (groupIndex) => {
|
||||
rule.value.rules[0].groups[groupIndex].rules.push({})
|
||||
}
|
||||
|
||||
const handleRemoveCondition = (groupIndex, ruleIndex) => {
|
||||
rule.value.rules[0].groups[groupIndex].rules.splice(ruleIndex, 1)
|
||||
}
|
||||
|
||||
const handleUpdateActions = (value, index) => {
|
||||
rule.value.rules[0].actions[index] = value
|
||||
}
|
||||
|
||||
const handleAddAction = () => {
|
||||
rule.value.rules[0].actions.push({})
|
||||
}
|
||||
|
||||
const handleRemoveAction = (index) => {
|
||||
rule.value.rules[0].actions.splice(index, 1)
|
||||
}
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(formSchema)
|
||||
})
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
handleSave(values)
|
||||
})
|
||||
|
||||
const handleSave = async (values) => {
|
||||
if (!areRulesValid()) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Invalid rules',
|
||||
variant: 'destructive',
|
||||
description: "Make sure you have atleast one action and one rule."
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
const updatedRule = { ...rule.value, ...values }
|
||||
// Delete fields not required.
|
||||
delete updatedRule.created_at
|
||||
delete updatedRule.updated_at
|
||||
if (props.id > 0) {
|
||||
await api.updateAutomationRule(props.id, updatedRule)
|
||||
} else {
|
||||
await api.createAutomationRule(updatedRule)
|
||||
router.push('/admin/automations')
|
||||
}
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Saved',
|
||||
description: "Rule saved successfully"
|
||||
})
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not save rule',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add some vee-validate validations.
|
||||
const areRulesValid = () => {
|
||||
// Must have atleast one action.
|
||||
if (rule.value.rules[0].actions.length == 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have atleast 1 group.
|
||||
if (rule.value.rules[0].groups.length == 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Group should have atleast one rule.
|
||||
if (rule.value.rules[0].groups[0].rules.length == 0) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.id > 0) {
|
||||
try {
|
||||
isLoading.value = true
|
||||
let resp = await api.getAutomationRule(props.id)
|
||||
rule.value = resp.data.data
|
||||
form.setValues(resp.data.data)
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not fetch rule',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
if (route.query.type) {
|
||||
form.setFieldValue('type', route.query.type)
|
||||
}
|
||||
firstRuleGroup.value = getFirstGroup()
|
||||
secondRuleGroup.value = getSecondGroup()
|
||||
groupOperator.value = getGroupOperator()
|
||||
})
|
||||
|
||||
</script>
|
||||
@@ -1,287 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-5">
|
||||
<RadioGroup class="flex" :modelValue="ruleGroup.logical_op" @update:modelValue="handleGroupOperator">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroupItem value="OR" />
|
||||
<Label for="r1">Match <b>ANY</b> of below.</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroupItem value="AND" />
|
||||
<Label for="r1">Match <b>ALL</b> of below.</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div class="box border p-5 space-y-5 rounded-lg">
|
||||
<div class="space-y-5">
|
||||
<div v-for="(rule, index) in ruleGroup.rules" :key="rule" class="space-y-5">
|
||||
<div v-if="index > 0">
|
||||
<hr class="border-t-2 border-dotted border-gray-200" />
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex space-x-5">
|
||||
<!-- Field selection -->
|
||||
<Select v-model="rule.field" @update:modelValue="(value) => handleFieldChange(value, index)">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select field" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Conversation</SelectLabel>
|
||||
<SelectItem v-for="(field, key) in conversationFields" :key="key" :value="key">
|
||||
{{ field.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Operator selection -->
|
||||
<Select v-model="rule.operator" @update:modelValue="(value) => handleOperatorChange(value, index)">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select operator" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="(op, key) in getFieldOperators(rule.field)" :key="key" :value="op">
|
||||
{{ op }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="cursor-pointer" @click.prevent="removeCondition(index)">
|
||||
<X size="16" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Value input based on field type -->
|
||||
<div v-if="showInput(index)">
|
||||
<!-- Text input -->
|
||||
<Input type="text" placeholder="Set value" v-if="inputType(index) === 'text'" v-model="rule.value"
|
||||
@update:modelValue="(value) => handleValueChange(value, index)" />
|
||||
|
||||
<!-- Number input -->
|
||||
<Input type="number" placeholder="Set value" v-if="inputType(index) === 'number'" v-model="rule.value"
|
||||
@update:modelValue="(value) => handleValueChange(value, index)" />
|
||||
|
||||
<!-- Dropdown -->
|
||||
<Select v-model="rule.value" @update:modelValue="(value) => handleValueChange(value, index)"
|
||||
v-if="inputType(index) === 'select'">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select value" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="(op, key) in getFieldOptions(rule.field)" :key="key" :value="op">
|
||||
{{ op }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Tag -->
|
||||
<TagsInput :defaultValue="fieldValueAsArray(rule.value)" v-if="inputType(index) === 'tag'"
|
||||
@update:modelValue="(value) => handleValueChange(value, index)">
|
||||
<TagsInputItem v-for="item in fieldValueAsArray(rule.value)" :key="item" :value="item">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput placeholder="Select values" />
|
||||
</TagsInput>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox id="terms" :defaultChecked="rule.case_sensitive_match"
|
||||
@update:checked="(value) => handleCaseSensitiveCheck(value, index)" />
|
||||
<label for="terms"> Case sensitive match </label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="outline" size="sm" @click.prevent="addCondition">Add condition</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toRefs, ref, onMounted } from 'vue'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { TagsInput, TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText } from '@/components/ui/tags-input'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import api from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
ruleGroup: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
groupIndex: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emitter = useEmitter()
|
||||
const statuses = ref([])
|
||||
const priorities = ref([])
|
||||
const { ruleGroup } = toRefs(props)
|
||||
|
||||
const conversationFields = {
|
||||
content: {
|
||||
label: 'Content',
|
||||
type: 'text',
|
||||
operators: ["contains", "not contains", "equals", "not equals", "set", "not set"],
|
||||
},
|
||||
subject: {
|
||||
label: 'Subject',
|
||||
type: 'text',
|
||||
operators: ["contains", "not contains", "equals", "not equals", "set", "not set"],
|
||||
},
|
||||
status: {
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
operators: ["equals", "not equals", "set", "not set"],
|
||||
options: statuses,
|
||||
},
|
||||
priority: {
|
||||
label: 'Priority',
|
||||
type: 'select',
|
||||
operators: ["equals", "not equals", "set", "not set"],
|
||||
options: priorities,
|
||||
},
|
||||
assigned_team: {
|
||||
label: 'Assigned team',
|
||||
type: 'text',
|
||||
operators: ["set", "not set"],
|
||||
},
|
||||
assigned_user: {
|
||||
label: 'Assigned user',
|
||||
type: 'text',
|
||||
operators: ["set", "not set"],
|
||||
},
|
||||
hours_since_created: {
|
||||
label: 'Hours since created',
|
||||
type: 'number',
|
||||
operators: ["greater than"],
|
||||
},
|
||||
hours_since_resolved: {
|
||||
label: 'Hours since resolved',
|
||||
type: 'number',
|
||||
operators: ["greater than"],
|
||||
},
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [statusesResp, prioritiesResp] = await Promise.all([api.getStatuses(), api.getPriorities()])
|
||||
statuses.value = statusesResp.data.data.map(status => (status.name))
|
||||
priorities.value = prioritiesResp.data.data.map(priority => (priority.name))
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not fetch statuses',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update-group', 'add-condition', 'remove-condition'])
|
||||
|
||||
const handleGroupOperator = (value) => {
|
||||
ruleGroup.value.logical_op = value
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
const handleFieldChange = (value, ruleIndex) => {
|
||||
ruleGroup.value.rules[ruleIndex].operator = ''
|
||||
ruleGroup.value.rules[ruleIndex].value = ''
|
||||
ruleGroup.value.rules[ruleIndex].field = value
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
const handleOperatorChange = (value, ruleIndex) => {
|
||||
// Set initial value based on operator.
|
||||
if (["contains", "not contains"].includes(value))
|
||||
ruleGroup.value.rules[ruleIndex].value = []
|
||||
else
|
||||
ruleGroup.value.rules[ruleIndex].value = ''
|
||||
ruleGroup.value.rules[ruleIndex].operator = value
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
const handleValueChange = (value, ruleIndex) => {
|
||||
const operator = ruleGroup.value.rules[ruleIndex].operator
|
||||
if (["contains", "not contains"].includes(operator)) {
|
||||
ruleGroup.value.rules[ruleIndex].value = Array.isArray(value) ? value.join(',') : value
|
||||
} else {
|
||||
ruleGroup.value.rules[ruleIndex].value = String(value)
|
||||
}
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
const fieldValueAsArray = (value) => {
|
||||
return Array.isArray(value) ? value : (value ? value.split(',') : [])
|
||||
}
|
||||
|
||||
const handleCaseSensitiveCheck = (value, ruleIndex) => {
|
||||
ruleGroup.value.rules[ruleIndex].case_sensitive_match = value
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
const removeCondition = (index) => {
|
||||
emit('remove-condition', props.groupIndex, index)
|
||||
}
|
||||
|
||||
const addCondition = () => {
|
||||
emit('add-condition', props.groupIndex)
|
||||
}
|
||||
|
||||
const emitUpdate = () => {
|
||||
emit('update-group', ruleGroup, props.groupIndex)
|
||||
}
|
||||
|
||||
const getFieldOperators = (field) => {
|
||||
return conversationFields[field]?.operators || []
|
||||
}
|
||||
|
||||
const getFieldOptions = (field) => {
|
||||
return conversationFields[field]?.options?.value || []
|
||||
}
|
||||
|
||||
const inputType = (index) => {
|
||||
const field = ruleGroup.value.rules[index]?.field
|
||||
const operator = ruleGroup.value.rules[index]?.operator
|
||||
|
||||
if (["contains", "not contains"].includes(operator)) {
|
||||
return "tag"
|
||||
}
|
||||
|
||||
if (field) {
|
||||
return conversationFields[field].type
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
const showInput = (index) => {
|
||||
const operator = ruleGroup.value.rules[index]?.operator
|
||||
return !["set", "not set"].includes(operator)
|
||||
}
|
||||
</script>
|
||||
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<p class="text-sm-muted">{{ helptext }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Spinner v-if=isLoading></Spinner>
|
||||
<div class="space-y-5" v-else>
|
||||
<RuleList v-for="rule in rules" :key="rule.name" :rule="rule" @delete-rule="deleteRule"
|
||||
@toggle-rule="toggleRule" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import RuleList from './RuleList.vue'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import api from '@/api'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const rules = ref([])
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
helptext: {
|
||||
type: String,
|
||||
required: false
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchRules()
|
||||
})
|
||||
|
||||
const fetchRules = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
let resp = await api.getAutomationRules(props.type)
|
||||
rules.value = resp.data.data
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteRule = async (id) => {
|
||||
await api.deleteAutomationRule(id)
|
||||
fetchRules()
|
||||
}
|
||||
|
||||
const toggleRule = async (id) => {
|
||||
await api.toggleAutomationRule(id)
|
||||
fetchRules()
|
||||
}
|
||||
</script>
|
||||
@@ -1,14 +0,0 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
export const formSchema = z.object({
|
||||
name: z.string({
|
||||
required_error: 'Rule name is required.'
|
||||
}),
|
||||
description: z.string({
|
||||
required_error: 'Rule description is required.'
|
||||
}),
|
||||
type: z.string({
|
||||
required_error: 'Rule type is required.'
|
||||
}),
|
||||
events: z.array(z.string()).min(1, 'Please select at least one event.'),
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="box flex-1 rounded-lg px-8 py-4 transition-shadow duration-170 cursor-pointer hover:bg-muted max-w-80"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div class="flex items-center mb-4">
|
||||
<component :is="icon" size="25" class="mr-2" />
|
||||
<p class="text-lg">{{ title }}</p>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{{ subTitle }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subTitle: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
icon: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
onClick: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.onClick) {
|
||||
props.onClick()
|
||||
}
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-1">
|
||||
<span class="text-2xl">{{ title }}</span>
|
||||
<p class="text-muted-foreground text-lg">{{ description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-5">
|
||||
<PageHeader title="Conversation" description="Manage conversation settings" />
|
||||
</div>
|
||||
<div class="flex space-x-5">
|
||||
<AdminMenuCard v-for="card in cards" :key="card.title" :onClick="card.onClick" :title="card.title"
|
||||
:subTitle="card.subTitle" :icon="card.icon">
|
||||
</AdminMenuCard>
|
||||
</div>
|
||||
</div>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Tag, TrendingUp, MessageCircleReply } from 'lucide-vue-next'
|
||||
import AdminMenuCard from '@/components/admin/common/MenuCard.vue'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const navigateToTags = () => {
|
||||
router.push('/admin/conversations/tags')
|
||||
}
|
||||
|
||||
const navigateToStatus = () => {
|
||||
router.push('/admin/conversations/statuses')
|
||||
}
|
||||
|
||||
const navigateToCannedResponse = () => {
|
||||
router.push('/admin/conversations/canned-responses')
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: 'Tags',
|
||||
subTitle: 'Manage conversation tags.',
|
||||
onClick: navigateToTags,
|
||||
icon: Tag
|
||||
},
|
||||
{
|
||||
title: 'Canned response',
|
||||
subTitle: 'Manage canned responses.',
|
||||
onClick: navigateToCannedResponse,
|
||||
icon: MessageCircleReply
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
subTitle: 'Manage conversation statuses.',
|
||||
onClick: navigateToStatus,
|
||||
icon: TrendingUp
|
||||
}
|
||||
]
|
||||
</script>
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between mb-5">
|
||||
<PageHeader title="Canned responses" description="Manage canned responses" />
|
||||
<div class="flex justify-end mb-4">
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button size="sm">New canned response</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="sm:max-w-[625px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New canned response</DialogTitle>
|
||||
<DialogDescription>Set title and content, click save when you're done. </DialogDescription>
|
||||
</DialogHeader>
|
||||
<CannedResponsesForm @submit="onSubmit">
|
||||
<template #footer>
|
||||
<DialogFooter class="mt-7">
|
||||
<Button type="submit" size="sm">Save Changes</Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</CannedResponsesForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
<Spinner v-if="formLoading"></Spinner>
|
||||
<div v-else>
|
||||
<DataTable :columns="columns" :data="cannedResponses" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import DataTable from '@/components/admin/DataTable.vue'
|
||||
import { columns } from './dataTableColumns.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import PageHeader from '@/components/admin/common/PageHeader.vue'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import CannedResponsesForm from './CannedResponsesForm.vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { formSchema } from './formSchema.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api'
|
||||
|
||||
const formLoading = ref(false)
|
||||
const cannedResponses = ref([])
|
||||
const emit = useEmitter()
|
||||
const dialogOpen = ref(false)
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(formSchema)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
getCannedResponses()
|
||||
emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||
form.setValues({
|
||||
title: "",
|
||||
content: "",
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||
})
|
||||
|
||||
const refreshList = (data) => {
|
||||
if (data?.model === 'canned_responses') getCannedResponses()
|
||||
}
|
||||
|
||||
const getCannedResponses = async () => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
const resp = await api.getCannedResponses()
|
||||
cannedResponses.value = resp.data.data
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
await api.createCannedResponse(values)
|
||||
dialogOpen.value = false
|
||||
getCannedResponses()
|
||||
} catch (error) {
|
||||
console.error('Failed to create canned response:', error)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<form class="space-y-6">
|
||||
<FormField v-slot="{ componentField }" name="title">
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription></FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField, handleInput }" name="content">
|
||||
<FormItem>
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<QuillEditor theme="snow" v-model:content="componentField.modelValue" contentType="html"
|
||||
@update:content="handleInput"></QuillEditor>
|
||||
</FormControl>
|
||||
<FormDescription></FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Form submit button slot -->
|
||||
<slot name="footer"></slot>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { QuillEditor } from '@vueup/vue-quill'
|
||||
import '@vueup/vue-quill/dist/vue-quill.snow.css'
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
</script>
|
||||
@@ -1,101 +0,0 @@
|
||||
<template>
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" class="w-8 h-8 p-0">
|
||||
<span class="sr-only">Open menu</span>
|
||||
<MoreHorizontal class="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DialogTrigger as-child>
|
||||
<DropdownMenuItem> Edit </DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DropdownMenuItem @click="deleteCannedResponse"> Delete </DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DialogContent class="sm:max-w-[625px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit canned response</DialogTitle>
|
||||
<DialogDescription>Edit title and content, click save when you're done. </DialogDescription>
|
||||
</DialogHeader>
|
||||
<CannedResponsesForm @submit="onSubmit">
|
||||
<template #footer>
|
||||
<DialogFooter class="mt-7">
|
||||
<Button type="submit" size="sm">Save Changes</Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</CannedResponsesForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch, ref } from 'vue'
|
||||
import { MoreHorizontal } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import CannedResponsesForm from './CannedResponsesForm.vue'
|
||||
import '@vueup/vue-quill/dist/vue-quill.snow.css';
|
||||
import { formSchema } from './formSchema.js'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const dialogOpen = ref(false)
|
||||
const emit = useEmitter()
|
||||
|
||||
const props = defineProps({
|
||||
cannedResponse: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(formSchema)
|
||||
})
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
await api.updateCannedResponse(props.cannedResponse.id, values)
|
||||
dialogOpen.value = false
|
||||
emitRefreshCannedResponseList()
|
||||
})
|
||||
|
||||
const deleteCannedResponse = async () => {
|
||||
await api.deleteCannedResponse(props.cannedResponse.id)
|
||||
dialogOpen.value = false
|
||||
emitRefreshCannedResponseList()
|
||||
}
|
||||
|
||||
const emitRefreshCannedResponseList = () => {
|
||||
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
|
||||
model: 'canned_responses'
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for changes in initialValues and update the form.
|
||||
watch(
|
||||
() => props.cannedResponse,
|
||||
(newValues) => {
|
||||
form.setValues(newValues)
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
</script>
|
||||
@@ -1,18 +0,0 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
export const formSchema = z.object({
|
||||
title: z
|
||||
.string({
|
||||
required_error: 'Title is required.'
|
||||
})
|
||||
.min(1, {
|
||||
message: 'Title must be at least 1 character.'
|
||||
}),
|
||||
content: z
|
||||
.string({
|
||||
required_error: 'Content is required.'
|
||||
})
|
||||
.min(1, {
|
||||
message: 'Content must be atleast 3 characters.'
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between mb-5">
|
||||
<PageHeader title="Status" description="Manage conversation statuses" />
|
||||
<div class="flex justify-end mb-4">
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button size="sm">New Status</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New status</DialogTitle>
|
||||
<DialogDescription> Set status name. Click save when you're done. </DialogDescription>
|
||||
</DialogHeader>
|
||||
<StatusForm @submit.prevent="onSubmit">
|
||||
<template #footer>
|
||||
<DialogFooter class="mt-10">
|
||||
<Button type="submit" size="sm"> Save changes </Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</StatusForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<div>
|
||||
<DataTable :columns="columns" :data="statuses" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import DataTable from '@/components/admin/DataTable.vue'
|
||||
import { columns } from './dataTableColumns.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import PageHeader from '@/components/admin/common/PageHeader.vue'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import StatusForm from './StatusForm.vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { formSchema } from './formSchema.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const statuses = ref([])
|
||||
const emit = useEmitter()
|
||||
const dialogOpen = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
getStatuses()
|
||||
emit.on(EMITTER_EVENTS.REFRESH_LIST, (data) => {
|
||||
if (data?.model === 'status') getStatuses()
|
||||
})
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(formSchema)
|
||||
})
|
||||
|
||||
const getStatuses = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const resp = await api.getStatuses()
|
||||
statuses.value = resp.data.data
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
await api.createStatus(values)
|
||||
dialogOpen.value = false
|
||||
getStatuses()
|
||||
} catch (error) {
|
||||
console.error('Failed to create status:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div class="flex justify-between mb-5">
|
||||
<PageHeader title="Tags" description="Manage conversation tags" />
|
||||
<div class="flex justify-end mb-4">
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button size="sm">New Tag</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create new tag</DialogTitle>
|
||||
<DialogDescription> Set tag name. Click save when you're done. </DialogDescription>
|
||||
</DialogHeader>
|
||||
<TagsForm @submit.prevent="onSubmit">
|
||||
<template #footer>
|
||||
<DialogFooter class="mt-10">
|
||||
<Button type="submit" size="sm"> Save changes </Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</TagsForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<div v-else>
|
||||
<DataTable :columns="columns" :data="tags" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import DataTable from '@/components/admin/DataTable.vue'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { columns } from '@/components/admin/conversation/tags/dataTableColumns.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import PageHeader from '@/components/admin/common/PageHeader.vue'
|
||||
import TagsForm from './TagsForm.vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { formSchema } from './formSchema.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const tags = ref([])
|
||||
const emit = useEmitter()
|
||||
const dialogOpen = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
getTags()
|
||||
emit.on(EMITTER_EVENTS.REFRESH_LIST, (data) => {
|
||||
if (data?.model === 'tags') getTags()
|
||||
})
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(formSchema)
|
||||
})
|
||||
|
||||
const getTags = async () => {
|
||||
isLoading.value = true
|
||||
const resp = await api.getTags()
|
||||
tags.value = resp.data.data
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
try {
|
||||
await api.createTag(values)
|
||||
dialogOpen.value = false
|
||||
getTags()
|
||||
} catch (error) {
|
||||
console.error('Failed to create tag:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,35 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="General" description="General app settings" />
|
||||
</div>
|
||||
<GeneralSettingForm :submitForm="submitForm" :initial-values="initialValues" submitLabel="Save" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import GeneralSettingForm from './GeneralSettingForm.vue'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
import api from '@/api'
|
||||
|
||||
const initialValues = ref({})
|
||||
|
||||
onMounted(async () => {
|
||||
const response = await api.getSettings('general')
|
||||
const data = response.data.data
|
||||
|
||||
initialValues.value = Object.keys(data).reduce((acc, key) => {
|
||||
// Remove 'app.' prefix
|
||||
const newKey = key.replace(/^app\./, '')
|
||||
acc[newKey] = data[key]
|
||||
return acc
|
||||
}, {})
|
||||
})
|
||||
|
||||
const submitForm = async (values) => {
|
||||
// Prepend keys with `app.`
|
||||
const updatedValues = Object.fromEntries(
|
||||
Object.entries(values).map(([key, value]) => [`app.${key}`, value])
|
||||
)
|
||||
await api.updateSettings('general', updatedValues)
|
||||
}
|
||||
</script>
|
||||
@@ -1,107 +0,0 @@
|
||||
<template>
|
||||
<AutoForm
|
||||
class="space-y-6"
|
||||
:schema="formSchema"
|
||||
:form="form"
|
||||
:field-config="{
|
||||
name: {
|
||||
description: 'Name for your inbox.'
|
||||
},
|
||||
from: {
|
||||
label: 'Email address',
|
||||
description: 'From email address. e.g. My Support <mysupport@example.com>'
|
||||
},
|
||||
imap: {
|
||||
label: 'IMAP',
|
||||
password: {
|
||||
inputProps: {
|
||||
type: 'password',
|
||||
placeholder: '••••••••'
|
||||
}
|
||||
},
|
||||
read_interval: {
|
||||
label: 'Emails scan interval'
|
||||
}
|
||||
},
|
||||
smtp: {
|
||||
label: 'SMTP',
|
||||
max_conns: {
|
||||
label: 'Max connections',
|
||||
description: 'Maximum number of concurrent connections to the server.'
|
||||
},
|
||||
max_msg_retries: {
|
||||
label: 'Retries',
|
||||
description: 'Number of times to retry when a message fails.'
|
||||
},
|
||||
idle_timeout: {
|
||||
label: 'Idle timeout',
|
||||
description: `IdleTimeout is the maximum time to wait for new activity on a connection
|
||||
before closing it and removing it from the pool.`
|
||||
},
|
||||
wait_timeout: {
|
||||
label: 'Wait timeout',
|
||||
description: `PoolWaitTimeout is the maximum time to wait to obtain a connection from
|
||||
a pool before timing out. This may happen when all open connections are
|
||||
busy sending e-mails and they're not returning to the pool fast enough.
|
||||
This is also the timeout used when creating new SMTP connections.
|
||||
`
|
||||
},
|
||||
auth_protocol: {
|
||||
label: 'Auth protocol'
|
||||
},
|
||||
password: {
|
||||
inputProps: {
|
||||
type: 'password',
|
||||
placeholder: '••••••••'
|
||||
}
|
||||
}
|
||||
}
|
||||
}"
|
||||
@submit="submitForm"
|
||||
>
|
||||
<Button type="submit" size="sm" :is-loading="isLoading"> {{ props.submitLabel }} </Button>
|
||||
</AutoForm>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { AutoForm } from '@/components/ui/auto-form'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { formSchema } from './formSchema.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps({
|
||||
initialValues: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
submitForm: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: () => 'Submit'
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
}
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(formSchema),
|
||||
initialValues: props.initialValues
|
||||
})
|
||||
|
||||
// Watch for changes in initialValues and update the form
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
(newValues) => {
|
||||
if (newValues) form.setValues(newValues)
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -1,50 +0,0 @@
|
||||
<script setup>
|
||||
import { MoreHorizontal } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['editInbox', 'deleteInbox', 'toggleInbox'])
|
||||
|
||||
function editInbox(id) {
|
||||
emit('editInbox', id)
|
||||
}
|
||||
|
||||
function deleteInbox(id) {
|
||||
emit('deleteInbox', id)
|
||||
}
|
||||
|
||||
function toggleInbox(id) {
|
||||
emit('toggleInbox', id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" class="w-8 h-8 p-0">
|
||||
<span class="sr-only">Open menu</span>
|
||||
<MoreHorizontal class="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem @click="editInbox(props.inbox.id)"> Edit </DropdownMenuItem>
|
||||
<DropdownMenuItem @click="deleteInbox(props.inbox.id)"> Delete </DropdownMenuItem>
|
||||
<DropdownMenuItem @click="toggleInbox(props.inbox.id)" v-if="props.inbox.disabled">
|
||||
Enable
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="toggleInbox(props.inbox.id)" v-else> Disable </DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -1,73 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="Notification" description="Manage notification settings" />
|
||||
</div>
|
||||
<div>
|
||||
<Spinner v-if="formLoading"></Spinner>
|
||||
<NotificationsForm :initial-values="initialValues" :submit-form="submitForm" :isLoading="formLoading" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '@/api'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
import NotificationsForm from './NotificationSettingForm.vue'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
|
||||
const initialValues = ref({})
|
||||
const formLoading = ref(false)
|
||||
const emitter = useEmitter()
|
||||
|
||||
onMounted(() => {
|
||||
getNotificationSettings()
|
||||
})
|
||||
|
||||
const getNotificationSettings = async () => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
const resp = await api.getEmailNotificationSettings()
|
||||
initialValues.value = Object.fromEntries(
|
||||
Object.entries(resp.data.data).map(([key, value]) => [key.replace('notification.email.', ''), value])
|
||||
)
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not fetch',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async (values) => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
const updatedValues = Object.fromEntries(
|
||||
Object.entries(values).map(([key, value]) => {
|
||||
if (key === 'password' && value.includes('•')) {
|
||||
return [`notification.email.${key}`, '']
|
||||
}
|
||||
return [`notification.email.${key}`, value]
|
||||
})
|
||||
);
|
||||
await api.updateEmailNotificationSettings(updatedValues)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
description: "Saved successfully"
|
||||
})
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not save',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -1,207 +0,0 @@
|
||||
<template>
|
||||
<form @submit="onSmtpSubmit" class="space-y-6"
|
||||
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }">
|
||||
|
||||
<!-- Enabled Field -->
|
||||
<FormField name="enabled" v-slot="{ value, handleChange }">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox :checked="value" @update:checked="handleChange" />
|
||||
<Label>Enabled</Label>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- SMTP Host Field -->
|
||||
<FormField v-slot="{ componentField }" name="host">
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="smtp.gmail.com" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- SMTP Port Field -->
|
||||
<FormField v-slot="{ componentField }" name="port">
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="587" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Username Field -->
|
||||
<FormField v-slot="{ componentField }" name="username">
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="admin@yourcompany.com" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Password Field -->
|
||||
<FormField v-slot="{ componentField }" name="password">
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Enter your password" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Max Connections Field -->
|
||||
<FormField v-slot="{ componentField }" name="max_conns">
|
||||
<FormItem>
|
||||
<FormLabel>Max Connections</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="2" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Idle Timeout Field -->
|
||||
<FormField v-slot="{ componentField }" name="idle_timeout">
|
||||
<FormItem>
|
||||
<FormLabel>Idle Timeout</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="15s" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Wait Timeout Field -->
|
||||
<FormField v-slot="{ componentField }" name="wait_timeout">
|
||||
<FormItem>
|
||||
<FormLabel>Wait Timeout</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="5s" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Authentication Protocol Field -->
|
||||
<FormField v-slot="{ componentField }" name="auth_protocol">
|
||||
<FormItem>
|
||||
<FormLabel>Authentication Protocol</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField" v-model="componentField.modelValue">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an authentication protocol" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="plain">Plain</SelectItem>
|
||||
<SelectItem value="login">Login</SelectItem>
|
||||
<SelectItem value="cram">CRAM-MD5</SelectItem>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Email Address Field -->
|
||||
<FormField v-slot="{ componentField }" name="email_address">
|
||||
<FormItem>
|
||||
<FormLabel>From Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="From email address. e.g. My Support <mysupport@example.com>" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>From email address. e.g. My Support <mysupport@example.com></FormDescription>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Max Message Retries Field -->
|
||||
<FormField v-slot="{ componentField }" name="max_msg_retries">
|
||||
<FormItem>
|
||||
<FormLabel>Max Message Retries</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="2" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { smtpConfigSchema } from './formSchema.js'
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from '@/components/ui/form'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps({
|
||||
initialValues: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
submitForm: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: () => 'Save'
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
});
|
||||
|
||||
const smtpForm = useForm({
|
||||
validationSchema: toTypedSchema(smtpConfigSchema)
|
||||
})
|
||||
|
||||
const onSmtpSubmit = smtpForm.handleSubmit((values) => {
|
||||
props.submitForm(values);
|
||||
})
|
||||
|
||||
// Watch for changes in initialValues and update the form.
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
(newValues) => {
|
||||
smtpForm.setValues(newValues);
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -1,52 +0,0 @@
|
||||
<script setup>
|
||||
import { MoreHorizontal } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '@/api'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
|
||||
const router = useRouter()
|
||||
const emit = useEmitter()
|
||||
const props = defineProps({
|
||||
role: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({
|
||||
id: ''
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function edit (id) {
|
||||
router.push({ path: `/admin/oidc/${id}/edit` })
|
||||
}
|
||||
|
||||
async function deleteOIDC (id) {
|
||||
await api.deleteOIDC(id)
|
||||
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
|
||||
model: 'oidc'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" class="w-8 h-8 p-0">
|
||||
<span class="sr-only">Open menu</span>
|
||||
<MoreHorizontal class="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem @click="edit(props.role.id)"> Edit </DropdownMenuItem>
|
||||
<DropdownMenuItem @click="deleteOIDC(props.role.id)"> Delete </DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -1,61 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-5">
|
||||
<PageHeader title="Teams" description="Manage teams, users and roles" />
|
||||
</div>
|
||||
<div class="flex space-x-5">
|
||||
<AdminMenuCard
|
||||
v-for="card in cards"
|
||||
:key="card.title"
|
||||
:onClick="card.onClick"
|
||||
:title="card.title"
|
||||
:subTitle="card.subTitle"
|
||||
:icon="card.icon"
|
||||
>
|
||||
</AdminMenuCard>
|
||||
</div>
|
||||
</div>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Users, UserRoundCog, User } from 'lucide-vue-next'
|
||||
import AdminMenuCard from '@/components/admin/common/MenuCard.vue'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const navigateToUsers = () => {
|
||||
router.push('/admin/teams/users')
|
||||
}
|
||||
|
||||
const navigateToTeams = () => {
|
||||
router.push('/admin/teams/teams')
|
||||
}
|
||||
|
||||
const navigateToRoles = () => {
|
||||
router.push('/admin/teams/roles')
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: 'Users',
|
||||
subTitle: 'Create and manage users.',
|
||||
onClick: navigateToUsers,
|
||||
icon: User
|
||||
},
|
||||
{
|
||||
title: 'Teams',
|
||||
subTitle: 'Create and manage teams.',
|
||||
onClick: navigateToTeams,
|
||||
icon: Users
|
||||
},
|
||||
{
|
||||
title: 'Roles',
|
||||
subTitle: 'Create and manage roles.',
|
||||
onClick: navigateToRoles,
|
||||
icon: UserRoundCog
|
||||
}
|
||||
]
|
||||
</script>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script setup>
|
||||
import { MoreHorizontal } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
team: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({
|
||||
id: ''
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function editTeam(id) {
|
||||
router.push({ path: `/admin/teams/teams/${id}/edit` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" class="w-8 h-8 p-0">
|
||||
<span class="sr-only">Open menu</span>
|
||||
<MoreHorizontal class="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem @click="editTeam(props.team.id)"> Edit </DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -1,86 +0,0 @@
|
||||
<template>
|
||||
<form @submit="onSubmit" class="space-y-6">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Name" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>Select an unique name.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField name="auto_assign_conversations" v-slot="{ value, handleChange }">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox :checked="value" @update:checked="handleChange" />
|
||||
<Label>Auto assign conversations</Label>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>Automatically assign new conversations to agents in this team in a round-robin fashion.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { teamFormSchema } from './teamFormSchema.js'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps({
|
||||
initialValues: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
submitForm: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: () => 'Submit'
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
}
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(teamFormSchema)
|
||||
})
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
props.submitForm(values)
|
||||
})
|
||||
|
||||
// Watch for changes in initialValues and update the form.
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
(newValues) => {
|
||||
form.setValues(newValues)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -1,64 +0,0 @@
|
||||
<template>
|
||||
<div class="mb-5">
|
||||
<CustomBreadcrumb :links="breadcrumbLinks" />
|
||||
</div>
|
||||
<div class="flex justify-end mb-5">
|
||||
<Button @click="navigateToAddTeam" size="sm"> New team </Button>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="data" v-else />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { columns } from '@/components/admin/team/teams/TeamsDataTableColumns.js'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||
import DataTable from '@/components/admin/DataTable.vue'
|
||||
import api from '@/api'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
|
||||
const breadcrumbLinks = [
|
||||
{ path: '/admin/teams', label: 'Teams' },
|
||||
{ path: '/admin/teams/', label: 'Teams' }
|
||||
]
|
||||
|
||||
const router = useRouter()
|
||||
const data = ref([])
|
||||
const isLoading = ref(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
const getData = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await api.getTeams()
|
||||
data.value = response.data.data
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not fetch teams.',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToAddTeam = () => {
|
||||
router.push('/admin/teams/teams/new')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
getData()
|
||||
})
|
||||
</script>
|
||||
@@ -1,12 +0,0 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
export const teamFormSchema = z.object({
|
||||
name: z
|
||||
.string({
|
||||
required_error: 'Team name is required.'
|
||||
})
|
||||
.min(2, {
|
||||
message: 'Team name must be at least 2 characters.'
|
||||
}),
|
||||
auto_assign_conversations: z.boolean().optional()
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
<template>
|
||||
<div class="mb-5">
|
||||
<CustomBreadcrumb :links="breadcrumbLinks" />
|
||||
</div>
|
||||
<div class="flex justify-end mb-5">
|
||||
<Button @click="navigateToAddUser" size="sm"> New user </Button>
|
||||
</div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="data" v-else />
|
||||
</div>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { columns } from '@/components/admin/team/users/UsersDataTableColumns.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import DataTable from '@/components/admin/DataTable.vue'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api'
|
||||
const { toast } = useToast()
|
||||
|
||||
const router = useRouter()
|
||||
const isLoading = ref(false)
|
||||
const data = ref([])
|
||||
const emit = useEmitter()
|
||||
const breadcrumbLinks = [
|
||||
{ path: '/admin/teams', label: 'Teams' },
|
||||
{ path: '#', label: 'Users' }
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
getData()
|
||||
emit.on(EMITTER_EVENTS.REFRESH_LIST, (data) => {
|
||||
if (data?.model === 'user') getData()
|
||||
})
|
||||
})
|
||||
|
||||
const getData = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await api.getUsers()
|
||||
data.value = response.data.data
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Uh oh! Could not fetch users.',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToAddUser = () => {
|
||||
router.push('/admin/teams/users/new')
|
||||
}
|
||||
</script>
|
||||
@@ -1,61 +0,0 @@
|
||||
<script setup>
|
||||
import { MoreHorizontal } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import api from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const emit = useEmitter()
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({
|
||||
id: ''
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const editTemplate = (id) => {
|
||||
router.push({ path: `/admin/templates/${id}/edit` })
|
||||
}
|
||||
|
||||
const deleteTemplate = async (id) => {
|
||||
try {
|
||||
await api.deleteTemplate(id)
|
||||
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
|
||||
model: 'templates'
|
||||
})
|
||||
} catch (error) {
|
||||
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not delete template',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" class="w-8 h-8 p-0">
|
||||
<span class="sr-only">Open menu</span>
|
||||
<MoreHorizontal class="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem @click="editTemplate(props.template.id)"> Edit </DropdownMenuItem>
|
||||
<DropdownMenuItem @click="deleteTemplate(props.template.id)"> Delete </DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -1,11 +0,0 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
export const formSchema = z.object({
|
||||
name: z.string({
|
||||
required_error: 'Template name is required.'
|
||||
}),
|
||||
body: z.string({
|
||||
required_error: 'Template content is required.'
|
||||
}),
|
||||
is_default: z.boolean().optional()
|
||||
})
|
||||
@@ -1,104 +0,0 @@
|
||||
<template>
|
||||
<form @submit="onLocalFsSubmit" class="space-y-6">
|
||||
<FormField v-slot="{ componentField }" name="provider">
|
||||
<FormItem>
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
v-bind="componentField"
|
||||
v-model="componentField.modelValue"
|
||||
@update:modelValue="handleProviderUpdate"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="s3"> S3 </SelectItem>
|
||||
<SelectItem value="localfs"> Local filesystem </SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="upload_path">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>Upload path</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="/home/ubuntu/uploads" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription> Path to the directory where files will be uploaded. </FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { localFsFormSchema } from './formSchema.js'
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from '@/components/ui/form'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps({
|
||||
initialValues: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
submitForm: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: () => 'Submit'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['provider-update'])
|
||||
|
||||
const localFsForm = useForm({
|
||||
validationSchema: toTypedSchema(localFsFormSchema)
|
||||
})
|
||||
|
||||
const onLocalFsSubmit = localFsForm.handleSubmit((values) => {
|
||||
props.submitForm(values)
|
||||
})
|
||||
|
||||
const handleProviderUpdate = (value) => {
|
||||
emit('provider-update', value)
|
||||
}
|
||||
|
||||
// Watch for changes in initialValues and update the form.
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
(newValues) => {
|
||||
localFsForm.setValues(newValues)
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -1,191 +0,0 @@
|
||||
<template>
|
||||
<form @submit="onS3FormSubmit" class="space-y-6">
|
||||
<FormField v-slot="{ componentField }" name="provider">
|
||||
<FormItem>
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
v-bind="componentField"
|
||||
v-model="componentField.modelValue"
|
||||
@update:modelValue="handleProviderUpdate"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="s3"> S3 </SelectItem>
|
||||
<SelectItem value="localfs"> Local filesystem </SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="region">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>Region</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="ap-south-1" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="access_key">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>AWS access key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="AWS access key" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="access_secret">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>AWS access secret</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="AWS access secret" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="bucket_type">
|
||||
<FormItem>
|
||||
<FormLabel>Bucket type</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField" v-model="componentField.modelValue">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select bucket type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="public"> Public </SelectItem>
|
||||
<SelectItem value="private"> Private </SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="bucket">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>Bucket</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Bucket" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="bucket_path">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>Bucket path</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Bucket path" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="upload_expiry">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>Upload expiry</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="24h" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>Only applicable for private buckets.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="url">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>S3 backend URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://ap-south-1.s3.amazonaws.com"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription
|
||||
>Only change if using a custom S3 compatible backend like Minio.</FormDescription
|
||||
>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch, defineEmits } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { s3FormSchema } from './formSchema.js'
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from '@/components/ui/form'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps({
|
||||
initialValues: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
submitForm: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: () => 'Submit'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['provider-update'])
|
||||
|
||||
const s3Form = useForm({
|
||||
validationSchema: toTypedSchema(s3FormSchema)
|
||||
})
|
||||
|
||||
const onS3FormSubmit = s3Form.handleSubmit((values) => {
|
||||
props.submitForm(values)
|
||||
})
|
||||
|
||||
const handleProviderUpdate = (value) => {
|
||||
emit('provider-update', value)
|
||||
}
|
||||
|
||||
// Watch for changes in initialValues and update the form.
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
(newValues) => {
|
||||
s3Form.setValues(newValues)
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -1,64 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="Uploads" description="Manage file upload settings" />
|
||||
</div>
|
||||
<div>
|
||||
<component
|
||||
:is="formProvider === 's3' ? S3Form : LocalFsForm"
|
||||
:submitForm="submitForm"
|
||||
:initialValues="initialValues"
|
||||
submitLabel="Save"
|
||||
@provider-update="handleProviderUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import S3Form from './S3Form.vue'
|
||||
import LocalFsForm from './LocalFsForm.vue'
|
||||
import api from '@/api'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
|
||||
const initialValues = ref({})
|
||||
|
||||
onMounted(async () => {
|
||||
const resp = await api.getSettings('upload')
|
||||
const config = resp.data.data
|
||||
const modifiedConfig = {}
|
||||
for (const key in config) {
|
||||
if (key.startsWith('upload.localfs.')) {
|
||||
const newKey = key.replace('upload.localfs.', '')
|
||||
modifiedConfig[newKey] = config[key]
|
||||
} else if (key.startsWith('upload.s3')) {
|
||||
const newKey = key.replace('upload.s3.', '')
|
||||
modifiedConfig[newKey] = config[key]
|
||||
} else if (key.startsWith('upload.')) {
|
||||
const newKey = key.replace('upload.', '')
|
||||
modifiedConfig[newKey] = config[key]
|
||||
}
|
||||
}
|
||||
initialValues.value = modifiedConfig
|
||||
})
|
||||
|
||||
const submitForm = async (values) => {
|
||||
const prefixedValues = {}
|
||||
for (const key in values) {
|
||||
if (values.provider === 'localfs') {
|
||||
prefixedValues[`upload.localfs.${key}`] = values[key]
|
||||
} else if (values.provider === 's3') {
|
||||
prefixedValues[`upload.s3.${key}`] = values[key]
|
||||
}
|
||||
}
|
||||
prefixedValues['upload.provider'] = values.provider
|
||||
await api.updateSettings('upload', prefixedValues)
|
||||
}
|
||||
|
||||
const formProvider = computed(() => {
|
||||
return initialValues.value.provider
|
||||
})
|
||||
|
||||
const handleProviderUpdate = (value) => {
|
||||
initialValues.value.provider = value
|
||||
}
|
||||
</script>
|
||||
@@ -1,44 +0,0 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
export const s3FormSchema = z.object({
|
||||
provider: z.string({
|
||||
required_error: 'Provider is required.'
|
||||
}),
|
||||
region: z.string({
|
||||
required_error: 'Region is required.'
|
||||
}),
|
||||
access_key: z.string({
|
||||
required_error: 'AWS access key is required.'
|
||||
}),
|
||||
access_secret: z.string({
|
||||
required_error: 'AWS access secret is required.'
|
||||
}),
|
||||
bucket_type: z.string({
|
||||
required_error: 'Bucket type is required.'
|
||||
}),
|
||||
bucket: z.string({
|
||||
required_error: 'Bucket is required.'
|
||||
}),
|
||||
bucket_path: z.string({
|
||||
required_error: 'Bucket path is required.'
|
||||
}),
|
||||
upload_expiry: z.string({
|
||||
required_error: 'Upload expiry is required.'
|
||||
}),
|
||||
url: z
|
||||
.string({
|
||||
required_error: 'S3 backend URL is required.'
|
||||
})
|
||||
.url({
|
||||
message: 'S3 backend URL must be a valid URL.'
|
||||
})
|
||||
})
|
||||
|
||||
export const localFsFormSchema = z.object({
|
||||
provider: z.string({
|
||||
required_error: 'Provider is required.'
|
||||
}),
|
||||
upload_path: z.string({
|
||||
required_error: 'Upload path is required.'
|
||||
})
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<div class="flex m-2 items-end text-sm overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer">
|
||||
<div v-for="attachment in attachments" :key="attachment.uuid"
|
||||
class="flex items-center p-1 bg-[#F5F5F4] gap-1 rounded-md max-w-[15rem]">
|
||||
<!-- Filename tooltip -->
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ getAttachmentName(attachment.filename) }}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{{ attachment.filename }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div>
|
||||
{{ formatBytes(attachment.size) }}
|
||||
</div>
|
||||
<div @click="onDelete(attachment.uuid)">
|
||||
<X size="13" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatBytes } from '@/utils/file.js'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
defineProps({
|
||||
attachments: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
onDelete: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const getAttachmentName = (name) => {
|
||||
return name.substring(0, 20)
|
||||
}
|
||||
</script>
|
||||
@@ -1,162 +0,0 @@
|
||||
<template>
|
||||
<div v-for="(filter, index) in modelValue" :key="index">
|
||||
<div class="flex items-center space-x-2 mb-2 flex-row justify-between">
|
||||
<div class="w-1/3">
|
||||
<Select v-model="filter.field" @update:modelValue="updateFieldModel(filter, $event)">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Select Field" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="field in fields" :key="field.value" :value="field.value">
|
||||
{{ field.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="w-1/3">
|
||||
<Select v-model="filter.operator">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Select Operator" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="operator in getFieldOperators(filter.field)" :key="operator.value"
|
||||
:value="operator.value">
|
||||
{{ operator.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="getFieldType(filter.field) === 'text'" class="w-1/3">
|
||||
<Input v-model="filter.value" type="text" placeholder="Value" class="w-full" />
|
||||
</div>
|
||||
<div v-else-if="getFieldType(filter.field) === 'select'" class="w-1/3">
|
||||
<Select v-model="filter.value">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Select Value" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="option in getFieldOptions(filter.field)" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-else-if="getFieldType(filter.field) === 'number'" class="w-1/3">
|
||||
<Input v-model="filter.value" type="number" placeholder="Value" class="w-full" />
|
||||
</div>
|
||||
<button v-if="modelValue.length > 1" @click="removeFilter(index)"
|
||||
class="flex items-center justify-center w-3 h-3 rounded-full bg-red-100 hover:bg-red-200 transition-colors">
|
||||
<X class="text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-4">
|
||||
<Button size="sm" @click="addFilter">Add Filter</Button>
|
||||
<div class="flex justify-end space-x-4">
|
||||
<Button size="sm" @click="applyFilters">Apply</Button>
|
||||
<Button size="sm" @click="clearFilters">Clear</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['apply', 'clear'])
|
||||
const modelValue = defineModel('modelValue', { required: true })
|
||||
const operatorsByType = {
|
||||
text: [
|
||||
{ label: 'Equals', value: '=' },
|
||||
{ label: 'Not Equals', value: '!=' },
|
||||
],
|
||||
select: [
|
||||
{ label: 'Equals', value: '=' },
|
||||
{ label: 'Not Equals', value: '!=' },
|
||||
],
|
||||
number: [
|
||||
{ label: 'Equals', value: '=' },
|
||||
{ label: 'Not Equals', value: '!=' },
|
||||
{ label: 'Greater Than', value: '>' },
|
||||
{ label: 'Less Than', value: '<' },
|
||||
{ label: 'Greater Than or Equal', value: '>=' },
|
||||
{ label: 'Less Than or Equal', value: '<=' },
|
||||
],
|
||||
}
|
||||
const createFilter = () => ({ model: '', field: '', operator: '', value: '' })
|
||||
|
||||
onMounted(() => {
|
||||
if (modelValue.value.length === 0) {
|
||||
modelValue.value.push(createFilter())
|
||||
}
|
||||
})
|
||||
|
||||
const addFilter = () => {
|
||||
modelValue.value.push(createFilter())
|
||||
}
|
||||
|
||||
const removeFilter = (index) => {
|
||||
modelValue.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const applyFilters = () => {
|
||||
if (validFilters.value.length > 0) emit('apply', validFilters.value)
|
||||
}
|
||||
|
||||
const validFilters = computed(() => {
|
||||
return modelValue.value.filter(filter => filter.field !== "" && filter.operator != "" && filter.value != "")
|
||||
})
|
||||
|
||||
const clearFilters = () => {
|
||||
modelValue.value = []
|
||||
emit('clear')
|
||||
}
|
||||
|
||||
const getFieldOperators = computed(() => (fieldValue) => {
|
||||
const field = props.fields.find(f => f.value === fieldValue)
|
||||
return field ? operatorsByType[field.type] : []
|
||||
})
|
||||
|
||||
const getFieldType = computed(() => (fieldValue) => {
|
||||
const field = props.fields.find(f => f.value === fieldValue)
|
||||
return field ? field.type : 'text'
|
||||
})
|
||||
|
||||
const getFieldOptions = computed(() => (fieldValue) => {
|
||||
const field = props.fields.find(f => f.value === fieldValue)
|
||||
return field && field.options ? field.options : []
|
||||
})
|
||||
|
||||
const updateFieldModel = (filter, fieldValue) => {
|
||||
const field = props.fields.find(f => f.value === fieldValue)
|
||||
if (field) {
|
||||
filter.model = field.model
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<span class="text-2xl">
|
||||
{{ title }}
|
||||
</span>
|
||||
<p class="text-sm-muted">
|
||||
{{ subTitle }}
|
||||
</p>
|
||||
</div>
|
||||
<Separator class="my-3" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subTitle: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<nav class="flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1">
|
||||
<router-link v-for="item in navItems" :key="item.title" :to="item.href">
|
||||
<template v-slot="{ navigate, isActive }">
|
||||
<Button
|
||||
as="a"
|
||||
:href="item.href"
|
||||
variant="ghost"
|
||||
:class="
|
||||
cn(
|
||||
'w-full text-left justify-start h-16 pl-3',
|
||||
isActive || isChildActive(item.href) ? 'bg-muted hover:bg-muted' : ''
|
||||
)
|
||||
"
|
||||
@click="navigate"
|
||||
>
|
||||
<div class="flex flex-col items-start space-y-1">
|
||||
<span class="text-sm">{{ item.title }}</span>
|
||||
<p class="text-muted-foreground text-xs break-words whitespace-normal">{{ item.description }}</p>
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
</router-link>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
defineProps({
|
||||
navItems: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const isChildActive = (href) => {
|
||||
return route.path.startsWith(href)
|
||||
}
|
||||
</script>
|
||||
@@ -1,66 +0,0 @@
|
||||
<template>
|
||||
<div class="relative" v-if="conversationStore.messages.data">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="px-4 border-b h-[47px] flex items-center justify-between shadow shadow-gray-100">
|
||||
<div class="flex items-center space-x-3 text-sm">
|
||||
<div class="font-semibold">
|
||||
{{ conversationStore.current.subject }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Badge variant="primary">
|
||||
{{ conversationStore.current.status }}
|
||||
</Badge>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem v-for="status in statuses" :key="status.name" @click="handleUpdateStatus(status.name)">
|
||||
{{ status.name }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages & reply box -->
|
||||
<div class="flex flex-col h-screen" v-auto-animate>
|
||||
<MessageList class="flex-1" />
|
||||
<ReplyBox class="h-max mb-12" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import MessageList from '@/components/message/MessageList.vue'
|
||||
import ReplyBox from './ReplyBox.vue'
|
||||
import api from '@/api'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const statuses = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
getStatuses()
|
||||
})
|
||||
|
||||
const getStatuses = async () => {
|
||||
const resp = await api.getStatuses()
|
||||
statuses.value = resp.data.data
|
||||
}
|
||||
|
||||
const handleUpdateStatus = (status) => {
|
||||
conversationStore.updateStatus(status)
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<h3 class="h-screen flex items-center justify-center">
|
||||
<div class="flex flex-row items-center justify-center">
|
||||
<p>Select a conversation from the left panel.</p>
|
||||
</div>
|
||||
</h3>
|
||||
</template>
|
||||
@@ -1,154 +0,0 @@
|
||||
<template>
|
||||
<div class="max-h-[600px] overflow-y-auto">
|
||||
<EditorContent :editor="editor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, watchEffect, onUnmounted } from 'vue'
|
||||
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
|
||||
const emit = defineEmits([
|
||||
'send',
|
||||
'editorText',
|
||||
'updateBold',
|
||||
'updateItalic',
|
||||
'contentCleared',
|
||||
'contentSet',
|
||||
'editorReady'
|
||||
])
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: String,
|
||||
isBold: Boolean,
|
||||
isItalic: Boolean,
|
||||
clearContent: Boolean,
|
||||
contentToSet: String
|
||||
})
|
||||
|
||||
const editor = ref(
|
||||
useEditor({
|
||||
content: '',
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
// Common class for all inline images.
|
||||
class: 'inline-image',
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: () => {
|
||||
return props.placeholder
|
||||
}
|
||||
}),
|
||||
Link,
|
||||
],
|
||||
autofocus: true,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
// No outline for the editor.
|
||||
class: 'outline-none'
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
if (editor.value) {
|
||||
// Emit the editor instance when it's ready
|
||||
if (editor.value) {
|
||||
emit('editorReady', editor.value)
|
||||
}
|
||||
|
||||
emit('editorText', {
|
||||
text: editor.value.getText(),
|
||||
html: editor.value.getHTML()
|
||||
})
|
||||
|
||||
// Emit bold and italic state changes
|
||||
emit('updateBold', editor.value.isActive('bold'))
|
||||
emit('updateItalic', editor.value.isActive('italic'))
|
||||
}
|
||||
})
|
||||
|
||||
// Watcher for bold and italic changes
|
||||
watchEffect(() => {
|
||||
if (props.isBold !== editor.value?.isActive('bold')) {
|
||||
if (props.isBold) {
|
||||
editor.value?.chain().focus().setBold().run()
|
||||
} else {
|
||||
editor.value?.chain().focus().unsetBold().run()
|
||||
}
|
||||
}
|
||||
if (props.isItalic !== editor.value?.isActive('italic')) {
|
||||
if (props.isItalic) {
|
||||
editor.value?.chain().focus().setItalic().run()
|
||||
} else {
|
||||
editor.value?.chain().focus().unsetItalic().run()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Watcher for clearContent prop
|
||||
watchEffect(() => {
|
||||
if (props.clearContent) {
|
||||
editor.value?.commands.clearContent()
|
||||
emit('contentCleared')
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.contentToSet,
|
||||
(newContent) => {
|
||||
if (newContent) {
|
||||
// Remove trailing break when setting content
|
||||
editor.value.commands.setContent(newContent)
|
||||
editor.value.commands.focus()
|
||||
emit('contentSet')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
editor.value.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
// Moving placeholder to the top.
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #adb5bd;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
// Editor height
|
||||
.ProseMirror {
|
||||
min-height: 150px !important;
|
||||
max-height: 100% !important;
|
||||
overflow-y: scroll !important;
|
||||
padding: 10px 10px;
|
||||
}
|
||||
|
||||
.tiptap {
|
||||
a {
|
||||
color: #0066cc;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #003d7a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
br.ProseMirror-trailingBreak {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,278 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
<!-- Canned responses -->
|
||||
<div v-if="filteredCannedResponses.length > 0" class="w-full overflow-hidden p-2 border-t backdrop-blur">
|
||||
<ul ref="responsesList" class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<li v-for="(response, index) in filteredCannedResponses" :key="response.id" :class="[
|
||||
'cursor-pointer rounded p-1 hover:bg-secondary',
|
||||
{ 'bg-secondary': index === selectedResponseIndex }
|
||||
]" @click="selectCannedResponse(response.content)" @mouseenter="selectedResponseIndex = index">
|
||||
<span class="font-semibold">{{ response.title }}</span> - {{ getTextFromHTML(response.content).slice(0, 150)
|
||||
}}...
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="border-t">
|
||||
|
||||
<!-- Message type toggle -->
|
||||
<div class="flex justify-between px-2 border-b py-2">
|
||||
<Tabs v-model:model-value="messageType">
|
||||
<TabsList>
|
||||
<TabsTrigger value="reply"> Reply </TabsTrigger>
|
||||
<TabsTrigger value="private_note"> Private note </TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<!-- Main Editor -->
|
||||
<Editor @keydown="handleKeydown" @editorText="handleEditorText" :placeholder="editorPlaceholder" :isBold="isBold"
|
||||
:clearContent="clearContent" :isItalic="isItalic" @updateBold="updateBold" @updateItalic="updateItalic"
|
||||
@contentCleared="handleContentCleared" @contentSet="clearContentToSet" @editorReady="onEditorReady"
|
||||
:contentToSet="contentToSet" :cannedResponses="cannedResponses" />
|
||||
|
||||
|
||||
<!-- Attachments preview -->
|
||||
<AttachmentsPreview :attachments="attachments" :onDelete="handleOnFileDelete"></AttachmentsPreview>
|
||||
|
||||
<!-- Bottom menu bar -->
|
||||
<ReplyBoxBottomMenuBar :handleFileUpload="handleFileUpload" :handleInlineImageUpload="handleInlineImageUpload"
|
||||
:isBold="isBold" :isItalic="isItalic" @toggleBold="toggleBold" @toggleItalic="toggleItalic" :hasText="hasText"
|
||||
:handleSend="handleSend">
|
||||
</ReplyBoxBottomMenuBar>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { transformImageSrcToCID } from '@/utils/strings'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api'
|
||||
|
||||
import { getTextFromHTML } from '@/utils/strings'
|
||||
import Editor from './ConversationTextEditor.vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import AttachmentsPreview from '@/components/attachment/AttachmentsPreview.vue'
|
||||
import ReplyBoxBottomMenuBar from '@/components/conversation/ReplyBoxMenuBar.vue'
|
||||
|
||||
const emitter = useEmitter()
|
||||
const clearContent = ref(false)
|
||||
const isBold = ref(false)
|
||||
const isItalic = ref(false)
|
||||
const editorText = ref('')
|
||||
const editorHTML = ref('')
|
||||
const contentToSet = ref('')
|
||||
const conversationStore = useConversationStore()
|
||||
const filteredCannedResponses = ref([])
|
||||
const uploadedFiles = ref([])
|
||||
const messageType = ref('reply')
|
||||
const selectedResponseIndex = ref(-1)
|
||||
const responsesList = ref(null)
|
||||
let editorInstance = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await api.getCannedResponses()
|
||||
cannedResponses.value = resp.data.data
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
})
|
||||
|
||||
const cannedResponses = ref([])
|
||||
|
||||
const updateBold = (newState) => {
|
||||
isBold.value = newState
|
||||
}
|
||||
|
||||
const updateItalic = (newState) => {
|
||||
isItalic.value = newState
|
||||
}
|
||||
|
||||
const toggleBold = () => {
|
||||
isBold.value = !isBold.value
|
||||
}
|
||||
|
||||
const toggleItalic = () => {
|
||||
isItalic.value = !isItalic.value
|
||||
}
|
||||
|
||||
const editorPlaceholder = computed(() => {
|
||||
return "Press enter to add a new line; Press '/' to select a Canned Response."
|
||||
})
|
||||
|
||||
const attachments = computed(() => {
|
||||
return uploadedFiles.value.filter(upload => upload.disposition === 'attachment')
|
||||
})
|
||||
|
||||
const filterCannedResponses = (input) => {
|
||||
// Extract the text after the last `/`
|
||||
const lastSlashIndex = input.lastIndexOf('/')
|
||||
if (lastSlashIndex !== -1) {
|
||||
const searchText = input.substring(lastSlashIndex + 1).trim()
|
||||
|
||||
// Filter canned responses based on the search text
|
||||
filteredCannedResponses.value = cannedResponses.value.filter((response) =>
|
||||
response.title.toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
|
||||
// Reset the selected response index
|
||||
selectedResponseIndex.value = filteredCannedResponses.value.length > 0 ? 0 : -1
|
||||
} else {
|
||||
filteredCannedResponses.value = []
|
||||
selectedResponseIndex.value = -1
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditorText = (text) => {
|
||||
editorText.value = text.text
|
||||
editorHTML.value = text.html
|
||||
filterCannedResponses(text.text)
|
||||
}
|
||||
|
||||
const hasText = computed(() => {
|
||||
return editorText.value.length > 0 ? true : false
|
||||
})
|
||||
|
||||
const onEditorReady = (editor) => {
|
||||
editorInstance.value = editor
|
||||
}
|
||||
|
||||
const handleFileUpload = (event) => {
|
||||
for (const file of event.target.files) {
|
||||
api
|
||||
.uploadMedia({
|
||||
files: file,
|
||||
inline: false,
|
||||
linked_model: "messages",
|
||||
})
|
||||
.then((resp) => {
|
||||
uploadedFiles.value.push(resp.data.data)
|
||||
})
|
||||
.catch((error) => {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error uploading file',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleInlineImageUpload = (event) => {
|
||||
for (const file of event.target.files) {
|
||||
api
|
||||
.uploadMedia({
|
||||
files: file,
|
||||
inline: true,
|
||||
linked_model: "messages",
|
||||
})
|
||||
.then((resp) => {
|
||||
editorInstance.value.commands.setImage({
|
||||
src: resp.data.data.url,
|
||||
alt: resp.data.data.filename,
|
||||
title: resp.data.data.uuid,
|
||||
})
|
||||
uploadedFiles.value.push(resp.data.data)
|
||||
})
|
||||
.catch((error) => {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error uploading file',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleContentCleared = () => {
|
||||
clearContent.value = false
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
try {
|
||||
// Replace inline image url with cid.
|
||||
const message = transformImageSrcToCID(editorHTML.value)
|
||||
|
||||
// Check which images are still in editor before sending.
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(editorHTML.value, 'text/html')
|
||||
const inlineImageUUIDs = Array.from(doc.querySelectorAll('img.inline-image'))
|
||||
.map(img => img.getAttribute('title'))
|
||||
.filter(Boolean)
|
||||
|
||||
uploadedFiles.value = uploadedFiles.value.filter(file =>
|
||||
// Keep if:
|
||||
// 1. Not an inline image OR
|
||||
// 2. Is an inline image that exists in editor
|
||||
file.disposition !== 'inline' || inlineImageUUIDs.includes(file.uuid)
|
||||
)
|
||||
|
||||
await api.sendMessage(conversationStore.current.uuid, {
|
||||
private: messageType.value === 'private_note',
|
||||
message: message,
|
||||
attachments: uploadedFiles.value.map((file) => file.id)
|
||||
})
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error sending message',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
clearContent.value = true
|
||||
uploadedFiles.value = []
|
||||
}
|
||||
api.updateAssigneeLastSeen(conversationStore.current.uuid)
|
||||
}
|
||||
|
||||
const handleOnFileDelete = (uuid) => {
|
||||
uploadedFiles.value = uploadedFiles.value.filter((item) => item.uuid !== uuid)
|
||||
}
|
||||
|
||||
const handleKeydown = (event) => {
|
||||
if (filteredCannedResponses.value.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
selectedResponseIndex.value =
|
||||
(selectedResponseIndex.value + 1) % filteredCannedResponses.value.length
|
||||
scrollToSelectedItem()
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
selectedResponseIndex.value =
|
||||
(selectedResponseIndex.value - 1 + filteredCannedResponses.value.length) %
|
||||
filteredCannedResponses.value.length
|
||||
scrollToSelectedItem()
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
selectCannedResponse(filteredCannedResponses.value[selectedResponseIndex.value].content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToSelectedItem = () => {
|
||||
const list = responsesList.value
|
||||
const selectedItem = list.children[selectedResponseIndex.value]
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const selectCannedResponse = (content) => {
|
||||
contentToSet.value = content
|
||||
filteredCannedResponses.value = []
|
||||
selectedResponseIndex.value = -1
|
||||
}
|
||||
|
||||
const clearContentToSet = () => {
|
||||
contentToSet.value = null
|
||||
}
|
||||
</script>
|
||||
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<div class="flex justify-between items-center border-y h-14 px-2">
|
||||
<div class="flex justify-items-start gap-2">
|
||||
<!-- File inputs -->
|
||||
<input type="file" class="hidden" ref="attachmentInput" multiple @change="handleFileUpload" />
|
||||
<input type="file" class="hidden" ref="inlineImageInput" accept="image/*" @change="handleInlineImageUpload" />
|
||||
<!-- Editor buttons -->
|
||||
<Toggle class="px-2 py-2 border-0" variant="outline" @click="toggleBold" :pressed="isBold">
|
||||
<Bold class="h-4 w-4" />
|
||||
</Toggle>
|
||||
<Toggle class="px-2 py-2 border-0" variant="outline" @click="toggleItalic" :pressed="isItalic">
|
||||
<Italic class="h-4 w-4" />
|
||||
</Toggle>
|
||||
<Toggle class="px-2 py-2 border-0" variant="outline" @click="triggerFileUpload" :pressed="false">
|
||||
<Paperclip class="h-4 w-4" />
|
||||
</Toggle>
|
||||
<Toggle class="px-2 py-2 border-0" variant="outline" @click="triggerInlineImage" :pressed="false">
|
||||
<Image class="h-4 w-4" />
|
||||
</Toggle>
|
||||
</div>
|
||||
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!hasText">Send</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Toggle } from '@/components/ui/toggle'
|
||||
import { Paperclip, Bold, Italic, Image } from 'lucide-vue-next'
|
||||
|
||||
const attachmentInput = ref(null)
|
||||
const inlineImageInput = ref(null)
|
||||
const emit = defineEmits(['toggleBold', 'toggleItalic'])
|
||||
defineProps({
|
||||
isBold: Boolean,
|
||||
isItalic: Boolean,
|
||||
hasText: Boolean,
|
||||
handleSend: Function,
|
||||
handleFileUpload: Function,
|
||||
handleInlineImageUpload: Function
|
||||
})
|
||||
|
||||
const toggleBold = () => {
|
||||
emit('toggleBold')
|
||||
}
|
||||
|
||||
const toggleItalic = () => {
|
||||
emit('toggleItalic')
|
||||
}
|
||||
|
||||
const triggerFileUpload = () => {
|
||||
attachmentInput.value.click()
|
||||
}
|
||||
|
||||
const triggerInlineImage = () => {
|
||||
inlineImageInput.value.click()
|
||||
}
|
||||
</script>
|
||||
@@ -1,92 +0,0 @@
|
||||
<template>
|
||||
<div class="h-screen flex flex-col">
|
||||
<!-- Filters -->
|
||||
<div class="shrink-0">
|
||||
<ConversationListFilters @updateFilters="handleUpdateFilters" />
|
||||
</div>
|
||||
|
||||
<!-- Empty list -->
|
||||
<EmptyList class="px-4" v-if="!hasConversations && !hasErrored && !isLoading" title="No conversations found"
|
||||
message="Try adjusting filters." :icon="MessageCircleQuestion"></EmptyList>
|
||||
|
||||
|
||||
<!-- List -->
|
||||
<div class="flex-grow overflow-y-auto">
|
||||
<EmptyList class="px-4" v-if="conversationStore.conversations.errorMessage" title="Could not fetch conversations"
|
||||
:message="conversationStore.conversations.errorMessage" :icon="MessageCircleWarning"></EmptyList>
|
||||
|
||||
<!-- Items -->
|
||||
<div v-else>
|
||||
<ConversationListItem :conversation="conversation" :currentConversation="conversationStore.current"
|
||||
v-for="conversation in conversationStore.sortedConversations" :key="conversation.uuid"
|
||||
:contactFullName="conversationStore.getContactFullName(conversation.uuid)" />
|
||||
</div>
|
||||
|
||||
<!-- List skeleton -->
|
||||
<div v-if="isLoading">
|
||||
<ConversationListItemSkeleton v-for="index in 10" :key="index" />
|
||||
</div>
|
||||
|
||||
<!-- Load more -->
|
||||
<div class="flex justify-center items-center p-5 relative" v-if="!hasErrored">
|
||||
<div v-if="conversationStore.conversations.hasMore">
|
||||
<Button variant="link" @click="loadNextPage">
|
||||
<p v-if="!isLoading">Load more</p>
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else-if="!conversationStore.conversations.hasMore && hasConversations">
|
||||
All conversations loaded!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, computed, onUnmounted } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { MessageCircleQuestion, MessageCircleWarning } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import EmptyList from '@/components/conversation/list/ConversationEmptyList.vue'
|
||||
import ConversationListItem from '@/components/conversation/list/ConversationListItem.vue'
|
||||
import ConversationListItemSkeleton from '@/components/conversation/list/ConversationListItemSkeleton.vue'
|
||||
import ConversationListFilters from '@/components/conversation/list/ConversationListFilters.vue'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
let listRefreshInterval = null
|
||||
|
||||
onMounted(() => {
|
||||
conversationStore.fetchConversationsList()
|
||||
// Refresh list every min.
|
||||
listRefreshInterval = setInterval(() => {
|
||||
conversationStore.fetchConversationsList(false)
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(listRefreshInterval)
|
||||
conversationStore.clearListReRenderInterval()
|
||||
})
|
||||
|
||||
const loadNextPage = () => {
|
||||
conversationStore.fetchNextConversations()
|
||||
}
|
||||
|
||||
const handleUpdateFilters = (filters) => {
|
||||
console.log("setting ", filters)
|
||||
conversationStore.setConversationListFilters(filters)
|
||||
}
|
||||
|
||||
const hasConversations = computed(() => {
|
||||
return conversationStore.sortedConversations.length !== 0
|
||||
})
|
||||
|
||||
const hasErrored = computed(() => {
|
||||
return conversationStore.conversations.errorMessage ? true : false
|
||||
})
|
||||
|
||||
const isLoading = computed(() => {
|
||||
return conversationStore.conversations.loading
|
||||
})
|
||||
</script>
|
||||
@@ -1,97 +0,0 @@
|
||||
<template>
|
||||
<div class="flex justify-between px-2 py-2 border-b w-full">
|
||||
<Tabs v-model="conversationStore.conversations.type">
|
||||
<TabsList class="w-full flex justify-evenly">
|
||||
<TabsTrigger value="assigned" class="w-full">Assigned</TabsTrigger>
|
||||
<TabsTrigger value="unassigned" class="w-full">Unassigned</TabsTrigger>
|
||||
<TabsTrigger value="all" class="w-full">All</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<div class="flex items-center mr-2 relative">
|
||||
<span class="absolute inline-flex h-2 w-2 rounded-full bg-primary opacity-75 right-0 bottom-5 z-20"
|
||||
v-if="conversationStore.conversations.filters.length > 0" />
|
||||
<ListFilter size="27"
|
||||
class="mx-auto cursor-pointer transition-all transform hover:scale-110 hover:bg-secondary hover:bg-opacity-80 p-1 rounded-md z-10" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[450px]">
|
||||
<Filter v-model:modelValue="localFilters" :fields="fields" @apply="handleApply" @clear="handleClear" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ListFilter } from 'lucide-vue-next'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import Filter from '@/components/common/Filter.vue'
|
||||
import api from '@/api'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const open = ref(false)
|
||||
const localFilters = ref([])
|
||||
const statuses = ref([])
|
||||
const priorities = ref([])
|
||||
const emit = defineEmits(['updateFilters'])
|
||||
|
||||
onMounted(() => {
|
||||
fetchInitialData()
|
||||
localFilters.value = [...conversationStore.conversations.filters]
|
||||
})
|
||||
|
||||
const fetchInitialData = async () => {
|
||||
const [statusesResp, prioritiesResp] = await Promise.all([
|
||||
api.getStatuses(),
|
||||
api.getPriorities()
|
||||
])
|
||||
|
||||
statuses.value = statusesResp.data.data.map(status => ({
|
||||
label: status.name,
|
||||
value: status.id.toString(),
|
||||
}))
|
||||
|
||||
priorities.value = prioritiesResp.data.data.map(priority => ({
|
||||
label: priority.name,
|
||||
value: priority.id.toString(),
|
||||
}))
|
||||
}
|
||||
|
||||
const fields = ref([
|
||||
{
|
||||
model: 'conversations',
|
||||
label: 'Status',
|
||||
value: 'status_id',
|
||||
type: 'select',
|
||||
options: statuses,
|
||||
},
|
||||
{
|
||||
model: 'conversations',
|
||||
label: 'Priority',
|
||||
value: 'priority_id',
|
||||
type: 'select',
|
||||
options: priorities,
|
||||
},
|
||||
{
|
||||
model: 'conversations',
|
||||
label: 'Reference number',
|
||||
value: 'reference_number',
|
||||
type: 'text',
|
||||
}
|
||||
])
|
||||
|
||||
const handleApply = (filters) => {
|
||||
emit('updateFilters', filters)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
localFilters.value = []
|
||||
emit('updateFilters', [])
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,67 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center cursor-pointer flex-row hover:bg-slate-50"
|
||||
:class="{ 'bg-slate-100': conversation.uuid === currentConversation?.uuid }"
|
||||
@click="router.push('/conversations/' + conversation.uuid)">
|
||||
|
||||
<div class="pl-3">
|
||||
<Avatar class="size-[45px]">
|
||||
<AvatarImage :src="conversation.avatar_url" v-if="conversation.avatar_url" />
|
||||
<AvatarFallback>
|
||||
{{ conversation.contact.first_name.substring(0, 2).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
<div class="ml-3 w-full border-b pb-2">
|
||||
<div class="flex justify-between pt-2 pr-3">
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 flex gap-x-1">
|
||||
<Mail size="13" />
|
||||
{{ conversation.inbox_name }}
|
||||
</p>
|
||||
<p class="text-base font-normal">
|
||||
{{ contactFullName }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-muted-foreground" v-if="conversation.last_message_at">
|
||||
{{ formatTime(conversation.last_message_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-2 pr-3">
|
||||
<div class="flex justify-between">
|
||||
<p class="text-gray-800 max-w-xs text-sm dark:text-white text-ellipsis flex gap-1">
|
||||
<CheckCheck :size="14" /> {{ trimmedLastMessage }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center bg-green-500 rounded-full w-[20px] h-[20px]"
|
||||
v-if="conversation.unread_message_count > 0">
|
||||
<span class="text-white text-xs font-extrabold">
|
||||
{{ conversation.unread_message_count }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { formatTime } from '@/utils/datetime'
|
||||
import { Mail, CheckCheck } from 'lucide-vue-next'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
|
||||
const router = useRouter()
|
||||
const props = defineProps({
|
||||
conversation: Object,
|
||||
currentConversation: Object,
|
||||
contactFullName: String
|
||||
})
|
||||
|
||||
const trimmedLastMessage = computed(() => {
|
||||
const message = props.conversation.last_message || ''
|
||||
return message.length > 45 ? message.slice(0, 45) + "..." : message
|
||||
})
|
||||
</script>
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-5 p-6 border-b">
|
||||
<Skeleton class="h-12 w-12 rounded-full" />
|
||||
<div class="space-y-2">
|
||||
<Skeleton class="h-4 w-[250px]" />
|
||||
<Skeleton class="h-4 w-[200px]" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
</script>
|
||||
@@ -1,75 +0,0 @@
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
|
||||
{{ assignedAgentName }}
|
||||
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="p-0">
|
||||
<Command @update:searchTerm="handleFilterAgents">
|
||||
<CommandInput class="h-9" placeholder="Search agent" />
|
||||
<CommandEmpty>No agent found.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem v-for="agent in filteredAgents" :key="agent.id" :value="agent.id"
|
||||
@select="handleSelectAgent(agent.id)">
|
||||
{{ `${agent.first_name} ${agent.last_name}` }}
|
||||
<CheckIcon :class="cn(
|
||||
'ml-auto h-4 w-4',
|
||||
conversation.assigned_user_id === agent.id ? 'opacity-100' : 'opacity-0'
|
||||
)" />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { CaretSortIcon, CheckIcon } from '@radix-icons/vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
Command,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
|
||||
const open = ref(false)
|
||||
const props = defineProps({
|
||||
conversation: Object,
|
||||
selectAgent: Function,
|
||||
agents: Array,
|
||||
})
|
||||
const filteredAgents = ref([])
|
||||
|
||||
const assignedAgentName = computed(() => {
|
||||
const assignedUserId = props.conversation.assigned_user_id
|
||||
if (!assignedUserId) return 'Select agent'
|
||||
const agent = props.agents.find(agent => agent.id === assignedUserId)
|
||||
return agent ? `${agent.first_name} ${agent.last_name}` : 'Select agent'
|
||||
})
|
||||
|
||||
const handleFilterAgents = (search) => {
|
||||
filteredAgents.value = props.agents.filter(agent =>
|
||||
`${agent.first_name.toLowerCase()} ${agent.last_name.toLowerCase()}`.includes(search.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
const handleSelectAgent = (id) => {
|
||||
props.selectAgent(id)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
watch(() => props.agents, (newAgents) => {
|
||||
filteredAgents.value = [...newAgents]
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
@@ -1,74 +0,0 @@
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
|
||||
{{ assignedTeamName }}
|
||||
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="p-0">
|
||||
<Command @update:searchTerm="handleFilterTeams">
|
||||
<CommandInput class="h-9" placeholder="Search team" />
|
||||
<CommandEmpty>No team found.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem v-for="team in filteredTeams" :key="team.id" :value="team.id" @select="handleSelectTeam(team.id)">
|
||||
{{ team.name }}
|
||||
<CheckIcon :class="cn(
|
||||
'ml-auto h-4 w-4',
|
||||
conversation.assigned_team_id === team.id ? 'opacity-100' : 'opacity-0'
|
||||
)" />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { CaretSortIcon, CheckIcon } from '@radix-icons/vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
Command,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
|
||||
const open = ref(false)
|
||||
const props = defineProps({
|
||||
conversation: Object,
|
||||
selectTeam: Function,
|
||||
teams: Array,
|
||||
})
|
||||
const filteredTeams = ref([])
|
||||
|
||||
const assignedTeamName = computed(() => {
|
||||
const assignedTeamId = props.conversation.assigned_team_id
|
||||
if (!assignedTeamId) return 'Select team'
|
||||
const team = props.teams.find(team => team.id === assignedTeamId)
|
||||
return team ? team.name : 'Select team'
|
||||
})
|
||||
|
||||
const handleFilterTeams = (search) => {
|
||||
filteredTeams.value = props.teams.filter(team =>
|
||||
team.name.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
const handleSelectTeam = (id) => {
|
||||
props.selectTeam(id)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
watch(() => props.teams, (newTeams) => {
|
||||
filteredTeams.value = [...newTeams]
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">Reference number</p>
|
||||
<p>
|
||||
#{{ conversation.reference_number }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">Initiated at</p>
|
||||
<p>
|
||||
{{ format(conversation.created_at, 'PPpp') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">First reply at</p>
|
||||
<p v-if="conversation.first_reply_at">
|
||||
{{ format(conversation.first_reply_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">Resolved at</p>
|
||||
<p v-if="conversation.resolved_at">
|
||||
{{ format(conversation.resolved_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">Closed at</p>
|
||||
<p v-if="conversation.closed_at">
|
||||
{{ format(conversation.closed_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { format } from 'date-fns'
|
||||
defineProps({
|
||||
conversation: Object
|
||||
})
|
||||
</script>
|
||||
@@ -1,168 +0,0 @@
|
||||
<template>
|
||||
<div class="p-3">
|
||||
<ConversationSideBarContact :conversation="conversationStore.current"></ConversationSideBarContact>
|
||||
<Accordion type="multiple" collapsible class="border-t mt-4" :default-value="['Actions', 'Information']">
|
||||
<AccordionItem value="Actions">
|
||||
<AccordionTrigger>
|
||||
<h4 class="scroll-m-20 text-base font-medium tracking-tight">
|
||||
Actions
|
||||
</h4>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent class="space-y-5">
|
||||
<!-- Agent -->
|
||||
<AssignAgent :agents="agents" :conversation="conversationStore.current" :selectAgent="selectAgent">
|
||||
</AssignAgent>
|
||||
<!-- Team -->
|
||||
<AssignTeam :teams="teams" :conversation="conversationStore.current" :selectTeam="selectTeam">
|
||||
</AssignTeam>
|
||||
<!-- Priority -->
|
||||
<PriorityChange :priorities="priorities" :conversation="conversationStore.current"
|
||||
:selectPriority="selectPriority"></PriorityChange>
|
||||
<!-- Tags -->
|
||||
<SelectTag v-model="conversationStore.current.tags" :items="tags" placeHolder="Select tags"></SelectTag>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="Information">
|
||||
<AccordionTrigger>
|
||||
<span class="scroll-m-20 text-base font-medium tracking-tight">
|
||||
Information
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ConversationInfo :conversation="conversationStore.current"></ConversationInfo>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import api from '@/api'
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger
|
||||
} from '@/components/ui/accordion'
|
||||
import ConversationInfo from './ConversationInfo.vue'
|
||||
import ConversationSideBarContact from '@/components/conversation/sidebar/ConversationSideBarContact.vue'
|
||||
import AssignAgent from './AssignAgent.vue'
|
||||
import AssignTeam from './AssignTeam.vue'
|
||||
import PriorityChange from './PriorityChange.vue'
|
||||
import { SelectTag } from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
|
||||
const { toast } = useToast()
|
||||
const conversationStore = useConversationStore()
|
||||
const priorities = ref([])
|
||||
const agents = ref([])
|
||||
const teams = ref([])
|
||||
const tags = ref([])
|
||||
const tagIDMap = {}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
fetchUsers(),
|
||||
fetchTeams(),
|
||||
fetchTags(),
|
||||
getPrioritites()
|
||||
])
|
||||
})
|
||||
|
||||
watch(() => conversationStore.current.tags, () => {
|
||||
handleUpsertTags()
|
||||
}, { deep: true })
|
||||
|
||||
const handleUpsertTags = () => {
|
||||
let tagIDs = conversationStore.current.tags.map((tag) => {
|
||||
if (tag in tagIDMap) {
|
||||
return tagIDMap[tag]
|
||||
}
|
||||
})
|
||||
conversationStore.upsertTags({
|
||||
tag_ids: JSON.stringify(tagIDs)
|
||||
})
|
||||
}
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const resp = await api.getUsersCompact()
|
||||
agents.value = resp.data.data
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not fetch users',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTeams = async () => {
|
||||
try {
|
||||
const resp = await api.getTeamsCompact()
|
||||
teams.value = resp.data.data
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not fetch teams',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const resp = await api.getTags()
|
||||
resp.data.data.forEach(item => {
|
||||
tagIDMap[item.name] = item.id
|
||||
tags.value.push(item.name)
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not fetch tags',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getPrioritites = async () => {
|
||||
const resp = await api.getPriorities()
|
||||
priorities.value = resp.data.data.map((priority) => priority.name)
|
||||
}
|
||||
|
||||
const handleAssignedUserChange = (id) => {
|
||||
conversationStore.updateAssignee('user', {
|
||||
assignee_id: id
|
||||
})
|
||||
}
|
||||
|
||||
const handleAssignedTeamChange = (id) => {
|
||||
conversationStore.updateAssignee('team', {
|
||||
assignee_id: id
|
||||
})
|
||||
}
|
||||
|
||||
const handlePriorityChange = (priority) => {
|
||||
conversationStore.updatePriority(priority)
|
||||
}
|
||||
|
||||
const selectAgent = (id) => {
|
||||
conversationStore.current.assigned_user_id = id
|
||||
handleAssignedUserChange(id)
|
||||
}
|
||||
|
||||
const selectTeam = (id) => {
|
||||
conversationStore.current.assigned_team_id = id
|
||||
handleAssignedTeamChange(id)
|
||||
}
|
||||
|
||||
const selectPriority = (priority) => {
|
||||
conversationStore.current.priority = priority
|
||||
handlePriorityChange(priority)
|
||||
}
|
||||
</script>
|
||||
@@ -1,29 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<Avatar class="size-20">
|
||||
<AvatarImage :src="conversation?.avatar_url" v-if="conversation?.avatar_url" />
|
||||
<AvatarFallback>
|
||||
{{ conversation?.contact.first_name.toUpperCase().substring(0, 2) }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<h4 class="mt-3">
|
||||
{{ conversation?.contact.first_name + ' ' + conversation?.contact.last_name }}
|
||||
</h4>
|
||||
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.contact.email">
|
||||
<Mail class="size-3 mt-1"></Mail>
|
||||
{{ conversation.contact.email }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.contact.phone_number">
|
||||
<Phone class="size-3 mt-1"></Phone>
|
||||
{{ conversation.contact.phone_number }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Mail, Phone } from 'lucide-vue-next'
|
||||
defineProps({
|
||||
conversation: Object
|
||||
});
|
||||
</script>
|
||||
@@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
|
||||
{{
|
||||
selectedPriority
|
||||
}}
|
||||
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="p-0 PopoverContent">
|
||||
<Command @update:searchTerm="handleFilterPriorities">
|
||||
<CommandInput class="h-9" placeholder="Search priority" />
|
||||
<CommandEmpty>No priority found.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem v-for="priority in filteredPriorities" :key="priority" :value="priority"
|
||||
@select="handleSelectPriority(priority)">
|
||||
{{ priority }}
|
||||
<CheckIcon :class="cn(
|
||||
'ml-auto h-4 w-4',
|
||||
conversation.priority === priority ? 'opacity-100' : 'opacity-0'
|
||||
)" />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { CaretSortIcon, CheckIcon } from '@radix-icons/vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
Command,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
|
||||
const open = ref(false)
|
||||
const props = defineProps({
|
||||
priorities: Array,
|
||||
conversation: Object,
|
||||
selectPriority: Function,
|
||||
})
|
||||
const filteredPriorities = ref([])
|
||||
|
||||
const selectedPriority = computed(() => {
|
||||
return props.conversation.priority
|
||||
? props.priorities.find(priority => priority === props.conversation.priority)
|
||||
: 'Select priority'
|
||||
})
|
||||
|
||||
const handleFilterPriorities = (search) => {
|
||||
filteredPriorities.value = props.priorities.filter(priority =>
|
||||
priority.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
watch(() => props.priorities, (newPriorities) => {
|
||||
filteredPriorities.value = [...newPriorities]
|
||||
}, { immediate: true })
|
||||
|
||||
const handleSelectPriority = (priority) => {
|
||||
props.selectPriority(priority)
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,29 +0,0 @@
|
||||
<template>
|
||||
<div class="flex gap-x-5">
|
||||
<Card class="w-1/6 box" v-for="(value, key) in counts" :key="key">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-2xl">
|
||||
{{ value }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{{ labels[key] }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
defineProps({
|
||||
counts: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
labels: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user