diff --git a/cmd/auth.go b/cmd/auth.go index adfe278..85531ce 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -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 } diff --git a/cmd/business_hours.go b/cmd/business_hours.go new file mode 100644 index 0000000..e6bc630 --- /dev/null +++ b/cmd/business_hours.go @@ -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) +} diff --git a/cmd/conversation.go b/cmd/conversation.go index 0bad770..4e385ce 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -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 +} diff --git a/cmd/csat.go b/cmd/csat.go new file mode 100644 index 0000000..3a6a5ca --- /dev/null +++ b/cmd/csat.go @@ -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") +} diff --git a/cmd/handlers.go b/cmd/handlers.go index 7815136..a60ada5 100644 --- a/cmd/handlers.go +++ b/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) diff --git a/cmd/init.go b/cmd/init.go index 5809455..bac5dc2 100644 --- a/cmd/init.go +++ b/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) } diff --git a/cmd/install.go b/cmd/install.go index 50bc78b..44b8310 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -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. diff --git a/cmd/login.go b/cmd/login.go index 22c6235..838fe77 100644 --- a/cmd/login.go +++ b/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)) } diff --git a/cmd/main.go b/cmd/main.go index 12abde6..7b3af32 100644 --- a/cmd/main.go +++ b/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.") } diff --git a/cmd/media.go b/cmd/media.go index 0c8448f..a4bd151 100644 --- a/cmd/media.go +++ b/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 { diff --git a/cmd/messages.go b/cmd/messages.go index acf87ef..dea9aa0 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -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) } diff --git a/cmd/middlewares.go b/cmd/middlewares.go index e65a24f..298313d 100644 --- a/cmd/middlewares.go +++ b/cmd/middlewares.go @@ -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) } diff --git a/cmd/settings.go b/cmd/settings.go index 28b3b90..a10c306 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -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") } diff --git a/cmd/sla.go b/cmd/sla.go new file mode 100644 index 0000000..8f6512f --- /dev/null +++ b/cmd/sla.go @@ -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) +} diff --git a/cmd/teams.go b/cmd/teams.go index 0ceaa54..833d48b 100644 --- a/cmd/teams.go +++ b/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) -} \ No newline at end of file + return r.SendEnvelope("Team deleted successfully.") +} diff --git a/cmd/templates.go b/cmd/templates.go index 5cf0b85..4c265f0 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -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) } diff --git a/cmd/users.go b/cmd/users.go index ad01241..93a735a 100644 --- a/cmd/users.go +++ b/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")) diff --git a/cmd/views.go b/cmd/views.go new file mode 100644 index 0000000..590f851 --- /dev/null +++ b/cmd/views.go @@ -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) +} diff --git a/cmd/websocket.go b/cmd/websocket.go index 01bb844..23c6a5c 100644 --- a/cmd/websocket.go +++ b/cmd/websocket.go @@ -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, diff --git a/frontend/package.json b/frontend/package.json index 369d5b0..be25907 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index dc41fa2..0674c38 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,7 +1,7 @@ diff --git a/frontend/src/Root.vue b/frontend/src/Root.vue index 1dc7b15..90d88de 100644 --- a/frontend/src/Root.vue +++ b/frontend/src/Root.vue @@ -1,6 +1,6 @@ @@ -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, { diff --git a/frontend/src/components/admin/automation/formSchema.js b/frontend/src/components/admin/automation/formSchema.js index 6414cce..989abcb 100644 --- a/frontend/src/components/admin/automation/formSchema.js +++ b/frontend/src/components/admin/automation/formSchema.js @@ -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, + }); + } + }); diff --git a/frontend/src/components/admin/business_hours/BusinessHours.vue b/frontend/src/components/admin/business_hours/BusinessHours.vue new file mode 100644 index 0000000..2137e98 --- /dev/null +++ b/frontend/src/components/admin/business_hours/BusinessHours.vue @@ -0,0 +1,65 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/admin/business_hours/BusinessHoursForm.vue b/frontend/src/components/admin/business_hours/BusinessHoursForm.vue new file mode 100644 index 0000000..b6188a6 --- /dev/null +++ b/frontend/src/components/admin/business_hours/BusinessHoursForm.vue @@ -0,0 +1,268 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/admin/business_hours/CreateOrEditBusinessHours.vue b/frontend/src/components/admin/business_hours/CreateOrEditBusinessHours.vue new file mode 100644 index 0000000..f06f934 --- /dev/null +++ b/frontend/src/components/admin/business_hours/CreateOrEditBusinessHours.vue @@ -0,0 +1,91 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/admin/business_hours/dataTableColumns.js b/frontend/src/components/admin/business_hours/dataTableColumns.js new file mode 100644 index 0000000..df4782c --- /dev/null +++ b/frontend/src/components/admin/business_hours/dataTableColumns.js @@ -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 + }) + ) + } + } +] diff --git a/frontend/src/components/admin/business_hours/dataTableDropdown.vue b/frontend/src/components/admin/business_hours/dataTableDropdown.vue new file mode 100644 index 0000000..8db8c64 --- /dev/null +++ b/frontend/src/components/admin/business_hours/dataTableDropdown.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend/src/components/admin/business_hours/formSchema.js b/frontend/src/components/admin/business_hours/formSchema.js new file mode 100644 index 0000000..8170e5a --- /dev/null +++ b/frontend/src/components/admin/business_hours/formSchema.js @@ -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(), +}) diff --git a/frontend/src/components/admin/common/MenuCard.vue b/frontend/src/components/admin/common/MenuCard.vue index 86c5d2b..596538e 100644 --- a/frontend/src/components/admin/common/MenuCard.vue +++ b/frontend/src/components/admin/common/MenuCard.vue @@ -1,13 +1,12 @@ @@ -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') } diff --git a/frontend/src/components/admin/common/PageHeader.vue b/frontend/src/components/admin/common/PageHeader.vue index 125fa16..d513ddc 100644 --- a/frontend/src/components/admin/common/PageHeader.vue +++ b/frontend/src/components/admin/common/PageHeader.vue @@ -1,6 +1,6 @@ diff --git a/frontend/src/components/admin/conversation/Conversation.vue b/frontend/src/components/admin/conversation/Conversation.vue deleted file mode 100644 index 48bfdb8..0000000 --- a/frontend/src/components/admin/conversation/Conversation.vue +++ /dev/null @@ -1,55 +0,0 @@ - - - diff --git a/frontend/src/components/admin/conversation/canned_responses/CannedResponses.vue b/frontend/src/components/admin/conversation/canned_responses/CannedResponses.vue index 31c1942..03ba604 100644 --- a/frontend/src/components/admin/conversation/canned_responses/CannedResponses.vue +++ b/frontend/src/components/admin/conversation/canned_responses/CannedResponses.vue @@ -1,21 +1,21 @@ diff --git a/frontend/src/components/admin/oidc/dataTableColumns.js b/frontend/src/components/admin/oidc/dataTableColumns.js index 7897266..b7be37e 100644 --- a/frontend/src/components/admin/oidc/dataTableColumns.js +++ b/frontend/src/components/admin/oidc/dataTableColumns.js @@ -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')) diff --git a/frontend/src/components/admin/sla/CreateEditSLA.vue b/frontend/src/components/admin/sla/CreateEditSLA.vue new file mode 100644 index 0000000..cc16526 --- /dev/null +++ b/frontend/src/components/admin/sla/CreateEditSLA.vue @@ -0,0 +1,91 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/admin/sla/SLA.vue b/frontend/src/components/admin/sla/SLA.vue new file mode 100644 index 0000000..34d370e --- /dev/null +++ b/frontend/src/components/admin/sla/SLA.vue @@ -0,0 +1,65 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/admin/sla/SLAForm.vue b/frontend/src/components/admin/sla/SLAForm.vue new file mode 100644 index 0000000..78947d4 --- /dev/null +++ b/frontend/src/components/admin/sla/SLAForm.vue @@ -0,0 +1,100 @@ + + + + diff --git a/frontend/src/components/admin/sla/dataTableColumns.js b/frontend/src/components/admin/sla/dataTableColumns.js new file mode 100644 index 0000000..df4782c --- /dev/null +++ b/frontend/src/components/admin/sla/dataTableColumns.js @@ -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 + }) + ) + } + } +] diff --git a/frontend/src/components/admin/sla/dataTableDropdown.vue b/frontend/src/components/admin/sla/dataTableDropdown.vue new file mode 100644 index 0000000..15abc7a --- /dev/null +++ b/frontend/src/components/admin/sla/dataTableDropdown.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend/src/components/admin/sla/formSchema.js b/frontend/src/components/admin/sla/formSchema.js new file mode 100644 index 0000000..34a1839 --- /dev/null +++ b/frontend/src/components/admin/sla/formSchema.js @@ -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).' + }), +}) diff --git a/frontend/src/components/admin/team/Team.vue b/frontend/src/components/admin/team/Team.vue deleted file mode 100644 index 1deaf4f..0000000 --- a/frontend/src/components/admin/team/Team.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - diff --git a/frontend/src/components/admin/team/roles/EditRole.vue b/frontend/src/components/admin/team/roles/EditRole.vue index 579bab0..8fac7a6 100644 --- a/frontend/src/components/admin/team/roles/EditRole.vue +++ b/frontend/src/components/admin/team/roles/EditRole.vue @@ -45,7 +45,7 @@ onMounted(async () => { }) const breadcrumbLinks = [ - { path: '/admin/teams', label: 'Teams' }, + { path: '/admin/teams/roles', label: 'Roles' }, { path: '#', label: 'Edit role' } ] diff --git a/frontend/src/components/admin/team/roles/NewRole.vue b/frontend/src/components/admin/team/roles/NewRole.vue index c636159..4937d73 100644 --- a/frontend/src/components/admin/team/roles/NewRole.vue +++ b/frontend/src/components/admin/team/roles/NewRole.vue @@ -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' } ] diff --git a/frontend/src/components/admin/team/roles/RoleForm.vue b/frontend/src/components/admin/team/roles/RoleForm.vue index 26931fd..437cef8 100644 --- a/frontend/src/components/admin/team/roles/RoleForm.vue +++ b/frontend/src/components/admin/team/roles/RoleForm.vue @@ -38,7 +38,7 @@ - + diff --git a/frontend/src/components/admin/team/roles/Roles.vue b/frontend/src/components/admin/team/roles/Roles.vue index d4ebabf..41b96c9 100644 --- a/frontend/src/components/admin/team/roles/Roles.vue +++ b/frontend/src/components/admin/team/roles/Roles.vue @@ -1,15 +1,17 @@ diff --git a/frontend/src/components/admin/team/teams/TeamDataTableDropdown.vue b/frontend/src/components/admin/team/teams/TeamDataTableDropdown.vue index 7b5140c..4dcc85f 100644 --- a/frontend/src/components/admin/team/teams/TeamDataTableDropdown.vue +++ b/frontend/src/components/admin/team/teams/TeamDataTableDropdown.vue @@ -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' + }) +} diff --git a/frontend/src/components/admin/team/teams/TeamForm.vue b/frontend/src/components/admin/team/teams/TeamForm.vue index bb577ea..6a40d08 100644 --- a/frontend/src/components/admin/team/teams/TeamForm.vue +++ b/frontend/src/components/admin/team/teams/TeamForm.vue @@ -11,32 +11,93 @@ - + -
- - -
+
- Automatically assign new conversations to agents in this team in a round-robin fashion. + + Round robin: Conversations are assigned to team members in a round-robin fashion.
+ Manual: Conversations are manually assigned to team members. +
- + + + Timezone + + + + Team's timezone will be used to calculate SLA. + + + + + + + Business hours + + + + Default business hours. + + + + + diff --git a/frontend/src/components/admin/team/teams/TeamsDataTableColumns.js b/frontend/src/components/admin/team/teams/TeamsDataTableColumns.js index f0a05ec..def5068 100644 --- a/frontend/src/components/admin/team/teams/TeamsDataTableColumns.js +++ b/frontend/src/components/admin/team/teams/TeamsDataTableColumns.js @@ -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( diff --git a/frontend/src/components/admin/team/teams/teamFormSchema.js b/frontend/src/components/admin/team/teams/teamFormSchema.js index 8167f57..b689cb4 100644 --- a/frontend/src/components/admin/team/teams/teamFormSchema.js +++ b/frontend/src/components/admin/team/teams/teamFormSchema.js @@ -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(), }) diff --git a/frontend/src/components/admin/team/users/AddUserForm.vue b/frontend/src/components/admin/team/users/AddUserForm.vue index 056af83..95d4360 100644 --- a/frontend/src/components/admin/team/users/AddUserForm.vue +++ b/frontend/src/components/admin/team/users/AddUserForm.vue @@ -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' } ] diff --git a/frontend/src/components/admin/team/users/EditUserForm.vue b/frontend/src/components/admin/team/users/EditUserForm.vue index bd0d6cc..959483a 100644 --- a/frontend/src/components/admin/team/users/EditUserForm.vue +++ b/frontend/src/components/admin/team/users/EditUserForm.vue @@ -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' } ] diff --git a/frontend/src/components/admin/team/users/UserForm.vue b/frontend/src/components/admin/team/users/UserForm.vue index 11f39eb..13ac289 100644 --- a/frontend/src/components/admin/team/users/UserForm.vue +++ b/frontend/src/components/admin/team/users/UserForm.vue @@ -73,7 +73,7 @@
- + diff --git a/frontend/src/components/admin/team/users/UsersCard.vue b/frontend/src/components/admin/team/users/UsersCard.vue index 4070f90..3fb382e 100644 --- a/frontend/src/components/admin/team/users/UsersCard.vue +++ b/frontend/src/components/admin/team/users/UsersCard.vue @@ -1,15 +1,19 @@ diff --git a/frontend/src/components/admin/templates/dataTableColumns.js b/frontend/src/components/admin/templates/dataTableColumns.js index c2bb9e1..a1a6304 100644 --- a/frontend/src/components/admin/templates/dataTableColumns.js +++ b/frontend/src/components/admin/templates/dataTableColumns.js @@ -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')) diff --git a/frontend/src/components/admin/templates/dataTableDropdown.vue b/frontend/src/components/admin/templates/dataTableDropdown.vue index 4d5c481..480bfb6 100644 --- a/frontend/src/components/admin/templates/dataTableDropdown.vue +++ b/frontend/src/components/admin/templates/dataTableDropdown.vue @@ -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 }) diff --git a/frontend/src/components/admin/templates/formSchema.js b/frontend/src/components/admin/templates/formSchema.js index f74d774..7da8177 100644 --- a/frontend/src/components/admin/templates/formSchema.js +++ b/frontend/src/components/admin/templates/formSchema.js @@ -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, + }); + } + }); diff --git a/frontend/src/components/admin/uploads/LocalFsForm.vue b/frontend/src/components/admin/uploads/LocalFsForm.vue index be07155..1028540 100644 --- a/frontend/src/components/admin/uploads/LocalFsForm.vue +++ b/frontend/src/components/admin/uploads/LocalFsForm.vue @@ -34,7 +34,7 @@ - + diff --git a/frontend/src/components/admin/uploads/S3Form.vue b/frontend/src/components/admin/uploads/S3Form.vue index 023d56f..15451b8 100644 --- a/frontend/src/components/admin/uploads/S3Form.vue +++ b/frontend/src/components/admin/uploads/S3Form.vue @@ -121,7 +121,7 @@ - + diff --git a/frontend/src/components/common/Filter.vue b/frontend/src/components/common/Filter.vue index 0b8302e..b70cd82 100644 --- a/frontend/src/components/common/Filter.vue +++ b/frontend/src/components/common/Filter.vue @@ -1,75 +1,75 @@ - \ No newline at end of file + +const getFieldOperators = (modelFilter) => { + const field = props.fields.find(f => f.field === modelFilter.field) + return field?.operators || [] +} + diff --git a/frontend/src/components/common/SimpleTable.vue b/frontend/src/components/common/SimpleTable.vue new file mode 100644 index 0000000..0ea54ae --- /dev/null +++ b/frontend/src/components/common/SimpleTable.vue @@ -0,0 +1,53 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/conversation/Conversation.vue b/frontend/src/components/conversation/Conversation.vue index 2a0d8f1..2468c2e 100644 --- a/frontend/src/components/conversation/Conversation.vue +++ b/frontend/src/components/conversation/Conversation.vue @@ -2,18 +2,19 @@
-
+
-
+
{{ conversationStore.current.subject }}
- - {{ conversationStore.current.status }} - +
+ + {{ conversationStore.current.status }} +
@@ -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' diff --git a/frontend/src/components/conversation/list/ConversationList.vue b/frontend/src/components/conversation/list/ConversationList.vue index 6014a01..55e52de 100644 --- a/frontend/src/components/conversation/list/ConversationList.vue +++ b/frontend/src/components/conversation/list/ConversationList.vue @@ -1,15 +1,44 @@