Files
top-tickets/src/lib/db/schema.ts
T
ogrechkoandClaude Sonnet 5 4efce5f307 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
2026-07-26 13:34:08 +00:00

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,
});