410 lines
16 KiB
JavaScript
410 lines
16 KiB
JavaScript
// FreeScout customer-service report — direct read-only queries against the
|
|
// freescout and acot_phone Postgres databases (same instance as inventory_db).
|
|
// FreeScout's Reports module has no JSON API (its /reports/ajax needs an agent
|
|
// session cookie and returns rendered Blade HTML), so we reproduce its formulas
|
|
// from Modules/Reports/Http/Controllers/ReportsController.php instead.
|
|
//
|
|
// Timezone: freescout.* timestamp columns are NAIVE literals in America/New_York
|
|
// (Laravel APP_TZ). Every comparison re-anchors them with AT TIME ZONE
|
|
// 'America/New_York'; day bucketing then converts to America/Chicago business
|
|
// days per shared/business-time. acot_phone columns are proper timestamptz.
|
|
//
|
|
// First-response / resolution times are NOT recomputed from threads — FreeScout's
|
|
// `freescout:reports-collect-data` cron precomputes them into conversations.meta
|
|
// under the "rpt" key (frt / rst / rnt / rtr / rfr, seconds). We read that JSON.
|
|
|
|
import { DateTime } from 'luxon';
|
|
import { BUSINESS_TZ } from '../../../shared/business-time/index.js';
|
|
|
|
// Enum values from freescout app/Conversation.php and app/Thread.php, and
|
|
// Modules/SatRatings (rating: 1=great 2=okay 3=bad).
|
|
const CONV_STATE_PUBLISHED = 2;
|
|
const CONV_STATUS_CLOSED = 3;
|
|
const CONV_STATUS_SPAM = 4;
|
|
const CONV_TYPES = { 1: 'email', 2: 'phone', 3: 'chat' };
|
|
const THREAD_TYPE_CUSTOMER = 1;
|
|
const THREAD_TYPE_MESSAGE = 2;
|
|
|
|
// FreeScout's report-table time buckets (Reports module getTimeTablePattern),
|
|
// used for the first-response-time histogram. Thresholds in seconds; SQL
|
|
// width_bucket() returns the index into this list.
|
|
const FRT_BUCKET_LABELS = ['<15m', '15-30m', '30-60m', '1-2h', '2-3h', '3-6h', '6-12h', '12-24h', '1-2d', '>2d'];
|
|
const FRT_BUCKET_THRESHOLDS = [900, 1800, 3600, 7200, 10800, 21600, 43200, 86400, 172800];
|
|
|
|
// Re-anchor a naive America/New_York timestamp column as timestamptz.
|
|
const ny = (col) => `(${col} AT TIME ZONE 'America/New_York')`;
|
|
// Bucket it to an America/Chicago business date.
|
|
const nyBizDate = (col) => `(${ny(col)} AT TIME ZONE '${BUSINESS_TZ}')::date`;
|
|
|
|
const num = (v) => (v === null || v === undefined ? null : Number(v));
|
|
|
|
export class FreescoutService {
|
|
constructor(redis, freescoutPool, phonePool = null) {
|
|
if (!redis) throw new Error('FreescoutService requires an ioredis client');
|
|
if (!freescoutPool) throw new Error('FreescoutService requires the freescout pg Pool');
|
|
this.redis = redis;
|
|
this.fs = freescoutPool;
|
|
this.phone = phonePool; // optional — phone card hidden when absent
|
|
}
|
|
|
|
get _redisReady() {
|
|
return this.redis.status === 'ready' || this.redis.status === 'connect';
|
|
}
|
|
|
|
async _cacheGet(key) {
|
|
if (!this._redisReady) return null;
|
|
try {
|
|
const raw = await this.redis.get(key);
|
|
return raw ? JSON.parse(raw) : null;
|
|
} catch (err) {
|
|
console.warn('[Freescout] cache get failed:', err.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async _cacheSet(key, value, ttlSec) {
|
|
if (!this._redisReady) return;
|
|
try {
|
|
await this.redis.setex(key, ttlSec, JSON.stringify(value));
|
|
} catch (err) {
|
|
console.warn('[Freescout] cache set failed:', err.message);
|
|
}
|
|
}
|
|
|
|
async getReport(days) {
|
|
const cacheKey = `freescout:report:v2:${days}`;
|
|
const cached = await this._cacheGet(cacheKey);
|
|
if (cached) return cached;
|
|
|
|
const todayStart = DateTime.now().setZone(BUSINESS_TZ).startOf('day');
|
|
const end = todayStart.plus({ days: 1 }); // exclusive — current period includes today-so-far
|
|
const start = todayStart.minus({ days: days - 1 });
|
|
const prevEnd = start;
|
|
const prevStart = start.minus({ days });
|
|
const cur = [start.toISO(), end.toISO()];
|
|
const prev = [prevStart.toISO(), prevEnd.toISO()];
|
|
|
|
const [
|
|
overview, overviewPrev,
|
|
times, timesPrev,
|
|
satisfaction, satisfactionPrev,
|
|
timeseries, channels, agents,
|
|
frtDistribution,
|
|
phone, phonePrev,
|
|
] = await Promise.all([
|
|
this._overview(cur), this._overview(prev),
|
|
this._responseTimes(cur), this._responseTimes(prev),
|
|
this._satisfaction(cur), this._satisfaction(prev),
|
|
this._timeseries(cur, start, todayStart),
|
|
this._channels(cur),
|
|
this._agents(cur),
|
|
this._frtDistribution(cur),
|
|
this._phoneStats(cur), this._phoneStats(prev),
|
|
]);
|
|
|
|
const report = {
|
|
range: {
|
|
days,
|
|
start: start.toISODate(),
|
|
end: todayStart.toISODate(),
|
|
prevStart: prevStart.toISODate(),
|
|
prevEnd: prevEnd.minus({ days: 1 }).toISODate(),
|
|
},
|
|
overview: { current: overview, previous: overviewPrev },
|
|
responseTimes: { current: times, previous: timesPrev },
|
|
satisfaction: { current: satisfaction, previous: satisfactionPrev },
|
|
timeseries,
|
|
channels,
|
|
agents,
|
|
frtDistribution,
|
|
phone: phone ? { current: phone, previous: phonePrev } : null,
|
|
};
|
|
|
|
await this._cacheSet(cacheKey, report, 300);
|
|
return report;
|
|
}
|
|
|
|
// Headline counts. Formulas mirror ReportsController: newConversations =
|
|
// countNewConv, messagesReceived = countMessages (customer threads),
|
|
// repliesSent = countRepliesSent, customersHelped = countCustomersHelped.
|
|
// "resolved" uses conversations.closed_at (simpler than the module's
|
|
// closing-thread reconstruction, same intent: closes that happened in range).
|
|
async _overview([from, to]) {
|
|
const { rows } = await this.fs.query(
|
|
`SELECT
|
|
(SELECT count(*) FROM conversations
|
|
WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM}
|
|
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2) AS new_conversations,
|
|
(SELECT count(*) FROM conversations
|
|
WHERE state = ${CONV_STATE_PUBLISHED} AND status = ${CONV_STATUS_CLOSED}
|
|
AND closed_at IS NOT NULL
|
|
AND ${ny('closed_at')} >= $1 AND ${ny('closed_at')} < $2) AS resolved,
|
|
(SELECT count(*) FROM threads
|
|
WHERE type = ${THREAD_TYPE_CUSTOMER}
|
|
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2) AS messages_received,
|
|
(SELECT count(*) FROM threads
|
|
WHERE type = ${THREAD_TYPE_MESSAGE} AND state = ${CONV_STATE_PUBLISHED}
|
|
AND created_by_user_id IS NOT NULL
|
|
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2) AS replies_sent,
|
|
(SELECT count(DISTINCT c.customer_id)
|
|
FROM threads t JOIN conversations c ON c.id = t.conversation_id
|
|
WHERE t.type = ${THREAD_TYPE_MESSAGE} AND t.state = ${CONV_STATE_PUBLISHED}
|
|
AND t.created_by_user_id IS NOT NULL
|
|
AND ${ny('t.created_at')} >= $1 AND ${ny('t.created_at')} < $2) AS customers_helped`,
|
|
[from, to],
|
|
);
|
|
const r = rows[0];
|
|
return {
|
|
newConversations: num(r.new_conversations),
|
|
resolved: num(r.resolved),
|
|
messagesReceived: num(r.messages_received),
|
|
repliesSent: num(r.replies_sent),
|
|
customersHelped: num(r.customers_helped),
|
|
};
|
|
}
|
|
|
|
// Precomputed per-conversation metrics from conversations.meta->rpt (seconds):
|
|
// frt = first response time, rnt = resolution time, rfr = resolved on first reply.
|
|
async _responseTimes([from, to]) {
|
|
const { rows } = await this.fs.query(
|
|
`SELECT count(frt) AS frt_count,
|
|
round(avg(frt)) AS frt_avg,
|
|
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY frt)) AS frt_median,
|
|
count(rnt) AS rnt_count,
|
|
round(avg(rnt)) AS rnt_avg,
|
|
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY rnt)) AS rnt_median,
|
|
count(*) FILTER (WHERE rfr) AS rfr_count
|
|
FROM (
|
|
SELECT nullif(meta::json #>> '{rpt,frt}', '')::numeric AS frt,
|
|
nullif(meta::json #>> '{rpt,rnt}', '')::numeric AS rnt,
|
|
(meta::json #>> '{rpt,rfr}') IN ('1', 'true') AS rfr
|
|
FROM conversations
|
|
WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM}
|
|
AND meta LIKE '{%' AND meta LIKE '%"rpt"%'
|
|
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
|
|
) rpt`,
|
|
[from, to],
|
|
);
|
|
const r = rows[0];
|
|
return {
|
|
firstResponse: { count: num(r.frt_count), avgSec: num(r.frt_avg), medianSec: num(r.frt_median) },
|
|
resolution: { count: num(r.rnt_count), avgSec: num(r.rnt_avg), medianSec: num(r.rnt_median) },
|
|
resolvedFirstReply: num(r.rfr_count),
|
|
};
|
|
}
|
|
|
|
// Histogram of first-response times (current period only), zero-filled over
|
|
// FreeScout's bucket boundaries.
|
|
async _frtDistribution([from, to]) {
|
|
const { rows } = await this.fs.query(
|
|
`SELECT width_bucket(frt, ARRAY[${FRT_BUCKET_THRESHOLDS.join(',')}]::numeric[]) AS bucket,
|
|
count(*) AS n
|
|
FROM (
|
|
SELECT nullif(meta::json #>> '{rpt,frt}', '')::numeric AS frt
|
|
FROM conversations
|
|
WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM}
|
|
AND meta LIKE '{%' AND meta LIKE '%"rpt"%'
|
|
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
|
|
) rpt
|
|
WHERE frt IS NOT NULL
|
|
GROUP BY 1`,
|
|
[from, to],
|
|
);
|
|
const counts = new Array(FRT_BUCKET_LABELS.length).fill(0);
|
|
for (const r of rows) counts[Number(r.bucket)] = Number(r.n);
|
|
return FRT_BUCKET_LABELS.map((label, i) => ({ bucket: label, count: counts[i] }));
|
|
}
|
|
|
|
// Ratings live on the rated reply thread (threads.rating, SatRatings module).
|
|
// Score formula per calcSatisfactionScore: ceil(great% - bad%).
|
|
async _satisfaction([from, to]) {
|
|
const { rows } = await this.fs.query(
|
|
`SELECT count(*) FILTER (WHERE rating = 1) AS great,
|
|
count(*) FILTER (WHERE rating = 2) AS okay,
|
|
count(*) FILTER (WHERE rating = 3) AS bad
|
|
FROM threads
|
|
WHERE rating IS NOT NULL AND rating > 0
|
|
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2`,
|
|
[from, to],
|
|
);
|
|
const great = num(rows[0].great);
|
|
const okay = num(rows[0].okay);
|
|
const bad = num(rows[0].bad);
|
|
const total = great + okay + bad;
|
|
return {
|
|
great,
|
|
okay,
|
|
bad,
|
|
total,
|
|
score: total ? Math.ceil((great * 100) / total - (bad * 100) / total) : null,
|
|
};
|
|
}
|
|
|
|
// Per-business-day new / resolved / customer messages, zero-filled.
|
|
async _timeseries([from, to], start, todayStart) {
|
|
const [news, closes, msgs] = await Promise.all([
|
|
this.fs.query(
|
|
// ::text so pg returns 'YYYY-MM-DD' strings — the driver's DATE→JS Date
|
|
// parsing is zone-ambiguous and can shift the day
|
|
`SELECT ${nyBizDate('created_at')}::text AS day, count(*) AS n
|
|
FROM conversations
|
|
WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM}
|
|
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
|
|
GROUP BY 1`,
|
|
[from, to],
|
|
),
|
|
this.fs.query(
|
|
`SELECT ${nyBizDate('closed_at')}::text AS day, count(*) AS n
|
|
FROM conversations
|
|
WHERE state = ${CONV_STATE_PUBLISHED} AND status = ${CONV_STATUS_CLOSED}
|
|
AND closed_at IS NOT NULL
|
|
AND ${ny('closed_at')} >= $1 AND ${ny('closed_at')} < $2
|
|
GROUP BY 1`,
|
|
[from, to],
|
|
),
|
|
this.fs.query(
|
|
`SELECT ${nyBizDate('created_at')}::text AS day, count(*) AS n
|
|
FROM threads
|
|
WHERE type = ${THREAD_TYPE_CUSTOMER}
|
|
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
|
|
GROUP BY 1`,
|
|
[from, to],
|
|
),
|
|
]);
|
|
|
|
const toMap = (res) => new Map(res.rows.map((row) => [row.day, Number(row.n)]));
|
|
const newsM = toMap(news);
|
|
const closesM = toMap(closes);
|
|
const msgsM = toMap(msgs);
|
|
|
|
const series = [];
|
|
for (let d = start; d <= todayStart; d = d.plus({ days: 1 })) {
|
|
const key = d.toISODate();
|
|
series.push({
|
|
date: key,
|
|
newConversations: newsM.get(key) ?? 0,
|
|
resolved: closesM.get(key) ?? 0,
|
|
messages: msgsM.get(key) ?? 0,
|
|
});
|
|
}
|
|
return series;
|
|
}
|
|
|
|
async _channels([from, to]) {
|
|
const { rows } = await this.fs.query(
|
|
`SELECT type, count(*) AS n
|
|
FROM conversations
|
|
WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM}
|
|
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
|
|
GROUP BY type`,
|
|
[from, to],
|
|
);
|
|
const channels = { email: 0, phone: 0, chat: 0, other: 0 };
|
|
for (const r of rows) {
|
|
channels[CONV_TYPES[r.type] ?? 'other'] += Number(r.n);
|
|
}
|
|
return channels;
|
|
}
|
|
|
|
// Per-agent table: replies + customers helped + ratings keyed off the reply
|
|
// thread's author; closes keyed off conversations.closed_by_user_id.
|
|
async _agents([from, to]) {
|
|
const [replies, ratings, closes, users] = await Promise.all([
|
|
this.fs.query(
|
|
`SELECT t.created_by_user_id AS user_id,
|
|
count(*) AS replies,
|
|
count(DISTINCT c.customer_id) AS customers_helped
|
|
FROM threads t JOIN conversations c ON c.id = t.conversation_id
|
|
WHERE t.type = ${THREAD_TYPE_MESSAGE} AND t.state = ${CONV_STATE_PUBLISHED}
|
|
AND t.created_by_user_id IS NOT NULL
|
|
AND ${ny('t.created_at')} >= $1 AND ${ny('t.created_at')} < $2
|
|
GROUP BY 1`,
|
|
[from, to],
|
|
),
|
|
this.fs.query(
|
|
`SELECT created_by_user_id AS user_id,
|
|
count(*) FILTER (WHERE rating = 1) AS great,
|
|
count(*) FILTER (WHERE rating = 2) AS okay,
|
|
count(*) FILTER (WHERE rating = 3) AS bad
|
|
FROM threads
|
|
WHERE rating IS NOT NULL AND rating > 0 AND created_by_user_id IS NOT NULL
|
|
AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2
|
|
GROUP BY 1`,
|
|
[from, to],
|
|
),
|
|
this.fs.query(
|
|
`SELECT closed_by_user_id AS user_id, count(*) AS closed
|
|
FROM conversations
|
|
WHERE state = ${CONV_STATE_PUBLISHED} AND status = ${CONV_STATUS_CLOSED}
|
|
AND closed_at IS NOT NULL AND closed_by_user_id IS NOT NULL
|
|
AND ${ny('closed_at')} >= $1 AND ${ny('closed_at')} < $2
|
|
GROUP BY 1`,
|
|
[from, to],
|
|
),
|
|
// type=1 excludes the Workflows robot user
|
|
this.fs.query(`SELECT id, first_name, last_name FROM users WHERE type = 1`),
|
|
]);
|
|
|
|
const agents = new Map();
|
|
const entry = (id) => {
|
|
if (!agents.has(id)) {
|
|
agents.set(id, {
|
|
id, name: null, replies: 0, customersHelped: 0, closed: 0,
|
|
great: 0, okay: 0, bad: 0, satScore: null,
|
|
});
|
|
}
|
|
return agents.get(id);
|
|
};
|
|
for (const r of replies.rows) {
|
|
const a = entry(r.user_id);
|
|
a.replies = Number(r.replies);
|
|
a.customersHelped = Number(r.customers_helped);
|
|
}
|
|
for (const r of closes.rows) entry(r.user_id).closed = Number(r.closed);
|
|
for (const r of ratings.rows) {
|
|
const a = entry(r.user_id);
|
|
a.great = Number(r.great);
|
|
a.okay = Number(r.okay);
|
|
a.bad = Number(r.bad);
|
|
const total = a.great + a.okay + a.bad;
|
|
if (total) a.satScore = Math.ceil((a.great * 100) / total - (a.bad * 100) / total);
|
|
}
|
|
|
|
const names = new Map(users.rows.map((u) => [u.id, `${u.first_name} ${u.last_name}`.trim()]));
|
|
return [...agents.values()]
|
|
.filter((a) => names.has(a.id)) // drop deleted/robot authors
|
|
.map((a) => ({ ...a, name: names.get(a.id) }))
|
|
.sort((a, b) => b.replies - a.replies);
|
|
}
|
|
|
|
// acot_phone bridge DB (timestamptz — no NY re-anchoring). Inbound calls always
|
|
// get answered_at set by the PBX, so "missed" is the voicemail count, not
|
|
// answered_at IS NULL.
|
|
async _phoneStats([from, to]) {
|
|
if (!this.phone) return null;
|
|
const [calls, vms] = await Promise.all([
|
|
this.phone.query(
|
|
`SELECT count(*) AS total,
|
|
count(*) FILTER (WHERE direction = 'inbound') AS inbound,
|
|
count(*) FILTER (WHERE direction = 'outbound') AS outbound,
|
|
round(avg(duration_seconds) FILTER (WHERE answered_at IS NOT NULL)) AS avg_duration_sec
|
|
FROM calls
|
|
WHERE started_at >= $1 AND started_at < $2`,
|
|
[from, to],
|
|
),
|
|
this.phone.query(
|
|
`SELECT count(*) AS voicemails FROM voicemails WHERE created_at >= $1 AND created_at < $2`,
|
|
[from, to],
|
|
),
|
|
]);
|
|
const c = calls.rows[0];
|
|
return {
|
|
total: num(c.total),
|
|
inbound: num(c.inbound),
|
|
outbound: num(c.outbound),
|
|
avgDurationSec: num(c.avg_duration_sec),
|
|
voicemails: num(vms.rows[0].voicemails),
|
|
};
|
|
}
|
|
}
|