Add SQLite database layer
Этот коммит содержится в:
@@ -0,0 +1,204 @@
|
||||
import { dirname } from 'node:path';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import Database from 'better-sqlite3';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { config } from './config.js';
|
||||
|
||||
mkdirSync(dirname(config.db.path), { recursive: true });
|
||||
|
||||
export const db = new Database(config.db.path);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
export function migrate() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
widget_key TEXT NOT NULL UNIQUE,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS operators (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
telegram_user_id TEXT UNIQUE,
|
||||
telegram_username TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS site_operators (
|
||||
site_id TEXT NOT NULL,
|
||||
operator_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (site_id, operator_id),
|
||||
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (operator_id) REFERENCES operators(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS visitors (
|
||||
id TEXT PRIMARY KEY,
|
||||
site_id TEXT NOT NULL,
|
||||
visitor_key TEXT NOT NULL,
|
||||
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
user_agent TEXT,
|
||||
ip TEXT,
|
||||
UNIQUE(site_id, visitor_key),
|
||||
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
site_id TEXT NOT NULL,
|
||||
visitor_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
assigned_operator_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (visitor_id) REFERENCES visitors(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (assigned_operator_id) REFERENCES operators(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
site_id TEXT NOT NULL,
|
||||
visitor_id TEXT,
|
||||
operator_id TEXT,
|
||||
direction TEXT NOT NULL CHECK(direction IN ('visitor','operator','system')),
|
||||
body TEXT NOT NULL,
|
||||
transport TEXT NOT NULL DEFAULT 'web',
|
||||
telegram_message_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (visitor_id) REFERENCES visitors(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (operator_id) REFERENCES operators(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_visitors_site_key ON visitors(site_id, visitor_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_site_status ON conversations(site_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_created ON messages(conversation_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_site_created ON messages(site_id, created_at);
|
||||
`);
|
||||
|
||||
ensureDefaultSite();
|
||||
}
|
||||
|
||||
export function ensureDefaultSite() {
|
||||
const existing = db.prepare('SELECT id FROM sites WHERE id = ?').get('site_default');
|
||||
if (existing) return existing.id;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO sites (id, name, domain, widget_key)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run('site_default', 'Default site', config.cors.widgetOrigin.replace(/^https?:\/\//, ''), 'site_default');
|
||||
|
||||
return 'site_default';
|
||||
}
|
||||
|
||||
export function getSiteByWidgetKey(widgetKey) {
|
||||
return db.prepare(`
|
||||
SELECT * FROM sites WHERE widget_key = ? AND is_active = 1
|
||||
`).get(widgetKey);
|
||||
}
|
||||
|
||||
export function touchVisitor({ siteId, visitorKey, userAgent, ip }) {
|
||||
const existing = db.prepare(`
|
||||
SELECT * FROM visitors WHERE site_id = ? AND visitor_key = ?
|
||||
`).get(siteId, visitorKey);
|
||||
|
||||
if (existing) {
|
||||
db.prepare(`
|
||||
UPDATE visitors
|
||||
SET last_seen_at = CURRENT_TIMESTAMP, user_agent = COALESCE(?, user_agent), ip = COALESCE(?, ip)
|
||||
WHERE id = ?
|
||||
`).run(userAgent || null, ip || null, existing.id);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const id = `visitor_${nanoid(12)}`;
|
||||
db.prepare(`
|
||||
INSERT INTO visitors (id, site_id, visitor_key, user_agent, ip)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(id, siteId, visitorKey, userAgent || null, ip || null);
|
||||
|
||||
return db.prepare('SELECT * FROM visitors WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
export function getOrCreateOpenConversation({ siteId, visitorId }) {
|
||||
const existing = db.prepare(`
|
||||
SELECT * FROM conversations
|
||||
WHERE site_id = ? AND visitor_id = ? AND status = 'open'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`).get(siteId, visitorId);
|
||||
|
||||
if (existing) return existing;
|
||||
|
||||
const id = `conv_${nanoid(12)}`;
|
||||
db.prepare(`
|
||||
INSERT INTO conversations (id, site_id, visitor_id)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(id, siteId, visitorId);
|
||||
|
||||
return db.prepare('SELECT * FROM conversations WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
export function createVisitorMessage({ conversationId, siteId, visitorId, body }) {
|
||||
const id = `msg_${nanoid(14)}`;
|
||||
db.prepare(`
|
||||
INSERT INTO messages (id, conversation_id, site_id, visitor_id, direction, body, transport)
|
||||
VALUES (?, ?, ?, ?, 'visitor', ?, 'web')
|
||||
`).run(id, conversationId, siteId, visitorId, body);
|
||||
|
||||
db.prepare(`
|
||||
UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||
`).run(conversationId);
|
||||
|
||||
return db.prepare('SELECT * FROM messages WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
export function listRecentMessages(limit = 50) {
|
||||
return db.prepare(`
|
||||
SELECT
|
||||
messages.*,
|
||||
sites.name AS site_name,
|
||||
sites.domain AS site_domain,
|
||||
visitors.visitor_key
|
||||
FROM messages
|
||||
JOIN sites ON sites.id = messages.site_id
|
||||
LEFT JOIN visitors ON visitors.id = messages.visitor_id
|
||||
ORDER BY messages.created_at DESC
|
||||
LIMIT ?
|
||||
`).all(limit);
|
||||
}
|
||||
|
||||
export function getStats() {
|
||||
const siteCount = db.prepare('SELECT COUNT(*) AS count FROM sites').get().count;
|
||||
const visitorCount = db.prepare('SELECT COUNT(*) AS count FROM visitors').get().count;
|
||||
const conversationCount = db.prepare('SELECT COUNT(*) AS count FROM conversations').get().count;
|
||||
const messageCount = db.prepare('SELECT COUNT(*) AS count FROM messages').get().count;
|
||||
const openConversationCount = db.prepare("SELECT COUNT(*) AS count FROM conversations WHERE status = 'open'").get().count;
|
||||
|
||||
return {
|
||||
sites: siteCount,
|
||||
visitors: visitorCount,
|
||||
conversations: conversationCount,
|
||||
openConversations: openConversationCount,
|
||||
messages: messageCount
|
||||
};
|
||||
}
|
||||
Ссылка в новой задаче
Block a user