Merge branch 'mvp'

This commit is contained in:
Abhinav Raut
2025-02-20 12:25:36 +05:30
431 changed files with 29258 additions and 19780 deletions
-2
View File
@@ -1,2 +0,0 @@
*.go linguist-detectable=true
*.vue linguist-detectable=false
+3 -2
View File
@@ -1,4 +1,5 @@
node_modules
config.toml
artemis.bin
uploads/*
libredesk.bin
uploads/*
.env
+35 -12
View File
@@ -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
+38 -1
View File
@@ -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.
+29
View File
@@ -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
View File
@@ -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
View File
@@ -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")
}
+107
View File
@@ -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)
}
-98
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(&macro, "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(&macro, "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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
)
+46
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,4 @@
# frontend-tailwind
# Libredesk frontend
This template should help get you started developing with Vue 3 in Vite.
+1 -1
View File
@@ -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>
+8 -6
View File
@@ -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"
}
+8250
View File
File diff suppressed because it is too large Load Diff
+166 -74
View File
@@ -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>
+8 -8
View File
@@ -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
View File
@@ -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,
}
+182 -74
View File
@@ -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;
}
}
-109
View File
@@ -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 &lt;mysupport@example.com&gt;</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>
-162
View File
@@ -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