// 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), }; } }