mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-11-03 21:43:35 +00:00 
			
		
		
		
	Compare commits
	
		
			90 Commits
		
	
	
		
			v0.4.0-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 | ||
| 
						 | 
					36d91de8f7 | ||
| 
						 | 
					57c1948379 | ||
| 
						 | 
					772152c40c | ||
| 
						 | 
					8e15d733ea | ||
| 
						 | 
					fc47e65fcb | ||
| 
						 | 
					760be37eda | 
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@ Open source, self-hosted customer support desk. Single binary app.
 | 
			
		||||
 | 
			
		||||
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10
									
								
								cmd/main.go
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								cmd/main.go
									
									
									
									
									
								
							@@ -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)
 | 
			
		||||
 | 
			
		||||
@@ -235,7 +237,7 @@ func main() {
 | 
			
		||||
		WriteTimeout:         ko.MustDuration("app.server.write_timeout"),
 | 
			
		||||
		MaxRequestBodySize:   ko.MustInt("app.server.max_body_size"),
 | 
			
		||||
		MaxKeepaliveDuration: ko.MustDuration("app.server.keepalive_timeout"),
 | 
			
		||||
		ReadBufferSize:       ko.MustInt("app.server.max_body_size"),
 | 
			
		||||
		ReadBufferSize:       ko.Int("app.server.read_buffer_size"),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	go func() {
 | 
			
		||||
 
 | 
			
		||||
@@ -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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -2,9 +2,11 @@ package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/oidc/models"
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/stringutil"
 | 
			
		||||
	"github.com/valyala/fasthttp"
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
@@ -26,6 +28,10 @@ func handleGetAllOIDC(r *fastglue.Request) error {
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
	// Replace secrets with dummy values.
 | 
			
		||||
	for i := range out {
 | 
			
		||||
		out[i].ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(out)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -11,6 +11,7 @@ socket = ""
 | 
			
		||||
read_timeout = "5s"
 | 
			
		||||
write_timeout = "5s"
 | 
			
		||||
max_body_size = 500000000
 | 
			
		||||
read_buffer_size = 4096
 | 
			
		||||
keepalive_timeout = "10s"
 | 
			
		||||
 | 
			
		||||
# File upload provider to use, either `fs` or `s3`.
 | 
			
		||||
 
 | 
			
		||||
@@ -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}
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@ Libredesk is an open source, self-hosted customer support desk. Single binary ap
 | 
			
		||||
 | 
			
		||||
<div style="border: 1px solid #ccc; padding: 1px; border-radius:5px; box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1); background-color: #fff;">
 | 
			
		||||
    <a href="https://libredesk.io">
 | 
			
		||||
        <img src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/Screenshot_20250220_231723-VxuEQgEiFfI9xhzJDOvgMK0yJ0TwR3.png" alt="libredesk screenshot" style="display: block; margin: 0 auto;">
 | 
			
		||||
        <img src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-HvmxvOkalQSLp4qVezdTXaCd3dB4Rm.png" alt="libredesk screenshot" style="display: block; margin: 0 auto;">
 | 
			
		||||
    </a>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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'
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@ import { isGoDuration } from '@/utils/strings';
 | 
			
		||||
 | 
			
		||||
export const smtpConfigSchema = z.object({
 | 
			
		||||
    enabled: z.boolean().describe('Enabled status').default(false),
 | 
			
		||||
    username: z.string().describe('SMTP username').email().nonempty({
 | 
			
		||||
    username: z.string().describe('SMTP username').nonempty({
 | 
			
		||||
        message: "SMTP username is required"
 | 
			
		||||
    }),
 | 
			
		||||
    host: z.string().describe('SMTP host').nonempty({
 | 
			
		||||
@@ -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;
 | 
			
		||||
 | 
			
		||||
@@ -372,7 +374,8 @@ SELECT
 | 
			
		||||
    source_id
 | 
			
		||||
FROM conversation_messages
 | 
			
		||||
WHERE conversation_id = $1
 | 
			
		||||
AND type in ('incoming', 'outgoing')
 | 
			
		||||
AND type in ('incoming', 'outgoing') and private = false
 | 
			
		||||
and source_id > ''
 | 
			
		||||
ORDER BY id DESC
 | 
			
		||||
LIMIT $2;
 | 
			
		||||
 | 
			
		||||
@@ -521,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
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
@@ -115,11 +115,13 @@ func (e *Email) Send(m models.Message) error {
 | 
			
		||||
	// Set In-Reply-To header
 | 
			
		||||
	if m.InReplyTo != "" {
 | 
			
		||||
		email.Headers.Set(headerInReplyTo, "<"+m.InReplyTo+">")
 | 
			
		||||
		e.lo.Debug("In-Reply-To header set", "message_id", m.InReplyTo)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set message id header
 | 
			
		||||
	if m.SourceID.String != "" {
 | 
			
		||||
		email.Headers.Set(headerMessageID, fmt.Sprintf("<%s>", m.SourceID.String))
 | 
			
		||||
		e.lo.Debug("Message-ID header set", "message_id", m.SourceID.String)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set references header
 | 
			
		||||
@@ -127,6 +129,7 @@ func (e *Email) Send(m models.Message) error {
 | 
			
		||||
	for _, ref := range m.References {
 | 
			
		||||
		references += "<" + ref + "> "
 | 
			
		||||
	}
 | 
			
		||||
	e.lo.Debug("References header set", "references", references)
 | 
			
		||||
	email.Headers.Set(headerReferences, references)
 | 
			
		||||
 | 
			
		||||
	// Set email content
 | 
			
		||||
 
 | 
			
		||||
@@ -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()
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,8 @@
 | 
			
		||||
-- name: get-all-oidc
 | 
			
		||||
SELECT id, created_at, updated_at, name, provider, provider_url, enabled FROM oidc order by updated_at desc;
 | 
			
		||||
SELECT id, created_at, updated_at, name, provider, client_id, client_secret, provider_url, enabled FROM oidc order by updated_at desc;
 | 
			
		||||
 | 
			
		||||
-- name: get-all-enabled
 | 
			
		||||
SELECT id, name, enabled, provider, updated_at FROM oidc WHERE enabled = true order by updated_at desc;
 | 
			
		||||
SELECT id, name, enabled, provider, client_id, updated_at FROM oidc WHERE enabled = true order by updated_at desc;
 | 
			
		||||
 | 
			
		||||
-- name: get-oidc
 | 
			
		||||
SELECT * FROM oidc WHERE id = $1;
 | 
			
		||||
 
 | 
			
		||||
@@ -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