Compare commits

...

84 Commits

Author SHA1 Message Date
Abhinav Raut
dcede8a461 fix: path matching for /admin route when no sidebar item is selected 2025-03-25 02:12:00 +05:30
Abhinav Raut
39fd5c9165 fix: update redirect path for unmatched routes to assigned inboxes 2025-03-25 02:11:34 +05:30
Abhinav Raut
4b8a954043 fix: update error message for invalid session to suggest clearing cookies 2025-03-25 02:01:08 +05:30
Abhinav Raut
6ac9f28a32 fix: upsert user teams only when newly created user has a team.
- update error msg for empty permissions
2025-03-25 01:41:18 +05:30
Abhinav Raut
8101c202fa improve logging messages for database installation and system user creation 2025-03-25 01:40:39 +05:30
Abhinav Raut
09746fb365 fix: set default value for Mailbox input in EmailInboxForm 2025-03-25 01:39:51 +05:30
Abhinav Raut
f59ea59a2e fix: set default value to empty array for teams in user form schema 2025-03-25 01:32:36 +05:30
Abhinav Raut
a2cdd728c0 remove app version print 2025-03-25 01:11:56 +05:30
Abhinav Raut
ac59a5defc refactor: update form field description for improved clarity. 2025-03-25 00:54:09 +05:30
Abhinav Raut
05fbe39315 refactor: update placeholder text in TagsForm for improved clarity 2025-03-25 00:53:22 +05:30
Abhinav Raut
c7c65a3d83 refactor: update tag delete dialog alert description for clarity.
fix: show toast when tag is created
2025-03-25 00:53:14 +05:30
Abhinav Raut
5bf6b7df47 refactor: update placeholder text in SLAForm recipients for improved clarity 2025-03-25 00:52:09 +05:30
Abhinav Raut
c034c21fa5 refactor: update label for From Address field to From Email Address in EmailInboxForm 2025-03-25 00:51:50 +05:30
Abhinav Raut
4ed241a03d refactor: update description for Root URL input in GeneralSettingForm 2025-03-25 00:51:35 +05:30
Abhinav Raut
6b00f70c37 fix: business hours form, more zod schema validatons to the business hours form 2025-03-25 00:51:03 +05:30
Abhinav Raut
c51073d289 feat[shadcn]: allow boolean along with string in RadioGroup components for model value 2025-03-25 00:50:10 +05:30
Abhinav Raut
d03d4477de fix: show toast on any tag api errors 2025-03-25 00:48:53 +05:30
Abhinav Raut
3b211dc372 fix: handle unique constraint violation when creating a tag 2025-03-25 00:47:52 +05:30
Abhinav Raut
6b4f243b74 chore: more validations before savign an inbox
chore: update error msgs for clarity
2025-03-25 00:47:35 +05:30
Abhinav Raut
9ff5a53ebb fix: handle null events array in automation rules 2025-03-25 00:39:08 +05:30
Abhinav Raut
9b9282dfd9 feat: add no records found message to SimpleTable component 2025-03-24 23:14:29 +05:30
Abhinav Raut
698e2d960e chore: add more comments to message handlers. 2025-03-24 23:13:55 +05:30
Abhinav Raut
a8db8f64b5 refactor: conversation handlers to use existing enforceConversationAccess function for authz check 2025-03-24 23:13:23 +05:30
Abhinav Raut
f688be1c88 feat: adds support for showing HTML tables in tiptap editor on *paste*.
does not support inserting a table yet from the menu yet.
Adds tiptap extension for the same.
2025-03-22 20:48:48 +05:30
Abhinav Raut
d3eb3499df fix: update docker compose to bind Postgres to local interface by default to prevent unintended access to the DB. 2025-03-22 20:43:11 +05:30
Abhinav Raut
721f7c811c fix: variables not being rendered when there's an fetching default outgoing email template / the outgoing email template is not found. 2025-03-22 18:56:50 +05:30
Abhinav Raut
a33e1453a8 fix: set proper error type when user is not found 2025-03-22 18:55:26 +05:30
Abhinav Raut
b6ce6975c9 fix: mark schedule notification as processed when agent fetch fails for email notification 2025-03-22 18:55:01 +05:30
Abhinav Raut
860b216e2b fix: remove redirect from /admin to /admin/general as some users may not have permission to the general settings 2025-03-22 00:30:04 +05:30
Abhinav Raut
eaa2b1ddcf fix: fixes blur in commandbox when zoom is not 100% 2025-03-22 00:11:22 +05:30
Abhinav Raut
0f12b2a3f3 feat: allow searching conversations by contact email address 2025-03-21 23:54:43 +05:30
Abhinav Raut
def0bb8e4c fix: limit search results to 30 in conversation messages search query 2025-03-21 23:42:24 +05:30
Abhinav Raut
a41c360cdb fix: update timezone from 'Asia/Calcutta' to 'Asia/Kolkata' in schema 2025-03-21 23:39:31 +05:30
Abhinav Raut
159cca6866 feat: add email templates to migration 2025-03-21 23:38:53 +05:30
Abhinav Raut
83f553227a migrations for v0.5.0 2025-03-21 23:24:18 +05:30
Abhinav Raut
28a6a3d246 refactor: use updated SelectTag component that accepts options array instead of string array to work 2025-03-21 23:24:06 +05:30
Abhinav Raut
7e16cc1a74 refactor(notifier): remove dependency on user store, instead accept recipient emails 2025-03-21 23:23:35 +05:30
Abhinav Raut
aeef7d4ad7 feat: configurable SLA alerts per SLA. 2025-03-21 23:23:03 +05:30
Abhinav Raut
f0358f67f0 feat: SelectTag component now supports object-based options (value & label) instead of a plain array 2025-03-21 23:18:48 +05:30
Abhinav Raut
12f2453f5a fix: dashboard chart query 2025-03-21 19:03:01 +05:30
Abhinav Raut
2742be5619 fix: exclude sent messages from report/dashboard charts to avoid data skew 2025-03-21 19:00:56 +05:30
Abhinav Raut
d837defbc9 Merge pull request #37 from abhinavxd/fix/remove-users-from-balancer
Fix: Remove soft deleted / disabled users from round robin balance pool
2025-03-18 23:41:43 +05:30
Abhinav Raut
5cc849e7eb tidy go mod 2025-03-18 23:22:41 +05:30
Abhinav Raut
729faf980c fix: incorrect balance import 2025-03-18 23:18:18 +05:30
Abhinav Raut
a36c81141b fix: update balance 2025-03-18 23:17:11 +05:30
Abhinav Raut
756147a2c9 fix: remove deleted / disabled users from auto assigner balancer. 2025-03-16 23:10:18 +05:30
Abhinav Raut
88a641fe09 fix: missing icon import. 2025-03-15 01:41:27 +05:30
Abhinav Raut
785da6715c feat: add timezone constants and update forms to use new timezone data instead of javscript internationalization to get timezone data 2025-03-15 00:28:51 +05:30
Abhinav Raut
32401fa231 fix: add tzdata package to Dockerfile 2025-03-15 00:27:54 +05:30
Abhinav Raut
83b891c92a fix: import tz data in main.go 2025-03-15 00:27:46 +05:30
Abhinav Raut
f277f76a0a fix: sql for migration 2025-03-14 22:59:27 +05:30
Abhinav Raut
5f1a40acba feat: adds new TLS type, tls skip verify and hello name to migrations and schema 2025-03-14 22:42:59 +05:30
Abhinav Raut
d90b9c2be7 feat: add TLS type, skip TLS verify, and hello hostname config options to SMTP notification settings 2025-03-14 22:42:09 +05:30
Abhinav Raut
43184ec2f3 feat: add TLS type option to inbox SMTP and IMAP config and TLS skip verify option.
feat: Adds `scan_inbox_since` config for IMAP to set the `SINCE` parameter for imap search, this will allow to scan only the emails received after the given date / time.
chore: remove autoform, use individual form fields for form field.
2025-03-14 21:53:15 +05:30
Abhinav Raut
2fdcf68a22 chore: set closed_at and resolved_at only once in conversations, on subsequent updates they are not to be updated again and again to the current time
- Remove unncessary websocket update due to this change
2025-03-12 03:21:13 +05:30
Abhinav Raut
4bef3e80a2 fix: remove unncessary onClickOutside for SelectTag component 2025-03-12 03:02:44 +05:30
Abhinav Raut
09703c1090 migrations for v0.5.0 2025-03-12 02:45:32 +05:30
Abhinav Raut
45541c221a fix: various bugs in SLA calculation
prevents multiple update queries unnecessarily on applied sla table.
clear next sla deadline in conversations properly when there's no deadline to be met.
uses the new status column in the applied sla table to determine if the sla is still active and has to be calculated again.
2025-03-12 02:45:17 +05:30
Abhinav Raut
fc0e0a8fff fix: Reopen conversations on all statuses, currently custom statuses were not reopening conversations when a new message was received by the customer.
fix: Set resolved_at timestamp when conversation is marked as closed. As agents might close the conversation without resolving it.
2025-03-12 02:41:51 +05:30
Abhinav Raut
d1f931106d fix: do not reopen onversations on agent messges, let the agents open conversations by themselves. 2025-03-12 02:40:06 +05:30
Abhinav Raut
227aa26c35 - fix: Inline images present in email quote replies previously not visible, now show up correctly, the media does not get uploaded again instead the existing media url is replaced with the cid url.
- fix: content id check for attachments, as content id is not globally unqiue.

