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
136 lines
5.3 KiB
TypeScript
136 lines
5.3 KiB
TypeScript
import { sql } from "drizzle-orm";
|
|
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
import crypto from "node:crypto";
|
|
|
|
const id = () =>
|
|
text("id")
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID());
|
|
|
|
const timestamps = {
|
|
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
.notNull()
|
|
.default(sql`(unixepoch('subsec') * 1000)`),
|
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
|
.notNull()
|
|
.default(sql`(unixepoch('subsec') * 1000)`),
|
|
};
|
|
|
|
/** Agents and admins — the people who work tickets. */
|
|
export const users = sqliteTable("users", {
|
|
id: id(),
|
|
email: text("email").notNull().unique(),
|
|
passwordHash: text("password_hash").notNull(),
|
|
name: text("name").notNull(),
|
|
role: text("role", { enum: ["admin", "agent"] })
|
|
.notNull()
|
|
.default("agent"),
|
|
createdAt: timestamps.createdAt,
|
|
});
|
|
|
|
export const sessions = sqliteTable("sessions", {
|
|
// primary key is the SHA-256 hash of the raw session token — the raw
|
|
// token only ever lives in the client's httpOnly cookie.
|
|
tokenHash: text("token_hash").primaryKey(),
|
|
userId: text("user_id")
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: "cascade" }),
|
|
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
|
createdAt: timestamps.createdAt,
|
|
});
|
|
|
|
/** People who file tickets — identified by whichever channel they came in on. */
|
|
export const customers = sqliteTable("customers", {
|
|
id: id(),
|
|
displayName: text("display_name").notNull(),
|
|
email: text("email"),
|
|
telegramChatId: text("telegram_chat_id").unique(),
|
|
// Bearer token embedded in the customer's portal link — long, random,
|
|
// unguessable. Grants access to only this customer's tickets.
|
|
portalToken: text("portal_token")
|
|
.notNull()
|
|
.unique()
|
|
.$defaultFn(() => crypto.randomBytes(32).toString("base64url")),
|
|
createdAt: timestamps.createdAt,
|
|
});
|
|
|
|
export const tickets = sqliteTable("tickets", {
|
|
id: id(),
|
|
subject: text("subject").notNull(),
|
|
status: text("status", { enum: ["new", "open", "pending", "closed"] })
|
|
.notNull()
|
|
.default("new"),
|
|
priority: text("priority", { enum: ["low", "normal", "high", "urgent"] })
|
|
.notNull()
|
|
.default("normal"),
|
|
channel: text("channel", { enum: ["telegram", "portal", "manual", "email", "widget"] }).notNull(),
|
|
customerId: text("customer_id")
|
|
.notNull()
|
|
.references(() => customers.id, { onDelete: "cascade" }),
|
|
assigneeId: text("assignee_id").references(() => users.id, { onDelete: "set null" }),
|
|
lastMessageAt: integer("last_message_at", { mode: "timestamp_ms" })
|
|
.notNull()
|
|
.default(sql`(unixepoch('subsec') * 1000)`),
|
|
...timestamps,
|
|
});
|
|
|
|
export const messages = sqliteTable("messages", {
|
|
id: id(),
|
|
ticketId: text("ticket_id")
|
|
.notNull()
|
|
.references(() => tickets.id, { onDelete: "cascade" }),
|
|
authorType: text("author_type", { enum: ["customer", "agent", "system"] }).notNull(),
|
|
// References customers.id or users.id depending on authorType; null for system messages.
|
|
authorId: text("author_id"),
|
|
authorName: text("author_name").notNull(),
|
|
body: text("body").notNull(),
|
|
direction: text("direction", { enum: ["in", "out"] }).notNull(),
|
|
// RFC 5322 Message-ID of this email, when the message came in/out over the
|
|
// email channel — lets a customer's future reply be matched back to the
|
|
// exact ticket via In-Reply-To/References, even if they have other tickets.
|
|
emailMessageId: text("email_message_id"),
|
|
createdAt: timestamps.createdAt,
|
|
});
|
|
|
|
/** Single-row-ish config for the Telegram channel (one bot per deployment). */
|
|
export const telegramConfig = sqliteTable("telegram_config", {
|
|
id: id(),
|
|
botTokenEnc: text("bot_token_enc").notNull(),
|
|
botUsername: text("bot_username"),
|
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
|
|
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
|
|
createdAt: timestamps.createdAt,
|
|
});
|
|
|
|
/** Single-row-ish config for the email channel (one support mailbox per deployment). */
|
|
export const mailboxConfig = sqliteTable("mailbox_config", {
|
|
id: id(),
|
|
imapHost: text("imap_host").notNull(),
|
|
imapPort: integer("imap_port").notNull().default(993),
|
|
smtpHost: text("smtp_host").notNull(),
|
|
smtpPort: integer("smtp_port").notNull().default(587),
|
|
user: text("user").notNull(),
|
|
passwordEnc: text("password_enc").notNull(),
|
|
// Both IMAP and SMTP presented an expired cert on support@top-sysops.ru at
|
|
// setup time. This is a LAN-only mailbox (never exposed to the internet),
|
|
// so skipping verification is an accepted risk — not a default for
|
|
// arbitrary/public mail servers. Revisit once the cert is renewed.
|
|
allowInsecureTls: integer("allow_insecure_tls", { mode: "boolean" }).notNull().default(false),
|
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
|
|
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,
|
|
});
|