From 4c766d8ccb5e202cecf8184907ea8bb80e308d4a Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 23 May 2025 03:45:57 +0530 Subject: [PATCH 01/19] wip: next response metric for sla --- cmd/main.go | 2 +- cmd/messages.go | 4 + cmd/sla.go | 14 +- frontend/src/features/admin/sla/SLAForm.vue | 29 +- frontend/src/features/admin/sla/formSchema.js | 4 + .../list/ConversationListItem.vue | 14 + .../conversation/sidebar/ConversationInfo.vue | 19 +- i18n/en.json | 2 + internal/conversation/conversation.go | 1 + internal/conversation/message.go | 18 +- internal/conversation/models/models.go | 87 ++--- internal/conversation/queries.sql | 29 +- internal/macro/macro.go | 14 +- internal/migrations/v0.6.0.go | 88 +++++ internal/sla/models/models.go | 15 + internal/sla/queries.sql | 73 +++- internal/sla/sla.go | 318 +++++++++++++++--- schema.sql | 25 +- 18 files changed, 623 insertions(+), 133 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 7779b017..80db36cc 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -195,7 +195,7 @@ func main() { go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval) go conversation.RunUnsnoozer(ctx, unsnoozeInterval) go notifier.Run(ctx) - go sla.Run(ctx, slaEvaluationInterval) + go sla.Start(ctx, slaEvaluationInterval) go sla.SendNotifications(ctx) go media.DeleteUnlinkedMedia(ctx) go user.MonitorAgentAvailability(ctx) diff --git a/cmd/messages.go b/cmd/messages.go index 5f38c358..78ded397 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -7,6 +7,7 @@ import ( "github.com/abhinavxd/libredesk/internal/automation/models" "github.com/abhinavxd/libredesk/internal/envelope" medModels "github.com/abhinavxd/libredesk/internal/media/models" + "github.com/abhinavxd/libredesk/internal/sla" "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" ) @@ -172,6 +173,9 @@ func handleSendMessage(r *fastglue.Request) error { } // Evaluate automation rules. app.automation.EvaluateConversationUpdateRules(cuuid, models.EventConversationMessageOutgoing) + + // Set `met at` timestamp for next response SLA metric as the agent has sent a message. + app.sla.SetLatestSLAEventMetAt(conv.AppliedSLAID.Int, sla.MetricNextResponse) } return r.SendEnvelope(true) diff --git a/cmd/sla.go b/cmd/sla.go index c58d264d..d838be56 100644 --- a/cmd/sla.go +++ b/cmd/sla.go @@ -54,7 +54,7 @@ func handleCreateSLA(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - if err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.Notifications); err != nil { + if err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications); err != nil { return sendErrorEnvelope(r, err) } @@ -81,11 +81,11 @@ func handleUpdateSLA(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - if err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.Notifications); err != nil { + if err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications); err != nil { return sendErrorEnvelope(r, err) } - return r.SendEnvelope("SLA updated successfully.") + return r.SendEnvelope(true) } // handleDeleteSLA deletes the SLA with the given ID. @@ -155,5 +155,13 @@ func validateSLA(app *App, sla *smodels.SLAPolicy) error { return envelope.NewError(envelope.InputError, app.i18n.T("sla.firstResponseTimeAfterResolution"), nil) } + nrt, err := time.ParseDuration(sla.NextResponseTime) + if err != nil { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil) + } + if nrt.Seconds() < 1 { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil) + } + return nil } diff --git a/frontend/src/features/admin/sla/SLAForm.vue b/frontend/src/features/admin/sla/SLAForm.vue index cc103096..70032600 100644 --- a/frontend/src/features/admin/sla/SLAForm.vue +++ b/frontend/src/features/admin/sla/SLAForm.vue @@ -44,6 +44,19 @@ + + + {{ t('admin.sla.nextResponseTime') }} + + + + + {{ t('admin.sla.nextResponseTime.description') }} + + + + +
@@ -93,7 +106,10 @@
- {{ notification.type === 'warning' ? t('admin.sla.warning') : t('admin.sla.breach') }} {{ t('admin.sla.notification') }} + {{ + notification.type === 'warning' ? t('admin.sla.warning') : t('admin.sla.breach') + }} + {{ t('admin.sla.notification') }}

{{ notification.type === 'warning' ? 'Pre-breach alert' : 'Post-breach action' }} @@ -149,7 +165,11 @@ - {{ notification.type === 'warning' ? t('admin.sla.advanceWarning') : t('admin.sla.followUpDelay') }} + {{ + notification.type === 'warning' + ? t('admin.sla.advanceWarning') + : t('admin.sla.followUpDelay') + }} + + + + + + + + + {{ t('admin.sla.metrics.all') }} + + + {{ t('admin.sla.firstResponseTime') }} + + + {{ t('admin.sla.nextResponseTime') }} + + + {{ t('admin.sla.resolutionTime') }} + + + + + + + +

@@ -235,7 +267,7 @@ class="flex flex-col items-center justify-center p-8 space-y-3 rounded-xl bg-muted/30 border border-dashed" > -

{{ t('admin.sla.noNotificationsConfigured') }}

+

{{ t('admin.sla.noAlertsConfigured') }}