- fix: send missing websocket updates to the fronend on conversation status update.

- refactor: combine get media by id and uuid into a singlequery
2025-03-12 02:39:39 +05:30
Abhinav Raut
79a3f0ff70 refactor: move set SLA deadlines to SQL query and remove from code 2025-03-12 02:31:19 +05:30
Abhinav Raut
eefacdbda2 chore: adds new column applied sla status to applied slas table. 2025-03-12 02:30:43 +05:30
Abhinav Raut
3783cce1be fix(email/imap): Properly extract all HTML parts to handle Apple Mail parsing quirks
Resolved issues where Apple Mail:
- Split HTML content across MIME parts, causing rendering inconsistencies, this fix combines them.
- Apple mails sends file attachments as inline......f......, leading to missing files if no Content-ID was present, this fix will treat all attachments without a Content-ID as attachments and not inline.

- Set imap lookback to 48 hrs.
2025-03-12 00:25:06 +05:30
Abhinav Raut
a4cb373f32 fix: validate time durations and ensure first response time is less than resolution time in SLA handling 2025-03-11 02:47:55 +05:30
Abhinav Raut
99e8949be6 fix: update first reply time only when sender is a non system user 2025-03-10 02:58:43 +05:30
Abhinav Raut
1240051825 fix: bind model value and handle change for SelectTag in UserForm and CreateOrEditRule components 2025-03-10 02:37:26 +05:30
Abhinav Raut
5398d4ec41 fix: close holidays dialog on save. 2025-03-10 02:36:47 +05:30
Abhinav Raut
fd4e47dc68 fix: Close dropdown on outside click in SelectTag component 2025-03-10 02:35:56 +05:30
Abhinav Raut
1ff7317c4d fix: Auto setting of SLA not working on change of assigned team. 2025-03-09 20:41:48 +05:30
Abhinav Raut
d6449b9336 feat: adds link functionality to tiptap text editor 2025-03-09 20:37:21 +05:30
Abhinav Raut
580fb76a39 fix: handle non-existent media deletion gracefully and improve logging 2025-03-09 17:28:25 +05:30
Abhinav Raut
91889423a2 fix: SQL for fetching media not linked to any message. 2025-03-09 17:27:55 +05:30
Abhinav Raut
f12efe5511 fix: remove trailing slash from root URL in settings update 2025-03-09 16:43:40 +05:30
Abhinav Raut
56187ddc46 fix: add background color for private notes in ReplyBox 2025-03-09 13:18:49 +05:30
Abhinav Raut
47af51d0dd update simple s3 2025-03-09 13:18:49 +05:30
Abhinav Raut
47a3985a51 Merge pull request #31 from keybits/patch-1
Clarify Docker installation instructions
2025-03-08 09:22:55 +05:30
Tom Atkins
3f11af13b8 Clarify Docker installation instructions 2025-03-07 12:51:52 +00:00
Abhinav Raut
da629c864c docs: update installation guide to include Nginx configuration for websocket support 2025-03-06 21:08:11 +05:30
Abhinav Raut
6fb35b90b3 fix: move apply SLA on team change from handler to conversations pkg as automations will also change assigned team and that should also set the appropriate SLA defined for the team. 2025-03-06 20:47:19 +05:30
Abhinav Raut
9892f9dae7 fix: shuffle users in team balancer to prevent ordering bias on app restart 2025-03-06 20:34:51 +05:30
Abhinav Raut
277586f025 fix: round robin assignment not working due to balancer being reloaded entirely. 2025-03-06 20:19:30 +05:30
Abhinav Raut
f3070e13a7 fix: non reactive time input in business hours form. 2025-03-06 20:18:36 +05:30
Abhinav Raut
8ed29df11c fix: missing component in simple table. 2025-03-06 20:16:58 +05:30
92 changed files with 3136 additions and 1191 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 (

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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"),
})

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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 {

View File

@@ -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 == "" {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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}

View File

@@ -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;
}
```

View File

@@ -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",

View File

@@ -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)

View File

@@ -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 -->

View File

@@ -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, {

View File

@@ -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>

View File

@@ -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 },

View File

@@ -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 },

View File

@@ -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>

View 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"
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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]
})
}
}
}
}
})

View File

@@ -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({

View File

@@ -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 }
)

View File

@@ -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'])
})
})

View File

@@ -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>

View File

@@ -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'

View File

@@ -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(),
});

View File

@@ -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>

View File

@@ -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([])
})

View File

@@ -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 />

View File

@@ -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'
})
}

View File

@@ -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 },

View File

@@ -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.` }}

