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:
1 parent
59e6c32d79
commit
4efce5f307
19 files changed
+1267
-14
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user