mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-03 05:23:48 +00:00
WIP: MVP with shadcn sidebar
- csat - SLA - email notification templates
This commit is contained in:
@@ -3,8 +3,8 @@ package main
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
@@ -55,7 +55,12 @@ func handleOIDCCallback(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Set the session.
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
104
cmd/business_hours.go
Normal file
104
cmd/business_hours.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
models "github.com/abhinavxd/artemis/internal/business_hours/models"
|
||||
"github.com/abhinavxd/artemis/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 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), 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)
|
||||
}
|
||||
@@ -3,12 +3,15 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||
"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"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
@@ -23,13 +26,23 @@ 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
|
||||
}
|
||||
|
||||
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
calculateSLA(app, &conversations[i])
|
||||
}
|
||||
}
|
||||
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Results: conversations,
|
||||
Total: total,
|
||||
@@ -43,21 +56,29 @@ 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
|
||||
}
|
||||
|
||||
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
calculateSLA(app, &conversations[i])
|
||||
}
|
||||
}
|
||||
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Results: conversations,
|
||||
Total: total,
|
||||
@@ -71,21 +92,130 @@ 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
|
||||
}
|
||||
|
||||
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
calculateSLA(app, &conversations[i])
|
||||
}
|
||||
}
|
||||
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Results: conversations,
|
||||
Total: total,
|
||||
PerPage: pageSize,
|
||||
TotalPages: (total + pageSize - 1) / pageSize,
|
||||
Page: page,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetViewConversations retrieves conversations for a view.
|
||||
func handleGetViewConversations(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
viewID, _ = strconv.Atoi(r.RequestCtx.UserValue("view_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
|
||||
)
|
||||
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 != user.ID {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You don't have access to this view.", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
conversations, err := app.conversation.GetViewConversationsList(user.ID, view.InboxType, order, orderBy, string(view.Filters), page, pageSize)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if len(conversations) > 0 {
|
||||
total = conversations[0].Total
|
||||
}
|
||||
|
||||
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
calculateSLA(app, &conversations[i])
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
user = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
teamIDStr = r.RequestCtx.UserValue("team_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, user.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
|
||||
}
|
||||
|
||||
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
calculateSLA(app, &conversations[i])
|
||||
}
|
||||
}
|
||||
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Results: conversations,
|
||||
Total: total,
|
||||
@@ -98,29 +228,55 @@ func handleGetUnassignedConversations(r *fastglue.Request) error {
|
||||
// handleGetConversation retrieves a single conversation by UUID with permission checks.
|
||||
func handleGetConversation(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)
|
||||
)
|
||||
conversation, err := enforceConversationAccess(app, uuid, user)
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
conversation, err := app.conversation.GetConversation(0, uuid)
|
||||
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))
|
||||
}
|
||||
|
||||
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||
if conversation.SLAPolicyID.Int != 0 {
|
||||
calculateSLA(app, &conversation)
|
||||
}
|
||||
return r.SendEnvelope(conversation)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
conversation, err := app.conversation.GetConversation(0, uuid)
|
||||
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.UpdateConversationAssigneeLastSeen(uuid); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -130,14 +286,25 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
|
||||
// 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)
|
||||
}
|
||||
conversation, err := app.conversation.GetConversation(0, uuid)
|
||||
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))
|
||||
}
|
||||
p, err := app.conversation.GetConversationParticipants(uuid)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
@@ -148,21 +315,33 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
|
||||
// handleUpdateConversationUserAssignee updates the user assigned to a conversation.
|
||||
func handleUpdateConversationUserAssignee(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")
|
||||
)
|
||||
|
||||
assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id")
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError)
|
||||
if assigneeID == 0 {
|
||||
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)
|
||||
}
|
||||
|
||||
conversation, err := app.conversation.GetConversation(0, uuid)
|
||||
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.UpdateConversationUserAssignee(uuid, assigneeID, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -176,18 +355,31 @@ func handleUpdateConversationUserAssignee(r *fastglue.Request) error {
|
||||
// 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)
|
||||
}
|
||||
|
||||
conversation, err := app.conversation.GetConversation(0, uuid)
|
||||
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.UpdateConversationTeamAssignee(uuid, assigneeID, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -204,12 +396,23 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
|
||||
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)
|
||||
)
|
||||
_, err := enforceConversationAccess(app, uuid, user)
|
||||
conversation, err := app.conversation.GetConversation(0, uuid)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
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, priority, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -223,23 +426,33 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
|
||||
// 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)
|
||||
p = r.RequestCtx.PostArgs()
|
||||
status = p.Peek("status")
|
||||
snoozedUntil = p.Peek("snoozed_until")
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
_, err := enforceConversationAccess(app, uuid, user)
|
||||
conversation, err := app.conversation.GetConversation(0, uuid)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if err := app.conversation.UpdateConversationStatus(uuid, status, 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.UpdateConversationStatus(uuid, status, snoozedUntil, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Evaluate automation rules.
|
||||
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationStatusChange)
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
@@ -250,7 +463,7 @@ func handleAddConversationTags(r *fastglue.Request) error {
|
||||
p = r.RequestCtx.PostArgs()
|
||||
tagIDs = []int{}
|
||||
tagJSON = p.Peek("tag_ids")
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
)
|
||||
|
||||
@@ -261,11 +474,24 @@ func handleAddConversationTags(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "error adding tags", nil, "")
|
||||
}
|
||||
|
||||
_, err = enforceConversationAccess(app, uuid, user)
|
||||
conversation, err := app.conversation.GetConversation(0, uuid)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
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.UpsertConversationTags(uuid, tagIDs); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -298,7 +524,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 +537,15 @@ func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmode
|
||||
}
|
||||
return &conversation, nil
|
||||
}
|
||||
|
||||
// calculateSLA calculates the SLA deadlines and sets them on the conversation.
|
||||
func calculateSLA(app *App, conversation *cmodels.Conversation) error {
|
||||
firstRespAt, resolutionDueAt, err := app.sla.CalculateConversationDeadlines(conversation.CreatedAt, conversation.AssignedTeamID.Int, conversation.SLAPolicyID.Int)
|
||||
if err != nil {
|
||||
app.lo.Error("error calculating SLA deadlines for conversation", "id", conversation.ID, "error", err)
|
||||
return err
|
||||
}
|
||||
conversation.FirstReplyDueAt = null.NewTime(firstRespAt, firstRespAt != time.Time{})
|
||||
conversation.ResolutionDueAt = null.NewTime(resolutionDueAt, resolutionDueAt != time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
81
cmd/csat.go
Normal file
81
cmd/csat.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
"github.com/valyala/fasthttp"
|
||||
"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)
|
||||
)
|
||||
|
||||
if uuid == "" {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"error_message": "Page not found",
|
||||
})
|
||||
}
|
||||
|
||||
csat, err := app.csat.Get(uuid)
|
||||
if err != nil {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"error_message": "CSAT not found",
|
||||
})
|
||||
}
|
||||
|
||||
if csat.ResponseTimestamp.Valid {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
|
||||
"message": "You've already submitted your feedback",
|
||||
})
|
||||
}
|
||||
|
||||
conversation, err := app.conversation.GetConversation(csat.ConversationID, "")
|
||||
if err != nil {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"error_message": "Conversation not found",
|
||||
})
|
||||
}
|
||||
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
|
||||
"csat": map[string]interface{}{
|
||||
"uuid": csat.UUID,
|
||||
},
|
||||
"conversation": map[string]interface{}{
|
||||
"subject": conversation.Subject.String,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 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 r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `rating`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if ratingI < 1 || ratingI > 5 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "`rating` should be between 1 and 5", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if uuid == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `uuid`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope("CSAT response updated")
|
||||
}
|
||||
240
cmd/handlers.go
240
cmd/handlers.go
@@ -12,129 +12,158 @@ import (
|
||||
"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)
|
||||
g.GET("/api/v1/oidc/{id}/login", handleOIDCLogin)
|
||||
g.GET("/api/v1/oidc/finish", handleOIDCCallback)
|
||||
|
||||
// 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"))
|
||||
g.GET("/api/v1/settings/general", handleGetGeneralSettings)
|
||||
g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "settings_general", "write"))
|
||||
g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "settings_notifications", "read"))
|
||||
g.PUT("/api/v1/settings/notifications/email", perm(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"))
|
||||
// OpenID connect single sign-on.
|
||||
g.GET("/api/v1/oidc", handleGetAllOIDC)
|
||||
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc", "read"))
|
||||
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc", "write"))
|
||||
g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc", "write"))
|
||||
g.DELETE("/api/v1/oidc/{id}", perm(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"))
|
||||
// All.
|
||||
g.GET("/api/v1/conversations/all", perm(handleGetAllConversations, "conversations", "read_all"))
|
||||
// Not assigned to any user or team.
|
||||
g.GET("/api/v1/conversations/unassigned", perm(handleGetUnassignedConversations, "conversations", "read_unassigned"))
|
||||
// Assigned to logged in user.
|
||||
g.GET("/api/v1/conversations/assigned", perm(handleGetAssignedConversations, "conversations", "read_assigned"))
|
||||
// Unassigned conversations assigned to a team.
|
||||
g.GET("/api/v1/teams/{team_id}/conversations/unassigned", perm(handleGetTeamUnassignedConversations, "conversations", "read_assigned"))
|
||||
// Filtered by view.
|
||||
g.GET("/api/v1/views/{view_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(handleUpdateConversationUserAssignee, "conversations", "update_user_assignee"))
|
||||
g.PUT("/api/v1/conversations/{uuid}/assignee/team", perm(handleUpdateTeamAssignee, "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(handleAddConversationTags, "conversations", "update_tags"))
|
||||
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages", "write"))
|
||||
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages", "read"))
|
||||
g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages", "write"))
|
||||
g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages", "read"))
|
||||
|
||||
// Views.
|
||||
g.GET("/api/v1/views/me", auth(handleGetUserViews))
|
||||
g.POST("/api/v1/views/me", auth(handleCreateUserView))
|
||||
g.PUT("/api/v1/views/me/{id}", auth(handleUpdateUserView))
|
||||
g.DELETE("/api/v1/views/me/{id}", auth(handleDeleteUserView))
|
||||
|
||||
// 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))
|
||||
g.GET("/api/v1/statuses", auth(handleGetStatuses))
|
||||
g.POST("/api/v1/statuses", perm(handleCreateStatus, "status", "write"))
|
||||
g.PUT("/api/v1/statuses/{id}", perm(handleUpdateStatus, "status", "write"))
|
||||
g.DELETE("/api/v1/statuses/{id}", perm(handleDeleteStatus, "status", "delete"))
|
||||
g.GET("/api/v1/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/tags", auth(handleGetTags))
|
||||
g.POST("/api/v1/tags", perm(handleCreateTag, "tags", "write"))
|
||||
g.PUT("/api/v1/tags/{id}", perm(handleUpdateTag, "tags", "write"))
|
||||
g.DELETE("/api/v1/tags/{id}", perm(handleDeleteTag, "tags", "delete"))
|
||||
|
||||
// Media.
|
||||
g.POST("/api/media", auth(handleMediaUpload))
|
||||
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"))
|
||||
g.GET("/api/v1/canned-responses", auth(handleGetCannedResponses))
|
||||
g.POST("/api/v1/canned-responses", perm(handleCreateCannedResponse, "canned_responses", "write"))
|
||||
g.PUT("/api/v1/canned-responses/{id}", perm(handleUpdateCannedResponse, "canned_responses", "write"))
|
||||
g.DELETE("/api/v1/canned-responses/{id}", perm(handleDeleteCannedResponse, "canned_responses", "delete"))
|
||||
|
||||
// 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", "read"))
|
||||
g.GET("/api/v1/users/{id}", perm(handleGetUser, "users", "read"))
|
||||
g.POST("/api/v1/users", perm(handleCreateUser, "users", "write"))
|
||||
g.PUT("/api/v1/users/{id}", perm(handleUpdateUser, "users", "write"))
|
||||
g.DELETE("/api/v1/users/{id}", perm(handleDeleteUser, "users", "delete"))
|
||||
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", "read"))
|
||||
g.GET("/api/v1/teams/{id}", perm(handleGetTeam, "teams", "read"))
|
||||
g.POST("/api/v1/teams", perm(handleCreateTeam, "teams", "write"))
|
||||
g.PUT("/api/v1/teams/{id}", perm(handleUpdateTeam, "teams", "write"))
|
||||
g.DELETE("/api/v1/teams/{id}", perm(handleDeleteTeam, "teams", "delete"))
|
||||
|
||||
// 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", "read"))
|
||||
g.GET("/api/v1/automation/rules/{id}", perm(handleGetAutomationRule, "automations", "read"))
|
||||
g.POST("/api/v1/automation/rules", perm(handleCreateAutomationRule, "automations", "write"))
|
||||
g.PUT("/api/v1/automation/rules/{id}/toggle", perm(handleToggleAutomationRule, "automations", "write"))
|
||||
g.PUT("/api/v1/automation/rules/{id}", perm(handleUpdateAutomationRule, "automations", "write"))
|
||||
g.DELETE("/api/v1/automation/rules/{id}", perm(handleDeleteAutomationRule, "automations", "delete"))
|
||||
|
||||
// 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", perm(handleGetInboxes, "inboxes", "read"))
|
||||
g.GET("/api/v1/inboxes/{id}", perm(handleGetInbox, "inboxes", "read"))
|
||||
g.POST("/api/v1/inboxes", perm(handleCreateInbox, "inboxes", "write"))
|
||||
g.PUT("/api/v1/inboxes/{id}/toggle", perm(handleToggleInbox, "inboxes", "write"))
|
||||
g.PUT("/api/v1/inboxes/{id}", perm(handleUpdateInbox, "inboxes", "write"))
|
||||
g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes", "delete"))
|
||||
|
||||
// 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", "read"))
|
||||
g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles", "read"))
|
||||
g.POST("/api/v1/roles", perm(handleCreateRole, "roles", "write"))
|
||||
g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles", "write"))
|
||||
g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "roles", "delete"))
|
||||
|
||||
// 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/dashboard/global/counts", perm(handleDashboardCounts, "dashboard_global", "read"))
|
||||
g.GET("/api/v1/dashboard/global/charts", perm(handleDashboardCharts, "dashboard_global", "read"))
|
||||
|
||||
// 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", "read"))
|
||||
g.GET("/api/v1/templates/{id}", perm(handleGetTemplate, "templates", "read"))
|
||||
g.POST("/api/v1/templates", perm(handleCreateTemplate, "templates", "write"))
|
||||
g.PUT("/api/v1/templates/{id}", perm(handleUpdateTemplate, "templates", "write"))
|
||||
g.DELETE("/api/v1/templates/{id}", perm(handleDeleteTemplate, "templates", "delete"))
|
||||
|
||||
// Business hours.
|
||||
g.GET("/api/v1/business-hours", auth(handleGetBusinessHours))
|
||||
g.GET("/api/v1/business-hours/{id}", auth(handleGetBusinessHour))
|
||||
g.POST("/api/v1/business-hours", auth(handleCreateBusinessHours))
|
||||
g.PUT("/api/v1/business-hours/{id}", auth(handleUpdateBusinessHours))
|
||||
g.DELETE("/api/v1/business-hours/{id}", auth(handleDeleteBusinessHour))
|
||||
|
||||
// SLA.
|
||||
g.GET("/api/v1/sla", auth(handleGetSLAs))
|
||||
g.GET("/api/v1/sla/{id}", auth(handleGetSLA))
|
||||
g.POST("/api/v1/sla", auth(fastglue.ReqLenRangeParams(handleCreateSLA, slaReqFields)))
|
||||
g.PUT("/api/v1/sla/{id}", auth(fastglue.ReqLenRangeParams(handleUpdateSLA, slaReqFields)))
|
||||
g.DELETE("/api/v1/sla/{id}", auth(handleDeleteSLA))
|
||||
|
||||
// WebSocket.
|
||||
g.GET("/ws", auth(func(r *fastglue.Request) error {
|
||||
@@ -150,8 +179,16 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
g.GET("/admin/{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)
|
||||
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}", fastglue.ReqLenRangeParams(handleUpdateCSATResponse, map[string][2]int{"feedback": {1, 1000}}))
|
||||
|
||||
// Health check.
|
||||
g.GET("/health", handleHealthCheck)
|
||||
}
|
||||
|
||||
// serveIndexPage serves the main index page of the application.
|
||||
@@ -186,6 +223,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)
|
||||
|
||||
89
cmd/init.go
89
cmd/init.go
@@ -16,11 +16,12 @@ import (
|
||||
"github.com/abhinavxd/artemis/internal/authz"
|
||||
"github.com/abhinavxd/artemis/internal/autoassigner"
|
||||
"github.com/abhinavxd/artemis/internal/automation"
|
||||
businesshours "github.com/abhinavxd/artemis/internal/business_hours"
|
||||
"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/csat"
|
||||
"github.com/abhinavxd/artemis/internal/inbox"
|
||||
"github.com/abhinavxd/artemis/internal/inbox/channel/email"
|
||||
imodels "github.com/abhinavxd/artemis/internal/inbox/models"
|
||||
@@ -32,10 +33,13 @@ import (
|
||||
"github.com/abhinavxd/artemis/internal/oidc"
|
||||
"github.com/abhinavxd/artemis/internal/role"
|
||||
"github.com/abhinavxd/artemis/internal/setting"
|
||||
"github.com/abhinavxd/artemis/internal/sla"
|
||||
"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/view"
|
||||
"github.com/abhinavxd/artemis/internal/workerpool"
|
||||
"github.com/abhinavxd/artemis/internal/ws"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/go-i18n"
|
||||
@@ -189,9 +193,19 @@ 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,
|
||||
hub *ws.Hub,
|
||||
n *notifier.Service,
|
||||
db *sqlx.DB,
|
||||
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, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{
|
||||
DB: db,
|
||||
Lo: initLogger("conversation_manager"),
|
||||
OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
|
||||
@@ -203,8 +217,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,6 +230,19 @@ func initTags(db *sqlx.DB) *tag.Manager {
|
||||
return mgr
|
||||
}
|
||||
|
||||
// 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 view manager: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// initCannedResponse inits canned response manager.
|
||||
func initCannedResponse(db *sqlx.DB) *cannedresp.Manager {
|
||||
var lo = initLogger("canned-response")
|
||||
@@ -229,26 +256,62 @@ func initCannedResponse(db *sqlx.DB) *cannedresp.Manager {
|
||||
return c
|
||||
}
|
||||
|
||||
func initContact(db *sqlx.DB) *contact.Manager {
|
||||
var lo = initLogger("contact-manager")
|
||||
m, err := contact.New(contact.Opts{
|
||||
// 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 contact manager: %v", err)
|
||||
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,
|
||||
ScannerInterval: ko.MustDuration("sla.scanner_interval"),
|
||||
}, workerpool.New(ko.MustInt("sla.worker_count"), ko.MustInt("sla.queue_size")), 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")
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
@@ -12,13 +13,13 @@ import (
|
||||
)
|
||||
|
||||
// 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.
|
||||
|
||||
12
cmd/login.go
12
cmd/login.go
@@ -1,12 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||
"github.com/abhinavxd/artemis/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))
|
||||
}
|
||||
|
||||
140
cmd/main.go
140
cmd/main.go
@@ -10,11 +10,15 @@ import (
|
||||
|
||||
auth_ "github.com/abhinavxd/artemis/internal/auth"
|
||||
"github.com/abhinavxd/artemis/internal/authz"
|
||||
businesshours "github.com/abhinavxd/artemis/internal/business_hours"
|
||||
"github.com/abhinavxd/artemis/internal/colorlog"
|
||||
"github.com/abhinavxd/artemis/internal/csat"
|
||||
notifier "github.com/abhinavxd/artemis/internal/notification"
|
||||
"github.com/abhinavxd/artemis/internal/sla"
|
||||
"github.com/abhinavxd/artemis/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"
|
||||
@@ -45,33 +49,37 @@ var (
|
||||
|
||||
// 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
|
||||
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
|
||||
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
|
||||
businessHours *businesshours.Manager
|
||||
sla *sla.Manager
|
||||
csat *csat.Manager
|
||||
view *view.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()
|
||||
@@ -95,13 +103,13 @@ func main() {
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -132,19 +140,20 @@ func main() {
|
||||
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)
|
||||
sla = initSLA(db, team, settings, businessHours)
|
||||
conversation = initConversations(i18n, wsHub, notifier, db, inbox, user, team, media, automation, template)
|
||||
autoassigner = initAutoAssigner(team, user, conversation)
|
||||
)
|
||||
|
||||
// Set stores.
|
||||
wsHub.SetConversationStore(conversation)
|
||||
automation.SetConversationStore(conversation)
|
||||
automation.SetConversationStore(conversation, sla)
|
||||
|
||||
// Start inbox receivers.
|
||||
startInboxes(ctx, inbox, conversation)
|
||||
@@ -161,37 +170,45 @@ func main() {
|
||||
// Start notifier.
|
||||
go notifier.Run(ctx)
|
||||
|
||||
// Delete media not linked to any message.
|
||||
// Start SLA monitor.
|
||||
go sla.Run(ctx)
|
||||
|
||||
// Purge unlinked message media.
|
||||
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,
|
||||
tmpl: template,
|
||||
notifier: notifier,
|
||||
consts: constants,
|
||||
conversation: conversation,
|
||||
automation: automation,
|
||||
businessHours: businessHours,
|
||||
view: initView(db),
|
||||
csat: initCSAT(db),
|
||||
authz: initAuthz(),
|
||||
status: initStatus(db),
|
||||
priority: initPriority(db),
|
||||
role: initRole(db),
|
||||
tag: initTag(db),
|
||||
cannedResp: initCannedResponse(db),
|
||||
}
|
||||
|
||||
// Init fastglue and set app in ctx.
|
||||
g := fastglue.NewGlue()
|
||||
|
||||
// Set the app in context.
|
||||
g.SetContext(app)
|
||||
|
||||
// Init HTTP handlers.
|
||||
@@ -206,24 +223,37 @@ 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() {
|
||||
if err := g.ListenAndServe(ko.String("app.server.address"), ko.String("server.socket"), s); err != nil {
|
||||
log.Fatalf("error starting server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
colorlog.Green("🚀 server listening on %s %s", ko.String("app.server.address"), ko.String("app.server.socket"))
|
||||
|
||||
// Wait for shutdown signal.
|
||||
<-ctx.Done()
|
||||
log.Printf("%sShutting down the server. Please wait.\x1b[0m", "\x1b[31m")
|
||||
colorlog.Red("Shutting down the server. Please wait....")
|
||||
// Shutdown HTTP server.
|
||||
s.Shutdown()
|
||||
colorlog.Red("Server shutdown complete.")
|
||||
colorlog.Red("Shutting down services. Please wait....")
|
||||
// Shutdown services.
|
||||
inbox.Close()
|
||||
colorlog.Red("Inbox shutdown complete.")
|
||||
automation.Close()
|
||||
colorlog.Red("Automation shutdown complete.")
|
||||
autoassigner.Close()
|
||||
colorlog.Red("Autoassigner shutdown complete.")
|
||||
notifier.Close()
|
||||
colorlog.Red("Notifier shutdown complete.")
|
||||
conversation.Close()
|
||||
colorlog.Red("Conversation shutdown complete.")
|
||||
sla.Close()
|
||||
colorlog.Red("SLA shutdown complete.")
|
||||
db.Close()
|
||||
colorlog.Red("Database shutdown complete.")
|
||||
rdb.Close()
|
||||
colorlog.Red("Redis shutdown complete.")
|
||||
colorlog.Green("Shutdown complete.")
|
||||
}
|
||||
|
||||
13
cmd/media.go
13
cmd/media.go
@@ -10,10 +10,10 @@ import (
|
||||
"slices"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/attachment"
|
||||
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||
"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/google/uuid"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,10 +3,10 @@ package main
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||
"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"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
@@ -22,14 +22,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)
|
||||
}
|
||||
@@ -60,11 +65,15 @@ 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)
|
||||
}
|
||||
@@ -87,11 +96,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,15 +120,20 @@ 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)
|
||||
req = messageReq{}
|
||||
media = []medModels.Media{}
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
@@ -27,7 +28,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,25 +58,30 @@ 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, object, action string) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
cookieToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
|
||||
hdrToken = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
|
||||
app = r.Context.(*App)
|
||||
// cookieToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
|
||||
// hdrToken = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
|
||||
)
|
||||
|
||||
if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
|
||||
app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken)
|
||||
return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError)
|
||||
}
|
||||
// if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
|
||||
// app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken)
|
||||
// return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError)
|
||||
// }
|
||||
|
||||
// Validate session and fetch user.
|
||||
userSession, err := app.auth.ValidateSession(r)
|
||||
@@ -96,7 +107,12 @@ func authPerm(handler fastglue.FastRequestHandler, object, action string) fastgl
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"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,10 @@ func handleUpdateGeneralSettings(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")
|
||||
}
|
||||
|
||||
// handleGetEmailNotificationSettings fetches email notification settings.
|
||||
func handleGetEmailNotificationSettings(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -59,6 +62,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 +90,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")
|
||||
}
|
||||
|
||||
106
cmd/sla.go
Normal file
106
cmd/sla.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/artemis/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(true)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
47
cmd/teams.go
47
cmd/teams.go
@@ -1,15 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
"github.com/abhinavxd/artemis/internal/team/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// handleGetTeams returns a list of all teams.
|
||||
func handleGetTeams(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -21,6 +20,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,6 +32,7 @@ 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)
|
||||
@@ -41,35 +42,38 @@ func handleGetTeam(r *fastglue.Request) error {
|
||||
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"))
|
||||
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
|
||||
)
|
||||
|
||||
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)
|
||||
businessHrsID, err := strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
|
||||
if err != nil || businessHrsID == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `business_hours_id`.", nil, envelope.InputError)
|
||||
}
|
||||
err := app.team.CreateTeam(req)
|
||||
if err != nil {
|
||||
if err := app.team.Create(name, timezone, conversationAssignmentType, businessHrsID); 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"))
|
||||
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
|
||||
)
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
@@ -77,11 +81,12 @@ func handleUpdateTeam(r *fastglue.Request) error {
|
||||
"Invalid team `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := r.Decode(&req, "json"); err != nil {
|
||||
return envelope.NewError(envelope.InputError, "Bad request", nil)
|
||||
businessHrsID, err := strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
|
||||
if err != nil || businessHrsID == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `business_hours_id`.", nil, envelope.InputError)
|
||||
}
|
||||
err = app.team.UpdateTeam(id, req)
|
||||
if err != nil {
|
||||
|
||||
if err = app.team.Update(id, name, timezone, conversationAssignmentType, businessHrsID); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
@@ -97,9 +102,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.")
|
||||
}
|
||||
@@ -9,17 +9,23 @@ import (
|
||||
"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)
|
||||
}
|
||||
|
||||
69
cmd/users.go
69
cmd/users.go
@@ -8,13 +8,14 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||
"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"
|
||||
"github.com/abhinavxd/artemis/internal/user/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
@@ -23,6 +24,7 @@ const (
|
||||
maxAvatarSizeMB = 5
|
||||
)
|
||||
|
||||
// handleGetUsers returns all users.
|
||||
func handleGetUsers(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -61,11 +63,33 @@ 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)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -144,17 +168,17 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
|
||||
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)
|
||||
err := app.user.CreateAgent(&user)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -172,7 +196,7 @@ 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,
|
||||
})
|
||||
@@ -198,7 +222,7 @@ func handleCreateUser(r *fastglue.Request) error {
|
||||
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 {
|
||||
@@ -252,9 +276,13 @@ func handleDeleteUser(r *fastglue.Request) error {
|
||||
// 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)
|
||||
)
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
u, err := app.user.Get(user.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
@@ -265,12 +293,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)
|
||||
}
|
||||
@@ -296,13 +324,12 @@ func handleDeleteAvatar(r *fastglue.Request) error {
|
||||
// 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 +348,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,
|
||||
})
|
||||
@@ -347,7 +374,7 @@ func handleResetPassword(r *fastglue.Request) error {
|
||||
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"))
|
||||
|
||||
139
cmd/views.go
Normal file
139
cmd/views.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
vmodels "github.com/abhinavxd/artemis/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, view.InboxType, 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, view.InboxType); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
umodels "github.com/abhinavxd/artemis/internal/user/models"
|
||||
amodels "github.com/abhinavxd/artemis/internal/auth/models"
|
||||
"github.com/abhinavxd/artemis/internal/ws"
|
||||
wsmodels "github.com/abhinavxd/artemis/internal/ws/models"
|
||||
"github.com/fasthttp/websocket"
|
||||
@@ -26,10 +26,14 @@ var upgrader = websocket.FastHTTPUpgrader{
|
||||
|
||||
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) {
|
||||
user, err := app.user.Get(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
err = upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) {
|
||||
c := ws.Client{
|
||||
ID: user.ID,
|
||||
Hub: hub,
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@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.2.0",
|
||||
"add": "^2.0.6",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -59,6 +59,7 @@
|
||||
"vue-letter": "^0.2.0",
|
||||
"vue-picture-cropper": "^0.7.0",
|
||||
"vue-router": "^4.2.5",
|
||||
"vue-sonner": "^1.3.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex">
|
||||
<NavBar :is-collapsed="isCollapsed" :links="navLinks" :bottom-links="bottomLinks"
|
||||
class="shadow shadow-gray-300 h-screen" />
|
||||
<Toaster />
|
||||
<Sidebar :isLoading="false" :open="sidebarOpen" :userTeams="userStore.teams" :userViews="userViews" @update:open="sidebarOpen = $event"
|
||||
@create-view="openCreateViewForm = true" @edit-view="editView" @delete-view="deleteView">
|
||||
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
|
||||
<ResizableHandle id="resize-handle-1" />
|
||||
<ResizablePanel id="resize-panel-2">
|
||||
@@ -9,75 +9,94 @@
|
||||
<RouterView />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</Sidebar>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { RouterView, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { initWS } from '@/websocket.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
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 ViewForm from '@/components/ViewForm.vue'
|
||||
import api from '@/api'
|
||||
import Sidebar from '@/components/sidebar/Sidebar.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { toast } = useToast()
|
||||
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 sidebarOpen = ref(true)
|
||||
const userStore = useUserStore()
|
||||
const conversationStore = useConversationStore()
|
||||
const router = useRouter()
|
||||
initWS()
|
||||
const userViews = ref([])
|
||||
const view = ref({})
|
||||
const openCreateViewForm = ref(false)
|
||||
|
||||
initWS()
|
||||
onMounted(() => {
|
||||
initToaster()
|
||||
listenViewRefresh()
|
||||
getCurrentUser()
|
||||
getUserViews()
|
||||
intiStores()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(EMITTER_EVENTS.SHOW_TOAST, toast)
|
||||
emitter.off(EMITTER_EVENTS.REFRESH_LIST, refreshViews)
|
||||
})
|
||||
|
||||
const intiStores = () => {
|
||||
Promise.all([
|
||||
conversationStore.fetchStatuses(),
|
||||
conversationStore.fetchPriorities()
|
||||
])
|
||||
}
|
||||
|
||||
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',
|
||||
variant: '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 getCurrentUser = () => {
|
||||
userStore.getCurrentUser().catch((err) => {
|
||||
if (err.response && err.response.status === 401) {
|
||||
@@ -90,9 +109,14 @@ const initToaster = () => {
|
||||
emitter.on(EMITTER_EVENTS.SHOW_TOAST, toast)
|
||||
}
|
||||
|
||||
const navLinks = computed(() =>
|
||||
allNavLinks.filter((link) =>
|
||||
!link.permission || (userStore.permissions.includes(link.permission) && link.permission)
|
||||
)
|
||||
)
|
||||
const listenViewRefresh = () => {
|
||||
emitter.on(EMITTER_EVENTS.REFRESH_LIST, refreshViews)
|
||||
}
|
||||
|
||||
const refreshViews = (data) => {
|
||||
openCreateViewForm.value = false
|
||||
if (data?.model === 'view') {
|
||||
getUserViews()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Toaster />
|
||||
<TooltipProvider :delay-duration="250">
|
||||
<TooltipProvider :delay-duration="200">
|
||||
<div class="font-inter">
|
||||
<RouterView />
|
||||
</div>
|
||||
|
||||
@@ -33,177 +33,214 @@ 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 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 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 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 deleteAutomationRule = (id) => http.delete(`/api/v1/automation/rules/${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 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/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 }
|
||||
})
|
||||
http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
|
||||
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 getCannedResponses = () => http.get('/api/v1/canned-responses')
|
||||
const createCannedResponse = (data) => http.post('/api/v1/canned-responses', data)
|
||||
const updateCannedResponse = (id, data) => http.put(`/api/v1/canned-responses/${id}`, data)
|
||||
const deleteCannedResponse = (id) => http.delete(`/api/v1/canned-responses/${id}`)
|
||||
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 getGlobalDashboardCounts = () => http.get('/api/v1/dashboard/global/counts')
|
||||
const getGlobalDashboardCharts = () => http.get('/api/v1/dashboard/global/charts')
|
||||
const getUserDashboardCounts = () => http.get(`/api/v1/dashboard/me/counts`)
|
||||
const getUserDashboardCharts = () => http.get(`/api/v1/dashboard/me/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}`)
|
||||
|
||||
export default {
|
||||
login,
|
||||
@@ -219,6 +256,7 @@ export default {
|
||||
deleteRole,
|
||||
updateRole,
|
||||
getTeams,
|
||||
deleteTeam,
|
||||
getUsers,
|
||||
getInbox,
|
||||
getInboxes,
|
||||
@@ -226,9 +264,21 @@ export default {
|
||||
getConversation,
|
||||
getAutomationRule,
|
||||
getAutomationRules,
|
||||
getAllBusinessHours,
|
||||
getBusinessHours,
|
||||
createBusinessHours,
|
||||
updateBusinessHours,
|
||||
deleteBusinessHours,
|
||||
getAllSLAs,
|
||||
getSLA,
|
||||
createSLA,
|
||||
updateSLA,
|
||||
deleteSLA,
|
||||
getAssignedConversations,
|
||||
getUnassignedConversations,
|
||||
getAllConversations,
|
||||
getTeamUnassignedConversations,
|
||||
getViewConversations,
|
||||
getGlobalDashboardCharts,
|
||||
getGlobalDashboardCounts,
|
||||
getUserDashboardCounts,
|
||||
@@ -237,6 +287,7 @@ export default {
|
||||
getConversationMessage,
|
||||
getConversationMessages,
|
||||
getCurrentUser,
|
||||
getCurrentUserTeams,
|
||||
getCannedResponses,
|
||||
createCannedResponse,
|
||||
updateCannedResponse,
|
||||
@@ -287,4 +338,8 @@ export default {
|
||||
getUsersCompact,
|
||||
getEmailNotificationSettings,
|
||||
updateEmailNotificationSettings,
|
||||
getCurrentUserViews,
|
||||
createView,
|
||||
updateView,
|
||||
deleteView
|
||||
}
|
||||
|
||||
@@ -5,12 +5,10 @@
|
||||
// App default font-size.
|
||||
// Default: 16px, 15px looks wide.
|
||||
:root {
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
@@ -176,27 +174,28 @@ body {
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
// Scrollbar
|
||||
::-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: 10px;
|
||||
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: 10px;
|
||||
}
|
||||
// End Scrollbar
|
||||
|
||||
.code-editor {
|
||||
@apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative;
|
||||
@@ -212,3 +211,46 @@ body {
|
||||
.ql-toolbar {
|
||||
@apply rounded-t-lg;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--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%;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
157
frontend/src/components/ViewForm.vue
Normal file
157
frontend/src/components/ViewForm.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<Dialog :open="openDialog" @update:open="openDialog = false">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ view?.id ? 'Edit' : 'Create' }} view</DialogTitle>
|
||||
<DialogDescription>Views let you create custom filters and save them for reuse.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="grid gap-4 py-4">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="name" class="col-span-3" placeholder="Enter view name"
|
||||
v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>Enter a unique name for your view</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="inbox_type">
|
||||
<FormItem>
|
||||
<FormLabel>Inbox</FormLabel>
|
||||
<FormControl>
|
||||
<Select class="w-full" v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select inbox" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="(value, key) in CONVERSATION_VIEWS_INBOXES" :key="key"
|
||||
:value="key">
|
||||
{{ value }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Select inbox to filter conversations</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="filters">
|
||||
<FormItem>
|
||||
<FormLabel>Filters</FormLabel>
|
||||
<FormControl>
|
||||
<Filter :fields="filterFields" :showButtons="false" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>Add filters to customize view</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" :disabled="isSubmitting" :isLoading="isSubmitting">
|
||||
{{ isSubmitting ? 'Saving...' : 'Save changes' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineModel, ref, onMounted, watch } from 'vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { CONVERSATION_VIEWS_INBOXES } from '@/constants/conversation'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import Filter from '@/components/common/Filter.vue'
|
||||
import { useConversationFilters } from '@/composables/useConversationFilters'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { z } from 'zod'
|
||||
import api from '@/api'
|
||||
|
||||
const emitter = useEmitter()
|
||||
const openDialog = defineModel('openDialog', { required: false, default: false })
|
||||
const view = defineModel('view', { required: false, default: {} })
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const {
|
||||
conversationsListFilters,
|
||||
initConversationListFilters
|
||||
} = useConversationFilters()
|
||||
|
||||
const filterFields = ref([])
|
||||
const initFields = async () => {
|
||||
await initConversationListFilters()
|
||||
filterFields.value = Object.entries(conversationsListFilters.value).map(([field, value]) => ({
|
||||
model: 'conversations',
|
||||
label: value.label,
|
||||
field,
|
||||
type: value.type,
|
||||
operators: value.operators,
|
||||
options: value.options?.map(option => ({
|
||||
...option,
|
||||
value: String(option.value)
|
||||
})) ?? []
|
||||
}))
|
||||
}
|
||||
|
||||
onMounted(initFields)
|
||||
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string()
|
||||
.min(2, { message: "Name must be at least 2 characters." })
|
||||
.max(250, { message: "Name cannot exceed 250 characters." }),
|
||||
inbox_type: z.enum(Object.keys(CONVERSATION_VIEWS_INBOXES)),
|
||||
filters: z.array(
|
||||
z.object({
|
||||
model: z.string({ required_error: "Model required" }),
|
||||
field: z.string({ required_error: "Field required" }),
|
||||
operator: z.string({ required_error: "Operator required" }),
|
||||
value: z.union([z.string(), z.number(), z.boolean()])
|
||||
})
|
||||
).default([])
|
||||
}))
|
||||
|
||||
const form = useForm({ validationSchema: formSchema })
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
if (isSubmitting.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
if (values.id) {
|
||||
await api.updateView(values.id, values)
|
||||
} else {
|
||||
await api.createView(values)
|
||||
}
|
||||
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
|
||||
openDialog.value = false
|
||||
form.resetForm()
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => view.value, (newVal) => {
|
||||
if (newVal && Object.keys(newVal).length) {
|
||||
form.setValues(newVal)
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
@@ -15,9 +15,6 @@ const sidebarNavItems = [
|
||||
<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>
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
<div class="flex flex-col space-y-5 justify-center">
|
||||
<input ref="uploadInput" type="file" hidden accept="image/jpg, image/jpeg, image/png, image/gif"
|
||||
@change="selectFile" />
|
||||
<Button class="w-28" @click="selectAvatar" size="sm"> Choose a file... </Button>
|
||||
<Button class="w-28" @click="removeAvatar" variant="destructive" size="sm">Remove
|
||||
<Button class="w-28" @click="selectAvatar"> Choose a file... </Button>
|
||||
<Button class="w-28" @click="removeAvatar" variant="destructive">Remove
|
||||
avatar</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,79 +1,5 @@
|
||||
<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 class="overflow-y-auto ">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
<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>
|
||||
<div class="w-full">
|
||||
<div class="rounded-md border shadow">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead v-for="header in headerGroup.headers" :key="header.id" class="font-semibold">
|
||||
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header"
|
||||
:props="header.getContext()" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</template>
|
||||
<template v-else>
|
||||
<TableRow>
|
||||
<TableCell :colspan="columns.length" class="h-24 text-center"> No results. </TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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" class="hover:bg-muted/50">
|
||||
<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">
|
||||
<div class="text-muted-foreground">{{ emptyText }}</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup>
|
||||
import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table'
|
||||
|
||||
@@ -48,14 +47,18 @@ import {
|
||||
|
||||
const props = defineProps({
|
||||
columns: Array,
|
||||
data: Array
|
||||
data: Array,
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: 'No results.'
|
||||
}
|
||||
})
|
||||
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
get data () {
|
||||
return props.data
|
||||
},
|
||||
get columns() {
|
||||
get columns () {
|
||||
return props.columns
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel()
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="outline" @click.prevent="addAction" size="sm">Add action</Button>
|
||||
<Button variant="outline" @click.prevent="addAction">Add action</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -83,6 +83,7 @@ const props = defineProps({
|
||||
|
||||
const { actions } = toRefs(props)
|
||||
const emitter = useEmitter()
|
||||
const slas = ref([])
|
||||
const teams = ref([])
|
||||
const users = ref([])
|
||||
const statuses = ref([])
|
||||
@@ -91,7 +92,8 @@ const emit = defineEmits(['update-actions', 'add-action', 'remove-action'])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [teamsResp, usersResp, statusesResp, prioritiesResp] = await Promise.all([
|
||||
const [slasResp, teamsResp, usersResp, statusesResp, prioritiesResp] = await Promise.all([
|
||||
api.getAllSLAs(),
|
||||
api.getTeamsCompact(),
|
||||
api.getUsersCompact(),
|
||||
api.getStatuses(),
|
||||
@@ -117,9 +119,14 @@ onMounted(async () => {
|
||||
value: priority.name,
|
||||
name: priority.name
|
||||
}))
|
||||
|
||||
slas.value = slasResp.data.data.map(sla => ({
|
||||
value: sla.id,
|
||||
name: sla.name
|
||||
}))
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Something went wrong',
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
@@ -173,6 +180,10 @@ const conversationActions = {
|
||||
reply: {
|
||||
label: 'Send reply',
|
||||
inputType: 'richtext',
|
||||
},
|
||||
set_sla: {
|
||||
label: 'Set SLA',
|
||||
inputType: 'select',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +192,7 @@ const actionDropdownValues = {
|
||||
assign_user: users,
|
||||
set_status: statuses,
|
||||
set_priority: priorities,
|
||||
set_sla: slas,
|
||||
}
|
||||
|
||||
const getDropdownValues = (field) => {
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
<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>
|
||||
<PageHeader title="Automation" description="Manage automation rules" />
|
||||
<div class="w-8/12">
|
||||
<div v-if="router.currentRoute.value.path === '/admin/automations'">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div class="ml-auto">
|
||||
<Button @click="newRule">New rule</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<AutomationTabs v-model="selectedTab" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<AutomationTabs v-model="selectedTab" />
|
||||
<router-view />
|
||||
</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 AutomationTabs from '@/components/admin/automation/AutomationTabs.vue'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
const router = useRouter()
|
||||
const selectedTab = ref('new_conversation')
|
||||
|
||||
const selectedTab = useStorage('automationsTab', 'new_conversation')
|
||||
const newRule = () => {
|
||||
router.push({ path: `/admin/automations/new`, query: { type: selectedTab.value } })
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import RuleTab from './RuleTab.vue'
|
||||
|
||||
const selectedTab = defineModel('selectedTab', {
|
||||
default: 'new_conversation'
|
||||
const selectedTab = defineModel('automationsTab', {
|
||||
default: 'new_conversation',
|
||||
type: String,
|
||||
required: true
|
||||
})
|
||||
</script>
|
||||
@@ -3,96 +3,101 @@
|
||||
<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-4">
|
||||
<p>{{ formTitle }}</p>
|
||||
<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="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="{ 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, 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 :class="{ 'hidden': form.values.type !== 'conversation_update' }">
|
||||
<FormField v-slot="{ componentField }" name="events">
|
||||
<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>
|
||||
|
||||
</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>
|
||||
|
||||
<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">Save</Button>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -322,6 +327,9 @@ onMounted(async () => {
|
||||
isLoading.value = true
|
||||
let resp = await api.getAutomationRule(props.id)
|
||||
rule.value = resp.data.data
|
||||
if (resp.data.data.type === 'conversation_update') {
|
||||
rule.value.rules.events = []
|
||||
}
|
||||
form.setValues(resp.data.data)
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import * as z from 'zod'
|
||||
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.'),
|
||||
})
|
||||
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()).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.type === 'conversation_update' && (!data.events || data.events.length === 0)) {
|
||||
ctx.addIssue({
|
||||
path: ['events'],
|
||||
message: 'Please select at least one event.',
|
||||
code: z.ZodIssueCode.custom,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<PageHeader title="Business hours" description="Manage business hours" />
|
||||
<div class="w-8/12">
|
||||
<template v-if="router.currentRoute.value.path === '/admin/business-hours'">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div></div>
|
||||
<div>
|
||||
<Button @click="navigateToAddBusinessHour">New business hour</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="businessHours" v-else />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-view/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import DataTable from '@/components/admin/DataTable.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { columns } from '@/components/admin/business_hours/dataTableColumns.js'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api'
|
||||
|
||||
const businessHours = ref([])
|
||||
const isLoading = ref(false)
|
||||
const router = useRouter()
|
||||
const emit = useEmitter()
|
||||
|
||||
onMounted(() => {
|
||||
fetchAll()
|
||||
emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||
})
|
||||
|
||||
const refreshList = (data) => {
|
||||
if (data?.model === 'business_hours') fetchAll()
|
||||
}
|
||||
|
||||
const fetchAll = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const resp = await api.getAllBusinessHours()
|
||||
businessHours.value = resp.data.data
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToAddBusinessHour = () => {
|
||||
router.push('/admin/business-hours/new')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<form @submit="onSubmit" class="space-y-8">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="General working hours" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="General working hours for my company" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="is_always_open">
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Set business hours
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup v-bind="componentField">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="flex items-center space-x-3">
|
||||
<RadioGroupItem id="r1" value="true" />
|
||||
<Label for="r1">Always open (24x7)</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<RadioGroupItem id="r2" value="false" />
|
||||
<Label for="r2">Custom business hours</Label>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<div v-if="form.values.is_always_open === 'false'">
|
||||
<div>
|
||||
<div v-for="day in WEEKDAYS" :key="day" class="flex items-center justify-between space-y-2">
|
||||
<div class="flex items-center space-x-3">
|
||||
<Checkbox :id="day" :checked="!!selectedDays[day]"
|
||||
@update:checked="handleDayToggle(day, $event)" />
|
||||
<Label :for="day" class="font-medium text-gray-800">{{ day }}</Label>
|
||||
</div>
|
||||
<div class="flex space-x-2 items-center">
|
||||
<div class="flex flex-col items-start">
|
||||
<Input type="time" :defaultValue="hours[day]?.open || '09:00'"
|
||||
@update:modelValue="(val) => updateHours(day, 'open', val)"
|
||||
:disabled="!selectedDays[day]" />
|
||||
</div>
|
||||
<span class="text-gray-500">to</span>
|
||||
<div class="flex flex-col items-start">
|
||||
<Input type="time" :defaultValue="hours[day]?.close || '17:00'"
|
||||
@update:modelValue="(val) => updateHours(day, 'close', val)"
|
||||
:disabled="!selectedDays[day]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog >
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div></div>
|
||||
<DialogTrigger as-child>
|
||||
<Button>New holiday</Button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</div>
|
||||
<SimpleTable :headers="['Name', 'Date']" :keys="['name', 'date']" :data="holidays" @deleteItem="deleteHoliday" />
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New holiday</DialogTitle>
|
||||
<DialogDescription>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="holiday_name" class="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input id="holiday_name" v-model="holidayName" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="date" class="text-right">
|
||||
Date
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" :class="cn(
|
||||
'w-[280px] justify-start text-left font-normal',
|
||||
!holidayDate && 'text-muted-foreground',
|
||||
)">
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
{{ holidayDate && !isNaN(new Date(holidayDate).getTime()) ? format(new
|
||||
Date(holidayDate), 'MMMM dd, yyyy') : "Pick a date" }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0">
|
||||
<Calendar v-model="holidayDate" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button :disabled="!holidayName || !holidayDate"
|
||||
@click="saveHoliday">
|
||||
Save changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button type="submit" :disabled="isLoading">{{ submitLabel }}</Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, reactive } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { formSchema } from './formSchema.js'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { format } from 'date-fns'
|
||||
import { WEEKDAYS } from '@/constants/date'
|
||||
import { Calendar as CalendarIcon } from 'lucide-vue-next'
|
||||
import SimpleTable from '@/components/common/SimpleTable.vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
const props = defineProps({
|
||||
initialValues: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
submitForm: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: () => 'Save'
|
||||
},
|
||||
isNewForm: {
|
||||
type: Boolean
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
})
|
||||
|
||||
let holidays = reactive([])
|
||||
const holidayName = ref('')
|
||||
const holidayDate = ref(null)
|
||||
const selectedDays = ref({})
|
||||
const hours = ref({})
|
||||
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(formSchema),
|
||||
initialValues: props.initialValues
|
||||
})
|
||||
|
||||
|
||||
const saveHoliday = () => {
|
||||
holidays.push({
|
||||
name: holidayName.value,
|
||||
date: new Date(holidayDate.value).toISOString().split('T')[0]
|
||||
})
|
||||
holidayName.value = ''
|
||||
holidayDate.value = null
|
||||
}
|
||||
|
||||
const deleteHoliday = (item) => {
|
||||
holidays.splice(holidays.findIndex(h => h.name === item.name), 1)
|
||||
}
|
||||
|
||||
const handleDayToggle = (day, checked) => {
|
||||
selectedDays.value = {
|
||||
...selectedDays.value,
|
||||
[day]: checked
|
||||
}
|
||||
|
||||
if (checked && !hours.value[day]) {
|
||||
hours.value[day] = { open: '09:00', close: '17:00' }
|
||||
} else if (!checked) {
|
||||
const newHours = { ...hours.value }
|
||||
delete newHours[day]
|
||||
hours.value = newHours
|
||||
}
|
||||
}
|
||||
|
||||
const updateHours = (day, type, value) => {
|
||||
if (!hours.value[day]) {
|
||||
hours.value[day] = { open: '09:00', close: '17:00' }
|
||||
}
|
||||
hours.value[day][type] = value
|
||||
}
|
||||
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
values.is_always_open = values.is_always_open === 'true'
|
||||
const businessHours = values.is_always_open === true
|
||||
? {}
|
||||
:
|
||||
Object.keys(selectedDays.value)
|
||||
.filter(day => selectedDays.value[day])
|
||||
.reduce((acc, day) => {
|
||||
acc[day] = hours.value[day]
|
||||
return acc
|
||||
}, {})
|
||||
const finalValues = {
|
||||
...values,
|
||||
hours: businessHours,
|
||||
holidays: holidays
|
||||
}
|
||||
props.submitForm(finalValues)
|
||||
})
|
||||
|
||||
// Watch for initial values
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
(newValues) => {
|
||||
if (!newValues || Object.keys(newValues).length === 0) return
|
||||
// Set business hours if provided
|
||||
newValues.is_always_open = newValues.is_always_open.toString()
|
||||
if (newValues.is_always_open === 'false') {
|
||||
hours.value = newValues.hours || {}
|
||||
selectedDays.value = Object.keys(hours.value).reduce((acc, day) => {
|
||||
acc[day] = true
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
// Set other form values
|
||||
form.setValues(newValues)
|
||||
holidays.length = 0
|
||||
holidays.push(...(newValues.holidays || []))
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="mb-5">
|
||||
<CustomBreadcrumb :links="breadcrumbLinks" />
|
||||
</div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<BusinessHoursForm :initial-values="businessHours" :submitForm="submitForm" :isNewForm="isNewForm"
|
||||
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }" :isLoading="formLoading" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import api from '@/api'
|
||||
import BusinessHoursForm from './BusinessHoursForm.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
|
||||
const businessHours = ref({})
|
||||
const emitter = useEmitter()
|
||||
const isLoading = ref(false)
|
||||
const formLoading = ref(false)
|
||||
const router = useRouter()
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: false
|
||||
}
|
||||
})
|
||||
|
||||
const submitForm = async (values) => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
if (props.id) {
|
||||
await api.updateBusinessHours(props.id, values)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Success',
|
||||
description: 'Business hours updated successfully',
|
||||
})
|
||||
} else {
|
||||
await api.createBusinessHours(values)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Success',
|
||||
description: 'Business hours created successfully',
|
||||
})
|
||||
router.push('/admin/business-hours')
|
||||
}
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not save business hours',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const breadCrumLabel = () => {
|
||||
return props.id ? 'Edit' : 'New'
|
||||
}
|
||||
|
||||
const isNewForm = computed(() => {
|
||||
return props.id ? false : true
|
||||
})
|
||||
|
||||
const breadcrumbLinks = [
|
||||
{ path: '/admin/business-hours', label: 'Business hours' },
|
||||
{ path: '#', label: breadCrumLabel() }
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.id) {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const resp = await api.getBusinessHours(props.id)
|
||||
businessHours.value = resp.data.data
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not fetch business hours',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { h } from 'vue'
|
||||
import dropdown from './dataTableDropdown.vue'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, 'Name')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, 'Created at')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, 'Updated at')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const role = row.original
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'relative' },
|
||||
h(dropdown, {
|
||||
role
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
<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({ name: 'edit-business-hours', params: { id } })
|
||||
}
|
||||
|
||||
async function deleteBusinessHours (id) {
|
||||
await api.deleteBusinessHours(id)
|
||||
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
|
||||
model: 'business_hours'
|
||||
})
|
||||
}
|
||||
</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="deleteBusinessHours(props.role.id)"> Delete </DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
13
frontend/src/components/admin/business_hours/formSchema.js
Normal file
13
frontend/src/components/admin/business_hours/formSchema.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
export const formSchema = z.object({
|
||||
name: z
|
||||
.string({
|
||||
required_error: 'Name is required.'
|
||||
})
|
||||
.min(1, {
|
||||
message: 'Name must be at least 1 character.'
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
is_always_open: z.string().default('true').optional(),
|
||||
})
|
||||
@@ -1,13 +1,12 @@
|
||||
<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>
|
||||
class="flex-1 rounded-xl px-6 py-4 border border-muted shadow-md hover:shadow-lg transition-transform duration-200 transform hover:scale-105 cursor-pointer bg-white max-w-80"
|
||||
@click="handleClick">
|
||||
<div class="flex items-center mb-3">
|
||||
<component :is="icon" size="24" class="mr-2 text-primary" />
|
||||
<p class="text-lg font-semibold text-gray-700">{{ title }}</p>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{{ subTitle }}</p>
|
||||
<p class="text-sm text-gray-500">{{ subTitle }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -15,18 +14,9 @@
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subTitle: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
icon: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
title: String,
|
||||
subTitle: String,
|
||||
icon: Function,
|
||||
onClick: {
|
||||
type: Function,
|
||||
default: null
|
||||
@@ -36,9 +26,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.onClick) {
|
||||
props.onClick()
|
||||
}
|
||||
if (props.onClick) props.onClick()
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-1">
|
||||
<span class="text-2xl">{{ title }}</span>
|
||||
<div class="flex flex-col space-y-1 border-b pb-3 mb-5 border-gray-200">
|
||||
<span class="font-semibold text-2xl">{{ title }}</span>
|
||||
<p class="text-muted-foreground text-lg">{{ description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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,11 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="Canned responses" description="Manage canned responses" />
|
||||
<div class="w-8/12">
|
||||
<div class="flex justify-between mb-5">
|
||||
<PageHeader title="Canned responses" description="Manage canned responses" />
|
||||
<div class="flex justify-end mb-4">
|
||||
<div class="flex justify-end mb-4 w-full">
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button size="sm">New canned response</Button>
|
||||
<Button class="ml-auto">New canned response</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="sm:max-w-[625px]">
|
||||
<DialogHeader>
|
||||
@@ -15,7 +15,7 @@
|
||||
<CannedResponsesForm @submit="onSubmit">
|
||||
<template #footer>
|
||||
<DialogFooter class="mt-7">
|
||||
<Button type="submit" size="sm">Save Changes</Button>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</CannedResponsesForm>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<CannedResponsesForm @submit="onSubmit">
|
||||
<template #footer>
|
||||
<DialogFooter class="mt-7">
|
||||
<Button type="submit" size="sm">Save Changes</Button>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</CannedResponsesForm>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="Status" description="Manage conversation statuses" />
|
||||
<div class="w-8/12">
|
||||
<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>
|
||||
<div class="flex justify-end mb-4 w-full">
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button class="ml-auto">New Status</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New status</DialogTitle>
|
||||
@@ -15,7 +15,7 @@
|
||||
<StatusForm @submit.prevent="onSubmit">
|
||||
<template #footer>
|
||||
<DialogFooter class="mt-10">
|
||||
<Button type="submit" size="sm"> Save changes </Button>
|
||||
<Button type="submit"> Save changes </Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</StatusForm>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<StatusForm @submit.prevent="onSubmit">
|
||||
<template #footer>
|
||||
<DialogFooter class="mt-10">
|
||||
<Button type="submit" size="sm"> Save changes </Button>
|
||||
<Button type="submit"> Save changes </Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</StatusForm>
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
<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>
|
||||
<PageHeader title="Tags" description="Manage conversation tags" />
|
||||
<div class="w-8/12">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div class="flex justify-end mb-4 w-full">
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button class="ml-auto">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"> Save changes </Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</TagsForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<div v-else>
|
||||
<DataTable :columns="columns" :data="tags" />
|
||||
</div>
|
||||
</div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<div v-else>
|
||||
<DataTable :columns="columns" :data="tags" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<TagsForm @submit.prevent="onSubmit">
|
||||
<template #footer>
|
||||
<DialogFooter class="mt-10">
|
||||
<Button type="submit" size="sm"> Save changes </Button>
|
||||
<Button type="submit"> Save changes </Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</TagsForm>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="General" description="General app settings" />
|
||||
<PageHeader title="General" description="Manage general app settings" />
|
||||
</div>
|
||||
<div class="flex justify-center items-center flex-col w-8/12">
|
||||
<GeneralSettingForm :submitForm="submitForm" :initial-values="initialValues" submitLabel="Save" />
|
||||
</div>
|
||||
<GeneralSettingForm :submitForm="submitForm" :initial-values="initialValues" submitLabel="Save" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Spinner v-if="formLoading"></Spinner>
|
||||
<form @submit="onSubmit" class="space-y-6" :class="{ 'opacity-50 transition-opacity duration-300': formLoading }">
|
||||
<form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50 transition-opacity duration-300': formLoading }">
|
||||
<FormField v-slot="{ field }" name="site_name">
|
||||
<FormItem>
|
||||
<FormLabel>Site Name</FormLabel>
|
||||
@@ -12,11 +12,11 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ field }" name="lang">
|
||||
<FormField v-slot="{ componentField }" name="lang">
|
||||
<FormItem>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="field" :modelValue="field.value">
|
||||
<Select v-bind="componentField" :modelValue="componentField.modelValue">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a language" />
|
||||
</SelectTrigger>
|
||||
@@ -32,6 +32,50 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="timezone">
|
||||
<FormItem>
|
||||
<FormLabel>Timezone</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a timezone" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="timezone in timezones" :key="timezone" :value="timezone">
|
||||
{{ timezone }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Default timezone.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="business_hours_id">
|
||||
<FormItem>
|
||||
<FormLabel>Business hours</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select business hours" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="bh in businessHours" :key="bh.id" :value="bh.id">
|
||||
{{ bh.name }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Default business hours.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ field }" name="root_url">
|
||||
<FormItem>
|
||||
<FormLabel>Root URL</FormLabel>
|
||||
@@ -92,12 +136,12 @@
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch, ref } from 'vue'
|
||||
import { watch, ref, onMounted } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
@@ -130,10 +174,13 @@ 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 emitter = useEmitter()
|
||||
const timezones = Intl.supportedValuesOf('timeZone')
|
||||
const isLoading = ref(false)
|
||||
const formLoading = ref(true)
|
||||
const businessHours = ref({})
|
||||
const props = defineProps({
|
||||
initialValues: {
|
||||
type: Object,
|
||||
@@ -154,6 +201,36 @@ const form = useForm({
|
||||
validationSchema: toTypedSchema(formSchema)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchBusinessHours()
|
||||
})
|
||||
|
||||
const fetchBusinessHours = async () => {
|
||||
try {
|
||||
const response = await api.getAllBusinessHours()
|
||||
// Convert business hours id to string
|
||||
response.data.data.forEach(bh => {
|
||||
bh.id = bh.id.toString()
|
||||
})
|
||||
businessHours.value = response.data.data
|
||||
} catch (error) {
|
||||
// If unauthorized (no permission), show a toast message.
|
||||
if (error.response.status === 403) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Unauthorized',
|
||||
variant: 'destructive',
|
||||
description: 'You do not have permission to view business hours.'
|
||||
})
|
||||
} else {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not fetch business hours',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
@@ -176,9 +253,15 @@ const onSubmit = form.handleSubmit(async (values) => {
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
(newValues) => {
|
||||
if (Object.keys(newValues).length === 0) {
|
||||
return
|
||||
}
|
||||
// Convert business hours id to string
|
||||
if (newValues.business_hours_id)
|
||||
newValues.business_hours_id = newValues.business_hours_id.toString()
|
||||
form.setValues(newValues)
|
||||
formLoading.value = false
|
||||
},
|
||||
{ deep: true }
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -9,20 +9,25 @@ export const formSchema = z.object({
|
||||
message: 'Site name must be at least 1 characters.'
|
||||
}),
|
||||
lang: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
business_hours_id: z.string().optional(),
|
||||
logo_url: z.string().url({
|
||||
message: 'Logo URL must be a valid URL.'
|
||||
}).url().optional(),
|
||||
root_url: z
|
||||
.string({
|
||||
required_error: 'Root URL is required.'
|
||||
})
|
||||
.url({
|
||||
message: 'Root URL must be a valid URL.'
|
||||
}),
|
||||
}).url(),
|
||||
favicon_url: z
|
||||
.string({
|
||||
required_error: 'Favicon URL is required.'
|
||||
})
|
||||
.url({
|
||||
message: 'Favicon URL must be a valid URL.'
|
||||
}),
|
||||
}).url(),
|
||||
max_file_upload_size: z
|
||||
.number({
|
||||
required_error: 'Max upload file size is required.'
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
}"
|
||||
@submit="submitForm"
|
||||
>
|
||||
<Button type="submit" size="sm" :is-loading="isLoading"> {{ props.submitLabel }} </Button>
|
||||
<Button type="submit" :is-loading="isLoading"> {{ props.submitLabel }} </Button>
|
||||
</AutoForm>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between mb-5">
|
||||
<PageHeader title="Inboxes" description="Manage your inboxes" />
|
||||
<div class="flex justify-end mb-4">
|
||||
<Button @click="navigateToAddInbox" size="sm"> New inbox </Button>
|
||||
<PageHeader title="Inboxes" description="Manage your inboxes" />
|
||||
<div class="w-8/12">
|
||||
<template v-if="router.currentRoute.value.path === '/admin/inboxes'">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div class="flex justify-end w-full mb-4">
|
||||
<Button @click="navigateToAddInbox"> New inbox </Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="data" v-else />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<router-view></router-view>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="data" v-else />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-view/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -99,7 +101,7 @@ const columns = [
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, 'Modified at')
|
||||
return h('div', { class: 'text-center' }, 'Updated at')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||
|
||||
@@ -42,13 +42,13 @@ export const formSchema = z.object({
|
||||
}),
|
||||
smtp: z
|
||||
.object({
|
||||
host: z.string().describe('Host').default('smtp.yourmailserver.com'),
|
||||
host: z.string().describe('Host').default('smtp.google.com'),
|
||||
port: z
|
||||
.number({ invalid_type_error: 'Port must be a number.' })
|
||||
.min(1, { message: 'Port must be at least 1.' })
|
||||
.max(65535, { message: 'Port must be at most 65535.' })
|
||||
.describe('Port')
|
||||
.default(25),
|
||||
.default(587),
|
||||
username: z.string().describe('Username'),
|
||||
password: z.string().describe('Password'),
|
||||
max_conns: z
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<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" />
|
||||
<PageHeader title="Notifications" description="Manage your email notification settings" />
|
||||
<div class="w-8/12">
|
||||
<div>
|
||||
<Spinner v-if="formLoading"></Spinner>
|
||||
<NotificationsForm :initial-values="initialValues" :submit-form="submitForm" :isLoading="formLoading" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
<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>
|
||||
<form @submit="onSmtpSubmit" class="space-y-6" :class="{ 'opacity-50 transition-opacity duration-300': isLoading }">
|
||||
|
||||
<!-- SMTP Host Field -->
|
||||
<FormField v-slot="{ componentField }" name="host">
|
||||
@@ -120,7 +106,8 @@
|
||||
<FormItem>
|
||||
<FormLabel>From Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="From email address. e.g. My Support <mysupport@example.com>" v-bind="componentField" />
|
||||
<Input type="text" placeholder="From email address. e.g. My Support <mysupport@example.com>"
|
||||
v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>From email address. e.g. My Support <mysupport@example.com></FormDescription>
|
||||
@@ -138,7 +125,20 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
<!-- 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>
|
||||
|
||||
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ const submitForm = async (values) => {
|
||||
})
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<template>
|
||||
<div class="flex justify-between mb-5">
|
||||
<PageHeader title="OpenID Connect SSO" description="Manage OpenID SSO configurations" />
|
||||
<div>
|
||||
<Button size="sm" @click="navigateToAddOIDC">New OIDC</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="oidc" v-else />
|
||||
<PageHeader title="OpenID Connect" description="Manage OpenID Connect configurations" />
|
||||
<div class="w-8/12">
|
||||
<template v-if="router.currentRoute.value.path === '/admin/oidc'">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div></div>
|
||||
<div>
|
||||
<Button @click="navigateToAddOIDC">New OIDC</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="oidc" v-else />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-view/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export const columns = [
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, 'Modified at')
|
||||
return h('div', { class: 'text-center' }, 'Updated at')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||
|
||||
91
frontend/src/components/admin/sla/CreateEditSLA.vue
Normal file
91
frontend/src/components/admin/sla/CreateEditSLA.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="mb-5">
|
||||
<CustomBreadcrumb :links="breadcrumbLinks" />
|
||||
</div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<SLAForm :initial-values="slaData" :submitForm="submitForm" :isNewForm="isNewForm"
|
||||
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }" :isLoading="formLoading" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import api from '@/api'
|
||||
import SLAForm from './SLAForm.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
|
||||
const slaData = ref({})
|
||||
const emitter = useEmitter()
|
||||
const isLoading = ref(false)
|
||||
const formLoading = ref(false)
|
||||
const router = useRouter()
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: false
|
||||
}
|
||||
})
|
||||
|
||||
const submitForm = async (values) => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
if (props.id) {
|
||||
await api.updateSLA(props.id, values)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Success',
|
||||
description: 'SLA updated successfully',
|
||||
})
|
||||
} else {
|
||||
await api.createSLA(values)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Success',
|
||||
description: 'SLA created successfully',
|
||||
})
|
||||
router.push('/admin/sla')
|
||||
}
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not save SLA',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const breadCrumLabel = () => {
|
||||
return props.id ? 'Edit' : 'New'
|
||||
}
|
||||
|
||||
const isNewForm = computed(() => {
|
||||
return props.id ? false : true
|
||||
})
|
||||
|
||||
const breadcrumbLinks = [
|
||||
{ path: '/admin/sla', label: 'SLA' },
|
||||
{ path: '#', label: breadCrumLabel() }
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.id) {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const resp = await api.getSLA(props.id)
|
||||
slaData.value = resp.data.data
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not fetch SLA',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
65
frontend/src/components/admin/sla/SLA.vue
Normal file
65
frontend/src/components/admin/sla/SLA.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<PageHeader title="SLA" description="Manage service level agreements" />
|
||||
<div class="w-8/12">
|
||||
<template v-if="router.currentRoute.value.path === '/admin/sla'">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div></div>
|
||||
<div>
|
||||
<Button @click="navigateToAddSLA">New SLA</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="slas" v-else />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-view/>
|
||||
</template>
|
||||
</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 { useRouter } from 'vue-router'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import PageHeader from '../common/PageHeader.vue'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api'
|
||||
|
||||
const slas = ref([])
|
||||
const isLoading = ref(false)
|
||||
const router = useRouter()
|
||||
const emit = useEmitter()
|
||||
|
||||
onMounted(() => {
|
||||
fetchAll()
|
||||
emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
|
||||
})
|
||||
|
||||
const refreshList = (data) => {
|
||||
if (data?.model === 'sla') fetchAll()
|
||||
}
|
||||
|
||||
const fetchAll = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const resp = await api.getAllSLAs()
|
||||
slas.value = resp.data.data
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToAddSLA = () => {
|
||||
router.push('/admin/sla/new')
|
||||
}
|
||||
</script>
|
||||
100
frontend/src/components/admin/sla/SLAForm.vue
Normal file
100
frontend/src/components/admin/sla/SLAForm.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<form @submit="onSubmit" class="space-y-8">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="SLA Name" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Describe the SLA" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="first_response_time">
|
||||
<FormItem>
|
||||
<FormLabel>First response time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="6h" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Duration in hours or minutes to respond to a conversation.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="resolution_time">
|
||||
<FormItem>
|
||||
<FormLabel>Resolution time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="4h" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Duration in hours or minutes to resolve a conversation.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" :disabled="isLoading">{{ submitLabel }}</Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { formSchema } from './formSchema.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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: () => 'Save'
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(formSchema),
|
||||
initialValues: props.initialValues
|
||||
})
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
props.submitForm(values)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
(newValues) => {
|
||||
if (!newValues || Object.keys(newValues).length === 0) return
|
||||
form.setValues(newValues)
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
</script>
|
||||
47
frontend/src/components/admin/sla/dataTableColumns.js
Normal file
47
frontend/src/components/admin/sla/dataTableColumns.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { h } from 'vue'
|
||||
import dropdown from './dataTableDropdown.vue'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, 'Name')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, 'Created at')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, 'Updated at')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const role = row.original
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'relative' },
|
||||
h(dropdown, {
|
||||
role
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
52
frontend/src/components/admin/sla/dataTableDropdown.vue
Normal file
52
frontend/src/components/admin/sla/dataTableDropdown.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<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/sla/${id}/edit` })
|
||||
}
|
||||
|
||||
async function deleteSLA (id) {
|
||||
await api.deleteSLA(id)
|
||||
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
|
||||
model: 'sla'
|
||||
})
|
||||
}
|
||||
</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="deleteSLA(props.role.id)"> Delete </DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
26
frontend/src/components/admin/sla/formSchema.js
Normal file
26
frontend/src/components/admin/sla/formSchema.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as z from 'zod'
|
||||
import { isGoHourMinuteDuration } from '@/utils/strings'
|
||||
|
||||
export const formSchema = z.object({
|
||||
name: z
|
||||
.string({
|
||||
required_error: 'Name is required.'
|
||||
})
|
||||
.max(255, {
|
||||
message: 'Name must be at most 255 characters.'
|
||||
}),
|
||||
description: z
|
||||
.string()
|
||||
.max(255, {
|
||||
message: 'Description must be at most 255 characters.'
|
||||
})
|
||||
.optional(),
|
||||
first_response_time: z.string().optional().refine(isGoHourMinuteDuration, {
|
||||
message:
|
||||
'Invalid duration format. Should be a number followed by h (hours), m (minutes).'
|
||||
}),
|
||||
resolution_time: z.string().optional().refine(isGoHourMinuteDuration, {
|
||||
message:
|
||||
'Invalid duration format. Should be a number followed by h (hours), m (minutes).'
|
||||
}),
|
||||
})
|
||||
@@ -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>
|
||||
@@ -45,7 +45,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
const breadcrumbLinks = [
|
||||
{ path: '/admin/teams', label: 'Teams' },
|
||||
|
||||
{ path: '/admin/teams/roles', label: 'Roles' },
|
||||
{ path: '#', label: 'Edit role' }
|
||||
]
|
||||
|
||||
@@ -19,7 +19,7 @@ const emitter = useEmitter()
|
||||
const router = useRouter()
|
||||
const formLoading = ref(false)
|
||||
const breadcrumbLinks = [
|
||||
{ path: '/admin/teams', label: 'Teams' },
|
||||
|
||||
{ path: '/admin/teams/roles', label: 'Roles' },
|
||||
{ path: '#', label: 'Add role' }
|
||||
]
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" size="sm" :isLoading="isLoading">{{ submitLabel }}</Button>
|
||||
<Button type="submit" :isLoading="isLoading">{{ submitLabel }}</Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<template>
|
||||
<div class="mb-5">
|
||||
<CustomBreadcrumb :links="breadcrumbLinks" />
|
||||
<PageHeader title="Roles" description="Manage roles" />
|
||||
<div class="w-8/12">
|
||||
<div v-if="router.currentRoute.value.path === '/admin/teams/roles'">
|
||||
<div class="flex justify-end mb-5">
|
||||
<Button @click="navigateToAddRole"> New role </Button>
|
||||
</div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="roles" v-else />
|
||||
</div>
|
||||
</div>
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
<div class="flex justify-end mb-5">
|
||||
<Button @click="navigateToAddRole" size="sm"> New role </Button>
|
||||
</div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="roles" v-else />
|
||||
</div>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -25,6 +27,7 @@ import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import PageHeader from '@/components/admin/common/PageHeader.vue'
|
||||
const { toast } = useToast()
|
||||
|
||||
const emit = useEmitter()
|
||||
@@ -32,7 +35,7 @@ const router = useRouter()
|
||||
const roles = ref([])
|
||||
const isLoading = ref(false)
|
||||
const breadcrumbLinks = [
|
||||
{ path: '/admin/teams', label: 'Teams' },
|
||||
|
||||
{ path: '#', label: 'Roles' }
|
||||
]
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ const formLoading = ref(false)
|
||||
const router = useRouter()
|
||||
const emitter = useEmitter()
|
||||
const breadcrumbLinks = [
|
||||
{ path: '/admin/teams', label: 'Teams' },
|
||||
{ path: '/admin/teams/teams', label: 'Teams' },
|
||||
{ path: '/admin/teams/teams/new', label: 'New team' }
|
||||
]
|
||||
@@ -39,7 +38,7 @@ const createTeam = async (values) => {
|
||||
router.push('/admin/teams/teams')
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Something went wrong',
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
@@ -22,11 +22,18 @@ const formLoading = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const breadcrumbLinks = [
|
||||
{ path: '/admin/teams', label: 'Teams' },
|
||||
|
||||
{ path: '/admin/teams/teams', label: 'Teams' },
|
||||
{ path: '#', label: 'Edit team' }
|
||||
]
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const submitForm = (values) => {
|
||||
updateTeam(values)
|
||||
}
|
||||
@@ -41,7 +48,7 @@ const updateTeam = async (payload) => {
|
||||
})
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not update team',
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
@@ -57,7 +64,7 @@ onMounted(async () => {
|
||||
team.value = resp.data.data
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not fetch team',
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
@@ -65,11 +72,4 @@ onMounted(async () => {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -8,9 +8,13 @@ import {
|
||||
} 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({
|
||||
team: {
|
||||
type: Object,
|
||||
@@ -21,9 +25,28 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
function editTeam(id) {
|
||||
function editTeam (id) {
|
||||
router.push({ path: `/admin/teams/teams/${id}/edit` })
|
||||
}
|
||||
|
||||
async function deleteTeam (id) {
|
||||
try {
|
||||
await api.deleteTeam(id)
|
||||
emitRefreshTeamList()
|
||||
} catch (error) {
|
||||
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const emitRefreshTeamList = () => {
|
||||
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
|
||||
model: 'team'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,6 +59,7 @@ function editTeam(id) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem @click="editTeam(props.team.id)"> Edit </DropdownMenuItem>
|
||||
<DropdownMenuItem @click="deleteTeam(props.team.id)"> Delete </DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
@@ -11,32 +11,93 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField name="auto_assign_conversations" v-slot="{ value, handleChange }">
|
||||
<FormField name="conversation_assignment_type" v-slot="{ componentField }">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox :checked="value" @update:checked="handleChange" />
|
||||
<Label>Auto assign conversations</Label>
|
||||
</div>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a assignment type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="at in assignmentTypes" :key="at" :value="at">
|
||||
{{ at }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Automatically assign new conversations to agents in this team in a round-robin fashion.</FormDescription>
|
||||
<FormDescription>
|
||||
Round robin: Conversations are assigned to team members in a round-robin fashion. <br>
|
||||
Manual: Conversations are manually assigned to team members.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
<FormField v-slot="{ componentField }" name="timezone">
|
||||
<FormItem>
|
||||
<FormLabel>Timezone</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a timezone" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="timezone in timezones" :key="timezone" :value="timezone">
|
||||
{{ timezone }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Team's timezone will be used to calculate SLA.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="business_hours_id">
|
||||
<FormItem>
|
||||
<FormLabel>Business hours</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select business hours" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="bh in businessHours" :key="bh.id" :value="bh.id">
|
||||
{{ bh.name }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Default business hours.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { watch, computed, ref, onMounted } 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
@@ -45,8 +106,18 @@ import {
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from '@/components/ui/form'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import api from '@/api'
|
||||
|
||||
const emitter = useEmitter()
|
||||
const timezones = computed(() => {
|
||||
return Intl.supportedValuesOf('timeZone')
|
||||
})
|
||||
const assignmentTypes = ['Round robin', 'Manual']
|
||||
const businessHours = ref([])
|
||||
const props = defineProps({
|
||||
initialValues: {
|
||||
type: Object,
|
||||
@@ -71,6 +142,32 @@ const form = useForm({
|
||||
validationSchema: toTypedSchema(teamFormSchema)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchBusinessHours()
|
||||
})
|
||||
|
||||
const fetchBusinessHours = async () => {
|
||||
try {
|
||||
const response = await api.getAllBusinessHours()
|
||||
businessHours.value = response.data.data
|
||||
} catch (error) {
|
||||
// If unauthorized (no permission), show a toast message.
|
||||
if (error.response.status === 403) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Unauthorized',
|
||||
variant: 'destructive',
|
||||
description: 'You do not have permission to view business hours.'
|
||||
})
|
||||
} else {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not fetch business hours',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
props.submitForm(values)
|
||||
})
|
||||
@@ -79,6 +176,7 @@ const onSubmit = form.handleSubmit((values) => {
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
(newValues) => {
|
||||
if (Object.keys(newValues).length === 0) return
|
||||
form.setValues(newValues)
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
<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 />
|
||||
<PageHeader title="Teams" description="Manage teams" />
|
||||
<div class="w-8/12">
|
||||
<div v-if="router.currentRoute.value.path === '/admin/teams/teams'">
|
||||
<div class="flex justify-end mb-5">
|
||||
<Button @click="navigateToAddTeam"> New team </Button>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="data" v-else />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<router-view></router-view>
|
||||
<template v-else>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { columns } from '@/components/admin/team/teams/TeamsDataTableColumns.js'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
@@ -25,14 +27,18 @@ import { Button } from '@/components/ui/button'
|
||||
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||
import DataTable from '@/components/admin/DataTable.vue'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/admin/common/PageHeader.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
|
||||
const breadcrumbLinks = [
|
||||
{ path: '/admin/teams', label: 'Teams' },
|
||||
|
||||
{ path: '/admin/teams/', label: 'Teams' }
|
||||
]
|
||||
|
||||
const emit = useEmitter()
|
||||
const router = useRouter()
|
||||
const data = ref([])
|
||||
const isLoading = ref(false)
|
||||
@@ -45,7 +51,7 @@ const getData = async () => {
|
||||
data.value = response.data.data
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not fetch teams.',
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
@@ -58,7 +64,24 @@ const navigateToAddTeam = () => {
|
||||
router.push('/admin/teams/teams/new')
|
||||
}
|
||||
|
||||
const listenForRefresh = () => {
|
||||
emit.on(EMITTER_EVENTS.REFRESH_LIST, (event) => {
|
||||
if (event.model === 'team') {
|
||||
getData()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const removeListeners = () => {
|
||||
emit.off(EMITTER_EVENTS.REFRESH_LIST)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
getData()
|
||||
listenForRefresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
removeListeners()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,7 @@ export const columns = [
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, 'Modified at')
|
||||
return h('div', { class: 'text-center' }, 'Updated at')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h(
|
||||
|
||||
@@ -8,5 +8,7 @@ export const teamFormSchema = z.object({
|
||||
.min(2, {
|
||||
message: 'Team name must be at least 2 characters.'
|
||||
}),
|
||||
auto_assign_conversations: z.boolean().optional()
|
||||
conversation_assignment_type: z.string({ required_error: 'Conversation assignment type is required.' }),
|
||||
business_hours_id : z.number({ required_error: 'Business hours is required.' }),
|
||||
timezone: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -18,7 +18,6 @@ const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const formLoading = ref(false)
|
||||
const breadcrumbLinks = [
|
||||
{ path: '/admin/teams', label: 'Teams' },
|
||||
{ path: '/admin/teams/users', label: 'Users' },
|
||||
{ path: '#', label: 'Add user' }
|
||||
]
|
||||
|
||||
@@ -22,7 +22,7 @@ const formLoading = ref(false)
|
||||
const emitter = useEmitter()
|
||||
|
||||
const breadcrumbLinks = [
|
||||
{ path: '/admin/teams', label: 'Teams' },
|
||||
|
||||
{ path: '/admin/teams/users', label: 'Users' },
|
||||
{ path: '#', label: 'Edit user' }
|
||||
]
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<template>
|
||||
<div class="mb-5">
|
||||
<CustomBreadcrumb :links="breadcrumbLinks" />
|
||||
<PageHeader title="Users" description="Manage users" />
|
||||
<div class="w-8/12">
|
||||
<div v-if="router.currentRoute.value.path === '/admin/teams/users'">
|
||||
<div class="flex justify-end mb-5">
|
||||
<Button @click="navigateToAddUser"> New user </Button>
|
||||
</div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="data" v-else />
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
</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>
|
||||
@@ -22,6 +26,7 @@ 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 PageHeader from '@/components/admin/common/PageHeader.vue'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api'
|
||||
@@ -32,7 +37,7 @@ const isLoading = ref(false)
|
||||
const data = ref([])
|
||||
const emit = useEmitter()
|
||||
const breadcrumbLinks = [
|
||||
{ path: '/admin/teams', label: 'Teams' },
|
||||
|
||||
{ path: '#', label: 'Users' }
|
||||
]
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export const columns = [
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, 'Modified at')
|
||||
return h('div', { class: 'text-center' }, 'Updated at')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h(
|
||||
|
||||
@@ -4,24 +4,37 @@
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Template name" v-bind="componentField" />
|
||||
<Input type="text" placeholder="Template name" v-bind="componentField" :disabled="!isOutgoingTemplate" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<div v-if="!isOutgoingTemplate">
|
||||
<FormField v-slot="{ componentField }" name="subject">
|
||||
<FormItem>
|
||||
<FormLabel>Subject</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Subject for email" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField v-slot="{ componentField, handleChange }" name="body">
|
||||
<FormItem>
|
||||
<FormLabel>Body</FormLabel>
|
||||
<FormControl>
|
||||
<CodeEditor v-model="componentField.modelValue" @update:modelValue="handleChange"></CodeEditor>
|
||||
</FormControl>
|
||||
<FormDescription>{{ `Make sure the template has \{\{ template "content" . \}\}` }}</FormDescription>
|
||||
<FormDescription v-if="isOutgoingTemplate">{{ `Make sure the template has \{\{ template "content" . \}\}` }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField name="is_default" v-slot="{ value, handleChange }">
|
||||
<FormField name="is_default" v-slot="{ value, handleChange }" v-if="isOutgoingTemplate">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -34,12 +47,12 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" size="sm" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { watch, computed } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
@@ -85,6 +98,10 @@ const onSubmit = form.handleSubmit((values) => {
|
||||
props.submitForm(values)
|
||||
})
|
||||
|
||||
const isOutgoingTemplate = computed(() => {
|
||||
return props.initialValues?.type === 'email_outgoing'
|
||||
})
|
||||
|
||||
// Watch for changes in initialValues and update the form.
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between mb-5">
|
||||
<PageHeader title="Email Templates" description="Manage outgoing email templates" />
|
||||
<div class="flex justify-end mb-4">
|
||||
<Button @click="navigateToAddTemplate" size="sm"> New template </Button>
|
||||
<PageHeader title="Email templates" description="Manage email templates" />
|
||||
<div class="w-8/12">
|
||||
<template v-if="router.currentRoute.value.path === '/admin/templates'">
|
||||
<div class="flex justify-between mb-5">
|
||||
<div></div>
|
||||
<div class="flex justify-end mb-4">
|
||||
<Button @click="navigateToAddTemplate"> New template </Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<DataTable :columns="columns" :data="templates" />
|
||||
</div>
|
||||
<div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<Tabs default-value="email_outgoing" v-model="templateType">
|
||||
<TabsList class="grid w-full grid-cols-2 mb-5">
|
||||
<TabsTrigger value="email_outgoing">Outgoing email templates</TabsTrigger>
|
||||
<TabsTrigger value="email_notification">Email notification templates</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="email_outgoing">
|
||||
<DataTable :columns="outgoingEmailTemplatesColumns" :data="templates" />
|
||||
</TabsContent>
|
||||
<TabsContent value="email_notification">
|
||||
<DataTable :columns="emailNotificationTemplates" :data="templates" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-view/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import DataTable from '@/components/admin/DataTable.vue'
|
||||
import { columns } from '@/components/admin/templates/dataTableColumns.js'
|
||||
import { emailNotificationTemplates, outgoingEmailTemplatesColumns } from '@/components/admin/templates/dataTableColumns.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import PageHeader from '@/components/admin/common/PageHeader.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui/tabs'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import api from '@/api'
|
||||
|
||||
const templateType = useStorage('templateType', 'email_outgoing')
|
||||
const templates = ref([])
|
||||
const isLoading = ref(false)
|
||||
const router = useRouter()
|
||||
@@ -42,7 +67,7 @@ onUnmounted(() => {
|
||||
const fetchAll = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const resp = await api.getTemplates()
|
||||
const resp = await api.getTemplates(templateType.value)
|
||||
templates.value = resp.data.data
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
@@ -56,4 +81,8 @@ const refreshList = (data) => {
|
||||
const navigateToAddTemplate = () => {
|
||||
router.push('/admin/templates/new')
|
||||
}
|
||||
|
||||
watch(templateType, () => {
|
||||
fetchAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { h } from 'vue'
|
||||
import dropdown from './dataTableDropdown.vue'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
export const columns = [
|
||||
export const outgoingEmailTemplatesColumns = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: function () {
|
||||
@@ -30,7 +30,44 @@ export const columns = [
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, 'Modified at')
|
||||
return h('div', { class: 'text-center' }, 'Updated at')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const template = row.original
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'relative' },
|
||||
h(dropdown, {
|
||||
template
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
export const emailNotificationTemplates = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, 'Name')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, 'Updated at')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||
|
||||
@@ -37,7 +37,7 @@ const deleteTemplate = async (id) => {
|
||||
})
|
||||
} catch (error) {
|
||||
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not delete template',
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import * as z from 'zod'
|
||||
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()
|
||||
})
|
||||
export const formSchema = z
|
||||
.object({
|
||||
name: z.string({
|
||||
required_error: 'Template name is required.',
|
||||
}),
|
||||
body: z.string({
|
||||
required_error: 'Template content is required.',
|
||||
}),
|
||||
type: z.string().optional(),
|
||||
subject: z.string().optional(),
|
||||
is_default: z.boolean().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.type !== 'email_outgoing' && !data.subject) {
|
||||
ctx.addIssue({
|
||||
path: ['subject'],
|
||||
message: 'Subject is required.',
|
||||
code: z.ZodIssueCode.custom,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
|
||||
<Button type="submit"> {{ submitLabel }} </Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
|
||||
<Button type="submit"> {{ submitLabel }} </Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,75 +1,75 @@
|
||||
<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" />
|
||||
<div class="space-y-4">
|
||||
<div v-for="(modelFilter, index) in modelValue" :key="index" class="group flex items-center gap-3">
|
||||
<div class="grid grid-cols-3 gap-2 w-full">
|
||||
<!-- Field -->
|
||||
<Select v-model="modelFilter.field">
|
||||
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
|
||||
<SelectValue placeholder="Field" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="field in fields" :key="field.value" :value="field.value">
|
||||
<SelectItem v-for="field in fields" :key="field.field" :value="field.field">
|
||||
{{ 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" />
|
||||
<!-- Operator -->
|
||||
<Select v-model="modelFilter.operator" v-if="modelFilter.field">
|
||||
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
|
||||
<SelectValue placeholder="Operator" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="operator in getFieldOperators(filter.field)" :key="operator.value"
|
||||
:value="operator.value">
|
||||
{{ operator.label }}
|
||||
<SelectItem v-for="op in getFieldOperators(modelFilter)" :key="op" :value="op">
|
||||
{{ op }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Value -->
|
||||
<div class="w-full" v-if="modelFilter.field && modelFilter.operator">
|
||||
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
|
||||
<Select v-if="getFieldOptions(modelFilter).length > 0" v-model="modelFilter.value">
|
||||
<SelectTrigger class="bg-transparent hover:bg-slate-100">
|
||||
<SelectValue placeholder="Select value" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="opt in getFieldOptions(modelFilter)" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input v-else v-model="modelFilter.value" class="bg-transparent hover:bg-slate-100" placeholder="Value"
|
||||
type="text" />
|
||||
</template>
|
||||
</div>
|
||||
</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 v-show="modelValue.length > 1" @click="removeFilter(index)" class="p-1 hover:bg-slate-100 rounded">
|
||||
<X class="w-4 h-4 text-slate-500" />
|
||||
</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 class="flex items-center justify-between pt-3">
|
||||
<Button variant="ghost" size="sm" @click.prevent="addFilter" class="text-slate-600">
|
||||
<Plus class="w-3 h-3 mr-1" /> Add filter
|
||||
</Button>
|
||||
<div class="flex gap-2" v-if="showButtons">
|
||||
<Button variant="ghost" @click="clearFilters">Reset</Button>
|
||||
<Button @click="applyFilters">Apply</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, watch, onUnmounted } from 'vue'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -78,7 +78,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
@@ -87,29 +87,16 @@ const props = defineProps({
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
showButtons: {
|
||||
type: Boolean,
|
||||
default: 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: '' })
|
||||
const modelValue = defineModel('modelValue', { required: false, default: () => [] })
|
||||
|
||||
const createFilter = () => ({ field: '', operator: '', value: '' })
|
||||
|
||||
onMounted(() => {
|
||||
if (modelValue.value.length === 0) {
|
||||
@@ -117,46 +104,42 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
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 != "")
|
||||
onUnmounted(() => {
|
||||
modelValue.value = []
|
||||
})
|
||||
|
||||
const getModel = (field) => {
|
||||
const fieldConfig = props.fields.find(f => f.field === field)
|
||||
return fieldConfig?.model || ''
|
||||
}
|
||||
watch(() => modelValue.value, (filters) => {
|
||||
filters.forEach(filter => {
|
||||
if (filter.field && !filter.model) {
|
||||
filter.model = getModel(filter.field)
|
||||
}
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
|
||||
const addFilter = () => modelValue.value.push(createFilter())
|
||||
const removeFilter = (index) => modelValue.value.splice(index, 1)
|
||||
const applyFilters = () => emit('apply', validFilters.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 validFilters = computed(() => {
|
||||
return modelValue.value.filter(filter => filter.field && filter.operator && filter.value)
|
||||
})
|
||||
|
||||
const getFieldType = computed(() => (fieldValue) => {
|
||||
const field = props.fields.find(f => f.value === fieldValue)
|
||||
return field ? field.type : 'text'
|
||||
})
|
||||
const getFieldOptions = (fieldValue) => {
|
||||
const field = props.fields.find(f => f.field === fieldValue.field)
|
||||
return field?.options || []
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
const getFieldOperators = (modelFilter) => {
|
||||
const field = props.fields.find(f => f.field === modelFilter.field)
|
||||
return field?.operators || []
|
||||
}
|
||||
</script>
|
||||
53
frontend/src/components/common/SimpleTable.vue
Normal file
53
frontend/src/components/common/SimpleTable.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th v-for="(header, index) in headers" :key="index" scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ header }}
|
||||
</th>
|
||||
<th scope="col" class="relative px-6 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="(item, index) in data" :key="index">
|
||||
<td v-for="key in keys" :key="key" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{{ item[key] }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<Button size="xs" variant="ghost" @click.prevent="deleteItem(item)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Trash2 } from 'lucide-vue-next';
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
|
||||
defineProps({
|
||||
headers: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
keys: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['deleteItem']);
|
||||
|
||||
function deleteItem(item) {
|
||||
emit('deleteItem', item);
|
||||
}
|
||||
</script>
|
||||
@@ -2,18 +2,19 @@
|
||||
<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="px-4 border-b h-[44px] flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3 text-sm">
|
||||
<div class="font-semibold">
|
||||
<div class="font-medium">
|
||||
{{ conversationStore.current.subject }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Badge variant="primary">
|
||||
{{ conversationStore.current.status }}
|
||||
</Badge>
|
||||
<div class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded-md text-sm">
|
||||
<GalleryVerticalEnd size="14" class="text-secondary" />
|
||||
<span class="text-secondary font-medium">{{ conversationStore.current.status }}</span>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem v-for="status in statuses" :key="status.name" @click="handleUpdateStatus(status.name)">
|
||||
@@ -37,13 +38,16 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
GalleryVerticalEnd,
|
||||
} from 'lucide-vue-next'
|
||||
import MessageList from '@/components/message/MessageList.vue'
|
||||
import ReplyBox from './ReplyBox.vue'
|
||||
import api from '@/api'
|
||||
|
||||
@@ -1,15 +1,44 @@
|
||||
<template>
|
||||
<div class="h-screen flex flex-col">
|
||||
<!-- Filters -->
|
||||
<div class="shrink-0">
|
||||
<ConversationListFilters @updateFilters="handleUpdateFilters" />
|
||||
|
||||
<div class="flex justify-between px-2 py-2 w-full">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="cursor-pointer">
|
||||
<Button variant="ghost">
|
||||
{{ conversationStore.getListStatus }}
|
||||
<ChevronDown class="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem v-for="status in conversationStore.statusesForSelect" :key="status.value"
|
||||
@click="handleStatusChange(status)">
|
||||
{{ status.label }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="cursor-pointer">
|
||||
<Button variant="ghost">
|
||||
{{ conversationStore.getListSortField }}
|
||||
<ChevronDown class="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem @click="handleSortChange('oldest')">Oldest</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="handleSortChange('newest')">Newest</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="handleSortChange('started_first')">Started first</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="handleSortChange('started_last')">Started last</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="handleSortChange('waiting_longest')">Waiting longest</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="handleSortChange('next_sla_target')">Next SLA target</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="handleSortChange('priority_first')">Priority first</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<!-- Empty list -->
|
||||
<!-- Empty -->
|
||||
<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"
|
||||
@@ -17,12 +46,15 @@
|
||||
|
||||
<!-- 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 class="space-y-5 px-2">
|
||||
<ConversationListItem class="mt-2" :conversation="conversation"
|
||||
:currentConversation="conversationStore.current"
|
||||
v-for="conversation in conversationStore.conversationsList" :key="conversation.uuid"
|
||||
:contactFullName="conversationStore.getContactFullName(conversation.uuid)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List skeleton -->
|
||||
<!-- skeleton -->
|
||||
<div v-if="isLoading">
|
||||
<ConversationListItemSkeleton v-for="index in 10" :key="index" />
|
||||
</div>
|
||||
@@ -46,40 +78,47 @@
|
||||
<script setup>
|
||||
import { onMounted, computed, onUnmounted } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { MessageCircleQuestion, MessageCircleWarning } from 'lucide-vue-next'
|
||||
import { MessageCircleQuestion, MessageCircleWarning, ChevronDown } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
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
|
||||
let reFetchInterval = null
|
||||
|
||||
// Re-fetch conversations list every 30 seconds for any missed updates.
|
||||
onMounted(() => {
|
||||
conversationStore.fetchConversationsList()
|
||||
// Refresh list every min.
|
||||
listRefreshInterval = setInterval(() => {
|
||||
conversationStore.fetchConversationsList(false)
|
||||
}, 60000)
|
||||
reFetchInterval = setInterval(() => {
|
||||
conversationStore.reFetchConversationsList(false)
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(listRefreshInterval)
|
||||
clearInterval(reFetchInterval)
|
||||
conversationStore.clearListReRenderInterval()
|
||||
})
|
||||
|
||||
const handleStatusChange = (status) => {
|
||||
conversationStore.setListStatus(status.label)
|
||||
}
|
||||
|
||||
const handleSortChange = (order) => {
|
||||
conversationStore.setListSortField(order)
|
||||
}
|
||||
|
||||
const loadNextPage = () => {
|
||||
conversationStore.fetchNextConversations()
|
||||
}
|
||||
|
||||
const handleUpdateFilters = (filters) => {
|
||||
console.log("setting ", filters)
|
||||
conversationStore.setConversationListFilters(filters)
|
||||
}
|
||||
|
||||
const hasConversations = computed(() => {
|
||||
return conversationStore.sortedConversations.length !== 0
|
||||
return conversationStore.conversationsList.length !== 0
|
||||
})
|
||||
|
||||
const hasErrored = computed(() => {
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
<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>
|
||||
<div class="flex justify-end px-2 py-2 border-b w-full">
|
||||
<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" />
|
||||
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]">
|
||||
@@ -25,10 +18,25 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ListFilter } from 'lucide-vue-next'
|
||||
import { ListFilter, ChevronDown } from 'lucide-vue-next'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Filter from '@/components/common/Filter.vue'
|
||||
import api from '@/api'
|
||||
|
||||
@@ -44,6 +52,14 @@ onMounted(() => {
|
||||
localFilters.value = [...conversationStore.conversations.filters]
|
||||
})
|
||||
|
||||
const handleStatusChange = (status) => {
|
||||
console.log('status', status)
|
||||
}
|
||||
|
||||
const handleSortChange = (order) => {
|
||||
console.log('order', order)
|
||||
}
|
||||
|
||||
const fetchInitialData = async () => {
|
||||
const [statusesResp, prioritiesResp] = await Promise.all([
|
||||
api.getStatuses(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex items-center cursor-pointer flex-row hover:bg-slate-50"
|
||||
:class="{ 'bg-slate-100': conversation.uuid === currentConversation?.uuid }"
|
||||
<div class="flex items-center cursor-pointer flex-row hover:bg-gray-100 hover:rounded-lg hover:box"
|
||||
:class="{ 'bg-white rounded-lg box': conversation.uuid === currentConversation?.uuid }"
|
||||
@click="router.push('/conversations/' + conversation.uuid)">
|
||||
|
||||
<div class="pl-3">
|
||||
@@ -12,7 +12,7 @@
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
<div class="ml-3 w-full border-b pb-2">
|
||||
<div class="ml-3 w-full pb-2">
|
||||
<div class="flex justify-between pt-2 pr-3">
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 flex gap-x-1">
|
||||
@@ -42,6 +42,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-2 mt-2">
|
||||
<SlaDisplay :dueAt="conversation.first_reply_due_at" :actualAt="conversation.first_reply_at" :label="'FRD'" :showSLAHit="false" />
|
||||
<SlaDisplay :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" :label="'RD'" :showSLAHit="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -52,6 +56,7 @@ 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'
|
||||
import SlaDisplay from '@/components/sla/SlaDisplay.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const props = defineProps({
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
<template>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">SLA policy</p>
|
||||
<p v-if="conversation.sla_policy_name">
|
||||
{{ conversation.sla_policy_name }}
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">Reference number</p>
|
||||
<p>
|
||||
#{{ conversation.reference_number }}
|
||||
{{ conversation.reference_number }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
@@ -14,6 +23,7 @@
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">First reply at</p>
|
||||
<SlaDisplay :dueAt="conversation.first_reply_due_at" :actualAt="conversation.first_reply_at" />
|
||||
<p v-if="conversation.first_reply_at">
|
||||
{{ format(conversation.first_reply_at, 'PPpp') }}
|
||||
</p>
|
||||
@@ -22,6 +32,7 @@
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">Resolved at</p>
|
||||
<SlaDisplay :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" />
|
||||
<p v-if="conversation.resolved_at">
|
||||
{{ format(conversation.resolved_at, 'PPpp') }}
|
||||
</p>
|
||||
@@ -35,10 +46,12 @@
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { format } from 'date-fns'
|
||||
import SlaDisplay from '@/components/sla/SlaDisplay.vue'
|
||||
defineProps({
|
||||
conversation: Object
|
||||
})
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
<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 class="flex">
|
||||
<div class="flex flex-col gap-x-5 box p-5 rounded-md space-y-5">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="text-2xl">{{title}}</p>
|
||||
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
|
||||
<span class="blinking-dot"></span>
|
||||
<strong class="uppercase tracking-wider">Live</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div v-for="(value, key) in counts" :key="key" class="flex flex-col items-center gap-y-2">
|
||||
<span class="text-muted-foreground">{{ labels[key] }}</span>
|
||||
<span class="text-2xl font-medium">{{ value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
defineProps({
|
||||
counts: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
labels: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
counts: { type: Object, required: true },
|
||||
labels: { type: Object, required: true },
|
||||
title: { type: String, required: true }
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-6" v-if="userStore.getFullName">
|
||||
<div>
|
||||
<span class="font-medium text-3xl space-y-1">
|
||||
<p>Hi, {{ userStore.getFullName }}</p>
|
||||
<p class="text-sm-muted">🌤️ {{ format(new Date(), 'EEEE, MMMM d, HH:mm a') }}</p>
|
||||
<span class="font-medium text-xl space-y-1">
|
||||
<p class="font-semibold text-2xl">Hi, {{ userStore.getFullName }}</p>
|
||||
<p class="text-muted-foreground text-lg">🌤️ {{ format(new Date(), 'EEEE, MMMM d, HH:mm a') }}</p>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user