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:
+16
-2
@@ -646,7 +646,8 @@ export function generateOpenApiSpec(): object {
|
||||
}));
|
||||
const SupportTicketRef = registry.register("SupportTicket", z.object({
|
||||
id: z.string(), userId: z.string(), type: SupportTicketTypeEnum, title: z.string(), description: z.string(),
|
||||
status: SupportTicketStatusEnum, giteaIssueUrl: z.string().nullable(), giteaError: z.string().nullable(),
|
||||
status: SupportTicketStatusEnum, giteaIssueUrl: z.string().nullable(), giteaIssueNumber: z.number().nullable().optional(),
|
||||
giteaError: z.string().nullable(),
|
||||
attachments: z.array(SupportAttachmentRef), createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
|
||||
}));
|
||||
const CreateSupportTicketRef = registry.register("CreateSupportTicket", z.object({
|
||||
@@ -668,6 +669,17 @@ export function generateOpenApiSpec(): object {
|
||||
registry.registerPath({ method: "post", path: "/api/v1/support", summary: "Submit a bug report, suggestion, or question", description: "Best-effort opens a matching issue on the configured Gitea repo (see giteaIssueUrl/giteaError) — this never blocks ticket creation. Sends a confirmation email. Attach files by presigning each one first via POST /api/v1/support/attachments/presign, uploading to the returned URL, then passing the keys here.", security, request: { body: { content: { "application/json": { schema: CreateSupportTicketRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: SupportTicketRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/support/attachments/presign", summary: "Get a presigned upload URL for a support ticket attachment", description: "Rate-limited: 20 req/hour. Accepts images, PDF, plain text, JSON, or zip, up to 10MB. Upload the file as multipart form data to the returned url+fields, then include the returned key when submitting the ticket.", security, request: { body: { content: { "application/json": { schema: PresignSupportAttachmentRef } }, required: true } }, responses: { 200: { description: "Presigned upload", content: { "application/json": { schema: PresignedPostRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
const SupportTicketCommentRef = registry.register("SupportTicketComment", z.object({
|
||||
id: z.string(), ticketId: z.string(), authorType: z.enum(["user", "admin", "gitea"]),
|
||||
authorId: z.string().nullable(), body: z.string(), createdAt: z.string().datetime(),
|
||||
}));
|
||||
const CreateSupportTicketCommentRef = registry.register("CreateSupportTicketComment", z.object({
|
||||
body: z.string().min(1).max(3000),
|
||||
}));
|
||||
|
||||
registry.registerPath({ method: "get", path: "/api/v1/support/{id}/comments", summary: "List a ticket's conversation", description: "Owner only. Includes replies posted from Epicure and, if the ticket has a linked Gitea issue, comments made directly on that issue (author type \"gitea\").", security, request: { params: idParam }, responses: { 200: { description: "Comments, oldest first", content: { "application/json": { schema: z.array(SupportTicketCommentRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/support/{id}/comments", summary: "Reply to your own support ticket", description: "Owner only. Best-effort mirrors the reply onto the linked Gitea issue as a comment if one exists.", security, request: { params: idParam, body: { content: { "application/json": { schema: CreateSupportTicketCommentRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: SupportTicketCommentRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
const AdminSupportTicketRef = registry.register("AdminSupportTicket", z.object({
|
||||
id: z.string(), userId: z.string(), userEmail: z.string(), username: z.string().nullable(),
|
||||
type: SupportTicketTypeEnum, title: z.string(), description: z.string(),
|
||||
@@ -879,7 +891,9 @@ export function generateOpenApiSpec(): object {
|
||||
registry.registerPath({ method: "get", path: "/api/v1/admin/reports", summary: "List pending reports (up to 100, newest first)", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Reports", content: { "application/json": { schema: z.object({ reports: z.array(AdminReportRef) }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
registry.registerPath({ method: "get", path: "/api/v1/admin/support", summary: "List all support tickets, newest first", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Tickets", content: { "application/json": { schema: z.array(AdminSupportTicketRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/support/{id}", summary: "Update a support ticket's status, or retry opening its Gitea issue", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UpdateAdminSupportTicketRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/support/{id}", summary: "Update a support ticket's status, or retry opening its Gitea issue", description: "Admin only. A status change to/from \"closed\" also best-effort closes/reopens the linked Gitea issue.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UpdateAdminSupportTicketRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/admin/support/{id}/comments", summary: "List a support ticket's conversation", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Comments, oldest first", content: { "application/json": { schema: z.array(SupportTicketCommentRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/admin/support/{id}/comments", summary: "Reply to a support ticket as an admin", description: "Admin only. Best-effort mirrors the reply onto the linked Gitea issue as a comment if one exists.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: CreateSupportTicketCommentRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: SupportTicketCommentRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
registry.registerPath({ method: "get", path: "/api/v1/admin/webhooks", summary: "List site-wide ops webhooks", description: "Admin only. Site-wide events (new signups, support tickets, reports) — distinct from the per-user webhooks under /api/v1/webhooks. The signing secret is never included — it is only ever returned once, at creation time.", security: adminSecurity, responses: { 200: { description: "Webhooks", content: { "application/json": { schema: z.array(AdminWebhookRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/admin/webhooks", summary: "Create a site-wide ops webhook", description: "Admin only. Validates the URL against SSRF targets. Generates a signing secret returned once in this response only.", security: adminSecurity, request: { body: { content: { "application/json": { schema: CreateAdminWebhookRef } }, required: true } }, responses: { 201: { description: "Created — includes the signing secret (write-once)", content: { "application/json": { schema: CreatedAdminWebhookRef } } }, 400: { description: "Validation error or blocked URL", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
Reference in New Issue
Block a user