Add customer service dashboard component

This commit is contained in:
2026-07-18 12:09:56 -04:00
parent e9894c893b
commit cde8148196
14 changed files with 1190 additions and 24 deletions
@@ -0,0 +1,30 @@
// FreeScout customer-service report router. Read-only — authenticated-only is
// sufficient (same stance as Google/Typeform); no requirePermission() gate.
import express from 'express';
import { FreescoutService } from '../../services/freescout/freescout.service.js';
const ALLOWED_DAYS = new Set([7, 14, 30, 90, 180, 365]);
export function createFreescoutRouter({ redis, freescoutPool, phonePool }) {
const router = express.Router();
const freescout = new FreescoutService(redis, freescoutPool, phonePool);
router.get('/report', async (req, res) => {
const days = Number(req.query.days) || 30;
if (!ALLOWED_DAYS.has(days)) {
return res.status(400).json({
error: 'Invalid days parameter',
details: `days must be one of ${[...ALLOWED_DAYS].join(', ')}`,
});
}
try {
res.json(await freescout.getReport(days));
} catch (error) {
console.error('FreeScout report error:', { days, error: error.message });
res.status(500).json({ error: 'Failed to build FreeScout report', details: error.message });
}
});
return router;
}
@@ -0,0 +1,37 @@
// Smoke test for the FreeScout CS report service, bypassing HTTP auth.
// Run on the server: cd /var/www/inventory/dashboard && node scripts/test-freescout-report.mjs [days]
import { config as loadEnv } from 'dotenv';
import { createPool } from '../../shared/db/pg.js';
import { FreescoutService } from '../services/freescout/freescout.service.js';
loadEnv({ path: new URL('../.env', import.meta.url).pathname });
const days = Number(process.argv[2]) || 30;
const freescoutPool = createPool('FREESCOUT_DB', {
user: process.env.FREESCOUT_DB_USERNAME,
database: process.env.FREESCOUT_DB_DATABASE,
max: 2,
});
const phonePool = process.env.ACOT_PHONE_DB_HOST
? createPool('ACOT_PHONE_DB', {
user: process.env.ACOT_PHONE_DB_USERNAME,
database: process.env.ACOT_PHONE_DB_DATABASE,
max: 2,
})
: null;
// Stub redis in "end" status so the service skips caching entirely.
const redisStub = { status: 'end' };
const service = new FreescoutService(redisStub, freescoutPool, phonePool);
const t0 = Date.now();
try {
const report = await service.getReport(days);
console.log(JSON.stringify(report, null, 2));
console.error(`\nOK — ${days}d report in ${Date.now() - t0}ms`);
} finally {
await freescoutPool.end();
await phonePool?.end();
}
+28
View File
@@ -7,6 +7,7 @@
// /api/meta/* → routes/meta (was meta-server :3005)
// /api/dashboard-analytics/* → routes/google (was google-server :3007 via Caddy /api/analytics rewrite)
// /api/typeform/* → routes/typeform (was typeform-server :3008)
// /api/freescout/* → routes/freescout (FreeScout + acot_phone CS report, new)
//
// Shared infrastructure (Phase 2 + Phase 6):
// - shared/auth/middleware.js authenticate() guards /api/* (Phase 6.1/6.2 — second line of defense)
@@ -39,6 +40,7 @@ import { createKlaviyoRouter } from './routes/klaviyo/index.js';
import { createMetaRouter } from './routes/meta/index.js';
import { createGoogleRouter } from './routes/google/index.js';
import { createTypeformRouter } from './routes/typeform/index.js';
import { createFreescoutRouter } from './routes/freescout/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -76,6 +78,25 @@ const pool = createPool('DB');
// if Redis is temporarily unavailable, and aligns with shared/db/redis.js defaults.
const redis = createRedis();
// Read-only pools for the FreeScout CS section. The .env keys use USERNAME /
// DATABASE where createPool() reads USER / NAME, hence the overrides. Both are
// optional: without FREESCOUT_DB_* the route isn't mounted; without
// ACOT_PHONE_DB_* the report's phone block is null.
const freescoutPool = process.env.FREESCOUT_DB_HOST
? createPool('FREESCOUT_DB', {
user: process.env.FREESCOUT_DB_USERNAME,
database: process.env.FREESCOUT_DB_DATABASE,
max: 5,
})
: null;
const phonePool = process.env.ACOT_PHONE_DB_HOST
? createPool('ACOT_PHONE_DB', {
user: process.env.ACOT_PHONE_DB_USERNAME,
database: process.env.ACOT_PHONE_DB_DATABASE,
max: 3,
})
: null;
app.use(requestLog());
app.use(cors(corsOptions));
app.use(express.json({ limit: '10mb' }));
@@ -91,6 +112,11 @@ app.use('/api/meta', createMetaRouter());
// Caddy can drop the rewrite — see Caddyfile.proposed.
app.use('/api/dashboard-analytics', createGoogleRouter({ redis }));
app.use('/api/typeform', createTypeformRouter({ redis }));
if (freescoutPool) {
app.use('/api/freescout', createFreescoutRouter({ redis, freescoutPool, phonePool }));
} else {
logger.warn('FREESCOUT_DB_* not set — /api/freescout not mounted');
}
app.get('/health', (req, res) => {
res.json({
@@ -118,6 +144,8 @@ const shutdown = async (signal) => {
server.close();
try { await redis.quit(); } catch { /* ignore */ }
try { await pool.end(); } catch { /* ignore */ }
try { await freescoutPool?.end(); } catch { /* ignore */ }
try { await phonePool?.end(); } catch { /* ignore */ }
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
@@ -0,0 +1,409 @@
// 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),
};
}
}