Track visitor activity presence
This commit is contained in:
@@ -19,6 +19,8 @@ const COOKIE_NAME = "mf_session";
|
||||
const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
|
||||
const ALLOWED_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]);
|
||||
const MAX_ATTACHMENT_BYTES = 3_000_000;
|
||||
const ACTIVE_VISITOR_SECONDS = 90;
|
||||
const PRESENT_VISITOR_SECONDS = 300;
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
@@ -175,6 +177,7 @@ function initDb() {
|
||||
ip TEXT,
|
||||
country_code TEXT,
|
||||
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_activity_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(site_id, visitor_token)
|
||||
);
|
||||
@@ -186,6 +189,9 @@ function initDb() {
|
||||
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();
|
||||
}
|
||||
if (ensureColumn("visitor_sessions", "last_activity_at", "TEXT")) {
|
||||
db.prepare("UPDATE visitor_sessions SET last_activity_at = last_seen_at WHERE last_activity_at IS NULL").run();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureColumn(table, column, definition) {
|
||||
@@ -308,8 +314,19 @@ function adminConversations(res) {
|
||||
SELECT 1 FROM visitor_sessions vs
|
||||
WHERE vs.site_id = c.site_id
|
||||
AND vs.visitor_token = c.visitor_token
|
||||
AND datetime(vs.last_seen_at) >= datetime('now', '-90 seconds')
|
||||
AND datetime(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds')
|
||||
) AS visitor_online,
|
||||
COALESCE((
|
||||
SELECT CASE
|
||||
WHEN datetime(vs.last_seen_at) < datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') THEN 'offline'
|
||||
WHEN datetime(vs.last_activity_at) >= datetime('now', '-${ACTIVE_VISITOR_SECONDS} seconds') THEN 'active'
|
||||
ELSE 'idle'
|
||||
END
|
||||
FROM visitor_sessions vs
|
||||
WHERE vs.site_id = c.site_id AND vs.visitor_token = c.visitor_token
|
||||
ORDER BY datetime(vs.last_seen_at) DESC
|
||||
LIMIT 1
|
||||
), 'offline') AS visitor_presence,
|
||||
(SELECT COUNT(*) FROM conversations vc WHERE vc.site_id = c.site_id AND vc.visitor_token = c.visitor_token) AS visitor_conversation_count
|
||||
FROM conversations c
|
||||
JOIN sites s ON s.id = c.site_id
|
||||
@@ -386,12 +403,13 @@ async function visitorPing(req, res) {
|
||||
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");
|
||||
const lastActivityAt = timestampOrNow(body.lastActivityAt);
|
||||
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
|
||||
device_json, language, timezone, ip, country_code, first_seen_at, last_activity_at, last_seen_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
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),
|
||||
@@ -401,6 +419,11 @@ async function visitorPing(req, res) {
|
||||
timezone = excluded.timezone,
|
||||
ip = excluded.ip,
|
||||
country_code = COALESCE(excluded.country_code, visitor_sessions.country_code),
|
||||
last_activity_at = CASE
|
||||
WHEN datetime(excluded.last_activity_at) > datetime(visitor_sessions.last_activity_at)
|
||||
THEN excluded.last_activity_at
|
||||
ELSE visitor_sessions.last_activity_at
|
||||
END,
|
||||
last_seen_at = CURRENT_TIMESTAMP
|
||||
`).run(
|
||||
site.company_id,
|
||||
@@ -413,7 +436,8 @@ async function visitorPing(req, res) {
|
||||
nullish(body.language),
|
||||
nullish(body.timezone),
|
||||
clientIp(req),
|
||||
nullish(countryFromHeaders(req))
|
||||
nullish(countryFromHeaders(req)),
|
||||
lastActivityAt
|
||||
);
|
||||
publish("admin", { type: "visitors:update", visitors: currentVisitors() });
|
||||
return json(res, 200, { ok: true });
|
||||
@@ -635,7 +659,7 @@ function currentVisitors() {
|
||||
(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')
|
||||
WHERE datetime(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds')
|
||||
ORDER BY datetime(vs.last_seen_at) DESC
|
||||
LIMIT 100
|
||||
`).all();
|
||||
@@ -647,11 +671,13 @@ function formatVisitorSession(row) {
|
||||
const device = parseJson(row.device_json, {});
|
||||
const bot = botInfo(device.userAgent);
|
||||
const countryCode = row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language);
|
||||
const presence = visitorPresence(row);
|
||||
return {
|
||||
id: row.visitor_token,
|
||||
initials: visitorInitials(row.visitor_token),
|
||||
label: visitorLabel(row.visitor_token),
|
||||
online: true,
|
||||
online: presence !== "offline",
|
||||
presence,
|
||||
currentUrl: row.current_url,
|
||||
referrer: row.referrer,
|
||||
browsingHistory: history,
|
||||
@@ -667,10 +693,18 @@ function formatVisitorSession(row) {
|
||||
countryCode,
|
||||
countryFlag: countryCode ? flagEmoji(countryCode) : "",
|
||||
sessionSeconds: sessionSeconds(row.browsing_history_json, row.first_seen_at),
|
||||
lastActivityAt: row.last_activity_at,
|
||||
lastSeenAt: row.last_seen_at
|
||||
};
|
||||
}
|
||||
|
||||
function visitorPresence(row) {
|
||||
const lastSeen = parseTimestampMs(row.last_seen_at);
|
||||
if (!Number.isFinite(lastSeen) || Date.now() - lastSeen > PRESENT_VISITOR_SECONDS * 1000) return "offline";
|
||||
const lastActivity = parseTimestampMs(row.last_activity_at || row.last_seen_at);
|
||||
return Number.isFinite(lastActivity) && Date.now() - lastActivity <= ACTIVE_VISITOR_SECONDS * 1000 ? "active" : "idle";
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -699,6 +733,7 @@ function formatConversationSummary(row) {
|
||||
visitorInitials: visitorInitials(row.visitor_token),
|
||||
visitorLabel: visitorLabel(row.visitor_token),
|
||||
visitorOnline: Boolean(row.visitor_online),
|
||||
visitorPresence: row.visitor_presence || (row.visitor_online ? "active" : "offline"),
|
||||
visitor: {
|
||||
name: row.visitor_name,
|
||||
email: row.visitor_email,
|
||||
@@ -779,8 +814,19 @@ function findConversation(publicId) {
|
||||
SELECT 1 FROM visitor_sessions vs
|
||||
WHERE vs.site_id = c.site_id
|
||||
AND vs.visitor_token = c.visitor_token
|
||||
AND datetime(vs.last_seen_at) >= datetime('now', '-90 seconds')
|
||||
AND datetime(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds')
|
||||
) AS visitor_online,
|
||||
COALESCE((
|
||||
SELECT CASE
|
||||
WHEN datetime(vs.last_seen_at) < datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') THEN 'offline'
|
||||
WHEN datetime(vs.last_activity_at) >= datetime('now', '-${ACTIVE_VISITOR_SECONDS} seconds') THEN 'active'
|
||||
ELSE 'idle'
|
||||
END
|
||||
FROM visitor_sessions vs
|
||||
WHERE vs.site_id = c.site_id AND vs.visitor_token = c.visitor_token
|
||||
ORDER BY datetime(vs.last_seen_at) DESC
|
||||
LIMIT 1
|
||||
), 'offline') AS visitor_presence,
|
||||
(SELECT COUNT(*) FROM conversations vc WHERE vc.site_id = c.site_id AND vc.visitor_token = c.visitor_token) AS visitor_conversation_count
|
||||
FROM conversations c
|
||||
JOIN sites s ON s.id = c.site_id
|
||||
@@ -909,6 +955,21 @@ function nullish(value) {
|
||||
return value === undefined || value === "" ? null : value;
|
||||
}
|
||||
|
||||
function timestampOrNow(value) {
|
||||
const parsed = Date.parse(value || "");
|
||||
if (!Number.isFinite(parsed)) return new Date().toISOString();
|
||||
const now = Date.now();
|
||||
const bounded = Math.min(Math.max(parsed, now - 24 * 60 * 60 * 1000), now);
|
||||
return new Date(bounded).toISOString();
|
||||
}
|
||||
|
||||
function parseTimestampMs(value) {
|
||||
if (!value) return NaN;
|
||||
const text = String(value);
|
||||
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text) ? `${text.replace(" ", "T")}Z` : text;
|
||||
return Date.parse(normalized);
|
||||
}
|
||||
|
||||
function clientIp(req) {
|
||||
return (req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").split(",")[0].trim();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user