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