Files
libredesk/internal/report/queries.sql
T

364 lines
10 KiB
SQL

-- name: get-overview-counts
WITH convs AS (
SELECT
COUNT(*) AS open,
COUNT(*) FILTER (
WHERE
c.last_message_sender = 'contact'
) AS awaiting_response,
COUNT(*) FILTER (
WHERE
c.assigned_user_id IS NULL
) AS unassigned,
COUNT(*) FILTER (
WHERE
c.first_reply_at IS NULL
) AS pending
FROM
conversations c
INNER JOIN conversation_statuses s ON c.status_id = s.id
WHERE
s.category != 'resolved'
),
agents AS (
SELECT
COUNT(*) FILTER (
WHERE
availability_status = 'online'
) AS agents_online,
COUNT(*) FILTER (
WHERE
availability_status = 'away_manual'
) AS agents_away,
COUNT(*) FILTER (
WHERE
availability_status = 'away_and_reassigning'
) AS agents_reassigning,
COUNT(*) FILTER (
WHERE
availability_status IN ('offline', 'away')
) AS agents_offline
FROM
users
WHERE
type = 'agent'
AND deleted_at IS NULL
)
SELECT
json_build_object(
'open', open,
'awaiting_response', awaiting_response,
'unassigned', unassigned,
'pending', pending,
'agents_online', agents_online,
'agents_away', agents_away,
'agents_reassigning', agents_reassigning,
'agents_offline', agents_offline
)
FROM
convs,
agents;
-- name: get-overview-sla-counts
-- Count only each conversation's latest applied SLA; superseded rows are kept as history and would double-count.
WITH latest_applied AS (
SELECT DISTINCT ON (conversation_id)
created_at, first_response_met_at, first_response_breached_at,
resolution_met_at, resolution_breached_at
FROM applied_slas
WHERE created_at >= CASE
WHEN %d = 0 THEN CURRENT_DATE
ELSE NOW() - INTERVAL '%d days'
END
ORDER BY conversation_id, created_at DESC, id DESC
),
first_and_resolution AS (
SELECT
COUNT(*) FILTER (
WHERE
first_response_met_at IS NOT NULL
) AS first_response_met_count,
COUNT(*) FILTER (
WHERE
first_response_breached_at IS NOT NULL
) AS first_response_breached_count,
COUNT(*) FILTER (
WHERE
resolution_met_at IS NOT NULL
) AS resolution_met_count,
COUNT(*) FILTER (
WHERE
resolution_breached_at IS NOT NULL
) AS resolution_breached_count,
COALESCE(
AVG(
EXTRACT(
EPOCH
FROM
(first_response_met_at - created_at)
)
) FILTER (
WHERE
first_response_met_at IS NOT NULL
),
0
) AS avg_first_response_time_sec,
COALESCE(
AVG(
EXTRACT(
EPOCH
FROM
(resolution_met_at - created_at)
)
) FILTER (
WHERE
resolution_met_at IS NOT NULL
),
0
) AS avg_resolution_time_sec
FROM
latest_applied
),
next_response AS (
-- A reply after the deadline carries both met_at and breached_at, so counting the
-- timestamps puts one event in both buckets. status holds a single terminal verdict.
SELECT
COUNT(*) FILTER (
WHERE
status = 'met'
) AS next_response_met_count,
COUNT(*) FILTER (
WHERE
status = 'breached'
) AS next_response_breached_count,
COALESCE(
AVG(
EXTRACT(
EPOCH
FROM
(met_at - created_at)
)
) FILTER (
WHERE
status = 'met'
),
0
) AS avg_next_response_time_sec
FROM
sla_events
WHERE
created_at >= CASE
WHEN %d = 0 THEN CURRENT_DATE
ELSE NOW() - INTERVAL '%d days'
END
AND type = 'next_response'
)
SELECT
fas.first_response_met_count,
fas.first_response_breached_count,
fas.avg_first_response_time_sec,
nr.next_response_met_count,
nr.next_response_breached_count,
nr.avg_next_response_time_sec,
fas.resolution_met_count,
fas.resolution_breached_count,
fas.avg_resolution_time_sec,
CASE
WHEN (fas.first_response_met_count + fas.first_response_breached_count) > 0
THEN ROUND((fas.first_response_met_count::numeric / (fas.first_response_met_count + fas.first_response_breached_count)::numeric) * 100, 1)
ELSE 0
END AS first_response_compliance_percent,
CASE
WHEN (nr.next_response_met_count + nr.next_response_breached_count) > 0
THEN ROUND((nr.next_response_met_count::numeric / (nr.next_response_met_count + nr.next_response_breached_count)::numeric) * 100, 1)
ELSE 0
END AS next_response_compliance_percent,
CASE
WHEN (fas.resolution_met_count + fas.resolution_breached_count) > 0
THEN ROUND((fas.resolution_met_count::numeric / (fas.resolution_met_count + fas.resolution_breached_count)::numeric) * 100, 1)
ELSE 0
END AS resolution_compliance_percent
FROM
first_and_resolution fas,
next_response nr;
-- name: get-overview-charts
WITH new_conversations AS (
SELECT
json_agg(row_to_json(agg)) AS data
FROM
(
SELECT
TO_CHAR(created_at :: date, 'YYYY-MM-DD') AS date,
COUNT(*) AS count
FROM
conversations c
WHERE
c.created_at >= CASE
WHEN %d = 0 THEN CURRENT_DATE
ELSE NOW() - INTERVAL '%d days'
END
GROUP BY
date
ORDER BY
date
) agg
),
resolved_conversations AS (
SELECT
json_agg(row_to_json(agg)) AS data
FROM
(
SELECT
TO_CHAR(resolved_at :: date, 'YYYY-MM-DD') AS date,
COUNT(*) AS count
FROM
conversations c
WHERE
c.resolved_at >= CASE
WHEN %d = 0 THEN CURRENT_DATE
ELSE NOW() - INTERVAL '%d days'
END
GROUP BY
date
ORDER BY
date
) agg
)
SELECT
json_build_object(
'new_conversations',
(
SELECT
data
FROM
new_conversations
),
'resolved_conversations',
(
SELECT
data
FROM
resolved_conversations
)
) AS result;
-- name: get-overview-csat
SELECT
json_build_object(
'average_rating',
COALESCE(AVG(rating) FILTER (WHERE rating > 0), 0),
'total_responses',
COUNT(*) FILTER (WHERE rating > 0),
'total_sent',
COUNT(*),
'response_rate',
CASE
WHEN COUNT(*) > 0
THEN ROUND((COUNT(*) FILTER (WHERE rating > 0)::numeric / COUNT(*)::numeric) * 100, 1)
ELSE 0
END
) AS result
FROM
csat_responses
WHERE
created_at >= CASE
WHEN %d = 0 THEN CURRENT_DATE
ELSE NOW() - INTERVAL '%d days'
END;
-- name: get-overview-message-volume
WITH per_conversation AS (
SELECT
conversation_id,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE type = 'incoming') AS incoming,
COUNT(*) FILTER (WHERE type = 'outgoing') AS outgoing
FROM
conversation_messages
WHERE
type IN ('incoming', 'outgoing')
AND private = false
AND (type = 'incoming' OR status = 'sent')
AND created_at >= CASE
WHEN %d = 0 THEN CURRENT_DATE
ELSE NOW() - INTERVAL '%d days'
END
GROUP BY
conversation_id
),
stats AS (
SELECT
COALESCE(SUM(total), 0) AS total,
COALESCE(SUM(incoming), 0) AS incoming,
COALESCE(SUM(outgoing), 0) AS outgoing,
COUNT(*) AS convos
FROM
per_conversation
)
SELECT
json_build_object(
'total_messages', total,
'incoming_messages', incoming,
'outgoing_messages', outgoing,
'messages_per_conversation',
CASE
WHEN convos > 0 THEN ROUND(total::numeric / convos::numeric, 1)
ELSE 0
END
) AS result
FROM
stats;
-- name: get-overview-tag-distribution
WITH tag_counts AS (
SELECT
t.id AS tag_id,
t.name AS tag_name,
COUNT(c.id) AS count
FROM
tags t
LEFT JOIN conversation_tags ct ON t.id = ct.tag_id
LEFT JOIN conversations c ON ct.conversation_id = c.id
AND c.created_at >= CASE
WHEN %d = 0 THEN CURRENT_DATE
ELSE NOW() - INTERVAL '%d days'
END
GROUP BY
t.id, t.name
ORDER BY
count DESC, t.id
LIMIT 10
),
tagging AS (
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (
WHERE EXISTS (
SELECT 1 FROM conversation_tags ct
WHERE ct.conversation_id = c.id
)
) AS tagged
FROM
conversations c
WHERE
c.created_at >= CASE
WHEN %d = 0 THEN CURRENT_DATE
ELSE NOW() - INTERVAL '%d days'
END
)
SELECT
json_build_object(
'top_tags',
COALESCE((SELECT json_agg(row_to_json(tc)) FROM tag_counts tc), '[]'::json),
'tagged_conversations', tagged,
'untagged_conversations', total - tagged,
'tagged_percentage',
CASE
WHEN total > 0
THEN ROUND((tagged::numeric / total::numeric) * 100, 1)
ELSE 0
END
) AS result
FROM
tagging;