mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-03 13:33:32 +00:00
Merge pull request #95 from abhinavxd/feat/sla-metric-next-response-time
Feature : Next response SLA metric
This commit is contained in:
@@ -173,6 +173,5 @@ func handleSendMessage(r *fastglue.Request) error {
|
||||
// Evaluate automation rules.
|
||||
app.automation.EvaluateConversationUpdateRules(cuuid, models.EventConversationMessageOutgoing)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
92
cmd/sla.go
92
cmd/sla.go
@@ -29,7 +29,7 @@ func handleGetSLA(r *fastglue.Request) error {
|
||||
)
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "SLA `id`"), nil, envelope.InputError)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
sla, err := app.sla.Get(id)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func handleUpdateSLA(r *fastglue.Request) error {
|
||||
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "SLA `id`"), nil, envelope.InputError)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := r.Decode(&sla, "json"); err != nil {
|
||||
@@ -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.
|
||||
@@ -95,7 +95,7 @@ func handleDeleteSLA(r *fastglue.Request) error {
|
||||
)
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "SLA `id`"), nil, envelope.InputError)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err = app.sla.Delete(id); err != nil {
|
||||
@@ -108,51 +108,79 @@ func handleDeleteSLA(r *fastglue.Request) error {
|
||||
// validateSLA validates the SLA policy and returns an envelope.Error if any validation fails.
|
||||
func validateSLA(app *App, sla *smodels.SLAPolicy) error {
|
||||
if sla.Name == "" {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA `name`"), nil)
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
|
||||
}
|
||||
if sla.FirstResponseTime == "" {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA `first_response_time`"), nil)
|
||||
}
|
||||
if sla.ResolutionTime == "" {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA `resolution_time`"), nil)
|
||||
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)
|
||||
}
|
||||
|
||||
// Validate notifications if any
|
||||
// Validate notifications if any.
|
||||
for _, n := range sla.Notifications {
|
||||
if n.Type == "" {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `type`"), nil)
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil)
|
||||
}
|
||||
if n.TimeDelayType == "" {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `time_delay_type`"), nil)
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`time_delay_type`"), nil)
|
||||
}
|
||||
if n.Metric == "" {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`metric`"), nil)
|
||||
}
|
||||
if n.TimeDelayType != "immediately" {
|
||||
if n.TimeDelay == "" {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `time_delay`"), nil)
|
||||
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", "SLA notification `recipients`"), nil)
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`recipients`"), nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate time duration strings
|
||||
frt, err := time.ParseDuration(sla.FirstResponseTime)
|
||||
if err != nil {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
|
||||
}
|
||||
if frt.Minutes() < 1 {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
|
||||
// Validate first response time duration string if not empty.
|
||||
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)
|
||||
}
|
||||
if frt.Minutes() < 1 {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
|
||||
}
|
||||
}
|
||||
|
||||
rt, err := time.ParseDuration(sla.ResolutionTime)
|
||||
if err != nil {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
|
||||
// Validate resolution time duration string if not empty.
|
||||
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)
|
||||
}
|
||||
if rt.Minutes() < 1 {
|
||||
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.String != "" {
|
||||
frt, _ := time.ParseDuration(sla.FirstResponseTime.String)
|
||||
if frt > rt {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.T("sla.firstResponseTimeAfterResolution"), nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
if rt.Minutes() < 1 {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
|
||||
}
|
||||
if frt > rt {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.T("sla.firstResponseTimeAfterResolution"), nil)
|
||||
|
||||
// Validate next response time duration string if not empty.
|
||||
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)
|
||||
}
|
||||
if nrt.Minutes() < 1 {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -340,7 +340,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
|
||||
<router-link :to="{ name: 'inbox', params: { type: 'all' } }">
|
||||
<UsersRound />
|
||||
<span>
|
||||
{{ t('navigation.all') }}
|
||||
{{ t('globals.messages.all') }}
|
||||
</span>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<Input type="text" placeholder="6h" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{{ t('admin.sla.firstResponseTime.description') }}
|
||||
{{ t('globals.messages.golangDurationHoursMinutes') }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -39,7 +39,20 @@
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="24h" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>{{ t('admin.sla.resolutionTime.description') }} </FormDescription>
|
||||
<FormDescription>{{ t('globals.messages.golangDurationHoursMinutes') }} </FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="next_response_time">
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('admin.sla.nextResponseTime') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="30m" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{{ t('globals.messages.golangDurationHoursMinutes') }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
@@ -93,16 +106,19 @@
|
||||
</span>
|
||||
<div>
|
||||
<div class="font-medium text-foreground">
|
||||
{{ 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('globals.terms.alert').toLowerCase() }}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ notification.type === 'warning' ? 'Pre-breach alert' : 'Post-breach action' }}
|
||||
{{ notification.type === 'warning' ? 'Pre-breach alert' : 'Post-breach alert' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="xs"
|
||||
@click.prevent="removeNotification(index)"
|
||||
class="opacity-70 hover:opacity-100 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -126,16 +142,16 @@
|
||||
{{ t('admin.sla.triggerTiming') }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField" class="hover:border-foreground/30">
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="immediately" class="focus:bg-accent">
|
||||
<SelectItem value="immediately">
|
||||
{{ t('admin.sla.immediatelyOnBreach') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="after" class="focus:bg-accent">
|
||||
<SelectItem value="after">
|
||||
{{ t('admin.sla.afterSpecificDuration') }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
@@ -149,26 +165,23 @@
|
||||
<FormItem v-if="shouldShowTimeDelay(index)">
|
||||
<FormLabel class="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Hourglass class="w-4 h-4 text-muted-foreground" />
|
||||
{{ notification.type === 'warning' ? t('admin.sla.advanceWarning') : t('admin.sla.followUpDelay') }}
|
||||
{{
|
||||
notification.type === 'warning'
|
||||
? t('admin.sla.advanceWarning')
|
||||
: t('admin.sla.followUpDelay')
|
||||
}}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField" class="hover:border-foreground/30">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue :placeholder="t('admin.sla.selectDuration')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="duration in delayDurations"
|
||||
:key="duration"
|
||||
:value="duration"
|
||||
class="focus:bg-accent"
|
||||
>
|
||||
{{ duration }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="text"
|
||||
:placeholder="
|
||||
t('globals.messages.enter', {
|
||||
name: t('globals.terms.duration').toLowerCase()
|
||||
})
|
||||
"
|
||||
v-bind="componentField"
|
||||
@keydown.enter.prevent
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -185,7 +198,7 @@
|
||||
<FormItem>
|
||||
<FormLabel class="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Users class="w-4 h-4 text-muted-foreground" />
|
||||
{{ t('admin.sla.notificationRecipients') }}
|
||||
{{ t('admin.sla.alertRecipients') }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<SelectTag
|
||||
@@ -205,6 +218,45 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField :name="`notifications.${index}.metric`" v-slot="{ componentField }">
|
||||
<FormItem>
|
||||
<FormLabel class="flex items-center gap-1.5 text-sm font-medium">
|
||||
<SlidersHorizontal class="w-4 h-4 text-muted-foreground" />
|
||||
{{ t('globals.terms.slaMetric') }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue
|
||||
:placeholder="
|
||||
t('form.field.select', {
|
||||
name: t('globals.terms.slaMetric').toLowerCase()
|
||||
})
|
||||
"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">
|
||||
{{ t('globals.messages.all') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="first_response">
|
||||
{{ t('admin.sla.firstResponseTime') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="next_response">
|
||||
{{ t('admin.sla.nextResponseTime') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="resolution">
|
||||
{{ t('admin.sla.resolutionTime') }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,7 +267,7 @@
|
||||
class="flex flex-col items-center justify-center p-8 space-y-3 rounded-xl bg-muted/30 border border-dashed"
|
||||
>
|
||||
<Bell class="w-8 h-8 text-muted-foreground" />
|
||||
<p class="text-sm text-muted-foreground">{{ t('admin.sla.noNotificationsConfigured') }}</p>
|
||||
<p class="text-sm text-muted-foreground">{{ t('admin.sla.noAlertsConfigured') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -231,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,
|
||||
@@ -274,27 +336,11 @@ const props = defineProps({
|
||||
|
||||
const usersStore = useUsersStore()
|
||||
const submitLabel = computed(() => {
|
||||
return props.submitLabel || (props.initialValues.id ? t('globals.buttons.update') : t('globals.buttons.create'))
|
||||
return (
|
||||
props.submitLabel ||
|
||||
(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({
|
||||
@@ -320,7 +366,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)
|
||||
}
|
||||
@@ -341,6 +388,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'
|
||||
|
||||
@@ -1,54 +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'),
|
||||
}),
|
||||
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().nullable().optional().refine(val => !val || isGoHourMinuteDuration(val), {
|
||||
message: t('globals.messages.goHourMinuteDuration'),
|
||||
}),
|
||||
resolution_time: z.string().nullable().optional().refine(val => !val || isGoHourMinuteDuration(val), {
|
||||
message: t('globals.messages.goHourMinuteDuration'),
|
||||
}),
|
||||
next_response_time: z.string().nullable().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('globals.messages.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,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -55,21 +55,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mt-2 space-x-2">
|
||||
<SlaBadge
|
||||
v-if="conversation.first_response_deadline_at"
|
||||
:dueAt="conversation.first_response_deadline_at"
|
||||
:actualAt="conversation.first_reply_at"
|
||||
:label="'FRD'"
|
||||
:showExtra="false"
|
||||
/>
|
||||
<SlaBadge
|
||||
v-if="conversation.resolution_deadline_at"
|
||||
:dueAt="conversation.resolution_deadline_at"
|
||||
:actualAt="conversation.resolved_at"
|
||||
:label="'RD'"
|
||||
:showExtra="false"
|
||||
/>
|
||||
<!-- SLA Badges -->
|
||||
<div class="flex items-center">
|
||||
<div :class="getSlaClass(frdStatus)">
|
||||
<SlaBadge
|
||||
:dueAt="conversation.first_response_deadline_at"
|
||||
:actualAt="conversation.first_reply_at"
|
||||
:label="'FRD'"
|
||||
:showExtra="false"
|
||||
@status="frdStatus = $event"
|
||||
:key="`${conversation.uuid}-${conversation.first_response_deadline_at}-${conversation.first_reply_at}`"
|
||||
/>
|
||||
</div>
|
||||
<div :class="getSlaClass(rdStatus)">
|
||||
<SlaBadge
|
||||
:dueAt="conversation.resolution_deadline_at"
|
||||
:actualAt="conversation.resolved_at"
|
||||
:label="'RD'"
|
||||
:showExtra="false"
|
||||
@status="rdStatus = $event"
|
||||
:key="`${conversation.uuid}-${conversation.resolution_deadline_at}-${conversation.resolved_at}`"
|
||||
/>
|
||||
</div>
|
||||
<div :class="getSlaClass(nrdStatus)">
|
||||
<SlaBadge
|
||||
:dueAt="conversation.next_response_deadline_at"
|
||||
:actualAt="conversation.next_response_met_at"
|
||||
:label="'NRD'"
|
||||
:showExtra="false"
|
||||
@status="nrdStatus = $event"
|
||||
:key="`${conversation.uuid}-${conversation.next_response_deadline_at}-${conversation.next_response_met_at}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +94,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { formatTime } from '@/utils/datetime'
|
||||
import { Mail, Reply } from 'lucide-vue-next'
|
||||
@@ -86,6 +103,9 @@ import SlaBadge from '@/features/sla/SlaBadge.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const frdStatus = ref('')
|
||||
const rdStatus = ref('')
|
||||
const nrdStatus = ref('')
|
||||
|
||||
const props = defineProps({
|
||||
conversation: Object,
|
||||
@@ -113,4 +133,6 @@ const trimmedLastMessage = computed(() => {
|
||||
const message = props.conversation.last_message || ''
|
||||
return message.length > 100 ? message.slice(0, 100) + '...' : message
|
||||
})
|
||||
|
||||
const getSlaClass = (status) => (['overdue', 'remaining'].includes(status) ? 'mr-2' : '')
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="flex flex-col"
|
||||
v-if="conversation.subject"
|
||||
>
|
||||
<div class="flex flex-col" v-if="conversation.subject">
|
||||
<p class="font-medium">{{ $t('form.field.subject') }}</p>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
|
||||
<p v-else>
|
||||
@@ -34,7 +31,7 @@
|
||||
v-if="conversation.first_response_deadline_at"
|
||||
:dueAt="conversation.first_response_deadline_at"
|
||||
:actualAt="conversation.first_reply_at"
|
||||
:key="conversation.uuid"
|
||||
:key="`${conversation.uuid}-${conversation.first_response_deadline_at}-${conversation.first_reply_at}`"
|
||||
/>
|
||||
</div>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
|
||||
@@ -53,7 +50,7 @@
|
||||
v-if="conversation.resolution_deadline_at"
|
||||
:dueAt="conversation.resolution_deadline_at"
|
||||
:actualAt="conversation.resolved_at"
|
||||
:key="conversation.uuid"
|
||||
:key="`${conversation.uuid}-${conversation.resolution_deadline_at}-${conversation.resolved_at}`"
|
||||
/>
|
||||
</div>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
|
||||
@@ -66,7 +63,15 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<p class="font-medium">{{ $t('form.field.lastReplyAt') }}</p>
|
||||
<div class="flex justify-start items-center space-x-2">
|
||||
<p class="font-medium">{{ $t('form.field.lastReplyAt') }}</p>
|
||||
<SlaBadge
|
||||
v-if="conversation.next_response_deadline_at"
|
||||
:dueAt="conversation.next_response_deadline_at"
|
||||
:actualAt="conversation.next_response_met_at"
|
||||
:key="`${conversation.uuid}-${conversation.next_response_deadline_at}-${conversation.next_response_met_at}`"
|
||||
/>
|
||||
</div>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
|
||||
<p v-if="conversation.last_reply_at">
|
||||
{{ format(conversation.last_reply_at, 'PPpp') }}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useSla } from '@/composables/useSla'
|
||||
import { AlertCircle, CheckCircle, Clock } from 'lucide-vue-next'
|
||||
const props = defineProps({
|
||||
@@ -45,10 +45,17 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
let sla = null
|
||||
if (props.dueAt) {
|
||||
sla = useSla(ref(props.dueAt), ref(props.actualAt))
|
||||
}
|
||||
const emit = defineEmits(['status'])
|
||||
let sla = useSla(ref(props.dueAt), ref(props.actualAt))
|
||||
|
||||
// Watch for status change and emit
|
||||
watch(
|
||||
sla,
|
||||
(newVal) => {
|
||||
if (newVal?.status) emit('status', newVal.status)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -638,9 +638,11 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
|
||||
function updateConversationProp (update) {
|
||||
// Update the current conversation if it matches the UUID.
|
||||
if (conversation.data?.uuid === update.uuid) {
|
||||
conversation.data[update.prop] = update.value
|
||||
}
|
||||
// Update the conversation in the list if it exists.
|
||||
const existingConversation = conversations?.data?.find(c => c.uuid === update.uuid)
|
||||
if (existingConversation) {
|
||||
existingConversation[update.prop] = update.value
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
|
||||
<template #help>
|
||||
<p>
|
||||
Configure SLA policies to set response and resolution time targets.
|
||||
Configure SLA policies to set response, resolution and next response time targets.
|
||||
</p>
|
||||
<p>
|
||||
SLAs help track team performance and ensure conversations are handled within expected timeframes.
|
||||
Breached SLAs trigger notifications to relevant team members.
|
||||
Breached SLAs trigger notifications to configured team members.
|
||||
</p>
|
||||
</template>
|
||||
</AdminPageWithHelp>
|
||||
|
||||
36
i18n/en.json
36
i18n/en.json
@@ -81,6 +81,10 @@
|
||||
"globals.terms.key": "Key | Keys",
|
||||
"globals.terms.note": "Note | Notes",
|
||||
"globals.terms.ipAddress": "IP Address | IP Addresses",
|
||||
"globals.terms.alert": "Alert | Alerts",
|
||||
"globals.terms.duration": "Duration | Durations",
|
||||
"globals.terms.slaMetric": "SLA Metric | SLA Metrics",
|
||||
"globals.messages.golangDurationHoursMinutes": "Duration in hours or minutes. Example: 1h, 30m, 1h30m",
|
||||
"globals.messages.badRequest": "Bad request",
|
||||
"globals.messages.adjustFilters": "Try adjusting filters",
|
||||
"globals.messages.errorUpdating": "Error updating {name}",
|
||||
@@ -119,8 +123,10 @@
|
||||
"globals.messages.create": "Create {name}",
|
||||
"globals.messages.new": "New {name}",
|
||||
"globals.messages.add": "Add {name}",
|
||||
"globals.messages.all": "All {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}",
|
||||
@@ -247,7 +253,6 @@
|
||||
"navigation.inbox": "Inbox",
|
||||
"navigation.myInbox": "My Inbox",
|
||||
"navigation.unassigned": "Unassigned",
|
||||
"navigation.all": "All",
|
||||
"navigation.teamInboxes": "Team Inboxes",
|
||||
"navigation.views": "Views",
|
||||
"navigation.reassignReplies": "Reassign replies",
|
||||
@@ -380,26 +385,23 @@
|
||||
"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.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.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.firstResponseTime": "First response time",
|
||||
"admin.sla.resolutionTime": "Resolution time",
|
||||
"admin.sla.nextResponseTime": "Next response time",
|
||||
"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.atleastOneSLATimeRequired": "At least one of First Response Time, Next Response Time, or Resolution Time is required.",
|
||||
"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",
|
||||
|
||||
@@ -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": "टॅग यशस्वीरित्या अद्ययावत केला",
|
||||
|
||||
@@ -82,6 +82,8 @@ type Manager struct {
|
||||
|
||||
type slaStore interface {
|
||||
ApplySLA(startTime time.Time, conversationID, assignedTeamID, slaID int) (slaModels.SLAPolicy, error)
|
||||
CreateNextResponseSLAEvent(conversationID, appliedSLAID, slaPolicyID, assignedTeamID int) (time.Time, error)
|
||||
SetLatestSLAEventMetAt(appliedSLAID int, metric string) (time.Time, error)
|
||||
}
|
||||
|
||||
type statusStore interface {
|
||||
@@ -245,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 <email>"
|
||||
@@ -591,6 +600,19 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn
|
||||
snoozeUntil = time.Now().Add(duration)
|
||||
}
|
||||
|
||||
conversationBeforeChange, err := c.GetConversation(0, uuid)
|
||||
if err != nil {
|
||||
c.lo.Error("error fetching conversation before status change", "uuid", uuid, "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil)
|
||||
}
|
||||
oldStatus := conversationBeforeChange.Status.String
|
||||
|
||||
// Status not changed? return early.
|
||||
if oldStatus == status {
|
||||
c.lo.Info("conversation status is unchanged", "uuid", uuid, "status", status)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update the conversation status.
|
||||
if _, err := c.q.UpdateConversationStatus.Exec(uuid, status, snoozeUntil); err != nil {
|
||||
c.lo.Error("error updating conversation status", "error", err)
|
||||
@@ -604,6 +626,16 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn
|
||||
|
||||
// Broadcast updates using websocket.
|
||||
c.BroadcastConversationUpdate(uuid, "status", status)
|
||||
|
||||
// Broadcast `resolved_at` if the status is changed to resolved, `resolved_at` is set only once when the conversation is resolved for the first time.
|
||||
// Subsequent status changes to resolved will not update the `resolved_at` field.
|
||||
if oldStatus != models.StatusResolved && status == models.StatusResolved {
|
||||
resolvedAt := conversationBeforeChange.ResolvedAt.Time
|
||||
if resolvedAt.IsZero() {
|
||||
resolvedAt = time.Now()
|
||||
}
|
||||
c.BroadcastConversationUpdate(uuid, "resolved_at", resolvedAt.Format(time.RFC3339))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/image"
|
||||
"github.com/abhinavxd/libredesk/internal/inbox"
|
||||
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
|
||||
"github.com/abhinavxd/libredesk/internal/sla"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/lib/pq"
|
||||
@@ -180,16 +181,35 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update status of the 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())
|
||||
} else if err != nil {
|
||||
m.lo.Error("error fetching system user for updating first reply time", "error", err)
|
||||
// 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 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 && !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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,15 +296,15 @@ func (m *Manager) GetMessage(uuid string) (models.Message, error) {
|
||||
}
|
||||
|
||||
// UpdateMessageStatus updates the status of a message.
|
||||
func (m *Manager) UpdateMessageStatus(uuid string, status string) error {
|
||||
if _, err := m.q.UpdateMessageStatus.Exec(status, uuid); err != nil {
|
||||
m.lo.Error("error updating message status", "error", err, "uuid", uuid)
|
||||
func (m *Manager) UpdateMessageStatus(messageUUID string, status string) error {
|
||||
if _, err := m.q.UpdateMessageStatus.Exec(status, messageUUID); err != nil {
|
||||
m.lo.Error("error updating message status", "message_uuid", messageUUID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Broadcast messge status update to all conversation subscribers.
|
||||
conversationUUID, _ := m.getConversationUUIDFromMessageUUID(uuid)
|
||||
m.BroadcastMessageUpdate(conversationUUID, uuid, "status" /*property*/, status)
|
||||
// Broadcast message status update to all conversation subscribers.
|
||||
conversationUUID, _ := m.getConversationUUIDFromMessageUUID(messageUUID)
|
||||
m.BroadcastMessageUpdate(conversationUUID, messageUUID, "status" /*property*/, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -392,9 +412,7 @@ func (m *Manager) InsertMessage(message *models.Message) error {
|
||||
}
|
||||
|
||||
// Add this user as a participant.
|
||||
if err := m.addConversationParticipant(message.SenderID, message.ConversationUUID); err != nil {
|
||||
return err
|
||||
}
|
||||
m.addConversationParticipant(message.SenderID, message.ConversationUUID)
|
||||
|
||||
// Hide CSAT message content as it contains a public link to the survey.
|
||||
lastMessage := message.TextContent
|
||||
@@ -577,6 +595,25 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
|
||||
|
||||
// Trigger automations on incoming message event.
|
||||
m.automation.EvaluateConversationUpdateRules(in.Message.ConversationUUID, amodels.EventConversationMessageIncoming)
|
||||
|
||||
// Create SLA event for next response if a SLA is applied and has next response time set, subsequent agent replies will mark this event as met.
|
||||
// This cycle continues for next response time SLA metric.
|
||||
conversation, err := m.GetConversation(in.Message.ConversationID, "")
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching conversation", "conversation_id", in.Message.ConversationID, "error", err)
|
||||
}
|
||||
if conversation.SLAPolicyID.Int == 0 {
|
||||
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.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.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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -86,10 +86,12 @@ 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"`
|
||||
SLAStatus null.String `db:"sla_status" json:"sla_status"`
|
||||
NextResponseDueAt null.Time `db:"next_response_deadline_at" json:"next_response_deadline_at"`
|
||||
NextResponseMetAt null.Time `db:"next_response_met_at" json:"next_response_met_at"`
|
||||
To json.RawMessage `db:"to" json:"to"`
|
||||
BCC json.RawMessage `db:"bcc" json:"bcc"`
|
||||
CC json.RawMessage `db:"cc" json:"cc"`
|
||||
|
||||
@@ -67,18 +67,28 @@ SELECT
|
||||
conversation_priorities.name as priority,
|
||||
as_latest.first_response_deadline_at,
|
||||
as_latest.resolution_deadline_at,
|
||||
as_latest.status as sla_status
|
||||
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 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
|
||||
) as_latest ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT se.deadline_at, se.met_at
|
||||
FROM sla_events se
|
||||
WHERE se.applied_sla_id = as_latest.id
|
||||
AND se.type = 'next_response'
|
||||
ORDER BY se.created_at DESC
|
||||
LIMIT 1
|
||||
) nxt_resp_event ON true
|
||||
WHERE 1=1 %s
|
||||
|
||||
-- name: get-conversation
|
||||
@@ -128,7 +138,9 @@ SELECT
|
||||
ct.custom_attributes as "contact.custom_attributes",
|
||||
as_latest.first_response_deadline_at,
|
||||
as_latest.resolution_deadline_at,
|
||||
as_latest.status as sla_status
|
||||
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
|
||||
@@ -137,15 +149,23 @@ 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 first_response_deadline_at, resolution_deadline_at, status
|
||||
FROM applied_slas
|
||||
SELECT id, first_response_deadline_at, resolution_deadline_at
|
||||
FROM applied_slas
|
||||
WHERE conversation_id = c.id
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) as_latest ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT se.deadline_at, se.met_at
|
||||
FROM sla_events se
|
||||
WHERE se.applied_sla_id = as_latest.id
|
||||
AND se.type = 'next_response'
|
||||
ORDER BY se.created_at DESC
|
||||
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
|
||||
|
||||
@@ -28,12 +28,12 @@ type Manager struct {
|
||||
|
||||
// Predefined queries.
|
||||
type queries struct {
|
||||
Get *sqlx.Stmt `query:"get"`
|
||||
GetAll *sqlx.Stmt `query:"get-all"`
|
||||
Create *sqlx.Stmt `query:"create"`
|
||||
Update *sqlx.Stmt `query:"update"`
|
||||
Delete *sqlx.Stmt `query:"delete"`
|
||||
IncUsageCount *sqlx.Stmt `query:"increment-usage-count"`
|
||||
Get *sqlx.Stmt `query:"get"`
|
||||
GetAll *sqlx.Stmt `query:"get-all"`
|
||||
Create *sqlx.Stmt `query:"create"`
|
||||
Update *sqlx.Stmt `query:"update"`
|
||||
Delete *sqlx.Stmt `query:"delete"`
|
||||
IncrUsageCount *sqlx.Stmt `query:"increment-usage-count"`
|
||||
}
|
||||
|
||||
// Opts contains the dependencies for the macro manager.
|
||||
@@ -115,7 +115,7 @@ func (m *Manager) Delete(id int) error {
|
||||
|
||||
// IncrementUsageCount increments the usage count of a macro.
|
||||
func (m *Manager) IncrementUsageCount(id int) error {
|
||||
if _, err := m.q.IncUsageCount.Exec(id); err != nil {
|
||||
if _, err := m.q.IncrUsageCount.Exec(id); err != nil {
|
||||
m.lo.Error("error incrementing usage count", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "macro usage count"), nil)
|
||||
}
|
||||
|
||||
@@ -207,5 +207,85 @@ func V0_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add column `next_response_time` to sla_policies table if it doesn't exist
|
||||
_, err = db.Exec(`
|
||||
ALTER TABLE sla_policies ADD COLUMN IF NOT EXISTS next_response_time TEXT NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add `next_response` value to type if it doesn't exist.
|
||||
_, err = db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_type WHERE typname = 'sla_metric'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM pg_enum e
|
||||
JOIN pg_type t ON t.oid = e.enumtypid
|
||||
WHERE t.typname = 'sla_metric'
|
||||
AND e.enumlabel = 'next_response'
|
||||
) THEN
|
||||
ALTER TYPE sla_metric ADD VALUE 'next_response';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
`)
|
||||
|
||||
// Create sla_event_status enum type if it doesn't exist
|
||||
_, err = db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_type WHERE typname = 'sla_event_status'
|
||||
) THEN
|
||||
CREATE TYPE sla_event_status AS ENUM ('pending', 'breached', 'met');
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create sla_events table if it does not exist
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS sla_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
status sla_event_status DEFAULT 'pending' NOT NULL,
|
||||
applied_sla_id BIGINT REFERENCES applied_slas(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
|
||||
sla_policy_id INT REFERENCES sla_policies(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
|
||||
type sla_metric NOT NULL,
|
||||
deadline_at TIMESTAMPTZ NOT NULL,
|
||||
met_at TIMESTAMPTZ,
|
||||
breached_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS index_sla_events_on_applied_sla_id ON sla_events(applied_sla_id);
|
||||
CREATE INDEX IF NOT EXISTS index_sla_events_on_status ON sla_events(status);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add sla_event_id column to scheduled_sla_notifications if it doesn't exist
|
||||
_, err = db.Exec(`
|
||||
ALTER TABLE scheduled_sla_notifications
|
||||
ADD COLUMN IF NOT EXISTS sla_event_id BIGINT REFERENCES sla_events(id) ON DELETE CASCADE;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create index on team_members(user_id) if it doesn't exist
|
||||
_, err = db.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS index_team_members_on_user_id ON team_members (user_id);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
EveryResponseTime string `db:"every_response_time" json:"every_response_time,omitempty"`
|
||||
ResolutionTime string `db:"resolution_time" json:"resolution_time,omitempty"`
|
||||
Notifications SlaNotifications `db:"notifications" json:"notifications,omitempty"`
|
||||
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
|
||||
@@ -51,6 +51,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
|
||||
@@ -58,6 +59,7 @@ type ScheduledSLANotification struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
SlaEventID null.Int `db:"sla_event_id" json:"sla_event_id"`
|
||||
AppliedSLAID int `db:"applied_sla_id" json:"applied_sla_id"`
|
||||
Metric string `db:"metric" json:"metric"`
|
||||
NotificationType string `db:"notification_type" json:"notification_type"`
|
||||
@@ -73,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"`
|
||||
@@ -87,4 +89,17 @@ type AppliedSLA struct {
|
||||
ConversationReferenceNumber string `db:"conversation_reference_number"`
|
||||
ConversationSubject string `db:"conversation_subject"`
|
||||
ConversationAssignedUserID null.Int `db:"conversation_assigned_user_id"`
|
||||
ConversationStatus string `db:"conversation_status"`
|
||||
}
|
||||
|
||||
type SLAEvent struct {
|
||||
ID int `db:"id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
AppliedSLAID int `db:"applied_sla_id"`
|
||||
SlaPolicyID int `db:"sla_policy_id"`
|
||||
Type string `db:"type"`
|
||||
DeadlineAt time.Time `db:"deadline_at"`
|
||||
MetAt null.Time `db:"met_at"`
|
||||
BreachedAt null.Time `db:"breached_at"`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- name: get-sla-policy
|
||||
SELECT id, name, description, first_response_time, resolution_time, notifications, created_at, updated_at FROM sla_policies WHERE id = $1;
|
||||
SELECT id, name, description, first_response_time, resolution_time, next_response_time, notifications, created_at, updated_at FROM sla_policies WHERE id = $1;
|
||||
|
||||
-- name: get-all-sla-policies
|
||||
SELECT id, name, created_at, updated_at FROM sla_policies ORDER BY updated_at DESC;
|
||||
@@ -10,8 +10,9 @@ INSERT INTO sla_policies (
|
||||
description,
|
||||
first_response_time,
|
||||
resolution_time,
|
||||
next_response_time,
|
||||
notifications
|
||||
) VALUES ($1, $2, $3, $4, $5);
|
||||
) VALUES ($1, $2, $3, $4, $5, $6);
|
||||
|
||||
-- name: update-sla-policy
|
||||
UPDATE sla_policies SET
|
||||
@@ -19,7 +20,8 @@ UPDATE sla_policies SET
|
||||
description = $3,
|
||||
first_response_time = $4,
|
||||
resolution_time = $5,
|
||||
notifications = $6,
|
||||
next_response_time = $6,
|
||||
notifications = $7,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
@@ -36,14 +38,16 @@ WITH new_sla AS (
|
||||
) VALUES ($1, $2, $3, $4)
|
||||
RETURNING conversation_id, id
|
||||
)
|
||||
-- update the conversation with the new SLA policy and next SLA deadline.
|
||||
UPDATE conversations c
|
||||
SET sla_policy_id = $2,
|
||||
next_sla_deadline_at = LEAST($3, $4)
|
||||
SET
|
||||
sla_policy_id = $2,
|
||||
next_sla_deadline_at = LEAST($3, $4)
|
||||
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
|
||||
@@ -51,34 +55,50 @@ 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-next-sla-deadline
|
||||
-- name: update-conversation-sla-deadline
|
||||
UPDATE conversations c
|
||||
SET next_sla_deadline_at = CASE
|
||||
WHEN c.status_id IN (SELECT id from conversation_statuses where name in ('Resolved', 'Closed')) THEN NULL
|
||||
WHEN c.first_reply_at IS NOT NULL AND c.resolved_at IS NULL AND a.resolution_deadline_at IS NOT NULL THEN a.resolution_deadline_at
|
||||
WHEN c.first_reply_at IS NULL AND c.resolved_at IS NULL AND a.first_response_deadline_at IS NOT NULL THEN a.first_response_deadline_at
|
||||
WHEN a.first_response_deadline_at IS NOT NULL AND a.resolution_deadline_at IS NOT NULL THEN LEAST(a.first_response_deadline_at, a.resolution_deadline_at)
|
||||
ELSE NULL
|
||||
SET next_sla_deadline_at = CASE
|
||||
-- If resolved or closed, clear the deadline
|
||||
WHEN c.status_id IN (SELECT id FROM conversation_statuses WHERE name IN ('Resolved', 'Closed')) THEN NULL
|
||||
|
||||
-- If an external timestamp ($2) is provided (e.g. next_response), use the earliest of $2.
|
||||
WHEN $2::TIMESTAMPTZ IS NOT NULL THEN LEAST(
|
||||
$2::TIMESTAMPTZ,
|
||||
CASE
|
||||
WHEN c.first_reply_at IS NOT NULL AND c.resolved_at IS NULL AND a.resolution_deadline_at IS NOT NULL THEN a.resolution_deadline_at
|
||||
WHEN c.first_reply_at IS NULL AND c.resolved_at IS NULL AND a.first_response_deadline_at IS NOT NULL THEN a.first_response_deadline_at
|
||||
WHEN a.first_response_deadline_at IS NOT NULL AND a.resolution_deadline_at IS NOT NULL THEN LEAST(a.first_response_deadline_at, a.resolution_deadline_at)
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
|
||||
-- No $2,
|
||||
ELSE CASE
|
||||
WHEN c.first_reply_at IS NOT NULL AND c.resolved_at IS NULL AND a.resolution_deadline_at IS NOT NULL THEN a.resolution_deadline_at
|
||||
WHEN c.first_reply_at IS NULL AND c.resolved_at IS NULL AND a.first_response_deadline_at IS NOT NULL THEN a.first_response_deadline_at
|
||||
WHEN a.first_response_deadline_at IS NOT NULL AND a.resolution_deadline_at IS NOT NULL THEN LEAST(a.first_response_deadline_at, a.resolution_deadline_at)
|
||||
ELSE NULL
|
||||
END
|
||||
END
|
||||
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
|
||||
@@ -95,14 +115,15 @@ WHERE applied_slas.id = $1;
|
||||
-- name: insert-scheduled-sla-notification
|
||||
INSERT INTO scheduled_sla_notifications (
|
||||
applied_sla_id,
|
||||
sla_event_id,
|
||||
metric,
|
||||
notification_type,
|
||||
recipients,
|
||||
send_at
|
||||
) VALUES ($1, $2, $3, $4, $5);
|
||||
) VALUES ($1, $2, $3, $4, $5, $6);
|
||||
|
||||
-- name: get-scheduled-sla-notifications
|
||||
SELECT id, created_at, updated_at, applied_sla_id, metric, notification_type, recipients, send_at, processed_at
|
||||
SELECT id, created_at, updated_at, applied_sla_id, sla_event_id, metric, notification_type, recipients, send_at, processed_at
|
||||
FROM scheduled_sla_notifications
|
||||
WHERE send_at <= NOW() AND processed_at IS NULL;
|
||||
|
||||
@@ -124,12 +145,55 @@ SELECT a.id,
|
||||
c.uuid as conversation_uuid,
|
||||
c.reference_number as conversation_reference_number,
|
||||
c.subject as conversation_subject,
|
||||
c.assigned_user_id as conversation_assigned_user_id
|
||||
FROM applied_slas a inner join conversations c on a.conversation_id = c.id
|
||||
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.id = $1;
|
||||
|
||||
-- name: mark-notification-processed
|
||||
-- name: update-notification-processed
|
||||
UPDATE scheduled_sla_notifications
|
||||
SET processed_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: insert-next-response-sla-event
|
||||
INSERT INTO sla_events (applied_sla_id, sla_policy_id, type, deadline_at)
|
||||
SELECT $1, $2, 'next_response', $3
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sla_events
|
||||
WHERE applied_sla_id = $1 AND type = 'next_response' AND met_at IS NULL
|
||||
)
|
||||
RETURNING id;
|
||||
|
||||
-- name: set-latest-sla-event-met-at
|
||||
UPDATE sla_events
|
||||
SET met_at = NOW()
|
||||
WHERE id = (
|
||||
SELECT id FROM sla_events
|
||||
WHERE applied_sla_id = $1 AND type = $2 AND met_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING met_at;
|
||||
|
||||
-- name: update-sla-event-as-breached
|
||||
UPDATE sla_events
|
||||
SET breached_at = NOW(),
|
||||
status = 'breached'
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: update-sla-event-as-met
|
||||
UPDATE sla_events
|
||||
SET status = 'met'
|
||||
WHERE id = $1;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- name: get-pending-sla-events
|
||||
SELECT id
|
||||
FROM sla_events
|
||||
WHERE status = 'pending' AND deadline_at IS NOT NULL;
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
|
||||
businesshours "github.com/abhinavxd/libredesk/internal/business_hours"
|
||||
bmodels "github.com/abhinavxd/libredesk/internal/business_hours/models"
|
||||
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
notifier "github.com/abhinavxd/libredesk/internal/notification"
|
||||
@@ -30,12 +32,16 @@ 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")
|
||||
ErrLatestSLAEventNotFound = errors.New("latest SLA event not found for the applied SLA and metric")
|
||||
)
|
||||
|
||||
const (
|
||||
MetricFirstResponse = "first_response"
|
||||
MetricsResolution = "resolution"
|
||||
MetricResolution = "resolution"
|
||||
MetricNextResponse = "next_response"
|
||||
MetricAll = "all"
|
||||
|
||||
NotificationTypeWarning = "warning"
|
||||
NotificationTypeBreach = "breach"
|
||||
@@ -43,7 +49,8 @@ const (
|
||||
|
||||
var metricLabels = map[string]string{
|
||||
MetricFirstResponse: "First Response",
|
||||
MetricsResolution: "Resolution",
|
||||
MetricResolution: "Resolution",
|
||||
MetricNextResponse: "Next Response",
|
||||
}
|
||||
|
||||
// Manager manages SLA policies and calculations.
|
||||
@@ -70,14 +77,16 @@ type Opts struct {
|
||||
|
||||
// Deadlines holds the deadlines for an SLA policy.
|
||||
type Deadlines struct {
|
||||
FirstResponse time.Time
|
||||
Resolution 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
|
||||
FirstResponse null.Time
|
||||
Resolution null.Time
|
||||
NextResponse null.Time
|
||||
}
|
||||
|
||||
type teamStore interface {
|
||||
@@ -98,36 +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"`
|
||||
GetScheduledSLANotifications *sqlx.Stmt `query:"get-scheduled-sla-notifications"`
|
||||
InsertScheduledSLANotification *sqlx.Stmt `query:"insert-scheduled-sla-notification"`
|
||||
InsertSLA *sqlx.Stmt `query:"insert-sla-policy"`
|
||||
DeleteSLA *sqlx.Stmt `query:"delete-sla-policy"`
|
||||
UpdateSLA *sqlx.Stmt `query:"update-sla-policy"`
|
||||
ApplySLA *sqlx.Stmt `query:"apply-sla"`
|
||||
GetPendingSLAs *sqlx.Stmt `query:"get-pending-slas"`
|
||||
UpdateBreach *sqlx.Stmt `query:"update-breach"`
|
||||
UpdateMet *sqlx.Stmt `query:"update-met"`
|
||||
SetNextSLADeadline *sqlx.Stmt `query:"set-next-sla-deadline"`
|
||||
UpdateSLAStatus *sqlx.Stmt `query:"update-sla-status"`
|
||||
MarkNotificationProcessed *sqlx.Stmt `query:"mark-notification-processed"`
|
||||
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)
|
||||
}
|
||||
@@ -140,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)
|
||||
}
|
||||
@@ -148,8 +187,8 @@ func (m *Manager) GetAll() ([]models.SLAPolicy, error) {
|
||||
}
|
||||
|
||||
// Create creates a new SLA policy.
|
||||
func (m *Manager) Create(name, description string, firstResponseTime, resolutionTime string, notifications models.SlaNotifications) error {
|
||||
if _, err := m.q.InsertSLA.Exec(name, description, firstResponseTime, resolutionTime, notifications); err != nil {
|
||||
func (m *Manager) Create(name, description string, firstResponseTime, resolutionTime, nextResponseTime null.String, notifications models.SlaNotifications) error {
|
||||
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)
|
||||
}
|
||||
@@ -157,8 +196,8 @@ 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 string, notifications models.SlaNotifications) error {
|
||||
if _, err := m.q.UpdateSLA.Exec(id, name, description, firstResponseTime, resolutionTime, notifications); err != nil {
|
||||
func (m *Manager) Update(id int, name, description string, firstResponseTime, resolutionTime, nextResponseTime null.String, notifications models.SlaNotifications) error {
|
||||
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)
|
||||
}
|
||||
@@ -167,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)
|
||||
}
|
||||
@@ -191,25 +230,28 @@ 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: %v", 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); 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.String); err != nil {
|
||||
return deadlines, err
|
||||
}
|
||||
return deadlines, nil
|
||||
@@ -224,6 +266,8 @@ func (m *Manager) ApplySLA(startTime time.Time, conversationID, assignedTeamID,
|
||||
if err != nil {
|
||||
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 = null.Time{}
|
||||
|
||||
// Insert applied SLA entry.
|
||||
var appliedSLAID int
|
||||
@@ -237,26 +281,186 @@ func (m *Manager) ApplySLA(startTime time.Time, conversationID, assignedTeamID,
|
||||
return sla, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorApplying", "name", "{globals.terms.sla}"), nil)
|
||||
}
|
||||
|
||||
// Schedule SLA notifications if any exist. SLA breaches have not occurred yet, as this is the first time the SLA is being applied.
|
||||
// Therefore, only schedule notifications for the deadlines.
|
||||
sla, err = m.Get(slaPolicyID)
|
||||
if err != nil {
|
||||
return sla, err
|
||||
}
|
||||
|
||||
// Schedule SLA notifications if there are any, SLA breaches did not happen yet as this is the first time SLA is applied.
|
||||
// So, only schedule SLA breach warnings.
|
||||
m.createNotificationSchedule(sla.Notifications, appliedSLAID, deadlines, Breaches{})
|
||||
m.createNotificationSchedule(sla.Notifications, appliedSLAID, null.Int{}, deadlines, Breaches{})
|
||||
|
||||
return sla, nil
|
||||
}
|
||||
|
||||
// Run starts the SLA evaluation loop and evaluates pending SLAs.
|
||||
func (m *Manager) Run(ctx context.Context, evalInterval time.Duration) {
|
||||
ticker := time.NewTicker(evalInterval)
|
||||
m.wg.Add(1)
|
||||
defer func() {
|
||||
m.wg.Done()
|
||||
ticker.Stop()
|
||||
}()
|
||||
// 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.GetSLAPolicy.Get(&slaPolicy, slaPolicyID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
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)
|
||||
}
|
||||
|
||||
if slaPolicy.NextResponseTime.String == "" {
|
||||
m.lo.Info("no next response time set for SLA policy, skipping event creation",
|
||||
"conversation_id", conversationID,
|
||||
"policy_id", slaPolicyID,
|
||||
"applied_sla_id", appliedSLAID,
|
||||
)
|
||||
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.
|
||||
deadlines, err := m.GetDeadlines(time.Now(), slaPolicy.ID, assignedTeamID)
|
||||
if err != nil {
|
||||
m.lo.Error("error calculating deadlines for next response SLA event", "error", err)
|
||||
return time.Time{}, fmt.Errorf("calculating deadlines for next response SLA event: %w", err)
|
||||
}
|
||||
|
||||
if deadlines.NextResponse.IsZero() {
|
||||
m.lo.Info("next response deadline is zero, skipping event creation",
|
||||
"conversation_id", conversationID,
|
||||
"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, slaPolicyID, appliedSLAID)
|
||||
}
|
||||
|
||||
var slaEventID int
|
||||
if err := m.q.InsertNextResponseSLAEvent.QueryRow(appliedSLAID, slaPolicyID, deadlines.NextResponse).Scan(&slaEventID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
m.lo.Info("skipping next response SLA event creation; unmet event already exists",
|
||||
"conversation_id", conversationID,
|
||||
"policy_id", slaPolicy.ID,
|
||||
"applied_sla_id", appliedSLAID,
|
||||
)
|
||||
return time.Time{}, ErrUnmetSLAEventAlreadyExists
|
||||
}
|
||||
m.lo.Error("error inserting SLA event",
|
||||
"error", err,
|
||||
"conversation_id", conversationID,
|
||||
"applied_sla_id", appliedSLAID,
|
||||
)
|
||||
return time.Time{}, fmt.Errorf("inserting SLA event (applied_sla: %d): %w", appliedSLAID, err)
|
||||
}
|
||||
|
||||
// Update next SLA deadline (SLA target) in the conversation.
|
||||
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,
|
||||
"applied_sla_id", appliedSLAID,
|
||||
)
|
||||
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 = null.Time{}
|
||||
deadlines.Resolution = null.Time{}
|
||||
m.createNotificationSchedule(slaPolicy.Notifications, appliedSLAID, null.IntFrom(slaEventID), deadlines, Breaches{})
|
||||
|
||||
return deadlines.NextResponse.Time, nil
|
||||
}
|
||||
|
||||
// SetLatestSLAEventMetAt marks the latest SLA event as met for a given applied SLA.
|
||||
func (m *Manager) SetLatestSLAEventMetAt(appliedSLAID int, metric string) (time.Time, error) {
|
||||
var metAt time.Time
|
||||
if err := m.q.SetLatestSLAEventMetAt.QueryRow(appliedSLAID, metric).Scan(&metAt); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
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)
|
||||
return metAt, fmt.Errorf("marking SLA event as met: %w", err)
|
||||
}
|
||||
return metAt, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
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)
|
||||
for _, event := range slaEvents {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if err := m.q.GetSLAEvent.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 evaluation", "sla_event_id", event.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Met at after the deadline or current time is after the deadline - mark event breached.
|
||||
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.UpdateSLAEventAsBreached.Exec(event.ID); err != nil {
|
||||
m.lo.Error("error marking SLA event as breached", "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Met at before the deadline - mark event met.
|
||||
if event.MetAt.Valid && event.MetAt.Time.Before(event.DeadlineAt) {
|
||||
if _, err := m.q.UpdateSLAEventAsMet.Exec(event.ID); err != nil {
|
||||
m.lo.Error("error marking SLA event as met", "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule a breach notification if the event is not met at all and SLA breached.
|
||||
if !event.MetAt.Valid && hasBreached {
|
||||
// Get policy from cache.
|
||||
slaPolicy, ok := slaPolicyCache[event.SlaPolicyID]
|
||||
if !ok {
|
||||
var err error
|
||||
slaPolicy, err = m.Get(event.SlaPolicyID)
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching SLA policy", "error", err)
|
||||
continue
|
||||
}
|
||||
slaPolicyCache[event.SlaPolicyID] = slaPolicy
|
||||
}
|
||||
m.createNotificationSchedule(slaPolicy.Notifications, event.AppliedSLAID, null.IntFrom(event.ID), Deadlines{}, Breaches{
|
||||
NextResponse: null.TimeFrom(time.Now()),
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// runSLAEvaluation periodically evaluates pending SLAs.
|
||||
func (m *Manager) runSLAEvaluation(ctx context.Context, interval time.Duration) {
|
||||
defer m.wg.Done()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -270,9 +474,29 @@ func (m *Manager) Run(ctx context.Context, evalInterval time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// runSLAEventEvaluation periodically evaluates pending SLA events.
|
||||
func (m *Manager) runSLAEventEvaluation(ctx context.Context, interval time.Duration) {
|
||||
defer m.wg.Done()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := m.evaluatePendingSLAEvents(ctx); err != nil {
|
||||
m.lo.Error("error marking SLA events as breached", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendNotifications picks scheduled SLA notifications from the database and sends them to agents as emails.
|
||||
func (m *Manager) SendNotifications(ctx context.Context) error {
|
||||
time.Sleep(10 * time.Second)
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -284,54 +508,77 @@ func (m *Manager) SendNotifications(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
m.lo.Error("error fetching scheduled SLA notifications", "error", err)
|
||||
} else {
|
||||
m.lo.Debug("found scheduled SLA notifications", "count", len(notifications))
|
||||
} else if len(notifications) > 0 {
|
||||
m.lo.Info("found scheduled SLA notifications", "count", len(notifications))
|
||||
for _, notification := range notifications {
|
||||
// Exit early if context is done.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
default:
|
||||
if err := m.SendNotification(notification); err != nil {
|
||||
m.lo.Error("error sending notification", "error", err)
|
||||
}
|
||||
}
|
||||
if err := m.SendNotification(notification); err != nil {
|
||||
m.lo.Error("error sending notification", "error", err)
|
||||
}
|
||||
}
|
||||
if len(notifications) > 0 {
|
||||
m.lo.Debug("sent SLA notifications", "count", len(notifications))
|
||||
}
|
||||
m.lo.Info("sent SLA notifications", "count", len(notifications))
|
||||
}
|
||||
|
||||
// Sleep for short duration to avoid hammering the database.
|
||||
time.Sleep(30 * time.Second)
|
||||
<-ticker.C
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendNotification sends a SLA notification to agents.
|
||||
// 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
|
||||
var (
|
||||
appliedSLA models.AppliedSLA
|
||||
slaEvent models.SLAEvent
|
||||
)
|
||||
if scheduledNotification.SlaEventID.Int != 0 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
if err := m.q.GetAppliedSLA.Get(&appliedSLA, scheduledNotification.AppliedSLAID); err != nil {
|
||||
m.lo.Error("error fetching applied SLA", "error", err)
|
||||
return fmt.Errorf("fetching applied SLA for notification: %w", err)
|
||||
}
|
||||
|
||||
// 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.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil {
|
||||
m.lo.Error("error marking notification as processed", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send to all recipients (agents).
|
||||
for _, recipientS := range scheduledNotification.Recipients {
|
||||
// Check if SLA is already met, if met for the metric, skip the notification and mark the notification as processed.
|
||||
// Check if SLA is already met, if met mark notification as processed and return.
|
||||
switch scheduledNotification.Metric {
|
||||
case MetricFirstResponse:
|
||||
if appliedSLA.FirstResponseMetAt.Valid {
|
||||
m.lo.Debug("skipping notification as first response is already met", "applied_sla_id", appliedSLA.ID)
|
||||
if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil {
|
||||
m.lo.Info("skipping notification as first response is already met", "applied_sla_id", appliedSLA.ID)
|
||||
if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil {
|
||||
m.lo.Error("error marking notification as processed", "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
case MetricsResolution:
|
||||
case MetricResolution:
|
||||
if appliedSLA.ResolutionMetAt.Valid {
|
||||
m.lo.Debug("skipping notification as resolution is already met", "applied_sla_id", appliedSLA.ID)
|
||||
if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil {
|
||||
m.lo.Info("skipping notification as resolution is already met", "applied_sla_id", appliedSLA.ID)
|
||||
if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil {
|
||||
m.lo.Error("error marking notification as processed", "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
case MetricNextResponse:
|
||||
if slaEvent.ID == 0 {
|
||||
m.lo.Warn("next response SLA event not found", "scheduled_notification_id", scheduledNotification.ID)
|
||||
return fmt.Errorf("next response SLA event not found for notification: %d", scheduledNotification.ID)
|
||||
}
|
||||
if slaEvent.MetAt.Valid {
|
||||
m.lo.Info("skipping notification as next response is already met", "applied_sla_id", appliedSLA.ID)
|
||||
if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil {
|
||||
m.lo.Error("error marking notification as processed", "error", err)
|
||||
}
|
||||
continue
|
||||
@@ -349,10 +596,19 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti
|
||||
m.lo.Error("error parsing recipient ID", "error", err, "recipient_id", recipientS)
|
||||
continue
|
||||
}
|
||||
|
||||
// Recipient not found?
|
||||
if recipientID == 0 {
|
||||
if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil {
|
||||
m.lo.Error("error marking notification as processed", "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
agent, err := m.userStore.GetAgent(recipientID, "")
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching agent for SLA notification", "recipient_id", recipientID, "error", err)
|
||||
if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil {
|
||||
if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil {
|
||||
m.lo.Error("error marking notification as processed", "error", err)
|
||||
}
|
||||
continue
|
||||
@@ -378,18 +634,21 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti
|
||||
getFriendlyDuration := func(target time.Time) string {
|
||||
d := time.Until(target)
|
||||
if d < 0 {
|
||||
return "Overdue by " + stringutil.FormatDuration(-d, false)
|
||||
return stringutil.FormatDuration(-d, false)
|
||||
}
|
||||
return stringutil.FormatDuration(d, false)
|
||||
}
|
||||
|
||||
switch scheduledNotification.Metric {
|
||||
case MetricFirstResponse:
|
||||
dueIn = getFriendlyDuration(appliedSLA.FirstResponseDeadlineAt)
|
||||
dueIn = getFriendlyDuration(appliedSLA.FirstResponseDeadlineAt.Time)
|
||||
overdueBy = getFriendlyDuration(appliedSLA.FirstResponseBreachedAt.Time)
|
||||
case MetricsResolution:
|
||||
dueIn = getFriendlyDuration(appliedSLA.ResolutionDeadlineAt)
|
||||
case MetricResolution:
|
||||
dueIn = getFriendlyDuration(appliedSLA.ResolutionDeadlineAt.Time)
|
||||
overdueBy = getFriendlyDuration(appliedSLA.ResolutionBreachedAt.Time)
|
||||
case MetricNextResponse:
|
||||
dueIn = getFriendlyDuration(slaEvent.DeadlineAt)
|
||||
overdueBy = getFriendlyDuration(slaEvent.BreachedAt.Time)
|
||||
default:
|
||||
m.lo.Error("unknown metric type", "metric", scheduledNotification.Metric)
|
||||
return fmt.Errorf("unknown metric type: %s", scheduledNotification.Metric)
|
||||
@@ -446,8 +705,8 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti
|
||||
m.lo.Error("error sending email notification", "error", err)
|
||||
}
|
||||
|
||||
// Set the notification as processed.
|
||||
if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil {
|
||||
// Mark the notification as processed.
|
||||
if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil {
|
||||
m.lo.Error("error marking notification as processed", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -511,59 +770,65 @@ func (m *Manager) getBusinessHoursAndTimezone(assignedTeamID int) (bmodels.Busin
|
||||
return bh, timezone, nil
|
||||
}
|
||||
|
||||
// createNotificationSchedule creates a notification schedule in database for the applied SLA.
|
||||
func (m *Manager) createNotificationSchedule(notifications models.SlaNotifications, appliedSLAID int, deadlines Deadlines, breaches Breaches) {
|
||||
// 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)
|
||||
m.lo.Warn("skipping scheduling notification as it is in the past", "send_at", sendAt, "applied_sla_id", appliedSLAID, "metric", metric, "type", notifType)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := m.q.InsertScheduledSLANotification.Exec(appliedSLAID, metric, notifType, pq.Array(recipients), sendAt); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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), MetricsResolution, notif.Type, notif.Recipients)
|
||||
}
|
||||
} else if notif.Type == NotificationTypeBreach {
|
||||
if !breaches.FirstResponse.IsZero() {
|
||||
scheduleNotification(breaches.FirstResponse.Add(delayDur), MetricFirstResponse, notif.Type, notif.Recipients)
|
||||
}
|
||||
if !breaches.Resolution.IsZero() {
|
||||
scheduleNotification(breaches.Resolution.Add(delayDur), MetricsResolution, notif.Type, notif.Recipients)
|
||||
if notif.Metric == "" {
|
||||
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.Time.Add(-delayDur)
|
||||
} else {
|
||||
sendAt = target.Time.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -583,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")
|
||||
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
|
||||
@@ -603,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)
|
||||
}
|
||||
}
|
||||
@@ -617,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, 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", MetricsResolution)
|
||||
if err := checkDeadline(sla.ResolutionDeadlineAt, sla.ConversationResolvedAt, MetricsResolution); 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.SetNextSLADeadline.Exec(sla.ConversationID); 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
|
||||
}
|
||||
|
||||
@@ -658,15 +924,15 @@ 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()
|
||||
} else if metric == MetricsResolution {
|
||||
resolution = time.Now()
|
||||
firstResponse = null.TimeFrom(time.Now())
|
||||
} else if metric == MetricResolution {
|
||||
resolution = null.TimeFrom(time.Now())
|
||||
}
|
||||
|
||||
// Create notification schedule.
|
||||
m.createNotificationSchedule(sla.Notifications, appliedSLAID, Deadlines{}, Breaches{
|
||||
m.createNotificationSchedule(sla.Notifications, appliedSLAID, null.Int{}, Deadlines{}, Breaches{
|
||||
FirstResponse: firstResponse,
|
||||
Resolution: resolution,
|
||||
})
|
||||
|
||||
24
schema.sql
24
schema.sql
@@ -15,7 +15,8 @@ DROP TYPE IF EXISTS "media_disposition" CASCADE; CREATE TYPE "media_disposition"
|
||||
DROP TYPE IF EXISTS "media_store" CASCADE; CREATE TYPE "media_store" AS ENUM ('s3', 'fs');
|
||||
DROP TYPE IF EXISTS "user_availability_status" CASCADE; CREATE TYPE "user_availability_status" AS ENUM ('online', 'away', 'away_manual', 'offline', 'away_and_reassigning');
|
||||
DROP TYPE IF EXISTS "applied_sla_status" CASCADE; CREATE TYPE "applied_sla_status" AS ENUM ('pending', 'breached', 'met', 'partially_met');
|
||||
DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('first_response', 'resolution');
|
||||
DROP TYPE IF EXISTS "sla_event_status" CASCADE; CREATE TYPE "sla_event_status" AS ENUM ('pending', 'breached', 'met');
|
||||
DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('first_response', 'resolution', 'next_response');
|
||||
DROP TYPE IF EXISTS "sla_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach');
|
||||
DROP TYPE IF EXISTS "activity_log_type" CASCADE; CREATE TYPE "activity_log_type" AS ENUM ('agent_login', 'agent_logout', 'agent_away', 'agent_away_reassigned', 'agent_online');
|
||||
|
||||
@@ -39,6 +40,7 @@ CREATE TABLE sla_policies (
|
||||
description TEXT NULL,
|
||||
first_response_time TEXT NOT NULL,
|
||||
resolution_time TEXT NOT NULL,
|
||||
next_response_time TEXT NULL,
|
||||
notifications JSONB DEFAULT '[]'::jsonb NOT NULL,
|
||||
CONSTRAINT constraint_sla_policies_on_name CHECK (length(name) <= 140),
|
||||
CONSTRAINT constraint_sla_policies_on_description CHECK (length(description) <= 300)
|
||||
@@ -201,7 +203,7 @@ CREATE TABLE conversations (
|
||||
|
||||
-- Set to NULL when SLA policy is deleted.
|
||||
sla_policy_id INT REFERENCES sla_policies(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
|
||||
|
||||
-- Cascade deletes when inbox is deleted.
|
||||
inbox_id INT REFERENCES inboxes(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
|
||||
|
||||
@@ -374,6 +376,7 @@ CREATE TABLE team_members (
|
||||
CONSTRAINT constraint_team_members_on_emoji CHECK (length(emoji) <= 1)
|
||||
);
|
||||
CREATE UNIQUE INDEX index_unique_team_members_on_team_id_and_user_id ON team_members (team_id, user_id);
|
||||
CREATE INDEX index_team_members_on_user_id ON team_members (user_id);
|
||||
|
||||
DROP TABLE IF EXISTS templates CASCADE;
|
||||
CREATE TABLE templates (
|
||||
@@ -456,12 +459,29 @@ CREATE TABLE applied_slas (
|
||||
CREATE INDEX index_applied_slas_on_conversation_id ON applied_slas(conversation_id);
|
||||
CREATE INDEX index_applied_slas_on_status ON applied_slas(status);
|
||||
|
||||
DROP TABLE IF EXISTS sla_events CASCADE;
|
||||
CREATE TABLE sla_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
status sla_event_status DEFAULT 'pending' NOT NULL,
|
||||
applied_sla_id BIGINT REFERENCES applied_slas(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
|
||||
sla_policy_id INT REFERENCES sla_policies(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
|
||||
type sla_metric NOT NULL,
|
||||
deadline_at TIMESTAMPTZ NOT NULL,
|
||||
met_at TIMESTAMPTZ,
|
||||
breached_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX index_sla_events_on_applied_sla_id ON sla_events(applied_sla_id);
|
||||
CREATE INDEX index_sla_events_on_status ON sla_events(status);
|
||||
|
||||
DROP TABLE IF EXISTS scheduled_sla_notifications CASCADE;
|
||||
CREATE TABLE scheduled_sla_notifications (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
applied_sla_id BIGINT NOT NULL REFERENCES applied_slas(id) ON DELETE CASCADE,
|
||||
sla_event_id BIGINT REFERENCES sla_events(id) ON DELETE CASCADE,
|
||||
metric sla_metric NOT NULL,
|
||||
notification_type sla_notification_type NOT NULL,
|
||||
recipients TEXT[] NOT NULL,
|
||||
|
||||
Reference in New Issue
Block a user