Files
Epicure/packages/db/src/schema/support.ts
T
Arnaud b17984fbef feat: two-way sync between support tickets and Gitea issues (v0.63.0)
Outbound (already existed one-way: ticket create -> Gitea issue) now
also mirrors status changes: closing/reopening a ticket in the admin
UI closes/reopens the linked Gitea issue, and replies posted from
Epicure (by the ticket owner or an admin) post as a comment on the
issue. Captures and stores the Gitea issue number at creation time
to address these follow-up calls without re-parsing the issue URL.

Inbound: new webhook receiver at /api/webhooks/gitea, verified via
HMAC-SHA256 signature (X-Gitea-Signature) with a configurable
GITEA_WEBHOOK_SECRET site setting, deduped by delivery id the same
way the existing Stripe receiver dedupes events. Handles "issues"
(closed/reopened -> ticket status) and "issue_comment" (created ->
appends to the ticket's comment thread), skipping comments Epicura
already posted itself (matched by Gitea comment id) to avoid loops.

New support_ticket_comments table backs a lightweight conversation
thread on both the user-facing support page and the admin support
manager, each comment tagged by author (user/admin/gitea).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 23:34:39 +02:00

77 lines
3.7 KiB
TypeScript

import { pgTable, text, timestamp, integer, index, pgEnum } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users } from "./users";
export const supportTicketTypeEnum = pgEnum("support_ticket_type", ["bug", "suggestion", "question"]);
export const supportTicketStatusEnum = pgEnum("support_ticket_status", ["open", "triaged", "closed"]);
export const supportCommentAuthorTypeEnum = pgEnum("support_comment_author_type", ["user", "admin", "gitea"]);
export const supportTickets = pgTable("support_tickets", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
type: supportTicketTypeEnum("type").notNull(),
title: text("title").notNull(),
description: text("description").notNull(),
status: supportTicketStatusEnum("status").notNull().default("open"),
giteaIssueUrl: text("gitea_issue_url"),
giteaIssueNumber: integer("gitea_issue_number"),
giteaError: text("gitea_error"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
}, (t) => [
index("support_tickets_user_idx").on(t.userId, t.createdAt),
index("support_tickets_status_idx").on(t.status, t.createdAt),
index("support_tickets_gitea_issue_idx").on(t.giteaIssueNumber),
]);
// A comment thread mirrored both ways with the linked Gitea issue: rows with
// authorType "user"/"admin" originate here and get pushed to Gitea (their
// giteaCommentId is filled in once posted); rows with authorType "gitea"
// arrived via the inbound webhook. giteaCommentId also doubles as the
// dedupe key so a comment Epicure just posted doesn't get re-inserted when
// its own webhook delivery arrives back.
export const supportTicketComments = pgTable("support_ticket_comments", {
id: text("id").primaryKey(),
ticketId: text("ticket_id").notNull().references(() => supportTickets.id, { onDelete: "cascade" }),
authorType: supportCommentAuthorTypeEnum("author_type").notNull(),
authorId: text("author_id"),
body: text("body").notNull(),
giteaCommentId: integer("gitea_comment_id"),
createdAt: timestamp("created_at").notNull().defaultNow(),
}, (t) => [
index("support_ticket_comments_ticket_idx").on(t.ticketId, t.createdAt),
index("support_ticket_comments_gitea_comment_idx").on(t.giteaCommentId),
]);
// Dedupes inbound Gitea webhook deliveries by their X-Gitea-Delivery id,
// same pattern as processedStripeEvents.
export const processedGiteaEvents = pgTable("processed_gitea_events", {
id: text("id").primaryKey(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const supportTicketAttachments = pgTable("support_ticket_attachments", {
id: text("id").primaryKey(),
ticketId: text("ticket_id").notNull().references(() => supportTickets.id, { onDelete: "cascade" }),
storageKey: text("storage_key").notNull(),
contentType: text("content_type").notNull(),
sizeBytes: integer("size_bytes").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
}, (t) => [
index("support_ticket_attachments_ticket_idx").on(t.ticketId),
]);
export const supportTicketsRelations = relations(supportTickets, ({ one, many }) => ({
user: one(users, { fields: [supportTickets.userId], references: [users.id] }),
attachments: many(supportTicketAttachments),
comments: many(supportTicketComments),
}));
export const supportTicketAttachmentsRelations = relations(supportTicketAttachments, ({ one }) => ({
ticket: one(supportTickets, { fields: [supportTicketAttachments.ticketId], references: [supportTickets.id] }),
}));
export const supportTicketCommentsRelations = relations(supportTicketComments, ({ one }) => ({
ticket: one(supportTickets, { fields: [supportTicketComments.ticketId], references: [supportTickets.id] }),
}));