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>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
CREATE TYPE "public"."support_comment_author_type" AS ENUM('user', 'admin', 'gitea');--> statement-breakpoint
|
||||
CREATE TABLE "processed_gitea_events" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "support_ticket_comments" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"ticket_id" text NOT NULL,
|
||||
"author_type" "support_comment_author_type" NOT NULL,
|
||||
"author_id" text,
|
||||
"body" text NOT NULL,
|
||||
"gitea_comment_id" integer,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "support_tickets" ADD COLUMN "gitea_issue_number" integer;--> statement-breakpoint
|
||||
ALTER TABLE "support_ticket_comments" ADD CONSTRAINT "support_ticket_comments_ticket_id_support_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."support_tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "support_ticket_comments_ticket_idx" ON "support_ticket_comments" USING btree ("ticket_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "support_ticket_comments_gitea_comment_idx" ON "support_ticket_comments" USING btree ("gitea_comment_id");--> statement-breakpoint
|
||||
CREATE INDEX "support_tickets_gitea_issue_idx" ON "support_tickets" USING btree ("gitea_issue_number");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -407,6 +407,13 @@
|
||||
"when": 1784582403171,
|
||||
"tag": "0057_sudden_kree",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 58,
|
||||
"version": "7",
|
||||
"when": 1784583059359,
|
||||
"tag": "0058_quiet_triton",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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(),
|
||||
@@ -13,14 +14,42 @@ export const supportTickets = pgTable("support_tickets", {
|
||||
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" }),
|
||||
@@ -35,8 +64,13 @@ export const supportTicketAttachments = pgTable("support_ticket_attachments", {
|
||||
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] }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user