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

+51 -6
View File
@@ -1,6 +1,6 @@
import { eq, and, ne, desc } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { customers, tickets, messages, users } from "@/lib/db/schema";
import { customers, tickets, messages, users, widgetSites } from "@/lib/db/schema";
import { publishTicketEvent } from "@/lib/events/bus";
import type { TicketDTO, MessageDTO, TicketChannel } from "@/lib/tickets/types";
@@ -139,12 +139,14 @@ async function findOpenTicketForCustomer(customerId: string) {
}
/**
* Telegram has no explicit ticket context — messages are a running chat
* stream. Reuse the customer's currently open ticket if one exists,
* otherwise open a new one. A closed ticket is reopened by a new message.
* Telegram and the website widget have no explicit ticket context —
* messages are a running chat stream. Reuse the customer's currently open
* ticket if one exists, otherwise open a new one. A closed ticket is
* reopened by a new message.
*/
export async function recordTelegramInboundMessage(params: {
export async function recordChatInboundMessage(params: {
customerId: string;
channel: TicketChannel;
body: string;
authorName: string;
subjectForNewTicket: string;
@@ -157,7 +159,7 @@ export async function recordTelegramInboundMessage(params: {
.insert(tickets)
.values({
subject: params.subjectForNewTicket,
channel: "telegram" satisfies TicketChannel,
channel: params.channel,
customerId: params.customerId,
})
.returning();
@@ -371,6 +373,49 @@ export async function getTicketForCustomer(customerId: string, ticketId: string)
return result;
}
export async function createWidgetSite(name: string, allowedOrigin?: string) {
const [site] = await db
.insert(widgetSites)
.values({ name, allowedOrigin: allowedOrigin || null })
.returning();
return site;
}
export async function listWidgetSites() {
return db.query.widgetSites.findMany({ orderBy: desc(widgetSites.createdAt) });
}
export async function getWidgetSiteByKey(siteKey: string) {
return db.query.widgetSites.findFirst({ where: eq(widgetSites.siteKey, siteKey) });
}
export async function setWidgetSiteEnabled(id: string, enabled: boolean) {
const [site] = await db
.update(widgetSites)
.set({ enabled })
.where(eq(widgetSites.id, id))
.returning();
return site;
}
const GUEST_NAME = "Гость";
/** Only overwrites the placeholder name — never clobbers a name the visitor already gave. */
export async function renameCustomerIfGuest(customerId: string, name: string) {
const trimmed = name.trim();
if (!trimmed) return;
await db
.update(customers)
.set({ displayName: trimmed })
.where(and(eq(customers.id, customerId), eq(customers.displayName, GUEST_NAME)));
}
export async function createGuestCustomer() {
const [customer] = await db.insert(customers).values({ displayName: GUEST_NAME }).returning();
return customer;
}
export async function createManualTicket(params: {
subject: string;
body: string;