Add live visitor monitoring
This commit is contained in:
@@ -62,6 +62,7 @@ async function route(req, res) {
|
||||
if (url.pathname.startsWith("/api/admin/")) return requireAdmin(req, res, () => adminApi(req, res, url));
|
||||
|
||||
if (url.pathname === "/api/widget/config" && req.method === "GET") return widgetConfig(res, url);
|
||||
if (url.pathname === "/api/widget/visitors/ping" && req.method === "POST") return visitorPing(req, res);
|
||||
if (url.pathname === "/api/widget/conversations" && req.method === "POST") return createConversation(req, res);
|
||||
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+$/) && req.method === "GET") return getVisitorConversation(res, url);
|
||||
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createVisitorMessage(req, res, url);
|
||||
@@ -77,6 +78,7 @@ async function adminApi(req, res, url) {
|
||||
if (url.pathname === "/api/admin/events" && req.method === "GET") return eventStream(req, res, url, "admin");
|
||||
if (url.pathname === "/api/admin/site" && req.method === "PATCH") return updateSite(req, res);
|
||||
if (url.pathname === "/api/admin/conversations" && req.method === "GET") return adminConversations(res);
|
||||
if (url.pathname === "/api/admin/visitors" && req.method === "GET") return adminVisitors(res);
|
||||
if (url.pathname.match(/^\/api\/admin\/attachments\/\d+$/) && req.method === "DELETE") return deleteAttachment(res, url);
|
||||
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+$/) && req.method === "GET") return adminConversation(res, url);
|
||||
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+$/) && req.method === "DELETE") return deleteConversation(res, url);
|
||||
@@ -159,8 +161,27 @@ function initDb() {
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS visitor_sessions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
company_id INTEGER NOT NULL REFERENCES companies(id),
|
||||
site_id INTEGER NOT NULL REFERENCES sites(id),
|
||||
visitor_token TEXT NOT NULL,
|
||||
current_url TEXT,
|
||||
referrer TEXT,
|
||||
browsing_history_json TEXT NOT NULL DEFAULT '[]',
|
||||
device_json TEXT NOT NULL DEFAULT '{}',
|
||||
language TEXT,
|
||||
timezone TEXT,
|
||||
ip TEXT,
|
||||
country_code TEXT,
|
||||
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(site_id, visitor_token)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_site_status ON conversations(site_id, status, last_message_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_visitor_sessions_seen ON visitor_sessions(site_id, last_seen_at);
|
||||
`);
|
||||
if (ensureColumn("conversations", "admin_seen_at", "TEXT")) {
|
||||
db.prepare("UPDATE conversations SET admin_seen_at = last_message_at WHERE admin_seen_at IS NULL").run();
|
||||
@@ -292,6 +313,10 @@ function adminConversations(res) {
|
||||
return json(res, 200, { conversations: rows.map(formatConversationSummary) });
|
||||
}
|
||||
|
||||
function adminVisitors(res) {
|
||||
return json(res, 200, { visitors: currentVisitors() });
|
||||
}
|
||||
|
||||
function adminConversation(res, url) {
|
||||
const conversation = findConversation(publicIdFrom(url));
|
||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
||||
@@ -350,6 +375,44 @@ function widgetConfig(res, url) {
|
||||
return json(res, 200, { site: formatSite(site), publicBaseUrl: PUBLIC_BASE_URL });
|
||||
}
|
||||
|
||||
async function visitorPing(req, res) {
|
||||
const body = await readJson(req);
|
||||
const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(body.siteKey || "9b-plus");
|
||||
if (!site) return json(res, 404, { error: "site_not_found" });
|
||||
const visitorToken = body.visitorToken || id("vis");
|
||||
db.prepare(`
|
||||
INSERT INTO visitor_sessions (
|
||||
company_id, site_id, visitor_token, current_url, referrer, browsing_history_json,
|
||||
device_json, language, timezone, ip, country_code, first_seen_at, last_seen_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(site_id, visitor_token) DO UPDATE SET
|
||||
current_url = excluded.current_url,
|
||||
referrer = COALESCE(excluded.referrer, visitor_sessions.referrer),
|
||||
browsing_history_json = excluded.browsing_history_json,
|
||||
device_json = excluded.device_json,
|
||||
language = excluded.language,
|
||||
timezone = excluded.timezone,
|
||||
ip = excluded.ip,
|
||||
country_code = COALESCE(excluded.country_code, visitor_sessions.country_code),
|
||||
last_seen_at = CURRENT_TIMESTAMP
|
||||
`).run(
|
||||
site.company_id,
|
||||
site.id,
|
||||
visitorToken,
|
||||
nullish(body.currentUrl),
|
||||
nullish(body.referrer),
|
||||
JSON.stringify(body.browsingHistory || []),
|
||||
JSON.stringify(body.device || {}),
|
||||
nullish(body.language),
|
||||
nullish(body.timezone),
|
||||
clientIp(req),
|
||||
nullish(countryFromHeaders(req))
|
||||
);
|
||||
publish("admin", { type: "visitors:update", visitors: currentVisitors() });
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
function getVisitorConversation(res, url) {
|
||||
const conversation = findConversation(publicIdFrom(url));
|
||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
||||
@@ -559,6 +622,44 @@ function updateVisitorContext(conversationId, body) {
|
||||
);
|
||||
}
|
||||
|
||||
function currentVisitors() {
|
||||
const rows = db.prepare(`
|
||||
SELECT vs.*, s.site_key,
|
||||
(SELECT COUNT(*) FROM conversations c WHERE c.site_id = vs.site_id AND c.visitor_token = vs.visitor_token) AS visitor_conversation_count
|
||||
FROM visitor_sessions vs
|
||||
JOIN sites s ON s.id = vs.site_id
|
||||
WHERE datetime(vs.last_seen_at) >= datetime('now', '-90 seconds')
|
||||
ORDER BY datetime(vs.last_seen_at) DESC
|
||||
LIMIT 100
|
||||
`).all();
|
||||
return rows.map(formatVisitorSession);
|
||||
}
|
||||
|
||||
function formatVisitorSession(row) {
|
||||
const history = parseJson(row.browsing_history_json, []);
|
||||
const device = parseJson(row.device_json, {});
|
||||
const countryCode = row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language);
|
||||
return {
|
||||
id: row.visitor_token,
|
||||
initials: visitorInitials(row.visitor_token),
|
||||
label: visitorLabel(row.visitor_token),
|
||||
online: true,
|
||||
currentUrl: row.current_url,
|
||||
referrer: row.referrer,
|
||||
browsingHistory: history,
|
||||
pageCount: new Set(history.map((item) => item.url).filter(Boolean)).size,
|
||||
visitCount: Math.max(1, history.length),
|
||||
conversationCount: row.visitor_conversation_count || 0,
|
||||
device,
|
||||
language: row.language,
|
||||
timezone: row.timezone,
|
||||
countryCode,
|
||||
countryFlag: countryCode ? flagEmoji(countryCode) : "",
|
||||
sessionSeconds: sessionSeconds(row.browsing_history_json, row.first_seen_at),
|
||||
lastSeenAt: row.last_seen_at
|
||||
};
|
||||
}
|
||||
|
||||
function conversationDetails(conversation) {
|
||||
const messages = db.prepare("SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
|
||||
const attachments = db.prepare("SELECT * FROM attachments WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
|
||||
@@ -812,6 +913,20 @@ function countryFromLanguage(language) {
|
||||
return match ? match[1].toUpperCase() : null;
|
||||
}
|
||||
|
||||
function visitorInitials(visitorToken) {
|
||||
const hash = crypto.createHash("sha1").update(String(visitorToken || "")).digest("hex").toUpperCase();
|
||||
return hash.slice(0, 2);
|
||||
}
|
||||
|
||||
function visitorLabel(visitorToken) {
|
||||
return `Navstevnik ${visitorInitials(visitorToken)}`;
|
||||
}
|
||||
|
||||
function flagEmoji(countryCode) {
|
||||
if (!countryCode || countryCode.length !== 2) return "";
|
||||
return countryCode.toUpperCase().replace(/./g, (char) => String.fromCodePoint(127397 + char.charCodeAt()));
|
||||
}
|
||||
|
||||
function isAllowedImageAttachment(attachment, buffer) {
|
||||
const extension = path.extname(attachment.name || "").toLowerCase();
|
||||
const type = String(attachment.type || "").toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user