View File

@@ -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)

View File

@@ -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.'),

View File

@@ -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]">

View File

@@ -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;

View File

@@ -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'

View File

@@ -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, {

View File

@@ -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"

View File

@@ -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"

View File

@@ -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'))

View File

@@ -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"
/>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'
}
}
]

View File

@@ -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 ''
}
}

View File

@@ -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({

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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 || []

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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,

View File

@@ -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()

View File

@@ -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)

View File

@@ -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 **/

View File

@@ -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:"-"`
}

View File

@@ -63,12 +63,21 @@ SELECT
) t
) as unread_message_count,
conversation_statuses.name as status,
conversation_priorities.name as priority
conversation_priorities.name as priority,
as_latest.first_response_deadline_at,
as_latest.resolution_deadline_at,
as_latest.status as sla_status
FROM conversations
JOIN users ON contact_id = users.id
JOIN inboxes ON inbox_id = inboxes.id
LEFT JOIN conversation_statuses ON status_id = conversation_statuses.id
LEFT JOIN conversation_priorities ON priority_id = conversation_priorities.id
LEFT JOIN LATERAL (
SELECT first_response_deadline_at, resolution_deadline_at, status
FROM applied_slas
WHERE conversation_id = conversations.id
ORDER BY created_at DESC LIMIT 1
) as_latest ON true
WHERE 1=1 %s
-- name: get-conversation
@@ -124,7 +133,10 @@ SELECT
ct.avatar_url as "contact.avatar_url",
ct.phone_number as "contact.phone_number",
COALESCE(lr.cc, '[]'::jsonb) as cc,
COALESCE(lr.bcc, '[]'::jsonb) as bcc
COALESCE(lr.bcc, '[]'::jsonb) as bcc,
as_latest.first_response_deadline_at,
as_latest.resolution_deadline_at,
as_latest.status as sla_status
FROM conversations c
JOIN users ct ON c.contact_id = ct.id
LEFT JOIN sla_policies sla ON c.sla_policy_id = sla.id
@@ -132,6 +144,12 @@ LEFT JOIN teams at ON at.id = c.assigned_team_id
LEFT JOIN conversation_statuses s ON c.status_id = s.id
LEFT JOIN conversation_priorities p ON c.priority_id = p.id
LEFT JOIN last_reply lr ON lr.conversation_id = c.id
LEFT JOIN LATERAL (
SELECT first_response_deadline_at, resolution_deadline_at, status
FROM applied_slas
WHERE conversation_id = c.id
ORDER BY created_at DESC LIMIT 1
) as_latest ON true
WHERE
($1 > 0 AND c.id = $1)
OR
@@ -179,10 +197,10 @@ WHERE uuid = $1;
-- name: update-conversation-status
UPDATE conversations
SET status_id = (SELECT id FROM conversation_statuses WHERE name = $2),
resolved_at = CASE WHEN $2 = 'Resolved' THEN NOW() ELSE resolved_at END,
closed_at = CASE WHEN $2 = 'Closed' THEN NOW() ELSE closed_at END,
snoozed_until = CASE WHEN $2 = 'Snoozed' THEN $3::timestamptz ELSE NULL END,
updated_at = now()
resolved_at = COALESCE(resolved_at, CASE WHEN $2 IN ('Resolved', 'Closed') THEN NOW() END),
closed_at = COALESCE(closed_at, CASE WHEN $2 = 'Closed' THEN NOW() END),
snoozed_until = CASE WHEN $2 = 'Snoozed' THEN $3::timestamptz ELSE snoozed_until END,
updated_at = NOW()
WHERE uuid = $1;
-- name: get-user-active-conversations-count
@@ -293,26 +311,10 @@ status_summary AS (
GROUP BY
s.name
) agg
),
messages_sent as (
SELECT json_agg(row_to_json(agg)) AS data
FROM (
SELECT
TO_CHAR(created_at::date, 'YYYY-MM-DD') AS date,
COUNT(*) AS count
FROM
conversation_messages c
WHERE status = 'sent' AND 1=1 %s
GROUP BY
date
ORDER BY
date
) agg
)
SELECT json_build_object(
'new_conversations', (SELECT data FROM new_conversations),
'resolved_conversations', (SELECT data FROM resolved_conversations),
'messages_sent', (SELECT data FROM messages_sent),
'status_summary', (SELECT data FROM status_summary)
) AS result;
@@ -522,11 +524,12 @@ SET
WHERE uuid = $1;
-- name: re-open-conversation
-- Open conversation if it is not already open.
UPDATE conversations
SET status_id = (SELECT id FROM conversation_statuses WHERE name = 'Open'), snoozed_until = NULL,
updated_at = now()
WHERE uuid = $1 and status_id in (
SELECT id FROM conversation_statuses WHERE name IN ('Snoozed', 'Closed', 'Resolved')
SELECT id FROM conversation_statuses WHERE name NOT IN ('Open')
)
-- name: delete-conversation

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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;

View 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
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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;

View File

@@ -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, &notifications); 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
}

View File

@@ -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, " ")
}

View File

@@ -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)
}

View File

@@ -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;

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
);