diff --git a/frontend/src/features/conversation/list/ConversationListItem.vue b/frontend/src/features/conversation/list/ConversationListItem.vue index 36ef3cb..08a877a 100644 --- a/frontend/src/features/conversation/list/ConversationListItem.vue +++ b/frontend/src/features/conversation/list/ConversationListItem.vue @@ -57,15 +57,15 @@

First reply at

@@ -46,8 +46,8 @@

Resolved at

diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go index c6b1b57..ab47664 100644 --- a/internal/conversation/models/models.go +++ b/internal/conversation/models/models.go @@ -61,10 +61,11 @@ type Conversation struct { SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"` SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"` NextSLADeadlineAt null.Time `db:"next_sla_deadline_at" json:"next_sla_deadline_at"` + FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"` + ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"` + SLAStatus null.String `db:"sla_status" json:"sla_status"` BCC json.RawMessage `db:"bcc" json:"bcc"` CC json.RawMessage `db:"cc" json:"cc"` - FirstResponseDueAt null.Time `db:"-" json:"first_response_due_at"` - ResolutionDueAt null.Time `db:"-" json:"resolution_due_at"` PreviousConversations []Conversation `db:"-" json:"previous_conversations"` Total int `db:"total" json:"-"` } diff --git a/internal/sla/models/models.go b/internal/sla/models/models.go index 9f08916..70763ea 100644 --- a/internal/sla/models/models.go +++ b/internal/sla/models/models.go @@ -28,6 +28,9 @@ type AppliedSLA struct { ResolutionDeadlineAt time.Time `db:"resolution_deadline_at"` FirstResponseBreachedAt null.Time `db:"first_response_breached_at"` ResolutionBreachedAt null.Time `db:"resolution_breached_at"` - FirstResponseAt null.Time `db:"first_response_at"` - ResolvedAt null.Time `db:"resolved_at"` + FirstResponseMetAt null.Time `db:"first_response_met_at"` + ResolutionMetAt null.Time `db:"resolution_met_at"` + + ConversationFirstResponseAt null.Time `db:"conversation_first_response_at"` + ConversationResolvedAt null.Time `db:"conversation_resolved_at"` } diff --git a/internal/sla/queries.sql b/internal/sla/queries.sql index 55ad45f..b02a00a 100644 --- a/internal/sla/queries.sql +++ b/internal/sla/queries.sql @@ -43,14 +43,12 @@ next_sla_deadline_at = LEAST( WHERE id IN (SELECT conversation_id FROM new_sla); -- name: get-pending-slas --- Get all the applied SLAs that are not yet breached or met and is also set on the conversation. --- This make sure when SLA is changed, we don't update the breached or met status of the previous SLA. -SELECT a.id, a.first_response_deadline_at, c.first_reply_at as first_response_at, -a.resolution_deadline_at, c.resolved_at as resolved_at +-- Get all the applied SLAs (applied to a conversation) that are pending +SELECT a.id, a.first_response_deadline_at, c.first_reply_at as conversation_first_response_at, +a.resolution_deadline_at, c.resolved_at as conversation_resolved_at, c.id as conversation_id, a.first_response_met_at, a.resolution_met_at, a.first_response_breached_at, a.resolution_breached_at FROM applied_slas a JOIN conversations c ON a.conversation_id = c.id and c.sla_policy_id = a.sla_policy_id -WHERE (first_response_breached_at IS NULL AND first_response_met_at IS NULL) - OR (resolution_breached_at IS NULL AND resolution_met_at IS NULL); +WHERE a.status = 'pending'::applied_sla_status; -- name: update-breach UPDATE applied_slas SET @@ -66,8 +64,29 @@ UPDATE applied_slas SET updated_at = NOW() WHERE id = $1; --- name: get-latest-sla-deadlines -SELECT first_response_deadline_at, resolution_deadline_at -FROM applied_slas -WHERE conversation_id = $1 -ORDER BY created_at DESC LIMIT 1; \ No newline at end of file +-- name: set-next-sla-deadline +UPDATE conversations c +SET next_sla_deadline_at = CASE + WHEN c.status_id IN (SELECT id from conversation_statuses where name in ('Resolved', 'Closed')) THEN NULL + WHEN c.first_reply_at IS NOT NULL AND c.resolved_at IS NULL AND a.resolution_deadline_at IS NOT NULL THEN a.resolution_deadline_at + WHEN c.first_reply_at IS NULL AND c.resolved_at IS NULL AND a.first_response_deadline_at IS NOT NULL THEN a.first_response_deadline_at + WHEN a.first_response_deadline_at IS NOT NULL AND a.resolution_deadline_at IS NOT NULL THEN LEAST(a.first_response_deadline_at, a.resolution_deadline_at) + ELSE NULL +END +FROM applied_slas a +WHERE a.conversation_id = c.id +AND c.id = $1; + +-- name: update-sla-status +UPDATE applied_slas +SET + status = CASE + WHEN first_response_met_at IS NOT NULL AND resolution_met_at IS NOT NULL THEN 'met'::applied_sla_status + WHEN first_response_breached_at IS NOT NULL AND resolution_breached_at IS NOT NULL THEN 'breached'::applied_sla_status + WHEN (first_response_met_at IS NOT NULL OR first_response_breached_at IS NOT NULL) + AND (resolution_met_at IS NOT NULL OR resolution_breached_at IS NOT NULL) THEN 'partially_met'::applied_sla_status + WHEN first_response_met_at IS NULL AND first_response_breached_at IS NULL THEN 'pending'::applied_sla_status + ELSE 'pending'::applied_sla_status + END, + updated_at = NOW() +WHERE applied_slas.id = $1; diff --git a/internal/sla/sla.go b/internal/sla/sla.go index c042c12..437913e 100644 --- a/internal/sla/sla.go +++ b/internal/sla/sla.go @@ -2,7 +2,6 @@ package sla import ( "context" - "database/sql" "embed" "encoding/json" "fmt" @@ -78,7 +77,8 @@ type queries struct { 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"` + SetNextSLADeadline *sqlx.Stmt `query:"set-next-sla-deadline"` + UpdateSLAStatus *sqlx.Stmt `query:"update-sla-status"` } // New creates a new SLA manager. @@ -254,23 +254,14 @@ func (m *Manager) ApplySLA(startTime time.Time, conversationID, assignedTeamID, 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, evalInterval time.Duration) { - m.wg.Add(1) - defer m.wg.Done() - ticker := time.NewTicker(evalInterval) - defer ticker.Stop() + m.wg.Add(1) + defer func() { + m.wg.Done() + ticker.Stop() + }() for { select { @@ -315,23 +306,30 @@ func (m *Manager) evaluatePendingSLAs(ctx context.Context) error { // evaluateSLA evaluates an SLA policy on an applied SLA. func (m *Manager) evaluateSLA(sla models.AppliedSLA) error { - now := time.Now() + m.lo.Debug("evaluating SLA", "conversation_id", sla.ConversationID, "applied_sla_id", sla.ID) checkDeadline := func(deadline time.Time, metAt null.Time, slaType string) error { if deadline.IsZero() { + m.lo.Debug("deadline zero, skipping checking the deadline") 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, "sla_type", slaType) if _, err := m.q.UpdateBreach.Exec(sla.ID, slaType); err != nil { return fmt.Errorf("updating SLA breach: %w", err) } return nil } + 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, "sla_type", slaType) if _, err := m.q.UpdateBreach.Exec(sla.ID, slaType); err != nil { return fmt.Errorf("updating SLA breach: %w", err) } } else { + m.lo.Debug("SLA type met", "deadline", deadline, "met_at", metAt.Time, "sla_type", slaType) if _, err := m.q.UpdateMet.Exec(sla.ID, slaType); err != nil { return fmt.Errorf("updating SLA met: %w", err) } @@ -340,11 +338,31 @@ func (m *Manager) evaluateSLA(sla models.AppliedSLA) error { return nil } - if err := checkDeadline(sla.FirstResponseDeadlineAt, sla.FirstResponseAt, SLATypeFirstResponse); err != nil { - return err + // If first response is not breached and not met, check the deadline and set them. + if !sla.FirstResponseBreachedAt.Valid && !sla.FirstResponseMetAt.Valid { + m.lo.Debug("checking deadline", "deadline", sla.FirstResponseDeadlineAt, "met_at", sla.ConversationFirstResponseAt.Time, "sla_type", SLATypeFirstResponse) + if err := checkDeadline(sla.FirstResponseDeadlineAt, sla.ConversationFirstResponseAt, SLATypeFirstResponse); err != nil { + return err + } } - if err := checkDeadline(sla.ResolutionDeadlineAt, sla.ResolvedAt, SLATypeResolution); err != nil { - return err + + // If resolution is not breached and not met, check the deadine and set them. + if !sla.ResolutionBreachedAt.Valid && !sla.ResolutionMetAt.Valid { + m.lo.Debug("checking deadline", "deadline", sla.ResolutionDeadlineAt, "met_at", sla.ConversationResolvedAt.Time, "sla_type", SLATypeResolution) + if err := checkDeadline(sla.ResolutionDeadlineAt, sla.ConversationResolvedAt, SLATypeResolution); err != nil { + return err + } } + + // Update the conversation next SLA deadline. + if _, err := m.q.SetNextSLADeadline.Exec(sla.ConversationID); err != nil { + return fmt.Errorf("setting conversation next SLA deadline: %w", err) + } + + // Update status of applied SLA. + if _, err := m.q.UpdateSLAStatus.Exec(sla.ID); err != nil { + return fmt.Errorf("updating applied SLA status: %w", err) + } + return nil }