mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-03 21:43:35 +00:00
Feat: Add SLAs per team
- Feat: Provide the ability to change SLAs for conversations; this will start a new deadline calculation from the time the SLA is set.
This commit is contained in:
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
businessHours "github.com/abhinavxd/libredesk/internal/business_hours"
|
||||
models "github.com/abhinavxd/libredesk/internal/business_hours/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/valyala/fasthttp"
|
||||
@@ -30,10 +31,12 @@ func handleGetBusinessHour(r *fastglue.Request) error {
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid business hour `id`.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
businessHour, err := app.businessHours.Get(id)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||
if err == businessHours.ErrBusinessHoursNotFound {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusNotFound, err.Error(), nil, envelope.NotFoundError)
|
||||
}
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error fetching business hour", nil, "")
|
||||
}
|
||||
return r.SendEnvelope(businessHour)
|
||||
}
|
||||
|
||||
@@ -39,10 +39,10 @@ func handleGetAllConversations(r *fastglue.Request) error {
|
||||
total = conversations[0].Total
|
||||
}
|
||||
|
||||
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||
// Set deadlines for SLA if conversation has a policy
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
calculateSLA(app, &conversations[i])
|
||||
setSLADeadlines(app, &conversations[i])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,10 +75,10 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
|
||||
total = conversations[0].Total
|
||||
}
|
||||
|
||||
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||
// Set deadlines for SLA if conversation has a policy
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
calculateSLA(app, &conversations[i])
|
||||
setSLADeadlines(app, &conversations[i])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,10 +111,10 @@ func handleGetUnassignedConversations(r *fastglue.Request) error {
|
||||
total = conversations[0].Total
|
||||
}
|
||||
|
||||
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||
// Set deadlines for SLA if conversation has a policy
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
calculateSLA(app, &conversations[i])
|
||||
setSLADeadlines(app, &conversations[i])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,10 +189,10 @@ func handleGetViewConversations(r *fastglue.Request) error {
|
||||
total = conversations[0].Total
|
||||
}
|
||||
|
||||
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||
// Set deadlines for SLA if conversation has a policy
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
calculateSLA(app, &conversations[i])
|
||||
setSLADeadlines(app, &conversations[i])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,10 +241,10 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
|
||||
total = conversations[0].Total
|
||||
}
|
||||
|
||||
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||
// Set deadlines for SLA if conversation has a policy
|
||||
for i := range conversations {
|
||||
if conversations[i].SLAPolicyID.Int != 0 {
|
||||
calculateSLA(app, &conversations[i])
|
||||
setSLADeadlines(app, &conversations[i])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,9 +280,9 @@ func handleGetConversation(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
|
||||
}
|
||||
|
||||
// Calculate SLA deadlines if conversation has an SLA policy.
|
||||
// Set deadlines for SLA if conversation has a policy
|
||||
if conversation.SLAPolicyID.Int != 0 {
|
||||
calculateSLA(app, &conversation)
|
||||
setSLADeadlines(app, &conversation)
|
||||
}
|
||||
return r.SendEnvelope(conversation)
|
||||
}
|
||||
@@ -406,22 +406,26 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
conversation, err := app.conversation.GetConversation(0, uuid)
|
||||
conversation, err := enforceConversationAccess(app, uuid, user)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
allowed, err := app.authz.EnforceConversationAccess(user, conversation)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if !allowed {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
|
||||
}
|
||||
if err := app.conversation.UpdateConversationTeamAssignee(uuid, assigneeID, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// TODO: Set SLA if the team has an SLA policy set.
|
||||
// Apply SLA policy if team has changed and has an SLA policy.
|
||||
if conversation.AssignedTeamID.Int != assigneeID && assigneeID != 0 {
|
||||
team, err := app.team.Get(assigneeID)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error fetching team for setting SLA", nil, envelope.GeneralError)
|
||||
}
|
||||
if team.SLAPolicyID.Int != 0 {
|
||||
if err := app.conversation.ApplySLA(conversation.UUID, conversation.ID, team.SLAPolicyID.Int, user); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error applying SLA policy", nil, envelope.GeneralError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate automation rules on team assignment.
|
||||
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationTeamAssigned)
|
||||
@@ -596,15 +600,18 @@ func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmode
|
||||
return &conversation, nil
|
||||
}
|
||||
|
||||
// calculateSLA calculates the SLA deadlines and sets them on the conversation.
|
||||
func calculateSLA(app *App, conversation *cmodels.Conversation) error {
|
||||
firstRespAt, resolutionDueAt, err := app.sla.CalculateConversationDeadlines(conversation.CreatedAt, conversation.AssignedTeamID.Int, conversation.SLAPolicyID.Int)
|
||||
// setSLADeadlines gets the latest SLA deadlines for a conversation and sets them.
|
||||
func setSLADeadlines(app *App, conversation *cmodels.Conversation) error {
|
||||
if conversation.ID < 1 {
|
||||
return nil
|
||||
}
|
||||
first, resolution, err := app.sla.GetLatestDeadlines(conversation.ID)
|
||||
if err != nil {
|
||||
app.lo.Error("error calculating SLA deadlines for conversation", "id", conversation.ID, "error", err)
|
||||
app.lo.Error("error getting SLA deadlines", "id", conversation.ID, "error", err)
|
||||
return err
|
||||
}
|
||||
conversation.FirstReplyDueAt = null.NewTime(firstRespAt, firstRespAt != time.Time{})
|
||||
conversation.ResolutionDueAt = null.NewTime(resolutionDueAt, resolutionDueAt != time.Time{})
|
||||
conversation.FirstResponseDueAt = null.NewTime(first, first != time.Time{})
|
||||
conversation.ResolutionDueAt = null.NewTime(resolution, resolution != time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ import (
|
||||
tmpl "github.com/abhinavxd/libredesk/internal/template"
|
||||
"github.com/abhinavxd/libredesk/internal/user"
|
||||
"github.com/abhinavxd/libredesk/internal/view"
|
||||
"github.com/abhinavxd/libredesk/internal/workerpool"
|
||||
"github.com/abhinavxd/libredesk/internal/ws"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/go-i18n"
|
||||
@@ -278,10 +277,9 @@ func initBusinessHours(db *sqlx.DB) *businesshours.Manager {
|
||||
func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager) *sla.Manager {
|
||||
var lo = initLogger("sla")
|
||||
m, err := sla.New(sla.Opts{
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
ScannerInterval: ko.MustDuration("sla.scanner_interval"),
|
||||
}, workerpool.New(ko.MustInt("sla.worker_count"), ko.MustInt("sla.queue_size")), teamManager, settings, businessHours)
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
}, teamManager, settings, businessHours)
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing SLA manager: %v", err)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ func handleCreateSLA(r *fastglue.Request) error {
|
||||
firstRespTime = string(r.RequestCtx.PostArgs().Peek("first_response_time"))
|
||||
resTime = string(r.RequestCtx.PostArgs().Peek("resolution_time"))
|
||||
)
|
||||
|
||||
// Validate time duration strings
|
||||
if _, err := time.ParseDuration(firstRespTime); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `first_response_time` duration.", nil, envelope.InputError)
|
||||
@@ -52,12 +51,10 @@ func handleCreateSLA(r *fastglue.Request) error {
|
||||
if _, err := time.ParseDuration(resTime); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `resolution_time` duration.", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := app.sla.Create(name, desc, firstRespTime, resTime); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope("SLA created successfully.")
|
||||
}
|
||||
|
||||
func handleDeleteSLA(r *fastglue.Request) error {
|
||||
|
||||
@@ -58,8 +58,9 @@ func handleCreateTeam(r *fastglue.Request) error {
|
||||
emoji = string(r.RequestCtx.PostArgs().Peek("emoji"))
|
||||
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
|
||||
businessHrsID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
|
||||
slaPolicyID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("sla_policy_id")))
|
||||
)
|
||||
if err := app.team.Create(name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID > 0), emoji); err != nil {
|
||||
if err := app.team.Create(name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope("Team created successfully.")
|
||||
@@ -75,11 +76,12 @@ func handleUpdateTeam(r *fastglue.Request) error {
|
||||
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
|
||||
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
businessHrsID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
|
||||
slaPolicyID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("sla_policy_id")))
|
||||
)
|
||||
if id < 1 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid team `id`", nil, envelope.InputError)
|
||||
}
|
||||
if err := app.team.Update(id, name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID > 0), emoji); err != nil {
|
||||
if err := app.team.Update(id, name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope("Team updated successfully.")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<form @submit="onSubmit" class="space-y-6">
|
||||
|
||||
<FormField name="emoji" v-slot="{ componentField }">
|
||||
<FormItem ref="emojiPickerContainer" class="relative">
|
||||
<FormLabel>Emoji</FormLabel>
|
||||
@@ -43,7 +42,7 @@
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Round robin: Conversations are assigned to team members in a round-robin fashion. <br>
|
||||
Round robin: Conversations are assigned to team members in a round-robin fashion. <br />
|
||||
Manual: Conversations are to be picked by team members.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -89,7 +88,34 @@
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Default business hours for the team, will be used to calculate SLA.</FormDescription>
|
||||
<FormDescription
|
||||
>Default business hours for the team, will be used to calculate SLA.</FormDescription
|
||||
>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="sla_policy_id">
|
||||
<FormItem>
|
||||
<FormLabel>SLA policy</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select policy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="sla in slaStore.options" :key="sla.value" :value="parseInt(sla.value)">
|
||||
{{ sla.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription
|
||||
>SLA policy to be auto applied to conversations, when conversations are assigned to this
|
||||
team.</FormDescription
|
||||
>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
@@ -127,12 +153,15 @@ import { Input } from '@/components/ui/input'
|
||||
import EmojiPicker from 'vue3-emoji-picker'
|
||||
import 'vue3-emoji-picker/css'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { useSlaStore } from '@/stores/sla'
|
||||
import api from '@/api'
|
||||
|
||||
const emitter = useEmitter()
|
||||
const slaStore = useSlaStore()
|
||||
const timezones = computed(() => Intl.supportedValuesOf('timeZone'))
|
||||
const assignmentTypes = ['Round robin', 'Manual']
|
||||
const businessHours = ref([])
|
||||
const slaPolicies = ref([])
|
||||
|
||||
const props = defineProps({
|
||||
initialValues: { type: Object, required: false },
|
||||
@@ -161,39 +190,40 @@ const fetchBusinessHours = async () => {
|
||||
businessHours.value = response.data.data
|
||||
} catch (error) {
|
||||
// If unauthorized (no permission), show a toast message.
|
||||
const toastPayload = error.response.status === 403
|
||||
? {
|
||||
title: 'Unauthorized',
|
||||
variant: 'destructive',
|
||||
description: 'You do not have permission to view business hours.'
|
||||
}
|
||||
: {
|
||||
title: 'Could not fetch business hours',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
}
|
||||
const toastPayload =
|
||||
error.response.status === 403
|
||||
? {
|
||||
title: 'Unauthorized',
|
||||
variant: 'destructive',
|
||||
description: 'You do not have permission to view business hours.'
|
||||
}
|
||||
: {
|
||||
title: 'Could not fetch business hours',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
}
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, toastPayload)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = form.handleSubmit(values => {
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
props.submitForm(values)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
newValues => {
|
||||
(newValues) => {
|
||||
if (Object.keys(newValues).length === 0) return
|
||||
form.setValues(newValues)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function toggleEmojiPicker () {
|
||||
function toggleEmojiPicker() {
|
||||
isEmojiPickerVisible.value = !isEmojiPickerVisible.value
|
||||
}
|
||||
|
||||
function onSelectEmoji (emoji) {
|
||||
function onSelectEmoji(emoji) {
|
||||
form.setFieldValue('emoji', emoji.i || emoji)
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -12,4 +12,5 @@ export const teamFormSchema = z.object({
|
||||
conversation_assignment_type: z.string({ required_error: 'Conversation assignment type is required.' }),
|
||||
timezone: z.string({ required_error: 'Timezone is required.' }),
|
||||
business_hours_id: z.number().optional().nullable(),
|
||||
sla_policy_id: z.number().optional().nullable(),
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
<div class="flex items-center mt-2 space-x-2">
|
||||
<SlaDisplay
|
||||
:dueAt="conversation.first_reply_due_at"
|
||||
:dueAt="conversation.first_response_due_at"
|
||||
:actualAt="conversation.first_reply_at"
|
||||
:label="'FRD'"
|
||||
:showSLAHit="false"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<div class="flex justify-start items-center space-x-2">
|
||||
<p class="font-medium">First reply at</p>
|
||||
<SlaDisplay
|
||||
:dueAt="conversation.first_reply_due_at"
|
||||
:dueAt="conversation.first_response_due_at"
|
||||
:actualAt="conversation.first_reply_at"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
package businesshours
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/business_hours/models"
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
@@ -15,7 +17,8 @@ import (
|
||||
|
||||
var (
|
||||
//go:embed queries.sql
|
||||
efs embed.FS
|
||||
efs embed.FS
|
||||
ErrBusinessHoursNotFound = errors.New("business hours not found")
|
||||
)
|
||||
|
||||
// Manager manages business hours.
|
||||
@@ -45,25 +48,25 @@ type queries struct {
|
||||
// New creates and returns a new instance of the Manager.
|
||||
func New(opts Opts) (*Manager, error) {
|
||||
var q queries
|
||||
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
q: q,
|
||||
lo: opts.Lo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get retrieves business hours by ID.
|
||||
// Get retrieves business hours.
|
||||
func (m *Manager) Get(id int) (models.BusinessHours, error) {
|
||||
var bh models.BusinessHours
|
||||
if err := m.q.GetBusinessHours.Get(&bh, id); err != nil {
|
||||
m.lo.Error("error fetching business hours", "error", err)
|
||||
return bh, envelope.NewError(envelope.GeneralError, "Error fetching business hours", nil)
|
||||
var businessHours models.BusinessHours
|
||||
if err := m.q.GetBusinessHours.Get(&businessHours, id); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return businessHours, ErrBusinessHoursNotFound
|
||||
}
|
||||
return businessHours, err
|
||||
}
|
||||
return bh, nil
|
||||
return businessHours, nil
|
||||
}
|
||||
|
||||
// GetAll retrieves all business hours.
|
||||
|
||||
@@ -73,7 +73,7 @@ type Manager struct {
|
||||
}
|
||||
|
||||
type slaStore interface {
|
||||
ApplySLA(conversationID, slaID int) (slaModels.SLAPolicy, error)
|
||||
ApplySLA(conversationID, assignedTeamID, slaID int) (slaModels.SLAPolicy, error)
|
||||
}
|
||||
|
||||
type statusStore interface {
|
||||
@@ -694,6 +694,20 @@ func (m *Manager) UnassignOpen(userID int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplySLA applies the SLA policy to a conversation.
|
||||
func (m *Manager) ApplySLA(conversationUUID string, conversationID, policyID int, actor umodels.User) error {
|
||||
policy, err := m.slaStore.ApplySLA(conversationID, 0, policyID)
|
||||
if err != nil {
|
||||
return envelope.NewError(envelope.GeneralError, "Error applying SLA", nil)
|
||||
}
|
||||
|
||||
// Record the SLA application as an activity.
|
||||
if err := m.RecordSLASet(conversationUUID, policy.Name, actor); err != nil {
|
||||
return envelope.NewError(envelope.GeneralError, "Error recording SLA application", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyAction applies an action to a conversation, this can be called from multiple packages across the app to perform actions on conversations.
|
||||
// all actions are executed on behalf of the provided user if the user is not provided, system user is used.
|
||||
func (m *Manager) ApplyAction(action amodels.RuleAction, conversation models.Conversation, user umodels.User) error {
|
||||
@@ -748,14 +762,10 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conversation models.Con
|
||||
}
|
||||
case amodels.ActionSetSLA:
|
||||
m.lo.Debug("executing apply SLA action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
|
||||
slaID, _ := strconv.Atoi(action.Value[0])
|
||||
slaPolicy, err := m.slaStore.ApplySLA(conversation.ID, slaID)
|
||||
if err != nil {
|
||||
slaPolicyID, _ := strconv.Atoi(action.Value[0])
|
||||
if err := m.ApplySLA(conversation.UUID, conversation.ID, slaPolicyID, user); err != nil {
|
||||
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
|
||||
}
|
||||
if err := m.RecordSLASet(conversation.UUID, slaPolicy.Name, user); err != nil {
|
||||
m.lo.Error("error recording SLA set activity", "error", err)
|
||||
}
|
||||
case amodels.ActionSetTags:
|
||||
m.lo.Debug("executing set tags action", "value", action.Value, "conversation_uuid", conversation.UUID)
|
||||
if err := m.UpsertConversationTags(conversation.UUID, action.Value); err != nil {
|
||||
|
||||
@@ -147,7 +147,7 @@ func (m *Manager) processOutgoingMessage(message models.Message) {
|
||||
// Helper function to handle errors
|
||||
handleError := func(err error, errorMsg string) bool {
|
||||
if err != nil {
|
||||
m.lo.Error(errorMsg, "error", err, "id", message.ID)
|
||||
m.lo.Error(errorMsg, "error", err, "message_id", message.ID)
|
||||
m.UpdateMessageStatus(message.UUID, MessageStatusFailed)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ type Conversation struct {
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
UUID string `db:"uuid" json:"uuid"`
|
||||
InboxID int `db:"inbox_id" json:"inbox_id"`
|
||||
InboxID int `db:"inbox_id" json:"inbox_id,omitempty"`
|
||||
ClosedAt null.Time `db:"closed_at" json:"closed_at,omitempty"`
|
||||
ResolvedAt null.Time `db:"resolved_at" json:"resolved_at,omitempty"`
|
||||
ReferenceNumber string `db:"reference_number" json:"reference_number,omitempty"`
|
||||
@@ -56,11 +56,11 @@ type Conversation struct {
|
||||
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
|
||||
LastMessage null.String `db:"last_message" json:"last_message"`
|
||||
Contact umodels.User `db:"contact" json:"contact"`
|
||||
FirstReplyDueAt null.Time `db:"first_reply_due_at" json:"first_reply_due_at"`
|
||||
ResolutionDueAt null.Time `db:"resolution_due_at" json:"resolution_due_at"`
|
||||
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
|
||||
SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"`
|
||||
NextSLADeadlineAt null.Time `db:"next_sla_deadline_at" json:"next_sla_deadline_at"`
|
||||
FirstResponseDueAt null.Time `db:"-" json:"first_response_due_at"`
|
||||
ResolutionDueAt null.Time `db:"-" json:"resolution_due_at"`
|
||||
Total int `db:"total" json:"-"`
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,13 @@ RETURNING id, uuid;
|
||||
-- name: get-conversations
|
||||
SELECT
|
||||
COUNT(*) OVER() as total,
|
||||
conversations.id,
|
||||
conversations.created_at,
|
||||
conversations.updated_at,
|
||||
conversations.uuid,
|
||||
conversations.assignee_last_seen_at,
|
||||
users.created_at as "contact.created_at",
|
||||
users.updated_at as "contact.updated_at",
|
||||
users.first_name as "contact.first_name",
|
||||
users.last_name as "contact.last_name",
|
||||
users.avatar_url as "contact.avatar_url",
|
||||
@@ -76,6 +79,8 @@ SELECT
|
||||
WHERE ct.conversation_id = c.id),
|
||||
'[]'::json
|
||||
)) AS tags,
|
||||
ct.created_at as "contact.created_at",
|
||||
ct.updated_at as "contact.updated_at",
|
||||
ct.first_name as "contact.first_name",
|
||||
ct.last_name as "contact.last_name",
|
||||
ct.email as "contact.email",
|
||||
|
||||
@@ -8,11 +8,16 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/business_hours/models"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidSLADuration = fmt.Errorf("invalid SLA duration")
|
||||
ErrMaxIterations = fmt.Errorf("sla: exceeded maximum iterations - check configuration")
|
||||
)
|
||||
|
||||
// CalculateDeadline computes the SLA deadline from a start time and SLA duration in minutes
|
||||
// considering the provided holidays, working hours, and time zone.
|
||||
func (m *Manager) CalculateDeadline(start time.Time, slaMinutes int, businessHours models.BusinessHours, timeZone string) (time.Time, error) {
|
||||
if slaMinutes <= 0 {
|
||||
return time.Time{}, fmt.Errorf("SLA duration must be positive")
|
||||
return time.Time{}, ErrInvalidSLADuration
|
||||
}
|
||||
|
||||
// If business is always open, return the deadline as the start time plus the SLA duration.
|
||||
@@ -34,13 +39,13 @@ func (m *Manager) CalculateDeadline(start time.Time, slaMinutes int, businessHou
|
||||
// Unmarshal working hours.
|
||||
var workingHours map[string]models.WorkingHours
|
||||
if err := json.Unmarshal(businessHours.Hours, &workingHours); err != nil {
|
||||
return time.Time{}, fmt.Errorf("could not unmarshal working hours: %v", err)
|
||||
return time.Time{}, fmt.Errorf("could not unmarshal working hours for SLA deadline calcuation: %v", err)
|
||||
}
|
||||
|
||||
// Unmarshal holidays.
|
||||
var holidays = []models.Holiday{}
|
||||
if err := json.Unmarshal(businessHours.Holidays, &holidays); err != nil {
|
||||
return time.Time{}, fmt.Errorf("could not unmarshal holidays: %v", err)
|
||||
return time.Time{}, fmt.Errorf("could not unmarshal holidays for SLA deadline calcuation: %v", err)
|
||||
}
|
||||
|
||||
// Create a map of holidays.
|
||||
@@ -53,7 +58,7 @@ func (m *Manager) CalculateDeadline(start time.Time, slaMinutes int, businessHou
|
||||
for remainingMinutes > 0 {
|
||||
iterations++
|
||||
if iterations > maxIterations {
|
||||
return time.Time{}, fmt.Errorf("sla: exceeded maximum iterations - check configuration")
|
||||
return time.Time{}, ErrMaxIterations
|
||||
}
|
||||
|
||||
// Skip holidays.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// package models contains the model definitions for the SLA package.
|
||||
package models
|
||||
|
||||
import (
|
||||
@@ -7,7 +6,7 @@ import (
|
||||
"github.com/volatiletech/null/v9"
|
||||
)
|
||||
|
||||
// SLAPolicy represents an SLA policy.
|
||||
// SLAPolicy represents a service level agreement policy definition
|
||||
type SLAPolicy struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
@@ -15,23 +14,20 @@ type SLAPolicy struct {
|
||||
Name string `db:"name" json:"name"`
|
||||
Description string `db:"description" json:"description"`
|
||||
FirstResponseTime string `db:"first_response_time" json:"first_response_time"`
|
||||
ResolutionTime string `db:"resolution_time" json:"resolution_time"`
|
||||
EveryResponseTime string `db:"every_response_time" json:"every_response_time"`
|
||||
ResolutionTime string `db:"resolution_time" json:"resolution_time"`
|
||||
}
|
||||
|
||||
// ConversationSLA represents an SLA policy applied to a conversation.
|
||||
type ConversationSLA struct {
|
||||
ID int `db:"id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
ConversationID int `db:"conversation_id"`
|
||||
ConversationCreatedAt time.Time `db:"conversation_created_at"`
|
||||
ConversationFirstReplyAt null.Time `db:"conversation_first_reply_at"`
|
||||
ConversationLastMessageAt null.Time `db:"conversation_last_message_at"`
|
||||
ConversationResolvedAt null.Time `db:"conversation_resolved_at"`
|
||||
ConversationAssignedTeamID null.Int `db:"conversation_assigned_team_id"`
|
||||
SLAPolicyID int `db:"sla_policy_id"`
|
||||
SLAType string `db:"sla_type"`
|
||||
DueAt null.Time `db:"due_at"`
|
||||
BreachedAt null.Time `db:"breached_at"`
|
||||
// AppliedSLA represents an SLA policy applied to a conversation with its deadlines and breach status
|
||||
type AppliedSLA struct {
|
||||
ID int `db:"id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ConversationID int `db:"conversation_id"`
|
||||
SLAPolicyID int `db:"sla_policy_id"`
|
||||
FirstResponseDeadlineAt time.Time `db:"first_response_deadline_at"`
|
||||
ResolutionDeadlineAt time.Time `db:"resolution_deadline_at"`
|
||||
FirstResponseBreachedAt null.Time `db:"first_response_breached_at"`
|
||||
ResolutionBreachedAt null.Time `db:"resolution_breached_at"`
|
||||
FirstResponseAt null.Time `db:"first_response_at"`
|
||||
ResolvedAt null.Time `db:"resolved_at"`
|
||||
}
|
||||
|
||||
@@ -1,105 +1,71 @@
|
||||
-- name: get-sla-policy
|
||||
SELECT id,
|
||||
created_at,
|
||||
updated_at,
|
||||
"name",
|
||||
description,
|
||||
first_response_time,
|
||||
resolution_time
|
||||
FROM sla_policies
|
||||
WHERE id = $1;
|
||||
SELECT * FROM sla_policies WHERE id = $1;
|
||||
|
||||
-- name: get-all-sla-policies
|
||||
SELECT id,
|
||||
created_at,
|
||||
updated_at,
|
||||
"name",
|
||||
description,
|
||||
first_response_time,
|
||||
resolution_time
|
||||
FROM sla_policies
|
||||
ORDER BY updated_at DESC;
|
||||
SELECT * FROM sla_policies ORDER BY updated_at DESC;
|
||||
|
||||
-- name: insert-sla-policy
|
||||
INSERT INTO sla_policies (
|
||||
"name",
|
||||
description,
|
||||
first_response_time,
|
||||
resolution_time
|
||||
)
|
||||
VALUES ($1, $2, $3, $4);
|
||||
name,
|
||||
description,
|
||||
first_response_time,
|
||||
resolution_time
|
||||
) VALUES ($1, $2, $3, $4);
|
||||
|
||||
-- name: delete-sla-policy
|
||||
DELETE FROM sla_policies
|
||||
WHERE id = $1;
|
||||
DELETE FROM sla_policies WHERE id = $1;
|
||||
|
||||
-- name: update-sla-policy
|
||||
UPDATE sla_policies
|
||||
SET "name" = $2,
|
||||
description = $3,
|
||||
first_response_time = $4,
|
||||
resolution_time = $5,
|
||||
updated_at = NOW()
|
||||
UPDATE sla_policies SET
|
||||
name = $2,
|
||||
description = $3,
|
||||
first_response_time = $4,
|
||||
resolution_time = $5,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: apply-sla-policy
|
||||
INSERT INTO applied_slas (
|
||||
status,
|
||||
conversation_id,
|
||||
sla_policy_id
|
||||
)
|
||||
VALUES ($1, $2, $3);
|
||||
|
||||
-- name: get-unbreached-slas
|
||||
-- TODO: name this better.
|
||||
SELECT
|
||||
cs.id,
|
||||
cs.created_at,
|
||||
cs.updated_at,
|
||||
cs.sla_policy_id,
|
||||
cs.sla_type,
|
||||
cs.breached_at,
|
||||
cs.due_at,
|
||||
c.created_at as conversation_created_at,
|
||||
c.first_reply_at as conversation_first_reply_at,
|
||||
c.last_message_at as conversation_last_message_at,
|
||||
c.resolved_at as conversation_resolved_at,
|
||||
c.assigned_team_id as conversation_assigned_team_id
|
||||
FROM conversation_slas cs
|
||||
INNER JOIN conversations c ON cs.conversation_id = c.id AND c.sla_policy_id = cs.sla_policy_id
|
||||
WHERE cs.breached_at is NULL AND cs.met_at is NULL
|
||||
|
||||
-- name: update-breached-at
|
||||
UPDATE conversation_slas
|
||||
SET breached_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: update-due-at
|
||||
WITH updated_slas AS (
|
||||
UPDATE conversation_slas
|
||||
SET due_at = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING conversation_id
|
||||
-- name: apply-sla
|
||||
WITH new_sla AS (
|
||||
INSERT INTO applied_slas (
|
||||
conversation_id,
|
||||
sla_policy_id,
|
||||
first_response_deadline_at,
|
||||
resolution_deadline_at
|
||||
) VALUES ($1, $2, $3, $4)
|
||||
RETURNING conversation_id
|
||||
)
|
||||
-- Also set the earliest due_at in the conversation
|
||||
UPDATE conversations
|
||||
SET next_sla_deadline_at = $2
|
||||
WHERE id IN (SELECT conversation_id FROM updated_slas)
|
||||
AND (next_sla_deadline_at IS NULL OR next_sla_deadline_at > $2);
|
||||
UPDATE conversations
|
||||
SET sla_policy_id = $2,
|
||||
next_sla_deadline_at = LEAST(
|
||||
NULLIF($3, NULL),
|
||||
NULLIF($4, NULL)
|
||||
)
|
||||
WHERE id IN (SELECT conversation_id FROM new_sla);
|
||||
|
||||
-- name: update-met-at
|
||||
UPDATE conversation_slas
|
||||
SET met_at = $2, updated_at = NOW()
|
||||
-- name: get-pending-slas
|
||||
SELECT a.id, a.first_response_deadline_at, c.first_reply_at as first_response_at,
|
||||
a.resolution_deadline_at, c.resolved_at as resolved_at
|
||||
FROM applied_slas a
|
||||
JOIN conversations c ON a.conversation_id = c.id and c.sla_policy_id = a.sla_policy_id
|
||||
WHERE (first_response_breached_at IS NULL AND first_response_met_at IS NULL)
|
||||
OR (resolution_breached_at IS NULL AND resolution_met_at IS NULL);
|
||||
|
||||
-- name: update-breach
|
||||
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: insert-conversation-sla
|
||||
WITH inserted AS (
|
||||
INSERT INTO conversation_slas (conversation_id, sla_policy_id, sla_type)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING conversation_id, sla_policy_id
|
||||
)
|
||||
UPDATE conversations
|
||||
SET sla_policy_id = inserted.sla_policy_id
|
||||
FROM inserted
|
||||
WHERE conversations.id = inserted.conversation_id;
|
||||
-- name: update-met
|
||||
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: get-latest-sla-deadlines
|
||||
SELECT first_response_deadline_at, resolution_deadline_at
|
||||
FROM applied_slas
|
||||
WHERE conversation_id = $1
|
||||
ORDER BY created_at DESC LIMIT 1;
|
||||
@@ -1,53 +1,58 @@
|
||||
// Package sla implements service-level agreement (SLA) calculations for conversations.
|
||||
package sla
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
businessHours "github.com/abhinavxd/libredesk/internal/business_hours"
|
||||
bmodels "github.com/abhinavxd/libredesk/internal/business_hours/models"
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
models "github.com/abhinavxd/libredesk/internal/sla/models"
|
||||
tmodels "github.com/abhinavxd/libredesk/internal/team/models"
|
||||
"github.com/abhinavxd/libredesk/internal/workerpool"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/jmoiron/sqlx/types"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed queries.sql
|
||||
efs embed.FS
|
||||
slaGracePeriod = 5 * time.Minute
|
||||
)
|
||||
|
||||
const (
|
||||
SLATypeFirstResponse = "first_response"
|
||||
SLATypeResolution = "resolution"
|
||||
SLATypeEveryResponse = "every_response"
|
||||
)
|
||||
|
||||
// Manager provides SLA management and calculations.
|
||||
// Manager manages SLA policies and calculations.
|
||||
type Manager struct {
|
||||
q queries
|
||||
lo *logf.Logger
|
||||
pool *workerpool.Pool
|
||||
teamStore teamStore
|
||||
appSettingsStore appSettingsStore
|
||||
businessHrsStore businessHrsStore
|
||||
wg sync.WaitGroup
|
||||
opts Opts
|
||||
}
|
||||
|
||||
// Opts defines options for initializing Manager.
|
||||
// Opts defines the options for creating SLA manager.
|
||||
type Opts struct {
|
||||
DB *sqlx.DB
|
||||
Lo *logf.Logger
|
||||
ScannerInterval time.Duration
|
||||
DB *sqlx.DB
|
||||
Lo *logf.Logger
|
||||
}
|
||||
|
||||
// Deadlines holds the deadlines for an SLA policy.
|
||||
type Deadlines struct {
|
||||
FirstResponse time.Time
|
||||
Resolution time.Time
|
||||
}
|
||||
|
||||
type teamStore interface {
|
||||
@@ -62,38 +67,30 @@ type businessHrsStore interface {
|
||||
Get(id int) (bmodels.BusinessHours, error)
|
||||
}
|
||||
|
||||
// queries holds prepared SQL statements.
|
||||
// queries hold prepared SQL queries.
|
||||
type queries struct {
|
||||
GetSLA *sqlx.Stmt `query:"get-sla-policy"`
|
||||
GetAllSLA *sqlx.Stmt `query:"get-all-sla-policies"`
|
||||
InsertSLA *sqlx.Stmt `query:"insert-sla-policy"`
|
||||
DeleteSLA *sqlx.Stmt `query:"delete-sla-policy"`
|
||||
UpdateSLA *sqlx.Stmt `query:"update-sla-policy"`
|
||||
GetUnbreachedSLAs *sqlx.Stmt `query:"get-unbreached-slas"`
|
||||
UpdateBreachedAt *sqlx.Stmt `query:"update-breached-at"`
|
||||
UpdateDueAt *sqlx.Stmt `query:"update-due-at"`
|
||||
UpdateMetAt *sqlx.Stmt `query:"update-met-at"`
|
||||
InsertConversationSLA *sqlx.Stmt `query:"insert-conversation-sla"`
|
||||
GetSLA *sqlx.Stmt `query:"get-sla-policy"`
|
||||
GetAllSLA *sqlx.Stmt `query:"get-all-sla-policies"`
|
||||
InsertSLA *sqlx.Stmt `query:"insert-sla-policy"`
|
||||
DeleteSLA *sqlx.Stmt `query:"delete-sla-policy"`
|
||||
UpdateSLA *sqlx.Stmt `query:"update-sla-policy"`
|
||||
ApplySLA *sqlx.Stmt `query:"apply-sla"`
|
||||
GetPendingSLAs *sqlx.Stmt `query:"get-pending-slas"`
|
||||
UpdateBreach *sqlx.Stmt `query:"update-breach"`
|
||||
UpdateMet *sqlx.Stmt `query:"update-met"`
|
||||
GetLatestDeadlines *sqlx.Stmt `query:"get-latest-sla-deadlines"`
|
||||
}
|
||||
|
||||
// New returns a new Manager.
|
||||
func New(opts Opts, pool *workerpool.Pool, teamStore teamStore, appSettingsStore appSettingsStore, businessHrsStore businessHrsStore) (*Manager, error) {
|
||||
// New creates a new SLA manager.
|
||||
func New(opts Opts, teamStore teamStore, appSettingsStore appSettingsStore, businessHrsStore businessHrsStore) (*Manager, error) {
|
||||
var q queries
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Manager{
|
||||
q: q,
|
||||
lo: opts.Lo,
|
||||
pool: pool,
|
||||
teamStore: teamStore,
|
||||
appSettingsStore: appSettingsStore,
|
||||
businessHrsStore: businessHrsStore,
|
||||
opts: opts,
|
||||
}, nil
|
||||
return &Manager{q: q, lo: opts.Lo, teamStore: teamStore, appSettingsStore: appSettingsStore, businessHrsStore: businessHrsStore, opts: opts}, nil
|
||||
}
|
||||
|
||||
// Get retrieves an SLA by its ID.
|
||||
// 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 {
|
||||
@@ -114,15 +111,15 @@ func (m *Manager) GetAll() ([]models.SLAPolicy, error) {
|
||||
}
|
||||
|
||||
// Create adds a new SLA policy.
|
||||
func (m *Manager) Create(name, description, firstResponseDuration, resolutionDuration string) error {
|
||||
if _, err := m.q.InsertSLA.Exec(name, description, firstResponseDuration, resolutionDuration); err != nil {
|
||||
func (m *Manager) Create(name, description string, firstResponseTime, resolutionTime string) error {
|
||||
if _, err := m.q.InsertSLA.Exec(name, description, firstResponseTime, resolutionTime); err != nil {
|
||||
m.lo.Error("error inserting SLA", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error creating SLA", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes an SLA policy by its ID.
|
||||
// Delete removes an SLA policy.
|
||||
func (m *Manager) Delete(id int) error {
|
||||
if _, err := m.q.DeleteSLA.Exec(id); err != nil {
|
||||
m.lo.Error("error deleting SLA", "error", err)
|
||||
@@ -131,94 +128,43 @@ func (m *Manager) Delete(id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update modifies an SLA policy by its ID.
|
||||
func (m *Manager) Update(id int, name, description, firstResponseDuration, resolutionDuration string) error {
|
||||
if _, err := m.q.UpdateSLA.Exec(id, name, description, firstResponseDuration, resolutionDuration); err != nil {
|
||||
// Update updates an existing SLA policy.
|
||||
func (m *Manager) Update(id int, name, description string, firstResponseTime, resolutionTime string) error {
|
||||
if _, err := m.q.UpdateSLA.Exec(id, name, description, firstResponseTime, resolutionTime); err != nil {
|
||||
m.lo.Error("error updating SLA", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error updating SLA", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplySLA associates an SLA policy with a conversation.
|
||||
func (m *Manager) ApplySLA(conversationID, slaPolicyID int) (models.SLAPolicy, error) {
|
||||
sla, err := m.Get(slaPolicyID)
|
||||
if err != nil {
|
||||
return sla, err
|
||||
}
|
||||
for _, t := range []string{SLATypeFirstResponse, SLATypeResolution} {
|
||||
if t == SLATypeFirstResponse && sla.FirstResponseTime == "" {
|
||||
continue
|
||||
}
|
||||
if t == SLATypeResolution && sla.ResolutionTime == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := m.q.InsertConversationSLA.Exec(conversationID, slaPolicyID, t); err != nil && !dbutil.IsUniqueViolationError(err) {
|
||||
m.lo.Error("error applying SLA to conversation", "error", err)
|
||||
return sla, envelope.NewError(envelope.GeneralError, "Error applying SLA to conversation", nil)
|
||||
}
|
||||
}
|
||||
return sla, nil
|
||||
}
|
||||
|
||||
// Run starts the SLA worker pool and periodically processes unbreached SLAs (blocking).
|
||||
func (m *Manager) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(m.opts.ScannerInterval)
|
||||
defer ticker.Stop()
|
||||
m.pool.Run()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := m.processUnbreachedSLAs(); err != nil {
|
||||
m.lo.Error("error during SLA periodic check", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down the SLA worker pool.
|
||||
func (m *Manager) Close() error {
|
||||
m.pool.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// CalculateConversationDeadlines calculates deadlines for SLA policies attached to a conversation.
|
||||
func (m *Manager) CalculateConversationDeadlines(conversationCreatedAt time.Time, assignedTeamID, slaPolicyID int) (time.Time, time.Time, error) {
|
||||
// getBusinessHours returns the business hours ID and timezone for a team.
|
||||
func (m *Manager) getBusinessHours(assignedTeamID int) (bmodels.BusinessHours, string, error) {
|
||||
var (
|
||||
businessHrsID, timezone = 0, ""
|
||||
firstResponseDeadline, resolutionDeadline = time.Time{}, time.Time{}
|
||||
businessHrsID int
|
||||
timezone string
|
||||
bh bmodels.BusinessHours
|
||||
)
|
||||
|
||||
// Fetch SLA policy.
|
||||
slaPolicy, err := m.Get(slaPolicyID)
|
||||
if err != nil {
|
||||
return firstResponseDeadline, resolutionDeadline, err
|
||||
}
|
||||
|
||||
// First fetch business hours and timezone from assigned team if available.
|
||||
// Fetch from team if assigned.
|
||||
if assignedTeamID != 0 {
|
||||
team, err := m.teamStore.Get(assignedTeamID)
|
||||
if err != nil {
|
||||
return firstResponseDeadline, resolutionDeadline, err
|
||||
return bh, "", err
|
||||
}
|
||||
businessHrsID = team.BusinessHoursID.Int
|
||||
timezone = team.Timezone
|
||||
}
|
||||
|
||||
// If not found in team, fetch from app settings.
|
||||
// Else fetch from app settings, this is System default.
|
||||
if businessHrsID == 0 || timezone == "" {
|
||||
settingsJ, err := m.appSettingsStore.GetByPrefix("app")
|
||||
if err != nil {
|
||||
return firstResponseDeadline, resolutionDeadline, err
|
||||
return bh, "", err
|
||||
}
|
||||
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(settingsJ), &out); err != nil {
|
||||
m.lo.Error("error parsing settings", "error", err)
|
||||
return firstResponseDeadline, resolutionDeadline, envelope.NewError(envelope.GeneralError, "Error parsing settings", nil)
|
||||
return bh, "", fmt.Errorf("parsing settings: %v", err)
|
||||
}
|
||||
|
||||
businessHrsIDStr, _ := out["app.business_hours_id"].(string)
|
||||
@@ -226,129 +172,170 @@ func (m *Manager) CalculateConversationDeadlines(conversationCreatedAt time.Time
|
||||
timezone, _ = out["app.timezone"].(string)
|
||||
}
|
||||
|
||||
// Not set, skip SLA calculation.
|
||||
// If still not found, return error.
|
||||
if businessHrsID == 0 || timezone == "" {
|
||||
m.lo.Warn("default business hours or timezone not set, skipping SLA calculation")
|
||||
return firstResponseDeadline, resolutionDeadline, nil
|
||||
return bh, "", fmt.Errorf("business hours or timezone not configured")
|
||||
}
|
||||
|
||||
bh, err := m.businessHrsStore.Get(businessHrsID)
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching business hours", "error", err)
|
||||
return firstResponseDeadline, resolutionDeadline, err
|
||||
if err == businessHours.ErrBusinessHoursNotFound {
|
||||
m.lo.Warn("business hours not found", "team_id", assignedTeamID)
|
||||
return bh, "", fmt.Errorf("business hours not found")
|
||||
}
|
||||
m.lo.Error("error fetching business hours for SLA", "error", err)
|
||||
return bh, "", err
|
||||
}
|
||||
return bh, timezone, nil
|
||||
}
|
||||
|
||||
// CalculateDeadline calculates the deadline for a given start time and duration.
|
||||
func (m *Manager) CalculateDeadlines(startTime time.Time, slaPolicyID, assignedTeamID int) (Deadlines, error) {
|
||||
var deadlines Deadlines
|
||||
|
||||
businessHrs, timezone, err := m.getBusinessHours(assignedTeamID)
|
||||
if err != nil {
|
||||
return deadlines, err
|
||||
}
|
||||
|
||||
m.lo.Info("calculating deadlines", "business_hours", businessHrs.Hours, "timezone", timezone, "always_open", businessHrs.IsAlwaysOpen)
|
||||
|
||||
sla, err := m.Get(slaPolicyID)
|
||||
if err != nil {
|
||||
return deadlines, err
|
||||
}
|
||||
|
||||
calculateDeadline := func(durationStr string) (time.Time, error) {
|
||||
if durationStr == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
dur, parseErr := time.ParseDuration(durationStr)
|
||||
if parseErr != nil {
|
||||
return time.Time{}, fmt.Errorf("parsing duration: %v", parseErr)
|
||||
dur, err := time.ParseDuration(durationStr)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("parsing SLA duration: %v", err)
|
||||
}
|
||||
deadline, err := m.CalculateDeadline(
|
||||
conversationCreatedAt,
|
||||
int(dur.Minutes()),
|
||||
bh,
|
||||
timezone,
|
||||
)
|
||||
deadline, err := m.CalculateDeadline(startTime, int(dur.Minutes()), businessHrs, timezone)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return deadline.Add(slaGracePeriod), nil
|
||||
return deadline, nil
|
||||
}
|
||||
firstResponseDeadline, err = calculateDeadline(slaPolicy.FirstResponseTime)
|
||||
if err != nil {
|
||||
return firstResponseDeadline, resolutionDeadline, err
|
||||
if deadlines.FirstResponse, err = calculateDeadline(sla.FirstResponseTime); err != nil {
|
||||
return deadlines, err
|
||||
}
|
||||
resolutionDeadline, err = calculateDeadline(slaPolicy.ResolutionTime)
|
||||
if err != nil {
|
||||
return firstResponseDeadline, resolutionDeadline, err
|
||||
if deadlines.Resolution, err = calculateDeadline(sla.ResolutionTime); err != nil {
|
||||
return deadlines, err
|
||||
}
|
||||
return firstResponseDeadline, resolutionDeadline, nil
|
||||
return deadlines, nil
|
||||
}
|
||||
|
||||
// processUnbreachedSLAs fetches unbreached SLAs and pushes them to the worker pool for processing.
|
||||
func (m *Manager) processUnbreachedSLAs() error {
|
||||
var unbreachedSLAs []models.ConversationSLA
|
||||
if err := m.q.GetUnbreachedSLAs.Select(&unbreachedSLAs); err != nil {
|
||||
m.lo.Error("error fetching unbreached SLAs", "error", err)
|
||||
// ApplySLA applies an SLA policy to a conversation.
|
||||
func (m *Manager) ApplySLA(conversationID, assignedTeamID, slaPolicyID int) (models.SLAPolicy, error) {
|
||||
var sla models.SLAPolicy
|
||||
|
||||
deadlines, err := m.CalculateDeadlines(time.Now(), slaPolicyID, assignedTeamID)
|
||||
if err != nil {
|
||||
return sla, err
|
||||
}
|
||||
if _, err := m.q.ApplySLA.Exec(
|
||||
conversationID,
|
||||
slaPolicyID,
|
||||
deadlines.FirstResponse,
|
||||
deadlines.Resolution,
|
||||
); err != nil {
|
||||
m.lo.Error("error applying SLA", "error", err)
|
||||
return sla, envelope.NewError(envelope.GeneralError, "Error applying SLA", nil)
|
||||
}
|
||||
sla, err = m.Get(slaPolicyID)
|
||||
if err != nil {
|
||||
return sla, err
|
||||
}
|
||||
return sla, nil
|
||||
}
|
||||
|
||||
// GetLatestDeadlines returns the latest deadlines for a conversation.
|
||||
func (m *Manager) GetLatestDeadlines(conversationID int) (time.Time, time.Time, error) {
|
||||
var first, resolution time.Time
|
||||
err := m.q.GetLatestDeadlines.QueryRow(conversationID).Scan(&first, &resolution)
|
||||
if err == sql.ErrNoRows {
|
||||
return first, resolution, nil
|
||||
}
|
||||
return first, resolution, err
|
||||
}
|
||||
|
||||
// Run starts the SLA evaluation loop and evaluates pending SLAs.
|
||||
func (m *Manager) Run(ctx context.Context) {
|
||||
m.wg.Add(1)
|
||||
defer m.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(2 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := m.evaluatePendingSLAs(ctx); err != nil {
|
||||
m.lo.Error("error processing pending SLAs", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the SLA evaluation loop by stopping the worker pool.
|
||||
func (m *Manager) Close() error {
|
||||
m.wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
// evaluatePendingSLAs fetches unbreached SLAs and evaluates them.
|
||||
func (m *Manager) evaluatePendingSLAs(ctx context.Context) error {
|
||||
var pendingSLAs []models.AppliedSLA
|
||||
if err := m.q.GetPendingSLAs.SelectContext(ctx, &pendingSLAs); err != nil {
|
||||
m.lo.Error("error fetching pending SLAs", "error", err)
|
||||
return err
|
||||
}
|
||||
m.lo.Debug("processing unbreached SLAs", "count", len(unbreachedSLAs))
|
||||
for _, u := range unbreachedSLAs {
|
||||
slaData := u
|
||||
m.pool.Push(func() {
|
||||
if err := m.evaluateSLA(slaData); err != nil {
|
||||
m.lo.Error("error processing SLA", "error", err)
|
||||
m.lo.Info("evaluating pending SLAs", "count", len(pendingSLAs))
|
||||
for _, sla := range pendingSLAs {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
if err := m.evaluateSLA(sla); err != nil {
|
||||
m.lo.Error("error evaluating SLA", "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// evaluateSLA checks if an SLA has been breached or met and updates the database accordingly.
|
||||
func (m *Manager) evaluateSLA(cSLA models.ConversationSLA) error {
|
||||
var deadline, compareTime time.Time
|
||||
|
||||
// Calculate deadlines using the `created_at` which is the time SLA was applied to the conversation.
|
||||
// This will take care of the case where SLA is changed for a conversation.
|
||||
m.lo.Info("calculating SLA deadlines", "start_time", cSLA.CreatedAt, "conversation_id", cSLA.ConversationID, "sla_policy_id", cSLA.SLAPolicyID)
|
||||
firstResponseDeadline, resolutionDeadline, err := m.CalculateConversationDeadlines(cSLA.CreatedAt, cSLA.ConversationAssignedTeamID.Int, cSLA.SLAPolicyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch cSLA.SLAType {
|
||||
case SLATypeFirstResponse:
|
||||
deadline = firstResponseDeadline
|
||||
compareTime = cSLA.ConversationFirstReplyAt.Time
|
||||
case SLATypeResolution:
|
||||
deadline = resolutionDeadline
|
||||
compareTime = cSLA.ConversationResolvedAt.Time
|
||||
default:
|
||||
return fmt.Errorf("unknown SLA type: %s", cSLA.SLAType)
|
||||
}
|
||||
|
||||
if deadline.IsZero() {
|
||||
m.lo.Warn("could not calculate SLA deadline", "conversation_id", cSLA.ConversationID, "sla_policy_id", cSLA.SLAPolicyID)
|
||||
// evaluateSLA evaluates an SLA policy on an applied SLA.
|
||||
func (m *Manager) evaluateSLA(sla models.AppliedSLA) error {
|
||||
now := time.Now()
|
||||
checkDeadline := func(deadline time.Time, metAt null.Time, slaType string) error {
|
||||
if deadline.IsZero() {
|
||||
return nil
|
||||
}
|
||||
if !metAt.Valid && now.After(deadline) {
|
||||
_, err := m.q.UpdateBreach.Exec(sla.ID, slaType)
|
||||
return err
|
||||
}
|
||||
if metAt.Valid {
|
||||
if metAt.Time.After(deadline) {
|
||||
_, err := m.q.UpdateBreach.Exec(sla.ID, slaType)
|
||||
return err
|
||||
}
|
||||
_, err := m.q.UpdateMet.Exec(sla.ID, slaType)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save deadline in DB.
|
||||
if _, err := m.q.UpdateDueAt.Exec(cSLA.ID, deadline); err != nil {
|
||||
m.lo.Error("error updating SLA due_at", "error", err)
|
||||
return fmt.Errorf("updating SLA due_at: %v", err)
|
||||
if err := checkDeadline(sla.FirstResponseDeadlineAt, sla.FirstResponseAt, SLATypeFirstResponse); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !compareTime.IsZero() {
|
||||
if compareTime.After(deadline) {
|
||||
return m.markSLABreached(cSLA.ID)
|
||||
}
|
||||
return m.markSLAMet(cSLA.ID, compareTime)
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return m.markSLABreached(cSLA.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// markSLABreached updates the breach time for a conversation SLA.
|
||||
func (m *Manager) markSLABreached(id int) error {
|
||||
if _, err := m.q.UpdateBreachedAt.Exec(id); err != nil {
|
||||
m.lo.Error("error updating SLA breach time", "error", err)
|
||||
return fmt.Errorf("updating SLA breach time: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markSLAMet updates the met time for a conversation SLA.
|
||||
func (m *Manager) markSLAMet(id int, t time.Time) error {
|
||||
if _, err := m.q.UpdateMetAt.Exec(id, t); err != nil {
|
||||
m.lo.Error("error updating SLA met time", "error", err)
|
||||
return fmt.Errorf("updating SLA met time: %v", err)
|
||||
if err := checkDeadline(sla.ResolutionDeadlineAt, sla.ResolvedAt, SLATypeResolution); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ type Team struct {
|
||||
Name string `db:"name" json:"name"`
|
||||
ConversationAssignmentType string `db:"conversation_assignment_type" json:"conversation_assignment_type,omitempty"`
|
||||
Timezone string `db:"timezone" json:"timezone,omitempty"`
|
||||
BusinessHoursID null.Int `db:"business_hours_id" json:"business_hours_id,omitempty"`
|
||||
BusinessHoursID null.Int `db:"business_hours_id" json:"business_hours_id,omitempty"`
|
||||
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id,omitempty"`
|
||||
}
|
||||
|
||||
type Teams []Team
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
-- name: get-teams
|
||||
SELECT id, emoji, created_at, updated_at, name, conversation_assignment_type, timezone from teams order by updated_at desc;
|
||||
|
||||
-- name: get-user-teams
|
||||
SELECT id, emoji, created_at, updated_at, name, conversation_assignment_type, timezone from teams WHERE id IN (SELECT team_id FROM team_members WHERE user_id = $1) order by updated_at desc;
|
||||
|
||||
-- name: get-teams-compact
|
||||
SELECT id, name, emoji from teams order by name;
|
||||
|
||||
-- name: get-user-teams
|
||||
SELECT id, emoji, created_at, updated_at, name, conversation_assignment_type, timezone from teams WHERE id IN (SELECT team_id FROM team_members WHERE user_id = $1) order by updated_at desc;
|
||||
|
||||
-- name: get-team
|
||||
SELECT id, emoji, name, conversation_assignment_type, timezone, business_hours_id from teams where id = $1;
|
||||
SELECT id, emoji, name, conversation_assignment_type, timezone, business_hours_id, sla_policy_id from teams where id = $1;
|
||||
|
||||
-- name: get-team-members
|
||||
SELECT u.id, t.id as team_id
|
||||
@@ -18,10 +18,10 @@ JOIN teams t ON t.id = tm.team_id
|
||||
WHERE t.id = $1;
|
||||
|
||||
-- name: insert-team
|
||||
INSERT INTO teams (name, timezone, conversation_assignment_type, business_hours_id, emoji) VALUES ($1, $2, $3, $4, $5) RETURNING id;
|
||||
INSERT INTO teams (name, timezone, conversation_assignment_type, business_hours_id, sla_policy_id, emoji) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id;
|
||||
|
||||
-- name: update-team
|
||||
UPDATE teams set name = $2, timezone = $3, conversation_assignment_type = $4, business_hours_id = $5, emoji = $6, updated_at = now() where id = $1;
|
||||
UPDATE teams set name = $2, timezone = $3, conversation_assignment_type = $4, business_hours_id = $5, sla_policy_id = $6, emoji = $7, updated_at = now() where id = $1;
|
||||
|
||||
-- name: upsert-user-teams
|
||||
WITH delete_old_teams AS (
|
||||
|
||||
@@ -101,8 +101,8 @@ func (u *Manager) Get(id int) (models.Team, error) {
|
||||
}
|
||||
|
||||
// Create creates a new team.
|
||||
func (u *Manager) Create(name, timezone, conversationAssignmentType string, businessHrsID null.Int, emoji string) error {
|
||||
if _, err := u.q.InsertTeam.Exec(name, timezone, conversationAssignmentType, businessHrsID, emoji); err != nil {
|
||||
func (u *Manager) Create(name, timezone, conversationAssignmentType string, businessHrsID, slaPolicyID null.Int, emoji string) error {
|
||||
if _, err := u.q.InsertTeam.Exec(name, timezone, conversationAssignmentType, businessHrsID, slaPolicyID, emoji); err != nil {
|
||||
if dbutil.IsUniqueViolationError(err) {
|
||||
return envelope.NewError(envelope.GeneralError, "Team with the same name already exists", nil)
|
||||
}
|
||||
@@ -113,8 +113,8 @@ func (u *Manager) Create(name, timezone, conversationAssignmentType string, busi
|
||||
}
|
||||
|
||||
// Update updates an existing team.
|
||||
func (u *Manager) Update(id int, name, timezone, conversationAssignmentType string, businessHrsID null.Int, emoji string) error {
|
||||
if _, err := u.q.UpdateTeam.Exec(id, name, timezone, conversationAssignmentType, businessHrsID, emoji); err != nil {
|
||||
func (u *Manager) Update(id int, name, timezone, conversationAssignmentType string, businessHrsID, slaPolicyID null.Int, emoji string) error {
|
||||
if _, err := u.q.UpdateTeam.Exec(id, name, timezone, conversationAssignmentType, businessHrsID, slaPolicyID, emoji); err != nil {
|
||||
u.lo.Error("error updating team", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error updating team", nil)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
ID int `db:"id" json:"id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
FirstName string `db:"first_name" json:"first_name"`
|
||||
@@ -18,14 +18,14 @@ type User struct {
|
||||
Type string `db:"type" json:"type"`
|
||||
PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"`
|
||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
Enabled bool `db:"enabled" json:"enabled,omitempty"`
|
||||
Password string `db:"password" json:"-"`
|
||||
Roles pq.StringArray `db:"roles" json:"roles,omitempty"`
|
||||
Permissions pq.StringArray `db:"permissions" json:"permissions,omitempty"`
|
||||
Meta pq.StringArray `db:"meta" json:"meta,omitempty"`
|
||||
CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes,omitempty"`
|
||||
Teams tmodels.Teams `db:"teams" json:"teams,omitempty"`
|
||||
ContactChannelID int `db:"contact_channel_id,omitempty"`
|
||||
ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
|
||||
NewPassword string `db:"-" json:"new_password,omitempty"`
|
||||
SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
|
||||
InboxID int `json:"-"`
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// package workerpool contains a single goroutine worker pool that executes arbitrary
|
||||
// encapsulated functions.
|
||||
package workerpool
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Pool is a single goroutine worker pool.
|
||||
type Pool struct {
|
||||
num int
|
||||
q chan func()
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// New returns a new goroutine workerpool.
|
||||
func New(num, queueSize int) *Pool {
|
||||
return &Pool{
|
||||
num: num,
|
||||
q: make(chan func(), queueSize),
|
||||
wg: sync.WaitGroup{},
|
||||
}
|
||||
}
|
||||
|
||||
// Run initializes the goroutine worker pool.
|
||||
func (w *Pool) Run() {
|
||||
for i := 0; i < w.num; i++ {
|
||||
w.wg.Add(1)
|
||||
go func() {
|
||||
for f := range w.q {
|
||||
f()
|
||||
}
|
||||
w.wg.Done()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Push pushes a job to the worker queue to execute.
|
||||
func (w *Pool) Push(f func()) {
|
||||
w.q <- f
|
||||
}
|
||||
|
||||
func (w *Pool) Close() {
|
||||
close(w.q)
|
||||
w.wg.Wait()
|
||||
}
|
||||
29
schema.sql
29
schema.sql
@@ -6,28 +6,28 @@ DROP TYPE IF EXISTS "message_type" CASCADE; CREATE TYPE "message_type" AS ENUM (
|
||||
DROP TYPE IF EXISTS "message_sender_type" CASCADE; CREATE TYPE "message_sender_type" AS ENUM ('user','contact');
|
||||
DROP TYPE IF EXISTS "message_status" CASCADE; CREATE TYPE "message_status" AS ENUM ('received','sent','failed','pending');
|
||||
DROP TYPE IF EXISTS "content_type" CASCADE; CREATE TYPE "content_type" AS ENUM ('text','html');
|
||||
DROP TYPE IF EXISTS "sla_status" CASCADE; CREATE TYPE "sla_status" AS ENUM ('active','missed');
|
||||
DROP TYPE IF EXISTS "conversation_assignment_type" CASCADE; CREATE TYPE "conversation_assignment_type" AS ENUM ('Round robin','Manual');
|
||||
DROP TYPE IF EXISTS "sla_type" CASCADE; CREATE TYPE "sla_type" AS ENUM ('first_response','resolution');
|
||||
DROP TYPE IF EXISTS "template_type" CASCADE; CREATE TYPE "template_type" AS ENUM ('email_outgoing', 'email_notification');
|
||||
DROP TYPE IF EXISTS "user_type" CASCADE; CREATE TYPE "user_type" AS ENUM ('agent', 'contact');
|
||||
DROP TYPE IF EXISTS "ai_provider" CASCADE; CREATE TYPE "ai_provider" AS ENUM ('openai');
|
||||
DROP TYPE IF EXISTS "automation_execution_mode" CASCADE; CREATE TYPE "automation_execution_mode" AS ENUM ('all', 'first_match');
|
||||
DROP TYPE IF EXISTS "macro_visibility" CASCADE; CREATE TYPE "macro_visibility" AS ENUM ('all', 'team', 'user');
|
||||
|
||||
DROP TABLE IF EXISTS conversation_slas CASCADE;
|
||||
CREATE TABLE conversation_slas (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||
conversation_id BIGINT NOT NULL REFERENCES conversations(id),
|
||||
sla_policy_id INT NOT NULL REFERENCES sla_policies(id),
|
||||
sla_type sla_type NOT NULL,
|
||||
due_at TIMESTAMPTZ NULL,
|
||||
met_at TIMESTAMPTZ NULL,
|
||||
breached_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT constraint_conversation_slas_unique UNIQUE (sla_policy_id, conversation_id, sla_type)
|
||||
DROP TABLE IF EXISTS applied_slas CASCADE;
|
||||
CREATE TABLE applied_slas (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
conversation_id BIGINT REFERENCES conversations(id),
|
||||
sla_policy_id BIGINT REFERENCES sla_policies(id),
|
||||
first_response_deadline_at TIMESTAMPTZ NULL,
|
||||
resolution_deadline_at TIMESTAMPTZ NULL,
|
||||
first_response_breached_at TIMESTAMPTZ NULL,
|
||||
resolution_breached_at TIMESTAMPTZ NULL,
|
||||
first_response_met_at TIMESTAMPTZ NULL,
|
||||
resolution_met_at TIMESTAMPTZ NULL
|
||||
);
|
||||
CREATE INDEX index_applied_slas_on_conversation_id ON applied_slas (conversation_id);
|
||||
|
||||
DROP TABLE IF EXISTS teams CASCADE;
|
||||
CREATE TABLE teams (
|
||||
@@ -38,6 +38,7 @@ CREATE TABLE teams (
|
||||
emoji TEXT NULL,
|
||||
conversation_assignment_type conversation_assignment_type NOT NULL,
|
||||
business_hours_id INT REFERENCES business_hours(id) ON DELETE SET NULL ON UPDATE CASCADE NULL,
|
||||
sla_policy_id INT REFERENCES sla_policies(id) ON DELETE SET NULL ON UPDATE CASCADE NULL,
|
||||
timezone TEXT NULL,
|
||||
CONSTRAINT constraint_teams_on_emoji CHECK (length(emoji) <= 1),
|
||||
CONSTRAINT constraint_teams_on_name CHECK (length("name") <= 140),
|
||||
|
||||
Reference in New Issue
Block a user