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:
1 parent
888b7b82ba
commit
cf0b6cf58e
59 files changed
+12556
No files matched your search
@@ -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,
|
||||
});
|
||||
Reference in new issue
Block a user