mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-10-23 05:11:57 +00:00
Compare commits
84 Commits
v0.4.3-alp
...
v0.5.0-alp
Author | SHA1 | Date | |
---|---|---|---|
|
dcede8a461 | ||
|
39fd5c9165 | ||
|
4b8a954043 | ||
|
6ac9f28a32 | ||
|
8101c202fa | ||
|
09746fb365 | ||
|
f59ea59a2e | ||
|
a2cdd728c0 | ||
|
ac59a5defc | ||
|
05fbe39315 | ||
|
c7c65a3d83 | ||
|
5bf6b7df47 | ||
|
c034c21fa5 | ||
|
4ed241a03d | ||
|
6b00f70c37 | ||
|
c51073d289 | ||
|
d03d4477de | ||
|
3b211dc372 | ||
|
6b4f243b74 | ||
|
9ff5a53ebb | ||
|
9b9282dfd9 | ||
|
698e2d960e | ||
|
a8db8f64b5 | ||
|
f688be1c88 | ||
|
d3eb3499df | ||
|
721f7c811c | ||
|
a33e1453a8 | ||
|
b6ce6975c9 | ||
|
860b216e2b | ||
|
eaa2b1ddcf | ||
|
0f12b2a3f3 | ||
|
def0bb8e4c | ||
|
a41c360cdb | ||
|
159cca6866 | ||
|
83f553227a | ||
|
28a6a3d246 | ||
|
7e16cc1a74 | ||
|
aeef7d4ad7 | ||
|
f0358f67f0 | ||
|
12f2453f5a | ||
|
2742be5619 | ||
|
d837defbc9 | ||
|
5cc849e7eb | ||
|
729faf980c | ||
|
a36c81141b | ||
|
756147a2c9 | ||
|
88a641fe09 | ||
|
785da6715c | ||
|
32401fa231 | ||
|
83b891c92a | ||
|
f277f76a0a | ||
|
5f1a40acba | ||
|
d90b9c2be7 | ||
|
43184ec2f3 | ||
|
2fdcf68a22 | ||
|
4bef3e80a2 | ||
|
09703c1090 | ||
|
45541c221a | ||
|
fc0e0a8fff | ||
|
d1f931106d | ||
|
227aa26c35 | ||
|
79a3f0ff70 | ||
|
eefacdbda2 | ||
|
3783cce1be | ||
|
a4cb373f32 | ||
|
99e8949be6 | ||
|
1240051825 | ||
|
5398d4ec41 | ||
|
fd4e47dc68 | ||
|
1ff7317c4d | ||
|
d6449b9336 | ||
|
580fb76a39 | ||
|
91889423a2 | ||
|
f12efe5511 | ||
|
56187ddc46 | ||
|
47af51d0dd | ||
|
47a3985a51 | ||
|
3f11af13b8 | ||
|
da629c864c | ||
|
6fb35b90b3 | ||
|
9892f9dae7 | ||
|
277586f025 | ||
|
f3070e13a7 | ||
|
8ed29df11c |
@@ -2,7 +2,7 @@
|
||||
FROM alpine:latest
|
||||
|
||||
# Install necessary packages
|
||||
RUN apk --no-cache add ca-certificates
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
# Set the working directory to /libredesk
|
||||
WORKDIR /libredesk
|
||||
|
@@ -54,6 +54,8 @@ curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
|
||||
# Copy the config.sample.toml to config.toml and edit it as needed.
|
||||
cp config.sample.toml config.toml
|
||||
|
||||
# Edit config.toml and find commented lines containing "docker compose". Replace the values in the lines below those comments with service names instead of IP addresses.
|
||||
|
||||
# Run the services in the background.
|
||||
docker compose up -d
|
||||
|
||||
|
@@ -38,13 +38,6 @@ func handleGetAllConversations(r *fastglue.Request) error {
|
||||
total = conversations[0].Total
|
||||
}
|
||||
|
||||
// Set deadlines for SLA if conversation has a policy
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
setSLADeadlines(app, &conversations[i])
|
||||
}
|
||||
}
|
||||
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Results: conversations,
|
||||
Total: total,
|
||||
@@ -74,13 +67,6 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
|
||||
total = conversations[0].Total
|
||||
}
|
||||
|
||||
// Set deadlines for SLA if conversation has a policy
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
setSLADeadlines(app, &conversations[i])
|
||||
}
|
||||
}
|
||||
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Results: conversations,
|
||||
Total: total,
|
||||
@@ -110,13 +96,6 @@ func handleGetUnassignedConversations(r *fastglue.Request) error {
|
||||
total = conversations[0].Total
|
||||
}
|
||||
|
||||
// Set deadlines for SLA if conversation has a policy
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
setSLADeadlines(app, &conversations[i])
|
||||
}
|
||||
}
|
||||
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Results: conversations,
|
||||
Total: total,
|
||||
@@ -188,13 +167,6 @@ func handleGetViewConversations(r *fastglue.Request) error {
|
||||
total = conversations[0].Total
|
||||
}
|
||||
|
||||
// Set deadlines for SLA if conversation has a policy
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
setSLADeadlines(app, &conversations[i])
|
||||
}
|
||||
}
|
||||
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Results: conversations,
|
||||
Total: total,
|
||||
@@ -240,13 +212,6 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
|
||||
total = conversations[0].Total
|
||||
}
|
||||
|
||||
// Set deadlines for SLA if conversation has a policy
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
setSLADeadlines(app, &conversations[i])
|
||||
}
|
||||
}
|
||||
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Results: conversations,
|
||||
Total: total,
|
||||
@@ -274,10 +239,6 @@ func handleGetConversation(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if conv.SLAPolicyID.Int != 0 {
|
||||
setSLADeadlines(app, conv)
|
||||
}
|
||||
|
||||
prev, _ := app.conversation.GetContactConversations(conv.ContactID)
|
||||
conv.PreviousConversations = filterCurrentConv(prev, conv.UUID)
|
||||
return r.SendEnvelope(conv)
|
||||
@@ -380,7 +341,7 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
conversation, err := enforceConversationAccess(app, uuid, user)
|
||||
_, err = enforceConversationAccess(app, uuid, user)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -391,18 +352,6 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
|
||||
// Evaluate automation rules on team assignment.
|
||||
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationTeamAssigned)
|
||||
|
||||
// Apply SLA policy if team has changed and the new team has an SLA policy.
|
||||
if conversation.AssignedTeamID.Int != assigneeID && assigneeID != 0 {
|
||||
team, err := app.team.Get(assigneeID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if team.SLAPolicyID.Int != 0 {
|
||||
if err := app.conversation.ApplySLA(*conversation, team.SLAPolicyID.Int, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return r.SendEnvelope("Team assigned successfully")
|
||||
}
|
||||
|
||||
@@ -417,27 +366,22 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
|
||||
if priority == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `priority`", nil, envelope.InputError)
|
||||
}
|
||||
conversation, err := app.conversation.GetConversation(0, uuid)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
allowed, err := app.authz.EnforceConversationAccess(user, conversation)
|
||||
_, err = enforceConversationAccess(app, uuid, user)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if !allowed {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
|
||||
}
|
||||
if err := app.conversation.UpdateConversationPriority(uuid, 0 /**priority_id**/, priority, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Evaluate automation rules.
|
||||
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationPriorityChange)
|
||||
|
||||
return r.SendEnvelope("Priority updated successfully")
|
||||
}
|
||||
|
||||
@@ -518,20 +462,14 @@ func handleUpdateConversationtags(r *fastglue.Request) error {
|
||||
app.lo.Error("error unmarshalling tags JSON", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling tags JSON", nil, envelope.GeneralError)
|
||||
}
|
||||
conversation, err := app.conversation.GetConversation(0, uuid)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if allowed, err := app.authz.EnforceConversationAccess(user, conversation); err != nil {
|
||||
_, err = enforceConversationAccess(app, uuid, user)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
} else if !allowed {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
|
||||
}
|
||||
|
||||
if err := app.conversation.UpsertConversationTags(uuid, tagNames, user); err != nil {
|
||||
@@ -580,21 +518,6 @@ func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmode
|
||||
return &conversation, nil
|
||||
}
|
||||
|
||||
// setSLADeadlines gets the latest SLA deadlines for a conversation and sets them.
|
||||
func setSLADeadlines(app *App, conversation *cmodels.Conversation) error {
|
||||
if conversation.ID < 1 {
|
||||
return nil
|
||||
}
|
||||
first, resolution, err := app.sla.GetLatestDeadlines(conversation.ID)
|
||||
if err != nil {
|
||||
app.lo.Error("error getting SLA deadlines", "id", conversation.ID, "error", err)
|
||||
return err
|
||||
}
|
||||
conversation.FirstResponseDueAt = null.NewTime(first, first != time.Time{})
|
||||
conversation.ResolutionDueAt = null.NewTime(resolution, resolution != time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleRemoveUserAssignee removes the user assigned to a conversation.
|
||||
func handleRemoveUserAssignee(r *fastglue.Request) error {
|
||||
var (
|
||||
|
@@ -12,10 +12,6 @@ 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.
|
||||
@@ -169,8 +165,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
// SLA.
|
||||
g.GET("/api/v1/sla", perm(handleGetSLAs, "sla:manage"))
|
||||
g.GET("/api/v1/sla/{id}", perm(handleGetSLA, "sla:manage"))
|
||||
g.POST("/api/v1/sla", perm(fastglue.ReqLenRangeParams(handleCreateSLA, slaReqFields), "sla:manage"))
|
||||
g.PUT("/api/v1/sla/{id}", perm(fastglue.ReqLenRangeParams(handleUpdateSLA, slaReqFields), "sla:manage"))
|
||||
g.POST("/api/v1/sla", perm(handleCreateSLA, "sla:manage"))
|
||||
g.PUT("/api/v1/sla/{id}", perm(handleUpdateSLA, "sla:manage"))
|
||||
g.DELETE("/api/v1/sla/{id}", perm(handleDeleteSLA, "sla:manage"))
|
||||
|
||||
// AI completion.
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strconv"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
@@ -36,14 +37,18 @@ func handleGetInbox(r *fastglue.Request) error {
|
||||
|
||||
func handleCreateInbox(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
inb = imodels.Inbox{}
|
||||
app = r.Context.(*App)
|
||||
inbox = imodels.Inbox{}
|
||||
)
|
||||
if err := r.Decode(&inb, "json"); err != nil {
|
||||
if err := r.Decode(&inbox, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
err := app.inbox.Create(inb)
|
||||
if err != nil {
|
||||
|
||||
if err := app.inbox.Create(inbox); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if err := validateInbox(inbox); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
@@ -69,18 +74,24 @@ func handleUpdateInbox(r *fastglue.Request) error {
|
||||
if err := r.Decode(&inbox, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
|
||||
if err := validateInbox(inbox); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
err = app.inbox.Update(id, inbox)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Could not update inbox.", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
if err := reloadInboxes(app); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading inboxes, Please restart the app if the issue persists", nil, envelope.GeneralError)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading inboxes, Please restart the app.", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(inbox)
|
||||
}
|
||||
|
||||
// handleToggleInbox toggles an inbox
|
||||
func handleToggleInbox(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -102,6 +113,7 @@ func handleToggleInbox(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleDeleteInbox deletes an inbox
|
||||
func handleDeleteInbox(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -118,3 +130,24 @@ func handleDeleteInbox(r *fastglue.Request) error {
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// validateInbox validates the inbox
|
||||
func validateInbox(inbox imodels.Inbox) error {
|
||||
// Make sure it's a valid from email address.
|
||||
if _, err := mail.ParseAddress(inbox.From); err != nil {
|
||||
return envelope.NewError(envelope.InputError, "Invalid from email address format, make sure it's a valid email address in the format `Name <mail@example.com>`", nil)
|
||||
}
|
||||
|
||||
if len(inbox.Config) == 0 {
|
||||
return envelope.NewError(envelope.InputError, "Empty config provided for inbox", nil)
|
||||
}
|
||||
|
||||
if inbox.Name == "" {
|
||||
return envelope.NewError(envelope.InputError, "Empty name provided for inbox", nil)
|
||||
}
|
||||
|
||||
if inbox.Channel == "" {
|
||||
return envelope.NewError(envelope.InputError, "Empty channel provided for inbox", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@@ -283,12 +283,12 @@ func initBusinessHours(db *sqlx.DB) *businesshours.Manager {
|
||||
}
|
||||
|
||||
// initSLA inits SLA manager.
|
||||
func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager) *sla.Manager {
|
||||
func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager, notifier *notifier.Service, template *tmpl.Manager, userManager *user.Manager) *sla.Manager {
|
||||
var lo = initLogger("sla")
|
||||
m, err := sla.New(sla.Opts{
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
}, teamManager, settings, businessHours)
|
||||
}, teamManager, settings, businessHours, notifier, template, userManager)
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing SLA manager: %v", err)
|
||||
}
|
||||
@@ -496,13 +496,13 @@ func initAutoAssigner(teamManager *team.Manager, userManager *user.Manager, conv
|
||||
}
|
||||
|
||||
// initNotifier initializes the notifier service with available providers.
|
||||
func initNotifier(userStore notifier.UserStore) *notifier.Service {
|
||||
func initNotifier() *notifier.Service {
|
||||
smtpCfg := email.SMTPConfig{}
|
||||
if err := ko.UnmarshalWithConf("notification.email", &smtpCfg, koanf.UnmarshalConf{Tag: "json"}); err != nil {
|
||||
log.Fatalf("error unmarshalling email notification provider config: %v", err)
|
||||
}
|
||||
|
||||
emailNotifier, err := emailnotifier.New([]email.SMTPConfig{smtpCfg}, userStore, emailnotifier.Opts{
|
||||
emailNotifier, err := emailnotifier.New([]email.SMTPConfig{smtpCfg}, emailnotifier.Opts{
|
||||
Lo: initLogger("email-notifier"),
|
||||
FromEmail: ko.String("notification.email.email_address"),
|
||||
})
|
||||
|
@@ -49,11 +49,10 @@ func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem, idempoten
|
||||
os.Exit(0)
|
||||
}
|
||||
} else {
|
||||
log.Println("installing database schema...")
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
|
||||
log.Println("installing database schema...")
|
||||
|
||||
// Install schema.
|
||||
if err := installSchema(db, fs); err != nil {
|
||||
log.Fatalf("error installing schema: %v", err)
|
||||
|
@@ -11,6 +11,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "time/tzdata"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/ai"
|
||||
auth_ "github.com/abhinavxd/libredesk/internal/auth"
|
||||
"github.com/abhinavxd/libredesk/internal/authz"
|
||||
@@ -106,7 +108,6 @@ func main() {
|
||||
|
||||
// Build string injected at build time.
|
||||
colorlog.Green("Build: %s", buildString)
|
||||
colorlog.Green("Version: %s", versionString)
|
||||
|
||||
// Load the config files into Koanf.
|
||||
initConfig(ko)
|
||||
@@ -176,9 +177,9 @@ func main() {
|
||||
businessHours = initBusinessHours(db)
|
||||
user = initUser(i18n, db)
|
||||
wsHub = initWS(user)
|
||||
notifier = initNotifier(user)
|
||||
notifier = initNotifier()
|
||||
automation = initAutomationEngine(db)
|
||||
sla = initSLA(db, team, settings, businessHours)
|
||||
sla = initSLA(db, team, settings, businessHours, notifier, template, user)
|
||||
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template)
|
||||
autoassigner = initAutoAssigner(team, user, conversation)
|
||||
)
|
||||
@@ -191,6 +192,7 @@ func main() {
|
||||
go conversation.RunUnsnoozer(ctx, unsnoozeInterval)
|
||||
go notifier.Run(ctx)
|
||||
go sla.Run(ctx, slaEvaluationInterval)
|
||||
go sla.SendNotifications(ctx)
|
||||
go media.DeleteUnlinkedMedia(ctx)
|
||||
go user.MonitorAgentAvailability(ctx)
|
||||
|
||||
|
@@ -156,7 +156,7 @@ func handleServeMedia(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Fetch media from DB.
|
||||
media, err := app.media.GetByUUID(strings.TrimPrefix(uuid, thumbPrefix))
|
||||
media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix))
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
@@ -48,11 +48,14 @@ func handleGetMessages(r *fastglue.Request) error {
|
||||
|
||||
for i := range messages {
|
||||
total = messages[i].Total
|
||||
// Populate attachment URLs
|
||||
for j := range messages[i].Attachments {
|
||||
messages[i].Attachments[j].URL = app.media.GetURL(messages[i].Attachments[j].UUID)
|
||||
}
|
||||
// Redact CSAT survey link
|
||||
messages[i].CensorCSATContent()
|
||||
}
|
||||
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Total: total,
|
||||
Results: messages,
|
||||
@@ -116,8 +119,7 @@ func handleRetryMessage(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
err = app.conversation.MarkMessageAsPending(uuid)
|
||||
if err != nil {
|
||||
if err = app.conversation.MarkMessageAsPending(uuid); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
@@ -150,7 +152,7 @@ func handleSendMessage(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
for _, id := range req.Attachments {
|
||||
m, err := app.media.Get(id)
|
||||
m, err := app.media.Get(id, "")
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching media", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error fetching media", nil, envelope.GeneralError)
|
||||
@@ -170,10 +172,5 @@ func handleSendMessage(r *fastglue.Request) error {
|
||||
app.automation.EvaluateConversationUpdateRules(cuuid, models.EventConversationMessageOutgoing)
|
||||
}
|
||||
|
||||
// Reopen if snoozed/closed/resolved regardless of automation rules - this is the default behavior
|
||||
if err := app.conversation.ReOpenConversation(cuuid, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope("Message sent successfully")
|
||||
}
|
||||
|
@@ -172,7 +172,7 @@ func notAuthPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandle
|
||||
user, err := app.auth.ValidateSession(r)
|
||||
if err != nil {
|
||||
app.lo.Error("error validating session", "error", err)
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session, clear cookies and try again", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
if user.ID != 0 {
|
||||
|
@@ -45,6 +45,9 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, "")
|
||||
}
|
||||
|
||||
// Remove any trailing slash `/` from the root url.
|
||||
req.RootURL = strings.TrimRight(req.RootURL, "/")
|
||||
|
||||
if err := app.setting.Update(req); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -103,7 +106,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
|
||||
|
||||
// Make sure it's a valid from email address.
|
||||
if _, err := mail.ParseAddress(req.EmailAddress); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid from email address format", nil, envelope.InputError)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid from email address format, make sure it's a valid email address in the format `Name <mail@example.com>`", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if req.Password == "" {
|
||||
|
120
cmd/sla.go
120
cmd/sla.go
@@ -5,10 +5,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
smodels "github.com/abhinavxd/libredesk/internal/sla/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// handleGetSLAs returns all SLAs.
|
||||
func handleGetSLAs(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -20,6 +22,7 @@ func handleGetSLAs(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(slas)
|
||||
}
|
||||
|
||||
// handleGetSLA returns the SLA with the given ID.
|
||||
func handleGetSLA(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -36,27 +39,56 @@ func handleGetSLA(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(sla)
|
||||
}
|
||||
|
||||
// handleCreateSLA creates a new 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"))
|
||||
app = r.Context.(*App)
|
||||
sla smodels.SLAPolicy
|
||||
)
|
||||
// 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 := r.Decode(&sla, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), 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 {
|
||||
|
||||
if err := validateSLA(&sla); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.Notifications); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope("SLA created successfully.")
|
||||
}
|
||||
|
||||
// handleUpdateSLA updates the SLA with the given ID.
|
||||
func handleUpdateSLA(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
sla smodels.SLAPolicy
|
||||
)
|
||||
|
||||
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 := r.Decode(&sla, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||
}
|
||||
|
||||
if err := validateSLA(&sla); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.Notifications); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope("SLA updated successfully.")
|
||||
}
|
||||
|
||||
// handleDeleteSLA deletes the SLA with the given ID.
|
||||
func handleDeleteSLA(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -73,31 +105,55 @@ func handleDeleteSLA(r *fastglue.Request) error {
|
||||
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"))
|
||||
)
|
||||
// validateSLA validates the SLA policy and returns an envelope.Error if any validation fails.
|
||||
func validateSLA(sla *smodels.SLAPolicy) error {
|
||||
if sla.Name == "" {
|
||||
return envelope.NewError(envelope.InputError, "SLA `name` is required", nil)
|
||||
}
|
||||
if sla.FirstResponseTime == "" {
|
||||
return envelope.NewError(envelope.InputError, "SLA `first_response_time` is required", nil)
|
||||
}
|
||||
if sla.ResolutionTime == "" {
|
||||
return envelope.NewError(envelope.InputError, "SLA `resolution_time` is required", nil)
|
||||
}
|
||||
|
||||
// Validate notifications if any
|
||||
for _, n := range sla.Notifications {
|
||||
if n.Type == "" {
|
||||
return envelope.NewError(envelope.InputError, "SLA notification `type` is required", nil)
|
||||
}
|
||||
if n.TimeDelayType == "" {
|
||||
return envelope.NewError(envelope.InputError, "SLA notification `time_delay_type` is required", nil)
|
||||
}
|
||||
if n.TimeDelayType != "immediately" {
|
||||
if n.TimeDelay == "" {
|
||||
return envelope.NewError(envelope.InputError, "SLA notification `time_delay` is required", nil)
|
||||
}
|
||||
}
|
||||
if len(n.Recipients) == 0 {
|
||||
return envelope.NewError(envelope.InputError, "SLA notification `recipients` is required", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate time duration strings
|
||||
if _, err := time.ParseDuration(firstRespTime); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `first_response_time` duration.", nil, envelope.InputError)
|
||||
frt, err := time.ParseDuration(sla.FirstResponseTime)
|
||||
if err != nil {
|
||||
return envelope.NewError(envelope.InputError, "Invalid `first_response_time` duration", nil)
|
||||
}
|
||||
if _, err := time.ParseDuration(resTime); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `resolution_time` duration.", nil, envelope.InputError)
|
||||
if frt.Minutes() < 1 {
|
||||
return envelope.NewError(envelope.InputError, "`first_response_time` should be greater than 1 minute", nil)
|
||||
}
|
||||
|
||||
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)
|
||||
rt, err := time.ParseDuration(sla.ResolutionTime)
|
||||
if err != nil {
|
||||
return envelope.NewError(envelope.InputError, "Invalid `resolution_time` duration", nil)
|
||||
}
|
||||
if rt.Minutes() < 1 {
|
||||
return envelope.NewError(envelope.InputError, "`resolution_time` should be greater than 1 minute", nil)
|
||||
}
|
||||
if frt > rt {
|
||||
return envelope.NewError(envelope.InputError, "`first_response_time` should be less than `resolution_time`", nil)
|
||||
}
|
||||
|
||||
if err := app.sla.Update(id, name, desc, firstRespTime, resTime); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
return nil
|
||||
}
|
||||
|
@@ -32,6 +32,7 @@ type migFunc struct {
|
||||
var migList = []migFunc{
|
||||
{"v0.3.0", migrations.V0_3_0},
|
||||
{"v0.4.0", migrations.V0_4_0},
|
||||
{"v0.5.0", migrations.V0_5_0},
|
||||
}
|
||||
|
||||
// upgrade upgrades the database to the current version by running SQL migration files
|
||||
|
22
cmd/users.go
22
cmd/users.go
@@ -205,8 +205,10 @@ func handleCreateUser(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Upsert user teams.
|
||||
if err := app.team.UpsertUserTeams(user.ID, user.Teams.Names()); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
if len(user.Teams) > 0 {
|
||||
if err := app.team.UpsertUserTeams(user.ID, user.Teams.Names()); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
}
|
||||
|
||||
if user.SendWelcomeEmail {
|
||||
@@ -227,10 +229,10 @@ func handleCreateUser(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
if err := app.notifier.Send(notifier.Message{
|
||||
UserIDs: []int{user.ID},
|
||||
Subject: "Welcome",
|
||||
Content: content,
|
||||
Provider: notifier.ProviderEmail,
|
||||
RecipientEmails: []string{user.Email.String},
|
||||
Subject: "Welcome",
|
||||
Content: content,
|
||||
Provider: notifier.ProviderEmail,
|
||||
}); err != nil {
|
||||
app.lo.Error("error sending notification message", "error", err)
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "User created successfully, but could not send welcome email.", nil))
|
||||
@@ -385,10 +387,10 @@ func handleResetPassword(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
if err := app.notifier.Send(notifier.Message{
|
||||
UserIDs: []int{user.ID},
|
||||
Subject: "Reset Password",
|
||||
Content: content,
|
||||
Provider: notifier.ProviderEmail,
|
||||
RecipientEmails: []string{user.Email.String},
|
||||
Subject: "Reset Password",
|
||||
Content: content,
|
||||
Provider: notifier.ProviderEmail,
|
||||
}); err != nil {
|
||||
app.lo.Error("error sending password reset email", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error sending password reset email", nil, envelope.GeneralError)
|
||||
|
@@ -28,7 +28,8 @@ services:
|
||||
networks:
|
||||
- libredesk
|
||||
ports:
|
||||
- "5432:5432"
|
||||
# Only bind on the local interface. To connect to Postgres externally, change this to 0.0.0.0
|
||||
- "127.0.0.1:5432:5432"
|
||||
environment:
|
||||
# Set these environment variables to configure the database, defaults to libredesk.
|
||||
POSTGRES_USER: ${POSTGRES_USER:-libredesk}
|
||||
|
@@ -27,6 +27,8 @@ curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
|
||||
# Copy the config.sample.toml to config.toml and edit it as needed.
|
||||
cp config.sample.toml config.toml
|
||||
|
||||
# Edit config.toml and find commented lines containing "docker compose". Replace the values in the lines below those comments with service names instead of IP addresses.
|
||||
|
||||
# Run the services in the background.
|
||||
docker compose up -d
|
||||
|
||||
@@ -36,8 +38,6 @@ docker exec -it libredesk_app ./libredesk --set-system-user-password
|
||||
|
||||
Go to `http://localhost:9000` and login with the email `System` and the password you set using the `--set-system-user-password` command.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Compiling from source
|
||||
|
||||
@@ -46,3 +46,19 @@ To compile the latest unreleased version (`main` branch):
|
||||
1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system.
|
||||
2. `git clone git@github.com:abhinavxd/libredesk.git`
|
||||
3. `cd libredesk && make`. This will generate the `libredesk` binary.
|
||||
|
||||
|
||||
## Nginx
|
||||
|
||||
Libredesk using websockets for real-time updates. If you are using Nginx, you need to add the following (or similar) configuration to your Nginx configuration file.
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://localhost:9000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
```
|
||||
|
@@ -21,8 +21,12 @@
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/vue-table": "^8.19.2",
|
||||
"@tiptap/extension-image": "^2.5.9",
|
||||
"@tiptap/extension-link": "^2.9.1",
|
||||
"@tiptap/extension-link": "^2.11.2",
|
||||
"@tiptap/extension-placeholder": "^2.4.0",
|
||||
"@tiptap/extension-table": "^2.11.5",
|
||||
"@tiptap/extension-table-cell": "^2.11.5",
|
||||
"@tiptap/extension-table-header": "^2.11.5",
|
||||
"@tiptap/extension-table-row": "^2.11.5",
|
||||
"@tiptap/pm": "^2.4.0",
|
||||
"@tiptap/starter-kit": "^2.4.0",
|
||||
"@tiptap/vue-3": "^2.4.0",
|
||||
|
52
frontend/pnpm-lock.yaml
generated
52
frontend/pnpm-lock.yaml
generated
@@ -27,11 +27,23 @@ importers:
|
||||
specifier: ^2.5.9
|
||||
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
|
||||
'@tiptap/extension-link':
|
||||
specifier: ^2.9.1
|
||||
specifier: ^2.11.2
|
||||
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
|
||||
'@tiptap/extension-placeholder':
|
||||
specifier: ^2.4.0
|
||||
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
|
||||
'@tiptap/extension-table':
|
||||
specifier: ^2.11.5
|
||||
version: 2.11.5(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
|
||||
'@tiptap/extension-table-cell':
|
||||
specifier: ^2.11.5
|
||||
version: 2.11.5(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
|
||||
'@tiptap/extension-table-header':
|
||||
specifier: ^2.11.5
|
||||
version: 2.11.5(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
|
||||
'@tiptap/extension-table-row':
|
||||
specifier: ^2.11.5
|
||||
version: 2.11.5(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
|
||||
'@tiptap/pm':
|
||||
specifier: ^2.4.0
|
||||
version: 2.11.2
|
||||
@@ -887,6 +899,27 @@ packages:
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-table-cell@2.11.5':
|
||||
resolution: {integrity: sha512-S967Au0pgeULstP3FaasOf/LEh72p61Ooh1PcUMF/az4x8EeGgpcEUARpVUxsGxLFvogv6LmhPHZdtcGgdHcBw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-table-header@2.11.5':
|
||||
resolution: {integrity: sha512-O1iBtzZP1XZDi4h1Xmgq1T63il+fpKPvBIMZ0JJH9TyCw5i5rcrMLL2dyy5zaWK3BFRJuYBNSke4c+VWnr/g6w==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-table-row@2.11.5':
|
||||
resolution: {integrity: sha512-+/VWhCuW24BcM5aaIc/f0bC6ZR1Q5gnuqw13MIo7gyPx7iIY6BXK8roGiZSs8wYAN4uBEf3EKFm0bSZwQuAeyg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-table@2.11.5':
|
||||
resolution: {integrity: sha512-NKXLhKWdAdURklm98YkCd2ai4fh8jY8HS/+X2s/2QiQt8Z98CU1keCm35fJEEExM234iB/hCqG5vY4JgTc0Tvw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
'@tiptap/pm': ^2.7.0
|
||||
|
||||
'@tiptap/extension-text-style@2.11.2':
|
||||
resolution: {integrity: sha512-RAa7BTwEOJRZN3EB2lg03KXyu7JC/Ce96cerh3D0Fo78yrtKOArPaiVHoTki6ZEIG43ccHEit1PPjMYxivPPeg==}
|
||||
peerDependencies:
|
||||
@@ -3862,6 +3895,23 @@ snapshots:
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
|
||||
|
||||
'@tiptap/extension-table-cell@2.11.5(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
|
||||
|
||||
'@tiptap/extension-table-header@2.11.5(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
|
||||
|
||||
'@tiptap/extension-table-row@2.11.5(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
|
||||
|
||||
'@tiptap/extension-table@2.11.5(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
|
||||
'@tiptap/pm': 2.11.2
|
||||
|
||||
'@tiptap/extension-text-style@2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
|
||||
|
@@ -16,7 +16,9 @@
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem v-if="userStore.hasAdminTabPermissions">
|
||||
<SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')">
|
||||
<router-link :to="{ name: 'admin' }">
|
||||
<router-link
|
||||
:to="{ name: userStore.can('general_settings:manage') ? 'general' : 'admin' }"
|
||||
>
|
||||
<Shield />
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
@@ -46,7 +48,7 @@
|
||||
@create-view="openCreateViewForm = true"
|
||||
@edit-view="editView"
|
||||
@delete-view="deleteView"
|
||||
@create-conversation="() => openCreateConversationDialog = true"
|
||||
@create-conversation="() => (openCreateConversationDialog = true)"
|
||||
>
|
||||
<div class="flex flex-col h-screen">
|
||||
<!-- Show app update only in admin routes -->
|
||||
|
@@ -82,8 +82,16 @@ const deleteBusinessHours = (id) => http.delete(`/api/v1/business-hours/${id}`)
|
||||
|
||||
const getAllSLAs = () => http.get('/api/v1/sla')
|
||||
const getSLA = (id) => http.get(`/api/v1/sla/${id}`)
|
||||
const createSLA = (data) => http.post('/api/v1/sla', data)
|
||||
const updateSLA = (id, data) => http.put(`/api/v1/sla/${id}`, data)
|
||||
const createSLA = (data) => http.post('/api/v1/sla', data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const updateSLA = (id, data) => http.put(`/api/v1/sla/${id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const deleteSLA = (id) => http.delete(`/api/v1/sla/${id}`)
|
||||
const createOIDC = (data) =>
|
||||
http.post('/api/v1/oidc', data, {
|
||||
|
@@ -1,53 +1,74 @@
|
||||
<template>
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th v-for="(header, index) in headers" :key="index" scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ header }}
|
||||
</th>
|
||||
<th scope="col" class="relative px-6 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="(item, index) in data" :key="index">
|
||||
<td v-for="key in keys" :key="key" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{{ item[key] }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<Button size="xs" variant="ghost" @click.prevent="deleteItem(item)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
v-for="(header, index) in headers"
|
||||
:key="index"
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{{ header }}
|
||||
</th>
|
||||
<th scope="col" class="relative px-6 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<template v-if="data.length === 0">
|
||||
<tr>
|
||||
<td :colspan="headers.length + 1" class="px-6 py-12 text-center">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<span class="text-md text-gray-500"> No records found. </span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<tr v-for="(item, index) in data" :key="index">
|
||||
<td
|
||||
v-for="key in keys"
|
||||
:key="key"
|
||||
class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"
|
||||
>
|
||||
{{ item[key] }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<Button size="xs" variant="ghost" @click.prevent="deleteItem(item)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Trash2 } from 'lucide-vue-next';
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import { Trash2 } from 'lucide-vue-next'
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
defineProps({
|
||||
headers: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
keys: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
headers: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
keys: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['deleteItem']);
|
||||
const emit = defineEmits(['deleteItem'])
|
||||
|
||||
function deleteItem(item) {
|
||||
emit('deleteItem', item);
|
||||
emit('deleteItem', item)
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@@ -4,8 +4,8 @@ import { RadioGroupRoot, useForwardPropsEmits } from 'radix-vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, required: false },
|
||||
defaultValue: { type: String, required: false },
|
||||
modelValue: { type: [String, Boolean], required: false },
|
||||
defaultValue: { type: [String, Boolean], required: false },
|
||||
disabled: { type: Boolean, required: false },
|
||||
name: { type: String, required: false },
|
||||
required: { type: Boolean, required: false },
|
||||
|
@@ -6,7 +6,7 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, required: false },
|
||||
value: { type: String, required: false },
|
||||
value: { type: [String, Boolean], required: false },
|
||||
disabled: { type: Boolean, required: false },
|
||||
required: { type: Boolean, required: false },
|
||||
name: { type: String, required: false },
|
||||
|
@@ -1,26 +1,46 @@
|
||||
<template>
|
||||
<TagsInput v-model="tags" class="px-0 gap-0">
|
||||
<TagsInput v-model="tags" class="px-0 gap-0" :displayValue="getLabel">
|
||||
<!-- Tags visible to the user -->
|
||||
<div class="flex gap-2 flex-wrap items-center px-3">
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText>{{ tag }}</TagsInputItemText>
|
||||
<TagsInputItem v-for="tagValue in tags" :key="tagValue" :value="tagValue">
|
||||
<TagsInputItemText/>
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
</div>
|
||||
<ComboboxRoot :model-value="tags" v-model:open="open" v-model:search-term="searchTerm" class="w-full">
|
||||
|
||||
<!-- Combobox for selecting new tags -->
|
||||
<ComboboxRoot
|
||||
:model-value="tags"
|
||||
v-model:open="open"
|
||||
v-model:search-term="searchTerm"
|
||||
:filterFunction="filterFunc"
|
||||
class="w-full"
|
||||
>
|
||||
<ComboboxAnchor as-child>
|
||||
<ComboboxInput :placeholder="placeholder" as-child>
|
||||
<TagsInputInput class="w-full px-3" :class="tags.length > 0 ? 'mt-2' : ''" @keydown.enter.prevent
|
||||
@blur="handleBlur" />
|
||||
<TagsInputInput
|
||||
class="w-full px-3"
|
||||
:class="tags.length > 0 ? 'mt-2' : ''"
|
||||
@keydown.enter.prevent
|
||||
@blur="handleBlur"
|
||||
/>
|
||||
</ComboboxInput>
|
||||
</ComboboxAnchor>
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent>
|
||||
<CommandList position="popper"
|
||||
class="w-[--radix-popper-anchor-width] rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2">
|
||||
<CommandEmpty />
|
||||
<CommandList
|
||||
position="popper"
|
||||
class="w-[--radix-popper-anchor-width] rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
>
|
||||
<CommandEmpty> No results found </CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem v-for="item in filteredOptions" :key="item" :value="item" @select="handleSelect">
|
||||
{{ item }}
|
||||
<CommandItem
|
||||
v-for="item in filteredOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
@select="handleSelect"
|
||||
>
|
||||
{{ item.label }}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
@@ -32,8 +52,20 @@
|
||||
|
||||
<script setup>
|
||||
import { CommandEmpty, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { TagsInput, TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText } from '@/components/ui/tags-input'
|
||||
import { ComboboxAnchor, ComboboxContent, ComboboxInput, ComboboxPortal, ComboboxRoot } from 'radix-vue'
|
||||
import {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
} from '@/components/ui/tags-input'
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
ComboboxContent,
|
||||
ComboboxInput,
|
||||
ComboboxPortal,
|
||||
ComboboxRoot
|
||||
} from 'radix-vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useField } from 'vee-validate'
|
||||
|
||||
@@ -54,7 +86,8 @@ const props = defineProps({
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
required: true,
|
||||
validator: (value) => value.every((item) => 'label' in item && 'value' in item)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -65,20 +98,35 @@ const { handleBlur } = useField(() => props.name, undefined, {
|
||||
const open = ref(false)
|
||||
const searchTerm = ref('')
|
||||
|
||||
const filteredOptions = computed(() =>
|
||||
props.items.filter(item => !tags.value.includes(item))
|
||||
)
|
||||
// Get all options that are not already selected and match the search term
|
||||
const filteredOptions = computed(() => {
|
||||
return props.items.filter(
|
||||
(item) =>
|
||||
!tags.value.includes(item.value) &&
|
||||
item.label.toLowerCase().includes(searchTerm.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
const getLabel = (value) => {
|
||||
const item = props.items.find((item) => item.value === value)
|
||||
return item?.label || value
|
||||
}
|
||||
|
||||
const handleSelect = (event) => {
|
||||
if (event.detail.value) {
|
||||
const selectedValue = event.detail.value
|
||||
if (selectedValue) {
|
||||
tags.value = [...tags.value, selectedValue]
|
||||
searchTerm.value = ''
|
||||
const newTags = [...tags.value]
|
||||
newTags.push(event.detail.value)
|
||||
tags.value = newTags
|
||||
}
|
||||
|
||||
if (filteredOptions.value.length === 0) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
// Custom filter function to filter items based on the search term
|
||||
const filterFunc = (remainingItemValues, term) => {
|
||||
const remainingItems = props.items.filter((item) => remainingItemValues.includes(item.value))
|
||||
return remainingItems.filter((item) => item.label.toLowerCase().includes(term.toLowerCase())).map(item => item.value)
|
||||
}
|
||||
</script>
|
||||
|
36
frontend/src/constants/timezones.js
Normal file
36
frontend/src/constants/timezones.js
Normal file
@@ -0,0 +1,36 @@
|
||||
export const timeZones = {
|
||||
"UTC (UTC+00:00)": "UTC",
|
||||
"New York, America (UTC-05:00)": "America/New_York",
|
||||
"Chicago, America (UTC-06:00)": "America/Chicago",
|
||||
"Denver, America (UTC-07:00)": "America/Denver",
|
||||
"Los Angeles, America (UTC-08:00)": "America/Los_Angeles",
|
||||
"Toronto, America (UTC-05:00)": "America/Toronto",
|
||||
"Mexico City, America (UTC-06:00)": "America/Mexico_City",
|
||||
"Bogotá, America (UTC-05:00)": "America/Bogota",
|
||||
"São Paulo, America (UTC-03:00)": "America/Sao_Paulo",
|
||||
"Buenos Aires, America (UTC-03:00)": "America/Buenos_Aires",
|
||||
"Santiago, America (UTC-04:00)": "America/Santiago",
|
||||
"London, Europe (UTC+00:00)": "Europe/London",
|
||||
"Berlin, Europe (UTC+01:00)": "Europe/Berlin",
|
||||
"Paris, Europe (UTC+01:00)": "Europe/Paris",
|
||||
"Rome, Europe (UTC+01:00)": "Europe/Rome",
|
||||
"Madrid, Europe (UTC+01:00)": "Europe/Madrid",
|
||||
"Moscow, Europe (UTC+03:00)": "Europe/Moscow",
|
||||
"Istanbul, Europe (UTC+03:00)": "Europe/Istanbul",
|
||||
"Dubai, Asia (UTC+04:00)": "Asia/Dubai",
|
||||
"Kolkata, Asia (UTC+05:30)": "Asia/Kolkata",
|
||||
"Bangkok, Asia (UTC+07:00)": "Asia/Bangkok",
|
||||
"Singapore, Asia (UTC+08:00)": "Asia/Singapore",
|
||||
"Shanghai, Asia (UTC+08:00)": "Asia/Shanghai",
|
||||
"Seoul, Asia (UTC+09:00)": "Asia/Seoul",
|
||||
"Tokyo, Asia (UTC+09:00)": "Asia/Tokyo",
|
||||
"Sydney, Australia (UTC+10:00)": "Australia/Sydney",
|
||||
"Melbourne, Australia (UTC+10:00)": "Australia/Melbourne",
|
||||
"Perth, Australia (UTC+08:00)": "Australia/Perth",
|
||||
"Auckland, Pacific (UTC+12:00)": "Pacific/Auckland",
|
||||
"Honolulu, Pacific (UTC-10:00)": "Pacific/Honolulu",
|
||||
"Cairo, Africa (UTC+02:00)": "Africa/Cairo",
|
||||
"Lagos, Africa (UTC+01:00)": "Africa/Lagos",
|
||||
"Nairobi, Africa (UTC+03:00)": "Africa/Nairobi",
|
||||
"Johannesburg, Africa (UTC+02:00)": "Africa/Johannesburg"
|
||||
}
|
@@ -39,7 +39,7 @@
|
||||
>
|
||||
<SelectTag
|
||||
v-model="action.value"
|
||||
:items="tagsStore.tagNames"
|
||||
:items="tagsStore.tagNames.map((tag) => ({ label: tag, value: tag }))"
|
||||
placeholder="Select tag"
|
||||
/>
|
||||
</div>
|
||||
|
@@ -1,127 +1,154 @@
|
||||
<template>
|
||||
<form @submit="onSubmit" class="space-y-8">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="General working hours" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<form @submit="onSubmit" class="space-y-8">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="General working hours" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="General working hours for my company" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="General working hours for my company"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="is_always_open">
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Set business hours
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup v-bind="componentField">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="flex items-center space-x-3">
|
||||
<RadioGroupItem id="r1" value="true" />
|
||||
<Label for="r1">Always open (24x7)</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<RadioGroupItem id="r2" value="false" />
|
||||
<Label for="r2">Custom business hours</Label>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<div v-if="form.values.is_always_open === 'false'">
|
||||
<div>
|
||||
<div v-for="day in WEEKDAYS" :key="day" class="flex items-center justify-between space-y-2">
|
||||
<div class="flex items-center space-x-3">
|
||||
<Checkbox :id="day" :checked="!!selectedDays[day]"
|
||||
@update:checked="handleDayToggle(day, $event)" />
|
||||
<Label :for="day" class="font-medium text-gray-800">{{ day }}</Label>
|
||||
</div>
|
||||
<div class="flex space-x-2 items-center">
|
||||
<div class="flex flex-col items-start">
|
||||
<Input type="time" :defaultValue="hours[day]?.open || '09:00'"
|
||||
@update:modelValue="(val) => updateHours(day, 'open', val)"
|
||||
:disabled="!selectedDays[day]" />
|
||||
</div>
|
||||
<span class="text-gray-500">to</span>
|
||||
<div class="flex flex-col items-start">
|
||||
<Input type="time" :defaultValue="hours[day]?.close || '17:00'"
|
||||
@update:modelValue="(val) => updateHours(day, 'close', val)"
|
||||
:disabled="!selectedDays[day]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormField v-slot="{ componentField }" name="is_always_open">
|
||||
<FormItem>
|
||||
<FormLabel> Set business hours </FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup v-bind="componentField">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="flex items-center space-x-3">
|
||||
<RadioGroupItem id="r1" :value="true" />
|
||||
<Label for="r1">Always open (24x7)</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<RadioGroupItem id="r2" :value="false" />
|
||||
<Label for="r2">Custom business hours</Label>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField name="hours">
|
||||
<div v-if="form.values.is_always_open === false">
|
||||
<FormItem>
|
||||
<div>
|
||||
<div
|
||||
v-for="day in WEEKDAYS"
|
||||
:key="day"
|
||||
class="flex items-center justify-between space-y-2"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
:id="day"
|
||||
:checked="!!selectedDays[day]"
|
||||
@update:checked="handleDayToggle(day, $event)"
|
||||
/>
|
||||
<Label :for="day" class="font-medium text-gray-800">{{ day }}</Label>
|
||||
</div>
|
||||
<div class="flex space-x-2 items-center">
|
||||
<div class="flex flex-col items-start">
|
||||
<Input
|
||||
type="time"
|
||||
:modelValue="hours[day]?.open || '09:00'"
|
||||
@update:modelValue="(val) => updateHours(day, 'open', val)"
|
||||
:disabled="!selectedDays[day]"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-gray-500">to</span>
|
||||
<div class="flex flex-col items-start">
|
||||
<Input
|
||||
type="time"
|
||||
:modelValue="hours[day]?.close || '17:00'"
|
||||
@update:modelValue="(val) => updateHours(day, 'close', val)"
|
||||
:disabled="!selectedDays[day]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<Dialog :open="openHolidayForm" @update:open="openHolidayForm = false">
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div></div>
|
||||
<DialogTrigger as-child>
|
||||
<Button @click="openHolidayForm = true"> New holiday </Button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
|
||||
<Dialog >
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div></div>
|
||||
<DialogTrigger as-child>
|
||||
<Button>New holiday</Button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</div>
|
||||
<SimpleTable :headers="['Name', 'Date']" :keys="['name', 'date']" :data="holidays" @deleteItem="deleteHoliday" />
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New holiday</DialogTitle>
|
||||
<DialogDescription>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="holiday_name" class="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input id="holiday_name" v-model="holidayName" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="date" class="text-right">
|
||||
Date
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" :class="cn(
|
||||
'w-[280px] justify-start text-left font-normal',
|
||||
!holidayDate && 'text-muted-foreground',
|
||||
)">
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
{{ holidayDate && !isNaN(new Date(holidayDate).getTime()) ? format(new
|
||||
Date(holidayDate), 'MMMM dd, yyyy') : "Pick a date" }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0">
|
||||
<Calendar v-model="holidayDate" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button :disabled="!holidayName || !holidayDate"
|
||||
@click="saveHoliday">
|
||||
Save changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button type="submit" :disabled="isLoading" :isLoading="isLoading">{{ submitLabel }}</Button>
|
||||
</form>
|
||||
</div>
|
||||
<SimpleTable
|
||||
:headers="['Name', 'Date']"
|
||||
:keys="['name', 'date']"
|
||||
:data="holidays"
|
||||
@deleteItem="deleteHoliday"
|
||||
/>
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New holiday</DialogTitle>
|
||||
<DialogDescription> </DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="holiday_name" class="text-right"> Name </Label>
|
||||
<Input id="holiday_name" v-model="holidayName" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="date" class="text-right"> Date </Label>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
:class="
|
||||
cn(
|
||||
'w-[280px] justify-start text-left font-normal',
|
||||
!holidayDate && 'text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
{{
|
||||
holidayDate && !isNaN(new Date(holidayDate).getTime())
|
||||
? format(new Date(holidayDate), 'MMMM dd, yyyy')
|
||||
: 'Pick a date'
|
||||
}}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0">
|
||||
<Calendar v-model="holidayDate" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button :disabled="!holidayName || !holidayDate" @click="saveHoliday">
|
||||
Save changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button type="submit" :disabled="isLoading" :isLoading="isLoading">{{ submitLabel }}</Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -143,36 +170,36 @@ import { WEEKDAYS } from '@/constants/date'
|
||||
import { Calendar as CalendarIcon } from 'lucide-vue-next'
|
||||
import SimpleTable from '@/components/table/SimpleTable.vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
const props = defineProps({
|
||||
initialValues: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
submitForm: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: () => 'Save'
|
||||
},
|
||||
isNewForm: {
|
||||
type: Boolean
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
initialValues: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
submitForm: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: () => 'Save'
|
||||
},
|
||||
isNewForm: {
|
||||
type: Boolean
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
}
|
||||
})
|
||||
|
||||
let holidays = reactive([])
|
||||
@@ -180,91 +207,97 @@ const holidayName = ref('')
|
||||
const holidayDate = ref(null)
|
||||
const selectedDays = ref({})
|
||||
const hours = ref({})
|
||||
|
||||
const openHolidayForm = ref(false)
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(formSchema),
|
||||
initialValues: props.initialValues
|
||||
validationSchema: toTypedSchema(formSchema),
|
||||
initialValues: props.initialValues
|
||||
})
|
||||
|
||||
|
||||
const saveHoliday = () => {
|
||||
holidays.push({
|
||||
name: holidayName.value,
|
||||
date: new Date(holidayDate.value).toISOString().split('T')[0]
|
||||
})
|
||||
holidayName.value = ''
|
||||
holidayDate.value = null
|
||||
holidays.push({
|
||||
name: holidayName.value,
|
||||
date: new Date(holidayDate.value).toISOString().split('T')[0]
|
||||
})
|
||||
holidayName.value = ''
|
||||
holidayDate.value = null
|
||||
openHolidayForm.value = false
|
||||
}
|
||||
|
||||
const deleteHoliday = (item) => {
|
||||
holidays.splice(holidays.findIndex(h => h.name === item.name), 1)
|
||||
holidays.splice(
|
||||
holidays.findIndex((h) => h.name === item.name),
|
||||
1
|
||||
)
|
||||
}
|
||||
|
||||
const handleDayToggle = (day, checked) => {
|
||||
selectedDays.value = {
|
||||
...selectedDays.value,
|
||||
[day]: checked
|
||||
}
|
||||
selectedDays.value = {
|
||||
...selectedDays.value,
|
||||
[day]: checked
|
||||
}
|
||||
|
||||
if (checked && !hours.value[day]) {
|
||||
hours.value[day] = { open: '09:00', close: '17:00' }
|
||||
} else if (!checked) {
|
||||
const newHours = { ...hours.value }
|
||||
delete newHours[day]
|
||||
hours.value = newHours
|
||||
}
|
||||
if (checked && !hours.value[day]) {
|
||||
hours.value[day] = { open: '09:00', close: '17:00' }
|
||||
} else if (!checked) {
|
||||
const newHours = { ...hours.value }
|
||||
delete newHours[day]
|
||||
hours.value = newHours
|
||||
}
|
||||
|
||||
// Sync with form values
|
||||
form.setFieldValue('hours', { ...hours.value })
|
||||
}
|
||||
|
||||
const updateHours = (day, type, value) => {
|
||||
if (!hours.value[day]) {
|
||||
hours.value[day] = { open: '09:00', close: '17:00' }
|
||||
}
|
||||
hours.value[day][type] = value
|
||||
if (!hours.value[day]) {
|
||||
hours.value[day] = { open: '09:00', close: '17:00' }
|
||||
}
|
||||
hours.value[day][type] = value
|
||||
|
||||
// Sync with form values
|
||||
form.setFieldValue('hours', { ...hours.value })
|
||||
}
|
||||
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
values.is_always_open = values.is_always_open === 'true'
|
||||
const businessHours = values.is_always_open === true
|
||||
? {}
|
||||
:
|
||||
Object.keys(selectedDays.value)
|
||||
.filter(day => selectedDays.value[day])
|
||||
.reduce((acc, day) => {
|
||||
acc[day] = hours.value[day]
|
||||
return acc
|
||||
}, {})
|
||||
const finalValues = {
|
||||
...values,
|
||||
hours: businessHours,
|
||||
holidays: holidays
|
||||
}
|
||||
props.submitForm(finalValues)
|
||||
const businessHours =
|
||||
values.is_always_open === true
|
||||
? {}
|
||||
: Object.keys(selectedDays.value)
|
||||
.filter((day) => selectedDays.value[day])
|
||||
.reduce((acc, day) => {
|
||||
acc[day] = hours.value[day]
|
||||
return acc
|
||||
}, {})
|
||||
const finalValues = {
|
||||
...values,
|
||||
is_always_open: values.is_always_open,
|
||||
hours: businessHours,
|
||||
holidays: holidays
|
||||
}
|
||||
props.submitForm(finalValues)
|
||||
})
|
||||
|
||||
// Watch for initial values
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
(newValues) => {
|
||||
if (!newValues || Object.keys(newValues).length === 0) {
|
||||
return
|
||||
}
|
||||
// Set business hours if provided
|
||||
newValues.is_always_open = newValues.is_always_open.toString()
|
||||
if (newValues.is_always_open === 'false') {
|
||||
hours.value = newValues.hours || {}
|
||||
selectedDays.value = Object.keys(hours.value).reduce((acc, day) => {
|
||||
acc[day] = true
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
// Set other form values
|
||||
form.setValues(newValues)
|
||||
holidays.length = 0
|
||||
holidays.push(...(newValues.holidays || []))
|
||||
},
|
||||
{ deep: true }
|
||||
() => props.initialValues,
|
||||
(newValues) => {
|
||||
if (!newValues || Object.keys(newValues).length === 0) {
|
||||
return
|
||||
}
|
||||
// Set business hours if provided
|
||||
if (newValues.is_always_open === false) {
|
||||
hours.value = newValues.hours || {}
|
||||
selectedDays.value = Object.keys(hours.value).reduce((acc, day) => {
|
||||
acc[day] = true
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
// Set other form values
|
||||
form.setValues(newValues)
|
||||
holidays.length = 0
|
||||
holidays.push(...(newValues.holidays || []))
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
@@ -1,13 +1,35 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/
|
||||
|
||||
export const formSchema = z.object({
|
||||
name: z
|
||||
.string({
|
||||
required_error: 'Name is required.'
|
||||
name: z.string().min(1, 'Name is required.'),
|
||||
description: z.string().min(1, 'Description is required.'),
|
||||
is_always_open: z.boolean().default(true),
|
||||
hours: z.record(
|
||||
z.object({
|
||||
open: z.string().regex(timeRegex, 'Invalid time format (HH:mm)'),
|
||||
close: z.string().regex(timeRegex, 'Invalid time format (HH:mm)')
|
||||
})
|
||||
.min(1, {
|
||||
message: 'Name must be at least 1 character.'
|
||||
}),
|
||||
description: z.string(),
|
||||
is_always_open: z.string().default('false'),
|
||||
).optional()
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.is_always_open === false) {
|
||||
if (!data.hours || Object.keys(data.hours).length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Business hours are required',
|
||||
path: ['hours']
|
||||
})
|
||||
} else {
|
||||
for (const day in data.hours) {
|
||||
if (!data.hours[day].open || !data.hours[day].close) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Open and close times are required for each day.',
|
||||
path: ['hours', day]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@@ -41,14 +41,14 @@
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="timezone in timezones" :key="timezone" :value="timezone">
|
||||
{{ timezone }}
|
||||
<SelectItem v-for="(value, label) in timeZones" :key="value" :value="value">
|
||||
{{ label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Default timezone.</FormDescription>
|
||||
<FormDescription>Default timezone for your desk.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
@@ -70,7 +70,7 @@
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Default business hours.</FormDescription>
|
||||
<FormDescription>Default business hours for your desk.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
@@ -81,7 +81,7 @@
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Root URL" v-bind="field" />
|
||||
</FormControl>
|
||||
<FormDescription>Root URL of the app.</FormDescription>
|
||||
<FormDescription>Root URL of the app. (No trailing slash)</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
@@ -123,27 +123,22 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
|
||||
<FormField name="allowed_file_upload_extensions" v-slot="{ componentField, handleChange }">
|
||||
<FormItem>
|
||||
<FormLabel>Allowed file upload extensions</FormLabel>
|
||||
<FormControl>
|
||||
<TagsInput
|
||||
:modelValue="componentField.modelValue"
|
||||
@update:modelValue="handleChange"
|
||||
>
|
||||
<TagsInputItem v-for="item in componentField.modelValue" :key="item" :value="item">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput placeholder="jpg" />
|
||||
</TagsInput>
|
||||
</FormControl>
|
||||
<FormDescription>Use `*` to allow any file.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField name="allowed_file_upload_extensions" v-slot="{ componentField, handleChange }">
|
||||
<FormItem>
|
||||
<FormLabel>Allowed file upload extensions</FormLabel>
|
||||
<FormControl>
|
||||
<TagsInput :modelValue="componentField.modelValue" @update:modelValue="handleChange">
|
||||
<TagsInputItem v-for="item in componentField.modelValue" :key="item" :value="item">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput placeholder="jpg" />
|
||||
</TagsInput>
|
||||
</FormControl>
|
||||
<FormDescription>Use `*` to allow any file.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" :isLoading="formLoading"> {{ submitLabel }} </Button>
|
||||
</form>
|
||||
@@ -182,10 +177,10 @@ import { Input } from '@/components/ui/input'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { timeZones } from '@/constants/timezones.js'
|
||||
import api from '@/api'
|
||||
|
||||
const emitter = useEmitter()
|
||||
const timezones = Intl.supportedValuesOf('timeZone')
|
||||
const businessHours = ref({})
|
||||
const formLoading = ref(false)
|
||||
const props = defineProps({
|
||||
|
@@ -1,27 +1,379 @@
|
||||
<template>
|
||||
<AutoForm
|
||||
class="space-y-6"
|
||||
:schema="formSchema"
|
||||
:form="form"
|
||||
:field-config="fieldConfig"
|
||||
@submit="submitForm"
|
||||
>
|
||||
<Button type="submit" :is-loading="isLoading"> {{ props.submitLabel }} </Button>
|
||||
</AutoForm>
|
||||
<form @submit="onSubmit" class="space-y-6 w-full">
|
||||
<!-- Basic Fields -->
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Inbox name" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription> Enter the name of the inbox. </FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="from">
|
||||
<FormItem>
|
||||
<FormLabel>From Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="My Support <support@example.com>"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription> Enter the from email address. </FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Toggle Fields -->
|
||||
<FormField v-slot="{ componentField, handleChange }" name="enabled">
|
||||
<FormItem class="flex flex-row items-center justify-between box p-4">
|
||||
<div class="space-y-0.5">
|
||||
<FormLabel class="text-base">Enabled</FormLabel>
|
||||
<FormDescription>Enable scanning inbox and sending emails</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField, handleChange }" name="csat_enabled">
|
||||
<FormItem class="flex flex-row items-center justify-between box p-4">
|
||||
<div class="space-y-0.5">
|
||||
<FormLabel class="text-base">CSAT Surveys</FormLabel>
|
||||
<FormDescription
|
||||
>Send customer satisfaction surveys when conversation is marked as resolved. <br />
|
||||
For better control on when to send surveys, disable this option and create an automation
|
||||
rule to send surveys.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- IMAP Section -->
|
||||
<div class="box p-4 space-y-4">
|
||||
<h3 class="font-semibold">IMAP Configuration</h3>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="imap.host">
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="imap.gmail.com" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="imap.port">
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="993" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="imap.mailbox">
|
||||
<FormItem>
|
||||
<FormLabel>Mailbox</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="INBOX" v-bind="componentField" :defaultValue="'INBOX'" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Mailbox (folder) to scan for incoming emails. Default is INBOX (usually no need to
|
||||
change).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="imap.username">
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="user@example.com" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="imap.password">
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="imap.tls_type">
|
||||
<FormItem>
|
||||
<FormLabel>TLS</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select TLS" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">OFF</SelectItem>
|
||||
<SelectItem value="tls">SSL/TLS</SelectItem>
|
||||
<SelectItem value="starttls">STARTTLS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Choose the encryption method for IMAP.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="imap.read_interval">
|
||||
<FormItem>
|
||||
<FormLabel>Scan Interval</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="120s" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Interval to scan the inbox for new emails. Format: 120s, 1m, 1h
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="imap.scan_inbox_since">
|
||||
<FormItem>
|
||||
<FormLabel>Scan Inbox Since</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="48h" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
To improve performance in large helpdesks with high email volume, this limits scans to
|
||||
emails received since the specified duration (e.g., `2h`, `48h`) by subtracting it from
|
||||
the current time.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField, handleChange }" name="imap.tls_skip_verify">
|
||||
<FormItem class="flex flex-row items-center justify-between box p-4">
|
||||
<div class="space-y-0.5">
|
||||
<FormLabel class="text-base">Skip TLS Verification</FormLabel>
|
||||
<FormDescription> Skip hostname check on the TLS certificate. </FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<!-- SMTP Section -->
|
||||
<div class="box p-4 space-y-4">
|
||||
<h3 class="font-semibold">SMTP Configuration</h3>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="smtp.host">
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="smtp.gmail.com" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="smtp.port">
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="587" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="smtp.username">
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="user@example.com" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="smtp.password">
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="smtp.max_conns">
|
||||
<FormItem>
|
||||
<FormLabel>Max Connections</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum number of concurrent connections to the server.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="smtp.max_msg_retries">
|
||||
<FormItem>
|
||||
<FormLabel>Max Retries</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="3s" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription> Number of times to retry when a message fails. </FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="smtp.idle_timeout">
|
||||
<FormItem>
|
||||
<FormLabel>Idle Timeout</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="25s" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
IdleTimeout is the maximum time to wait for new activity on a connection before closing
|
||||
it and removing it from the pool.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="smtp.wait_timeout">
|
||||
<FormItem>
|
||||
<FormLabel>Wait Timeout</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="60s" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
PoolWaitTimeout is the maximum time to wait to obtain a connection from a pool before
|
||||
timing out. This may happen when all open connections are busy sending e-mails and
|
||||
they're not returning to the pool fast enough. This is also the timeout used when
|
||||
creating new SMTP connections.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="smtp.auth_protocol">
|
||||
<FormItem>
|
||||
<FormLabel>Auth Protocol</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select protocol" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="login">Login</SelectItem>
|
||||
<SelectItem value="cram">CRAM</SelectItem>
|
||||
<SelectItem value="plain">Plain</SelectItem>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription> Authentication protocol to use. </FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="smtp.tls_type">
|
||||
<FormItem>
|
||||
<FormLabel>TLS</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select TLS" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">OFF</SelectItem>
|
||||
<SelectItem value="tls">SSL/TLS</SelectItem>
|
||||
<SelectItem value="starttls">STARTTLS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription> TLS/SSL encryption, STARTTLS is commonly used. </FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="smtp.hello_hostname">
|
||||
<FormItem>
|
||||
<FormLabel>HELO Hostname</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="smtp.example.com" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The hostname to use in the HELO/EHLO command. If not set, defaults to localhost.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField, handleChange }" name="smtp.tls_skip_verify">
|
||||
<FormItem class="flex flex-row items-center justify-between box p-4">
|
||||
<div class="space-y-0.5">
|
||||
<FormLabel class="text-base">Skip TLS Verification</FormLabel>
|
||||
<FormDescription> Skip hostname check on the TLS certificate. </FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<Button type="submit" :is-loading="isLoading" :disabled="isLoading">
|
||||
{{ props.submitLabel }}
|
||||
</Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { AutoForm } from '@/components/ui/auto-form'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { formSchema } from './formSchema.js'
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
|
||||
const props = defineProps({
|
||||
initialValues: {
|
||||
type: Object,
|
||||
required: false
|
||||
default: () => ({})
|
||||
},
|
||||
submitForm: {
|
||||
type: Function,
|
||||
@@ -29,90 +381,29 @@ const props = defineProps({
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: () => 'Submit'
|
||||
default: 'Submit'
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const fieldConfig = {
|
||||
name: {
|
||||
description: 'Name for your inbox.'
|
||||
},
|
||||
from: {
|
||||
label: 'From email address',
|
||||
description: 'From email address. e.g. My Support <mysupport@example.com>'
|
||||
},
|
||||
enabled: {
|
||||
label: 'Enabled',
|
||||
description: 'Disable to scanning incoming emails and sending outgoing emails.',
|
||||
component: 'switch'
|
||||
},
|
||||
csat_enabled: {
|
||||
label: 'CSAT',
|
||||
description: 'Send a CSAT survey after a conversation is marked as resolved.',
|
||||
component: 'switch'
|
||||
},
|
||||
imap: {
|
||||
label: 'IMAP',
|
||||
password: {
|
||||
inputProps: {
|
||||
type: 'password',
|
||||
placeholder: '••••••••'
|
||||
}
|
||||
},
|
||||
read_interval: {
|
||||
label: 'Emails scan interval'
|
||||
}
|
||||
},
|
||||
smtp: {
|
||||
label: 'SMTP',
|
||||
max_conns: {
|
||||
label: 'Max connections',
|
||||
description: 'Maximum number of concurrent connections to the server.'
|
||||
},
|
||||
max_msg_retries: {
|
||||
label: 'Retries',
|
||||
description: 'Number of times to retry when a message fails.'
|
||||
},
|
||||
idle_timeout: {
|
||||
label: 'Idle timeout',
|
||||
description: `IdleTimeout is the maximum time to wait for new activity on a connection
|
||||
before closing it and removing it from the pool.`
|
||||
},
|
||||
wait_timeout: {
|
||||
label: 'Wait timeout',
|
||||
description: `PoolWaitTimeout is the maximum time to wait to obtain a connection from
|
||||
a pool before timing out. This may happen when all open connections are
|
||||
busy sending e-mails and they're not returning to the pool fast enough.
|
||||
This is also the timeout used when creating new SMTP connections.
|
||||
`
|
||||
},
|
||||
auth_protocol: {
|
||||
label: 'Auth protocol'
|
||||
},
|
||||
password: {
|
||||
inputProps: {
|
||||
type: 'password',
|
||||
placeholder: '••••••••'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(formSchema),
|
||||
initialValues: props.initialValues
|
||||
validationSchema: toTypedSchema(formSchema)
|
||||
})
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
await props.submitForm(values)
|
||||
})
|
||||
|
||||
// Watch for changes in initialValues and update the form
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
(newValues) => {
|
||||
if (newValues) form.setValues(newValues)
|
||||
if (Object.keys(newValues).length === 0) {
|
||||
return
|
||||
}
|
||||
form.setValues(newValues)
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
@@ -2,89 +2,36 @@ import * as z from 'zod'
|
||||
import { isGoDuration } from '@/utils/strings'
|
||||
|
||||
export const formSchema = z.object({
|
||||
name: z.string().describe('Name').default(''),
|
||||
from: z.string().describe('From address').default(''),
|
||||
enabled: z.boolean().describe('Enabled').default(true),
|
||||
csat_enabled: z.boolean().describe('CSAT').default(false).optional(),
|
||||
imap: z
|
||||
.object({
|
||||
host: z.string().describe('Host').default('imap.gmail.com'),
|
||||
port: z
|
||||
.number({
|
||||
invalid_type_error: 'Port must be a number.'
|
||||
})
|
||||
.min(1, {
|
||||
message: 'Port must be at least 1.'
|
||||
})
|
||||
.max(65535, {
|
||||
message: 'Port must be at most 65535.'
|
||||
})
|
||||
.describe('Port')
|
||||
.default(993),
|
||||
mailbox: z.string().describe('Mailbox name').default('INBOX'),
|
||||
username: z.string().describe('Username'),
|
||||
password: z.string().describe('Password'),
|
||||
read_interval: z
|
||||
.string()
|
||||
.describe('Email scan interval')
|
||||
.refine(isGoDuration, {
|
||||
message:
|
||||
'Invalid duration format. Should be a number followed by s (seconds), m (minutes), or h (hours).'
|
||||
})
|
||||
.default('120s')
|
||||
})
|
||||
.describe('IMAP client')
|
||||
.default({
|
||||
host: 'imap.gmail.com',
|
||||
port: 993,
|
||||
mailbox: 'INBOX',
|
||||
username: '',
|
||||
password: '',
|
||||
read_interval: '30s'
|
||||
name: z.string().min(1, 'Required'),
|
||||
from: z.string().min(1, 'Required'),
|
||||
enabled: z.boolean().optional(),
|
||||
csat_enabled: z.boolean().optional(),
|
||||
imap: z.object({
|
||||
host: z.string().min(1, 'Required'),
|
||||
port: z.number().min(1).max(65535),
|
||||
mailbox: z.string().min(1, 'Required'),
|
||||
username: z.string().min(1, 'Required'),
|
||||
password: z.string().min(1, 'Required'),
|
||||
tls_type: z.enum(['none', 'starttls', 'tls']),
|
||||
tls_skip_verify: z.boolean().optional(),
|
||||
scan_inbox_since: z.string().min(1, 'Required').refine(isGoDuration, {
|
||||
message: 'Invalid duration. Please use a valid duration format (e.g. 1h, 30m, 1h30m, 48h, etc.)'
|
||||
}),
|
||||
smtp: z
|
||||
.object({
|
||||
host: z.string().describe('Host').default('smtp.gmail.com'),
|
||||
port: z
|
||||
.number({ invalid_type_error: 'Port must be a number.' })
|
||||
.min(1, { message: 'Port must be at least 1.' })
|
||||
.max(65535, { message: 'Port must be at most 65535.' })
|
||||
.describe('Port')
|
||||
.default(587),
|
||||
username: z.string().describe('Username'),
|
||||
password: z.string().describe('Password'),
|
||||
max_conns: z
|
||||
.number({ invalid_type_error: 'Must be a number.' })
|
||||
.min(1, { message: 'Must be at least 1.' })
|
||||
.describe('Maximum concurrent connections to the server.')
|
||||
.default(2),
|
||||
max_msg_retries: z
|
||||
.number({ invalid_type_error: 'Must be a number.' })
|
||||
.min(0, { message: 'Must be at least 0.' })
|
||||
.max(100, { message: 'Max retries allowed are 100.' })
|
||||
.describe('Number of times to retry when a message fails.')
|
||||
.default(2),
|
||||
idle_timeout: z
|
||||
.string()
|
||||
.describe(
|
||||
'Time to wait for new activity on a connection before closing it and removing it from the pool (s for seconds, m for minutes, h for hours).'
|
||||
)
|
||||
.refine(isGoDuration, {
|
||||
message:
|
||||
'Invalid duration format. Should be a number followed by s (seconds), m (minutes), or h (hours).'
|
||||
})
|
||||
.default('5s'),
|
||||
wait_timeout: z
|
||||
.string()
|
||||
.describe(
|
||||
'Time to wait for new activity on a connection before closing it and removing it from the pool (s for seconds, m for minutes, h for hours).'
|
||||
)
|
||||
.refine(isGoDuration, {
|
||||
message:
|
||||
'Invalid duration format. Should be a number followed by s (seconds), m (minutes), or h (hours).'
|
||||
})
|
||||
.default('5s'),
|
||||
auth_protocol: z.enum(['login', 'cram', 'plain', 'none']).default('plain').optional(),
|
||||
})
|
||||
.describe('SMTP server')
|
||||
read_interval: z.string().min(1, 'Required').refine(isGoDuration)
|
||||
}),
|
||||
|
||||
smtp: z.object({
|
||||
host: z.string().min(1, 'Required'),
|
||||
port: z.number().min(1).max(65535),
|
||||
username: z.string().min(1, 'Required'),
|
||||
password: z.string().min(1, 'Required'),
|
||||
max_conns: z.number().min(1),
|
||||
max_msg_retries: z.number().min(0).max(100),
|
||||
idle_timeout: z.string().min(1, 'Required').refine(isGoDuration),
|
||||
wait_timeout: z.string().min(1, 'Required').refine(isGoDuration),
|
||||
tls_type: z.enum(['none', 'starttls', 'tls']),
|
||||
tls_skip_verify: z.boolean().optional(),
|
||||
hello_hostname: z.string().optional(),
|
||||
auth_protocol: z.enum(['login', 'cram', 'plain', 'none'])
|
||||
})
|
||||
})
|
||||
|
@@ -104,7 +104,7 @@
|
||||
<div v-if="action.type && config.actions[action.type]?.type === 'tag'">
|
||||
<SelectTag
|
||||
v-model="action.value"
|
||||
:items="tagsStore.tagNames"
|
||||
:items="tagsStore.tagNames.map((tag) => ({ label: tag, value: tag }))"
|
||||
placeholder="Select tag"
|
||||
/>
|
||||
</div>
|
||||
|
@@ -152,6 +152,55 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- HELO Hostname Field -->
|
||||
<FormField v-slot="{ componentField }" name="hello_hostname">
|
||||
<FormItem>
|
||||
<FormLabel>HELO Hostname</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="smtp.example.com" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The hostname to use in the HELO/EHLO command. If not set, defaults to localhost.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- TLS Type Field -->
|
||||
<FormField v-slot="{ componentField }" name="tls_type">
|
||||
<FormItem>
|
||||
<FormLabel>TLS</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField" v-model="componentField.modelValue">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a TLS type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">Off</SelectItem>
|
||||
<SelectItem value="tls">SSL/TLS</SelectItem>
|
||||
<SelectItem value="starttls">STARTTLS</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Skip TLS Verification Field -->
|
||||
<FormField v-slot="{ componentField, handleChange }" name="tls_skip_verify">
|
||||
<FormItem class="flex flex-row items-center justify-between box p-4">
|
||||
<div class="space-y-0.5">
|
||||
<FormLabel class="text-base">Skip TLS Verification</FormLabel>
|
||||
<FormDescription> Skip hostname check on the TLS certificate. </FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
</form>
|
||||
</template>
|
||||
@@ -179,6 +228,7 @@ import {
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
|
@@ -66,5 +66,8 @@ export const smtpConfigSchema = z.object({
|
||||
message: 'Max message retries must be at most 100.'
|
||||
})
|
||||
.describe('Maximum message retries')
|
||||
.default(2)
|
||||
.default(2),
|
||||
hello_hostname: z.string().optional(),
|
||||
tls_type: z.enum(['none', 'starttls', 'tls']),
|
||||
tls_skip_verify: z.boolean().optional(),
|
||||
});
|
||||
|
@@ -37,14 +37,187 @@
|
||||
<FormItem>
|
||||
<FormLabel>Resolution time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="4h" v-bind="componentField" />
|
||||
<Input type="text" placeholder="24h" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription> Duration in hours or minutes to resolve a conversation. </FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" :disabled="isLoading" :isLoading="isLoading">{{ submitLabel }}</Button>
|
||||
<!-- Notifications Section -->
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between pb-3 border-b">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-lg font-semibold text-foreground">Alert Configuration</h3>
|
||||
<p class="text-sm text-muted-foreground">Set up notification triggers and recipients</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" @click="addNotification('breach')">
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Breach
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" @click="addNotification('warning')">
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Warning
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifications List -->
|
||||
<div v-if="form.values.notifications?.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="(notification, index) in form.values.notifications"
|
||||
:key="index"
|
||||
class="group relative p-5 box bg-background transition-all hover:border-foreground/20"
|
||||
>
|
||||
<FormField :name="`notifications.${index}.type`" v-slot="{ componentField }">
|
||||
<Input v-bind="componentField" type="hidden" />
|
||||
</FormField>
|
||||
|
||||
<!-- Card Header -->
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg"
|
||||
:class="{
|
||||
'bg-red-100/80 text-red-600': notification.type === 'breach',
|
||||
'bg-amber-100/80 text-amber-600': notification.type === 'warning'
|
||||
}"
|
||||
>
|
||||
<CircleAlert size="18" v-if="notification.type === 'warning'" />
|
||||
<Timer size="18" v-else />
|
||||
</span>
|
||||
<div>
|
||||
<div class="font-medium text-foreground">
|
||||
{{ notification.type === 'warning' ? 'Warning' : 'Breach' }} Notification
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ notification.type === 'warning' ? 'Pre-breach alert' : 'Post-breach action' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click.prevent="removeNotification(index)"
|
||||
class="opacity-70 hover:opacity-100 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Fields -->
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
<!-- Timing Section -->
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-6">
|
||||
<FormField
|
||||
:name="`notifications.${index}.time_delay_type`"
|
||||
v-slot="{ componentField }"
|
||||
v-if="notification.type === 'breach'"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel class="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Clock class="w-4 h-4 text-muted-foreground" />
|
||||
Trigger Timing
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField" class="hover:border-foreground/30">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="immediately" class="focus:bg-accent">
|
||||
Immediately on breach
|
||||
</SelectItem>
|
||||
<SelectItem value="after" class="focus:bg-accent">
|
||||
After specific duration
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField :name="`notifications.${index}.time_delay`" v-slot="{ componentField }">
|
||||
<FormItem v-if="shouldShowTimeDelay(index)">
|
||||
<FormLabel class="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Hourglass class="w-4 h-4 text-muted-foreground" />
|
||||
{{ notification.type === 'warning' ? 'Advance Warning' : 'Follow-up Delay' }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField" class="hover:border-foreground/30">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Select duration..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="duration in delayDurations"
|
||||
:key="duration"
|
||||
:value="duration"
|
||||
class="focus:bg-accent"
|
||||
>
|
||||
{{ duration }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recipients Section -->
|
||||
<div class="space-y-3">
|
||||
<FormField
|
||||
:name="`notifications.${index}.recipients`"
|
||||
v-slot="{ componentField, handleChange }"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel class="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Users class="w-4 h-4 text-muted-foreground" />
|
||||
Notification Recipients
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<SelectTag
|
||||
:items="
|
||||
usersStore.options.concat({
|
||||
label: 'Assigned user',
|
||||
value: 'assigned_user'
|
||||
})
|
||||
"
|
||||
placeholder="Start typing to search..."
|
||||
v-model="componentField.modelValue"
|
||||
@update:modelValue="handleChange"
|
||||
class="w-full hover:border-foreground/30"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center p-8 space-y-3 rounded-xl bg-muted/30 border border-dashed"
|
||||
>
|
||||
<Bell class="w-8 h-8 text-muted-foreground" />
|
||||
<p class="text-sm text-muted-foreground">No active notifications configured</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="isLoading" :isLoading="isLoading" class="mt-6">
|
||||
{{ submitLabel }}
|
||||
</Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
@@ -52,8 +225,10 @@
|
||||
import { watch } from 'vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { formSchema } from './formSchema.js'
|
||||
import { formSchema } from './formSchema'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X, Plus, Timer, CircleAlert, Users, Clock, Hourglass, Bell } from 'lucide-vue-next'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
@@ -62,12 +237,21 @@ import {
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from '@/components/ui/form'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { SelectTag } from '@/components/ui/select'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps({
|
||||
initialValues: {
|
||||
type: Object,
|
||||
required: false
|
||||
default: () => ({})
|
||||
},
|
||||
submitForm: {
|
||||
type: Function,
|
||||
@@ -75,30 +259,115 @@ const props = defineProps({
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: () => 'Save'
|
||||
default: 'Save'
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const usersStore = useUsersStore()
|
||||
|
||||
const delayDurations = [
|
||||
'5m',
|
||||
'10m',
|
||||
'15m',
|
||||
'30m',
|
||||
'45m',
|
||||
'1h',
|
||||
'2h',
|
||||
'3h',
|
||||
'4h',
|
||||
'5h',
|
||||
'6h',
|
||||
'7h',
|
||||
'8h',
|
||||
'9h',
|
||||
'10h',
|
||||
'11h',
|
||||
'12h'
|
||||
]
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(formSchema),
|
||||
initialValues: props.initialValues
|
||||
initialValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
first_response_time: '',
|
||||
resolution_time: '',
|
||||
notifications: []
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
props.submitForm(values)
|
||||
})
|
||||
const shouldShowTimeDelay = (index) => {
|
||||
const notification = form.values.notifications?.[index]
|
||||
if (!notification) return false
|
||||
return notification.type === 'warning' || notification.time_delay_type === 'after'
|
||||
}
|
||||
|
||||
const addNotification = (type) => {
|
||||
const notifications = [...form.values.notifications || []]
|
||||
notifications.push({
|
||||
type: type,
|
||||
time_delay_type: type === 'warning' ? 'before' : 'immediately',
|
||||
time_delay: type === 'warning' ? '10m' : '',
|
||||
recipients: []
|
||||
})
|
||||
form.setFieldValue('notifications', notifications)
|
||||
}
|
||||
|
||||
const removeNotification = (index) => {
|
||||
const notifications = [...form.values.notifications]
|
||||
notifications.splice(index, 1)
|
||||
console.log("Notifications", notifications)
|
||||
form.setFieldValue('notifications', notifications)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
(newValues) => {
|
||||
if (!newValues || Object.keys(newValues).length === 0) return
|
||||
form.setValues(newValues)
|
||||
if (!newValues || Object.keys(newValues).length === 0) {
|
||||
form.resetForm()
|
||||
return
|
||||
}
|
||||
|
||||
const transformedNotifications = (newValues.notifications || []).map((notification) => ({
|
||||
...notification,
|
||||
time_delay_type:
|
||||
notification.type === 'warning'
|
||||
? 'before'
|
||||
: notification.time_delay
|
||||
? 'after'
|
||||
: 'immediately'
|
||||
}))
|
||||
|
||||
form.setValues({
|
||||
...newValues,
|
||||
notifications: transformedNotifications
|
||||
})
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
const payload = {
|
||||
...values,
|
||||
notifications: values.notifications.map((notification) => ({
|
||||
...notification,
|
||||
time_delay: notification.time_delay_type === 'immediately' ? '' : notification.time_delay
|
||||
}))
|
||||
}
|
||||
props.submitForm(payload)
|
||||
})
|
||||
|
||||
// watch(
|
||||
// () => form.errors,
|
||||
// (errors) => {
|
||||
// if (Object.keys(errors).length > 0) {
|
||||
// console.log('Form has errors', errors)
|
||||
// }
|
||||
// },
|
||||
// { deep: true }
|
||||
// )
|
||||
</script>
|
||||
|
@@ -5,15 +5,11 @@ export const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: 'Name is required' })
|
||||
.max(255, {
|
||||
message: 'Name must be at most 255 characters.'
|
||||
}),
|
||||
.max(255, { message: 'Name must be at most 255 characters.' }),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, { message: 'Description is required' })
|
||||
.max(255, {
|
||||
message: 'Description must be at most 255 characters.'
|
||||
}),
|
||||
.max(255, { message: 'Description must be at most 255 characters.' }),
|
||||
first_response_time: z.string().refine(isGoHourMinuteDuration, {
|
||||
message:
|
||||
'Invalid duration format. Should be a number followed by h (hours), m (minutes).'
|
||||
@@ -22,4 +18,37 @@ export const formSchema = z.object({
|
||||
message:
|
||||
'Invalid duration format. Should be a number followed by h (hours), m (minutes).'
|
||||
}),
|
||||
notifications: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
type: z.enum(['breach', 'warning']),
|
||||
time_delay_type: z.enum(['immediately', 'after', 'before']),
|
||||
time_delay: z.string().optional(),
|
||||
recipients: z
|
||||
.array(z.string())
|
||||
.min(1, { message: 'At least one recipient is required' })
|
||||
})
|
||||
.superRefine((obj, ctx) => {
|
||||
if (obj.time_delay_type !== 'immediately') {
|
||||
if (!obj.time_delay || obj.time_delay === '') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
'Delay is required',
|
||||
path: ['time_delay']
|
||||
})
|
||||
} else if (!isGoHourMinuteDuration(obj.time_delay)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
'Invalid duration format. Should be a number followed by h (hours), m (minutes).',
|
||||
path: ['time_delay']
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.default([])
|
||||
})
|
||||
|
@@ -4,7 +4,7 @@
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="billing, order" v-bind="componentField" />
|
||||
<Input type="text" placeholder="billing" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription></FormDescription>
|
||||
<FormMessage />
|
||||
|
@@ -34,7 +34,8 @@
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently the tag.
|
||||
This action cannot be undone. This will permanently delete the tag and
|
||||
<strong>remove it from all conversations.</strong>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -75,7 +76,7 @@ import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
@@ -84,7 +85,7 @@ import api from '@/api/index.js'
|
||||
|
||||
const dialogOpen = ref(false)
|
||||
const alertOpen = ref(false)
|
||||
const emit = useEmitter()
|
||||
const emitter = useEmitter()
|
||||
|
||||
const props = defineProps({
|
||||
tag: {
|
||||
@@ -103,6 +104,10 @@ const form = useForm({
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
await api.updateTag(props.tag.id, values)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Success',
|
||||
description: 'Tag updated successfully'
|
||||
})
|
||||
dialogOpen.value = false
|
||||
emitRefreshTagsList()
|
||||
})
|
||||
@@ -118,7 +123,7 @@ const deleteTag = async () => {
|
||||
}
|
||||
|
||||
const emitRefreshTagsList = () => {
|
||||
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
|
||||
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, {
|
||||
model: 'tags'
|
||||
})
|
||||
}
|
||||
|
@@ -57,8 +57,8 @@
|
||||
<Input type="number" placeholder="0" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum number of active conversations that can be auto-assigned to an agent at once.
|
||||
Conversations in "Resolved" or "Closed" states do not count toward this limit. Set to 0
|
||||
Maximum number of conversations that can be auto-assigned to an agent,
|
||||
conversations in "Resolved" or "Closed" states do not count toward this limit. Set to 0
|
||||
for unlimited.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -75,8 +75,8 @@
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="timezone in timezones" :key="timezone" :value="timezone">
|
||||
{{ timezone }}
|
||||
<SelectItem v-for="(value, label) in timeZones" :key="value" :value="value">
|
||||
{{ label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
@@ -145,7 +145,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useForm } from 'vee-validate'
|
||||
@@ -174,14 +174,13 @@ import EmojiPicker from 'vue3-emoji-picker'
|
||||
import 'vue3-emoji-picker/css'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { useSlaStore } from '@/stores/sla'
|
||||
import { timeZones } from '@/constants/timezones.js'
|
||||
import api from '@/api'
|
||||
|
||||
const emitter = useEmitter()
|
||||
const slaStore = useSlaStore()
|
||||
const timezones = computed(() => Intl.supportedValuesOf('timeZone'))
|
||||
const assignmentTypes = ['Round robin', 'Manual']
|
||||
const businessHours = ref([])
|
||||
const slaPolicies = ref([])
|
||||
|
||||
const props = defineProps({
|
||||
initialValues: { type: Object, required: false },
|
||||
|
@@ -32,7 +32,7 @@
|
||||
<CodeEditor
|
||||
v-model="componentField.modelValue"
|
||||
@update:modelValue="handleChange"
|
||||
></CodeEditor>
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription v-if="isOutgoingTemplate">
|
||||
{{ `Make sure the template has \{\{ template "content" . \}\} only once.` }}
|
||||
|
@@ -30,23 +30,21 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="teams">
|
||||
<FormField v-slot="{ componentField , handleChange }" name="teams">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>Teams</FormLabel>
|
||||
<FormControl>
|
||||
<SelectTag :items="teamNames" placeholder="Select teams" v-bind="componentField">
|
||||
</SelectTag>
|
||||
<SelectTag :items="teamOptions" placeholder="Select teams" v-model="componentField.modelValue" @update:modelValue="handleChange"/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="roles">
|
||||
<FormField v-slot="{ componentField, handleChange }" name="roles">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>Roles</FormLabel>
|
||||
<FormControl>
|
||||
<SelectTag :items="roleNames" placeholder="Select roles" v-bind="componentField">
|
||||
</SelectTag>
|
||||
<SelectTag :items="roleOptions" placeholder="Select roles" v-model="componentField.modelValue" @update:modelValue="handleChange"/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -142,8 +140,8 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const teamNames = computed(() => teams.value.map((team) => team.name))
|
||||
const roleNames = computed(() => roles.value.map((role) => role.name))
|
||||
const teamOptions = computed(() => teams.value.map((team) => ({ label: team.name, value: team.name })))
|
||||
const roleOptions = computed(() => roles.value.map((role) => ({ label: role.name, value: role.name })))
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(userFormSchema)
|
||||
|
@@ -21,7 +21,7 @@ export const userFormSchema = z.object({
|
||||
|
||||
send_welcome_email: z.boolean().optional(),
|
||||
|
||||
teams: z.array(z.string()).optional(),
|
||||
teams: z.array(z.string()).default([]),
|
||||
|
||||
roles: z.array(z.string()).min(1, 'Please select at least one role.'),
|
||||
|
||||
|
@@ -2,7 +2,7 @@
|
||||
<CommandDialog
|
||||
:open="open"
|
||||
@update:open="handleOpenChange"
|
||||
class="z-[51] !min-w-[50vw] !min-h-[60vh]"
|
||||
class="transform-gpu z-[51] !min-w-[50vw] !min-h-[60vh]"
|
||||
>
|
||||
<CommandInput placeholder="Type a command or search..." @keydown="onInputKeydown" />
|
||||
<CommandList class="!min-h-[60vh] !min-w-[50vw]">
|
||||
|
@@ -62,6 +62,28 @@
|
||||
>
|
||||
<ListOrdered size="14" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click.prevent="openLinkModal"
|
||||
:class="{ 'bg-gray-200': editor?.isActive('link') }"
|
||||
>
|
||||
<LinkIcon size="14" />
|
||||
</Button>
|
||||
<div v-if="showLinkInput" class="flex space-x-2 p-2 bg-white border rounded-lg">
|
||||
<input
|
||||
v-model="linkUrl"
|
||||
type="text"
|
||||
placeholder="Enter link URL"
|
||||
class="border p-1 text-sm"
|
||||
/>
|
||||
<Button size="sm" @click="setLink">
|
||||
<Check size="14" />
|
||||
</Button>
|
||||
<Button size="sm" @click="unsetLink">
|
||||
<X size="14" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
<EditorContent :editor="editor" class="native-html" />
|
||||
@@ -71,7 +93,17 @@
|
||||
<script setup>
|
||||
import { ref, watch, watchEffect, onUnmounted } from 'vue'
|
||||
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3'
|
||||
import { ChevronDown, Bold, Italic, Bot, List, ListOrdered } from 'lucide-vue-next'
|
||||
import {
|
||||
ChevronDown,
|
||||
Bold,
|
||||
Italic,
|
||||
Bot,
|
||||
List,
|
||||
ListOrdered,
|
||||
Link as LinkIcon,
|
||||
Check,
|
||||
X
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -83,6 +115,10 @@ import Placeholder from '@tiptap/extension-placeholder'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Table from '@tiptap/extension-table'
|
||||
import TableRow from '@tiptap/extension-table-row'
|
||||
import TableCell from '@tiptap/extension-table-cell'
|
||||
import TableHeader from '@tiptap/extension-table-header'
|
||||
|
||||
const selectedText = defineModel('selectedText', { default: '' })
|
||||
const textContent = defineModel('textContent')
|
||||
@@ -90,6 +126,8 @@ const htmlContent = defineModel('htmlContent')
|
||||
const isBold = defineModel('isBold')
|
||||
const isItalic = defineModel('isItalic')
|
||||
const cursorPosition = defineModel('cursorPosition', { default: 0 })
|
||||
const showLinkInput = ref(false)
|
||||
const linkUrl = ref('')
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: String,
|
||||
@@ -109,13 +147,58 @@ const emitPrompt = (key) => emit('aiPromptSelected', key)
|
||||
|
||||
const getSelectionText = (from, to, doc) => doc.textBetween(from, to)
|
||||
|
||||
// To preseve the table styling in emails, need to set the table style inline.
|
||||
// Created these custom extensions to set the table style inline.
|
||||
const CustomTable = Table.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
style: {
|
||||
parseHTML: (element) =>
|
||||
(element.getAttribute('style') || '') + ' border: 1px solid #dee2e6 !important; width: 100%; margin:0; table-layout: fixed; border-collapse: collapse; position:relative; border-radius: 0.25rem;'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const CustomTableCell = TableCell.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
style: {
|
||||
parseHTML: (element) =>
|
||||
(element.getAttribute('style') || '') +
|
||||
' border: 1px solid #dee2e6 !important; box-sizing: border-box !important; min-width: 1em !important; padding: 6px 8px !important; vertical-align: top !important;'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const CustomTableHeader = TableHeader.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
style: {
|
||||
parseHTML: (element) =>
|
||||
(element.getAttribute('style') || '') +
|
||||
' background-color: #f8f9fa !important; color: #212529 !important; font-weight: bold !important; text-align: left !important; border: 1px solid #dee2e6 !important; padding: 6px 8px !important;'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const editorConfig = {
|
||||
extensions: [
|
||||
// Lists are unstyled in tailwind, so need to add classes to them.
|
||||
StarterKit.configure(),
|
||||
Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
|
||||
Placeholder.configure({ placeholder: () => props.placeholder }),
|
||||
Link
|
||||
Link,
|
||||
CustomTable.configure({
|
||||
resizable: false
|
||||
}),
|
||||
TableRow,
|
||||
CustomTableCell,
|
||||
CustomTableHeader
|
||||
],
|
||||
autofocus: true,
|
||||
editorProps: {
|
||||
@@ -246,6 +329,27 @@ const toggleOrderedList = () => {
|
||||
editor.value.chain().focus().toggleOrderedList().run()
|
||||
}
|
||||
}
|
||||
|
||||
const openLinkModal = () => {
|
||||
if (editor.value?.isActive('link')) {
|
||||
linkUrl.value = editor.value.getAttributes('link').href
|
||||
} else {
|
||||
linkUrl.value = ''
|
||||
}
|
||||
showLinkInput.value = true
|
||||
}
|
||||
|
||||
const setLink = () => {
|
||||
if (linkUrl.value) {
|
||||
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: linkUrl.value }).run()
|
||||
}
|
||||
showLinkInput.value = false
|
||||
}
|
||||
|
||||
const unsetLink = () => {
|
||||
editor.value?.chain().focus().unsetLink().run()
|
||||
showLinkInput.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -277,8 +381,14 @@ const toggleOrderedList = () => {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
// Anchor tag styling
|
||||
.tiptap {
|
||||
// Table styling
|
||||
.tableWrapper {
|
||||
margin: 1.5rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
// Anchor tag styling
|
||||
a {
|
||||
color: #0066cc;
|
||||
cursor: pointer;
|
||||
|
@@ -234,6 +234,7 @@ import { ref, defineModel, watch } from 'vue'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
|
||||
import { Users } from 'lucide-vue-next'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { useInboxStore } from '@/stores/inbox'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
|
@@ -38,6 +38,7 @@
|
||||
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
|
||||
<DialogContent
|
||||
class="max-w-[70%] max-h-[70%] h-[70%] bg-card text-card-foreground p-4 flex flex-col"
|
||||
:class="{ '!bg-[#FEF1E1]': messageType === 'private_note' }"
|
||||
@escapeKeyDown="isEditorFullscreen = false"
|
||||
:hide-close-button="true"
|
||||
>
|
||||
@@ -85,6 +86,7 @@
|
||||
<!-- Main Editor non-fullscreen -->
|
||||
<div
|
||||
class="bg-card text-card-foreground box m-2 px-2 pt-2 flex flex-col"
|
||||
:class="{ '!bg-[#FEF1E1]': messageType === 'private_note' }"
|
||||
v-if="!isEditorFullscreen"
|
||||
>
|
||||
<ReplyBoxContent
|
||||
@@ -393,7 +395,6 @@ const processSend = async () => {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
hasAPIErrored = true
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
|
@@ -6,7 +6,7 @@
|
||||
class="flex justify-between items-center"
|
||||
:class="{ 'mb-4': !isFullscreen, 'border-b border-border pb-4': isFullscreen }"
|
||||
>
|
||||
<Tabs v-model="messageType" class="rounded-lg">
|
||||
<Tabs v-model="messageType" class="rounded-lg border">
|
||||
<TabsList class="bg-muted p-1 rounded-lg">
|
||||
<TabsTrigger
|
||||
value="reply"
|
||||
|
@@ -57,15 +57,15 @@
|
||||
|
||||
<div class="flex items-center mt-2 space-x-2">
|
||||
<SlaBadge
|
||||
v-if="conversation.first_response_due_at"
|
||||
:dueAt="conversation.first_response_due_at"
|
||||
v-if="conversation.first_response_deadline_at"
|
||||
:dueAt="conversation.first_response_deadline_at"
|
||||
:actualAt="conversation.first_reply_at"
|
||||
:label="'FRD'"
|
||||
:showExtra="false"
|
||||
/>
|
||||
<SlaBadge
|
||||
v-if="conversation.resolution_due_at"
|
||||
:dueAt="conversation.resolution_due_at"
|
||||
v-if="conversation.resolution_deadline_at"
|
||||
:dueAt="conversation.resolution_deadline_at"
|
||||
:actualAt="conversation.resolved_at"
|
||||
:label="'RD'"
|
||||
:showExtra="false"
|
||||
|
@@ -28,7 +28,7 @@
|
||||
<!-- Message Text -->
|
||||
<Letter
|
||||
:html="sanitizedMessageContent"
|
||||
:allowedSchemas="['cid', 'https', 'http']"
|
||||
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
|
||||
class="mb-1 native-html"
|
||||
:class="{ 'mb-3': message.attachments.length > 0 }"
|
||||
/>
|
||||
@@ -72,6 +72,7 @@ import { useConversationStore } from '@/stores/conversation'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Letter } from 'vue-letter'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import MessageAttachmentPreview from '@/features/conversation/message/attachment/MessageAttachmentPreview.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -79,18 +80,26 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const convStore = useConversationStore()
|
||||
const settingsStore = useAppSettingsStore()
|
||||
const showQuotedText = ref(false)
|
||||
|
||||
const getAvatar = computed(() => {
|
||||
return convStore.current?.contact?.avatar_url || ''
|
||||
})
|
||||
|
||||
const sanitizedMessageContent = computed(() => {
|
||||
const content = props.message.content || ''
|
||||
return props.message.attachments.reduce(
|
||||
let content = props.message.content || ''
|
||||
const baseUrl = settingsStore.settings['app.root_url']
|
||||
|
||||
// Replace CID with URL for inline attachments from the message.
|
||||
content = props.message.attachments.reduce(
|
||||
(acc, { content_id, url }) => acc.replace(new RegExp(`cid:${content_id}`, 'g'), url),
|
||||
content
|
||||
)
|
||||
|
||||
// Add base URL to all img src starting with /uploads/ as vue-letter does not allow relative URLs.
|
||||
content = content.replace(/src="\/uploads\//g, `src="${baseUrl}/uploads/`)
|
||||
|
||||
return content
|
||||
})
|
||||
|
||||
const hasQuotedContent = computed(() => sanitizedMessageContent.value.includes('<blockquote'))
|
||||
|
@@ -27,8 +27,8 @@
|
||||
<div class="flex justify-start items-center space-x-2">
|
||||
<p class="font-medium">First reply at</p>
|
||||
<SlaBadge
|
||||
v-if="conversation.first_response_due_at"
|
||||
:dueAt="conversation.first_response_due_at"
|
||||
v-if="conversation.first_response_deadline_at"
|
||||
:dueAt="conversation.first_response_deadline_at"
|
||||
:actualAt="conversation.first_reply_at"
|
||||
:key="conversation.uuid"
|
||||
/>
|
||||
@@ -46,8 +46,8 @@
|
||||
<div class="flex justify-start items-center space-x-2">
|
||||
<p class="font-medium">Resolved at</p>
|
||||
<SlaBadge
|
||||
v-if="conversation.resolution_due_at"
|
||||
:dueAt="conversation.resolution_due_at"
|
||||
v-if="conversation.resolution_deadline_at"
|
||||
:dueAt="conversation.resolution_deadline_at"
|
||||
:actualAt="conversation.resolved_at"
|
||||
:key="conversation.uuid"
|
||||
/>
|
||||
|
@@ -117,7 +117,7 @@
|
||||
<SelectTag
|
||||
v-if="conversationStore.current"
|
||||
v-model="conversationStore.current.tags"
|
||||
:items="tags"
|
||||
:items="tags.map((tag) => ({ label: tag, value: tag }))"
|
||||
placeholder="Select tags"
|
||||
/>
|
||||
</AccordionContent>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<LineChart :data="data" index="date" :categories="['New conversations', 'Resolved conversations', 'Messages sent']"
|
||||
<LineChart :data="data" index="date" :categories="['New conversations', 'Resolved conversations']"
|
||||
:x-formatter="xFormatter" :y-formatter="yFormatter" />
|
||||
</template>
|
||||
|
||||
|
@@ -1,7 +1,13 @@
|
||||
<template>
|
||||
<div class="overflow-y-auto h-screen">
|
||||
<div class="p-6 sm:p-8 min-h-full flex flex-col">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center flex-grow"
|
||||
v-if="$route.name === 'admin'"
|
||||
>
|
||||
<div>Select a section from the sidebar</div>
|
||||
</div>
|
||||
<router-view class="flex-grow" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
@@ -155,12 +155,12 @@ const routes = [
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
redirect: '/admin/general',
|
||||
component: AdminLayout,
|
||||
meta: { title: 'Admin' },
|
||||
children: [
|
||||
{
|
||||
path: 'general',
|
||||
name: 'general',
|
||||
component: () => import('@/views/admin/general/General.vue'),
|
||||
meta: { title: 'General' }
|
||||
},
|
||||
@@ -441,10 +441,8 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: (to) => {
|
||||
// TODO: Remove this alert and redirect to 404 page
|
||||
alert(`Redirecting to overview from: ${to.fullPath}`)
|
||||
return '/reports/overview'
|
||||
redirect: () => {
|
||||
return '/inboxes/assigned'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@@ -47,14 +47,14 @@ export const isGoHourMinuteDuration = (value) => {
|
||||
}
|
||||
|
||||
const template = document.createElement('template')
|
||||
export function getTextFromHTML(htmlString) {
|
||||
try {
|
||||
template.innerHTML = htmlString
|
||||
const text = template.content.textContent || template.content.innerText || ''
|
||||
template.innerHTML = ''
|
||||
return text.trim()
|
||||
} catch (error) {
|
||||
console.error('Error converting HTML to text:', error)
|
||||
return ''
|
||||
}
|
||||
export function getTextFromHTML (htmlString) {
|
||||
try {
|
||||
template.innerHTML = htmlString
|
||||
const text = template.content.textContent || template.content.innerText || ''
|
||||
template.innerHTML = ''
|
||||
return text.trim()
|
||||
} catch (error) {
|
||||
console.error('Error converting HTML to text:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
@@ -71,19 +71,20 @@
|
||||
</FormField>
|
||||
|
||||
<div :class="{ hidden: form.values.type !== 'conversation_update' }">
|
||||
<FormField v-slot="{ componentField }" name="events">
|
||||
<FormField v-slot="{ componentField, handleChange }" name="events">
|
||||
<FormItem>
|
||||
<FormLabel>Events</FormLabel>
|
||||
<FormControl>
|
||||
<SelectTag
|
||||
v-bind="componentField"
|
||||
:items="conversationEvents || []"
|
||||
v-model="componentField.modelValue"
|
||||
@update:modelValue="handleChange"
|
||||
:items="conversationEventOptions"
|
||||
placeholder="Select events"
|
||||
>
|
||||
</SelectTag>
|
||||
</FormControl>
|
||||
<FormDescription>Evaluate rule on these events.</FormDescription>
|
||||
<FormMessage></FormMessage>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -204,13 +205,13 @@ const rule = ref({
|
||||
]
|
||||
})
|
||||
|
||||
const conversationEvents = [
|
||||
'conversation.user.assigned',
|
||||
'conversation.team.assigned',
|
||||
'conversation.priority.change',
|
||||
'conversation.status.change',
|
||||
'conversation.message.outgoing',
|
||||
'conversation.message.incoming'
|
||||
const conversationEventOptions = [
|
||||
{ label: 'User assigned', value: 'conversation.user.assigned' },
|
||||
{ label: 'Team assigned', value: 'conversation.team.assigned' },
|
||||
{ label: 'Priority change', value: 'conversation.priority.change' },
|
||||
{ label: 'Status change', value: 'conversation.status.change' },
|
||||
{ label: 'Outgoing message', value: 'conversation.message.outgoing' },
|
||||
{ label: 'Incoming message', value: 'conversation.message.incoming' }
|
||||
]
|
||||
|
||||
const props = defineProps({
|
||||
|
@@ -1,14 +1,18 @@
|
||||
<template>
|
||||
<div class="mb-5">
|
||||
<CustomBreadcrumb :links="breadcrumbLinks" />
|
||||
</div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<SLAForm :initial-values="slaData" :submitForm="submitForm" :isNewForm="isNewForm"
|
||||
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }" :isLoading="formLoading" />
|
||||
<div class="mb-5">
|
||||
<CustomBreadcrumb :links="breadcrumbLinks" />
|
||||
</div>
|
||||
<Spinner v-if="isLoading"></Spinner>
|
||||
<SLAForm
|
||||
:initial-values="slaData"
|
||||
:submitForm="submitForm"
|
||||
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
|
||||
:isLoading="formLoading"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import api from '@/api'
|
||||
import SLAForm from '@/features/admin/sla/SLAForm.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -24,68 +28,64 @@ const isLoading = ref(false)
|
||||
const formLoading = ref(false)
|
||||
const router = useRouter()
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: false
|
||||
}
|
||||
id: {
|
||||
type: String,
|
||||
required: false
|
||||
}
|
||||
})
|
||||
|
||||
const submitForm = async (values) => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
if (props.id) {
|
||||
await api.updateSLA(props.id, values)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Success',
|
||||
description: 'SLA updated successfully',
|
||||
})
|
||||
} else {
|
||||
await api.createSLA(values)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Success',
|
||||
description: 'SLA created successfully',
|
||||
})
|
||||
router.push({ name: 'sla-list' })
|
||||
}
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not save SLA',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
try {
|
||||
formLoading.value = true
|
||||
if (props.id) {
|
||||
await api.updateSLA(props.id, values)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Success',
|
||||
description: 'SLA updated successfully'
|
||||
})
|
||||
} else {
|
||||
await api.createSLA(values)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Success',
|
||||
description: 'SLA created successfully'
|
||||
})
|
||||
router.push({ name: 'sla-list' })
|
||||
}
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not save SLA',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const breadCrumLabel = () => {
|
||||
return props.id ? 'Edit' : 'New'
|
||||
return props.id ? 'Edit' : 'New'
|
||||
}
|
||||
|
||||
const isNewForm = computed(() => {
|
||||
return props.id ? false : true
|
||||
})
|
||||
|
||||
const breadcrumbLinks = [
|
||||
{ path: 'sla-list', label: 'SLA' },
|
||||
{ path: '', label: breadCrumLabel() }
|
||||
{ path: 'sla-list', label: 'SLA' },
|
||||
{ path: '', label: breadCrumLabel() }
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.id) {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const resp = await api.getSLA(props.id)
|
||||
slaData.value = resp.data.data
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not fetch SLA',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
if (props.id) {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const resp = await api.getSLA(props.id)
|
||||
slaData.value = resp.data.data
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not fetch SLA',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
@@ -60,16 +60,17 @@ import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { formSchema } from '../../../features/admin/tags/formSchema.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import api from '@/api'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const tags = ref([])
|
||||
const emit = useEmitter()
|
||||
const emitter = useEmitter()
|
||||
const dialogOpen = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
getTags()
|
||||
emit.on(EMITTER_EVENTS.REFRESH_LIST, (data) => {
|
||||
emitter.on(EMITTER_EVENTS.REFRESH_LIST, (data) => {
|
||||
if (data?.model === 'tags') getTags()
|
||||
})
|
||||
})
|
||||
@@ -91,8 +92,16 @@ const onSubmit = form.handleSubmit(async (values) => {
|
||||
await api.createTag(values)
|
||||
dialogOpen.value = false
|
||||
getTags()
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Success',
|
||||
description: 'Tag created successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to create tag:', error)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
@@ -138,8 +138,6 @@ const getDashboardCharts = async () => {
|
||||
chartData.value.new_conversations.find((item) => item.date === date)?.count || 0,
|
||||
'Resolved conversations':
|
||||
chartData.value.resolved_conversations.find((item) => item.date === date)?.count || 0,
|
||||
'Messages sent':
|
||||
chartData.value.messages_sent.find((item) => item.date === date)?.count || 0
|
||||
}))
|
||||
|
||||
chartData.value.status_summary = resp.data.data.status_summary || []
|
||||
|
@@ -28,7 +28,7 @@
|
||||
<div v-else class="mt-16 text-center">
|
||||
<h2 class="text-2xl font-semibold text-primary mb-4">Search conversations</h2>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
Search by reference number, messages, or any keywords related to your conversations.
|
||||
Search by reference number, contact email address or messages in conversations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
4
go.mod
4
go.mod
@@ -24,9 +24,9 @@ require (
|
||||
github.com/knadh/smtppool v1.1.0
|
||||
github.com/knadh/stuffbin v1.3.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mr-karan/balance v0.0.0-20230131075323-e0d55eb3e4b9
|
||||
github.com/mr-karan/balance v0.0.0-20250317053523-d32c6ade6cf1
|
||||
github.com/redis/go-redis/v9 v9.5.4
|
||||
github.com/rhnvrm/simples3 v0.8.3
|
||||
github.com/rhnvrm/simples3 v0.8.4
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/valyala/fasthttp v1.54.0
|
||||
github.com/volatiletech/null/v9 v9.0.0
|
||||
|
8
go.sum
8
go.sum
@@ -124,8 +124,8 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/mr-karan/balance v0.0.0-20230131075323-e0d55eb3e4b9 h1:mQECODpWykYPwBg+ELr04Lwm/kSP6+2LPWmvVTwrTPo=
|
||||
github.com/mr-karan/balance v0.0.0-20230131075323-e0d55eb3e4b9/go.mod h1:YMjMm+2l1ye+v1MeuUJ1QPxXKzWp+x8iqg4vWuKB3Ao=
|
||||
github.com/mr-karan/balance v0.0.0-20250317053523-d32c6ade6cf1 h1:YYLFUQMdeCyUVUAxE/IkPbkDfOVkUl9mbBdwkzU4UbE=
|
||||
github.com/mr-karan/balance v0.0.0-20250317053523-d32c6ade6cf1/go.mod h1:YMjMm+2l1ye+v1MeuUJ1QPxXKzWp+x8iqg4vWuKB3Ao=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
@@ -136,8 +136,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.5.4 h1:vOFYDKKVgrI5u++QvnMT7DksSMYg7Aw/Np4vLJLKLwY=
|
||||
github.com/redis/go-redis/v9 v9.5.4/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
||||
github.com/rhnvrm/simples3 v0.8.3 h1:6dS0EE/hMIkaJd9gJOoXZOwtQQqI4NJyk0jvtl86n28=
|
||||
github.com/rhnvrm/simples3 v0.8.3/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
|
||||
github.com/rhnvrm/simples3 v0.8.4 h1:w3lhMtL7Cqpi5T61gW03pPFCTHHMwtHCwczUowmLCvc=
|
||||
github.com/rhnvrm/simples3 v0.8.4/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
|
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -61,12 +62,11 @@ func New(teamStore teamStore, conversationStore conversationStore, systemUser um
|
||||
systemUser: systemUser,
|
||||
lo: lo,
|
||||
teamMaxAutoAssignments: make(map[int]int),
|
||||
roundRobinBalancer: make(map[int]*balance.Balance),
|
||||
}
|
||||
balancer, err := e.populateTeamBalancer()
|
||||
if err != nil {
|
||||
if err := e.populateTeamBalancer(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.roundRobinBalancer = balancer
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
@@ -90,9 +90,11 @@ func (e *Engine) Run(ctx context.Context, autoAssignInterval time.Duration) {
|
||||
if closed {
|
||||
return
|
||||
}
|
||||
// Reload the balancer with latest team and user data.
|
||||
if err := e.reloadBalancer(); err != nil {
|
||||
e.lo.Error("error reloading balancer", "error", err)
|
||||
}
|
||||
// Start assigning conversations.
|
||||
if err := e.assignConversations(); err != nil {
|
||||
e.lo.Error("error assigning conversations", "error", err)
|
||||
}
|
||||
@@ -117,23 +119,19 @@ func (e *Engine) reloadBalancer() error {
|
||||
e.balanceMu.Lock()
|
||||
defer e.balanceMu.Unlock()
|
||||
|
||||
balancer, err := e.populateTeamBalancer()
|
||||
err := e.populateTeamBalancer()
|
||||
if err != nil {
|
||||
e.lo.Error("error updating team balancer pool", "error", err)
|
||||
return err
|
||||
}
|
||||
e.roundRobinBalancer = balancer
|
||||
return nil
|
||||
}
|
||||
|
||||
// populateTeamBalancer populates the team balancer pool with the team members.
|
||||
func (e *Engine) populateTeamBalancer() (map[int]*balance.Balance, error) {
|
||||
var (
|
||||
balancer = make(map[int]*balance.Balance)
|
||||
teams, err = e.teamStore.GetAll()
|
||||
)
|
||||
func (e *Engine) populateTeamBalancer() error {
|
||||
teams, err := e.teamStore.GetAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
for _, team := range teams {
|
||||
@@ -143,21 +141,51 @@ func (e *Engine) populateTeamBalancer() (map[int]*balance.Balance, error) {
|
||||
|
||||
users, err := e.teamStore.GetMembers(team.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
e.lo.Error("error fetching team members", "team_id", team.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Add users to team's balancer pool.
|
||||
// Shuffle users to prevent ordering bias, as every app restart will pick the same first user.
|
||||
rand.New(rand.NewSource(time.Now().UnixNano())).Shuffle(len(users), func(i, j int) {
|
||||
users[i], users[j] = users[j], users[i]
|
||||
})
|
||||
|
||||
// Initialize team balancer if missing
|
||||
if _, exists := e.roundRobinBalancer[team.ID]; !exists {
|
||||
e.lo.Debug("creating new balancer for team", "team_id", team.ID)
|
||||
e.roundRobinBalancer[team.ID] = balance.NewBalance()
|
||||
}
|
||||
|
||||
balancer := e.roundRobinBalancer[team.ID]
|
||||
existingUsers := make(map[string]struct{})
|
||||
|
||||
for _, user := range users {
|
||||
if _, ok := balancer[team.ID]; !ok {
|
||||
balancer[team.ID] = balance.NewBalance()
|
||||
uid := strconv.Itoa(user.ID)
|
||||
existingUsers[uid] = struct{}{}
|
||||
if err := balancer.Add(uid, 1); err != nil {
|
||||
if err != balance.ErrDuplicateID {
|
||||
e.lo.Error("error adding user to balancer pool", "team_id", team.ID, "user_id", user.ID, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
balancer[team.ID].Add(strconv.Itoa(user.ID), 1)
|
||||
e.lo.Debug("added user to balancer pool", "team_id", team.ID, "user_id", user.ID)
|
||||
}
|
||||
|
||||
// Set max auto assigned conversations for the team.
|
||||
// Remove users no longer in the team
|
||||
for _, id := range balancer.ItemIDs() {
|
||||
if _, exists := existingUsers[id]; !exists {
|
||||
if err := balancer.Remove(id); err != nil {
|
||||
e.lo.Error("error removing user from balancer pool", "team_id", team.ID, "user_id", id, "error", err)
|
||||
} else {
|
||||
e.lo.Debug("removed user from balancer pool", "team_id", team.ID, "user_id", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set max auto assigned conversations for the team
|
||||
e.teamMaxAutoAssignments[team.ID] = team.MaxAutoAssignedConversations
|
||||
}
|
||||
return balancer, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// assignConversations function fetches conversations that have been assigned to teams but not to any individual user,
|
||||
|
@@ -188,7 +188,7 @@ func (e *Engine) GetRule(id int) (models.RuleRecord, error) {
|
||||
return rule, envelope.NewError(envelope.InputError, "Rule not found.", nil)
|
||||
}
|
||||
e.lo.Error("error fetching rule", "error", err)
|
||||
return rule, envelope.NewError(envelope.GeneralError, "Error fetching automation rule.", nil)
|
||||
return rule, envelope.NewError(envelope.GeneralError, "Error fetching automation rule", nil)
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
@@ -197,7 +197,7 @@ func (e *Engine) GetRule(id int) (models.RuleRecord, error) {
|
||||
func (e *Engine) ToggleRule(id int) error {
|
||||
if _, err := e.q.ToggleRule.Exec(id); err != nil {
|
||||
e.lo.Error("error toggling rule", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error toggling automation rule.", nil)
|
||||
return envelope.NewError(envelope.GeneralError, "Error toggling automation rule", nil)
|
||||
}
|
||||
// Reload rules.
|
||||
e.ReloadRules()
|
||||
@@ -206,9 +206,12 @@ func (e *Engine) ToggleRule(id int) error {
|
||||
|
||||
// UpdateRule updates an existing rule.
|
||||
func (e *Engine) UpdateRule(id int, rule models.RuleRecord) error {
|
||||
if _, err := e.q.UpdateRule.Exec(id, rule.Name, rule.Description, rule.Type, pq.Array(rule.Events), rule.Rules, rule.Enabled); err != nil {
|
||||
if rule.Events == nil {
|
||||
rule.Events = pq.StringArray{}
|
||||
}
|
||||
if _, err := e.q.UpdateRule.Exec(id, rule.Name, rule.Description, rule.Type, rule.Events, rule.Rules, rule.Enabled); err != nil {
|
||||
e.lo.Error("error updating rule", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error updating automation rule.", nil)
|
||||
return envelope.NewError(envelope.GeneralError, "Error updating automation rule", nil)
|
||||
}
|
||||
// Reload rules.
|
||||
e.ReloadRules()
|
||||
@@ -217,9 +220,12 @@ func (e *Engine) UpdateRule(id int, rule models.RuleRecord) error {
|
||||
|
||||
// CreateRule creates a new rule.
|
||||
func (e *Engine) CreateRule(rule models.RuleRecord) error {
|
||||
if _, err := e.q.InsertRule.Exec(rule.Name, rule.Description, rule.Type, pq.Array(rule.Events), rule.Rules); err != nil {
|
||||
if rule.Events == nil {
|
||||
rule.Events = pq.StringArray{}
|
||||
}
|
||||
if _, err := e.q.InsertRule.Exec(rule.Name, rule.Description, rule.Type, rule.Events, rule.Rules); err != nil {
|
||||
e.lo.Error("error creating rule", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error creating automation rule.", nil)
|
||||
return envelope.NewError(envelope.GeneralError, "Error creating automation rule", nil)
|
||||
}
|
||||
// Reload rules.
|
||||
e.ReloadRules()
|
||||
@@ -230,7 +236,7 @@ func (e *Engine) CreateRule(rule models.RuleRecord) error {
|
||||
func (e *Engine) DeleteRule(id int) error {
|
||||
if _, err := e.q.DeleteRule.Exec(id); err != nil {
|
||||
e.lo.Error("error deleting rule", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error deleting automation rule.", nil)
|
||||
return envelope.NewError(envelope.GeneralError, "Error deleting automation rule", nil)
|
||||
}
|
||||
// Reload rules.
|
||||
e.ReloadRules()
|
||||
|
@@ -106,7 +106,7 @@ type mediaStore interface {
|
||||
GetBlob(name string) ([]byte, error)
|
||||
Attach(id int, model string, modelID int) error
|
||||
GetByModel(id int, model string) ([]mmodels.Media, error)
|
||||
ContentIDExists(contentID string) (bool, error)
|
||||
ContentIDExists(contentID string) (bool, string, error)
|
||||
Upload(fileName, contentType string, content io.ReadSeeker) (string, error)
|
||||
UploadAndInsert(fileName, contentType, contentID string, modelType null.String, modelID null.Int, content io.ReadSeeker, fileSize int, disposition null.String, meta []byte) (mmodels.Media, error)
|
||||
}
|
||||
@@ -449,11 +449,43 @@ func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, ac
|
||||
|
||||
// UpdateConversationTeamAssignee sets the assignee of a conversation to a specific team and sets the assigned user id to NULL.
|
||||
func (c *Manager) UpdateConversationTeamAssignee(uuid string, teamID int, actor umodels.User) error {
|
||||
// Store previous assigned team ID to apply SLA policy if team has changed.
|
||||
conversation, err := c.GetConversation(0, uuid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
previousAssignedTeamID := conversation.AssignedTeamID.Int
|
||||
|
||||
if err := c.UpdateAssignee(uuid, teamID, models.AssigneeTypeTeam); err != nil {
|
||||
return envelope.NewError(envelope.GeneralError, "Error updating assignee", nil)
|
||||
}
|
||||
|
||||
// Assignment successful, any errors now are non-critical and can be ignored by returning nil.
|
||||
if err := c.RecordAssigneeTeamChange(uuid, teamID, actor); err != nil {
|
||||
return envelope.NewError(envelope.GeneralError, "Error recording assignee change", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply SLA policy if team has changed and the new team has an SLA policy.
|
||||
if previousAssignedTeamID != teamID && teamID > 0 {
|
||||
team, err := c.teamStore.Get(teamID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if team.SLAPolicyID.Int > 0 {
|
||||
systemUser, err := c.userStore.GetSystemUser()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fetch the conversation again to get the updated assignee details.
|
||||
conversation, err := c.GetConversation(0, uuid)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := c.ApplySLA(conversation, team.SLAPolicyID.Int, systemUser); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -540,7 +572,7 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn
|
||||
return envelope.NewError(envelope.GeneralError, "Error recording status change", nil)
|
||||
}
|
||||
|
||||
// Broadcast update using WS
|
||||
// Broadcast updates using websocket.
|
||||
c.BroadcastConversationUpdate(uuid, "status", status)
|
||||
return nil
|
||||
}
|
||||
@@ -610,7 +642,7 @@ func (c *Manager) GetDashboardChart(userID, teamID int) (json.RawMessage, error)
|
||||
cond += " AND c.created_at >= NOW() - INTERVAL '90 days'"
|
||||
|
||||
// Apply the same condition across queries.
|
||||
query := fmt.Sprintf(c.q.GetDashboardCharts, cond, cond, cond, cond)
|
||||
query := fmt.Sprintf(c.q.GetDashboardCharts, cond, cond, cond)
|
||||
if err := tx.Get(&stats, query, qArgs...); err != nil {
|
||||
c.lo.Error("error fetching dashboard charts", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, "Error fetching dashboard charts", nil)
|
||||
@@ -803,10 +835,10 @@ func (m *Manager) SendAssignedConversationEmail(userIDs []int, conversation mode
|
||||
return fmt.Errorf("rendering template: %w", err)
|
||||
}
|
||||
nm := notifier.Message{
|
||||
UserIDs: userIDs,
|
||||
Subject: subject,
|
||||
Content: content,
|
||||
Provider: notifier.ProviderEmail,
|
||||
RecipientEmails: []string{agent.Email.String},
|
||||
Subject: subject,
|
||||
Content: content,
|
||||
Provider: notifier.ProviderEmail,
|
||||
}
|
||||
if err := m.notifier.Send(nm); err != nil {
|
||||
m.lo.Error("error sending notification message", "error", err)
|
||||
|
@@ -208,9 +208,16 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update status and first reply time
|
||||
// Update status of the message.
|
||||
m.UpdateMessageStatus(message.UUID, MessageStatusSent)
|
||||
m.UpdateConversationFirstReplyAt(message.ConversationUUID, message.ConversationID, message.CreatedAt)
|
||||
|
||||
// Update first reply time if the sender is not the system user.
|
||||
// All automated messages are sent by the system user.
|
||||
if systemUser, err := m.userStore.GetSystemUser(); err == nil && message.SenderID != systemUser.ID {
|
||||
m.UpdateConversationFirstReplyAt(message.ConversationUUID, message.ConversationID, time.Now())
|
||||
} else if err != nil {
|
||||
m.lo.Error("error fetching system user for updating first reply time", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RenderContentInTemplate renders message content in template.
|
||||
@@ -563,22 +570,25 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Evaluate automation rules for this conversation.
|
||||
// Evaluate automation rules for new conversation.
|
||||
if isNewConversation {
|
||||
m.automation.EvaluateNewConversationRules(in.Message.ConversationUUID)
|
||||
} else {
|
||||
m.automation.EvaluateConversationUpdateRules(in.Message.ConversationUUID, amodels.EventConversationMessageIncoming)
|
||||
// Reopen conversation if it's closed, snoozed, or resolved.
|
||||
systemUser, err := m.userStore.GetSystemUser()
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching system user", "error", err)
|
||||
return fmt.Errorf("error fetching system user for reopening conversation: %w", err)
|
||||
}
|
||||
if err := m.ReOpenConversation(in.Message.ConversationUUID, systemUser); err != nil {
|
||||
m.lo.Error("error reopening conversation", "error", err)
|
||||
return fmt.Errorf("error reopening conversation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reopen conversation if it's not Open.
|
||||
systemUser, err := m.userStore.GetSystemUser()
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching system user", "error", err)
|
||||
return fmt.Errorf("error fetching system user for reopening conversation: %w", err)
|
||||
}
|
||||
if err := m.ReOpenConversation(in.Message.ConversationUUID, systemUser); err != nil {
|
||||
m.lo.Error("error reopening conversation", "error", err)
|
||||
return fmt.Errorf("error reopening conversation: %w", err)
|
||||
}
|
||||
|
||||
// Trigger automations on incoming message event.
|
||||
m.automation.EvaluateConversationUpdateRules(in.Message.ConversationUUID, amodels.EventConversationMessageIncoming)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -648,7 +658,7 @@ func (c *Manager) generateMessagesQuery(baseQuery string, qArgs []interface{}, p
|
||||
return sqlQuery, pageSize, qArgs, nil
|
||||
}
|
||||
|
||||
// uploadMessageAttachments uploads attachments for a message.
|
||||
// uploadMessageAttachments uploads all attachments for a message.
|
||||
func (m *Manager) uploadMessageAttachments(message *models.Message) []error {
|
||||
if len(message.Attachments) == 0 {
|
||||
return nil
|
||||
@@ -656,28 +666,42 @@ func (m *Manager) uploadMessageAttachments(message *models.Message) []error {
|
||||
|
||||
var uploadErr []error
|
||||
for _, attachment := range message.Attachments {
|
||||
// Check if this attachment already exists by the content ID.
|
||||
if attachment.ContentID != "" {
|
||||
exists, err := m.mediaStore.ContentIDExists(attachment.ContentID)
|
||||
// Check if this attachment already exists by the content ID, as inline images can be repeated across conversations.
|
||||
contentID := attachment.ContentID
|
||||
if contentID != "" {
|
||||
// Make content ID MORE unique by prefixing it with the conversation UUID, as content id is not globally unique practically,
|
||||
// different messages can have the same content ID, I do not have the message ID at this point, so I am using sticking with the conversation UUID
|
||||
// to make it more unique.
|
||||
contentID = message.ConversationUUID + "_" + contentID
|
||||
|
||||
exists, uuid, err := m.mediaStore.ContentIDExists(contentID)
|
||||
if err != nil {
|
||||
m.lo.Error("error checking media existence by content ID", "content_id", attachment.ContentID, "error", err)
|
||||
continue
|
||||
m.lo.Error("error checking media existence by content ID", "content_id", contentID, "error", err)
|
||||
}
|
||||
|
||||
// This attachment already exists, replace the cid:content_id with the media relative url, not using absolute path as the root path can change.
|
||||
if exists {
|
||||
m.lo.Debug("attachment with content ID already exists", "content_id", attachment.ContentID)
|
||||
m.lo.Debug("attachment with content ID already exists replacing content ID with media relative URL", "content_id", contentID, "media_uuid", uuid)
|
||||
message.Content = strings.ReplaceAll(message.Content, fmt.Sprintf("cid:%s", attachment.ContentID), "/uploads/"+uuid)
|
||||
continue
|
||||
}
|
||||
|
||||
// Attachment does not exist, replace the content ID with the new more unique content ID.
|
||||
message.Content = strings.ReplaceAll(message.Content, fmt.Sprintf("cid:%s", attachment.ContentID), fmt.Sprintf("cid:%s", contentID))
|
||||
}
|
||||
|
||||
m.lo.Debug("uploading message attachment", "name", attachment.Name, "content_id", attachment.ContentID, "size", attachment.Size)
|
||||
|
||||
// Sanitize filename and upload.
|
||||
// Sanitize filename.
|
||||
attachment.Name = stringutil.SanitizeFilename(attachment.Name)
|
||||
|
||||
m.lo.Debug("uploading message attachment", "name", attachment.Name, "content_id", contentID, "size", attachment.Size, "content_type", attachment.ContentType,
|
||||
"content_id", contentID, "disposition", attachment.Disposition)
|
||||
|
||||
// Upload and insert entry in media table.
|
||||
attachReader := bytes.NewReader(attachment.Content)
|
||||
media, err := m.mediaStore.UploadAndInsert(
|
||||
attachment.Name,
|
||||
attachment.ContentType,
|
||||
attachment.ContentID,
|
||||
contentID,
|
||||
/** Linking media to message happens later **/
|
||||
null.String{}, /** modelType */
|
||||
null.Int{}, /** modelID **/
|
||||
|
@@ -61,10 +61,11 @@ type Conversation struct {
|
||||
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
|
||||
SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"`
|
||||
NextSLADeadlineAt null.Time `db:"next_sla_deadline_at" json:"next_sla_deadline_at"`
|
||||
FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"`
|
||||
ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"`
|
||||
SLAStatus null.String `db:"sla_status" json:"sla_status"`
|
||||
BCC json.RawMessage `db:"bcc" json:"bcc"`
|
||||
CC json.RawMessage `db:"cc" json:"cc"`
|
||||
FirstResponseDueAt null.Time `db:"-" json:"first_response_due_at"`
|
||||
ResolutionDueAt null.Time `db:"-" json:"resolution_due_at"`
|
||||
PreviousConversations []Conversation `db:"-" json:"previous_conversations"`
|
||||
Total int `db:"total" json:"-"`
|
||||
}
|
||||
|
@@ -63,12 +63,21 @@ SELECT
|
||||
) t
|
||||
) as unread_message_count,
|
||||
conversation_statuses.name as status,
|
||||
conversation_priorities.name as priority
|
||||
conversation_priorities.name as priority,
|
||||
as_latest.first_response_deadline_at,
|
||||
as_latest.resolution_deadline_at,
|
||||
as_latest.status as sla_status
|
||||
FROM conversations
|
||||
JOIN users ON contact_id = users.id
|
||||
JOIN inboxes ON inbox_id = inboxes.id
|
||||
LEFT JOIN conversation_statuses ON status_id = conversation_statuses.id
|
||||
LEFT JOIN conversation_priorities ON priority_id = conversation_priorities.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT first_response_deadline_at, resolution_deadline_at, status
|
||||
FROM applied_slas
|
||||
WHERE conversation_id = conversations.id
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) as_latest ON true
|
||||
WHERE 1=1 %s
|
||||
|
||||
-- name: get-conversation
|
||||
@@ -124,7 +133,10 @@ SELECT
|
||||
ct.avatar_url as "contact.avatar_url",
|
||||
ct.phone_number as "contact.phone_number",
|
||||
COALESCE(lr.cc, '[]'::jsonb) as cc,
|
||||
COALESCE(lr.bcc, '[]'::jsonb) as bcc
|
||||
COALESCE(lr.bcc, '[]'::jsonb) as bcc,
|
||||
as_latest.first_response_deadline_at,
|
||||
as_latest.resolution_deadline_at,
|
||||
as_latest.status as sla_status
|
||||
FROM conversations c
|
||||
JOIN users ct ON c.contact_id = ct.id
|
||||
LEFT JOIN sla_policies sla ON c.sla_policy_id = sla.id
|
||||
@@ -132,6 +144,12 @@ LEFT JOIN teams at ON at.id = c.assigned_team_id
|
||||
LEFT JOIN conversation_statuses s ON c.status_id = s.id
|
||||
LEFT JOIN conversation_priorities p ON c.priority_id = p.id
|
||||
LEFT JOIN last_reply lr ON lr.conversation_id = c.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT first_response_deadline_at, resolution_deadline_at, status
|
||||
FROM applied_slas
|
||||
WHERE conversation_id = c.id
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) as_latest ON true
|
||||
WHERE
|
||||
($1 > 0 AND c.id = $1)
|
||||
OR
|
||||
@@ -179,10 +197,10 @@ WHERE uuid = $1;
|
||||
-- name: update-conversation-status
|
||||
UPDATE conversations
|
||||
SET status_id = (SELECT id FROM conversation_statuses WHERE name = $2),
|
||||
resolved_at = CASE WHEN $2 = 'Resolved' THEN NOW() ELSE resolved_at END,
|
||||
closed_at = CASE WHEN $2 = 'Closed' THEN NOW() ELSE closed_at END,
|
||||
snoozed_until = CASE WHEN $2 = 'Snoozed' THEN $3::timestamptz ELSE NULL END,
|
||||
updated_at = now()
|
||||
resolved_at = COALESCE(resolved_at, CASE WHEN $2 IN ('Resolved', 'Closed') THEN NOW() END),
|
||||
closed_at = COALESCE(closed_at, CASE WHEN $2 = 'Closed' THEN NOW() END),
|
||||
snoozed_until = CASE WHEN $2 = 'Snoozed' THEN $3::timestamptz ELSE snoozed_until END,
|
||||
updated_at = NOW()
|
||||
WHERE uuid = $1;
|
||||
|
||||
-- name: get-user-active-conversations-count
|
||||
@@ -293,26 +311,10 @@ status_summary AS (
|
||||
GROUP BY
|
||||
s.name
|
||||
) agg
|
||||
),
|
||||
messages_sent as (
|
||||
SELECT json_agg(row_to_json(agg)) AS data
|
||||
FROM (
|
||||
SELECT
|
||||
TO_CHAR(created_at::date, 'YYYY-MM-DD') AS date,
|
||||
COUNT(*) AS count
|
||||
FROM
|
||||
conversation_messages c
|
||||
WHERE status = 'sent' AND 1=1 %s
|
||||
GROUP BY
|
||||
date
|
||||
ORDER BY
|
||||
date
|
||||
) agg
|
||||
)
|
||||
SELECT json_build_object(
|
||||
'new_conversations', (SELECT data FROM new_conversations),
|
||||
'resolved_conversations', (SELECT data FROM resolved_conversations),
|
||||
'messages_sent', (SELECT data FROM messages_sent),
|
||||
'status_summary', (SELECT data FROM status_summary)
|
||||
) AS result;
|
||||
|
||||
@@ -522,11 +524,12 @@ SET
|
||||
WHERE uuid = $1;
|
||||
|
||||
-- name: re-open-conversation
|
||||
-- Open conversation if it is not already open.
|
||||
UPDATE conversations
|
||||
SET status_id = (SELECT id FROM conversation_statuses WHERE name = 'Open'), snoozed_until = NULL,
|
||||
updated_at = now()
|
||||
WHERE uuid = $1 and status_id in (
|
||||
SELECT id FROM conversation_statuses WHERE name IN ('Snoozed', 'Closed', 'Resolved')
|
||||
SELECT id FROM conversation_statuses WHERE name NOT IN ('Open')
|
||||
)
|
||||
|
||||
-- name: delete-conversation
|
||||
|
@@ -34,12 +34,15 @@ type SMTPConfig struct {
|
||||
|
||||
// IMAPConfig holds IMAP client credentials and configuration.
|
||||
type IMAPConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Mailbox string `json:"mailbox"`
|
||||
ReadInterval string `json:"read_interval"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Mailbox string `json:"mailbox"`
|
||||
ReadInterval string `json:"read_interval"`
|
||||
ScanInboxSince string `json:"scan_inbox_since"`
|
||||
TLSType string `json:"tls_type"`
|
||||
TLSSkipVerify bool `json:"tls_skip_verify"`
|
||||
}
|
||||
|
||||
// Email represents the email inbox with multiple SMTP servers and IMAP clients.
|
||||
|
@@ -2,6 +2,7 @@ package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -19,15 +20,22 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultReadInterval = time.Duration(5 * time.Minute)
|
||||
defaultReadInterval = time.Duration(5 * time.Minute)
|
||||
defaultScanInboxSince = time.Duration(48 * time.Hour)
|
||||
)
|
||||
|
||||
// ReadIncomingMessages reads and processes incoming messages from an IMAP server based on the provided configuration.
|
||||
func (e *Email) ReadIncomingMessages(ctx context.Context, cfg IMAPConfig) error {
|
||||
readInterval, err := time.ParseDuration(cfg.ReadInterval)
|
||||
if err != nil {
|
||||
e.lo.Warn("could not parse IMAP read interval, using the default read interval", "interval", cfg.ReadInterval, "inbox_id", e.Identifier(), "error", err)
|
||||
readInterval = DefaultReadInterval
|
||||
e.lo.Warn("could not parse IMAP read interval, using the default read interval of 5 minutes", "interval", cfg.ReadInterval, "inbox_id", e.Identifier(), "error", err)
|
||||
readInterval = defaultReadInterval
|
||||
}
|
||||
|
||||
scanInboxSince, err := time.ParseDuration(cfg.ScanInboxSince)
|
||||
if err != nil {
|
||||
e.lo.Warn("could not parse IMAP scan inbox since duration, using the default value of 48 hours", "interval", cfg.ScanInboxSince, "inbox_id", e.Identifier(), "error", err)
|
||||
scanInboxSince = defaultScanInboxSince
|
||||
}
|
||||
|
||||
readTicker := time.NewTicker(readInterval)
|
||||
@@ -38,23 +46,49 @@ func (e *Email) ReadIncomingMessages(ctx context.Context, cfg IMAPConfig) error
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-readTicker.C:
|
||||
e.lo.Debug("processing emails from mailbox", "mailbox", cfg.Mailbox, "inbox_id", e.Identifier())
|
||||
if err := e.processMailbox(ctx, cfg); err != nil && err != context.Canceled {
|
||||
e.lo.Error("error processing mailbox", "error", err)
|
||||
// If the ticker interval is too short, it may trigger while the previous `processMailbox` call is still running,
|
||||
// leading to overlapping executions or delays in handling context cancellation, check if the context is already done.
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
e.lo.Debug("finished processing emails from mailbox", "mailbox", cfg.Mailbox, "inbox_id", e.Identifier())
|
||||
|
||||
e.lo.Debug("scanning emails", "mailbox", cfg.Mailbox, "inbox_id", e.Identifier())
|
||||
if err := e.processMailbox(ctx, scanInboxSince, cfg); err != nil && err != context.Canceled {
|
||||
e.lo.Error("error scanning emails", "error", err)
|
||||
}
|
||||
e.lo.Debug("finished scanning emails", "mailbox", cfg.Mailbox, "inbox_id", e.Identifier())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processMailbox processes emails in the specified mailbox.
|
||||
func (e *Email) processMailbox(ctx context.Context, cfg IMAPConfig) error {
|
||||
client, err := imapclient.DialTLS(cfg.Host+":"+fmt.Sprint(cfg.Port), &imapclient.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error connecting to IMAP server: %w", err)
|
||||
}
|
||||
defer client.Logout()
|
||||
func (e *Email) processMailbox(ctx context.Context, scanInboxSince time.Duration, cfg IMAPConfig) error {
|
||||
var (
|
||||
client *imapclient.Client
|
||||
err error
|
||||
)
|
||||
|
||||
address := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
imapOptions := &imapclient.Options{
|
||||
TLSConfig: &tls.Config{
|
||||
InsecureSkipVerify: cfg.TLSSkipVerify,
|
||||
},
|
||||
}
|
||||
switch cfg.TLSType {
|
||||
case "none":
|
||||
client, err = imapclient.DialInsecure(address, imapOptions)
|
||||
case "starttls":
|
||||
client, err = imapclient.DialStartTLS(address, imapOptions)
|
||||
case "tls":
|
||||
client, err = imapclient.DialTLS(address, imapOptions)
|
||||
default:
|
||||
return fmt.Errorf("unknown IMAP TLS type: %q", cfg.TLSType)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to IMAP server: %w", err)
|
||||
}
|
||||
|
||||
defer client.Logout()
|
||||
if err := client.Login(cfg.Username, cfg.Password).Wait(); err != nil {
|
||||
return fmt.Errorf("error logging in to the IMAP server: %w", err)
|
||||
}
|
||||
@@ -63,15 +97,18 @@ func (e *Email) processMailbox(ctx context.Context, cfg IMAPConfig) error {
|
||||
return fmt.Errorf("error selecting mailbox: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Set value from config.
|
||||
since := time.Now().Add(-24 * time.Hour)
|
||||
// Scan emails since the specified duration.
|
||||
since := time.Now().Add(-scanInboxSince)
|
||||
|
||||
searchData, err := e.searchMessages(client, since)
|
||||
e.lo.Debug("searching emails", "since", since, "mailbox", cfg.Mailbox, "inbox_id", e.Identifier())
|
||||
|
||||
// Search for messages in the mailbox.
|
||||
searchResults, err := e.searchMessages(client, since)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error searching messages: %w", err)
|
||||
}
|
||||
|
||||
return e.fetchAndProcessMessages(ctx, client, searchData, e.Identifier())
|
||||
return e.fetchAndProcessMessages(ctx, client, searchResults, e.Identifier())
|
||||
}
|
||||
|
||||
// searchMessages searches for messages in the specified time range.
|
||||
@@ -90,11 +127,11 @@ func (e *Email) searchMessages(client *imapclient.Client, since time.Time) (*ima
|
||||
}
|
||||
|
||||
// fetchAndProcessMessages fetches and processes messages based on the search results.
|
||||
func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.Client, searchData *imap.SearchData, inboxID int) error {
|
||||
func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.Client, searchResults *imap.SearchData, inboxID int) error {
|
||||
seqSet := imap.SeqSet{}
|
||||
seqSet.AddRange(searchData.Min, searchData.Max)
|
||||
seqSet.AddRange(searchResults.Min, searchResults.Max)
|
||||
|
||||
// Fetch only envelope, body is fetch later.
|
||||
// Fetch only envelope, body is fetch later if the message is new.
|
||||
fetchOptions := &imap.FetchOptions{
|
||||
Envelope: true,
|
||||
}
|
||||
@@ -235,15 +272,58 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client,
|
||||
}
|
||||
}
|
||||
|
||||
// processFullMessage processes the full email message.
|
||||
// extractAllHTMLParts extracts all HTML parts from the given enmime part by traversing the tree.
|
||||
func extractAllHTMLParts(part *enmime.Part) []string {
|
||||
var htmlParts []string
|
||||
|
||||
// Check current part
|
||||
if strings.HasPrefix(part.ContentType, "text/html") && len(part.Content) > 0 {
|
||||
htmlParts = append(htmlParts, string(part.Content))
|
||||
}
|
||||
|
||||
// Process children recursively
|
||||
for child := part.FirstChild; child != nil; child = child.NextSibling {
|
||||
childParts := extractAllHTMLParts(child)
|
||||
htmlParts = append(htmlParts, childParts...)
|
||||
}
|
||||
|
||||
return htmlParts
|
||||
}
|
||||
|
||||
func (e *Email) processFullMessage(item imapclient.FetchItemDataBodySection, incomingMsg models.IncomingMessage) error {
|
||||
envelope, err := enmime.ReadEnvelope(item.Literal)
|
||||
if err != nil {
|
||||
e.lo.Error("error parsing email envelope", "error", err, "envelope_errors", envelope.Errors)
|
||||
e.lo.Error("error parsing email envelope", "error", err, "message_id", incomingMsg.Message.SourceID.String)
|
||||
for _, err := range envelope.Errors {
|
||||
e.lo.Error("error parsing email envelope. envelope_error: ", "error", err.Error(), "message_id", incomingMsg.Message.SourceID.String)
|
||||
}
|
||||
return fmt.Errorf("parsing email envelope: %w", err)
|
||||
}
|
||||
|
||||
if len(envelope.HTML) > 0 {
|
||||
// Log any envelope errors.
|
||||
for _, err := range envelope.Errors {
|
||||
e.lo.Error("error parsing email envelope", "error", err.Error(), "message_id", incomingMsg.Message.SourceID.String)
|
||||
}
|
||||
|
||||
// Extract all HTML content by traversing the tree
|
||||
var allHTML strings.Builder
|
||||
if envelope.Root != nil {
|
||||
htmlParts := extractAllHTMLParts(envelope.Root)
|
||||
if len(htmlParts) > 0 {
|
||||
allHTML.WriteString("<div>")
|
||||
for _, part := range htmlParts {
|
||||
allHTML.WriteString(part)
|
||||
}
|
||||
allHTML.WriteString("</div>")
|
||||
}
|
||||
}
|
||||
|
||||
// Set message content - prioritize combined HTML
|
||||
if allHTML.Len() > 0 {
|
||||
incomingMsg.Message.Content = allHTML.String()
|
||||
incomingMsg.Message.ContentType = conversation.ContentTypeHTML
|
||||
e.lo.Debug("extracted HTML content from parts", "message_id", incomingMsg.Message.SourceID.String, "content", incomingMsg.Message.Content)
|
||||
} else if len(envelope.HTML) > 0 {
|
||||
incomingMsg.Message.Content = envelope.HTML
|
||||
incomingMsg.Message.ContentType = conversation.ContentTypeHTML
|
||||
} else if len(envelope.Text) > 0 {
|
||||
@@ -251,7 +331,10 @@ func (e *Email) processFullMessage(item imapclient.FetchItemDataBodySection, inc
|
||||
incomingMsg.Message.ContentType = conversation.ContentTypeText
|
||||
}
|
||||
|
||||
// Remove the angle brackets from the In-Reply-To and References headers.
|
||||
e.lo.Debug("envelope HTML content", "message_id", incomingMsg.Message.SourceID.String, "content", incomingMsg.Message.Content)
|
||||
e.lo.Debug("envelope text content", "message_id", incomingMsg.Message.SourceID.String, "content", envelope.Text)
|
||||
|
||||
// Clean headers
|
||||
inReplyTo := strings.ReplaceAll(strings.ReplaceAll(envelope.GetHeader("In-Reply-To"), "<", ""), ">", "")
|
||||
references := strings.Fields(envelope.GetHeader("References"))
|
||||
for i, ref := range references {
|
||||
@@ -261,6 +344,7 @@ func (e *Email) processFullMessage(item imapclient.FetchItemDataBodySection, inc
|
||||
incomingMsg.Message.InReplyTo = inReplyTo
|
||||
incomingMsg.Message.References = references
|
||||
|
||||
// Process attachments
|
||||
for _, att := range envelope.Attachments {
|
||||
incomingMsg.Message.Attachments = append(incomingMsg.Message.Attachments, attachment.Attachment{
|
||||
Name: att.FileName,
|
||||
@@ -271,17 +355,27 @@ func (e *Email) processFullMessage(item imapclient.FetchItemDataBodySection, inc
|
||||
Disposition: attachment.DispositionAttachment,
|
||||
})
|
||||
}
|
||||
|
||||
// Process inlines - treat ones without ContentID as regular attachments
|
||||
for _, inline := range envelope.Inlines {
|
||||
disposition := attachment.DispositionInline
|
||||
if inline.ContentID == "" {
|
||||
disposition = attachment.DispositionAttachment
|
||||
}
|
||||
|
||||
incomingMsg.Message.Attachments = append(incomingMsg.Message.Attachments, attachment.Attachment{
|
||||
Name: inline.FileName,
|
||||
Content: inline.Content,
|
||||
ContentType: inline.ContentType,
|
||||
ContentID: inline.ContentID,
|
||||
Size: len(inline.Content),
|
||||
Disposition: attachment.DispositionInline,
|
||||
Disposition: disposition,
|
||||
})
|
||||
}
|
||||
e.lo.Debug("enqueuing incoming email message for inserting in DB", "message_id", incomingMsg.Message.SourceID.String, "attachments", len(envelope.Attachments), "inlines", len(envelope.Inlines))
|
||||
|
||||
e.lo.Debug("enqueuing incoming email message", "message_id", incomingMsg.Message.SourceID.String,
|
||||
"attachments", len(envelope.Attachments), "inline_attachments", len(envelope.Inlines))
|
||||
|
||||
if err := e.messageStore.EnqueueIncoming(incomingMsg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
@@ -49,7 +49,7 @@ func NewSmtpPool(configs []SMTPConfig) ([]*smtppool.Pool, error) {
|
||||
}
|
||||
|
||||
// SSL/TLS, not STARTTLS
|
||||
if cfg.TLSType == "TLS" {
|
||||
if cfg.TLSType == "tls" {
|
||||
cfg.Opt.SSL = true
|
||||
}
|
||||
}
|
||||
|
@@ -252,6 +252,7 @@ func (m *Manager) Update(id int, inbox imodels.Inbox) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Preserve existing passwords if update has empty password
|
||||
switch current.Channel {
|
||||
case "email":
|
||||
var currentCfg struct {
|
||||
@@ -300,6 +301,7 @@ func (m *Manager) Update(id int, inbox imodels.Inbox) error {
|
||||
inbox.Config = updatedConfig
|
||||
}
|
||||
|
||||
// Update the inbox in the DB.
|
||||
if _, err := m.queries.Update.Exec(id, inbox.Channel, inbox.Config, inbox.Name, inbox.From, inbox.CSATEnabled, inbox.Enabled); err != nil {
|
||||
m.lo.Error("error updating inbox", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error updating inbox", nil)
|
||||
|
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
@@ -104,13 +105,13 @@ func (m *Manager) Insert(disposition null.String, fileName, contentType, content
|
||||
if err := m.queries.Insert.QueryRow(m.store.Name(), fileName, contentType, fileSize, meta, modelID, modelType, disposition, contentID, uuid).Scan(&id); err != nil {
|
||||
m.lo.Error("error inserting media", "error", err)
|
||||
}
|
||||
return m.Get(id)
|
||||
return m.Get(id, "")
|
||||
}
|
||||
|
||||
// Get retrieves the media record by its ID and returns the media.
|
||||
func (m *Manager) Get(id int) (models.Media, error) {
|
||||
func (m *Manager) Get(id int, uuid string) (models.Media, error) {
|
||||
var media models.Media
|
||||
if err := m.queries.Get.Get(&media, id); err != nil {
|
||||
if err := m.queries.Get.Get(&media, id, uuid); err != nil {
|
||||
m.lo.Error("error fetching media", "error", err)
|
||||
return media, envelope.NewError(envelope.GeneralError, "Error fetching media", nil)
|
||||
}
|
||||
@@ -118,28 +119,17 @@ func (m *Manager) Get(id int) (models.Media, error) {
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// GetByUUID retrieves a media record by the uuid.
|
||||
func (m *Manager) GetByUUID(uuid string) (models.Media, error) {
|
||||
var media models.Media
|
||||
if err := m.queries.GetByUUID.Get(&media, uuid); err != nil {
|
||||
// ContentIDExists checks if a content_id exists in the database and returns the UUID of the media file.
|
||||
func (m *Manager) ContentIDExists(contentID string) (bool, string, error) {
|
||||
var uuid string
|
||||
if err := m.queries.ContentIDExists.Get(&uuid, contentID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return media, envelope.NewError(envelope.GeneralError, "File not found", nil)
|
||||
return false, "", nil
|
||||
}
|
||||
m.lo.Error("error fetching media", "error", err)
|
||||
return media, envelope.NewError(envelope.GeneralError, "Error fetching media", nil)
|
||||
m.lo.Error("error checking if content_id exists", "error", err)
|
||||
return false, "", fmt.Errorf("checking if content_id exists: %w", err)
|
||||
}
|
||||
media.URL = m.store.GetURL(uuid)
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// ContentIDExists returns true if a media file with the given content ID exists.
|
||||
func (m *Manager) ContentIDExists(contentID string) (bool, error) {
|
||||
var exists bool
|
||||
if err := m.queries.ContentIDExists.Get(&exists, contentID); err != nil {
|
||||
m.lo.Error("error checking media existence", "error", err)
|
||||
return false, fmt.Errorf("checking media existence: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
return true, uuid, nil
|
||||
}
|
||||
|
||||
// GetBlob retrieves the raw binary content of a media file by its name.
|
||||
@@ -176,8 +166,12 @@ func (m *Manager) GetByModel(modelID int, model string) ([]models.Media, error)
|
||||
func (m *Manager) Delete(name string) error {
|
||||
if err := m.store.Delete(name); err != nil {
|
||||
m.lo.Error("error deleting media from store", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error deleting media from store", nil)
|
||||
// If the file does not exist, ignore the error.
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return envelope.NewError(envelope.GeneralError, "Error deleting media from store", nil)
|
||||
}
|
||||
}
|
||||
// Delete the media record from the database.
|
||||
if _, err := m.queries.Delete.Exec(name); err != nil {
|
||||
m.lo.Error("error deleting media from db", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error deleting media from DB", nil)
|
||||
@@ -192,8 +186,8 @@ func (m *Manager) DeleteUnlinkedMedia(ctx context.Context) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(2 * time.Hour):
|
||||
m.lo.Info("deleting unlinked message media")
|
||||
case <-time.After(12 * time.Hour):
|
||||
m.lo.Info("starting periodic deletion of unlinked media")
|
||||
if err := m.deleteUnlinkedMessageMedia(); err != nil {
|
||||
m.lo.Error("error deleting unlinked media", "error", err)
|
||||
}
|
||||
@@ -209,8 +203,10 @@ func (m *Manager) deleteUnlinkedMessageMedia() error {
|
||||
return err
|
||||
}
|
||||
for _, mm := range media {
|
||||
m.lo.Debug("deleting media not linked to any message", "media_id", mm.ID)
|
||||
if err := m.Delete(mm.UUID); err != nil {
|
||||
return err
|
||||
m.lo.Error("error deleting unlinked media", "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
@@ -17,7 +17,10 @@ RETURNING id;
|
||||
-- name: get-media
|
||||
SELECT id, created_at, "uuid", store, filename, content_type, model_id, model_type, "size", disposition
|
||||
FROM media
|
||||
WHERE id = $1;
|
||||
WHERE
|
||||
($1 > 0 AND id = $1)
|
||||
OR
|
||||
($2 != '' AND uuid = $2::uuid)
|
||||
|
||||
-- name: get-media-by-uuid
|
||||
SELECT id, created_at, "uuid", store, filename, content_type, model_id, model_type, "size", disposition
|
||||
@@ -43,8 +46,9 @@ WHERE model_type = $1
|
||||
-- name: get-unlinked-message-media
|
||||
SELECT id, created_at, "uuid", store, filename, content_type, model_id, model_type, "size", disposition
|
||||
FROM media
|
||||
WHERE model_type = 'messages'
|
||||
AND model_id IS NULL or model_id = 0 AND created_at < NOW() - INTERVAL '1 day';
|
||||
WHERE model_type = 'messages'
|
||||
AND (model_id IS NULL OR model_id = 0)
|
||||
AND created_at < NOW() - INTERVAL '1 day';
|
||||
|
||||
-- name: content-id-exists
|
||||
SELECT EXISTS(SELECT 1 FROM media WHERE content_id = $1);
|
||||
SELECT uuid FROM media WHERE content_id = $1;
|
247
internal/migrations/v0.5.0.go
Normal file
247
internal/migrations/v0.5.0.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V0_5_0 updates the database schema to v0.5.0.
|
||||
func V0_5_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
_, err := db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'applied_sla_status') THEN
|
||||
CREATE TYPE "applied_sla_status" AS ENUM ('pending', 'breached', 'met', 'partially_met');
|
||||
END IF;
|
||||
END$$;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
ALTER TABLE applied_slas ADD COLUMN IF NOT EXISTS status applied_sla_status DEFAULT 'pending' NOT NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS index_applied_slas_on_status ON applied_slas(status);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO settings (key, value)
|
||||
VALUES
|
||||
('notification.email.tls_type', '"starttls"'::jsonb),
|
||||
('notification.email.tls_skip_verify', 'false'::jsonb),
|
||||
('notification.email.hello_hostname', '""'::jsonb)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update tls_type for IMAP
|
||||
_, err = db.Exec(`
|
||||
UPDATE inboxes
|
||||
SET config = jsonb_set(config, '{imap,0,tls_type}', '"tls"', true)
|
||||
WHERE config->'imap' IS NOT NULL AND config#>'{imap,0,tls_type}' IS NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update tls_skip_verify for IMAP
|
||||
_, err = db.Exec(`
|
||||
UPDATE inboxes
|
||||
SET config = jsonb_set(config, '{imap,0,tls_skip_verify}', 'false', true)
|
||||
WHERE config->'imap' IS NOT NULL AND config#>'{imap,0,tls_skip_verify}' IS NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update scan_inbox_since for IMAP
|
||||
_, err = db.Exec(`
|
||||
UPDATE inboxes
|
||||
SET config = jsonb_set(config, '{imap,0,scan_inbox_since}', '"48h"', true)
|
||||
WHERE config->'imap' IS NOT NULL AND config#>'{imap,0,scan_inbox_since}' IS NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update tls_type for SMTP
|
||||
_, err = db.Exec(`
|
||||
UPDATE inboxes
|
||||
SET config = jsonb_set(config, '{smtp,0,tls_type}', '"starttls"', true)
|
||||
WHERE config->'smtp' IS NOT NULL AND config#>'{smtp,0,tls_type}' IS NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update tls_skip_verify for SMTP
|
||||
_, err = db.Exec(`
|
||||
UPDATE inboxes
|
||||
SET config = jsonb_set(config, '{smtp,0,tls_skip_verify}', 'false', true)
|
||||
WHERE config->'smtp' IS NOT NULL AND config#>'{smtp,0,tls_skip_verify}' IS NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update hello_hostname for SMTP
|
||||
_, err = db.Exec(`
|
||||
UPDATE inboxes
|
||||
SET config = jsonb_set(config, '{smtp,0,hello_hostname}', '""', true)
|
||||
WHERE config->'smtp' IS NOT NULL AND config#>'{smtp,0,hello_hostname}' IS NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add notifications column to sla_policies
|
||||
_, err = db.Exec(`
|
||||
ALTER TABLE sla_policies
|
||||
ADD COLUMN IF NOT EXISTS notifications JSONB DEFAULT '[]'::jsonb NOT NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'sla_metric') THEN
|
||||
CREATE TYPE "sla_metric" AS ENUM ('first_response', 'resolution');
|
||||
END IF;
|
||||
END$$;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'sla_notification_type') THEN
|
||||
CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach');
|
||||
END IF;
|
||||
END$$;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS scheduled_sla_notifications (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
applied_sla_id BIGINT NOT NULL REFERENCES applied_slas(id) ON DELETE CASCADE,
|
||||
metric sla_metric NOT NULL,
|
||||
notification_type sla_notification_type NOT NULL,
|
||||
recipients TEXT[] NOT NULL,
|
||||
send_at TIMESTAMPTZ NOT NULL,
|
||||
processed_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS index_scheduled_sla_notifications_on_send_at ON scheduled_sla_notifications(send_at);
|
||||
CREATE INDEX IF NOT EXISTS index_scheduled_sla_notifications_on_processed_at ON scheduled_sla_notifications(processed_at);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM templates WHERE "name" = 'SLA breach warning') THEN
|
||||
INSERT INTO templates
|
||||
("type", body, is_default, "name", subject, is_builtin)
|
||||
VALUES (
|
||||
'email_notification'::template_type,
|
||||
'
|
||||
|
||||
<p>This is a notification that the SLA for conversation {{ .Conversation.ReferenceNumber }} is approaching the SLA deadline for {{ .SLA.Metric }}.</p>
|
||||
|
||||
<p>
|
||||
Details:<br>
|
||||
- Conversation reference number: {{ .Conversation.ReferenceNumber }}<br>
|
||||
- Metric: {{ .SLA.Metric }}<br>
|
||||
- Due in: {{ .SLA.DueIn }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
Libredesk
|
||||
</p>
|
||||
|
||||
',
|
||||
false,
|
||||
'SLA breach warning',
|
||||
'SLA Alert: Conversation {{ .Conversation.ReferenceNumber }} is approaching SLA deadline for {{ .SLA.Metric }}',
|
||||
true
|
||||
);
|
||||
END IF;
|
||||
END$$;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM templates WHERE "name" = 'SLA breached') THEN
|
||||
INSERT INTO templates
|
||||
("type", body, is_default, "name", subject, is_builtin)
|
||||
VALUES (
|
||||
'email_notification'::template_type,
|
||||
'
|
||||
<p>This is an urgent alert that the SLA for conversation {{ .Conversation.ReferenceNumber }} has been breached for {{ .SLA.Metric }}. Please take immediate action.</p>
|
||||
|
||||
<p>
|
||||
Details:<br>
|
||||
- Conversation reference number: {{ .Conversation.ReferenceNumber }}<br>
|
||||
- Metric: {{ .SLA.Metric }}<br>
|
||||
- Overdue by: {{ .SLA.OverdueBy }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
Libredesk
|
||||
</p>
|
||||
|
||||
',
|
||||
false,
|
||||
'SLA breached',
|
||||
'Urgent: SLA Breach for Conversation {{ .Conversation.ReferenceNumber }} for {{ .SLA.Metric }}',
|
||||
true
|
||||
);
|
||||
|
||||
END IF;
|
||||
END$$;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
@@ -17,6 +17,8 @@ const (
|
||||
type Message struct {
|
||||
// Recipients of the message
|
||||
UserIDs []int
|
||||
// Email addresses of the recipients
|
||||
RecipientEmails []string
|
||||
// Subject of the message
|
||||
Subject string
|
||||
// Body of the message
|
||||
@@ -33,16 +35,6 @@ type Message struct {
|
||||
Headers map[string][]string
|
||||
}
|
||||
|
||||
// UserEmailFetcher defines the interface for fetching user email addresses.
|
||||
type UserEmailFetcher interface {
|
||||
GetEmail(id int) (string, error)
|
||||
}
|
||||
|
||||
// UserStore defines the interface for the user store.
|
||||
type UserStore interface {
|
||||
UserEmailFetcher
|
||||
}
|
||||
|
||||
// Notifier defines the interface for sending notifications through various providers.
|
||||
type Notifier interface {
|
||||
// Sends the notification message using the specified provider
|
||||
|
@@ -17,7 +17,6 @@ type Email struct {
|
||||
lo *logf.Logger
|
||||
from string
|
||||
smtpPools []*smtppool.Pool
|
||||
userStore notifier.UserStore
|
||||
}
|
||||
|
||||
// Opts contains options for creating a new Email sender.
|
||||
@@ -27,7 +26,7 @@ type Opts struct {
|
||||
}
|
||||
|
||||
// New initializes a new Email sender.
|
||||
func New(smtpConfig []email.SMTPConfig, userStore notifier.UserStore, opts Opts) (*Email, error) {
|
||||
func New(smtpConfig []email.SMTPConfig, opts Opts) (*Email, error) {
|
||||
pools, err := email.NewSmtpPool(smtpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -36,17 +35,12 @@ func New(smtpConfig []email.SMTPConfig, userStore notifier.UserStore, opts Opts)
|
||||
lo: opts.Lo,
|
||||
smtpPools: pools,
|
||||
from: opts.FromEmail,
|
||||
userStore: userStore,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Send sends a notification message via email.
|
||||
func (e *Email) Send(msg notifier.Message) error {
|
||||
recipientEmails, err := e.getUserEmails(msg.UserIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
emailMessage := e.prepareEmail(msg.Subject, msg.Content, recipientEmails, msg)
|
||||
emailMessage := e.prepareEmail(msg.Subject, msg.Content, msg.RecipientEmails, msg)
|
||||
return e.send(emailMessage)
|
||||
}
|
||||
|
||||
@@ -55,20 +49,6 @@ func (e *Email) Name() string {
|
||||
return notifier.ProviderEmail
|
||||
}
|
||||
|
||||
// getUserEmails fetches email addresses for specified user IDs.
|
||||
func (e *Email) getUserEmails(userIDs []int) ([]string, error) {
|
||||
var recipientEmails []string
|
||||
for _, userID := range userIDs {
|
||||
userEmail, err := e.userStore.GetEmail(userID)
|
||||
if err != nil {
|
||||
e.lo.Error("error fetching user email", "user_id", userID, "error", err)
|
||||
continue
|
||||
}
|
||||
recipientEmails = append(recipientEmails, userEmail)
|
||||
}
|
||||
return recipientEmails, nil
|
||||
}
|
||||
|
||||
// send sends an email message.
|
||||
func (e *Email) send(em smtppool.Email) error {
|
||||
srv := e.selectSmtpPool()
|
||||
|
@@ -134,7 +134,7 @@ func (u *Manager) Update(id int, r models.Role) error {
|
||||
// validatePermissions returns true if all given permissions are valid
|
||||
func (u *Manager) validatePermissions(permissions []string) error {
|
||||
if len(permissions) == 0 {
|
||||
return envelope.NewError(envelope.InputError, "Permissions cannot be empty", nil)
|
||||
return envelope.NewError(envelope.InputError, "Select at least one permission", nil)
|
||||
}
|
||||
for _, perm := range permissions {
|
||||
if !amodels.IsValidPermission(perm) {
|
||||
|
@@ -1,4 +1,4 @@
|
||||
-- name: search-conversations
|
||||
-- name: search-conversations-by-reference-number
|
||||
SELECT
|
||||
conversations.created_at,
|
||||
conversations.uuid,
|
||||
@@ -7,6 +7,18 @@ SELECT
|
||||
FROM conversations
|
||||
WHERE reference_number::text = $1;
|
||||
|
||||
-- name: search-conversations-by-contact-email
|
||||
SELECT
|
||||
conversations.created_at,
|
||||
conversations.uuid,
|
||||
conversations.reference_number,
|
||||
conversations.subject
|
||||
FROM conversations
|
||||
JOIN users ON conversations.contact_id = users.id
|
||||
WHERE users.email = $1
|
||||
ORDER BY conversations.created_at DESC
|
||||
LIMIT 1000;
|
||||
|
||||
-- name: search-messages
|
||||
SELECT
|
||||
c.created_at as "conversation_created_at",
|
||||
@@ -15,7 +27,8 @@ SELECT
|
||||
m.text_content
|
||||
FROM conversation_messages m
|
||||
JOIN conversations c ON m.conversation_id = c.id
|
||||
WHERE m.type != 'activity' and m.text_content ILIKE '%' || $1 || '%';
|
||||
WHERE m.type != 'activity' and m.text_content ILIKE '%' || $1 || '%'
|
||||
LIMIT 30;
|
||||
|
||||
-- name: search-contacts
|
||||
SELECT
|
||||
|
@@ -30,9 +30,10 @@ type Opts struct {
|
||||
|
||||
// queries contains all the prepared queries
|
||||
type queries struct {
|
||||
SearchConversations *sqlx.Stmt `query:"search-conversations"`
|
||||
SearchMessages *sqlx.Stmt `query:"search-messages"`
|
||||
SearchContacts *sqlx.Stmt `query:"search-contacts"`
|
||||
SearchConversationsByRefNum *sqlx.Stmt `query:"search-conversations-by-reference-number"`
|
||||
SearchConversationsByContactEmail *sqlx.Stmt `query:"search-conversations-by-contact-email"`
|
||||
SearchMessages *sqlx.Stmt `query:"search-messages"`
|
||||
SearchContacts *sqlx.Stmt `query:"search-contacts"`
|
||||
}
|
||||
|
||||
// New creates a new search manager
|
||||
@@ -46,12 +47,18 @@ func New(opts Opts) (*Manager, error) {
|
||||
|
||||
// Conversations searches conversations based on the query
|
||||
func (s *Manager) Conversations(query string) ([]models.Conversation, error) {
|
||||
var results = make([]models.Conversation, 0)
|
||||
if err := s.q.SearchConversations.Select(&results, query); err != nil {
|
||||
var refNumResults = make([]models.Conversation, 0)
|
||||
if err := s.q.SearchConversationsByRefNum.Select(&refNumResults, query); err != nil {
|
||||
s.lo.Error("error searching conversations", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, "Error searching conversations", nil)
|
||||
}
|
||||
return results, nil
|
||||
|
||||
var emailResults = make([]models.Conversation, 0)
|
||||
if err := s.q.SearchConversationsByContactEmail.Select(&emailResults, query); err != nil {
|
||||
s.lo.Error("error searching conversations", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, "Error searching conversations", nil)
|
||||
}
|
||||
return append(refNumResults, emailResults...), nil
|
||||
}
|
||||
|
||||
// Messages searches messages based on the query
|
||||
@@ -72,4 +79,4 @@ func (s *Manager) Contacts(query string) ([]models.Contact, error) {
|
||||
return nil, envelope.NewError(envelope.GeneralError, "Error searching contacts", nil)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
|
@@ -23,6 +23,9 @@ type EmailNotification struct {
|
||||
AuthProtocol string `json:"notification.email.auth_protocol" db:"notification.email.auth_protocol"`
|
||||
EmailAddress string `json:"notification.email.email_address" db:"notification.email.email_address"`
|
||||
MaxMsgRetries int `json:"notification.email.max_msg_retries" db:"notification.email.max_msg_retries"`
|
||||
TLSType string `json:"notification.email.tls_type" db:"notification.email.tls_type"`
|
||||
TLSSkipVerify bool `json:"notification.email.tls_skip_verify" db:"notification.email.tls_skip_verify"`
|
||||
HelloHostname string `json:"notification.email.hello_hostname" db:"notification.email.hello_hostname"`
|
||||
Enabled bool `json:"notification.email.enabled" db:"notification.email.enabled"`
|
||||
}
|
||||
|
||||
|
@@ -1,33 +1,90 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"github.com/volatiletech/null/v9"
|
||||
)
|
||||
|
||||
// SLAPolicy represents a service level agreement policy definition
|
||||
type SLAPolicy struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Description string `db:"description" json:"description"`
|
||||
FirstResponseTime string `db:"first_response_time" json:"first_response_time"`
|
||||
EveryResponseTime string `db:"every_response_time" json:"every_response_time"`
|
||||
ResolutionTime string `db:"resolution_time" json:"resolution_time"`
|
||||
ID int `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Description string `db:"description" json:"description,omitempty"`
|
||||
FirstResponseTime string `db:"first_response_time" json:"first_response_time,omitempty"`
|
||||
EveryResponseTime string `db:"every_response_time" json:"every_response_time,omitempty"`
|
||||
ResolutionTime string `db:"resolution_time" json:"resolution_time,omitempty"`
|
||||
Notifications SlaNotifications `db:"notifications" json:"notifications,omitempty"`
|
||||
}
|
||||
|
||||
// AppliedSLA represents an SLA policy applied to a conversation with its deadlines and breach status
|
||||
type SlaNotifications []SlaNotification
|
||||
|
||||
// Value implements the driver.Valuer interface.
|
||||
func (sn SlaNotifications) Value() (driver.Value, error) {
|
||||
return json.Marshal(sn)
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface.
|
||||
func (sn *SlaNotifications) Scan(src any) error {
|
||||
var data []byte
|
||||
|
||||
switch v := src.(type) {
|
||||
case string:
|
||||
data = []byte(v)
|
||||
case []byte:
|
||||
data = v
|
||||
default:
|
||||
return fmt.Errorf("unsupported type: %T", src)
|
||||
}
|
||||
return json.Unmarshal(data, sn)
|
||||
}
|
||||
|
||||
// SlaNotification represents the notification settings for an SLA policy
|
||||
type SlaNotification struct {
|
||||
Type string `db:"type" json:"type"`
|
||||
Recipients []string `db:"recipients" json:"recipients"`
|
||||
TimeDelay string `db:"time_delay" json:"time_delay"`
|
||||
TimeDelayType string `db:"time_delay_type" json:"time_delay_type"`
|
||||
}
|
||||
|
||||
// ScheduledSLANotification represents a scheduled SLA notification
|
||||
type ScheduledSLANotification struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
AppliedSLAID int `db:"applied_sla_id" json:"applied_sla_id"`
|
||||
Metric string `db:"metric" json:"metric"`
|
||||
NotificationType string `db:"notification_type" json:"notification_type"`
|
||||
Recipients pq.StringArray `db:"recipients" json:"recipients"`
|
||||
SendAt time.Time `db:"send_at" json:"send_at"`
|
||||
ProcessedAt null.Time `db:"processed_at" json:"processed_at,omitempty"`
|
||||
}
|
||||
|
||||
// AppliedSLA represents an SLA policy applied to a conversation
|
||||
type AppliedSLA struct {
|
||||
ID int `db:"id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
Status string `db:"status"`
|
||||
ConversationID int `db:"conversation_id"`
|
||||
SLAPolicyID int `db:"sla_policy_id"`
|
||||
FirstResponseDeadlineAt time.Time `db:"first_response_deadline_at"`
|
||||
ResolutionDeadlineAt time.Time `db:"resolution_deadline_at"`
|
||||
FirstResponseBreachedAt null.Time `db:"first_response_breached_at"`
|
||||
ResolutionBreachedAt null.Time `db:"resolution_breached_at"`
|
||||
FirstResponseAt null.Time `db:"first_response_at"`
|
||||
ResolvedAt null.Time `db:"resolved_at"`
|
||||
FirstResponseMetAt null.Time `db:"first_response_met_at"`
|
||||
ResolutionMetAt null.Time `db:"resolution_met_at"`
|
||||
|
||||
// Conversation fields.
|
||||
ConversationFirstResponseAt null.Time `db:"conversation_first_response_at"`
|
||||
ConversationResolvedAt null.Time `db:"conversation_resolved_at"`
|
||||
ConversationUUID string `db:"conversation_uuid"`
|
||||
ConversationReferenceNumber string `db:"conversation_reference_number"`
|
||||
ConversationSubject string `db:"conversation_subject"`
|
||||
ConversationAssignedUserID null.Int `db:"conversation_assigned_user_id"`
|
||||
}
|
||||
|
@@ -1,19 +1,17 @@
|
||||
-- name: get-sla-policy
|
||||
SELECT * FROM sla_policies WHERE id = $1;
|
||||
SELECT id, name, description, first_response_time, resolution_time, notifications, created_at, updated_at FROM sla_policies WHERE id = $1;
|
||||
|
||||
-- name: get-all-sla-policies
|
||||
SELECT * FROM sla_policies ORDER BY updated_at DESC;
|
||||
SELECT id, name, created_at, updated_at FROM sla_policies ORDER BY updated_at DESC;
|
||||
|
||||
-- name: insert-sla-policy
|
||||
INSERT INTO sla_policies (
|
||||
name,
|
||||
description,
|
||||
first_response_time,
|
||||
resolution_time
|
||||
) VALUES ($1, $2, $3, $4);
|
||||
|
||||
-- name: delete-sla-policy
|
||||
DELETE FROM sla_policies WHERE id = $1;
|
||||
resolution_time,
|
||||
notifications
|
||||
) VALUES ($1, $2, $3, $4, $5);
|
||||
|
||||
-- name: update-sla-policy
|
||||
UPDATE sla_policies SET
|
||||
@@ -21,36 +19,37 @@ UPDATE sla_policies SET
|
||||
description = $3,
|
||||
first_response_time = $4,
|
||||
resolution_time = $5,
|
||||
notifications = $6,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: delete-sla-policy
|
||||
DELETE FROM sla_policies WHERE id = $1;
|
||||
|
||||
-- name: apply-sla
|
||||
WITH new_sla AS (
|
||||
INSERT INTO applied_slas (
|
||||
conversation_id,
|
||||
sla_policy_id,
|
||||
first_response_deadline_at,
|
||||
resolution_deadline_at
|
||||
) VALUES ($1, $2, $3, $4)
|
||||
RETURNING conversation_id
|
||||
INSERT INTO applied_slas (
|
||||
conversation_id,
|
||||
sla_policy_id,
|
||||
first_response_deadline_at,
|
||||
resolution_deadline_at
|
||||
) VALUES ($1, $2, $3, $4)
|
||||
RETURNING conversation_id, id
|
||||
)
|
||||
UPDATE conversations
|
||||
UPDATE conversations c
|
||||
SET sla_policy_id = $2,
|
||||
next_sla_deadline_at = LEAST(
|
||||
NULLIF($3, NULL),
|
||||
NULLIF($4, NULL)
|
||||
)
|
||||
WHERE id IN (SELECT conversation_id FROM new_sla);
|
||||
next_sla_deadline_at = LEAST($3, $4)
|
||||
FROM new_sla ns
|
||||
WHERE c.id = ns.conversation_id
|
||||
RETURNING ns.id;
|
||||
|
||||
-- name: get-pending-slas
|
||||
-- Get all the applied SLAs that are not yet breached or met and is also set on the conversation.
|
||||
-- This make sure when SLA is changed, we don't update the breached or met status of the previous SLA.
|
||||
SELECT a.id, a.first_response_deadline_at, c.first_reply_at as first_response_at,
|
||||
a.resolution_deadline_at, c.resolved_at as resolved_at
|
||||
-- Get all the applied SLAs (applied to a conversation) that are pending
|
||||
SELECT a.id, a.first_response_deadline_at, c.first_reply_at as conversation_first_response_at, a.sla_policy_id,
|
||||
a.resolution_deadline_at, c.resolved_at as conversation_resolved_at, c.id as conversation_id, a.first_response_met_at, a.resolution_met_at, a.first_response_breached_at, a.resolution_breached_at
|
||||
FROM applied_slas a
|
||||
JOIN conversations c ON a.conversation_id = c.id and c.sla_policy_id = a.sla_policy_id
|
||||
WHERE (first_response_breached_at IS NULL AND first_response_met_at IS NULL)
|
||||
OR (resolution_breached_at IS NULL AND resolution_met_at IS NULL);
|
||||
WHERE a.status = 'pending'::applied_sla_status;
|
||||
|
||||
-- name: update-breach
|
||||
UPDATE applied_slas SET
|
||||
@@ -66,8 +65,71 @@ UPDATE applied_slas SET
|
||||
updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: get-latest-sla-deadlines
|
||||
SELECT first_response_deadline_at, resolution_deadline_at
|
||||
FROM applied_slas
|
||||
WHERE conversation_id = $1
|
||||
ORDER BY created_at DESC LIMIT 1;
|
||||
-- name: set-next-sla-deadline
|
||||
UPDATE conversations c
|
||||
SET next_sla_deadline_at = CASE
|
||||
WHEN c.status_id IN (SELECT id from conversation_statuses where name in ('Resolved', 'Closed')) THEN NULL
|
||||
WHEN c.first_reply_at IS NOT NULL AND c.resolved_at IS NULL AND a.resolution_deadline_at IS NOT NULL THEN a.resolution_deadline_at
|
||||
WHEN c.first_reply_at IS NULL AND c.resolved_at IS NULL AND a.first_response_deadline_at IS NOT NULL THEN a.first_response_deadline_at
|
||||
WHEN a.first_response_deadline_at IS NOT NULL AND a.resolution_deadline_at IS NOT NULL THEN LEAST(a.first_response_deadline_at, a.resolution_deadline_at)
|
||||
ELSE NULL
|
||||
END
|
||||
FROM applied_slas a
|
||||
WHERE a.conversation_id = c.id
|
||||
AND c.id = $1;
|
||||
|
||||
-- name: update-sla-status
|
||||
UPDATE applied_slas
|
||||
SET
|
||||
status = CASE
|
||||
WHEN first_response_met_at IS NOT NULL AND resolution_met_at IS NOT NULL THEN 'met'::applied_sla_status
|
||||
WHEN first_response_breached_at IS NOT NULL AND resolution_breached_at IS NOT NULL THEN 'breached'::applied_sla_status
|
||||
WHEN (first_response_met_at IS NOT NULL OR first_response_breached_at IS NOT NULL)
|
||||
AND (resolution_met_at IS NOT NULL OR resolution_breached_at IS NOT NULL) THEN 'partially_met'::applied_sla_status
|
||||
WHEN first_response_met_at IS NULL AND first_response_breached_at IS NULL THEN 'pending'::applied_sla_status
|
||||
ELSE 'pending'::applied_sla_status
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE applied_slas.id = $1;
|
||||
|
||||
-- name: insert-scheduled-sla-notification
|
||||
INSERT INTO scheduled_sla_notifications (
|
||||
applied_sla_id,
|
||||
metric,
|
||||
notification_type,
|
||||
recipients,
|
||||
send_at
|
||||
) VALUES ($1, $2, $3, $4, $5);
|
||||
|
||||
-- name: get-scheduled-sla-notifications
|
||||
SELECT id, created_at, updated_at, applied_sla_id, metric, notification_type, recipients, send_at, processed_at
|
||||
FROM scheduled_sla_notifications
|
||||
WHERE send_at <= NOW() AND processed_at IS NULL;
|
||||
|
||||
-- name: get-applied-sla
|
||||
SELECT a.id,
|
||||
a.created_at,
|
||||
a.updated_at,
|
||||
a.conversation_id,
|
||||
a.sla_policy_id,
|
||||
a.first_response_deadline_at,
|
||||
a.resolution_deadline_at,
|
||||
a.first_response_met_at,
|
||||
a.resolution_met_at,
|
||||
a.first_response_breached_at,
|
||||
a.resolution_breached_at,
|
||||
a.status,
|
||||
c.first_reply_at as conversation_first_response_at,
|
||||
c.resolved_at as conversation_resolved_at,
|
||||
c.uuid as conversation_uuid,
|
||||
c.reference_number as conversation_reference_number,
|
||||
c.subject as conversation_subject,
|
||||
c.assigned_user_id as conversation_assigned_user_id
|
||||
FROM applied_slas a inner join conversations c on a.conversation_id = c.id
|
||||
WHERE a.id = $1;
|
||||
|
||||
-- name: mark-notification-processed
|
||||
UPDATE scheduled_sla_notifications
|
||||
SET processed_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1;
|
@@ -10,14 +10,19 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
businessHours "github.com/abhinavxd/libredesk/internal/business_hours"
|
||||
businesshours "github.com/abhinavxd/libredesk/internal/business_hours"
|
||||
bmodels "github.com/abhinavxd/libredesk/internal/business_hours/models"
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
notifier "github.com/abhinavxd/libredesk/internal/notification"
|
||||
models "github.com/abhinavxd/libredesk/internal/sla/models"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
tmodels "github.com/abhinavxd/libredesk/internal/team/models"
|
||||
"github.com/abhinavxd/libredesk/internal/template"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/jmoiron/sqlx/types"
|
||||
"github.com/lib/pq"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
@@ -28,17 +33,28 @@ var (
|
||||
)
|
||||
|
||||
const (
|
||||
SLATypeFirstResponse = "first_response"
|
||||
SLATypeResolution = "resolution"
|
||||
MetricFirstResponse = "first_response"
|
||||
MetricsResolution = "resolution"
|
||||
|
||||
NotificationTypeWarning = "warning"
|
||||
NotificationTypeBreach = "breach"
|
||||
)
|
||||
|
||||
var metricLabels = map[string]string{
|
||||
MetricFirstResponse: "First Response",
|
||||
MetricsResolution: "Resolution",
|
||||
}
|
||||
|
||||
// Manager manages SLA policies and calculations.
|
||||
type Manager struct {
|
||||
q queries
|
||||
lo *logf.Logger
|
||||
teamStore teamStore
|
||||
userStore userStore
|
||||
appSettingsStore appSettingsStore
|
||||
businessHrsStore businessHrsStore
|
||||
notifier *notifier.Service
|
||||
template *template.Manager
|
||||
wg sync.WaitGroup
|
||||
opts Opts
|
||||
}
|
||||
@@ -55,10 +71,20 @@ type Deadlines struct {
|
||||
Resolution time.Time
|
||||
}
|
||||
|
||||
// Breaches holds the breach timestamps for an SLA policy.
|
||||
type Breaches struct {
|
||||
FirstResponse time.Time
|
||||
Resolution time.Time
|
||||
}
|
||||
|
||||
type teamStore interface {
|
||||
Get(id int) (tmodels.Team, error)
|
||||
}
|
||||
|
||||
type userStore interface {
|
||||
GetAgent(int) (umodels.User, error)
|
||||
}
|
||||
|
||||
type appSettingsStore interface {
|
||||
GetByPrefix(prefix string) (types.JSONText, error)
|
||||
}
|
||||
@@ -69,31 +95,39 @@ type businessHrsStore interface {
|
||||
|
||||
// queries hold prepared SQL queries.
|
||||
type queries struct {
|
||||
GetSLA *sqlx.Stmt `query:"get-sla-policy"`
|
||||
GetAllSLA *sqlx.Stmt `query:"get-all-sla-policies"`
|
||||
InsertSLA *sqlx.Stmt `query:"insert-sla-policy"`
|
||||
DeleteSLA *sqlx.Stmt `query:"delete-sla-policy"`
|
||||
UpdateSLA *sqlx.Stmt `query:"update-sla-policy"`
|
||||
ApplySLA *sqlx.Stmt `query:"apply-sla"`
|
||||
GetPendingSLAs *sqlx.Stmt `query:"get-pending-slas"`
|
||||
UpdateBreach *sqlx.Stmt `query:"update-breach"`
|
||||
UpdateMet *sqlx.Stmt `query:"update-met"`
|
||||
GetLatestDeadlines *sqlx.Stmt `query:"get-latest-sla-deadlines"`
|
||||
GetSLA *sqlx.Stmt `query:"get-sla-policy"`
|
||||
GetAllSLA *sqlx.Stmt `query:"get-all-sla-policies"`
|
||||
GetAppliedSLA *sqlx.Stmt `query:"get-applied-sla"`
|
||||
GetScheduledSLANotifications *sqlx.Stmt `query:"get-scheduled-sla-notifications"`
|
||||
InsertScheduledSLANotification *sqlx.Stmt `query:"insert-scheduled-sla-notification"`
|
||||
InsertSLA *sqlx.Stmt `query:"insert-sla-policy"`
|
||||
DeleteSLA *sqlx.Stmt `query:"delete-sla-policy"`
|
||||
UpdateSLA *sqlx.Stmt `query:"update-sla-policy"`
|
||||
ApplySLA *sqlx.Stmt `query:"apply-sla"`
|
||||
GetPendingSLAs *sqlx.Stmt `query:"get-pending-slas"`
|
||||
UpdateBreach *sqlx.Stmt `query:"update-breach"`
|
||||
UpdateMet *sqlx.Stmt `query:"update-met"`
|
||||
SetNextSLADeadline *sqlx.Stmt `query:"set-next-sla-deadline"`
|
||||
UpdateSLAStatus *sqlx.Stmt `query:"update-sla-status"`
|
||||
MarkNotificationProcessed *sqlx.Stmt `query:"mark-notification-processed"`
|
||||
}
|
||||
|
||||
// New creates a new SLA manager.
|
||||
func New(opts Opts, teamStore teamStore, appSettingsStore appSettingsStore, businessHrsStore businessHrsStore) (*Manager, error) {
|
||||
func New(opts Opts, teamStore teamStore, appSettingsStore appSettingsStore, businessHrsStore businessHrsStore, notifier *notifier.Service, template *template.Manager, userStore userStore) (*Manager, error) {
|
||||
var q queries
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Manager{q: q, lo: opts.Lo, teamStore: teamStore, appSettingsStore: appSettingsStore, businessHrsStore: businessHrsStore, opts: opts}, nil
|
||||
return &Manager{q: q, lo: opts.Lo, teamStore: teamStore, appSettingsStore: appSettingsStore, businessHrsStore: businessHrsStore, notifier: notifier, template: template, userStore: userStore, opts: opts}, nil
|
||||
}
|
||||
|
||||
// Get retrieves an SLA by ID.
|
||||
func (m *Manager) Get(id int) (models.SLAPolicy, error) {
|
||||
var sla models.SLAPolicy
|
||||
if err := m.q.GetSLA.Get(&sla, id); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return sla, envelope.NewError(envelope.NotFoundError, "SLA not found", nil)
|
||||
}
|
||||
m.lo.Error("error fetching SLA", "error", err)
|
||||
return sla, envelope.NewError(envelope.GeneralError, "Error fetching SLA", nil)
|
||||
}
|
||||
@@ -111,14 +145,23 @@ func (m *Manager) GetAll() ([]models.SLAPolicy, error) {
|
||||
}
|
||||
|
||||
// Create creates a new SLA policy.
|
||||
func (m *Manager) Create(name, description string, firstResponseTime, resolutionTime string) error {
|
||||
if _, err := m.q.InsertSLA.Exec(name, description, firstResponseTime, resolutionTime); err != nil {
|
||||
func (m *Manager) Create(name, description string, firstResponseTime, resolutionTime string, notifications models.SlaNotifications) error {
|
||||
if _, err := m.q.InsertSLA.Exec(name, description, firstResponseTime, resolutionTime, notifications); err != nil {
|
||||
m.lo.Error("error inserting SLA", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error creating SLA", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates a SLA policy.
|
||||
func (m *Manager) Update(id int, name, description string, firstResponseTime, resolutionTime string, notifications models.SlaNotifications) error {
|
||||
if _, err := m.q.UpdateSLA.Exec(id, name, description, firstResponseTime, resolutionTime, notifications); err != nil {
|
||||
m.lo.Error("error updating SLA", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error updating SLA", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete deletes an SLA policy.
|
||||
func (m *Manager) Delete(id int) error {
|
||||
if _, err := m.q.DeleteSLA.Exec(id); err != nil {
|
||||
@@ -128,69 +171,8 @@ func (m *Manager) Delete(id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates an existing SLA policy.
|
||||
func (m *Manager) Update(id int, name, description string, firstResponseTime, resolutionTime string) error {
|
||||
if _, err := m.q.UpdateSLA.Exec(id, name, description, firstResponseTime, resolutionTime); err != nil {
|
||||
m.lo.Error("error updating SLA", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error updating SLA", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getBusinessHoursAndTimezone returns the business hours ID and timezone for a team, falling back to app settings.
|
||||
func (m *Manager) getBusinessHoursAndTimezone(assignedTeamID int) (bmodels.BusinessHours, string, error) {
|
||||
var (
|
||||
businessHrsID int
|
||||
timezone string
|
||||
bh bmodels.BusinessHours
|
||||
)
|
||||
|
||||
// Fetch from team if assignedTeamID is provided.
|
||||
if assignedTeamID != 0 {
|
||||
team, err := m.teamStore.Get(assignedTeamID)
|
||||
if err != nil {
|
||||
return bh, "", err
|
||||
}
|
||||
businessHrsID = team.BusinessHoursID.Int
|
||||
timezone = team.Timezone
|
||||
}
|
||||
|
||||
// Else fetch from app settings, this is System default.
|
||||
if businessHrsID == 0 || timezone == "" {
|
||||
settingsJ, err := m.appSettingsStore.GetByPrefix("app")
|
||||
if err != nil {
|
||||
return bh, "", err
|
||||
}
|
||||
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(settingsJ), &out); err != nil {
|
||||
return bh, "", fmt.Errorf("parsing settings: %v", err)
|
||||
}
|
||||
|
||||
businessHrsIDStr, _ := out["app.business_hours_id"].(string)
|
||||
businessHrsID, _ = strconv.Atoi(businessHrsIDStr)
|
||||
timezone, _ = out["app.timezone"].(string)
|
||||
}
|
||||
|
||||
// If still not found, return error.
|
||||
if businessHrsID == 0 || timezone == "" {
|
||||
return bh, "", fmt.Errorf("business hours or timezone not configured")
|
||||
}
|
||||
|
||||
bh, err := m.businessHrsStore.Get(businessHrsID)
|
||||
if err != nil {
|
||||
if err == businessHours.ErrBusinessHoursNotFound {
|
||||
m.lo.Warn("business hours not found", "team_id", assignedTeamID)
|
||||
return bh, "", fmt.Errorf("business hours not found")
|
||||
}
|
||||
m.lo.Error("error fetching business hours for SLA", "error", err)
|
||||
return bh, "", err
|
||||
}
|
||||
return bh, timezone, nil
|
||||
}
|
||||
|
||||
// CalculateDeadline calculates the deadline for a given start time and duration.
|
||||
func (m *Manager) CalculateDeadlines(startTime time.Time, slaPolicyID, assignedTeamID int) (Deadlines, error) {
|
||||
// GetDeadlines returns the deadline for a given start time, sla policy and assigned team.
|
||||
func (m *Manager) GetDeadlines(startTime time.Time, slaPolicyID, assignedTeamID int) (Deadlines, error) {
|
||||
var deadlines Deadlines
|
||||
|
||||
businessHrs, timezone, err := m.getBusinessHoursAndTimezone(assignedTeamID)
|
||||
@@ -230,47 +212,48 @@ func (m *Manager) CalculateDeadlines(startTime time.Time, slaPolicyID, assignedT
|
||||
return deadlines, nil
|
||||
}
|
||||
|
||||
// ApplySLA applies an SLA policy to a conversation.
|
||||
// ApplySLA applies an SLA policy to a conversation by calculating and setting the deadlines.
|
||||
func (m *Manager) ApplySLA(startTime time.Time, conversationID, assignedTeamID, slaPolicyID int) (models.SLAPolicy, error) {
|
||||
var sla models.SLAPolicy
|
||||
|
||||
deadlines, err := m.CalculateDeadlines(startTime, slaPolicyID, assignedTeamID)
|
||||
// Get deadlines for the SLA policy and assigned team.
|
||||
deadlines, err := m.GetDeadlines(startTime, slaPolicyID, assignedTeamID)
|
||||
if err != nil {
|
||||
return sla, err
|
||||
}
|
||||
if _, err := m.q.ApplySLA.Exec(
|
||||
|
||||
// Insert applied SLA entry.
|
||||
var appliedSLAID int
|
||||
if err := m.q.ApplySLA.QueryRowx(
|
||||
conversationID,
|
||||
slaPolicyID,
|
||||
deadlines.FirstResponse,
|
||||
deadlines.Resolution,
|
||||
); err != nil {
|
||||
).Scan(&appliedSLAID); err != nil {
|
||||
m.lo.Error("error applying SLA", "error", err)
|
||||
return sla, envelope.NewError(envelope.GeneralError, "Error applying SLA", nil)
|
||||
}
|
||||
|
||||
sla, err = m.Get(slaPolicyID)
|
||||
if err != nil {
|
||||
return sla, err
|
||||
}
|
||||
return sla, nil
|
||||
}
|
||||
|
||||
// GetLatestDeadlines returns the latest deadlines for a conversation.
|
||||
func (m *Manager) GetLatestDeadlines(conversationID int) (time.Time, time.Time, error) {
|
||||
var first, resolution time.Time
|
||||
err := m.q.GetLatestDeadlines.QueryRow(conversationID).Scan(&first, &resolution)
|
||||
if err == sql.ErrNoRows {
|
||||
return first, resolution, nil
|
||||
}
|
||||
return first, resolution, err
|
||||
// Schedule SLA notifications if there are any, SLA breaches did not happen yet as this is the first time SLA is applied.
|
||||
// So, only schedule SLA breach warnings.
|
||||
m.createNotificationSchedule(sla.Notifications, appliedSLAID, deadlines, Breaches{})
|
||||
|
||||
return sla, nil
|
||||
}
|
||||
|
||||
// Run starts the SLA evaluation loop and evaluates pending SLAs.
|
||||
func (m *Manager) Run(ctx context.Context, evalInterval time.Duration) {
|
||||
m.wg.Add(1)
|
||||
defer m.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(evalInterval)
|
||||
defer ticker.Stop()
|
||||
m.wg.Add(1)
|
||||
defer func() {
|
||||
m.wg.Done()
|
||||
ticker.Stop()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -284,14 +267,296 @@ func (m *Manager) Run(ctx context.Context, evalInterval time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// SendNotifications picks scheduled SLA notifications from the database and sends them to agents as emails.
|
||||
func (m *Manager) SendNotifications(ctx context.Context) error {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
var notifications []models.ScheduledSLANotification
|
||||
if err := m.q.GetScheduledSLANotifications.SelectContext(ctx, ¬ifications); err != nil {
|
||||
if err == ctx.Err() {
|
||||
return err
|
||||
}
|
||||
m.lo.Error("error fetching scheduled SLA notifications", "error", err)
|
||||
} else {
|
||||
m.lo.Debug("found scheduled SLA notifications", "count", len(notifications))
|
||||
for _, notification := range notifications {
|
||||
// Exit early if context is done.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
if err := m.SendNotification(notification); err != nil {
|
||||
m.lo.Error("error sending notification", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(notifications) > 0 {
|
||||
m.lo.Debug("sent SLA notifications", "count", len(notifications))
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep for short duration to avoid hammering the database.
|
||||
time.Sleep(30 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendNotification sends a SLA notification to agents.
|
||||
func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANotification) error {
|
||||
var appliedSLA models.AppliedSLA
|
||||
if err := m.q.GetAppliedSLA.Get(&appliedSLA, scheduledNotification.AppliedSLAID); err != nil {
|
||||
m.lo.Error("error fetching applied SLA", "error", err)
|
||||
return fmt.Errorf("fetching applied SLA for notification: %w", err)
|
||||
}
|
||||
|
||||
// Send to all recipients (agents).
|
||||
for _, recipientS := range scheduledNotification.Recipients {
|
||||
// Check if SLA is already met, if met for the metric, skip the notification and mark the notification as processed.
|
||||
switch scheduledNotification.Metric {
|
||||
case MetricFirstResponse:
|
||||
if appliedSLA.FirstResponseMetAt.Valid {
|
||||
m.lo.Debug("skipping notification as first response is already met", "applied_sla_id", appliedSLA.ID)
|
||||
if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil {
|
||||
m.lo.Error("error marking notification as processed", "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
case MetricsResolution:
|
||||
if appliedSLA.ResolutionMetAt.Valid {
|
||||
m.lo.Debug("skipping notification as resolution is already met", "applied_sla_id", appliedSLA.ID)
|
||||
if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil {
|
||||
m.lo.Error("error marking notification as processed", "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
default:
|
||||
m.lo.Error("unknown metric type", "metric", scheduledNotification.Metric)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get recipient agent, recipient can be a specific agent or assigned user.
|
||||
recipientID, err := strconv.Atoi(recipientS)
|
||||
if recipientS == "assigned_user" {
|
||||
recipientID = appliedSLA.ConversationAssignedUserID.Int
|
||||
} else if err != nil {
|
||||
m.lo.Error("error parsing recipient ID", "error", err, "recipient_id", recipientS)
|
||||
continue
|
||||
}
|
||||
agent, err := m.userStore.GetAgent(recipientID)
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching agent for SLA notification", "recipient_id", recipientID, "error", err)
|
||||
if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil {
|
||||
m.lo.Error("error marking notification as processed", "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
dueIn, overdueBy string
|
||||
tmpl string
|
||||
)
|
||||
// Set the template based on the notification type.
|
||||
switch scheduledNotification.NotificationType {
|
||||
case NotificationTypeBreach:
|
||||
tmpl = template.TmplSLABreached
|
||||
case NotificationTypeWarning:
|
||||
tmpl = template.TmplSLABreachWarning
|
||||
default:
|
||||
m.lo.Error("unknown notification type", "notification_type", scheduledNotification.NotificationType)
|
||||
return fmt.Errorf("unknown notification type: %s", scheduledNotification.NotificationType)
|
||||
}
|
||||
|
||||
// Set the dueIn and overdueBy values based on the metric.
|
||||
// These are relative to the current time as setting exact time would require agent's timezone.
|
||||
getFriendlyDuration := func(target time.Time) string {
|
||||
d := time.Until(target)
|
||||
if d < 0 {
|
||||
return "Overdue by " + stringutil.FormatDuration(-d, false)
|
||||
}
|
||||
return stringutil.FormatDuration(d, false)
|
||||
}
|
||||
|
||||
switch scheduledNotification.Metric {
|
||||
case MetricFirstResponse:
|
||||
dueIn = getFriendlyDuration(appliedSLA.FirstResponseDeadlineAt)
|
||||
overdueBy = getFriendlyDuration(appliedSLA.FirstResponseBreachedAt.Time)
|
||||
case MetricsResolution:
|
||||
dueIn = getFriendlyDuration(appliedSLA.ResolutionDeadlineAt)
|
||||
overdueBy = getFriendlyDuration(appliedSLA.ResolutionBreachedAt.Time)
|
||||
default:
|
||||
m.lo.Error("unknown metric type", "metric", scheduledNotification.Metric)
|
||||
return fmt.Errorf("unknown metric type: %s", scheduledNotification.Metric)
|
||||
}
|
||||
|
||||
// Set the metric label.
|
||||
var metricLabel string
|
||||
if label, ok := metricLabels[scheduledNotification.Metric]; ok {
|
||||
metricLabel = label
|
||||
}
|
||||
|
||||
// Render the email template.
|
||||
content, subject, err := m.template.RenderStoredEmailTemplate(tmpl,
|
||||
map[string]any{
|
||||
"SLA": map[string]any{
|
||||
"DueIn": dueIn,
|
||||
"OverdueBy": overdueBy,
|
||||
"Metric": metricLabel,
|
||||
},
|
||||
"Conversation": map[string]any{
|
||||
"ReferenceNumber": appliedSLA.ConversationReferenceNumber,
|
||||
"Subject": appliedSLA.ConversationSubject,
|
||||
"Priority": "",
|
||||
"UUID": appliedSLA.ConversationUUID,
|
||||
},
|
||||
"Agent": map[string]any{
|
||||
"FirstName": agent.FirstName,
|
||||
"LastName": agent.LastName,
|
||||
"FullName": agent.FullName(),
|
||||
"Email": agent.Email,
|
||||
},
|
||||
"Recipient": map[string]any{
|
||||
"FirstName": agent.FirstName,
|
||||
"LastName": agent.LastName,
|
||||
"FullName": agent.FullName(),
|
||||
"Email": agent.Email,
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
m.lo.Error("error rendering email template", "template", template.TmplConversationAssigned, "scheduled_notification_id", scheduledNotification.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Enqueue email notification.
|
||||
if err := m.notifier.Send(notifier.Message{
|
||||
RecipientEmails: []string{
|
||||
agent.Email.String,
|
||||
},
|
||||
Subject: subject,
|
||||
Content: content,
|
||||
Provider: notifier.ProviderEmail,
|
||||
}); err != nil {
|
||||
m.lo.Error("error sending email notification", "error", err)
|
||||
}
|
||||
|
||||
// Set the notification as processed.
|
||||
if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil {
|
||||
m.lo.Error("error marking notification as processed", "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the SLA evaluation loop by stopping the worker pool.
|
||||
func (m *Manager) Close() error {
|
||||
m.wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
// evaluatePendingSLAs fetches unbreached SLAs and evaluates them.
|
||||
// Here evaluation means checking if the SLA deadlines have been met or breached and updating timestamps accordingly.
|
||||
// getBusinessHoursAndTimezone returns the business hours ID and timezone for a team, falling back to app settings i.e. default helpdesk settings.
|
||||
func (m *Manager) getBusinessHoursAndTimezone(assignedTeamID int) (bmodels.BusinessHours, string, error) {
|
||||
var (
|
||||
businessHrsID int
|
||||
timezone string
|
||||
bh bmodels.BusinessHours
|
||||
)
|
||||
|
||||
// Fetch from team if assignedTeamID is provided.
|
||||
if assignedTeamID != 0 {
|
||||
team, err := m.teamStore.Get(assignedTeamID)
|
||||
if err == nil {
|
||||
businessHrsID = team.BusinessHoursID.Int
|
||||
timezone = team.Timezone
|
||||
}
|
||||
}
|
||||
|
||||
// Else fetch from app settings, this is System default.
|
||||
if businessHrsID == 0 || timezone == "" {
|
||||
settingsJ, err := m.appSettingsStore.GetByPrefix("app")
|
||||
if err != nil {
|
||||
return bh, "", err
|
||||
}
|
||||
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(settingsJ), &out); err != nil {
|
||||
return bh, "", fmt.Errorf("parsing settings: %v", err)
|
||||
}
|
||||
|
||||
businessHrsIDStr, _ := out["app.business_hours_id"].(string)
|
||||
businessHrsID, _ = strconv.Atoi(businessHrsIDStr)
|
||||
timezone, _ = out["app.timezone"].(string)
|
||||
}
|
||||
|
||||
// If still not found, return error.
|
||||
if businessHrsID == 0 || timezone == "" {
|
||||
return bh, "", fmt.Errorf("business hours or timezone not configured")
|
||||
}
|
||||
|
||||
bh, err := m.businessHrsStore.Get(businessHrsID)
|
||||
if err != nil {
|
||||
if err == businesshours.ErrBusinessHoursNotFound {
|
||||
m.lo.Warn("business hours not found", "team_id", assignedTeamID)
|
||||
return bh, "", fmt.Errorf("business hours not found")
|
||||
}
|
||||
m.lo.Error("error fetching business hours for SLA", "error", err)
|
||||
return bh, "", err
|
||||
}
|
||||
return bh, timezone, nil
|
||||
}
|
||||
|
||||
// createNotificationSchedule creates a notification schedule in database for the applied SLA.
|
||||
func (m *Manager) createNotificationSchedule(notifications models.SlaNotifications, appliedSLAID int, deadlines Deadlines, breaches Breaches) {
|
||||
scheduleNotification := func(sendAt time.Time, metric, notifType string, recipients []string) {
|
||||
if sendAt.Before(time.Now().Add(-5 * time.Minute)) {
|
||||
m.lo.Debug("skipping scheduling notification as it is in the past", "send_at", sendAt)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := m.q.InsertScheduledSLANotification.Exec(appliedSLAID, metric, notifType, pq.Array(recipients), sendAt); err != nil {
|
||||
m.lo.Error("error inserting scheduled SLA notification", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert scheduled entries for each notification.
|
||||
for _, notif := range notifications {
|
||||
var (
|
||||
delayDur time.Duration
|
||||
err error
|
||||
)
|
||||
|
||||
// No delay for immediate notifications.
|
||||
if notif.TimeDelayType == "immediately" {
|
||||
delayDur = 0
|
||||
} else {
|
||||
delayDur, err = time.ParseDuration(notif.TimeDelay)
|
||||
if err != nil {
|
||||
m.lo.Error("error parsing sla notification delay", "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if notif.Type == NotificationTypeWarning {
|
||||
if !deadlines.FirstResponse.IsZero() {
|
||||
scheduleNotification(deadlines.FirstResponse.Add(-delayDur), MetricFirstResponse, notif.Type, notif.Recipients)
|
||||
}
|
||||
if !deadlines.Resolution.IsZero() {
|
||||
scheduleNotification(deadlines.Resolution.Add(-delayDur), MetricsResolution, notif.Type, notif.Recipients)
|
||||
}
|
||||
} else if notif.Type == NotificationTypeBreach {
|
||||
if !breaches.FirstResponse.IsZero() {
|
||||
scheduleNotification(breaches.FirstResponse.Add(delayDur), MetricFirstResponse, notif.Type, notif.Recipients)
|
||||
}
|
||||
if !breaches.Resolution.IsZero() {
|
||||
scheduleNotification(breaches.Resolution.Add(delayDur), MetricsResolution, notif.Type, notif.Recipients)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evaluatePendingSLAs fetches pending SLAs and evaluates them, pending SLAs are applied SLAs that have not breached or met yet.
|
||||
func (m *Manager) evaluatePendingSLAs(ctx context.Context) error {
|
||||
var pendingSLAs []models.AppliedSLA
|
||||
if err := m.q.GetPendingSLAs.SelectContext(ctx, &pendingSLAs); err != nil {
|
||||
@@ -315,24 +580,31 @@ func (m *Manager) evaluatePendingSLAs(ctx context.Context) error {
|
||||
|
||||
// evaluateSLA evaluates an SLA policy on an applied SLA.
|
||||
func (m *Manager) evaluateSLA(sla models.AppliedSLA) error {
|
||||
now := time.Now()
|
||||
checkDeadline := func(deadline time.Time, metAt null.Time, slaType string) error {
|
||||
m.lo.Debug("evaluating SLA", "conversation_id", sla.ConversationID, "applied_sla_id", sla.ID)
|
||||
checkDeadline := func(deadline time.Time, metAt null.Time, metric string) error {
|
||||
if deadline.IsZero() {
|
||||
m.lo.Warn("deadline zero, skipping checking the deadline")
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if !metAt.Valid && now.After(deadline) {
|
||||
if _, err := m.q.UpdateBreach.Exec(sla.ID, slaType); err != nil {
|
||||
return fmt.Errorf("updating SLA breach: %w", err)
|
||||
m.lo.Debug("SLA breached as current time is after deadline", "deadline", deadline, "now", now, "metric", metric)
|
||||
if err := m.updateBreachAt(sla.ID, sla.SLAPolicyID, metric); err != nil {
|
||||
return fmt.Errorf("updating SLA breach timestamp: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if metAt.Valid {
|
||||
if metAt.Time.After(deadline) {
|
||||
if _, err := m.q.UpdateBreach.Exec(sla.ID, slaType); err != nil {
|
||||
m.lo.Debug("SLA breached as met_at is after deadline", "deadline", deadline, "met_at", metAt.Time, "metric", metric)
|
||||
if err := m.updateBreachAt(sla.ID, sla.SLAPolicyID, metric); err != nil {
|
||||
return fmt.Errorf("updating SLA breach: %w", err)
|
||||
}
|
||||
} else {
|
||||
if _, err := m.q.UpdateMet.Exec(sla.ID, slaType); err != nil {
|
||||
m.lo.Debug("SLA type met", "deadline", deadline, "met_at", metAt.Time, "metric", metric)
|
||||
if _, err := m.q.UpdateMet.Exec(sla.ID, metric); err != nil {
|
||||
return fmt.Errorf("updating SLA met: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -340,11 +612,60 @@ func (m *Manager) evaluateSLA(sla models.AppliedSLA) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := checkDeadline(sla.FirstResponseDeadlineAt, sla.FirstResponseAt, SLATypeFirstResponse); err != nil {
|
||||
return err
|
||||
// If first response is not breached and not met, check the deadline and set them.
|
||||
if !sla.FirstResponseBreachedAt.Valid && !sla.FirstResponseMetAt.Valid {
|
||||
m.lo.Debug("checking deadline", "deadline", sla.FirstResponseDeadlineAt, "met_at", sla.ConversationFirstResponseAt.Time, "metric", MetricFirstResponse)
|
||||
if err := checkDeadline(sla.FirstResponseDeadlineAt, sla.ConversationFirstResponseAt, MetricFirstResponse); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := checkDeadline(sla.ResolutionDeadlineAt, sla.ResolvedAt, SLATypeResolution); err != nil {
|
||||
return err
|
||||
|
||||
// If resolution is not breached and not met, check the deadine and set them.
|
||||
if !sla.ResolutionBreachedAt.Valid && !sla.ResolutionMetAt.Valid {
|
||||
m.lo.Debug("checking deadline", "deadline", sla.ResolutionDeadlineAt, "met_at", sla.ConversationResolvedAt.Time, "metric", MetricsResolution)
|
||||
if err := checkDeadline(sla.ResolutionDeadlineAt, sla.ConversationResolvedAt, MetricsResolution); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Update the conversation next SLA deadline.
|
||||
if _, err := m.q.SetNextSLADeadline.Exec(sla.ConversationID); err != nil {
|
||||
return fmt.Errorf("setting conversation next SLA deadline: %w", err)
|
||||
}
|
||||
|
||||
// Update status of applied SLA.
|
||||
if _, err := m.q.UpdateSLAStatus.Exec(sla.ID); err != nil {
|
||||
return fmt.Errorf("updating applied SLA status: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateBreachAt updates the breach timestamp for an SLA.
|
||||
func (m *Manager) updateBreachAt(appliedSLAID, slaPolicyID int, metric string) error {
|
||||
if _, err := m.q.UpdateBreach.Exec(appliedSLAID, metric); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Schedule notification for the breach if there are any.
|
||||
sla, err := m.Get(slaPolicyID)
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching SLA for scheduling breach notification", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
var firstResponse, resolution time.Time
|
||||
if metric == MetricFirstResponse {
|
||||
firstResponse = time.Now()
|
||||
} else if metric == MetricsResolution {
|
||||
resolution = time.Now()
|
||||
}
|
||||
|
||||
// Create notification schedule.
|
||||
m.createNotificationSchedule(sla.Notifications, appliedSLAID, Deadlines{}, Breaches{
|
||||
FirstResponse: firstResponse,
|
||||
Resolution: resolution,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@@ -160,3 +160,25 @@ func RemoveItemByValue(slice []string, value string) []string {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FormatDuration formats a duration as a string.
|
||||
func FormatDuration(d time.Duration, includeSeconds bool) string {
|
||||
d = d.Round(time.Second)
|
||||
h := int64(d.Hours())
|
||||
d -= time.Duration(h) * time.Hour
|
||||
m := int64(d.Minutes())
|
||||
d -= time.Duration(m) * time.Minute
|
||||
s := int64(d.Seconds())
|
||||
|
||||
var parts []string
|
||||
if h > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d hours", h))
|
||||
}
|
||||
if m >= 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d minutes", m))
|
||||
}
|
||||
if s > 0 && includeSeconds {
|
||||
parts = append(parts, fmt.Sprintf("%d seconds", s))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
@@ -63,6 +63,9 @@ func (t *Manager) GetAll() ([]models.Tag, error) {
|
||||
// Create creates a new tag.
|
||||
func (t *Manager) Create(name string) error {
|
||||
if _, err := t.q.InsertTag.Exec(name); err != nil {
|
||||
if dbutil.IsUniqueViolationError(err) {
|
||||
return envelope.NewError(envelope.ConflictError, "Tag already exists", nil)
|
||||
}
|
||||
t.lo.Error("error inserting tag", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error creating tag", nil)
|
||||
}
|
||||
|
@@ -15,7 +15,7 @@ SELECT u.id, t.id as team_id
|
||||
FROM users u
|
||||
JOIN team_members tm ON tm.user_id = u.id
|
||||
JOIN teams t ON t.id = tm.team_id
|
||||
WHERE t.id = $1;
|
||||
WHERE t.id = $1 AND u.deleted_at IS NULL AND u.type = 'agent' AND u.enabled = true;
|
||||
|
||||
-- name: insert-team
|
||||
INSERT INTO teams (name, timezone, conversation_assignment_type, business_hours_id, sla_policy_id, emoji, max_auto_assigned_conversations) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id;
|
||||
|
@@ -12,6 +12,8 @@ import (
|
||||
const (
|
||||
// Built-in templates names stored in the database.
|
||||
TmplConversationAssigned = "Conversation assigned"
|
||||
TmplSLABreachWarning = "SLA breach warning"
|
||||
TmplSLABreached = "SLA breached"
|
||||
|
||||
// Built-in templates fetched from memory stored in `static` directory.
|
||||
TmplResetPassword = "reset-password"
|
||||
@@ -29,12 +31,11 @@ func (m *Manager) RenderEmailWithTemplate(data any, content string) (string, err
|
||||
|
||||
defaultTmpl, err := m.getDefaultOutgoingEmailTemplate()
|
||||
if err != nil {
|
||||
if err == ErrTemplateNotFound {
|
||||
m.lo.Warn("default outgoing email template not found, rendering content without any template")
|
||||
return content, nil
|
||||
}
|
||||
m.lo.Error("error fetching default outgoing email template", "error", err)
|
||||
return "", fmt.Errorf("fetching default outgoing email template: %w", err)
|
||||
}
|
||||
|
||||
if defaultTmpl.Body == "" {
|
||||
defaultTmpl.Body = `{{ template "content" . }}`
|
||||
}
|
||||
|
||||
baseTemplate, err := template.New(TmplBase).Funcs(m.funcMap).Parse(defaultTmpl.Body)
|
||||
@@ -70,18 +71,6 @@ func (m *Manager) RenderStoredEmailTemplate(name string, data any) (string, stri
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
executeContentTemplate := func(tmplBody string) (string, error) {
|
||||
var sb strings.Builder
|
||||
t, err := template.New(name).Funcs(m.funcMap).Parse(tmplBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parsing content template: %w", err)
|
||||
}
|
||||
if err := t.Execute(&sb, data); err != nil {
|
||||
return "", fmt.Errorf("executing content template: %w", err)
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
executeSubjectTemplate := func(subject string) (string, error) {
|
||||
var sb strings.Builder
|
||||
subjectTmpl, err := template.New("subject").Funcs(m.funcMap).Parse(subject)
|
||||
@@ -96,19 +85,11 @@ func (m *Manager) RenderStoredEmailTemplate(name string, data any) (string, stri
|
||||
|
||||
defaultTmpl, err := m.getDefaultOutgoingEmailTemplate()
|
||||
if err != nil {
|
||||
if err == ErrTemplateNotFound {
|
||||
m.lo.Warn("default outgoing email template not found, rendering content any template")
|
||||
content, err := executeContentTemplate(tmpl.Body)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
subject, err := executeSubjectTemplate(tmpl.Subject.String)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return content, subject, nil
|
||||
}
|
||||
return "", "", err
|
||||
m.lo.Error("error fetching default outgoing email template", "error", err)
|
||||
}
|
||||
|
||||
if defaultTmpl.Body == "" {
|
||||
defaultTmpl.Body = `{{ template "content" . }}`
|
||||
}
|
||||
|
||||
baseTemplate, err := template.New(TmplBase).Funcs(m.funcMap).Parse(defaultTmpl.Body)
|
||||
|
@@ -5,9 +5,13 @@ WHERE u.email != 'System' AND u.deleted_at IS NULL AND u.type = 'agent'
|
||||
ORDER BY u.updated_at DESC;
|
||||
|
||||
-- name: soft-delete-user
|
||||
UPDATE users
|
||||
SET deleted_at = now(), updated_at = now()
|
||||
WHERE id = $1 AND type = 'agent';
|
||||
WITH soft_delete AS (
|
||||
UPDATE users
|
||||
SET deleted_at = now(), updated_at = now()
|
||||
WHERE id = $1 AND type = 'agent'
|
||||
RETURNING id
|
||||
)
|
||||
DELETE FROM team_members WHERE user_id IN (SELECT id FROM soft_delete);
|
||||
|
||||
-- name: get-users-compact
|
||||
SELECT u.id, u.first_name, u.last_name, u.enabled, u.avatar_url
|
||||
|
@@ -175,7 +175,7 @@ func (u *Manager) Get(id int, type_ string) (models.User, error) {
|
||||
if err := u.q.GetUser.Get(&user, id, "", type_); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
u.lo.Error("user not found", "id", id, "error", err)
|
||||
return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
|
||||
return user, envelope.NewError(envelope.NotFoundError, "User not found", nil)
|
||||
}
|
||||
u.lo.Error("error fetching user from db", "error", err)
|
||||
return user, envelope.NewError(envelope.GeneralError, "Error fetching user", nil)
|
||||
@@ -435,7 +435,7 @@ func CreateSystemUser(ctx context.Context, password string, db *sqlx.DB) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create system user: %v", err)
|
||||
}
|
||||
log.Print("system user created successfully")
|
||||
log.Print("system user created successfully. Use command 'libredesk --set-system-user-password' to set the password and login with email 'System'.")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
94
schema.sql
94
schema.sql
@@ -14,6 +14,9 @@ DROP TYPE IF EXISTS "macro_visibility" CASCADE; CREATE TYPE "macro_visibility" A
|
||||
DROP TYPE IF EXISTS "media_disposition" CASCADE; CREATE TYPE "media_disposition" AS ENUM ('inline', 'attachment');
|
||||
DROP TYPE IF EXISTS "media_store" CASCADE; CREATE TYPE "media_store" AS ENUM ('s3', 'fs');
|
||||
DROP TYPE IF EXISTS "user_availability_status" CASCADE; CREATE TYPE "user_availability_status" AS ENUM ('online', 'away', 'away_manual', 'offline');
|
||||
DROP TYPE IF EXISTS "applied_sla_status" CASCADE; CREATE TYPE "applied_sla_status" AS ENUM ('pending', 'breached', 'met', 'partially_met');
|
||||
DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('first_response', 'resolution');
|
||||
DROP TYPE IF EXISTS "sla_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach');
|
||||
|
||||
-- Sequence to generate reference number for conversations.
|
||||
DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100;
|
||||
@@ -35,6 +38,7 @@ CREATE TABLE sla_policies (
|
||||
description TEXT NULL,
|
||||
first_response_time TEXT NOT NULL,
|
||||
resolution_time TEXT NOT NULL,
|
||||
notifications JSONB DEFAULT '[]'::jsonb NOT NULL,
|
||||
CONSTRAINT constraint_sla_policies_on_name CHECK (length(name) <= 140),
|
||||
CONSTRAINT constraint_sla_policies_on_description CHECK (length(description) <= 300)
|
||||
);
|
||||
@@ -431,6 +435,8 @@ CREATE TABLE applied_slas (
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
status applied_sla_status DEFAULT 'pending' NOT NULL,
|
||||
|
||||
-- Conversation / SLA policy maybe deleted but for reports the applied SLA should remain.
|
||||
conversation_id BIGINT REFERENCES conversations(id) ON DELETE SET NULL ON UPDATE CASCADE NOT NULL,
|
||||
sla_policy_id INT REFERENCES sla_policies(id) ON DELETE SET NULL ON UPDATE CASCADE NOT NULL,
|
||||
@@ -443,6 +449,22 @@ CREATE TABLE applied_slas (
|
||||
resolution_met_at TIMESTAMPTZ NULL
|
||||
);
|
||||
CREATE INDEX index_applied_slas_on_conversation_id ON applied_slas(conversation_id);
|
||||
CREATE INDEX index_applied_slas_on_status ON applied_slas(status);
|
||||
|
||||
DROP TABLE IF EXISTS scheduled_sla_notifications CASCADE;
|
||||
CREATE TABLE scheduled_sla_notifications (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
applied_sla_id BIGINT NOT NULL REFERENCES applied_slas(id) ON DELETE CASCADE,
|
||||
metric sla_metric NOT NULL,
|
||||
notification_type sla_notification_type NOT NULL,
|
||||
recipients TEXT[] NOT NULL,
|
||||
send_at TIMESTAMPTZ NOT NULL,
|
||||
processed_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX index_scheduled_sla_notifications_on_send_at ON scheduled_sla_notifications(send_at);
|
||||
CREATE INDEX index_scheduled_sla_notifications_on_processed_at ON scheduled_sla_notifications(processed_at);
|
||||
|
||||
DROP TABLE IF EXISTS ai_providers CASCADE;
|
||||
CREATE TABLE ai_providers (
|
||||
@@ -494,7 +516,7 @@ VALUES
|
||||
('app.favicon_url', '"http://localhost:9000/favicon.ico"'::jsonb),
|
||||
('app.max_file_upload_size', '20'::jsonb),
|
||||
('app.allowed_file_upload_extensions', '["*"]'::jsonb),
|
||||
('app.timezone', '"Asia/Calcutta"'::jsonb),
|
||||
('app.timezone', '"Asia/Kolkata"'::jsonb),
|
||||
('app.business_hours_id', '""'::jsonb),
|
||||
('notification.email.username', '"admin@yourcompany.com"'::jsonb),
|
||||
('notification.email.host', '"smtp.gmail.com"'::jsonb),
|
||||
@@ -504,6 +526,9 @@ VALUES
|
||||
('notification.email.idle_timeout', '"5s"'::jsonb),
|
||||
('notification.email.wait_timeout', '"5s"'::jsonb),
|
||||
('notification.email.auth_protocol', '"plain"'::jsonb),
|
||||
('notification.email.tls_type', '"starttls"'::jsonb),
|
||||
('notification.email.tls_skip_verify', 'false'::jsonb),
|
||||
('notification.email.hello_hostname', '""'::jsonb),
|
||||
('notification.email.email_address', '"admin@yourcompany.com"'::jsonb),
|
||||
('notification.email.max_msg_retries', '3'::jsonb),
|
||||
('notification.email.enabled', 'false'::jsonb);
|
||||
@@ -545,8 +570,6 @@ VALUES
|
||||
INSERT INTO templates
|
||||
("type", body, is_default, "name", subject, is_builtin)
|
||||
VALUES('email_notification'::template_type, '
|
||||
<p>Hi {{ .Agent.FirstName }},</p>
|
||||
|
||||
<p>A new conversation has been assigned to you:</p>
|
||||
|
||||
<div>
|
||||
@@ -563,4 +586,67 @@ VALUES('email_notification'::template_type, '
|
||||
Libredesk
|
||||
</div>
|
||||
|
||||
', false, 'Conversation assigned', 'New conversation assigned to you', true);
|
||||
', false, 'Conversation assigned', 'New conversation assigned to you', true);
|
||||
|
||||
INSERT INTO templates
|
||||
("type", body, is_default, "name", subject, is_builtin)
|
||||
VALUES (
|
||||
'email_notification'::template_type,
|
||||
'
|
||||
|
||||
<p>This is a notification that the SLA for conversation {{ .Conversation.ReferenceNumber }} is approaching the SLA deadline for {{ .SLA.Metric }}.</p>
|
||||
|
||||
<p>
|
||||
Details:<br>
|
||||
- Conversation reference number: {{ .Conversation.ReferenceNumber }}<br>
|
||||
- Metric: {{ .SLA.Metric }}<br>
|
||||
- Due in: {{ .SLA.DueIn }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
Libredesk
|
||||
</p>
|
||||
|
||||
',
|
||||
false,
|
||||
'SLA breach warning',
|
||||
'SLA Alert: Conversation {{ .Conversation.ReferenceNumber }} is approaching SLA deadline for {{ .SLA.Metric }}',
|
||||
true
|
||||
);
|
||||
|
||||
INSERT INTO templates
|
||||
("type", body, is_default, "name", subject, is_builtin)
|
||||
VALUES (
|
||||
'email_notification'::template_type,
|
||||
'
|
||||
<p>This is an urgent alert that the SLA for conversation {{ .Conversation.ReferenceNumber }} has been breached for {{ .SLA.Metric }}. Please take immediate action.</p>
|
||||
|
||||
<p>
|
||||
Details:<br>
|
||||
- Conversation reference number: {{ .Conversation.ReferenceNumber }}<br>
|
||||
- Metric: {{ .SLA.Metric }}<br>
|
||||
- Overdue by: {{ .SLA.OverdueBy }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
Libredesk
|
||||
</p>
|
||||
|
||||
',
|
||||
false,
|
||||
'SLA breached',
|
||||
'Urgent: SLA Breach for Conversation {{ .Conversation.ReferenceNumber }} for {{ .SLA.Metric }}',
|
||||
true
|
||||
);
|
||||
|
Reference in New Issue
Block a user