MVP-1: helpdesk core with dashboard, Telegram channel, customer portal

Next.js 16 + Drizzle/SQLite (matching ~/project-claude conventions), SSE-based
realtime updates, admin dashboard with status columns, ticket thread view,
Telegram long-polling integration (inbound + outbound), token-based customer
portal, Docker Compose packaging.

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 12:40:56 +00:00
1 parent 888b7b82ba
commit cf0b6cf58e
59 files changed
+12556

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
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"] }).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(),
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,
});