Add embeddable chat widget for websites

Reuses the existing portal-token/SSE mechanism (customers.portalToken,
/api/portal/events) for anonymous widget visitors instead of building a
parallel auth system. Public API: session bootstrap + messages endpoint,
both scoped by a non-secret siteKey with optional origin allowlist.
Embeddable script (public/widget.js) is a small self-contained vanilla JS
file: floating button + iframe kept mounted for a live SSE connection,
with a postMessage bridge for the unread badge. Admin UI at
Settings -> Виджет manages sites and shows the embed snippet.

Also generalized recordTelegramInboundMessage -> recordChatInboundMessage
(channel param) since Telegram and the widget need the identical
find-or-create-open-ticket heuristic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-07-26 13:34:08 +00:00
1 parent 59e6c32d79
commit 4efce5f307
19 files changed
+1267 -14

No files matched your search

+14 -1
View File
@@ -63,7 +63,7 @@ export const tickets = sqliteTable("tickets", {
priority: text("priority", { enum: ["low", "normal", "high", "urgent"] })
.notNull()
.default("normal"),
channel: text("channel", { enum: ["telegram", "portal", "manual", "email"] }).notNull(),
channel: text("channel", { enum: ["telegram", "portal", "manual", "email", "widget"] }).notNull(),
customerId: text("customer_id")
.notNull()
.references(() => customers.id, { onDelete: "cascade" }),
@@ -120,3 +120,16 @@ export const mailboxConfig = sqliteTable("mailbox_config", {
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
createdAt: timestamps.createdAt,
});
/** A site the chat widget is embedded on. Public, non-secret identifier — labels ticket origin, not a trust boundary. */
export const widgetSites = sqliteTable("widget_sites", {
id: id(),
siteKey: text("site_key")
.notNull()
.unique()
.$defaultFn(() => crypto.randomBytes(12).toString("base64url")),
name: text("name").notNull(),
allowedOrigin: text("allowed_origin"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: timestamps.createdAt,
});