@@ -251,7 +283,17 @@ import { useForm } from 'vee-validate' import { toTypedSchema } from '@vee-validate/zod' import { createFormSchema } from './formSchema' import { Button } from '@/components/ui/button' -import { X, Plus, Timer, CircleAlert, Users, Clock, Hourglass, Bell } from 'lucide-vue-next' +import { + X, + Plus, + Timer, + CircleAlert, + Users, + Clock, + Hourglass, + Bell, + SlidersHorizontal +} from 'lucide-vue-next' import { useUsersStore } from '@/stores/users' import { FormControl, @@ -343,7 +385,8 @@ const addNotification = (type) => { type: type, time_delay_type: type === 'warning' ? 'before' : 'immediately', time_delay: type === 'warning' ? '10m' : '', - recipients: [] + recipients: [], + metric: 'all' }) form.setFieldValue('notifications', notifications) } @@ -364,6 +407,8 @@ watch( const transformedNotifications = (newValues.notifications || []).map((notification) => ({ ...notification, + // Default value, notification applies to all metrics unless specified. + metric: notification.metric || 'all', time_delay_type: notification.type === 'warning' ? 'before' diff --git a/frontend/src/features/admin/sla/formSchema.js b/frontend/src/features/admin/sla/formSchema.js index dfaa10c5..a5ed22ae 100644 --- a/frontend/src/features/admin/sla/formSchema.js +++ b/frontend/src/features/admin/sla/formSchema.js @@ -1,58 +1,79 @@ import * as z from 'zod' import { isGoHourMinuteDuration } from '@/utils/strings' -export const createFormSchema = (t) => z.object({ - name: z - .string() - .min(1, { message: t('admin.sla.name.valid') }) - .max(255, { message: t('admin.sla.name.valid') }), - description: z - .string() - .min(1, { message: t('admin.sla.description.valid') }) - .max(255, { message: t('admin.sla.description.valid') }), - first_response_time: z.string().refine(isGoHourMinuteDuration, { - message: - t('globals.messages.goHourMinuteDuration'), - }), - resolution_time: z.string().refine(isGoHourMinuteDuration, { - message: - t('globals.messages.goHourMinuteDuration'), - }), - next_response_time: z.string().refine(isGoHourMinuteDuration, { - message: - t('globals.messages.goHourMinuteDuration'), - }), - 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: t('globals.messages.atleastOneRecipient') }) +export const createFormSchema = (t) => + z + .object({ + name: z + .string() + .min(1, { message: t('admin.sla.name.valid') }) + .max(255, { message: t('admin.sla.name.valid') }), + description: z + .string() + .min(1, { message: t('admin.sla.description.valid') }) + .max(255, { message: t('admin.sla.description.valid') }), + first_response_time: z.string().optional().refine(val => !val || isGoHourMinuteDuration(val), { + message: t('globals.messages.goHourMinuteDuration'), + }), + resolution_time: z.string().optional().refine(val => !val || isGoHourMinuteDuration(val), { + message: t('globals.messages.goHourMinuteDuration'), + }), + next_response_time: z.string().optional().refine(val => !val || isGoHourMinuteDuration(val), { + message: t('globals.messages.goHourMinuteDuration'), + }), + notifications: z + .array( + z + .object({ + type: z.enum(['breach', 'warning']), + time_delay_type: z.enum(['immediately', 'after', 'before']), + time_delay: z.string().optional(), + metric: z.enum(['first_response', 'resolution', 'next_response', 'all']), + recipients: z + .array(z.string()) + .min(1, { message: t('globals.messages.atleastOneRecipient') }), + }) + .superRefine((obj, ctx) => { + if (obj.time_delay_type !== 'immediately') { + if (!obj.time_delay || obj.time_delay === '') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t('admin.sla.delay.required'), + path: ['time_delay'], + }); + } else if (!isGoHourMinuteDuration(obj.time_delay)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t('globals.messages.goHourMinuteDuration'), + path: ['time_delay'], + }); + } + } + }) + ) + .optional() + .default([]), + }) + .superRefine((data, ctx) => { + const { first_response_time, resolution_time, next_response_time } = data + const isEmpty = !first_response_time && !resolution_time && !next_response_time + + if (isEmpty) { + const msg = t('admin.sla.atleastOneSLATimeRequired') + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['first_response_time'], + message: msg, }) - .superRefine((obj, ctx) => { - if (obj.time_delay_type !== 'immediately') { - if (!obj.time_delay || obj.time_delay === '') { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - t('admin.sla.delay.required'), - path: ['time_delay'] - }) - } else if (!isGoHourMinuteDuration(obj.time_delay)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - t('globals.messages.goHourMinuteDuration'), - path: ['time_delay'] - }) - } - } + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['resolution_time'], + message: msg, }) - ) - .optional() - .default([]) -}) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['next_response_time'], + message: msg, + }) + } + }) diff --git a/i18n/en.json b/i18n/en.json index 94e24832..b56634bd 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -81,6 +81,7 @@ "globals.terms.key": "Key | Keys", "globals.terms.note": "Note | Notes", "globals.terms.ipAddress": "IP Address | IP Addresses", + "globals.terms.alert": "Alert | Alerts", "globals.messages.badRequest": "Bad request", "globals.messages.adjustFilters": "Try adjusting filters", "globals.messages.errorUpdating": "Error updating {name}", @@ -381,27 +382,30 @@ "admin.sla.name.valid": "SLA Policy name should be between 1 and 255 characters", "admin.sla.description.valid": "SLA Policy description should be between 1 and 255 characters", "admin.sla.delay.required": "Delay is required", - "admin.sla.firstResponseTime": "First Response Time", + "admin.sla.firstResponseTime": "First response time", + "admin.sla.resolutionTime": "Resolution time", + "admin.sla.nextResponseTime": "Next response time", "admin.sla.firstResponseTime.description": "Duration in hours or minutes. Example: 1h, 30m, 1h30m", - "admin.sla.resolutionTime": "Resolution Time", "admin.sla.resolutionTime.description": "Duration in hours or minutes. Example: 1h, 30m, 1h30m", - "admin.sla.nextResponseTime": "Next Response Time", "admin.sla.nextResponseTime.description": "Duration in hours or minutes. Example: 1h, 30m, 1h30m", - "admin.sla.alertConfiguration": "Alert Configuration", - "admin.sla.alertConfiguration.description": "Set up notification triggers and recipients", - "admin.sla.addBreachAlert": "Add Breach Alert", - "admin.sla.addWarningAlert": "Add Warning Alert", + "admin.sla.alertConfiguration": "Alert configuration", + "admin.sla.alertConfiguration.description": "Set up alert triggers and recipients", + "admin.sla.addBreachAlert": "Add breach alert", + "admin.sla.addWarningAlert": "Add warning alert", "admin.sla.warning": "Warning", "admin.sla.breach": "Breach", - "admin.sla.notification": "Notification", - "admin.sla.triggerTiming": "Trigger Timing", + "admin.sla.triggerTiming": "Trigger timing", "admin.sla.immediatelyOnBreach": "Immediately on breach", "admin.sla.afterSpecificDuration": "After specific duration", "admin.sla.selectDuration": "Select duration...", - "admin.sla.advanceWarning": "Advance Warning", - "admin.sla.followUpDelay": "Follow Up Delay", - "admin.sla.notificationRecipients": "Notification Recipients", - "admin.sla.noNotificationsConfigured": "No notifications configured", + "admin.sla.advanceWarning": "Advance warning", + "admin.sla.followUpDelay": "Follow up delay", + "admin.sla.alertRecipients": "Alert recipients", + "admin.sla.noAlertsConfigured": "No alerts configured", + "admin.sla.metric": "SLA Metric", + "admin.sla.selectMetric": "Select SLA metric", + "admin.sla.metrics.all": "All", + "admin.sla.atleastOneSLATimeRequired": "At least one of First Response Time, Next Response Time, or Resolution Time must be provided.", "admin.conversationTags.edit.description": "Change the tag name. Click save when you're done.", "admin.conversationTags.new.description": "Set tag name. Click save when you're done.", "admin.conversationTags.updated": "Tag updated successfully", diff --git a/i18n/mr.json b/i18n/mr.json index dbf1be78..be5f6e8a 100644 --- a/i18n/mr.json +++ b/i18n/mr.json @@ -361,15 +361,15 @@ "admin.sla.addWarningAlert": "चेतावणी सूचना जोडा", "admin.sla.warning": "चेतावणी", "admin.sla.breach": "उल्लंघन", - "admin.sla.notification": "सूचना", + "globals.terms.alert": "सूचना", "admin.sla.triggerTiming": "ट्रिगर वेळ", "admin.sla.immediatelyOnBreach": "उल्लंघनावर लगेच", "admin.sla.afterSpecificDuration": "विशिष्ट कालावधीनंतर", "admin.sla.selectDuration": "कालावधी निवडा...", "admin.sla.advanceWarning": "अग्रिम चेतावणी", "admin.sla.followUpDelay": "फॉलो अप विलंब", - "admin.sla.notificationRecipients": "सूचना प्राप्तकर्ते", - "admin.sla.noNotificationsConfigured": "सूचना कॉन्फिगर केलेल्या नाहीत", + "admin.sla.alertRecipients": "सूचना प्राप्तकर्ते", + "admin.sla.noAlertsConfigured": "सूचना कॉन्फिगर केलेल्या नाहीत", "admin.conversationTags.edit.description": "टॅग नाव बदला. पूर्ण झाल्यावर जतन करा क्लिक करा.", "admin.conversationTags.new.description": "टॅग नाव सेट करा. पूर्ण झाल्यावर जतन करा क्लिक करा.", "admin.conversationTags.updated": "टॅग यशस्वीरित्या अद्ययावत केला", diff --git a/internal/sla/models/models.go b/internal/sla/models/models.go index d0d7bed5..b302eb44 100644 --- a/internal/sla/models/models.go +++ b/internal/sla/models/models.go @@ -52,6 +52,7 @@ type SlaNotification struct { Recipients []string `db:"recipients" json:"recipients"` TimeDelay string `db:"time_delay" json:"time_delay"` TimeDelayType string `db:"time_delay_type" json:"time_delay_type"` + Metric string `db:"metric" json:"metric"` } // ScheduledSLANotification represents a scheduled SLA notification diff --git a/internal/sla/queries.sql b/internal/sla/queries.sql index 5b3a04d2..da68f821 100644 --- a/internal/sla/queries.sql +++ b/internal/sla/queries.sql @@ -195,8 +195,7 @@ RETURNING id; -- name: set-latest-sla-event-met-at UPDATE sla_events -SET met_at = NOW(), -status = CASE WHEN NOW() > deadline_at THEN 'breached'::sla_event_status ELSE 'met'::sla_event_status END +SET met_at = NOW() WHERE id = ( SELECT id FROM sla_events WHERE applied_sla_id = $1 AND type = $2 AND met_at IS NULL @@ -208,7 +207,7 @@ RETURNING met_at; -- name: mark-sla-event-as-breached UPDATE sla_events SET breached_at = NOW(), -status = 'breached' + status = 'breached' WHERE id = $1; -- name: mark-sla-event-as-met @@ -216,7 +215,7 @@ UPDATE sla_events SET status = 'met' WHERE id = $1; --- name: get-sla-event +-- name: get-sla-event-by-id SELECT id, created_at, updated_at, applied_sla_id, sla_policy_id, type, deadline_at, met_at, breached_at FROM sla_events WHERE id = $1; @@ -224,4 +223,4 @@ WHERE id = $1; -- name: get-pending-sla-events SELECT id FROM sla_events -WHERE status = 'pending' and deadline_at IS NOT NULL; \ No newline at end of file +WHERE status = 'pending' AND deadline_at IS NOT NULL; diff --git a/internal/sla/sla.go b/internal/sla/sla.go index 02d9a162..454a908e 100644 --- a/internal/sla/sla.go +++ b/internal/sla/sla.go @@ -38,6 +38,7 @@ const ( MetricFirstResponse = "first_response" MetricResolution = "resolution" MetricNextResponse = "next_response" + MetricAll = "all" NotificationTypeWarning = "warning" NotificationTypeBreach = "breach" @@ -103,11 +104,10 @@ type businessHrsStore interface { // queries hold prepared SQL queries. type queries struct { - // TODO: name queries better. GetSLA *sqlx.Stmt `query:"get-sla-policy"` GetAllSLA *sqlx.Stmt `query:"get-all-sla-policies"` GetAppliedSLA *sqlx.Stmt `query:"get-applied-sla"` - GetSLAEvent *sqlx.Stmt `query:"get-sla-event"` + GetSLAEventByID *sqlx.Stmt `query:"get-sla-event-by-id"` GetScheduledSLANotifications *sqlx.Stmt `query:"get-scheduled-sla-notifications"` GetLatestAppliedSLAForConversation *sqlx.Stmt `query:"get-latest-applied-sla-for-conversation"` InsertScheduledSLANotification *sqlx.Stmt `query:"insert-scheduled-sla-notification"` @@ -372,14 +372,18 @@ func (m *Manager) SetLatestSLAEventMetAt(conversationID int, metric string) (tim return metAt, nil } -// evaluatePendingSLAEvents fetches pending SLA events and marks them as breached if the deadline has passed. +// evaluatePendingSLAEvents fetches pending SLA events, updates their status based on deadlines, and schedules notifications for breached SLAs. func (m *Manager) evaluatePendingSLAEvents(ctx context.Context) error { var slaEvents []models.SLAEvent if err := m.q.GetPendingSLAEvents.SelectContext(ctx, &slaEvents); err != nil { m.lo.Error("error fetching pending SLA events", "error", err) return fmt.Errorf("fetching pending SLA events: %w", err) } - m.lo.Info("found SLA events that have breached", "count", len(slaEvents)) + if len(slaEvents) == 0 { + return nil + } + + m.lo.Info("found pending SLA events for evaluation", "count", len(slaEvents)) // Cache for SLA policies. var slaPolicyCache = make(map[int]models.SLAPolicy) @@ -390,13 +394,13 @@ func (m *Manager) evaluatePendingSLAEvents(ctx context.Context) error { default: } - if err := m.q.GetSLAEvent.GetContext(ctx, &event, event.ID); err != nil { + if err := m.q.GetSLAEventByID.GetContext(ctx, &event, event.ID); err != nil { m.lo.Error("error fetching SLA event", "error", err) continue } if event.DeadlineAt.IsZero() { - m.lo.Warn("SLA event deadline is zero, skipping marking as breached", "sla_event_id", event.ID) + m.lo.Warn("SLA event deadline is zero, skipping evaluation", "sla_event_id", event.ID) continue } @@ -418,9 +422,9 @@ func (m *Manager) evaluatePendingSLAEvents(ctx context.Context) error { } } - // Schedule a breach notification if the event is not met at all. + // Schedule a breach notification if the event is not met at all and SLA breached. if !event.MetAt.Valid && hasBreached { - // Check if the SLA policy is already cached. + // Get policy from cache. slaPolicy, ok := slaPolicyCache[event.SlaPolicyID] if !ok { var err error @@ -439,8 +443,8 @@ func (m *Manager) evaluatePendingSLAEvents(ctx context.Context) error { return nil } -// Start begins SLA and SLA event evaluation loops in separate goroutines. -func (m *Manager) Start(ctx context.Context, interval time.Duration) { +// Run starts Applied SLA and SLA event evaluation loops in separate goroutines. +func (m *Manager) Run(ctx context.Context, interval time.Duration) { m.wg.Add(2) go m.runSLAEvaluation(ctx, interval) go m.runSLAEventEvaluation(ctx, interval) @@ -515,14 +519,14 @@ func (m *Manager) SendNotifications(ctx context.Context) error { } } -// SendNotification sends a SLA notification to agents. +// SendNotification sends a SLA notification to agents, a schedule notification can be linked to a specific SLA event or applied SLA. func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANotification) error { var ( appliedSLA models.AppliedSLA slaEvent models.SLAEvent ) if scheduledNotification.SlaEventID.Int != 0 { - if err := m.q.GetSLAEvent.Get(&slaEvent, scheduledNotification.SlaEventID.Int); err != nil { + if err := m.q.GetSLAEventByID.Get(&slaEvent, scheduledNotification.SlaEventID.Int); err != nil { m.lo.Error("error fetching SLA event", "error", err) return fmt.Errorf("fetching SLA event for notification: %w", err) } @@ -760,14 +764,15 @@ func (m *Manager) getBusinessHoursAndTimezone(assignedTeamID int) (bmodels.Busin return bh, timezone, nil } -// createNotificationSchedule creates a notification schedule in database for the applied SLA. +// createNotificationSchedule creates a notification schedule in database for the applied SLA to be sent later. func (m *Manager) createNotificationSchedule(notifications models.SlaNotifications, appliedSLAID int, slaEventID null.Int, deadlines Deadlines, breaches Breaches) { scheduleNotification := func(sendAt time.Time, metric, notifType string, recipients []string) { // Make sure the sendAt time is in not too far in the past. if sendAt.Before(time.Now().Add(-5 * time.Minute)) { - m.lo.Debug("skipping scheduling notification as it is in the past", "send_at", sendAt, "applied_sla_id", appliedSLAID, "metric", metric, "type", notifType) + m.lo.Warn("skipping scheduling notification as it is in the past", "send_at", sendAt, "applied_sla_id", appliedSLAID, "metric", metric, "type", notifType) return } + m.lo.Info("scheduling SLA notification", "send_at", sendAt, "applied_sla_id", appliedSLAID, "metric", metric, "type", notifType, "recipients", recipients) if _, err := m.q.InsertScheduledSLANotification.Exec(appliedSLAID, slaEventID, metric, notifType, pq.Array(recipients), sendAt); err != nil { m.lo.Error("error inserting scheduled SLA notification", "error", err) } @@ -775,43 +780,42 @@ func (m *Manager) createNotificationSchedule(notifications models.SlaNotificatio // 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 { + delayDur := time.Duration(0) + if notif.TimeDelayType != "immediately" && notif.TimeDelay != "" { + if d, err := time.ParseDuration(notif.TimeDelay); err == nil { + delayDur = d + } else { 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), MetricResolution, notif.Type, notif.Recipients) - } - if !deadlines.NextResponse.IsZero() { - scheduleNotification(deadlines.NextResponse.Add(-delayDur), MetricNextResponse, 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), MetricResolution, notif.Type, notif.Recipients) - } - if !breaches.NextResponse.IsZero() { - scheduleNotification(breaches.NextResponse.Add(delayDur), MetricNextResponse, notif.Type, notif.Recipients) + if notif.Metric == "" { + notif.Metric = MetricAll + } + + schedule := func(target time.Time, metricType string) { + if !target.IsZero() && (notif.Metric == metricType || notif.Metric == MetricAll) { + var sendAt time.Time + if notif.Type == NotificationTypeWarning { + sendAt = target.Add(-delayDur) + } else { + sendAt = target.Add(delayDur) + } + scheduleNotification(sendAt, metricType, notif.Type, notif.Recipients) } } + + switch notif.Type { + case NotificationTypeWarning: + schedule(deadlines.FirstResponse, MetricFirstResponse) + schedule(deadlines.Resolution, MetricResolution) + schedule(deadlines.NextResponse, MetricNextResponse) + case NotificationTypeBreach: + schedule(breaches.FirstResponse, MetricFirstResponse) + schedule(breaches.Resolution, MetricResolution) + schedule(breaches.NextResponse, MetricNextResponse) + } } } From a84ed1ed3293169a939d0079bb4d7a2d9b1f273a Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 25 May 2025 20:19:17 +0530 Subject: [PATCH 09/19] Allow setting any value for SLA delay duration, replace select with input text Validations to delay duration --- cmd/sla.go | 10 ++++- frontend/src/features/admin/sla/SLAForm.vue | 43 ++++--------------- frontend/src/features/admin/sla/formSchema.js | 4 +- i18n/en.json | 3 +- 4 files changed, 22 insertions(+), 38 deletions(-) diff --git a/cmd/sla.go b/cmd/sla.go index 44918898..e54e7c9b 100644 --- a/cmd/sla.go +++ b/cmd/sla.go @@ -129,6 +129,14 @@ func validateSLA(app *App, sla *smodels.SLAPolicy) error { if n.TimeDelay == "" { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`time_delay`"), nil) } + // Validate time delay duration. + td, err := time.ParseDuration(n.TimeDelay) + if err != nil { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`time_delay`"), nil) + } + if td.Minutes() < 1 { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`time_delay`"), nil) + } } if len(n.Recipients) == 0 { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`recipients`"), nil) @@ -170,7 +178,7 @@ func validateSLA(app *App, sla *smodels.SLAPolicy) error { if err != nil { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil) } - if nrt.Seconds() < 1 { + if nrt.Minutes() < 1 { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil) } } diff --git a/frontend/src/features/admin/sla/SLAForm.vue b/frontend/src/features/admin/sla/SLAForm.vue index a48fc11a..3630beec 100644 --- a/frontend/src/features/admin/sla/SLAForm.vue +++ b/frontend/src/features/admin/sla/SLAForm.vue @@ -172,22 +172,14 @@ }} - + @@ -341,25 +333,6 @@ const submitLabel = computed(() => { (props.initialValues.id ? t('globals.buttons.update') : t('globals.buttons.create')) ) }) -const delayDurations = [ - '5m', - '10m', - '15m', - '30m', - '45m', - '1h', - '2h', - '3h', - '4h', - '5h', - '6h', - '7h', - '8h', - '9h', - '10h', - '11h', - '12h' -] const { t } = useI18n() const form = useForm({ diff --git a/frontend/src/features/admin/sla/formSchema.js b/frontend/src/features/admin/sla/formSchema.js index a5ed22ae..55663bde 100644 --- a/frontend/src/features/admin/sla/formSchema.js +++ b/frontend/src/features/admin/sla/formSchema.js @@ -27,7 +27,9 @@ export const createFormSchema = (t) => .object({ type: z.enum(['breach', 'warning']), time_delay_type: z.enum(['immediately', 'after', 'before']), - time_delay: z.string().optional(), + time_delay: z.string().optional().refine(val => isGoHourMinuteDuration(val), { + message: t('globals.messages.goHourMinuteDuration'), + }), metric: z.enum(['first_response', 'resolution', 'next_response', 'all']), recipients: z .array(z.string()) diff --git a/i18n/en.json b/i18n/en.json index b56634bd..f916d66f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -82,6 +82,7 @@ "globals.terms.note": "Note | Notes", "globals.terms.ipAddress": "IP Address | IP Addresses", "globals.terms.alert": "Alert | Alerts", + "globals.terms.duration": "Duration | Durations", "globals.messages.badRequest": "Bad request", "globals.messages.adjustFilters": "Try adjusting filters", "globals.messages.errorUpdating": "Error updating {name}", @@ -122,6 +123,7 @@ "globals.messages.add": "Add {name}", "globals.messages.denied": "{name} denied", "globals.messages.noResults": "No {name} found", + "globals.messages.enter": "Enter {name}", "globals.messages.yes": "Yes", "globals.messages.no": "No", "globals.messages.typeOf": "Type of {name}", @@ -397,7 +399,6 @@ "admin.sla.triggerTiming": "Trigger timing", "admin.sla.immediatelyOnBreach": "Immediately on breach", "admin.sla.afterSpecificDuration": "After specific duration", - "admin.sla.selectDuration": "Select duration...", "admin.sla.advanceWarning": "Advance warning", "admin.sla.followUpDelay": "Follow up delay", "admin.sla.alertRecipients": "Alert recipients", From 8ce0464603c6e52604dc9f8c13e0669f6ee74599 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 25 May 2025 21:22:12 +0530 Subject: [PATCH 10/19] fix: simplify time_delay validation in SLA notification schema --- frontend/src/features/admin/sla/formSchema.js | 6 ++---- frontend/src/features/sla/SlaBadge.vue | 6 +----- frontend/src/views/admin/sla/SLA.vue | 4 ++-- i18n/en.json | 1 - internal/sla/models/models.go | 1 - 5 files changed, 5 insertions(+), 13 deletions(-) diff --git a/frontend/src/features/admin/sla/formSchema.js b/frontend/src/features/admin/sla/formSchema.js index 55663bde..5833ee46 100644 --- a/frontend/src/features/admin/sla/formSchema.js +++ b/frontend/src/features/admin/sla/formSchema.js @@ -27,9 +27,7 @@ export const createFormSchema = (t) => .object({ type: z.enum(['breach', 'warning']), time_delay_type: z.enum(['immediately', 'after', 'before']), - time_delay: z.string().optional().refine(val => isGoHourMinuteDuration(val), { - message: t('globals.messages.goHourMinuteDuration'), - }), + time_delay: z.string().optional(), metric: z.enum(['first_response', 'resolution', 'next_response', 'all']), recipients: z .array(z.string()) @@ -40,7 +38,7 @@ export const createFormSchema = (t) => if (!obj.time_delay || obj.time_delay === '') { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: t('admin.sla.delay.required'), + message: t('globals.messages.required'), path: ['time_delay'], }); } else if (!isGoHourMinuteDuration(obj.time_delay)) { diff --git a/frontend/src/features/sla/SlaBadge.vue b/frontend/src/features/sla/SlaBadge.vue index b462990b..de396a24 100644 --- a/frontend/src/features/sla/SlaBadge.vue +++ b/frontend/src/features/sla/SlaBadge.vue @@ -46,11 +46,7 @@ const props = defineProps({ }) const emit = defineEmits(['status']) - -let sla = null -if (props.dueAt) { - sla = useSla(ref(props.dueAt), ref(props.actualAt)) -} +let sla = useSla(ref(props.dueAt), ref(props.actualAt)) // Watch for status change and emit watch( diff --git a/frontend/src/views/admin/sla/SLA.vue b/frontend/src/views/admin/sla/SLA.vue index c05f7592..5b04ec48 100644 --- a/frontend/src/views/admin/sla/SLA.vue +++ b/frontend/src/views/admin/sla/SLA.vue @@ -6,11 +6,11 @@ diff --git a/i18n/en.json b/i18n/en.json index f916d66f..dc218754 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -383,7 +383,6 @@ "admin.sla.deleteConfirmation": "This action cannot be undone. This will permanently delete this SLA policy.", "admin.sla.name.valid": "SLA Policy name should be between 1 and 255 characters", "admin.sla.description.valid": "SLA Policy description should be between 1 and 255 characters", - "admin.sla.delay.required": "Delay is required", "admin.sla.firstResponseTime": "First response time", "admin.sla.resolutionTime": "Resolution time", "admin.sla.nextResponseTime": "Next response time", diff --git a/internal/sla/models/models.go b/internal/sla/models/models.go index b302eb44..59e55257 100644 --- a/internal/sla/models/models.go +++ b/internal/sla/models/models.go @@ -18,7 +18,6 @@ type SLAPolicy struct { 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"` NextResponseTime string `db:"next_response_time" json:"next_response_time,omitempty"` ResolutionTime string `db:"resolution_time" json:"resolution_time,omitempty"` Notifications SlaNotifications `db:"notifications" json:"notifications,omitempty"` From b715483260e529bc7fee4ef487f6a154ca5dc0c0 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 25 May 2025 22:42:14 +0530 Subject: [PATCH 11/19] refactor(conversation): reduce DB I/O by using existing appliedSLAID from conversation - Passes appliedSLAID directly to SLA logic instead of refetching - Adds appliedSLAID field to conversation struct (already fetched in get-conversation query) --- internal/conversation/conversation.go | 4 +- internal/conversation/message.go | 36 +++++--- internal/conversation/models/models.go | 63 +++++++------- internal/conversation/queries.sql | 19 +++-- internal/sla/queries.sql | 27 ------ internal/sla/sla.go | 111 ++++++++++--------------- 6 files changed, 114 insertions(+), 146 deletions(-) diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index a10b5c11..a0363150 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -82,8 +82,8 @@ type Manager struct { type slaStore interface { ApplySLA(startTime time.Time, conversationID, assignedTeamID, slaID int) (slaModels.SLAPolicy, error) - CreateNextResponseSLAEvent(conversationID, assignedTeamID int) (time.Time, error) - SetLatestSLAEventMetAt(conversationID int, metric string) (time.Time, error) + CreateNextResponseSLAEvent(conversationID, appliedSLAID, slaPolicyID, assignedTeamID int) (time.Time, error) + SetLatestSLAEventMetAt(appliedSLAID int, metric string) (time.Time, error) } type statusStore interface { diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 64292b41..07cfaba8 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -184,20 +184,32 @@ func (m *Manager) sendOutgoingMessage(message models.Message) { // Update status. m.UpdateMessageStatus(message.UUID, models.MessageStatusSent) - // Update first and last 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()) - m.UpdateConversationLastReplyAt(message.ConversationUUID, message.ConversationID, time.Now()) - // Set the `met_at` timestamp for the latest next response SLA event if the event exists and is not already met. - metAt, err := m.slaStore.SetLatestSLAEventMetAt(message.ConversationID, sla.MetricNextResponse) + // Skip system user replies since we only update timestamps and SLA for human replies. + systemUser, err := m.userStore.GetSystemUser() + if err != nil { + m.lo.Error("error fetching system user", "error", err) + return + } + if message.SenderID != systemUser.ID { + conversation, err := m.GetConversation(message.ConversationID, "") if err != nil { - m.lo.Error("error setting next response SLA event met at", "conversation_id", message.ConversationID, "error", err) + m.lo.Error("error fetching conversation", "conversation_id", message.ConversationID, "error", err) + return + } + + now := time.Now() + if conversation.FirstReplyAt.IsZero() { + m.UpdateConversationFirstReplyAt(message.ConversationUUID, message.ConversationID, now) + } + m.UpdateConversationLastReplyAt(message.ConversationUUID, message.ConversationID, now) + + // Mark latest SLA event for next response as met. + metAt, err := m.slaStore.SetLatestSLAEventMetAt(conversation.AppliedSLAID.Int, sla.MetricNextResponse) + if err != nil { + m.lo.Error("error setting next response SLA event met at", "conversation_id", conversation.ID, "error", err) } else if !metAt.IsZero() { m.BroadcastConversationUpdate(message.ConversationUUID, "next_response_met_at", metAt.Format(time.RFC3339)) } - } else if err != nil { - m.lo.Error("error fetching system user for updating first reply time", "error", err) } } @@ -594,10 +606,10 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { m.lo.Info("no SLA policy applied to conversation, skipping next response SLA event creation") return nil } - if deadline, err := m.slaStore.CreateNextResponseSLAEvent(conversation.ID, conversation.AssignedTeamID.Int); err != nil { + if deadline, err := m.slaStore.CreateNextResponseSLAEvent(conversation.ID, conversation.AppliedSLAID.Int, conversation.SLAPolicyID.Int, conversation.AssignedTeamID.Int); err != nil && !errors.Is(err, sla.ErrUnmetSLAEventAlreadyExists) { m.lo.Error("error creating next response SLA event", "conversation_id", conversation.ID, "error", err) } else if !deadline.IsZero() { - m.lo.Debug("next response SLA event created for conversation", "conversation_id", conversation.ID, "deadline", deadline, "sla_policy_id", conversation.SLAPolicyID.Int) + m.lo.Info("next response SLA event created for conversation", "conversation_id", conversation.ID, "deadline", deadline, "sla_policy_id", conversation.SLAPolicyID.Int) m.BroadcastConversationUpdate(in.Message.ConversationUUID, "next_response_deadline_at", deadline.Format(time.RFC3339)) // Clear next response met at timestamp as this event was just created. m.BroadcastConversationUpdate(in.Message.ConversationUUID, "next_response_met_at", nil) diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go index 5bf9218c..d646d0b8 100644 --- a/internal/conversation/models/models.go +++ b/internal/conversation/models/models.go @@ -86,6 +86,7 @@ type Conversation struct { Contact umodels.User `db:"contact" json:"contact"` SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"` SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"` + AppliedSLAID null.Int `db:"applied_sla_id" json:"applied_sla_id"` 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"` @@ -119,37 +120,37 @@ type NewConversationsStats struct { // Message represents a message in a conversation type Message struct { - ID int `db:"id" json:"id,omitempty"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - UUID string `db:"uuid" json:"uuid"` - Type string `db:"type" json:"type"` - Status string `db:"status" json:"status"` - ConversationID int `db:"conversation_id" json:"conversation_id"` - Content string `db:"content" json:"content"` - TextContent string `db:"text_content" json:"text_content"` - ContentType string `db:"content_type" json:"content_type"` - Private bool `db:"private" json:"private"` - SourceID null.String `db:"source_id" json:"-"` - SenderID int `db:"sender_id" json:"sender_id"` - SenderType string `db:"sender_type" json:"sender_type"` - InboxID int `db:"inbox_id" json:"-"` - Meta json.RawMessage `db:"meta" json:"meta"` - Attachments attachment.Attachments `db:"attachments" json:"attachments"` - ConversationUUID string `db:"conversation_uuid" json:"-"` - From string `db:"from" json:"-"` - Subject string `db:"subject" json:"-"` - Channel string `db:"channel" json:"-"` - To pq.StringArray `db:"to" json:"-"` - CC pq.StringArray `db:"cc" json:"-"` - BCC pq.StringArray `db:"bcc" json:"-"` - References []string `json:"-"` - InReplyTo string `json:"-"` - Headers textproto.MIMEHeader `json:"-"` - AltContent string `db:"-" json:"-"` - Media []mmodels.Media `db:"-" json:"-"` - IsCSAT bool `db:"-" json:"-"` - Total int `db:"total" json:"-"` + ID int `db:"id" json:"id,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + UUID string `db:"uuid" json:"uuid"` + Type string `db:"type" json:"type"` + Status string `db:"status" json:"status"` + ConversationID int `db:"conversation_id" json:"conversation_id"` + Content string `db:"content" json:"content"` + TextContent string `db:"text_content" json:"text_content"` + ContentType string `db:"content_type" json:"content_type"` + Private bool `db:"private" json:"private"` + SourceID null.String `db:"source_id" json:"-"` + SenderID int `db:"sender_id" json:"sender_id"` + SenderType string `db:"sender_type" json:"sender_type"` + InboxID int `db:"inbox_id" json:"-"` + Meta json.RawMessage `db:"meta" json:"meta"` + Attachments attachment.Attachments `db:"attachments" json:"attachments"` + ConversationUUID string `db:"conversation_uuid" json:"-"` + From string `db:"from" json:"-"` + Subject string `db:"subject" json:"-"` + Channel string `db:"channel" json:"-"` + To pq.StringArray `db:"to" json:"-"` + CC pq.StringArray `db:"cc" json:"-"` + BCC pq.StringArray `db:"bcc" json:"-"` + References []string `json:"-"` + InReplyTo string `json:"-"` + Headers textproto.MIMEHeader `json:"-"` + AltContent string `db:"-" json:"-"` + Media []mmodels.Media `db:"-" json:"-"` + IsCSAT bool `db:"-" json:"-"` + Total int `db:"total" json:"-"` } // CensorCSATContent redacts the content of a CSAT message to prevent leaking the CSAT survey public link. diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index c742ddcb..9400f0d1 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -67,15 +67,16 @@ SELECT conversation_priorities.name as priority, as_latest.first_response_deadline_at, as_latest.resolution_deadline_at, - next_sla.deadline_at AS next_response_deadline_at, - next_sla.met_at as next_response_met_at + as_latest.id as applied_sla_id, + nxt_resp_event.deadline_at AS next_response_deadline_at, + nxt_resp_event.met_at as next_response_met_at 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 id, first_response_deadline_at, resolution_deadline_at, status + SELECT id, first_response_deadline_at, resolution_deadline_at FROM applied_slas WHERE conversation_id = conversations.id ORDER BY created_at DESC LIMIT 1 @@ -87,7 +88,7 @@ SELECT AND se.type = 'next_response' ORDER BY se.created_at DESC LIMIT 1 - ) next_sla ON true + ) nxt_resp_event ON true WHERE 1=1 %s -- name: get-conversation @@ -137,9 +138,9 @@ SELECT ct.custom_attributes as "contact.custom_attributes", as_latest.first_response_deadline_at, as_latest.resolution_deadline_at, - next_sla.deadline_at AS next_response_deadline_at, - next_sla.met_at as next_response_met_at, - as_latest.id as applied_sla_id + as_latest.id as applied_sla_id, + nxt_resp_event.deadline_at AS next_response_deadline_at, + nxt_resp_event.met_at as next_response_met_at FROM conversations c JOIN users ct ON c.contact_id = ct.id JOIN inboxes inb ON c.inbox_id = inb.id @@ -148,7 +149,7 @@ 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 LATERAL ( - SELECT id, first_response_deadline_at, resolution_deadline_at, status + SELECT id, first_response_deadline_at, resolution_deadline_at FROM applied_slas WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1 @@ -160,7 +161,7 @@ LEFT JOIN LATERAL ( AND se.type = 'next_response' ORDER BY se.created_at DESC LIMIT 1 -) next_sla ON true +) nxt_resp_event ON true WHERE ($1 > 0 AND c.id = $1) OR diff --git a/internal/sla/queries.sql b/internal/sla/queries.sql index da68f821..4556af38 100644 --- a/internal/sla/queries.sql +++ b/internal/sla/queries.sql @@ -151,33 +151,6 @@ FROM applied_slas a INNER JOIN conversations c on a.conversation_id = c.id LEFT JOIN conversation_statuses s ON c.status_id = s.id WHERE a.id = $1; --- name: get-latest-applied-sla-for-conversation -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, - s.name as conversation_status -FROM applied_slas a -INNER JOIN conversations c ON a.conversation_id = c.id -LEFT JOIN conversation_statuses s ON c.status_id = s.id -WHERE a.conversation_id = $1 -ORDER BY a.created_at DESC -LIMIT 1; - -- name: mark-notification-processed UPDATE scheduled_sla_notifications SET processed_at = NOW(), diff --git a/internal/sla/sla.go b/internal/sla/sla.go index 454a908e..8fc1488d 100644 --- a/internal/sla/sla.go +++ b/internal/sla/sla.go @@ -5,6 +5,7 @@ import ( "database/sql" "embed" "encoding/json" + "errors" "fmt" "strconv" "sync" @@ -31,7 +32,8 @@ import ( var ( //go:embed queries.sql - efs embed.FS + efs embed.FS + ErrUnmetSLAEventAlreadyExists = errors.New("unmet SLA event already exists, cannot create a new one for the same applied SLA and metric") ) const ( @@ -104,28 +106,27 @@ 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"` - GetAppliedSLA *sqlx.Stmt `query:"get-applied-sla"` - GetSLAEventByID *sqlx.Stmt `query:"get-sla-event-by-id"` - GetScheduledSLANotifications *sqlx.Stmt `query:"get-scheduled-sla-notifications"` - GetLatestAppliedSLAForConversation *sqlx.Stmt `query:"get-latest-applied-sla-for-conversation"` - InsertScheduledSLANotification *sqlx.Stmt `query:"insert-scheduled-sla-notification"` - InsertSLA *sqlx.Stmt `query:"insert-sla-policy"` - InsertNextResponseSLAEvent *sqlx.Stmt `query:"insert-next-response-sla-event"` - 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"` - SetConversationNextSLADeadline *sqlx.Stmt `query:"set-conversation-sla-deadline"` - GetPendingSLAEvents *sqlx.Stmt `query:"get-pending-sla-events"` - UpdateSLAStatus *sqlx.Stmt `query:"update-sla-status"` - MarkNotificationProcessed *sqlx.Stmt `query:"mark-notification-processed"` - MarkSLAEventAsBreached *sqlx.Stmt `query:"mark-sla-event-as-breached"` - MarkSLAEventAsMet *sqlx.Stmt `query:"mark-sla-event-as-met"` - SetLatestSLAEventMetAt *sqlx.Stmt `query:"set-latest-sla-event-met-at"` + GetSLA *sqlx.Stmt `query:"get-sla-policy"` + GetAllSLA *sqlx.Stmt `query:"get-all-sla-policies"` + GetAppliedSLA *sqlx.Stmt `query:"get-applied-sla"` + GetSLAEventByID *sqlx.Stmt `query:"get-sla-event-by-id"` + GetScheduledSLANotifications *sqlx.Stmt `query:"get-scheduled-sla-notifications"` + InsertScheduledSLANotification *sqlx.Stmt `query:"insert-scheduled-sla-notification"` + InsertSLA *sqlx.Stmt `query:"insert-sla-policy"` + InsertNextResponseSLAEvent *sqlx.Stmt `query:"insert-next-response-sla-event"` + 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"` + SetConversationNextSLADeadline *sqlx.Stmt `query:"set-conversation-sla-deadline"` + GetPendingSLAEvents *sqlx.Stmt `query:"get-pending-sla-events"` + UpdateSLAStatus *sqlx.Stmt `query:"update-sla-status"` + MarkNotificationProcessed *sqlx.Stmt `query:"mark-notification-processed"` + MarkSLAEventAsBreached *sqlx.Stmt `query:"mark-sla-event-as-breached"` + MarkSLAEventAsMet *sqlx.Stmt `query:"mark-sla-event-as-met"` + SetLatestSLAEventMetAt *sqlx.Stmt `query:"set-latest-sla-event-met-at"` } // New creates a new SLA manager. @@ -267,21 +268,11 @@ func (m *Manager) ApplySLA(startTime time.Time, conversationID, assignedTeamID, } // CreateNextResponseSLAEvent creates a next response SLA event for a conversation. -func (m *Manager) CreateNextResponseSLAEvent(conversationID, assignedTeamID int) (time.Time, error) { - // Fetch the latest applied SLA for the conversation. - var appliedSLA models.AppliedSLA - if err := m.q.GetLatestAppliedSLAForConversation.Get(&appliedSLA, conversationID); err != nil { - if err == sql.ErrNoRows { - return time.Time{}, fmt.Errorf("no applied SLA found for conversation: %d", conversationID) - } - m.lo.Error("error fetching latest applied SLA for conversation", "error", err) - return time.Time{}, fmt.Errorf("fetching latest applied SLA for conversation: %w", err) - } - +func (m *Manager) CreateNextResponseSLAEvent(conversationID, appliedSLAID, slaPolicyID, assignedTeamID int) (time.Time, error) { var slaPolicy models.SLAPolicy - if err := m.q.GetSLA.Get(&slaPolicy, appliedSLA.SLAPolicyID); err != nil { + if err := m.q.GetSLA.Get(&slaPolicy, slaPolicyID); err != nil { if err == sql.ErrNoRows { - return time.Time{}, fmt.Errorf("SLA policy not found: %d", appliedSLA.SLAPolicyID) + return time.Time{}, fmt.Errorf("SLA policy not found: %d", slaPolicyID) } m.lo.Error("error fetching SLA policy", "error", err) return time.Time{}, fmt.Errorf("fetching SLA policy: %w", err) @@ -290,10 +281,10 @@ func (m *Manager) CreateNextResponseSLAEvent(conversationID, assignedTeamID int) if slaPolicy.NextResponseTime == "" { m.lo.Info("no next response time set for SLA policy, skipping event creation", "conversation_id", conversationID, - "policy_id", appliedSLA.SLAPolicyID, - "applied_sla_id", appliedSLA.ID, + "policy_id", slaPolicyID, + "applied_sla_id", appliedSLAID, ) - return time.Time{}, fmt.Errorf("no next response time set for SLA policy: %d, applied_sla: %d", appliedSLA.SLAPolicyID, appliedSLA.ID) + return time.Time{}, fmt.Errorf("no next response time set for SLA policy: %d, applied_sla: %d", slaPolicyID, appliedSLAID) } // Calculate the deadline for the next response SLA event. @@ -306,28 +297,28 @@ func (m *Manager) CreateNextResponseSLAEvent(conversationID, assignedTeamID int) if deadlines.NextResponse.IsZero() { m.lo.Info("next response deadline is zero, skipping event creation", "conversation_id", conversationID, - "policy_id", appliedSLA.SLAPolicyID, - "applied_sla_id", appliedSLA.ID, + "policy_id", slaPolicyID, + "applied_sla_id", appliedSLAID, ) - return time.Time{}, fmt.Errorf("next response deadline is zero for conversation: %d, policy: %d, applied_sla: %d", conversationID, appliedSLA.SLAPolicyID, appliedSLA.ID) + return time.Time{}, fmt.Errorf("next response deadline is zero for conversation: %d, policy: %d, applied_sla: %d", conversationID, slaPolicyID, appliedSLAID) } var slaEventID int - if err := m.q.InsertNextResponseSLAEvent.QueryRow(appliedSLA.ID, appliedSLA.SLAPolicyID, deadlines.NextResponse).Scan(&slaEventID); err != nil { + if err := m.q.InsertNextResponseSLAEvent.QueryRow(appliedSLAID, slaPolicyID, deadlines.NextResponse).Scan(&slaEventID); err != nil { if err == sql.ErrNoRows { - m.lo.Debug("unmet SLA event for next response already exists, skipping creation", + m.lo.Info("skipping next response SLA event creation; unmet event already exists", "conversation_id", conversationID, "policy_id", slaPolicy.ID, - "applied_sla_id", appliedSLA.ID, + "applied_sla_id", appliedSLAID, ) - return time.Time{}, fmt.Errorf("unmet next response SLA event already exists for conversation: %d, policy: %d, applied_sla: %d", conversationID, slaPolicy.ID, appliedSLA.ID) + return time.Time{}, ErrUnmetSLAEventAlreadyExists } m.lo.Error("error inserting SLA event", "error", err, "conversation_id", conversationID, - "applied_sla_id", appliedSLA.ID, + "applied_sla_id", appliedSLAID, ) - return time.Time{}, fmt.Errorf("inserting SLA event (applied_sla: %d): %w", appliedSLA.ID, err) + return time.Time{}, fmt.Errorf("inserting SLA event (applied_sla: %d): %w", appliedSLAID, err) } // Update next SLA deadline (SLA target) in the conversation. @@ -335,36 +326,26 @@ func (m *Manager) CreateNextResponseSLAEvent(conversationID, assignedTeamID int) m.lo.Error("error updating conversation next SLA deadline", "error", err, "conversation_id", conversationID, - "applied_sla_id", appliedSLA.ID, + "applied_sla_id", appliedSLAID, ) - return time.Time{}, fmt.Errorf("updating conversation next SLA deadline (applied_sla: %d): %w", appliedSLA.ID, err) + return time.Time{}, fmt.Errorf("updating conversation next SLA deadline (applied_sla: %d): %w", appliedSLAID, err) } // Create notification schedule for the next response SLA event. deadlines.FirstResponse = time.Time{} deadlines.Resolution = time.Time{} - m.createNotificationSchedule(slaPolicy.Notifications, appliedSLA.ID, null.IntFrom(slaEventID), deadlines, Breaches{}) + m.createNotificationSchedule(slaPolicy.Notifications, appliedSLAID, null.IntFrom(slaEventID), deadlines, Breaches{}) return deadlines.NextResponse, nil } // SetLatestSLAEventMetAt marks the latest SLA event as met for a given applied SLA. -func (m *Manager) SetLatestSLAEventMetAt(conversationID int, metric string) (time.Time, error) { - var appliedSLA models.AppliedSLA - if err := m.q.GetLatestAppliedSLAForConversation.Get(&appliedSLA, conversationID); err != nil { - if err == sql.ErrNoRows { - m.lo.Warn("no applied SLA found for conversation to update met at", "conversation_id", conversationID) - return time.Time{}, fmt.Errorf("no applied SLA found for conversation: %d to update met at", conversationID) - } - m.lo.Error("error fetching latest applied SLA for conversation", "error", err) - return time.Time{}, fmt.Errorf("fetching latest applied SLA for conversation: %w", err) - } - +func (m *Manager) SetLatestSLAEventMetAt(appliedSLAID int, metric string) (time.Time, error) { var metAt time.Time - if err := m.q.SetLatestSLAEventMetAt.QueryRow(appliedSLA.ID, metric).Scan(&metAt); err != nil { + if err := m.q.SetLatestSLAEventMetAt.QueryRow(appliedSLAID, metric).Scan(&metAt); err != nil { if err == sql.ErrNoRows { - m.lo.Warn("no SLA event found for applied SLA and metric to update met at", "applied_sla_id", appliedSLA.ID, "metric", metric) - return metAt, fmt.Errorf("no SLA event found for applied SLA ID: %d and metric: %s to update met at", appliedSLA.ID, metric) + m.lo.Warn("no SLA event found for applied SLA and metric to update met at", "applied_sla_id", appliedSLAID, "metric", metric) + return metAt, fmt.Errorf("no SLA event found for applied SLA ID: %d and metric: %s to update met at", appliedSLAID, metric) } m.lo.Error("error marking SLA event as met", "error", err) return metAt, fmt.Errorf("marking SLA event as met: %w", err) From 5583b472f7243b1ab91d4ab0b6d94dbf41212898 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 25 May 2025 22:52:16 +0530 Subject: [PATCH 12/19] fix: change debug logs to info level for scheduled SLA notifications --- internal/sla/sla.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/sla/sla.go b/internal/sla/sla.go index 8fc1488d..fdca7928 100644 --- a/internal/sla/sla.go +++ b/internal/sla/sla.go @@ -484,7 +484,7 @@ func (m *Manager) SendNotifications(ctx context.Context) error { } m.lo.Error("error fetching scheduled SLA notifications", "error", err) } else if len(notifications) > 0 { - m.lo.Debug("found scheduled SLA notifications", "count", len(notifications)) + m.lo.Info("found scheduled SLA notifications", "count", len(notifications)) for _, notification := range notifications { if ctx.Err() != nil { return ctx.Err() @@ -493,7 +493,7 @@ func (m *Manager) SendNotifications(ctx context.Context) error { m.lo.Error("error sending notification", "error", err) } } - m.lo.Debug("sent SLA notifications", "count", len(notifications)) + m.lo.Info("sent SLA notifications", "count", len(notifications)) } <-ticker.C } From d1478e1971c674dbb8319e01de3b3cbac19e6f3e Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 25 May 2025 23:08:22 +0530 Subject: [PATCH 13/19] fix: clarify comment on SendNotification method regarding SLA linkage --- internal/sla/sla.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/sla/sla.go b/internal/sla/sla.go index fdca7928..4ea5a8cc 100644 --- a/internal/sla/sla.go +++ b/internal/sla/sla.go @@ -500,7 +500,7 @@ func (m *Manager) SendNotifications(ctx context.Context) error { } } -// SendNotification sends a SLA notification to agents, a schedule notification can be linked to a specific SLA event or applied SLA. +// SendNotification sends a SLA notification to agents, a schedule notification is always linked to an applied SLA and optionally to a SLA event. func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANotification) error { var ( appliedSLA models.AppliedSLA From 506bb91e20281a6d0a26feb667edebc787e8cafb Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Mon, 26 May 2025 00:06:13 +0530 Subject: [PATCH 14/19] fix: make sla metric timestamps nullable --- cmd/sla.go | 18 +++++++++--------- frontend/src/features/admin/sla/formSchema.js | 6 +++--- internal/conversation/message.go | 4 ++-- internal/sla/models/models.go | 10 +++++----- internal/sla/sla.go | 15 ++++++++------- 5 files changed, 27 insertions(+), 26 deletions(-) diff --git a/cmd/sla.go b/cmd/sla.go index e54e7c9b..c6f2fb2e 100644 --- a/cmd/sla.go +++ b/cmd/sla.go @@ -110,7 +110,7 @@ func validateSLA(app *App, sla *smodels.SLAPolicy) error { if sla.Name == "" { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil) } - if sla.FirstResponseTime == "" && sla.NextResponseTime == "" && sla.ResolutionTime == "" { + if sla.FirstResponseTime.String == "" && sla.NextResponseTime.String == "" && sla.ResolutionTime.String == "" { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "At least one of `first_response_time`, `next_response_time`, or `resolution_time` must be provided."), nil) } @@ -144,8 +144,8 @@ func validateSLA(app *App, sla *smodels.SLAPolicy) error { } // Validate first response time duration string if not empty. - if sla.FirstResponseTime != "" { - frt, err := time.ParseDuration(sla.FirstResponseTime) + if sla.FirstResponseTime.String != "" { + frt, err := time.ParseDuration(sla.FirstResponseTime.String) if err != nil { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil) } @@ -155,8 +155,8 @@ func validateSLA(app *App, sla *smodels.SLAPolicy) error { } // Validate resolution time duration string if not empty. - if sla.ResolutionTime != "" { - rt, err := time.ParseDuration(sla.ResolutionTime) + if sla.ResolutionTime.String != "" { + rt, err := time.ParseDuration(sla.ResolutionTime.String) if err != nil { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil) } @@ -164,8 +164,8 @@ func validateSLA(app *App, sla *smodels.SLAPolicy) error { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil) } // Compare with first response time if both are present. - if sla.FirstResponseTime != "" { - frt, _ := time.ParseDuration(sla.FirstResponseTime) + if sla.FirstResponseTime.String != "" { + frt, _ := time.ParseDuration(sla.FirstResponseTime.String) if frt > rt { return envelope.NewError(envelope.InputError, app.i18n.T("sla.firstResponseTimeAfterResolution"), nil) } @@ -173,8 +173,8 @@ func validateSLA(app *App, sla *smodels.SLAPolicy) error { } // Validate next response time duration string if not empty. - if sla.NextResponseTime != "" { - nrt, err := time.ParseDuration(sla.NextResponseTime) + if sla.NextResponseTime.String != "" { + nrt, err := time.ParseDuration(sla.NextResponseTime.String) if err != nil { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil) } diff --git a/frontend/src/features/admin/sla/formSchema.js b/frontend/src/features/admin/sla/formSchema.js index 5833ee46..ac07a318 100644 --- a/frontend/src/features/admin/sla/formSchema.js +++ b/frontend/src/features/admin/sla/formSchema.js @@ -12,13 +12,13 @@ export const createFormSchema = (t) => .string() .min(1, { message: t('admin.sla.description.valid') }) .max(255, { message: t('admin.sla.description.valid') }), - first_response_time: z.string().optional().refine(val => !val || isGoHourMinuteDuration(val), { + first_response_time: z.string().nullable().optional().refine(val => !val || isGoHourMinuteDuration(val), { message: t('globals.messages.goHourMinuteDuration'), }), - resolution_time: z.string().optional().refine(val => !val || isGoHourMinuteDuration(val), { + resolution_time: z.string().nullable().optional().refine(val => !val || isGoHourMinuteDuration(val), { message: t('globals.messages.goHourMinuteDuration'), }), - next_response_time: z.string().optional().refine(val => !val || isGoHourMinuteDuration(val), { + next_response_time: z.string().nullable().optional().refine(val => !val || isGoHourMinuteDuration(val), { message: t('globals.messages.goHourMinuteDuration'), }), notifications: z diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 07cfaba8..613a5862 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -205,8 +205,8 @@ func (m *Manager) sendOutgoingMessage(message models.Message) { // Mark latest SLA event for next response as met. metAt, err := m.slaStore.SetLatestSLAEventMetAt(conversation.AppliedSLAID.Int, sla.MetricNextResponse) - if err != nil { - m.lo.Error("error setting next response SLA event met at", "conversation_id", conversation.ID, "error", err) + if err != nil && !errors.Is(err, sla.ErrLatestSLAEventNotFound) { + m.lo.Error("error setting next response SLA event `met_at`", "conversation_id", conversation.ID, "metric", sla.MetricNextResponse, "applied_sla_id", conversation.AppliedSLAID.Int, "error", err) } else if !metAt.IsZero() { m.BroadcastConversationUpdate(message.ConversationUUID, "next_response_met_at", metAt.Format(time.RFC3339)) } diff --git a/internal/sla/models/models.go b/internal/sla/models/models.go index 59e55257..0b585cea 100644 --- a/internal/sla/models/models.go +++ b/internal/sla/models/models.go @@ -16,11 +16,11 @@ type SLAPolicy struct { 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"` - NextResponseTime string `db:"next_response_time" json:"next_response_time,omitempty"` - ResolutionTime string `db:"resolution_time" json:"resolution_time,omitempty"` - Notifications SlaNotifications `db:"notifications" json:"notifications,omitempty"` + Description string `db:"description" json:"description"` + FirstResponseTime null.String `db:"first_response_time" json:"first_response_time"` + NextResponseTime null.String `db:"next_response_time" json:"next_response_time"` + ResolutionTime null.String `db:"resolution_time" json:"resolution_time"` + Notifications SlaNotifications `db:"notifications" json:"notifications"` } type SlaNotifications []SlaNotification diff --git a/internal/sla/sla.go b/internal/sla/sla.go index 4ea5a8cc..570cdf73 100644 --- a/internal/sla/sla.go +++ b/internal/sla/sla.go @@ -34,6 +34,7 @@ var ( //go:embed queries.sql efs embed.FS ErrUnmetSLAEventAlreadyExists = errors.New("unmet SLA event already exists, cannot create a new one for the same applied SLA and metric") + ErrLatestSLAEventNotFound = errors.New("latest SLA event not found for the applied SLA and metric") ) const ( @@ -162,7 +163,7 @@ func (m *Manager) GetAll() ([]models.SLAPolicy, error) { } // Create creates a new SLA policy. -func (m *Manager) Create(name, description string, firstResponseTime, resolutionTime, nextResponseTime string, notifications models.SlaNotifications) error { +func (m *Manager) Create(name, description string, firstResponseTime, resolutionTime, nextResponseTime null.String, notifications models.SlaNotifications) error { if _, err := m.q.InsertSLA.Exec(name, description, firstResponseTime, resolutionTime, nextResponseTime, notifications); err != nil { m.lo.Error("error inserting SLA", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.sla}"), nil) @@ -171,7 +172,7 @@ func (m *Manager) Create(name, description string, firstResponseTime, resolution } // Update updates a SLA policy. -func (m *Manager) Update(id int, name, description string, firstResponseTime, resolutionTime, nextResponseTime string, notifications models.SlaNotifications) error { +func (m *Manager) Update(id int, name, description string, firstResponseTime, resolutionTime, nextResponseTime null.String, notifications models.SlaNotifications) error { if _, err := m.q.UpdateSLA.Exec(id, name, description, firstResponseTime, resolutionTime, nextResponseTime, notifications); err != nil { m.lo.Error("error updating SLA", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.sla}"), nil) @@ -220,13 +221,13 @@ func (m *Manager) GetDeadlines(startTime time.Time, slaPolicyID, assignedTeamID return deadline, nil } - if deadlines.FirstResponse, err = calculateDeadline(sla.FirstResponseTime); err != nil { + if deadlines.FirstResponse, err = calculateDeadline(sla.FirstResponseTime.String); err != nil { return deadlines, err } - if deadlines.Resolution, err = calculateDeadline(sla.ResolutionTime); err != nil { + if deadlines.Resolution, err = calculateDeadline(sla.ResolutionTime.String); err != nil { return deadlines, err } - if deadlines.NextResponse, err = calculateDeadline(sla.NextResponseTime); err != nil { + if deadlines.NextResponse, err = calculateDeadline(sla.NextResponseTime.String); err != nil { return deadlines, err } return deadlines, nil @@ -278,7 +279,7 @@ func (m *Manager) CreateNextResponseSLAEvent(conversationID, appliedSLAID, slaPo return time.Time{}, fmt.Errorf("fetching SLA policy: %w", err) } - if slaPolicy.NextResponseTime == "" { + if slaPolicy.NextResponseTime.String == "" { m.lo.Info("no next response time set for SLA policy, skipping event creation", "conversation_id", conversationID, "policy_id", slaPolicyID, @@ -345,7 +346,7 @@ func (m *Manager) SetLatestSLAEventMetAt(appliedSLAID int, metric string) (time. if err := m.q.SetLatestSLAEventMetAt.QueryRow(appliedSLAID, metric).Scan(&metAt); err != nil { if err == sql.ErrNoRows { m.lo.Warn("no SLA event found for applied SLA and metric to update met at", "applied_sla_id", appliedSLAID, "metric", metric) - return metAt, fmt.Errorf("no SLA event found for applied SLA ID: %d and metric: %s to update met at", appliedSLAID, metric) + return metAt, ErrLatestSLAEventNotFound } m.lo.Error("error marking SLA event as met", "error", err) return metAt, fmt.Errorf("marking SLA event as met: %w", err) From 54bad59392293b7ba68811e1535800772e506f95 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Mon, 26 May 2025 00:06:42 +0530 Subject: [PATCH 15/19] fix: getConversation to handle nullable UUID parameter --- internal/conversation/conversation.go | 15 +++++++++++---- internal/conversation/queries.sql | 6 +++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index a0363150..353ba143 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -247,15 +247,22 @@ func (c *Manager) CreateConversation(contactID, contactChannelID, inboxID int, l return id, uuid, nil } -// GetConversation retrieves a conversation by its UUID. +// GetConversation retrieves a conversation by its ID or UUID. func (c *Manager) GetConversation(id int, uuid string) (models.Conversation, error) { var conversation models.Conversation - if err := c.q.GetConversation.Get(&conversation, id, uuid); err != nil { + var uuidParam any + if uuid != "" { + uuidParam = uuid + } + + if err := c.q.GetConversation.Get(&conversation, id, uuidParam); err != nil { if err == sql.ErrNoRows { - return conversation, envelope.NewError(envelope.InputError, c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.conversation}"), nil) + return conversation, envelope.NewError(envelope.InputError, + c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.conversation}"), nil) } c.lo.Error("error fetching conversation", "error", err) - return conversation, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil) + return conversation, envelope.NewError(envelope.GeneralError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil) } // Strip name and extract plain email from "Name " diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index 9400f0d1..f525fae8 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -163,9 +163,9 @@ LEFT JOIN LATERAL ( LIMIT 1 ) nxt_resp_event ON true WHERE - ($1 > 0 AND c.id = $1) - OR - ($2 != '' AND c.uuid = $2::uuid) + ($1 > 0 AND c.id = $1) + OR + ($2::uuid IS NOT NULL AND c.uuid = $2::uuid) -- name: get-conversations-created-after From 88ef5d26db167f198bd26f4f407c63986c9331ba Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Mon, 26 May 2025 01:04:42 +0530 Subject: [PATCH 16/19] fix: update sla timestamps to nullable types --- internal/sla/sla.go | 46 ++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/internal/sla/sla.go b/internal/sla/sla.go index 570cdf73..d73d7227 100644 --- a/internal/sla/sla.go +++ b/internal/sla/sla.go @@ -77,16 +77,16 @@ type Opts struct { // Deadlines holds the deadlines for an SLA policy. type Deadlines struct { - FirstResponse time.Time - Resolution time.Time - NextResponse time.Time + FirstResponse null.Time + Resolution null.Time + NextResponse null.Time } // Breaches holds the breach timestamps for an SLA policy. type Breaches struct { - FirstResponse time.Time - Resolution time.Time - NextResponse time.Time + FirstResponse null.Time + Resolution null.Time + NextResponse null.Time } type teamStore interface { @@ -206,19 +206,19 @@ func (m *Manager) GetDeadlines(startTime time.Time, slaPolicyID, assignedTeamID } // Helper function to calculate deadlines by parsing the duration string. - calculateDeadline := func(durationStr string) (time.Time, error) { + calculateDeadline := func(durationStr string) (null.Time, error) { if durationStr == "" { - return time.Time{}, nil + return null.Time{}, nil } dur, err := time.ParseDuration(durationStr) if err != nil { - return time.Time{}, fmt.Errorf("parsing SLA duration (%s): %v", durationStr, err) + return null.Time{}, fmt.Errorf("parsing SLA duration (%s): %v", durationStr, err) } deadline, err := m.CalculateDeadline(startTime, int(dur.Minutes()), businessHrs, timezone) if err != nil { - return time.Time{}, err + return null.Time{}, err } - return deadline, nil + return null.TimeFrom(deadline), nil } if deadlines.FirstResponse, err = calculateDeadline(sla.FirstResponseTime.String); err != nil { @@ -243,7 +243,7 @@ func (m *Manager) ApplySLA(startTime time.Time, conversationID, assignedTeamID, return sla, err } // Next response is not set at this point, next response are stored in SLA events as there can be multiple entries for next response. - deadlines.NextResponse = time.Time{} + deadlines.NextResponse = null.Time{} // Insert applied SLA entry. var appliedSLAID int @@ -333,11 +333,11 @@ func (m *Manager) CreateNextResponseSLAEvent(conversationID, appliedSLAID, slaPo } // Create notification schedule for the next response SLA event. - deadlines.FirstResponse = time.Time{} - deadlines.Resolution = time.Time{} + deadlines.FirstResponse = null.Time{} + deadlines.Resolution = null.Time{} m.createNotificationSchedule(slaPolicy.Notifications, appliedSLAID, null.IntFrom(slaEventID), deadlines, Breaches{}) - return deadlines.NextResponse, nil + return deadlines.NextResponse.Time, nil } // SetLatestSLAEventMetAt marks the latest SLA event as met for a given applied SLA. @@ -418,7 +418,7 @@ func (m *Manager) evaluatePendingSLAEvents(ctx context.Context) error { slaPolicyCache[event.SlaPolicyID] = slaPolicy } m.createNotificationSchedule(slaPolicy.Notifications, event.AppliedSLAID, null.IntFrom(event.ID), Deadlines{}, Breaches{ - NextResponse: time.Now(), + NextResponse: null.TimeFrom(time.Now()), }) } } @@ -776,13 +776,13 @@ func (m *Manager) createNotificationSchedule(notifications models.SlaNotificatio notif.Metric = MetricAll } - schedule := func(target time.Time, metricType string) { - if !target.IsZero() && (notif.Metric == metricType || notif.Metric == MetricAll) { + schedule := func(target null.Time, metricType string) { + if target.Valid && (notif.Metric == metricType || notif.Metric == MetricAll) { var sendAt time.Time if notif.Type == NotificationTypeWarning { - sendAt = target.Add(-delayDur) + sendAt = target.Time.Add(-delayDur) } else { - sendAt = target.Add(delayDur) + sendAt = target.Time.Add(delayDur) } scheduleNotification(sendAt, metricType, notif.Type, notif.Recipients) } @@ -899,11 +899,11 @@ func (m *Manager) updateBreachAt(appliedSLAID, slaPolicyID int, metric string) e return err } - var firstResponse, resolution time.Time + var firstResponse, resolution null.Time if metric == MetricFirstResponse { - firstResponse = time.Now() + firstResponse = null.TimeFrom(time.Now()) } else if metric == MetricResolution { - resolution = time.Now() + resolution = null.TimeFrom(time.Now()) } // Create notification schedule. From 70b5da29e10c9f6054f73e386fa7a41febb178e9 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Mon, 26 May 2025 03:48:34 +0530 Subject: [PATCH 17/19] fix: change SLA deadline fields to use nullable types --- internal/sla/models/models.go | 4 ++-- internal/sla/sla.go | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/sla/models/models.go b/internal/sla/models/models.go index 0b585cea..547ad231 100644 --- a/internal/sla/models/models.go +++ b/internal/sla/models/models.go @@ -75,8 +75,8 @@ type AppliedSLA struct { 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"` + FirstResponseDeadlineAt null.Time `db:"first_response_deadline_at"` + ResolutionDeadlineAt null.Time `db:"resolution_deadline_at"` FirstResponseBreachedAt null.Time `db:"first_response_breached_at"` ResolutionBreachedAt null.Time `db:"resolution_breached_at"` FirstResponseMetAt null.Time `db:"first_response_met_at"` diff --git a/internal/sla/sla.go b/internal/sla/sla.go index d73d7227..d9d67310 100644 --- a/internal/sla/sla.go +++ b/internal/sla/sla.go @@ -617,10 +617,10 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti switch scheduledNotification.Metric { case MetricFirstResponse: - dueIn = getFriendlyDuration(appliedSLA.FirstResponseDeadlineAt) + dueIn = getFriendlyDuration(appliedSLA.FirstResponseDeadlineAt.Time) overdueBy = getFriendlyDuration(appliedSLA.FirstResponseBreachedAt.Time) case MetricResolution: - dueIn = getFriendlyDuration(appliedSLA.ResolutionDeadlineAt) + dueIn = getFriendlyDuration(appliedSLA.ResolutionDeadlineAt.Time) overdueBy = getFriendlyDuration(appliedSLA.ResolutionBreachedAt.Time) case MetricNextResponse: dueIn = getFriendlyDuration(slaEvent.DeadlineAt) @@ -828,7 +828,7 @@ func (m *Manager) evaluateSLA(sla models.AppliedSLA) 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") + m.lo.Warn("deadline zero, skipping checking the deadline", "conversation_id", sla.ConversationID, "applied_sla_id", sla.ID, "metric", metric) return nil } @@ -860,7 +860,7 @@ func (m *Manager) evaluateSLA(sla models.AppliedSLA) error { // 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 { + if err := checkDeadline(sla.FirstResponseDeadlineAt.Time, sla.ConversationFirstResponseAt, MetricFirstResponse); err != nil { return err } } @@ -868,7 +868,7 @@ func (m *Manager) evaluateSLA(sla models.AppliedSLA) error { // 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", MetricResolution) - if err := checkDeadline(sla.ResolutionDeadlineAt, sla.ConversationResolvedAt, MetricResolution); err != nil { + if err := checkDeadline(sla.ResolutionDeadlineAt.Time, sla.ConversationResolvedAt, MetricResolution); err != nil { return err } } From 3998798e54e3db5e520f3a9f4cdd8698c53116db Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 27 May 2025 02:45:43 +0530 Subject: [PATCH 18/19] refactor: rename SQL query names and struct fields for clarity and consistency --- internal/sla/queries.sql | 18 ++--- internal/sla/sla.go | 147 +++++++++++++++++++++++---------------- 2 files changed, 95 insertions(+), 70 deletions(-) diff --git a/internal/sla/queries.sql b/internal/sla/queries.sql index 4556af38..b1f54937 100644 --- a/internal/sla/queries.sql +++ b/internal/sla/queries.sql @@ -47,7 +47,7 @@ FROM new_sla ns WHERE c.id = ns.conversation_id RETURNING ns.id; --- name: get-pending-slas +-- name: get-pending-applied-sla -- 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 @@ -55,21 +55,21 @@ FROM applied_slas a JOIN conversations c ON a.conversation_id = c.id and c.sla_policy_id = a.sla_policy_id WHERE a.status = 'pending'::applied_sla_status; --- name: update-breach +-- name: update-applied-sla-breached-at UPDATE applied_slas SET first_response_breached_at = CASE WHEN $2 = 'first_response' THEN NOW() ELSE first_response_breached_at END, resolution_breached_at = CASE WHEN $2 = 'resolution' THEN NOW() ELSE resolution_breached_at END, updated_at = NOW() WHERE id = $1; --- name: update-met +-- name: update-applied-sla-met-at UPDATE applied_slas SET first_response_met_at = CASE WHEN $2 = 'first_response' THEN NOW() ELSE first_response_met_at END, resolution_met_at = CASE WHEN $2 = 'resolution' THEN NOW() ELSE resolution_met_at END, updated_at = NOW() WHERE id = $1; --- name: set-conversation-sla-deadline +-- name: update-conversation-sla-deadline UPDATE conversations c SET next_sla_deadline_at = CASE -- If resolved or closed, clear the deadline @@ -98,7 +98,7 @@ FROM applied_slas a WHERE a.conversation_id = c.id AND c.id = $1; --- name: update-sla-status +-- name: update-applied-sla-status UPDATE applied_slas SET status = CASE @@ -151,7 +151,7 @@ FROM applied_slas a INNER JOIN conversations c on a.conversation_id = c.id LEFT JOIN conversation_statuses s ON c.status_id = s.id WHERE a.id = $1; --- name: mark-notification-processed +-- name: update-notification-processed UPDATE scheduled_sla_notifications SET processed_at = NOW(), updated_at = NOW() @@ -177,18 +177,18 @@ WHERE id = ( ) RETURNING met_at; --- name: mark-sla-event-as-breached +-- name: update-sla-event-as-breached UPDATE sla_events SET breached_at = NOW(), status = 'breached' WHERE id = $1; --- name: mark-sla-event-as-met +-- name: update-sla-event-as-met UPDATE sla_events SET status = 'met' WHERE id = $1; --- name: get-sla-event-by-id +-- name: get-sla-event SELECT id, created_at, updated_at, applied_sla_id, sla_policy_id, type, deadline_at, met_at, breached_at FROM sla_events WHERE id = $1; diff --git a/internal/sla/sla.go b/internal/sla/sla.go index d9d67310..cf7c2661 100644 --- a/internal/sla/sla.go +++ b/internal/sla/sla.go @@ -107,42 +107,66 @@ 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"` - GetAppliedSLA *sqlx.Stmt `query:"get-applied-sla"` - GetSLAEventByID *sqlx.Stmt `query:"get-sla-event-by-id"` - GetScheduledSLANotifications *sqlx.Stmt `query:"get-scheduled-sla-notifications"` - InsertScheduledSLANotification *sqlx.Stmt `query:"insert-scheduled-sla-notification"` - InsertSLA *sqlx.Stmt `query:"insert-sla-policy"` - InsertNextResponseSLAEvent *sqlx.Stmt `query:"insert-next-response-sla-event"` - 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"` - SetConversationNextSLADeadline *sqlx.Stmt `query:"set-conversation-sla-deadline"` - GetPendingSLAEvents *sqlx.Stmt `query:"get-pending-sla-events"` - UpdateSLAStatus *sqlx.Stmt `query:"update-sla-status"` - MarkNotificationProcessed *sqlx.Stmt `query:"mark-notification-processed"` - MarkSLAEventAsBreached *sqlx.Stmt `query:"mark-sla-event-as-breached"` - MarkSLAEventAsMet *sqlx.Stmt `query:"mark-sla-event-as-met"` - SetLatestSLAEventMetAt *sqlx.Stmt `query:"set-latest-sla-event-met-at"` + GetSLAPolicy *sqlx.Stmt `query:"get-sla-policy"` + GetAllSLAPolicies *sqlx.Stmt `query:"get-all-sla-policies"` + GetAppliedSLA *sqlx.Stmt `query:"get-applied-sla"` + GetSLAEvent *sqlx.Stmt `query:"get-sla-event"` + GetScheduledSLANotifications *sqlx.Stmt `query:"get-scheduled-sla-notifications"` + GetPendingAppliedSLA *sqlx.Stmt `query:"get-pending-applied-sla"` + GetPendingSLAEvents *sqlx.Stmt `query:"get-pending-sla-events"` + InsertScheduledSLANotification *sqlx.Stmt `query:"insert-scheduled-sla-notification"` + InsertSLAPolicy *sqlx.Stmt `query:"insert-sla-policy"` + InsertNextResponseSLAEvent *sqlx.Stmt `query:"insert-next-response-sla-event"` + UpdateSLAPolicy *sqlx.Stmt `query:"update-sla-policy"` + UpdateAppliedSLABreachedAt *sqlx.Stmt `query:"update-applied-sla-breached-at"` + UpdateAppliedSLAMetAt *sqlx.Stmt `query:"update-applied-sla-met-at"` + UpdateConversationNextSLADeadline *sqlx.Stmt `query:"update-conversation-sla-deadline"` + UpdateAppliedSLAStatus *sqlx.Stmt `query:"update-applied-sla-status"` + UpdateSLANotificationProcessed *sqlx.Stmt `query:"update-notification-processed"` + UpdateSLAEventAsBreached *sqlx.Stmt `query:"update-sla-event-as-breached"` + UpdateSLAEventAsMet *sqlx.Stmt `query:"update-sla-event-as-met"` + SetLatestSLAEventMetAt *sqlx.Stmt `query:"set-latest-sla-event-met-at"` + ApplySLA *sqlx.Stmt `query:"apply-sla"` + DeleteSLAPolicy *sqlx.Stmt `query:"delete-sla-policy"` } // New creates a new SLA manager. -func New(opts Opts, teamStore teamStore, appSettingsStore appSettingsStore, businessHrsStore businessHrsStore, notifier *notifier.Service, template *template.Manager, userStore userStore) (*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 { + if err := dbutil.ScanSQLFile( + "queries.sql", + &q, + opts.DB, + efs, + ); err != nil { return nil, err } - return &Manager{q: q, lo: opts.Lo, i18n: opts.I18n, teamStore: teamStore, appSettingsStore: appSettingsStore, businessHrsStore: businessHrsStore, notifier: notifier, template: template, userStore: userStore, opts: opts}, nil + return &Manager{ + q: q, + lo: opts.Lo, + i18n: opts.I18n, + 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 := m.q.GetSLAPolicy.Get(&sla, id); err != nil { if err == sql.ErrNoRows { return sla, envelope.NewError(envelope.NotFoundError, m.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.sla}"), nil) } @@ -155,7 +179,7 @@ func (m *Manager) Get(id int) (models.SLAPolicy, error) { // GetAll fetches all SLA policies. func (m *Manager) GetAll() ([]models.SLAPolicy, error) { var slas = make([]models.SLAPolicy, 0) - if err := m.q.GetAllSLA.Select(&slas); err != nil { + if err := m.q.GetAllSLAPolicies.Select(&slas); err != nil { m.lo.Error("error fetching SLAs", "error", err) return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", m.i18n.P("globals.terms.sla")), nil) } @@ -164,7 +188,7 @@ func (m *Manager) GetAll() ([]models.SLAPolicy, error) { // Create creates a new SLA policy. func (m *Manager) Create(name, description string, firstResponseTime, resolutionTime, nextResponseTime null.String, notifications models.SlaNotifications) error { - if _, err := m.q.InsertSLA.Exec(name, description, firstResponseTime, resolutionTime, nextResponseTime, notifications); err != nil { + if _, err := m.q.InsertSLAPolicy.Exec(name, description, firstResponseTime, resolutionTime, nextResponseTime, notifications); err != nil { m.lo.Error("error inserting SLA", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.sla}"), nil) } @@ -173,7 +197,7 @@ func (m *Manager) Create(name, description string, firstResponseTime, resolution // Update updates a SLA policy. func (m *Manager) Update(id int, name, description string, firstResponseTime, resolutionTime, nextResponseTime null.String, notifications models.SlaNotifications) error { - if _, err := m.q.UpdateSLA.Exec(id, name, description, firstResponseTime, resolutionTime, nextResponseTime, notifications); err != nil { + if _, err := m.q.UpdateSLAPolicy.Exec(id, name, description, firstResponseTime, resolutionTime, nextResponseTime, notifications); err != nil { m.lo.Error("error updating SLA", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.sla}"), nil) } @@ -182,7 +206,7 @@ func (m *Manager) Update(id int, name, description string, firstResponseTime, re // Delete deletes an SLA policy. func (m *Manager) Delete(id int) error { - if _, err := m.q.DeleteSLA.Exec(id); err != nil { + if _, err := m.q.DeleteSLAPolicy.Exec(id); err != nil { m.lo.Error("error deleting SLA", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.sla}"), nil) } @@ -271,7 +295,7 @@ func (m *Manager) ApplySLA(startTime time.Time, conversationID, assignedTeamID, // CreateNextResponseSLAEvent creates a next response SLA event for a conversation. func (m *Manager) CreateNextResponseSLAEvent(conversationID, appliedSLAID, slaPolicyID, assignedTeamID int) (time.Time, error) { var slaPolicy models.SLAPolicy - if err := m.q.GetSLA.Get(&slaPolicy, slaPolicyID); err != nil { + if err := m.q.GetSLAPolicy.Get(&slaPolicy, slaPolicyID); err != nil { if err == sql.ErrNoRows { return time.Time{}, fmt.Errorf("SLA policy not found: %d", slaPolicyID) } @@ -323,7 +347,7 @@ func (m *Manager) CreateNextResponseSLAEvent(conversationID, appliedSLAID, slaPo } // Update next SLA deadline (SLA target) in the conversation. - if _, err := m.q.SetConversationNextSLADeadline.Exec(conversationID, deadlines.NextResponse); err != nil { + if _, err := m.q.UpdateConversationNextSLADeadline.Exec(conversationID, deadlines.NextResponse); err != nil { m.lo.Error("error updating conversation next SLA deadline", "error", err, "conversation_id", conversationID, @@ -345,7 +369,7 @@ func (m *Manager) SetLatestSLAEventMetAt(appliedSLAID int, metric string) (time. var metAt time.Time if err := m.q.SetLatestSLAEventMetAt.QueryRow(appliedSLAID, metric).Scan(&metAt); err != nil { if err == sql.ErrNoRows { - m.lo.Warn("no SLA event found for applied SLA and metric to update met at", "applied_sla_id", appliedSLAID, "metric", metric) + m.lo.Info("no SLA event found for applied SLA and metric to update `met_at` timestamp", "applied_sla_id", appliedSLAID, "metric", metric) return metAt, ErrLatestSLAEventNotFound } m.lo.Error("error marking SLA event as met", "error", err) @@ -376,7 +400,7 @@ func (m *Manager) evaluatePendingSLAEvents(ctx context.Context) error { default: } - if err := m.q.GetSLAEventByID.GetContext(ctx, &event, event.ID); err != nil { + if err := m.q.GetSLAEvent.GetContext(ctx, &event, event.ID); err != nil { m.lo.Error("error fetching SLA event", "error", err) continue } @@ -390,7 +414,7 @@ func (m *Manager) evaluatePendingSLAEvents(ctx context.Context) error { var hasBreached bool if (event.MetAt.Valid && event.MetAt.Time.After(event.DeadlineAt)) || (time.Now().After(event.DeadlineAt) && !event.MetAt.Valid) { hasBreached = true - if _, err := m.q.MarkSLAEventAsBreached.Exec(event.ID); err != nil { + if _, err := m.q.UpdateSLAEventAsBreached.Exec(event.ID); err != nil { m.lo.Error("error marking SLA event as breached", "error", err) continue } @@ -398,7 +422,7 @@ func (m *Manager) evaluatePendingSLAEvents(ctx context.Context) error { // Met at before the deadline - mark event met. if event.MetAt.Valid && event.MetAt.Time.Before(event.DeadlineAt) { - if _, err := m.q.MarkSLAEventAsMet.Exec(event.ID); err != nil { + if _, err := m.q.UpdateSLAEventAsMet.Exec(event.ID); err != nil { m.lo.Error("error marking SLA event as met", "error", err) continue } @@ -508,7 +532,7 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti slaEvent models.SLAEvent ) if scheduledNotification.SlaEventID.Int != 0 { - if err := m.q.GetSLAEventByID.Get(&slaEvent, scheduledNotification.SlaEventID.Int); err != nil { + if err := m.q.GetSLAEvent.Get(&slaEvent, scheduledNotification.SlaEventID.Int); err != nil { m.lo.Error("error fetching SLA event", "error", err) return fmt.Errorf("fetching SLA event for notification: %w", err) } @@ -521,7 +545,7 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti // If conversation is `Resolved` / `Closed`, mark the notification as processed and return. if appliedSLA.ConversationStatus == cmodels.StatusResolved || appliedSLA.ConversationStatus == cmodels.StatusClosed { m.lo.Info("marking sla notification as processed as the conversation is resolved/closed", "status", appliedSLA.ConversationStatus, "scheduled_notification_id", scheduledNotification.ID) - if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil { + if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil { m.lo.Error("error marking notification as processed", "error", err) } return nil @@ -534,7 +558,7 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti case MetricFirstResponse: if appliedSLA.FirstResponseMetAt.Valid { m.lo.Info("skipping notification as first response is already met", "applied_sla_id", appliedSLA.ID) - if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil { + if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil { m.lo.Error("error marking notification as processed", "error", err) } continue @@ -542,7 +566,7 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti case MetricResolution: if appliedSLA.ResolutionMetAt.Valid { m.lo.Info("skipping notification as resolution is already met", "applied_sla_id", appliedSLA.ID) - if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil { + if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil { m.lo.Error("error marking notification as processed", "error", err) } continue @@ -554,7 +578,7 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti } if slaEvent.MetAt.Valid { m.lo.Info("skipping notification as next response is already met", "applied_sla_id", appliedSLA.ID) - if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil { + if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil { m.lo.Error("error marking notification as processed", "error", err) } continue @@ -575,7 +599,7 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti // Recipient not found? if recipientID == 0 { - if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil { + if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil { m.lo.Error("error marking notification as processed", "error", err) } continue @@ -584,7 +608,7 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti 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 { + if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil { m.lo.Error("error marking notification as processed", "error", err) } continue @@ -682,7 +706,7 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti } // Mark the notification as processed. - if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil { + if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil { m.lo.Error("error marking notification as processed", "error", err) } } @@ -804,7 +828,7 @@ func (m *Manager) createNotificationSchedule(notifications models.SlaNotificatio // 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 { + if err := m.q.GetPendingAppliedSLA.SelectContext(ctx, &pendingSLAs); err != nil { m.lo.Error("error fetching pending SLAs", "error", err) return err } @@ -824,18 +848,18 @@ 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 { - m.lo.Debug("evaluating SLA", "conversation_id", sla.ConversationID, "applied_sla_id", sla.ID) +func (m *Manager) evaluateSLA(appliedSLA models.AppliedSLA) error { + m.lo.Debug("evaluating SLA", "conversation_id", appliedSLA.ConversationID, "applied_sla_id", appliedSLA.ID) checkDeadline := func(deadline time.Time, metAt null.Time, metric string) error { if deadline.IsZero() { - m.lo.Warn("deadline zero, skipping checking the deadline", "conversation_id", sla.ConversationID, "applied_sla_id", sla.ID, "metric", metric) + m.lo.Warn("deadline zero, skipping checking the deadline", "conversation_id", appliedSLA.ConversationID, "applied_sla_id", appliedSLA.ID, "metric", metric) return nil } now := time.Now() if !metAt.Valid && now.After(deadline) { 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 { + if err := m.handleSLABreach(appliedSLA.ID, appliedSLA.SLAPolicyID, metric); err != nil { return fmt.Errorf("updating SLA breach timestamp: %w", err) } return nil @@ -844,12 +868,12 @@ func (m *Manager) evaluateSLA(sla models.AppliedSLA) error { if metAt.Valid { if metAt.Time.After(deadline) { 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 { + if err := m.handleSLABreach(appliedSLA.ID, appliedSLA.SLAPolicyID, metric); err != nil { return fmt.Errorf("updating SLA breach: %w", err) } } else { 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 { + if _, err := m.q.UpdateAppliedSLAMetAt.Exec(appliedSLA.ID, metric); err != nil { return fmt.Errorf("updating SLA met: %w", err) } } @@ -858,37 +882,38 @@ func (m *Manager) evaluateSLA(sla models.AppliedSLA) error { } // 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.Time, sla.ConversationFirstResponseAt, MetricFirstResponse); err != nil { + if !appliedSLA.FirstResponseBreachedAt.Valid && !appliedSLA.FirstResponseMetAt.Valid { + m.lo.Debug("checking deadline", "deadline", appliedSLA.FirstResponseDeadlineAt, "met_at", appliedSLA.ConversationFirstResponseAt.Time, "metric", MetricFirstResponse) + if err := checkDeadline(appliedSLA.FirstResponseDeadlineAt.Time, appliedSLA.ConversationFirstResponseAt, MetricFirstResponse); 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", MetricResolution) - if err := checkDeadline(sla.ResolutionDeadlineAt.Time, sla.ConversationResolvedAt, MetricResolution); err != nil { + if !appliedSLA.ResolutionBreachedAt.Valid && !appliedSLA.ResolutionMetAt.Valid { + m.lo.Debug("checking deadline", "deadline", appliedSLA.ResolutionDeadlineAt, "met_at", appliedSLA.ConversationResolvedAt.Time, "metric", MetricResolution) + if err := checkDeadline(appliedSLA.ResolutionDeadlineAt.Time, appliedSLA.ConversationResolvedAt, MetricResolution); err != nil { return err } } // Update the conversation next SLA deadline. - if _, err := m.q.SetConversationNextSLADeadline.Exec(sla.ConversationID, nil); err != nil { + if _, err := m.q.UpdateConversationNextSLADeadline.Exec(appliedSLA.ConversationID, nil); 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 { + if _, err := m.q.UpdateAppliedSLAStatus.Exec(appliedSLA.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 { +// handleSLABreach processes a breach for the given SLA metric on an applied SLA. +// It updates the breach timestamp and schedules breach notifications if applicable. +func (m *Manager) handleSLABreach(appliedSLAID, slaPolicyID int, metric string) error { + if _, err := m.q.UpdateAppliedSLABreachedAt.Exec(appliedSLAID, metric); err != nil { return err } From 696e4780ac9c58f27739b328e1375065d9a7a7d7 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 27 May 2025 02:52:33 +0530 Subject: [PATCH 19/19] refactor: reuse existing i18n keys for sla translations --- frontend/src/components/sidebar/Sidebar.vue | 2 +- frontend/src/features/admin/sla/SLAForm.vue | 26 ++++++++++++++------- i18n/en.json | 12 ++++------ 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/sidebar/Sidebar.vue b/frontend/src/components/sidebar/Sidebar.vue index 552e14ad..f6e6a1f8 100644 --- a/frontend/src/components/sidebar/Sidebar.vue +++ b/frontend/src/components/sidebar/Sidebar.vue @@ -340,7 +340,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true) - {{ t('navigation.all') }} + {{ t('globals.messages.all') }} diff --git a/frontend/src/features/admin/sla/SLAForm.vue b/frontend/src/features/admin/sla/SLAForm.vue index 3630beec..800a72f6 100644 --- a/frontend/src/features/admin/sla/SLAForm.vue +++ b/frontend/src/features/admin/sla/SLAForm.vue @@ -27,7 +27,7 @@ - {{ t('admin.sla.firstResponseTime.description') }} + {{ t('globals.messages.golangDurationHoursMinutes') }} @@ -39,7 +39,7 @@ - {{ t('admin.sla.resolutionTime.description') }} + {{ t('globals.messages.golangDurationHoursMinutes') }} @@ -51,7 +51,7 @@ - {{ t('admin.sla.nextResponseTime.description') }} + {{ t('globals.messages.golangDurationHoursMinutes') }} @@ -174,9 +174,11 @@ @@ -221,17 +223,23 @@ - {{ t('admin.sla.metric') }} + {{ t('globals.terms.slaMetric') }}